From 5a6a95682ded995f77bfb1823451aa0354d15b9b Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Thu, 4 Jun 2026 16:55:20 -0400 Subject: [PATCH] fix(cleanup): library scans survive navigation, reconnect on return MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The transparency / single-color audit cards held the run + poll timer in local component state, so navigating away destroyed both and onMounted never reconnected — the Celery scan kept running and writing LibraryAuditRun, but the UI forgot it. Now each card, on mount, fetches its rule's latest run (GET /api/cleanup/audit?rule=&limit=1) and rehydrates: shows progress + resumes polling if still running, or shows the completed result (ready/applied/ error) so the operator can act on it after returning. Adds the ?rule= filter to the audit-history endpoint + cleanup store latestAuditForRule(). Co-Authored-By: Claude Opus 4.8 (1M context) --- backend/app/api/cleanup.py | 13 +++++++----- .../cleanup/SingleColorAuditCard.vue | 10 +++++++++ .../cleanup/TransparencyAuditCard.vue | 10 +++++++++ frontend/src/stores/cleanup.js | 10 ++++++++- tests/test_api_cleanup.py | 21 +++++++++++++++++++ 5 files changed, 58 insertions(+), 6 deletions(-) diff --git a/backend/app/api/cleanup.py b/backend/app/api/cleanup.py index 24853fe..68e0f00 100644 --- a/backend/app/api/cleanup.py +++ b/backend/app/api/cleanup.py @@ -154,12 +154,15 @@ async def audit_history(): limit = min(int(request.args.get("limit", "20")), 100) except ValueError: return _bad("invalid_limit") + # Optional rule filter so a card can reconnect to ITS latest run on mount + # (?rule=transparency&limit=1) — the audit survives navigation; the UI + # rehydrates from this rather than losing the in-flight scan. + rule = request.args.get("rule") or None async with get_session() as session: - rows = (await session.execute( - select(LibraryAuditRun) - .order_by(LibraryAuditRun.id.desc()) - .limit(limit) - )).scalars().all() + stmt = select(LibraryAuditRun).order_by(LibraryAuditRun.id.desc()) + if rule is not None: + stmt = stmt.where(LibraryAuditRun.rule == rule) + rows = (await session.execute(stmt.limit(limit))).scalars().all() return jsonify({"runs": [_serialize_audit_run(r) for r in rows]}) diff --git a/frontend/src/components/cleanup/SingleColorAuditCard.vue b/frontend/src/components/cleanup/SingleColorAuditCard.vue index 08c6476..dc75e4c 100644 --- a/frontend/src/components/cleanup/SingleColorAuditCard.vue +++ b/frontend/src/components/cleanup/SingleColorAuditCard.vue @@ -112,6 +112,16 @@ onMounted(async () => { await store.loadDefaults() threshold.value = store.defaults.single_color_threshold tolerance.value = store.defaults.single_color_tolerance + // Reconnect to this rule's latest run so a scan started before navigating + // away keeps showing progress / its result on return (the scan itself runs + // backend-side regardless). + try { + const latest = await store.latestAuditForRule('single_color') + if (latest) { + audit.value = latest + if (latest.status === 'running') startPoll(latest.id) + } + } catch { /* non-fatal — card still works for a fresh scan */ } }) onUnmounted(() => stopPoll()) diff --git a/frontend/src/components/cleanup/TransparencyAuditCard.vue b/frontend/src/components/cleanup/TransparencyAuditCard.vue index 6e26f99..5fb7523 100644 --- a/frontend/src/components/cleanup/TransparencyAuditCard.vue +++ b/frontend/src/components/cleanup/TransparencyAuditCard.vue @@ -97,6 +97,16 @@ let pollTimer = null onMounted(async () => { await store.loadDefaults() threshold.value = store.defaults.transparency_threshold + // Reconnect to this rule's latest run so a scan started before navigating + // away keeps showing progress / its result on return (the scan itself runs + // backend-side regardless). + try { + const latest = await store.latestAuditForRule('transparency') + if (latest) { + audit.value = latest + if (latest.status === 'running') startPoll(latest.id) + } + } catch { /* non-fatal — card still works for a fresh scan */ } }) onUnmounted(() => stopPoll()) diff --git a/frontend/src/stores/cleanup.js b/frontend/src/stores/cleanup.js index fac5112..82813a2 100644 --- a/frontend/src/stores/cleanup.js +++ b/frontend/src/stores/cleanup.js @@ -55,6 +55,14 @@ export const useCleanupStore = defineStore('cleanup', () => { return body.runs } + // The most recent audit run for a given rule, or null. Cards call this on + // mount to reconnect to a scan that's still running (or to show the last + // completed result) after the user navigates away and back. + async function latestAuditForRule(rule) { + const body = await api.get('/api/cleanup/audit', { params: { rule, limit: 1 } }) + return (body.runs && body.runs[0]) || null + } + async function applyAudit(id, confirm) { return await api.post(`/api/cleanup/audit/${id}/apply`, { body: { confirm } }) } @@ -67,6 +75,6 @@ export const useCleanupStore = defineStore('cleanup', () => { defaults, recentRuns, loadDefaults, previewMinDim, deleteMinDim, - startAudit, getAudit, loadHistory, applyAudit, cancelAudit, + startAudit, getAudit, loadHistory, latestAuditForRule, applyAudit, cancelAudit, } }) diff --git a/tests/test_api_cleanup.py b/tests/test_api_cleanup.py index eaccba5..2456101 100644 --- a/tests/test_api_cleanup.py +++ b/tests/test_api_cleanup.py @@ -155,6 +155,27 @@ async def test_audit_history_returns_recent_runs(client, db): assert len(body["runs"]) >= 3 +@pytest.mark.asyncio +async def test_audit_history_filters_by_rule(client, db): + db.add(LibraryAuditRun( + rule="transparency", params={"threshold": 0.9}, + status="applied", matched_ids=[], finished_at=datetime.now(UTC), + )) + db.add(LibraryAuditRun( + rule="single_color", params={"threshold": 0.95, "tolerance": 30}, + status="ready", matched_count=2, matched_ids=[1, 2], + finished_at=datetime.now(UTC), + )) + await db.commit() + # ?rule=&limit=1 → just THIS rule's latest run (the card-reconnect query). + resp = await client.get("/api/cleanup/audit?rule=single_color&limit=1") + assert resp.status_code == 200 + runs = (await resp.get_json())["runs"] + assert len(runs) == 1 + assert runs[0]["rule"] == "single_color" + assert runs[0]["matched_count"] == 2 + + @pytest.mark.asyncio async def test_audit_apply_with_token_deletes(client, db, tmp_path): rec = await _seed_image(db, tmp_path, w=100, h=100, name="apply.png")