Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 29 additions & 3 deletions inferedgelab/studio/routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -213,16 +213,42 @@ 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")
precision = enriched.get("precision")
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)
173 changes: 145 additions & 28 deletions inferedgelab/studio/static/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand All @@ -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 {
Expand All @@ -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();
}
Expand All @@ -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();
}
Expand All @@ -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);
Expand Down Expand Up @@ -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;
}

Expand All @@ -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",
Expand All @@ -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");
Expand All @@ -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");
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand All @@ -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");
Expand All @@ -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),
);
}

Expand All @@ -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 || ""),
);
}
Expand Down Expand Up @@ -494,20 +564,30 @@ 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) || "-"}`),
);
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(
Expand All @@ -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) {
Expand Down Expand Up @@ -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 "-";
Expand Down
11 changes: 9 additions & 2 deletions inferedgelab/studio/static/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -132,7 +132,7 @@
}
}
</style>
<link rel="stylesheet" href="/studio/static/style.css?v=8" />
<link rel="stylesheet" href="/studio/static/style.css?v=9" />
</head>
<body>
<main class="shell">
Expand Down Expand Up @@ -192,8 +192,15 @@ <h3 id="import-title">Runtime result JSON</h3>
<div class="form-stack">
<label for="import-json-path">result path</label>
<input id="import-json-path" type="text" value="results/latest.json" placeholder="results/latest.json" />
<label for="import-json-payload">or JSON payload</label>
<textarea
id="import-json-payload"
rows="5"
placeholder='{"runtime_role":"runtime-result","model":"yolov8n","engine":"onnxruntime","device":"cpu","mean_ms":45.0}'
></textarea>
<button id="import-button" type="button">Import</button>
<p id="import-status" class="status-line"></p>
<div id="import-evidence" class="evidence-summary"></div>
</div>
</article>

Expand Down Expand Up @@ -275,6 +282,6 @@ <h2 id="future-title">Future Work</h2>
</section>
</main>

<script src="/studio/static/app.js?v=8" defer></script>
<script src="/studio/static/app.js?v=9" defer></script>
</body>
</html>
Loading
Loading