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>
93 lines
2.2 KiB
Vue
93 lines
2.2 KiB
Vue
<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>
|