fix(maint): resurface dedup/gated-purge results after navigate-away (#877)
Long-running maintenance tasks must survive navigating away or reloading
the page. VideoDedupCard + GatedPurgeCard held the in-flight Celery task id
only in component refs and polled task-result inline, so leaving the page
mid-run lost the id and the result was never shown — even though the task
finished on the worker.
New shared composable useMaintenanceTask: persists {taskId, mode, startedAt}
to localStorage on dispatch, re-attaches on mount, and re-shows the result
when the task finishes (the celery result backend retains the summary well
under result_expires). Stale-guard skips resume past 3h. Both cards refactored
onto it; card-specific computeds + confirm dialog kept.
Also fixed the QueueStatusBar lane: both cards watched queue="maintenance"
but tasks.admin.* routes to maintenance_long, so the bar never reflected
their own task — now queue="maintenance_long".
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -73,7 +73,7 @@
|
|||||||
</template>
|
</template>
|
||||||
</v-alert>
|
</v-alert>
|
||||||
|
|
||||||
<QueueStatusBar queue="maintenance" queue-label="Maintenance" />
|
<QueueStatusBar queue="maintenance_long" queue-label="Maintenance" />
|
||||||
</v-card-text>
|
</v-card-text>
|
||||||
|
|
||||||
<v-dialog v-model="confirmOpen" max-width="460">
|
<v-dialog v-model="confirmOpen" max-width="460">
|
||||||
@@ -98,16 +98,19 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { computed, ref } from 'vue'
|
import { computed, ref } from 'vue'
|
||||||
|
|
||||||
import { useApi } from '../../composables/useApi.js'
|
import { useMaintenanceTask } from '../../composables/useMaintenanceTask.js'
|
||||||
import { toast } from '../../utils/toast.js'
|
|
||||||
import QueueStatusBar from './QueueStatusBar.vue'
|
import QueueStatusBar from './QueueStatusBar.vue'
|
||||||
|
|
||||||
const api = useApi()
|
|
||||||
const previewing = ref(false)
|
|
||||||
const applying = ref(false)
|
|
||||||
const confirmOpen = ref(false)
|
const confirmOpen = ref(false)
|
||||||
const summary = ref(null)
|
|
||||||
const applied = ref(false)
|
// The re-walk hits every enabled Patreon feed and can run long — the lifecycle +
|
||||||
|
// resurface-after-navigation live in the shared composable. 300 polls × 2s ≈ 10m.
|
||||||
|
const { previewing, applying, summary, applied, preview, apply: applyTask } = useMaintenanceTask({
|
||||||
|
endpoint: '/api/admin/maintenance/purge-gated-previews',
|
||||||
|
storageKey: 'fc.maint.gatedPurge',
|
||||||
|
appliedToast: 'Gated-post previews removed',
|
||||||
|
maxPolls: 300,
|
||||||
|
})
|
||||||
|
|
||||||
const canApply = computed(() => !!summary.value && !applied.value && summary.value.matched > 0)
|
const canApply = computed(() => !!summary.value && !applied.value && summary.value.matched > 0)
|
||||||
const summaryType = computed(() => {
|
const summaryType = computed(() => {
|
||||||
@@ -123,51 +126,9 @@ function humanBytes (n) {
|
|||||||
return b + ' B'
|
return b + ' B'
|
||||||
}
|
}
|
||||||
|
|
||||||
// Poll the maintenance task result until ready. The re-walk hits every Patreon
|
// The confirm dialog gates the destructive apply; close it, then run.
|
||||||
// feed, so allow a generous window.
|
function apply () {
|
||||||
async function pollResult (taskId) {
|
|
||||||
for (let i = 0; i < 300; i++) {
|
|
||||||
await new Promise(r => setTimeout(r, 2000))
|
|
||||||
const r = await api.get(`/api/admin/maintenance/task-result/${taskId}`)
|
|
||||||
if (r.ready) {
|
|
||||||
if (!r.successful) throw new Error('the cleanup task failed — check the worker logs')
|
|
||||||
return r.result
|
|
||||||
}
|
|
||||||
}
|
|
||||||
throw new Error('timed out waiting for the cleanup task — check the task dashboard')
|
|
||||||
}
|
|
||||||
|
|
||||||
async function run (dryRun) {
|
|
||||||
const { task_id: taskId } = await api.post(
|
|
||||||
'/api/admin/maintenance/purge-gated-previews', { body: { dry_run: dryRun } },
|
|
||||||
)
|
|
||||||
return pollResult(taskId)
|
|
||||||
}
|
|
||||||
|
|
||||||
async function preview () {
|
|
||||||
previewing.value = true
|
|
||||||
applied.value = false
|
|
||||||
summary.value = null
|
|
||||||
try {
|
|
||||||
summary.value = await run(true)
|
|
||||||
} catch (e) {
|
|
||||||
toast({ text: e?.body?.detail || e?.message || 'Preview failed', type: 'error' })
|
|
||||||
} finally {
|
|
||||||
previewing.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function apply () {
|
|
||||||
confirmOpen.value = false
|
confirmOpen.value = false
|
||||||
applying.value = true
|
applyTask()
|
||||||
try {
|
|
||||||
summary.value = await run(false)
|
|
||||||
applied.value = true
|
|
||||||
toast({ text: 'Gated-post previews removed', type: 'success' })
|
|
||||||
} catch (e) {
|
|
||||||
toast({ text: e?.body?.detail || e?.message || 'Apply failed', type: 'error' })
|
|
||||||
} finally {
|
|
||||||
applying.value = false
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -49,7 +49,7 @@
|
|||||||
<span v-else>No duplicate videos found.</span>
|
<span v-else>No duplicate videos found.</span>
|
||||||
</v-alert>
|
</v-alert>
|
||||||
|
|
||||||
<QueueStatusBar queue="maintenance" queue-label="Maintenance" />
|
<QueueStatusBar queue="maintenance_long" queue-label="Maintenance" />
|
||||||
</v-card-text>
|
</v-card-text>
|
||||||
|
|
||||||
<v-dialog v-model="confirmOpen" max-width="440">
|
<v-dialog v-model="confirmOpen" max-width="440">
|
||||||
@@ -74,16 +74,19 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { computed, ref } from 'vue'
|
import { computed, ref } from 'vue'
|
||||||
|
|
||||||
import { useApi } from '../../composables/useApi.js'
|
import { useMaintenanceTask } from '../../composables/useMaintenanceTask.js'
|
||||||
import { toast } from '../../utils/toast.js'
|
|
||||||
import QueueStatusBar from './QueueStatusBar.vue'
|
import QueueStatusBar from './QueueStatusBar.vue'
|
||||||
|
|
||||||
const api = useApi()
|
|
||||||
const previewing = ref(false)
|
|
||||||
const applying = ref(false)
|
|
||||||
const confirmOpen = ref(false)
|
const confirmOpen = ref(false)
|
||||||
const summary = ref(null)
|
|
||||||
const applied = ref(false)
|
// Long task (first run re-probes every NULL-duration video) — the lifecycle +
|
||||||
|
// resurface-after-navigation live in the shared composable. 150 polls × 2s ≈ 5m.
|
||||||
|
const { previewing, applying, summary, applied, preview, apply: applyTask } = useMaintenanceTask({
|
||||||
|
endpoint: '/api/admin/maintenance/dedup-videos',
|
||||||
|
storageKey: 'fc.maint.dedupVideos',
|
||||||
|
appliedToast: 'Duplicate videos removed',
|
||||||
|
maxPolls: 150,
|
||||||
|
})
|
||||||
|
|
||||||
const canApply = computed(() => !!summary.value && !applied.value && summary.value.redundant > 0)
|
const canApply = computed(() => !!summary.value && !applied.value && summary.value.redundant > 0)
|
||||||
const summaryType = computed(() => {
|
const summaryType = computed(() => {
|
||||||
@@ -99,51 +102,9 @@ function humanBytes (n) {
|
|||||||
return b + ' B'
|
return b + ' B'
|
||||||
}
|
}
|
||||||
|
|
||||||
// Poll the maintenance task result until ready (or give up). The dedup task can
|
// The confirm dialog gates the destructive apply; close it, then run.
|
||||||
// re-probe the whole video library on its first run, so allow a generous window.
|
function apply () {
|
||||||
async function pollResult (taskId) {
|
|
||||||
for (let i = 0; i < 150; i++) {
|
|
||||||
await new Promise(r => setTimeout(r, 2000))
|
|
||||||
const r = await api.get(`/api/admin/maintenance/task-result/${taskId}`)
|
|
||||||
if (r.ready) {
|
|
||||||
if (!r.successful) throw new Error('the dedup task failed — check the worker logs')
|
|
||||||
return r.result
|
|
||||||
}
|
|
||||||
}
|
|
||||||
throw new Error('timed out waiting for the dedup task — check the task dashboard')
|
|
||||||
}
|
|
||||||
|
|
||||||
async function run (dryRun) {
|
|
||||||
const { task_id: taskId } = await api.post(
|
|
||||||
'/api/admin/maintenance/dedup-videos', { body: { dry_run: dryRun } },
|
|
||||||
)
|
|
||||||
return pollResult(taskId)
|
|
||||||
}
|
|
||||||
|
|
||||||
async function preview () {
|
|
||||||
previewing.value = true
|
|
||||||
applied.value = false
|
|
||||||
summary.value = null
|
|
||||||
try {
|
|
||||||
summary.value = await run(true)
|
|
||||||
} catch (e) {
|
|
||||||
toast({ text: e?.body?.detail || e?.message || 'Preview failed', type: 'error' })
|
|
||||||
} finally {
|
|
||||||
previewing.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function apply () {
|
|
||||||
confirmOpen.value = false
|
confirmOpen.value = false
|
||||||
applying.value = true
|
applyTask()
|
||||||
try {
|
|
||||||
summary.value = await run(false)
|
|
||||||
applied.value = true
|
|
||||||
toast({ text: 'Duplicate videos removed', type: 'success' })
|
|
||||||
} catch (e) {
|
|
||||||
toast({ text: e?.body?.detail || e?.message || 'Apply failed', type: 'error' })
|
|
||||||
} finally {
|
|
||||||
applying.value = false
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -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 }
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user