shipwright-cli 2.2.1 → 2.3.0

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 (156) hide show
  1. package/README.md +19 -19
  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.ts +381 -0
  18. package/dashboard/src/core/helpers.ts +118 -0
  19. package/dashboard/src/core/router.ts +190 -0
  20. package/dashboard/src/core/sse.ts +38 -0
  21. package/dashboard/src/core/state.ts +150 -0
  22. package/dashboard/src/core/ws.ts +143 -0
  23. package/dashboard/src/design/icons.ts +131 -0
  24. package/dashboard/src/design/tokens.ts +160 -0
  25. package/dashboard/src/main.ts +68 -0
  26. package/dashboard/src/types/api.ts +337 -0
  27. package/dashboard/src/views/activity.ts +185 -0
  28. package/dashboard/src/views/agent-cockpit.ts +236 -0
  29. package/dashboard/src/views/agents.ts +72 -0
  30. package/dashboard/src/views/fleet-map.ts +299 -0
  31. package/dashboard/src/views/insights.ts +298 -0
  32. package/dashboard/src/views/machines.ts +162 -0
  33. package/dashboard/src/views/metrics.ts +420 -0
  34. package/dashboard/src/views/overview.ts +409 -0
  35. package/dashboard/src/views/pipeline-theater.ts +219 -0
  36. package/dashboard/src/views/pipelines.ts +595 -0
  37. package/dashboard/src/views/team.ts +362 -0
  38. package/dashboard/src/views/timeline.ts +389 -0
  39. package/dashboard/tsconfig.json +21 -0
  40. package/docs/AGI-PLATFORM-PLAN.md +5 -5
  41. package/docs/AGI-WHATS-NEXT.md +19 -16
  42. package/docs/README.md +2 -0
  43. package/package.json +8 -1
  44. package/scripts/check-version-consistency.sh +72 -0
  45. package/scripts/lib/daemon-adaptive.sh +610 -0
  46. package/scripts/lib/daemon-dispatch.sh +489 -0
  47. package/scripts/lib/daemon-failure.sh +387 -0
  48. package/scripts/lib/daemon-patrol.sh +1113 -0
  49. package/scripts/lib/daemon-poll.sh +1202 -0
  50. package/scripts/lib/daemon-state.sh +550 -0
  51. package/scripts/lib/daemon-triage.sh +490 -0
  52. package/scripts/lib/helpers.sh +81 -0
  53. package/scripts/lib/pipeline-intelligence.sh +0 -6
  54. package/scripts/lib/pipeline-quality-checks.sh +3 -1
  55. package/scripts/lib/pipeline-stages.sh +20 -0
  56. package/scripts/sw +109 -168
  57. package/scripts/sw-activity.sh +1 -1
  58. package/scripts/sw-adaptive.sh +2 -2
  59. package/scripts/sw-adversarial.sh +1 -1
  60. package/scripts/sw-architecture-enforcer.sh +1 -1
  61. package/scripts/sw-auth.sh +14 -6
  62. package/scripts/sw-autonomous.sh +1 -1
  63. package/scripts/sw-changelog.sh +2 -2
  64. package/scripts/sw-checkpoint.sh +1 -1
  65. package/scripts/sw-ci.sh +1 -1
  66. package/scripts/sw-cleanup.sh +1 -1
  67. package/scripts/sw-code-review.sh +1 -1
  68. package/scripts/sw-connect.sh +1 -1
  69. package/scripts/sw-context.sh +1 -1
  70. package/scripts/sw-cost.sh +1 -1
  71. package/scripts/sw-daemon.sh +53 -4817
  72. package/scripts/sw-dashboard.sh +1 -1
  73. package/scripts/sw-db.sh +1 -1
  74. package/scripts/sw-decompose.sh +1 -1
  75. package/scripts/sw-deps.sh +1 -1
  76. package/scripts/sw-developer-simulation.sh +1 -1
  77. package/scripts/sw-discovery.sh +1 -1
  78. package/scripts/sw-doc-fleet.sh +1 -1
  79. package/scripts/sw-docs-agent.sh +1 -1
  80. package/scripts/sw-docs.sh +1 -1
  81. package/scripts/sw-doctor.sh +49 -1
  82. package/scripts/sw-dora.sh +1 -1
  83. package/scripts/sw-durable.sh +1 -1
  84. package/scripts/sw-e2e-orchestrator.sh +1 -1
  85. package/scripts/sw-eventbus.sh +1 -1
  86. package/scripts/sw-feedback.sh +1 -1
  87. package/scripts/sw-fix.sh +6 -5
  88. package/scripts/sw-fleet-discover.sh +1 -1
  89. package/scripts/sw-fleet-viz.sh +3 -3
  90. package/scripts/sw-fleet.sh +1 -1
  91. package/scripts/sw-github-app.sh +5 -2
  92. package/scripts/sw-github-checks.sh +1 -1
  93. package/scripts/sw-github-deploy.sh +1 -1
  94. package/scripts/sw-github-graphql.sh +1 -1
  95. package/scripts/sw-guild.sh +1 -1
  96. package/scripts/sw-heartbeat.sh +1 -1
  97. package/scripts/sw-hygiene.sh +1 -1
  98. package/scripts/sw-incident.sh +1 -1
  99. package/scripts/sw-init.sh +112 -9
  100. package/scripts/sw-instrument.sh +6 -1
  101. package/scripts/sw-intelligence.sh +5 -1
  102. package/scripts/sw-jira.sh +1 -1
  103. package/scripts/sw-launchd.sh +1 -1
  104. package/scripts/sw-linear.sh +20 -9
  105. package/scripts/sw-logs.sh +1 -1
  106. package/scripts/sw-loop.sh +2 -1
  107. package/scripts/sw-memory.sh +10 -1
  108. package/scripts/sw-mission-control.sh +1 -1
  109. package/scripts/sw-model-router.sh +4 -1
  110. package/scripts/sw-otel.sh +4 -4
  111. package/scripts/sw-oversight.sh +1 -1
  112. package/scripts/sw-pipeline-composer.sh +3 -1
  113. package/scripts/sw-pipeline-vitals.sh +4 -6
  114. package/scripts/sw-pipeline.sh +19 -56
  115. package/scripts/sw-pipeline.sh.mock +7 -0
  116. package/scripts/sw-pm.sh +5 -2
  117. package/scripts/sw-pr-lifecycle.sh +1 -1
  118. package/scripts/sw-predictive.sh +4 -1
  119. package/scripts/sw-prep.sh +3 -2
  120. package/scripts/sw-ps.sh +1 -1
  121. package/scripts/sw-public-dashboard.sh +10 -4
  122. package/scripts/sw-quality.sh +1 -1
  123. package/scripts/sw-reaper.sh +1 -1
  124. package/scripts/sw-recruit.sh +25 -1
  125. package/scripts/sw-regression.sh +2 -1
  126. package/scripts/sw-release-manager.sh +1 -1
  127. package/scripts/sw-release.sh +7 -5
  128. package/scripts/sw-remote.sh +1 -1
  129. package/scripts/sw-replay.sh +1 -1
  130. package/scripts/sw-retro.sh +1 -1
  131. package/scripts/sw-scale.sh +11 -5
  132. package/scripts/sw-security-audit.sh +1 -1
  133. package/scripts/sw-self-optimize.sh +172 -7
  134. package/scripts/sw-session.sh +1 -1
  135. package/scripts/sw-setup.sh +1 -1
  136. package/scripts/sw-standup.sh +4 -3
  137. package/scripts/sw-status.sh +1 -1
  138. package/scripts/sw-strategic.sh +2 -1
  139. package/scripts/sw-stream.sh +8 -2
  140. package/scripts/sw-swarm.sh +12 -10
  141. package/scripts/sw-team-stages.sh +1 -1
  142. package/scripts/sw-templates.sh +1 -1
  143. package/scripts/sw-testgen.sh +3 -2
  144. package/scripts/sw-tmux-pipeline.sh +2 -1
  145. package/scripts/sw-tmux.sh +1 -1
  146. package/scripts/sw-trace.sh +1 -1
  147. package/scripts/sw-tracker-jira.sh +1 -0
  148. package/scripts/sw-tracker-linear.sh +1 -0
  149. package/scripts/sw-tracker.sh +24 -6
  150. package/scripts/sw-triage.sh +1 -1
  151. package/scripts/sw-upgrade.sh +1 -1
  152. package/scripts/sw-ux.sh +1 -1
  153. package/scripts/sw-webhook.sh +1 -1
  154. package/scripts/sw-widgets.sh +2 -2
  155. package/scripts/sw-worktree.sh +1 -1
  156. package/dashboard/public/app.js +0 -4422
@@ -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
+ };