feat(ui): group repeated failures by subscription on Dashboard recent activity

Consecutive failures on the same source no longer spam the feed as
separate rows. Failed downloads are now bucketed per subscription and
rendered as a single row with a failure count and the latest
timestamp, while successful/running entries still render individually.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-04-18 15:04:21 -04:00
parent b9c0d0efbf
commit 9f8ce67084
+78 -30
View File
@@ -53,6 +53,10 @@
:loading="checkingAll"
@click="checkAllSources"
>
<template #loader>
<v-progress-circular indeterminate size="18" width="2" class="mr-2" />
Checking
</template>
Check All Sources
</v-btn>
<v-btn
@@ -62,7 +66,11 @@
:disabled="!failedCount"
@click="retryAllFailed"
>
Retry All Failed ({{ failedCount }})
<template #loader>
<v-progress-circular indeterminate size="18" width="2" class="mr-2" />
Retrying
</template>
Retry All Failed
</v-btn>
<v-btn
color="secondary"
@@ -72,6 +80,10 @@
:disabled="!stuckRunningCount"
@click="resetOrphanedJobs"
>
<template #loader>
<v-progress-circular indeterminate size="18" width="2" class="mr-2" />
Resetting
</template>
Reset Stuck ({{ stuckRunningCount }})
</v-btn>
</v-card-text>
@@ -189,35 +201,48 @@
</v-chip>
</v-card-title>
<v-card-text>
<v-list v-if="recentActivity.length">
<v-list-item
v-for="download in recentActivity"
:key="download.id"
>
<template v-slot:prepend>
<v-icon :color="getStatusColor(download.status)">
{{ getStatusIcon(download.status) }}
</v-icon>
</template>
<v-list-item-title>
{{ getDownloadLabel(download) }}
</v-list-item-title>
<v-list-item-subtitle>
{{ formatRelativeTime(download.created_at) }}
<span v-if="download.file_count > 0" class="text-success ml-2">
{{ download.file_count }} new file{{ download.file_count !== 1 ? 's' : '' }}
</span>
</v-list-item-subtitle>
<template v-slot:append>
<v-chip
:color="getStatusColor(download.status)"
size="small"
variant="tonal"
>
{{ download.status === 'completed' ? 'new content' : download.status }}
</v-chip>
</template>
</v-list-item>
<v-list v-if="recentActivityGrouped.length">
<template v-for="entry in recentActivityGrouped" :key="entry.key">
<v-list-item v-if="entry.kind === 'single'">
<template v-slot:prepend>
<v-icon :color="getStatusColor(entry.download.status)">
{{ getStatusIcon(entry.download.status) }}
</v-icon>
</template>
<v-list-item-title>
{{ getDownloadLabel(entry.download) }}
</v-list-item-title>
<v-list-item-subtitle>
{{ formatRelativeTime(entry.download.created_at) }}
<span v-if="entry.download.file_count > 0" class="text-success ml-2">
{{ entry.download.file_count }} new file{{ entry.download.file_count !== 1 ? 's' : '' }}
</span>
</v-list-item-subtitle>
<template v-slot:append>
<v-chip
:color="getStatusColor(entry.download.status)"
size="small"
variant="tonal"
>
{{ entry.download.status === 'completed' ? 'new content' : entry.download.status }}
</v-chip>
</template>
</v-list-item>
<v-list-item v-else>
<template v-slot:prepend>
<v-icon color="error">mdi-alert-circle</v-icon>
</template>
<v-list-item-title>{{ entry.label }}</v-list-item-title>
<v-list-item-subtitle>
{{ entry.items.length }} failure{{ entry.items.length !== 1 ? 's' : '' }} · latest {{ formatRelativeTime(entry.latest.created_at) }}
</v-list-item-subtitle>
<template v-slot:append>
<v-chip color="error" size="small" variant="tonal">
{{ entry.items.length }}× failed
</v-chip>
</template>
</v-list-item>
</template>
</v-list>
<div v-else class="text-center py-8">
<v-icon size="48" color="secondary" :style="{ opacity: 0.5 }">mdi-history</v-icon>
@@ -354,6 +379,29 @@ let refreshInterval = null
const stats = computed(() => downloadsStore.stats)
const recentActivity = computed(() => downloadsStore.recentActivity)
const recentActivityGrouped = computed(() => {
const singles = []
const groups = new Map()
for (const d of recentActivity.value) {
if (d.status === 'failed') {
const key = `${d.subscription_name || '?'}:${d.platform || '?'}`
const g = groups.get(key) || { kind: 'group', key, label: key, items: [], latest: d }
g.items.push(d)
if (new Date(d.created_at) > new Date(g.latest.created_at)) g.latest = d
groups.set(key, g)
} else {
singles.push({ kind: 'single', key: `d-${d.id}`, download: d })
}
}
const out = [...singles, ...groups.values()]
out.sort((a, b) => {
const tA = new Date(a.kind === 'single' ? a.download.created_at : a.latest.created_at)
const tB = new Date(b.kind === 'single' ? b.download.created_at : b.latest.created_at)
return tB - tA
})
return out
})
// Read threshold from settings, default 5 if unset or invalid
const failureThreshold = computed(() => {
const n = Number(settingsStore.settings['dashboard.failure_threshold'])