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