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
+48
View File
@@ -126,6 +126,54 @@ async def downloads_stats():
return jsonify(out)
@downloads_bp.route("/activity", methods=["GET"])
async def downloads_activity():
"""Hourly download-event counts over the last `?hours=` (default 24).
Returns a fixed-length, oldest-first bucket array so the UI can render
a sparkline directly. Bucketing is done in Python against UTC to dodge
session-timezone ambiguity in SQL date_trunc.
"""
try:
hours = int(request.args.get("hours", "24"))
except ValueError:
return jsonify({"error": "invalid_hours"}), 400
hours = max(1, min(168, hours))
now = datetime.now(UTC)
end = now.replace(minute=0, second=0, microsecond=0)
start = end - timedelta(hours=hours - 1)
buckets = [
{"hour": (start + timedelta(hours=i)).isoformat(),
"ok": 0, "error": 0, "other": 0, "total": 0}
for i in range(hours)
]
async with get_session() as session:
rows = (await session.execute(
select(DownloadEvent.started_at, DownloadEvent.status)
.where(DownloadEvent.started_at >= start)
)).all()
for started_at, status in rows:
if started_at is None:
continue
sa = started_at if started_at.tzinfo else started_at.replace(tzinfo=UTC)
idx = int((sa - start).total_seconds() // 3600)
if not (0 <= idx < hours):
continue
b = buckets[idx]
if status == "ok":
b["ok"] += 1
elif status == "error":
b["error"] += 1
else:
b["other"] += 1
b["total"] += 1
return jsonify({"hours": hours, "buckets": buckets})
@downloads_bp.route("/<int:event_id>", methods=["GET"])
async def get_download(event_id: int):
async with get_session() as session:
+10 -1
View File
@@ -5,6 +5,7 @@ from sqlalchemy import select
from ..extensions import get_session
from ..models import DownloadEvent, Source
from ..services.scheduler_service import scheduler_status
from ..services.source_service import (
KNOWN_PLATFORMS,
ArtistNotFoundError,
@@ -35,11 +36,19 @@ async def list_sources():
artist_id = int(artist_id_raw)
except ValueError:
return _bad("invalid_artist_id", detail="artist_id must be an integer")
failing = request.args.get("failing", "").lower() in ("1", "true", "yes")
async with get_session() as session:
records = await SourceService(session).list(artist_id=artist_id)
records = await SourceService(session).list(artist_id=artist_id, failing=failing)
return jsonify([r.to_dict() for r in records])
@sources_bp.route("/schedule-status", methods=["GET"])
async def schedule_status():
"""FC-dashboards: scheduler health for the Subscriptions hub."""
async with get_session() as session:
return jsonify(await scheduler_status(session))
@sources_bp.route("/<int:source_id>", methods=["GET"])
async def get_source(source_id: int):
async with get_session() as session:
+67 -1
View File
@@ -12,12 +12,17 @@ from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from ..models import Artist, ImportSettings, Source
from ..models import AppSetting, Artist, ImportSettings, Source
MIN_INTERVAL_SECONDS = 60
MAX_INTERVAL_SECONDS = 86400
MAX_BACKOFF_EXPONENT = 6
# AppSetting key stamped every time the Beat tick fires (see scan.py). The
# tick runs every 60s; the UI flags the scheduler as stalled if the last
# stamp is older than a few minutes.
SCHEDULER_LAST_TICK_KEY = "scheduler_last_tick_at"
def compute_effective_interval(
source: Source, artist: Artist, settings: ImportSettings,
@@ -78,3 +83,64 @@ def compute_next_check_at(
return None
interval = compute_effective_interval(source, artist, settings)
return source.last_checked_at + timedelta(seconds=interval)
async def record_tick(session: AsyncSession) -> None:
"""Stamp the current time on the SCHEDULER_LAST_TICK_KEY AppSetting.
Called once per Beat tick so the UI can prove the scheduler is alive.
Commits its own write so the stamp survives even if the rest of the
tick errors out.
"""
now_iso = datetime.now(UTC).isoformat()
row = (await session.execute(
select(AppSetting).where(AppSetting.key == SCHEDULER_LAST_TICK_KEY)
)).scalar_one_or_none()
if row is None:
session.add(AppSetting(key=SCHEDULER_LAST_TICK_KEY, value=now_iso))
else:
row.value = now_iso
await session.commit()
async def scheduler_status(session: AsyncSession) -> dict:
"""Summarise scheduler health for the dashboard.
Returns last_tick_at (when Beat last fired), next_due_at (earliest
upcoming scheduled check across enabled auto-check sources), due_now
(how many are due right now), and auto_sources (total under schedule).
"""
last_tick_at = (await session.execute(
select(AppSetting.value).where(AppSetting.key == SCHEDULER_LAST_TICK_KEY)
)).scalar_one_or_none()
rows = (await session.execute(
select(Source)
.options(selectinload(Source.artist))
.join(Artist, Source.artist_id == Artist.id)
.where(Source.enabled.is_(True))
.where(Artist.auto_check.is_(True))
)).scalars().all()
settings = (await session.execute(
select(ImportSettings).where(ImportSettings.id == 1)
)).scalar_one()
now = datetime.now(UTC)
due_now = 0
next_due_at: datetime | None = None
for s in rows:
if s.last_checked_at is None:
due_now += 1
continue
nca = compute_next_check_at(s, s.artist, settings)
if nca is None or nca <= now:
due_now += 1
elif next_due_at is None or nca < next_due_at:
next_due_at = nca
return {
"last_tick_at": last_tick_at,
"next_due_at": next_due_at.isoformat() if next_due_at else None,
"due_now": due_now,
"auto_sources": len(rows),
}
+11 -6
View File
@@ -151,14 +151,19 @@ class SourceService:
settings = await self._load_settings()
return self._build_record(source, artist, settings)
async def list(self, artist_id: int | None = None) -> list[SourceRecord]:
stmt = (
select(Source, Artist)
.join(Artist, Artist.id == Source.artist_id)
.order_by(Artist.name.asc(), Source.id.asc())
)
async def list(
self, artist_id: int | None = None, failing: bool = False,
) -> list[SourceRecord]:
stmt = select(Source, Artist).join(Artist, Artist.id == Source.artist_id)
if artist_id is not None:
stmt = stmt.where(Source.artist_id == artist_id)
if failing:
# Worst-first so the rollup card surfaces the loudest failures.
stmt = stmt.where(Source.consecutive_failures > 0).order_by(
Source.consecutive_failures.desc(), Artist.name.asc(),
)
else:
stmt = stmt.order_by(Artist.name.asc(), Source.id.asc())
rows = (await self.session.execute(stmt)).all()
settings = await self._load_settings()
return [self._build_record(s, a, settings) for s, a in rows]
+3 -1
View File
@@ -18,7 +18,7 @@ from ..celery_app import celery
from ..config import get_config
from ..models import DownloadEvent, ImportBatch, ImportSettings, ImportTask
from ..services.archive_extractor import is_archive
from ..services.scheduler_service import select_due_sources
from ..services.scheduler_service import record_tick, select_due_sources
from ._sync_engine import sync_session_factory as _sync_session_factory
@@ -151,6 +151,8 @@ async def _tick_due_sources_async() -> dict:
factory, engine = _async_session_factory()
try:
async with factory() as session:
# Prove the scheduler is alive even on empty ticks (UI reads this).
await record_tick(session)
due = await select_due_sources(session)
if not due:
return {
@@ -24,9 +24,17 @@
</v-icon>
<span>Expires {{ fmtDate(credential.expires_at) }}</span>
</div>
<div v-if="credential.last_verified_at" class="fc-cred-card__row">
<v-icon size="small" color="on-surface-variant">mdi-shield-check</v-icon>
<span>Last verified {{ fmtDate(credential.last_verified_at) }}</span>
<div v-if="credential.last_verified" class="fc-cred-card__row">
<v-icon size="small" :color="verifyStale ? 'warning' : 'success'">
{{ verifyStale ? 'mdi-shield-alert' : 'mdi-shield-check' }}
</v-icon>
<span :class="verifyStale ? 'text-warning' : undefined">
Last verified {{ verifiedRelative }}<template v-if="verifyStale"> re-verify recommended</template>
</span>
</div>
<div v-else-if="!freshlyVerified" class="fc-cred-card__row">
<v-icon size="small" color="warning">mdi-shield-alert</v-icon>
<span class="text-warning">Never verified click Verify to confirm</span>
</div>
</template>
<template v-else>
@@ -139,14 +147,42 @@ const expiringSoon = computed(() => {
return diff > 0 && diff < 7
})
// Verification staleness: a stored cookie/token that hasn't been confirmed
// in a month is the silent-failure trap (subscribestar/HF). Nudge the
// operator to re-verify before a scheduled download dies on bad auth.
const STALE_DAYS = 30
// A successful Verify in this session clears the warning even before the
// parent reloads the credential list (last_verified prop is still old).
const freshlyVerified = computed(() => verifyResult.value?.valid === true)
const verifiedAgeDays = computed(() => {
const v = props.credential?.last_verified
if (!v) return null
return (Date.now() - new Date(v).getTime()) / 86400_000
})
const verifyStale = computed(() => {
if (!hasCredential.value || freshlyVerified.value) return false
const age = verifiedAgeDays.value
return age == null || age > STALE_DAYS
})
const verifiedRelative = computed(() => {
const age = verifiedAgeDays.value
if (age == null) return '—'
if (age < 1) return 'today'
if (age < 2) return 'yesterday'
if (age < 30) return `${Math.floor(age)}d ago`
if (age < 365) return `${Math.floor(age / 30)}mo ago`
return `${Math.floor(age / 365)}y ago`
})
const statusLabel = computed(() => {
if (!hasCredential.value) return 'Not configured'
if (expiringSoon.value) return 'Expiring soon'
if (verifyStale.value) return 'Verify due'
return 'Active'
})
const statusColor = computed(() => {
if (!hasCredential.value) return 'grey'
if (expiringSoon.value) return 'warning'
if (expiringSoon.value || verifyStale.value) return 'warning'
return 'success'
})
@@ -0,0 +1,92 @@
<template>
<div class="fc-spark" :title="summaryTip">
<div class="fc-spark__bars">
<div
v-for="(b, i) in buckets" :key="i"
class="fc-spark__bar"
:title="barTip(b)"
>
<div class="fc-spark__fill" :style="{ height: fillPct(b) + '%' }">
<div
v-if="b.error > 0"
class="fc-spark__err"
:style="{ height: errPct(b) + '%' }"
/>
</div>
</div>
</div>
<div class="fc-spark__caption">
{{ hours }}h · {{ totalEvents }} events
</div>
</div>
</template>
<script setup>
import { computed } from 'vue'
const props = defineProps({
// [{ hour, ok, error, other, total }] oldest-first
buckets: { type: Array, default: () => [] },
hours: { type: Number, default: 24 },
})
const maxTotal = computed(() =>
Math.max(1, ...props.buckets.map((b) => b.total || 0)),
)
const totalEvents = computed(() =>
props.buckets.reduce((n, b) => n + (b.total || 0), 0),
)
function fillPct(b) {
return Math.round(((b.total || 0) / maxTotal.value) * 100)
}
function errPct(b) {
if (!b.total) return 0
return Math.round((b.error / b.total) * 100)
}
function barTip(b) {
const t = new Date(b.hour)
const label = t.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
return `${label}${b.total} events (${b.ok} ok, ${b.error} failed)`
}
const summaryTip = computed(
() => `Download activity, last ${props.hours}h (red = failures)`,
)
</script>
<style scoped>
.fc-spark {
display: flex; flex-direction: column; gap: 2px;
min-width: 160px;
}
.fc-spark__bars {
display: flex; align-items: flex-end; gap: 2px;
height: 36px;
}
.fc-spark__bar {
flex: 1 1 0;
height: 100%;
display: flex; align-items: flex-end;
min-width: 2px;
}
.fc-spark__fill {
width: 100%;
min-height: 1px;
position: relative;
border-radius: 2px 2px 0 0;
background: rgb(var(--v-theme-accent) / 0.7);
}
.fc-spark__err {
position: absolute; bottom: 0; left: 0;
width: 100%;
border-radius: 0 0 2px 2px;
background: rgb(var(--v-theme-error));
}
.fc-spark__caption {
font-size: 0.68rem;
text-transform: uppercase;
letter-spacing: 0.05em;
color: rgb(var(--v-theme-on-surface-variant));
text-align: center;
}
</style>
@@ -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;
@@ -0,0 +1,87 @@
<template>
<v-card v-if="sources.length" variant="tonal" color="error" class="fc-fail mb-4">
<div class="fc-fail__head" @click="open = !open">
<v-icon size="small">mdi-alert-circle</v-icon>
<span class="fc-fail__title">
{{ sources.length }}
{{ sources.length === 1 ? 'source is' : 'sources are' }} failing
</span>
<v-spacer />
<v-btn
size="small" variant="text" prepend-icon="mdi-refresh"
:loading="retryingAll"
@click.stop="$emit('retry-all', sources)"
>
Retry all
</v-btn>
<v-icon size="small">{{ open ? 'mdi-chevron-up' : 'mdi-chevron-down' }}</v-icon>
</div>
<v-expand-transition>
<div v-if="open" class="fc-fail__body">
<div v-for="s in sources" :key="s.id" class="fc-fail__row">
<PlatformChip :platform="s.platform" size="x-small" />
<span class="fc-fail__artist">{{ s.artist_name || `#${s.artist_id}` }}</span>
<v-chip size="x-small" color="error" variant="flat" label class="fc-fail__count">
{{ s.consecutive_failures }}× failed
</v-chip>
<span class="fc-fail__err" :title="s.last_error || ''">
{{ s.last_error || 'no error message recorded' }}
</span>
<v-spacer />
<v-btn
size="x-small" variant="text" prepend-icon="mdi-refresh"
:loading="retryingIds.has(s.id)"
@click="$emit('retry', s)"
>
Retry
</v-btn>
</div>
</div>
</v-expand-transition>
</v-card>
</template>
<script setup>
import { ref } from 'vue'
import PlatformChip from './PlatformChip.vue'
defineProps({
// source records from /api/sources?failing=true
sources: { type: Array, default: () => [] },
retryingIds: { type: Set, default: () => new Set() },
retryingAll: { type: Boolean, default: false },
})
defineEmits(['retry', 'retry-all'])
const open = ref(true)
</script>
<style scoped>
.fc-fail { border-radius: 8px; }
.fc-fail__head {
display: flex; align-items: center; gap: 8px;
padding: 10px 14px;
cursor: pointer;
user-select: none;
}
.fc-fail__title { font-weight: 600; }
.fc-fail__body {
padding: 0 14px 10px;
display: flex; flex-direction: column; gap: 2px;
}
.fc-fail__row {
display: flex; align-items: center; gap: 10px;
padding: 6px 8px;
border-radius: 4px;
background: rgb(var(--v-theme-surface) / 0.4);
}
.fc-fail__artist { font-weight: 600; white-space: nowrap; }
.fc-fail__count { flex: 0 0 auto; }
.fc-fail__err {
color: rgb(var(--v-theme-on-surface-variant));
font-size: 0.8rem;
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
max-width: 46ch;
}
</style>
@@ -0,0 +1,110 @@
<template>
<div v-if="status" class="fc-sched" :class="`fc-sched--${health}`">
<span class="fc-sched__dot" />
<span class="fc-sched__label">Scheduler</span>
<span class="fc-sched__sep">·</span>
<span class="fc-sched__item">
<v-icon size="x-small">mdi-clock-outline</v-icon>
last ran {{ lastRanLabel }}
</span>
<span class="fc-sched__sep">·</span>
<span class="fc-sched__item">
<v-icon size="x-small">mdi-calendar-clock</v-icon>
{{ nextLabel }}
</span>
<span class="fc-sched__sep">·</span>
<span class="fc-sched__item">
{{ status.auto_sources }} on schedule
</span>
<v-tooltip activator="parent" location="bottom">
{{ tooltip }}
</v-tooltip>
</div>
</template>
<script setup>
import { computed } from 'vue'
const props = defineProps({
// { last_tick_at, next_due_at, due_now, auto_sources } | null
status: { type: Object, default: null },
})
// The Beat tick fires every 60s; if the last stamp is older than ~3
// minutes the scheduler (Beat) is almost certainly down.
const STALE_MS = 180_000
const tickAgeMs = computed(() => {
const t = props.status?.last_tick_at
if (!t) return null
return Date.now() - new Date(t).getTime()
})
const health = computed(() => {
if (tickAgeMs.value == null) return 'unknown'
return tickAgeMs.value <= STALE_MS ? 'ok' : 'stale'
})
function rel(ms) {
const s = Math.max(0, Math.floor(ms / 1000))
if (s < 60) return `${s}s ago`
if (s < 3600) return `${Math.floor(s / 60)}m ago`
if (s < 86400) return `${Math.floor(s / 3600)}h ago`
return `${Math.floor(s / 86400)}d ago`
}
const lastRanLabel = computed(() =>
tickAgeMs.value == null ? 'never' : rel(tickAgeMs.value),
)
const nextLabel = computed(() => {
const due = props.status?.due_now || 0
if (due > 0) return `${due} due now`
const n = props.status?.next_due_at
if (!n) return 'nothing scheduled'
const diff = new Date(n).getTime() - Date.now()
if (diff <= 0) return 'check due'
const s = Math.floor(diff / 1000)
if (s < 60) return `next in ${s}s`
if (s < 3600) return `next in ${Math.floor(s / 60)}m`
if (s < 86400) return `next in ${Math.floor(s / 3600)}h`
return `next in ${Math.floor(s / 86400)}d`
})
const tooltip = computed(() => {
if (health.value === 'unknown') return 'The scheduler has not ticked yet (or the worker just started).'
if (health.value === 'stale') return 'The scheduler tick is overdue — check that Celery Beat is running.'
return 'Celery Beat is firing the periodic source-check tick on schedule.'
})
</script>
<style scoped>
.fc-sched {
display: inline-flex; align-items: center; gap: 8px;
flex-wrap: wrap;
font-size: 0.78rem;
color: rgb(var(--v-theme-on-surface-variant));
padding: 4px 10px;
border-radius: 6px;
background: rgb(var(--v-theme-on-surface) / 0.04);
}
.fc-sched__dot {
width: 8px; height: 8px; border-radius: 50%;
flex: 0 0 auto;
background: rgb(var(--v-theme-on-surface-variant));
}
.fc-sched--ok .fc-sched__dot { background: rgb(var(--v-theme-success)); }
.fc-sched--stale .fc-sched__dot { background: rgb(var(--v-theme-error)); }
.fc-sched--unknown .fc-sched__dot { background: rgb(var(--v-theme-on-surface-variant)); }
.fc-sched--stale { color: rgb(var(--v-theme-error)); }
.fc-sched__label {
font-weight: 600; text-transform: uppercase; letter-spacing: 0.05em;
color: rgb(var(--v-theme-on-surface));
}
.fc-sched--stale .fc-sched__label { color: rgb(var(--v-theme-error)); }
.fc-sched__sep { opacity: 0.4; }
.fc-sched__item {
display: inline-flex; align-items: center; gap: 3px;
white-space: nowrap;
}
</style>
@@ -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;
+14
View File
@@ -16,6 +16,8 @@ export const useDownloadsStore = defineStore('downloads', () => {
const loading = ref(false)
const error = ref(null)
const stats = ref({ pending: 0, running: 0, ok: 0, error: 0, skipped: 0 })
const activity = ref({ hours: 24, buckets: [] })
const failing = ref([])
function _params(extra = {}) {
const out = { limit: 50, ...extra }
@@ -74,8 +76,20 @@ export const useDownloadsStore = defineStore('downloads', () => {
return stats.value
}
async function loadActivity(hours = 24) {
activity.value = await api.get('/api/downloads/activity', { params: { hours } })
return activity.value
}
async function loadFailing() {
failing.value = await api.get('/api/sources', { params: { failing: true } })
return failing.value
}
return {
events, cursor, hasMore, filter, selected, loading, error, stats,
activity, failing,
loadFirst, loadMore, loadOne, applyFilter, closeDetail, loadStats,
loadActivity, loadFailing,
}
})
+8 -1
View File
@@ -9,6 +9,7 @@ export const useSourcesStore = defineStore('sources', () => {
const byArtist = ref(new Map())
const loading = ref(false)
const error = ref(null)
const scheduleStatus = ref(null)
const allSources = computed(() => byArtist.value.get(null) ?? [])
@@ -70,6 +71,11 @@ export const useSourcesStore = defineStore('sources', () => {
return await api.get('/api/artists/autocomplete', { params: { q: query, limit } })
}
async function loadScheduleStatus() {
scheduleStatus.value = await api.get('/api/sources/schedule-status')
return scheduleStatus.value
}
// FC-3c: trigger a download for one source. Returns {download_event_id,status}.
const checkingIds = ref(new Set())
@@ -104,13 +110,14 @@ export const useSourcesStore = defineStore('sources', () => {
}
return {
byArtist, loading, error,
byArtist, loading, error, scheduleStatus,
allSources,
checkingIds,
loadAll, loadForArtist,
create, update, remove,
checkNow,
findOrCreateArtist, autocompleteArtist,
loadScheduleStatus,
sourcesByArtistGrouped,
}
})
+20
View File
@@ -126,3 +126,23 @@ async def test_stats_window_hours_rejects_out_of_range(client):
assert resp.status_code == 400
resp = await client.get("/api/downloads/stats?window_hours=bogus")
assert resp.status_code == 400
@pytest.mark.asyncio
async def test_activity_returns_fixed_bucket_array(client, seed):
resp = await client.get("/api/downloads/activity")
assert resp.status_code == 200
body = await resp.get_json()
assert body["hours"] == 24
assert len(body["buckets"]) == 24
assert sum(b["total"] for b in body["buckets"]) == 2
# Both seed events were created "now" → land in the newest bucket.
newest = body["buckets"][-1]
assert newest["ok"] == 1
assert newest["error"] == 1
@pytest.mark.asyncio
async def test_activity_rejects_bogus_hours(client):
resp = await client.get("/api/downloads/activity?hours=bogus")
assert resp.status_code == 400
+32 -1
View File
@@ -1,7 +1,7 @@
import pytest
from backend.app import create_app
from backend.app.models import Artist
from backend.app.models import Artist, Source
pytestmark = pytest.mark.integration
@@ -25,6 +25,37 @@ async def artist(db):
return a
@pytest.mark.asyncio
async def test_list_failing_filter_returns_only_failing(client, artist, db):
healthy = Source(
artist_id=artist.id, platform="patreon", url="https://p/ok",
enabled=True, consecutive_failures=0,
)
broken = Source(
artist_id=artist.id, platform="pixiv", url="https://p/bad",
enabled=True, consecutive_failures=3, last_error="boom",
)
db.add_all([healthy, broken])
await db.commit()
resp = await client.get("/api/sources?failing=true")
assert resp.status_code == 200
body = await resp.get_json()
assert len(body) == 1
assert body[0]["consecutive_failures"] == 3
assert body[0]["last_error"] == "boom"
@pytest.mark.asyncio
async def test_schedule_status_shape(client):
resp = await client.get("/api/sources/schedule-status")
assert resp.status_code == 200
body = await resp.get_json()
assert set(body) == {"last_tick_at", "next_due_at", "due_now", "auto_sources"}
assert isinstance(body["due_now"], int)
assert isinstance(body["auto_sources"], int)
@pytest.mark.asyncio
async def test_create_list_get_delete(client, artist):
create = await client.post("/api/sources", json={