diff --git a/src/Platform/StellaOps.Platform.WebService/Contracts/TopologyLayoutModels.cs b/src/Platform/StellaOps.Platform.WebService/Contracts/TopologyLayoutModels.cs index c9f63bb8f..9014fa00b 100644 --- a/src/Platform/StellaOps.Platform.WebService/Contracts/TopologyLayoutModels.cs +++ b/src/Platform/StellaOps.Platform.WebService/Contracts/TopologyLayoutModels.cs @@ -24,7 +24,12 @@ public sealed record TopologyPositionedNode( int TargetCount, string? CurrentReleaseId, bool IsFrozen, - int PromotionPathCount); + int PromotionPathCount, + int DeployingCount, + int PendingCount, + int FailedCount, + int TotalDeployments, + string? AgentStatus); public sealed record TopologyRoutedEdge( string Id, diff --git a/src/Platform/StellaOps.Platform.WebService/Services/TopologyLayoutService.cs b/src/Platform/StellaOps.Platform.WebService/Services/TopologyLayoutService.cs index 2fe253f40..952417f53 100644 --- a/src/Platform/StellaOps.Platform.WebService/Services/TopologyLayoutService.cs +++ b/src/Platform/StellaOps.Platform.WebService/Services/TopologyLayoutService.cs @@ -12,7 +12,7 @@ public sealed class TopologyLayoutService { private const double RegionPadding = 30; private const double EnvironmentNodeWidth = 180; - private const double EnvironmentNodeHeight = 72; + private const double EnvironmentNodeHeight = 76; private readonly TopologyReadModelService readModel; private readonly IElkLayoutEngine elkLayout; @@ -50,7 +50,10 @@ public sealed class TopologyLayoutService .ConfigureAwait(false); var regions = regionsTask.Result.Items; - var environments = environmentsTask.Result.Items; + var environments = environmentsTask.Result.Items + .GroupBy(e => e.EnvironmentId, StringComparer.OrdinalIgnoreCase) + .Select(g => g.First()) + .ToList(); var paths = pathsTask.Result.Items; var gates = gatesTask.Result.Items; var hosts = hostsTask.Result.Items; @@ -92,8 +95,14 @@ public sealed class TopologyLayoutService }); } + var seenEnvIds = new HashSet(StringComparer.Ordinal); foreach (var env in environments) { + if (!seenEnvIds.Add($"env-{env.EnvironmentId}")) + { + continue; + } + elkNodes.Add(new ElkNode { Id = $"env-{env.EnvironmentId}", @@ -188,7 +197,12 @@ public sealed class TopologyLayoutService TargetCount: region?.TargetCount ?? 0, CurrentReleaseId: null, IsFrozen: false, - PromotionPathCount: 0); + PromotionPathCount: 0, + DeployingCount: 0, + PendingCount: 0, + FailedCount: 0, + TotalDeployments: 0, + AgentStatus: null); } else { @@ -197,6 +211,15 @@ public sealed class TopologyLayoutService string.Equals(e.EnvironmentId, envId, StringComparison.OrdinalIgnoreCase)); var health = ResolveEnvironmentHealth(envId, targetsByEnv); var latestRelease = ResolveLatestRelease(envId, targetsByEnv); + var envPaths = paths.Where(p => + string.Equals(p.TargetEnvironmentId, envId, StringComparison.OrdinalIgnoreCase) + || string.Equals(p.SourceEnvironmentId, envId, StringComparison.OrdinalIgnoreCase)).ToList(); + var deployingCount = envPaths.Count(p => p.Status is "running" or "deploying"); + var pendingCount = envPaths.Count(p => p.Status is "pending" or "awaiting_approval" or "gates_running"); + var failedCount = envPaths.Count(p => p.Status is "failed"); + var totalDeployments = envPaths.Count; + + var agentStatus = ResolveAgentStatus(envId, targetsByEnv); return new TopologyPositionedNode( Id: node.Id, @@ -215,7 +238,12 @@ public sealed class TopologyLayoutService TargetCount: env?.TargetCount ?? 0, CurrentReleaseId: latestRelease, IsFrozen: false, - PromotionPathCount: env?.PromotionPathCount ?? 0); + PromotionPathCount: env?.PromotionPathCount ?? 0, + DeployingCount: deployingCount, + PendingCount: pendingCount, + FailedCount: failedCount, + TotalDeployments: totalDeployments, + AgentStatus: agentStatus); } }).ToList(); @@ -321,4 +349,27 @@ public sealed class TopologyLayoutService .Select(t => t.ReleaseId) .FirstOrDefault(); } + + private static string? ResolveAgentStatus( + string environmentId, + Dictionary> targetsByEnv) + { + if (!targetsByEnv.TryGetValue(environmentId, out var envTargets) || envTargets.Count == 0) + { + return null; + } + + var agentIds = envTargets + .Where(t => !string.IsNullOrEmpty(t.AgentId)) + .Select(t => t.AgentId) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToList(); + + if (agentIds.Count == 0) + { + return "no_agent"; + } + + return "active"; + } } diff --git a/src/Web/StellaOps.Web/src/app/features/topology/topology-graph.component.ts b/src/Web/StellaOps.Web/src/app/features/topology/topology-graph.component.ts index 00031f1b0..644a0b30c 100644 --- a/src/Web/StellaOps.Web/src/app/features/topology/topology-graph.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/topology/topology-graph.component.ts @@ -159,7 +159,9 @@ import { cy="16" r="5" [class]="'health-dot health-dot--' + (node.healthStatus ?? 'unknown')" - /> + > + Health: {{ node.healthStatus ?? 'unknown' }} + {{ truncate(node.label, 18) }} @@ -172,8 +174,8 @@ import { [class]="'env-type env-type--' + (node.environmentType ?? 'development')" >{{ formatEnvType(node.environmentType) }} - - + + {{ node.hostCount }} host{{ node.hostCount !== 1 ? 's' : '' }} ยท {{ node.targetCount }} target{{ node.targetCount !== 1 ? 's' : '' }} @@ -181,17 +183,54 @@ import { @if (node.currentReleaseId) { {{ truncate(node.currentReleaseId, 12) }} } + + + @if (node.deployingCount > 0) { + + + {{ node.deployingCount }} + {{ node.deployingCount }} deploying now + + } + @if (node.pendingCount > 0) { + + + {{ node.pendingCount }} + {{ node.pendingCount }} pending approval + + } + @if (node.failedCount > 0) { + + + {{ node.failedCount }} + {{ node.failedCount }} failed deployment{{ node.failedCount > 1 ? 's' : '' }} + + } + + + {{ node.totalDeployments }} + {{ node.totalDeployments }} total deployment{{ node.totalDeployments !== 1 ? 's' : '' }} + + @if (node.agentStatus === 'no_agent') { + + + ! + No agent assigned + + } + + @if (node.isFrozen) { FROZEN @@ -480,6 +519,50 @@ import { pointer-events: none; } + /* Deployment status badges */ + .deploy-badges { pointer-events: all; } + + .badge rect { + stroke-width: 0; + } + + .badge text { + font-size: 8px; + font-weight: 600; + text-anchor: middle; + pointer-events: none; + } + + .badge--deploying rect { fill: var(--color-status-info-bg, #dbeafe); } + .badge--deploying text { fill: var(--color-status-info-text, #1d4ed8); } + + .badge--pending rect { fill: var(--color-status-warning-bg); } + .badge--pending text { fill: var(--color-status-warning-text); } + + .badge--failed rect { fill: var(--color-status-error-bg); } + .badge--failed text { fill: var(--color-status-error-text); } + + .badge--total rect { fill: var(--color-surface-tertiary, #e2e8f0); } + .badge--total text { fill: var(--color-text-muted); } + + .badge--warn rect { fill: var(--color-status-warning-bg); } + .badge--warn text { fill: var(--color-status-warning-text); font-size: 9px; } + + .badge--pulse { + animation: badge-pulse 2s ease-in-out infinite; + } + + @keyframes badge-pulse { + 0%, 100% { opacity: 1; } + 50% { opacity: 0.5; } + } + + @media (prefers-reduced-motion: reduce) { + .badge--pulse { + animation: none; + } + } + /* Minimap */ .minimap { position: absolute; @@ -607,6 +690,15 @@ export class TopologyGraphComponent { return label.length * 4.5; } + badgeOffset(node: TopologyPositionedNode, position: number): number { + let offset = 0; + if (position > 0 && node.deployingCount > 0) offset += 32; + if (position > 1 && node.pendingCount > 0) offset += 32; + if (position > 2 && node.failedCount > 0) offset += 32; + if (position > 3) offset += 32; + return offset; + } + truncateEdgeLabel(label: string | undefined | null): string { if (!label) return ''; return label.length > 30 ? label.substring(0, 29) + '\u2026' : label; diff --git a/src/Web/StellaOps.Web/src/app/features/topology/topology-layout.models.ts b/src/Web/StellaOps.Web/src/app/features/topology/topology-layout.models.ts index d24ca7fc6..fd00489c3 100644 --- a/src/Web/StellaOps.Web/src/app/features/topology/topology-layout.models.ts +++ b/src/Web/StellaOps.Web/src/app/features/topology/topology-layout.models.ts @@ -22,6 +22,11 @@ export interface TopologyPositionedNode { currentReleaseId?: string; isFrozen: boolean; promotionPathCount: number; + deployingCount: number; + pendingCount: number; + failedCount: number; + totalDeployments: number; + agentStatus?: string; } export interface TopologyRoutedEdge {