feat(downloads): front-and-center "active now" panel with live elapsed timers
New ActiveDownloadsPanel pinned at the top of the Downloads tab shows what's running (pulsing dot + live mm:ss timer counting from started_at) and what's queued, so the operator can see activity at a glance without the running filter. Backed by store.loadActive() which fetches running + pending independent of the feed filter; refreshed every 4s by the existing live poll. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,133 @@
|
|||||||
|
<template>
|
||||||
|
<v-card v-if="active.length" variant="tonal" color="info" class="fc-active mb-4">
|
||||||
|
<div class="fc-active__head">
|
||||||
|
<span class="fc-active__pulse" />
|
||||||
|
<span class="fc-active__title">
|
||||||
|
<template v-if="running.length">
|
||||||
|
{{ running.length }} downloading
|
||||||
|
</template>
|
||||||
|
<template v-if="running.length && queued.length"> · </template>
|
||||||
|
<template v-if="queued.length">
|
||||||
|
{{ queued.length }} queued
|
||||||
|
</template>
|
||||||
|
</span>
|
||||||
|
<v-spacer />
|
||||||
|
<span class="fc-active__live">live</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="fc-active__body">
|
||||||
|
<div
|
||||||
|
v-for="e in running" :key="e.id"
|
||||||
|
class="fc-active__row fc-active__row--running"
|
||||||
|
>
|
||||||
|
<span class="fc-active__dot" />
|
||||||
|
<PlatformChip :platform="e.platform" size="x-small" />
|
||||||
|
<span class="fc-active__artist">{{ e.artist_name || '—' }}</span>
|
||||||
|
<v-spacer />
|
||||||
|
<span class="fc-active__timer" :title="`started ${e.started_at}`">
|
||||||
|
{{ elapsed(e.started_at) }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-for="e in queued" :key="e.id"
|
||||||
|
class="fc-active__row fc-active__row--queued"
|
||||||
|
>
|
||||||
|
<v-icon size="x-small" class="fc-active__queue-icon">mdi-clock-outline</v-icon>
|
||||||
|
<PlatformChip :platform="e.platform" size="x-small" />
|
||||||
|
<span class="fc-active__artist">{{ e.artist_name || '—' }}</span>
|
||||||
|
<v-spacer />
|
||||||
|
<span class="fc-active__queued">queued</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</v-card>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { computed, onMounted, onUnmounted, ref } from 'vue'
|
||||||
|
|
||||||
|
import { useDownloadsStore } from '../../stores/downloads.js'
|
||||||
|
import PlatformChip from './PlatformChip.vue'
|
||||||
|
|
||||||
|
const store = useDownloadsStore()
|
||||||
|
|
||||||
|
const running = computed(() => store.activeEvents.filter((e) => e.status === 'running'))
|
||||||
|
const queued = computed(() => store.activeEvents.filter((e) => e.status === 'pending'))
|
||||||
|
const active = computed(() => [...running.value, ...queued.value])
|
||||||
|
|
||||||
|
// Ticking clock so the running timers update every second. started_at is
|
||||||
|
// tz-aware UTC; Date parses the instant, so (now - start) is correct in
|
||||||
|
// any viewer timezone.
|
||||||
|
const now = ref(Date.now())
|
||||||
|
let timer = null
|
||||||
|
onMounted(() => { timer = setInterval(() => { now.value = Date.now() }, 1000) })
|
||||||
|
onUnmounted(() => { if (timer) clearInterval(timer) })
|
||||||
|
|
||||||
|
function elapsed (startedIso) {
|
||||||
|
if (!startedIso) return '—'
|
||||||
|
const start = new Date(startedIso).getTime()
|
||||||
|
if (isNaN(start)) return '—'
|
||||||
|
const secs = Math.max(0, Math.floor((now.value - start) / 1000))
|
||||||
|
const h = Math.floor(secs / 3600)
|
||||||
|
const m = Math.floor((secs % 3600) / 60)
|
||||||
|
const s = secs % 60
|
||||||
|
const mm = String(m).padStart(2, '0')
|
||||||
|
const ss = String(s).padStart(2, '0')
|
||||||
|
return h > 0 ? `${h}:${mm}:${ss}` : `${m}:${ss}`
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.fc-active { border-radius: 8px; }
|
||||||
|
.fc-active__head {
|
||||||
|
display: flex; align-items: center; gap: 10px;
|
||||||
|
padding: 10px 14px;
|
||||||
|
}
|
||||||
|
.fc-active__title {
|
||||||
|
font-weight: 700;
|
||||||
|
text-transform: uppercase; letter-spacing: 0.04em; font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
.fc-active__pulse {
|
||||||
|
width: 10px; height: 10px; border-radius: 50%; flex: 0 0 auto;
|
||||||
|
background: rgb(var(--v-theme-info));
|
||||||
|
animation: fc-active-pulse 1.4s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
.fc-active__live {
|
||||||
|
font-size: 0.7rem; text-transform: uppercase; letter-spacing: 0.08em;
|
||||||
|
color: rgb(var(--v-theme-info));
|
||||||
|
}
|
||||||
|
.fc-active__body {
|
||||||
|
padding: 0 14px 12px;
|
||||||
|
display: flex; flex-direction: column; gap: 4px;
|
||||||
|
}
|
||||||
|
.fc-active__row {
|
||||||
|
display: flex; align-items: center; gap: 10px;
|
||||||
|
padding: 7px 10px;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: rgb(var(--v-theme-surface) / 0.5);
|
||||||
|
}
|
||||||
|
.fc-active__row--queued { opacity: 0.75; }
|
||||||
|
.fc-active__dot {
|
||||||
|
width: 8px; height: 8px; border-radius: 50%; flex: 0 0 auto;
|
||||||
|
background: rgb(var(--v-theme-info));
|
||||||
|
animation: fc-active-pulse 1.4s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
.fc-active__queue-icon { color: rgb(var(--v-theme-on-surface-variant)); }
|
||||||
|
.fc-active__artist { font-weight: 600; }
|
||||||
|
.fc-active__timer {
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
font-weight: 700;
|
||||||
|
color: rgb(var(--v-theme-info));
|
||||||
|
}
|
||||||
|
.fc-active__queued {
|
||||||
|
font-size: 0.78rem; text-transform: uppercase; letter-spacing: 0.05em;
|
||||||
|
color: rgb(var(--v-theme-on-surface-variant));
|
||||||
|
}
|
||||||
|
@keyframes fc-active-pulse {
|
||||||
|
0%, 100% { opacity: 1; transform: scale(1); }
|
||||||
|
50% { opacity: 0.35; transform: scale(0.7); }
|
||||||
|
}
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.fc-active__pulse, .fc-active__dot { animation: none; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -43,6 +43,8 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<ActiveDownloadsPanel />
|
||||||
|
|
||||||
<v-alert v-if="store.error" type="error" variant="tonal" closable class="my-4">
|
<v-alert v-if="store.error" type="error" variant="tonal" closable class="my-4">
|
||||||
{{ String(store.error) }}
|
{{ String(store.error) }}
|
||||||
</v-alert>
|
</v-alert>
|
||||||
@@ -124,6 +126,7 @@ import DownloadDetailModal from '../downloads/DownloadDetailModal.vue'
|
|||||||
import DownloadStatChips from './DownloadStatChips.vue'
|
import DownloadStatChips from './DownloadStatChips.vue'
|
||||||
import DownloadActivitySparkline from './DownloadActivitySparkline.vue'
|
import DownloadActivitySparkline from './DownloadActivitySparkline.vue'
|
||||||
import FailingSourcesCard from './FailingSourcesCard.vue'
|
import FailingSourcesCard from './FailingSourcesCard.vue'
|
||||||
|
import ActiveDownloadsPanel from './ActiveDownloadsPanel.vue'
|
||||||
import MaintenanceMenu from './MaintenanceMenu.vue'
|
import MaintenanceMenu from './MaintenanceMenu.vue'
|
||||||
import DownloadsFilterPopover from './DownloadsFilterPopover.vue'
|
import DownloadsFilterPopover from './DownloadsFilterPopover.vue'
|
||||||
|
|
||||||
@@ -160,6 +163,7 @@ async function refresh() {
|
|||||||
store.loadStats(24),
|
store.loadStats(24),
|
||||||
store.loadActivity(24),
|
store.loadActivity(24),
|
||||||
store.loadFailing(),
|
store.loadFailing(),
|
||||||
|
store.loadActive(),
|
||||||
])
|
])
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -218,9 +222,10 @@ function startPolling() {
|
|||||||
if (pollId) return
|
if (pollId) return
|
||||||
pollId = setInterval(async () => {
|
pollId = setInterval(async () => {
|
||||||
if (document.hidden) return
|
if (document.hidden) return
|
||||||
// Refresh stats + sparkline cheaply every tick; only reload the event
|
// Refresh stats + sparkline + active panel every tick (so new runs
|
||||||
// list + failing rollup when there's active work (keeps idle ticks light).
|
// surface within 4s even from idle); only reload the full event list +
|
||||||
await Promise.all([store.loadStats(24), store.loadActivity(24)])
|
// failing rollup when there's active work (keeps idle ticks light).
|
||||||
|
await Promise.all([store.loadStats(24), store.loadActivity(24), store.loadActive()])
|
||||||
if (liveActive.value) await Promise.all([store.loadFirst(), store.loadFailing()])
|
if (liveActive.value) await Promise.all([store.loadFirst(), store.loadFailing()])
|
||||||
}, POLL_MS)
|
}, POLL_MS)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,6 +18,10 @@ export const useDownloadsStore = defineStore('downloads', () => {
|
|||||||
const stats = ref({ pending: 0, running: 0, ok: 0, error: 0, skipped: 0 })
|
const stats = ref({ pending: 0, running: 0, ok: 0, error: 0, skipped: 0 })
|
||||||
const activity = ref({ hours: 24, buckets: [] })
|
const activity = ref({ hours: 24, buckets: [] })
|
||||||
const failing = ref([])
|
const failing = ref([])
|
||||||
|
// Running + queued events, fetched independent of the feed's filter so
|
||||||
|
// the "active now" panel always reflects what's happening regardless of
|
||||||
|
// how the operator has filtered the historical list below.
|
||||||
|
const activeEvents = ref([])
|
||||||
|
|
||||||
function _params(extra = {}) {
|
function _params(extra = {}) {
|
||||||
const out = { limit: 50, ...extra }
|
const out = { limit: 50, ...extra }
|
||||||
@@ -75,10 +79,19 @@ export const useDownloadsStore = defineStore('downloads', () => {
|
|||||||
return failing.value
|
return failing.value
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function loadActive() {
|
||||||
|
const [running, pending] = await Promise.all([
|
||||||
|
api.get('/api/downloads', { params: { status: 'running', limit: 50 } }),
|
||||||
|
api.get('/api/downloads', { params: { status: 'pending', limit: 50 } }),
|
||||||
|
])
|
||||||
|
activeEvents.value = [...running, ...pending]
|
||||||
|
return activeEvents.value
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
events, cursor, hasMore, filter, selected, loading, error, stats,
|
events, cursor, hasMore, filter, selected, loading, error, stats,
|
||||||
activity, failing,
|
activity, failing, activeEvents,
|
||||||
loadFirst, loadMore, loadOne, applyFilter, closeDetail, loadStats,
|
loadFirst, loadMore, loadOne, applyFilter, closeDetail, loadStats,
|
||||||
loadActivity, loadFailing,
|
loadActivity, loadFailing, loadActive,
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user