// 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 } }