Fix topology filtering: use global context, right side drawer, edge filter

- Remove local filter bar (Search/Type/Health) — use global header
  context (Region/Env/Stage) as single source of filtering
- Pass global context selections to layout API — graph re-fetches and
  ElkSharp re-lays out only the filtered environments
- Fix backend: skip promotion edges where source or target environment
  is outside the filtered set (was causing 400 errors)
- Replace below-graph detail zone with 360px right side drawer that
  doesn't scroll away from the graph
- Stats badges (regions/environments/paths) moved to overlay on graph

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
master
2026-03-28 22:49:24 +02:00
parent 24964c8e53
commit 4733285f03
2 changed files with 274 additions and 352 deletions

View File

@@ -107,14 +107,26 @@ public sealed class TopologyLayoutService
}); });
} }
var envNodeIds = new HashSet<string>(
environments.Select(e => $"env-{e.EnvironmentId}"),
StringComparer.Ordinal);
foreach (var path in paths) 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); var gateLabel = BuildGateLabel(path, gatesByProfile);
elkEdges.Add(new ElkEdge elkEdges.Add(new ElkEdge
{ {
Id = $"path-{path.PathId}", Id = $"path-{path.PathId}",
SourceNodeId = $"env-{path.SourceEnvironmentId}", SourceNodeId = sourceNodeId,
TargetNodeId = $"env-{path.TargetEnvironmentId}", TargetNodeId = targetNodeId,
Kind = "promotion", Kind = "promotion",
Label = gateLabel, Label = gateLabel,
}); });

View File

