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:
@@ -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,
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user