runmonitor 0.2.0__py3-none-any.whl

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.
@@ -0,0 +1,615 @@
1
+ <!doctype html>
2
+ <html lang="en" data-theme="midnight">
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1">
6
+ <title>runmonitor</title>
7
+ <link rel="preconnect" href="https://fonts.googleapis.com">
8
+ <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
9
+ <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&family=JetBrains+Mono:wght@400;500;600&display=swap" rel="stylesheet">
10
+ <link rel="stylesheet" href="/static/style.css">
11
+ <script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.0/dist/chart.umd.min.js"></script>
12
+ </head>
13
+ <body>
14
+
15
+ <header class="navbar">
16
+ <div class="navbar-brand">
17
+ <svg class="logo" width="22" height="22" viewBox="0 0 200 200">
18
+ <circle cx="100" cy="100" r="94" fill="none" stroke="currentColor" stroke-width="4"/>
19
+ <circle cx="100" cy="100" r="78" fill="none" stroke="currentColor" stroke-width="2"/>
20
+ <text x="100" y="102" fill="currentColor" font-family="'Inter','Arial Black',sans-serif" font-weight="900" font-size="52" text-anchor="middle" dominant-baseline="central">R</text>
21
+ </svg>
22
+ <span class="brand-text">runmonitor</span>
23
+ </div>
24
+ <div class="navbar-actions">
25
+ <span class="gamification-badges" id="gamification-badges"></span>
26
+ <span class="status-dot" id="status-dot" title="live"></span>
27
+ <button class="btn-icon" id="btn-export" title="Export">↓</button>
28
+ <button class="btn-icon" id="btn-refresh" title="Refresh">⟳</button>
29
+ <button class="btn-icon" id="btn-theme" title="Toggle theme">◐</button>
30
+ </div>
31
+ </header>
32
+
33
+ <main class="main">
34
+
35
+ <!-- Selectors -->
36
+ <div class="selectors">
37
+ <div class="field">
38
+ <label>Project</label>
39
+ <select id="sel-project"></select>
40
+ </div>
41
+ <div class="field">
42
+ <label>Run</label>
43
+ <select id="sel-run"></select>
44
+ </div>
45
+ <div class="field" id="compare-field" hidden>
46
+ <label>Compare with</label>
47
+ <select id="sel-compare"><option value="">— none —</option></select>
48
+ </div>
49
+ </div>
50
+
51
+ <!-- Empty state -->
52
+ <div class="empty" id="empty">
53
+ <div class="big">no run selected</div>
54
+ <div>pick a project &amp; run above — the curve comes alive here</div>
55
+ </div>
56
+
57
+ <!-- Run header -->
58
+ <section class="panel" id="runhead" hidden>
59
+ <span class="panel-label">run</span>
60
+ <div class="runhead-grid">
61
+ <span class="rh-name" id="rh-name">—</span>
62
+ <span class="rh-step">step <strong id="rh-step">0</strong></span>
63
+ <span class="rh-status" id="rh-status">—</span>
64
+ <span class="rh-spacer"></span>
65
+ <span class="rh-stat"><label>elapsed</label><span id="rh-elapsed">—</span></span>
66
+ <span class="rh-stat"><label>steps/sec</label><span id="rh-velocity">—</span></span>
67
+ <span class="rh-stat" id="rh-eta-wrap" hidden><label>eta</label><span id="rh-eta">—</span></span>
68
+ </div>
69
+ <div class="progress" id="rh-progress" hidden>
70
+ <div class="progress-bar"><div class="progress-fill" id="rh-fill"></div></div>
71
+ <span class="progress-label" id="rh-proglabel"></span>
72
+ </div>
73
+ </section>
74
+
75
+ <!-- Metric ticker = selector -->
76
+ <section class="panel" id="metrics-panel" hidden>
77
+ <span class="panel-label">metrics</span>
78
+ <div class="ticker" id="ticker"></div>
79
+ </section>
80
+
81
+ <!-- Hero chart -->
82
+ <section class="panel hero" id="hero-panel" hidden>
83
+ <span class="panel-label" id="hero-label">metric</span>
84
+ <div class="hero-wrap"><canvas id="hero-canvas"></canvas></div>
85
+ </section>
86
+
87
+ <!-- Anomaly status line -->
88
+ <div class="statusline calm" id="statusline" hidden>● all calm</div>
89
+
90
+ <!-- System metrics -->
91
+ <section class="panel" id="sys-panel" hidden>
92
+ <span class="panel-label">system</span>
93
+ <div class="sys-grid">
94
+ <div>
95
+ <div class="sys-chart-title">cpu %</div>
96
+ <div class="sys-canvas-wrap"><canvas id="chart-sys-cpu"></canvas></div>
97
+ </div>
98
+ <div>
99
+ <div class="sys-chart-title">ram %</div>
100
+ <div class="sys-canvas-wrap"><canvas id="chart-sys-mem"></canvas></div>
101
+ </div>
102
+ </div>
103
+ </section>
104
+
105
+ <!-- Hyperparameters -->
106
+ <section class="panel" id="config-panel" hidden>
107
+ <span class="panel-label">hyperparameters</span>
108
+ <table class="config-table" id="config-table"></table>
109
+ </section>
110
+
111
+ <!-- Artifacts -->
112
+ <section class="panel" id="artifacts-panel" hidden>
113
+ <span class="panel-label">artifacts</span>
114
+ <table class="config-table" id="artifacts-table"></table>
115
+ </section>
116
+
117
+ </main>
118
+
119
+ <script>
120
+ const API = { async get(p) { const r = await fetch(p); return r.json(); } };
121
+
122
+ // ── State ────────────────────────────────────────────────
123
+ let state = {
124
+ project: null, runId: null, run: null, compareId: null,
125
+ selectedKey: null,
126
+ heroChart: null, sysCharts: {},
127
+ pollTimer: null, theme: "midnight",
128
+ lastPoll: Date.now(), prevMaxStep: 0, lastStep: 0,
129
+ metricsByKey: {}, lastValues: {}, sysData: null,
130
+ startTime: null, streakCount: 0, bestValue: null, bestKey: null,
131
+ };
132
+
133
+ // ── Helpers ──────────────────────────────────────────────
134
+ const $ = (s) => document.querySelector(s);
135
+ const esc = (v) => String(v).replace(/[&<>"']/g, c =>
136
+ ({"&":"&amp;","<":"&lt;",">":"&gt;",'"':"&quot;","'":"&#39;"}[c]));
137
+ const css = (n) => getComputedStyle(document.documentElement).getPropertyValue(n).trim();
138
+ const fmtVal = (v) => (Math.abs(v) >= 1000 || (v !== 0 && Math.abs(v) < 0.001))
139
+ ? v.toExponential(2) : (+v.toFixed(4)).toString();
140
+
141
+ const selProject = $("#sel-project"), selRun = $("#sel-run"), selCompare = $("#sel-compare");
142
+ const compareField = $("#compare-field"), ticker = $("#ticker");
143
+ const statusDot = $("#status-dot"), statusLine = $("#statusline");
144
+ const gamoBadges = $("#gamification-badges"), emptyState = $("#empty");
145
+
146
+ // ── Theme ────────────────────────────────────────────────
147
+ function setTheme(t) {
148
+ state.theme = t;
149
+ document.documentElement.setAttribute("data-theme", t);
150
+ // recolor live charts
151
+ if (state.selectedKey && state.metricsByKey[state.selectedKey]) createHeroChart(true);
152
+ if (state.sysData) renderSystemCharts(state.sysData);
153
+ }
154
+ $("#btn-theme").onclick = () => setTheme(state.theme === "midnight" ? "light" : "midnight");
155
+
156
+ // ── Projects / runs ──────────────────────────────────────
157
+ async function loadProjects() {
158
+ const projects = await API.get("/api/projects");
159
+ selProject.innerHTML = '<option value="">— select project —</option>';
160
+ projects.forEach(p => selProject.innerHTML += `<option value="${esc(p.name)}">${esc(p.name)}</option>`);
161
+ if (state.project && projects.find(p => p.name === state.project)) {
162
+ selProject.value = state.project;
163
+ await loadRuns(state.project);
164
+ }
165
+ }
166
+
167
+ const STATUS_ICON = { running:" ◉", crashed:" ✕", finished:" ✓" };
168
+ const runLabel = (r) => (r.name ? `${esc(r.name)} (${esc(r.id)})` : esc(r.id)) + (STATUS_ICON[r.status] || "");
169
+ const runName = (id) => { const r = (state.runs || []).find(r => r.id === id); return (r && r.name) ? r.name : id; };
170
+
171
+ async function loadRuns(project) {
172
+ const runs = await API.get(`/api/runs?project=${encodeURIComponent(project)}`);
173
+ state.runs = runs;
174
+ selRun.innerHTML = '<option value="">— select run —</option>' +
175
+ runs.map(r => `<option value="${esc(r.id)}">${runLabel(r)}</option>`).join("");
176
+ if (state.runId && runs.find(r => r.id === state.runId)) selRun.value = state.runId;
177
+ populateCompare(state.runId);
178
+ }
179
+
180
+ // Build the compare dropdown, always excluding the active run.
181
+ function populateCompare(excludeId) {
182
+ const others = (state.runs || []).filter(r => r.id !== excludeId);
183
+ const cur = selCompare.value;
184
+ selCompare.innerHTML = '<option value="">— none —</option>' +
185
+ others.map(r => `<option value="${esc(r.id)}">${runLabel(r)}</option>`).join("");
186
+ selCompare.value = (cur && others.find(r => r.id === cur)) ? cur : "";
187
+ compareField.hidden = others.length === 0;
188
+ }
189
+
190
+ // ── Load a single run ────────────────────────────────────
191
+ async function loadRun(runId) {
192
+ Object.assign(state, {
193
+ runId, run: null, compareId: null, selectedKey: null,
194
+ prevMaxStep: 0, lastStep: 0, metricsByKey: {}, lastValues: {},
195
+ startTime: Date.now(), streakCount: 0, bestValue: null, sysData: null,
196
+ });
197
+ if (state.heroChart) { state.heroChart.destroy(); state.heroChart = null; }
198
+ destroySysCharts();
199
+ ticker.innerHTML = ""; gamoBadges.innerHTML = "";
200
+ selCompare.value = "";
201
+ populateCompare(runId);
202
+ emptyState.hidden = true;
203
+ $("#runhead").hidden = false;
204
+ $("#metrics-panel").hidden = false;
205
+ $("#hero-panel").hidden = false;
206
+
207
+ const config = await API.get(`/api/runs/${runId}/config`);
208
+ if (config && !config.error) {
209
+ state.run = config;
210
+ $("#rh-name").textContent = state.runId;
211
+ // Hyperparameters
212
+ let cfg = {};
213
+ try { cfg = JSON.parse(config.config_json || "{}"); } catch(e) {}
214
+ const keys = Object.keys(cfg);
215
+ if (keys.length) {
216
+ $("#config-panel").hidden = false;
217
+ $("#config-table").innerHTML = keys.map(k =>
218
+ `<tr><td class="cfg-key">${esc(k)}</td><td class="cfg-val">${esc(JSON.stringify(cfg[k]))}</td></tr>`).join("");
219
+ } else { $("#config-panel").hidden = true; }
220
+ $("#rh-progress").hidden = !config.total_steps;
221
+ }
222
+
223
+ await loadArtifacts();
224
+ await loadSystemMetrics();
225
+ await pollMetrics();
226
+ if (state.pollTimer) clearInterval(state.pollTimer);
227
+ state.pollTimer = setInterval(pollMetrics, 2000);
228
+ }
229
+
230
+ async function loadArtifacts() {
231
+ const arts = await API.get(`/api/runs/${state.runId}/artifacts`);
232
+ if (arts.length) {
233
+ $("#artifacts-panel").hidden = false;
234
+ $("#artifacts-table").innerHTML = arts.map(a =>
235
+ `<tr><td class="cfg-key">${esc(a.filename)}</td><td class="cfg-val">${(a.size_bytes/1024).toFixed(1)} KB</td></tr>`).join("");
236
+ } else { $("#artifacts-panel").hidden = true; }
237
+ }
238
+
239
+ // ── Poll metrics ─────────────────────────────────────────
240
+ async function pollMetrics() {
241
+ if (!state.runId) return;
242
+ statusDot.classList.remove("pulse"); void statusDot.offsetWidth; statusDot.classList.add("pulse");
243
+ try {
244
+ const fresh = await API.get(`/api/runs/${state.runId}/metrics/live?since=${state.lastStep}`);
245
+ fresh.forEach(m => {
246
+ (state.metricsByKey[m.key] ||= []).push(m);
247
+ if (m.step > state.lastStep) state.lastStep = m.step;
248
+ });
249
+
250
+ const byKey = state.metricsByKey;
251
+ const keys = Object.keys(byKey);
252
+ if (!keys.length) return;
253
+
254
+ // pick default selected metric (prefer "loss")
255
+ if (!state.selectedKey || !byKey[state.selectedKey]) {
256
+ state.selectedKey = keys.find(k => /loss/i.test(k)) || keys[0];
257
+ }
258
+
259
+ let maxStep = 0;
260
+ keys.forEach(k => byKey[k].forEach(m => { if (m.step > maxStep) maxStep = m.step; }));
261
+
262
+ updateRunHeader(maxStep);
263
+ buildTicker(byKey);
264
+ renderHero(byKey);
265
+ updateGamification(byKey);
266
+ if (state.compareId) await pollCompare();
267
+ } catch(e) {
268
+ console.error("poll error", e);
269
+ }
270
+ }
271
+
272
+ // ── Run header ───────────────────────────────────────────
273
+ function updateRunHeader(maxStep) {
274
+ $("#rh-step").textContent = maxStep;
275
+
276
+ if (state.startTime) {
277
+ const el = Math.floor((Date.now() - state.startTime) / 1000);
278
+ $("#rh-elapsed").textContent = `${Math.floor(el/60)}m ${el%60}s`;
279
+ }
280
+ let vel = 0;
281
+ if (state.lastPoll > 0) {
282
+ const dt = (Date.now() - state.lastPoll) / 1000;
283
+ const ds = maxStep - state.prevMaxStep;
284
+ if (dt > 0) { vel = ds/dt; $("#rh-velocity").textContent = vel.toFixed(1); }
285
+ }
286
+ state.prevMaxStep = maxStep; state.lastPoll = Date.now();
287
+
288
+ if (state.run && state.run.total_steps) {
289
+ const total = state.run.total_steps;
290
+ const pct = Math.min(100, Math.round((maxStep/total)*100));
291
+ $("#rh-fill").style.width = pct + "%";
292
+ $("#rh-proglabel").textContent = `${maxStep} / ${total} (${pct}%)`;
293
+ const etaWrap = $("#rh-eta-wrap");
294
+ if (vel > 0 && maxStep < total) {
295
+ const eta = Math.round((total - maxStep) / vel);
296
+ $("#rh-eta").textContent = `${Math.floor(eta/60)}m ${eta%60}s`;
297
+ etaWrap.hidden = false;
298
+ } else { etaWrap.hidden = true; }
299
+ }
300
+
301
+ const st = (state.run && state.run.status) || "running";
302
+ const pill = $("#rh-status");
303
+ pill.textContent = st;
304
+ pill.className = "rh-status " + st;
305
+ }
306
+
307
+ // ── Ticker (the selector) ────────────────────────────────
308
+ function buildTicker(byKey) {
309
+ const keys = Object.keys(byKey);
310
+ const html = keys.map(k => {
311
+ const last = byKey[k][byKey[k].length - 1].value;
312
+ const changed = state.lastValues[k] !== undefined && state.lastValues[k] !== last;
313
+ state.lastValues[k] = last;
314
+ const sel = k === state.selectedKey ? " sel" : "";
315
+ const flash = changed ? " flash" : "";
316
+ return `<span class="chip${sel}${flash}" data-key="${esc(k)}"><span class="k">${esc(k)}</span>=<span class="v">${esc(fmtVal(last))}</span></span>`;
317
+ }).join("");
318
+ ticker.innerHTML = html;
319
+ ticker.querySelectorAll(".chip").forEach(el =>
320
+ el.onclick = () => selectMetric(el.dataset.key));
321
+ }
322
+
323
+ function selectMetric(key) {
324
+ if (key === state.selectedKey) return;
325
+ state.selectedKey = key;
326
+ ticker.querySelectorAll(".chip").forEach(el =>
327
+ el.classList.toggle("sel", el.dataset.key === key));
328
+ createHeroChart(); // recreate → curve "materializes"
329
+ if (state.compareId) pollCompare();
330
+ }
331
+
332
+ // ── Hero chart ───────────────────────────────────────────
333
+ function makeGradient(ctx, color) {
334
+ const g = ctx.createLinearGradient(0, 0, 0, 320);
335
+ g.addColorStop(0, color + "44");
336
+ g.addColorStop(1, color + "00");
337
+ return g;
338
+ }
339
+
340
+ function heroDataset(key, byKey, ctx) {
341
+ const data = byKey[key];
342
+ const color = css("--accent");
343
+ return {
344
+ label: key,
345
+ data: data.map(d => ({ x: d.step, y: d.value })),
346
+ borderColor: color,
347
+ backgroundColor: makeGradient(ctx, color),
348
+ borderWidth: 1.7,
349
+ tension: 0.25,
350
+ fill: true,
351
+ pointRadius: (c) => c.dataIndex === c.dataset.data.length - 1 ? 3.2 : 0,
352
+ pointBackgroundColor: color,
353
+ pointBorderColor: css("--paper"),
354
+ pointBorderWidth: 1.5,
355
+ };
356
+ }
357
+
358
+ // progressive left-to-right "draw" animation (replayed on each (re)create)
359
+ function materializeAnimation(n) {
360
+ const per = n > 0 ? Math.min(900 / n, 40) : 0;
361
+ const prevY = (ctx) => {
362
+ if (ctx.index === 0) return ctx.chart.scales.y.getPixelForValue(ctx.chart.scales.y.min);
363
+ const meta = ctx.chart.getDatasetMeta(ctx.datasetIndex).data[ctx.index - 1];
364
+ return meta ? meta.getProps(['y'], true).y : 0;
365
+ };
366
+ return {
367
+ x: { type:"number", easing:"linear", duration: per, from: NaN,
368
+ delay(ctx){ if (ctx.type!=="data"||ctx.xStarted) return 0; ctx.xStarted=true; return ctx.index*per; } },
369
+ y: { type:"number", easing:"linear", duration: per, from: prevY,
370
+ delay(ctx){ if (ctx.type!=="data"||ctx.yStarted) return 0; ctx.yStarted=true; return ctx.index*per; } },
371
+ };
372
+ }
373
+
374
+ function heroOptions(animate, n) {
375
+ return {
376
+ responsive: true,
377
+ maintainAspectRatio: false,
378
+ animation: animate ? materializeAnimation(n) : false,
379
+ interaction: { intersect: false, mode: "index" },
380
+ plugins: {
381
+ legend: { display: false },
382
+ tooltip: {
383
+ backgroundColor: css("--paper-2"), titleColor: css("--ink"),
384
+ bodyColor: css("--ink-2"), borderColor: css("--rule"), borderWidth: 1,
385
+ titleFont: { family: "JetBrains Mono" }, bodyFont: { family: "JetBrains Mono" },
386
+ },
387
+ },
388
+ scales: {
389
+ x: { type: "linear",
390
+ title: { display: true, text: "step", color: css("--muted") },
391
+ grid: { color: css("--rule-2") },
392
+ ticks: { color: css("--muted"), font: { family: "JetBrains Mono", size: 11 }, maxRotation: 0 } },
393
+ y: { grid: { color: css("--rule-2") },
394
+ ticks: { color: css("--muted"), font: { family: "JetBrains Mono", size: 11 } } },
395
+ },
396
+ };
397
+ }
398
+
399
+ function createHeroChart(animate) {
400
+ const key = state.selectedKey, byKey = state.metricsByKey;
401
+ if (!key || !byKey[key]) return;
402
+ const canvas = $("#hero-canvas");
403
+ const ctx = canvas.getContext("2d");
404
+ if (state.heroChart) { state.heroChart.destroy(); state.heroChart = null; }
405
+ state.heroChart = new Chart(ctx, {
406
+ type: "line",
407
+ data: { datasets: [ heroDataset(key, byKey, ctx) ] },
408
+ options: heroOptions(animate !== false, byKey[key].length),
409
+ });
410
+ applyAnomalies();
411
+ }
412
+
413
+ function renderHero(byKey) {
414
+ const key = state.selectedKey;
415
+ if (!key || !byKey[key]) return;
416
+ $("#hero-label").innerHTML =
417
+ `${esc(key)} <span style="color:var(--muted)">(latest ${esc(fmtVal(byKey[key][byKey[key].length-1].value))})</span><span class="hero-cursor">▌</span>`;
418
+
419
+ if (!state.heroChart) { createHeroChart(true); return; }
420
+ // live append — no replay of the materialize animation
421
+ state.heroChart.data.datasets[0].data = byKey[key].map(d => ({ x: d.step, y: d.value }));
422
+ state.heroChart.update("none");
423
+ applyAnomalies();
424
+ }
425
+
426
+ // ── Anomaly detection (rolling z-score) ──────────────────
427
+ function computeAnomalies(values, steps) {
428
+ const W = 20, THRESH = 6;
429
+ const markers = []; let latest = null;
430
+ for (let i = W; i < values.length; i++) {
431
+ const win = values.slice(i - W, i);
432
+ const mean = win.reduce((a,b)=>a+b,0) / win.length;
433
+ const variance = win.reduce((a,b)=>a+(b-mean)**2,0) / win.length;
434
+ const sd = Math.sqrt(variance);
435
+ if (sd < 1e-9) continue;
436
+ const z = Math.abs((values[i] - mean) / sd);
437
+ if (z > THRESH) { markers.push(steps[i]); latest = { step: steps[i], z }; }
438
+ }
439
+ return { markers, latest };
440
+ }
441
+
442
+ function applyAnomalies() {
443
+ const key = state.selectedKey, byKey = state.metricsByKey;
444
+ if (!state.heroChart || !key || !byKey[key]) return;
445
+ const series = byKey[key];
446
+ const { markers, latest } = computeAnomalies(series.map(d=>d.value), series.map(d=>d.step));
447
+ state.heroChart.$anomalies = markers;
448
+ state.heroChart.update("none");
449
+
450
+ statusLine.hidden = false;
451
+ if (latest) {
452
+ statusLine.className = "statusline alert";
453
+ statusLine.textContent = `⚠ anomaly at step ${latest.step} (z-score ${latest.z.toFixed(1)}) — this may be a transient blip`;
454
+ } else {
455
+ statusLine.className = "statusline calm";
456
+ statusLine.textContent = "● all calm — no anomalies detected";
457
+ }
458
+ }
459
+
460
+ // Chart.js plugin: vertical lines at anomaly steps
461
+ const anomalyPlugin = {
462
+ id: "anomalyLines",
463
+ afterDatasetsDraw(chart) {
464
+ const lines = chart.$anomalies || [];
465
+ if (!lines.length || !chart.scales.x) return;
466
+ const { ctx, chartArea: { top, bottom }, scales: { x } } = chart;
467
+ ctx.save();
468
+ ctx.strokeStyle = css("--alert");
469
+ ctx.globalAlpha = 0.5;
470
+ ctx.lineWidth = 1;
471
+ ctx.setLineDash([3, 3]);
472
+ lines.forEach(step => {
473
+ const px = x.getPixelForValue(step);
474
+ if (px == null || isNaN(px)) return;
475
+ ctx.beginPath(); ctx.moveTo(px, top); ctx.lineTo(px, bottom); ctx.stroke();
476
+ });
477
+ ctx.restore();
478
+ },
479
+ };
480
+ Chart.register(anomalyPlugin);
481
+
482
+ // ── Gamification badges ──────────────────────────────────
483
+ function updateGamification(byKey) {
484
+ const key = state.selectedKey;
485
+ if (!key || !byKey[key]) return;
486
+ const vals = byKey[key].map(d => d.value);
487
+ if (vals.length < 2) { gamoBadges.innerHTML = ""; return; }
488
+
489
+ const higherBetter = /acc|reward|score|f1|auc|precision|recall|map|bleu|iou|r2/i.test(key);
490
+ const improved = (a, b) => higherBetter ? a > b : a < b;
491
+
492
+ let streak = 0;
493
+ for (let i = vals.length - 1; i > 0; i--) {
494
+ if (improved(vals[i], vals[i-1])) streak++; else break;
495
+ }
496
+ const bestVal = higherBetter ? Math.max(...vals) : Math.min(...vals);
497
+ const isNewBest = state.bestValue === null || improved(bestVal, state.bestValue);
498
+ state.bestValue = bestVal; state.bestKey = key;
499
+
500
+ let badges = "";
501
+ if (streak >= 3) badges += `<span class="badge badge-fire" title="${streak}-step improving streak">🔥${streak}</span>`;
502
+ badges += `<span class="badge badge-best" title="best ${esc(key)}">🏆 ${esc(fmtVal(bestVal))}</span>`;
503
+ gamoBadges.innerHTML = badges;
504
+ }
505
+
506
+ // ── Compare mode ─────────────────────────────────────────
507
+ async function pollCompare() {
508
+ const key = state.selectedKey;
509
+ if (!state.compareId || !key || !state.heroChart) return;
510
+ const cmp = await API.get(`/api/runs/${state.runId}/compare?other=${state.compareId}&key=${encodeURIComponent(key)}`);
511
+ if (!cmp.run_b) return;
512
+ const dataB = cmp.run_b.metrics.map(d => ({ x: d.step, y: d.value }));
513
+ const baseName = runName(state.compareId);
514
+ const labelB = dataB.length ? baseName : `${baseName} — no "${key}" logged`;
515
+ const chart = state.heroChart;
516
+ const colorB = css("--accent-2");
517
+ const legend = { display: true, labels: { color: css("--muted"), boxWidth: 12, font: { family:"JetBrains Mono", size: 10 } } };
518
+ if (chart.data.datasets.length === 1) {
519
+ chart.data.datasets[0].label = runName(state.runId);
520
+ chart.data.datasets.push({
521
+ label: labelB, data: dataB,
522
+ borderColor: colorB, backgroundColor: "transparent",
523
+ borderWidth: 1.6, borderDash: [5,3], pointRadius: 0, tension: 0.25, fill: false,
524
+ });
525
+ chart.options.plugins.legend = legend;
526
+ } else {
527
+ chart.data.datasets[1].data = dataB;
528
+ chart.data.datasets[1].label = labelB;
529
+ chart.data.datasets[1].borderColor = colorB;
530
+ }
531
+ chart.update("none");
532
+ }
533
+
534
+ // ── System charts ────────────────────────────────────────
535
+ async function loadSystemMetrics() {
536
+ const sys = await API.get(`/api/runs/${state.runId}/system`);
537
+ if (!sys.length) { $("#sys-panel").hidden = true; state.sysData = null; return; }
538
+ state.sysData = sys;
539
+ $("#sys-panel").hidden = false;
540
+ renderSystemCharts(sys);
541
+ }
542
+
543
+ function sysConfig(steps, values, color) {
544
+ return {
545
+ type: "line",
546
+ data: { labels: steps, datasets: [{
547
+ data: values, borderColor: color, backgroundColor: color + "1A",
548
+ borderWidth: 1.4, pointRadius: 0, tension: 0.25, fill: true,
549
+ }]},
550
+ options: {
551
+ responsive: true, maintainAspectRatio: false, animation: false,
552
+ plugins: { legend: { display: false } },
553
+ scales: {
554
+ x: { grid: { color: css("--rule-2") }, ticks: { color: css("--muted"), font: { family:"JetBrains Mono", size: 10 }, maxRotation: 0 } },
555
+ y: { min: 0, max: 100, grid: { color: css("--rule-2") }, ticks: { color: css("--muted"), font: { family:"JetBrains Mono", size: 10 } } },
556
+ },
557
+ },
558
+ };
559
+ }
560
+
561
+ function renderSystemCharts(sys) {
562
+ destroySysCharts();
563
+ const steps = sys.map(d => d.step);
564
+ state.sysCharts.cpu = new Chart($("#chart-sys-cpu").getContext("2d"),
565
+ sysConfig(steps, sys.map(d => d.cpu_percent), css("--magenta")));
566
+ state.sysCharts.mem = new Chart($("#chart-sys-mem").getContext("2d"),
567
+ sysConfig(steps, sys.map(d => d.mem_percent), css("--accent-2")));
568
+ }
569
+ function destroySysCharts() {
570
+ Object.values(state.sysCharts).forEach(c => c.destroy());
571
+ state.sysCharts = {};
572
+ }
573
+
574
+ // ── Events ───────────────────────────────────────────────
575
+ selProject.onchange = async () => {
576
+ state.project = selProject.value;
577
+ state.runId = null; state.run = null;
578
+ if (state.heroChart) { state.heroChart.destroy(); state.heroChart = null; }
579
+ destroySysCharts();
580
+ ["#runhead","#metrics-panel","#hero-panel","#sys-panel","#config-panel","#artifacts-panel","#statusline"]
581
+ .forEach(s => $(s).hidden = true);
582
+ compareField.hidden = true;
583
+ emptyState.hidden = false;
584
+ if (state.project) await loadRuns(state.project);
585
+ };
586
+
587
+ selRun.onchange = async () => { if (selRun.value) await loadRun(selRun.value); };
588
+
589
+ selCompare.onchange = async () => {
590
+ state.compareId = selCompare.value || null;
591
+ if (!state.compareId && state.heroChart && state.heroChart.data.datasets.length > 1) {
592
+ state.heroChart.data.datasets.pop();
593
+ state.heroChart.options.plugins.legend = { display: false };
594
+ state.heroChart.update("none");
595
+ }
596
+ if (state.compareId) await pollCompare();
597
+ };
598
+
599
+ $("#btn-refresh").onclick = () => {
600
+ if (state.runId) { pollMetrics(); loadArtifacts(); loadSystemMetrics(); }
601
+ if (state.project) loadRuns(state.project);
602
+ loadProjects();
603
+ };
604
+
605
+ $("#btn-export").onclick = () => {
606
+ if (!state.runId) return;
607
+ const fmt = confirm("OK = JSON, Cancel = CSV") ? "json" : "csv";
608
+ window.open(`/api/runs/${state.runId}/export?format=${fmt}`, "_blank");
609
+ };
610
+
611
+ // ── Boot ─────────────────────────────────────────────────
612
+ loadProjects();
613
+ </script>
614
+ </body>
615
+ </html>