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:
@@ -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', () => {
|
||||
|
||||
@@ -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' },
|
||||
},
|
||||
];
|
||||
|
||||
@@ -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()">×</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()">×</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),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 ?? []));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {}
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
@@ -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' },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
Reference in New Issue
Block a user