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
@@ -1,5 +1,7 @@
<template>
<div>
<SchedulerStatusBar :status="store.scheduleStatus" class="fc-subs__sched" />
<div class="fc-subs__bar">
<v-btn color="accent" prepend-icon="mdi-plus" @click="openAddSource(null)">
Add subscription
@@ -197,7 +199,7 @@
</template>
<script setup>
import { computed, onMounted, ref, watch } from 'vue'
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useSourcesStore } from '../../stores/sources.js'
import { usePlatformsStore } from '../../stores/platforms.js'
@@ -207,6 +209,7 @@ import SourceHealthDot from './SourceHealthDot.vue'
import SourceFormDialog from './SourceFormDialog.vue'
import ArtistCreateDialog from './ArtistCreateDialog.vue'
import PlatformChip from './PlatformChip.vue'
import SchedulerStatusBar from './SchedulerStatusBar.vue'
const ITEMS_PER_PAGE_OPTIONS = [
{ value: 25, title: '25' },
@@ -250,14 +253,24 @@ const failureThreshold = computed(() =>
async function refresh() {
await store.loadAll()
await platformsStore.loadAll()
store.loadScheduleStatus()
if (!importStore.settings) await importStore.loadSettings()
}
// Re-poll scheduler status every 30s so the "last ran" reading + health
// dot stay honest (the Beat tick fires every 60s).
let schedId = null
onMounted(() => {
refresh()
if (artistFilter.value != null) {
expanded.value = [`artist-${artistFilter.value}`]
}
schedId = setInterval(() => {
if (!document.hidden) store.loadScheduleStatus()
}, 30000)
})
onUnmounted(() => {
if (schedId) { clearInterval(schedId); schedId = null }
})
watch(() => route.query.artist_id, refresh)
@@ -500,6 +513,7 @@ async function bulkDelete() {
</script>
<style scoped>
.fc-subs__sched { margin-bottom: 10px; }
.fc-subs__bar {
display: flex; gap: 0.75rem; align-items: center;
flex-wrap: wrap;