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.
- expops-0.1.3.dist-info/METADATA +826 -0
- expops-0.1.3.dist-info/RECORD +86 -0
- expops-0.1.3.dist-info/WHEEL +5 -0
- expops-0.1.3.dist-info/entry_points.txt +3 -0
- expops-0.1.3.dist-info/licenses/LICENSE +674 -0
- expops-0.1.3.dist-info/top_level.txt +1 -0
- mlops/__init__.py +0 -0
- mlops/__main__.py +11 -0
- mlops/_version.py +34 -0
- mlops/adapters/__init__.py +12 -0
- mlops/adapters/base.py +86 -0
- mlops/adapters/config_schema.py +89 -0
- mlops/adapters/custom/__init__.py +3 -0
- mlops/adapters/custom/custom_adapter.py +447 -0
- mlops/adapters/plugin_manager.py +113 -0
- mlops/adapters/sklearn/__init__.py +3 -0
- mlops/adapters/sklearn/adapter.py +94 -0
- mlops/cluster/__init__.py +3 -0
- mlops/cluster/controller.py +496 -0
- mlops/cluster/process_runner.py +91 -0
- mlops/cluster/providers.py +258 -0
- mlops/core/__init__.py +95 -0
- mlops/core/custom_model_base.py +38 -0
- mlops/core/dask_networkx_executor.py +1265 -0
- mlops/core/executor_worker.py +1239 -0
- mlops/core/experiment_tracker.py +81 -0
- mlops/core/graph_types.py +64 -0
- mlops/core/networkx_parser.py +135 -0
- mlops/core/payload_spill.py +278 -0
- mlops/core/pipeline_utils.py +162 -0
- mlops/core/process_hashing.py +216 -0
- mlops/core/step_state_manager.py +1298 -0
- mlops/core/step_system.py +956 -0
- mlops/core/workspace.py +99 -0
- mlops/environment/__init__.py +10 -0
- mlops/environment/base.py +43 -0
- mlops/environment/conda_manager.py +307 -0
- mlops/environment/factory.py +70 -0
- mlops/environment/pyenv_manager.py +146 -0
- mlops/environment/setup_env.py +31 -0
- mlops/environment/system_manager.py +66 -0
- mlops/environment/utils.py +105 -0
- mlops/environment/venv_manager.py +134 -0
- mlops/main.py +527 -0
- mlops/managers/project_manager.py +400 -0
- mlops/managers/reproducibility_manager.py +575 -0
- mlops/platform.py +996 -0
- mlops/reporting/__init__.py +16 -0
- mlops/reporting/context.py +187 -0
- mlops/reporting/entrypoint.py +292 -0
- mlops/reporting/kv_utils.py +77 -0
- mlops/reporting/registry.py +50 -0
- mlops/runtime/__init__.py +9 -0
- mlops/runtime/context.py +34 -0
- mlops/runtime/env_export.py +113 -0
- mlops/storage/__init__.py +12 -0
- mlops/storage/adapters/__init__.py +9 -0
- mlops/storage/adapters/gcp_kv_store.py +778 -0
- mlops/storage/adapters/gcs_object_store.py +96 -0
- mlops/storage/adapters/memory_store.py +240 -0
- mlops/storage/adapters/redis_store.py +438 -0
- mlops/storage/factory.py +199 -0
- mlops/storage/interfaces/__init__.py +6 -0
- mlops/storage/interfaces/kv_store.py +118 -0
- mlops/storage/path_utils.py +38 -0
- mlops/templates/premier-league/charts/plot_metrics.js +70 -0
- mlops/templates/premier-league/charts/plot_metrics.py +145 -0
- mlops/templates/premier-league/charts/requirements.txt +6 -0
- mlops/templates/premier-league/configs/cluster_config.yaml +13 -0
- mlops/templates/premier-league/configs/project_config.yaml +207 -0
- mlops/templates/premier-league/data/England CSV.csv +12154 -0
- mlops/templates/premier-league/models/premier_league_model.py +638 -0
- mlops/templates/premier-league/requirements.txt +8 -0
- mlops/templates/sklearn-basic/README.md +22 -0
- mlops/templates/sklearn-basic/charts/plot_metrics.py +85 -0
- mlops/templates/sklearn-basic/charts/requirements.txt +3 -0
- mlops/templates/sklearn-basic/configs/project_config.yaml +64 -0
- mlops/templates/sklearn-basic/data/train.csv +14 -0
- mlops/templates/sklearn-basic/models/model.py +62 -0
- mlops/templates/sklearn-basic/requirements.txt +10 -0
- mlops/web/__init__.py +3 -0
- mlops/web/server.py +585 -0
- mlops/web/ui/index.html +52 -0
- mlops/web/ui/mlops-charts.js +357 -0
- mlops/web/ui/script.js +1244 -0
- 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
|
+
|