2358cedf3e
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>
111 lines
3.5 KiB
Vue
111 lines
3.5 KiB
Vue
<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>
|