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

View File

@@ -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()">&times;</button>
<button type="button" class="drawer-close" (click)="clearSelection()">&times;</button>
</header>
@if (selectedNode(); as node) {
@if (node.kind === 'environment') {
<div class="detail-zone__summary">
<div class="detail-chip">
<span class="detail-chip__label">Region</span>
<span>{{ node.regionId }}</span>
</div>
<div class="detail-chip">
<span class="detail-chip__label">Type</span>
<span class="type-val" [class]="'type-val--' + (node.environmentType ?? '')">{{ node.environmentType }}</span>
</div>
<div class="detail-chip">
<span class="detail-chip__label">Health</span>
<span class="health-val" [class]="'health-val--' + (node.healthStatus ?? 'unknown')">{{ node.healthStatus ?? 'unknown' }}</span>
</div>
<div class="detail-chip">
<span class="detail-chip__label">Hosts</span>
<span>{{ node.hostCount }}</span>
</div>
<div class="detail-chip">
<span class="detail-chip__label">Targets</span>
<span>{{ node.targetCount }}</span>
</div>
@if (node.currentReleaseId) {
<div class="detail-chip">
<span class="detail-chip__label">Release</span>
<span>{{ node.currentReleaseId }}</span>
</div>
}
<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();
},
});
}