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.
- package/README.md +12 -11
- package/dashboard/public/index.html +224 -8
- package/dashboard/public/styles.css +1078 -4
- package/dashboard/server.ts +1100 -15
- package/dashboard/src/canvas/interactions.ts +74 -0
- package/dashboard/src/canvas/layout.ts +85 -0
- package/dashboard/src/canvas/overlays.ts +117 -0
- package/dashboard/src/canvas/particles.ts +105 -0
- package/dashboard/src/canvas/renderer.ts +191 -0
- package/dashboard/src/components/charts/bar.ts +54 -0
- package/dashboard/src/components/charts/donut.ts +25 -0
- package/dashboard/src/components/charts/pipeline-rail.ts +105 -0
- package/dashboard/src/components/charts/sparkline.ts +82 -0
- package/dashboard/src/components/header.ts +616 -0
- package/dashboard/src/components/modal.ts +413 -0
- package/dashboard/src/components/terminal.ts +144 -0
- package/dashboard/src/core/api.ts +381 -0
- package/dashboard/src/core/helpers.ts +118 -0
- package/dashboard/src/core/router.ts +190 -0
- package/dashboard/src/core/sse.ts +38 -0
- package/dashboard/src/core/state.ts +150 -0
- package/dashboard/src/core/ws.ts +143 -0
- package/dashboard/src/design/icons.ts +131 -0
- package/dashboard/src/design/tokens.ts +160 -0
- package/dashboard/src/main.ts +68 -0
- package/dashboard/src/types/api.ts +337 -0
- package/dashboard/src/views/activity.ts +185 -0
- package/dashboard/src/views/agent-cockpit.ts +236 -0
- package/dashboard/src/views/agents.ts +72 -0
- package/dashboard/src/views/fleet-map.ts +299 -0
- package/dashboard/src/views/insights.ts +298 -0
- package/dashboard/src/views/machines.ts +162 -0
- package/dashboard/src/views/metrics.ts +420 -0
- package/dashboard/src/views/overview.ts +409 -0
- package/dashboard/src/views/pipeline-theater.ts +219 -0
- package/dashboard/src/views/pipelines.ts +595 -0
- package/dashboard/src/views/team.ts +362 -0
- package/dashboard/src/views/timeline.ts +389 -0
- package/dashboard/tsconfig.json +21 -0
- package/docs/AGI-WHATS-NEXT.md +15 -15
- package/package.json +8 -1
- package/scripts/lib/helpers.sh +30 -0
- package/scripts/lib/pipeline-quality-checks.sh +1 -1
- package/scripts/sw +86 -167
- package/scripts/sw-activity.sh +1 -1
- package/scripts/sw-adaptive.sh +1 -1
- package/scripts/sw-adversarial.sh +1 -1
- package/scripts/sw-architecture-enforcer.sh +1 -1
- package/scripts/sw-auth.sh +14 -6
- package/scripts/sw-autonomous.sh +1 -1
- package/scripts/sw-changelog.sh +2 -2
- package/scripts/sw-checkpoint.sh +1 -1
- package/scripts/sw-ci.sh +1 -1
- package/scripts/sw-cleanup.sh +1 -1
- package/scripts/sw-code-review.sh +1 -1
- package/scripts/sw-connect.sh +1 -1
- package/scripts/sw-context.sh +1 -1
- package/scripts/sw-cost.sh +1 -1
- package/scripts/sw-daemon.sh +2 -2
- package/scripts/sw-dashboard.sh +1 -1
- package/scripts/sw-db.sh +1 -1
- package/scripts/sw-decompose.sh +1 -1
- package/scripts/sw-deps.sh +1 -1
- package/scripts/sw-developer-simulation.sh +1 -1
- package/scripts/sw-discovery.sh +1 -1
- package/scripts/sw-doc-fleet.sh +1 -1
- package/scripts/sw-docs-agent.sh +1 -1
- package/scripts/sw-docs.sh +1 -1
- package/scripts/sw-doctor.sh +8 -1
- package/scripts/sw-dora.sh +1 -1
- package/scripts/sw-durable.sh +1 -1
- package/scripts/sw-e2e-orchestrator.sh +1 -1
- package/scripts/sw-eventbus.sh +1 -1
- package/scripts/sw-feedback.sh +1 -1
- package/scripts/sw-fix.sh +6 -5
- package/scripts/sw-fleet-discover.sh +1 -1
- package/scripts/sw-fleet-viz.sh +1 -1
- package/scripts/sw-fleet.sh +1 -1
- package/scripts/sw-github-app.sh +5 -2
- package/scripts/sw-github-checks.sh +1 -1
- package/scripts/sw-github-deploy.sh +1 -1
- package/scripts/sw-github-graphql.sh +1 -1
- package/scripts/sw-guild.sh +1 -1
- package/scripts/sw-heartbeat.sh +1 -1
- package/scripts/sw-hygiene.sh +1 -1
- package/scripts/sw-incident.sh +1 -1
- package/scripts/sw-init.sh +112 -9
- package/scripts/sw-instrument.sh +6 -1
- package/scripts/sw-intelligence.sh +5 -1
- package/scripts/sw-jira.sh +1 -1
- package/scripts/sw-launchd.sh +1 -1
- package/scripts/sw-linear.sh +20 -9
- package/scripts/sw-logs.sh +1 -1
- package/scripts/sw-loop.sh +2 -1
- package/scripts/sw-memory.sh +10 -1
- package/scripts/sw-mission-control.sh +1 -1
- package/scripts/sw-model-router.sh +4 -1
- package/scripts/sw-otel.sh +1 -1
- package/scripts/sw-oversight.sh +1 -1
- package/scripts/sw-pipeline-composer.sh +3 -1
- package/scripts/sw-pipeline-vitals.sh +4 -6
- package/scripts/sw-pipeline.sh +4 -1
- package/scripts/sw-pm.sh +5 -2
- package/scripts/sw-pr-lifecycle.sh +1 -1
- package/scripts/sw-predictive.sh +4 -1
- package/scripts/sw-prep.sh +3 -2
- package/scripts/sw-ps.sh +1 -1
- package/scripts/sw-public-dashboard.sh +10 -4
- package/scripts/sw-quality.sh +1 -1
- package/scripts/sw-reaper.sh +1 -1
- package/scripts/sw-recruit.sh +16 -0
- package/scripts/sw-regression.sh +2 -1
- package/scripts/sw-release-manager.sh +1 -1
- package/scripts/sw-release.sh +7 -5
- package/scripts/sw-remote.sh +1 -1
- package/scripts/sw-replay.sh +1 -1
- package/scripts/sw-retro.sh +1 -1
- package/scripts/sw-scale.sh +4 -1
- package/scripts/sw-security-audit.sh +1 -1
- package/scripts/sw-self-optimize.sh +15 -1
- package/scripts/sw-session.sh +1 -1
- package/scripts/sw-setup.sh +1 -1
- package/scripts/sw-standup.sh +2 -1
- package/scripts/sw-status.sh +1 -1
- package/scripts/sw-strategic.sh +2 -1
- package/scripts/sw-stream.sh +1 -1
- package/scripts/sw-swarm.sh +6 -1
- package/scripts/sw-team-stages.sh +1 -1
- package/scripts/sw-templates.sh +1 -1
- package/scripts/sw-testgen.sh +3 -2
- package/scripts/sw-tmux-pipeline.sh +2 -1
- package/scripts/sw-tmux.sh +1 -1
- package/scripts/sw-trace.sh +1 -1
- package/scripts/sw-tracker-jira.sh +1 -0
- package/scripts/sw-tracker-linear.sh +1 -0
- package/scripts/sw-tracker.sh +1 -1
- package/scripts/sw-triage.sh +1 -1
- package/scripts/sw-upgrade.sh +1 -1
- package/scripts/sw-ux.sh +1 -1
- package/scripts/sw-webhook.sh +1 -1
- package/scripts/sw-widgets.sh +2 -2
- package/scripts/sw-worktree.sh +1 -1
- package/dashboard/public/app.js +0 -4422
package/dashboard/public/app.js
DELETED
|
@@ -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, "&")
|
|
185
|
-
.replace(/</g, "<")
|
|
186
|
-
.replace(/>/g, ">")
|
|
187
|
-
.replace(/"/g, """);
|
|
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">▮▮</button>' +
|
|
1896
|
-
'<button class="agent-action-btn" onclick="sendIntervention(' +
|
|
1897
|
-
a.issue +
|
|
1898
|
-
', \'resume\')" title="Resume">▶</button>' +
|
|
1899
|
-
'<button class="agent-action-btn" onclick="openInterventionModal(' +
|
|
1900
|
-
a.issue +
|
|
1901
|
-
')" title="Message">✉</button>' +
|
|
1902
|
-
'<button class="agent-action-btn btn-abort" onclick="confirmAbort(' +
|
|
1903
|
-
a.issue +
|
|
1904
|
-
')" title="Abort">✕</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();
|