Add releases table with pending/done toggle to environment side drawer

When an environment is selected, the side drawer now shows a Releases
section with a Pending/Done toggle. Fetches from /api/v2/releases/activity
filtered by environment. Release name and version are clickable links to
the release detail and version pages.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
master
2026-03-28 23:47:23 +02:00
parent 2a77a27560
commit 3692317663

View File

@@ -6,8 +6,9 @@ import {
inject,
signal,
} from '@angular/core';
import { HttpClient, HttpParams } from '@angular/common/http';
import { RouterLink } from '@angular/router';
import { catchError, of, take } from 'rxjs';
import { catchError, map, of, take } from 'rxjs';
import { PlatformContextStore } from '../../core/context/platform-context.store';
import { TopologyLayoutService } from './topology-layout.service';
@@ -17,7 +18,22 @@ import {
TopologyPositionedNode,
TopologyRoutedEdge,
} from './topology-layout.models';
import { TopologyTarget, TopologyHost } from './topology.models';
import { TopologyTarget, TopologyHost, PlatformListResponse } from './topology.models';
interface ReleaseActivity {
activityId: string;
releaseId: string;
releaseName: string;
version?: string;
status: string;
eventType: string;
occurredAt: string;
}
const PENDING_STATUSES = new Set([
'pending', 'promoting', 'awaiting_approval', 'gates_running',
'deploying', 'draft', 'ready',
]);
@Component({
selector: 'app-topology-graph-page',
@@ -128,6 +144,41 @@ import { TopologyTarget, TopologyHost } from './topology.models';
@if (detailTargets().length === 0 && detailHosts().length === 0 && !detailLoading()) {
<p class="drawer-empty">No hosts or targets registered.</p>
}
<!-- Releases -->
<div class="drawer-section-header">
<h3 class="drawer-section">Releases</h3>
<div class="toggle-group">
<button
type="button"
[class.toggle-active]="!releasesShowDone()"
(click)="releasesShowDone.set(false)"
>Pending</button>
<button
type="button"
[class.toggle-active]="releasesShowDone()"
(click)="releasesShowDone.set(true)"
>Done</button>
</div>
</div>
@if (filteredReleases().length > 0) {
<table class="drawer-table">
<thead>
<tr><th>Release</th><th>Version</th><th>Status</th></tr>
</thead>
<tbody>
@for (rel of filteredReleases(); track rel.activityId) {
<tr>
<td><a class="drawer-link" [routerLink]="['/releases/detail', rel.releaseId]">{{ rel.releaseName }}</a></td>
<td><a class="drawer-link" [routerLink]="['/releases/versions', rel.releaseId, 'overview']">{{ rel.version || '—' }}</a></td>
<td><span class="release-status" [class]="'release-status--' + rel.status">{{ rel.status }}</span></td>
</tr>
}
</tbody>
</table>
} @else if (!detailLoading()) {
<p class="drawer-empty">No {{ releasesShowDone() ? 'completed' : 'pending' }} releases.</p>
}
@if (detailLoading()) {
<p class="drawer-loading">Loading...</p>
}
@@ -398,6 +449,60 @@ import { TopologyTarget, TopologyHost } from './topology.models';
margin-bottom: 0.15rem;
}
/* Section header with toggle */
.drawer-section-header {
display: flex;
justify-content: space-between;
align-items: center;
padding-top: 0.3rem;
border-top: 1px solid var(--color-border-primary);
}
.toggle-group {
display: flex;
border: 1px solid var(--color-border-primary);
border-radius: var(--radius-sm);
overflow: hidden;
}
.toggle-group button {
border: none;
background: var(--color-surface-secondary);
color: var(--color-text-secondary);
font-size: 0.65rem;
padding: 0.15rem 0.4rem;
cursor: pointer;
}
.toggle-group button + button {
border-left: 1px solid var(--color-border-primary);
}
.toggle-group .toggle-active {
background: var(--color-brand-primary);
color: var(--color-btn-primary-text, #fff);
}
.drawer-link {
color: var(--color-text-link);
text-decoration: none;
font-weight: 500;
}
.drawer-link:hover {
text-decoration: underline;
}
.release-status {
font-size: 0.65rem;
font-weight: 500;
}
.release-status--deployed, .release-status--succeeded { color: var(--color-status-success-text); }
.release-status--pending, .release-status--promoting, .release-status--awaiting_approval { color: var(--color-status-warning-text); }
.release-status--failed, .release-status--cancelled, .release-status--rejected { color: var(--color-status-error-text); }
.release-status--draft, .release-status--ready { color: var(--color-text-secondary); }
.drawer-actions {
padding-top: 0.3rem;
border-top: 1px solid var(--color-border-primary);
@@ -425,6 +530,7 @@ import { TopologyTarget, TopologyHost } from './topology.models';
`],
})
export class TopologyGraphPageComponent {
private readonly http = inject(HttpClient);
private readonly layoutService = inject(TopologyLayoutService);
readonly context = inject(PlatformContextStore);
@@ -435,7 +541,17 @@ export class TopologyGraphPageComponent {
readonly selectedEdge = signal<TopologyRoutedEdge | null>(null);
readonly detailTargets = signal<TopologyTarget[]>([]);
readonly detailHosts = signal<TopologyHost[]>([]);
readonly detailReleases = signal<ReleaseActivity[]>([]);
readonly detailLoading = signal(false);
readonly releasesShowDone = signal(false);
readonly filteredReleases = computed(() => {
const all = this.detailReleases();
const showDone = this.releasesShowDone();
return all.filter((r) =>
showDone ? !PENDING_STATUSES.has(r.status) : PENDING_STATUSES.has(r.status),
);
});
readonly detailTitle = computed(() => {
const node = this.selectedNode();
@@ -488,8 +604,7 @@ export class TopologyGraphPageComponent {
onNodeSelected(node: TopologyPositionedNode): void {
this.selectedNode.set(node);
this.selectedEdge.set(null);
this.detailTargets.set([]);
this.detailHosts.set([]);
this.resetDetail();
if (node.kind === 'environment' && node.environmentId) {
this.loadEnvironmentDetail(node.environmentId);
@@ -499,15 +614,20 @@ export class TopologyGraphPageComponent {
onEdgeSelected(edge: TopologyRoutedEdge): void {
this.selectedEdge.set(edge);
this.selectedNode.set(null);
this.detailTargets.set([]);
this.detailHosts.set([]);
this.resetDetail();
}
clearSelection(): void {
this.selectedNode.set(null);
this.selectedEdge.set(null);
this.resetDetail();
}
private resetDetail(): void {
this.detailTargets.set([]);
this.detailHosts.set([]);
this.detailReleases.set([]);
this.releasesShowDone.set(false);
}
getNodeLabel(nodeId: string): string {
@@ -553,14 +673,28 @@ export class TopologyGraphPageComponent {
this.detailLoading.set(true);
this.layoutService.getTargets(environmentId).pipe(take(1), catchError(() => of([]))).subscribe({
next: (targets) => {
this.detailTargets.set(targets);
this.detailLoading.set(false);
},
next: (targets) => this.detailTargets.set(targets),
});
this.layoutService.getHosts(environmentId).pipe(take(1), catchError(() => of([]))).subscribe({
next: (hosts) => this.detailHosts.set(hosts),
});
const params = new HttpParams()
.set('environment', environmentId)
.set('limit', '20');
this.http
.get<PlatformListResponse<ReleaseActivity>>('/api/v2/releases/activity', { params })
.pipe(
take(1),
map((r) => r?.items ?? []),
catchError(() => of([])),
)
.subscribe({
next: (releases) => {
this.detailReleases.set(releases);
this.detailLoading.set(false);
},
});
}
}