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

View File

@@ -159,7 +159,9 @@ import {
cy="16"
r="5"
[class]="'health-dot health-dot--' + (node.healthStatus ?? 'unknown')"
/>
>
<title>Health: {{ node.healthStatus ?? 'unknown' }}</title>
</circle>
<!-- Environment name -->
<text x="26" y="20" class="env-name">{{ truncate(node.label, 18) }}</text>
@@ -172,8 +174,8 @@ import {
[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">
<!-- Middle row: host count + release -->
<text x="14" y="42" class="env-meta">
{{ node.hostCount }} host{{ node.hostCount !== 1 ? 's' : '' }}
· {{ node.targetCount }} target{{ node.targetCount !== 1 ? 's' : '' }}
</text>
@@ -181,17 +183,54 @@ import {
@if (node.currentReleaseId) {
<text
[attr.x]="node.width - 8"
y="48"
y="42"
text-anchor="end"
class="env-release"
>{{ truncate(node.currentReleaseId, 12) }}</text>
}
<!-- Deployment status badges (bottom row) -->
<g class="deploy-badges" transform="translate(8, 54)">
@if (node.deployingCount > 0) {
<g class="badge badge--deploying badge--pulse">
<rect [attr.x]="0" y="0" [attr.width]="28" height="14" rx="7" />
<text x="14" y="10">{{ node.deployingCount }}</text>
<title>{{ node.deployingCount }} deploying now</title>
</g>
}
@if (node.pendingCount > 0) {
<g class="badge badge--pending badge--pulse" [attr.transform]="'translate(' + (node.deployingCount > 0 ? 32 : 0) + ',0)'">
<rect x="0" y="0" [attr.width]="28" height="14" rx="7" />
<text x="14" y="10">{{ node.pendingCount }}</text>
<title>{{ node.pendingCount }} pending approval</title>
</g>
}
@if (node.failedCount > 0) {
<g class="badge badge--failed" [attr.transform]="'translate(' + badgeOffset(node, 2) + ',0)'">
<rect x="0" y="0" [attr.width]="28" height="14" rx="7" />
<text x="14" y="10">{{ node.failedCount }}</text>
<title>{{ node.failedCount }} failed deployment{{ node.failedCount > 1 ? 's' : '' }}</title>
</g>
}
<g class="badge badge--total" [attr.transform]="'translate(' + badgeOffset(node, 3) + ',0)'">
<rect x="0" y="0" [attr.width]="28" height="14" rx="7" />
<text x="14" y="10">{{ node.totalDeployments }}</text>
<title>{{ node.totalDeployments }} total deployment{{ node.totalDeployments !== 1 ? 's' : '' }}</title>
</g>
@if (node.agentStatus === 'no_agent') {
<g class="badge badge--warn" [attr.transform]="'translate(' + badgeOffset(node, 4) + ',0)'">
<rect x="0" y="0" [attr.width]="14" height="14" rx="7" />
<text x="7" y="10">!</text>
<title>No agent assigned</title>
</g>
}
</g>
<!-- Frozen indicator -->
@if (node.isFrozen) {
<text
[attr.x]="node.width - 8"
y="64"
y="66"
text-anchor="end"
class="env-frozen"
>FROZEN</text>
@@ -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;

View File

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