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" />