@@ -2,13 +2,14 @@ import {
ChangeDetectionStrategy, ChangeDetectionStrategy,
Component, Component,
computed, computed,
effect,
inject, inject,
signal, signal,
} from '@angular/core'; } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { RouterLink } from '@angular/router'; import { RouterLink } from '@angular/router';
import { catchError, of, take } from 'rxjs'; import { catchError, of, take } from 'rxjs';
import { PlatformContextStore } from '../../core/context/platform-context.store';
import { TopologyLayoutService } from './topology-layout.service'; import { TopologyLayoutService } from './topology-layout.service';
import { TopologyGraphComponent } from './topology-graph.component'; import { TopologyGraphComponent } from './topology-graph.component';
import { import {
@@ -21,52 +22,15 @@ import { TopologyTarget, TopologyHost } from './topology.models';
@Component({ @Component({
selector: 'app-topology-graph-page', selector: 'app-topology-graph-page',
standalone: true, standalone: true,
imports: [FormsModule, RouterLink, TopologyGraphComponent], imports: [RouterLink, TopologyGraphComponent],
changeDetection: ChangeDetectionStrategy.OnPush, changeDetection: ChangeDetectionStrategy.OnPush,
template: ` template: `
<section class="topo-page"> <section class="topo-page" [class.topo-page--panel-open]="selectedNode() || selectedEdge()">
<!-- Filter bar -->
<div class="filter-bar">
<div class="filter-item filter-item--wide">
<label for="topo-search">Search</label>
<input
id="topo-search"
type="text"
placeholder="Filter environments..."
[ngModel]="searchQuery()"
(ngModelChange)="searchQuery.set($event)"
/>
</div>
<div class="filter-item">
<label for="topo-type">Type</label>
<select id="topo-type" [ngModel]="typeFilter()" (ngModelChange)="typeFilter.set($event)">
<option value="">All</option>
<option value="production">Production</option>
<option value="staging">Staging</option>
<option value="development">Development</option>
</select>
</div>
<div class="filter-item">
<label for="topo-health">Health</label>
<select id="topo-health" [ngModel]="healthFilter()" (ngModelChange)="healthFilter.set($event)">
<option value="">All</option>
<option value="healthy">Healthy</option>
<option value="degraded">Degraded</option>
<option value="unhealthy">Unhealthy</option>
</select>
</div>
<div class="filter-stats">
<span>{{ layout()?.metadata?.regionCount ?? 0 }} regions</span>
<span>{{ layout()?.metadata?.environmentCount ?? 0 }} environments</span>
<span>{{ layout()?.metadata?.promotionPathCount ?? 0 }} paths</span>
</div>
</div>
@if (error()) { @if (error()) {
<div class="banner banner--error">{{ error() }}</div> <div class="banner banner--error">{{ error() }}</div>
} }
<!-- Graph --> <!-- Graph fills the page -->
<div class="graph-pane"> <div class="graph-pane">
@if (loading()) { @if (loading()) {
<div class="loading"> <div class="loading">
@@ -74,241 +38,167 @@ import { TopologyTarget, TopologyHost } from './topology.models';
<span>Loading topology...</span> <span>Loading topology...</span>
</div> </div>
} @else { } @else {
<div class="graph-stats">
<span>{{ layout()?.metadata?.regionCount ?? 0 }} regions</span>
<span>{{ layout()?.metadata?.environmentCount ?? 0 }} environments</span>
<span>{{ layout()?.metadata?.promotionPathCount ?? 0 }} paths</span>
</div>
<app-topology-graph <app-topology-graph
[layout]="filteredLayout()" [layout]="layout()"
(nodeSelected)="onNodeSelected($event)" (nodeSelected)="onNodeSelected($event)"
(edgeSelected)="onEdgeSelected($event)" (edgeSelected)="onEdgeSelected($event)"
/> />
} }
</div> </div>
<!-- Detail zone (below graph) --> <!-- Right side drawer (overlay) -->
@if (selectedNode() || selectedEdge()) { @if (selectedNode() || selectedEdge()) {
<section class="detail-zone"> <aside class="detail-drawer">
<header class="detail-zone__header"> <header class="drawer-header">
<h2>{{ detailTitle() }}</h2> <h2>{{ detailTitle() }}</h2>
<button type="button" class="detail-zone__close" (click)="clearSelection()">&times;</button> <button type="button" class="drawer-close" (click)="clearSelection()">&times;</button>
</header> </header>
@if (selectedNode(); as node) { @if (selectedNode(); as node) {
@if (node.kind === 'environment') { @if (node.kind === 'environment') {
<div class="detail-zone__summary"> <div class="drawer-body">
<div class="detail-chip"> <dl class="drawer-grid">
<span class="detail-chip__label">Region</span> <dt>Region</dt>
<span>{{ node.regionId }}</span> <dd>{{ node.regionId }}</dd>
</div> <dt>Type</dt>
<div class="detail-chip"> <dd class="type-val" [class]="'type-val--' + (node.environmentType ?? '')">{{ node.environmentType }}</dd>
<span class="detail-chip__label">Type</span> <dt>Health</dt>
<span class="type-val" [class]="'type-val--' + (node.environmentType ?? '')">{{ node.environmentType }}</span> <dd class="health-val" [class]="'health-val--' + (node.healthStatus ?? 'unknown')">{{ node.healthStatus ?? 'unknown' }}</dd>
</div> <dt>Hosts</dt>
<div class="detail-chip"> <dd>{{ node.hostCount }}</dd>
<span class="detail-chip__label">Health</span> <dt>Targets</dt>
<span class="health-val" [class]="'health-val--' + (node.healthStatus ?? 'unknown')">{{ node.healthStatus ?? 'unknown' }}</span> <dd>{{ node.targetCount }}</dd>
</div> @if (node.currentReleaseId) {
<div class="detail-chip"> <dt>Release</dt>
<span class="detail-chip__label">Hosts</span> <dd>{{ node.currentReleaseId }}</dd>
<span>{{ node.hostCount }}</span> }
</div> <dt>Paths</dt>
<div class="detail-chip"> <dd>{{ node.promotionPathCount }}</dd>
<span class="detail-chip__label">Targets</span> </dl>
<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) { @if (node.isFrozen) {
<div class="detail-chip detail-chip--frozen"> <div class="frozen-banner">FROZEN</div>
<span>FROZEN</span>
</div>
} }
<div class="detail-chip detail-chip--actions">
<a [routerLink]="['/environments/environments', node.environmentId, 'posture']" class="action-link">Open Detail</a> <!-- Hosts table -->
@if (detailHosts().length > 0) {
<h3 class="drawer-section">Hosts</h3>
<table class="drawer-table">
<thead>
<tr><th>Host</th><th>Runtime</th><th>Status</th><th>Targets</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>
</tr>
}
</tbody>
</table>
}
<!-- Targets table -->
@if (detailTargets().length > 0) {
<h3 class="drawer-section">Targets</h3>
<table class="drawer-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>
}
@if (detailTargets().length === 0 && detailHosts().length === 0 && !detailLoading()) {
<p class="drawer-empty">No hosts or targets registered.</p>
}
@if (detailLoading()) {
<p class="drawer-loading">Loading...</p>
}
<div class="drawer-actions">
<a [routerLink]="['/environments/environments', node.environmentId, 'posture']" class="action-btn">Open Detail</a>
</div> </div>
</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 { } @else {
<!-- Region selected --> <!-- Region selected -->
<div class="detail-zone__summary"> <div class="drawer-body">
<div class="detail-chip"> <dl class="drawer-grid">
<span class="detail-chip__label">Hosts</span> <dt>Hosts</dt>
<span>{{ node.hostCount }}</span> <dd>{{ node.hostCount }}</dd>
</div> <dt>Targets</dt>
<div class="detail-chip"> <dd>{{ node.targetCount }}</dd>
<span class="detail-chip__label">Targets</span> </dl>
<span>{{ node.targetCount }}</span>
</div>
</div> </div>
} }
} }
@if (selectedEdge(); as edge) { @if (selectedEdge(); as edge) {
<div class="detail-zone__summary"> <div class="drawer-body">
<div class="detail-chip"> <dl class="drawer-grid">
<span class="detail-chip__label">From</span> <dt>From</dt>
<span>{{ getNodeLabel(edge.sourceNodeId) }}</span> <dd>{{ getNodeLabel(edge.sourceNodeId) }}</dd>
</div> <dt>To</dt>
<div class="detail-chip"> <dd>{{ getNodeLabel(edge.targetNodeId) }}</dd>
<span class="detail-chip__label">To</span> @if (edge.pathMode) {
<span>{{ getNodeLabel(edge.targetNodeId) }}</span> <dt>Mode</dt>
</div> <dd>{{ edge.pathMode }}</dd>
@if (edge.pathMode) { }
<div class="detail-chip"> @if (edge.status) {
<span class="detail-chip__label">Mode</span> <dt>Status</dt>
<span>{{ edge.pathMode }}</span> <dd>{{ edge.status }}</dd>
</div> }
} <dt>Approvals</dt>
@if (edge.status) { <dd>{{ edge.requiredApprovals }}</dd>
<div class="detail-chip"> @if (edge.gateProfileName) {
<span class="detail-chip__label">Status</span> <dt>Gate Profile</dt>
<span>{{ edge.status }}</span> <dd>{{ edge.gateProfileName }}</dd>
</div> }
} </dl>
<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) { @if (edge.label) {
<div class="detail-chip detail-chip--wide"> <div class="drawer-gates">
<span class="detail-chip__label">Gates</span> <span class="drawer-gates__label">Gates</span>
<span>{{ edge.label }}</span> <span>{{ edge.label }}</span>
</div> </div>
} }
</div> </div>
} }
</section> </aside>
} }
</section> </section>
`, `,
styles: [` styles: [`
.topo-page { .topo-page {
display: grid; display: grid;
grid-template-columns: 1fr;
gap: 0.5rem; gap: 0.5rem;
height: 100%;
position: relative;
} }
/* Filter bar */ .topo-page--panel-open {
.filter-bar { grid-template-columns: 1fr 360px;
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;
} }
/* Error */ /* Error */
.banner--error { .banner--error {
grid-column: 1 / -1;
border: 1px solid var(--color-status-error-border); border: 1px solid var(--color-status-error-border);
border-radius: var(--radius-md); border-radius: var(--radius-md);
background: var(--color-status-error-bg); background: var(--color-status-error-bg);
@@ -319,9 +209,27 @@ import { TopologyTarget, TopologyHost } from './topology.models';
/* Graph */ /* Graph */
.graph-pane { .graph-pane {
min-height: 420px;
max-height: 520px;
position: relative; 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 { .loading {
@@ -346,28 +254,34 @@ import { TopologyTarget, TopologyHost } from './topology.models';
@keyframes spin { to { transform: rotate(360deg); } } @keyframes spin { to { transform: rotate(360deg); } }
/* Detail zone */ /* Right side drawer */
.detail-zone { .detail-drawer {
border: 1px solid var(--color-border-primary); border: 1px solid var(--color-border-primary);
border-radius: var(--radius-md); border-radius: var(--radius-md);
background: var(--color-surface-primary); background: var(--color-surface-primary);
overflow-y: auto;
max-height: 100%;
} }
.detail-zone__header { .drawer-header {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
align-items: center; align-items: center;
padding: 0.45rem 0.65rem; padding: 0.5rem 0.65rem;
border-bottom: 1px solid var(--color-border-primary); 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; margin: 0;
font-size: 0.88rem; font-size: 0.85rem;
font-weight: 600; font-weight: 600;
} }
.detail-zone__close { .drawer-close {
border: none; border: none;
background: none; background: none;
color: var(--color-text-secondary); color: var(--color-text-secondary);
@@ -376,58 +290,33 @@ import { TopologyTarget, TopologyHost } from './topology.models';
padding: 0 0.2rem; padding: 0 0.2rem;
} }
.detail-zone__close:hover { color: var(--color-text-primary); } .drawer-close:hover { color: var(--color-text-primary); }
.detail-zone__summary { .drawer-body {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
padding: 0.5rem 0.65rem; padding: 0.5rem 0.65rem;
display: grid;
gap: 0.5rem;
} }
.detail-chip { /* Definition list grid */
display: flex; .drawer-grid {
gap: 0.35rem; display: grid;
align-items: center; grid-template-columns: auto 1fr;
gap: 0.2rem 0.6rem;
margin: 0;
font-size: 0.78rem; 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%; } .drawer-grid dt {
.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 {
color: var(--color-text-muted); color: var(--color-text-muted);
font-size: 0.68rem; font-size: 0.7rem;
text-transform: uppercase; text-transform: uppercase;
letter-spacing: 0.03em; letter-spacing: 0.03em;
padding-top: 0.1rem;
} }
.action-link { .drawer-grid dd {
padding: 0.25rem 0.6rem; margin: 0;
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);
} }
.type-val--production { color: var(--color-status-error-text); font-weight: 500; } .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--unhealthy { color: var(--color-status-error-text); font-weight: 500; }
.health-val--unknown { color: var(--color-text-muted); } .health-val--unknown { color: var(--color-text-muted); }
/* Tables */ .frozen-banner {
.detail-zone__tables { background: var(--color-status-error-bg);
padding: 0 0.65rem 0.5rem; border: 1px solid var(--color-status-error-border);
display: grid; color: var(--color-status-error-text);
gap: 0.5rem; 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%; width: 100%;
border-collapse: collapse; border-collapse: collapse;
font-size: 0.75rem; font-size: 0.72rem;
} }
.detail-table th { .drawer-table th {
text-align: left; text-align: left;
font-size: 0.67rem; font-size: 0.65rem;
color: var(--color-text-muted); color: var(--color-text-muted);
text-transform: uppercase; text-transform: uppercase;
letter-spacing: 0.04em; letter-spacing: 0.04em;
padding: 0.3rem 0.4rem; padding: 0.2rem 0.3rem;
border-bottom: 1px solid var(--color-border-primary); border-bottom: 1px solid var(--color-border-primary);
} }
.detail-table td { .drawer-table td {
padding: 0.3rem 0.4rem; padding: 0.2rem 0.3rem;
border-bottom: 1px solid var(--color-border-primary); border-bottom: 1px solid var(--color-border-primary);
} }
.detail-table tr:last-child td { border-bottom: none; } .drawer-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.65rem; }
.mono { font-family: var(--font-mono, monospace); font-size: 0.7rem; }
.detail-zone__empty, .drawer-empty, .drawer-loading {
.detail-zone__loading { margin: 0;
padding: 0.5rem 0.65rem;
color: var(--color-text-muted); 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) { @media (max-width: 960px) {
.filter-bar { flex-direction: column; } .topo-page--panel-open {
.filter-item--wide { min-width: auto; } grid-template-columns: 1fr;
}
.detail-drawer {
max-height: 300px;
}
} }
`], `],
}) })
export class TopologyGraphPageComponent { export class TopologyGraphPageComponent {
private readonly layoutService = inject(TopologyLayoutService); private readonly layoutService = inject(TopologyLayoutService);
readonly context = inject(PlatformContextStore);
readonly loading = signal(false); readonly loading = signal(false);
readonly error = signal<string | null>(null); readonly error = signal<string | null>(null);
readonly layout = signal<TopologyLayoutResponse | null>(null); readonly layout = signal<TopologyLayoutResponse | null>(null);
readonly searchQuery = signal('');
readonly typeFilter = signal('');
readonly healthFilter = signal('');
readonly selectedNode = signal<TopologyPositionedNode | null>(null); readonly selectedNode = signal<TopologyPositionedNode | null>(null);
readonly selectedEdge = signal<TopologyRoutedEdge | null>(null); readonly selectedEdge = signal<TopologyRoutedEdge | null>(null);
readonly detailTargets = signal<TopologyTarget[]>([]); readonly detailTargets = signal<TopologyTarget[]>([]);
@@ -503,53 +441,18 @@ export class TopologyGraphPageComponent {
const node = this.selectedNode(); const node = this.selectedNode();
if (node) return node.label; if (node) return node.label;
const edge = this.selectedEdge(); 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 ''; 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<string>();
const matchedRegionIds = new Set<string>();
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() { 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 { onNodeSelected(node: TopologyPositionedNode): void {
@@ -586,8 +489,14 @@ export class TopologyGraphPageComponent {
this.loading.set(true); this.loading.set(true);
this.error.set(null); this.error.set(null);
const regions = this.context.selectedRegions();
const environments = this.context.selectedEnvironments();
this.layoutService this.layoutService
.getLayout() .getLayout({
region: regions.length > 0 ? regions.join(',') : undefined,
environment: environments.length > 0 ? environments.join(',') : undefined,
})
.pipe( .pipe(
take(1), take(1),
catchError((err: unknown) => { catchError((err: unknown) => {
@@ -601,6 +510,7 @@ export class TopologyGraphPageComponent {
next: (result) => { next: (result) => {
this.layout.set(result); this.layout.set(result);
this.loading.set(false); this.loading.set(false);
this.clearSelection();
}, },
}); });
} }