docs: add failure display implementation plan
4-task plan: backend default setting, Settings page input, Dashboard script computeds, Dashboard template replacement. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,483 @@
|
||||
# Failure Display Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Introduce a configurable "failing source" threshold (default 5 consecutive failures) and surface it on the Dashboard as per-platform health bars plus a strict failing-source list.
|
||||
|
||||
**Architecture:** Four small changes. (1) Backend adds one default setting key. (2) Settings page adds one number input. (3) Dashboard script adds a settings fetch and three derived computeds. (4) Dashboard template replaces the "Sources Needing Attention" card body with platform bars + a filtered table, removing the old recent-failure-based list.
|
||||
|
||||
**Tech Stack:** Python/Quart + SQLAlchemy backend, Vue 3 + Vuetify 3 + Pinia frontend. No test infrastructure in the repo; verification is via `npm run build`, spec-file diffs, and visual inspection after the user deploys.
|
||||
|
||||
Spec: `docs/superpowers/specs/2026-04-17-failure-display-design.md`
|
||||
|
||||
---
|
||||
|
||||
## File Structure
|
||||
|
||||
| File | Role | Change |
|
||||
|---|---|---|
|
||||
| `backend/app/models/setting.py` | DEFAULT_SETTINGS dict | Add `"dashboard.failure_threshold": 5` |
|
||||
| `frontend/src/views/Settings.vue` | Application settings page | Add Dashboard section with threshold input |
|
||||
| `frontend/src/views/Dashboard.vue` | Main dashboard page | Add settings store + computeds; replace "Sources Needing Attention" card body; remove obsolete `sourcesWithErrors`; add scoped CSS |
|
||||
|
||||
No new files. No store changes — `useSettingsStore` in `frontend/src/stores/settings.js` already exposes `fetchSettings()` and a reactive `settings` ref.
|
||||
|
||||
---
|
||||
|
||||
## Task 1: Add default threshold to backend settings
|
||||
|
||||
**Files:**
|
||||
- Modify: `backend/app/models/setting.py:39-46`
|
||||
|
||||
- [ ] **Step 1: Add the new key to `DEFAULT_SETTINGS`**
|
||||
|
||||
Open `backend/app/models/setting.py` and replace the `DEFAULT_SETTINGS` dict (currently lines 39–46) with:
|
||||
|
||||
```python
|
||||
# Default settings that should exist
|
||||
DEFAULT_SETTINGS = {
|
||||
"download.parallel_limit": 3,
|
||||
"download.rate_limit": 3.0,
|
||||
"download.retry_count": 3,
|
||||
"download.schedule_interval": 28800, # 8 hours
|
||||
"notification.enabled": False,
|
||||
"notification.webhook_url": None,
|
||||
"dashboard.failure_threshold": 5,
|
||||
}
|
||||
```
|
||||
|
||||
Why this is enough: `settings` is a key-value table. `GET /api/settings` (in `backend/app/api/settings.py`) merges stored rows on top of `DEFAULT_SETTINGS` and returns the result. A brand-new key appears in the API response immediately; existing installations get the default until/unless the user saves a different value from the Settings page. No migration, no new endpoint.
|
||||
|
||||
- [ ] **Step 2: Commit**
|
||||
|
||||
```bash
|
||||
git add backend/app/models/setting.py
|
||||
git commit -m "feat(settings): add dashboard.failure_threshold default (5)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 2: Add threshold input to the Settings page
|
||||
|
||||
**Files:**
|
||||
- Modify: `frontend/src/views/Settings.vue` (template only)
|
||||
|
||||
- [ ] **Step 1: Insert a Dashboard section above the Notifications divider**
|
||||
|
||||
In `frontend/src/views/Settings.vue`, locate the block that ends the Download Settings section and starts the Notifications section:
|
||||
|
||||
```vue
|
||||
</v-row>
|
||||
|
||||
<v-divider class="my-6" />
|
||||
|
||||
<!-- Notification Settings -->
|
||||
<h3 class="text-h6 mb-4">Notifications</h3>
|
||||
```
|
||||
|
||||
Replace it with:
|
||||
|
||||
```vue
|
||||
</v-row>
|
||||
|
||||
<v-divider class="my-6" />
|
||||
|
||||
<!-- Dashboard Settings -->
|
||||
<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>
|
||||
|
||||
<v-divider class="my-6" />
|
||||
|
||||
<!-- Notification Settings -->
|
||||
<h3 class="text-h6 mb-4">Notifications</h3>
|
||||
```
|
||||
|
||||
Why this is enough: `saveSettings()` at `Settings.vue:463` calls `settingsStore.updateSettings(settings.value)` which PATCHes every key in the object. A new `v-model` binding flows through without any handler changes.
|
||||
|
||||
- [ ] **Step 2: Verify the frontend still builds**
|
||||
|
||||
Run from the repo root:
|
||||
```bash
|
||||
cd frontend && npm run build
|
||||
```
|
||||
Expected: build completes with no errors. A Vuetify warning about `persistent-hint` is non-fatal if it appears.
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add frontend/src/views/Settings.vue
|
||||
git commit -m "feat(settings-ui): expose dashboard failure threshold"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 3: Dashboard script — settings store, fetch, computeds
|
||||
|
||||
**Files:**
|
||||
- Modify: `frontend/src/views/Dashboard.vue` (`<script setup>` block only)
|
||||
|
||||
- [ ] **Step 1: Import the settings store**
|
||||
|
||||
Find the existing imports at the top of `<script setup>` (around line 297–303):
|
||||
|
||||
```js
|
||||
<script setup>
|
||||
import { ref, computed, onMounted, onUnmounted } from 'vue'
|
||||
import { useSourcesStore } from '../stores/sources'
|
||||
import { useDownloadsStore } from '../stores/downloads'
|
||||
import { useCredentialsStore } from '../stores/credentials'
|
||||
import { useNotificationStore } from '../stores/notifications'
|
||||
import { settingsApi, downloadsApi } from '../services/api'
|
||||
```
|
||||
|
||||
Add one line for the settings store:
|
||||
|
||||
```js
|
||||
<script setup>
|
||||
import { ref, computed, onMounted, onUnmounted } from 'vue'
|
||||
import { useSourcesStore } from '../stores/sources'
|
||||
import { useDownloadsStore } from '../stores/downloads'
|
||||
import { useCredentialsStore } from '../stores/credentials'
|
||||
import { useNotificationStore } from '../stores/notifications'
|
||||
import { useSettingsStore } from '../stores/settings'
|
||||
import { settingsApi, downloadsApi } from '../services/api'
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Instantiate the store**
|
||||
|
||||
Find the block of store instantiations (around lines 305–308):
|
||||
|
||||
```js
|
||||
const sourcesStore = useSourcesStore()
|
||||
const downloadsStore = useDownloadsStore()
|
||||
const credentialsStore = useCredentialsStore()
|
||||
const notifications = useNotificationStore()
|
||||
```
|
||||
|
||||
Add the settings store instance:
|
||||
|
||||
```js
|
||||
const sourcesStore = useSourcesStore()
|
||||
const downloadsStore = useDownloadsStore()
|
||||
const credentialsStore = useCredentialsStore()
|
||||
const notifications = useNotificationStore()
|
||||
const settingsStore = useSettingsStore()
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Fetch settings in `loadDashboardData`**
|
||||
|
||||
Locate `loadDashboardData()` (around line 367). The current body is:
|
||||
|
||||
```js
|
||||
function loadDashboardData() {
|
||||
// Fire off all requests in parallel, don't block UI
|
||||
// Each section handles its own loading state
|
||||
sourcesStore.fetchSources().catch(e => console.error('Failed to fetch sources:', e))
|
||||
credentialsStore.fetchCredentials().catch(e => console.error('Failed to fetch credentials:', e))
|
||||
|
||||
downloadsStore.fetchStats()
|
||||
.catch(e => console.error('Failed to fetch stats:', e))
|
||||
.finally(() => loadingStats.value = false)
|
||||
|
||||
downloadsStore.fetchRecentActivity({ limit: 10 })
|
||||
.catch(e => console.error('Failed to fetch recent activity:', e))
|
||||
.finally(() => loadingActivity.value = false)
|
||||
|
||||
fetchActiveDownloads()
|
||||
|
||||
// Storage stats can be slow - load independently
|
||||
fetchStorageStats()
|
||||
}
|
||||
```
|
||||
|
||||
Add one line — the `settingsStore.fetchSettings()` call — right after the `credentialsStore.fetchCredentials()` line:
|
||||
|
||||
```js
|
||||
function loadDashboardData() {
|
||||
// Fire off all requests in parallel, don't block UI
|
||||
// Each section handles its own loading state
|
||||
sourcesStore.fetchSources().catch(e => console.error('Failed to fetch sources:', e))
|
||||
credentialsStore.fetchCredentials().catch(e => console.error('Failed to fetch credentials:', e))
|
||||
settingsStore.fetchSettings().catch(e => console.error('Failed to fetch settings:', e))
|
||||
|
||||
downloadsStore.fetchStats()
|
||||
.catch(e => console.error('Failed to fetch stats:', e))
|
||||
.finally(() => loadingStats.value = false)
|
||||
|
||||
downloadsStore.fetchRecentActivity({ limit: 10 })
|
||||
.catch(e => console.error('Failed to fetch recent activity:', e))
|
||||
.finally(() => loadingActivity.value = false)
|
||||
|
||||
fetchActiveDownloads()
|
||||
|
||||
// Storage stats can be slow - load independently
|
||||
fetchStorageStats()
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Add platform order constant, threshold computed, and two derived computeds**
|
||||
|
||||
Find the `sourcesWithErrors` computed (currently lines 329–347). Leave it alone for now — Task 4 removes it. Immediately above it, after the line `const recentActivity = computed(() => downloadsStore.recentActivity)`, insert:
|
||||
|
||||
```js
|
||||
// Fixed display order for platform health bars
|
||||
const PLATFORM_ORDER = [
|
||||
'patreon', 'subscribestar', 'hentaifoundry',
|
||||
'discord', 'pixiv', 'deviantart',
|
||||
]
|
||||
|
||||
// Read threshold from settings, default 5 if unset
|
||||
const failureThreshold = computed(() =>
|
||||
settingsStore.settings?.['dashboard.failure_threshold'] ?? 5
|
||||
)
|
||||
|
||||
// Per-platform health 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
|
||||
const failing = sources.filter(
|
||||
s => (s.error_count || 0) >= failureThreshold.value
|
||||
).length
|
||||
return { platform, total: sources.length, failing }
|
||||
})
|
||||
.filter(Boolean)
|
||||
})
|
||||
|
||||
// Sources currently in 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)
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Verify the frontend still builds**
|
||||
|
||||
```bash
|
||||
cd frontend && npm run build
|
||||
```
|
||||
Expected: build completes with no errors.
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add frontend/src/views/Dashboard.vue
|
||||
git commit -m "feat(dashboard): add failure-threshold computeds backed by settings store"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 4: Dashboard template — replace card body, remove obsolete code, add CSS
|
||||
|
||||
This task rewrites the "Sources Needing Attention" card to show platform health bars plus the strict failing-source list, and deletes the now-dead `sourcesWithErrors` computed. Do the steps in order — the removal at the end relies on the template no longer referencing the old computed.
|
||||
|
||||
**Files:**
|
||||
- Modify: `frontend/src/views/Dashboard.vue` (template, script cleanup, scoped CSS)
|
||||
|
||||
- [ ] **Step 1: Replace the card header chip binding**
|
||||
|
||||
Find the "Sources Needing Attention" card title (around lines 239–244):
|
||||
|
||||
```vue
|
||||
<v-card-title>
|
||||
Sources Needing Attention
|
||||
<v-chip class="ml-2" color="error" size="small" v-if="sourcesWithErrors.length">
|
||||
{{ sourcesWithErrors.length }}
|
||||
</v-chip>
|
||||
</v-card-title>
|
||||
```
|
||||
|
||||
Replace the chip binding so it reads from the new total:
|
||||
|
||||
```vue
|
||||
<v-card-title>
|
||||
Sources Needing Attention
|
||||
<v-chip class="ml-2" color="error" size="small" v-if="totalFailingCount">
|
||||
{{ totalFailingCount }}
|
||||
</v-chip>
|
||||
</v-card-title>
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Replace the card body**
|
||||
|
||||
Locate the `v-card-text` that follows that title (currently lines 245–290 — the block starting with `<v-card-text>` and containing the `<v-table v-if="sourcesWithErrors.length">` and its `v-else` empty state, ending with `</v-card-text>`).
|
||||
|
||||
Replace the entire `v-card-text` block with:
|
||||
|
||||
```vue
|
||||
<v-card-text>
|
||||
<!-- 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-if="!platformHealth.length"
|
||||
class="text-caption text-medium-emphasis text-center py-2"
|
||||
>
|
||||
No enabled sources
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<v-divider class="my-3" />
|
||||
|
||||
<!-- 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-tooltip activator="parent">View in Subscriptions</v-tooltip>
|
||||
</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>
|
||||
```
|
||||
|
||||
Notes:
|
||||
- The extra "No enabled sources" line inside the Platform Health block is a defensive empty state for the edge case where the user has deleted every source. Normally `platformHealth` has at least one entry.
|
||||
- `getPlatformColor`, `getPlatformIcon`, `getSourceLabel`, `formatDate`, and `retrySource` are all existing functions in the same component — no new helpers needed.
|
||||
- `source.subscription_name` is included in the `Source.to_dict()` payload (see `backend/app/models/source.py:66`), so `getSourceLabel` works on the store's raw source objects.
|
||||
|
||||
- [ ] **Step 3: Delete the obsolete `sourcesWithErrors` computed**
|
||||
|
||||
In `<script setup>`, remove the entire `sourcesWithErrors` computed (previously lines 329–347). After removal, this block:
|
||||
|
||||
```js
|
||||
const stats = computed(() => downloadsStore.stats)
|
||||
const recentActivity = computed(() => downloadsStore.recentActivity)
|
||||
|
||||
// Fixed display order for platform health bars
|
||||
const PLATFORM_ORDER = [
|
||||
'patreon', 'subscribestar', 'hentaifoundry',
|
||||
'discord', 'pixiv', 'deviantart',
|
||||
]
|
||||
```
|
||||
|
||||
should sit directly after the platform-health computeds you added in Task 3, with nothing between `recentActivity` and `PLATFORM_ORDER` except a blank line. Verify no `sourcesWithErrors` references remain:
|
||||
|
||||
```bash
|
||||
grep -n "sourcesWithErrors" frontend/src/views/Dashboard.vue
|
||||
```
|
||||
Expected: no output.
|
||||
|
||||
- [ ] **Step 4: Add scoped CSS for label alignment**
|
||||
|
||||
Find the existing `<style scoped>` block at the bottom of `Dashboard.vue` (currently lines 601–611, containing `.download-running`). Append two rules inside that block so it becomes:
|
||||
|
||||
```vue
|
||||
<style scoped>
|
||||
.download-running {
|
||||
border-left: 3px solid rgb(var(--v-theme-primary));
|
||||
animation: pulse-border 2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes pulse-border {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.4; }
|
||||
}
|
||||
|
||||
.platform-label {
|
||||
min-width: 110px;
|
||||
text-transform: capitalize;
|
||||
}
|
||||
|
||||
.count-label {
|
||||
min-width: 50px;
|
||||
text-align: right;
|
||||
}
|
||||
</style>
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Verify the frontend builds**
|
||||
|
||||
```bash
|
||||
cd frontend && npm run build
|
||||
```
|
||||
Expected: build completes with no errors. Any warning about the removed `sourcesWithErrors` reference means Step 3 missed a spot — re-check the template.
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add frontend/src/views/Dashboard.vue
|
||||
git commit -m "feat(dashboard): replace recent-failures list with platform health bars + strict failing-source table"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Verification after all tasks
|
||||
|
||||
The user deploys separately. After deploy:
|
||||
1. Visit `/settings` — confirm the Dashboard section shows a "Failing source threshold" input defaulting to 5.
|
||||
2. Change the threshold, Save, reload — confirm the value persists.
|
||||
3. Visit `/` — confirm the "Sources Needing Attention" card shows:
|
||||
- One bar per platform with ≥1 enabled source, in the fixed order.
|
||||
- Green full bars for platforms with no failing sources.
|
||||
- Proportional red fill on platforms where some sources have `error_count >= threshold`.
|
||||
- A table listing sources in failing state (if any), sorted by `last_check` DESC.
|
||||
- "No sources in failing state" caption when the list is empty.
|
||||
4. Lower the threshold on the Settings page — confirm additional sources appear in the failing list when their `error_count` crosses the new (lower) line.
|
||||
|
||||
No automated test verification is available. The build passing and the Dashboard rendering without console errors is the bar.
|
||||
Reference in New Issue
Block a user