From 6e26df8f8e3095fb78d8467da4960f8b624e50fd Mon Sep 17 00:00:00 2001 From: hyeokjun32 Date: Wed, 29 Apr 2026 23:13:04 +0900 Subject: [PATCH] fix: stabilize local studio job detail --- inferedgelab/studio/static/app.js | 83 +++++++++++++++++++++++---- inferedgelab/studio/static/index.html | 4 +- inferedgelab/studio/static/style.css | 20 +++++++ tests/test_studio_routes.py | 35 +++++++++++ 4 files changed, 128 insertions(+), 14 deletions(-) diff --git a/inferedgelab/studio/static/app.js b/inferedgelab/studio/static/app.js index 1dd2796..cdb8f06 100644 --- a/inferedgelab/studio/static/app.js +++ b/inferedgelab/studio/static/app.js @@ -24,6 +24,7 @@ const pipelineStages = [ let currentJobs = []; let selectedJob = null; +let selectedJobId = null; let compareData = null; let activeDecision = null; let importedResult = null; @@ -41,23 +42,35 @@ function createElement(tagName, className, textContent) { function setStatus(id, message, tone = "muted") { const target = document.querySelector(id); + if (!target) { + return; + } target.textContent = message; target.className = `status-line ${tone}`; } function setState(id, state) { const target = document.querySelector(id); + if (!target) { + return; + } target.textContent = state; target.className = `state-pill ${normalizeState(state)}`; } function setLoading(selector, label = "Loading...") { const target = document.querySelector(selector); + if (!target) { + return; + } target.replaceChildren(createElement("p", "empty-state loading-dot", label)); } function setEmpty(selector, label = "No data available") { const target = document.querySelector(selector); + if (!target) { + return; + } target.replaceChildren(createElement("p", "empty-state", label)); } @@ -85,21 +98,27 @@ async function postJson(url, payload) { return response.json(); } -async function loadJobs() { +async function loadJobs(preferredJobId = selectedJobId) { setLoading("#job-list"); try { const payload = await fetchJson("/studio/api/jobs"); currentJobs = Array.isArray(payload.jobs) ? payload.jobs : []; - renderJobList(); if (currentJobs.length > 0) { - await loadJobDetail(currentJobs[0].job_id); + const preferredJob = currentJobs.find((job) => job.job_id === preferredJobId); + const nextJob = preferredJob || currentJobs[0]; + selectedJobId = nextJob.job_id; + renderJobList(); + await loadJobDetail(nextJob.job_id); } else { selectedJob = null; + selectedJobId = null; + renderJobList(); renderJobDetail(); } } catch (error) { currentJobs = []; selectedJob = null; + selectedJobId = null; setEmpty("#job-list"); renderJobDetail(); } @@ -109,18 +128,23 @@ async function loadJobs() { async function loadJobDetail(jobId) { if (!jobId) { selectedJob = null; + selectedJobId = null; renderJobDetail(); return; } + selectedJobId = jobId; + renderJobList(); setLoading("#job-detail"); try { selectedJob = await fetchJson(`/studio/api/job/${encodeURIComponent(jobId)}`); + selectedJobId = selectedJob.job_id || jobId; renderJobDetail(); + renderJobList(); updateDecision(extractDecision(selectedJob)); } catch (error) { selectedJob = null; - renderJobDetail(); + renderJobDetail(`Unable to load ${jobId}.`); } renderPipeline(); } @@ -161,9 +185,11 @@ async function runModel() { renderPipeline(); try { const payload = await postJson("/studio/api/run", { model_path: modelPath }); + selectedJobId = payload.job_id; + selectedJob = payload.job || null; setStatus("#run-status", `Success: created ${payload.job_id}`, "success"); setState("#run-state", "completed"); - await loadJobs(); + await loadJobs(payload.job_id); } catch (error) { setStatus("#run-status", "Error: run request failed.", "error"); setState("#run-state", "idle"); @@ -258,9 +284,9 @@ function renderPipeline() { } function renderRunPanel() { - document.querySelector("#run-button").addEventListener("click", runModel); - document.querySelector("#import-button").addEventListener("click", importResult); - document.querySelector("#copy-jetson-command").addEventListener("click", copyJetsonCommand); + document.querySelector("#run-button").onclick = runModel; + document.querySelector("#import-button").onclick = importResult; + document.querySelector("#copy-jetson-command").onclick = copyJetsonCommand; setState("#run-state", "idle"); setState("#import-state", "idle"); setState("#jetson-state", "idle"); @@ -280,7 +306,7 @@ function renderJobList() { currentJobs.forEach((job) => { const row = createElement("button", "job-row"); row.type = "button"; - if (selectedJob?.job_id === job.job_id) { + if (selectedJobId === job.job_id) { row.classList.add("selected"); } row.addEventListener("click", () => loadJobDetail(job.job_id)); @@ -295,14 +321,14 @@ function renderJobList() { }); } -function renderJobDetail() { +function renderJobDetail(emptyMessage = "Select a job or import a Runtime result.") { const target = document.querySelector("#job-detail"); const selectedStatus = document.querySelector("#selected-job-status"); target.replaceChildren(); if (!selectedJob) { setState("#selected-job-status", "idle"); - setEmpty("#job-detail", "Select a job or import a Runtime result."); + setEmpty("#job-detail", emptyMessage); return; } @@ -310,7 +336,7 @@ function renderJobDetail() { selectedStatus.className = `state-pill ${normalizeState(selectedJob.status)}`; const result = selectedJob.result || {}; - const runtimeResult = result.runtime_result || result.data?.new || result.new || result; + const runtimeResult = extractRuntimeResult(selectedJob); const compareMetrics = result.comparison?.result?.metrics || result.data?.result?.metrics || {}; const input = selectedJob.input_summary || {}; @@ -328,6 +354,18 @@ function renderJobDetail() { metrics.forEach(([label, value]) => { target.append(metricTile(label, formatValue(value))); }); + + const status = String(selectedJob.status || "").toLowerCase(); + if (!selectedJob.result && status === "queued") { + target.append( + detailNote( + "Queued job", + "The local API accepted this analyze job. Runtime metrics will appear after a worker/dev completion flow or after importing Runtime result JSON.", + ), + ); + } else if (selectedJob.error) { + target.append(detailNote("Job error", selectedJob.error.message || selectedJob.error.detail || "Inspect job error details.")); + } } function renderImportedResult() { @@ -415,6 +453,12 @@ function metricTile(label, value) { return tile; } +function detailNote(title, message) { + const note = createElement("div", "detail-note"); + note.append(createElement("strong", "", title), createElement("p", "body-text", message)); + return note; +} + function compareMetricCard(label, meanMs, backendKey) { const card = createElement("article", "compare-card"); card.append( @@ -455,6 +499,21 @@ function extractDecision(payload) { return payload.deployment_decision || payload.result?.deployment_decision || payload.data?.deployment_decision || null; } +function extractRuntimeResult(job) { + const result = job?.result; + if (!result) { + return {}; + } + return ( + result.runtime_result || + result.comparison?.result?.runtime_result || + result.comparison?.result?.new || + result.data?.new || + result.new || + result + ); +} + function pipelineStatus() { const anyRunning = currentJobs.some((job) => job.status === "queued" || job.status === "running"); const anyCompleted = currentJobs.some((job) => job.status === "completed") || Boolean(importedResult); diff --git a/inferedgelab/studio/static/index.html b/inferedgelab/studio/static/index.html index 33dfba8..ea9c98f 100644 --- a/inferedgelab/studio/static/index.html +++ b/inferedgelab/studio/static/index.html @@ -132,7 +132,7 @@ } } - +
@@ -275,6 +275,6 @@

Future Work

- + diff --git a/inferedgelab/studio/static/style.css b/inferedgelab/studio/static/style.css index 9a82b20..377f181 100644 --- a/inferedgelab/studio/static/style.css +++ b/inferedgelab/studio/static/style.css @@ -392,6 +392,26 @@ body { padding: 12px; } +.detail-note { + grid-column: 1 / -1; + border: 1px solid rgba(34, 211, 238, 0.22); + border-radius: 10px; + background: rgba(34, 211, 238, 0.06); + color: var(--muted); + padding: 12px; +} + +.detail-note strong { + display: block; + color: var(--ink); + font-size: 0.9rem; + margin-bottom: 6px; +} + +.detail-note p { + margin: 0; +} + .metric-name, .metric-value { display: block; diff --git a/tests/test_studio_routes.py b/tests/test_studio_routes.py index b16713e..691f426 100644 --- a/tests/test_studio_routes.py +++ b/tests/test_studio_routes.py @@ -72,6 +72,22 @@ def test_studio_static_assets_include_redesigned_ui_contracts(): assert ".tool-card" in style_text +def test_studio_app_preserves_selected_job_detail_contract(): + app = api.create_app() + route = _get_route(app, "/studio/static/{asset_name}") + + app_response = route.endpoint(asset_name="app.js") + style_response = route.endpoint(asset_name="style.css") + app_text = Path(app_response.path).read_text(encoding="utf-8") + style_text = Path(style_response.path).read_text(encoding="utf-8") + + assert "selectedJobId" in app_text + assert "loadJobs(payload.job_id)" in app_text + assert "Queued job" in app_text + assert "Runtime metrics will appear" in app_text + assert ".detail-note" in style_text + + def test_studio_jobs_api_returns_json_structure(): app = api.create_app() route = _get_route(app, "/studio/api/jobs") @@ -111,6 +127,25 @@ def test_studio_run_api_creates_analyze_job(): assert response["job"]["input_summary"]["model_path"] == "models/yolov8n.onnx" +def test_studio_run_job_can_be_listed_and_selected(): + app = api.create_app() + request = SimpleNamespace(app=app) + run_route = _get_route(app, "/studio/api/run") + jobs_route = _get_route(app, "/studio/api/jobs") + detail_route = _get_route(app, "/studio/api/job/{job_id}") + + created = run_route.endpoint(request=request, payload={"model_path": "models/yolov8n.onnx"}) + jobs = jobs_route.endpoint(request=request) + detail = detail_route.endpoint(request=request, job_id=created["job_id"]) + + assert jobs["count"] == 1 + assert jobs["jobs"][0]["job_id"] == created["job_id"] + assert detail["job_id"] == created["job_id"] + assert detail["status"] == "queued" + assert detail["result"] is None + assert detail["next_actions"] == ["poll_self"] + + def test_studio_import_api_accepts_runtime_result_json(): app = api.create_app() route = _get_route(app, "/studio/api/import")