expops 0.1.3__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.
Files changed (86) hide show
  1. expops-0.1.3.dist-info/METADATA +826 -0
  2. expops-0.1.3.dist-info/RECORD +86 -0
  3. expops-0.1.3.dist-info/WHEEL +5 -0
  4. expops-0.1.3.dist-info/entry_points.txt +3 -0
  5. expops-0.1.3.dist-info/licenses/LICENSE +674 -0
  6. expops-0.1.3.dist-info/top_level.txt +1 -0
  7. mlops/__init__.py +0 -0
  8. mlops/__main__.py +11 -0
  9. mlops/_version.py +34 -0
  10. mlops/adapters/__init__.py +12 -0
  11. mlops/adapters/base.py +86 -0
  12. mlops/adapters/config_schema.py +89 -0
  13. mlops/adapters/custom/__init__.py +3 -0
  14. mlops/adapters/custom/custom_adapter.py +447 -0
  15. mlops/adapters/plugin_manager.py +113 -0
  16. mlops/adapters/sklearn/__init__.py +3 -0
  17. mlops/adapters/sklearn/adapter.py +94 -0
  18. mlops/cluster/__init__.py +3 -0
  19. mlops/cluster/controller.py +496 -0
  20. mlops/cluster/process_runner.py +91 -0
  21. mlops/cluster/providers.py +258 -0
  22. mlops/core/__init__.py +95 -0
  23. mlops/core/custom_model_base.py +38 -0
  24. mlops/core/dask_networkx_executor.py +1265 -0
  25. mlops/core/executor_worker.py +1239 -0
  26. mlops/core/experiment_tracker.py +81 -0
  27. mlops/core/graph_types.py +64 -0
  28. mlops/core/networkx_parser.py +135 -0
  29. mlops/core/payload_spill.py +278 -0
  30. mlops/core/pipeline_utils.py +162 -0
  31. mlops/core/process_hashing.py +216 -0
  32. mlops/core/step_state_manager.py +1298 -0
  33. mlops/core/step_system.py +956 -0
  34. mlops/core/workspace.py +99 -0
  35. mlops/environment/__init__.py +10 -0
  36. mlops/environment/base.py +43 -0
  37. mlops/environment/conda_manager.py +307 -0
  38. mlops/environment/factory.py +70 -0
  39. mlops/environment/pyenv_manager.py +146 -0
  40. mlops/environment/setup_env.py +31 -0
  41. mlops/environment/system_manager.py +66 -0
  42. mlops/environment/utils.py +105 -0
  43. mlops/environment/venv_manager.py +134 -0
  44. mlops/main.py +527 -0
  45. mlops/managers/project_manager.py +400 -0
  46. mlops/managers/reproducibility_manager.py +575 -0
  47. mlops/platform.py +996 -0
  48. mlops/reporting/__init__.py +16 -0
  49. mlops/reporting/context.py +187 -0
  50. mlops/reporting/entrypoint.py +292 -0
  51. mlops/reporting/kv_utils.py +77 -0
  52. mlops/reporting/registry.py +50 -0
  53. mlops/runtime/__init__.py +9 -0
  54. mlops/runtime/context.py +34 -0
  55. mlops/runtime/env_export.py +113 -0
  56. mlops/storage/__init__.py +12 -0
  57. mlops/storage/adapters/__init__.py +9 -0
  58. mlops/storage/adapters/gcp_kv_store.py +778 -0
  59. mlops/storage/adapters/gcs_object_store.py +96 -0
  60. mlops/storage/adapters/memory_store.py +240 -0
  61. mlops/storage/adapters/redis_store.py +438 -0
  62. mlops/storage/factory.py +199 -0
  63. mlops/storage/interfaces/__init__.py +6 -0
  64. mlops/storage/interfaces/kv_store.py +118 -0
  65. mlops/storage/path_utils.py +38 -0
  66. mlops/templates/premier-league/charts/plot_metrics.js +70 -0
  67. mlops/templates/premier-league/charts/plot_metrics.py +145 -0
  68. mlops/templates/premier-league/charts/requirements.txt +6 -0
  69. mlops/templates/premier-league/configs/cluster_config.yaml +13 -0
  70. mlops/templates/premier-league/configs/project_config.yaml +207 -0
  71. mlops/templates/premier-league/data/England CSV.csv +12154 -0
  72. mlops/templates/premier-league/models/premier_league_model.py +638 -0
  73. mlops/templates/premier-league/requirements.txt +8 -0
  74. mlops/templates/sklearn-basic/README.md +22 -0
  75. mlops/templates/sklearn-basic/charts/plot_metrics.py +85 -0
  76. mlops/templates/sklearn-basic/charts/requirements.txt +3 -0
  77. mlops/templates/sklearn-basic/configs/project_config.yaml +64 -0
  78. mlops/templates/sklearn-basic/data/train.csv +14 -0
  79. mlops/templates/sklearn-basic/models/model.py +62 -0
  80. mlops/templates/sklearn-basic/requirements.txt +10 -0
  81. mlops/web/__init__.py +3 -0
  82. mlops/web/server.py +585 -0
  83. mlops/web/ui/index.html +52 -0
  84. mlops/web/ui/mlops-charts.js +357 -0
  85. mlops/web/ui/script.js +1244 -0
  86. mlops/web/ui/styles.css +248 -0
