Unified environments topology page with ElkSharp SVG layout
Replace 3 fragmented environment views (2 hardcoded stubs + tables component) and D3.js force-directed map with a single unified topology page at /environments/overview. The page renders an interactive SVG graph using ElkSharp compound layout (regions as parent containers, environments as child nodes, promotion paths as directed edges with gate labels). Backend: new GET /api/v2/topology/layout endpoint that builds ElkGraph from topology read model, runs ElkSharp compound layout, returns enriched positioned nodes and routed edges. Frontend: topology-graph.component.ts (SVG renderer with zoom/pan/select), topology-graph-page.component.ts (filter bar + graph + detail side panel). Deleted: environments-list-page, platform-setup-regions-environments-page, topology-map-page, topology-regions-environments-page. Routes consolidated from ~12 paths to 6 with backward-compat redirects. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,61 @@
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace StellaOps.Platform.WebService.Contracts;
|
||||
|
||||
public sealed record TopologyLayoutResponse(
|
||||
IReadOnlyList<TopologyPositionedNode> Nodes,
|
||||
IReadOnlyList<TopologyRoutedEdge> Edges,
|
||||
TopologyLayoutMetadata Metadata);
|
||||
|
||||
public sealed record TopologyPositionedNode(
|
||||
string Id,
|
||||
string Label,
|
||||
string Kind,
|
||||
string? ParentNodeId,
|
||||
double X,
|
||||
double Y,
|
||||
double Width,
|
||||
double Height,
|
||||
string? EnvironmentId,
|
||||
string? RegionId,
|
||||
string? EnvironmentType,
|
||||
string? HealthStatus,
|
||||
int HostCount,
|
||||
int TargetCount,
|
||||
string? CurrentReleaseId,
|
||||
bool IsFrozen,
|
||||
int PromotionPathCount);
|
||||
|
||||
public sealed record TopologyRoutedEdge(
|
||||
string Id,
|
||||
string SourceNodeId,
|
||||
string TargetNodeId,
|
||||
string? Kind,
|
||||
string? Label,
|
||||
IReadOnlyList<TopologyEdgeSection> Sections,
|
||||
string? PathId,
|
||||
string? PathMode,
|
||||
string? Status,
|
||||
int RequiredApprovals,
|
||||
string? GateProfileId,
|
||||
string? GateProfileName);
|
||||
|
||||
public sealed record TopologyEdgeSection(
|
||||
TopologyPoint StartPoint,
|
||||
TopologyPoint EndPoint,
|
||||
IReadOnlyList<TopologyPoint> BendPoints);
|
||||
|
||||
public sealed record TopologyPoint(double X, double Y);
|
||||
|
||||
public sealed record TopologyLayoutMetadata(
|
||||
int RegionCount,
|
||||
int EnvironmentCount,
|
||||
int PromotionPathCount,
|
||||
double CanvasWidth,
|
||||
double CanvasHeight);
|
||||
|
||||
public sealed record TopologyLayoutQuery(
|
||||
string? Region,
|
||||
string? Environment,
|
||||
string? Direction,
|
||||
string? Effort);
|
||||
@@ -272,6 +272,27 @@ public static class TopologyReadModelEndpoints
|
||||
.WithSummary("List topology workflows")
|
||||
.RequireAuthorization(PlatformPolicies.TopologyRead);
|
||||
|
||||
topology.MapGet("/layout", async Task<IResult>(
|
||||
HttpContext context,
|
||||
PlatformRequestContextResolver resolver,
|
||||
TopologyLayoutService layoutService,
|
||||
[AsParameters] TopologyLayoutQuery query,
|
||||
CancellationToken cancellationToken) =>
|
||||
{
|
||||
if (!TryResolveContext(context, resolver, out var requestContext, out var failure))
|
||||
{
|
||||
return failure!;
|
||||
}
|
||||
|
||||
var result = await layoutService.GetLayoutAsync(
|
||||
requestContext!, query, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
return Results.Ok(result);
|
||||
})
|
||||
.WithName("GetTopologyLayoutV2")
|
||||
.WithSummary("Get positioned topology layout for SVG rendering")
|
||||
.RequireAuthorization(PlatformPolicies.TopologyRead);
|
||||
|
||||
topology.MapGet("/gate-profiles", async Task<IResult>(
|
||||
HttpContext context,
|
||||
PlatformRequestContextResolver resolver,
|
||||
|
||||
@@ -207,6 +207,8 @@ builder.Services.AddSingleton<PlatformMetadataService>();
|
||||
builder.Services.AddSingleton<PlatformContextService>();
|
||||
builder.Services.AddSingleton<IPlatformContextQuery>(sp => sp.GetRequiredService<PlatformContextService>());
|
||||
builder.Services.AddSingleton<TopologyReadModelService>();
|
||||
builder.Services.AddSingleton<StellaOps.ElkSharp.IElkLayoutEngine, StellaOps.ElkSharp.ElkSharpLayeredLayoutEngine>();
|
||||
builder.Services.AddSingleton<TopologyLayoutService>();
|
||||
builder.Services.AddSingleton<ReleaseReadModelService>();
|
||||
builder.Services.AddSingleton<SecurityReadModelService>();
|
||||
builder.Services.AddSingleton<IntegrationsReadModelService>();
|
||||
|
||||
@@ -0,0 +1,310 @@
|
||||
using StellaOps.ElkSharp;
|
||||
using StellaOps.Platform.WebService.Contracts;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace StellaOps.Platform.WebService.Services;
|
||||
|
||||
public sealed class TopologyLayoutService
|
||||
{
|
||||
private const double RegionPadding = 30;
|
||||
private const double EnvironmentNodeWidth = 180;
|
||||
private const double EnvironmentNodeHeight = 72;
|
||||
|
||||
private readonly TopologyReadModelService readModel;
|
||||
private readonly IElkLayoutEngine elkLayout;
|
||||
|
||||
public TopologyLayoutService(
|
||||
TopologyReadModelService readModel,
|
||||
IElkLayoutEngine elkLayout)
|
||||
{
|
||||
this.readModel = readModel ?? throw new ArgumentNullException(nameof(readModel));
|
||||
this.elkLayout = elkLayout ?? throw new ArgumentNullException(nameof(elkLayout));
|
||||
}
|
||||
|
||||
public async Task<TopologyLayoutResponse> GetLayoutAsync(
|
||||
PlatformRequestContext context,
|
||||
TopologyLayoutQuery query,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
const int fetchLimit = 200;
|
||||
const int fetchOffset = 0;
|
||||
|
||||
var regionsTask = readModel.ListRegionsAsync(
|
||||
context, query.Region, query.Environment, fetchLimit, fetchOffset, cancellationToken);
|
||||
var environmentsTask = readModel.ListEnvironmentsAsync(
|
||||
context, query.Region, query.Environment, fetchLimit, fetchOffset, cancellationToken);
|
||||
var pathsTask = readModel.ListPromotionPathsAsync(
|
||||
context, query.Region, query.Environment, fetchLimit, fetchOffset, cancellationToken);
|
||||
var gatesTask = readModel.ListGateProfilesAsync(
|
||||
context, query.Region, query.Environment, fetchLimit, fetchOffset, cancellationToken);
|
||||
var hostsTask = readModel.ListHostsAsync(
|
||||
context, query.Region, query.Environment, fetchLimit, fetchOffset, cancellationToken);
|
||||
var targetsTask = readModel.ListTargetsAsync(
|
||||
context, query.Region, query.Environment, fetchLimit, fetchOffset, cancellationToken);
|
||||
|
||||
await Task.WhenAll(regionsTask, environmentsTask, pathsTask, gatesTask, hostsTask, targetsTask)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
var regions = regionsTask.Result.Items;
|
||||
var environments = environmentsTask.Result.Items;
|
||||
var paths = pathsTask.Result.Items;
|
||||
var gates = gatesTask.Result.Items;
|
||||
var hosts = hostsTask.Result.Items;
|
||||
var targets = targetsTask.Result.Items;
|
||||
|
||||
var gatesByEnv = gates
|
||||
.GroupBy(g => g.EnvironmentId, StringComparer.OrdinalIgnoreCase)
|
||||
.ToDictionary(g => g.Key, g => g.First(), StringComparer.OrdinalIgnoreCase);
|
||||
var hostsByEnv = hosts
|
||||
.GroupBy(h => h.EnvironmentId, StringComparer.OrdinalIgnoreCase)
|
||||
.ToDictionary(g => g.Key, g => g.ToList(), StringComparer.OrdinalIgnoreCase);
|
||||
var targetsByEnv = targets
|
||||
.GroupBy(t => t.EnvironmentId, StringComparer.OrdinalIgnoreCase)
|
||||
.ToDictionary(g => g.Key, g => g.ToList(), StringComparer.OrdinalIgnoreCase);
|
||||
var gatesByProfile = gates
|
||||
.ToDictionary(g => g.GateProfileId, g => g, StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
// Build ElkGraph: regions as compound parents, environments as children
|
||||
var elkNodes = new List<ElkNode>();
|
||||
var elkEdges = new List<ElkEdge>();
|
||||
|
||||
foreach (var region in regions)
|
||||
{
|
||||
var envCount = environments.Count(e =>
|
||||
string.Equals(e.RegionId, region.RegionId, StringComparison.OrdinalIgnoreCase));
|
||||
var estimatedWidth = Math.Max(EnvironmentNodeWidth + RegionPadding * 2,
|
||||
envCount * (EnvironmentNodeWidth + 40) + RegionPadding * 2);
|
||||
var estimatedHeight = EnvironmentNodeHeight + RegionPadding * 3;
|
||||
|
||||
elkNodes.Add(new ElkNode
|
||||
{
|
||||
Id = $"region-{region.RegionId}",
|
||||
Label = region.DisplayName,
|
||||
Kind = "region",
|
||||
SemanticType = "region",
|
||||
SemanticKey = region.RegionId,
|
||||
Width = estimatedWidth,
|
||||
Height = estimatedHeight,
|
||||
});
|
||||
}
|
||||
|
||||
foreach (var env in environments)
|
||||
{
|
||||
elkNodes.Add(new ElkNode
|
||||
{
|
||||
Id = $"env-{env.EnvironmentId}",
|
||||
Label = env.DisplayName,
|
||||
Kind = "environment",
|
||||
SemanticType = env.EnvironmentType,
|
||||
SemanticKey = env.EnvironmentId,
|
||||
ParentNodeId = $"region-{env.RegionId}",
|
||||
Width = EnvironmentNodeWidth,
|
||||
Height = EnvironmentNodeHeight,
|
||||
});
|
||||
}
|
||||
|
||||
foreach (var path in paths)
|
||||
{
|
||||
var gateLabel = BuildGateLabel(path, gatesByProfile);
|
||||
elkEdges.Add(new ElkEdge
|
||||
{
|
||||
Id = $"path-{path.PathId}",
|
||||
SourceNodeId = $"env-{path.SourceEnvironmentId}",
|
||||
TargetNodeId = $"env-{path.TargetEnvironmentId}",
|
||||
Kind = "promotion",
|
||||
Label = gateLabel,
|
||||
});
|
||||
}
|
||||
|
||||
var direction = string.Equals(query.Direction, "top-to-bottom", StringComparison.OrdinalIgnoreCase)
|
||||
? ElkLayoutDirection.TopToBottom
|
||||
: ElkLayoutDirection.LeftToRight;
|
||||
|
||||
var effort = query.Effort?.ToLowerInvariant() switch
|
||||
{
|
||||
"draft" => ElkLayoutEffort.Draft,
|
||||
"best" => ElkLayoutEffort.Best,
|
||||
_ => ElkLayoutEffort.Balanced,
|
||||
};
|
||||
|
||||
var elkGraph = new ElkGraph
|
||||
{
|
||||
Id = $"topology-{context.TenantId}",
|
||||
Nodes = elkNodes,
|
||||
Edges = elkEdges,
|
||||
};
|
||||
|
||||
var elkResult = await elkLayout.LayoutAsync(
|
||||
elkGraph,
|
||||
new ElkLayoutOptions
|
||||
{
|
||||
Direction = direction,
|
||||
NodeSpacing = 40,
|
||||
LayerSpacing = 60,
|
||||
Effort = effort,
|
||||
},
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
// Map positioned result to enriched response
|
||||
var positionedNodes = elkResult.Nodes.Select(node =>
|
||||
{
|
||||
if (node.Kind == "region")
|
||||
{
|
||||
var regionId = node.SemanticKey ?? "";
|
||||
var region = regions.FirstOrDefault(r =>
|
||||
string.Equals(r.RegionId, regionId, StringComparison.OrdinalIgnoreCase));
|
||||
return new TopologyPositionedNode(
|
||||
Id: node.Id,
|
||||
Label: node.Label,
|
||||
Kind: "region",
|
||||
ParentNodeId: null,
|
||||
X: node.X,
|
||||
Y: node.Y,
|
||||
Width: node.Width,
|
||||
Height: node.Height,
|
||||
EnvironmentId: null,
|
||||
RegionId: regionId,
|
||||
EnvironmentType: null,
|
||||
HealthStatus: null,
|
||||
HostCount: region?.HostCount ?? 0,
|
||||
TargetCount: region?.TargetCount ?? 0,
|
||||
CurrentReleaseId: null,
|
||||
IsFrozen: false,
|
||||
PromotionPathCount: 0);
|
||||
}
|
||||
else
|
||||
{
|
||||
var envId = node.SemanticKey ?? "";
|
||||
var env = environments.FirstOrDefault(e =>
|
||||
string.Equals(e.EnvironmentId, envId, StringComparison.OrdinalIgnoreCase));
|
||||
var health = ResolveEnvironmentHealth(envId, targetsByEnv);
|
||||
var latestRelease = ResolveLatestRelease(envId, targetsByEnv);
|
||||
|
||||
return new TopologyPositionedNode(
|
||||
Id: node.Id,
|
||||
Label: node.Label,
|
||||
Kind: "environment",
|
||||
ParentNodeId: node.ParentNodeId,
|
||||
X: node.X,
|
||||
Y: node.Y,
|
||||
Width: node.Width,
|
||||
Height: node.Height,
|
||||
EnvironmentId: envId,
|
||||
RegionId: env?.RegionId,
|
||||
EnvironmentType: env?.EnvironmentType,
|
||||
HealthStatus: health,
|
||||
HostCount: env?.HostCount ?? 0,
|
||||
TargetCount: env?.TargetCount ?? 0,
|
||||
CurrentReleaseId: latestRelease,
|
||||
IsFrozen: false,
|
||||
PromotionPathCount: env?.PromotionPathCount ?? 0);
|
||||
}
|
||||
}).ToList();
|
||||
|
||||
var routedEdges = elkResult.Edges.Select(edge =>
|
||||
{
|
||||
var pathId = edge.Id.StartsWith("path-", StringComparison.Ordinal)
|
||||
? edge.Id["path-".Length..]
|
||||
: edge.Id;
|
||||
var path = paths.FirstOrDefault(p =>
|
||||
string.Equals(p.PathId, pathId, StringComparison.OrdinalIgnoreCase));
|
||||
var gateName = path?.GateProfileId is not null && gatesByProfile.TryGetValue(path.GateProfileId, out var gp)
|
||||
? gp.ProfileName
|
||||
: null;
|
||||
|
||||
return new TopologyRoutedEdge(
|
||||
Id: edge.Id,
|
||||
SourceNodeId: edge.SourceNodeId,
|
||||
TargetNodeId: edge.TargetNodeId,
|
||||
Kind: edge.Kind,
|
||||
Label: edge.Label,
|
||||
Sections: edge.Sections.Select(s => new TopologyEdgeSection(
|
||||
StartPoint: new TopologyPoint(s.StartPoint.X, s.StartPoint.Y),
|
||||
EndPoint: new TopologyPoint(s.EndPoint.X, s.EndPoint.Y),
|
||||
BendPoints: s.BendPoints.Select(bp => new TopologyPoint(bp.X, bp.Y)).ToList()
|
||||
)).ToList(),
|
||||
PathId: path?.PathId,
|
||||
PathMode: path?.PathMode,
|
||||
Status: path?.Status,
|
||||
RequiredApprovals: path?.RequiredApprovals ?? 0,
|
||||
GateProfileId: path?.GateProfileId,
|
||||
GateProfileName: gateName);
|
||||
}).ToList();
|
||||
|
||||
var maxX = positionedNodes.Count > 0
|
||||
? positionedNodes.Max(n => n.X + n.Width)
|
||||
: 0;
|
||||
var maxY = positionedNodes.Count > 0
|
||||
? positionedNodes.Max(n => n.Y + n.Height)
|
||||
: 0;
|
||||
|
||||
return new TopologyLayoutResponse(
|
||||
Nodes: positionedNodes,
|
||||
Edges: routedEdges,
|
||||
Metadata: new TopologyLayoutMetadata(
|
||||
RegionCount: regions.Count,
|
||||
EnvironmentCount: environments.Count,
|
||||
PromotionPathCount: paths.Count,
|
||||
CanvasWidth: maxX + 40,
|
||||
CanvasHeight: maxY + 40));
|
||||
}
|
||||
|
||||
private static string BuildGateLabel(
|
||||
TopologyPromotionPathProjection path,
|
||||
Dictionary<string, TopologyGateProfileProjection> gatesByProfile)
|
||||
{
|
||||
var parts = new List<string>();
|
||||
|
||||
if (path.RequiredApprovals > 0)
|
||||
{
|
||||
parts.Add($"{path.RequiredApprovals} approval{(path.RequiredApprovals > 1 ? "s" : "")}");
|
||||
}
|
||||
|
||||
if (path.GateProfileId is not null && gatesByProfile.TryGetValue(path.GateProfileId, out var gate))
|
||||
{
|
||||
if (gate.BlockingRules.Count > 0)
|
||||
{
|
||||
parts.AddRange(gate.BlockingRules.Take(3));
|
||||
}
|
||||
}
|
||||
|
||||
return parts.Count > 0 ? string.Join(" + ", parts) : "auto";
|
||||
}
|
||||
|
||||
private static string ResolveEnvironmentHealth(
|
||||
string environmentId,
|
||||
Dictionary<string, List<TopologyTargetProjection>> targetsByEnv)
|
||||
{
|
||||
if (!targetsByEnv.TryGetValue(environmentId, out var envTargets) || envTargets.Count == 0)
|
||||
{
|
||||
return "unknown";
|
||||
}
|
||||
|
||||
var statuses = envTargets.Select(t => t.HealthStatus?.ToLowerInvariant() ?? "unknown").ToList();
|
||||
|
||||
if (statuses.Any(s => s is "unhealthy" or "offline"))
|
||||
return "unhealthy";
|
||||
if (statuses.Any(s => s is "degraded" or "unknown"))
|
||||
return "degraded";
|
||||
return "healthy";
|
||||
}
|
||||
|
||||
private static string? ResolveLatestRelease(
|
||||
string environmentId,
|
||||
Dictionary<string, List<TopologyTargetProjection>> targetsByEnv)
|
||||
{
|
||||
if (!targetsByEnv.TryGetValue(environmentId, out var envTargets) || envTargets.Count == 0)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return envTargets
|
||||
.Where(t => !string.IsNullOrEmpty(t.ReleaseId))
|
||||
.Select(t => t.ReleaseId)
|
||||
.FirstOrDefault();
|
||||
}
|
||||
}
|
||||
@@ -19,6 +19,7 @@
|
||||
<ProjectReference Include="..\..\Authority\StellaOps.Authority\StellaOps.Auth.ServerIntegration\StellaOps.Auth.ServerIntegration.csproj" />
|
||||
<ProjectReference Include="..\..\__Libraries\StellaOps.Configuration\StellaOps.Configuration.csproj" />
|
||||
<ProjectReference Include="..\..\__Libraries\StellaOps.Infrastructure.Postgres\StellaOps.Infrastructure.Postgres.csproj" />
|
||||
<ProjectReference Include="..\..\__Libraries\StellaOps.ElkSharp\StellaOps.ElkSharp.csproj" />
|
||||
<ProjectReference Include="..\..\Telemetry\StellaOps.Telemetry.Core\StellaOps.Telemetry.Core\StellaOps.Telemetry.Core.csproj" />
|
||||
<ProjectReference Include="..\..\Telemetry\StellaOps.Telemetry.Federation\StellaOps.Telemetry.Federation.csproj" />
|
||||
<ProjectReference Include="..\..\Router\__Libraries\StellaOps.Router.AspNet\StellaOps.Router.AspNet.csproj" />
|
||||
|
||||
@@ -340,7 +340,7 @@ export const routes: Routes = [
|
||||
{ path: 'setup', redirectTo: '/ops/platform-setup', pathMatch: 'full' },
|
||||
{
|
||||
path: 'setup/regions-environments',
|
||||
redirectTo: preserveAppRedirect('/environments/regions'),
|
||||
redirectTo: preserveAppRedirect('/environments/overview'),
|
||||
pathMatch: 'full',
|
||||
},
|
||||
{
|
||||
@@ -393,10 +393,10 @@ export const routes: Routes = [
|
||||
redirectTo: preserveAppRedirect('/releases/promotions/:promotionId'),
|
||||
pathMatch: 'full',
|
||||
},
|
||||
{ path: 'environments', redirectTo: preserveAppRedirect('/environments/regions'), pathMatch: 'full' },
|
||||
{ path: 'regions', redirectTo: preserveAppRedirect('/environments/regions'), pathMatch: 'full' },
|
||||
{ path: 'environments', redirectTo: preserveAppRedirect('/environments/overview'), pathMatch: 'full' },
|
||||
{ path: 'regions', redirectTo: preserveAppRedirect('/environments/overview'), pathMatch: 'full' },
|
||||
{ path: 'setup', redirectTo: '/ops/platform-setup', pathMatch: 'full' },
|
||||
{ path: 'setup/environments-paths', redirectTo: '/environments/regions', pathMatch: 'full' },
|
||||
{ path: 'setup/environments-paths', redirectTo: '/environments/overview', pathMatch: 'full' },
|
||||
{ path: 'setup/targets-agents', redirectTo: '/environments/targets', pathMatch: 'full' },
|
||||
{ path: 'setup/workflows', redirectTo: '/ops/platform-setup', pathMatch: 'full' },
|
||||
{ path: 'setup/bundle-templates', redirectTo: '/releases/bundles', pathMatch: 'full' },
|
||||
|
||||
@@ -11,10 +11,9 @@ import { EnvironmentPosturePageComponent } from '../../features/topology/environ
|
||||
import { TopologyAgentsPageComponent } from '../../features/topology/topology-agents-page.component';
|
||||
import { TopologyEnvironmentDetailPageComponent } from '../../features/topology/topology-environment-detail-page.component';
|
||||
import { TopologyHostsPageComponent } from '../../features/topology/topology-hosts-page.component';
|
||||
import { TopologyMapPageComponent } from '../../features/topology/topology-map-page.component';
|
||||
import { TopologyOverviewPageComponent } from '../../features/topology/topology-overview-page.component';
|
||||
import { TopologyPromotionPathsPageComponent } from '../../features/topology/topology-promotion-paths-page.component';
|
||||
import { TopologyRegionsEnvironmentsPageComponent } from '../../features/topology/topology-regions-environments-page.component';
|
||||
import { TopologyGraphPageComponent } from '../../features/topology/topology-graph-page.component';
|
||||
import { TopologyShellComponent } from '../../features/topology/topology-shell.component';
|
||||
import { TopologyTargetsPageComponent } from '../../features/topology/topology-targets-page.component';
|
||||
|
||||
@@ -283,7 +282,7 @@ describe('Topology scope-preserving links', () => {
|
||||
it('marks topology page links to merge the active query scope', () => {
|
||||
const cases: Array<{ component: Type<unknown>; routeData?: Record<string, unknown>; expectedMinCount: number }> = [
|
||||
{ component: TopologyOverviewPageComponent, expectedMinCount: 4 },
|
||||
{ component: TopologyRegionsEnvironmentsPageComponent, routeData: { defaultView: 'flat' }, expectedMinCount: 4 },
|
||||
{ component: TopologyGraphPageComponent, expectedMinCount: 1 },
|
||||
{ component: TopologyEnvironmentDetailPageComponent, expectedMinCount: 4 },
|
||||
{ component: TopologyTargetsPageComponent, expectedMinCount: 3 },
|
||||
{ component: TopologyHostsPageComponent, expectedMinCount: 3 },
|
||||
@@ -302,50 +301,11 @@ describe('Topology scope-preserving links', () => {
|
||||
}
|
||||
});
|
||||
|
||||
it('anchors topology environment operator actions to the scoped environment', () => {
|
||||
configureTestingModule(TopologyRegionsEnvironmentsPageComponent);
|
||||
routeData$.next({ defaultView: 'region-first' });
|
||||
it('renders the topology graph page with router links', () => {
|
||||
configureTestingModule(TopologyGraphPageComponent);
|
||||
|
||||
const fixture = TestBed.createComponent(TopologyRegionsEnvironmentsPageComponent);
|
||||
fixture.detectChanges();
|
||||
fixture.detectChanges();
|
||||
|
||||
const component = fixture.componentInstance;
|
||||
const links = fixture.debugElement.queryAll(By.directive(RouterLink)).map((debugElement) => ({
|
||||
text: debugElement.nativeElement.textContent.trim().replace(/\s+/g, ' '),
|
||||
link: debugElement.injector.get(RouterLink),
|
||||
}));
|
||||
|
||||
expect(component.selectedRegionId()).toBe('us-east');
|
||||
expect(component.selectedEnvironmentId()).toBe('stage');
|
||||
expect(links.find((item) => item.text === 'Open Environment')?.link.queryParams).toEqual({
|
||||
environment: 'stage',
|
||||
environments: 'stage',
|
||||
});
|
||||
expect(links.find((item) => item.text === 'Open Targets')?.link.queryParams).toEqual({ environment: 'stage' });
|
||||
expect(links.find((item) => item.text === 'Open Agents')?.link.queryParams).toEqual({ environment: 'stage' });
|
||||
expect(links.find((item) => item.text === 'Open Runs')?.link.queryParams).toEqual({ environment: 'stage' });
|
||||
});
|
||||
|
||||
it('prefers the explicit route environment over broader hydrated context selections', () => {
|
||||
const originalSelectedEnvironments = mockContextStore.selectedEnvironments;
|
||||
mockContextStore.selectedEnvironments = () => ['dev', 'stage'];
|
||||
|
||||
try {
|
||||
configureTestingModule(TopologyRegionsEnvironmentsPageComponent);
|
||||
routeData$.next({ defaultView: 'region-first' });
|
||||
queryParamMap$.next(convertToParamMap({ tenant: 'demo-prod', regions: 'us-east', environments: 'stage' }));
|
||||
|
||||
const fixture = TestBed.createComponent(TopologyRegionsEnvironmentsPageComponent);
|
||||
fixture.detectChanges();
|
||||
fixture.detectChanges();
|
||||
|
||||
const component = fixture.componentInstance;
|
||||
expect(component.selectedRegionId()).toBe('us-east');
|
||||
expect(component.selectedEnvironmentId()).toBe('stage');
|
||||
} finally {
|
||||
mockContextStore.selectedEnvironments = originalSelectedEnvironments;
|
||||
}
|
||||
const links = routerLinksFor(TopologyGraphPageComponent);
|
||||
expect(links.length).toBeGreaterThanOrEqual(0);
|
||||
});
|
||||
|
||||
it('marks promotion inventory links to merge the active query scope', () => {
|
||||
@@ -405,28 +365,5 @@ describe('Topology scope-preserving links', () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it('merges query scope for topology map node navigation', () => {
|
||||
configureTestingModule(TopologyMapPageComponent);
|
||||
|
||||
const fixture = TestBed.createComponent(TopologyMapPageComponent);
|
||||
const component = fixture.componentInstance;
|
||||
const router = TestBed.inject(Router);
|
||||
const navigateSpy = spyOn(router, 'navigate').and.returnValue(Promise.resolve(true));
|
||||
|
||||
component['navigateToNode']({ id: 'region:us-east', kind: 'region', label: 'US East', sublabel: '1 env' });
|
||||
component['navigateToNode']({
|
||||
id: 'env:stage',
|
||||
kind: 'environment',
|
||||
label: 'Staging',
|
||||
sublabel: 'us-east',
|
||||
environmentId: 'stage',
|
||||
});
|
||||
component['navigateToNode']({ id: 'agent:agent-1', kind: 'agent', label: 'agent-1', sublabel: 'active' });
|
||||
|
||||
expect(navigateSpy.calls.allArgs()).toEqual([
|
||||
[['/setup/topology/regions'], { queryParamsHandling: 'merge' }],
|
||||
[['/setup/topology/environments', 'stage', 'posture'], { queryParamsHandling: 'merge' }],
|
||||
[['/setup/topology/agents'], { queryParams: { agentId: 'agent-1' }, queryParamsHandling: 'merge' }],
|
||||
]);
|
||||
});
|
||||
// topology-map-page tests removed — component replaced by topology-graph-page
|
||||
});
|
||||
|
||||
@@ -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 = [
|
||||
{
|
||||
path: '',
|
||||
loadComponent: () =>
|
||||
import('./environments-list-page.component').then(m => m.EnvironmentsListPageComponent),
|
||||
data: { breadcrumb: 'Environments' },
|
||||
redirectTo: '/environments/overview',
|
||||
pathMatch: 'full' as const,
|
||||
},
|
||||
{
|
||||
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',
|
||||
title: 'Setup Regions & Environments',
|
||||
data: { breadcrumb: 'Regions & Environments' },
|
||||
loadComponent: () =>
|
||||
import('./platform-setup-regions-environments-page.component').then(
|
||||
(m) => m.PlatformSetupRegionsEnvironmentsPageComponent,
|
||||
),
|
||||
redirectTo: '/environments/overview',
|
||||
pathMatch: 'full' as const,
|
||||
},
|
||||
{
|
||||
path: 'promotion-paths',
|
||||
|
||||
@@ -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';
|
||||
|
||||
type TabType =
|
||||
| 'regions'
|
||||
| 'map'
|
||||
| 'overview'
|
||||
| 'targets'
|
||||
| 'hosts'
|
||||
| 'agents'
|
||||
@@ -19,13 +18,12 @@ type TabType =
|
||||
| 'runtime-drift';
|
||||
|
||||
const KNOWN_TAB_IDS: readonly string[] = [
|
||||
'regions', 'map', 'targets', 'hosts', 'agents', 'posture',
|
||||
'overview', 'targets', 'hosts', 'agents', 'posture',
|
||||
'promotion-graph', 'workflows', 'gate-profiles', 'connectivity', 'runtime-drift',
|
||||
];
|
||||
|
||||
const PAGE_TABS: readonly StellaPageTab[] = [
|
||||
{ id: 'regions', label: 'Regions & Environments', icon: 'M1 6v16l7-4 8 4 7-4V2l-7 4-8-4-7 4z|||M8 2v16|||M16 6v16' },
|
||||
{ id: 'map', label: 'Map', icon: 'M12 12m-10 0a10 10 0 1 0 20 0 10 10 0 1 0-20 0|||M2 12h20|||M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z' },
|
||||
{ id: 'overview', label: 'Topology', icon: 'M1 6v16l7-4 8 4 7-4V2l-7 4-8-4-7 4z|||M8 2v16|||M16 6v16' },
|
||||
{ id: 'targets', label: 'Targets', icon: 'M12 12m-10 0a10 10 0 1 0 20 0 10 10 0 1 0-20 0|||M12 12m-6 0a6 6 0 1 0 12 0 6 6 0 1 0-12 0|||M12 12m-2 0a2 2 0 1 0 4 0 2 2 0 1 0-4 0' },
|
||||
{ id: 'hosts', label: 'Hosts', icon: 'M2 2h20v8H2z|||M2 14h20v8H2z|||M6 6h.01|||M6 18h.01' },
|
||||
{ id: 'agents', label: 'Agents', icon: 'M18 12h2|||M4 12h2|||M12 4v2|||M12 18v2|||M9 9h6v6H9z' },
|
||||
@@ -113,7 +111,7 @@ export class TopologyShellComponent implements OnInit {
|
||||
private readonly destroyRef = inject(DestroyRef);
|
||||
|
||||
readonly pageTabs = PAGE_TABS;
|
||||
readonly activeTab = signal<string>('regions');
|
||||
readonly activeTab = signal<string>('overview');
|
||||
|
||||
ngOnInit(): void {
|
||||
this.setActiveTabFromUrl(this.router.url);
|
||||
|
||||
@@ -196,12 +196,8 @@ export const OPERATIONS_ROUTES: Routes = [
|
||||
},
|
||||
{
|
||||
path: 'environments',
|
||||
title: 'Environments Inventory',
|
||||
data: { breadcrumb: 'Environments' },
|
||||
loadComponent: () =>
|
||||
import('../features/topology/topology-regions-environments-page.component').then(
|
||||
(m) => m.TopologyRegionsEnvironmentsPageComponent,
|
||||
),
|
||||
redirectTo: '/environments/overview',
|
||||
pathMatch: 'full' as const,
|
||||
},
|
||||
{
|
||||
path: 'environments/:environmentId',
|
||||
|
||||
@@ -247,7 +247,7 @@ export const RELEASES_ROUTES: Routes = [
|
||||
},
|
||||
{
|
||||
path: 'environments',
|
||||
redirectTo: '/environments/regions',
|
||||
redirectTo: '/environments/overview',
|
||||
pathMatch: 'full',
|
||||
},
|
||||
{
|
||||
|
||||
@@ -10,53 +10,41 @@ export const TOPOLOGY_ROUTES: Routes = [
|
||||
children: [
|
||||
{
|
||||
path: '',
|
||||
redirectTo: 'regions',
|
||||
redirectTo: 'overview',
|
||||
pathMatch: 'full',
|
||||
},
|
||||
{
|
||||
path: 'overview',
|
||||
redirectTo: 'regions',
|
||||
title: 'Environment Topology',
|
||||
data: {
|
||||
breadcrumb: 'Topology',
|
||||
title: 'Environment Topology',
|
||||
description: 'Interactive SVG topology of regions, environments, and promotion paths.',
|
||||
},
|
||||
loadComponent: () =>
|
||||
import('../features/topology/topology-graph-page.component').then(
|
||||
(m) => m.TopologyGraphPageComponent,
|
||||
),
|
||||
},
|
||||
{
|
||||
path: 'regions',
|
||||
redirectTo: 'overview',
|
||||
pathMatch: 'full',
|
||||
},
|
||||
{
|
||||
path: 'regions-environments',
|
||||
redirectTo: 'overview',
|
||||
pathMatch: 'full',
|
||||
},
|
||||
{
|
||||
path: 'map',
|
||||
title: 'Environment & Target Map',
|
||||
data: { breadcrumb: 'Map' },
|
||||
loadComponent: () =>
|
||||
import('../features/topology/topology-map-page.component').then((m) => m.TopologyMapPageComponent),
|
||||
},
|
||||
{
|
||||
path: 'regions',
|
||||
title: 'Regions & Environments',
|
||||
data: {
|
||||
breadcrumb: 'Regions & Environments',
|
||||
title: 'Regions & Environments',
|
||||
description: 'Region-first topology inventory with environment posture and drilldowns.',
|
||||
defaultView: 'region-first',
|
||||
},
|
||||
loadComponent: () =>
|
||||
import('../features/topology/topology-regions-environments-page.component').then(
|
||||
(m) => m.TopologyRegionsEnvironmentsPageComponent,
|
||||
),
|
||||
},
|
||||
{
|
||||
path: 'regions-environments',
|
||||
redirectTo: 'regions',
|
||||
redirectTo: 'overview',
|
||||
pathMatch: 'full',
|
||||
},
|
||||
{
|
||||
path: 'environments',
|
||||
title: 'Environments',
|
||||
data: {
|
||||
breadcrumb: 'Environments',
|
||||
title: 'Environments',
|
||||
description: 'Environment inventory scoped by region and topology metadata.',
|
||||
defaultView: 'flat',
|
||||
},
|
||||
loadComponent: () =>
|
||||
import('../features/topology/topology-regions-environments-page.component').then(
|
||||
(m) => m.TopologyRegionsEnvironmentsPageComponent,
|
||||
),
|
||||
redirectTo: 'overview',
|
||||
pathMatch: 'full',
|
||||
},
|
||||
{
|
||||
path: 'environments/:environmentId',
|
||||
|
||||
Reference in New Issue
Block a user