shipwright-cli 2.2.2 → 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 (143) 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.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-WHATS-NEXT.md +15 -15
  41. package/package.json +8 -1
  42. package/scripts/lib/helpers.sh +30 -0
  43. package/scripts/lib/pipeline-quality-checks.sh +1 -1
  44. package/scripts/sw +86 -167
  45. package/scripts/sw-activity.sh +1 -1
  46. package/scripts/sw-adaptive.sh +1 -1
  47. package/scripts/sw-adversarial.sh +1 -1
  48. package/scripts/sw-architecture-enforcer.sh +1 -1
  49. package/scripts/sw-auth.sh +14 -6
  50. package/scripts/sw-autonomous.sh +1 -1
  51. package/scripts/sw-changelog.sh +2 -2
  52. package/scripts/sw-checkpoint.sh +1 -1
  53. package/scripts/sw-ci.sh +1 -1
  54. package/scripts/sw-cleanup.sh +1 -1
  55. package/scripts/sw-code-review.sh +1 -1
  56. package/scripts/sw-connect.sh +1 -1
  57. package/scripts/sw-context.sh +1 -1
  58. package/scripts/sw-cost.sh +1 -1
  59. package/scripts/sw-daemon.sh +2 -2
  60. package/scripts/sw-dashboard.sh +1 -1
  61. package/scripts/sw-db.sh +1 -1
  62. package/scripts/sw-decompose.sh +1 -1
  63. package/scripts/sw-deps.sh +1 -1
  64. package/scripts/sw-developer-simulation.sh +1 -1
  65. package/scripts/sw-discovery.sh +1 -1
  66. package/scripts/sw-doc-fleet.sh +1 -1
  67. package/scripts/sw-docs-agent.sh +1 -1
  68. package/scripts/sw-docs.sh +1 -1
  69. package/scripts/sw-doctor.sh +8 -1
  70. package/scripts/sw-dora.sh +1 -1
  71. package/scripts/sw-durable.sh +1 -1
  72. package/scripts/sw-e2e-orchestrator.sh +1 -1
  73. package/scripts/sw-eventbus.sh +1 -1
  74. package/scripts/sw-feedback.sh +1 -1
  75. package/scripts/sw-fix.sh +6 -5
  76. package/scripts/sw-fleet-discover.sh +1 -1
  77. package/scripts/sw-fleet-viz.sh +1 -1
  78. package/scripts/sw-fleet.sh +1 -1
  79. package/scripts/sw-github-app.sh +5 -2
  80. package/scripts/sw-github-checks.sh +1 -1
  81. package/scripts/sw-github-deploy.sh +1 -1
  82. package/scripts/sw-github-graphql.sh +1 -1
  83. package/scripts/sw-guild.sh +1 -1
  84. package/scripts/sw-heartbeat.sh +1 -1
  85. package/scripts/sw-hygiene.sh +1 -1
  86. package/scripts/sw-incident.sh +1 -1
  87. package/scripts/sw-init.sh +112 -9
  88. package/scripts/sw-instrument.sh +6 -1
  89. package/scripts/sw-intelligence.sh +5 -1
  90. package/scripts/sw-jira.sh +1 -1
  91. package/scripts/sw-launchd.sh +1 -1
  92. package/scripts/sw-linear.sh +20 -9
  93. package/scripts/sw-logs.sh +1 -1
  94. package/scripts/sw-loop.sh +2 -1
  95. package/scripts/sw-memory.sh +10 -1
  96. package/scripts/sw-mission-control.sh +1 -1
  97. package/scripts/sw-model-router.sh +4 -1
  98. package/scripts/sw-otel.sh +1 -1
  99. package/scripts/sw-oversight.sh +1 -1
  100. package/scripts/sw-pipeline-composer.sh +3 -1
  101. package/scripts/sw-pipeline-vitals.sh +4 -6
  102. package/scripts/sw-pipeline.sh +4 -1
  103. package/scripts/sw-pm.sh +5 -2
  104. package/scripts/sw-pr-lifecycle.sh +1 -1
  105. package/scripts/sw-predictive.sh +4 -1
  106. package/scripts/sw-prep.sh +3 -2
  107. package/scripts/sw-ps.sh +1 -1
  108. package/scripts/sw-public-dashboard.sh +10 -4
  109. package/scripts/sw-quality.sh +1 -1
  110. package/scripts/sw-reaper.sh +1 -1
  111. package/scripts/sw-recruit.sh +16 -0
  112. package/scripts/sw-regression.sh +2 -1
  113. package/scripts/sw-release-manager.sh +1 -1
  114. package/scripts/sw-release.sh +7 -5
  115. package/scripts/sw-remote.sh +1 -1
  116. package/scripts/sw-replay.sh +1 -1
  117. package/scripts/sw-retro.sh +1 -1
  118. package/scripts/sw-scale.sh +4 -1
  119. package/scripts/sw-security-audit.sh +1 -1
  120. package/scripts/sw-self-optimize.sh +15 -1
  121. package/scripts/sw-session.sh +1 -1
  122. package/scripts/sw-setup.sh +1 -1
  123. package/scripts/sw-standup.sh +2 -1
  124. package/scripts/sw-status.sh +1 -1
  125. package/scripts/sw-strategic.sh +2 -1
  126. package/scripts/sw-stream.sh +1 -1
  127. package/scripts/sw-swarm.sh +6 -1
  128. package/scripts/sw-team-stages.sh +1 -1
  129. package/scripts/sw-templates.sh +1 -1
  130. package/scripts/sw-testgen.sh +3 -2
  131. package/scripts/sw-tmux-pipeline.sh +2 -1
  132. package/scripts/sw-tmux.sh +1 -1
  133. package/scripts/sw-trace.sh +1 -1
  134. package/scripts/sw-tracker-jira.sh +1 -0
  135. package/scripts/sw-tracker-linear.sh +1 -0
  136. package/scripts/sw-tracker.sh +1 -1
  137. package/scripts/sw-triage.sh +1 -1
  138. package/scripts/sw-upgrade.sh +1 -1
  139. package/scripts/sw-ux.sh +1 -1
  140. package/scripts/sw-webhook.sh +1 -1
  141. package/scripts/sw-widgets.sh +2 -2
  142. package/scripts/sw-worktree.sh +1 -1
  143. package/dashboard/public/app.js +0 -4422
