diff --git a/src/Platform/StellaOps.Platform.WebService/Services/TopologyLayoutService.cs b/src/Platform/StellaOps.Platform.WebService/Services/TopologyLayoutService.cs index 76592112f..2fe253f40 100644 --- a/src/Platform/StellaOps.Platform.WebService/Services/TopologyLayoutService.cs +++ b/src/Platform/StellaOps.Platform.WebService/Services/TopologyLayoutService.cs @@ -107,14 +107,26 @@ public sealed class TopologyLayoutService }); } + var envNodeIds = new HashSet( + environments.Select(e => $"env-{e.EnvironmentId}"), + StringComparer.Ordinal); + foreach (var path in paths) { + var sourceNodeId = $"env-{path.SourceEnvironmentId}"; + var targetNodeId = $"env-{path.TargetEnvironmentId}"; + + if (!envNodeIds.Contains(sourceNodeId) || !envNodeIds.Contains(targetNodeId)) + { + continue; + } + var gateLabel = BuildGateLabel(path, gatesByProfile); elkEdges.Add(new ElkEdge { Id = $"path-{path.PathId}", - SourceNodeId = $"env-{path.SourceEnvironmentId}", - TargetNodeId = $"env-{path.TargetEnvironmentId}", + SourceNodeId = sourceNodeId, + TargetNodeId = targetNodeId, Kind = "promotion", Label = gateLabel, }); 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 97d33abea..86c144d13 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,13 +2,14 @@ import { ChangeDetectionStrategy, Component, computed, + effect, inject, signal, } from '@angular/core'; -import { FormsModule } from '@angular/forms'; import { RouterLink } from '@angular/router'; import { catchError, of, take } from 'rxjs'; +import { PlatformContextStore } from '../../core/context/platform-context.store'; import { TopologyLayoutService } from './topology-layout.service'; import { TopologyGraphComponent } from './topology-graph.component'; import { @@ -21,52 +22,15 @@ import { TopologyTarget, TopologyHost } from './topology.models'; @Component({ selector: 'app-topology-graph-page', standalone: true, - imports: [FormsModule, RouterLink, TopologyGraphComponent], + imports: [RouterLink, TopologyGraphComponent], changeDetection: ChangeDetectionStrategy.OnPush, template: ` -
- -
-
- - -
-
- - -
-
- - -
-
- {{ layout()?.metadata?.regionCount ?? 0 }} regions - {{ layout()?.metadata?.environmentCount ?? 0 }} environments - {{ layout()?.metadata?.promotionPathCount ?? 0 }} paths -
-
- +
@if (error()) { } - +
@if (loading()) {
@@ -74,241 +38,167 @@ import { TopologyTarget, TopologyHost } from './topology.models'; Loading topology...
} @else { +
+ {{ layout()?.metadata?.regionCount ?? 0 }} regions + {{ layout()?.metadata?.environmentCount ?? 0 }} environments + {{ layout()?.metadata?.promotionPathCount ?? 0 }} paths +
}
- + @if (selectedNode() || selectedEdge()) { -
-
+
+ }
`, styles: [` .topo-page { display: grid; + grid-template-columns: 1fr; gap: 0.5rem; + height: 100%; + position: relative; } - /* Filter bar */ - .filter-bar { - border: 1px solid var(--color-border-primary); - border-radius: var(--radius-md); - background: var(--color-surface-primary); - padding: 0.5rem 0.65rem; - display: flex; - gap: 0.5rem; - align-items: flex-end; - flex-wrap: wrap; - } - - .filter-item { display: grid; gap: 0.1rem; } - .filter-item--wide { flex: 1; min-width: 180px; } - - .filter-bar label { - font-size: 0.65rem; - color: var(--color-text-muted); - text-transform: uppercase; - letter-spacing: 0.04em; - font-weight: 500; - } - - .filter-bar select, - .filter-bar input { - border: 1px solid var(--color-border-primary); - border-radius: var(--radius-sm); - background: var(--color-surface-secondary); - color: var(--color-text-primary); - font-size: 0.78rem; - padding: 0.28rem 0.4rem; - } - - .filter-bar select:focus, - .filter-bar input:focus { - outline: none; - border-color: var(--color-border-focus); - box-shadow: 0 0 0 2px var(--color-focus-ring); - } - - .filter-stats { - display: flex; - gap: 0.4rem; - align-items: center; - margin-left: auto; - } - - .filter-stats span { - font-size: 0.68rem; - color: var(--color-text-secondary); - border: 1px solid var(--color-border-primary); - border-radius: var(--radius-full); - padding: 0.1rem 0.4rem; - background: var(--color-surface-secondary); - white-space: nowrap; + .topo-page--panel-open { + grid-template-columns: 1fr 360px; } /* Error */ .banner--error { + grid-column: 1 / -1; border: 1px solid var(--color-status-error-border); border-radius: var(--radius-md); background: var(--color-status-error-bg); @@ -319,9 +209,27 @@ import { TopologyTarget, TopologyHost } from './topology.models'; /* Graph */ .graph-pane { - min-height: 420px; - max-height: 520px; position: relative; + min-height: 400px; + } + + .graph-stats { + position: absolute; + top: 0.4rem; + left: 0.4rem; + z-index: 1; + display: flex; + gap: 0.3rem; + } + + .graph-stats span { + font-size: 0.65rem; + color: var(--color-text-secondary); + border: 1px solid var(--color-border-primary); + border-radius: var(--radius-full); + padding: 0.08rem 0.35rem; + background: var(--color-surface-primary); + white-space: nowrap; } .loading { @@ -346,28 +254,34 @@ import { TopologyTarget, TopologyHost } from './topology.models'; @keyframes spin { to { transform: rotate(360deg); } } - /* Detail zone */ - .detail-zone { + /* Right side drawer */ + .detail-drawer { border: 1px solid var(--color-border-primary); border-radius: var(--radius-md); background: var(--color-surface-primary); + overflow-y: auto; + max-height: 100%; } - .detail-zone__header { + .drawer-header { display: flex; justify-content: space-between; align-items: center; - padding: 0.45rem 0.65rem; + padding: 0.5rem 0.65rem; border-bottom: 1px solid var(--color-border-primary); + position: sticky; + top: 0; + background: var(--color-surface-primary); + z-index: 1; } - .detail-zone__header h2 { + .drawer-header h2 { margin: 0; - font-size: 0.88rem; + font-size: 0.85rem; font-weight: 600; } - .detail-zone__close { + .drawer-close { border: none; background: none; color: var(--color-text-secondary); @@ -376,58 +290,33 @@ import { TopologyTarget, TopologyHost } from './topology.models'; padding: 0 0.2rem; } - .detail-zone__close:hover { color: var(--color-text-primary); } + .drawer-close:hover { color: var(--color-text-primary); } - .detail-zone__summary { - display: flex; - flex-wrap: wrap; - gap: 0.5rem; + .drawer-body { padding: 0.5rem 0.65rem; + display: grid; + gap: 0.5rem; } - .detail-chip { - display: flex; - gap: 0.35rem; - align-items: center; + /* Definition list grid */ + .drawer-grid { + display: grid; + grid-template-columns: auto 1fr; + gap: 0.2rem 0.6rem; + margin: 0; 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); } - .detail-chip--wide { flex-basis: 100%; } - - .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; - } - - .detail-chip--actions { - border: none; - background: none; - padding: 0; - margin-left: auto; - } - - .detail-chip__label { + .drawer-grid dt { color: var(--color-text-muted); - font-size: 0.68rem; + font-size: 0.7rem; text-transform: uppercase; letter-spacing: 0.03em; + padding-top: 0.1rem; } - .action-link { - padding: 0.25rem 0.6rem; - border-radius: var(--radius-sm); - font-size: 0.72rem; - font-weight: 500; - text-decoration: none; - background: var(--color-btn-primary-bg, var(--color-brand-primary)); - color: var(--color-btn-primary-text, #fff); + .drawer-grid dd { + margin: 0; } .type-val--production { color: var(--color-status-error-text); font-weight: 500; } @@ -439,60 +328,109 @@ import { TopologyTarget, TopologyHost } from './topology.models'; .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; + .frozen-banner { + background: var(--color-status-error-bg); + border: 1px solid var(--color-status-error-border); + color: var(--color-status-error-text); + font-weight: 700; + font-size: 0.72rem; + text-align: center; + padding: 0.2rem; + border-radius: var(--radius-sm); } - .detail-table { + /* Section tables */ + .drawer-section { + margin: 0; + font-size: 0.72rem; + font-weight: 600; + color: var(--color-text-secondary); + text-transform: uppercase; + letter-spacing: 0.04em; + padding-top: 0.3rem; + border-top: 1px solid var(--color-border-primary); + } + + .drawer-table { width: 100%; border-collapse: collapse; - font-size: 0.75rem; + font-size: 0.72rem; } - .detail-table th { + .drawer-table th { text-align: left; - font-size: 0.67rem; + font-size: 0.65rem; color: var(--color-text-muted); text-transform: uppercase; letter-spacing: 0.04em; - padding: 0.3rem 0.4rem; + padding: 0.2rem 0.3rem; border-bottom: 1px solid var(--color-border-primary); } - .detail-table td { - padding: 0.3rem 0.4rem; + .drawer-table td { + padding: 0.2rem 0.3rem; 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; } + .drawer-table tr:last-child td { border-bottom: none; } + .mono { font-family: var(--font-mono, monospace); font-size: 0.65rem; } - .detail-zone__empty, - .detail-zone__loading { - padding: 0.5rem 0.65rem; + .drawer-empty, .drawer-loading { + margin: 0; color: var(--color-text-muted); - font-size: 0.78rem; + font-size: 0.75rem; + } + + .drawer-gates { + font-size: 0.75rem; + padding: 0.3rem; + background: var(--color-surface-secondary); + border-radius: var(--radius-sm); + border: 1px solid var(--color-border-primary); + } + + .drawer-gates__label { + display: block; + font-size: 0.65rem; + color: var(--color-text-muted); + text-transform: uppercase; + letter-spacing: 0.03em; + margin-bottom: 0.15rem; + } + + .drawer-actions { + padding-top: 0.3rem; + border-top: 1px solid var(--color-border-primary); + } + + .action-btn { + display: inline-block; + padding: 0.3rem 0.65rem; + border-radius: var(--radius-sm); + font-size: 0.72rem; + font-weight: 500; + text-decoration: none; + background: var(--color-btn-primary-bg, var(--color-brand-primary)); + color: var(--color-btn-primary-text, #fff); } @media (max-width: 960px) { - .filter-bar { flex-direction: column; } - .filter-item--wide { min-width: auto; } + .topo-page--panel-open { + grid-template-columns: 1fr; + } + .detail-drawer { + max-height: 300px; + } } `], }) export class TopologyGraphPageComponent { private readonly layoutService = inject(TopologyLayoutService); + readonly context = inject(PlatformContextStore); readonly loading = signal(false); readonly error = signal(null); readonly layout = signal(null); - readonly searchQuery = signal(''); - readonly typeFilter = signal(''); - readonly healthFilter = signal(''); readonly selectedNode = signal(null); readonly selectedEdge = signal(null); readonly detailTargets = signal([]); @@ -503,53 +441,18 @@ export class TopologyGraphPageComponent { const node = this.selectedNode(); if (node) return node.label; const edge = this.selectedEdge(); - if (edge) return `Promotion: ${this.getNodeLabel(edge.sourceNodeId)} → ${this.getNodeLabel(edge.targetNodeId)}`; + if (edge) return `${this.getNodeLabel(edge.sourceNodeId)} \u2192 ${this.getNodeLabel(edge.targetNodeId)}`; return ''; }); - readonly filteredLayout = computed((): TopologyLayoutResponse | null => { - const data = this.layout(); - if (!data) return null; - - const query = this.searchQuery().trim().toLowerCase(); - const typeF = this.typeFilter(); - const healthF = this.healthFilter(); - - if (!query && !typeF && !healthF) return data; - - const matchedEnvIds = new Set(); - const matchedRegionIds = new Set(); - - const filteredNodes = data.nodes.filter((n) => { - if (n.kind === 'region') return true; - - const matchesSearch = !query - || n.label.toLowerCase().includes(query) - || (n.environmentId?.toLowerCase().includes(query) ?? false) - || (n.regionId?.toLowerCase().includes(query) ?? false); - const matchesType = !typeF || n.environmentType === typeF; - const matchesHealth = !healthF || n.healthStatus === healthF; - const keep = matchesSearch && matchesType && matchesHealth; - - if (keep) { - matchedEnvIds.add(n.id); - if (n.parentNodeId) matchedRegionIds.add(n.parentNodeId); - } - return keep; - }).filter((n) => { - if (n.kind === 'region') return matchedRegionIds.has(n.id); - return true; - }); - - const filteredEdges = data.edges.filter( - (e) => matchedEnvIds.has(e.sourceNodeId) && matchedEnvIds.has(e.targetNodeId), - ); - - return { ...data, nodes: filteredNodes, edges: filteredEdges }; - }); - constructor() { - this.load(); + this.context.initialize(); + + effect(() => { + // Re-fetch layout when global context changes (region/env selection) + this.context.contextVersion(); + this.load(); + }); } onNodeSelected(node: TopologyPositionedNode): void { @@ -586,8 +489,14 @@ export class TopologyGraphPageComponent { this.loading.set(true); this.error.set(null); + const regions = this.context.selectedRegions(); + const environments = this.context.selectedEnvironments(); + this.layoutService - .getLayout() + .getLayout({ + region: regions.length > 0 ? regions.join(',') : undefined, + environment: environments.length > 0 ? environments.join(',') : undefined, + }) .pipe( take(1), catchError((err: unknown) => { @@ -601,6 +510,7 @@ export class TopologyGraphPageComponent { next: (result) => { this.layout.set(result); this.loading.set(false); + this.clearSelection(); }, }); }