958378312c
Allowlist / Alias / ImportTask tables scroll their bodies (height=360/480) but the column headers scrolled away with the rows, so you lost the column labels (operator-flagged 2026-06-27). Add Vuetify `fixed-header` so the header row stays pinned while the body scrolls. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
255 lines
8.8 KiB
Vue
255 lines
8.8 KiB
Vue
<template>
|
|
<v-card>
|
|
<CardHeading title="Recent import tasks">
|
|
<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>
|
|
</CardHeading>
|
|
<v-data-table-virtual
|
|
:headers="headers" :items="store.tasks" :loading="store.tasksLoading"
|
|
height="480" density="compact" fixed-header 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 }">
|
|
<button
|
|
v-if="item.error" type="button" class="fc-err-link text-caption"
|
|
@click="openError(`Task ${item.id} failed`, item.error)"
|
|
title="Click for full error"
|
|
>{{ shorten(item.error, 60) }}</button>
|
|
</template>
|
|
<template #item.actions="{ item }">
|
|
<v-btn
|
|
v-if="item.status === 'failed'"
|
|
icon size="x-small" variant="text"
|
|
:loading="refetching === item.id"
|
|
@click="onRefetch(item)"
|
|
>
|
|
<v-icon size="small">mdi-cloud-refresh</v-icon>
|
|
<v-tooltip activator="parent" location="top">
|
|
Re-fetch original (re-download from source)
|
|
</v-tooltip>
|
|
</v-btn>
|
|
</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>
|
|
|
|
<ErrorDetailModal
|
|
v-model="showErrorModal"
|
|
:title="errorModalTitle"
|
|
:message="errorModalMessage"
|
|
/>
|
|
</v-card>
|
|
</template>
|
|
|
|
<script setup>
|
|
import { toast } from '../../utils/toast.js'
|
|
import { computed, ref } from 'vue'
|
|
import { useImportStore } from '../../stores/import.js'
|
|
import ErrorDetailModal from '../common/ErrorDetailModal.vue'
|
|
import CardHeading from '../common/CardHeading.vue'
|
|
|
|
const store = useImportStore()
|
|
const statusFilter = ref(null)
|
|
const clearDialog = ref(false)
|
|
// Click-to-open modal for full error text (operator-flagged 2026-05-26
|
|
// — the prior :title="..." tooltip cramped multi-line SQLAlchemy
|
|
// tracebacks into an unusable popup with no copy-paste affordance).
|
|
const showErrorModal = ref(false)
|
|
const errorModalTitle = ref('')
|
|
const errorModalMessage = ref('')
|
|
|
|
function openError(title, message) {
|
|
errorModalTitle.value = title
|
|
errorModalMessage.value = message || ''
|
|
showErrorModal.value = true
|
|
}
|
|
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 },
|
|
{ title: '', key: 'actions', sortable: false, width: 56 }
|
|
]
|
|
|
|
const refetching = ref(null)
|
|
const _REFETCH_MSG = {
|
|
refetch_queued: { text: 'Re-fetch queued — re-downloading from source', type: 'success' },
|
|
no_source: { text: 'No re-fetchable source (filesystem import — replace the file manually)', type: 'info' },
|
|
already_refetched: { text: 'Already re-fetched once', type: 'info' },
|
|
}
|
|
async function onRefetch(item) {
|
|
refetching.value = item.id
|
|
try {
|
|
const res = await store.refetchTask(item.id)
|
|
const msg = _REFETCH_MSG[res.status] || { text: `Re-fetch: ${res.status}`, type: 'info' }
|
|
toast(msg)
|
|
} catch (e) {
|
|
toast({ text: `Re-fetch failed: ${e.message}`, type: 'error' })
|
|
} finally {
|
|
refetching.value = null
|
|
}
|
|
}
|
|
|
|
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>
|
|
|
|
<style scoped>
|
|
.fc-err-link {
|
|
/* Truncated error preview as a clickable button — opens
|
|
ErrorDetailModal with the full text. Inherits the row's font
|
|
sizing so it doesn't visually drift from the prior tooltip-bearing
|
|
span. */
|
|
color: rgb(var(--v-theme-error, 220 80 80));
|
|
background: transparent;
|
|
border: 0;
|
|
padding: 0;
|
|
font: inherit;
|
|
text-align: left;
|
|
text-decoration: underline dotted;
|
|
cursor: pointer;
|
|
}
|
|
.fc-err-link:hover { text-decoration: underline; }
|
|
</style>
|