From bf07f95eade35682c02dab3a7a312291564912e2 Mon Sep 17 00:00:00 2001 From: master <> Date: Sat, 28 Mar 2026 22:24:35 +0200 Subject: [PATCH] Remove topology tabs, add detail zone below graph, relocate pages MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The environments page is now a single graph + detail zone: - Removed all 10 tabs from topology shell (StellaPageTabsComponent gone) - Detail zone appears below graph at full width when node/edge selected - Shows environment summary chips, hosts/targets table, and action links Relocated pages to their conceptual homes with backward-compat redirects: - Agents, Runtime Drift, Pending Deletions → /ops/operations/ - Connectivity → /security/connectivity - Gate Profiles → /ops/policy/gates/profiles - Promotion Graph, Workflows, Readiness → /releases/ - Added Agent Fleet + Runtime Drift to Operations sidebar Co-Authored-By: Claude Opus 4.6 (1M context) --- .../topology-scope-links.component.spec.ts | 7 +- .../policy-gates/policy-gates.routes.ts | 9 + .../topology/topology-graph-page.component.ts | 536 ++++++++++-------- .../topology/topology-layout.service.ts | 17 +- .../topology/topology-shell.component.ts | 82 +-- .../app-sidebar/app-sidebar.component.ts | 23 + .../src/app/routes/operations.routes.ts | 36 ++ .../src/app/routes/releases.routes.ts | 27 + .../src/app/routes/security.routes.ts | 9 + .../src/app/routes/topology.routes.ts | 190 +------ 10 files changed, 462 insertions(+), 474 deletions(-) diff --git a/src/Web/StellaOps.Web/src/app/core/testing/topology-scope-links.component.spec.ts b/src/Web/StellaOps.Web/src/app/core/testing/topology-scope-links.component.spec.ts index 7c5ad9aea..99bc87931 100644 --- a/src/Web/StellaOps.Web/src/app/core/testing/topology-scope-links.component.spec.ts +++ b/src/Web/StellaOps.Web/src/app/core/testing/topology-scope-links.component.spec.ts @@ -270,13 +270,12 @@ function routerLinksFor(component: Type): RouterLink[] { } describe('Topology scope-preserving links', () => { - it('marks topology shell tabs to merge the active query scope', () => { + it('renders topology shell without tabs', () => { configureTestingModule(TopologyShellComponent); const links = routerLinksFor(TopologyShellComponent); - - expect(links.length).toBe(11); - expect(links.every((link) => link.queryParamsHandling === 'merge')).toBeTrue(); + // Shell no longer has tab links — tabs were removed in favor of graph + detail zone + expect(links.length).toBe(0); }); it('marks topology page links to merge the active query scope', () => { diff --git a/src/Web/StellaOps.Web/src/app/features/policy-gates/policy-gates.routes.ts b/src/Web/StellaOps.Web/src/app/features/policy-gates/policy-gates.routes.ts index 487f400e6..2579221bd 100644 --- a/src/Web/StellaOps.Web/src/app/features/policy-gates/policy-gates.routes.ts +++ b/src/Web/StellaOps.Web/src/app/features/policy-gates/policy-gates.routes.ts @@ -17,4 +17,13 @@ export const POLICY_GATES_ROUTES: Routes = [ ), title: 'Bundle Simulation', }, + { + path: 'profiles', + loadComponent: () => + import('../topology/topology-inventory-page.component').then( + (m) => m.TopologyInventoryPageComponent + ), + title: 'Gate Profiles', + data: { breadcrumb: 'Gate Profiles', endpoint: '/api/v2/topology/gate-profiles' }, + }, ]; 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 87977c23f..97d33abea 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 @@ -2,7 +2,6 @@ import { ChangeDetectionStrategy, Component, computed, - effect, inject, signal, } from '@angular/core'; @@ -17,6 +16,7 @@ import { TopologyPositionedNode, TopologyRoutedEdge, } from './topology-layout.models'; +import { TopologyTarget, TopologyHost } from './topology.models'; @Component({ selector: 'app-topology-graph-page', @@ -66,145 +66,188 @@ import { } - -
-
- @if (loading()) { -
-
- Loading topology... -
- } @else { - - } -
- - @if (panelOpen()) { - + +
+ @if (loading()) { +
+
+ Loading topology... +
+ } @else { + }
+ + + @if (selectedNode() || selectedEdge()) { +
+
+

{{ detailTitle() }}

+ +
+ + @if (selectedNode(); as node) { + @if (node.kind === 'environment') { +
+
+ Region + {{ node.regionId }} +
+
+ Type + {{ node.environmentType }} +
+
+ Health + {{ node.healthStatus ?? 'unknown' }} +
+
+ Hosts + {{ node.hostCount }} +
+
+ Targets + {{ node.targetCount }} +
+ @if (node.currentReleaseId) { +
+ Release + {{ node.currentReleaseId }} +
+ } + @if (node.isFrozen) { +
+ FROZEN +
+ } + +
+ + + @if (detailTargets().length > 0 || detailHosts().length > 0) { +
+ @if (detailHosts().length > 0) { + + + + + + + + + + + + @for (host of detailHosts(); track host.hostId) { + + + + + + + + } + +
HostRuntimeStatusTargetsAgent
{{ host.hostName }}{{ host.runtimeType }}{{ host.status }}{{ host.targetCount }}{{ host.agentId || '—' }}
+ } + @if (detailTargets().length > 0) { + + + + + + + + + + + @for (target of detailTargets(); track target.targetId) { + + + + + + + } + +
TargetTypeHealthImage
{{ target.name }}{{ target.targetType }}{{ target.healthStatus }}{{ target.imageDigest ? target.imageDigest.substring(0, 16) + '...' : '—' }}
+ } +
+ } @else if (!detailLoading()) { +

No hosts or targets registered for this environment.

+ } + @if (detailLoading()) { +

Loading environment detail...

+ } + } @else { + +
+
+ Hosts + {{ node.hostCount }} +
+
+ Targets + {{ node.targetCount }} +
+
+ } + } + + @if (selectedEdge(); as edge) { +
+
+ From + {{ getNodeLabel(edge.sourceNodeId) }} +
+
+ To + {{ getNodeLabel(edge.targetNodeId) }} +
+ @if (edge.pathMode) { +
+ Mode + {{ edge.pathMode }} +
+ } + @if (edge.status) { +
+ Status + {{ edge.status }} +
+ } +
+ Approvals + {{ edge.requiredApprovals }} +
+ @if (edge.gateProfileName) { +
+ Gate Profile + {{ edge.gateProfileName }} +
+ } + @if (edge.label) { +
+ Gates + {{ edge.label }} +
+ } +
+ } +
+ } `, styles: [` .topo-page { display: grid; gap: 0.5rem; - height: 100%; - grid-template-rows: auto auto 1fr; } /* Filter bar */ @@ -219,15 +262,8 @@ import { flex-wrap: wrap; } - .filter-item { - display: grid; - gap: 0.1rem; - } - - .filter-item--wide { - flex: 1; - min-width: 180px; - } + .filter-item { display: grid; gap: 0.1rem; } + .filter-item--wide { flex: 1; min-width: 180px; } .filter-bar label { font-size: 0.65rem; @@ -271,7 +307,7 @@ import { white-space: nowrap; } - /* Banner */ + /* Error */ .banner--error { border: 1px solid var(--color-status-error-border); border-radius: var(--radius-md); @@ -281,24 +317,13 @@ import { font-size: 0.78rem; } - /* Main area */ - .main-area { - display: grid; - grid-template-columns: 1fr; - gap: 0.5rem; - min-height: 0; - } - - .main-area--panel-open { - grid-template-columns: 1fr 300px; - } - + /* Graph */ .graph-pane { - min-height: 400px; + min-height: 420px; + max-height: 520px; position: relative; } - /* Loading */ .loading { display: flex; flex-direction: column; @@ -319,125 +344,143 @@ import { animation: spin 0.8s linear infinite; } - @keyframes spin { - to { transform: rotate(360deg); } - } + @keyframes spin { to { transform: rotate(360deg); } } - /* Detail panel */ - .detail-panel { + /* Detail zone */ + .detail-zone { border: 1px solid var(--color-border-primary); border-radius: var(--radius-md); background: var(--color-surface-primary); - overflow-y: auto; - display: grid; - grid-template-rows: auto 1fr; } - .panel-header { + .detail-zone__header { display: flex; justify-content: space-between; align-items: center; - padding: 0.5rem 0.65rem; + padding: 0.45rem 0.65rem; border-bottom: 1px solid var(--color-border-primary); } - .panel-header h3 { + .detail-zone__header h2 { margin: 0; - font-size: 0.85rem; + font-size: 0.88rem; font-weight: 600; - color: var(--color-text-heading); } - .panel-close { + .detail-zone__close { border: none; background: none; color: var(--color-text-secondary); font-size: 1.2rem; cursor: pointer; - line-height: 1; padding: 0 0.2rem; } - .panel-close:hover { - color: var(--color-text-primary); - } + .detail-zone__close:hover { color: var(--color-text-primary); } - .panel-body { - padding: 0.5rem 0.65rem; - display: grid; - gap: 0.4rem; - } - - .panel-row { + .detail-zone__summary { display: flex; - justify-content: space-between; + flex-wrap: wrap; + gap: 0.5rem; + padding: 0.5rem 0.65rem; + } + + .detail-chip { + display: flex; + gap: 0.35rem; align-items: center; font-size: 0.78rem; + border: 1px solid var(--color-border-primary); + border-radius: var(--radius-sm); + padding: 0.2rem 0.5rem; + background: var(--color-surface-secondary); } - .panel-label { - color: var(--color-text-secondary); - font-size: 0.72rem; - } + .detail-chip--wide { flex-basis: 100%; } - .type-badge--production { color: var(--color-status-error-text); font-weight: 500; } - .type-badge--staging { color: var(--color-status-warning-text); font-weight: 500; } - .type-badge--development { color: var(--color-text-secondary); font-weight: 500; } - - .health-badge--healthy { color: var(--color-status-success-text); font-weight: 500; } - .health-badge--degraded { color: var(--color-status-warning-text); font-weight: 500; } - .health-badge--unhealthy { color: var(--color-status-error-text); font-weight: 500; } - .health-badge--unknown { color: var(--color-text-muted); font-weight: 500; } - - .frozen-badge { + .detail-chip--frozen { + background: var(--color-status-error-bg); + border-color: var(--color-status-error-border); color: var(--color-status-error-text); font-weight: 700; font-size: 0.72rem; } - .panel-actions { - display: flex; - gap: 0.35rem; - padding-top: 0.3rem; - border-top: 1px solid var(--color-border-primary); - margin-top: 0.2rem; + .detail-chip--actions { + border: none; + background: none; + padding: 0; + margin-left: auto; } - .btn { - padding: 0.3rem 0.6rem; + .detail-chip__label { + color: var(--color-text-muted); + font-size: 0.68rem; + text-transform: uppercase; + letter-spacing: 0.03em; + } + + .action-link { + padding: 0.25rem 0.6rem; border-radius: var(--radius-sm); font-size: 0.72rem; font-weight: 500; text-decoration: none; - cursor: pointer; - text-align: center; - } - - .btn--primary { background: var(--color-btn-primary-bg, var(--color-brand-primary)); color: var(--color-btn-primary-text, #fff); - border: none; } - .btn--secondary { - background: var(--color-surface-secondary); - color: var(--color-text-link); - border: 1px solid var(--color-border-primary); + .type-val--production { color: var(--color-status-error-text); font-weight: 500; } + .type-val--staging { color: var(--color-status-warning-text); font-weight: 500; } + .type-val--development { color: var(--color-text-secondary); font-weight: 500; } + + .health-val--healthy { color: var(--color-status-success-text); font-weight: 500; } + .health-val--degraded { color: var(--color-status-warning-text); font-weight: 500; } + .health-val--unhealthy { color: var(--color-status-error-text); font-weight: 500; } + .health-val--unknown { color: var(--color-text-muted); } + + /* Tables */ + .detail-zone__tables { + padding: 0 0.65rem 0.5rem; + display: grid; + gap: 0.5rem; + } + + .detail-table { + width: 100%; + border-collapse: collapse; + font-size: 0.75rem; + } + + .detail-table th { + text-align: left; + font-size: 0.67rem; + color: var(--color-text-muted); + text-transform: uppercase; + letter-spacing: 0.04em; + padding: 0.3rem 0.4rem; + border-bottom: 1px solid var(--color-border-primary); + } + + .detail-table td { + padding: 0.3rem 0.4rem; + border-bottom: 1px solid var(--color-border-primary); + } + + .detail-table tr:last-child td { border-bottom: none; } + .detail-table tr:hover td { background: var(--color-surface-secondary); } + .mono { font-family: var(--font-mono, monospace); font-size: 0.7rem; } + + .detail-zone__empty, + .detail-zone__loading { + padding: 0.5rem 0.65rem; + color: var(--color-text-muted); + font-size: 0.78rem; } @media (max-width: 960px) { - .main-area--panel-open { - grid-template-columns: 1fr; - grid-template-rows: 1fr auto; - } - - .filter-bar { - flex-direction: column; - } - - .filter-item--wide { - min-width: auto; - } + .filter-bar { flex-direction: column; } + .filter-item--wide { min-width: auto; } } `], }) @@ -452,14 +495,15 @@ export class TopologyGraphPageComponent { readonly healthFilter = signal(''); readonly selectedNode = signal(null); readonly selectedEdge = signal(null); + readonly detailTargets = signal([]); + readonly detailHosts = signal([]); + readonly detailLoading = signal(false); - readonly panelOpen = computed(() => this.selectedNode() !== null || this.selectedEdge() !== null); - - readonly panelTitle = computed(() => { + readonly detailTitle = computed(() => { const node = this.selectedNode(); if (node) return node.label; const edge = this.selectedEdge(); - if (edge) return edge.label ?? 'Promotion Path'; + if (edge) return `Promotion: ${this.getNodeLabel(edge.sourceNodeId)} → ${this.getNodeLabel(edge.targetNodeId)}`; return ''; }); @@ -511,16 +555,31 @@ export class TopologyGraphPageComponent { onNodeSelected(node: TopologyPositionedNode): void { this.selectedNode.set(node); this.selectedEdge.set(null); + this.detailTargets.set([]); + this.detailHosts.set([]); + + if (node.kind === 'environment' && node.environmentId) { + this.loadEnvironmentDetail(node.environmentId); + } } onEdgeSelected(edge: TopologyRoutedEdge): void { this.selectedEdge.set(edge); this.selectedNode.set(null); + this.detailTargets.set([]); + this.detailHosts.set([]); } - closePanel(): void { + clearSelection(): void { this.selectedNode.set(null); this.selectedEdge.set(null); + this.detailTargets.set([]); + this.detailHosts.set([]); + } + + getNodeLabel(nodeId: string): string { + const node = this.layout()?.nodes.find((n) => n.id === nodeId); + return node?.label ?? nodeId; } private load(): void { @@ -545,4 +604,19 @@ export class TopologyGraphPageComponent { }, }); } + + private loadEnvironmentDetail(environmentId: string): void { + this.detailLoading.set(true); + + this.layoutService.getTargets(environmentId).pipe(take(1), catchError(() => of([]))).subscribe({ + next: (targets) => { + this.detailTargets.set(targets); + this.detailLoading.set(false); + }, + }); + + this.layoutService.getHosts(environmentId).pipe(take(1), catchError(() => of([]))).subscribe({ + next: (hosts) => this.detailHosts.set(hosts), + }); + } } diff --git a/src/Web/StellaOps.Web/src/app/features/topology/topology-layout.service.ts b/src/Web/StellaOps.Web/src/app/features/topology/topology-layout.service.ts index dd396ef19..0722fa3ae 100644 --- a/src/Web/StellaOps.Web/src/app/features/topology/topology-layout.service.ts +++ b/src/Web/StellaOps.Web/src/app/features/topology/topology-layout.service.ts @@ -1,8 +1,9 @@ import { HttpClient, HttpParams } from '@angular/common/http'; import { Injectable, inject } from '@angular/core'; -import { Observable } from 'rxjs'; +import { map, Observable } from 'rxjs'; import { TopologyLayoutResponse } from './topology-layout.models'; +import { TopologyTarget, TopologyHost, PlatformListResponse } from './topology.models'; @Injectable({ providedIn: 'root' }) export class TopologyLayoutService { @@ -31,4 +32,18 @@ export class TopologyLayoutService { return this.http.get('/api/v2/topology/layout', { params }); } + + getTargets(environmentId: string): Observable { + const params = new HttpParams().set('environment', environmentId).set('limit', '200'); + return this.http + .get>('/api/v2/topology/targets', { params }) + .pipe(map((r) => r?.items ?? [])); + } + + getHosts(environmentId: string): Observable { + const params = new HttpParams().set('environment', environmentId).set('limit', '200'); + return this.http + .get>('/api/v2/topology/hosts', { params }) + .pipe(map((r) => r?.items ?? [])); + } } diff --git a/src/Web/StellaOps.Web/src/app/features/topology/topology-shell.component.ts b/src/Web/StellaOps.Web/src/app/features/topology/topology-shell.component.ts index 07bddf1e9..4da1c6cae 100644 --- a/src/Web/StellaOps.Web/src/app/features/topology/topology-shell.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/topology/topology-shell.component.ts @@ -1,44 +1,10 @@ -import { ChangeDetectionStrategy, Component, DestroyRef, inject, OnInit, signal } from '@angular/core'; -import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; -import { ActivatedRoute, NavigationEnd, Router, RouterOutlet } from '@angular/router'; -import { filter } from 'rxjs'; - -import { StellaPageTabsComponent, StellaPageTab } from '../../shared/components/stella-page-tabs/stella-page-tabs.component'; - -type TabType = - | 'overview' - | 'targets' - | 'hosts' - | 'agents' - | 'posture' - | 'promotion-graph' - | 'workflows' - | 'gate-profiles' - | 'connectivity' - | 'runtime-drift'; - -const KNOWN_TAB_IDS: readonly string[] = [ - 'overview', 'targets', 'hosts', 'agents', 'posture', - 'promotion-graph', 'workflows', 'gate-profiles', 'connectivity', 'runtime-drift', -]; - -const PAGE_TABS: readonly StellaPageTab[] = [ - { id: 'overview', label: 'Topology', icon: 'M1 6v16l7-4 8 4 7-4V2l-7 4-8-4-7 4z|||M8 2v16|||M16 6v16' }, - { id: 'targets', label: 'Targets', icon: 'M12 12m-10 0a10 10 0 1 0 20 0 10 10 0 1 0-20 0|||M12 12m-6 0a6 6 0 1 0 12 0 6 6 0 1 0-12 0|||M12 12m-2 0a2 2 0 1 0 4 0 2 2 0 1 0-4 0' }, - { id: 'hosts', label: 'Hosts', icon: 'M2 2h20v8H2z|||M2 14h20v8H2z|||M6 6h.01|||M6 18h.01' }, - { id: 'agents', label: 'Agents', icon: 'M18 12h2|||M4 12h2|||M12 4v2|||M12 18v2|||M9 9h6v6H9z' }, - { id: 'posture', label: 'Posture', icon: 'M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z' }, - { id: 'promotion-graph', label: 'Promotion Graph', icon: 'M6 3v12|||M18 9a3 3 0 1 0 0-6 3 3 0 0 0 0 6z|||M6 21a3 3 0 1 0 0-6 3 3 0 0 0 0 6z|||M18 9a9 9 0 0 1-9 9' }, - { id: 'workflows', label: 'Workflows', icon: 'M22 12h-4l-3 9L9 3l-3 9H2' }, - { id: 'gate-profiles', label: 'Gate Profiles', icon: 'M9 11l3 3L22 4|||M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11' }, - { id: 'connectivity', label: 'Connectivity', icon: 'M12 12m-10 0a10 10 0 1 0 20 0 10 10 0 1 0-20 0|||M2 12h20|||M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z' }, - { id: 'runtime-drift', label: 'Runtime Drift', icon: 'M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z|||M12 9v4|||M12 17h.01' }, -]; +import { ChangeDetectionStrategy, Component } from '@angular/core'; +import { RouterOutlet } from '@angular/router'; @Component({ selector: 'app-topology-shell', standalone: true, - imports: [RouterOutlet, StellaPageTabsComponent], + imports: [RouterOutlet], changeDetection: ChangeDetectionStrategy.OnPush, template: `
@@ -50,18 +16,10 @@ const PAGE_TABS: readonly StellaPageTab[] = [

Environments

-

Regions, targets, hosts, agents, promotion flows, and release posture

+

Topology, targets, promotion flows, and release posture

- - - - + `, styles: [` @@ -104,32 +62,4 @@ const PAGE_TABS: readonly StellaPageTab[] = [ } `], }) -export class TopologyShellComponent implements OnInit { - private readonly router = inject(Router); - private readonly route = inject(ActivatedRoute); - private readonly destroyRef = inject(DestroyRef); - - readonly pageTabs = PAGE_TABS; - readonly activeTab = signal('overview'); - - ngOnInit(): void { - this.setActiveTabFromUrl(this.router.url); - this.router.events.pipe( - filter((e): e is NavigationEnd => e instanceof NavigationEnd), - takeUntilDestroyed(this.destroyRef), - ).subscribe(e => this.setActiveTabFromUrl(e.urlAfterRedirects)); - } - - onTabChange(tabId: string): void { - this.activeTab.set(tabId as TabType); - this.router.navigate([tabId], { relativeTo: this.route, queryParamsHandling: 'merge' }); - } - - private setActiveTabFromUrl(url: string): void { - const segments = url.split('?')[0].split('/').filter(Boolean); - const lastSegment = segments.at(-1) ?? ''; - if (KNOWN_TAB_IDS.includes(lastSegment as TabType)) { - this.activeTab.set(lastSegment as TabType); - } - } -} +export class TopologyShellComponent {} diff --git a/src/Web/StellaOps.Web/src/app/layout/app-sidebar/app-sidebar.component.ts b/src/Web/StellaOps.Web/src/app/layout/app-sidebar/app-sidebar.component.ts index 5a98b0533..2148a7dcd 100644 --- a/src/Web/StellaOps.Web/src/app/layout/app-sidebar/app-sidebar.component.ts +++ b/src/Web/StellaOps.Web/src/app/layout/app-sidebar/app-sidebar.component.ts @@ -773,6 +773,29 @@ export class AppSidebarComponent implements AfterViewInit { StellaOpsScopes.HEALTH_READ, ], }, + { + id: 'ops-agents', + label: 'Agent Fleet', + icon: 'cpu', + route: '/ops/operations/agents', + menuGroupId: 'operations', + menuGroupLabel: 'Operations', + requireAnyScope: [ + StellaOpsScopes.ORCH_READ, + StellaOpsScopes.ORCH_OPERATE, + ], + }, + { + id: 'ops-drift', + label: 'Runtime Drift', + icon: 'alert-triangle', + route: '/ops/operations/drift', + menuGroupId: 'operations', + menuGroupLabel: 'Operations', + requireAnyScope: [ + StellaOpsScopes.ORCH_READ, + ], + }, { id: 'ops-environments', label: 'Environments', diff --git a/src/Web/StellaOps.Web/src/app/routes/operations.routes.ts b/src/Web/StellaOps.Web/src/app/routes/operations.routes.ts index 88177e43a..4cd403dd5 100644 --- a/src/Web/StellaOps.Web/src/app/routes/operations.routes.ts +++ b/src/Web/StellaOps.Web/src/app/routes/operations.routes.ts @@ -242,4 +242,40 @@ export const OPERATIONS_ROUTES: Routes = [ (m) => m.TrustAnalyticsComponent, ), }, + { + path: 'agents', + title: 'Agent Fleet', + data: { breadcrumb: 'Agent Fleet' }, + loadComponent: () => + import('../features/topology/topology-agents-page.component').then( + (m) => m.TopologyAgentsPageComponent, + ), + }, + { + path: 'agents/:agentGroupId', + title: 'Agent Group Detail', + data: { breadcrumb: 'Agent Group Detail' }, + loadComponent: () => + import('../features/topology/topology-agent-group-detail-page.component').then( + (m) => m.TopologyAgentGroupDetailPageComponent, + ), + }, + { + path: 'drift', + title: 'Runtime Drift', + data: { breadcrumb: 'Runtime Drift' }, + loadComponent: () => + import('../features/topology/topology-runtime-drift-page.component').then( + (m) => m.TopologyRuntimeDriftPageComponent, + ), + }, + { + path: 'pending-deletions', + title: 'Pending Deletions', + data: { breadcrumb: 'Pending Deletions' }, + loadComponent: () => + import('../features/topology/pending-deletions-panel.component').then( + (m) => m.PendingDeletionsPanelComponent, + ), + }, ]; diff --git a/src/Web/StellaOps.Web/src/app/routes/releases.routes.ts b/src/Web/StellaOps.Web/src/app/routes/releases.routes.ts index 9c72b3cb1..b229f196e 100644 --- a/src/Web/StellaOps.Web/src/app/routes/releases.routes.ts +++ b/src/Web/StellaOps.Web/src/app/routes/releases.routes.ts @@ -299,4 +299,31 @@ export const RELEASES_ROUTES: Routes = [ loadChildren: () => import('../features/bundles/bundles.routes').then((m) => m.BUNDLE_ROUTES), }, + { + path: 'promotion-graph', + title: 'Promotion Graph', + data: { breadcrumb: 'Promotion Graph' }, + loadComponent: () => + import('../features/topology/topology-promotion-paths-page.component').then( + (m) => m.TopologyPromotionPathsPageComponent, + ), + }, + { + path: 'workflows', + title: 'Release Workflows', + data: { breadcrumb: 'Workflows' }, + loadComponent: () => + import('../features/topology/topology-inventory-page.component').then( + (m) => m.TopologyInventoryPageComponent, + ), + }, + { + path: 'readiness', + title: 'Readiness Dashboard', + data: { breadcrumb: 'Readiness' }, + loadComponent: () => + import('../features/topology/readiness-dashboard.component').then( + (m) => m.ReadinessDashboardComponent, + ), + }, ]; diff --git a/src/Web/StellaOps.Web/src/app/routes/security.routes.ts b/src/Web/StellaOps.Web/src/app/routes/security.routes.ts index 4471a4840..545c48bb4 100644 --- a/src/Web/StellaOps.Web/src/app/routes/security.routes.ts +++ b/src/Web/StellaOps.Web/src/app/routes/security.routes.ts @@ -152,4 +152,13 @@ export const SECURITY_ROUTES: Routes = [ loadComponent: () => import('../features/security/security-reports-page.component').then((m) => m.SecurityReportsPageComponent), }, + { + path: 'connectivity', + title: 'Connectivity', + data: { breadcrumb: 'Connectivity' }, + loadComponent: () => + import('../features/topology/topology-connectivity-page.component').then( + (m) => m.TopologyConnectivityPageComponent, + ), + }, ]; diff --git a/src/Web/StellaOps.Web/src/app/routes/topology.routes.ts b/src/Web/StellaOps.Web/src/app/routes/topology.routes.ts index f56d1a36d..3b678976c 100644 --- a/src/Web/StellaOps.Web/src/app/routes/topology.routes.ts +++ b/src/Web/StellaOps.Web/src/app/routes/topology.routes.ts @@ -26,34 +26,12 @@ export const TOPOLOGY_ROUTES: Routes = [ (m) => m.TopologyGraphPageComponent, ), }, - { - path: 'regions', - redirectTo: 'overview', - pathMatch: 'full', - }, - { - path: 'regions-environments', - redirectTo: 'overview', - pathMatch: 'full', - }, - { - path: 'map', - redirectTo: 'overview', - pathMatch: 'full', - }, - { - path: 'environments', - redirectTo: 'overview', - pathMatch: 'full', - }, + + // --- Drilldown pages (reached via links in graph detail zone) --- { path: 'environments/:environmentId', title: 'Environment Detail', - data: { - breadcrumb: 'Environment Detail', - title: 'Environment Detail', - description: 'Topology-first tabs for targets, deployments, agents, security, and evidence.', - }, + data: { breadcrumb: 'Environment Detail' }, loadComponent: () => import('../features/topology/topology-environment-detail-page.component').then( (m) => m.TopologyEnvironmentDetailPageComponent, @@ -61,12 +39,8 @@ export const TOPOLOGY_ROUTES: Routes = [ }, { path: 'environments/:environmentId/posture', - title: 'Environment Detail', - data: { - breadcrumb: 'Environment Detail', - title: 'Environment Detail', - description: 'Topology-first tabs for targets, deployments, agents, security, and evidence.', - }, + title: 'Environment Posture', + data: { breadcrumb: 'Environment Posture' }, loadComponent: () => import('../features/topology/topology-environment-detail-page.component').then( (m) => m.TopologyEnvironmentDetailPageComponent, @@ -75,11 +49,7 @@ export const TOPOLOGY_ROUTES: Routes = [ { path: 'targets', title: 'Targets', - data: { - breadcrumb: 'Targets', - title: 'Targets', - description: 'Target runtime inventory with deployment and health context.', - }, + data: { breadcrumb: 'Targets' }, loadComponent: () => import('../features/topology/topology-targets-page.component').then( (m) => m.TopologyTargetsPageComponent, @@ -97,13 +67,11 @@ export const TOPOLOGY_ROUTES: Routes = [ { path: 'hosts', title: 'Hosts', - data: { - breadcrumb: 'Hosts', - title: 'Hosts', - description: 'Host runtime inventory and topology placement.', - }, + data: { breadcrumb: 'Hosts' }, loadComponent: () => - import('../features/topology/topology-hosts-page.component').then((m) => m.TopologyHostsPageComponent), + import('../features/topology/topology-hosts-page.component').then( + (m) => m.TopologyHostsPageComponent, + ), }, { path: 'hosts/:hostId', @@ -114,134 +82,32 @@ export const TOPOLOGY_ROUTES: Routes = [ (m) => m.TopologyHostDetailPageComponent, ), }, - { - path: 'agents', - title: 'Agent Fleet', - data: { - breadcrumb: 'Agent Fleet', - title: 'Agent Fleet', - description: 'Agent fleet status and assignments by region and environment.', - }, - loadComponent: () => - import('../features/topology/topology-agents-page.component').then((m) => m.TopologyAgentsPageComponent), - }, - { - path: 'agents/:agentGroupId', - title: 'Agent Group Detail', - data: { breadcrumb: 'Agent Group Detail' }, - loadComponent: () => - import('../features/topology/topology-agent-group-detail-page.component').then( - (m) => m.TopologyAgentGroupDetailPageComponent, - ), - }, - { - path: 'connectivity', - title: 'Connectivity', - data: { breadcrumb: 'Connectivity' }, - loadComponent: () => - import('../features/topology/topology-connectivity-page.component').then( - (m) => m.TopologyConnectivityPageComponent, - ), - }, - { - path: 'runtime-drift', - title: 'Runtime Drift', - data: { breadcrumb: 'Runtime Drift' }, - loadComponent: () => - import('../features/topology/topology-runtime-drift-page.component').then( - (m) => m.TopologyRuntimeDriftPageComponent, - ), - }, - { - path: 'promotion-graph', - title: 'Promotion Graph', - data: { - breadcrumb: 'Promotion Graph', - title: 'Promotion Graph', - description: 'Promotion path configurations and gate ownership.', - }, - loadComponent: () => - import('../features/topology/topology-promotion-paths-page.component').then( - (m) => m.TopologyPromotionPathsPageComponent, - ), - }, - { - path: 'promotion-paths', - redirectTo: 'promotion-graph', - pathMatch: 'full', - }, - { - path: 'workflows', - title: 'Workflows', - data: { - breadcrumb: 'Workflows', - title: 'Workflows', - description: 'Release workflow inventory for configured topology stages.', - endpoint: '/api/v2/topology/workflows', - }, - loadComponent: () => - import('../features/topology/topology-inventory-page.component').then( - (m) => m.TopologyInventoryPageComponent, - ), - }, - { - path: 'workflows-gates', - redirectTo: 'workflows', - pathMatch: 'full', - }, - { - path: 'gate-profiles', - title: 'Gate Profiles', - data: { - breadcrumb: 'Gate Profiles', - title: 'Gate Profiles', - description: 'Gate profile inventory and required approval policies.', - endpoint: '/api/v2/topology/gate-profiles', - }, - loadComponent: () => - import('../features/topology/topology-inventory-page.component').then( - (m) => m.TopologyInventoryPageComponent, - ), - }, - { - path: 'readiness', - title: 'Readiness Dashboard', - data: { - breadcrumb: 'Readiness', - title: 'Readiness Dashboard', - description: 'Gate status for all targets across environments and regions.', - }, - loadComponent: () => - import('../features/topology/readiness-dashboard.component').then( - (m) => m.ReadinessDashboardComponent, - ), - }, { path: 'posture', title: 'Release Posture', - data: { - breadcrumb: 'Posture', - title: 'Release Posture', - description: 'Environment posture, release activity, security findings, and evidence capsules.', - }, + data: { breadcrumb: 'Posture' }, loadComponent: () => import('../features/topology/environment-posture-page.component').then( (m) => m.EnvironmentPosturePageComponent, ), }, - { - path: 'pending-deletions', - title: 'Pending Deletions', - data: { - breadcrumb: 'Pending Deletions', - title: 'Pending Deletions', - description: 'Active deletion requests with cool-off countdown timers.', - }, - loadComponent: () => - import('../features/topology/pending-deletions-panel.component').then( - (m) => m.PendingDeletionsPanelComponent, - ), - }, + + // --- Redirects to new canonical locations --- + { path: 'regions', redirectTo: 'overview', pathMatch: 'full' }, + { path: 'regions-environments', redirectTo: 'overview', pathMatch: 'full' }, + { path: 'map', redirectTo: 'overview', pathMatch: 'full' }, + { path: 'environments', redirectTo: 'overview', pathMatch: 'full' }, + { path: 'agents', redirectTo: '/ops/operations/agents', pathMatch: 'full' }, + { path: 'agents/:agentGroupId', redirectTo: '/ops/operations/agents', pathMatch: 'full' }, + { path: 'runtime-drift', redirectTo: '/ops/operations/drift', pathMatch: 'full' }, + { path: 'connectivity', redirectTo: '/security/connectivity', pathMatch: 'full' }, + { path: 'pending-deletions', redirectTo: '/ops/operations/pending-deletions', pathMatch: 'full' }, + { path: 'gate-profiles', redirectTo: '/ops/policy/gates/profiles', pathMatch: 'full' }, + { path: 'promotion-graph', redirectTo: '/releases/promotion-graph', pathMatch: 'full' }, + { path: 'promotion-paths', redirectTo: '/releases/promotion-graph', pathMatch: 'full' }, + { path: 'workflows', redirectTo: '/releases/workflows', pathMatch: 'full' }, + { path: 'workflows-gates', redirectTo: '/releases/workflows', pathMatch: 'full' }, + { path: 'readiness', redirectTo: '/releases/readiness', pathMatch: 'full' }, ], }, ];