@@ -1,4422 +0,0 @@
1
- // ── Fleet Command Dashboard ─────────────────────────────────────
2
- // Multi-tab command center with WebSocket state + REST detail views
3
-
4
- const STAGES = [
5
- "intake",
6
- "plan",
7
- "design",
8
- "build",
9
- "test",
10
- "review",
11
- "compound_quality",
12
- "pr",
13
- "merge",
14
- "deploy",
15
- "monitor",
16
- ];
17
- const STAGE_SHORT = {
18
- intake: "INT",
19
- plan: "PLN",
20
- design: "DSN",
21
- build: "BLD",
22
- test: "TST",
23
- review: "REV",
24
- compound_quality: "QA",
25
- pr: "PR",
26
- merge: "MRG",
27
- deploy: "DPL",
28
- monitor: "MON",
29
- };
30
- const STAGE_COLORS = [
31
- "c-cyan",
32
- "c-blue",
33
- "c-purple",
34
- "c-green",
35
- "c-amber",
36
- "c-cyan",
37
- "c-blue",
38
- "c-purple",
39
- "c-green",
40
- "c-amber",
41
- "c-cyan",
42
- ];
43
- const STAGE_HEX = {
44
- intake: "#00d4ff",
45
- plan: "#0066ff",
46
- design: "#7c3aed",
47
- build: "#4ade80",
48
- test: "#fbbf24",
49
- review: "#00d4ff",
50
- compound_quality: "#0066ff",
51
- pr: "#7c3aed",
52
- merge: "#4ade80",
53
- deploy: "#fbbf24",
54
- monitor: "#00d4ff",
55
- };
56
-
57
- // ── State ───────────────────────────────────────────────────────
58
- var currentData = null;
59
- var activeTab = "overview";
60
- var metricsCache = null;
61
- var pipelineDetail = null;
62
- var selectedPipelineIssue = null;
63
- var activityEvents = [];
64
- var activityOffset = 0;
65
- var activityHasMore = false;
66
- var activityFilter = "all";
67
- var activityIssueFilter = "";
68
- var pipelineFilter = "all";
69
- var firstRender = true;
70
- var insightsCache = null;
71
- var selectedIssues = {};
72
- var alertsCache = null;
73
- var alertDismissed = false;
74
- var costBreakdownCache = null;
75
- var machinesCache = null;
76
- var joinTokensCache = null;
77
- var workerUpdateTimer = null;
78
- var removeMachineTarget = null;
79
- var teamCache = null;
80
- var teamActivityCache = null;
81
- var teamRefreshTimer = null;
82
-
83
- // ── WebSocket ───────────────────────────────────────────────────
84
- var wsUrl = "ws://" + location.host + "/ws";
85
- var ws;
86
- var reconnectDelay = 1000;
87
- var connectedAt = null;
88
- var connectionTimer = null;
89
-
90
- function connect() {
91
- ws = new WebSocket(wsUrl);
92
-
93
- ws.onopen = function () {
94
- reconnectDelay = 1000;
95
- connectedAt = Date.now();
96
- updateConnectionStatus("LIVE");
97
- startConnectionTimer();
98
- };
99
-
100
- ws.onclose = function () {
101
- connectedAt = null;
102
- stopConnectionTimer();
103
- updateConnectionStatus("OFFLINE");
104
- setTimeout(connect, reconnectDelay);
105
- reconnectDelay = Math.min(reconnectDelay * 2, 10000);
106
- };
107
-
108
- ws.onerror = function () {};
109
-
110
- ws.onmessage = function (e) {
111
- try {
112
- var data = JSON.parse(e.data);
113
- currentData = data;
114
- renderCostTicker(data);
115
- renderActiveTab();
116
- renderAlertBanner();
117
- updateEmergencyBrakeVisibility(data);
118
- if (data.team && activeTab === "team") {
119
- renderTeamGrid(data.team);
120
- renderTeamStats(data.team);
121
- }
122
- firstRender = false;
123
- } catch (err) {
124
- console.error("Failed to parse message:", err);
125
- }
126
- };
127
- }
128
-
129
- // ── Connection Timer ────────────────────────────────────────────
130
- function startConnectionTimer() {
131
- stopConnectionTimer();
132
- connectionTimer = setInterval(function () {
133
- if (connectedAt) {
134
- var elapsed = Math.floor((Date.now() - connectedAt) / 1000);
135
- var h = String(Math.floor(elapsed / 3600)).padStart(2, "0");
136
- var m = String(Math.floor((elapsed % 3600) / 60)).padStart(2, "0");
137
- var s = String(elapsed % 60).padStart(2, "0");
138
- document.getElementById("connection-text").textContent =
139
- "LIVE \u2014 " + h + ":" + m + ":" + s;
140
- }
141
- }, 1000);
142
- }
143
-
144
- function stopConnectionTimer() {
145
- if (connectionTimer) {
146
- clearInterval(connectionTimer);
147
- connectionTimer = null;
148
- }
149
- }
150
-
151
- function updateConnectionStatus(status) {
152
- var dot = document.getElementById("connection-dot");
153
- var text = document.getElementById("connection-text");
154
- if (status === "LIVE") {
155
- dot.className = "connection-dot live";
156
- text.textContent = "LIVE \u2014 00:00:00";
157
- } else {
158
- dot.className = "connection-dot offline";
159
- text.textContent = "OFFLINE";
160
- }
161
- }
162
-
163
- // ── Helpers ─────────────────────────────────────────────────────
164
- function formatDuration(s) {
165
- if (s == null) return "\u2014";
166
- s = Math.floor(s);
167
- if (s < 60) return s + "s";
168
- if (s < 3600) return Math.floor(s / 60) + "m " + (s % 60) + "s";
169
- return Math.floor(s / 3600) + "h " + Math.floor((s % 3600) / 60) + "m";
170
- }
171
-
172
- function formatTime(iso) {
173
- if (!iso) return "\u2014";
174
- var d = new Date(iso);
175
- var h = String(d.getHours()).padStart(2, "0");
176
- var m = String(d.getMinutes()).padStart(2, "0");
177
- var s = String(d.getSeconds()).padStart(2, "0");
178
- return h + ":" + m + ":" + s;
179
- }
180
-
181
- function escapeHtml(str) {
182
- if (!str) return "";
183
- return str
184
- .replace(/&/g, "&amp;")
185
- .replace(/</g, "&lt;")
186
- .replace(/>/g, "&gt;")
187
- .replace(/"/g, "&quot;");
188
- }
189
-
190
- function fmtNum(n) {
191
- if (n == null) return "0";
192
- return Number(n).toLocaleString();
193
- }
194
-
195
- function getBadgeClass(typeRaw) {
196
- if (typeRaw.includes("intervention")) return "intervention";
197
- if (typeRaw.includes("heartbeat")) return "heartbeat";
198
- if (typeRaw.includes("recovery") || typeRaw.includes("checkpoint"))
199
- return "recovery";
200
- if (typeRaw.includes("remote") || typeRaw.includes("distributed"))
201
- return "remote";
202
- if (typeRaw.includes("poll")) return "poll";
203
- if (typeRaw.includes("spawn")) return "spawn";
204
- if (typeRaw.includes("started")) return "started";
205
- if (typeRaw.includes("completed") || typeRaw.includes("reap"))
206
- return "completed";
207
- if (typeRaw.includes("failed")) return "failed";
208
- if (typeRaw.includes("stage")) return "stage";
209
- if (typeRaw.includes("scale")) return "scale";
210
- return "default";
211
- }
212
-
213
- function getTypeShort(typeRaw) {
214
- var parts = String(typeRaw || "unknown").split(".");
215
- return parts[parts.length - 1];
216
- }
217
-
218
- // ── Animated Number Counter ─────────────────────────────────────
219
- function animateValue(el, start, end, duration, suffix) {
220
- if (!el) return;
221
- if (typeof suffix === "undefined") suffix = "";
222
- var startTime = null;
223
- var diff = end - start;
224
-
225
- function step(timestamp) {
226
- if (!startTime) startTime = timestamp;
227
- var progress = Math.min((timestamp - startTime) / duration, 1);
228
- var current = Math.floor(start + diff * progress);
229
- el.textContent = fmtNum(current) + suffix;
230
- if (progress < 1) {
231
- requestAnimationFrame(step);
232
- }
233
- }
234
-
235
- if (diff === 0) {
236
- el.textContent = fmtNum(end) + suffix;
237
- return;
238
- }
239
- requestAnimationFrame(step);
240
- }
241
-
242
- // ── SVG Pipeline Visualization ──────────────────────────────────
243
- function renderPipelineSVG(pipeline) {
244
- var stagesDone = pipeline.stagesDone || [];
245
- var currentStage = pipeline.stage || "";
246
- var failed = pipeline.status === "failed";
247
-
248
- var nodeSpacing = 80;
249
- var nodeR = 14;
250
- var svgWidth = STAGES.length * nodeSpacing + 40;
251
- var svgHeight = 72;
252
- var yCenter = 28;
253
- var yLabel = 60;
254
-
255
- var svg =
256
- '<svg class="pipeline-svg" viewBox="0 0 ' +
257
- svgWidth +
258
- " " +
259
- svgHeight +
260
- '" width="100%" height="' +
261
- svgHeight +
262
- '" xmlns="http://www.w3.org/2000/svg">';
263
-
264
- // Connecting lines
265
- for (var i = 0; i < STAGES.length - 1; i++) {
266
- var x1 = 20 + i * nodeSpacing + nodeR;
267
- var x2 = 20 + (i + 1) * nodeSpacing - nodeR;
268
- var isDone = stagesDone.indexOf(STAGES[i]) !== -1;
269
- var lineColor = isDone ? "#4ade80" : "#1a3a6a";
270
- var dashAttr = isDone ? "" : ' stroke-dasharray="4,3"';
271
- svg +=
272
- '<line x1="' +
273
- x1 +
274
- '" y1="' +
275
- yCenter +
276
- '" x2="' +
277
- x2 +
278
- '" y2="' +
279
- yCenter +
280
- '" stroke="' +
281
- lineColor +
282
- '" stroke-width="2"' +
283
- dashAttr +
284
- "/>";
285
- }
286
-
287
- // Stage nodes
288
- for (var i = 0; i < STAGES.length; i++) {
289
- var s = STAGES[i];
290
- var cx = 20 + i * nodeSpacing;
291
- var isDone = stagesDone.indexOf(s) !== -1;
292
- var isActive = s === currentStage;
293
- var isFailed = failed && isActive;
294
-
295
- var fillColor = "#0d1f3c";
296
- var strokeColor = "#1a3a6a";
297
- var textColor = "#5a6d8a";
298
- var extra = "";
299
-
300
- if (isDone) {
301
- fillColor = "#4ade80";
302
- strokeColor = "#4ade80";
303
- textColor = "#060a14";
304
- } else if (isFailed) {
305
- fillColor = "#f43f5e";
306
- strokeColor = "#f43f5e";
307
- textColor = "#fff";
308
- } else if (isActive) {
309
- fillColor = "#00d4ff";
310
- strokeColor = "#00d4ff";
311
- textColor = "#060a14";
312
- extra = ' class="stage-node-active"';
313
- }
314
-
315
- // Glow filter for active
316
- if (isActive && !isFailed) {
317
- svg +=
318
- '<circle cx="' +
319
- cx +
320
- '" cy="' +
321
- yCenter +
322
- '" r="' +
323
- (nodeR + 4) +
324
- '" fill="none" stroke="' +
325
- strokeColor +
326
- '" stroke-width="1" opacity="0.3"' +
327
- extra +
328
- ">" +
329
- '<animate attributeName="r" values="' +
330
- (nodeR + 2) +
331
- ";" +
332
- (nodeR + 6) +
333
- ";" +
334
- (nodeR + 2) +
335
- '" dur="2s" repeatCount="indefinite"/>' +
336
- '<animate attributeName="opacity" values="0.3;0.1;0.3" dur="2s" repeatCount="indefinite"/>' +
337
- "</circle>";
338
- }
339
-
340
- svg +=
341
- '<circle cx="' +
342
- cx +
343
- '" cy="' +
344
- yCenter +
345
- '" r="' +
346
- nodeR +
347
- '" fill="' +
348
- fillColor +
349
- '" stroke="' +
350
- strokeColor +
351
- '" stroke-width="2"/>';
352
- svg +=
353
- '<text x="' +
354
- cx +
355
- '" y="' +
356
- (yCenter + 4) +
357
- '" text-anchor="middle" fill="' +
358
- textColor +
359
- '" font-family="\'JetBrains Mono\', monospace" font-size="8" font-weight="600">' +
360
- STAGE_SHORT[s] +
361
- "</text>";
362
- svg +=
363
- '<text x="' +
364
- cx +
365
- '" y="' +
366
- yLabel +
367
- '" text-anchor="middle" fill="#5a6d8a" font-family="\'JetBrains Mono\', monospace" font-size="7">' +
368
- escapeHtml(s === "compound_quality" ? "quality" : s) +
369
- "</text>";
370
- }
371
-
372
- svg += "</svg>";
373
- return svg;
374
- }
375
-
376
- // ── SVG Donut Chart ─────────────────────────────────────────────
377
- function renderSVGDonut(rate) {
378
- var size = 120;
379
- var strokeW = 12;
380
- var r = (size - strokeW) / 2;
381
- var c = Math.PI * 2 * r;
382
- var pct = Math.max(0, Math.min(100, rate));
383
- var offset = c - (pct / 100) * c;
384
-
385
- var svg =
386
- '<svg class="svg-donut" width="' +
387
- size +
388
- '" height="' +
389
- size +
390
- '" viewBox="0 0 ' +
391
- size +
392
- " " +
393
- size +
394
- '">';
395
- svg +=
396
- '<defs><linearGradient id="donut-grad" x1="0%" y1="0%" x2="100%" y2="100%"><stop offset="0%" stop-color="#00d4ff"/><stop offset="100%" stop-color="#7c3aed"/></linearGradient></defs>';
397
- // Background track
398
- svg +=
399
- '<circle cx="' +
400
- size / 2 +
401
- '" cy="' +
402
- size / 2 +
403
- '" r="' +
404
- r +
405
- '" fill="none" stroke="#0d1f3c" stroke-width="' +
406
- strokeW +
407
- '"/>';
408
- // Foreground arc
409
- svg +=
410
- '<circle cx="' +
411
- size / 2 +
412
- '" cy="' +
413
- size / 2 +
414
- '" r="' +
415
- r +
416
- '" fill="none" stroke="url(#donut-grad)" stroke-width="' +
417
- strokeW +
418
- '" stroke-linecap="round" stroke-dasharray="' +
419
- c +
420
- '" stroke-dashoffset="' +
421
- offset +
422
- '" transform="rotate(-90 ' +
423
- size / 2 +
424
- " " +
425
- size / 2 +
426
- ')" style="transition: stroke-dashoffset 0.8s ease"/>';
427
- // Center text
428
- svg +=
429
- '<text x="' +
430
- size / 2 +
431
- '" y="' +
432
- (size / 2 + 8) +
433
- '" text-anchor="middle" fill="#e8ecf4" font-family="\'Instrument Serif\', serif" font-size="24">' +
434
- pct.toFixed(1) +
435
- "%</text>";
436
- svg += "</svg>";
437
- return svg;
438
- }
439
-
440
- // ── SVG Bar Chart ───────────────────────────────────────────────
441
- function renderSVGBarChart(dailyCounts) {
442
- if (!dailyCounts || dailyCounts.length === 0) return "";
443
-
444
- var chartW = 700;
445
- var chartH = 100;
446
- var barGap = 4;
447
- var barW = Math.max(
448
- 8,
449
- (chartW - (dailyCounts.length - 1) * barGap) / dailyCounts.length,
450
- );
451
- var maxCount = 0;
452
- for (var i = 0; i < dailyCounts.length; i++) {
453
- var total = (dailyCounts[i].completed || 0) + (dailyCounts[i].failed || 0);
454
- if (total > maxCount) maxCount = total;
455
- }
456
- if (maxCount === 0) maxCount = 1;
457
-
458
- var svg =
459
- '<svg class="svg-bar-chart" viewBox="0 0 ' +
460
- chartW +
461
- " " +
462
- (chartH + 20) +
463
- '" width="100%" height="' +
464
- (chartH + 20) +
465
- '">';
466
-
467
- for (var i = 0; i < dailyCounts.length; i++) {
468
- var day = dailyCounts[i];
469
- var completed = day.completed || 0;
470
- var failed = day.failed || 0;
471
- var x = i * (barW + barGap);
472
- var cH = (completed / maxCount) * chartH;
473
- var fH = (failed / maxCount) * chartH;
474
-
475
- if (cH > 0) {
476
- svg +=
477
- '<rect x="' +
478
- x +
479
- '" y="' +
480
- (chartH - cH - fH) +
481
- '" width="' +
482
- barW +
483
- '" height="' +
484
- cH +
485
- '" rx="3" fill="#4ade80" opacity="0.85"/>';
486
- }
487
- if (fH > 0) {
488
- svg +=
489
- '<rect x="' +
490
- x +
491
- '" y="' +
492
- (chartH - fH) +
493
- '" width="' +
494
- barW +
495
- '" height="' +
496
- fH +
497
- '" rx="3" fill="#f43f5e" opacity="0.85"/>';
498
- }
499
- if (cH === 0 && fH === 0) {
500
- svg +=
501
- '<rect x="' +
502
- x +
503
- '" y="' +
504
- (chartH - 1) +
505
- '" width="' +
506
- barW +
507
- '" height="1" fill="#0d1f3c"/>';
508
- }
509
-
510
- // Date label
511
- var dateStr = day.date || "";
512
- var parts = dateStr.split("-");
513
- var label = parts.length >= 3 ? parts[1] + "/" + parts[2] : dateStr;
514
- svg +=
515
- '<text x="' +
516
- (x + barW / 2) +
517
- '" y="' +
518
- (chartH + 14) +
519
- '" text-anchor="middle" fill="#5a6d8a" font-family="\'JetBrains Mono\', monospace" font-size="8">' +
520
- escapeHtml(label) +
521
- "</text>";
522
- }
523
-
524
- svg += "</svg>";
525
- return svg;
526
- }
527
-
528
- // ── DORA Grade Badges ───────────────────────────────────────────
529
- function renderDoraGrades(dora) {
530
- if (!dora) return "";
531
-
532
- var metrics = [
533
- { key: "deploy_freq", label: "Deploy Frequency" },
534
- { key: "lead_time", label: "Lead Time" },
535
- { key: "cfr", label: "Change Failure Rate" },
536
- { key: "mttr", label: "Mean Time to Recovery" },
537
- ];
538
-
539
- var html = '<div class="dora-grades-row">';
540
- for (var i = 0; i < metrics.length; i++) {
541
- var m = metrics[i];
542
- var d = dora[m.key];
543
- if (!d) continue;
544
- var grade = (d.grade || "N/A").toLowerCase();
545
- var gradeClass = "dora-" + grade;
546
- html +=
547
- '<div class="dora-grade-card">' +
548
- '<span class="dora-grade-label">' +
549
- escapeHtml(m.label) +
550
- "</span>" +
551
- '<span class="dora-badge ' +
552
- gradeClass +
553
- '">' +
554
- escapeHtml(d.grade || "N/A") +
555
- "</span>" +
556
- '<span class="dora-grade-value">' +
557
- (d.value != null ? d.value.toFixed(1) : "\u2014") +
558
- " " +
559
- escapeHtml(d.unit || "") +
560
- "</span>" +
561
- "</div>";
562
- }
563
- html += "</div>";
564
- return html;
565
- }
566
-
567
- // ── User Menu ───────────────────────────────────────────────────
568
- var currentUser = null;
569
-
570
- function fetchUser() {
571
- fetch("/api/me")
572
- .then(function (r) {
573
- if (!r.ok) throw new Error(r.status);
574
- return r.json();
575
- })
576
- .then(function (user) {
577
- currentUser = user;
578
- var initialsEl = document.getElementById("avatar-initials");
579
- var avatarBtn = document.getElementById("user-avatar");
580
- var usernameEl = document.getElementById("dropdown-username");
581
-
582
- usernameEl.textContent = escapeHtml(user.name || user.username || "User");
583
-
584
- if (user.avatar_url) {
585
- var img = document.createElement("img");
586
- img.src = user.avatar_url;
587
- img.alt = escapeHtml(user.name || "User");
588
- avatarBtn.innerHTML = "";
589
- avatarBtn.appendChild(img);
590
- } else {
591
- var name = user.name || user.username || "?";
592
- var parts = name.split(" ");
593
- var initials =
594
- parts.length >= 2
595
- ? (parts[0][0] + parts[parts.length - 1][0]).toUpperCase()
596
- : name.substring(0, 2).toUpperCase();
597
- initialsEl.textContent = initials;
598
- }
599
- })
600
- .catch(function () {});
601
- }
602
-
603
- function setupUserMenu() {
604
- var avatar = document.getElementById("user-avatar");
605
- var dropdown = document.getElementById("user-dropdown");
606
-
607
- avatar.addEventListener("click", function (e) {
608
- e.stopPropagation();
609
- dropdown.classList.toggle("open");
610
- });
611
-
612
- document.addEventListener("click", function () {
613
- dropdown.classList.remove("open");
614
- });
615
- }
616
-
617
- // ══════════════════════════════════════════════════════════════════
618
- // TAB NAVIGATION
619
- // ══════════════════════════════════════════════════════════════════
620
-
621
- function setupTabs() {
622
- var btns = document.querySelectorAll(".tab-btn");
623
- for (var i = 0; i < btns.length; i++) {
624
- btns[i].addEventListener("click", function () {
625
- switchTab(this.getAttribute("data-tab"));
626
- });
627
- }
628
-
629
- // Read initial hash
630
- var hash = location.hash.replace("#", "");
631
- var validTabs = [
632
- "overview",
633
- "agents",
634
- "pipelines",
635
- "timeline",
636
- "activity",
637
- "metrics",
638
- "machines",
639
- "insights",
640
- "team",
641
- ];
642
- if (validTabs.indexOf(hash) !== -1) {
643
- switchTab(hash);
644
- }
645
-
646
- window.addEventListener("hashchange", function () {
647
- var hash = location.hash.replace("#", "");
648
- if (validTabs.indexOf(hash) !== -1 && hash !== activeTab) {
649
- switchTab(hash);
650
- }
651
- });
652
- }
653
-
654
- function switchTab(tab) {
655
- activeTab = tab;
656
- location.hash = "#" + tab;
657
-
658
- // Update tab buttons
659
- var btns = document.querySelectorAll(".tab-btn");
660
- for (var i = 0; i < btns.length; i++) {
661
- if (btns[i].getAttribute("data-tab") === tab) {
662
- btns[i].classList.add("active");
663
- } else {
664
- btns[i].classList.remove("active");
665
- }
666
- }
667
-
668
- // Update panels
669
- var panels = document.querySelectorAll(".tab-panel");
670
- for (var i = 0; i < panels.length; i++) {
671
- if (panels[i].id === "panel-" + tab) {
672
- panels[i].classList.add("active");
673
- } else {
674
- panels[i].classList.remove("active");
675
- }
676
- }
677
-
678
- // Trigger renders for the activated tab
679
- if (tab === "activity" && activityEvents.length === 0) {
680
- loadActivity();
681
- }
682
- if (tab === "metrics") {
683
- fetchMetrics();
684
- }
685
- if (tab === "timeline") {
686
- fetchTimeline();
687
- }
688
- if (tab === "insights") {
689
- fetchInsightsData();
690
- }
691
- if (tab === "machines") {
692
- fetchMachinesTab();
693
- }
694
- if (tab === "team") {
695
- fetchTeamData();
696
- if (teamRefreshTimer) clearInterval(teamRefreshTimer);
697
- teamRefreshTimer = setInterval(fetchTeamData, 10000);
698
- } else {
699
- if (teamRefreshTimer) {
700
- clearInterval(teamRefreshTimer);
701
- teamRefreshTimer = null;
702
- }
703
- }
704
- if (currentData) {
705
- renderActiveTab();
706
- }
707
- }
708
-
709
- function renderActiveTab() {
710
- if (!currentData) return;
711
- switch (activeTab) {
712
- case "overview":
713
- renderOverview(currentData);
714
- break;
715
- case "agents":
716
- renderAgentsTab(currentData);
717
- break;
718
- case "pipelines":
719
- renderPipelinesTab(currentData);
720
- break;
721
- case "timeline":
722
- // Timeline uses its own fetch; don't re-fetch on every WS push
723
- break;
724
- case "activity":
725
- // Activity tab uses its own data from /api/activity; just re-render filtered list
726
- renderActivityTimeline();
727
- break;
728
- case "metrics":
729
- // Metrics use cached data; don't re-fetch on every WS push
730
- break;
731
- case "machines":
732
- // Machines use cached data; don't re-fetch on every WS push
733
- if (machinesCache) renderMachinesTab(machinesCache);
734
- break;
735
- case "insights":
736
- // Insights use cached data; don't re-fetch on every WS push
737
- if (insightsCache) renderInsightsTab(insightsCache);
738
- break;
739
- case "team":
740
- // Team uses cached data; don't re-fetch on every WS push
741
- if (teamCache) {
742
- renderTeamGrid(teamCache);
743
- renderTeamStats(teamCache);
744
- }
745
- break;
746
- }
747
- }
748
-
749
- // ══════════════════════════════════════════════════════════════════
750
- // OVERVIEW TAB
751
- // ══════════════════════════════════════════════════════════════════
752
-
753
- function renderOverview(data) {
754
- renderStats(data);
755
- renderOverviewPipelines(data);
756
- renderQueue(data);
757
- renderOverviewActivity(data);
758
- renderResources(data);
759
- renderCostTicker(data);
760
- renderMachines(data);
761
- }
762
-
763
- // ── Stats ───────────────────────────────────────────────────────
764
- function renderStats(data) {
765
- var d = data.daemon || {};
766
- var m = data.metrics || {};
767
-
768
- var statusEl = document.getElementById("stat-status");
769
- var statusDot = document.getElementById("status-dot");
770
- if (d.running) {
771
- statusEl.textContent = "OPERATIONAL";
772
- statusEl.className = "stat-value status-green";
773
- statusDot.className = "pulse-dot operational";
774
- } else {
775
- statusEl.textContent = "OFFLINE";
776
- statusEl.className = "stat-value status-rose";
777
- statusDot.className = "pulse-dot offline";
778
- }
779
-
780
- var active = data.pipelines ? data.pipelines.length : 0;
781
- var max = d.maxParallel || 0;
782
- var activeEl = document.getElementById("stat-active");
783
- if (firstRender && active > 0) {
784
- animateValue(activeEl, 0, active, 600, " / " + fmtNum(max));
785
- } else {
786
- activeEl.textContent = fmtNum(active) + " / " + fmtNum(max);
787
- }
788
- var barPct = max > 0 ? Math.min((active / max) * 100, 100) : 0;
789
- document.getElementById("stat-active-bar").style.width = barPct + "%";
790
-
791
- var queued = data.queue ? data.queue.length : 0;
792
- var queueEl = document.getElementById("stat-queue");
793
- queueEl.textContent = fmtNum(queued);
794
- queueEl.className =
795
- queued > 0 ? "stat-value status-amber" : "stat-value status-green";
796
- document.getElementById("stat-queue-sub").textContent =
797
- queued === 1 ? "issue waiting" : "issues waiting";
798
-
799
- var completed = m.completed != null ? m.completed : 0;
800
- var completedEl = document.getElementById("stat-completed");
801
- if (firstRender && completed > 0) {
802
- animateValue(completedEl, 0, completed, 800, "");
803
- } else {
804
- completedEl.textContent = fmtNum(completed);
805
- }
806
- var failed = m.failed != null ? m.failed : 0;
807
- var failedSub = document.getElementById("stat-failed-sub");
808
- failedSub.textContent = fmtNum(failed) + " failed";
809
- failedSub.className =
810
- failed > 0 ? "stat-subtitle failed-some" : "stat-subtitle failed-none";
811
- }
812
-
813
- // ── Overview Pipeline Cards ─────────────────────────────────────
814
- function renderOverviewPipelines(data) {
815
- var container = document.getElementById("active-pipelines");
816
-
817
- if (!data.pipelines || data.pipelines.length === 0) {
818
- container.innerHTML =
819
- '<div class="empty-state">' +
820
- '<svg class="empty-icon" viewBox="0 0 24 24" width="32" height="32" fill="none" stroke="currentColor" stroke-width="1.5">' +
821
- '<path d="M12 6v6l4 2M12 2a10 10 0 100 20 10 10 0 000-20z"/>' +
822
- "</svg>" +
823
- "<p>No active pipelines</p>" +
824
- "</div>";
825
- return;
826
- }
827
-
828
- var html = "";
829
- for (var idx = 0; idx < data.pipelines.length; idx++) {
830
- var p = data.pipelines[idx];
831
-
832
- var maxIter = p.maxIterations || 20;
833
- var curIter = p.iteration || 0;
834
- var iterPct = maxIter > 0 ? Math.min((curIter / maxIter) * 100, 100) : 0;
835
-
836
- var linesText =
837
- p.linesWritten != null ? fmtNum(p.linesWritten) + " lines" : "";
838
- var testsText =
839
- p.testsPassing === true
840
- ? '<span class="tests-pass">Tests \u2713</span>'
841
- : p.testsPassing === false
842
- ? '<span class="tests-fail">Tests \u2717</span>'
843
- : "";
844
- var metaParts = [linesText, testsText].filter(Boolean);
845
-
846
- var animDelay = firstRender
847
- ? ' style="animation-delay:' + idx * 0.05 + 's"'
848
- : "";
849
-
850
- html +=
851
- '<div class="pipeline-card" data-issue="' +
852
- p.issue +
853
- '"' +
854
- animDelay +
855
- ">" +
856
- '<div class="pipeline-header">' +
857
- '<span class="pipeline-issue">#' +
858
- p.issue +
859
- "</span>" +
860
- '<span class="pipeline-title">' +
861
- escapeHtml(p.title) +
862
- "</span>" +
863
- '<span class="pipeline-elapsed">' +
864
- formatDuration(p.elapsed_s) +
865
- "</span>" +
866
- "</div>" +
867
- '<div class="pipeline-svg-wrap">' +
868
- renderPipelineSVG(p) +
869
- "</div>" +
870
- '<div class="pipeline-iter">' +
871
- '<span class="pipeline-iter-label">Iteration ' +
872
- curIter +
873
- "/" +
874
- maxIter +
875
- "</span>" +
876
- '<div class="iter-bar-track"><div class="iter-bar-fill" style="width:' +
877
- iterPct +
878
- '%"></div></div>' +
879
- "</div>" +
880
- '<div class="pipeline-meta">' +
881
- metaParts.join(" <span>\u00b7</span> ") +
882
- "</div>" +
883
- (p.worktree
884
- ? '<div class="pipeline-worktree">WORKTREE: ' +
885
- escapeHtml(p.worktree) +
886
- "</div>"
887
- : "") +
888
- "</div>";
889
- }
890
-
891
- container.innerHTML = html;
892
-
893
- // Click handlers to switch to pipelines tab and show detail
894
- var cards = container.querySelectorAll(".pipeline-card");
895
- for (var i = 0; i < cards.length; i++) {
896
- cards[i].addEventListener("click", function () {
897
- var issue = this.getAttribute("data-issue");
898
- switchTab("pipelines");
899
- fetchPipelineDetail(issue);
900
- });
901
- }
902
- }
903
-
904
- // ── Queue ───────────────────────────────────────────────────────
905
- function renderQueue(data) {
906
- var container = document.getElementById("queue-list");
907
-
908
- if (!data.queue || data.queue.length === 0) {
909
- container.innerHTML = '<div class="empty-state"><p>Queue clear</p></div>';
910
- return;
911
- }
912
-
913
- var html = "";
914
- for (var i = 0; i < data.queue.length; i++) {
915
- var q = data.queue[i];
916
- var costEst =
917
- q.estimated_cost != null
918
- ? ' <span class="queue-cost-est">~$' +
919
- q.estimated_cost.toFixed(2) +
920
- "</span>"
921
- : "";
922
- html +=
923
- '<div class="queue-row" data-queue-idx="' +
924
- i +
925
- '">' +
926
- '<span class="queue-issue">#' +
927
- q.issue +
928
- "</span>" +
929
- '<span class="queue-title-text">' +
930
- escapeHtml(q.title) +
931
- "</span>" +
932
- '<span class="queue-score">' +
933
- (q.score != null ? q.score : "\u2014") +
934
- "</span>" +
935
- costEst +
936
- "</div>";
937
- if (q.factors) {
938
- html +=
939
- '<div class="queue-scoring-detail" id="queue-detail-' +
940
- i +
941
- '" style="display:none">';
942
- html += renderScoringFactors(q.factors);
943
- html += "</div>";
944
- }
945
- }
946
- container.innerHTML = html;
947
-
948
- // Click handlers for expandable queue items
949
- var rows = container.querySelectorAll(".queue-row");
950
- for (var i = 0; i < rows.length; i++) {
951
- rows[i].addEventListener("click", function () {
952
- var idx = this.getAttribute("data-queue-idx");
953
- var detail = document.getElementById("queue-detail-" + idx);
954
- if (detail) {
955
- detail.style.display = detail.style.display === "none" ? "" : "none";
956
- }
957
- });
958
- }
959
- }
960
-
961
- function renderScoringFactors(factors) {
962
- if (!factors) return "";
963
- var keys = [
964
- "complexity",
965
- "impact",
966
- "priority",
967
- "age",
968
- "dependency",
969
- "memory",
970
- ];
971
- var html = '<div class="scoring-factors">';
972
- for (var i = 0; i < keys.length; i++) {
973
- var k = keys[i];
974
- var val = factors[k] != null ? factors[k] : 0;
975
- var pct = Math.max(0, Math.min(100, val));
976
- html +=
977
- '<div class="scoring-factor-row">' +
978
- '<span class="scoring-factor-label">' +
979
- escapeHtml(k) +
980
- "</span>" +
981
- '<div class="scoring-factor-track">' +
982
- '<div class="scoring-factor-fill" style="width:' +
983
- pct +
984
- '%"></div>' +
985
- "</div>" +
986
- '<span class="scoring-factor-val">' +
987
- pct +
988
- "</span>" +
989
- "</div>";
990
- }
991
- html += "</div>";
992
- return html;
993
- }
994
-
995
- // ── Overview Activity Feed (compact, 10 items) ──────────────────
996
- function renderOverviewActivity(data) {
997
- var container = document.getElementById("activity-feed");
998
-
999
- if (!data.events || data.events.length === 0) {
1000
- container.innerHTML =
1001
- '<div class="empty-state"><p>Awaiting events...</p></div>';
1002
- return;
1003
- }
1004
-
1005
- var events = data.events.slice(-10).reverse();
1006
- var html = "";
1007
- for (var i = 0; i < events.length; i++) {
1008
- var ev = events[i];
1009
- var typeRaw = String(ev.type || "unknown");
1010
- var typeShort = getTypeShort(typeRaw);
1011
- var badgeClass = getBadgeClass(typeRaw);
1012
-
1013
- var detail = "";
1014
- var skip = { ts: 1, type: 1, timestamp: 1 };
1015
- var keys = Object.keys(ev);
1016
- var dparts = [];
1017
- for (var k = 0; k < keys.length; k++) {
1018
- if (!skip[keys[k]]) dparts.push(keys[k] + "=" + ev[keys[k]]);
1019
- }
1020
- detail = dparts.join(" ");
1021
-
1022
- html +=
1023
- '<div class="activity-row">' +
1024
- '<span class="activity-ts">' +
1025
- formatTime(ev.ts || ev.timestamp) +
1026
- "</span>" +
1027
- '<span class="activity-badge ' +
1028
- badgeClass +
1029
- '">' +
1030
- escapeHtml(typeShort) +
1031
- "</span>" +
1032
- '<span class="activity-detail">' +
1033
- escapeHtml(detail) +
1034
- "</span>" +
1035
- "</div>";
1036
- }
1037
- container.innerHTML = html;
1038
- }
1039
-
1040
- // ── Resources ───────────────────────────────────────────────────
1041
- function renderResources(data) {
1042
- var s = data.scale || {};
1043
- var m = data.metrics || {};
1044
-
1045
- var cores = m.cpuCores || s.cpuCores || 0;
1046
- var maxByCpu = s.maxByCpu != null ? s.maxByCpu : null;
1047
- var maxByMem = s.maxByMem != null ? s.maxByMem : null;
1048
- var maxByBudget = s.maxByBudget != null ? s.maxByBudget : null;
1049
- var active = data.pipelines ? data.pipelines.length : 0;
1050
-
1051
- var cpuBar = document.getElementById("res-cpu-bar");
1052
- var cpuInfo = document.getElementById("res-cpu-info");
1053
- if (maxByCpu != null) {
1054
- var cpuPct = maxByCpu > 0 ? Math.min((active / maxByCpu) * 100, 100) : 0;
1055
- cpuBar.style.width = cpuPct + "%";
1056
- cpuBar.className = "resource-bar-fill";
1057
- cpuInfo.textContent = maxByCpu + " max (" + cores + " cores)";
1058
- } else {
1059
- cpuBar.style.width = "0%";
1060
- cpuInfo.textContent = "\u2014";
1061
- }
1062
-
1063
- var memBar = document.getElementById("res-mem-bar");
1064
- var memInfo = document.getElementById("res-mem-info");
1065
- if (maxByMem != null) {
1066
- var memPct = maxByMem > 0 ? Math.min((active / maxByMem) * 100, 100) : 0;
1067
- memBar.style.width = memPct + "%";
1068
- memBar.className =
1069
- maxByMem <= 1
1070
- ? "resource-bar-fill critical"
1071
- : maxByMem <= 2
1072
- ? "resource-bar-fill warning"
1073
- : "resource-bar-fill";
1074
- var memGb = s.availMemGb != null ? s.availMemGb + "GB free" : "";
1075
- memInfo.textContent = maxByMem + " max" + (memGb ? " (" + memGb + ")" : "");
1076
- } else {
1077
- memBar.style.width = "0%";
1078
- memInfo.textContent = "\u2014";
1079
- }
1080
-
1081
- var budgetBar = document.getElementById("res-budget-bar");
1082
- var budgetInfo = document.getElementById("res-budget-info");
1083
- if (maxByBudget != null) {
1084
- var budgetPct =
1085
- maxByBudget > 0 ? Math.min((active / maxByBudget) * 100, 100) : 0;
1086
- budgetBar.style.width = budgetPct + "%";
1087
- budgetBar.className = "resource-bar-fill";
1088
- budgetInfo.textContent = maxByBudget + " max";
1089
- } else {
1090
- budgetBar.style.width = "0%";
1091
- budgetInfo.textContent = "unlimited";
1092
- }
1093
-
1094
- var constraintEl = document.getElementById("resource-constraint");
1095
- if (maxByMem != null && maxByCpu != null) {
1096
- var minFactor = Math.min(
1097
- maxByCpu || Infinity,
1098
- maxByMem || Infinity,
1099
- maxByBudget != null ? maxByBudget : Infinity,
1100
- );
1101
- if (minFactor === maxByMem && maxByMem <= 2) {
1102
- constraintEl.innerHTML =
1103
- '<span class="constraint-badge warning">MEM-BOUND</span>';
1104
- } else if (maxByBudget != null && minFactor === maxByBudget) {
1105
- constraintEl.innerHTML =
1106
- '<span class="constraint-badge warning">BUDGET-BOUND</span>';
1107
- } else {
1108
- constraintEl.innerHTML =
1109
- '<span class="constraint-badge nominal">NOMINAL</span>';
1110
- }
1111
- } else {
1112
- constraintEl.innerHTML =
1113
- '<span class="constraint-badge nominal">NOMINAL</span>';
1114
- }
1115
- }
1116
-
1117
- // ══════════════════════════════════════════════════════════════════
1118
- // PIPELINES TAB
1119
- // ══════════════════════════════════════════════════════════════════
1120
-
1121
- function setupPipelineFilters() {
1122
- var chips = document.querySelectorAll("#pipeline-filters .filter-chip");
1123
- for (var i = 0; i < chips.length; i++) {
1124
- chips[i].addEventListener("click", function () {
1125
- pipelineFilter = this.getAttribute("data-filter");
1126
- var siblings = document.querySelectorAll(
1127
- "#pipeline-filters .filter-chip",
1128
- );
1129
- for (var j = 0; j < siblings.length; j++)
1130
- siblings[j].classList.remove("active");
1131
- this.classList.add("active");
1132
- if (currentData) renderPipelinesTab(currentData);
1133
- });
1134
- }
1135
-
1136
- document
1137
- .getElementById("detail-panel-close")
1138
- .addEventListener("click", function () {
1139
- closePipelineDetail();
1140
- });
1141
- }
1142
-
1143
- function renderPipelinesTab(data) {
1144
- var tbody = document.getElementById("pipeline-table-body");
1145
- var pipelines = data.pipelines || [];
1146
- var events = data.events || [];
1147
-
1148
- // Build unified list: active pipelines + completed/failed from events
1149
- var rows = [];
1150
-
1151
- // Active pipelines
1152
- for (var i = 0; i < pipelines.length; i++) {
1153
- var p = pipelines[i];
1154
- rows.push({
1155
- issue: p.issue,
1156
- title: p.title || "",
1157
- status: "active",
1158
- stage: STAGE_SHORT[p.stage] || p.stage || "\u2014",
1159
- elapsed_s: p.elapsed_s,
1160
- branch: p.worktree || "",
1161
- _raw: p,
1162
- });
1163
- }
1164
-
1165
- // Completed/failed from events
1166
- var seen = {};
1167
- for (var i = 0; i < rows.length; i++) seen[rows[i].issue] = true;
1168
-
1169
- for (var i = events.length - 1; i >= 0; i--) {
1170
- var ev = events[i];
1171
- if (!ev.issue || seen[ev.issue]) continue;
1172
- var typeRaw = String(ev.type || "");
1173
- if (typeRaw.includes("completed") || typeRaw.includes("failed")) {
1174
- var st = typeRaw.includes("failed") ? "failed" : "completed";
1175
- rows.push({
1176
- issue: ev.issue,
1177
- title: ev.issueTitle || ev.title || "",
1178
- status: st,
1179
- stage: st === "completed" ? "DONE" : "FAIL",
1180
- elapsed_s: ev.duration_s || null,
1181
- branch: "",
1182
- _raw: ev,
1183
- });
1184
- seen[ev.issue] = true;
1185
- }
1186
- }
1187
-
1188
- // Filter
1189
- var filtered = rows;
1190
- if (pipelineFilter !== "all") {
1191
- filtered = [];
1192
- for (var i = 0; i < rows.length; i++) {
1193
- if (rows[i].status === pipelineFilter) filtered.push(rows[i]);
1194
- }
1195
- }
1196
-
1197
- if (filtered.length === 0) {
1198
- tbody.innerHTML =
1199
- '<tr><td colspan="7" class="empty-state"><p>No pipelines match filter</p></td></tr>';
1200
- return;
1201
- }
1202
-
1203
- var html = "";
1204
- for (var i = 0; i < filtered.length; i++) {
1205
- var r = filtered[i];
1206
- var selectedClass = selectedPipelineIssue == r.issue ? " row-selected" : "";
1207
- var isChecked = selectedIssues[r.issue] ? " checked" : "";
1208
- html +=
1209
- '<tr class="pipeline-row' +
1210
- selectedClass +
1211
- '" data-issue="' +
1212
- r.issue +
1213
- '">' +
1214
- '<td class="col-checkbox"><input type="checkbox" class="pipeline-checkbox" data-issue="' +
1215
- r.issue +
1216
- '"' +
1217
- isChecked +
1218
- "></td>" +
1219
- '<td class="col-issue">#' +
1220
- r.issue +
1221
- "</td>" +
1222
- '<td class="col-title">' +
1223
- escapeHtml(r.title) +
1224
- "</td>" +
1225
- '<td><span class="status-badge ' +
1226
- r.status +
1227
- '">' +
1228
- r.status.toUpperCase() +
1229
- "</span></td>" +
1230
- '<td class="col-stage">' +
1231
- escapeHtml(r.stage) +
1232
- "</td>" +
1233
- '<td class="col-duration">' +
1234
- formatDuration(r.elapsed_s) +
1235
- "</td>" +
1236
- '<td class="col-branch">' +
1237
- escapeHtml(r.branch) +
1238
- "</td>" +
1239
- "</tr>";
1240
- }
1241
- tbody.innerHTML = html;
1242
-
1243
- // Checkbox handlers
1244
- var checkboxes = tbody.querySelectorAll(".pipeline-checkbox");
1245
- for (var i = 0; i < checkboxes.length; i++) {
1246
- checkboxes[i].addEventListener("change", function (e) {
1247
- e.stopPropagation();
1248
- var iss = this.getAttribute("data-issue");
1249
- if (this.checked) {
1250
- selectedIssues[iss] = true;
1251
- } else {
1252
- delete selectedIssues[iss];
1253
- }
1254
- updateBulkToolbar();
1255
- });
1256
- checkboxes[i].addEventListener("click", function (e) {
1257
- e.stopPropagation();
1258
- });
1259
- }
1260
-
1261
- // Select-all checkbox
1262
- var selectAll = document.getElementById("select-all-pipelines");
1263
- if (selectAll) {
1264
- selectAll.addEventListener("change", function () {
1265
- var cbs = tbody.querySelectorAll(".pipeline-checkbox");
1266
- for (var j = 0; j < cbs.length; j++) {
1267
- cbs[j].checked = this.checked;
1268
- var iss = cbs[j].getAttribute("data-issue");
1269
- if (this.checked) {
1270
- selectedIssues[iss] = true;
1271
- } else {
1272
- delete selectedIssues[iss];
1273
- }
1274
- }
1275
- updateBulkToolbar();
1276
- });
1277
- }
1278
-
1279
- // Click handlers
1280
- var trs = tbody.querySelectorAll(".pipeline-row");
1281
- for (var i = 0; i < trs.length; i++) {
1282
- trs[i].addEventListener("click", function () {
1283
- var issue = this.getAttribute("data-issue");
1284
- if (selectedPipelineIssue == issue) {
1285
- closePipelineDetail();
1286
- } else {
1287
- fetchPipelineDetail(issue);
1288
- }
1289
- });
1290
- }
1291
- }
1292
-
1293
- function fetchPipelineDetail(issue) {
1294
- selectedPipelineIssue = issue;
1295
-
1296
- // Highlight row
1297
- var trs = document.querySelectorAll("#pipeline-table-body .pipeline-row");
1298
- for (var i = 0; i < trs.length; i++) {
1299
- if (trs[i].getAttribute("data-issue") == issue) {
1300
- trs[i].classList.add("row-selected");
1301
- } else {
1302
- trs[i].classList.remove("row-selected");
1303
- }
1304
- }
1305
-
1306
- var panel = document.getElementById("pipeline-detail-panel");
1307
- var title = document.getElementById("detail-panel-title");
1308
- var body = document.getElementById("detail-panel-body");
1309
-
1310
- title.textContent = "Pipeline #" + issue;
1311
- body.innerHTML = '<div class="empty-state"><p>Loading...</p></div>';
1312
- panel.classList.add("open");
1313
-
1314
- fetch("/api/pipeline/" + encodeURIComponent(issue))
1315
- .then(function (r) {
1316
- if (!r.ok) throw new Error("HTTP " + r.status);
1317
- return r.json();
1318
- })
1319
- .then(function (detail) {
1320
- pipelineDetail = detail;
1321
- renderPipelineDetail(detail);
1322
- })
1323
- .catch(function (err) {
1324
- body.innerHTML =
1325
- '<div class="empty-state"><p>Failed to load: ' +
1326
- escapeHtml(String(err)) +
1327
- "</p></div>";
1328
- });
1329
- }
1330
-
1331
- function renderPipelineDetail(detail) {
1332
- var body = document.getElementById("detail-panel-body");
1333
- var html = "";
1334
- var issue = detail.issue || selectedPipelineIssue;
1335
-
1336
- // GitHub status banner at top
1337
- html +=
1338
- '<div id="github-status-' + issue + '" class="github-status-banner"></div>';
1339
-
1340
- // SVG pipeline visualization at top of detail
1341
- html +=
1342
- '<div class="pipeline-svg-wrap">' +
1343
- renderPipelineSVG({
1344
- stagesDone: (detail.stageHistory || []).map(function (h) {
1345
- return h.stage;
1346
- }),
1347
- stage: detail.stage,
1348
- status: detail.status || "",
1349
- }) +
1350
- "</div>";
1351
-
1352
- // Error highlight for failed stages
1353
- if (detail.status === "failed" || detail.error) {
1354
- html +=
1355
- '<div id="error-highlight-' +
1356
- issue +
1357
- '" class="error-highlight-box"></div>';
1358
- }
1359
-
1360
- // Stage timeline
1361
- var history = detail.stageHistory || [];
1362
- if (history.length > 0) {
1363
- html += '<div class="stage-timeline">';
1364
- for (var i = 0; i < history.length; i++) {
1365
- var sh = history[i];
1366
- var isActive = sh.stage === detail.stage;
1367
- var dotCls = isActive ? "active" : "done";
1368
- html +=
1369
- '<div class="stage-timeline-item">' +
1370
- '<div class="stage-timeline-dot ' +
1371
- dotCls +
1372
- '"></div>' +
1373
- '<span class="stage-timeline-name">' +
1374
- escapeHtml(sh.stage) +
1375
- "</span>" +
1376
- '<span class="stage-timeline-duration">' +
1377
- formatDuration(sh.duration_s) +
1378
- "</span>" +
1379
- "</div>";
1380
- }
1381
- html += "</div>";
1382
- }
1383
-
1384
- // Meta row
1385
- html += '<div class="detail-meta-row">';
1386
- if (detail.branch) {
1387
- html +=
1388
- '<div class="detail-meta-item">Branch: <span>' +
1389
- escapeHtml(detail.branch) +
1390
- "</span></div>";
1391
- }
1392
- if (detail.elapsed_s != null) {
1393
- html +=
1394
- '<div class="detail-meta-item">Elapsed: <span>' +
1395
- formatDuration(detail.elapsed_s) +
1396
- "</span></div>";
1397
- }
1398
- if (detail.prLink) {
1399
- html +=
1400
- '<div class="detail-meta-item">PR: <a href="' +
1401
- escapeHtml(detail.prLink) +
1402
- '" target="_blank">' +
1403
- escapeHtml(detail.prLink) +
1404
- "</a></div>";
1405
- }
1406
- html += "</div>";
1407
-
1408
- // Failure pattern match box
1409
- if (detail.failurePatterns && detail.failurePatterns.length > 0) {
1410
- html +=
1411
- '<div class="detail-section pattern-match-box">' +
1412
- '<div class="detail-section-label">MATCHED FAILURE PATTERNS</div>';
1413
- for (var fp = 0; fp < detail.failurePatterns.length; fp++) {
1414
- var pat = detail.failurePatterns[fp];
1415
- html +=
1416
- '<div class="pattern-match-item">' +
1417
- '<span class="pattern-match-desc">' +
1418
- escapeHtml(pat.description || pat.pattern || "") +
1419
- "</span>" +
1420
- (pat.fix
1421
- ? '<span class="pattern-match-fix">Fix: ' +
1422
- escapeHtml(pat.fix) +
1423
- "</span>"
1424
- : "") +
1425
- "</div>";
1426
- }
1427
- html += "</div>";
1428
- }
1429
-
1430
- // Artifact viewer tabs (replaces static plan/design/dod)
1431
- html += renderArtifactViewer(issue, detail);
1432
-
1433
- body.innerHTML = html;
1434
-
1435
- // Async: fetch GitHub status
1436
- if (issue) {
1437
- renderGitHubStatus(issue);
1438
- }
1439
-
1440
- // Async: fetch error highlight for failed pipelines
1441
- if (issue && (detail.status === "failed" || detail.error)) {
1442
- renderErrorHighlight(issue);
1443
- }
1444
-
1445
- // Setup artifact tab clicks
1446
- setupArtifactTabs(issue);
1447
- }
1448
-
1449
- function closePipelineDetail() {
1450
- selectedPipelineIssue = null;
1451
- pipelineDetail = null;
1452
- document.getElementById("pipeline-detail-panel").classList.remove("open");
1453
-
1454
- var trs = document.querySelectorAll("#pipeline-table-body .pipeline-row");
1455
- for (var i = 0; i < trs.length; i++) {
1456
- trs[i].classList.remove("row-selected");
1457
- }
1458
- }
1459
-
1460
- // ══════════════════════════════════════════════════════════════════
1461
- // ACTIVITY TAB
1462
- // ══════════════════════════════════════════════════════════════════
1463
-
1464
- function setupActivityFilters() {
1465
- var chips = document.querySelectorAll("#activity-filters .filter-chip");
1466
- for (var i = 0; i < chips.length; i++) {
1467
- chips[i].addEventListener("click", function () {
1468
- activityFilter = this.getAttribute("data-filter");
1469
- var siblings = document.querySelectorAll(
1470
- "#activity-filters .filter-chip",
1471
- );
1472
- for (var j = 0; j < siblings.length; j++)
1473
- siblings[j].classList.remove("active");
1474
- this.classList.add("active");
1475
- renderActivityTimeline();
1476
- });
1477
- }
1478
-
1479
- document
1480
- .getElementById("activity-issue-filter")
1481
- .addEventListener("input", function () {
1482
- activityIssueFilter = this.value.replace(/[^0-9]/g, "");
1483
- renderActivityTimeline();
1484
- });
1485
-
1486
- document
1487
- .getElementById("load-more-btn")
1488
- .addEventListener("click", function () {
1489
- loadMoreActivity();
1490
- });
1491
- }
1492
-
1493
- function loadActivity() {
1494
- activityOffset = 0;
1495
- activityEvents = [];
1496
-
1497
- fetch("/api/activity?limit=50&offset=0")
1498
- .then(function (r) {
1499
- if (!r.ok) throw new Error("HTTP " + r.status);
1500
- return r.json();
1501
- })
1502
- .then(function (result) {
1503
- activityEvents = result.events || [];
1504
- activityHasMore = result.hasMore || false;
1505
- activityOffset = activityEvents.length;
1506
- renderActivityTimeline();
1507
- })
1508
- .catch(function (err) {
1509
- document.getElementById("activity-timeline").innerHTML =
1510
- '<div class="empty-state"><p>Failed to load: ' +
1511
- escapeHtml(String(err)) +
1512
- "</p></div>";
1513
- });
1514
- }
1515
-
1516
- function loadMoreActivity() {
1517
- var btn = document.getElementById("load-more-btn");
1518
- btn.disabled = true;
1519
- btn.textContent = "Loading...";
1520
-
1521
- fetch("/api/activity?limit=50&offset=" + activityOffset)
1522
- .then(function (r) {
1523
- if (!r.ok) throw new Error("HTTP " + r.status);
1524
- return r.json();
1525
- })
1526
- .then(function (result) {
1527
- var newEvents = result.events || [];
1528
- for (var i = 0; i < newEvents.length; i++) {
1529
- activityEvents.push(newEvents[i]);
1530
- }
1531
- activityHasMore = result.hasMore || false;
1532
- activityOffset = activityEvents.length;
1533
- renderActivityTimeline();
1534
- btn.disabled = false;
1535
- btn.textContent = "Load more";
1536
- })
1537
- .catch(function () {
1538
- btn.disabled = false;
1539
- btn.textContent = "Load more";
1540
- });
1541
- }
1542
-
1543
- function renderActivityTimeline() {
1544
- var container = document.getElementById("activity-timeline");
1545
- var loadMoreWrap = document.getElementById("activity-load-more");
1546
-
1547
- // Filter events
1548
- var filtered = [];
1549
- for (var i = 0; i < activityEvents.length; i++) {
1550
- var ev = activityEvents[i];
1551
- var typeRaw = String(ev.type || "");
1552
- var badge = getBadgeClass(typeRaw);
1553
-
1554
- // Type filter
1555
- if (activityFilter !== "all") {
1556
- if (badge !== activityFilter && !typeRaw.includes(activityFilter))
1557
- continue;
1558
- }
1559
-
1560
- // Issue filter
1561
- if (activityIssueFilter && String(ev.issue || "") !== activityIssueFilter)
1562
- continue;
1563
-
1564
- filtered.push(ev);
1565
- }
1566
-
1567
- if (filtered.length === 0) {
1568
- container.innerHTML =
1569
- '<div class="empty-state"><p>No matching events</p></div>';
1570
- loadMoreWrap.style.display = activityHasMore ? "" : "none";
1571
- return;
1572
- }
1573
-
1574
- var html = "";
1575
- for (var i = 0; i < filtered.length; i++) {
1576
- var ev = filtered[i];
1577
- var typeRaw = String(ev.type || "unknown");
1578
- var typeShort = getTypeShort(typeRaw);
1579
- var badgeClass = getBadgeClass(typeRaw);
1580
-
1581
- var detail = "";
1582
- if (ev.stage) detail += "stage=" + ev.stage + " ";
1583
- if (ev.issueTitle) detail += ev.issueTitle;
1584
- else if (ev.title) detail += ev.title;
1585
- detail = detail.trim();
1586
-
1587
- // Remaining keys
1588
- if (!detail) {
1589
- var skip = {
1590
- ts: 1,
1591
- type: 1,
1592
- timestamp: 1,
1593
- issue: 1,
1594
- stage: 1,
1595
- duration_s: 1,
1596
- issueTitle: 1,
1597
- title: 1,
1598
- };
1599
- var keys = Object.keys(ev);
1600
- var dparts = [];
1601
- for (var k = 0; k < keys.length; k++) {
1602
- if (!skip[keys[k]]) dparts.push(keys[k] + "=" + ev[keys[k]]);
1603
- }
1604
- detail = dparts.join(" ");
1605
- }
1606
-
1607
- html +=
1608
- '<div class="timeline-row">' +
1609
- '<span class="timeline-ts">' +
1610
- formatTime(ev.ts || ev.timestamp) +
1611
- "</span>" +
1612
- '<span class="activity-badge ' +
1613
- badgeClass +
1614
- '">' +
1615
- escapeHtml(typeShort) +
1616
- "</span>" +
1617
- (ev.issue
1618
- ? '<span class="timeline-issue">#' + ev.issue + "</span>"
1619
- : "") +
1620
- '<span class="timeline-detail">' +
1621
- escapeHtml(detail) +
1622
- "</span>" +
1623
- (ev.duration_s != null
1624
- ? '<span class="timeline-duration">' +
1625
- formatDuration(ev.duration_s) +
1626
- "</span>"
1627
- : "") +
1628
- "</div>";
1629
- }
1630
-
1631
- container.innerHTML = html;
1632
- loadMoreWrap.style.display = activityHasMore ? "" : "none";
1633
- }
1634
-
1635
- // ══════════════════════════════════════════════════════════════════
1636
- // METRICS TAB
1637
- // ══════════════════════════════════════════════════════════════════
1638
-
1639
- function fetchMetrics() {
1640
- fetch("/api/metrics/history")
1641
- .then(function (r) {
1642
- if (!r.ok) throw new Error("HTTP " + r.status);
1643
- return r.json();
1644
- })
1645
- .then(function (data) {
1646
- metricsCache = data;
1647
- renderMetrics(data);
1648
- })
1649
- .catch(function (err) {
1650
- document.getElementById("metrics-grid").innerHTML =
1651
- '<div class="empty-state"><p>Failed to load metrics: ' +
1652
- escapeHtml(String(err)) +
1653
- "</p></div>";
1654
- });
1655
- }
1656
-
1657
- function renderMetrics(data) {
1658
- // Success rate — SVG donut
1659
- var rate = data.success_rate != null ? data.success_rate : 0;
1660
- var donutWrap = document.getElementById("metric-donut-wrap");
1661
- if (donutWrap) {
1662
- donutWrap.innerHTML = renderSVGDonut(rate);
1663
- } else {
1664
- // Fallback: use the CSS donut
1665
- var donut = document.getElementById("metric-donut");
1666
- if (donut) {
1667
- donut.style.setProperty("--pct", rate + "%");
1668
- var rateEl = document.getElementById("metric-success-rate");
1669
- if (rateEl) rateEl.textContent = rate.toFixed(1) + "%";
1670
- }
1671
- }
1672
-
1673
- // Avg duration
1674
- var avgDurEl = document.getElementById("metric-avg-duration");
1675
- if (avgDurEl) {
1676
- avgDurEl.textContent = formatDuration(data.avg_duration_s);
1677
- }
1678
-
1679
- // Throughput
1680
- var tp = data.throughput_per_hour != null ? data.throughput_per_hour : 0;
1681
- var tpEl = document.getElementById("metric-throughput");
1682
- if (tpEl) tpEl.textContent = tp.toFixed(2);
1683
-
1684
- // Totals
1685
- var totalCompleted = data.total_completed != null ? data.total_completed : 0;
1686
- var totalFailed = data.total_failed != null ? data.total_failed : 0;
1687
- var tcEl = document.getElementById("metric-total-completed");
1688
- if (tcEl) {
1689
- if (firstRender && totalCompleted > 0) {
1690
- animateValue(tcEl, 0, totalCompleted, 800, "");
1691
- } else {
1692
- tcEl.textContent = fmtNum(totalCompleted);
1693
- }
1694
- }
1695
- var failedEl = document.getElementById("metric-total-failed");
1696
- if (failedEl) {
1697
- failedEl.textContent = fmtNum(totalFailed) + " failed";
1698
- failedEl.style.color = totalFailed > 0 ? "var(--rose)" : "";
1699
- }
1700
-
1701
- // Stage duration breakdown — SVG bars
1702
- renderStageBreakdown(data.stage_durations || {});
1703
-
1704
- // Daily chart — SVG
1705
- renderDailyChart(data.daily_counts || []);
1706
-
1707
- // DORA grades
1708
- var doraContainer = document.getElementById("dora-grades-container");
1709
- if (doraContainer && data.dora_grades) {
1710
- doraContainer.innerHTML = renderDoraGrades(data.dora_grades);
1711
- doraContainer.style.display = "";
1712
- } else if (doraContainer) {
1713
- doraContainer.style.display = "none";
1714
- }
1715
-
1716
- // Phase 2: Cost breakdown and trend
1717
- var costBreakdownEl = document.getElementById("cost-breakdown-container");
1718
- if (costBreakdownEl) {
1719
- renderCostBreakdown();
1720
- }
1721
- var costTrendEl = document.getElementById("cost-trend-container");
1722
- if (costTrendEl) {
1723
- renderCostTrend();
1724
- }
1725
-
1726
- // Phase 2: DORA trend sparklines
1727
- var doraTrendEl = document.getElementById("dora-trend-container");
1728
- if (doraTrendEl) {
1729
- renderDoraTrend();
1730
- }
1731
-
1732
- // Phase 4: Stage performance, bottleneck, throughput, capacity
1733
- var stagePerfEl = document.getElementById("stage-performance-container");
1734
- if (stagePerfEl) {
1735
- renderStagePerformance();
1736
- }
1737
- var bottleneckEl = document.getElementById("bottleneck-alert-container");
1738
- if (bottleneckEl) {
1739
- renderBottleneckAlert();
1740
- }
1741
- var throughputEl = document.getElementById("throughput-trend-container");
1742
- if (throughputEl) {
1743
- renderThroughputTrend();
1744
- }
1745
- var capacityEl = document.getElementById("capacity-forecast-container");
1746
- if (capacityEl) {
1747
- renderCapacityForecast();
1748
- }
1749
- }
1750
-
1751
- function renderStageBreakdown(stageDurations) {
1752
- var container = document.getElementById("stage-breakdown");
1753
- var keys = Object.keys(stageDurations);
1754
- if (keys.length === 0) {
1755
- container.innerHTML = '<div class="empty-state"><p>No data</p></div>';
1756
- return;
1757
- }
1758
-
1759
- // Find max for scaling
1760
- var maxVal = 0;
1761
- for (var i = 0; i < keys.length; i++) {
1762
- if (stageDurations[keys[i]] > maxVal) maxVal = stageDurations[keys[i]];
1763
- }
1764
- if (maxVal === 0) maxVal = 1;
1765
-
1766
- var html = "";
1767
- for (var i = 0; i < keys.length; i++) {
1768
- var stage = keys[i];
1769
- var val = stageDurations[stage];
1770
- var pct = (val / maxVal) * 100;
1771
- var colorIdx = i % STAGE_COLORS.length;
1772
-
1773
- html +=
1774
- '<div class="stage-bar-row">' +
1775
- '<span class="stage-bar-label">' +
1776
- escapeHtml(stage) +
1777
- "</span>" +
1778
- '<div class="stage-bar-track-h">' +
1779
- '<div class="stage-bar-fill-h ' +
1780
- STAGE_COLORS[colorIdx] +
1781
- '" style="width:' +
1782
- pct +
1783
- '%"></div>' +
1784
- "</div>" +
1785
- '<span class="stage-bar-value">' +
1786
- formatDuration(val) +
1787
- "</span>" +
1788
- "</div>";
1789
- }
1790
-
1791
- container.innerHTML = html;
1792
- }
1793
-
1794
- function renderDailyChart(dailyCounts) {
1795
- var container = document.getElementById("daily-chart");
1796
-
1797
- if (!dailyCounts || dailyCounts.length === 0) {
1798
- container.innerHTML = '<div class="empty-state"><p>No data</p></div>';
1799
- return;
1800
- }
1801
-
1802
- container.innerHTML = renderSVGBarChart(dailyCounts);
1803
- }
1804
-
1805
- // ══════════════════════════════════════════════════════════════════
1806
- // AGENTS TAB
1807
- // ══════════════════════════════════════════════════════════════════
1808
-
1809
- function renderAgentsTab(data) {
1810
- var container = document.getElementById("agents-grid");
1811
- var agents = data.agents || [];
1812
-
1813
- if (agents.length === 0) {
1814
- container.innerHTML =
1815
- '<div class="empty-state">' +
1816
- '<svg class="empty-icon" viewBox="0 0 24 24" width="32" height="32" fill="none" stroke="currentColor" stroke-width="1.5">' +
1817
- '<path d="M17 21v-2a4 4 0 00-4-4H5a4 4 0 00-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 00-3-3.87"/><path d="M16 3.13a4 4 0 010 7.75"/>' +
1818
- "</svg>" +
1819
- "<p>No active agents. Start a pipeline to see agents here.</p>" +
1820
- "</div>";
1821
- return;
1822
- }
1823
-
1824
- var html = "";
1825
- for (var i = 0; i < agents.length; i++) {
1826
- var a = agents[i];
1827
- var presenceClass = a.status || "dead";
1828
- var elapsed = a.elapsed_s ? formatDuration(a.elapsed_s) : "\u2014";
1829
- var memPct =
1830
- a.memory_mb > 0 ? Math.min((a.memory_mb / 2048) * 100, 100) : 0;
1831
- var cpuPct = a.cpu_pct || 0;
1832
-
1833
- html +=
1834
- '<div class="agent-card" data-issue="' +
1835
- a.issue +
1836
- '">' +
1837
- '<div class="agent-card-header">' +
1838
- '<span class="presence-dot ' +
1839
- presenceClass +
1840
- '"></span>' +
1841
- '<span class="agent-issue">#' +
1842
- a.issue +
1843
- "</span>" +
1844
- '<span class="agent-machine">' +
1845
- escapeHtml(a.machine || "localhost") +
1846
- "</span>" +
1847
- "</div>" +
1848
- '<div class="agent-title">' +
1849
- escapeHtml(a.title || "Untitled") +
1850
- "</div>" +
1851
- '<div class="agent-stage">' +
1852
- '<span class="agent-stage-badge">' +
1853
- escapeHtml(a.stage || "\u2014") +
1854
- "</span>" +
1855
- '<span class="agent-iteration">iter ' +
1856
- (a.iteration || 0) +
1857
- "</span>" +
1858
- "</div>" +
1859
- '<div class="agent-activity">' +
1860
- escapeHtml(a.activity || "\u2014") +
1861
- "</div>" +
1862
- '<div class="agent-resources">' +
1863
- '<div class="agent-res-row">' +
1864
- '<span class="agent-res-label">CPU</span>' +
1865
- '<div class="resource-bar-track"><div class="resource-bar-fill" style="width:' +
1866
- cpuPct +
1867
- '%"></div></div>' +
1868
- '<span class="agent-res-val">' +
1869
- cpuPct.toFixed(0) +
1870
- "%</span>" +
1871
- "</div>" +
1872
- '<div class="agent-res-row">' +
1873
- '<span class="agent-res-label">MEM</span>' +
1874
- '<div class="resource-bar-track"><div class="resource-bar-fill" style="width:' +
1875
- memPct.toFixed(0) +
1876
- '%"></div></div>' +
1877
- '<span class="agent-res-val">' +
1878
- a.memory_mb +
1879
- "MB</span>" +
1880
- "</div>" +
1881
- "</div>" +
1882
- '<div class="agent-meta">' +
1883
- '<span class="agent-elapsed">' +
1884
- elapsed +
1885
- "</span>" +
1886
- '<span class="agent-heartbeat">' +
1887
- (a.heartbeat_age_s != null
1888
- ? a.heartbeat_age_s + "s ago"
1889
- : "no heartbeat") +
1890
- "</span>" +
1891
- "</div>" +
1892
- '<div class="agent-actions">' +
1893
- '<button class="agent-action-btn" onclick="sendIntervention(' +
1894
- a.issue +
1895
- ', \'pause\')" title="Pause">&#9646;&#9646;</button>' +
1896
- '<button class="agent-action-btn" onclick="sendIntervention(' +
1897
- a.issue +
1898
- ', \'resume\')" title="Resume">&#9654;</button>' +
1899
- '<button class="agent-action-btn" onclick="openInterventionModal(' +
1900
- a.issue +
1901
- ')" title="Message">&#9993;</button>' +
1902
- '<button class="agent-action-btn btn-abort" onclick="confirmAbort(' +
1903
- a.issue +
1904
- ')" title="Abort">&#10005;</button>' +
1905
- "</div>" +
1906
- "</div>";
1907
- }
1908
-
1909
- container.innerHTML = html;
1910
- }
1911
-
1912
- // ══════════════════════════════════════════════════════════════════
1913
- // TIMELINE TAB
1914
- // ══════════════════════════════════════════════════════════════════
1915
-
1916
- var timelineCache = null;
1917
-
1918
- function fetchTimeline() {
1919
- var rangeEl = document.getElementById("timeline-range");
1920
- var hours = rangeEl ? rangeEl.value : "24";
1921
- fetch("/api/timeline?range=" + hours + "h")
1922
- .then(function (r) {
1923
- if (!r.ok) throw new Error("HTTP " + r.status);
1924
- return r.json();
1925
- })
1926
- .then(function (data) {
1927
- timelineCache = data;
1928
- renderTimelineTab(data);
1929
- })
1930
- .catch(function (err) {
1931
- document.getElementById("gantt-chart").innerHTML =
1932
- '<div class="empty-state"><p>Failed to load timeline: ' +
1933
- escapeHtml(String(err)) +
1934
- "</p></div>";
1935
- });
1936
- }
1937
-
1938
- function renderTimelineTab(data) {
1939
- var container = document.getElementById("gantt-chart");
1940
- var entries = data;
1941
-
1942
- if (!Array.isArray(entries)) entries = data.timeline || [];
1943
- if (entries.length === 0) {
1944
- container.innerHTML =
1945
- '<div class="empty-state"><p>No timeline data</p></div>';
1946
- return;
1947
- }
1948
-
1949
- // Calculate time range
1950
- var rangeEl = document.getElementById("timeline-range");
1951
- var rangeHours = rangeEl ? parseInt(rangeEl.value, 10) : 24;
1952
- var now = Date.now();
1953
- var rangeStart = now - rangeHours * 3600 * 1000;
1954
- var rangeMs = now - rangeStart;
1955
-
1956
- // Build hour markers
1957
- var markerCount = Math.min(rangeHours, 12);
1958
- var markerStep = rangeHours / markerCount;
1959
- var headerHtml =
1960
- '<div class="gantt-header"><span class="gantt-label-header">Issue</span><div class="gantt-bar-header">';
1961
- for (var m = 0; m <= markerCount; m++) {
1962
- var markerTime = new Date(rangeStart + m * markerStep * 3600 * 1000);
1963
- var markerLabel = padZero(markerTime.getHours()) + ":00";
1964
- var markerPct = (m / markerCount) * 100;
1965
- headerHtml +=
1966
- '<span class="gantt-marker" style="left:' +
1967
- markerPct +
1968
- '%">' +
1969
- markerLabel +
1970
- "</span>";
1971
- }
1972
- headerHtml += "</div></div>";
1973
-
1974
- // Build rows
1975
- var rowsHtml = "";
1976
- for (var i = 0; i < entries.length; i++) {
1977
- var entry = entries[i];
1978
- var segments = entry.segments || [];
1979
-
1980
- rowsHtml +=
1981
- '<div class="gantt-row">' +
1982
- '<span class="gantt-label">#' +
1983
- entry.issue +
1984
- '<span class="gantt-label-title">' +
1985
- escapeHtml(truncate(entry.title || "", 20)) +
1986
- "</span></span>" +
1987
- '<div class="gantt-bar-area">';
1988
-
1989
- for (var s = 0; s < segments.length; s++) {
1990
- var seg = segments[s];
1991
- if (!seg.start) continue;
1992
- var segStart = new Date(seg.start).getTime();
1993
- var segEnd = seg.end ? new Date(seg.end).getTime() : now;
1994
-
1995
- // Clamp to visible range
1996
- if (segEnd < rangeStart) continue;
1997
- if (segStart < rangeStart) segStart = rangeStart;
1998
-
1999
- var leftPct = ((segStart - rangeStart) / rangeMs) * 100;
2000
- var widthPct = ((segEnd - segStart) / rangeMs) * 100;
2001
- if (widthPct < 0.3) widthPct = 0.3; // min visible width
2002
-
2003
- var statusClass =
2004
- seg.status === "failed"
2005
- ? "failed"
2006
- : seg.status === "running"
2007
- ? "running"
2008
- : "done";
2009
- var segDuration = formatDuration(Math.round((segEnd - segStart) / 1000));
2010
-
2011
- rowsHtml +=
2012
- '<div class="gantt-segment ' +
2013
- statusClass +
2014
- '" style="left:' +
2015
- leftPct.toFixed(2) +
2016
- "%;width:" +
2017
- widthPct.toFixed(2) +
2018
- '%" title="' +
2019
- escapeHtml(seg.stage) +
2020
- " \u2014 " +
2021
- segDuration +
2022
- '">' +
2023
- '<span class="gantt-seg-label">' +
2024
- escapeHtml(seg.stage) +
2025
- "</span>" +
2026
- "</div>";
2027
- }
2028
-
2029
- rowsHtml += "</div></div>";
2030
- }
2031
-
2032
- container.innerHTML = headerHtml + rowsHtml;
2033
- }
2034
-
2035
- function setupTimelineControls() {
2036
- // Support both select and segmented control
2037
- var rangeEl = document.getElementById("timeline-range");
2038
- if (rangeEl) {
2039
- rangeEl.addEventListener("change", function () {
2040
- fetchTimeline();
2041
- });
2042
- }
2043
-
2044
- // Segmented control buttons
2045
- var segBtns = document.querySelectorAll(".timeline-seg-btn");
2046
- for (var i = 0; i < segBtns.length; i++) {
2047
- segBtns[i].addEventListener("click", function () {
2048
- var val = this.getAttribute("data-value");
2049
- // Update hidden select
2050
- if (rangeEl) rangeEl.value = val;
2051
- // Update active state
2052
- var siblings = document.querySelectorAll(".timeline-seg-btn");
2053
- for (var j = 0; j < siblings.length; j++)
2054
- siblings[j].classList.remove("active");
2055
- this.classList.add("active");
2056
- fetchTimeline();
2057
- });
2058
- }
2059
- }
2060
-
2061
- // ══════════════════════════════════════════════════════════════════
2062
- // COST TICKER
2063
- // ══════════════════════════════════════════════════════════════════
2064
-
2065
- function renderCostTicker(data) {
2066
- var ticker = document.getElementById("cost-ticker");
2067
- if (!ticker) return;
2068
-
2069
- var cost = data.cost;
2070
- if (!cost || cost.daily_budget == null) {
2071
- ticker.innerHTML = "";
2072
- return;
2073
- }
2074
-
2075
- var spent = cost.today_spent || 0;
2076
- var budget = cost.daily_budget || 1;
2077
- var pct = budget > 0 ? Math.min((spent / budget) * 100, 100) : 0;
2078
- var statusClass =
2079
- pct >= 80 ? "cost-over" : pct >= 60 ? "cost-warn" : "cost-ok";
2080
-
2081
- ticker.innerHTML =
2082
- '<span class="cost-amount">$' +
2083
- spent.toFixed(2) +
2084
- "</span>" +
2085
- '<span class="cost-sep"> / </span>' +
2086
- '<span class="cost-budget">$' +
2087
- budget.toFixed(2) +
2088
- "</span>" +
2089
- '<div class="cost-bar-track"><div class="cost-bar-fill ' +
2090
- statusClass +
2091
- '" style="width:' +
2092
- pct.toFixed(0) +
2093
- '%"></div></div>';
2094
- }
2095
-
2096
- // ══════════════════════════════════════════════════════════════════
2097
- // MACHINES
2098
- // ══════════════════════════════════════════════════════════════════
2099
-
2100
- function renderMachines(data) {
2101
- var section = document.getElementById("machines-section");
2102
- var grid = document.getElementById("machines-grid");
2103
- if (!section || !grid) return;
2104
-
2105
- var machines = data.machines || [];
2106
- if (machines.length === 0) {
2107
- section.style.display = "none";
2108
- return;
2109
- }
2110
-
2111
- section.style.display = "";
2112
- var html = "";
2113
- for (var i = 0; i < machines.length; i++) {
2114
- var m = machines[i];
2115
- var statusClass = m.status || "offline";
2116
-
2117
- html +=
2118
- '<div class="machine-card">' +
2119
- '<div class="machine-card-header">' +
2120
- '<span class="presence-dot ' +
2121
- statusClass +
2122
- '"></span>' +
2123
- '<span class="machine-name">' +
2124
- escapeHtml(m.name) +
2125
- "</span>" +
2126
- '<span class="machine-role">' +
2127
- escapeHtml(m.role || "worker") +
2128
- "</span>" +
2129
- "</div>" +
2130
- '<div class="machine-host">' +
2131
- escapeHtml(m.host || "\u2014") +
2132
- "</div>" +
2133
- '<div class="machine-workers">' +
2134
- '<span class="machine-workers-label">Workers:</span> ' +
2135
- (m.active_workers || 0) +
2136
- " / " +
2137
- (m.max_workers || 0) +
2138
- "</div>" +
2139
- "</div>";
2140
- }
2141
-
2142
- grid.innerHTML = html;
2143
- }
2144
-
2145
- // ── Machines Tab Functions ────────────────────────────────────────
2146
-
2147
- function fetchMachinesTab() {
2148
- fetch("/api/machines")
2149
- .then(function (r) {
2150
- return r.json();
2151
- })
2152
- .then(function (data) {
2153
- machinesCache = data;
2154
- renderMachinesTab(data);
2155
- })
2156
- .catch(function (err) {
2157
- console.error("Failed to fetch machines:", err);
2158
- });
2159
- fetchJoinTokens();
2160
- }
2161
-
2162
- function fetchJoinTokens() {
2163
- fetch("/api/join-tokens")
2164
- .then(function (r) {
2165
- return r.json();
2166
- })
2167
- .then(function (data) {
2168
- joinTokensCache = data;
2169
- renderJoinTokens(data || []);
2170
- })
2171
- .catch(function () {
2172
- /* ignore */
2173
- });
2174
- }
2175
-
2176
- function renderMachinesTab(machines) {
2177
- var summaryEl = document.getElementById("machines-summary");
2178
- var gridEl = document.getElementById("machines-tab-grid");
2179
- if (!summaryEl || !gridEl) return;
2180
-
2181
- if (!machines || machines.length === 0) {
2182
- summaryEl.innerHTML = "";
2183
- gridEl.innerHTML =
2184
- '<div class="empty-state"><p>No machines registered. Click <strong>+ Add Machine</strong> to get started.</p></div>';
2185
- return;
2186
- }
2187
-
2188
- summaryEl.innerHTML = renderMachineSummary(machines);
2189
-
2190
- var cardsHtml = "";
2191
- for (var i = 0; i < machines.length; i++) {
2192
- cardsHtml += renderMachineCard(machines[i]);
2193
- }
2194
- gridEl.innerHTML = cardsHtml;
2195
- }
2196
-
2197
- function renderMachineSummary(machines) {
2198
- var totalMachines = machines.length;
2199
- var totalMaxWorkers = 0;
2200
- var totalActiveWorkers = 0;
2201
- var onlineCount = 0;
2202
- for (var i = 0; i < machines.length; i++) {
2203
- totalMaxWorkers += machines[i].max_workers || 0;
2204
- totalActiveWorkers += machines[i].active_workers || 0;
2205
- if (machines[i].status === "online") onlineCount++;
2206
- }
2207
-
2208
- return (
2209
- '<div class="machines-summary-card">' +
2210
- '<div class="stat-value">' +
2211
- totalMachines +
2212
- "</div>" +
2213
- '<div class="stat-label">Total Machines</div>' +
2214
- "</div>" +
2215
- '<div class="machines-summary-card">' +
2216
- '<div class="stat-value">' +
2217
- onlineCount +
2218
- "</div>" +
2219
- '<div class="stat-label">Online</div>' +
2220
- "</div>" +
2221
- '<div class="machines-summary-card">' +
2222
- '<div class="stat-value">' +
2223
- totalActiveWorkers +
2224
- " / " +
2225
- totalMaxWorkers +
2226
- "</div>" +
2227
- '<div class="stat-label">Active / Max Workers</div>' +
2228
- "</div>"
2229
- );
2230
- }
2231
-
2232
- function renderMachineCard(machine) {
2233
- var name = machine.name || "";
2234
- var host = machine.host || "\u2014";
2235
- var role = machine.role || "worker";
2236
- var status = machine.status || "offline";
2237
- var maxWorkers = machine.max_workers || 4;
2238
- var activeWorkers = machine.active_workers || 0;
2239
- var health = machine.health || {};
2240
- var daemonRunning = health.daemon_running || false;
2241
- var heartbeatCount = health.heartbeat_count || 0;
2242
- var lastHbAge = health.last_heartbeat_s_ago;
2243
- var lastHbText = "\u2014";
2244
- if (typeof lastHbAge === "number" && lastHbAge < 9999) {
2245
- if (lastHbAge < 60) lastHbText = lastHbAge + "s ago";
2246
- else if (lastHbAge < 3600)
2247
- lastHbText = Math.floor(lastHbAge / 60) + "m ago";
2248
- else lastHbText = Math.floor(lastHbAge / 3600) + "h ago";
2249
- }
2250
-
2251
- return (
2252
- '<div class="machine-card" id="machine-card-' +
2253
- escapeHtml(name) +
2254
- '">' +
2255
- '<div class="machine-card-header">' +
2256
- '<span class="presence-dot ' +
2257
- status +
2258
- '"></span>' +
2259
- '<span class="machine-name">' +
2260
- escapeHtml(name) +
2261
- "</span>" +
2262
- '<span class="machine-role">' +
2263
- escapeHtml(role) +
2264
- "</span>" +
2265
- "</div>" +
2266
- '<div class="machine-host">' +
2267
- escapeHtml(host) +
2268
- "</div>" +
2269
- '<div class="machine-workers-section">' +
2270
- '<div class="machine-workers-label-row">' +
2271
- "<span>Workers</span>" +
2272
- '<span class="workers-count">' +
2273
- activeWorkers +
2274
- " / " +
2275
- maxWorkers +
2276
- "</span>" +
2277
- "</div>" +
2278
- '<input type="range" class="workers-slider" min="1" max="64" value="' +
2279
- maxWorkers +
2280
- '"' +
2281
- " oninput=\"updateWorkerCount('" +
2282
- escapeHtml(name) +
2283
- "', this.value)\"" +
2284
- ' title="Max workers" />' +
2285
- "</div>" +
2286
- '<div class="machine-health">' +
2287
- '<div class="machine-health-row">' +
2288
- '<span class="health-label">Daemon</span>' +
2289
- '<span class="health-status ' +
2290
- (daemonRunning ? "running" : "stopped") +
2291
- '">' +
2292
- (daemonRunning ? "Running" : "Stopped") +
2293
- "</span>" +
2294
- "</div>" +
2295
- '<div class="machine-health-row">' +
2296
- '<span class="health-label">Heartbeats</span>' +
2297
- '<span class="health-value">' +
2298
- heartbeatCount +
2299
- "</span>" +
2300
- "</div>" +
2301
- '<div class="machine-health-row">' +
2302
- '<span class="health-label">Last heartbeat</span>' +
2303
- '<span class="health-value">' +
2304
- lastHbText +
2305
- "</span>" +
2306
- "</div>" +
2307
- "</div>" +
2308
- '<div class="machine-card-actions">' +
2309
- '<button class="machine-action-btn" onclick="machineHealthCheck(\'' +
2310
- escapeHtml(name) +
2311
- "')\">Check</button>" +
2312
- '<button class="machine-action-btn danger" onclick="confirmMachineRemove(\'' +
2313
- escapeHtml(name) +
2314
- "')\">Remove</button>" +
2315
- "</div>" +
2316
- "</div>"
2317
- );
2318
- }
2319
-
2320
- function renderJoinTokens(tokens) {
2321
- var section = document.getElementById("join-tokens-section");
2322
- var list = document.getElementById("join-tokens-list");
2323
- if (!section || !list) return;
2324
-
2325
- if (!tokens || tokens.length === 0) {
2326
- section.style.display = "none";
2327
- return;
2328
- }
2329
-
2330
- section.style.display = "";
2331
- var html = "";
2332
- for (var i = 0; i < tokens.length; i++) {
2333
- var t = tokens[i];
2334
- var label = t.label || "Unlabeled";
2335
- var created = t.created_at
2336
- ? new Date(t.created_at).toLocaleDateString()
2337
- : "\u2014";
2338
- var used = t.used ? "Claimed" : "Active";
2339
- var usedClass = t.used ? "c-amber" : "c-green";
2340
- html +=
2341
- '<div class="join-token-row">' +
2342
- '<span class="join-token-label">' +
2343
- escapeHtml(label) +
2344
- "</span>" +
2345
- '<span class="join-token-created">' +
2346
- created +
2347
- "</span>" +
2348
- '<span class="join-token-status ' +
2349
- usedClass +
2350
- '">' +
2351
- used +
2352
- "</span>" +
2353
- "</div>";
2354
- }
2355
- list.innerHTML = html;
2356
- }
2357
-
2358
- function openAddMachineModal() {
2359
- document.getElementById("add-machine-modal").style.display = "flex";
2360
- document.getElementById("machine-name").value = "";
2361
- document.getElementById("machine-host").value = "";
2362
- document.getElementById("machine-ssh-user").value = "";
2363
- document.getElementById("machine-path").value = "";
2364
- document.getElementById("machine-workers").value = "4";
2365
- document.getElementById("machine-role").value = "worker";
2366
- document.getElementById("machine-modal-error").style.display = "none";
2367
- }
2368
-
2369
- function closeAddMachineModal() {
2370
- document.getElementById("add-machine-modal").style.display = "none";
2371
- }
2372
-
2373
- function submitAddMachine() {
2374
- var name = document.getElementById("machine-name").value.trim();
2375
- var host = document.getElementById("machine-host").value.trim();
2376
- var sshUser = document.getElementById("machine-ssh-user").value.trim();
2377
- var swPath = document.getElementById("machine-path").value.trim();
2378
- var maxWorkers =
2379
- parseInt(document.getElementById("machine-workers").value, 10) || 4;
2380
- var role = document.getElementById("machine-role").value;
2381
- var errEl = document.getElementById("machine-modal-error");
2382
-
2383
- if (!name || !host) {
2384
- errEl.textContent = "Name and host are required";
2385
- errEl.style.display = "";
2386
- return;
2387
- }
2388
-
2389
- var body = { name: name, host: host, role: role, max_workers: maxWorkers };
2390
- if (sshUser) body.ssh_user = sshUser;
2391
- if (swPath) body.shipwright_path = swPath;
2392
-
2393
- fetch("/api/machines", {
2394
- method: "POST",
2395
- headers: { "Content-Type": "application/json" },
2396
- body: JSON.stringify(body),
2397
- })
2398
- .then(function (r) {
2399
- if (!r.ok)
2400
- return r.json().then(function (d) {
2401
- throw new Error(d.error || "Failed");
2402
- });
2403
- return r.json();
2404
- })
2405
- .then(function () {
2406
- closeAddMachineModal();
2407
- fetchMachinesTab();
2408
- })
2409
- .catch(function (err) {
2410
- errEl.textContent = err.message || "Failed to register machine";
2411
- errEl.style.display = "";
2412
- });
2413
- }
2414
-
2415
- function updateWorkerCount(name, value) {
2416
- if (workerUpdateTimer) clearTimeout(workerUpdateTimer);
2417
- workerUpdateTimer = setTimeout(function () {
2418
- fetch("/api/machines/" + encodeURIComponent(name), {
2419
- method: "PATCH",
2420
- headers: { "Content-Type": "application/json" },
2421
- body: JSON.stringify({ max_workers: parseInt(value, 10) }),
2422
- })
2423
- .then(function (r) {
2424
- return r.json();
2425
- })
2426
- .then(function (updated) {
2427
- // Update the count display in the card
2428
- var card = document.getElementById("machine-card-" + name);
2429
- if (card) {
2430
- var countEl = card.querySelector(".workers-count");
2431
- if (countEl) {
2432
- countEl.textContent =
2433
- (updated.active_workers || 0) +
2434
- " / " +
2435
- (updated.max_workers || value);
2436
- }
2437
- }
2438
- })
2439
- .catch(function (err) {
2440
- console.error("Worker update failed:", err);
2441
- });
2442
- }, 500);
2443
- }
2444
-
2445
- function machineHealthCheck(name) {
2446
- var card = document.getElementById("machine-card-" + name);
2447
- if (card) {
2448
- var checkBtn = card.querySelector(".machine-action-btn");
2449
- if (checkBtn) {
2450
- checkBtn.textContent = "Checking\u2026";
2451
- checkBtn.disabled = true;
2452
- }
2453
- }
2454
-
2455
- fetch("/api/machines/" + encodeURIComponent(name) + "/health-check", {
2456
- method: "POST",
2457
- headers: { "Content-Type": "application/json" },
2458
- })
2459
- .then(function (r) {
2460
- return r.json();
2461
- })
2462
- .then(function (result) {
2463
- if (result.machine && card) {
2464
- var m = result.machine;
2465
- var health = m.health || {};
2466
- var daemonRunning = health.daemon_running || false;
2467
- var heartbeatCount = health.heartbeat_count || 0;
2468
- var lastHbAge = health.last_heartbeat_s_ago;
2469
- var lastHbText = "\u2014";
2470
- if (typeof lastHbAge === "number" && lastHbAge < 9999) {
2471
- if (lastHbAge < 60) lastHbText = lastHbAge + "s ago";
2472
- else if (lastHbAge < 3600)
2473
- lastHbText = Math.floor(lastHbAge / 60) + "m ago";
2474
- else lastHbText = Math.floor(lastHbAge / 3600) + "h ago";
2475
- }
2476
-
2477
- var healthRows = card.querySelectorAll(".machine-health-row");
2478
- if (healthRows.length >= 3) {
2479
- healthRows[0].querySelector(".health-status").className =
2480
- "health-status " + (daemonRunning ? "running" : "stopped");
2481
- healthRows[0].querySelector(".health-status").textContent =
2482
- daemonRunning ? "Running" : "Stopped";
2483
- healthRows[1].querySelector(".health-value").textContent =
2484
- heartbeatCount;
2485
- healthRows[2].querySelector(".health-value").textContent = lastHbText;
2486
- }
2487
-
2488
- // Update presence dot
2489
- var dot = card.querySelector(".presence-dot");
2490
- if (dot) {
2491
- dot.className = "presence-dot " + (m.status || "offline");
2492
- }
2493
- }
2494
- // Reset button
2495
- if (card) {
2496
- var btn = card.querySelector(".machine-action-btn");
2497
- if (btn) {
2498
- btn.textContent = "Check";
2499
- btn.disabled = false;
2500
- }
2501
- }
2502
- })
2503
- .catch(function (err) {
2504
- console.error("Health check failed:", err);
2505
- if (card) {
2506
- var btn = card.querySelector(".machine-action-btn");
2507
- if (btn) {
2508
- btn.textContent = "Check";
2509
- btn.disabled = false;
2510
- }
2511
- }
2512
- });
2513
- }
2514
-
2515
- function confirmMachineRemove(name) {
2516
- removeMachineTarget = name;
2517
- document.getElementById("remove-machine-name").textContent = name;
2518
- document.getElementById("remove-stop-daemon").checked = false;
2519
- document.getElementById("remove-machine-modal").style.display = "flex";
2520
- }
2521
-
2522
- function executeRemoveMachine() {
2523
- if (!removeMachineTarget) return;
2524
- var name = removeMachineTarget;
2525
-
2526
- fetch("/api/machines/" + encodeURIComponent(name), {
2527
- method: "DELETE",
2528
- headers: { "Content-Type": "application/json" },
2529
- })
2530
- .then(function (r) {
2531
- if (!r.ok)
2532
- return r.json().then(function (d) {
2533
- throw new Error(d.error || "Failed");
2534
- });
2535
- return r.json();
2536
- })
2537
- .then(function () {
2538
- document.getElementById("remove-machine-modal").style.display = "none";
2539
- removeMachineTarget = null;
2540
- fetchMachinesTab();
2541
- })
2542
- .catch(function (err) {
2543
- console.error("Remove machine failed:", err);
2544
- document.getElementById("remove-machine-modal").style.display = "none";
2545
- removeMachineTarget = null;
2546
- });
2547
- }
2548
-
2549
- function openJoinLinkModal() {
2550
- document.getElementById("join-link-modal").style.display = "flex";
2551
- document.getElementById("join-label").value = "";
2552
- document.getElementById("join-workers").value = "4";
2553
- document.getElementById("join-command-display").style.display = "none";
2554
- document.getElementById("join-command-text").textContent = "";
2555
- }
2556
-
2557
- function closeJoinLinkModal() {
2558
- document.getElementById("join-link-modal").style.display = "none";
2559
- }
2560
-
2561
- function generateJoinLink() {
2562
- var label = document.getElementById("join-label").value.trim();
2563
- var maxWorkers =
2564
- parseInt(document.getElementById("join-workers").value, 10) || 4;
2565
- var generateBtn = document.getElementById("join-modal-generate");
2566
- generateBtn.textContent = "Generating\u2026";
2567
- generateBtn.disabled = true;
2568
-
2569
- fetch("/api/join-token", {
2570
- method: "POST",
2571
- headers: { "Content-Type": "application/json" },
2572
- body: JSON.stringify({ label: label, max_workers: maxWorkers }),
2573
- })
2574
- .then(function (r) {
2575
- return r.json();
2576
- })
2577
- .then(function (data) {
2578
- document.getElementById("join-command-text").textContent =
2579
- data.join_cmd || "";
2580
- document.getElementById("join-command-display").style.display = "";
2581
- generateBtn.textContent = "Generate";
2582
- generateBtn.disabled = false;
2583
- // Refresh token list
2584
- fetchJoinTokens();
2585
- })
2586
- .catch(function (err) {
2587
- console.error("Generate join link failed:", err);
2588
- generateBtn.textContent = "Generate";
2589
- generateBtn.disabled = false;
2590
- });
2591
- }
2592
-
2593
- function copyJoinCommand() {
2594
- var text = document.getElementById("join-command-text").textContent;
2595
- if (text && navigator.clipboard) {
2596
- navigator.clipboard.writeText(text).then(function () {
2597
- var btn = document.getElementById("join-copy-btn");
2598
- btn.textContent = "Copied!";
2599
- setTimeout(function () {
2600
- btn.textContent = "Copy";
2601
- }, 2000);
2602
- });
2603
- }
2604
- }
2605
-
2606
- function setupMachinesTab() {
2607
- var addBtn = document.getElementById("btn-add-machine");
2608
- if (addBtn) addBtn.addEventListener("click", openAddMachineModal);
2609
-
2610
- var joinBtn = document.getElementById("btn-join-link");
2611
- if (joinBtn) joinBtn.addEventListener("click", openJoinLinkModal);
2612
-
2613
- var machineModalClose = document.getElementById("machine-modal-close");
2614
- if (machineModalClose)
2615
- machineModalClose.addEventListener("click", closeAddMachineModal);
2616
-
2617
- var machineModalCancel = document.getElementById("machine-modal-cancel");
2618
- if (machineModalCancel)
2619
- machineModalCancel.addEventListener("click", closeAddMachineModal);
2620
-
2621
- var machineModalSubmit = document.getElementById("machine-modal-submit");
2622
- if (machineModalSubmit)
2623
- machineModalSubmit.addEventListener("click", submitAddMachine);
2624
-
2625
- var joinModalClose = document.getElementById("join-modal-close");
2626
- if (joinModalClose)
2627
- joinModalClose.addEventListener("click", closeJoinLinkModal);
2628
-
2629
- var joinModalCancel = document.getElementById("join-modal-cancel");
2630
- if (joinModalCancel)
2631
- joinModalCancel.addEventListener("click", closeJoinLinkModal);
2632
-
2633
- var joinModalGenerate = document.getElementById("join-modal-generate");
2634
- if (joinModalGenerate)
2635
- joinModalGenerate.addEventListener("click", generateJoinLink);
2636
-
2637
- var joinCopyBtn = document.getElementById("join-copy-btn");
2638
- if (joinCopyBtn) joinCopyBtn.addEventListener("click", copyJoinCommand);
2639
-
2640
- var removeModalClose = document.getElementById("remove-modal-close");
2641
- if (removeModalClose)
2642
- removeModalClose.addEventListener("click", function () {
2643
- document.getElementById("remove-machine-modal").style.display = "none";
2644
- });
2645
-
2646
- var removeModalCancel = document.getElementById("remove-modal-cancel");
2647
- if (removeModalCancel)
2648
- removeModalCancel.addEventListener("click", function () {
2649
- document.getElementById("remove-machine-modal").style.display = "none";
2650
- });
2651
-
2652
- var removeModalConfirm = document.getElementById("remove-modal-confirm");
2653
- if (removeModalConfirm)
2654
- removeModalConfirm.addEventListener("click", executeRemoveMachine);
2655
- }
2656
-
2657
- // ══════════════════════════════════════════════════════════════════
2658
- // INTERVENTION HANDLERS
2659
- // ══════════════════════════════════════════════════════════════════
2660
-
2661
- var interventionTarget = null;
2662
-
2663
- function sendIntervention(issue, action, body) {
2664
- var opts = {
2665
- method: "POST",
2666
- headers: { "Content-Type": "application/json" },
2667
- };
2668
- if (body) opts.body = JSON.stringify(body);
2669
- fetch("/api/intervention/" + issue + "/" + action, opts)
2670
- .then(function (r) {
2671
- if (!r.ok) throw new Error("HTTP " + r.status);
2672
- return r.json();
2673
- })
2674
- .then(function () {
2675
- // Refresh agents tab
2676
- if (activeTab === "agents" && currentData) renderAgentsTab(currentData);
2677
- })
2678
- .catch(function (err) {
2679
- console.error("Intervention failed:", err);
2680
- });
2681
- }
2682
-
2683
- function confirmAbort(issue) {
2684
- if (
2685
- confirm("Abort pipeline for issue #" + issue + "? This cannot be undone.")
2686
- ) {
2687
- sendIntervention(issue, "abort");
2688
- }
2689
- }
2690
-
2691
- function openInterventionModal(issue) {
2692
- interventionTarget = issue;
2693
- var modal = document.getElementById("intervention-modal");
2694
- var title = document.getElementById("modal-title");
2695
- var msg = document.getElementById("modal-message");
2696
- if (modal) modal.style.display = "";
2697
- if (title) title.textContent = "Send Message to #" + issue;
2698
- if (msg) msg.value = "";
2699
- }
2700
-
2701
- function setupInterventionModal() {
2702
- var modal = document.getElementById("intervention-modal");
2703
- var closeBtn = document.getElementById("modal-close");
2704
- var cancelBtn = document.getElementById("modal-cancel");
2705
- var sendBtn = document.getElementById("modal-send");
2706
- var msgEl = document.getElementById("modal-message");
2707
-
2708
- function closeModal() {
2709
- if (modal) modal.style.display = "none";
2710
- interventionTarget = null;
2711
- }
2712
-
2713
- if (closeBtn) closeBtn.addEventListener("click", closeModal);
2714
- if (cancelBtn) cancelBtn.addEventListener("click", closeModal);
2715
- if (modal) {
2716
- modal.addEventListener("click", function (e) {
2717
- if (e.target === modal) closeModal();
2718
- });
2719
- }
2720
- if (sendBtn) {
2721
- sendBtn.addEventListener("click", function () {
2722
- if (interventionTarget && msgEl && msgEl.value.trim()) {
2723
- sendIntervention(interventionTarget, "message", {
2724
- message: msgEl.value.trim(),
2725
- });
2726
- closeModal();
2727
- }
2728
- });
2729
- }
2730
- }
2731
-
2732
- // ══════════════════════════════════════════════════════════════════
2733
- // PHASE 1: ARTIFACT VIEWER, GITHUB STATUS, LOG VIEWER, ERROR HIGHLIGHT
2734
- // ══════════════════════════════════════════════════════════════════
2735
-
2736
- function renderArtifactViewer(issue, detail) {
2737
- var tabs = [
2738
- { key: "plan", label: "Plan", content: detail.plan },
2739
- { key: "design", label: "Design", content: detail.design },
2740
- { key: "dod", label: "DoD", content: detail.dod },
2741
- { key: "tests", label: "Tests", content: null },
2742
- { key: "review", label: "Review", content: null },
2743
- { key: "logs", label: "Logs", content: null },
2744
- ];
2745
-
2746
- var html = '<div class="artifact-viewer">';
2747
- html += '<div class="artifact-tabs">';
2748
- for (var i = 0; i < tabs.length; i++) {
2749
- var activeClass = i === 0 ? " active" : "";
2750
- html +=
2751
- '<button class="artifact-tab-btn' +
2752
- activeClass +
2753
- '" data-artifact="' +
2754
- tabs[i].key +
2755
- '" data-issue="' +
2756
- issue +
2757
- '">' +
2758
- escapeHtml(tabs[i].label) +
2759
- "</button>";
2760
- }
2761
- html += "</div>";
2762
-
2763
- html += '<div class="artifact-content" id="artifact-content-' + issue + '">';
2764
- // Show plan by default if available
2765
- if (detail.plan) {
2766
- html +=
2767
- '<div class="detail-plan-content">' +
2768
- formatMarkdown(detail.plan) +
2769
- "</div>";
2770
- } else {
2771
- html += '<div class="empty-state"><p>No plan data</p></div>';
2772
- }
2773
- html += "</div>";
2774
- html += "</div>";
2775
- return html;
2776
- }
2777
-
2778
- function setupArtifactTabs(issue) {
2779
- var btns = document.querySelectorAll(
2780
- '.artifact-tab-btn[data-issue="' + issue + '"]',
2781
- );
2782
- for (var i = 0; i < btns.length; i++) {
2783
- btns[i].addEventListener("click", function () {
2784
- var artifact = this.getAttribute("data-artifact");
2785
- var iss = this.getAttribute("data-issue");
2786
- var siblings = document.querySelectorAll(
2787
- '.artifact-tab-btn[data-issue="' + iss + '"]',
2788
- );
2789
- for (var j = 0; j < siblings.length; j++) {
2790
- siblings[j].classList.remove("active");
2791
- }
2792
- this.classList.add("active");
2793
- fetchArtifact(iss, artifact);
2794
- });
2795
- }
2796
- }
2797
-
2798
- function fetchArtifact(issue, type) {
2799
- var container = document.getElementById("artifact-content-" + issue);
2800
- if (!container) return;
2801
- container.innerHTML = '<div class="empty-state"><p>Loading...</p></div>';
2802
-
2803
- // Check if we have inline data from detail
2804
- if (pipelineDetail) {
2805
- if (type === "plan" && pipelineDetail.plan) {
2806
- container.innerHTML =
2807
- '<div class="detail-plan-content">' +
2808
- formatMarkdown(pipelineDetail.plan) +
2809
- "</div>";
2810
- return;
2811
- }
2812
- if (type === "design" && pipelineDetail.design) {
2813
- container.innerHTML =
2814
- '<div class="detail-plan-content">' +
2815
- formatMarkdown(pipelineDetail.design) +
2816
- "</div>";
2817
- return;
2818
- }
2819
- if (type === "dod" && pipelineDetail.dod) {
2820
- container.innerHTML =
2821
- '<div class="detail-plan-content">' +
2822
- formatMarkdown(pipelineDetail.dod) +
2823
- "</div>";
2824
- return;
2825
- }
2826
- }
2827
-
2828
- fetch(
2829
- "/api/artifacts/" +
2830
- encodeURIComponent(issue) +
2831
- "/" +
2832
- encodeURIComponent(type),
2833
- )
2834
- .then(function (r) {
2835
- if (!r.ok) throw new Error("HTTP " + r.status);
2836
- return r.json();
2837
- })
2838
- .then(function (data) {
2839
- if (type === "logs") {
2840
- container.innerHTML = renderLogViewer(data.content || "");
2841
- } else {
2842
- container.innerHTML =
2843
- '<div class="detail-plan-content">' +
2844
- formatMarkdown(data.content || "") +
2845
- "</div>";
2846
- }
2847
- })
2848
- .catch(function (err) {
2849
- container.innerHTML =
2850
- '<div class="empty-state"><p>Not available: ' +
2851
- escapeHtml(String(err)) +
2852
- "</p></div>";
2853
- });
2854
- }
2855
-
2856
- function formatMarkdown(text) {
2857
- if (!text) return "";
2858
- var escaped = escapeHtml(text);
2859
- // Headers → bold
2860
- escaped = escaped.replace(/^#{1,3}\s+(.+)$/gm, function (_m, content) {
2861
- return "<strong>" + content + "</strong>";
2862
- });
2863
- // Code blocks → monospace
2864
- escaped = escaped.replace(/```[\s\S]*?```/g, function (block) {
2865
- var inner = block.replace(/^```\w*\n?/, "").replace(/\n?```$/, "");
2866
- return '<pre class="artifact-code">' + inner + "</pre>";
2867
- });
2868
- // Inline code
2869
- escaped = escaped.replace(/`([^`]+)`/g, "<code>$1</code>");
2870
- // Bullet lists
2871
- escaped = escaped.replace(/^[-*]\s+(.+)$/gm, "<li>$1</li>");
2872
- // Line breaks
2873
- escaped = escaped.replace(/\n/g, "<br>");
2874
- return escaped;
2875
- }
2876
-
2877
- function renderGitHubStatus(issue) {
2878
- var container = document.getElementById("github-status-" + issue);
2879
- if (!container) return;
2880
-
2881
- fetch("/api/github/" + encodeURIComponent(issue))
2882
- .then(function (r) {
2883
- if (!r.ok) throw new Error("HTTP " + r.status);
2884
- return r.json();
2885
- })
2886
- .then(function (data) {
2887
- if (!data.configured) {
2888
- container.innerHTML = "";
2889
- return;
2890
- }
2891
- var html = '<div class="github-banner">';
2892
- // Issue state badge
2893
- if (data.issue_state) {
2894
- html +=
2895
- '<span class="github-badge ' +
2896
- escapeHtml(data.issue_state) +
2897
- '">' +
2898
- escapeHtml(data.issue_state) +
2899
- "</span>";
2900
- }
2901
- // PR link
2902
- if (data.pr_number) {
2903
- html +=
2904
- '<a class="github-link" href="' +
2905
- escapeHtml(data.pr_url || "") +
2906
- '" target="_blank">PR #' +
2907
- data.pr_number +
2908
- "</a>";
2909
- }
2910
- // CI checks
2911
- if (data.checks && data.checks.length > 0) {
2912
- html += '<span class="github-checks">';
2913
- for (var c = 0; c < data.checks.length; c++) {
2914
- var check = data.checks[c];
2915
- var icon =
2916
- check.status === "success"
2917
- ? "\u2713"
2918
- : check.status === "failure"
2919
- ? "\u2717"
2920
- : "\u25CF";
2921
- var cls =
2922
- check.status === "success"
2923
- ? "github-badge success"
2924
- : check.status === "failure"
2925
- ? "github-badge failure"
2926
- : "github-badge pending";
2927
- html +=
2928
- '<span class="' +
2929
- cls +
2930
- '" title="' +
2931
- escapeHtml(check.name || "") +
2932
- '">' +
2933
- icon +
2934
- "</span>";
2935
- }
2936
- html += "</span>";
2937
- }
2938
- html += "</div>";
2939
- container.innerHTML = html;
2940
- })
2941
- .catch(function () {
2942
- container.innerHTML = "";
2943
- });
2944
- }
2945
-
2946
- function renderLogViewer(content) {
2947
- if (!content)
2948
- return '<div class="empty-state"><p>No logs available</p></div>';
2949
- // Strip ANSI escape codes
2950
- var clean = content.replace(/\x1b\[[0-9;]*m/g, "");
2951
- var lines = clean.split("\n");
2952
- var html = '<div class="log-viewer">';
2953
- for (var i = 0; i < lines.length; i++) {
2954
- var lineNum = i + 1;
2955
- var lineClass = "";
2956
- var lower = lines[i].toLowerCase();
2957
- if (lower.indexOf("error") !== -1 || lower.indexOf("fail") !== -1) {
2958
- lineClass = " log-line-error";
2959
- }
2960
- html +=
2961
- '<div class="log-line' +
2962
- lineClass +
2963
- '">' +
2964
- '<span class="log-line-num">' +
2965
- lineNum +
2966
- "</span>" +
2967
- '<span class="log-line-text">' +
2968
- escapeHtml(lines[i]) +
2969
- "</span>" +
2970
- "</div>";
2971
- }
2972
- html += "</div>";
2973
- return html;
2974
- }
2975
-
2976
- function renderErrorHighlight(issue) {
2977
- var container = document.getElementById("error-highlight-" + issue);
2978
- if (!container) return;
2979
-
2980
- fetch("/api/logs/" + encodeURIComponent(issue))
2981
- .then(function (r) {
2982
- if (!r.ok) throw new Error("HTTP " + r.status);
2983
- return r.json();
2984
- })
2985
- .then(function (data) {
2986
- var content = data.content || "";
2987
- var lines = content.split("\n");
2988
- var errorLines = [];
2989
- for (var i = 0; i < lines.length; i++) {
2990
- var lower = lines[i].toLowerCase();
2991
- if (lower.indexOf("error") !== -1 || lower.indexOf("fail") !== -1) {
2992
- errorLines.push(lines[i]);
2993
- }
2994
- }
2995
- if (errorLines.length === 0) {
2996
- container.innerHTML = "";
2997
- return;
2998
- }
2999
- // Show last error
3000
- var lastError = errorLines[errorLines.length - 1];
3001
- container.innerHTML =
3002
- '<div class="error-highlight">' +
3003
- '<span class="error-highlight-title">LAST ERROR</span>' +
3004
- '<pre class="error-highlight-content">' +
3005
- escapeHtml(lastError) +
3006
- "</pre>" +
3007
- "</div>";
3008
- })
3009
- .catch(function () {
3010
- container.innerHTML = "";
3011
- });
3012
- }
3013
-
3014
- // ══════════════════════════════════════════════════════════════════
3015
- // PHASE 2: QUEUE DETAILED, COST BREAKDOWN, COST TREND, DORA TREND
3016
- // ══════════════════════════════════════════════════════════════════
3017
-
3018
- function renderQueueDetailed() {
3019
- var container = document.getElementById("queue-detailed-container");
3020
- if (!container) return;
3021
-
3022
- fetch("/api/queue/detailed")
3023
- .then(function (r) {
3024
- if (!r.ok) throw new Error("HTTP " + r.status);
3025
- return r.json();
3026
- })
3027
- .then(function (data) {
3028
- var items = data.items || data.queue || [];
3029
- if (items.length === 0) {
3030
- container.innerHTML =
3031
- '<div class="empty-state"><p>Queue empty</p></div>';
3032
- return;
3033
- }
3034
- var html = "";
3035
- for (var i = 0; i < items.length; i++) {
3036
- var q = items[i];
3037
- var costEst =
3038
- q.estimated_cost != null
3039
- ? "$" + q.estimated_cost.toFixed(2)
3040
- : "\u2014";
3041
- html +=
3042
- '<div class="queue-detailed-row" data-idx="' +
3043
- i +
3044
- '">' +
3045
- '<div class="queue-detailed-header">' +
3046
- '<span class="queue-issue">#' +
3047
- q.issue +
3048
- "</span>" +
3049
- '<span class="queue-title-text">' +
3050
- escapeHtml(q.title || "") +
3051
- "</span>" +
3052
- '<span class="queue-score">' +
3053
- (q.score != null ? q.score : "\u2014") +
3054
- "</span>" +
3055
- '<span class="queue-cost-est">' +
3056
- costEst +
3057
- "</span>" +
3058
- "</div>" +
3059
- '<div class="queue-detailed-body" id="queue-detailed-body-' +
3060
- i +
3061
- '" style="display:none">';
3062
- if (q.factors) {
3063
- html += renderScoringFactors(q.factors);
3064
- }
3065
- html += "</div></div>";
3066
- }
3067
- container.innerHTML = html;
3068
-
3069
- // Expand/collapse handlers
3070
- var rows = container.querySelectorAll(".queue-detailed-row");
3071
- for (var i = 0; i < rows.length; i++) {
3072
- rows[i]
3073
- .querySelector(".queue-detailed-header")
3074
- .addEventListener("click", function () {
3075
- var idx = this.parentNode.getAttribute("data-idx");
3076
- var body = document.getElementById("queue-detailed-body-" + idx);
3077
- if (body) {
3078
- body.style.display = body.style.display === "none" ? "" : "none";
3079
- }
3080
- });
3081
- }
3082
- })
3083
- .catch(function (err) {
3084
- container.innerHTML =
3085
- '<div class="empty-state"><p>Failed to load: ' +
3086
- escapeHtml(String(err)) +
3087
- "</p></div>";
3088
- });
3089
- }
3090
-
3091
- function renderCostBreakdown() {
3092
- var container = document.getElementById("cost-breakdown-container");
3093
- if (!container) return;
3094
-
3095
- fetch("/api/costs/breakdown?period=7")
3096
- .then(function (r) {
3097
- if (!r.ok) throw new Error("HTTP " + r.status);
3098
- return r.json();
3099
- })
3100
- .then(function (data) {
3101
- costBreakdownCache = data;
3102
- var html = "";
3103
-
3104
- // Cost by model
3105
- if (data.by_model) {
3106
- html +=
3107
- '<div class="cost-section"><div class="cost-section-label">COST BY MODEL</div>';
3108
- var modelColors = {
3109
- opus: "#7c3aed",
3110
- sonnet: "#00d4ff",
3111
- haiku: "#4ade80",
3112
- };
3113
- var models = Object.keys(data.by_model);
3114
- var maxModel = 0;
3115
- for (var i = 0; i < models.length; i++) {
3116
- if (data.by_model[models[i]] > maxModel)
3117
- maxModel = data.by_model[models[i]];
3118
- }
3119
- if (maxModel === 0) maxModel = 1;
3120
- for (var i = 0; i < models.length; i++) {
3121
- var m = models[i];
3122
- var val = data.by_model[m];
3123
- var pct = (val / maxModel) * 100;
3124
- var color = modelColors[m.toLowerCase()] || "#5a6d8a";
3125
- html +=
3126
- '<div class="cost-bar-row">' +
3127
- '<span class="cost-bar-label">' +
3128
- escapeHtml(m) +
3129
- "</span>" +
3130
- '<div class="cost-bar-track-h">' +
3131
- '<div class="cost-bar-fill-h" style="width:' +
3132
- pct +
3133
- "%;background:" +
3134
- color +
3135
- '"></div>' +
3136
- "</div>" +
3137
- '<span class="cost-bar-value">$' +
3138
- val.toFixed(2) +
3139
- "</span>" +
3140
- "</div>";
3141
- }
3142
- html += "</div>";
3143
- }
3144
-
3145
- // Cost by stage
3146
- if (data.by_stage) {
3147
- html +=
3148
- '<div class="cost-section"><div class="cost-section-label">COST BY STAGE</div>';
3149
- var stages = Object.keys(data.by_stage);
3150
- var maxStage = 0;
3151
- for (var i = 0; i < stages.length; i++) {
3152
- if (data.by_stage[stages[i]] > maxStage)
3153
- maxStage = data.by_stage[stages[i]];
3154
- }
3155
- if (maxStage === 0) maxStage = 1;
3156
- for (var i = 0; i < stages.length; i++) {
3157
- var s = stages[i];
3158
- var val = data.by_stage[s];
3159
- var pct = (val / maxStage) * 100;
3160
- var colorIdx = STAGES.indexOf(s);
3161
- var barColor = colorIdx >= 0 ? STAGE_HEX[s] || "#5a6d8a" : "#5a6d8a";
3162
- html +=
3163
- '<div class="cost-bar-row">' +
3164
- '<span class="cost-bar-label">' +
3165
- escapeHtml(s) +
3166
- "</span>" +
3167
- '<div class="cost-bar-track-h">' +
3168
- '<div class="cost-bar-fill-h" style="width:' +
3169
- pct +
3170
- "%;background:" +
3171
- barColor +
3172
- '"></div>' +
3173
- "</div>" +
3174
- '<span class="cost-bar-value">$' +
3175
- val.toFixed(2) +
3176
- "</span>" +
3177
- "</div>";
3178
- }
3179
- html += "</div>";
3180
- }
3181
-
3182
- // Cost per issue
3183
- if (data.by_issue && data.by_issue.length > 0) {
3184
- html +=
3185
- '<div class="cost-section"><div class="cost-section-label">COST PER ISSUE</div>';
3186
- html +=
3187
- '<table class="cost-issue-table"><thead><tr><th>Issue</th><th>Cost</th></tr></thead><tbody>';
3188
- var sorted = data.by_issue.slice().sort(function (a, b) {
3189
- return (b.cost || 0) - (a.cost || 0);
3190
- });
3191
- for (var i = 0; i < sorted.length; i++) {
3192
- html +=
3193
- "<tr><td>#" +
3194
- sorted[i].issue +
3195
- "</td><td>$" +
3196
- (sorted[i].cost || 0).toFixed(2) +
3197
- "</td></tr>";
3198
- }
3199
- html += "</tbody></table></div>";
3200
- }
3201
-
3202
- // Budget utilization
3203
- if (data.budget != null && data.spent != null) {
3204
- var budgetPct =
3205
- data.budget > 0 ? Math.min((data.spent / data.budget) * 100, 100) : 0;
3206
- var budgetClass =
3207
- budgetPct >= 80
3208
- ? "cost-over"
3209
- : budgetPct >= 60
3210
- ? "cost-warn"
3211
- : "cost-ok";
3212
- html +=
3213
- '<div class="cost-section"><div class="cost-section-label">BUDGET UTILIZATION</div>' +
3214
- '<div class="budget-util-bar">' +
3215
- '<div class="cost-bar-track"><div class="cost-bar-fill ' +
3216
- budgetClass +
3217
- '" style="width:' +
3218
- budgetPct.toFixed(0) +
3219
- '%"></div></div>' +
3220
- '<span class="budget-util-text">$' +
3221
- data.spent.toFixed(2) +
3222
- " / $" +
3223
- data.budget.toFixed(2) +
3224
- " (" +
3225
- budgetPct.toFixed(0) +
3226
- "%)</span>" +
3227
- "</div></div>";
3228
- }
3229
-
3230
- container.innerHTML =
3231
- html || '<div class="empty-state"><p>No cost data</p></div>';
3232
- })
3233
- .catch(function (err) {
3234
- container.innerHTML =
3235
- '<div class="empty-state"><p>Failed to load: ' +
3236
- escapeHtml(String(err)) +
3237
- "</p></div>";
3238
- });
3239
- }
3240
-
3241
- function renderCostTrend() {
3242
- var container = document.getElementById("cost-trend-container");
3243
- if (!container) return;
3244
-
3245
- fetch("/api/costs/trend?period=30")
3246
- .then(function (r) {
3247
- if (!r.ok) throw new Error("HTTP " + r.status);
3248
- return r.json();
3249
- })
3250
- .then(function (data) {
3251
- var points = data.points || data.daily || [];
3252
- if (points.length === 0) {
3253
- container.innerHTML =
3254
- '<div class="empty-state"><p>No trend data</p></div>';
3255
- return;
3256
- }
3257
- container.innerHTML = renderSVGLineChart(
3258
- points,
3259
- "cost",
3260
- "#00d4ff",
3261
- 300,
3262
- 100,
3263
- );
3264
- })
3265
- .catch(function (err) {
3266
- container.innerHTML =
3267
- '<div class="empty-state"><p>Failed to load: ' +
3268
- escapeHtml(String(err)) +
3269
- "</p></div>";
3270
- });
3271
- }
3272
-
3273
- function renderDoraTrend() {
3274
- var container = document.getElementById("dora-trend-container");
3275
- if (!container) return;
3276
-
3277
- fetch("/api/metrics/dora-trend?period=30")
3278
- .then(function (r) {
3279
- if (!r.ok) throw new Error("HTTP " + r.status);
3280
- return r.json();
3281
- })
3282
- .then(function (data) {
3283
- var metrics = [
3284
- { key: "deploy_freq", label: "Deploy Freq", color: "#00d4ff" },
3285
- { key: "lead_time", label: "Lead Time", color: "#0066ff" },
3286
- { key: "cfr", label: "Change Fail Rate", color: "#f43f5e" },
3287
- { key: "mttr", label: "MTTR", color: "#4ade80" },
3288
- ];
3289
- var html = '<div class="dora-trend-grid">';
3290
- for (var i = 0; i < metrics.length; i++) {
3291
- var m = metrics[i];
3292
- var points = data[m.key] || [];
3293
- html +=
3294
- '<div class="dora-trend-card">' +
3295
- '<span class="dora-trend-label">' +
3296
- escapeHtml(m.label) +
3297
- "</span>";
3298
- if (points.length > 0) {
3299
- html += renderSparkline(points, m.color, 120, 30);
3300
- } else {
3301
- html += '<span class="dora-trend-empty">\u2014</span>';
3302
- }
3303
- html += "</div>";
3304
- }
3305
- html += "</div>";
3306
- container.innerHTML = html;
3307
- })
3308
- .catch(function (err) {
3309
- container.innerHTML =
3310
- '<div class="empty-state"><p>Failed to load: ' +
3311
- escapeHtml(String(err)) +
3312
- "</p></div>";
3313
- });
3314
- }
3315
-
3316
- function renderSparkline(points, color, width, height) {
3317
- if (!points || points.length < 2) return "";
3318
- var maxVal = 0;
3319
- var minVal = Infinity;
3320
- for (var i = 0; i < points.length; i++) {
3321
- var v = typeof points[i] === "object" ? points[i].value || 0 : points[i];
3322
- if (v > maxVal) maxVal = v;
3323
- if (v < minVal) minVal = v;
3324
- }
3325
- var range = maxVal - minVal || 1;
3326
- var padding = 2;
3327
- var w = width - padding * 2;
3328
- var h = height - padding * 2;
3329
-
3330
- var pathParts = [];
3331
- for (var i = 0; i < points.length; i++) {
3332
- var v = typeof points[i] === "object" ? points[i].value || 0 : points[i];
3333
- var x = padding + (i / (points.length - 1)) * w;
3334
- var y = padding + h - ((v - minVal) / range) * h;
3335
- pathParts.push((i === 0 ? "M" : "L") + x.toFixed(1) + "," + y.toFixed(1));
3336
- }
3337
-
3338
- return (
3339
- '<svg class="sparkline" width="' +
3340
- width +
3341
- '" height="' +
3342
- height +
3343
- '" viewBox="0 0 ' +
3344
- width +
3345
- " " +
3346
- height +
3347
- '">' +
3348
- '<path d="' +
3349
- pathParts.join(" ") +
3350
- '" fill="none" stroke="' +
3351
- color +
3352
- '" stroke-width="1.5" stroke-linecap="round"/></svg>'
3353
- );
3354
- }
3355
-
3356
- function renderSVGLineChart(points, valueKey, color, width, height) {
3357
- if (!points || points.length < 2)
3358
- return '<div class="empty-state"><p>Not enough data</p></div>';
3359
- var maxVal = 0;
3360
- for (var i = 0; i < points.length; i++) {
3361
- var v =
3362
- typeof points[i] === "object"
3363
- ? points[i][valueKey] || points[i].value || 0
3364
- : points[i];
3365
- if (v > maxVal) maxVal = v;
3366
- }
3367
- if (maxVal === 0) maxVal = 1;
3368
- var padding = 20;
3369
- var chartW = width - padding * 2;
3370
- var chartH = height - padding * 2;
3371
-
3372
- var svg =
3373
- '<svg class="svg-line-chart" viewBox="0 0 ' +
3374
- width +
3375
- " " +
3376
- height +
3377
- '" width="100%" height="' +
3378
- height +
3379
- '">';
3380
-
3381
- // Grid lines
3382
- for (var g = 0; g <= 4; g++) {
3383
- var gy = padding + (g / 4) * chartH;
3384
- svg +=
3385
- '<line x1="' +
3386
- padding +
3387
- '" y1="' +
3388
- gy +
3389
- '" x2="' +
3390
- (width - padding) +
3391
- '" y2="' +
3392
- gy +
3393
- '" stroke="#1a3a6a" stroke-width="0.5"/>';
3394
- }
3395
-
3396
- var pathParts = [];
3397
- for (var i = 0; i < points.length; i++) {
3398
- var v =
3399
- typeof points[i] === "object"
3400
- ? points[i][valueKey] || points[i].value || 0
3401
- : points[i];
3402
- var x = padding + (i / (points.length - 1)) * chartW;
3403
- var y = padding + chartH - (v / maxVal) * chartH;
3404
- pathParts.push((i === 0 ? "M" : "L") + x.toFixed(1) + "," + y.toFixed(1));
3405
- }
3406
-
3407
- // Fill area
3408
- var lastX = padding + chartW;
3409
- var firstX = padding;
3410
- svg +=
3411
- '<path d="' +
3412
- pathParts.join(" ") +
3413
- " L" +
3414
- lastX +
3415
- "," +
3416
- (padding + chartH) +
3417
- " L" +
3418
- firstX +
3419
- "," +
3420
- (padding + chartH) +
3421
- ' Z" fill="' +
3422
- color +
3423
- '" opacity="0.1"/>';
3424
- // Line
3425
- svg +=
3426
- '<path d="' +
3427
- pathParts.join(" ") +
3428
- '" fill="none" stroke="' +
3429
- color +
3430
- '" stroke-width="2" stroke-linecap="round"/>';
3431
-
3432
- svg += "</svg>";
3433
- return svg;
3434
- }
3435
-
3436
- // ══════════════════════════════════════════════════════════════════
3437
- // PHASE 3: INSIGHTS TAB
3438
- // ══════════════════════════════════════════════════════════════════
3439
-
3440
- function fetchInsightsData() {
3441
- var panel = document.getElementById("panel-insights");
3442
- if (!panel) return;
3443
- if (insightsCache) {
3444
- renderInsightsTab(insightsCache);
3445
- return;
3446
- }
3447
-
3448
- panel.innerHTML = '<div class="empty-state"><p>Loading insights...</p></div>';
3449
-
3450
- var results = {
3451
- patterns: null,
3452
- decisions: null,
3453
- patrol: null,
3454
- heatmap: null,
3455
- };
3456
- var pending = 4;
3457
-
3458
- function checkDone() {
3459
- pending--;
3460
- if (pending <= 0) {
3461
- insightsCache = results;
3462
- renderInsightsTab(results);
3463
- }
3464
- }
3465
-
3466
- fetch("/api/memory/patterns")
3467
- .then(function (r) {
3468
- return r.ok ? r.json() : { patterns: [] };
3469
- })
3470
- .then(function (d) {
3471
- results.patterns = d.patterns || d;
3472
- })
3473
- .catch(function () {
3474
- results.patterns = [];
3475
- })
3476
- .then(checkDone);
3477
-
3478
- fetch("/api/memory/decisions")
3479
- .then(function (r) {
3480
- return r.ok ? r.json() : { decisions: [] };
3481
- })
3482
- .then(function (d) {
3483
- results.decisions = d.decisions || d;
3484
- })
3485
- .catch(function () {
3486
- results.decisions = [];
3487
- })
3488
- .then(checkDone);
3489
-
3490
- fetch("/api/patrol/recent")
3491
- .then(function (r) {
3492
- return r.ok ? r.json() : { findings: [] };
3493
- })
3494
- .then(function (d) {
3495
- results.patrol = d.findings || d;
3496
- })
3497
- .catch(function () {
3498
- results.patrol = [];
3499
- })
3500
- .then(checkDone);
3501
-
3502
- fetch("/api/metrics/failure-heatmap")
3503
- .then(function (r) {
3504
- return r.ok ? r.json() : { data: [] };
3505
- })
3506
- .then(function (d) {
3507
- results.heatmap = d;
3508
- })
3509
- .catch(function () {
3510
- results.heatmap = null;
3511
- })
3512
- .then(checkDone);
3513
- }
3514
-
3515
- function renderInsightsTab(data) {
3516
- var panel = document.getElementById("panel-insights");
3517
- if (!panel) return;
3518
-
3519
- var html = '<div class="insights-grid">';
3520
-
3521
- // Failure patterns section
3522
- html +=
3523
- '<div class="insights-section">' +
3524
- '<div class="section-header"><h3>Failure Patterns</h3></div>' +
3525
- '<div id="failure-patterns-content">' +
3526
- renderFailurePatterns(data.patterns || []) +
3527
- "</div></div>";
3528
-
3529
- // Patrol findings section
3530
- html +=
3531
- '<div class="insights-section">' +
3532
- '<div class="section-header"><h3>Patrol Findings</h3></div>' +
3533
- '<div id="patrol-findings-content">' +
3534
- renderPatrolFindings(data.patrol || []) +
3535
- "</div></div>";
3536
-
3537
- // Decision log section
3538
- html +=
3539
- '<div class="insights-section insights-full-width">' +
3540
- '<div class="section-header"><h3>Decision Log</h3></div>' +
3541
- '<div id="decision-log-content">' +
3542
- renderDecisionLog(data.decisions || []) +
3543
- "</div></div>";
3544
-
3545
- // Failure heatmap section
3546
- html +=
3547
- '<div class="insights-section insights-full-width">' +
3548
- '<div class="section-header"><h3>Failure Heatmap</h3></div>' +
3549
- '<div id="failure-heatmap-content">' +
3550
- renderFailureHeatmap(data.heatmap) +
3551
- "</div></div>";
3552
-
3553
- html += "</div>";
3554
- panel.innerHTML = html;
3555
- }
3556
-
3557
- function renderFailurePatterns(patterns) {
3558
- if (!patterns || patterns.length === 0) {
3559
- return '<div class="empty-state"><p>No failure patterns recorded</p></div>';
3560
- }
3561
-
3562
- // Sort by frequency (most common first)
3563
- var sorted = patterns.slice().sort(function (a, b) {
3564
- return (b.frequency || b.count || 0) - (a.frequency || a.count || 0);
3565
- });
3566
-
3567
- var html = "";
3568
- for (var i = 0; i < sorted.length; i++) {
3569
- var p = sorted[i];
3570
- var freq = p.frequency || p.count || 0;
3571
- html +=
3572
- '<div class="pattern-card">' +
3573
- '<div class="pattern-card-header">' +
3574
- '<span class="pattern-desc">' +
3575
- escapeHtml(p.description || p.pattern || "") +
3576
- "</span>" +
3577
- '<span class="pattern-freq-badge">' +
3578
- freq +
3579
- "x</span>" +
3580
- "</div>";
3581
- if (p.root_cause) {
3582
- html +=
3583
- '<div class="pattern-detail"><span class="pattern-label">Root cause:</span> ' +
3584
- escapeHtml(p.root_cause) +
3585
- "</div>";
3586
- }
3587
- if (p.fix || p.suggested_fix) {
3588
- html +=
3589
- '<div class="pattern-detail pattern-fix"><span class="pattern-label">Fix:</span> ' +
3590
- escapeHtml(p.fix || p.suggested_fix) +
3591
- "</div>";
3592
- }
3593
- html += "</div>";
3594
- }
3595
- return html;
3596
- }
3597
-
3598
- function renderPatrolFindings(findings) {
3599
- if (!findings || findings.length === 0) {
3600
- return '<div class="empty-state"><p>No patrol findings</p></div>';
3601
- }
3602
-
3603
- var html = "";
3604
- for (var i = 0; i < findings.length; i++) {
3605
- var f = findings[i];
3606
- var severity = (f.severity || "low").toLowerCase();
3607
- html +=
3608
- '<div class="patrol-card">' +
3609
- '<div class="patrol-card-header">' +
3610
- '<span class="patrol-severity-badge severity-' +
3611
- escapeHtml(severity) +
3612
- '">' +
3613
- escapeHtml(severity.toUpperCase()) +
3614
- "</span>" +
3615
- '<span class="patrol-type">' +
3616
- escapeHtml(f.type || f.category || "") +
3617
- "</span>" +
3618
- "</div>" +
3619
- '<div class="patrol-desc">' +
3620
- escapeHtml(f.description || f.message || "") +
3621
- "</div>" +
3622
- (f.file
3623
- ? '<div class="patrol-file">' + escapeHtml(f.file) + "</div>"
3624
- : "") +
3625
- "</div>";
3626
- }
3627
- return html;
3628
- }
3629
-
3630
- function renderDecisionLog(decisions) {
3631
- if (!decisions || decisions.length === 0) {
3632
- return '<div class="empty-state"><p>No decisions logged</p></div>';
3633
- }
3634
-
3635
- var html = '<div class="decision-list">';
3636
- for (var i = 0; i < decisions.length; i++) {
3637
- var d = decisions[i];
3638
- html +=
3639
- '<div class="decision-row">' +
3640
- '<span class="decision-ts">' +
3641
- formatTime(d.timestamp || d.ts) +
3642
- "</span>" +
3643
- '<span class="decision-action">' +
3644
- escapeHtml(d.action || d.decision || "") +
3645
- "</span>" +
3646
- '<span class="decision-outcome">' +
3647
- escapeHtml(d.outcome || d.result || "") +
3648
- "</span>" +
3649
- (d.issue ? '<span class="decision-issue">#' + d.issue + "</span>" : "") +
3650
- "</div>";
3651
- }
3652
- html += "</div>";
3653
- return html;
3654
- }
3655
-
3656
- function renderFailureHeatmap(data) {
3657
- if (!data || !data.stages || !data.days) {
3658
- return '<div class="empty-state"><p>No heatmap data</p></div>';
3659
- }
3660
-
3661
- var stages = data.stages || [];
3662
- var days = data.days || [];
3663
- var cells = data.cells || {};
3664
-
3665
- if (stages.length === 0 || days.length === 0) {
3666
- return '<div class="empty-state"><p>No heatmap data</p></div>';
3667
- }
3668
-
3669
- // Find max for color scaling
3670
- var maxCount = 0;
3671
- for (var key in cells) {
3672
- if (cells[key] > maxCount) maxCount = cells[key];
3673
- }
3674
- if (maxCount === 0) maxCount = 1;
3675
-
3676
- var html =
3677
- '<div class="heatmap-grid" style="grid-template-columns: 100px repeat(' +
3678
- days.length +
3679
- ', 1fr)">';
3680
-
3681
- // Header row
3682
- html += '<div class="heatmap-corner"></div>';
3683
- for (var d = 0; d < days.length; d++) {
3684
- var parts = days[d].split("-");
3685
- var label = parts.length >= 3 ? parts[1] + "/" + parts[2] : days[d];
3686
- html += '<div class="heatmap-day-label">' + escapeHtml(label) + "</div>";
3687
- }
3688
-
3689
- // Data rows
3690
- for (var s = 0; s < stages.length; s++) {
3691
- html +=
3692
- '<div class="heatmap-stage-label">' + escapeHtml(stages[s]) + "</div>";
3693
- for (var d = 0; d < days.length; d++) {
3694
- var key = stages[s] + ":" + days[d];
3695
- var count = cells[key] || 0;
3696
- var intensity = count / maxCount;
3697
- var bgColor =
3698
- count === 0
3699
- ? "transparent"
3700
- : "rgba(244, 63, 94, " + (0.2 + intensity * 0.8).toFixed(2) + ")";
3701
- html +=
3702
- '<div class="heatmap-cell" style="background:' +
3703
- bgColor +
3704
- '" title="' +
3705
- escapeHtml(stages[s]) +
3706
- " " +
3707
- escapeHtml(days[d]) +
3708
- ": " +
3709
- count +
3710
- ' failures">' +
3711
- (count > 0 ? count : "") +
3712
- "</div>";
3713
- }
3714
- }
3715
-
3716
- html += "</div>";
3717
- return html;
3718
- }
3719
-
3720
- // ══════════════════════════════════════════════════════════════════
3721
- // PHASE 4: STAGE PERFORMANCE, BOTTLENECK, THROUGHPUT, CAPACITY
3722
- // ══════════════════════════════════════════════════════════════════
3723
-
3724
- function renderStagePerformance() {
3725
- var container = document.getElementById("stage-performance-container");
3726
- if (!container) return;
3727
-
3728
- fetch("/api/metrics/stage-performance?period=7")
3729
- .then(function (r) {
3730
- if (!r.ok) throw new Error("HTTP " + r.status);
3731
- return r.json();
3732
- })
3733
- .then(function (data) {
3734
- var stages = data.stages || [];
3735
- if (stages.length === 0) {
3736
- container.innerHTML =
3737
- '<div class="empty-state"><p>No stage performance data</p></div>';
3738
- return;
3739
- }
3740
- var html =
3741
- '<table class="stage-perf-table">' +
3742
- "<thead><tr><th>Stage</th><th>Avg</th><th>Min</th><th>Max</th><th>Count</th><th>Trend</th></tr></thead>" +
3743
- "<tbody>";
3744
- for (var i = 0; i < stages.length; i++) {
3745
- var s = stages[i];
3746
- var trendArrow = "";
3747
- if (s.trend_pct != null) {
3748
- if (s.trend_pct > 5)
3749
- trendArrow =
3750
- '<span class="trend-up">\u2191 ' +
3751
- s.trend_pct.toFixed(0) +
3752
- "%</span>";
3753
- else if (s.trend_pct < -5)
3754
- trendArrow =
3755
- '<span class="trend-down">\u2193 ' +
3756
- Math.abs(s.trend_pct).toFixed(0) +
3757
- "%</span>";
3758
- else trendArrow = '<span class="trend-flat">\u2192</span>';
3759
- }
3760
- html +=
3761
- "<tr>" +
3762
- "<td>" +
3763
- escapeHtml(s.name || s.stage || "") +
3764
- "</td>" +
3765
- "<td>" +
3766
- formatDuration(s.avg_s) +
3767
- "</td>" +
3768
- "<td>" +
3769
- formatDuration(s.min_s) +
3770
- "</td>" +
3771
- "<td>" +
3772
- formatDuration(s.max_s) +
3773
- "</td>" +
3774
- "<td>" +
3775
- (s.count || 0) +
3776
- "</td>" +
3777
- "<td>" +
3778
- trendArrow +
3779
- "</td>" +
3780
- "</tr>";
3781
- }
3782
- html += "</tbody></table>";
3783
- container.innerHTML = html;
3784
- })
3785
- .catch(function (err) {
3786
- container.innerHTML =
3787
- '<div class="empty-state"><p>Failed to load: ' +
3788
- escapeHtml(String(err)) +
3789
- "</p></div>";
3790
- });
3791
- }
3792
-
3793
- function renderBottleneckAlert() {
3794
- var container = document.getElementById("bottleneck-alert-container");
3795
- if (!container) return;
3796
-
3797
- fetch("/api/metrics/bottlenecks")
3798
- .then(function (r) {
3799
- if (!r.ok) throw new Error("HTTP " + r.status);
3800
- return r.json();
3801
- })
3802
- .then(function (data) {
3803
- if (!data.bottleneck) {
3804
- container.innerHTML = "";
3805
- return;
3806
- }
3807
- var b = data.bottleneck;
3808
- var msg =
3809
- escapeHtml(b.stage || "Unknown") +
3810
- " stage averages " +
3811
- formatDuration(b.avg_s) +
3812
- ", " +
3813
- (b.ratio || "?") +
3814
- "x longer than " +
3815
- escapeHtml(b.comparison_stage || "other stages");
3816
- var suggestion = b.suggestion
3817
- ? '<div class="bottleneck-suggestion">' +
3818
- escapeHtml(b.suggestion) +
3819
- "</div>"
3820
- : "";
3821
- container.innerHTML =
3822
- '<div class="bottleneck-alert">' +
3823
- '<span class="bottleneck-icon">\u26A0</span>' +
3824
- '<span class="bottleneck-msg">' +
3825
- msg +
3826
- "</span>" +
3827
- suggestion +
3828
- "</div>";
3829
- })
3830
- .catch(function () {
3831
- container.innerHTML = "";
3832
- });
3833
- }
3834
-
3835
- function renderThroughputTrend() {
3836
- var container = document.getElementById("throughput-trend-container");
3837
- if (!container) return;
3838
-
3839
- fetch("/api/metrics/throughput-trend?period=30")
3840
- .then(function (r) {
3841
- if (!r.ok) throw new Error("HTTP " + r.status);
3842
- return r.json();
3843
- })
3844
- .then(function (data) {
3845
- var points = data.points || data.daily || [];
3846
- if (points.length === 0) {
3847
- container.innerHTML =
3848
- '<div class="empty-state"><p>No throughput data</p></div>';
3849
- return;
3850
- }
3851
- container.innerHTML = renderSVGLineChart(
3852
- points,
3853
- "throughput",
3854
- "#4ade80",
3855
- 300,
3856
- 100,
3857
- );
3858
- })
3859
- .catch(function (err) {
3860
- container.innerHTML =
3861
- '<div class="empty-state"><p>Failed to load: ' +
3862
- escapeHtml(String(err)) +
3863
- "</p></div>";
3864
- });
3865
- }
3866
-
3867
- function renderCapacityForecast() {
3868
- var container = document.getElementById("capacity-forecast-container");
3869
- if (!container) return;
3870
-
3871
- fetch("/api/metrics/capacity")
3872
- .then(function (r) {
3873
- if (!r.ok) throw new Error("HTTP " + r.status);
3874
- return r.json();
3875
- })
3876
- .then(function (data) {
3877
- if (!data.rate && !data.queue_clear_hours) {
3878
- container.innerHTML =
3879
- '<div class="empty-state"><p>No capacity data</p></div>';
3880
- return;
3881
- }
3882
- var rate = data.rate != null ? data.rate.toFixed(1) : "?";
3883
- var clearTime =
3884
- data.queue_clear_hours != null
3885
- ? data.queue_clear_hours.toFixed(1)
3886
- : "?";
3887
- container.innerHTML =
3888
- '<div class="capacity-forecast">' +
3889
- '<span class="capacity-text">At current rate (' +
3890
- rate +
3891
- "/hr), queue will clear in " +
3892
- "<strong>" +
3893
- clearTime +
3894
- " hours</strong></span>" +
3895
- "</div>";
3896
- })
3897
- .catch(function (err) {
3898
- container.innerHTML =
3899
- '<div class="empty-state"><p>Failed to load: ' +
3900
- escapeHtml(String(err)) +
3901
- "</p></div>";
3902
- });
3903
- }
3904
-
3905
- // ══════════════════════════════════════════════════════════════════
3906
- // PHASE 5: ALERT BANNER, BULK ACTIONS, EMERGENCY BRAKE
3907
- // ══════════════════════════════════════════════════════════════════
3908
-
3909
- function renderAlertBanner() {
3910
- var container = document.getElementById("alert-banner");
3911
- if (!container) return;
3912
-
3913
- if (alertDismissed) {
3914
- container.innerHTML = "";
3915
- container.style.display = "none";
3916
- return;
3917
- }
3918
-
3919
- fetch("/api/alerts")
3920
- .then(function (r) {
3921
- if (!r.ok) throw new Error("HTTP " + r.status);
3922
- return r.json();
3923
- })
3924
- .then(function (data) {
3925
- var alerts = data.alerts || [];
3926
- if (alerts.length === 0) {
3927
- container.innerHTML = "";
3928
- container.style.display = "none";
3929
- return;
3930
- }
3931
-
3932
- // Show highest severity alert
3933
- var alert = alerts[0];
3934
- alertsCache = alerts;
3935
-
3936
- var severityClass = "alert-" + (alert.severity || "info");
3937
- var html =
3938
- '<div class="alert-banner-content ' +
3939
- severityClass +
3940
- '">' +
3941
- '<span class="alert-banner-icon">\u26A0</span>' +
3942
- '<span class="alert-banner-msg">' +
3943
- escapeHtml(alert.message || "") +
3944
- "</span>" +
3945
- '<span class="alert-banner-actions">';
3946
-
3947
- // Action buttons depend on alert type
3948
- if (alert.issue) {
3949
- html +=
3950
- '<button class="alert-action-btn" onclick="switchTab(\'pipelines\');fetchPipelineDetail(' +
3951
- alert.issue +
3952
- ')">View</button>';
3953
- }
3954
- if (alert.type === "failure_spike") {
3955
- html +=
3956
- "<button class=\"alert-action-btn btn-abort\" onclick=\"document.getElementById('emergency-modal').style.display=''\">Emergency Brake</button>";
3957
- }
3958
- if (alert.type === "stuck_pipeline" && alert.issue) {
3959
- html +=
3960
- '<button class="alert-action-btn btn-abort" onclick="sendIntervention(' +
3961
- alert.issue +
3962
- ",'abort')\">Abort</button>" +
3963
- '<button class="alert-action-btn" onclick="sendIntervention(' +
3964
- alert.issue +
3965
- ",'skip_stage')\">Skip Stage</button>";
3966
- }
3967
-
3968
- html +=
3969
- '<button class="alert-dismiss-btn" onclick="dismissAlert()">\u2715</button>';
3970
- html += "</span></div>";
3971
-
3972
- container.innerHTML = html;
3973
- container.style.display = "";
3974
- })
3975
- .catch(function () {
3976
- container.innerHTML = "";
3977
- container.style.display = "none";
3978
- });
3979
- }
3980
-
3981
- function dismissAlert() {
3982
- alertDismissed = true;
3983
- var container = document.getElementById("alert-banner");
3984
- if (container) {
3985
- container.innerHTML = "";
3986
- container.style.display = "none";
3987
- }
3988
- // Reset on next WS message with new alerts
3989
- setTimeout(function () {
3990
- alertDismissed = false;
3991
- }, 30000);
3992
- }
3993
-
3994
- function updateBulkToolbar() {
3995
- var toolbar = document.getElementById("bulk-actions");
3996
- if (!toolbar) return;
3997
- var count = Object.keys(selectedIssues).length;
3998
- if (count === 0) {
3999
- toolbar.style.display = "none";
4000
- return;
4001
- }
4002
- toolbar.style.display = "";
4003
- var countEl = document.getElementById("bulk-count");
4004
- if (countEl) countEl.textContent = count + " selected";
4005
- }
4006
-
4007
- function setupBulkActions() {
4008
- var toolbar = document.getElementById("bulk-actions");
4009
- if (!toolbar) return;
4010
-
4011
- var pauseBtn = document.getElementById("bulk-pause");
4012
- var resumeBtn = document.getElementById("bulk-resume");
4013
- var abortBtn = document.getElementById("bulk-abort");
4014
-
4015
- if (pauseBtn) {
4016
- pauseBtn.addEventListener("click", function () {
4017
- var issues = Object.keys(selectedIssues);
4018
- for (var i = 0; i < issues.length; i++) {
4019
- sendIntervention(issues[i], "pause");
4020
- }
4021
- });
4022
- }
4023
-
4024
- if (resumeBtn) {
4025
- resumeBtn.addEventListener("click", function () {
4026
- var issues = Object.keys(selectedIssues);
4027
- for (var i = 0; i < issues.length; i++) {
4028
- sendIntervention(issues[i], "resume");
4029
- }
4030
- });
4031
- }
4032
-
4033
- if (abortBtn) {
4034
- abortBtn.addEventListener("click", function () {
4035
- var issues = Object.keys(selectedIssues);
4036
- if (issues.length === 0) return;
4037
- if (
4038
- confirm(
4039
- "Abort " + issues.length + " pipeline(s)? This cannot be undone.",
4040
- )
4041
- ) {
4042
- for (var i = 0; i < issues.length; i++) {
4043
- sendIntervention(issues[i], "abort");
4044
- }
4045
- selectedIssues = {};
4046
- updateBulkToolbar();
4047
- }
4048
- });
4049
- }
4050
- }
4051
-
4052
- function updateEmergencyBrakeVisibility(data) {
4053
- var brakeBtn = document.getElementById("emergency-brake");
4054
- if (!brakeBtn) return;
4055
- var active = data.pipelines ? data.pipelines.length : 0;
4056
- brakeBtn.style.display = active > 0 ? "" : "none";
4057
- }
4058
-
4059
- function setupEmergencyBrake() {
4060
- var brakeBtn = document.getElementById("emergency-brake");
4061
- if (!brakeBtn) return;
4062
-
4063
- brakeBtn.addEventListener("click", function () {
4064
- var modal = document.getElementById("emergency-modal");
4065
- if (modal) modal.style.display = "";
4066
- });
4067
-
4068
- var confirmBtn = document.getElementById("emergency-confirm");
4069
- var cancelBtn = document.getElementById("emergency-cancel");
4070
- var modal = document.getElementById("emergency-modal");
4071
-
4072
- if (cancelBtn && modal) {
4073
- cancelBtn.addEventListener("click", function () {
4074
- modal.style.display = "none";
4075
- });
4076
- }
4077
-
4078
- if (modal) {
4079
- modal.addEventListener("click", function (e) {
4080
- if (e.target === modal) modal.style.display = "none";
4081
- });
4082
- }
4083
-
4084
- if (confirmBtn) {
4085
- confirmBtn.addEventListener("click", function () {
4086
- fetch("/api/emergency-brake", {
4087
- method: "POST",
4088
- headers: { "Content-Type": "application/json" },
4089
- })
4090
- .then(function (r) {
4091
- if (!r.ok) throw new Error("HTTP " + r.status);
4092
- return r.json();
4093
- })
4094
- .then(function () {
4095
- if (modal) modal.style.display = "none";
4096
- })
4097
- .catch(function (err) {
4098
- console.error("Emergency brake failed:", err);
4099
- if (modal) modal.style.display = "none";
4100
- });
4101
- });
4102
- }
4103
- }
4104
-
4105
- // ══════════════════════════════════════════════════════════════════
4106
- // HELPERS — truncate
4107
- // ══════════════════════════════════════════════════════════════════
4108
-
4109
- function truncate(str, maxLen) {
4110
- if (!str) return "";
4111
- return str.length > maxLen ? str.substring(0, maxLen) + "\u2026" : str;
4112
- }
4113
-
4114
- function padZero(n) {
4115
- return n < 10 ? "0" + n : "" + n;
4116
- }
4117
-
4118
- // ══════════════════════════════════════════════════════════════════
4119
- // Daemon Control
4120
- // ══════════════════════════════════════════════════════════════════
4121
- async function daemonControl(action) {
4122
- var btn = document.getElementById("daemon-btn-" + action);
4123
- if (btn) btn.disabled = true;
4124
-
4125
- try {
4126
- var method = "POST";
4127
- var url = "/api/daemon/" + action;
4128
-
4129
- // Toggle pause/resume
4130
- if (action === "pause") {
4131
- var badge = document.getElementById("daemon-status-badge");
4132
- if (badge && badge.classList.contains("paused")) {
4133
- url = "/api/daemon/resume";
4134
- }
4135
- }
4136
-
4137
- var resp = await fetch(url, { method: method });
4138
- var data = await resp.json();
4139
- if (!data.ok && data.error) {
4140
- console.warn("Daemon control error:", data.error);
4141
- }
4142
- // Refresh daemon status after action
4143
- setTimeout(fetchDaemonConfig, 1000);
4144
- } catch (err) {
4145
- console.error("Daemon control failed:", err);
4146
- } finally {
4147
- if (btn) btn.disabled = false;
4148
- }
4149
- }
4150
-
4151
- async function fetchDaemonConfig() {
4152
- try {
4153
- var resp = await fetch("/api/daemon/config");
4154
- if (!resp.ok) return;
4155
- var data = await resp.json();
4156
- updateDaemonControlBar(data);
4157
- } catch {
4158
- // dashboard may not be running
4159
- }
4160
- }
4161
-
4162
- function updateDaemonControlBar(data) {
4163
- var badge = document.getElementById("daemon-status-badge");
4164
- var pauseBtn = document.getElementById("daemon-btn-pause");
4165
- var workersEl = document.getElementById("daemon-info-workers");
4166
- var pollEl = document.getElementById("daemon-info-poll");
4167
- var patrolEl = document.getElementById("daemon-info-patrol");
4168
- var budgetEl = document.getElementById("daemon-info-budget");
4169
-
4170
- if (!badge) return;
4171
-
4172
- // Determine daemon status
4173
- if (data.paused) {
4174
- badge.textContent = "Paused";
4175
- badge.className = "daemon-status-badge paused";
4176
- if (pauseBtn) pauseBtn.textContent = "Resume";
4177
- } else if (data.config && data.config.watch_label) {
4178
- badge.textContent = "Running";
4179
- badge.className = "daemon-status-badge running";
4180
- if (pauseBtn) pauseBtn.textContent = "Pause";
4181
- } else {
4182
- badge.textContent = "Stopped";
4183
- badge.className = "daemon-status-badge stopped";
4184
- if (pauseBtn) pauseBtn.textContent = "Pause";
4185
- }
4186
-
4187
- // Update config info
4188
- if (data.config) {
4189
- if (workersEl) workersEl.textContent = data.config.max_workers || "-";
4190
- if (pollEl) pollEl.textContent = data.config.poll_interval || "-";
4191
- if (patrolEl)
4192
- patrolEl.textContent =
4193
- (data.config.patrol && data.config.patrol.interval) || "-";
4194
- }
4195
-
4196
- // Update budget info
4197
- if (data.budget && budgetEl) {
4198
- var remaining = data.budget.remaining || data.budget.daily_limit || "-";
4199
- budgetEl.textContent =
4200
- typeof remaining === "number" ? remaining.toFixed(2) : remaining;
4201
- }
4202
- }
4203
-
4204
- // Wire alert actions for daemon control
4205
- function handleAlertAction(action) {
4206
- if (action === "pause_daemon") {
4207
- daemonControl("pause");
4208
- } else if (action === "scale_up") {
4209
- // Could implement config update; for now just log
4210
- console.log("Scale up requested via alert action");
4211
- }
4212
- }
4213
-
4214
- // ══════════════════════════════════════════════════════════════════
4215
- // TEAM TAB
4216
- // ══════════════════════════════════════════════════════════════════
4217
-
4218
- function timeAgo(date) {
4219
- var seconds = Math.floor((Date.now() - date.getTime()) / 1000);
4220
- if (seconds < 60) return seconds + "s ago";
4221
- var minutes = Math.floor(seconds / 60);
4222
- if (minutes < 60) return minutes + "m ago";
4223
- var hours = Math.floor(minutes / 60);
4224
- if (hours < 24) return hours + "h ago";
4225
- return Math.floor(hours / 24) + "d ago";
4226
- }
4227
-
4228
- function fetchTeamData() {
4229
- fetch("/api/team")
4230
- .then(function (r) {
4231
- return r.json();
4232
- })
4233
- .then(function (data) {
4234
- teamCache = data;
4235
- renderTeamGrid(data);
4236
- renderTeamStats(data);
4237
- })
4238
- .catch(function () {});
4239
-
4240
- fetch("/api/team/activity")
4241
- .then(function (r) {
4242
- return r.json();
4243
- })
4244
- .then(function (data) {
4245
- teamActivityCache = data;
4246
- renderTeamActivity(data);
4247
- })
4248
- .catch(function () {});
4249
- }
4250
-
4251
- function renderTeamStats(data) {
4252
- var el = document.getElementById("team-stat-online");
4253
- if (el) el.textContent = (data.total_online || 0).toString();
4254
- el = document.getElementById("team-stat-pipelines");
4255
- if (el) el.textContent = (data.total_active_pipelines || 0).toString();
4256
- el = document.getElementById("team-stat-queued");
4257
- if (el) el.textContent = (data.total_queued || 0).toString();
4258
- }
4259
-
4260
- function renderTeamGrid(data) {
4261
- var grid = document.getElementById("team-grid");
4262
- if (!grid) return;
4263
-
4264
- var devs = data.developers || [];
4265
- if (devs.length === 0) {
4266
- grid.innerHTML =
4267
- '<div class="empty-state">No developers connected. Run <code>shipwright connect start</code> to join.</div>';
4268
- return;
4269
- }
4270
-
4271
- grid.innerHTML = devs
4272
- .map(function (dev) {
4273
- var presence = dev._presence || "offline";
4274
- var initials = (dev.developer_id || "?").substring(0, 2).toUpperCase();
4275
- var pipelines = (dev.active_jobs || [])
4276
- .map(function (job) {
4277
- return (
4278
- '<div class="team-card-pipeline-item">' +
4279
- '<span class="team-card-pipeline-issue">#' +
4280
- escapeHtml(String(job.issue)) +
4281
- "</span>" +
4282
- '<span class="team-card-pipeline-stage">' +
4283
- escapeHtml(job.stage || "\u2014") +
4284
- "</span>" +
4285
- "</div>"
4286
- );
4287
- })
4288
- .join("");
4289
-
4290
- var pipelineSection = pipelines
4291
- ? '<div class="team-card-pipelines">' + pipelines + "</div>"
4292
- : "";
4293
-
4294
- return (
4295
- '<div class="team-card">' +
4296
- '<div class="team-card-header">' +
4297
- '<div class="team-card-avatar">' +
4298
- escapeHtml(initials) +
4299
- "</div>" +
4300
- '<div class="team-card-info">' +
4301
- '<div class="team-card-name">' +
4302
- escapeHtml(dev.developer_id) +
4303
- "</div>" +
4304
- '<div class="team-card-machine">' +
4305
- escapeHtml(dev.machine_name) +
4306
- "</div>" +
4307
- "</div>" +
4308
- '<div class="presence-dot ' +
4309
- presence +
4310
- '" title="' +
4311
- presence +
4312
- '"></div>' +
4313
- "</div>" +
4314
- '<div class="team-card-body">' +
4315
- '<div class="team-card-row">' +
4316
- '<span class="team-card-row-label">Daemon</span>' +
4317
- '<span class="team-card-row-value">' +
4318
- (dev.daemon_running ? "\u25cf Running" : "\u25cb Stopped") +
4319
- "</span>" +
4320
- "</div>" +
4321
- '<div class="team-card-row">' +
4322
- '<span class="team-card-row-label">Active</span>' +
4323
- '<span class="team-card-row-value">' +
4324
- (dev.active_jobs || []).length +
4325
- " pipelines</span>" +
4326
- "</div>" +
4327
- '<div class="team-card-row">' +
4328
- '<span class="team-card-row-label">Queued</span>' +
4329
- '<span class="team-card-row-value">' +
4330
- (dev.queued || []).length +
4331
- " issues</span>" +
4332
- "</div>" +
4333
- pipelineSection +
4334
- "</div>" +
4335
- "</div>"
4336
- );
4337
- })
4338
- .join("");
4339
- }
4340
-
4341
- function renderTeamActivity(events) {
4342
- var container = document.getElementById("team-activity");
4343
- if (!container) return;
4344
-
4345
- var items = Array.isArray(events) ? events : events.events || [];
4346
- if (items.length === 0) {
4347
- container.innerHTML =
4348
- '<div class="empty-state">No team activity yet.</div>';
4349
- return;
4350
- }
4351
-
4352
- container.innerHTML = items
4353
- .slice(0, 50)
4354
- .map(function (evt) {
4355
- var isCI = evt.from_developer === "github-actions";
4356
- var badgeClass = isCI ? "ci" : "local";
4357
- var badgeText = isCI ? "CI" : evt.from_developer || "local";
4358
- var text = formatTeamEvent(evt);
4359
- var time = evt.ts ? timeAgo(new Date(evt.ts)) : "";
4360
-
4361
- return (
4362
- '<div class="team-activity-item">' +
4363
- '<span class="source-badge ' +
4364
- badgeClass +
4365
- '">' +
4366
- escapeHtml(badgeText) +
4367
- "</span>" +
4368
- '<div class="team-activity-content">' +
4369
- '<div class="team-activity-text">' +
4370
- text +
4371
- "</div>" +
4372
- '<div class="team-activity-time">' +
4373
- time +
4374
- "</div>" +
4375
- "</div>" +
4376
- "</div>"
4377
- );
4378
- })
4379
- .join("");
4380
- }
4381
-
4382
- function formatTeamEvent(evt) {
4383
- var type = evt.type || "";
4384
- var issue = evt.issue ? " #" + evt.issue : "";
4385
-
4386
- if (type.indexOf("pipeline.started") !== -1)
4387
- return "Pipeline started" + issue;
4388
- if (
4389
- type.indexOf("pipeline.completed") !== -1 ||
4390
- type.indexOf("pipeline_completed") !== -1
4391
- ) {
4392
- var result = evt.result === "success" ? "\u2713" : "\u2717";
4393
- return "Pipeline " + result + issue;
4394
- }
4395
- if (type.indexOf("stage.") !== -1) {
4396
- var stage = evt.stage || type.split(".").pop();
4397
- return "Stage " + escapeHtml(stage) + issue;
4398
- }
4399
- if (type.indexOf("daemon.") !== -1)
4400
- return type.replace("daemon.", "Daemon: ");
4401
- if (type.indexOf("ci.") !== -1) return type.replace("ci.", "CI: ") + issue;
4402
-
4403
- return escapeHtml(type) + issue;
4404
- }
4405
-
4406
- // ══════════════════════════════════════════════════════════════════
4407
- // BOOT
4408
- // ══════════════════════════════════════════════════════════════════
4409
-
4410
- fetchUser();
4411
- setupUserMenu();
4412
- setupTabs();
4413
- setupPipelineFilters();
4414
- setupActivityFilters();
4415
- setupTimelineControls();
4416
- setupInterventionModal();
4417
- setupBulkActions();
4418
- setupEmergencyBrake();
4419
- setupMachinesTab();
4420
- fetchDaemonConfig();
4421
- setInterval(fetchDaemonConfig, 30000);
4422
- connect();