Remove topology tabs, add detail zone below graph, relocate pages

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) <noreply@anthropic.com>
This commit is contained in:
master
2026-03-28 22:24:35 +02:00
parent 302de9bf46
commit bf07f95ead
10 changed files with 462 additions and 474 deletions

View File

@@ -270,13 +270,12 @@ function routerLinksFor<T>(component: Type<T>): 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', () => {

View File

@@ -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' },
},
];

View File

@@ -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 {
<div class="banner banner--error">{{ error() }}</div>
}
<!-- Main area: graph + side panel -->
<div class="main-area" [class.main-area--panel-open]="panelOpen()">
<div class="graph-pane">
@if (loading()) {
<div class="loading">
<div class="loading-spinner"></div>
<span>Loading topology...</span>
</div>
} @else {
<app-topology-graph
[layout]="filteredLayout()"
(nodeSelected)="onNodeSelected($event)"
(edgeSelected)="onEdgeSelected($event)"
/>
}
</div>
@if (panelOpen()) {
<aside class="detail-panel">
<div class="panel-header">
<h3>{{ panelTitle() }}</h3>
<button type="button" class="panel-close" (click)="closePanel()">&times;</button>
</div>
@if (selectedNode(); as node) {
@if (node.kind === 'environment') {
<div class="panel-body">
<div class="panel-row">
<span class="panel-label">Region</span>
<span>{{ node.regionId }}</span>
</div>
<div class="panel-row">
<span class="panel-label">Type</span>
<span class="type-badge" [class]="'type-badge--' + (node.environmentType ?? 'development')">
{{ node.environmentType }}
</span>
</div>
<div class="panel-row">
<span class="panel-label">Health</span>
<span class="health-badge" [class]="'health-badge--' + (node.healthStatus ?? 'unknown')">
{{ node.healthStatus ?? 'unknown' }}
</span>
</div>
<div class="panel-row">
<span class="panel-label">Hosts</span>
<span>{{ node.hostCount }}</span>
</div>
<div class="panel-row">
<span class="panel-label">Targets</span>
<span>{{ node.targetCount }}</span>
</div>
@if (node.currentReleaseId) {
<div class="panel-row">
<span class="panel-label">Release</span>
<span>{{ node.currentReleaseId }}</span>
</div>
}
@if (node.isFrozen) {
<div class="panel-row">
<span class="panel-label">Status</span>
<span class="frozen-badge">FROZEN</span>
</div>
}
<div class="panel-row">
<span class="panel-label">Promotion Paths</span>
<span>{{ node.promotionPathCount }}</span>
</div>
<div class="panel-actions">
<a
[routerLink]="['/environments/environments', node.environmentId, 'posture']"
class="btn btn--primary"
>Open Detail</a>
<a
[routerLink]="['/environments/targets']"
[queryParams]="{ environment: node.environmentId }"
class="btn btn--secondary"
>View Hosts</a>
</div>
</div>
} @else {
<!-- Region selected -->
<div class="panel-body">
<div class="panel-row">
<span class="panel-label">Hosts</span>
<span>{{ node.hostCount }}</span>
</div>
<div class="panel-row">
<span class="panel-label">Targets</span>
<span>{{ node.targetCount }}</span>
</div>
</div>
}
}
@if (selectedEdge(); as edge) {
<div class="panel-body">
<div class="panel-row">
<span class="panel-label">From</span>
<span>{{ edge.sourceNodeId }}</span>
</div>
<div class="panel-row">
<span class="panel-label">To</span>
<span>{{ edge.targetNodeId }}</span>
</div>
@if (edge.pathMode) {
<div class="panel-row">
<span class="panel-label">Mode</span>
<span>{{ edge.pathMode }}</span>
</div>
}
@if (edge.status) {
<div class="panel-row">
<span class="panel-label">Status</span>
<span>{{ edge.status }}</span>
</div>
}
<div class="panel-row">
<span class="panel-label">Approvals</span>
<span>{{ edge.requiredApprovals }}</span>
</div>
@if (edge.gateProfileName) {
<div class="panel-row">
<span class="panel-label">Gate Profile</span>
<span>{{ edge.gateProfileName }}</span>
</div>
}
</div>
}
</aside>
<!-- Graph -->
<div class="graph-pane">
@if (loading()) {
<div class="loading">
<div class="loading-spinner"></div>
<span>Loading topology...</span>
</div>
} @else {
<app-topology-graph
[layout]="filteredLayout()"
(nodeSelected)="onNodeSelected($event)"
(edgeSelected)="onEdgeSelected($event)"
/>
}
</div>
<!-- Detail zone (below graph) -->
@if (selectedNode() || selectedEdge()) {
<section class="detail-zone">
<header class="detail-zone__header">
<h2>{{ detailTitle() }}</h2>
<button type="button" class="detail-zone__close" (click)="clearSelection()">&times;</button>
</header>
@if (selectedNode(); as node) {
@if (node.kind === 'environment') {
<div class="detail-zone__summary">
<div class="detail-chip">
<span class="detail-chip__label">Region</span>
<span>{{ node.regionId }}</span>
</div>
<div class="detail-chip">
<span class="detail-chip__label">Type</span>
<span class="type-val" [class]="'type-val--' + (node.environmentType ?? '')">{{ node.environmentType }}</span>
</div>
<div class="detail-chip">
<span class="detail-chip__label">Health</span>
<span class="health-val" [class]="'health-val--' + (node.healthStatus ?? 'unknown')">{{ node.healthStatus ?? 'unknown' }}</span>
</div>
<div class="detail-chip">
<span class="detail-chip__label">Hosts</span>
<span>{{ node.hostCount }}</span>
</div>
<div class="detail-chip">
<span class="detail-chip__label">Targets</span>
<span>{{ node.targetCount }}</span>
</div>
@if (node.currentReleaseId) {
<div class="detail-chip">
<span class="detail-chip__label">Release</span>
<span>{{ node.currentReleaseId }}</span>
</div>
}
@if (node.isFrozen) {
<div class="detail-chip detail-chip--frozen">
<span>FROZEN</span>
</div>
}
<div class="detail-chip detail-chip--actions">
<a [routerLink]="['/environments/environments', node.environmentId, 'posture']" class="action-link">Open Detail</a>
</div>
</div>
<!-- Hosts/Targets table -->
@if (detailTargets().length > 0 || detailHosts().length > 0) {
<div class="detail-zone__tables">
@if (detailHosts().length > 0) {
<table class="detail-table">
<thead>
<tr>
<th>Host</th>
<th>Runtime</th>
<th>Status</th>
<th>Targets</th>
<th>Agent</th>
</tr>
</thead>
<tbody>
@for (host of detailHosts(); track host.hostId) {
<tr>
<td>{{ host.hostName }}</td>
<td>{{ host.runtimeType }}</td>
<td><span class="health-val" [class]="'health-val--' + host.status">{{ host.status }}</span></td>
<td>{{ host.targetCount }}</td>
<td>{{ host.agentId || '—' }}</td>
</tr>
}
</tbody>
</table>
}
@if (detailTargets().length > 0) {
<table class="detail-table">
<thead>
<tr>
<th>Target</th>
<th>Type</th>
<th>Health</th>
<th>Image</th>
</tr>
</thead>
<tbody>
@for (target of detailTargets(); track target.targetId) {
<tr>
<td>{{ target.name }}</td>
<td>{{ target.targetType }}</td>
<td><span class="health-val" [class]="'health-val--' + target.healthStatus">{{ target.healthStatus }}</span></td>
<td class="mono">{{ target.imageDigest ? target.imageDigest.substring(0, 16) + '...' : '—' }}</td>
</tr>
}
</tbody>
</table>
}
</div>
} @else if (!detailLoading()) {
<p class="detail-zone__empty">No hosts or targets registered for this environment.</p>
}
@if (detailLoading()) {
<p class="detail-zone__loading">Loading environment detail...</p>
}
} @else {
<!-- Region selected -->
<div class="detail-zone__summary">
<div class="detail-chip">
<span class="detail-chip__label">Hosts</span>
<span>{{ node.hostCount }}</span>
</div>
<div class="detail-chip">
<span class="detail-chip__label">Targets</span>
<span>{{ node.targetCount }}</span>
</div>
</div>
}
}
@if (selectedEdge(); as edge) {
<div class="detail-zone__summary">
<div class="detail-chip">
<span class="detail-chip__label">From</span>
<span>{{ getNodeLabel(edge.sourceNodeId) }}</span>
</div>
<div class="detail-chip">
<span class="detail-chip__label">To</span>
<span>{{ getNodeLabel(edge.targetNodeId) }}</span>
</div>
@if (edge.pathMode) {
<div class="detail-chip">
<span class="detail-chip__label">Mode</span>
<span>{{ edge.pathMode }}</span>
</div>
}
@if (edge.status) {
<div class="detail-chip">
<span class="detail-chip__label">Status</span>
<span>{{ edge.status }}</span>
</div>
}
<div class="detail-chip">
<span class="detail-chip__label">Approvals</span>
<span>{{ edge.requiredApprovals }}</span>
</div>
@if (edge.gateProfileName) {
<div class="detail-chip">
<span class="detail-chip__label">Gate Profile</span>
<span>{{ edge.gateProfileName }}</span>
</div>
}
@if (edge.label) {
<div class="detail-chip detail-chip--wide">
<span class="detail-chip__label">Gates</span>
<span>{{ edge.label }}</span>
</div>
}
</div>
}
</section>
}
</section>
`,
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<TopologyPositionedNode | null>(null);
readonly selectedEdge = signal<TopologyRoutedEdge | null>(null);
readonly detailTargets = signal<TopologyTarget[]>([]);
readonly detailHosts = signal<TopologyHost[]>([]);
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),
});
}
}

View File

@@ -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<TopologyLayoutResponse>('/api/v2/topology/layout', { params });
}
getTargets(environmentId: string): Observable<TopologyTarget[]> {
const params = new HttpParams().set('environment', environmentId).set('limit', '200');
return this.http
.get<PlatformListResponse<TopologyTarget>>('/api/v2/topology/targets', { params })
.pipe(map((r) => r?.items ?? []));
}
getHosts(environmentId: string): Observable<TopologyHost[]> {
const params = new HttpParams().set('environment', environmentId).set('limit', '200');
return this.http
.get<PlatformListResponse<TopologyHost>>('/api/v2/topology/hosts', { params })
.pipe(map((r) => r?.items ?? []));
}
}

View File

@@ -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: `
<section class="topology-shell">
@@ -50,18 +16,10 @@ const PAGE_TABS: readonly StellaPageTab[] = [
</div>
<div>
<h1>Environments</h1>
<p>Regions, targets, hosts, agents, promotion flows, and release posture</p>
<p>Topology, targets, promotion flows, and release posture</p>
</div>
</header>
<stella-page-tabs
[tabs]="pageTabs"
[activeTab]="activeTab()"
ariaLabel="Environments tabs"
(tabChange)="onTabChange($event)"
>
<router-outlet />
</stella-page-tabs>
<router-outlet />
</section>
`,
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<string>('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 {}

View File

@@ -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',

View File

@@ -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,
),
},
];

View File

@@ -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,
),
},
];

View File

@@ -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,
),
},
];

View File

@@ -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' },
],
},
];