feat(dashboards): scheduler health strip, failing-source rollup, 24h activity sparkline, credential staleness nudge

D1 scheduler visibility: AppSetting last-tick stamp on every Beat tick +
GET /api/sources/schedule-status (last_tick_at/next_due_at/due_now/auto_sources)
+ SchedulerStatusBar on the Subscriptions tab (re-polled every 30s).

D2 failing-source rollup: ?failing=true on the sources list + FailingSourcesCard
on Downloads with per-source and bulk "retry" (re-runs the feed via /check).

D3 activity sparkline: GET /api/downloads/activity hourly buckets + CSS bar
chart by the stat chips (failures stacked in error color); refreshes on live poll.

D4 credential staleness: surface last_verified age + "re-verify recommended"
warning past 30d; also fixes the dead last_verified_at field-name mismatch so
the verification row renders at all.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-28 08:30:14 -04:00
parent 215a8993a1
commit 2358cedf3e
15 changed files with 623 additions and 20 deletions
@@ -12,6 +12,12 @@
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>
@@ -41,6 +47,14 @@
{{ 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>
@@ -103,15 +117,20 @@ 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 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
@@ -138,9 +157,51 @@ async function refresh() {
await Promise.all([
store.loadFirst(),
store.loadStats(24),
store.loadActivity(24),
store.loadFailing(),
])
}
// 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)
globalThis.window?.__fcToast?.({ text: `Retry queued for ${source.artist_name || source.platform}`, type: 'success' })
} catch (e) {
if (e?.body?.download_event_id) {
globalThis.window?.__fcToast?.({ text: 'Already running — see below', type: 'info' })
} else {
globalThis.window?.__fcToast?.({ 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`)
globalThis.window?.__fcToast?.({ text: parts.join(', ') || 'Nothing to retry', type: 'info' })
}
// 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
@@ -156,10 +217,10 @@ function startPolling() {
if (pollId) return
pollId = setInterval(async () => {
if (document.hidden) return
// Refresh stats cheaply every tick; only reload the event list when
// there's active work to reflect (keeps idle ticks light).
await store.loadStats(24)
if (liveActive.value) await store.loadFirst()
// Refresh stats + sparkline cheaply every tick; only reload the event
// list + failing rollup when there's active work (keeps idle ticks light).
await Promise.all([store.loadStats(24), store.loadActivity(24)])
if (liveActive.value) await Promise.all([store.loadFirst(), store.loadFailing()])
}, POLL_MS)
}
function stopPolling() {
@@ -288,6 +349,7 @@ async function openDetail(id) {
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;