feat(I3): always-on pipeline status indicator in the top nav

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>
This commit is contained in:
2026-05-29 13:24:11 -04:00
parent 9ab5d709c8
commit 00e2608ba1
5 changed files with 221 additions and 7 deletions
@@ -0,0 +1,146 @@
<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>
+2
View File
@@ -8,6 +8,7 @@
<span class="fc-health" :title="health.label">
<v-icon size="x-small" :color="health.color">{{ health.icon }}</v-icon>
</span>
<PipelineStatusChip />
</div>
<nav class="fc-links">
@@ -29,6 +30,7 @@
import { computed, onMounted } from 'vue'
import router, { FRONT_DOOR } from '../router.js'
import { useSystemStore } from '../stores/system.js'
import PipelineStatusChip from './PipelineStatusChip.vue'
const system = useSystemStore()
onMounted(() => system.refreshHealth())
+14 -2
View File
@@ -93,11 +93,23 @@ export const useSystemActivityStore = defineStore('systemActivity', () => {
runsFilter.value = { ...runsFilter.value, ...filter }
}
// One-call rollup for the always-on TopNav pipeline indicator:
// { scheduler, queues, queued_total, running, failing }.
const summary = ref(null)
async function loadSummary() {
try {
summary.value = await api.get('/api/system/activity/summary')
} catch (e) {
lastError.value = e.message
}
return summary.value
}
return {
queues, workers, recentMinute, failures,
queues, workers, recentMinute, failures, summary,
runs, runsCursor, runsHasMore, runsFilter,
loading, lastError,
loadQueues, loadWorkers, loadRecentMinute,
loadRuns, loadFailures, setFilter,
loadRuns, loadFailures, loadSummary, setFilter,
}
})