fix(cleanup): library scans survive navigation, reconnect on return
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=<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) <noreply@anthropic.com>
This commit is contained in:
@@ -154,12 +154,15 @@ async def audit_history():
|
|||||||
limit = min(int(request.args.get("limit", "20")), 100)
|
limit = min(int(request.args.get("limit", "20")), 100)
|
||||||
except ValueError:
|
except ValueError:
|
||||||
return _bad("invalid_limit")
|
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:
|
async with get_session() as session:
|
||||||
rows = (await session.execute(
|
stmt = select(LibraryAuditRun).order_by(LibraryAuditRun.id.desc())
|
||||||
select(LibraryAuditRun)
|
if rule is not None:
|
||||||
.order_by(LibraryAuditRun.id.desc())
|
stmt = stmt.where(LibraryAuditRun.rule == rule)
|
||||||
.limit(limit)
|
rows = (await session.execute(stmt.limit(limit))).scalars().all()
|
||||||
)).scalars().all()
|
|
||||||
return jsonify({"runs": [_serialize_audit_run(r) for r in rows]})
|
return jsonify({"runs": [_serialize_audit_run(r) for r in rows]})
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -112,6 +112,16 @@ onMounted(async () => {
|
|||||||
await store.loadDefaults()
|
await store.loadDefaults()
|
||||||
threshold.value = store.defaults.single_color_threshold
|
threshold.value = store.defaults.single_color_threshold
|
||||||
tolerance.value = store.defaults.single_color_tolerance
|
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())
|
onUnmounted(() => stopPoll())
|
||||||
|
|||||||
@@ -97,6 +97,16 @@ let pollTimer = null
|
|||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
await store.loadDefaults()
|
await store.loadDefaults()
|
||||||
threshold.value = store.defaults.transparency_threshold
|
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())
|
onUnmounted(() => stopPoll())
|
||||||
|
|||||||
@@ -55,6 +55,14 @@ export const useCleanupStore = defineStore('cleanup', () => {
|
|||||||
return body.runs
|
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) {
|
async function applyAudit(id, confirm) {
|
||||||
return await api.post(`/api/cleanup/audit/${id}/apply`, { body: { confirm } })
|
return await api.post(`/api/cleanup/audit/${id}/apply`, { body: { confirm } })
|
||||||
}
|
}
|
||||||
@@ -67,6 +75,6 @@ export const useCleanupStore = defineStore('cleanup', () => {
|
|||||||
defaults, recentRuns,
|
defaults, recentRuns,
|
||||||
loadDefaults,
|
loadDefaults,
|
||||||
previewMinDim, deleteMinDim,
|
previewMinDim, deleteMinDim,
|
||||||
startAudit, getAudit, loadHistory, applyAudit, cancelAudit,
|
startAudit, getAudit, loadHistory, latestAuditForRule, applyAudit, cancelAudit,
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -155,6 +155,27 @@ async def test_audit_history_returns_recent_runs(client, db):
|
|||||||
assert len(body["runs"]) >= 3
|
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
|
@pytest.mark.asyncio
|
||||||
async def test_audit_apply_with_token_deletes(client, db, tmp_path):
|
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")
|
rec = await _seed_image(db, tmp_path, w=100, h=100, name="apply.png")
|
||||||
|
|||||||
Reference in New Issue
Block a user