Skip to content

Commit 3ad758d

Browse files
author
Michal Warda
committed
Add refresh-safe dashboard routes
1 parent 62e8ece commit 3ad758d

6 files changed

Lines changed: 119 additions & 18 deletions

File tree

docs/pipeline/ARCHITECTURE.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -79,7 +79,9 @@ rule are persisted under `adaptive_routing`.
7979
displays its channel layout, codec/container, sample rate, bit depth when
8080
available, bitrate, and lossless/lossy quality tier. The explorer plays the
8181
full joined result beside its original, retains each chunk join and its
82-
diagnostics, and graphs the dataset-wide source-score distribution.
82+
diagnostics, and graphs the dataset-wide source-score distribution. Pipeline,
83+
review, dataset, job, and source selections use durable browser URLs so a
84+
refresh, copied link, or back/forward navigation restores the same context.
8385

8486
## AWS resources
8587

docs/pipeline/OPERATIONS.md

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -150,6 +150,22 @@ ssh ubuntu@ec2-44-211-31-38.compute-1.amazonaws.com \
150150
The command is idempotent; pass one or more `--job-id JOB_ID` arguments instead
151151
of `--all`, or use `--force` to refresh already-profiled sources.
152152

153+
## Dashboard URLs
154+
155+
Dashboard state is encoded in clean paths and is safe to refresh or share:
156+
157+
```text
158+
/ pipeline and upload overview
159+
/review manual review queue
160+
/data/{dataset_id} dataset explorer
161+
/data/{dataset_id}/jobs/{job_id} job within a dataset
162+
/data/{dataset_id}/jobs/{job_id}/sources/{source_id}
163+
exact sound detail
164+
```
165+
166+
Selecting a tab, job, dataset, or sound updates browser history. Back and
167+
forward navigation restore the corresponding view and selected record.
168+
153169
## AudioSet validation batches
154170

155171
Acquire a reproducible random sample from the official AudioSet segment CSVs.

docs/pipeline/PROGRESS.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,8 @@ was an example batch size, not a workflow limit.
3636
metrics, and graph the dataset-wide reconstruction score distribution.
3737
- [x] Persist and display each original's mono/stereo layout and encoding quality
3838
facts, with an idempotent historical backfill.
39+
- [x] Give pipeline, review, dataset, job, and source dashboard states durable
40+
URLs with refresh and browser-history restoration.
3941
- [x] Require stereo input, gate SAM targets with source-scene presence, support
4042
single-stage inference, and use identity pass-through for pure SFX sources.
4143
- [x] Add persistent datasets, successive upload jobs, and reconciliation of

pipeline/src/sam_audio_pipeline/api.py

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -367,6 +367,21 @@ def health(request: Request) -> dict[str, Any]:
367367
def panel() -> str:
368368
return (Path(__file__).parent / "web" / "index.html").read_text()
369369

370+
@app.get("/review", response_class=HTMLResponse)
371+
@app.get("/data", response_class=HTMLResponse)
372+
@app.get("/data/{dataset_id}", response_class=HTMLResponse)
373+
@app.get("/data/{dataset_id}/jobs/{job_id}", response_class=HTMLResponse)
374+
@app.get(
375+
"/data/{dataset_id}/jobs/{job_id}/sources/{source_id}",
376+
response_class=HTMLResponse,
377+
)
378+
def panel_route(
379+
dataset_id: str | None = None,
380+
job_id: str | None = None,
381+
source_id: str | None = None,
382+
) -> str:
383+
return (Path(__file__).parent / "web" / "index.html").read_text()
384+
370385
@app.get("/v1/overview")
371386
def overview(request: Request) -> dict[str, Any]:
372387
store = backend(request)

pipeline/src/sam_audio_pipeline/web/index.html

Lines changed: 61 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -282,14 +282,55 @@ <h1>SAM Audio Pipeline</h1>
282282
return `<div class="card similarity-panel"><div class="similarity-head"><div><div class="eyebrow">Reconstruction similarity</div><strong>${formatScore(summary.mean)} mean</strong></div><span>${records.length} joined source(s) · median ${formatScore(summary.median)}<br>sample-aligned · phase + level sensitive</span></div><svg class="similarity-chart" viewBox="0 0 ${width} ${height}" role="img" aria-label="${escapeHtml(description)}"><title>${escapeHtml(description)}</title>${ticks}<line class="axis" x1="${left}" y1="${top + plotHeight}" x2="${width - right}" y2="${top + plotHeight}"></line>${bars}<line class="mean-line" x1="${meanX}" y1="${top - 4}" x2="${meanX}" y2="${top + plotHeight}"></line><text x="${Math.min(meanX + 4, width - 70)}" y="${top - 8}">mean ${formatScore(summary.mean)}</text></svg></div>`;
283283
}
284284

