From d8e6a2a53dc882cba0dc752889c877afae3a000d Mon Sep 17 00:00:00 2001 From: master <> Date: Sun, 29 Mar 2026 00:23:19 +0200 Subject: [PATCH] Add deployment status badges to environment nodes in topology graph MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Each environment node now shows pill badges at the bottom: - Deploying (blue, pulsing) — count of active deployments - Pending (amber, pulsing) — count awaiting approval - Failed (red) — count of failed deployments - Total (muted) — historical deployment count - Agent warning (!) — when no agent is assigned Badges pulse with CSS animation (respects prefers-reduced-motion). Native SVG elements provide hover tooltips. Backend enriches TopologyPositionedNode with deployment counts from promotion path statuses and agent assignment data. Also fixes duplicate environment IDs causing layout API 400 errors. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> --- .../Contracts/TopologyLayoutModels.cs | 7 +- .../Services/TopologyLayoutService.cs | 59 +++++++++- .../topology/topology-graph.component.ts | 102 +++++++++++++++++- .../topology/topology-layout.models.ts | 5 + 4 files changed, 163 insertions(+), 10 deletions(-) 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<string>(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<string, List<TopologyTargetProjection>> 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')" - /> + > + <title>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 {