77e9859da3
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>
430 lines
14 KiB
Vue
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>
|