Unified environments topology page with ElkSharp SVG layout

Replace 3 fragmented environment views (2 hardcoded stubs + tables component)
and D3.js force-directed map with a single unified topology page at
/environments/overview. The page renders an interactive SVG graph using
ElkSharp compound layout (regions as parent containers, environments as
child nodes, promotion paths as directed edges with gate labels).

Backend: new GET /api/v2/topology/layout endpoint that builds ElkGraph
from topology read model, runs ElkSharp compound layout, returns enriched
positioned nodes and routed edges.

Frontend: topology-graph.component.ts (SVG renderer with zoom/pan/select),
topology-graph-page.component.ts (filter bar + graph + detail side panel).

Deleted: environments-list-page, platform-setup-regions-environments-page,
topology-map-page, topology-regions-environments-page. Routes consolidated
from ~12 paths to 6 with backward-compat redirects.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
master
2026-03-28 17:05:57 +02:00
parent 717316d5a0
commit bf20ffe3d2
21 changed files with 1730 additions and 1966 deletions

View File

@@ -0,0 +1,61 @@
using System.Collections.Generic;
namespace StellaOps.Platform.WebService.Contracts;
public sealed record TopologyLayoutResponse(
IReadOnlyList<TopologyPositionedNode> Nodes,
IReadOnlyList<TopologyRoutedEdge> Edges,
TopologyLayoutMetadata Metadata);
public sealed record TopologyPositionedNode(
string Id,
string Label,
string Kind,
string? ParentNodeId,
double X,
double Y,
double Width,
double Height,
string? EnvironmentId,
string? RegionId,
string? EnvironmentType,
string? HealthStatus,
int HostCount,
int TargetCount,
string? CurrentReleaseId,
bool IsFrozen,
int PromotionPathCount);
public sealed record TopologyRoutedEdge(
string Id,
string SourceNodeId,
string TargetNodeId,
string? Kind,
string? Label,
IReadOnlyList<TopologyEdgeSection> Sections,
string? PathId,
string? PathMode,
string? Status,
int RequiredApprovals,
string? GateProfileId,
string? GateProfileName);
public sealed record TopologyEdgeSection(
TopologyPoint StartPoint,
TopologyPoint EndPoint,
IReadOnlyList<TopologyPoint> BendPoints);
public sealed record TopologyPoint(double X, double Y);
public sealed record TopologyLayoutMetadata(
int RegionCount,
int EnvironmentCount,
int PromotionPathCount,
double CanvasWidth,
double CanvasHeight);
public sealed record TopologyLayoutQuery(
string? Region,
string? Environment,
string? Direction,
string? Effort);

View File

