Files
FabledCurator/frontend/src/components/subscriptions/DownloadsTab.vue
T
bvandeusen 77e9859da3 fix(downloads): rewire MaintenanceMenu to the downloads pipeline
The maintenance dropdown in Subscriptions → Downloads was wired to the
filesystem-import pipeline (POST /api/import/retry-failed +
POST /api/import/clear-stuck) — the subtitles even said so ("Re-enqueue
every failed import task"), but it was contextually misplaced. From the
Downloads view "Retry failed" queued nothing the operator could see
because the action operated on import_task rows, not download_event
rows. Import-pipeline maintenance is already reachable from Settings →
Imports (ImportTaskList.vue), so removing the import wiring loses
nothing.

Rewired:
- "Retry failed" → bulk-retries the failing-sources list, same loop as
  FailingSourcesCard's RETRY ALL (sourcesStore.checkNow per source).
  Subtitle now matches: "Re-queue every currently failing source".
- "Force recovery sweep" → triggers recover_stalled_download_events on
  demand via a new POST /api/downloads/recover-stalled endpoint. The
  sweep also runs every 5 min on Beat; this is the manual fallback so
  the operator doesn't have to wait for the next tick to clear newly
  stranded events.

MaintenanceMenu is now stateless — emits retry-failed and recover-
stalled. DownloadsTab owns the handlers (reuses the existing
onRetryAll; new onRecoverStalled with a delayed refresh so swept rows
land in the failing rollup).

Operator-flagged 2026-05-29 — "the retry failed button in the
maintenance dropdown doesn't appear to queue anything but manual
requeues works."

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-30 00:59:05 -04:00

430 lines
14 KiB
Vue

<template>
<div>
<div class="fc-dl__sticky">
<div class="fc-dl__top">
<DownloadStatChips
:stats="store.stats"
:active-status="filterModel.status"
@select="onStatusChipSelect"
/>
<span v-if="liveActive" class="fc-dl__live" title="Auto-refreshing while downloads are active">
<span class="fc-dl__live-dot" />
live
</span>
<v-spacer />
<DownloadActivitySparkline
v-if="store.activity.buckets.length"
:buckets="store.activity.buckets"
:hours="store.activity.hours"
class="fc-dl__spark"
/>
<v-btn variant="text" icon @click="refresh">
<v-icon>mdi-refresh</v-icon>
<v-tooltip activator="parent" location="top">Refresh</v-tooltip>
</v-btn>
<MaintenanceMenu
@retry-failed="onRetryAll(store.failing)"
@recover-stalled="onRecoverStalled"
/>
</div>
<div class="fc-dl__controls">
<v-text-field
v-model="search"
density="compact" variant="outlined" hide-details clearable
prepend-inner-icon="mdi-magnify"
placeholder="Search artist / platform / error"
style="max-width: 340px"
/>
<DownloadsFilterPopover v-model="filterModel" />
<v-switch
v-model="showNoChange"
label="Show no-change scans"
density="compact" hide-details color="accent"
class="fc-dl__nochange"
/>
</div>
</div>
<ActiveDownloadsPanel />
<v-alert v-if="store.error" type="error" variant="tonal" closable class="my-4">
{{ String(store.error) }}
</v-alert>
<FailingSourcesCard
:sources="store.failing"
:retrying-ids="sourcesStore.checkingIds"
:retrying-all="retryingAll"
@retry="onRetrySource"
@retry-all="onRetryAll"
/>
<div v-if="store.loading && store.events.length === 0" class="fc-dl__loading">
<v-progress-circular indeterminate color="accent" size="36" />
</div>
<div v-else-if="filteredEvents.length === 0" class="fc-dl__empty">
<p>No download events match the current filter.</p>
</div>
<div v-else>
<section
v-for="g in groups" :key="g.key"
class="fc-dl__group"
>
<header
class="fc-dl__group-head"
role="button" tabindex="0"
@click="toggle(g.key)" @keydown.enter="toggle(g.key)"
>
<v-icon size="small" class="fc-dl__group-chev">
{{ collapsed[g.key] ? 'mdi-chevron-right' : 'mdi-chevron-down' }}
</v-icon>
<span class="fc-dl__group-label">{{ g.label }}</span>
<span class="fc-dl__group-counts">
<v-chip
v-if="g.failedCount > 0"
size="x-small" color="error" variant="tonal"
prepend-icon="mdi-alert-circle"
>{{ g.failedCount }}</v-chip>
<v-chip size="x-small" variant="tonal">
{{ g.items.length }}
{{ g.items.length === 1 ? 'event' : 'events' }}
</v-chip>
</span>
</header>
<div v-if="!collapsed[g.key]" class="fc-dl__group-body">
<DownloadEventRow
v-for="e in g.items" :key="e.id" :event="e"
@open="openDetail"
/>
</div>
</section>
<div class="fc-dl__sentinel">
<v-btn v-if="store.hasMore" variant="text" @click="store.loadMore()" :loading="store.loading">
Load more
</v-btn>
<span v-else class="text-caption" style="opacity: 0.5">No more events.</span>
</div>
</div>
<DownloadDetailModal
:event="store.selected"
@close="store.closeDetail()"
/>
</div>
</template>
<script setup>
import { toast } from '../../utils/toast.js'
import { computed, onMounted, onUnmounted, reactive, ref, watch } from 'vue'
import { useRoute } from 'vue-router'
import { useDownloadsStore } from '../../stores/downloads.js'
import { useSourcesStore } from '../../stores/sources.js'
import DownloadEventRow from '../downloads/DownloadEventRow.vue'
import DownloadDetailModal from '../downloads/DownloadDetailModal.vue'
import DownloadStatChips from './DownloadStatChips.vue'
import DownloadActivitySparkline from './DownloadActivitySparkline.vue'
import FailingSourcesCard from './FailingSourcesCard.vue'
import ActiveDownloadsPanel from './ActiveDownloadsPanel.vue'
import MaintenanceMenu from './MaintenanceMenu.vue'
import DownloadsFilterPopover from './DownloadsFilterPopover.vue'
const route = useRoute()
const store = useDownloadsStore()
const sourcesStore = useSourcesStore()
const filterModel = ref({ ...store.filter })
const retryingAll = ref(false)
// Free-text search (client-side over the loaded events) + a toggle to
// hide "no-change" scheduled scans (status=ok/skipped with 0 files) so
// real downloads + failures stand out. Operator-flagged 2026-05-28.
const search = ref('')
const showNoChange = ref(false)
// Clicking a stat chip toggles a status filter (re-click clears it).
function onStatusChipSelect(statusKey) {
filterModel.value = { ...filterModel.value, status: statusKey }
}
// Each group's collapsed state persists across refreshes for the
// lifetime of the SubscriptionsView (operator-friendly default: all
// expanded; collapse what you don't care about right now).
const collapsed = reactive({
today: false, yesterday: false, week: false, earlier: false,
})
function toggle(key) {
collapsed[key] = !collapsed[key]
}
async function refresh() {
await Promise.all([
store.loadFirst(),
store.loadStats(24),
store.loadActivity(24),
store.loadFailing(),
store.loadActive(),
])
}
// Retry a single failing source (re-runs its whole feed) then refresh the
// rollup + stats so the operator sees it move.
async function onRetrySource(source) {
try {
await sourcesStore.checkNow(source.id)
toast({ text: `Retry queued for ${source.artist_name || source.platform}`, type: 'success' })
} catch (e) {
if (e?.body?.download_event_id) {
toast({ text: 'Already running — see below', type: 'info' })
} else {
toast({ text: `Retry failed: ${e?.detail || e?.message || e}`, type: 'error' })
}
} finally {
await Promise.all([store.loadFailing(), store.loadStats(24), store.loadFirst()])
}
}
async function onRetryAll(sources) {
retryingAll.value = true
let ok = 0
let conflict = 0
try {
for (const s of sources) {
try {
await sourcesStore.checkNow(s.id)
ok += 1
} catch (e) {
if (e?.body?.download_event_id) conflict += 1
}
}
} finally {
retryingAll.value = false
await Promise.all([store.loadFailing(), store.loadStats(24), store.loadFirst()])
}
const parts = []
if (ok) parts.push(`${ok} queued`)
if (conflict) parts.push(`${conflict} already running`)
toast({ text: parts.join(', ') || 'Nothing to retry', type: 'info' })
}
// Manual trigger for recover_stalled_download_events. The sweep runs on
// Beat every 5 min; this lets the operator force-clear stuck pending/
// running events on demand. Fire-and-forget: the API returns 202 once the
// task is dispatched, and we refresh after a few seconds so swept rows
// show up in the failing rollup.
async function onRecoverStalled() {
try {
await store.recoverStalled()
toast({
text: 'Recovery sweep queued — refreshing in a few seconds',
type: 'success',
})
setTimeout(refresh, 4000)
} catch (e) {
toast({ text: `Sweep failed: ${e?.detail || e?.message || e}`, type: 'error' })
}
}
// Live auto-refresh: while any download is queued or running, poll the
// stats + first page every 4s so the operator can watch events succeed/
// fail in real time without hitting Refresh. Polling stops automatically
// when the queue drains (nothing pending/running), and is paused while
// the tab is backgrounded. Operator-flagged 2026-05-28: couldn't tell if
// downloads were succeeding because the view was static.
const POLL_MS = 4000
const liveActive = computed(
() => (store.stats.pending || 0) + (store.stats.running || 0) > 0,
)
let pollId = null
function startPolling() {
if (pollId) return
pollId = setInterval(async () => {
if (document.hidden) return
// Refresh stats + sparkline + active panel every tick (so new runs
// surface within 4s even from idle); only reload the full event list +
// failing rollup when there's active work (keeps idle ticks light).
await Promise.all([store.loadStats(24), store.loadActivity(24), store.loadActive()])
if (liveActive.value) await Promise.all([store.loadFirst(), store.loadFailing()])
}, POLL_MS)
}
function stopPolling() {
if (pollId) { clearInterval(pollId); pollId = null }
}
onMounted(() => {
if (route.query.source_id) {
filterModel.value = { ...filterModel.value, source_id: Number(route.query.source_id) }
}
refresh()
startPolling()
})
onUnmounted(stopPolling)
// Client-side date filter on the loaded page (avoids a backend round-trip
// for the date pickers; the existing /api/downloads endpoint can grow
// these as proper query params later if a UX need shows up).
const filteredEvents = computed(() => {
let arr = store.events
const from = filterModel.value.from_date
const to = filterModel.value.to_date
if (from) {
const fromTs = new Date(from).getTime()
arr = arr.filter((e) => new Date(e.started_at).getTime() >= fromTs)
}
if (to) {
const toTs = new Date(to).getTime() + 24 * 3600 * 1000 - 1
arr = arr.filter((e) => new Date(e.started_at).getTime() <= toTs)
}
// Hide no-change scheduled scans (succeeded/skipped, downloaded
// nothing) unless the operator opts to show them.
if (!showNoChange.value) {
arr = arr.filter(
(e) => !((e.status === 'ok' || e.status === 'skipped') && !(e.files_count > 0)),
)
}
const q = search.value?.trim().toLowerCase()
if (q) {
arr = arr.filter((e) =>
(e.artist_name || '').toLowerCase().includes(q)
|| (e.platform || '').toLowerCase().includes(q)
|| (e.error || '').toLowerCase().includes(q),
)
}
return arr
})
// Group events by relative date bucket and pin failed runs to the
// top of each bucket. Buckets boundaries are computed against the
// operator's local-time start-of-day so "Today" matches their
// intuition regardless of the event's stored UTC timestamp.
const groups = computed(() => {
const now = new Date()
const startOfToday = new Date(
now.getFullYear(), now.getMonth(), now.getDate(),
).getTime()
const startOfYesterday = startOfToday - 24 * 3600 * 1000
const startOfWeek = startOfToday - 7 * 24 * 3600 * 1000
const buckets = { today: [], yesterday: [], week: [], earlier: [] }
for (const e of filteredEvents.value) {
const t = new Date(e.started_at).getTime()
if (t >= startOfToday) buckets.today.push(e)
else if (t >= startOfYesterday) buckets.yesterday.push(e)
else if (t >= startOfWeek) buckets.week.push(e)
else buckets.earlier.push(e)
}
function withFailedPinned(items) {
const fail = items.filter((e) => e.status === 'error')
const rest = items.filter((e) => e.status !== 'error')
return [...fail, ...rest]
}
const meta = [
{ key: 'today', label: 'Today' },
{ key: 'yesterday', label: 'Yesterday' },
{ key: 'week', label: 'Last 7 days' },
{ key: 'earlier', label: 'Earlier' },
]
return meta
.map(({ key, label }) => {
const items = withFailedPinned(buckets[key])
const failedCount = items.filter((e) => e.status === 'error').length
return { key, label, items, failedCount }
})
.filter((g) => g.items.length > 0)
})
watch(filterModel, async (m) => {
await store.applyFilter({
status: m.status,
source_id: m.source_id || null,
artist_id: m.artist_id || null,
from_date: m.from_date,
to_date: m.to_date,
})
await store.loadStats(24)
}, { deep: true })
async function openDetail(id) {
await store.loadOne(id)
}
</script>
<style scoped>
/* Sticky control header: stat chips + search + filter + toggle stay
pinned below the top nav while the event feed scrolls. Opaque
background so rows don't bleed through. */
.fc-dl__sticky {
position: sticky;
top: 0;
z-index: 3;
background: rgb(var(--v-theme-background));
padding: 8px 0 10px;
margin-bottom: 4px;
border-bottom: 1px solid rgb(var(--v-theme-on-surface-variant) / 0.12);
}
.fc-dl__top {
display: flex; gap: 8px; align-items: center;
margin-bottom: 10px;
flex-wrap: wrap;
}
.fc-dl__controls {
display: flex; gap: 12px; align-items: center; flex-wrap: wrap;
}
.fc-dl__nochange { flex: 0 0 auto; }
.fc-dl__spark { flex: 0 0 auto; margin-right: 4px; max-width: 240px; }
.fc-dl__live {
display: inline-flex; align-items: center; gap: 5px;
font-size: 0.72rem; text-transform: uppercase; letter-spacing: 0.06em;
color: rgb(var(--v-theme-info));
}
.fc-dl__live-dot {
width: 7px; height: 7px; border-radius: 50%;
background: rgb(var(--v-theme-info));
animation: fc-dl-pulse 1.4s ease-in-out infinite;
}
@keyframes fc-dl-pulse {
0%, 100% { opacity: 1; transform: scale(1); }
50% { opacity: 0.35; transform: scale(0.7); }
}
@media (prefers-reduced-motion: reduce) {
.fc-dl__live-dot { animation: none; }
}
.fc-dl__loading, .fc-dl__empty {
display: flex; justify-content: center; padding: 3rem 0;
color: rgb(var(--v-theme-on-surface-variant));
}
.fc-dl__sentinel {
display: flex; justify-content: center; padding: 1rem 0;
}
.fc-dl__group { margin-bottom: 12px; }
.fc-dl__group-head {
display: flex; align-items: center; gap: 8px;
padding: 6px 8px;
background: rgb(var(--v-theme-on-surface) / 0.04);
border-radius: 4px;
cursor: pointer;
user-select: none;
}
.fc-dl__group-head:hover {
background: rgb(var(--v-theme-on-surface) / 0.08);
}
.fc-dl__group-chev {
color: rgb(var(--v-theme-on-surface-variant));
}
.fc-dl__group-label {
font-weight: 600;
color: rgb(var(--v-theme-on-surface));
flex: 1;
}
.fc-dl__group-counts {
display: flex; gap: 6px; align-items: center;
}
.fc-dl__group-body { margin-top: 4px; }
</style>