From 36923176630b691184dc00fbf2971ab762df3c17 Mon Sep 17 00:00:00 2001 From: master <> Date: Sat, 28 Mar 2026 23:47:23 +0200 Subject: [PATCH] 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) --- .../topology/topology-graph-page.component.ts | 154 ++++++++++++++++-- 1 file changed, 144 insertions(+), 10 deletions(-) diff --git a/src/Web/StellaOps.Web/src/app/features/topology/topology-graph-page.component.ts b/src/Web/StellaOps.Web/src/app/features/topology/topology-graph-page.component.ts index 111d9e5d1..fd0894da5 100644 --- a/src/Web/StellaOps.Web/src/app/features/topology/topology-graph-page.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/topology/topology-graph-page.component.ts @@ -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()) {

No hosts or targets registered.

} + + +
+

Releases

+
+ + +
+
+ @if (filteredReleases().length > 0) { + + + + + + @for (rel of filteredReleases(); track rel.activityId) { + + + + + + } + +
ReleaseVersionStatus
{{ rel.releaseName }}{{ rel.version || '—' }}{{ rel.status }}
+ } @else if (!detailLoading()) { +

No {{ releasesShowDone() ? 'completed' : 'pending' }} releases.

+ } @if (detailLoading()) {

Loading...

} @@ -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(null); readonly detailTargets = signal([]); readonly detailHosts = signal([]); + readonly detailReleases = signal([]); 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>('/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); + }, + }); } }