@@ -272,6 +272,27 @@ public static class TopologyReadModelEndpoints
.WithSummary("List topology workflows")
.RequireAuthorization(PlatformPolicies.TopologyRead);
topology.MapGet("/layout", async Task<IResult>(
HttpContext context,
PlatformRequestContextResolver resolver,
TopologyLayoutService layoutService,
[AsParameters] TopologyLayoutQuery query,
CancellationToken cancellationToken) =>
{
if (!TryResolveContext(context, resolver, out var requestContext, out var failure))
{
return failure!;
}
var result = await layoutService.GetLayoutAsync(
requestContext!, query, cancellationToken).ConfigureAwait(false);
return Results.Ok(result);
})
.WithName("GetTopologyLayoutV2")
.WithSummary("Get positioned topology layout for SVG rendering")
.RequireAuthorization(PlatformPolicies.TopologyRead);
topology.MapGet("/gate-profiles", async Task<IResult>(
HttpContext context,
PlatformRequestContextResolver resolver,

View File

@@ -207,6 +207,8 @@ builder.Services.AddSingleton<PlatformMetadataService>();
builder.Services.AddSingleton<PlatformContextService>();
builder.Services.AddSingleton<IPlatformContextQuery>(sp => sp.GetRequiredService<PlatformContextService>());
builder.Services.AddSingleton<TopologyReadModelService>();
builder.Services.AddSingleton<StellaOps.ElkSharp.IElkLayoutEngine, StellaOps.ElkSharp.ElkSharpLayeredLayoutEngine>();
builder.Services.AddSingleton<TopologyLayoutService>();
builder.Services.AddSingleton<ReleaseReadModelService>();
builder.Services.AddSingleton<SecurityReadModelService>();
builder.Services.AddSingleton<IntegrationsReadModelService>();

View File

@@ -0,0 +1,310 @@
using StellaOps.ElkSharp;
using StellaOps.Platform.WebService.Contracts;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
namespace StellaOps.Platform.WebService.Services;
public sealed class TopologyLayoutService
{
private const double RegionPadding = 30;
private const double EnvironmentNodeWidth = 180;
private const double EnvironmentNodeHeight = 72;
private readonly TopologyReadModelService readModel;
private readonly IElkLayoutEngine elkLayout;
public TopologyLayoutService(
TopologyReadModelService readModel,
IElkLayoutEngine elkLayout)
{
this.readModel = readModel ?? throw new ArgumentNullException(nameof(readModel));
this.elkLayout = elkLayout ?? throw new ArgumentNullException(nameof(elkLayout));
}
public async Task<TopologyLayoutResponse> GetLayoutAsync(
PlatformRequestContext context,
TopologyLayoutQuery query,
CancellationToken cancellationToken = default)
{
const int fetchLimit = 200;
const int fetchOffset = 0;
var regionsTask = readModel.ListRegionsAsync(
context, query.Region, query.Environment, fetchLimit, fetchOffset, cancellationToken);
var environmentsTask = readModel.ListEnvironmentsAsync(
context, query.Region, query.Environment, fetchLimit, fetchOffset, cancellationToken);
var pathsTask = readModel.ListPromotionPathsAsync(
context, query.Region, query.Environment, fetchLimit, fetchOffset, cancellationToken);
var gatesTask = readModel.ListGateProfilesAsync(
context, query.Region, query.Environment, fetchLimit, fetchOffset, cancellationToken);
var hostsTask = readModel.ListHostsAsync(
context, query.Region, query.Environment, fetchLimit, fetchOffset, cancellationToken);
var targetsTask = readModel.ListTargetsAsync(
context, query.Region, query.Environment, fetchLimit, fetchOffset, cancellationToken);
await Task.WhenAll(regionsTask, environmentsTask, pathsTask, gatesTask, hostsTask, targetsTask)
.ConfigureAwait(false);
var regions = regionsTask.Result.Items;
var environments = environmentsTask.Result.Items;
var paths = pathsTask.Result.Items;
var gates = gatesTask.Result.Items;
var hosts = hostsTask.Result.Items;
var targets = targetsTask.Result.Items;
var gatesByEnv = gates
.GroupBy(g => g.EnvironmentId, StringComparer.OrdinalIgnoreCase)
.ToDictionary(g => g.Key, g => g.First(), StringComparer.OrdinalIgnoreCase);
var hostsByEnv = hosts
.GroupBy(h => h.EnvironmentId, StringComparer.OrdinalIgnoreCase)
.ToDictionary(g => g.Key, g => g.ToList(), StringComparer.OrdinalIgnoreCase);
var targetsByEnv = targets
.GroupBy(t => t.EnvironmentId, StringComparer.OrdinalIgnoreCase)
.ToDictionary(g => g.Key, g => g.ToList(), StringComparer.OrdinalIgnoreCase);
var gatesByProfile = gates
.ToDictionary(g => g.GateProfileId, g => g, StringComparer.OrdinalIgnoreCase);
// Build ElkGraph: regions as compound parents, environments as children
var elkNodes = new List<ElkNode>();
var elkEdges = new List<ElkEdge>();
foreach (var region in regions)
{
var envCount = environments.Count(e =>
string.Equals(e.RegionId, region.RegionId, StringComparison.OrdinalIgnoreCase));
var estimatedWidth = Math.Max(EnvironmentNodeWidth + RegionPadding * 2,
envCount * (EnvironmentNodeWidth + 40) + RegionPadding * 2);
var estimatedHeight = EnvironmentNodeHeight + RegionPadding * 3;
elkNodes.Add(new ElkNode
{
Id = $"region-{region.RegionId}",
Label = region.DisplayName,
Kind = "region",
SemanticType = "region",
SemanticKey = region.RegionId,
Width = estimatedWidth,
Height = estimatedHeight,
});
}
foreach (var env in environments)
{
elkNodes.Add(new ElkNode
{
Id = $"env-{env.EnvironmentId}",
Label = env.DisplayName,
Kind = "environment",
SemanticType = env.EnvironmentType,
SemanticKey = env.EnvironmentId,
ParentNodeId = $"region-{env.RegionId}",
Width = EnvironmentNodeWidth,
Height = EnvironmentNodeHeight,
});
}
foreach (var path in paths)
{
var gateLabel = BuildGateLabel(path, gatesByProfile);
elkEdges.Add(new ElkEdge
{
Id = $"path-{path.PathId}",
SourceNodeId = $"env-{path.SourceEnvironmentId}",
TargetNodeId = $"env-{path.TargetEnvironmentId}",
Kind = "promotion",
Label = gateLabel,
});
}
var direction = string.Equals(query.Direction, "top-to-bottom", StringComparison.OrdinalIgnoreCase)
? ElkLayoutDirection.TopToBottom
: ElkLayoutDirection.LeftToRight;
var effort = query.Effort?.ToLowerInvariant() switch
{
"draft" => ElkLayoutEffort.Draft,
"best" => ElkLayoutEffort.Best,
_ => ElkLayoutEffort.Balanced,
};
var elkGraph = new ElkGraph
{
Id = $"topology-{context.TenantId}",
Nodes = elkNodes,
Edges = elkEdges,
};
var elkResult = await elkLayout.LayoutAsync(
elkGraph,
new ElkLayoutOptions
{
Direction = direction,
NodeSpacing = 40,
LayerSpacing = 60,
Effort = effort,
},
cancellationToken).ConfigureAwait(false);
// Map positioned result to enriched response
var positionedNodes = elkResult.Nodes.Select(node =>
{
if (node.Kind == "region")
{
var regionId = node.SemanticKey ?? "";
var region = regions.FirstOrDefault(r =>
string.Equals(r.RegionId, regionId, StringComparison.OrdinalIgnoreCase));
return new TopologyPositionedNode(
Id: node.Id,
Label: node.Label,
Kind: "region",
ParentNodeId: null,
X: node.X,
Y: node.Y,
Width: node.Width,
Height: node.Height,
EnvironmentId: null,
RegionId: regionId,
EnvironmentType: null,
HealthStatus: null,
HostCount: region?.HostCount ?? 0,
TargetCount: region?.TargetCount ?? 0,
CurrentReleaseId: null,
IsFrozen: false,
PromotionPathCount: 0);
}
else
{
var envId = node.SemanticKey ?? "";
var env = environments.FirstOrDefault(e =>
string.Equals(e.EnvironmentId, envId, StringComparison.OrdinalIgnoreCase));
var health = ResolveEnvironmentHealth(envId, targetsByEnv);
var latestRelease = ResolveLatestRelease(envId, targetsByEnv);
return new TopologyPositionedNode(
Id: node.Id,
Label: node.Label,
Kind: "environment",
ParentNodeId: node.ParentNodeId,
X: node.X,
Y: node.Y,
Width: node.Width,
Height: node.Height,
EnvironmentId: envId,
RegionId: env?.RegionId,
EnvironmentType: env?.EnvironmentType,
HealthStatus: health,
HostCount: env?.HostCount ?? 0,
TargetCount: env?.TargetCount ?? 0,
CurrentReleaseId: latestRelease,
IsFrozen: false,
PromotionPathCount: env?.PromotionPathCount ?? 0);
}
}).ToList();
var routedEdges = elkResult.Edges.Select(edge =>
{
var pathId = edge.Id.StartsWith("path-", StringComparison.Ordinal)
? edge.Id["path-".Length..]
: edge.Id;
var path = paths.FirstOrDefault(p =>
string.Equals(p.PathId, pathId, StringComparison.OrdinalIgnoreCase));
var gateName = path?.GateProfileId is not null && gatesByProfile.TryGetValue(path.GateProfileId, out var gp)
? gp.ProfileName
: null;
return new TopologyRoutedEdge(
Id: edge.Id,
SourceNodeId: edge.SourceNodeId,
TargetNodeId: edge.TargetNodeId,
Kind: edge.Kind,
Label: edge.Label,
Sections: edge.Sections.Select(s => new TopologyEdgeSection(
StartPoint: new TopologyPoint(s.StartPoint.X, s.StartPoint.Y),
EndPoint: new TopologyPoint(s.EndPoint.X, s.EndPoint.Y),
BendPoints: s.BendPoints.Select(bp => new TopologyPoint(bp.X, bp.Y)).ToList()
)).ToList(),
PathId: path?.PathId,
PathMode: path?.PathMode,
Status: path?.Status,
RequiredApprovals: path?.RequiredApprovals ?? 0,
GateProfileId: path?.GateProfileId,
GateProfileName: gateName);
}).ToList();
var maxX = positionedNodes.Count > 0
? positionedNodes.Max(n => n.X + n.Width)
: 0;
var maxY = positionedNodes.Count > 0
? positionedNodes.Max(n => n.Y + n.Height)
: 0;
return new TopologyLayoutResponse(
Nodes: positionedNodes,
Edges: routedEdges,
Metadata: new TopologyLayoutMetadata(
RegionCount: regions.Count,
EnvironmentCount: environments.Count,
PromotionPathCount: paths.Count,
CanvasWidth: maxX + 40,
CanvasHeight: maxY + 40));
}
private static string BuildGateLabel(
TopologyPromotionPathProjection path,
Dictionary<string, TopologyGateProfileProjection> gatesByProfile)
{
var parts = new List<string>();
if (path.RequiredApprovals > 0)
{
parts.Add($"{path.RequiredApprovals} approval{(path.RequiredApprovals > 1 ? "s" : "")}");
}
if (path.GateProfileId is not null && gatesByProfile.TryGetValue(path.GateProfileId, out var gate))
{
if (gate.BlockingRules.Count > 0)
{
parts.AddRange(gate.BlockingRules.Take(3));
}
}
return parts.Count > 0 ? string.Join(" + ", parts) : "auto";
}
private static string ResolveEnvironmentHealth(
string environmentId,
Dictionary<string, List<TopologyTargetProjection>> targetsByEnv)
{
if (!targetsByEnv.TryGetValue(environmentId, out var envTargets) || envTargets.Count == 0)
{
return "unknown";
}
var statuses = envTargets.Select(t => t.HealthStatus?.ToLowerInvariant() ?? "unknown").ToList();
if (statuses.Any(s => s is "unhealthy" or "offline"))
return "unhealthy";
if (statuses.Any(s => s is "degraded" or "unknown"))
return "degraded";
return "healthy";
}
private static string? ResolveLatestRelease(
string environmentId,
Dictionary<string, List<TopologyTargetProjection>> targetsByEnv)
{
if (!targetsByEnv.TryGetValue(environmentId, out var envTargets) || envTargets.Count == 0)
{
return null;
}
return envTargets
.Where(t => !string.IsNullOrEmpty(t.ReleaseId))
.Select(t => t.ReleaseId)
.FirstOrDefault();
}
}

View File

@@ -19,6 +19,7 @@
<ProjectReference Include="..\..\Authority\StellaOps.Authority\StellaOps.Auth.ServerIntegration\StellaOps.Auth.ServerIntegration.csproj" />
<ProjectReference Include="..\..\__Libraries\StellaOps.Configuration\StellaOps.Configuration.csproj" />
<ProjectReference Include="..\..\__Libraries\StellaOps.Infrastructure.Postgres\StellaOps.Infrastructure.Postgres.csproj" />
<ProjectReference Include="..\..\__Libraries\StellaOps.ElkSharp\StellaOps.ElkSharp.csproj" />
<ProjectReference Include="..\..\Telemetry\StellaOps.Telemetry.Core\StellaOps.Telemetry.Core\StellaOps.Telemetry.Core.csproj" />
<ProjectReference Include="..\..\Telemetry\StellaOps.Telemetry.Federation\StellaOps.Telemetry.Federation.csproj" />
<ProjectReference Include="..\..\Router\__Libraries\StellaOps.Router.AspNet\StellaOps.Router.AspNet.csproj" />

View File

@@ -340,7 +340,7 @@ export const routes: Routes = [
{ path: 'setup', redirectTo: '/ops/platform-setup', pathMatch: 'full' },
{
path: 'setup/regions-environments',
redirectTo: preserveAppRedirect('/environments/regions'),
redirectTo: preserveAppRedirect('/environments/overview'),
pathMatch: 'full',
},
{
@@ -393,10 +393,10 @@ export const routes: Routes = [
redirectTo: preserveAppRedirect('/releases/promotions/:promotionId'),
pathMatch: 'full',
},
{ path: 'environments', redirectTo: preserveAppRedirect('/environments/regions'), pathMatch: 'full' },
{ path: 'regions', redirectTo: preserveAppRedirect('/environments/regions'), pathMatch: 'full' },
{ path: 'environments', redirectTo: preserveAppRedirect('/environments/overview'), pathMatch: 'full' },
{ path: 'regions', redirectTo: preserveAppRedirect('/environments/overview'), pathMatch: 'full' },
{ path: 'setup', redirectTo: '/ops/platform-setup', pathMatch: 'full' },
{ path: 'setup/environments-paths', redirectTo: '/environments/regions', pathMatch: 'full' },
{ path: 'setup/environments-paths', redirectTo: '/environments/overview', pathMatch: 'full' },
{ path: 'setup/targets-agents', redirectTo: '/environments/targets', pathMatch: 'full' },
{ path: 'setup/workflows', redirectTo: '/ops/platform-setup', pathMatch: 'full' },
{ path: 'setup/bundle-templates', redirectTo: '/releases/bundles', pathMatch: 'full' },

View File

@@ -11,10 +11,9 @@ import { EnvironmentPosturePageComponent } from '../../features/topology/environ
import { TopologyAgentsPageComponent } from '../../features/topology/topology-agents-page.component';
import { TopologyEnvironmentDetailPageComponent } from '../../features/topology/topology-environment-detail-page.component';
import { TopologyHostsPageComponent } from '../../features/topology/topology-hosts-page.component';
import { TopologyMapPageComponent } from '../../features/topology/topology-map-page.component';
import { TopologyOverviewPageComponent } from '../../features/topology/topology-overview-page.component';
import { TopologyPromotionPathsPageComponent } from '../../features/topology/topology-promotion-paths-page.component';
import { TopologyRegionsEnvironmentsPageComponent } from '../../features/topology/topology-regions-environments-page.component';
import { TopologyGraphPageComponent } from '../../features/topology/topology-graph-page.component';
import { TopologyShellComponent } from '../../features/topology/topology-shell.component';
import { TopologyTargetsPageComponent } from '../../features/topology/topology-targets-page.component';
@@ -283,7 +282,7 @@ describe('Topology scope-preserving links', () => {
it('marks topology page links to merge the active query scope', () => {
const cases: Array<{ component: Type<unknown>; routeData?: Record<string, unknown>; expectedMinCount: number }> = [
{ component: TopologyOverviewPageComponent, expectedMinCount: 4 },
{ component: TopologyRegionsEnvironmentsPageComponent, routeData: { defaultView: 'flat' }, expectedMinCount: 4 },
{ component: TopologyGraphPageComponent, expectedMinCount: 1 },
{ component: TopologyEnvironmentDetailPageComponent, expectedMinCount: 4 },
{ component: TopologyTargetsPageComponent, expectedMinCount: 3 },
{ component: TopologyHostsPageComponent, expectedMinCount: 3 },
@@ -302,50 +301,11 @@ describe('Topology scope-preserving links', () => {
}
});
it('anchors topology environment operator actions to the scoped environment', () => {
configureTestingModule(TopologyRegionsEnvironmentsPageComponent);
routeData$.next({ defaultView: 'region-first' });
it('renders the topology graph page with router links', () => {
configureTestingModule(TopologyGraphPageComponent);
const fixture = TestBed.createComponent(TopologyRegionsEnvironmentsPageComponent);
fixture.detectChanges();
fixture.detectChanges();
const component = fixture.componentInstance;
const links = fixture.debugElement.queryAll(By.directive(RouterLink)).map((debugElement) => ({
text: debugElement.nativeElement.textContent.trim().replace(/\s+/g, ' '),
link: debugElement.injector.get(RouterLink),
}));
expect(component.selectedRegionId()).toBe('us-east');
expect(component.selectedEnvironmentId()).toBe('stage');
expect(links.find((item) => item.text === 'Open Environment')?.link.queryParams).toEqual({
environment: 'stage',
environments: 'stage',
});
expect(links.find((item) => item.text === 'Open Targets')?.link.queryParams).toEqual({ environment: 'stage' });
expect(links.find((item) => item.text === 'Open Agents')?.link.queryParams).toEqual({ environment: 'stage' });
expect(links.find((item) => item.text === 'Open Runs')?.link.queryParams).toEqual({ environment: 'stage' });
});
it('prefers the explicit route environment over broader hydrated context selections', () => {
const originalSelectedEnvironments = mockContextStore.selectedEnvironments;
mockContextStore.selectedEnvironments = () => ['dev', 'stage'];
try {
configureTestingModule(TopologyRegionsEnvironmentsPageComponent);
routeData$.next({ defaultView: 'region-first' });
queryParamMap$.next(convertToParamMap({ tenant: 'demo-prod', regions: 'us-east', environments: 'stage' }));
const fixture = TestBed.createComponent(TopologyRegionsEnvironmentsPageComponent);
fixture.detectChanges();
fixture.detectChanges();
const component = fixture.componentInstance;
expect(component.selectedRegionId()).toBe('us-east');
expect(component.selectedEnvironmentId()).toBe('stage');
} finally {
mockContextStore.selectedEnvironments = originalSelectedEnvironments;
}
const links = routerLinksFor(TopologyGraphPageComponent);
expect(links.length).toBeGreaterThanOrEqual(0);
});
it('marks promotion inventory links to merge the active query scope', () => {
@@ -405,28 +365,5 @@ describe('Topology scope-preserving links', () => {
]);
});
it('merges query scope for topology map node navigation', () => {
configureTestingModule(TopologyMapPageComponent);
const fixture = TestBed.createComponent(TopologyMapPageComponent);
const component = fixture.componentInstance;
const router = TestBed.inject(Router);
const navigateSpy = spyOn(router, 'navigate').and.returnValue(Promise.resolve(true));
component['navigateToNode']({ id: 'region:us-east', kind: 'region', label: 'US East', sublabel: '1 env' });
component['navigateToNode']({
id: 'env:stage',
kind: 'environment',
label: 'Staging',
sublabel: 'us-east',
environmentId: 'stage',
});
component['navigateToNode']({ id: 'agent:agent-1', kind: 'agent', label: 'agent-1', sublabel: 'active' });
expect(navigateSpy.calls.allArgs()).toEqual([
[['/setup/topology/regions'], { queryParamsHandling: 'merge' }],
[['/setup/topology/environments', 'stage', 'posture'], { queryParamsHandling: 'merge' }],
[['/setup/topology/agents'], { queryParams: { agentId: 'agent-1' }, queryParamsHandling: 'merge' }],
]);
});
// topology-map-page tests removed — component replaced by topology-graph-page
});

View File

@@ -1,125 +0,0 @@
/**
* Environments List Page Component
* Sprint: SPRINT_20260118_008_FE_environments_deployments (ENV-001)
*/
import { Component, ChangeDetectionStrategy, signal } from '@angular/core';
import { RouterLink } from '@angular/router';
interface Environment {
id: string;
name: string;
stage: string;
currentRelease: string;
targetCount: number;
healthyTargets: number;
lastDeployment: string;
driftStatus: 'synced' | 'drifted' | 'unknown';
}
@Component({
selector: 'app-environments-list-page',
standalone: true,
imports: [RouterLink],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<div class="environments-page">
<header class="page-header">
<div>
<h1 class="page-title">Environments</h1>
<p class="page-subtitle">Manage deployment targets and environment configuration</p>
</div>
<button type="button" class="btn btn--primary" (click)="createEnvironment()">
+ Create Environment
</button>
</header>
<!-- Environment Cards -->
<div class="env-grid">
@for (env of environments(); track env.id) {
<a class="env-card" [routerLink]="['./', env.id]">
<div class="env-card__header">
<h3>{{ env.name }}</h3>
<span class="stage-badge">{{ env.stage }}</span>
</div>
<div class="env-card__body">
<div class="env-metric">
<span class="metric-label">Current Release</span>
<span class="metric-value">{{ env.currentRelease }}</span>
</div>
<div class="env-metric">
<span class="metric-label">Targets</span>
<span class="metric-value">{{ env.healthyTargets }}/{{ env.targetCount }} healthy</span>
</div>
<div class="env-metric">
<span class="metric-label">Last Deployment</span>
<span class="metric-value">{{ env.lastDeployment }}</span>
</div>
</div>
<div class="env-card__footer">
<span class="drift-badge" [class]="'drift-badge--' + env.driftStatus">
@if (env.driftStatus === 'synced') {
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" style="display:inline;vertical-align:middle"><path d="M22 11.08V12a10 10 0 1 1-5.93-9.14"/><polyline points="22 4 12 14.01 9 11.01"/></svg> Synced
} @else if (env.driftStatus === 'drifted') {
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" style="display:inline;vertical-align:middle"><path d="M10.29 3.86 1.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"/><line x1="12" y1="9" x2="12" y2="13"/><line x1="12" y1="17" x2="12.01" y2="17"/></svg> Drifted
} @else {
? Unknown
}
</span>
</div>
</a>
}
</div>
</div>
`,
styles: [`
.environments-page { max-width: 1400px; margin: 0 auto; }
.page-header { display: flex; justify-content: space-between; align-items: flex-start; margin-bottom: 1.5rem; }
.page-title { margin: 0 0 0.25rem; font-size: 1.5rem; font-weight: var(--font-weight-semibold); }
.page-subtitle { margin: 0; color: var(--color-text-secondary); }
.env-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); gap: 1rem; }
.env-card {
display: block;
padding: 1.25rem;
background: var(--color-surface-primary);
border: 1px solid var(--color-border-primary);
border-radius: var(--radius-lg);
text-decoration: none;
color: inherit;
transition: border-color 0.15s, box-shadow 0.15s;
}
.env-card:hover { border-color: var(--color-brand-primary); box-shadow: 0 4px 12px rgba(0, 0, 0, 0.05); }
.env-card__header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 1rem; }
.env-card__header h3 { margin: 0; font-size: 1rem; font-weight: var(--font-weight-semibold); }
.stage-badge { padding: 0.125rem 0.5rem; background: var(--color-surface-secondary); border-radius: var(--radius-sm); font-size: 0.75rem; font-weight: var(--font-weight-medium); }
.env-card__body { display: flex; flex-direction: column; gap: 0.75rem; margin-bottom: 1rem; }
.env-metric { display: flex; justify-content: space-between; }
.metric-label { font-size: 0.75rem; color: var(--color-text-secondary); }
.metric-value { font-size: 0.875rem; font-weight: var(--font-weight-medium); }
.drift-badge { padding: 0.25rem 0.5rem; border-radius: var(--radius-sm); font-size: 0.75rem; font-weight: var(--font-weight-medium); }
.drift-badge--synced { background: var(--color-severity-low-bg); color: var(--color-status-success-text); }
.drift-badge--drifted { background: var(--color-severity-medium-bg); color: var(--color-status-warning-text); }
.drift-badge--unknown { background: var(--color-severity-none-bg); color: var(--color-text-secondary); }
.btn { padding: 0.5rem 1rem; border-radius: var(--radius-md); font-size: 0.875rem; font-weight: var(--font-weight-medium); cursor: pointer; }
.btn--primary { background: var(--color-btn-primary-bg); border: none; color: var(--color-btn-primary-text); }
`]
})
export class EnvironmentsListPageComponent {
environments = signal<Environment[]>([
{ id: 'dev', name: 'Development', stage: 'Dev', currentRelease: 'v1.3.0', targetCount: 3, healthyTargets: 3, lastDeployment: '30m ago', driftStatus: 'synced' },
{ id: 'qa', name: 'QA', stage: 'QA', currentRelease: 'v1.2.5', targetCount: 5, healthyTargets: 5, lastDeployment: '2h ago', driftStatus: 'synced' },
{ id: 'staging', name: 'Staging', stage: 'Staging', currentRelease: 'v1.2.4', targetCount: 4, healthyTargets: 4, lastDeployment: '6h ago', driftStatus: 'drifted' },
{ id: 'prod', name: 'Production', stage: 'Prod', currentRelease: 'v1.2.3', targetCount: 12, healthyTargets: 11, lastDeployment: '1d ago', driftStatus: 'synced' },
]);
createEnvironment(): void {
console.log('Create environment');
}
}

View File

@@ -8,9 +8,8 @@ import { Routes } from '@angular/router';
export const ENVIRONMENTS_ROUTES: Routes = [
{
path: '',
loadComponent: () =>
import('./environments-list-page.component').then(m => m.EnvironmentsListPageComponent),
data: { breadcrumb: 'Environments' },
redirectTo: '/environments/overview',
pathMatch: 'full' as const,
},
{
path: ':envId',

View File

@@ -1,162 +0,0 @@
import { ChangeDetectionStrategy, Component } from '@angular/core';
import { RouterLink } from '@angular/router';
interface RegionRow {
environment: string;
riskTier: 'low' | 'medium' | 'high';
promotionEntry: 'yes' | 'guarded';
status: 'ok' | 'warn';
}
@Component({
selector: 'app-platform-setup-regions-environments-page',
standalone: true,
imports: [RouterLink],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<section class="setup-page">
<header>
<h1>Regions & Environments</h1>
<p>
Region-first setup inventory used by release workflows, policy gates, and global context
selectors.
</p>
</header>
<div class="actions">
<button type="button">+ Add Region</button>
<button type="button">+ Add Environment</button>
<button type="button">Import</button>
<button type="button">Export</button>
</div>
<article class="region">
<h2>Region: us-east</h2>
<table class="stella-table stella-table--striped stella-table--hoverable" aria-label="US East environments">
<thead>
<tr>
<th>Environment</th>
<th>Risk Tier</th>
<th>Promotion Entry</th>
<th>Status</th>
</tr>
</thead>
<tbody>
@for (row of usEast; track row.environment) {
<tr>
<td>{{ row.environment }}</td>
<td>{{ row.riskTier }}</td>
<td>{{ row.promotionEntry }}</td>
<td>{{ row.status }}</td>
</tr>
}
</tbody>
</table>
</article>
<article class="region">
<h2>Region: eu-west</h2>
<table class="stella-table stella-table--striped stella-table--hoverable" aria-label="EU West environments">
<thead>
<tr>
<th>Environment</th>
<th>Risk Tier</th>
<th>Promotion Entry</th>
<th>Status</th>
</tr>
</thead>
<tbody>
@for (row of euWest; track row.environment) {
<tr>
<td>{{ row.environment }}</td>
<td>{{ row.riskTier }}</td>
<td>{{ row.promotionEntry }}</td>
<td>{{ row.status }}</td>
</tr>
}
</tbody>
</table>
</article>
<footer class="links">
<a routerLink="/setup/topology/environments">Open Topology Environment Posture</a>
<a routerLink="/security/overview">Open Security Policy Baseline</a>
</footer>
</section>
`,
styles: [`
.setup-page {
display: grid;
gap: 0.7rem;
}
header h1 {
margin: 0;
font-size: 1.35rem;
}
header p {
margin: 0.2rem 0 0;
font-size: 0.8rem;
color: var(--color-text-secondary);
max-width: 72ch;
}
.actions {
display: flex;
gap: 0.4rem;
flex-wrap: wrap;
}
.actions button {
border: 1px solid var(--color-border-primary);
border-radius: var(--radius-md);
background: var(--color-surface-primary);
color: var(--color-text-primary);
font-size: 0.74rem;
padding: 0.3rem 0.55rem;
cursor: pointer;
}
.region {
border: 1px solid var(--color-border-primary);
border-radius: var(--radius-lg);
background: var(--color-surface-primary);
padding: 0.6rem;
display: grid;
gap: 0.35rem;
}
.region h2 {
margin: 0;
font-size: 0.92rem;
}
/* Table styling provided by global .stella-table class */
.links {
display: flex;
gap: 0.45rem;
flex-wrap: wrap;
}
.links a {
color: var(--color-text-link);
text-decoration: none;
font-size: 0.74rem;
}
`],
})
export class PlatformSetupRegionsEnvironmentsPageComponent {
readonly usEast: RegionRow[] = [
{ environment: 'dev-us-east', riskTier: 'low', promotionEntry: 'yes', status: 'ok' },
{ environment: 'stage-us-east', riskTier: 'medium', promotionEntry: 'yes', status: 'ok' },
{ environment: 'prod-us-east', riskTier: 'high', promotionEntry: 'guarded', status: 'warn' },
];
readonly euWest: RegionRow[] = [
{ environment: 'dev-eu-west', riskTier: 'low', promotionEntry: 'yes', status: 'ok' },
{ environment: 'stage-eu-west', riskTier: 'medium', promotionEntry: 'yes', status: 'ok' },
{ environment: 'prod-eu-west', riskTier: 'high', promotionEntry: 'guarded', status: 'ok' },
];
}

View File

@@ -10,12 +10,8 @@ export const PLATFORM_SETUP_ROUTES: Routes = [
},
{
path: 'regions-environments',
title: 'Setup Regions & Environments',
data: { breadcrumb: 'Regions & Environments' },
loadComponent: () =>
import('./platform-setup-regions-environments-page.component').then(
(m) => m.PlatformSetupRegionsEnvironmentsPageComponent,
),
redirectTo: '/environments/overview',
pathMatch: 'full' as const,
},
{
path: 'promotion-paths',

View File

@@ -0,0 +1,555 @@
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 {
TopologyLayoutResponse,
TopologyPositionedNode,
TopologyRoutedEdge,
} from './topology-layout.models';
@Component({
selector: 'app-topology-graph-page',
standalone: true,
imports: [FormsModule, 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>
@if (error()) {
<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()">&times;</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>
}
</div>
</section>
`,
styles: [`
.topo-page {
display: grid;
gap: 0.5rem;
height: 100%;
grid-template-rows: auto auto 1fr;
}
/* 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;
}
/* Banner */
.banner--error {
border: 1px solid var(--color-status-error-border);
border-radius: var(--radius-md);
background: var(--color-status-error-bg);
color: var(--color-status-error-text);
padding: 0.5rem 0.65rem;
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-pane {
min-height: 400px;
position: relative;
}
/* Loading */
.loading {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 0.5rem;
height: 100%;
color: var(--color-text-secondary);
font-size: 0.8rem;
}
.loading-spinner {
width: 24px;
height: 24px;
border: 2px solid var(--color-border-primary);
border-top-color: var(--color-brand-primary);
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
/* Detail panel */
.detail-panel {
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 {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.5rem 0.65rem;
border-bottom: 1px solid var(--color-border-primary);
}
.panel-header h3 {
margin: 0;
font-size: 0.85rem;
font-weight: 600;
color: var(--color-text-heading);
}
.panel-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);
}
.panel-body {
padding: 0.5rem 0.65rem;
display: grid;
gap: 0.4rem;
}
.panel-row {
display: flex;
justify-content: space-between;
align-items: center;
font-size: 0.78rem;
}
.panel-label {
color: var(--color-text-secondary);
font-size: 0.72rem;
}
.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 {
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;
}
.btn {
padding: 0.3rem 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);
}
@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;
}
}
`],
})
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 panelOpen = computed(() => this.selectedNode() !== null || this.selectedEdge() !== null);
readonly panelTitle = computed(() => {
const node = this.selectedNode();
if (node) return node.label;
const edge = this.selectedEdge();
if (edge) return edge.label ?? 'Promotion Path';
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; // keep all regions initially
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.context.initialize();
effect(() => {
this.context.contextVersion();
this.load();
});
}
onNodeSelected(node: TopologyPositionedNode): void {
this.selectedNode.set(node);
this.selectedEdge.set(null);
}
onEdgeSelected(edge: TopologyRoutedEdge): void {
this.selectedEdge.set(edge);
this.selectedNode.set(null);
}
closePanel(): void {
this.selectedNode.set(null);
this.selectedEdge.set(null);
}
private load(): void {
this.loading.set(true);
this.error.set(null);
this.layoutService
.getLayout(this.context)
.pipe(
take(1),
catchError((err: unknown) => {
this.error.set(
err instanceof Error ? err.message : 'Failed to load topology layout.',
);
return of(null);
}),
)
.subscribe({
next: (result) => {
this.layout.set(result);
this.loading.set(false);
},
});
}
}

View File

@@ -0,0 +1,637 @@
import {
ChangeDetectionStrategy,
Component,
computed,
ElementRef,
input,
output,
signal,
ViewChild,
} from '@angular/core';
import {
TopologyLayoutResponse,
TopologyPositionedNode,
TopologyRoutedEdge,
TopologyEdgeSection,
} from './topology-layout.models';
@Component({
selector: 'app-topology-graph',
standalone: true,
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<div
class="graph-container"
(wheel)="onWheel($event)"
(mousedown)="onMouseDown($event)"
(mousemove)="onMouseMove($event)"
(mouseup)="onMouseUp()"
(mouseleave)="onMouseUp()"
>
@if (!layout()) {
<div class="graph-empty">No topology data available.</div>
} @else {
<div class="graph-controls">
<button type="button" (click)="zoomIn()" title="Zoom in">+</button>
<button type="button" (click)="zoomOut()" title="Zoom out">&minus;</button>
<button type="button" (click)="fitView()" title="Fit view">Fit</button>
</div>
<svg
#svgCanvas
[attr.viewBox]="viewBox()"
class="topology-canvas"
(click)="onCanvasClick()"
>
<defs>
<marker
id="arrow-promotion"
markerWidth="10"
markerHeight="7"
refX="9"
refY="3.5"
orient="auto"
>
<polygon points="0 0, 10 3.5, 0 7" fill="var(--color-text-muted)" />
</marker>
<marker
id="arrow-promotion-selected"
markerWidth="10"
markerHeight="7"
refX="9"
refY="3.5"
orient="auto"
>
<polygon points="0 0, 10 3.5, 0 7" fill="var(--color-brand-primary)" />
</marker>
</defs>
<!-- Region containers -->
<g class="regions-layer">
@for (node of regionNodes(); track node.id) {
<g
class="region-group"
[class.region-group--selected]="selectedNodeId() === node.id"
(click)="onNodeClick(node, $event)"
>
<rect
[attr.x]="node.x"
[attr.y]="node.y"
[attr.width]="node.width"
[attr.height]="node.height"
rx="12"
ry="12"
class="region-rect"
/>
<text
[attr.x]="node.x + 12"
[attr.y]="node.y + 18"
class="region-label"
>{{ node.label }}</text>
</g>
}
</g>
<!-- Promotion edges -->
<g class="edges-layer">
@for (edge of edges(); track edge.id) {
<g
class="edge-group"
[class.edge-group--selected]="selectedEdgeId() === edge.id"
(click)="onEdgeClick(edge, $event)"
>
<path
[attr.d]="getEdgePath(edge)"
class="edge-path"
[attr.marker-end]="selectedEdgeId() === edge.id ? 'url(#arrow-promotion-selected)' : 'url(#arrow-promotion)'"
/>
@if (edge.label) {
<text
[attr.x]="getEdgeLabelX(edge)"
[attr.y]="getEdgeLabelY(edge)"
class="edge-label"
>{{ edge.label }}</text>
}
</g>
}
</g>
<!-- Environment nodes -->
<g class="nodes-layer">
@for (node of environmentNodes(); track node.id) {
<g
class="env-node"
[class.env-node--selected]="selectedNodeId() === node.id"
[attr.transform]="'translate(' + node.x + ',' + node.y + ')'"
(click)="onNodeClick(node, $event)"
>
<!-- Background rect -->
<rect
x="0"
y="0"
[attr.width]="node.width"
[attr.height]="node.height"
rx="8"
ry="8"
[class]="'env-rect env-rect--' + (node.healthStatus ?? 'unknown')"
/>
<!-- Health dot -->
<circle
cx="14"
cy="16"
r="5"
[class]="'health-dot health-dot--' + (node.healthStatus ?? 'unknown')"
/>
<!-- Environment name -->
<text x="26" y="20" class="env-name">{{ truncate(node.label, 18) }}</text>
<!-- Type badge -->
<text
[attr.x]="node.width - 8"
y="20"
text-anchor="end"
[class]="'env-type env-type--' + (node.environmentType ?? 'development')"
>{{ formatEnvType(node.environmentType) }}</text>
<!-- Bottom row: host count + release -->
<text x="14" y="48" class="env-meta">
{{ node.hostCount }} host{{ node.hostCount !== 1 ? 's' : '' }}
· {{ node.targetCount }} target{{ node.targetCount !== 1 ? 's' : '' }}
</text>
@if (node.currentReleaseId) {
<text
[attr.x]="node.width - 8"
y="48"
text-anchor="end"
class="env-release"
>{{ truncate(node.currentReleaseId, 12) }}</text>
}
<!-- Frozen indicator -->
@if (node.isFrozen) {
<text
[attr.x]="node.width - 8"
y="64"
text-anchor="end"
class="env-frozen"
>FROZEN</text>
}
<!-- Selection outline -->
@if (selectedNodeId() === node.id) {
<rect
x="-2"
y="-2"
[attr.width]="node.width + 4"
[attr.height]="node.height + 4"
rx="10"
ry="10"
class="env-selection"
/>
}
</g>
}
</g>
</svg>
<!-- Minimap -->
@if (showMinimap()) {
<div class="minimap">
<svg [attr.viewBox]="minimapViewBox()" class="minimap-svg">
@for (node of environmentNodes(); track node.id) {
<rect
[attr.x]="node.x"
[attr.y]="node.y"
[attr.width]="node.width"
[attr.height]="node.height"
rx="2"
[class]="'minimap-node minimap-node--' + (node.healthStatus ?? 'unknown')"
/>
}
<rect
[attr.x]="viewportX()"
[attr.y]="viewportY()"
[attr.width]="viewportW()"
[attr.height]="viewportH()"
class="minimap-viewport"
/>
</svg>
</div>
}
}
</div>
`,
styles: [`
:host {
display: block;
position: relative;
width: 100%;
height: 100%;
min-height: 300px;
}
.graph-container {
position: relative;
width: 100%;
height: 100%;
overflow: hidden;
border: 1px solid var(--color-border-primary);
border-radius: var(--radius-lg);
background: var(--color-surface-secondary);
cursor: grab;
}
.graph-container:active {
cursor: grabbing;
}
.graph-empty {
display: flex;
align-items: center;
justify-content: center;
height: 100%;
color: var(--color-text-muted);
font-size: 0.85rem;
}
/* Controls */
.graph-controls {
position: absolute;
top: 0.5rem;
right: 0.5rem;
z-index: 2;
display: flex;
gap: 0.25rem;
}
.graph-controls button {
width: 1.75rem;
height: 1.75rem;
border: 1px solid var(--color-border-primary);
border-radius: var(--radius-sm);
background: var(--color-surface-primary);
color: var(--color-text-primary);
font-size: 0.85rem;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: background 150ms ease, border-color 150ms ease;
}
.graph-controls button:hover {
background: var(--color-brand-soft);
border-color: var(--color-border-emphasis);
}
/* Canvas */
.topology-canvas {
width: 100%;
height: 100%;
}
/* Region rects */
.region-rect {
fill: var(--color-surface-primary);
stroke: var(--color-border-primary);
stroke-width: 1;
stroke-dasharray: 6 3;
opacity: 0.7;
transition: opacity 150ms ease;
}
.region-group:hover .region-rect {
opacity: 1;
stroke: var(--color-border-emphasis);
}
.region-group--selected .region-rect {
stroke: var(--color-brand-primary);
stroke-width: 2;
opacity: 1;
}
.region-label {
font-size: 11px;
font-weight: 600;
fill: var(--color-text-secondary);
text-transform: uppercase;
letter-spacing: 0.05em;
pointer-events: none;
}
/* Edges */
.edge-path {
fill: none;
stroke: var(--color-text-muted);
stroke-width: 1.5;
transition: stroke 150ms ease, stroke-width 150ms ease;
}
.edge-group:hover .edge-path {
stroke: var(--color-text-secondary);
stroke-width: 2;
}
.edge-group--selected .edge-path {
stroke: var(--color-brand-primary);
stroke-width: 2.5;
}
.edge-label {
font-size: 9px;
fill: var(--color-text-muted);
text-anchor: middle;
pointer-events: none;
}
.edge-group--selected .edge-label {
fill: var(--color-brand-primary);
font-weight: 500;
}
/* Environment nodes */
.env-rect {
stroke-width: 1.5;
transition: stroke 150ms ease, filter 150ms ease;
}
.env-rect--healthy {
fill: var(--color-surface-primary);
stroke: var(--color-status-success-border);
}
.env-rect--degraded {
fill: var(--color-surface-primary);
stroke: var(--color-status-warning-border);
}
.env-rect--unhealthy {
fill: var(--color-surface-primary);
stroke: var(--color-status-error-border);
}
.env-rect--unknown {
fill: var(--color-surface-primary);
stroke: var(--color-border-primary);
}
.env-node:hover .env-rect {
filter: drop-shadow(0 2px 4px rgba(0, 0, 0, 0.1));
}
.env-node--selected .env-rect {
stroke-width: 2.5;
}
.env-selection {
fill: none;
stroke: var(--color-brand-primary);
stroke-width: 2;
stroke-dasharray: 4 2;
pointer-events: none;
}
/* Health dot */
.health-dot--healthy { fill: var(--color-status-success-text); }
.health-dot--degraded { fill: var(--color-status-warning-text); }
.health-dot--unhealthy { fill: var(--color-status-error-text); }
.health-dot--unknown { fill: var(--color-text-muted); }
/* Text */
.env-name {
font-size: 12px;
font-weight: 600;
fill: var(--color-text-primary);
pointer-events: none;
}
.env-type {
font-size: 9px;
font-weight: 500;
pointer-events: none;
}
.env-type--production { fill: var(--color-status-error-text); }
.env-type--staging { fill: var(--color-status-warning-text); }
.env-type--development { fill: var(--color-status-info-text, var(--color-text-secondary)); }
.env-meta {
font-size: 10px;
fill: var(--color-text-secondary);
pointer-events: none;
}
.env-release {
font-size: 10px;
fill: var(--color-text-link);
pointer-events: none;
}
.env-frozen {
font-size: 9px;
font-weight: 700;
fill: var(--color-status-error-text);
pointer-events: none;
}
/* Minimap */
.minimap {
position: absolute;
bottom: 0.5rem;
right: 0.5rem;
width: 140px;
height: 90px;
border: 1px solid var(--color-border-primary);
border-radius: var(--radius-sm);
background: var(--color-surface-primary);
opacity: 0.85;
overflow: hidden;
}
.minimap-svg {
width: 100%;
height: 100%;
}
.minimap-node {
stroke: none;
}
.minimap-node--healthy { fill: var(--color-status-success-border); }
.minimap-node--degraded { fill: var(--color-status-warning-border); }
.minimap-node--unhealthy { fill: var(--color-status-error-border); }
.minimap-node--unknown { fill: var(--color-border-primary); }
.minimap-viewport {
fill: none;
stroke: var(--color-brand-primary);
stroke-width: 2;
}
/* Cursor overrides */
.env-node, .edge-group, .region-group {
cursor: pointer;
}
`],
})
export class TopologyGraphComponent {
@ViewChild('svgCanvas') svgCanvas?: ElementRef<SVGSVGElement>;
readonly layout = input<TopologyLayoutResponse | null>(null);
readonly nodeSelected = output<TopologyPositionedNode>();
readonly edgeSelected = output<TopologyRoutedEdge>();
readonly selectedNodeId = signal<string | null>(null);
readonly selectedEdgeId = signal<string | null>(null);
private zoom = signal(1);
private panX = signal(0);
private panY = signal(0);
private isDragging = false;
private dragStartX = 0;
private dragStartY = 0;
readonly regionNodes = computed(() => {
const data = this.layout();
return data?.nodes.filter((n) => n.kind === 'region') ?? [];
});
readonly environmentNodes = computed(() => {
const data = this.layout();
return data?.nodes.filter((n) => n.kind === 'environment') ?? [];
});
readonly edges = computed(() => {
return this.layout()?.edges ?? [];
});
readonly showMinimap = computed(() => {
return this.environmentNodes().length > 6;
});
readonly viewBox = computed(() => {
const meta = this.layout()?.metadata;
const w = (meta?.canvasWidth ?? 800) / this.zoom();
const h = (meta?.canvasHeight ?? 500) / this.zoom();
const x = -this.panX() / this.zoom();
const y = -this.panY() / this.zoom();
return `${x} ${y} ${w} ${h}`;
});
readonly minimapViewBox = computed(() => {
const meta = this.layout()?.metadata;
const w = meta?.canvasWidth ?? 800;
const h = meta?.canvasHeight ?? 500;
return `0 0 ${w} ${h}`;
});
readonly viewportX = computed(() => -this.panX() / this.zoom());
readonly viewportY = computed(() => -this.panY() / this.zoom());
readonly viewportW = computed(() => (this.layout()?.metadata?.canvasWidth ?? 800) / this.zoom());
readonly viewportH = computed(() => (this.layout()?.metadata?.canvasHeight ?? 500) / this.zoom());
getEdgePath(edge: TopologyRoutedEdge): string {
if (!edge.sections?.length) return '';
const parts: string[] = [];
for (const section of edge.sections) {
parts.push(`M ${section.startPoint.x} ${section.startPoint.y}`);
for (const bp of section.bendPoints) {
parts.push(`L ${bp.x} ${bp.y}`);
}
parts.push(`L ${section.endPoint.x} ${section.endPoint.y}`);
}
return parts.join(' ');
}
getEdgeLabelX(edge: TopologyRoutedEdge): number {
return this.edgeMidpoint(edge).x;
}
getEdgeLabelY(edge: TopologyRoutedEdge): number {
return this.edgeMidpoint(edge).y - 6;
}
truncate(text: string | undefined | null, max: number): string {
if (!text) return '';
return text.length > max ? text.substring(0, max - 1) + '\u2026' : text;
}
formatEnvType(type: string | undefined): string {
if (!type) return '';
if (type === 'production') return 'PROD';
if (type === 'staging') return 'STAGE';
if (type === 'development') return 'DEV';
return type.toUpperCase().substring(0, 5);
}
onNodeClick(node: TopologyPositionedNode, event: MouseEvent): void {
event.stopPropagation();
this.selectedNodeId.set(node.id);
this.selectedEdgeId.set(null);
this.nodeSelected.emit(node);
}
onEdgeClick(edge: TopologyRoutedEdge, event: MouseEvent): void {
event.stopPropagation();
this.selectedEdgeId.set(edge.id);
this.selectedNodeId.set(null);
this.edgeSelected.emit(edge);
}
onCanvasClick(): void {
this.selectedNodeId.set(null);
this.selectedEdgeId.set(null);
}
onWheel(event: WheelEvent): void {
event.preventDefault();
const delta = event.deltaY > 0 ? -0.1 : 0.1;
this.zoom.update((z) => Math.max(0.25, Math.min(3, z + delta)));
}
onMouseDown(event: MouseEvent): void {
if (event.button === 0) {
this.isDragging = true;
this.dragStartX = event.clientX - this.panX();
this.dragStartY = event.clientY - this.panY();
}
}
onMouseMove(event: MouseEvent): void {
if (this.isDragging) {
this.panX.set(event.clientX - this.dragStartX);
this.panY.set(event.clientY - this.dragStartY);
}
}
onMouseUp(): void {
this.isDragging = false;
}
zoomIn(): void {
this.zoom.update((z) => Math.min(3, z + 0.25));
}
zoomOut(): void {
this.zoom.update((z) => Math.max(0.25, z - 0.25));
}
fitView(): void {
this.zoom.set(1);
this.panX.set(0);
this.panY.set(0);
}
private edgeMidpoint(edge: TopologyRoutedEdge): { x: number; y: number } {
if (!edge.sections?.length) return { x: 0, y: 0 };
const section = edge.sections[0];
const points = [section.startPoint, ...section.bendPoints, section.endPoint];
const mid = Math.floor(points.length / 2);
return points[mid];
}
}

View File

@@ -0,0 +1,59 @@
export interface TopologyLayoutResponse {
nodes: TopologyPositionedNode[];
edges: TopologyRoutedEdge[];
metadata: TopologyLayoutMetadata;
}
export interface TopologyPositionedNode {
id: string;
label: string;
kind: 'region' | 'environment' | 'host';
parentNodeId: string | null;
x: number;
y: number;
width: number;
height: number;
environmentId?: string;
regionId?: string;
environmentType?: string;
healthStatus?: string;
hostCount: number;
targetCount: number;
currentReleaseId?: string;
isFrozen: boolean;
promotionPathCount: number;
}
export interface TopologyRoutedEdge {
id: string;
sourceNodeId: string;
targetNodeId: string;
kind?: string;
label?: string;
sections: TopologyEdgeSection[];
pathId?: string;
pathMode?: string;
status?: string;
requiredApprovals: number;
gateProfileId?: string;
gateProfileName?: string;
}
export interface TopologyEdgeSection {
startPoint: TopologyPoint;
endPoint: TopologyPoint;
bendPoints: TopologyPoint[];
}
export interface TopologyPoint {
x: number;
y: number;
}
export interface TopologyLayoutMetadata {
regionCount: number;
environmentCount: number;
promotionPathCount: number;
canvasWidth: number;
canvasHeight: number;
}

View File

@@ -0,0 +1,39 @@
import { HttpClient, HttpParams } from '@angular/common/http';
import { Injectable, inject } from '@angular/core';
import { Observable } from 'rxjs';
import { PlatformContextStore } from '../../core/context/platform-context.store';
import { TopologyLayoutResponse } from './topology-layout.models';
@Injectable({ providedIn: 'root' })
export class TopologyLayoutService {
private readonly http = inject(HttpClient);
getLayout(
context: PlatformContextStore,
options?: {
direction?: 'left-to-right' | 'top-to-bottom';
effort?: 'draft' | 'balanced' | 'best';
},
): Observable<TopologyLayoutResponse> {
let params = new HttpParams();
const regions = context.selectedRegions();
const environments = context.selectedEnvironments();
if (regions.length > 0) {
params = params.set('region', regions.join(','));
}
if (environments.length > 0) {
params = params.set('environment', environments.join(','));
}
if (options?.direction) {
params = params.set('direction', options.direction);
}
if (options?.effort) {
params = params.set('effort', options.effort);
}
return this.http.get<TopologyLayoutResponse>('/api/v2/topology/layout', { params });
}
}

View File

@@ -1,729 +0,0 @@
import {
ChangeDetectionStrategy,
Component,
ElementRef,
ViewChild,
AfterViewInit,
OnDestroy,
effect,
inject,
signal,
} from '@angular/core';
import { FormsModule } from '@angular/forms';
import { LoadingStateComponent } from '../../shared/components/loading-state/loading-state.component';
import { Router } from '@angular/router';
import { catchError, forkJoin, of, take } from 'rxjs';
import * as d3 from 'd3';
import { PlatformContextStore } from '../../core/context/platform-context.store';
import { TopologyDataService } from './topology-data.service';
import {
TopologyAgent,
TopologyEnvironment,
TopologyPromotionPath,
TopologyRegion,
} from './topology.models';
type TopoNodeKind = 'region' | 'environment' | 'agent';
interface TopoNode extends d3.SimulationNodeDatum {
id: string;
kind: TopoNodeKind;
label: string;
sublabel: string;
regionId?: string;
environmentId?: string;
status?: string;
}
interface TopoLink extends d3.SimulationLinkDatum<TopoNode> {
id: string;
relation: 'contains' | 'assigned' | 'promotion';
}
@Component({
selector: 'app-topology-map-page',
standalone: true,
imports: [FormsModule, LoadingStateComponent],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<section class="topo-map">
<header class="topo-map__header">
<h1 class="topo-map__title">Environment and Target Map</h1>
<p class="topo-map__subtitle">Region-first map of environments, agents, and promotion paths.</p>
</header>
@if (error()) {
<div class="topo-map__banner topo-map__banner--error">{{ error() }}</div>
}
<div class="topo-map__toolbar">
<div class="topo-map__search">
<input
type="text"
placeholder="Search nodes..."
[ngModel]="searchQuery()"
(ngModelChange)="searchQuery.set($event)"
/>
</div>
<div class="topo-map__zoom-controls">
<button type="button" (click)="onZoomIn()" title="Zoom in">+</button>
<button type="button" (click)="onZoomOut()" title="Zoom out">&minus;</button>
<button type="button" (click)="onResetZoom()" title="Reset view">Reset</button>
</div>
</div>
<div class="topo-map__graph" #graphContainer>
@if (loading()) {
<app-loading-state size="lg" message="Loading topology..." />
}
</div>
<div class="topo-map__legend">
<span class="topo-map__legend-item topo-map__legend-item--region">Region</span>
<span class="topo-map__legend-item topo-map__legend-item--environment">Environment</span>
<span class="topo-map__legend-item topo-map__legend-item--agent">Agent</span>
<span class="topo-map__legend-item topo-map__legend-item--promotion">Promotion path</span>
</div>
</section>
`,
styles: [`
.topo-map {
display: grid;
gap: 0.6rem;
}
.topo-map__header {
display: grid;
gap: 0.1rem;
}
.topo-map__title {
margin: 0;
font-size: 1.15rem;
font-weight: 700;
color: var(--color-text-heading);
}
.topo-map__subtitle {
margin: 0;
font-size: 0.78rem;
color: var(--color-text-secondary);
}
.topo-map__banner {
border: 1px solid var(--color-border-primary);
border-radius: var(--radius-md);
background: var(--color-surface-primary);
color: var(--color-text-secondary);
padding: 0.55rem 0.7rem;
font-size: 0.76rem;
}
.topo-map__banner--error {
color: var(--color-status-error-text);
border-color: var(--color-status-error-border);
background: var(--color-status-error-bg);
}
.topo-map__toolbar {
display: flex;
align-items: center;
gap: 0.5rem;
border: 1px solid var(--color-border-primary);
border-radius: var(--radius-md);
background: var(--color-surface-primary);
padding: 0.4rem 0.55rem;
}
.topo-map__search {
flex: 1;
min-width: 100px;
}
.topo-map__search input {
width: 100%;
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.74rem;
padding: 0.25rem 0.45rem;
transition: border-color 150ms ease, box-shadow 150ms ease;
}
.topo-map__search input:focus {
outline: none;
border-color: var(--color-border-focus);
box-shadow: 0 0 0 2px var(--color-focus-ring);
}
.topo-map__search input::placeholder {
color: var(--color-text-muted);
}
.topo-map__zoom-controls {
display: flex;
gap: 0.2rem;
}
.topo-map__zoom-controls button {
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.72rem;
padding: 0.2rem 0.4rem;
cursor: pointer;
line-height: 1;
font-weight: 500;
transition: background 150ms ease, border-color 150ms ease, color 150ms ease;
}
.topo-map__zoom-controls button:hover {
background: var(--color-brand-soft);
border-color: var(--color-border-emphasis);
color: var(--color-text-link);
}
.topo-map__graph {
position: relative;
border: 1px solid var(--color-border-primary);
border-radius: var(--radius-lg);
background: var(--color-surface-primary);
height: calc(100vh - 260px);
min-height: 420px;
overflow: hidden;
transition: box-shadow 180ms ease;
}
.topo-map__graph:hover {
box-shadow: var(--shadow-sm);
}
.topo-map__graph :deep(svg) {
display: block;
width: 100%;
height: 100%;
}
.topo-map__loading {
position: absolute;
inset: 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 0.5rem;
color: var(--color-text-muted);
font-size: 0.8rem;
}
.topo-map__loading::before {
content: '';
display: block;
width: 24px;
height: 24px;
border: 2px solid var(--color-border-primary);
border-top-color: var(--color-brand-primary);
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
.topo-map__legend {
display: flex;
gap: 0.65rem;
flex-wrap: wrap;
padding: 0.35rem 0.55rem;
border: 1px solid var(--color-border-primary);
border-radius: var(--radius-md);
background: var(--color-surface-primary);
}
.topo-map__legend-item {
display: flex;
align-items: center;
gap: 0.25rem;
font-size: 0.68rem;
color: var(--color-text-secondary);
}
.topo-map__legend-item::before {
content: '';
display: inline-block;
width: 10px;
height: 10px;
border-radius: 2px;
flex-shrink: 0;
}
.topo-map__legend-item--region::before {
background: #6082A8;
border-radius: 50%;
}
.topo-map__legend-item--environment::before {
background: #4D9B40;
border-radius: 3px;
}
.topo-map__legend-item--agent::before {
background: #7A5090;
clip-path: polygon(50% 0%, 100% 25%, 100% 75%, 50% 100%, 0% 75%, 0% 25%);
}
.topo-map__legend-item--promotion::before {
background: transparent;
border: 1.5px dashed #C89820;
border-radius: 0;
width: 16px;
height: 0;
}
`],
})
export class TopologyMapPageComponent implements AfterViewInit, OnDestroy {
private readonly router = inject(Router);
private readonly topologyApi = inject(TopologyDataService);
readonly context = inject(PlatformContextStore);
@ViewChild('graphContainer', { static: true }) graphContainer!: ElementRef<HTMLDivElement>;
readonly loading = signal(false);
readonly error = signal<string | null>(null);
readonly searchQuery = signal('');
private svg: d3.Selection<SVGSVGElement, unknown, null, undefined> | null = null;
private simulation: d3.Simulation<TopoNode, TopoLink> | null = null;
private zoom: d3.ZoomBehavior<SVGSVGElement, unknown> | null = null;
private resizeObserver: ResizeObserver | null = null;
private allNodes: TopoNode[] = [];
private allLinks: TopoLink[] = [];
private readonly nodeColors: Record<TopoNodeKind, string> = {
region: '#6082A8',
environment: '#4D9B40',
agent: '#7A5090',
};
private readonly nodeRadii: Record<TopoNodeKind, number> = {
region: 16,
environment: 14,
agent: 10,
};
constructor() {
this.context.initialize();
effect(() => {
this.context.contextVersion();
this.load();
});
effect(() => {
this.searchQuery();
this.applyFilters();
});
}
ngAfterViewInit(): void {
requestAnimationFrame(() => {
this.initGraph();
// Re-render data that may have arrived before initGraph completed
if (this.allNodes.length > 0) {
this.applyFilters();
}
this.resizeObserver = new ResizeObserver(() => this.handleResize());
this.resizeObserver.observe(this.graphContainer.nativeElement);
});
}
ngOnDestroy(): void {
this.simulation?.stop();
this.resizeObserver?.disconnect();
}
onZoomIn(): void {
if (!this.svg || !this.zoom) return;
this.svg.transition().duration(300).call(this.zoom.scaleBy, 1.3);
}
onZoomOut(): void {
if (!this.svg || !this.zoom) return;
this.svg.transition().duration(300).call(this.zoom.scaleBy, 0.7);
}
onResetZoom(): void {
if (!this.svg || !this.zoom) return;
this.svg.transition().duration(300).call(this.zoom.transform, d3.zoomIdentity);
}
private initGraph(): void {
const container = this.graphContainer.nativeElement;
const width = container.clientWidth || 800;
const height = container.clientHeight || 420;
this.svg = d3.select(container)
.append('svg')
.attr('width', '100%')
.attr('height', '100%')
.attr('viewBox', `0 0 ${width} ${height}`);
this.zoom = d3.zoom<SVGSVGElement, unknown>()
.scaleExtent([0.15, 4])
.on('zoom', (event) => {
mainGroup.attr('transform', event.transform.toString());
});
this.svg.call(this.zoom);
const mainGroup = this.svg.append('g').attr('class', 'main-group');
const defs = this.svg.append('defs');
defs.append('marker')
.attr('id', 'map-arrow')
.attr('viewBox', '0 -5 10 10')
.attr('refX', 22)
.attr('refY', 0)
.attr('orient', 'auto')
.attr('markerWidth', 5)
.attr('markerHeight', 5)
.append('path')
.attr('d', 'M0,-4L10,0L0,4')
.attr('fill', 'var(--color-text-muted)');
defs.append('marker')
.attr('id', 'map-arrow-promotion')
.attr('viewBox', '0 -5 10 10')
.attr('refX', 22)
.attr('refY', 0)
.attr('orient', 'auto')
.attr('markerWidth', 5)
.attr('markerHeight', 5)
.append('path')
.attr('d', 'M0,-4L10,0L0,4')
.attr('fill', '#C89820');
mainGroup.append('g').attr('class', 'links');
mainGroup.append('g').attr('class', 'nodes');
mainGroup.append('g').attr('class', 'labels');
this.simulation = d3.forceSimulation<TopoNode, TopoLink>()
.force('link', d3.forceLink<TopoNode, TopoLink>().id(d => d.id).distance(d => {
const rel = (d as TopoLink).relation;
if (rel === 'promotion') return 140;
return 80;
}))
.force('charge', d3.forceManyBody().strength(-300))
.force('center', d3.forceCenter(width / 2, height / 2))
.force('collision', d3.forceCollide().radius(30))
.force('y', d3.forceY<TopoNode>().y(d => {
const layerMap: Record<TopoNodeKind, number> = {
region: height * 0.2,
environment: height * 0.5,
agent: height * 0.8,
};
return layerMap[d.kind] ?? height / 2;
}).strength(0.1));
}
private load(): void {
this.loading.set(true);
this.error.set(null);
forkJoin({
regions: this.topologyApi.list<TopologyRegion>('/api/v2/topology/regions', this.context).pipe(catchError(() => of([]))),
environments: this.topologyApi.list<TopologyEnvironment>('/api/v2/topology/environments', this.context).pipe(catchError(() => of([]))),
agents: this.topologyApi.list<TopologyAgent>('/api/v2/topology/agents', this.context).pipe(catchError(() => of([]))),
paths: this.topologyApi.list<TopologyPromotionPath>('/api/v2/topology/promotion-paths', this.context).pipe(catchError(() => of([]))),
})
.pipe(take(1))
.subscribe({
next: ({ regions, environments, agents, paths }) => {
this.loading.set(false);
this.buildGraph(regions, environments, agents, paths);
},
error: (err: unknown) => {
this.error.set(err instanceof Error ? err.message : 'Failed to load topology data.');
this.loading.set(false);
},
});
}
private buildGraph(
regions: TopologyRegion[],
environments: TopologyEnvironment[],
agents: TopologyAgent[],
paths: TopologyPromotionPath[],
): void {
const nodes: TopoNode[] = [];
const links: TopoLink[] = [];
for (const r of regions) {
nodes.push({
id: `region:${r.regionId}`,
kind: 'region',
label: r.displayName,
sublabel: `${r.environmentCount} env · ${r.targetCount} targets`,
regionId: r.regionId,
});
}
for (const e of environments) {
nodes.push({
id: `env:${e.environmentId}`,
kind: 'environment',
label: e.displayName,
sublabel: `${e.environmentType} · ${e.targetCount} targets`,
regionId: e.regionId,
environmentId: e.environmentId,
});
links.push({
id: `r-e:${e.environmentId}`,
source: `region:${e.regionId}`,
target: `env:${e.environmentId}`,
relation: 'contains',
});
}
for (const a of agents) {
nodes.push({
id: `agent:${a.agentId}`,
kind: 'agent',
label: a.agentName,
sublabel: `${a.status} · ${a.assignedTargetCount} targets`,
regionId: a.regionId,
environmentId: a.environmentId,
status: a.status,
});
links.push({
id: `e-a:${a.agentId}`,
source: `env:${a.environmentId}`,
target: `agent:${a.agentId}`,
relation: 'assigned',
});
}
for (const p of paths) {
links.push({
id: `promo:${p.pathId}`,
source: `env:${p.sourceEnvironmentId}`,
target: `env:${p.targetEnvironmentId}`,
relation: 'promotion',
});
}
this.allNodes = nodes;
this.allLinks = links;
this.applyFilters();
}
private applyFilters(): void {
if (!this.svg || !this.simulation) return;
const query = this.searchQuery().trim().toLowerCase();
let visibleNodes = [...this.allNodes];
if (query.length >= 2) {
visibleNodes = visibleNodes.filter(n =>
n.label.toLowerCase().includes(query) ||
n.sublabel.toLowerCase().includes(query)
);
}
const visibleIds = new Set(visibleNodes.map(n => n.id));
const visibleLinks = this.allLinks.filter(
l => visibleIds.has((l.source as TopoNode).id ?? l.source as string) &&
visibleIds.has((l.target as TopoNode).id ?? l.target as string)
);
this.renderGraph(visibleNodes, visibleLinks);
}
private renderGraph(nodes: TopoNode[], links: TopoLink[]): void {
if (!this.svg || !this.simulation) return;
const mainGroup = this.svg.select<SVGGElement>('.main-group');
// Links
const linkGroup = mainGroup.select<SVGGElement>('.links');
const linkSel = linkGroup.selectAll<SVGLineElement, TopoLink>('line').data(links, d => d.id);
linkSel.exit().remove();
const linkEnter = linkSel.enter()
.append('line')
.attr('stroke', d => d.relation === 'promotion' ? '#C89820' : 'var(--color-text-muted)')
.attr('stroke-opacity', d => d.relation === 'promotion' ? 0.6 : 0.35)
.attr('stroke-width', d => d.relation === 'promotion' ? 1.5 : 1.2)
.attr('stroke-dasharray', d => d.relation === 'promotion' ? '6 3' : 'none')
.attr('marker-end', d => d.relation === 'promotion' ? 'url(#map-arrow-promotion)' : 'url(#map-arrow)');
const allLinks = linkEnter.merge(linkSel);
// Nodes
const nodeGroup = mainGroup.select<SVGGElement>('.nodes');
const nodeSel = nodeGroup.selectAll<SVGGElement, TopoNode>('g.topo-node').data(nodes, d => d.id);
nodeSel.exit().remove();
const nodeEnter = nodeSel.enter()
.append('g')
.attr('class', 'topo-node')
.attr('cursor', 'pointer')
.on('click', (event, d) => {
event.stopPropagation();
this.navigateToNode(d);
})
.call(this.dragBehavior());
nodeEnter.each((d, i, els) => {
const g = d3.select(els[i]);
const r = this.nodeRadii[d.kind];
const color = this.nodeColors[d.kind];
if (d.kind === 'region') {
g.append('circle')
.attr('r', r)
.attr('fill', color)
.attr('stroke', 'var(--color-surface-primary)')
.attr('stroke-width', 2);
} else if (d.kind === 'environment') {
g.append('rect')
.attr('width', r * 2)
.attr('height', r * 1.4)
.attr('x', -r)
.attr('y', -r * 0.7)
.attr('rx', 5)
.attr('fill', color)
.attr('stroke', 'var(--color-surface-primary)')
.attr('stroke-width', 2);
} else {
g.append('path')
.attr('d', this.hexPath(r))
.attr('fill', color)
.attr('stroke', 'var(--color-surface-primary)')
.attr('stroke-width', 1.5);
}
});
nodeEnter.append('title')
.text(d => `${d.label}\n${d.sublabel}`);
const allNodes = nodeEnter.merge(nodeSel);
// Labels
const labelGroup = mainGroup.select<SVGGElement>('.labels');
const labelSel = labelGroup.selectAll<SVGTextElement, TopoNode>('text').data(nodes, d => d.id);
labelSel.exit().remove();
const labelEnter = labelSel.enter()
.append('text')
.attr('text-anchor', 'middle')
.attr('dy', d => (this.nodeRadii[d.kind] + 11))
.attr('font-size', 9)
.attr('fill', 'var(--color-text-secondary)')
.text(d => this.truncate(d.label, 16));
const allLabels = labelEnter.merge(labelSel);
// Simulation
this.simulation
.nodes(nodes)
.on('tick', () => {
allLinks
.attr('x1', d => (d.source as TopoNode).x ?? 0)
.attr('y1', d => (d.source as TopoNode).y ?? 0)
.attr('x2', d => (d.target as TopoNode).x ?? 0)
.attr('y2', d => (d.target as TopoNode).y ?? 0);
allNodes.attr('transform', d => `translate(${d.x ?? 0},${d.y ?? 0})`);
allLabels
.attr('x', d => d.x ?? 0)
.attr('y', d => d.y ?? 0);
});
(this.simulation.force('link') as d3.ForceLink<TopoNode, TopoLink>).links(links);
this.simulation.alpha(0.8).restart();
}
private navigateToNode(node: TopoNode): void {
switch (node.kind) {
case 'region':
void this.router.navigate(['/setup/topology/regions'], { queryParamsHandling: 'merge' });
break;
case 'environment':
if (node.environmentId) {
void this.router.navigate(['/setup/topology/environments', node.environmentId, 'posture'], {
queryParamsHandling: 'merge',
});
}
break;
case 'agent':
void this.router.navigate(['/setup/topology/agents'], {
queryParams: { agentId: node.id.replace('agent:', '') },
queryParamsHandling: 'merge',
});
break;
}
}
private dragBehavior(): d3.DragBehavior<SVGGElement, TopoNode, TopoNode | d3.SubjectPosition> {
return d3.drag<SVGGElement, TopoNode>()
.on('start', (event, d) => {
if (!event.active) this.simulation?.alphaTarget(0.3).restart();
d.fx = d.x;
d.fy = d.y;
})
.on('drag', (event, d) => {
d.fx = event.x;
d.fy = event.y;
})
.on('end', (event, d) => {
if (!event.active) this.simulation?.alphaTarget(0);
d.fx = null;
d.fy = null;
});
}
private handleResize(): void {
if (!this.svg || !this.simulation) return;
const container = this.graphContainer.nativeElement;
const width = container.clientWidth || 800;
const height = container.clientHeight || 420;
this.svg.attr('viewBox', `0 0 ${width} ${height}`);
(this.simulation.force('center') as d3.ForceCenter<TopoNode>)
.x(width / 2)
.y(height / 2);
// Update y-force layer positions to match new height
this.simulation.force('y', d3.forceY<TopoNode>().y(d => {
const layerMap: Record<TopoNodeKind, number> = {
region: height * 0.2,
environment: height * 0.5,
agent: height * 0.8,
};
return layerMap[d.kind] ?? height / 2;
}).strength(0.1));
this.simulation.alpha(0.3).restart();
}
private hexPath(r: number): string {
const points: [number, number][] = [];
for (let i = 0; i < 6; i++) {
const angle = (Math.PI / 3) * i - Math.PI / 6;
points.push([r * Math.cos(angle), r * Math.sin(angle)]);
}
return 'M' + points.map(p => p.join(',')).join('L') + 'Z';
}
private truncate(text: string, max: number): string {
return text.length <= max ? text : text.substring(0, max - 1) + '\u2026';
}
}

View File

@@ -1,819 +0,0 @@
import { ChangeDetectionStrategy, Component, computed, effect, inject, signal } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { ActivatedRoute, RouterLink } from '@angular/router';
import { catchError, forkJoin, of, take } from 'rxjs';
import { PlatformContextStore } from '../../core/context/platform-context.store';
import { TopologyDataService } from './topology-data.service';
import { TopologyEnvironment, TopologyPromotionPath, TopologyRegion, TopologyTarget } from './topology.models';
type RegionsView = 'region-first' | 'flat' | 'graph';
@Component({
selector: 'app-topology-regions-environments-page',
standalone: true,
imports: [FormsModule, RouterLink],
template: `
<section class="regions-env">
<header class="regions-env__header">
<div>
<p>Region-first topology inventory with environment posture and drilldowns.</p>
</div>
<div class="regions-env__scope">
<span>{{ context.regionSummary() }}</span>
<span>{{ context.environmentSummary() }}</span>
</div>
</header>
<section class="filters">
<div class="filters__item">
<label for="regions-view">View</label>
<select id="regions-view" [ngModel]="viewMode()" (ngModelChange)="viewMode.set($event)">
<option value="region-first">Region-first</option>
<option value="flat">Flat list</option>
<option value="graph">Graph</option>
</select>
</div>
<div class="filters__item filters__item--wide">
<label for="regions-search">Search</label>
<input
id="regions-search"
type="text"
placeholder="Search region or environment"
[ngModel]="searchQuery()"
(ngModelChange)="searchQuery.set($event)"
/>
</div>
</section>
@if (error()) {
<div class="banner banner--error">{{ error() }}</div>
}
@if (loading()) {
<div class="skeleton-table">
<div class="skeleton-row" style="width:100%"><div class="skeleton-line skeleton-line--title"></div></div>
<div class="skeleton-row"><div class="skeleton-line"></div><div class="skeleton-line skeleton-line--short"></div></div>
<div class="skeleton-row"><div class="skeleton-line"></div><div class="skeleton-line skeleton-line--short"></div></div>
<div class="skeleton-row"><div class="skeleton-line"></div><div class="skeleton-line skeleton-line--short"></div></div>
</div>
} @else {
@if (viewMode() === 'region-first') {
<section class="split">
<article class="card">
<h2>Regions</h2>
<ul class="region-list">
@for (region of filteredRegions(); track region.regionId) {
<li>
<button type="button" [class.active]="selectedRegionId() === region.regionId" (click)="selectRegion(region.regionId)">
<strong>{{ region.displayName }}</strong>
<small>env {{ region.environmentCount }} · targets {{ region.targetCount }}</small>
</button>
</li>
} @empty {
<li class="muted">No regions in current scope.</li>
}
</ul>
</article>
<article class="card">
<h2>Environments · {{ selectedRegionLabel() }}</h2>
<table class="stella-table stella-table--striped stella-table--hoverable">
<thead>
<tr>
<th>Environment</th>
<th>Type</th>
<th>Health</th>
<th>Targets</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
@for (env of selectedRegionEnvironments(); track env.environmentId) {
<tr>
<td>{{ env.displayName }}</td>
<td>{{ env.environmentType }}</td>
<td><span class="status-badge" [class.status-badge--healthy]="environmentHealthLabel(env.environmentId) === 'Healthy'" [class.status-badge--degraded]="environmentHealthLabel(env.environmentId) === 'Degraded'" [class.status-badge--unhealthy]="environmentHealthLabel(env.environmentId) === 'Unhealthy'" [class.status-badge--muted]="environmentHealthLabel(env.environmentId) === 'No target data'">{{ environmentHealthLabel(env.environmentId) }}</span></td>
<td>{{ env.targetCount }}</td>
<td>
<button type="button" class="icon-btn" title="Open environment posture" [routerLink]="['/setup/topology/environments', env.environmentId, 'posture']" [queryParams]="{ environment: env.environmentId, environments: env.environmentId }" queryParamsHandling="merge">
<svg viewBox="0 0 20 20" fill="currentColor" width="14" height="14"><path fill-rule="evenodd" d="M10.293 3.293a1 1 0 011.414 0l6 6a1 1 0 010 1.414l-6 6a1 1 0 01-1.414-1.414L14.586 11H3a1 1 0 110-2h11.586l-4.293-4.293a1 1 0 010-1.414z" clip-rule="evenodd"/></svg>
</button>
</td>
</tr>
} @empty {
<tr>
<td colspan="5" class="empty-cell">
<svg class="empty-cell__icon" viewBox="0 0 20 20" fill="currentColor" width="16" height="16"><path fill-rule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7 4a1 1 0 11-2 0 1 1 0 012 0zm-1-9a1 1 0 00-1 1v4a1 1 0 102 0V6a1 1 0 00-1-1z" clip-rule="evenodd"/></svg>
No environments for this region.
</td>
</tr>
}
</tbody>
</table>
</article>
</section>
} @else if (viewMode() === 'flat') {
<article class="card">
<h2>Environment Inventory</h2>
<table class="stella-table stella-table--striped stella-table--hoverable">
<thead>
<tr>
<th>Environment</th>
<th>Region</th>
<th>Type</th>
<th>Health</th>
<th>Targets</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
@for (env of filteredEnvironments(); track env.environmentId) {
<tr>
<td>{{ env.displayName }}</td>
<td>{{ env.regionId }}</td>
<td>{{ env.environmentType }}</td>
<td><span class="status-badge" [class.status-badge--healthy]="environmentHealthLabel(env.environmentId) === 'Healthy'" [class.status-badge--degraded]="environmentHealthLabel(env.environmentId) === 'Degraded'" [class.status-badge--unhealthy]="environmentHealthLabel(env.environmentId) === 'Unhealthy'" [class.status-badge--muted]="environmentHealthLabel(env.environmentId) === 'No target data'">{{ environmentHealthLabel(env.environmentId) }}</span></td>
<td>{{ env.targetCount }}</td>
<td>
<button type="button" class="icon-btn" title="Open environment posture" [routerLink]="['/setup/topology/environments', env.environmentId, 'posture']" [queryParams]="{ environment: env.environmentId, environments: env.environmentId }" queryParamsHandling="merge">
<svg viewBox="0 0 20 20" fill="currentColor" width="14" height="14"><path fill-rule="evenodd" d="M10.293 3.293a1 1 0 011.414 0l6 6a1 1 0 010 1.414l-6 6a1 1 0 01-1.414-1.414L14.586 11H3a1 1 0 110-2h11.586l-4.293-4.293a1 1 0 010-1.414z" clip-rule="evenodd"/></svg>
</button>
</td>
</tr>
} @empty {
<tr>
<td colspan="6" class="empty-cell">
<svg class="empty-cell__icon" viewBox="0 0 20 20" fill="currentColor" width="16" height="16"><path fill-rule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7 4a1 1 0 11-2 0 1 1 0 012 0zm-1-9a1 1 0 00-1 1v4a1 1 0 102 0V6a1 1 0 00-1-1z" clip-rule="evenodd"/></svg>
No environments for current filters.
</td>
</tr>
}
</tbody>
</table>
</article>
} @else {
<article class="card">
<h2>Promotion Graph (by region)</h2>
<ul class="graph-list">
@for (edge of graphEdges(); track edge.pathId) {
<li>{{ edge.regionId }} · {{ edge.sourceEnvironmentId }} -> {{ edge.targetEnvironmentId }} · {{ edge.status }}</li>
} @empty {
<li class="muted">No promotion edges in current scope.</li>
}
</ul>
</article>
}
<article class="card">
<h2>Environment Signals</h2>
<p>
Selected:
<strong>{{ selectedEnvironmentLabel() }}</strong>
· {{ selectedEnvironmentHealth() }}
· targets {{ selectedEnvironmentTargetCount() }}
</p>
<div class="actions">
<a
[routerLink]="['/setup/topology/environments', selectedEnvironmentId(), 'posture']"
[queryParams]="{ environment: selectedEnvironmentId(), environments: selectedEnvironmentId() }"
queryParamsHandling="merge"
>Open Environment</a>
<a [routerLink]="['/setup/topology/targets']" [queryParams]="{ environment: selectedEnvironmentId() }" queryParamsHandling="merge">Open Targets</a>
<a [routerLink]="['/setup/topology/agents']" [queryParams]="{ environment: selectedEnvironmentId() }" queryParamsHandling="merge">Open Agents</a>
<a [routerLink]="['/releases/runs']" [queryParams]="{ environment: selectedEnvironmentId() }" queryParamsHandling="merge">Open Runs</a>
</div>
</article>
}
</section>
`,
styles: [`
.regions-env {
display: grid;
gap: 0.75rem;
}
.regions-env__header {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 1rem;
}
.regions-env__header h1 {
margin: 0;
font-size: 1.15rem;
color: var(--color-text-heading);
}
.regions-env__header p {
margin: 0.15rem 0 0;
color: var(--color-text-secondary);
font-size: 0.78rem;
}
.regions-env__scope {
display: flex;
gap: 0.3rem;
flex-wrap: wrap;
justify-content: flex-end;
padding-top: 0.1rem;
}
.regions-env__scope span {
border: 1px solid var(--color-border-primary);
border-radius: var(--radius-full);
background: var(--color-surface-secondary);
color: var(--color-text-secondary);
font-size: 0.68rem;
padding: 0.1rem 0.45rem;
white-space: nowrap;
}
/* --- Filters --- */
.filters {
border: 1px solid var(--color-border-primary);
border-radius: var(--radius-md);
background: var(--color-surface-primary);
padding: 0.55rem 0.65rem;
display: flex;
gap: 0.55rem;
align-items: flex-end;
flex-wrap: wrap;
}
.filters__item {
display: grid;
gap: 0.15rem;
}
.filters__item--wide {
flex: 1;
min-width: 200px;
}
.filters label {
font-size: 0.67rem;
color: var(--color-text-muted);
text-transform: uppercase;
letter-spacing: 0.04em;
font-weight: 500;
}
.filters select,
.filters 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.3rem 0.42rem;
transition: border-color 150ms ease, box-shadow 150ms ease;
}
.filters select:focus,
.filters input:focus {
outline: none;
border-color: var(--color-border-focus);
box-shadow: 0 0 0 2px var(--color-focus-ring);
}
/* --- Banner --- */
.banner {
border: 1px solid var(--color-border-primary);
border-radius: var(--radius-md);
background: var(--color-surface-primary);
color: var(--color-text-secondary);
padding: 0.7rem;
font-size: 0.78rem;
}
.banner--error {
color: var(--color-status-error-text);
background: var(--color-status-error-bg);
border-color: var(--color-status-error-border);
}
/* --- Skeleton --- */
.skeleton-table {
border: 1px solid var(--color-border-primary);
border-radius: var(--radius-md);
background: var(--color-surface-primary);
padding: 0.75rem;
display: grid;
gap: 0.5rem;
}
.skeleton-row {
display: flex;
gap: 0.75rem;
}
.skeleton-line {
flex: 1;
height: 0.65rem;
border-radius: var(--radius-sm);
background: linear-gradient(90deg, var(--color-skeleton-base) 25%, var(--color-skeleton-highlight) 50%, var(--color-skeleton-base) 75%);
background-size: 200% 100%;
animation: shimmer 1.5s ease-in-out infinite;
}
.skeleton-line--title {
height: 0.85rem;
max-width: 30%;
}
.skeleton-line--short {
max-width: 40%;
}
@keyframes shimmer {
0% { background-position: 200% 0; }
100% { background-position: -200% 0; }
}
/* --- Split layout --- */
.split {
display: grid;
gap: 0.6rem;
grid-template-columns: minmax(220px, 320px) 1fr;
align-items: start;
}
/* --- Cards --- */
.card {
border: 1px solid var(--color-border-primary);
border-radius: var(--radius-md);
background: var(--color-surface-primary);
padding: 0.7rem;
display: grid;
gap: 0.45rem;
transition: box-shadow 180ms ease, border-color 180ms ease;
}
.card:hover {
box-shadow: var(--shadow-sm);
}
.card h2 {
margin: 0;
font-size: 0.92rem;
color: var(--color-card-heading);
font-weight: 600;
}
/* --- Region list --- */
.region-list {
list-style: none;
margin: 0;
padding: 0;
display: grid;
gap: 0.25rem;
}
.region-list li button {
width: 100%;
text-align: left;
border: 1px solid var(--color-border-primary);
border-radius: var(--radius-sm);
background: var(--color-surface-secondary);
color: var(--color-text-primary);
padding: 0.4rem 0.5rem;
display: grid;
gap: 0.1rem;
cursor: pointer;
transition: background 150ms ease, border-color 150ms ease, transform 150ms ease;
}
.region-list li button:hover {
background: var(--color-brand-soft);
border-color: var(--color-border-emphasis);
}
.region-list li button.active {
border-color: var(--color-brand-primary);
background: var(--color-brand-primary-10);
box-shadow: inset 3px 0 0 var(--color-brand-primary);
}
.region-list small {
color: var(--color-text-secondary);
font-size: 0.7rem;
}
/* --- Tables --- */
/* Table styling provided by global .stella-table class */
th {
position: sticky;
top: 0;
z-index: 1;
}
tbody tr {
transition: background 120ms ease;
}
tr:last-child td {
border-bottom: none;
}
.muted {
color: var(--color-text-secondary);
font-size: 0.74rem;
}
/* --- Status badges --- */
.status-badge {
display: inline-flex;
align-items: center;
border-radius: var(--radius-full);
font-size: 0.67rem;
font-weight: 500;
padding: 0.1rem 0.4rem;
line-height: 1.3;
border: 1px solid transparent;
white-space: nowrap;
}
.status-badge--healthy {
background: var(--color-status-success-bg);
color: var(--color-status-success-text);
border-color: var(--color-status-success-border);
}
.status-badge--degraded {
background: var(--color-status-warning-bg);
color: var(--color-status-warning-text);
border-color: var(--color-status-warning-border);
}
.status-badge--unhealthy {
background: var(--color-status-error-bg);
color: var(--color-status-error-text);
border-color: var(--color-status-error-border);
}
.status-badge--muted {
background: var(--color-surface-tertiary);
color: var(--color-text-muted);
border-color: var(--color-border-primary);
}
/* --- Icon button --- */
.icon-btn {
display: inline-flex;
align-items: center;
justify-content: center;
border: 1px solid var(--color-border-primary);
border-radius: var(--radius-sm);
background: var(--color-surface-secondary);
color: var(--color-text-secondary);
width: 1.5rem;
height: 1.5rem;
padding: 0;
cursor: pointer;
transition: background 150ms ease, border-color 150ms ease, color 150ms ease;
}
.icon-btn:hover {
background: var(--color-brand-soft);
border-color: var(--color-border-emphasis);
color: var(--color-text-link);
}
/* --- Empty cell --- */
.empty-cell {
text-align: center;
color: var(--color-text-muted);
font-size: 0.74rem;
padding: 1rem 0.5rem;
display: flex;
align-items: center;
justify-content: center;
gap: 0.35rem;
}
.empty-cell__icon {
opacity: 0.4;
flex-shrink: 0;
}
/* --- Graph list --- */
.graph-list {
margin: 0;
padding: 0;
list-style: none;
display: grid;
gap: 0.25rem;
}
.graph-list li {
border: 1px solid var(--color-border-primary);
border-radius: var(--radius-sm);
padding: 0.32rem 0.42rem;
font-size: 0.74rem;
background: var(--color-surface-secondary);
transition: background 150ms ease;
}
.graph-list li:hover {
background: var(--color-brand-soft);
}
/* --- Actions --- */
.actions {
display: flex;
flex-wrap: wrap;
gap: 0.35rem;
}
.actions a {
display: inline-flex;
align-items: center;
gap: 0.2rem;
color: var(--color-text-link);
font-size: 0.73rem;
font-weight: 500;
text-decoration: none;
padding: 0.2rem 0.45rem;
border-radius: var(--radius-sm);
transition: background 150ms ease, color 150ms ease;
}
.actions a:hover {
background: var(--color-brand-soft);
color: var(--color-text-link-hover);
}
@media (max-width: 960px) {
.filters {
flex-direction: column;
}
.filters__item--wide {
min-width: auto;
}
.split {
grid-template-columns: 1fr;
}
}
`],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class TopologyRegionsEnvironmentsPageComponent {
private readonly topologyApi = inject(TopologyDataService);
private readonly route = inject(ActivatedRoute);
readonly context = inject(PlatformContextStore);
readonly loading = signal(false);
readonly error = signal<string | null>(null);
readonly searchQuery = signal('');
readonly viewMode = signal<RegionsView>('region-first');
readonly requestedEnvironmentId = signal('');
readonly selectedRegionId = signal('');
readonly selectedEnvironmentId = signal('');
readonly regions = signal<TopologyRegion[]>([]);
readonly environments = signal<TopologyEnvironment[]>([]);
readonly targets = signal<TopologyTarget[]>([]);
readonly paths = signal<TopologyPromotionPath[]>([]);
readonly filteredRegions = computed(() => {
const query = this.searchQuery().trim().toLowerCase();
if (!query) {
return this.regions();
}
return this.regions().filter((item) => this.match(query, [item.displayName, item.regionId]));
});
readonly filteredEnvironments = computed(() => {
const query = this.searchQuery().trim().toLowerCase();
if (!query) {
return this.environments();
}
return this.environments().filter((item) =>
this.match(query, [item.displayName, item.environmentId, item.regionId, item.environmentType]),
);
});
readonly selectedRegionLabel = computed(() => {
const selected = this.regions().find((item) => item.regionId === this.selectedRegionId());
return (selected?.displayName ?? this.selectedRegionId()) || 'All Regions';
});
readonly selectedRegionEnvironments = computed(() => {
const selectedRegion = this.selectedRegionId();
if (!selectedRegion) {
return this.filteredEnvironments();
}
return this.filteredEnvironments().filter((item) => item.regionId === selectedRegion);
});
readonly selectedEnvironmentLabel = computed(() => {
const selected = this.environments().find((item) => item.environmentId === this.selectedEnvironmentId());
return (selected?.displayName ?? this.selectedEnvironmentId()) || 'No environment selected';
});
readonly selectedEnvironmentHealth = computed(() => {
const environmentId = this.selectedEnvironmentId();
if (!environmentId) {
return 'No environment selected';
}
return this.environmentHealthLabel(environmentId);
});
readonly selectedEnvironmentTargetCount = computed(() => {
const environmentId = this.selectedEnvironmentId();
if (!environmentId) {
return 0;
}
return this.targets().filter((item) => item.environmentId === environmentId).length;
});
readonly graphEdges = computed(() => {
const selectedRegion = this.selectedRegionId();
if (!selectedRegion) {
return this.paths();
}
return this.paths().filter((item) => item.regionId === selectedRegion);
});
constructor() {
this.context.initialize();
this.route.data.subscribe((data) => {
const defaultView = (data['defaultView'] as RegionsView | undefined) ?? 'region-first';
this.viewMode.set(defaultView);
});
this.route.queryParamMap.subscribe((queryParamMap) => {
this.requestedEnvironmentId.set(this.resolveRequestedEnvironmentId(queryParamMap));
});
effect(() => {
this.context.contextVersion();
this.requestedEnvironmentId();
this.load();
});
}
selectRegion(regionId: string): void {
this.selectedRegionId.set(regionId);
const firstEnv = this.environments().find((item) => item.regionId === regionId);
this.selectedEnvironmentId.set(firstEnv?.environmentId ?? '');
}
environmentHealthLabel(environmentId: string): string {
const statuses = this.targets()
.filter((item) => item.environmentId === environmentId)
.map((item) => item.healthStatus.trim().toLowerCase());
if (statuses.length === 0) {
return 'No target data';
}
if (statuses.includes('unhealthy') || statuses.includes('offline')) {
return 'Unhealthy';
}
if (statuses.includes('degraded') || statuses.includes('unknown')) {
return 'Degraded';
}
return 'Healthy';
}
private load(): void {
this.loading.set(true);
this.error.set(null);
forkJoin({
regions: this.topologyApi.list<TopologyRegion>('/api/v2/topology/regions', this.context).pipe(catchError(() => of([]))),
environments: this.topologyApi
.list<TopologyEnvironment>('/api/v2/topology/environments', this.context)
.pipe(catchError(() => of([]))),
targets: this.topologyApi.list<TopologyTarget>('/api/v2/topology/targets', this.context).pipe(catchError(() => of([]))),
paths: this.topologyApi
.list<TopologyPromotionPath>('/api/v2/topology/promotion-paths', this.context)
.pipe(catchError(() => of([]))),
})
.pipe(take(1))
.subscribe({
next: ({ regions, environments, targets, paths }) => {
this.regions.set(regions);
this.environments.set(environments);
this.targets.set(targets);
this.paths.set(paths);
const nextSelectedRegion = this.resolveSelectedRegionId(regions, environments);
this.selectedRegionId.set(nextSelectedRegion);
const nextSelectedEnvironment = this.resolveSelectedEnvironmentId(environments, nextSelectedRegion);
this.selectedEnvironmentId.set(nextSelectedEnvironment);
this.loading.set(false);
},
error: (err: unknown) => {
this.error.set(err instanceof Error ? err.message : 'Failed to load region and environment inventory.');
this.regions.set([]);
this.environments.set([]);
this.targets.set([]);
this.paths.set([]);
this.selectedRegionId.set('');
this.selectedEnvironmentId.set('');
this.loading.set(false);
},
});
}
private match(query: string, values: string[]): boolean {
return values.some((value) => value.toLowerCase().includes(query));
}
private resolveSelectedRegionId(
regions: TopologyRegion[],
environments: TopologyEnvironment[],
): string {
const current = this.selectedRegionId();
const scopedEnvironments = this.context.selectedEnvironments();
const scopedRegions = this.context.selectedRegions();
const requestedEnvironmentId = this.requestedEnvironmentId();
const regionFromRequestedEnvironment =
environments.find((item) => item.environmentId === requestedEnvironmentId)?.regionId ?? '';
const regionFromScopedEnvironment = environments.find((item) => scopedEnvironments.includes(item.environmentId))?.regionId ?? '';
const preferredScopedRegion =
regionFromRequestedEnvironment
|| regionFromScopedEnvironment
|| scopedRegions.find((regionId) => regions.some((item) => item.regionId === regionId))
|| '';
if (preferredScopedRegion) {
return preferredScopedRegion;
}
if (current && regions.some((item) => item.regionId === current)) {
return current;
}
return regions[0]?.regionId ?? '';
}
private resolveSelectedEnvironmentId(
environments: TopologyEnvironment[],
selectedRegionId: string,
): string {
const current = this.selectedEnvironmentId();
const scopedEnvironments = this.context.selectedEnvironments();
const requestedEnvironmentId = this.requestedEnvironmentId();
const environmentsInRegion = selectedRegionId
? environments.filter((item) => item.regionId === selectedRegionId)
: environments;
const preferredRequestedEnvironment =
environmentsInRegion.find((item) => item.environmentId === requestedEnvironmentId)?.environmentId
?? environments.find((item) => item.environmentId === requestedEnvironmentId)?.environmentId
?? '';
if (preferredRequestedEnvironment) {
return preferredRequestedEnvironment;
}
const preferredScopedEnvironment =
scopedEnvironments.find((environmentId) =>
environmentsInRegion.some((item) => item.environmentId === environmentId),
)
?? scopedEnvironments.find((environmentId) =>
environments.some((item) => item.environmentId === environmentId),
)
?? '';
if (preferredScopedEnvironment) {
return preferredScopedEnvironment;
}
if (current && environmentsInRegion.some((item) => item.environmentId === current)) {
return current;
}
return environmentsInRegion[0]?.environmentId ?? environments[0]?.environmentId ?? '';
}
private resolveRequestedEnvironmentId(queryParamMap: Pick<import('@angular/router').ParamMap, 'get'>): string {
const explicitEnvironment = queryParamMap.get('environment')?.trim();
if (explicitEnvironment) {
return explicitEnvironment;
}
const environmentsValue = queryParamMap.get('environments')?.trim();
if (!environmentsValue) {
return '';
}
return environmentsValue
.split(',')
.map((value) => value.trim())
.find((value) => value.length > 0) ?? '';
}
}

View File

@@ -6,8 +6,7 @@ import { filter } from 'rxjs';
import { StellaPageTabsComponent, StellaPageTab } from '../../shared/components/stella-page-tabs/stella-page-tabs.component';
type TabType =
| 'regions'
| 'map'
| 'overview'
| 'targets'
| 'hosts'
| 'agents'
@@ -19,13 +18,12 @@ type TabType =
| 'runtime-drift';
const KNOWN_TAB_IDS: readonly string[] = [
'regions', 'map', 'targets', 'hosts', 'agents', 'posture',
'overview', 'targets', 'hosts', 'agents', 'posture',
'promotion-graph', 'workflows', 'gate-profiles', 'connectivity', 'runtime-drift',
];
const PAGE_TABS: readonly StellaPageTab[] = [
{ id: 'regions', label: 'Regions & Environments', icon: 'M1 6v16l7-4 8 4 7-4V2l-7 4-8-4-7 4z|||M8 2v16|||M16 6v16' },
{ id: 'map', label: 'Map', 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: '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' },
@@ -113,7 +111,7 @@ export class TopologyShellComponent implements OnInit {
private readonly destroyRef = inject(DestroyRef);
readonly pageTabs = PAGE_TABS;
readonly activeTab = signal<string>('regions');
readonly activeTab = signal<string>('overview');
ngOnInit(): void {
this.setActiveTabFromUrl(this.router.url);

View File

@@ -196,12 +196,8 @@ export const OPERATIONS_ROUTES: Routes = [
},
{
path: 'environments',
title: 'Environments Inventory',
data: { breadcrumb: 'Environments' },
loadComponent: () =>
import('../features/topology/topology-regions-environments-page.component').then(
(m) => m.TopologyRegionsEnvironmentsPageComponent,
),
redirectTo: '/environments/overview',
pathMatch: 'full' as const,
},
{
path: 'environments/:environmentId',

View File

@@ -247,7 +247,7 @@ export const RELEASES_ROUTES: Routes = [
},
{
path: 'environments',
redirectTo: '/environments/regions',
redirectTo: '/environments/overview',
pathMatch: 'full',
},
{

View File

@@ -10,53 +10,41 @@ export const TOPOLOGY_ROUTES: Routes = [
children: [
{
path: '',
redirectTo: 'regions',
redirectTo: 'overview',
pathMatch: 'full',
},
{
path: 'overview',
redirectTo: 'regions',
title: 'Environment Topology',
data: {
breadcrumb: 'Topology',
title: 'Environment Topology',
description: 'Interactive SVG topology of regions, environments, and promotion paths.',
},
loadComponent: () =>
import('../features/topology/topology-graph-page.component').then(
(m) => m.TopologyGraphPageComponent,
),
},
{
path: 'regions',
redirectTo: 'overview',
pathMatch: 'full',
},
{
path: 'regions-environments',
redirectTo: 'overview',
pathMatch: 'full',
},
{
path: 'map',
title: 'Environment & Target Map',
data: { breadcrumb: 'Map' },
loadComponent: () =>
import('../features/topology/topology-map-page.component').then((m) => m.TopologyMapPageComponent),
},
{
path: 'regions',
title: 'Regions & Environments',
data: {
breadcrumb: 'Regions & Environments',
title: 'Regions & Environments',
description: 'Region-first topology inventory with environment posture and drilldowns.',
defaultView: 'region-first',
},
loadComponent: () =>
import('../features/topology/topology-regions-environments-page.component').then(
(m) => m.TopologyRegionsEnvironmentsPageComponent,
),
},
{
path: 'regions-environments',
redirectTo: 'regions',
redirectTo: 'overview',
pathMatch: 'full',
},
{
path: 'environments',
title: 'Environments',
data: {
breadcrumb: 'Environments',
title: 'Environments',
description: 'Environment inventory scoped by region and topology metadata.',
defaultView: 'flat',
},
loadComponent: () =>
import('../features/topology/topology-regions-environments-page.component').then(
(m) => m.TopologyRegionsEnvironmentsPageComponent,
),
redirectTo: 'overview',
pathMatch: 'full',
},
{
path: 'environments/:environmentId',