docs: add failure display design spec
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>
This commit is contained in:
@@ -0,0 +1,238 @@
|
||||
# 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`:
|
||||
|
||||
```python
|
||||
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:
|
||||
|
||||
```vue
|
||||
<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
|
||||
|
||||
```js
|
||||
// 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
|
||||
|
||||
```vue
|
||||
<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:
|
||||
```vue
|
||||
<v-chip class="ml-2" color="error" size="small" v-if="sourcesWithErrors.length">
|
||||
{{ sourcesWithErrors.length }}
|
||||
</v-chip>
|
||||
```
|
||||
|
||||
Swap the binding to `totalFailingCount`:
|
||||
```vue
|
||||
<v-chip class="ml-2" color="error" size="small" v-if="totalFailingCount">
|
||||
{{ totalFailingCount }}
|
||||
</v-chip>
|
||||
```
|
||||
|
||||
#### Removed code
|
||||
|
||||
- `sourcesWithErrors` computed (derived from recent activity) is no longer used. Delete it.
|
||||
- The existing `v-else` empty 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)
|
||||
|
||||
```css
|
||||
.platform-label {
|
||||
min-width: 110px;
|
||||
text-transform: capitalize;
|
||||
}
|
||||
.count-label {
|
||||
min-width: 50px;
|
||||
text-align: right;
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Data flow
|
||||
|
||||
1. `Dashboard.vue` on mount fetches sources (already happens), recent activity (already happens), and settings (new — one call to `settingsStore.fetchSettings()`).
|
||||
2. `platformHealth` and `failingSources` are purely derived from `sourcesStore.sources` + `failureThreshold`.
|
||||
3. When a download completes or fails, the existing `download.completed` / `download.failed` websocket handlers already trigger `sourcesStore.fetchSources()` through `source.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 / total` against 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_count` increment/reset behavior — the logic in `backend/app/tasks/downloads.py` already 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.vue` or `Downloads.vue`.
|
||||
- No per-subscription health view (only per-platform).
|
||||
Reference in New Issue
Block a user