Add deployment status badges to environment nodes in topology graph

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 <title> 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>
This commit is contained in:
master
2026-03-29 00:23:19 +02:00
parent 8f3f33efc5
commit d8e6a2a53d
4 changed files with 163 additions and 10 deletions

View File

@@ -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,

View File

@@ -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";
}
}