e287802ecb
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>
135 lines
5.3 KiB
Vue
135 lines
5.3 KiB
Vue
<template>
|
||
<!-- #874 follow-up: purge blurred locked-preview images grabbed from
|
||
tier-gated Patreon posts before the ingester fix. Re-walks each feed and
|
||
matches by content hash, so real content downloaded when access existed is
|
||
provably spared. Preview first, then apply (destructive: deletes the
|
||
matched blurred-preview files). -->
|
||
<v-card>
|
||
<v-card-title>Clean up gated-post previews</v-card-title>
|
||
<v-card-text>
|
||
<p class="text-body-2 mb-3">
|
||
Removes the blurred locked-preview images that were grabbed from
|
||
tier-gated Patreon posts before they were filtered out. It re-walks every
|
||
enabled Patreon source and matches by exact content hash, so anything you
|
||
downloaded while you <em>had</em> access is left untouched —
|
||
only the blurred previews are removed. <strong>Preview</strong> first to
|
||
see the count; <strong>Apply</strong> deletes the matched files and clears
|
||
their download ledger so the real media can re-download if you regain
|
||
access. The re-walk can take a while across many sources.
|
||
</p>
|
||
|
||
<div class="d-flex align-center flex-wrap" style="gap: 12px;">
|
||
<v-btn
|
||
color="primary" variant="tonal" rounded="pill"
|
||
:loading="previewing" :disabled="applying" @click="preview"
|
||
>
|
||
<v-icon start>mdi-magnify</v-icon> Preview
|
||
</v-btn>
|
||
<v-btn
|
||
color="error" rounded="pill"
|
||
:loading="applying"
|
||
:disabled="previewing || !canApply"
|
||
@click="confirmOpen = true"
|
||
>
|
||
<v-icon start>mdi-image-off</v-icon> Apply
|
||
</v-btn>
|
||
</div>
|
||
|
||
<v-alert
|
||
v-if="summary" :type="summaryType" variant="tonal" class="mt-4"
|
||
density="comfortable"
|
||
>
|
||
<span v-if="applied">
|
||
Removed {{ summary.deleted }} blurred preview image(s) across
|
||
{{ summary.gated_posts }} gated post(s); reclaimed
|
||
{{ humanBytes(summary.reclaim_bytes) }};
|
||
removed {{ summary.posts_deleted }} now-empty post(s).
|
||
<template v-if="summary.unverifiable > 0">
|
||
{{ summary.unverifiable }} image(s) couldn't be verified (no stored
|
||
source hash) and were kept.
|
||
</template>
|
||
</span>
|
||
<span v-else-if="summary.matched > 0">
|
||
{{ summary.matched }} blurred preview image(s) across
|
||
{{ summary.gated_posts }} gated post(s) —
|
||
{{ humanBytes(summary.reclaim_bytes) }} reclaimable. Click
|
||
<strong>Apply</strong> to delete them.
|
||
<template v-if="summary.unverifiable > 0">
|
||
({{ summary.unverifiable }} image(s) on gated posts have no stored
|
||
source hash, so they can't be verified and will be kept.)
|
||
</template>
|
||
</span>
|
||
<span v-else>
|
||
No blurred gated-post previews found
|
||
<template v-if="summary.gated_posts > 0">
|
||
across {{ summary.gated_posts }} gated post(s)</template>.
|
||
</span>
|
||
<template v-if="summary.partial">
|
||
<br>
|
||
<small>
|
||
Scanned {{ summary.sources_scanned }}/{{ summary.sources_total }}
|
||
sources before the time budget — run again to finish the rest.
|
||
</small>
|
||
</template>
|
||
</v-alert>
|
||
|
||
<QueueStatusBar queue="maintenance_long" queue-label="Maintenance" />
|
||
</v-card-text>
|
||
|
||
<v-dialog v-model="confirmOpen" max-width="460">
|
||
<v-card>
|
||
<v-card-title>Delete gated-post previews?</v-card-title>
|
||
<v-card-text class="text-body-2">
|
||
This permanently deletes
|
||
<strong>{{ summary?.matched ?? 0 }}</strong> blurred preview file(s)
|
||
matched by exact content hash. Real content you downloaded with access
|
||
has a different hash and is not affected.
|
||
</v-card-text>
|
||
<v-card-actions>
|
||
<v-spacer />
|
||
<v-btn variant="text" @click="confirmOpen = false">Cancel</v-btn>
|
||
<v-btn color="error" @click="apply">Delete previews</v-btn>
|
||
</v-card-actions>
|
||
</v-card>
|
||
</v-dialog>
|
||
</v-card>
|
||
</template>
|
||
|
||
<script setup>
|
||
import { computed, ref } from 'vue'
|
||
|
||
import { useMaintenanceTask } from '../../composables/useMaintenanceTask.js'
|
||
import QueueStatusBar from './QueueStatusBar.vue'
|
||
|
||
const confirmOpen = 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(() => {
|
||
if (applied.value) return 'success'
|
||
return summary.value && summary.value.matched > 0 ? 'info' : 'success'
|
||
})
|
||
|
||
function humanBytes (n) {
|
||
const b = Number(n || 0)
|
||
if (b >= 1 << 30) return (b / (1 << 30)).toFixed(1) + ' GB'
|
||
if (b >= 1 << 20) return (b / (1 << 20)).toFixed(1) + ' MB'
|
||
if (b >= 1 << 10) return (b / (1 << 10)).toFixed(1) + ' KB'
|
||
return b + ' B'
|
||
}
|
||
|
||
// The confirm dialog gates the destructive apply; close it, then run.
|
||
function apply () {
|
||
confirmOpen.value = false
|
||
applyTask()
|
||
}
|
||
</script>
|