Defines "failing source" as error_count >= configurable threshold (default 5), adds per-platform health bars and a strict failing-source list to the Dashboard, surfaces the threshold on the Settings page. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
8.7 KiB
Failure Display — Design Spec
Date: 2026-04-17 Scope: Define a single "failing source" state and surface it on the Dashboard as per-platform health bars plus a filtered failing-source list. Make the threshold configurable via Settings.
Motivation
The user is seeing a large number of source failures and needs a way to spot which platforms are affected at a glance. Today, the Dashboard's "Sources Needing Attention" card shows any source with a failed download in the last 7 days — a transient, noisy definition. A single bad run puts a source on the list; a later success does nothing to clear it.
This spec introduces a stricter, self-clearing definition: a source is failing when it has N consecutive failures (default 5), and a single success resets the counter. That semantic already exists in the data model — Source.error_count increments on failure and is set to 0 on success — so this is a presentation change with one small settings addition, not a schema change.
Design
Backend
File: backend/app/models/setting.py
Add one entry to DEFAULT_SETTINGS:
DEFAULT_SETTINGS = {
# ... existing keys unchanged ...
"dashboard.failure_threshold": 5,
}
No migration. The settings table is a key-value store and get_setting_value(session, key, default) already falls back through DEFAULT_SETTINGS. No new endpoint — the existing GET /api/settings returns it in the merged settings dict.
Settings page — frontend/src/views/Settings.vue
Add a new section above the Notifications divider:
<v-divider class="my-6" />
<h3 class="text-h6 mb-4">Dashboard</h3>
<v-row>
<v-col cols="12" md="6">
<v-text-field
v-model.number="settings['dashboard.failure_threshold']"
label="Failing source threshold"
type="number"
min="1"
max="20"
hint="Consecutive download failures before a source is marked failing. One success resets the counter."
persistent-hint
/>
</v-col>
</v-row>
The existing save handler in Settings.vue already patches any key present in the settings object, so no handler changes are needed.
Dashboard — frontend/src/views/Dashboard.vue
Replace the body of the existing "Sources Needing Attention" card. The card title, outer shell, and card actions remain unchanged. The inner v-card-text is rewritten.
New computeds
// Read threshold from settings store, default 5 if unset
const settingsStore = useSettingsStore()
const failureThreshold = computed(() =>
settingsStore.settings?.['dashboard.failure_threshold'] ?? 5
)
// Fixed platform display order
const PLATFORM_ORDER = [
'patreon', 'subscribestar', 'hentaifoundry',
'discord', 'pixiv', 'deviantart',
]
// Per-platform health: { platform, total, failing } for platforms with ≥1 enabled source
const platformHealth = computed(() => {
const enabled = sourcesStore.sources.filter(s => s.enabled)
return PLATFORM_ORDER
.map(platform => {
const sources = enabled.filter(s => s.platform === platform)
if (sources.length === 0) return null // hide zero-enabled platforms
const failing = sources.filter(s => (s.error_count || 0) >= failureThreshold.value).length
return { platform, total: sources.length, failing }
})
.filter(Boolean)
})
// Sources currently in a failing state, most-recent-check first
const failingSources = computed(() => {
return sourcesStore.sources
.filter(s => s.enabled && (s.error_count || 0) >= failureThreshold.value)
.sort((a, b) => new Date(b.last_check || 0) - new Date(a.last_check || 0))
})
const totalFailingCount = computed(() => failingSources.value.length)
useSettingsStore already exists at frontend/src/stores/settings.js with fetchSettings() and a reactive settings ref. Dashboard.vue must call settingsStore.fetchSettings() in loadDashboardData (alongside the existing fetchSources/fetchStats/etc calls) so settings is populated before the threshold is read.
Template — replace v-card-text body
<v-card-text>
<!-- Section 1: Platform Health bars -->
<div class="mb-4">
<div class="text-caption text-medium-emphasis mb-2">Platform Health</div>
<div
v-for="row in platformHealth"
:key="row.platform"
class="d-flex align-center mb-2"
>
<v-icon :color="getPlatformColor(row.platform)" size="small" class="mr-2">
{{ getPlatformIcon(row.platform) }}
</v-icon>
<div class="text-body-2 platform-label">{{ row.platform }}</div>
<v-progress-linear
:model-value="row.failing > 0 ? (row.failing / row.total) * 100 : 100"
:color="row.failing > 0 ? 'error' : 'success'"
height="12"
rounded
class="mx-3 flex-grow-1"
/>
<div class="text-caption text-medium-emphasis count-label">
{{ row.failing }}/{{ row.total }}
</div>
</div>
</div>
<v-divider class="my-3" />
<!-- Section 2: Failing Sources list -->
<v-table v-if="failingSources.length" density="compact">
<thead>
<tr>
<th>Source</th>
<th>Consecutive failures</th>
<th>Last check</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
<tr v-for="source in failingSources" :key="source.id">
<td>{{ getSourceLabel(source) }}</td>
<td>
<v-chip color="error" size="small">{{ source.error_count }}</v-chip>
</td>
<td>{{ formatDate(source.last_check) }}</td>
<td>
<v-btn size="small" variant="text" color="primary" @click="retrySource(source)">
Retry
</v-btn>
<v-btn size="small" variant="text" to="/subscriptions">View</v-btn>
</td>
</tr>
</tbody>
</v-table>
<div v-else class="text-caption text-medium-emphasis text-center py-3">
No sources in failing state
</div>
</v-card-text>
Header chip
The card title already has:
<v-chip class="ml-2" color="error" size="small" v-if="sourcesWithErrors.length">
{{ sourcesWithErrors.length }}
</v-chip>
Swap the binding to totalFailingCount:
<v-chip class="ml-2" color="error" size="small" v-if="totalFailingCount">
{{ totalFailingCount }}
</v-chip>
Removed code
sourcesWithErrorscomputed (derived from recent activity) is no longer used. Delete it.- The existing
v-elseempty state block ("All sources healthy" big icon) is removed — its role is now played by the always-visible bars plus the small inline "No sources in failing state" line.
Minor CSS (scoped block)
.platform-label {
min-width: 110px;
text-transform: capitalize;
}
.count-label {
min-width: 50px;
text-align: right;
}
Data flow
Dashboard.vueon mount fetches sources (already happens), recent activity (already happens), and settings (new — one call tosettingsStore.fetchSettings()).platformHealthandfailingSourcesare purely derived fromsourcesStore.sources+failureThreshold.- When a download completes or fails, the existing
download.completed/download.failedwebsocket handlers already triggersourcesStore.fetchSources()throughsource.updated. Re-render happens via Vue reactivity.
No polling changes; no new backend work beyond the one default value.
Visual behavior
- All platforms healthy: each bar is 100% green.
- One platform has failures: that bar shows red fill proportional to
failing / totalagainst a neutral track; the others stay green. - Platform has zero enabled sources: row omitted entirely.
- Header chip: visible only when
totalFailingCount > 0, shows that count. - Failing sources list: rendered when non-empty; otherwise inline "No sources in failing state" caption sits directly under the bars.
Files changed
| File | Change |
|---|---|
backend/app/models/setting.py |
Add "dashboard.failure_threshold": 5 to DEFAULT_SETTINGS |
frontend/src/views/Settings.vue |
Add Dashboard section with threshold v-text-field (1–20, default 5) |
frontend/src/views/Dashboard.vue |
Replace "Sources Needing Attention" card body; add platformHealth, failingSources, totalFailingCount, failureThreshold computeds; fetch settings on mount; remove sourcesWithErrors computed and the old empty state; small scoped CSS |
Non-goals
- No changes to
Source.error_countincrement/reset behavior — the logic inbackend/app/tasks/downloads.pyalready has the correct semantics. - No alembic migration (the settings table is key-value; new keys arrive via
DEFAULT_SETTINGS). - No new API endpoints.
- No click-through navigation on platform rows — bars are pure display.
- No changes to
Subscriptions.vueorDownloads.vue. - No per-subscription health view (only per-platform).