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:
@@ -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);
|
||||
@@ -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,
|
||||
|
||||
@@ -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>();
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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" />
|
||||
|
||||
Reference in New Issue
Block a user