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")
|
.WithSummary("List topology workflows")
|
||||||
.RequireAuthorization(PlatformPolicies.TopologyRead);
|
.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>(
|
topology.MapGet("/gate-profiles", async Task<IResult>(
|
||||||
HttpContext context,
|
HttpContext context,
|
||||||
PlatformRequestContextResolver resolver,
|
PlatformRequestContextResolver resolver,
|
||||||
|
|||||||
@@ -207,6 +207,8 @@ builder.Services.AddSingleton<PlatformMetadataService>();
|
|||||||
builder.Services.AddSingleton<PlatformContextService>();
|
builder.Services.AddSingleton<PlatformContextService>();
|
||||||
builder.Services.AddSingleton<IPlatformContextQuery>(sp => sp.GetRequiredService<PlatformContextService>());
|
builder.Services.AddSingleton<IPlatformContextQuery>(sp => sp.GetRequiredService<PlatformContextService>());
|
||||||
builder.Services.AddSingleton<TopologyReadModelService>();
|
builder.Services.AddSingleton<TopologyReadModelService>();
|
||||||
|
builder.Services.AddSingleton<StellaOps.ElkSharp.IElkLayoutEngine, StellaOps.ElkSharp.ElkSharpLayeredLayoutEngine>();
|
||||||
|
builder.Services.AddSingleton<TopologyLayoutService>();
|
||||||
builder.Services.AddSingleton<ReleaseReadModelService>();
|
builder.Services.AddSingleton<ReleaseReadModelService>();
|
||||||
builder.Services.AddSingleton<SecurityReadModelService>();
|
builder.Services.AddSingleton<SecurityReadModelService>();
|
||||||
builder.Services.AddSingleton<IntegrationsReadModelService>();
|
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="..\..\Authority\StellaOps.Authority\StellaOps.Auth.ServerIntegration\StellaOps.Auth.ServerIntegration.csproj" />
|
||||||
<ProjectReference Include="..\..\__Libraries\StellaOps.Configuration\StellaOps.Configuration.csproj" />
|
<ProjectReference Include="..\..\__Libraries\StellaOps.Configuration\StellaOps.Configuration.csproj" />
|
||||||
<ProjectReference Include="..\..\__Libraries\StellaOps.Infrastructure.Postgres\StellaOps.Infrastructure.Postgres.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.Core\StellaOps.Telemetry.Core\StellaOps.Telemetry.Core.csproj" />
|
||||||
<ProjectReference Include="..\..\Telemetry\StellaOps.Telemetry.Federation\StellaOps.Telemetry.Federation.csproj" />
|
<ProjectReference Include="..\..\Telemetry\StellaOps.Telemetry.Federation\StellaOps.Telemetry.Federation.csproj" />
|
||||||
<ProjectReference Include="..\..\Router\__Libraries\StellaOps.Router.AspNet\StellaOps.Router.AspNet.csproj" />
|
<ProjectReference Include="..\..\Router\__Libraries\StellaOps.Router.AspNet\StellaOps.Router.AspNet.csproj" />
|
||||||
|
|||||||
@@ -340,7 +340,7 @@ export const routes: Routes = [
|
|||||||
{ path: 'setup', redirectTo: '/ops/platform-setup', pathMatch: 'full' },
|
{ path: 'setup', redirectTo: '/ops/platform-setup', pathMatch: 'full' },
|
||||||
{
|
{
|
||||||
path: 'setup/regions-environments',
|
path: 'setup/regions-environments',
|
||||||
redirectTo: preserveAppRedirect('/environments/regions'),
|
redirectTo: preserveAppRedirect('/environments/overview'),
|
||||||
pathMatch: 'full',
|
pathMatch: 'full',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -393,10 +393,10 @@ export const routes: Routes = [
|
|||||||
redirectTo: preserveAppRedirect('/releases/promotions/:promotionId'),
|
redirectTo: preserveAppRedirect('/releases/promotions/:promotionId'),
|
||||||
pathMatch: 'full',
|
pathMatch: 'full',
|
||||||
},
|
},
|
||||||
{ path: 'environments', redirectTo: preserveAppRedirect('/environments/regions'), pathMatch: 'full' },
|
{ path: 'environments', redirectTo: preserveAppRedirect('/environments/overview'), pathMatch: 'full' },
|
||||||
{ path: 'regions', redirectTo: preserveAppRedirect('/environments/regions'), pathMatch: 'full' },
|
{ path: 'regions', redirectTo: preserveAppRedirect('/environments/overview'), pathMatch: 'full' },
|
||||||
{ path: 'setup', redirectTo: '/ops/platform-setup', 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/targets-agents', redirectTo: '/environments/targets', pathMatch: 'full' },
|
||||||
{ path: 'setup/workflows', redirectTo: '/ops/platform-setup', pathMatch: 'full' },
|
{ path: 'setup/workflows', redirectTo: '/ops/platform-setup', pathMatch: 'full' },
|
||||||
{ path: 'setup/bundle-templates', redirectTo: '/releases/bundles', pathMatch: 'full' },
|
{ path: 'setup/bundle-templates', redirectTo: '/releases/bundles', pathMatch: 'full' },
|
||||||
|
|||||||
@@ -11,10 +11,9 @@ import { EnvironmentPosturePageComponent } from '../../features/topology/environ
|
|||||||
import { TopologyAgentsPageComponent } from '../../features/topology/topology-agents-page.component';
|
import { TopologyAgentsPageComponent } from '../../features/topology/topology-agents-page.component';
|
||||||
import { TopologyEnvironmentDetailPageComponent } from '../../features/topology/topology-environment-detail-page.component';
|
import { TopologyEnvironmentDetailPageComponent } from '../../features/topology/topology-environment-detail-page.component';
|
||||||
import { TopologyHostsPageComponent } from '../../features/topology/topology-hosts-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 { TopologyOverviewPageComponent } from '../../features/topology/topology-overview-page.component';
|
||||||
import { TopologyPromotionPathsPageComponent } from '../../features/topology/topology-promotion-paths-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 { TopologyShellComponent } from '../../features/topology/topology-shell.component';
|
||||||
import { TopologyTargetsPageComponent } from '../../features/topology/topology-targets-page.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', () => {
|
it('marks topology page links to merge the active query scope', () => {
|
||||||
const cases: Array<{ component: Type<unknown>; routeData?: Record<string, unknown>; expectedMinCount: number }> = [
|
const cases: Array<{ component: Type<unknown>; routeData?: Record<string, unknown>; expectedMinCount: number }> = [
|
||||||
{ component: TopologyOverviewPageComponent, expectedMinCount: 4 },
|
{ component: TopologyOverviewPageComponent, expectedMinCount: 4 },
|
||||||
{ component: TopologyRegionsEnvironmentsPageComponent, routeData: { defaultView: 'flat' }, expectedMinCount: 4 },
|
{ component: TopologyGraphPageComponent, expectedMinCount: 1 },
|
||||||
{ component: TopologyEnvironmentDetailPageComponent, expectedMinCount: 4 },
|
{ component: TopologyEnvironmentDetailPageComponent, expectedMinCount: 4 },
|
||||||
{ component: TopologyTargetsPageComponent, expectedMinCount: 3 },
|
{ component: TopologyTargetsPageComponent, expectedMinCount: 3 },
|
||||||
{ component: TopologyHostsPageComponent, 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', () => {
|
it('renders the topology graph page with router links', () => {
|
||||||
configureTestingModule(TopologyRegionsEnvironmentsPageComponent);
|
configureTestingModule(TopologyGraphPageComponent);
|
||||||
routeData$.next({ defaultView: 'region-first' });
|
|
||||||
|
|
||||||
const fixture = TestBed.createComponent(TopologyRegionsEnvironmentsPageComponent);
|
const links = routerLinksFor(TopologyGraphPageComponent);
|
||||||
fixture.detectChanges();
|
expect(links.length).toBeGreaterThanOrEqual(0);
|
||||||
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;
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('marks promotion inventory links to merge the active query scope', () => {
|
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', () => {
|
// topology-map-page tests removed — component replaced by topology-graph-page
|
||||||
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' }],
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -8,9 +8,8 @@ import { Routes } from '@angular/router';
|
|||||||
export const ENVIRONMENTS_ROUTES: Routes = [
|
export const ENVIRONMENTS_ROUTES: Routes = [
|
||||||
{
|
{
|
||||||
path: '',
|
path: '',
|
||||||
loadComponent: () =>
|
redirectTo: '/environments/overview',
|
||||||
import('./environments-list-page.component').then(m => m.EnvironmentsListPageComponent),
|
pathMatch: 'full' as const,
|
||||||
data: { breadcrumb: 'Environments' },
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: ':envId',
|
path: ':envId',
|
||||||
|
|||||||
@@ -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' },
|
|
||||||
];
|
|
||||||
}
|
|
||||||
@@ -10,12 +10,8 @@ export const PLATFORM_SETUP_ROUTES: Routes = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: 'regions-environments',
|
path: 'regions-environments',
|
||||||
title: 'Setup Regions & Environments',
|
redirectTo: '/environments/overview',
|
||||||
data: { breadcrumb: 'Regions & Environments' },
|
pathMatch: 'full' as const,
|
||||||
loadComponent: () =>
|
|
||||||
import('./platform-setup-regions-environments-page.component').then(
|
|
||||||
(m) => m.PlatformSetupRegionsEnvironmentsPageComponent,
|
|
||||||
),
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: 'promotion-paths',
|
path: 'promotion-paths',
|
||||||
|
|||||||
@@ -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()">×</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);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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">−</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];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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">−</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';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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) ?? '';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@@ -6,8 +6,7 @@ import { filter } from 'rxjs';
|
|||||||
import { StellaPageTabsComponent, StellaPageTab } from '../../shared/components/stella-page-tabs/stella-page-tabs.component';
|
import { StellaPageTabsComponent, StellaPageTab } from '../../shared/components/stella-page-tabs/stella-page-tabs.component';
|
||||||
|
|
||||||
type TabType =
|
type TabType =
|
||||||
| 'regions'
|
| 'overview'
|
||||||
| 'map'
|
|
||||||
| 'targets'
|
| 'targets'
|
||||||
| 'hosts'
|
| 'hosts'
|
||||||
| 'agents'
|
| 'agents'
|
||||||
@@ -19,13 +18,12 @@ type TabType =
|
|||||||
| 'runtime-drift';
|
| 'runtime-drift';
|
||||||
|
|
||||||
const KNOWN_TAB_IDS: readonly string[] = [
|
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',
|
'promotion-graph', 'workflows', 'gate-profiles', 'connectivity', 'runtime-drift',
|
||||||
];
|
];
|
||||||
|
|
||||||
const PAGE_TABS: readonly StellaPageTab[] = [
|
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: 'overview', label: 'Topology', 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: '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: '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: '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' },
|
{ 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);
|
private readonly destroyRef = inject(DestroyRef);
|
||||||
|
|
||||||
readonly pageTabs = PAGE_TABS;
|
readonly pageTabs = PAGE_TABS;
|
||||||
readonly activeTab = signal<string>('regions');
|
readonly activeTab = signal<string>('overview');
|
||||||
|
|
||||||
ngOnInit(): void {
|
ngOnInit(): void {
|
||||||
this.setActiveTabFromUrl(this.router.url);
|
this.setActiveTabFromUrl(this.router.url);
|
||||||
|
|||||||
@@ -196,12 +196,8 @@ export const OPERATIONS_ROUTES: Routes = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: 'environments',
|
path: 'environments',
|
||||||
title: 'Environments Inventory',
|
redirectTo: '/environments/overview',
|
||||||
data: { breadcrumb: 'Environments' },
|
pathMatch: 'full' as const,
|
||||||
loadComponent: () =>
|
|
||||||
import('../features/topology/topology-regions-environments-page.component').then(
|
|
||||||
(m) => m.TopologyRegionsEnvironmentsPageComponent,
|
|
||||||
),
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: 'environments/:environmentId',
|
path: 'environments/:environmentId',
|
||||||
|
|||||||
@@ -247,7 +247,7 @@ export const RELEASES_ROUTES: Routes = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: 'environments',
|
path: 'environments',
|
||||||
redirectTo: '/environments/regions',
|
redirectTo: '/environments/overview',
|
||||||
pathMatch: 'full',
|
pathMatch: 'full',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -10,53 +10,41 @@ export const TOPOLOGY_ROUTES: Routes = [
|
|||||||
children: [
|
children: [
|
||||||
{
|
{
|
||||||
path: '',
|
path: '',
|
||||||
redirectTo: 'regions',
|
redirectTo: 'overview',
|
||||||
pathMatch: 'full',
|
pathMatch: 'full',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: 'overview',
|
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',
|
pathMatch: 'full',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: 'map',
|
path: 'map',
|
||||||
title: 'Environment & Target Map',
|
redirectTo: 'overview',
|
||||||
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',
|
|
||||||
pathMatch: 'full',
|
pathMatch: 'full',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: 'environments',
|
path: 'environments',
|
||||||
title: 'Environments',
|
redirectTo: 'overview',
|
||||||
data: {
|
pathMatch: 'full',
|
||||||
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,
|
|
||||||
),
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: 'environments/:environmentId',
|
path: 'environments/:environmentId',
|
||||||
|
|||||||
Reference in New Issue
Block a user