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:
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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),
|
||||
}
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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={
|
||||
|
||||
Reference in New Issue
Block a user