b6a917ac81
Operator hit 3 large PNGs stuck in 'processing' for 2 days 2026-05-25:
the existing recover_interrupted_tasks flips processing > 5min back to
queued + .delay(), but if the underlying file is unfixably broken (e.g.,
PIL OSError, also patched in 68cffce), the loop never terminates and the
'Scanning...' banner sticks at 0/0 forever blocking new scans.
/api/import/clear-stuck:
- Flips every task in pending/queued/processing to 'failed' with a clear
marker error message
- Finalizes any 'running' ImportBatch that has no remaining active children
- Idempotent + non-destructive: rows survive, can be retried once the
underlying cause is resolved
UI button 'Clear stuck...' sits next to 'Retry failed' / 'Clear completed'
with a warning-tonal alert in the confirm dialog explaining what it does
and recommending Retry failed once the cause is fixed.
Tests: clears mixed non-terminal states, untouches complete rows,
finalizes orphan batch, no-op when nothing stuck.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
182 lines
6.3 KiB
Vue
182 lines
6.3 KiB
Vue
<template>
|
|
<v-card>
|
|
<v-card-title class="d-flex align-center" style="gap: 12px;">
|
|
<span>Recent import tasks</span>
|
|
<v-spacer />
|
|
<v-select
|
|
v-model="statusFilter" :items="statusOptions" density="compact"
|
|
hide-details style="max-width: 180px;" @update:model-value="onFilterChange"
|
|
/>
|
|
<v-btn variant="text" rounded="pill" size="small" @click="onRefresh">
|
|
<v-icon start>mdi-refresh</v-icon>
|
|
Refresh
|
|
</v-btn>
|
|
<v-btn
|
|
variant="text" rounded="pill" size="small" color="warning"
|
|
:disabled="!hasFailed" @click="onRetryFailed"
|
|
>
|
|
Retry failed
|
|
</v-btn>
|
|
<v-btn
|
|
variant="text" rounded="pill" size="small" color="warning"
|
|
:disabled="!hasStuck" @click="onClearStuckOpen"
|
|
>
|
|
Clear stuck…
|
|
</v-btn>
|
|
<v-btn
|
|
variant="text" rounded="pill" size="small" color="error"
|
|
@click="onClearOpen"
|
|
>
|
|
Clear completed…
|
|
</v-btn>
|
|
</v-card-title>
|
|
<v-data-table-virtual
|
|
:headers="headers" :items="store.tasks" :loading="store.tasksLoading"
|
|
height="480" density="compact" no-data-text="No tasks yet — trigger a scan above."
|
|
>
|
|
<template #item.status="{ item }">
|
|
<v-chip :color="statusColor(item.status)" size="small" variant="tonal">
|
|
{{ item.status }}
|
|
</v-chip>
|
|
</template>
|
|
<template #item.source_path="{ item }">
|
|
<span :title="item.source_path">{{ shorten(item.source_path) }}</span>
|
|
</template>
|
|
<template #item.size_bytes="{ item }">{{ formatBytes(item.size_bytes) }}</template>
|
|
<template #item.created_at="{ item }">{{ formatDate(item.created_at) }}</template>
|
|
<template #item.error="{ item }">
|
|
<span v-if="item.error" :title="item.error" class="text-caption">
|
|
{{ shorten(item.error, 60) }}
|
|
</span>
|
|
</template>
|
|
</v-data-table-virtual>
|
|
<div v-if="store.hasMore" class="d-flex justify-center py-3">
|
|
<v-btn variant="text" size="small" @click="onLoadMore">Load more</v-btn>
|
|
</div>
|
|
|
|
<v-dialog v-model="clearDialog" max-width="400">
|
|
<v-card>
|
|
<v-card-title>Clear completed tasks</v-card-title>
|
|
<v-card-text>
|
|
<v-select
|
|
v-model="clearAgeDays" label="Older than"
|
|
:items="[
|
|
{ title: 'All finished', value: 0 },
|
|
{ title: '1 day', value: 1 },
|
|
{ title: '7 days', value: 7 },
|
|
{ title: '30 days', value: 30 }
|
|
]"
|
|
/>
|
|
</v-card-text>
|
|
<v-card-actions>
|
|
<v-spacer />
|
|
<v-btn @click="clearDialog = false">Cancel</v-btn>
|
|
<v-btn color="error" rounded="pill" @click="onClearConfirm">Clear</v-btn>
|
|
</v-card-actions>
|
|
</v-card>
|
|
</v-dialog>
|
|
|
|
<v-dialog v-model="clearStuckDialog" max-width="480">
|
|
<v-card>
|
|
<v-card-title>Clear stuck tasks</v-card-title>
|
|
<v-card-text>
|
|
<v-alert type="warning" variant="tonal" density="compact" class="mb-3">
|
|
Force every <strong>pending / queued / processing</strong> task to
|
|
<strong>failed</strong> and finalize any active batch that
|
|
has no remaining work. Use this when the automatic recovery
|
|
sweep keeps re-queueing the same row (e.g., corrupt file in
|
|
an autoretry loop, or worker model missing).
|
|
</v-alert>
|
|
<p class="text-body-2">
|
|
Tasks remain in the database with status=<code>failed</code>;
|
|
click <em>Retry failed</em> once the underlying cause is
|
|
resolved to re-queue them.
|
|
</p>
|
|
</v-card-text>
|
|
<v-card-actions>
|
|
<v-spacer />
|
|
<v-btn @click="clearStuckDialog = false">Cancel</v-btn>
|
|
<v-btn color="warning" rounded="pill" @click="onClearStuckConfirm">Clear stuck</v-btn>
|
|
</v-card-actions>
|
|
</v-card>
|
|
</v-dialog>
|
|
</v-card>
|
|
</template>
|
|
|
|
<script setup>
|
|
import { computed, ref } from 'vue'
|
|
import { useImportStore } from '../../stores/import.js'
|
|
|
|
const store = useImportStore()
|
|
const statusFilter = ref(null)
|
|
const clearDialog = ref(false)
|
|
const clearAgeDays = ref(7)
|
|
const clearStuckDialog = ref(false)
|
|
|
|
const statusOptions = [
|
|
{ title: 'All', value: null },
|
|
{ title: 'Pending', value: 'pending' },
|
|
{ title: 'Queued', value: 'queued' },
|
|
{ title: 'Processing', value: 'processing' },
|
|
{ title: 'Complete', value: 'complete' },
|
|
{ title: 'Skipped', value: 'skipped' },
|
|
{ title: 'Failed', value: 'failed' }
|
|
]
|
|
|
|
const headers = [
|
|
{ title: 'Status', key: 'status', sortable: false, width: 120 },
|
|
{ title: 'Source', key: 'source_path', sortable: false },
|
|
{ title: 'Size', key: 'size_bytes', sortable: false, width: 90 },
|
|
{ title: 'Created', key: 'created_at', sortable: false, width: 150 },
|
|
{ title: 'Note', key: 'error', sortable: false }
|
|
]
|
|
|
|
const hasFailed = computed(() => store.tasks.some(t => t.status === 'failed'))
|
|
const hasStuck = computed(() => store.tasks.some(
|
|
t => t.status === 'pending' || t.status === 'queued' || t.status === 'processing'
|
|
))
|
|
|
|
function statusColor(s) {
|
|
return {
|
|
complete: 'success',
|
|
skipped: 'warning',
|
|
failed: 'error',
|
|
processing: 'accent',
|
|
queued: 'info',
|
|
pending: 'info'
|
|
}[s] || 'default'
|
|
}
|
|
function shorten(s, max = 90) {
|
|
if (!s) return ''
|
|
if (s.length <= max) return s
|
|
const head = Math.floor((max - 3) * 0.6)
|
|
const tail = max - 3 - head
|
|
return s.slice(0, head) + '...' + s.slice(-tail)
|
|
}
|
|
function formatBytes(b) {
|
|
if (!b) return ''
|
|
const units = ['B', 'KiB', 'MiB', 'GiB']
|
|
let i = 0; let v = b
|
|
while (v >= 1024 && i < units.length - 1) { v /= 1024; i++ }
|
|
return `${v.toFixed(i === 0 ? 0 : 1)} ${units[i]}`
|
|
}
|
|
function formatDate(s) {
|
|
try { return new Date(s).toLocaleString() } catch { return s }
|
|
}
|
|
|
|
async function onRefresh() { await store.loadTasks(true) }
|
|
function onFilterChange() { store.setStatusFilter(statusFilter.value); store.loadTasks(true) }
|
|
async function onLoadMore() { await store.loadTasks(false) }
|
|
async function onRetryFailed() { await store.retryFailed() }
|
|
function onClearOpen() { clearDialog.value = true }
|
|
async function onClearConfirm() {
|
|
await store.clearCompleted(clearAgeDays.value)
|
|
clearDialog.value = false
|
|
}
|
|
function onClearStuckOpen() { clearStuckDialog.value = true }
|
|
async function onClearStuckConfirm() {
|
|
await store.clearStuck()
|
|
clearStuckDialog.value = false
|
|
}
|
|
</script>
|