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

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