mlops/web/ui/script.js ADDED
@@ -0,0 +1,1244 @@
1
+ import { renderDynamicChart, renderStaticChart } from './mlops-charts.js';
2
+
3
+ const API_BASE = location.origin + "/api";
4
+
5
+ const elProject = document.getElementById("projectSelect");
6
+ const elRunList = document.getElementById("runList");
7
+ const elStatus = document.getElementById("statusText");
8
+ const svg = document.getElementById("graph");
9
+ const elThemeToggle = document.getElementById("themeToggle");
10
+ // Dynamic charts elements
11
+ const elDynamicChartsContainer = document.getElementById("dynamicChartsContainer");
12
+ const elDynamicChartsGrid = document.getElementById("dynamicChartsGrid");
13
+ // Modal elements for chart preview
14
+ const elChartModal = document.getElementById("chartModal");
15
+ const elChartModalClose = document.getElementById("chartModalClose");
16
+ const elChartModalImage = document.getElementById("chartModalImage");
17
+ const elChartModalMessage = document.getElementById("chartModalMessage");
18
+
19
+ let graphData = { nodes: [], adj: {}, indeg: {} };
20
+ let positions = {}; // node -> {x,y}
21
+ let current = { project: null, runId: null };
22
+ let pollTimer = null;
23
+ let runPollTimer = null;
24
+ let tooltipEl = null;
25
+ let dynamicChartCleanups = []; // Cleanup functions for dynamic charts
26
+ let requestGen = 0; // Bump to invalidate in-flight async updates on project/run change
27
+ let lastStatusMap = {}; // Stable process status across polls to avoid flicker
28
+ let emptyStatusStreak = 0; // Count consecutive polls with empty process_status
29
+ let chartNodes = new Set(); // Nodes that should be rendered as squares (type: chart)
30
+ let runsCache = []; // Cached runs list for sidebar rendering
31
+ let dynamicChartsInitTimer = null; // Periodic attempt to (re)initialize dynamic charts
32
+ let dynamicChartsInitialized = false; // Set true once dynamic charts are rendered
33
+ let chartModalPollTimer = null; // Timer for auto-refreshing chart availability
34
+ let isModalOpen = false; // Track if chart modal is currently open
35
+ let chartCache = new Map(); // nodeName -> { blob, objectUrl }
36
+
37
+ // ---- Theme handling (light/dark) ----
38
+ function updateThemeToggleUI(theme) {
39
+ if (!elThemeToggle) return;
40
+ elThemeToggle.textContent = (theme === 'light') ? '☀️' : '🌙';
41
+ elThemeToggle.setAttribute('aria-pressed', theme === 'dark' ? 'true' : 'false');
42
+ elThemeToggle.title = `Switch to ${theme === 'light' ? 'dark' : 'light'} mode`;
43
+ }
44
+
45
+ function applyChartTheme() {
46
+ try {
47
+ const cs = getComputedStyle(document.documentElement);
48
+ const textColor = cs.getPropertyValue('--color-text').trim();
49
+ const borderColor = cs.getPropertyValue('--color-border').trim();
50
+ const gridColor = (cs.getPropertyValue('--color-border-2').trim()) || borderColor;
51
+ if (window.Chart) {
52
+ window.Chart.defaults.color = textColor;
53
+ window.Chart.defaults.borderColor = borderColor;
54
+ try { window.Chart.defaults.scale.grid.color = gridColor; } catch {}
55
+ try { window.Chart.defaults.plugins.legend.labels.color = textColor; } catch {}
56
+ }
57
+ } catch {}
58
+ }
59
+
60
+ function applyTheme(theme) {
61
+ const t = (theme === 'light') ? 'light' : 'dark';
62
+ document.documentElement.setAttribute('data-theme', t);
63
+ try { localStorage.setItem('theme', t); } catch {}
64
+ updateThemeToggleUI(t);
65
+ applyChartTheme();
66
+ // Re-render dynamic charts to adopt new theme defaults
67
+ try { loadDynamicCharts(); } catch {}
68
+ }
69
+
70
+ // Initialize theme early
71
+ (() => {
72
+ let t = null;
73
+ try { t = (localStorage.getItem('theme') || '').toLowerCase(); } catch {}
74
+ if (t !== 'light' && t !== 'dark') {
75
+ t = 'dark'; // preserve current default look
76
+ }
77
+ applyTheme(t);
78
+ })();
79
+
80
+ if (elThemeToggle) {
81
+ elThemeToggle.addEventListener('click', () => {
82
+ const currentTheme = document.documentElement.getAttribute('data-theme') || 'dark';
83
+ applyTheme(currentTheme === 'dark' ? 'light' : 'dark');
84
+ });
85
+ }
86
+
87
+ async function fetchJSON(url) {
88
+ const res = await fetch(url);
89
+ if (!res.ok) throw new Error(`Request failed: ${res.status}`);
90
+ return await res.json();
91
+ }
92
+
93
+ // Chart pre-loading functions
94
+ async function preloadChartForNode(nodeName) {
95
+ // Skip if already cached
96
+ if (chartCache.has(nodeName)) {
97
+ return;
98
+ }
99
+
100
+ if (!current.project || !current.runId) {
101
+ return;
102
+ }
103
+
104
+ try {
105
+ // Check if this node is cached and has a cached_run_id
106
+ const nodeInfo = lastProcessInfo[nodeName] || {};
107
+ const nodeStatus = (lastStatusMap[nodeName] || "pending").toLowerCase();
108
+ const cachedRunId = (nodeStatus === "cached" && nodeInfo.cached_run_id) ? nodeInfo.cached_run_id : null;
109
+
110
+ // Use cached run ID if available, otherwise current run ID
111
+ const runIdToFetch = cachedRunId || current.runId;
112
+
113
+ // Fetch chart data for the target run
114
+ const url = `${API_BASE}/projects/${encodeURIComponent(current.project)}/runs/${encodeURIComponent(runIdToFetch)}/charts`;
115
+ const data = await fetchJSON(url);
116
+ const charts = data.charts || {};
117
+
118
+ // Look for chart items matching the node name
119
+ let chartItem = null;
120
+ for (const [chartName, chartInfo] of Object.entries(charts)) {
121
+ if (chartName === nodeName && chartInfo.items && chartInfo.items.length > 0) {
122
+ chartItem = chartInfo.items[0]; // Take the first item
123
+ break;
124
+ }
125
+ }
126
+
127
+ if (chartItem && chartItem.object_path) {
128
+ // Chart is available, fetch and cache it
129
+ console.log(`Pre-loading chart for ${nodeName} from run ${runIdToFetch}:`, chartItem);
130
+ await cacheChartImage(nodeName, chartItem, runIdToFetch);
131
+ }
132
+ } catch (error) {
133
+ console.error(`Error pre-loading chart for ${nodeName}:`, error);
134
+ }
135
+ }
136
+
137
+ async function cacheChartImage(nodeName, chartItem, runIdToFetch) {
138
+ try {
139
+ const params = new URLSearchParams();
140
+ if (chartItem.object_path) params.set('uri', chartItem.object_path);
141
+ if (chartItem.cache_path) params.set('cache_path', chartItem.cache_path);
142
+
143
+ // Use the provided runId or fallback to current.runId
144
+ const targetRunId = runIdToFetch || current.runId;
145
+
146
+ const fetchUrl = `${API_BASE}/projects/${encodeURIComponent(current.project)}/runs/${encodeURIComponent(targetRunId)}/charts/fetch?` + params.toString();
147
+ console.log(`Pre-fetching chart image for ${nodeName} from: ${fetchUrl}`);
148
+
149
+ const resp = await fetch(fetchUrl);
150
+ if (!resp.ok) {
151
+ throw new Error(`Chart fetch failed: ${resp.status}`);
152
+ }
153
+
154
+ const blob = await resp.blob();
155
+ const objectUrl = URL.createObjectURL(blob);
156
+
157
+ // Store in cache
158
+ chartCache.set(nodeName, { blob, objectUrl });
159
+ console.log(`Chart for ${nodeName} cached successfully`);
160
+ } catch (error) {
161
+ console.error(`Error caching chart image for ${nodeName}:`, error);
162
+ }
163
+ }
164
+
165
+ function clearChartCache() {
166
+ // Revoke all object URLs to prevent memory leaks
167
+ for (const [nodeName, { objectUrl }] of chartCache) {
168
+ URL.revokeObjectURL(objectUrl);
169
+ }
170
+ chartCache.clear();
171
+ console.log("Chart cache cleared");
172
+ }
173
+
174
+ // Helper functions for tooltips (defined early to avoid hoisting issues)
175
+ function fmt(ts) {
176
+ if (!ts && ts !== 0) return "-";
177
+ try {
178
+ const d = new Date(ts * 1000);
179
+ return d.toLocaleString();
180
+ } catch { return "-"; }
181
+ }
182
+
183
+ function buildTooltipText(nodeName, info) {
184
+ const status = (info && info.status) ? String(info.status).toLowerCase() : 'pending';
185
+
186
+ // For cached steps, use cached timing if available
187
+ let started, ended, dur;
188
+ if (status === 'cached' && info && info.cached_started_at !== undefined) {
189
+ started = fmt(info.cached_started_at);
190
+ ended = fmt(info.cached_ended_at);
191
+ dur = (typeof info.cached_execution_time === 'number') ? `${info.cached_execution_time.toFixed(2)}s` : '-';
192
+ } else {
193
+ started = fmt(info && info.started_at);
194
+ ended = fmt(info && info.ended_at);
195
+ dur = (info && typeof info.duration_sec === 'number') ? `${info.duration_sec.toFixed(2)}s` : '-';
196
+ }
197
+
198
+ if (status === 'cached' && info && info.cached_run_id) {
199
+ return `${nodeName}\nStatus: ${status}\nCached from run: ${info.cached_run_id}\nStarted: ${started}\nEnded: ${ended}\nTime taken: ${dur}`;
200
+ } else {
201
+ return `${nodeName}\nStatus: ${status}\nStarted: ${started}\nEnded: ${ended}\nTime taken: ${dur}`;
202
+ }
203
+ }
204
+
205
+ // ---- Run ID ordering helpers (ensure oldest is #1) ----
206
+ function extractRunTimestamp(runId) {
207
+ try {
208
+ const s = String(runId || "");
209
+ const m = s.match(/-(\d{14})-/);
210
+ if (m && m[1]) return Number(m[1]);
211
+ } catch {}
212
+ return null;
213
+ }
214
+
215
+ function sortRunsOldestFirst(list) {
216
+ try {
217
+ const arr = Array.from(list || []);
218
+ const decorated = arr.map(r => ({ id: r, ts: extractRunTimestamp(r) }));
219
+ decorated.sort((a, b) => {
220
+ const at = a.ts, bt = b.ts;
221
+ if (typeof at === 'number' && typeof bt === 'number') return at - bt;
222
+ if (typeof at === 'number') return -1;
223
+ if (typeof bt === 'number') return 1;
224
+ return String(a.id).localeCompare(String(b.id));
225
+ });
226
+ return decorated.map(d => d.id);
227
+ } catch {
228
+ return Array.from(list || []);
229
+ }
230
+ }
231
+
232
+ async function loadProjects() {
233
+ const data = await fetchJSON(`${API_BASE}/projects`);
234
+ const projects = data.projects || [];
235
+ elProject.innerHTML = "";
236
+ for (const p of projects) {
237
+ const opt = document.createElement("option");
238
+ opt.value = p; opt.textContent = p; elProject.appendChild(opt);
239
+ }
240
+ if (projects.length) {
241
+ elProject.value = projects[0];
242
+ }
243
+ }
244
+
245
+ async function loadRuns(projectId) {
246
+ console.log("loadRuns: projectId=", projectId);
247
+ const data = await fetchJSON(`${API_BASE}/projects/${encodeURIComponent(projectId)}/runs`);
248
+ console.log("loadRuns: data=", data);
249
+ const runs = data.runs || [];
250
+ console.log("loadRuns: runs count=", runs.length, runs);
251
+ runsCache = sortRunsOldestFirst(runs);
252
+ renderRunList(runsCache);
253
+ if (runsCache.length) {
254
+ current.runId = runsCache[runsCache.length - 1];
255
+ selectRunInList(current.runId);
256
+ console.log("loadRuns: selected run=", current.runId);
257
+ } else {
258
+ console.log("loadRuns: no runs found");
259
+ current.runId = null;
260
+ }
261
+ }
262
+
263
+ function renderRunList(runs) {
264
+ if (!elRunList) return;
265
+ elRunList.innerHTML = "";
266
+ runs.forEach((runId, idx) => {
267
+ const li = document.createElement('li');
268
+ li.className = 'run-list-item';
269
+ li.dataset.runId = String(runId);
270
+ li.textContent = `${idx + 1}. ${runId}`;
271
+ if (current.runId === runId) li.classList.add('selected');
272
+ li.addEventListener('click', async () => {
273
+ if (current.runId === runId) return;
274
+ current.runId = runId;
275
+ requestGen++;
276
+ lastStatusMap = {};
277
+ emptyStatusStreak = 0;
278
+
279
+ // Clear chart cache for new run
280
+ clearChartCache();
281
+
282
+ selectRunInList(runId);
283
+ startPolling();
284
+ await loadDynamicCharts();
285
+ });
286
+ elRunList.appendChild(li);
287
+ });
288
+ }
289
+
290
+ function selectRunInList(runId) {
291
+ if (!elRunList) return;
292
+ Array.from(elRunList.children).forEach((li) => {
293
+ if (li.dataset && li.dataset.runId === String(runId)) li.classList.add('selected');
294
+ else li.classList.remove('selected');
295
+ });
296
+ }
297
+
298
+ async function loadGraph(projectId, initialStatusMap = {}, initialInfoMap = {}) {
299
+ const localGen = requestGen;
300
+ const g = await fetchJSON(`${API_BASE}/projects/${encodeURIComponent(projectId)}/graph`);
301
+ if (localGen !== requestGen) return; // Stale response, ignore
302
+ graphData = g;
303
+ layoutGraph();
304
+ if (localGen !== requestGen) return; // Stale after layout, ignore
305
+ renderGraph(initialStatusMap, initialInfoMap);
306
+ }
307
+
308
+ function layoutGraph() {
309
+ const nodes = graphData.nodes || [];
310
+ const indeg = graphData.indeg || {};
311
+ const adj = graphData.adj || {};
312
+ // Layering based on longest-path topological ranks: child layer >= max(parent layer + 1)
313
+ const indegWork = {};
314
+ for (const n of nodes) indegWork[n] = Math.max(0, indeg[n] || 0);
315
+ const queue = [];
316
+ const layerOf = {};
317
+ for (const n of nodes) {
318
+ if ((indegWork[n] || 0) === 0) { queue.push(n); layerOf[n] = 0; }
319
+ }
320
+ const topoOrder = [];
321
+ while (queue.length) {
322
+ const u = queue.shift();
323
+ topoOrder.push(u);
324
+ const base = (layerOf[u] || 0) + 1;
325
+ for (const v of (adj[u] || [])) {
326
+ if (layerOf[v] === undefined || base > layerOf[v]) layerOf[v] = base;
327
+ indegWork[v] = (indegWork[v] || 0) - 1;
328
+ if (indegWork[v] <= 0) queue.push(v);
329
+ }
330
+ }
331
+ // Any remaining nodes (in case of isolated/cyclic) fallback to 0
332
+ for (const n of nodes) if (layerOf[n] === undefined) layerOf[n] = 0;
333
+ // Group by layer, keeping topo order inside each layer
334
+ const orderIndex = {};
335
+ topoOrder.forEach((n, i) => { orderIndex[n] = i; });
336
+ const layers = new Map();
337
+ for (const n of nodes) {
338
+ const d = Math.max(0, layerOf[n] || 0);
339
+ if (!layers.has(d)) layers.set(d, []);
340
+ layers.get(d).push(n);
341
+ }
342
+ for (const [d, arr] of layers) {
343
+ arr.sort((a, b) => (orderIndex[a] || 0) - (orderIndex[b] || 0));
344
+ }
345
+ // Assign positions with small outer margins and full stretch
346
+ positions = {};
347
+ const width = svg.clientWidth || 1000;
348
+ const height = svg.clientHeight || 600;
349
+ const layerCount = layers.size || 1;
350
+ const marginX = 12;
351
+ const marginY = 12;
352
+ const innerW = Math.max(100, width - 2 * marginX);
353
+ const innerH = Math.max(100, height - 2 * marginY);
354
+ // Use more of the canvas while preserving small outer margins
355
+ const scaleH = 0.95; // horizontal scale (0-1]
356
+ const scaleV = 0.90; // vertical scale (0-1]
357
+ const usableW = innerW * scaleH;
358
+ const usableH = innerH * scaleV;
359
+ const offsetX = marginX + (innerW - usableW) / 2;
360
+ const offsetY = marginY + (innerH - usableH) / 2;
361
+ const vStep = (layerCount > 1) ? usableH / (layerCount - 1) : usableH / 2;
362
+ for (const [d, arr] of layers.entries()) {
363
+ const count = arr.length || 1;
364
+ const hStep = (count > 1) ? usableW / count : usableW;
365
+ arr.forEach((n, i) => {
366
+ const x = (count > 1)
367
+ ? (offsetX + (i + 0.5) * hStep)
368
+ : (offsetX + usableW / 2);
369
+ const y = offsetY + d * vStep;
370
+ positions[n] = { x, y };
371
+ });
372
+ }
373
+ // Fallback for nodes not reached
374
+ const remaining = nodes.filter(n => !(n in positions));
375
+ remaining.forEach((n, i) => {
376
+ positions[n] = { x: offsetX + 40 + i * 90, y: offsetY + usableH + 40 };
377
+ });
378
+ }
379
+
380
+ function clearSVG() {
381
+ while (svg.firstChild) svg.removeChild(svg.firstChild);
382
+ }
383
+
384
+ function renderGraph(statusMap = {}, infoMap = {}) {
385
+ clearSVG();
386
+ const adj = graphData.adj || {};
387
+ // Draw links
388
+ for (const [u, vs] of Object.entries(adj)) {
389
+ for (const v of vs) {
390
+ const a = positions[u], b = positions[v];
391
+ if (!a || !b) continue;
392
+ const line = document.createElementNS("http://www.w3.org/2000/svg", "line");
393
+ line.setAttribute("x1", a.x);
394
+ line.setAttribute("y1", a.y);
395
+ line.setAttribute("x2", b.x);
396
+ line.setAttribute("y2", b.y);
397
+ line.setAttribute("class", "link");
398
+ svg.appendChild(line);
399
+ }
400
+ }
401
+ // Draw nodes
402
+ for (const n of graphData.nodes || []) {
403
+ const g = document.createElementNS("http://www.w3.org/2000/svg", "g");
404
+ const p = positions[n];
405
+ const st = (statusMap[n] || "pending").toLowerCase();
406
+ const isChart = chartNodes.has(n);
407
+ g.setAttribute("class", `node status-${st}${isChart ? ' clickable' : ''}`);
408
+ if (isChart) {
409
+ const size = 26;
410
+ const rect = document.createElementNS("http://www.w3.org/2000/svg", "rect");
411
+ rect.setAttribute("x", String(p.x - size / 2));
412
+ rect.setAttribute("y", String(p.y - size / 2));
413
+ rect.setAttribute("width", String(size));
414
+ rect.setAttribute("height", String(size));
415
+ rect.setAttribute("rx", "6");
416
+ rect.setAttribute("ry", "6");
417
+ g.appendChild(rect);
418
+ } else {
419
+ const c = document.createElementNS("http://www.w3.org/2000/svg", "circle");
420
+ c.setAttribute("cx", p.x);
421
+ c.setAttribute("cy", p.y);
422
+ c.setAttribute("r", "12");
423
+ g.appendChild(c);
424
+ }
425
+ // Spinner arc for running
426
+ if (st === "running") {
427
+ const arc = document.createElementNS("http://www.w3.org/2000/svg", "circle");
428
+ arc.setAttribute("cx", p.x);
429
+ arc.setAttribute("cy", p.y);
430
+ arc.setAttribute("r", "10");
431
+ arc.setAttribute("fill", "none");
432
+ arc.setAttribute("class", "spinner");
433
+ g.appendChild(arc);
434
+ }
435
+ const t = document.createElementNS("http://www.w3.org/2000/svg", "text");
436
+ t.setAttribute("x", p.x);
437
+ t.setAttribute("y", p.y - 18);
438
+ t.setAttribute("text-anchor", "middle");
439
+ t.textContent = n;
440
+ g.appendChild(t);
441
+
442
+ // For cached nodes, draw a small numeric badge indicating the source run index
443
+ try {
444
+ const info = infoMap[n] || {};
445
+ if (st === "cached" && info && info.cached_run_id) {
446
+ const srcId = String(info.cached_run_id);
447
+ const idx = runsCache.indexOf(srcId);
448
+ if (idx !== -1) {
449
+ const badge = document.createElementNS("http://www.w3.org/2000/svg", "text");
450
+ badge.setAttribute("x", String(p.x - 16));
451
+ badge.setAttribute("y", String(p.y + 16));
452
+ badge.setAttribute("text-anchor", "start");
453
+ badge.setAttribute("class", "node-cached-index");
454
+ badge.textContent = String(idx + 1);
455
+ g.appendChild(badge);
456
+ }
457
+ }
458
+ } catch {}
459
+
460
+ // Custom tooltip hover
461
+ g.addEventListener("mousemove", (ev) => showTooltip(n, ev.clientX, ev.clientY));
462
+ g.addEventListener("mouseleave", hideTooltip);
463
+ // Open modal on click for chart nodes
464
+ if (isChart) {
465
+ g.addEventListener("click", () => { openChartModal(n); });
466
+ }
467
+ svg.appendChild(g);
468
+ }
469
+ }
470
+
471
+ // ---- Status Smoothing (no-downgrade merge) ----
472
+ function normalizeStatus(s) {
473
+ const v = String(s || 'pending').toLowerCase();
474
+ if (v === 'completed' || v === 'cached' || v === 'failed' || v === 'running') return v;
475
+ return 'pending';
476
+ }
477
+
478
+ function mergeStatusMaps(prev = {}, next = {}) {
479
+ // Status precedence with a guard: do not promote running -> cached mid-run.
480
+ // This avoids the brief blue flash when some steps report cached while the
481
+ // overall process is still executing. We'll keep nodes orange (running)
482
+ // until a terminal state (completed/failed) arrives.
483
+ const rank = { completed: 4, failed: 4, running: 3, cached: 3, pending: 0 };
484
+ const out = {};
485
+ const nodes = (graphData && Array.isArray(graphData.nodes)) ? graphData.nodes : [];
486
+ for (const n of nodes) {
487
+ const p = normalizeStatus(prev[n]);
488
+ let q = normalizeStatus(next[n]);
489
+ // Special-case: prevent upgrading from running to cached during execution
490
+ if (p === 'running' && q === 'cached') {
491
+ out[n] = 'running';
492
+ continue;
493
+ }
494
+ // Only upgrade on strictly higher precedence to avoid oscillation
495
+ out[n] = (rank[q] > rank[p]) ? q : p;
496
+ }
497
+ return out;
498
+ }
499
+
500
+ async function pollStatus() {
501
+ if (!current.project || !current.runId) return;
502
+ try {
503
+ const localGen = requestGen;
504
+ const data = await fetchJSON(`${API_BASE}/projects/${encodeURIComponent(current.project)}/runs/${encodeURIComponent(current.runId)}/status`);
505
+ if (localGen !== requestGen) return; // Stale response, ignore
506
+ elStatus.textContent = data.status || "unknown";
507
+ const proc = data.process_status || {};
508
+ const info = data.process_info || {};
509
+ lastProcessInfo = info;
510
+ // Detect if backend returned no process statuses repeatedly (e.g., project/run missing)
511
+ if (proc && Object.keys(proc).length > 0) {
512
+ emptyStatusStreak = 0;
513
+ // Merge with last known to prevent transient downgrades (e.g., missing -> pending)
514
+ const merged = mergeStatusMaps(lastStatusMap, proc);
515
+ lastStatusMap = merged;
516
+ renderGraph(merged, info);
517
+ } else {
518
+ emptyStatusStreak += 1;
519
+ // If we observe empty process map for 2+ consecutive polls, clear cache to avoid stale green
520
+ if (emptyStatusStreak >= 2) {
521
+ lastStatusMap = {};
522
+ }
523
+ renderGraph(lastStatusMap, info);
524
+ }
525
+
526
+ // Update tooltip if currently visible for a node
527
+ if (tooltipEl && tooltipEl.style.display === 'block' && tooltipEl.dataset.nodeName) {
528
+ const nodeName = tooltipEl.dataset.nodeName;
529
+ const nodeInfo = info[nodeName] || {};
530
+ const tooltipText = buildTooltipText(nodeName, nodeInfo);
531
+ const lines = tooltipText.split('\n');
532
+ tooltipEl.innerHTML = lines.map(line => `<div>${line}</div>`).join('');
533
+ }
534
+
535
+ // Pre-load charts for completed chart nodes
536
+ for (const nodeName of graphData.nodes || []) {
537
+ const status = (merged[nodeName] || "pending").toLowerCase();
538
+ const isChart = chartNodes.has(nodeName);
539
+
540
+ // Pre-load if it's a chart node and is completed/cached
541
+ if (isChart && (status === "completed" || status === "cached")) {
542
+ preloadChartForNode(nodeName);
543
+ }
544
+ }
545
+ } catch (e) {
546
+ // ignore errors to keep polling
547
+ }
548
+ }
549
+
550
+ function startPolling() {
551
+ if (pollTimer) clearInterval(pollTimer);
552
+ pollTimer = setInterval(pollStatus, 2000);
553
+ pollStatus();
554
+ }
555
+
556
+ function startRunPolling() {
557
+ if (runPollTimer) clearInterval(runPollTimer);
558
+ runPollTimer = setInterval(async () => {
559
+ if (!current.project) return;
560
+ try {
561
+ const data = await fetchJSON(`${API_BASE}/projects/${encodeURIComponent(current.project)}/runs`);
562
+ const runs = data.runs || [];
563
+ const normalized = sortRunsOldestFirst(runs);
564
+ // Merge with existing list to preserve current order and append new items at bottom
565
+ const existing = runsCache;
566
+ const different = normalized.length !== existing.length || normalized.some((r, i) => existing[i] !== r);
567
+ if (different) {
568
+ const prev = current.runId;
569
+ // Preserve existing order for items that still exist; append any new ones
570
+ const incomingSet = new Set(normalized);
571
+ const kept = existing.filter(r => incomingSet.has(r));
572
+ const keptSet = new Set(kept);
573
+ const added = normalized.filter(r => !keptSet.has(r));
574
+ runsCache = kept.concat(added);
575
+ renderRunList(runsCache);
576
+ if (normalized.includes(prev)) {
577
+ current.runId = prev;
578
+ selectRunInList(prev);
579
+ } else if (normalized.length) {
580
+ current.runId = normalized[normalized.length - 1];
581
+ selectRunInList(current.runId);
582
+ // Reset chart and status state and reload dynamic charts for the new run
583
+ requestGen++;
584
+ lastStatusMap = {};
585
+ emptyStatusStreak = 0;
586
+ clearChartCache();
587
+ startPolling();
588
+ try {
589
+ // Close stale chart modal, if any
590
+ if (isModalOpen) closeChartModal();
591
+ await loadDynamicCharts();
592
+ } catch {}
593
+ }
594
+ else {
595
+ // No runs left – clear run and hide dynamic charts
596
+ current.runId = null;
597
+ selectRunInList(null);
598
+ requestGen++;
599
+ lastStatusMap = {};
600
+ emptyStatusStreak = 0;
601
+ clearChartCache();
602
+ if (pollTimer) clearInterval(pollTimer);
603
+ try {
604
+ if (isModalOpen) closeChartModal();
605
+ await loadDynamicCharts();
606
+ } catch {}
607
+ }
608
+ }
609
+ } catch (e) {
610
+ // ignore
611
+ }
612
+ }, 5000);
613
+ }
614
+
615
+ elProject.addEventListener("change", async () => {
616
+ console.log("Project changed to:", elProject.value);
617
+ current.project = elProject.value;
618
+ // Invalidate any in-flight async work from previous project
619
+ requestGen++;
620
+
621
+ // Clear previous project state to avoid flickering
622
+ clearSVG();
623
+ graphData = { nodes: [], adj: {}, indeg: {} };
624
+ positions = {};
625
+ lastProcessInfo = {};
626
+ lastStatusMap = {};
627
+ emptyStatusStreak = 0;
628
+ hideTooltip();
629
+ if (pollTimer) clearInterval(pollTimer);
630
+ if (runPollTimer) clearInterval(runPollTimer);
631
+
632
+ // Clear chart cache for new project
633
+ clearChartCache();
634
+
635
+ // Immediately clear current run and hide any dynamic charts to avoid stale display
636
+ current.runId = null;
637
+ try {
638
+ if (isModalOpen) closeChartModal();
639
+ await loadDynamicCharts();
640
+ } catch {}
641
+
642
+
643
+ await loadRuns(current.project);
644
+ // current.runId set by loadRuns
645
+ console.log("After loading runs, runId:", current.runId);
646
+
647
+ // Fetch initial status before rendering to avoid color flickering
648
+ let initialStatusMap = {};
649
+ let initialInfoMap = {};
650
+ if (current.runId) {
651
+ try {
652
+ const data = await fetchJSON(`${API_BASE}/projects/${encodeURIComponent(current.project)}/runs/${encodeURIComponent(current.runId)}/status`);
653
+ initialStatusMap = data.process_status || {};
654
+ initialInfoMap = data.process_info || {};
655
+ lastProcessInfo = initialInfoMap;
656
+ // Seed lastStatusMap with initial merged values
657
+ lastStatusMap = mergeStatusMaps({}, initialStatusMap);
658
+ emptyStatusStreak = 0;
659
+ } catch (e) {
660
+ console.log("Could not fetch initial status, will use defaults");
661
+ lastStatusMap = {};
662
+ emptyStatusStreak = 0;
663
+ }
664
+ }
665
+ // Determine chart nodes from chart-config
666
+ try {
667
+ const configUrl = `${API_BASE}/projects/${encodeURIComponent(current.project)}/chart-config`;
668
+ const configData = await fetchJSON(configUrl);
669
+ const charts = configData.charts || [];
670
+ chartNodes = new Set(charts.map(c => c.name));
671
+ } catch (e) {
672
+ chartNodes = new Set();
673
+ }
674
+
675
+ await loadGraph(current.project, lastStatusMap, initialInfoMap);
676
+ startPolling();
677
+ startRunPolling();
678
+ startDynamicChartsAutoload();
679
+ });
680
+
681
+ // Run selection handled by sidebar list clicks
682
+
683
+ // -------- Modal Event Listeners ---------
684
+ // Close modal when close button is clicked
685
+ if (elChartModalClose) {
686
+ elChartModalClose.addEventListener('click', closeChartModal);
687
+ }
688
+
689
+ // Close modal when clicking outside the modal content
690
+ if (elChartModal) {
691
+ elChartModal.addEventListener('click', (event) => {
692
+ if (event.target === elChartModal) {
693
+ closeChartModal();
694
+ }
695
+ });
696
+ }
697
+
698
+ // Close modal with Escape key
699
+ document.addEventListener('keydown', (event) => {
700
+ if (event.key === 'Escape' && isModalOpen) {
701
+ closeChartModal();
702
+ }
703
+ });
704
+
705
+ (async function init() {
706
+ console.log("=== INIT START ===");
707
+ await loadProjects();
708
+ current.project = elProject.value || null;
709
+ console.log("Current project:", current.project);
710
+ if (current.project) {
711
+ await loadRuns(current.project);
712
+ console.log("Current runId:", current.runId);
713
+
714
+ // Fetch initial status before rendering to avoid color flickering
715
+ let initialStatusMap = {};
716
+ let initialInfoMap = {};
717
+ if (current.runId) {
718
+ try {
719
+ const data = await fetchJSON(`${API_BASE}/projects/${encodeURIComponent(current.project)}/runs/${encodeURIComponent(current.runId)}/status`);
720
+ initialStatusMap = data.process_status || {};
721
+ initialInfoMap = data.process_info || {};
722
+ lastProcessInfo = initialInfoMap;
723
+ lastStatusMap = mergeStatusMaps({}, initialStatusMap);
724
+ } catch (e) {
725
+ console.log("Could not fetch initial status, will use defaults");
726
+ lastStatusMap = {};
727
+ }
728
+ }
729
+ // Determine chart nodes from chart-config
730
+ try {
731
+ const configUrl = `${API_BASE}/projects/${encodeURIComponent(current.project)}/chart-config`;
732
+ const configData = await fetchJSON(configUrl);
733
+ const charts = configData.charts || [];
734
+ chartNodes = new Set(charts.map(c => c.name));
735
+ } catch (e) {
736
+ chartNodes = new Set();
737
+ }
738
+
739
+ await loadGraph(current.project, lastStatusMap, initialInfoMap);
740
+ startPolling();
741
+ startRunPolling();
742
+ startDynamicChartsAutoload();
743
+ } else {
744
+ console.log("No project selected, skipping initialization");
745
+ }
746
+ console.log("=== INIT COMPLETE ===");
747
+ })();
748
+
749
+ // Tooltip rendering using process_info from last poll
750
+ let lastProcessInfo = {};
751
+
752
+ function showTooltip(nodeName, x, y) {
753
+ const info = lastProcessInfo[nodeName] || {};
754
+ if (!tooltipEl) {
755
+ tooltipEl = document.createElement('div');
756
+ tooltipEl.className = 'tooltip';
757
+ document.body.appendChild(tooltipEl);
758
+ }
759
+
760
+ const tooltipText = buildTooltipText(nodeName, info);
761
+ const lines = tooltipText.split('\n');
762
+
763
+ tooltipEl.innerHTML = lines.map(line => `<div>${line}</div>`).join('');
764
+ tooltipEl.style.left = (x + 12) + 'px';
765
+ tooltipEl.style.top = (y + 12) + 'px';
766
+ tooltipEl.style.display = 'block';
767
+ // Store current node so we can update it on poll
768
+ tooltipEl.dataset.nodeName = nodeName;
769
+ }
770
+
771
+ function hideTooltip() {
772
+ if (tooltipEl) tooltipEl.style.display = 'none';
773
+ }
774
+
775
+ // -------- Chart Modal Functions ---------
776
+ async function openChartModal(nodeName) {
777
+ if (!current.project || !current.runId) {
778
+ console.log("openChartModal: No project or runId");
779
+ return;
780
+ }
781
+
782
+ console.log(`openChartModal: Opening modal for node: ${nodeName}`);
783
+
784
+ // Clear any existing polling
785
+ if (chartModalPollTimer) {
786
+ clearInterval(chartModalPollTimer);
787
+ chartModalPollTimer = null;
788
+ }
789
+
790
+ // Show modal
791
+ if (elChartModal) {
792
+ elChartModal.classList.remove('hidden');
793
+ isModalOpen = true;
794
+ }
795
+
796
+ // Clear image src to prevent flash of previous chart
797
+ if (elChartModalImage) {
798
+ elChartModalImage.src = '';
799
+ elChartModalImage.style.display = 'none';
800
+ }
801
+
802
+ // Check cache first
803
+ if (chartCache.has(nodeName)) {
804
+ console.log(`Chart for ${nodeName} found in cache, displaying immediately`);
805
+ const { objectUrl } = chartCache.get(nodeName);
806
+ if (elChartModalImage) {
807
+ elChartModalImage.src = objectUrl;
808
+ elChartModalImage.style.display = 'block';
809
+ }
810
+ if (elChartModalMessage) {
811
+ elChartModalMessage.style.display = 'none';
812
+ }
813
+ return;
814
+ }
815
+
816
+ // Show loading message if not cached
817
+ if (elChartModalMessage) {
818
+ elChartModalMessage.textContent = 'Loading chart...';
819
+ elChartModalMessage.style.display = 'block';
820
+ }
821
+
822
+ // Try to fetch and display the chart
823
+ await tryLoadChartForNode(nodeName);
824
+ }
825
+
826
+ async function tryLoadChartForNode(nodeName) {
827
+ try {
828
+ // Check if this node is cached and has a cached_run_id
829
+ const nodeInfo = lastProcessInfo[nodeName] || {};
830
+ const nodeStatus = (lastStatusMap[nodeName] || "pending").toLowerCase();
831
+ const cachedRunId = (nodeStatus === "cached" && nodeInfo.cached_run_id) ? nodeInfo.cached_run_id : null;
832
+
833
+ // Use cached run ID if available, otherwise current run ID
834
+ const runIdToFetch = cachedRunId || current.runId;
835
+
836
+ // Fetch chart data for the target run
837
+ const url = `${API_BASE}/projects/${encodeURIComponent(current.project)}/runs/${encodeURIComponent(runIdToFetch)}/charts`;
838
+ console.log(`Fetching charts from: ${url} (${cachedRunId ? 'cached run' : 'current run'})`);
839
+ const data = await fetchJSON(url);
840
+ const charts = data.charts || {};
841
+
842
+ // Look for chart items matching the node name
843
+ let chartItem = null;
844
+ for (const [chartName, chartInfo] of Object.entries(charts)) {
845
+ if (chartName === nodeName && chartInfo.items && chartInfo.items.length > 0) {
846
+ chartItem = chartInfo.items[0]; // Take the first item
847
+ break;
848
+ }
849
+ }
850
+
851
+ if (chartItem && chartItem.object_path) {
852
+ // Chart is available, fetch and display it
853
+ console.log(`Found chart for ${nodeName}:`, chartItem);
854
+ await loadChartImage(chartItem, runIdToFetch);
855
+
856
+ // Clear polling since we found the chart
857
+ if (chartModalPollTimer) {
858
+ clearInterval(chartModalPollTimer);
859
+ chartModalPollTimer = null;
860
+ }
861
+ } else {
862
+ // Chart not available yet, set up polling
863
+ console.log(`Chart not yet available for ${nodeName}, setting up polling`);
864
+ if (elChartModalMessage) {
865
+ elChartModalMessage.textContent = `Chart for "${nodeName}" is not yet available.`;
866
+ elChartModalMessage.style.display = 'block';
867
+ }
868
+
869
+ // Start polling every 3 seconds
870
+ chartModalPollTimer = setInterval(async () => {
871
+ if (!isModalOpen) {
872
+ clearInterval(chartModalPollTimer);
873
+ chartModalPollTimer = null;
874
+ return;
875
+ }
876
+ await tryLoadChartForNode(nodeName);
877
+ }, 3000);
878
+ }
879
+ } catch (error) {
880
+ console.error(`Error loading chart for ${nodeName}:`, error);
881
+ if (elChartModalMessage) {
882
+ elChartModalMessage.textContent = `Error loading chart: ${error.message}`;
883
+ elChartModalMessage.style.display = 'block';
884
+ }
885
+ }
886
+ }
887
+
888
+ async function loadChartImage(chartItem, runIdToFetch) {
889
+ try {
890
+ const params = new URLSearchParams();
891
+ if (chartItem.object_path) params.set('uri', chartItem.object_path);
892
+ if (chartItem.cache_path) params.set('cache_path', chartItem.cache_path);
893
+
894
+ // Use the provided runId or fallback to current.runId
895
+ const targetRunId = runIdToFetch || current.runId;
896
+
897
+ const fetchUrl = `${API_BASE}/projects/${encodeURIComponent(current.project)}/runs/${encodeURIComponent(targetRunId)}/charts/fetch?` + params.toString();
898
+ console.log(`Fetching chart image from: ${fetchUrl}`);
899
+
900
+ const resp = await fetch(fetchUrl);
901
+ if (!resp.ok) {
902
+ throw new Error(`Chart fetch failed: ${resp.status}`);
903
+ }
904
+
905
+ const blob = await resp.blob();
906
+ const url = URL.createObjectURL(blob);
907
+
908
+ if (elChartModalImage) {
909
+ elChartModalImage.src = url;
910
+ elChartModalImage.style.display = 'block';
911
+ }
912
+ if (elChartModalMessage) {
913
+ elChartModalMessage.style.display = 'none';
914
+ }
915
+
916
+ console.log("Chart image loaded successfully");
917
+ } catch (error) {
918
+ console.error("Error loading chart image:", error);
919
+ if (elChartModalMessage) {
920
+ elChartModalMessage.textContent = `Error loading chart image: ${error.message}`;
921
+ elChartModalMessage.style.display = 'block';
922
+ }
923
+ }
924
+ }
925
+
926
+ function closeChartModal() {
927
+ console.log("closeChartModal: Closing modal");
928
+
929
+ // Clear polling timer
930
+ if (chartModalPollTimer) {
931
+ clearInterval(chartModalPollTimer);
932
+ chartModalPollTimer = null;
933
+ }
934
+
935
+ // Hide modal
936
+ if (elChartModal) {
937
+ elChartModal.classList.add('hidden');
938
+ isModalOpen = false;
939
+ }
940
+
941
+ // Clear image and message
942
+ if (elChartModalImage) {
943
+ elChartModalImage.src = '';
944
+ elChartModalImage.style.display = 'none';
945
+ }
946
+ if (elChartModalMessage) {
947
+ elChartModalMessage.style.display = 'none';
948
+ }
949
+ }
950
+
951
+ // -------- Dynamic Charts ---------
952
+ async function loadDynamicCharts() {
953
+ // Clean up existing dynamic charts
954
+ dynamicChartCleanups.forEach(cleanup => cleanup());
955
+ dynamicChartCleanups = [];
956
+ if (elDynamicChartsGrid) elDynamicChartsGrid.innerHTML = '';
957
+ if (elDynamicChartsContainer) elDynamicChartsContainer.style.display = 'none';
958
+ dynamicChartsInitialized = false;
959
+
960
+ if (!current.project || !current.runId) {
961
+ dynamicChartsInitialized = false;
962
+ return;
963
+ }
964
+
965
+ try {
966
+ // Fetch chart configuration from project
967
+ const configUrl = `${API_BASE}/projects/${encodeURIComponent(current.project)}/chart-config`;
968
+ const configData = await fetchJSON(configUrl);
969
+ console.log('Chart config:', configData);
970
+
971
+ const charts = configData.charts || [];
972
+ const entrypoint = configData.entrypoint;
973
+
974
+ // Filter for dynamic charts
975
+ const dynamicCharts = charts.filter(c => c.type === 'dynamic');
976
+
977
+ if (dynamicCharts.length === 0) {
978
+ dynamicChartsInitialized = false;
979
+ return;
980
+ }
981
+
982
+ // Show dynamic charts container
983
+ if (elDynamicChartsContainer) elDynamicChartsContainer.style.display = 'block';
984
+
985
+ // Load user chart definitions if entrypoint exists
986
+ if (entrypoint) {
987
+ try {
988
+ // Dynamically import user chart file at /projects/<id>/charts/*.js
989
+ const chartModulePath = '/' + entrypoint; // e.g., /projects/titanic/charts/plot_metrics.js
990
+ console.log('Loading user charts from:', chartModulePath);
991
+ await import(chartModulePath);
992
+ console.log('User charts loaded successfully');
993
+ } catch (error) {
994
+ console.error('Failed to load user chart file:', error);
995
+ elDynamicChartsGrid.innerHTML = `<div class="chart-error">Failed to load chart definitions: ${error.message}</div>`;
996
+ dynamicChartsInitialized = false;
997
+ return;
998
+ }
999
+ }
1000
+
1001
+ // Render each dynamic chart
1002
+ for (const chartConfig of dynamicCharts) {
1003
+ const chartDiv = document.createElement('div');
1004
+ chartDiv.className = 'dynamic-chart-item';
1005
+ chartDiv.innerHTML = `<h4>${chartConfig.name}</h4><div class="chart-canvas-container"></div>`;
1006
+ if (elDynamicChartsGrid) elDynamicChartsGrid.appendChild(chartDiv);
1007
+ const canvasContainer = chartDiv.querySelector('.chart-canvas-container');
1008
+
1009
+ try {
1010
+ const cleanup = renderDynamicChart(
1011
+ current.project,
1012
+ current.runId,
1013
+ chartConfig.name,
1014
+ chartConfig,
1015
+ canvasContainer,
1016
+ API_BASE
1017
+ );
1018
+ dynamicChartCleanups.push(cleanup);
1019
+ } catch (error) {
1020
+ console.error(`Failed to render chart ${chartConfig.name}:`, error);
1021
+ canvasContainer.innerHTML = `<div class="chart-error">Error: ${error.message}</div>`;
1022
+ }
1023
+ }
1024
+ // Mark initialized after attempting to render at least one chart
1025
+ dynamicChartCleanups.length > 0 ? (dynamicChartsInitialized = true) : (dynamicChartsInitialized = false);
1026
+ } catch (error) {
1027
+ console.error('Error loading dynamic charts:', error);
1028
+ dynamicChartsInitialized = false;
1029
+ }
1030
+ }
1031
+
1032
+ // -------- Charts (KV-backed) ---------
1033
+ async function refreshCharts() {
1034
+ if (!current.project || !current.runId) {
1035
+ console.log("refreshCharts: No project or runId");
1036
+ return;
1037
+ }
1038
+ console.log(`refreshCharts: project=${current.project}, runId=${current.runId}`);
1039
+
1040
+ // Load dynamic charts
1041
+ await loadDynamicCharts();
1042
+
1043
+ // Load static charts (existing logic)
1044
+ try {
1045
+ const url = `${API_BASE}/projects/${encodeURIComponent(current.project)}/runs/${encodeURIComponent(current.runId)}/charts`;
1046
+ console.log(`Fetching charts from: ${url}`);
1047
+ const data = await fetchJSON(url);
1048
+ console.log("Charts response:", data);
1049
+ const charts = data.charts || {};
1050
+ const candidates = [];
1051
+ for (const [name, info] of Object.entries(charts)) {
1052
+ const items = (info && info.items) || [];
1053
+ for (const it of items) {
1054
+ candidates.push({
1055
+ label: `${name}/${it.title}`,
1056
+ uri: it.object_path || '',
1057
+ cache_path: it.cache_path || ''
1058
+ });
1059
+ }
1060
+ }
1061
+ console.log(`Found ${candidates.length} chart candidates:`, candidates);
1062
+ elChartSelect.innerHTML = "";
1063
+ for (const c of candidates) {
1064
+ const opt = document.createElement("option");
1065
+ opt.value = JSON.stringify({ uri: c.uri, cache_path: c.cache_path });
1066
+ opt.textContent = c.label; elChartSelect.appendChild(opt);
1067
+ }
1068
+ if (candidates.length) {
1069
+ elChartSelect.value = JSON.stringify({ uri: candidates[0].uri, cache_path: candidates[0].cache_path });
1070
+ console.log("Loading first chart...");
1071
+ await loadChart(JSON.parse(elChartSelect.value));
1072
+ // Show image, hide empty message
1073
+ if (elChartImage) elChartImage.style.display = '';
1074
+ if (elChartEmptyMessage) elChartEmptyMessage.style.display = 'none';
1075
+ } else {
1076
+ console.log("No charts found");
1077
+ if (elChartImage) {
1078
+ elChartImage.src = "";
1079
+ elChartImage.style.display = 'none';
1080
+ }
1081
+ if (elChartEmptyMessage) {
1082
+ elChartEmptyMessage.textContent = 'No charts currently';
1083
+ elChartEmptyMessage.style.display = 'block';
1084
+ }
1085
+ }
1086
+ } catch (e) {
1087
+ console.error("Error refreshing charts:", e);
1088
+ if (elChartImage) {
1089
+ elChartImage.src = "";
1090
+ elChartImage.style.display = 'none';
1091
+ }
1092
+ if (elChartEmptyMessage) {
1093
+ elChartEmptyMessage.textContent = 'No charts currently';
1094
+ elChartEmptyMessage.style.display = 'block';
1095
+ }
1096
+ }
1097
+ }
1098
+
1099
+ // Periodic static charts refresh (preserve current selection)
1100
+ async function refreshStaticChartsList() {
1101
+ if (!current.project || !current.runId) return;
1102
+ try {
1103
+ const url = `${API_BASE}/projects/${encodeURIComponent(current.project)}/runs/${encodeURIComponent(current.runId)}/charts`;
1104
+ const data = await fetchJSON(url);
1105
+ const charts = data.charts || {};
1106
+ const candidates = [];
1107
+ for (const [name, info] of Object.entries(charts)) {
1108
+ const items = (info && info.items) || [];
1109
+ for (const it of items) {
1110
+ candidates.push({
1111
+ label: `${name}/${it.title} (${info.type || 'static'})`,
1112
+ uri: it.object_path || '',
1113
+ cache_path: it.cache_path || ''
1114
+ });
1115
+ }
1116
+ }
1117
+
1118
+ // Preserve current selection if still available
1119
+ const prev = elChartSelect.value;
1120
+ const prevSet = new Set(candidates.map(c => JSON.stringify({ uri: c.uri, cache_path: c.cache_path })));
1121
+
1122
+ // Rebuild options
1123
+ elChartSelect.innerHTML = "";
1124
+ for (const c of candidates) {
1125
+ const opt = document.createElement("option");
1126
+ opt.value = JSON.stringify({ uri: c.uri, cache_path: c.cache_path });
1127
+ opt.textContent = c.label; elChartSelect.appendChild(opt);
1128
+ }
1129
+
1130
+ if (prev && prevSet.has(prev)) {
1131
+ elChartSelect.value = prev;
1132
+ // Do not reload image to avoid flicker; user can switch manually
1133
+ if (elChartImage) elChartImage.style.display = '';
1134
+ if (elChartEmptyMessage) elChartEmptyMessage.style.display = 'none';
1135
+ } else if (!prev && candidates.length) {
1136
+ // No previous selection; select first and load once
1137
+ elChartSelect.value = JSON.stringify({ uri: candidates[0].uri, cache_path: candidates[0].cache_path });
1138
+ await loadChart(JSON.parse(elChartSelect.value));
1139
+ if (elChartImage) elChartImage.style.display = '';
1140
+ if (elChartEmptyMessage) elChartEmptyMessage.style.display = 'none';
1141
+ } else if (prev && !prevSet.has(prev)) {
1142
+ // Previous selection disappeared; keep current image but set first option
1143
+ if (candidates.length) {
1144
+ elChartSelect.value = JSON.stringify({ uri: candidates[0].uri, cache_path: candidates[0].cache_path });
1145
+ if (elChartImage) elChartImage.style.display = '';
1146
+ if (elChartEmptyMessage) elChartEmptyMessage.style.display = 'none';
1147
+ } else {
1148
+ if (elChartImage) {
1149
+ elChartImage.src = "";
1150
+ elChartImage.style.display = 'none';
1151
+ }
1152
+ if (elChartEmptyMessage) {
1153
+ elChartEmptyMessage.textContent = 'No charts currently';
1154
+ elChartEmptyMessage.style.display = 'block';
1155
+ }
1156
+ }
1157
+ }
1158
+ } catch (e) {
1159
+ // Ignore transient errors
1160
+ }
1161
+ }
1162
+
1163
+ function startStaticChartsPolling() {
1164
+ if (staticChartsPollTimer) clearInterval(staticChartsPollTimer);
1165
+ staticChartsPollTimer = setInterval(() => {
1166
+ if (!current.project || !current.runId) return;
1167
+ refreshStaticChartsList();
1168
+ }, 10000);
1169
+ }
1170
+
1171
+ function startDynamicChartsAutoload() {
1172
+ if (dynamicChartsInitTimer) clearInterval(dynamicChartsInitTimer);
1173
+ dynamicChartsInitTimer = setInterval(async () => {
1174
+ if (!current.project || !current.runId) return;
1175
+ if (dynamicChartsInitialized) {
1176
+ clearInterval(dynamicChartsInitTimer);
1177
+ dynamicChartsInitTimer = null;
1178
+ return;
1179
+ }
1180
+ await loadDynamicCharts();
1181
+ if (dynamicChartsInitialized) {
1182
+ clearInterval(dynamicChartsInitTimer);
1183
+ dynamicChartsInitTimer = null;
1184
+ }
1185
+ }, 3000);
1186
+ }
1187
+
1188
+ elChartSelect.addEventListener('change', async () => {
1189
+ try {
1190
+ const ref = JSON.parse(elChartSelect.value);
1191
+ await loadChart(ref);
1192
+ } catch {
1193
+ // ignore
1194
+ }
1195
+ });
1196
+
1197
+ async function loadChart(ref) {
1198
+ if (!ref) {
1199
+ console.log("loadChart: No ref provided");
1200
+ if (elChartImage) {
1201
+ elChartImage.src = "";
1202
+ elChartImage.style.display = 'none';
1203
+ }
1204
+ if (elChartEmptyMessage) {
1205
+ elChartEmptyMessage.textContent = 'No charts currently';
1206
+ elChartEmptyMessage.style.display = 'block';
1207
+ }
1208
+ return;
1209
+ }
1210
+ console.log("loadChart: ref=", ref);
1211
+ try {
1212
+ const params = new URLSearchParams();
1213
+ if (ref.uri) params.set('uri', ref.uri);
1214
+ if (ref.cache_path) params.set('cache_path', ref.cache_path);
1215
+ const fetchUrl = `${API_BASE}/projects/${encodeURIComponent(current.project)}/runs/${encodeURIComponent(current.runId)}/charts/fetch?` + params.toString();
1216
+ console.log(`Fetching chart from: ${fetchUrl}`);
1217
+ const resp = await fetch(fetchUrl);
1218
+ console.log(`Chart fetch response: status=${resp.status}, content-type=${resp.headers.get('content-type')}`);
1219
+ if (!resp.ok) {
1220
+ const text = await resp.text();
1221
+ console.error(`Chart fetch failed: ${resp.status} ${text}`);
1222
+ throw new Error(`fetch failed: ${resp.status}`);
1223
+ }
1224
+ const blob = await resp.blob();
1225
+ console.log(`Chart blob size: ${blob.size} bytes`);
1226
+ const url = URL.createObjectURL(blob);
1227
+ elChartImage.src = url;
1228
+ if (elChartImage) elChartImage.style.display = '';
1229
+ if (elChartEmptyMessage) elChartEmptyMessage.style.display = 'none';
1230
+ console.log("Chart loaded successfully");
1231
+ } catch (e) {
1232
+ console.error("Error loading chart:", e);
1233
+ if (elChartImage) {
1234
+ elChartImage.src = "";
1235
+ elChartImage.style.display = 'none';
1236
+ }
1237
+ if (elChartEmptyMessage) {
1238
+ elChartEmptyMessage.textContent = 'No charts currently';
1239
+ elChartEmptyMessage.style.display = 'block';
1240
+ }
1241
+ }
1242
+ }
1243
+
1244
+