00e2608ba1
New /api/system/activity/summary aggregates scheduler health + per-queue pending depths + running count + 24h failure count in one cached call (safe to poll app-wide). PipelineStatusChip lives in the TopNav on every page: a compact running/queued/failing chip with a scheduler-health dot that expands to a popover (scheduler, busy queues, counts, link to downloads). Polls the summary every 8s, paused when backgrounded. Reuses the queue-cache read via _queues_cached(). + API test for the summary shape. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
147 lines
5.5 KiB
Vue
147 lines
5.5 KiB
Vue
<template>
|
|
<v-menu location="bottom start" :close-on-content-click="false">
|
|
<template #activator="{ props }">
|
|
<button
|
|
v-bind="props" type="button"
|
|
class="fc-pulse" :class="`fc-pulse--${schedHealth}`"
|
|
:aria-label="`pipeline: ${running} running, ${queued} queued, ${failing} failing`"
|
|
>
|
|
<span class="fc-pulse__dot" />
|
|
<span v-if="running" class="fc-pulse__stat">
|
|
<v-icon size="13">mdi-progress-download</v-icon>{{ running }}
|
|
</span>
|
|
<span v-if="queued" class="fc-pulse__stat">
|
|
<v-icon size="13">mdi-tray-full</v-icon>{{ queued }}
|
|
</span>
|
|
<span v-if="failing" class="fc-pulse__stat fc-pulse__stat--err">
|
|
<v-icon size="13">mdi-alert-circle</v-icon>{{ failing }}
|
|
</span>
|
|
<span v-if="!running && !queued && !failing" class="fc-pulse__idle">idle</span>
|
|
</button>
|
|
</template>
|
|
|
|
<v-card min-width="300" class="fc-pulse__panel pa-3">
|
|
<div class="fc-pulse__row">
|
|
<span class="fc-pulse__rowdot" :class="`fc-pulse--${schedHealth}`" />
|
|
<strong>Scheduler</strong>
|
|
<v-spacer />
|
|
<span class="fc-pulse__muted">{{ schedLabel }}</span>
|
|
</div>
|
|
|
|
<div class="fc-pulse__sec">
|
|
<div class="fc-pulse__sechead">Queues</div>
|
|
<div v-if="busyQueues.length === 0" class="fc-pulse__muted">All idle</div>
|
|
<div v-for="q in busyQueues" :key="q.name" class="fc-pulse__qrow">
|
|
<span>{{ q.name }}</span><v-spacer /><strong>{{ q.depth }}</strong>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="fc-pulse__sec fc-pulse__grid">
|
|
<div><span class="fc-pulse__muted">Running</span><div class="fc-pulse__big">{{ running }}</div></div>
|
|
<div><span class="fc-pulse__muted">Queued</span><div class="fc-pulse__big">{{ queued }}</div></div>
|
|
<div>
|
|
<span class="fc-pulse__muted">Failures 24h</span>
|
|
<div class="fc-pulse__big" :class="{ 'fc-pulse__big--err': failing }">{{ failing }}</div>
|
|
</div>
|
|
</div>
|
|
|
|
<RouterLink
|
|
class="fc-pulse__link"
|
|
:to="{ path: '/subscriptions', query: { tab: 'downloads' } }"
|
|
>Open downloads →</RouterLink>
|
|
</v-card>
|
|
</v-menu>
|
|
</template>
|
|
|
|
<script setup>
|
|
import { computed, onMounted, onUnmounted } from 'vue'
|
|
|
|
import { useSystemActivityStore } from '../stores/systemActivity.js'
|
|
import { formatRelative } from '../utils/date.js'
|
|
|
|
const store = useSystemActivityStore()
|
|
|
|
// Beat ticks every 60s; flag the scheduler stale past ~3 min.
|
|
const STALE_MS = 180_000
|
|
const summary = computed(() => store.summary)
|
|
const running = computed(() => summary.value?.running ?? 0)
|
|
const queued = computed(() => summary.value?.queued_total ?? 0)
|
|
const failing = computed(() => summary.value?.failing ?? 0)
|
|
|
|
const schedHealth = computed(() => {
|
|
const t = summary.value?.scheduler?.last_tick_at
|
|
if (!t) return 'unknown'
|
|
return (Date.now() - new Date(t).getTime()) <= STALE_MS ? 'ok' : 'stale'
|
|
})
|
|
const schedLabel = computed(() => {
|
|
const s = summary.value?.scheduler
|
|
if (!s) return '—'
|
|
const ran = formatRelative(s.last_tick_at, { nullText: 'never' })
|
|
if (s.due_now > 0) return `ran ${ran} · ${s.due_now} due`
|
|
return `ran ${ran}`
|
|
})
|
|
const busyQueues = computed(() => {
|
|
const q = summary.value?.queues || {}
|
|
return Object.entries(q)
|
|
.filter(([, depth]) => typeof depth === 'number' && depth > 0)
|
|
.map(([name, depth]) => ({ name, depth }))
|
|
})
|
|
|
|
const POLL_MS = 8000
|
|
let timer = null
|
|
onMounted(() => {
|
|
store.loadSummary()
|
|
timer = setInterval(() => {
|
|
if (!document.hidden) store.loadSummary()
|
|
}, POLL_MS)
|
|
})
|
|
onUnmounted(() => { if (timer) clearInterval(timer) })
|
|
</script>
|
|
|
|
<style scoped>
|
|
.fc-pulse {
|
|
display: inline-flex; align-items: center; gap: 6px;
|
|
padding: 3px 8px; border-radius: 999px;
|
|
background: rgb(var(--v-theme-on-surface) / 0.08);
|
|
color: rgb(var(--v-theme-on-surface));
|
|
font-size: 0.78rem; font-variant-numeric: tabular-nums;
|
|
cursor: pointer; border: 0;
|
|
}
|
|
.fc-pulse:hover { background: rgb(var(--v-theme-on-surface) / 0.16); }
|
|
.fc-pulse__dot {
|
|
width: 8px; height: 8px; border-radius: 50%; flex: 0 0 auto;
|
|
background: rgb(var(--v-theme-on-surface-variant));
|
|
}
|
|
.fc-pulse--ok .fc-pulse__dot,
|
|
.fc-pulse--ok.fc-pulse__rowdot { background: rgb(var(--v-theme-success)); }
|
|
.fc-pulse--stale .fc-pulse__dot,
|
|
.fc-pulse--stale.fc-pulse__rowdot { background: rgb(var(--v-theme-error)); }
|
|
.fc-pulse__stat { display: inline-flex; align-items: center; gap: 2px; }
|
|
.fc-pulse__stat--err { color: rgb(var(--v-theme-error)); }
|
|
.fc-pulse__idle {
|
|
text-transform: uppercase; letter-spacing: 0.06em;
|
|
color: rgb(var(--v-theme-on-surface-variant)); font-size: 0.68rem;
|
|
}
|
|
|
|
.fc-pulse__panel { background: rgb(var(--v-theme-surface)); }
|
|
.fc-pulse__row { display: flex; align-items: center; gap: 8px; }
|
|
.fc-pulse__rowdot {
|
|
width: 9px; height: 9px; border-radius: 50%;
|
|
background: rgb(var(--v-theme-on-surface-variant));
|
|
}
|
|
.fc-pulse__muted { color: rgb(var(--v-theme-on-surface-variant)); font-size: 0.8rem; }
|
|
.fc-pulse__sec { margin-top: 12px; }
|
|
.fc-pulse__sechead {
|
|
font-size: 0.72rem; text-transform: uppercase; letter-spacing: 0.05em;
|
|
color: rgb(var(--v-theme-on-surface-variant)); margin-bottom: 4px;
|
|
}
|
|
.fc-pulse__qrow { display: flex; align-items: center; font-size: 0.85rem; padding: 2px 0; }
|
|
.fc-pulse__grid { display: flex; gap: 16px; }
|
|
.fc-pulse__big { font-size: 1.3rem; font-weight: 700; font-variant-numeric: tabular-nums; }
|
|
.fc-pulse__big--err { color: rgb(var(--v-theme-error)); }
|
|
.fc-pulse__link {
|
|
display: inline-block; margin-top: 14px;
|
|
color: rgb(var(--v-theme-accent)); text-decoration: none; font-size: 0.85rem;
|
|
}
|
|
</style>
|