diff --git a/inferedgelab/studio/routes.py b/inferedgelab/studio/routes.py index 3299f3d..6cdf126 100644 --- a/inferedgelab/studio/routes.py +++ b/inferedgelab/studio/routes.py @@ -213,12 +213,16 @@ def _build_imported_compare_response(base: dict[str, Any], new: dict[str, Any]) def _with_compare_keys(result: dict[str, Any]) -> dict[str, Any]: enriched = dict(result) if not enriched.get("backend_key"): - engine = enriched.get("engine") or enriched.get("backend") - device = enriched.get("device") or enriched.get("device_name") + engine = _first_display_value( + enriched.get("engine_backend"), + enriched.get("engine"), + enriched.get("backend"), + ) + device = _first_display_value(enriched.get("device_name"), enriched.get("device")) if engine and device: enriched["backend_key"] = f"{engine}__{device}" if not enriched.get("compare_key"): - model = enriched.get("model") + model = _first_display_value(enriched.get("model_name"), enriched.get("model")) batch = enriched.get("batch") height = enriched.get("height") width = enriched.get("width") @@ -226,3 +230,25 @@ def _with_compare_keys(result: dict[str, Any]) -> dict[str, Any]: if model and batch and height and width and precision: enriched["compare_key"] = f"{model}__b{batch}__h{height}w{width}__{precision}" return enriched + + +def _first_display_value(*values: Any) -> str: + for value in values: + display_value = _display_value(value) + if display_value: + return display_value + return "" + + +def _display_value(value: Any) -> str: + if value is None or value == "": + return "" + if isinstance(value, dict): + return _first_display_value( + value.get("name"), + value.get("backend"), + value.get("path"), + value.get("status"), + value.get("id"), + ) + return str(value) diff --git a/inferedgelab/studio/static/app.js b/inferedgelab/studio/static/app.js index b14c15d..87abdea 100644 --- a/inferedgelab/studio/static/app.js +++ b/inferedgelab/studio/static/app.js @@ -76,27 +76,41 @@ function setEmpty(selector, label = "No data available") { async function fetchJson(url) { assertHttpStudio(); - const response = await fetch(url, { headers: { Accept: "application/json" } }); - if (!response.ok) { - throw new Error(await responseErrorMessage(response)); + try { + const response = await fetch(url, { headers: { Accept: "application/json" } }); + if (!response.ok) { + throw new Error(await responseErrorMessage(response)); + } + return await parseJsonResponse(response); + } catch (error) { + if (error instanceof SyntaxError) { + throw new Error(`Invalid JSON response from ${url}.`); + } + throw error; } - return response.json(); } async function postJson(url, payload) { assertHttpStudio(); - const response = await fetch(url, { - method: "POST", - headers: { - Accept: "application/json", - "Content-Type": "application/json", - }, - body: JSON.stringify(payload), - }); - if (!response.ok) { - throw new Error(await responseErrorMessage(response)); + try { + const response = await fetch(url, { + method: "POST", + headers: { + Accept: "application/json", + "Content-Type": "application/json", + }, + body: JSON.stringify(payload), + }); + if (!response.ok) { + throw new Error(await responseErrorMessage(response)); + } + return await parseJsonResponse(response); + } catch (error) { + if (error instanceof SyntaxError) { + throw new Error(`Invalid JSON response from ${url}.`); + } + throw error; } - return response.json(); } function assertHttpStudio() { @@ -122,6 +136,14 @@ async function responseErrorMessage(response) { return fallback; } +async function parseJsonResponse(response) { + try { + return await response.json(); + } catch (error) { + throw new SyntaxError("Response body is not valid JSON."); + } +} + async function loadJobs(preferredJobId = selectedJobId) { setLoading("#job-list"); try { @@ -143,8 +165,8 @@ async function loadJobs(preferredJobId = selectedJobId) { currentJobs = []; selectedJob = null; selectedJobId = null; - setEmpty("#job-list"); - renderJobDetail(); + setEmpty("#job-list", `Error: ${formatError(error)}`); + renderJobDetail(`Unable to load jobs: ${formatError(error)}`); } renderPipeline(); } @@ -168,7 +190,7 @@ async function loadJobDetail(jobId) { updateDecision(extractDecision(selectedJob)); } catch (error) { selectedJob = null; - renderJobDetail(`Unable to load ${jobId}.`); + renderJobDetail(`Unable to load ${jobId}: ${formatError(error)}`); } renderPipeline(); } @@ -182,11 +204,11 @@ async function loadCompare() { if (compareData.status === "empty") { activeDecision = null; renderDecision(decision); - } else if (!activeDecision) { + } else { updateDecision(decision); } } catch (error) { - compareData = null; + compareData = { status: "error", error: formatError(error) }; renderCompare(); if (!activeDecision) { updateDecision(null); @@ -226,8 +248,9 @@ async function runModel() { async function importResult() { const button = document.querySelector("#import-button"); const jsonPath = document.querySelector("#import-json-path").value.trim(); - if (!jsonPath) { - setStatus("#import-status", "Error: enter a result path.", "error"); + const jsonPayload = document.querySelector("#import-json-payload").value.trim(); + if (!jsonPath && !jsonPayload) { + setStatus("#import-status", "Error: enter a result path or JSON payload.", "error"); return; } @@ -236,7 +259,7 @@ async function importResult() { setStatus("#import-status", "Loading: importing Runtime result...", "loading"); renderPipeline(); try { - const payload = await postJson("/studio/api/import", { path: jsonPath }); + const payload = await postJson("/studio/api/import", buildImportPayload(jsonPath, jsonPayload)); importedResult = payload.result; setStatus( "#import-status", @@ -246,8 +269,10 @@ async function importResult() { "success", ); setState("#import-state", "completed"); + renderImportEvidence(payload); renderImportedResult(); await loadCompare(); + renderPipeline(); } catch (error) { setStatus("#import-status", `Error: ${formatError(error)}`, "error"); setState("#import-state", "idle"); @@ -257,6 +282,17 @@ async function importResult() { } } +function buildImportPayload(path, jsonPayload) { + if (jsonPayload) { + try { + return { result: JSON.parse(jsonPayload) }; + } catch (error) { + throw new Error("JSON payload is not valid JSON."); + } + } + return { path }; +} + async function loadJetsonCommand() { const textarea = document.querySelector("#jetson-command"); setState("#jetson-state", "running"); @@ -307,6 +343,34 @@ function renderPipeline() { }); } +function renderImportEvidence(payload) { + const target = document.querySelector("#import-evidence"); + if (!target) { + return; + } + target.replaceChildren(); + + const result = payload?.result || {}; + const missing = missingResultKeys(result); + target.append( + evidenceItem("model", runtimeModelName(result)), + evidenceItem("backend", normalizedBackendKey(result) || runtimeBackendName(result)), + evidenceItem("compare", result.compare_key || fallbackLabel("compare_key")), + evidenceItem("mean", result.mean_ms === undefined ? "-" : `${formatNumber(result.mean_ms)} ms`), + ); + if (missing.length > 0) { + target.append( + createElement("p", "evidence-warning", `Fallback metadata: missing ${missing.join(", ")}.`), + ); + } +} + +function evidenceItem(label, value) { + const item = createElement("div", "evidence-item"); + item.append(createElement("span", "metric-name", label), createElement("strong", "", formatValue(value))); + return item; +} + function renderRunPanel() { document.querySelector("#run-button").onclick = runModel; document.querySelector("#import-button").onclick = importResult; @@ -422,6 +486,11 @@ function renderCompare() { const target = document.querySelector("#compare-panel"); target.replaceChildren(); + if (compareData?.status === "error") { + target.append(errorCompareCard(compareData.error)); + return; + } + if (!compareData || compareData.status === "empty") { target.append(emptyCompareCard()); return; @@ -430,6 +499,7 @@ function renderCompare() { const base = compareData.base || compareData.data?.base || {}; const newer = compareData.new || compareData.data?.new || {}; const result = compareData.result || compareData.data?.result || {}; + const judgement = compareData.judgement || compareData.data?.judgement || {}; const meanMetric = result.metrics?.mean_ms || {}; const speedup = result.speedup || result.backend_comparison?.speedup || calculateSpeedup(base, newer); const tensorRt = findResultByBackend([base, newer], "tensorrt"); @@ -439,7 +509,7 @@ function renderCompare() { target.append( compareMetricCard("TensorRT", tensorRt?.mean_ms, normalizedBackendKey(tensorRt) || "tensorrt"), compareMetricCard("ONNX Runtime", onnx?.mean_ms, normalizedBackendKey(onnx) || "onnxruntime"), - compareSummaryCard(meanMetric, speedup, base, newer, sameBackend), + compareSummaryCard(meanMetric, speedup, base, newer, sameBackend, judgement.overall), ); } @@ -462,7 +532,7 @@ function renderDecision(decision) { target.append( createElement("p", "caption", "Decision"), createElement("h3", "", decisionName.toUpperCase()), - createElement("p", "body-text", decision.reason || "-"), + createElement("p", "body-text", decisionReason(decision)), createElement("p", "caption", decision.notes || decision.recommended_action || ""), ); } @@ -494,13 +564,13 @@ function compareMetricCard(label, meanMs, backendKey) { return card; } -function compareSummaryCard(metric, speedup, base, newer, sameBackend = false) { - const card = createElement("article", "compare-card highlight"); +function compareSummaryCard(metric, speedup, base, newer, sameBackend = false, overall = "unknown") { + const card = createElement("article", `compare-card highlight ${compareTone(overall)}`); const diff = formatLatencyDiff(metric); const faster = sameBackend ? "Same backend" : speedup ? `${formatNumber(speedup)}x faster` : "speedup unavailable"; const note = sameBackend ? "Import a TensorRT and an ONNX Runtime result to compare backend speedup." : `Latency diff: ${diff}`; card.append( - createElement("p", "caption", "Latency comparison"), + createElement("p", "caption", `Latency comparison / ${overall || "unknown"}`), createElement("h3", "", faster), createElement("p", "body-text", note), createElement("p", "caption", `${normalizedBackendKey(base) || "-"} -> ${normalizedBackendKey(newer) || "-"}`), @@ -508,6 +578,16 @@ function compareSummaryCard(metric, speedup, base, newer, sameBackend = false) { return card; } +function errorCompareCard(message) { + const card = createElement("article", "compare-card empty error-card"); + card.append( + createElement("p", "caption", "Compare error"), + createElement("h3", "", "Unable to load compare data"), + createElement("p", "body-text", message || "No error detail was provided."), + ); + return card; +} + function emptyCompareCard() { const card = createElement("article", "compare-card empty"); card.append( @@ -525,6 +605,14 @@ function extractDecision(payload) { return payload.deployment_decision || payload.result?.deployment_decision || payload.data?.deployment_decision || null; } +function decisionReason(decision) { + const decisionName = String(decision?.decision || "unknown").toLowerCase(); + if (decisionName === "unknown" && !decision?.guard_status) { + return "AIGuard evidence not provided."; + } + return decision?.reason || "-"; +} + function extractRuntimeResult(job) { const result = job?.result; if (!result) { @@ -641,6 +729,35 @@ function displayValue(value) { return String(value); } +function missingResultKeys(result = {}) { + const missing = []; + if (!result.compare_key) { + missing.push("compare_key"); + } + if (!normalizedBackendKey(result)) { + missing.push("backend_key"); + } + return missing; +} + +function fallbackLabel(key) { + return `${key} unavailable`; +} + +function compareTone(overall) { + const value = String(overall || "").toLowerCase(); + if (value.includes("improvement")) { + return "improvement"; + } + if (value.includes("regression")) { + return "regression"; + } + if (value.includes("neutral")) { + return "neutral"; + } + return "unknown"; +} + function formatLatencyDiff(metric) { if (!metric || Object.keys(metric).length === 0) { return "-"; diff --git a/inferedgelab/studio/static/index.html b/inferedgelab/studio/static/index.html index 14f525d..882bd4f 100644 --- a/inferedgelab/studio/static/index.html +++ b/inferedgelab/studio/static/index.html @@ -132,7 +132,7 @@ } } - +