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:
@@ -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)
|
||||
{
|
||||
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,
|
||||
});
|
||||
|
||||
@@ -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: `
|
||||
<section class="topo-page">
|
||||
<!-- 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>
|
||||
|
||||
<section class="topo-page" [class.topo-page--panel-open]="selectedNode() || selectedEdge()">
|
||||
@if (error()) {
|
||||
<div class="banner banner--error">{{ error() }}</div>
|
||||
}
|
||||
|
||||
<!-- Graph -->
|
||||
<!-- Graph fills the page -->
|
||||
<div class="graph-pane">
|
||||
@if (loading()) {
|
||||
<div class="loading">
|
||||
@@ -74,241 +38,167 @@ import { TopologyTarget, TopologyHost } from './topology.models';
|
||||
<span>Loading topology...</span>
|
||||
</div>
|
||||
} @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
|
||||
[layout]="filteredLayout()"
|
||||
[layout]="layout()"
|
||||
(nodeSelected)="onNodeSelected($event)"
|
||||
(edgeSelected)="onEdgeSelected($event)"
|
||||
/>
|
||||
}
|
||||
</div>
|
||||
|
||||
<!-- Detail zone (below graph) -->
|
||||
<!-- Right side drawer (overlay) -->
|
||||
@if (selectedNode() || selectedEdge()) {
|
||||
<section class="detail-zone">
|
||||
<header class="detail-zone__header">
|
||||
<aside class="detail-drawer">
|
||||
<header class="drawer-header">
|
||||
<h2>{{ detailTitle() }}</h2>
|
||||
<button type="button" class="detail-zone__close" (click)="clearSelection()">×</button>
|
||||
<button type="button" class="drawer-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>
|
||||
}
|
||||
<div class="drawer-body">
|
||||
<dl class="drawer-grid">
|
||||
<dt>Region</dt>
|
||||
<dd>{{ node.regionId }}</dd>
|
||||
<dt>Type</dt>
|
||||
<dd class="type-val" [class]="'type-val--' + (node.environmentType ?? '')">{{ node.environmentType }}</dd>
|
||||
<dt>Health</dt>
|
||||
<dd class="health-val" [class]="'health-val--' + (node.healthStatus ?? 'unknown')">{{ node.healthStatus ?? 'unknown' }}</dd>
|
||||
<dt>Hosts</dt>
|
||||
<dd>{{ node.hostCount }}</dd>
|
||||
<dt>Targets</dt>
|
||||
<dd>{{ node.targetCount }}</dd>
|
||||
@if (node.currentReleaseId) {
|
||||
<dt>Release</dt>
|
||||
<dd>{{ node.currentReleaseId }}</dd>
|
||||
}
|
||||
<dt>Paths</dt>
|
||||
<dd>{{ node.promotionPathCount }}</dd>
|
||||
</dl>
|
||||
|
||||
@if (node.isFrozen) {
|
||||
<div class="detail-chip detail-chip--frozen">
|
||||
<span>FROZEN</span>
|
||||
</div>
|
||||
<div class="frozen-banner">FROZEN</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>
|
||||
|
||||
<!-- 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 class="drawer-body">
|
||||
<dl class="drawer-grid">
|
||||
<dt>Hosts</dt>
|
||||
<dd>{{ node.hostCount }}</dd>
|
||||
<dt>Targets</dt>
|
||||
<dd>{{ node.targetCount }}</dd>
|
||||
</dl>
|
||||
</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>
|
||||
}
|
||||
<div class="drawer-body">
|
||||
<dl class="drawer-grid">
|
||||
<dt>From</dt>
|
||||
<dd>{{ getNodeLabel(edge.sourceNodeId) }}</dd>
|
||||
<dt>To</dt>
|
||||
<dd>{{ getNodeLabel(edge.targetNodeId) }}</dd>
|
||||
@if (edge.pathMode) {
|
||||
<dt>Mode</dt>
|
||||
<dd>{{ edge.pathMode }}</dd>
|
||||
}
|
||||
@if (edge.status) {
|
||||
<dt>Status</dt>
|
||||
<dd>{{ edge.status }}</dd>
|
||||
}
|
||||
<dt>Approvals</dt>
|
||||
<dd>{{ edge.requiredApprovals }}</dd>
|
||||
@if (edge.gateProfileName) {
|
||||
<dt>Gate Profile</dt>
|
||||
<dd>{{ edge.gateProfileName }}</dd>
|
||||
}
|
||||
</dl>
|
||||
@if (edge.label) {
|
||||
<div class="detail-chip detail-chip--wide">
|
||||
<span class="detail-chip__label">Gates</span>
|
||||
<div class="drawer-gates">
|
||||
<span class="drawer-gates__label">Gates</span>
|
||||
<span>{{ edge.label }}</span>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</section>
|
||||
</aside>
|
||||
}
|
||||
</section>
|
||||
`,
|
||||
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<string | null>(null);
|
||||
readonly layout = signal<TopologyLayoutResponse | null>(null);
|
||||
readonly searchQuery = signal('');
|
||||
readonly typeFilter = signal('');
|
||||
readonly healthFilter = signal('');
|
||||
readonly selectedNode = signal<TopologyPositionedNode | null>(null);
|
||||
readonly selectedEdge = signal<TopologyRoutedEdge | null>(null);
|
||||
readonly detailTargets = signal<TopologyTarget[]>([]);
|
||||
@@ -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<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() {
|
||||
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();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user