diff --git a/frontend/src/components/settings/GatedPurgeCard.vue b/frontend/src/components/settings/GatedPurgeCard.vue index a08d528..64d2a3b 100644 --- a/frontend/src/components/settings/GatedPurgeCard.vue +++ b/frontend/src/components/settings/GatedPurgeCard.vue @@ -73,7 +73,7 @@ - + @@ -98,16 +98,19 @@ diff --git a/frontend/src/components/settings/VideoDedupCard.vue b/frontend/src/components/settings/VideoDedupCard.vue index 3b3571b..3ff9cd4 100644 --- a/frontend/src/components/settings/VideoDedupCard.vue +++ b/frontend/src/components/settings/VideoDedupCard.vue @@ -49,7 +49,7 @@ No duplicate videos found. - + @@ -74,16 +74,19 @@ diff --git a/frontend/src/composables/useMaintenanceTask.js b/frontend/src/composables/useMaintenanceTask.js new file mode 100644 index 0000000..acbff0e --- /dev/null +++ b/frontend/src/composables/useMaintenanceTask.js @@ -0,0 +1,118 @@ +// Drives a preview/apply maintenance card whose work runs as a long Celery task +// (re-walks, full-library probes) and whose result must survive the operator +// navigating away or reloading the page — "being able to navigate away from a +// maintenance task is very important" (operator, 2026-06-16). +// +// The card-specific bits (canApply/summaryType/humanBytes, the confirm dialog, +// the result copy) stay in the card; this composable owns the shared task +// lifecycle: dispatch → persist the task id → poll → resurface on mount. +// +// How resurfacing works: on dispatch we stash {taskId, mode, startedAt} in +// localStorage. On mount we re-read it and resume polling the SAME task, so a +// run that finished while the operator was away shows its result the moment +// they come back, and one still in flight re-attaches its spinner. The Celery +// result backend retains the summary well under result_expires, so the re-fetch +// succeeds. localStorage is cleared once the result is observed. (localStorage +// is safe on the plain-HTTP origin — rule 95.) + +import { onMounted, ref } from 'vue' + +import { useApi } from './useApi.js' +import { toast } from '../utils/toast.js' + +// Don't resume a task whose dispatch is older than this — past the longest +// plausible run (the gated purge's hard time_limit is 40min) it's almost +// certainly finished-and-expired or dead, and a stale poll would just spin. +const STALE_RESUME_MS = 3 * 60 * 60 * 1000 // 3h + +export function useMaintenanceTask ({ endpoint, storageKey, appliedToast, maxPolls = 300, pollMs = 2000 }) { + const api = useApi() + const previewing = ref(false) + const applying = ref(false) + const summary = ref(null) + const applied = ref(false) + + function persist (taskId, mode, startedAt) { + try { + localStorage.setItem(storageKey, JSON.stringify({ taskId, mode, startedAt })) + } catch { /* private mode / quota — degrade to non-resurfacing, no crash */ } + } + function clearPersist () { + try { localStorage.removeItem(storageKey) } catch { /* ignore */ } + } + + // Poll-then-wait: check immediately first so a resumed-but-already-finished + // task resolves on the first tick instead of after a needless pollMs delay. + async function pollResult (taskId) { + for (let i = 0; i < maxPolls; i++) { + const r = await api.get(`/api/admin/maintenance/task-result/${taskId}`) + if (r.ready) { + clearPersist() + if (!r.successful) throw new Error('the task failed — check the worker logs') + return r.result + } + await new Promise(res => setTimeout(res, pollMs)) + } + throw new Error('timed out waiting for the task — check the task dashboard') + } + + async function run (mode) { + const { task_id: taskId } = await api.post(endpoint, { body: { dry_run: mode === 'preview' } }) + persist(taskId, mode, Date.now()) + return pollResult(taskId) + } + + async function preview () { + previewing.value = true + applied.value = false + summary.value = null + try { + summary.value = await run('preview') + } catch (e) { + toast({ text: e?.body?.detail || e?.message || 'Preview failed', type: 'error' }) + } finally { + previewing.value = false + } + } + + async function apply () { + applying.value = true + try { + summary.value = await run('apply') + applied.value = true + toast({ text: appliedToast, type: 'success' }) + } catch (e) { + toast({ text: e?.body?.detail || e?.message || 'Apply failed', type: 'error' }) + } finally { + applying.value = false + } + } + + // Re-attach to a task left in flight (or finished) while the card was unmounted. + function resume () { + let saved = null + try { saved = JSON.parse(localStorage.getItem(storageKey) || 'null') } catch { saved = null } + if (!saved?.taskId) return + if (saved.startedAt && (Date.now() - saved.startedAt) > STALE_RESUME_MS) { + clearPersist() + return + } + const isApply = saved.mode === 'apply' + if (isApply) applying.value = true + else previewing.value = true + pollResult(saved.taskId) + .then(result => { + summary.value = result + if (isApply) { + applied.value = true + toast({ text: appliedToast, type: 'success' }) + } + }) + .catch(() => { clearPersist() }) // expired/gone — drop quietly, no scary toast on load + .finally(() => { previewing.value = false; applying.value = false }) + } + + onMounted(resume) + + return { previewing, applying, summary, applied, preview, apply } +}