fix(maint): resurface dedup/gated-purge results after navigate-away (#877)
CI / lint (push) Successful in 4s
CI / frontend-build (push) Successful in 21s
CI / backend-lint-and-test (push) Successful in 28s
CI / integration (push) Successful in 3m16s

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:
2026-06-16 16:38:32 -04:00
parent 540151290b
commit e287802ecb
3 changed files with 146 additions and 106 deletions
@@ -73,7 +73,7 @@
</template>
</v-alert>
<QueueStatusBar queue="maintenance" queue-label="Maintenance" />
<QueueStatusBar queue="maintenance_long" queue-label="Maintenance" />
</v-card-text>
<v-dialog v-model="confirmOpen" max-width="460">
@@ -98,16 +98,19 @@
<script setup>
import { computed, ref } from 'vue'
import { useApi } from '../../composables/useApi.js'
import { toast } from '../../utils/toast.js'
import { useMaintenanceTask } from '../../composables/useMaintenanceTask.js'
import QueueStatusBar from './QueueStatusBar.vue'
const api = useApi()
const previewing = ref(false)
const applying = 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 summaryType = computed(() => {
@@ -123,51 +126,9 @@ function humanBytes (n) {
return b + ' B'
}
// Poll the maintenance task result until ready. The re-walk hits every Patreon
// feed, so allow a generous window.
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 () {
// The confirm dialog gates the destructive apply; close it, then run.
function apply () {
confirmOpen.value = false
applying.value = true
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
}
applyTask()
}
</script>
@@ -49,7 +49,7 @@
<span v-else>No duplicate videos found.</span>
</v-alert>
<QueueStatusBar queue="maintenance" queue-label="Maintenance" />
<QueueStatusBar queue="maintenance_long" queue-label="Maintenance" />
</v-card-text>
<v-dialog v-model="confirmOpen" max-width="440">
@@ -74,16 +74,19 @@
<script setup>
import { computed, ref } from 'vue'
import { useApi } from '../../composables/useApi.js'
import { toast } from '../../utils/toast.js'
import { useMaintenanceTask } from '../../composables/useMaintenanceTask.js'
import QueueStatusBar from './QueueStatusBar.vue'
const api = useApi()
const previewing = ref(false)
const applying = 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 summaryType = computed(() => {
@@ -99,51 +102,9 @@ function humanBytes (n) {
return b + ' B'
}
// Poll the maintenance task result until ready (or give up). The dedup task can
// re-probe the whole video library on its first run, so allow a generous window.
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 () {
// The confirm dialog gates the destructive apply; close it, then run.
function apply () {
confirmOpen.value = false
applying.value = true
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
}
applyTask()
}
</script>