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
83 changes: 71 additions & 12 deletions inferedgelab/studio/static/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ const pipelineStages = [

let currentJobs = [];
let selectedJob = null;
let selectedJobId = null;
let compareData = null;
let activeDecision = null;
let importedResult = null;
Expand All @@ -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));
}

Expand Down Expand Up @@ -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();
}
Expand All @@ -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();
}
Expand Down Expand Up @@ -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");
Expand Down Expand Up @@ -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");
Expand All @@ -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));
Expand All @@ -295,22 +321,22 @@ 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;
}

selectedStatus.textContent = selectedJob.status || "idle";
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 || {};

Expand All @@ -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() {
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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);
Expand Down
4 changes: 2 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=4" />
<link rel="stylesheet" href="/studio/static/style.css?v=5" />
</head>
<body>
<main class="shell">
Expand Down Expand Up @@ -275,6 +275,6 @@ <h2 id="future-title">Future Work</h2>
</section>
</main>

<script src="/studio/static/app.js?v=4" defer></script>
<script src="/studio/static/app.js?v=5" defer></script>
</body>
</html>
20 changes: 20 additions & 0 deletions inferedgelab/studio/static/style.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
35 changes: 35 additions & 0 deletions tests/test_studio_routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down Expand Up @@ -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")
Expand Down
Loading