shipwright-cli 2.2.2 → 2.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (151) hide show
  1. package/README.md +12 -11
  2. package/dashboard/public/index.html +224 -8
  3. package/dashboard/public/styles.css +1078 -4
  4. package/dashboard/server.ts +1100 -15
  5. package/dashboard/src/canvas/interactions.ts +74 -0
  6. package/dashboard/src/canvas/layout.ts +85 -0
  7. package/dashboard/src/canvas/overlays.ts +117 -0
  8. package/dashboard/src/canvas/particles.ts +105 -0
  9. package/dashboard/src/canvas/renderer.ts +191 -0
  10. package/dashboard/src/components/charts/bar.ts +54 -0
  11. package/dashboard/src/components/charts/donut.ts +25 -0
  12. package/dashboard/src/components/charts/pipeline-rail.ts +105 -0
  13. package/dashboard/src/components/charts/sparkline.ts +82 -0
  14. package/dashboard/src/components/header.ts +616 -0
  15. package/dashboard/src/components/modal.ts +413 -0
  16. package/dashboard/src/components/terminal.ts +144 -0
  17. package/dashboard/src/core/api.test.ts +362 -0
  18. package/dashboard/src/core/api.ts +381 -0
  19. package/dashboard/src/core/helpers.ts +118 -0
  20. package/dashboard/src/core/router.test.ts +266 -0
  21. package/dashboard/src/core/router.ts +190 -0
  22. package/dashboard/src/core/sse.ts +38 -0
  23. package/dashboard/src/core/state.test.ts +235 -0
  24. package/dashboard/src/core/state.ts +150 -0
  25. package/dashboard/src/core/ws.test.ts +216 -0
  26. package/dashboard/src/core/ws.ts +143 -0
  27. package/dashboard/src/design/icons.test.ts +105 -0
  28. package/dashboard/src/design/icons.ts +131 -0
  29. package/dashboard/src/design/tokens.test.ts +204 -0
  30. package/dashboard/src/design/tokens.ts +160 -0
  31. package/dashboard/src/main.ts +68 -0
  32. package/dashboard/src/types/api.ts +337 -0
  33. package/dashboard/src/views/activity.ts +185 -0
  34. package/dashboard/src/views/agent-cockpit.ts +236 -0
  35. package/dashboard/src/views/agents.ts +72 -0
  36. package/dashboard/src/views/fleet-map.ts +299 -0
  37. package/dashboard/src/views/insights.ts +298 -0
  38. package/dashboard/src/views/machines.ts +162 -0
  39. package/dashboard/src/views/metrics.ts +420 -0
  40. package/dashboard/src/views/overview.ts +409 -0
  41. package/dashboard/src/views/pipeline-theater.ts +219 -0
  42. package/dashboard/src/views/pipelines.ts +595 -0
  43. package/dashboard/src/views/team.ts +362 -0
  44. package/dashboard/src/views/timeline.ts +389 -0
  45. package/dashboard/tsconfig.json +21 -0
  46. package/dashboard/vitest.config.ts +27 -0
  47. package/docs/AGI-WHATS-NEXT.md +15 -15
  48. package/package.json +16 -2
  49. package/scripts/lib/helpers.sh +30 -0
  50. package/scripts/lib/pipeline-quality-checks.sh +1 -1
  51. package/scripts/lib/pipeline-stages.sh +59 -0
  52. package/scripts/sw +86 -167
  53. package/scripts/sw-activity.sh +1 -1
  54. package/scripts/sw-adaptive.sh +1 -1
  55. package/scripts/sw-adversarial.sh +1 -1
  56. package/scripts/sw-architecture-enforcer.sh +1 -1
  57. package/scripts/sw-auth.sh +14 -6
  58. package/scripts/sw-autonomous.sh +230 -13
  59. package/scripts/sw-changelog.sh +2 -2
  60. package/scripts/sw-checkpoint.sh +1 -1
  61. package/scripts/sw-ci.sh +1 -1
  62. package/scripts/sw-cleanup.sh +1 -1
  63. package/scripts/sw-code-review.sh +1 -1
  64. package/scripts/sw-connect.sh +1 -1
  65. package/scripts/sw-context.sh +1 -1
  66. package/scripts/sw-cost.sh +1 -1
  67. package/scripts/sw-daemon.sh +2 -2
  68. package/scripts/sw-dashboard.sh +1 -1
  69. package/scripts/sw-db.sh +1 -1
  70. package/scripts/sw-decompose.sh +1 -1
  71. package/scripts/sw-deps.sh +1 -1
  72. package/scripts/sw-developer-simulation.sh +1 -1
  73. package/scripts/sw-discovery.sh +1 -1
  74. package/scripts/sw-doc-fleet.sh +1 -1
  75. package/scripts/sw-docs-agent.sh +1 -1
  76. package/scripts/sw-docs.sh +1 -1
  77. package/scripts/sw-doctor.sh +8 -1
  78. package/scripts/sw-dora.sh +1 -1
  79. package/scripts/sw-durable.sh +1 -1
  80. package/scripts/sw-e2e-orchestrator.sh +1 -1
  81. package/scripts/sw-eventbus.sh +1 -1
  82. package/scripts/sw-feedback.sh +1 -1
  83. package/scripts/sw-fix.sh +6 -5
  84. package/scripts/sw-fleet-discover.sh +1 -1
  85. package/scripts/sw-fleet-viz.sh +1 -1
  86. package/scripts/sw-fleet.sh +1 -1
  87. package/scripts/sw-github-app.sh +5 -2
  88. package/scripts/sw-github-checks.sh +1 -1
  89. package/scripts/sw-github-deploy.sh +1 -1
  90. package/scripts/sw-github-graphql.sh +1 -1
  91. package/scripts/sw-guild.sh +1 -1
  92. package/scripts/sw-heartbeat.sh +1 -1
  93. package/scripts/sw-hygiene.sh +1 -1
  94. package/scripts/sw-incident.sh +1 -1
  95. package/scripts/sw-init.sh +112 -9
  96. package/scripts/sw-instrument.sh +6 -1
  97. package/scripts/sw-intelligence.sh +5 -1
  98. package/scripts/sw-jira.sh +1 -1
  99. package/scripts/sw-launchd.sh +1 -1
  100. package/scripts/sw-linear.sh +20 -9
  101. package/scripts/sw-logs.sh +1 -1
  102. package/scripts/sw-loop.sh +2 -1
  103. package/scripts/sw-memory.sh +10 -1
  104. package/scripts/sw-mission-control.sh +1 -1
  105. package/scripts/sw-model-router.sh +4 -1
  106. package/scripts/sw-otel.sh +1 -1
  107. package/scripts/sw-oversight.sh +1 -1
  108. package/scripts/sw-pipeline-composer.sh +3 -1
  109. package/scripts/sw-pipeline-vitals.sh +4 -6
  110. package/scripts/sw-pipeline.sh +4 -1
  111. package/scripts/sw-pm.sh +5 -2
  112. package/scripts/sw-pr-lifecycle.sh +1 -1
  113. package/scripts/sw-predictive.sh +4 -1
  114. package/scripts/sw-prep.sh +3 -2
  115. package/scripts/sw-ps.sh +1 -1
  116. package/scripts/sw-public-dashboard.sh +10 -4
  117. package/scripts/sw-quality.sh +1 -1
  118. package/scripts/sw-reaper.sh +1 -1
  119. package/scripts/sw-recruit.sh +16 -0
  120. package/scripts/sw-regression.sh +2 -1
  121. package/scripts/sw-release-manager.sh +1 -1
  122. package/scripts/sw-release.sh +7 -5
  123. package/scripts/sw-remote.sh +1 -1
  124. package/scripts/sw-replay.sh +1 -1
  125. package/scripts/sw-retro.sh +4 -1
  126. package/scripts/sw-scale.sh +4 -1
  127. package/scripts/sw-security-audit.sh +1 -1
  128. package/scripts/sw-self-optimize.sh +113 -1
  129. package/scripts/sw-session.sh +1 -1
  130. package/scripts/sw-setup.sh +1 -1
  131. package/scripts/sw-standup.sh +2 -1
  132. package/scripts/sw-status.sh +1 -1
  133. package/scripts/sw-strategic.sh +2 -1
  134. package/scripts/sw-stream.sh +1 -1
  135. package/scripts/sw-swarm.sh +6 -1
  136. package/scripts/sw-team-stages.sh +1 -1
  137. package/scripts/sw-templates.sh +1 -1
  138. package/scripts/sw-testgen.sh +3 -2
  139. package/scripts/sw-tmux-pipeline.sh +2 -1
  140. package/scripts/sw-tmux.sh +1 -1
  141. package/scripts/sw-trace.sh +1 -1
  142. package/scripts/sw-tracker-jira.sh +1 -0
  143. package/scripts/sw-tracker-linear.sh +1 -0
  144. package/scripts/sw-tracker.sh +1 -1
  145. package/scripts/sw-triage.sh +198 -11
  146. package/scripts/sw-upgrade.sh +1 -1
  147. package/scripts/sw-ux.sh +1 -1
  148. package/scripts/sw-webhook.sh +1 -1
  149. package/scripts/sw-widgets.sh +2 -2
  150. package/scripts/sw-worktree.sh +1 -1
  151. package/dashboard/public/app.js +0 -4422