285-
function show(view) {
285+
function routeState(pathname = window.location.pathname) {
286+
const segments = pathname.split("/").filter(Boolean).map(segment => decodeURIComponent(segment));
287+
if (segments[0] === "review") return {view: "review"};
288+
if (segments[0] !== "data") return {view: "dashboard"};
289+
return {
290+
view: "data",
291+
datasetId: segments[1] || null,
292+
jobId: segments[2] === "jobs" ? segments[3] || null : null,
293+
sourceId: segments[4] === "sources" ? segments[5] || null : null
294+
};
295+
}
296+
297+
function dataRoutePath(datasetId, jobId = null, sourceId = null) {
298+
if (!datasetId) return "/data";
299+
let path = `/data/${encodeURIComponent(datasetId)}`;
300+
if (jobId) path += `/jobs/${encodeURIComponent(jobId)}`;
301+
if (jobId && sourceId) path += `/sources/${encodeURIComponent(sourceId)}`;
302+
return path;
303+
}
304+
305+
function setView(view) {
286306
activeView = view;
287307
for (const name of ["dashboard", "data", "review"]) {
288308
$(`#${name}`).hidden = view !== name;
289309
$(`#${name}-tab`).classList.toggle("active", view === name);
290310
}
291-
if (view === "review") loadReview();
292-
if (view === "data") loadDataset();
311+
}
312+
313+
async function navigateTo(path) {
314+
if (window.location.pathname !== path) history.pushState({}, "", path);
315+
await renderRoute();
316+
}
317+
318+
async function navigateToSource(jobId, sourceId) {
319+
const datasetId = currentDataset?.dataset?.dataset_id || $("#data-dataset").value;
320+
const path = dataRoutePath(datasetId, jobId, sourceId);
321+
if (window.location.pathname !== path) history.pushState({}, "", path);
322+
await loadSource(jobId, sourceId);
323+
}
324+
325+
async function renderRoute() {
326+
const route = routeState();
327+
currentSourceId = route.sourceId || null;
328+
currentJobId = route.jobId || null;
329+
await loadDatasets(route.datasetId);
330+
setView(route.view);
331+
if (route.view === "review") return loadReview();
332+
if (route.view === "data") return loadDataset(route.sourceId, route.jobId);
333+
return refresh();
293334
}
294335

295336
async function refresh() {
@@ -321,7 +362,10 @@ <h1>SAM Audio Pipeline</h1>
321362
const options = datasets.map(dataset => `<option value="${escapeHtml(dataset.dataset_id)}">${escapeHtml(dataset.name)}</option>`).join("");
322363
$("#dataset").innerHTML = options;
323364
$("#data-dataset").innerHTML = options;
324-
const value = selected || $("#dataset").value || datasets[0]?.dataset_id;
365+
const requested = selected || $("#dataset").value;
366+
const value = datasets.some(dataset => dataset.dataset_id === requested)
367+
? requested
368+
: datasets[0]?.dataset_id;
325369
if (value) {
326370
$("#dataset").value = value;
327371
$("#data-dataset").value = value;
@@ -377,7 +421,7 @@ <h1>SAM Audio Pipeline</h1>
377421
return `<div class="card"><div class="eyebrow">${escapeHtml(label)}</div><div class="metric-value">${escapeHtml(value)}</div><div class="metric-detail">${escapeHtml(detail)}</div></div>`;
378422
}
379423

380-
async function loadDataset(preferredSource) {
424+
async function loadDataset(preferredSource, preferredJob) {
381425
const datasetId = $("#data-dataset").value || $("#dataset").value;
382426
if (!datasetId) return;
383427
$("#dataset").value = datasetId;
@@ -401,22 +445,21 @@ <h1>SAM Audio Pipeline</h1>
401445
const skipped = source.skip_reason === "non_stereo_input" ? " · skipped: input is not stereo" : "";
402446
const similarity = source.similarity_score == null ? "" : ` · ${formatScore(source.similarity_score)} joined`;
403447
const audioProfile = audioProfileText(source.audio_profile);
404-
return `<button class="source-item ${source.source_id === currentSourceId ? "active" : ""}" data-source="${escapeHtml(source.source_id)}" onclick="loadSource('${escapeHtml(source.job_id)}', '${escapeHtml(source.source_id)}')">
448+
return `<button class="source-item ${source.source_id === currentSourceId ? "active" : ""}" data-source="${escapeHtml(source.source_id)}" onclick="navigateToSource('${escapeHtml(source.job_id)}', '${escapeHtml(source.source_id)}')">
405449
<strong>${escapeHtml(source.filename)}</strong>
406450
<small>${escapeHtml(source.pipeline_status)} · ${formatDuration(source.duration_seconds)} · ${source.stem_count} stem(s)${escapeHtml(route)}${escapeHtml(skipped)}${escapeHtml(similarity)}${needsReview ? ` · ${needsReview} need review` : ""}</small>
407451
${audioProfile ? `<small class="source-audio-profile">${escapeHtml(audioProfile)}</small>` : ""}
408452
</button>`;
409453
}).join("") || '<div class="empty">No uploaded sounds yet.</div>';
410454
const target = preferredSource || currentSourceId;
411-
const source = currentDataset.sources.find(item => item.source_id === target) || currentDataset.sources[0];
455+
const source = currentDataset.sources.find(item => item.source_id === target)
456+
|| currentDataset.sources.find(item => item.job_id === preferredJob)
457+
|| currentDataset.sources[0];
412458
if (source) await loadSource(source.job_id, source.source_id);
413459
}
414460

415461
async function openJob(jobId, datasetId) {
416-
await loadDatasets(datasetId);
417-
show("data");
418-
const source = currentDataset?.sources.find(item => item.job_id === jobId);
419-
if (source) await loadSource(source.job_id, source.source_id);
462+
await navigateTo(dataRoutePath(datasetId, jobId));
420463
}
421464

422465
function trackRow(kind, label, subtitle, rawUrl, stereoUrl = null) {
@@ -558,22 +601,23 @@ <h2>${escapeHtml(item.assertion)}</h2>
558601
await loadReview();
559602
}
560603

561-
$("#dashboard-tab").onclick = () => show("dashboard");
562-
$("#data-tab").onclick = () => show("data");
563-
$("#review-tab").onclick = () => show("review");
604+
$("#dashboard-tab").onclick = () => navigateTo("/");
605+
$("#data-tab").onclick = () => navigateTo(dataRoutePath($("#data-dataset").value || $("#dataset").value));
606+
$("#review-tab").onclick = () => navigateTo("/review");
564607
$("#submit").onclick = submitFiles;
565608
$("#new-dataset").onclick = newDataset;
566-
$("#refresh-dataset").onclick = () => loadDataset(currentSourceId);
609+
$("#refresh-dataset").onclick = () => loadDataset(currentSourceId, currentJobId);
567610
$("#dataset").onchange = event => { $("#data-dataset").value = event.target.value; };
568-
$("#data-dataset").onchange = event => { $("#dataset").value = event.target.value; currentSourceId = null; loadDataset(); };
611+
$("#data-dataset").onchange = event => navigateTo(dataRoutePath(event.target.value));
612+
window.addEventListener("popstate", () => renderRoute());
569613
document.addEventListener("keydown", event => {
570614
if (activeView !== "review" || !currentReview || event.repeat) return;
571615
if (event.key === "1") decide("pass");
572616
if (event.key === "2") decide("fail");
573617
if (event.key === "3") decide("pending");
574618
if (event.key === " ") { event.preventDefault(); const audio = $("#review-audio"); audio.paused ? audio.play() : audio.pause(); }
575619
});
576-
loadDatasets().then(refresh);
620+
renderRoute();
577621
setInterval(() => {
578622
if (activeView === "dashboard") refresh();
579623
if (activeView === "review") loadReviewStats();

pipeline/tests/test_api.py

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
from collections import defaultdict
44
from typing import Any
55

6+
import pytest
67
from fastapi.testclient import TestClient
78

89
from sam_audio_pipeline.api import create_app
@@ -131,6 +132,27 @@ def settings() -> Settings:
131132
)
132133

133134

135+
@pytest.mark.parametrize(
136+
"path",
137+
[
138+
"/",
139+
"/review",
140+
"/data",
141+
"/data/dataset-1",
142+
"/data/dataset-1/jobs/job-1",
143+
"/data/dataset-1/jobs/job-1/sources/source-1",
144+
],
145+
)
146+
def test_dashboard_routes_support_direct_load_and_refresh(path: str) -> None:
147+
config = settings()
148+
with TestClient(create_app(config, FakeAWS(config))) as client:
149+
response = client.get(path)
150+
151+
assert response.status_code == 200
152+
assert "SAM Audio Pipeline" in response.text
153+
assert "routeState" in response.text
154+
155+
134156
def test_persistent_dataset_accepts_successive_unbounded_jobs() -> None:
135157
config = settings()
136158
aws = FakeAWS(config)

0 commit comments

Comments
 (0)