@@ -0,0 +1,162 @@
1
+ // Machines tab - machine grid, health checks, worker management
2
+
3
+ import { store } from "../core/state";
4
+ import { escapeHtml } from "../core/helpers";
5
+ import { icon } from "../design/icons";
6
+ import {
7
+ setupMachinesModals,
8
+ updateWorkerCount,
9
+ machineHealthCheckAction,
10
+ confirmMachineRemove,
11
+ } from "../components/modal";
12
+ import * as api from "../core/api";
13
+ import type { FleetState, View, MachineInfo, JoinToken } from "../types/api";
14
+
15
+ function fetchMachinesTab(): void {
16
+ api
17
+ .fetchMachines()
18
+ .then((machines) => {
19
+ store.set("machinesCache", machines);
20
+ renderMachinesTab(machines);
21
+ })
22
+ .catch(() => {});
23
+
24
+ api
25
+ .fetchJoinTokens()
26
+ .then(({ tokens }) => {
27
+ store.set("joinTokensCache", tokens);
28
+ renderJoinTokens(tokens);
29
+ })
30
+ .catch(() => {});
31
+ }
32
+
33
+ function renderMachinesTab(machines: MachineInfo[]): void {
34
+ const summaryEl = document.getElementById("machines-summary");
35
+ const gridEl = document.getElementById("machines-grid");
36
+ if (!summaryEl || !gridEl) return;
37
+
38
+ summaryEl.innerHTML = renderMachineSummary(machines);
39
+
40
+ if (machines.length === 0) {
41
+ gridEl.innerHTML = `<div class="empty-state">${icon("server", 32)}<p>No machines registered</p></div>`;
42
+ return;
43
+ }
44
+
45
+ gridEl.innerHTML = machines.map((m) => renderMachineCard(m)).join("");
46
+
47
+ // Wire up action buttons
48
+ gridEl.querySelectorAll(".machine-action-btn").forEach((btn) => {
49
+ btn.addEventListener("click", (e) => {
50
+ e.stopPropagation();
51
+ const action = btn.getAttribute("data-machine-action");
52
+ const name = btn.getAttribute("data-machine-name") || "";
53
+ if (action === "check") machineHealthCheckAction(name);
54
+ else if (action === "remove") confirmMachineRemove(name);
55
+ });
56
+ });
57
+
58
+ // Wire up worker sliders
59
+ gridEl.querySelectorAll(".workers-slider").forEach((slider) => {
60
+ slider.addEventListener("input", (e) => {
61
+ const el = e.target as HTMLInputElement;
62
+ const name = el.getAttribute("data-machine-name") || "";
63
+ updateWorkerCount(name, el.value);
64
+ });
65
+ });
66
+ }
67
+
68
+ function renderMachineSummary(machines: MachineInfo[]): string {
69
+ let totalMaxWorkers = 0;
70
+ let totalActiveWorkers = 0;
71
+ let onlineCount = 0;
72
+ for (const m of machines) {
73
+ totalMaxWorkers += m.max_workers || 0;
74
+ totalActiveWorkers += m.active_workers || 0;
75
+ if (m.status === "online") onlineCount++;
76
+ }
77
+
78
+ return (
79
+ `<div class="machines-summary-card"><div class="stat-value">${machines.length}</div><div class="stat-label">Total Machines</div></div>` +
80
+ `<div class="machines-summary-card"><div class="stat-value">${onlineCount}</div><div class="stat-label">Online</div></div>` +
81
+ `<div class="machines-summary-card"><div class="stat-value">${totalActiveWorkers} / ${totalMaxWorkers}</div><div class="stat-label">Active / Max Workers</div></div>`
82
+ );
83
+ }
84
+
85
+ function renderMachineCard(machine: MachineInfo): string {
86
+ const name = machine.name || "";
87
+ const host = machine.host || "\u2014";
88
+ const role = machine.role || "worker";
89
+ const status = machine.status || "offline";
90
+ const maxWorkers = machine.max_workers || 4;
91
+ const activeWorkers = machine.active_workers || 0;
92
+ const health = machine.health || {};
93
+ const daemonRunning = health.daemon_running || false;
94
+ const heartbeatCount = health.heartbeat_count || 0;
95
+ const lastHbAge = health.last_heartbeat_s_ago;
96
+ let lastHbText = "\u2014";
97
+ if (typeof lastHbAge === "number" && lastHbAge < 9999) {
98
+ if (lastHbAge < 60) lastHbText = lastHbAge + "s ago";
99
+ else if (lastHbAge < 3600)
100
+ lastHbText = Math.floor(lastHbAge / 60) + "m ago";
101
+ else lastHbText = Math.floor(lastHbAge / 3600) + "h ago";
102
+ }
103
+
104
+ return (
105
+ `<div class="machine-card" id="machine-card-${escapeHtml(name)}">` +
106
+ `<div class="machine-card-header">` +
107
+ `<span class="presence-dot ${status}"></span>` +
108
+ `<span class="machine-name">${escapeHtml(name)}</span>` +
109
+ `<span class="machine-role">${escapeHtml(role)}</span></div>` +
110
+ `<div class="machine-host">${escapeHtml(host)}</div>` +
111
+ `<div class="machine-workers-section">` +
112
+ `<div class="machine-workers-label-row"><span>Workers</span><span class="workers-count">${activeWorkers} / ${maxWorkers}</span></div>` +
113
+ `<input type="range" class="workers-slider" min="1" max="64" value="${maxWorkers}" data-machine-name="${escapeHtml(name)}" title="Max workers" /></div>` +
114
+ `<div class="machine-health">` +
115
+ `<div class="machine-health-row"><span class="health-label">Daemon</span>` +
116
+ `<span class="health-status ${daemonRunning ? "running" : "stopped"}">${daemonRunning ? "Running" : "Stopped"}</span></div>` +
117
+ `<div class="machine-health-row"><span class="health-label">Heartbeats</span><span class="health-value">${heartbeatCount}</span></div>` +
118
+ `<div class="machine-health-row"><span class="health-label">Last heartbeat</span><span class="health-value">${lastHbText}</span></div></div>` +
119
+ `<div class="machine-card-actions">` +
120
+ `<button class="machine-action-btn" data-machine-action="check" data-machine-name="${escapeHtml(name)}">Check</button>` +
121
+ `<button class="machine-action-btn danger" data-machine-action="remove" data-machine-name="${escapeHtml(name)}">Remove</button></div></div>`
122
+ );
123
+ }
124
+
125
+ function renderJoinTokens(tokens: JoinToken[]): void {
126
+ const section = document.getElementById("join-tokens-section");
127
+ const list = document.getElementById("join-tokens-list");
128
+ if (!section || !list) return;
129
+ if (!tokens || tokens.length === 0) {
130
+ section.style.display = "none";
131
+ return;
132
+ }
133
+ section.style.display = "";
134
+
135
+ let html = "";
136
+ for (const t of tokens) {
137
+ const label = t.label || "Unlabeled";
138
+ const created = t.created_at
139
+ ? new Date(t.created_at).toLocaleDateString()
140
+ : "\u2014";
141
+ const used = t.used ? "Claimed" : "Active";
142
+ const usedClass = t.used ? "c-amber" : "c-green";
143
+ html +=
144
+ `<div class="join-token-row">` +
145
+ `<span class="join-token-label">${escapeHtml(label)}</span>` +
146
+ `<span class="join-token-created">${created}</span>` +
147
+ `<span class="join-token-status ${usedClass}">${used}</span></div>`;
148
+ }
149
+ list.innerHTML = html;
150
+ }
151
+
152
+ export const machinesView: View = {
153
+ init() {
154
+ setupMachinesModals();
155
+ fetchMachinesTab();
156
+ },
157
+ render(_data: FleetState) {
158
+ const cache = store.get("machinesCache");
159
+ if (cache) renderMachinesTab(cache);
160
+ },
161
+ destroy() {},
162
+ };
@@ -0,0 +1,420 @@
1
+ // Metrics tab - success rate, duration, throughput, DORA, costs, stage performance
2
+
3
+ import { store } from "../core/state";
4
+ import {
5
+ escapeHtml,
6
+ fmtNum,
7
+ formatDuration,
8
+ animateValue,
9
+ } from "../core/helpers";
10
+ import { renderSVGDonut } from "../components/charts/donut";
11
+ import { renderSVGBarChart } from "../components/charts/bar";
12
+ import {
13
+ renderSparkline,
14
+ renderSVGLineChart,
15
+ } from "../components/charts/sparkline";
16
+ import { renderDoraGrades } from "../components/charts/pipeline-rail";
17
+ import { STAGES, STAGE_COLORS, STAGE_HEX } from "../design/tokens";
18
+ import * as api from "../core/api";
19
+ import type { FleetState, View, MetricsData } from "../types/api";
20
+
21
+ function fetchMetrics(): void {
22
+ api
23
+ .fetchMetricsHistory()
24
+ .then((data) => {
25
+ store.set("metricsCache", data);
26
+ renderMetrics(data);
27
+ })
28
+ .catch((err) => {
29
+ const el = document.getElementById("metrics-grid");
30
+ if (el)
31
+ el.innerHTML = `<div class="empty-state"><p>Failed to load metrics: ${escapeHtml(String(err))}</p></div>`;
32
+ });
33
+ }
34
+
35
+ function renderMetrics(data: MetricsData): void {
36
+ const firstRender = store.get("firstRender");
37
+
38
+ // Success rate donut
39
+ const rate = data.success_rate ?? 0;
40
+ const donutWrap = document.getElementById("metric-donut-wrap");
41
+ if (donutWrap) donutWrap.innerHTML = renderSVGDonut(rate);
42
+
43
+ // Avg duration
44
+ const avgDurEl = document.getElementById("metric-avg-duration");
45
+ if (avgDurEl) avgDurEl.textContent = formatDuration(data.avg_duration_s);
46
+
47
+ // Throughput
48
+ const tp = data.throughput_per_hour ?? 0;
49
+ const tpEl = document.getElementById("metric-throughput");
50
+ if (tpEl) tpEl.textContent = tp.toFixed(2);
51
+
52
+ // Totals
53
+ const totalCompleted = data.total_completed ?? 0;
54
+ const totalFailed = data.total_failed ?? 0;
55
+ const tcEl = document.getElementById("metric-total-completed");
56
+ if (tcEl) {
57
+ if (firstRender && totalCompleted > 0)
58
+ animateValue(tcEl, 0, totalCompleted, 800, "");
59
+ else tcEl.textContent = fmtNum(totalCompleted);
60
+ }
61
+ const failedEl = document.getElementById("metric-total-failed");
62
+ if (failedEl) {
63
+ failedEl.textContent = fmtNum(totalFailed) + " failed";
64
+ failedEl.style.color = totalFailed > 0 ? "var(--rose)" : "";
65
+ }
66
+
67
+ // Stage breakdown
68
+ renderStageBreakdown(data.stage_durations || {});
69
+ // Daily chart
70
+ renderDailyChart(data.daily_counts || []);
71
+ // DORA grades
72
+ const doraContainer = document.getElementById("dora-grades-container");
73
+ if (doraContainer && data.dora_grades) {
74
+ doraContainer.innerHTML = renderDoraGrades(
75
+ data.dora_grades as unknown as Record<
76
+ string,
77
+ { grade: string; value: number; unit: string }
78
+ >,
79
+ );
80
+ doraContainer.style.display = "";
81
+ } else if (doraContainer) {
82
+ doraContainer.style.display = "none";
83
+ }
84
+
85
+ // Cost breakdown/trend
86
+ if (document.getElementById("cost-breakdown-container"))
87
+ renderCostBreakdown();
88
+ if (document.getElementById("cost-trend-container")) renderCostTrend();
89
+ if (document.getElementById("dora-trend-container")) renderDoraTrend();
90
+ if (document.getElementById("stage-performance-container"))
91
+ renderStagePerformance();
92
+ if (document.getElementById("bottleneck-alert-container"))
93
+ renderBottleneckAlert();
94
+ if (document.getElementById("throughput-trend-container"))
95
+ renderThroughputTrend();
96
+ if (document.getElementById("capacity-forecast-container"))
97
+ renderCapacityForecast();
98
+ }
99
+
100
+ function renderStageBreakdown(stageDurations: Record<string, number>): void {
101
+ const container = document.getElementById("stage-breakdown");
102
+ if (!container) return;
103
+ const keys = Object.keys(stageDurations);
104
+ if (keys.length === 0) {
105
+ container.innerHTML = '<div class="empty-state"><p>No data</p></div>';
106
+ return;
107
+ }
108
+
109
+ let maxVal = 0;
110
+ for (const k of keys) {
111
+ if (stageDurations[k] > maxVal) maxVal = stageDurations[k];
112
+ }
113
+ if (maxVal === 0) maxVal = 1;
114
+
115
+ let html = "";
116
+ keys.forEach((stage, i) => {
117
+ const val = stageDurations[stage];
118
+ const pct = (val / maxVal) * 100;
119
+ const colorIdx = i % STAGE_COLORS.length;
120
+ html +=
121
+ `<div class="stage-bar-row">` +
122
+ `<span class="stage-bar-label">${escapeHtml(stage)}</span>` +
123
+ `<div class="stage-bar-track-h"><div class="stage-bar-fill-h ${STAGE_COLORS[colorIdx]}" style="width:${pct}%"></div></div>` +
124
+ `<span class="stage-bar-value">${formatDuration(val)}</span></div>`;
125
+ });
126
+ container.innerHTML = html;
127
+ }
128
+
129
+ function renderDailyChart(dailyCounts: any[]): void {
130
+ const container = document.getElementById("daily-chart");
131
+ if (!container) return;
132
+ if (!dailyCounts || dailyCounts.length === 0) {
133
+ container.innerHTML = '<div class="empty-state"><p>No data</p></div>';
134
+ return;
135
+ }
136
+ container.innerHTML = renderSVGBarChart(dailyCounts);
137
+ }
138
+
139
+ function renderCostBreakdown(): void {
140
+ const container = document.getElementById("cost-breakdown-container");
141
+ if (!container) return;
142
+
143
+ api
144
+ .fetchCostBreakdown()
145
+ .then((data) => {
146
+ store.set("costBreakdownCache", data as any);
147
+ let html = "";
148
+
149
+ if (data.by_model) {
150
+ html +=
151
+ '<div class="cost-section"><div class="cost-section-label">COST BY MODEL</div>';
152
+ const modelColors: Record<string, string> = {
153
+ opus: "#7c3aed",
154
+ sonnet: "#00d4ff",
155
+ haiku: "#4ade80",
156
+ };
157
+ const models = Object.keys(data.by_model);
158
+ let maxModel = 0;
159
+ models.forEach((m) => {
160
+ if (data.by_model![m] > maxModel) maxModel = data.by_model![m];
161
+ });
162
+ if (maxModel === 0) maxModel = 1;
163
+ models.forEach((m) => {
164
+ const val = data.by_model![m];
165
+ const pct = (val / maxModel) * 100;
166
+ const color = modelColors[m.toLowerCase()] || "#5a6d8a";
167
+ html +=
168
+ `<div class="cost-bar-row"><span class="cost-bar-label">${escapeHtml(m)}</span>` +
169
+ `<div class="cost-bar-track-h"><div class="cost-bar-fill-h" style="width:${pct}%;background:${color}"></div></div>` +
170
+ `<span class="cost-bar-value">$${val.toFixed(2)}</span></div>`;
171
+ });
172
+ html += "</div>";
173
+ }
174
+
175
+ if (data.by_stage) {
176
+ html +=
177
+ '<div class="cost-section"><div class="cost-section-label">COST BY STAGE</div>';
178
+ const stages = Object.keys(data.by_stage);
179
+ let maxStage = 0;
180
+ stages.forEach((s) => {
181
+ if (data.by_stage![s] > maxStage) maxStage = data.by_stage![s];
182
+ });
183
+ if (maxStage === 0) maxStage = 1;
184
+ stages.forEach((s) => {
185
+ const val = data.by_stage![s];
186
+ const pct = (val / maxStage) * 100;
187
+ const barColor =
188
+ (STAGE_HEX as Record<string, string>)[s] || "#5a6d8a";
189
+ html +=
190
+ `<div class="cost-bar-row"><span class="cost-bar-label">${escapeHtml(s)}</span>` +
191
+ `<div class="cost-bar-track-h"><div class="cost-bar-fill-h" style="width:${pct}%;background:${barColor}"></div></div>` +
192
+ `<span class="cost-bar-value">$${val.toFixed(2)}</span></div>`;
193
+ });
194
+ html += "</div>";
195
+ }
196
+
197
+ if (data.by_issue?.length) {
198
+ html +=
199
+ '<div class="cost-section"><div class="cost-section-label">COST PER ISSUE</div>';
200
+ html +=
201
+ '<table class="cost-issue-table"><thead><tr><th>Issue</th><th>Cost</th></tr></thead><tbody>';
202
+ const sorted = [...data.by_issue].sort(
203
+ (a, b) => (b.cost || 0) - (a.cost || 0),
204
+ );
205
+ sorted.forEach((item) => {
206
+ html += `<tr><td>#${item.issue}</td><td>$${(item.cost || 0).toFixed(2)}</td></tr>`;
207
+ });
208
+ html += "</tbody></table></div>";
209
+ }
210
+
211
+ if (data.budget != null && data.spent != null) {
212
+ const budgetPct =
213
+ data.budget > 0 ? Math.min((data.spent / data.budget) * 100, 100) : 0;
214
+ const budgetClass =
215
+ budgetPct >= 80
216
+ ? "cost-over"
217
+ : budgetPct >= 60
218
+ ? "cost-warn"
219
+ : "cost-ok";
220
+ html +=
221
+ `<div class="cost-section"><div class="cost-section-label">BUDGET UTILIZATION</div>` +
222
+ `<div class="budget-util-bar"><div class="cost-bar-track"><div class="cost-bar-fill ${budgetClass}" style="width:${budgetPct.toFixed(0)}%"></div></div>` +
223
+ `<span class="budget-util-text">$${data.spent.toFixed(2)} / $${data.budget.toFixed(2)} (${budgetPct.toFixed(0)}%)</span></div></div>`;
224
+ }
225
+
226
+ container.innerHTML =
227
+ html || '<div class="empty-state"><p>No cost data</p></div>';
228
+ })
229
+ .catch((err) => {
230
+ container.innerHTML = `<div class="empty-state"><p>Failed to load: ${escapeHtml(String(err))}</p></div>`;
231
+ });
232
+ }
233
+
234
+ function renderCostTrend(): void {
235
+ const container = document.getElementById("cost-trend-container");
236
+ if (!container) return;
237
+ api
238
+ .fetchCostTrend()
239
+ .then((data) => {
240
+ const points = data.points || [];
241
+ if (points.length === 0) {
242
+ container.innerHTML =
243
+ '<div class="empty-state"><p>No trend data</p></div>';
244
+ return;
245
+ }
246
+ container.innerHTML = renderSVGLineChart(
247
+ points,
248
+ "cost",
249
+ "#00d4ff",
250
+ 300,
251
+ 100,
252
+ );
253
+ })
254
+ .catch((err) => {
255
+ container.innerHTML = `<div class="empty-state"><p>Failed to load: ${escapeHtml(String(err))}</p></div>`;
256
+ });
257
+ }
258
+
259
+ function renderDoraTrend(): void {
260
+ const container = document.getElementById("dora-trend-container");
261
+ if (!container) return;
262
+ api
263
+ .fetchDoraTrend()
264
+ .then((data) => {
265
+ const metrics = [
266
+ { key: "deploy_freq", label: "Deploy Freq", color: "#00d4ff" },
267
+ { key: "lead_time", label: "Lead Time", color: "#0066ff" },
268
+ { key: "cfr", label: "Change Fail Rate", color: "#f43f5e" },
269
+ { key: "mttr", label: "MTTR", color: "#4ade80" },
270
+ ];
271
+ let html = '<div class="dora-trend-grid">';
272
+ for (const m of metrics) {
273
+ const points = data[m.key] || [];
274
+ html += `<div class="dora-trend-card"><span class="dora-trend-label">${escapeHtml(m.label)}</span>`;
275
+ html +=
276
+ points.length > 0
277
+ ? renderSparkline(
278
+ points as Array<number | { value: number }>,
279
+ m.color,
280
+ 120,
281
+ 30,
282
+ )
283
+ : '<span class="dora-trend-empty">\u2014</span>';
284
+ html += "</div>";
285
+ }
286
+ html += "</div>";
287
+ container.innerHTML = html;
288
+ })
289
+ .catch((err) => {
290
+ container.innerHTML = `<div class="empty-state"><p>Failed to load: ${escapeHtml(String(err))}</p></div>`;
291
+ });
292
+ }
293
+
294
+ function renderStagePerformance(): void {
295
+ const container = document.getElementById("stage-performance-container");
296
+ if (!container) return;
297
+ api
298
+ .fetchStagePerformance()
299
+ .then((data) => {
300
+ const stages = data.stages || [];
301
+ if (stages.length === 0) {
302
+ container.innerHTML =
303
+ '<div class="empty-state"><p>No stage performance data</p></div>';
304
+ return;
305
+ }
306
+ let html =
307
+ '<table class="stage-perf-table"><thead><tr><th>Stage</th><th>Avg</th><th>Min</th><th>Max</th><th>Count</th><th>Trend</th></tr></thead><tbody>';
308
+ for (const s of stages) {
309
+ let trendArrow = "";
310
+ if (s.trend_pct != null) {
311
+ if (s.trend_pct > 5)
312
+ trendArrow = `<span class="trend-up">\u2191 ${s.trend_pct.toFixed(0)}%</span>`;
313
+ else if (s.trend_pct < -5)
314
+ trendArrow = `<span class="trend-down">\u2193 ${Math.abs(s.trend_pct).toFixed(0)}%</span>`;
315
+ else trendArrow = '<span class="trend-flat">\u2192</span>';
316
+ }
317
+ html +=
318
+ `<tr><td>${escapeHtml(s.name || s.stage || "")}</td><td>${formatDuration(s.avg_s)}</td><td>${formatDuration(s.min_s)}</td>` +
319
+ `<td>${formatDuration(s.max_s)}</td><td>${s.count || 0}</td><td>${trendArrow}</td></tr>`;
320
+ }
321
+ html += "</tbody></table>";
322
+ container.innerHTML = html;
323
+ })
324
+ .catch((err) => {
325
+ container.innerHTML = `<div class="empty-state"><p>Failed to load: ${escapeHtml(String(err))}</p></div>`;
326
+ });
327
+ }
328
+
329
+ function renderBottleneckAlert(): void {
330
+ const container = document.getElementById("bottleneck-alert-container");
331
+ if (!container) return;
332
+ api
333
+ .fetchBottlenecks()
334
+ .then((data) => {
335
+ const bottlenecks = data.bottlenecks || [];
336
+ if (bottlenecks.length === 0) {
337
+ container.innerHTML = "";
338
+ return;
339
+ }
340
+ let html = "";
341
+ for (const b of bottlenecks) {
342
+ if (b.impact === "low") continue;
343
+ const msg =
344
+ escapeHtml(b.stage || "Unknown") +
345
+ " stage averages " +
346
+ formatDuration(b.avgDuration) +
347
+ " (" +
348
+ escapeHtml(b.impact) +
349
+ " impact)";
350
+ const suggestion = b.suggestion
351
+ ? `<div class="bottleneck-suggestion">${escapeHtml(b.suggestion)}</div>`
352
+ : "";
353
+ html += `<div class="bottleneck-alert"><span class="bottleneck-icon">\u26A0</span><span class="bottleneck-msg">${msg}</span>${suggestion}</div>`;
354
+ }
355
+ container.innerHTML = html;
356
+ })
357
+ .catch(() => {
358
+ container.innerHTML = "";
359
+ });
360
+ }
361
+
362
+ function renderThroughputTrend(): void {
363
+ const container = document.getElementById("throughput-trend-container");
364
+ if (!container) return;
365
+ api
366
+ .fetchThroughputTrend()
367
+ .then((data) => {
368
+ const points = data.points || [];
369
+ if (points.length === 0) {
370
+ container.innerHTML =
371
+ '<div class="empty-state"><p>No throughput data</p></div>';
372
+ return;
373
+ }
374
+ container.innerHTML = renderSVGLineChart(
375
+ points,
376
+ "throughput",
377
+ "#4ade80",
378
+ 300,
379
+ 100,
380
+ );
381
+ })
382
+ .catch((err) => {
383
+ container.innerHTML = `<div class="empty-state"><p>Failed to load: ${escapeHtml(String(err))}</p></div>`;
384
+ });
385
+ }
386
+
387
+ function renderCapacityForecast(): void {
388
+ const container = document.getElementById("capacity-forecast-container");
389
+ if (!container) return;
390
+ api
391
+ .fetchCapacity()
392
+ .then((data) => {
393
+ if (!data.rate && !data.queue_clear_hours) {
394
+ container.innerHTML =
395
+ '<div class="empty-state"><p>No capacity data</p></div>';
396
+ return;
397
+ }
398
+ const rate = data.rate != null ? data.rate.toFixed(1) : "?";
399
+ const clearTime =
400
+ data.queue_clear_hours != null
401
+ ? data.queue_clear_hours.toFixed(1)
402
+ : "?";
403
+ container.innerHTML = `<div class="capacity-forecast"><span class="capacity-text">At current rate (${rate}/hr), queue will clear in <strong>${clearTime} hours</strong></span></div>`;
404
+ })
405
+ .catch((err) => {
406
+ container.innerHTML = `<div class="empty-state"><p>Failed to load: ${escapeHtml(String(err))}</p></div>`;
407
+ });
408
+ }
409
+
410
+ export const metricsView: View = {
411
+ init() {
412
+ fetchMetrics();
413
+ },
414
+ render(_data: FleetState) {
415
+ // Metrics use cached data; don't re-fetch on every WS push
416
+ const cache = store.get("metricsCache");
417
+ if (cache) renderMetrics(cache);
418
+ },
419
+ destroy() {},
420
+ };