feat(dashboards): scheduler health strip, failing-source rollup, 24h activity sparkline, credential staleness nudge

D1 scheduler visibility: AppSetting last-tick stamp on every Beat tick +
GET /api/sources/schedule-status (last_tick_at/next_due_at/due_now/auto_sources)
+ SchedulerStatusBar on the Subscriptions tab (re-polled every 30s).

D2 failing-source rollup: ?failing=true on the sources list + FailingSourcesCard
on Downloads with per-source and bulk "retry" (re-runs the feed via /check).

D3 activity sparkline: GET /api/downloads/activity hourly buckets + CSS bar
chart by the stat chips (failures stacked in error color); refreshes on live poll.

D4 credential staleness: surface last_verified age + "re-verify recommended"
warning past 30d; also fixes the dead last_verified_at field-name mismatch so
the verification row renders at all.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-28 08:30:14 -04:00
parent 215a8993a1
commit 2358cedf3e
15 changed files with 623 additions and 20 deletions
+20
View File
@@ -126,3 +126,23 @@ async def test_stats_window_hours_rejects_out_of_range(client):
assert resp.status_code == 400
resp = await client.get("/api/downloads/stats?window_hours=bogus")
assert resp.status_code == 400
@pytest.mark.asyncio
async def test_activity_returns_fixed_bucket_array(client, seed):
resp = await client.get("/api/downloads/activity")
assert resp.status_code == 200
body = await resp.get_json()
assert body["hours"] == 24
assert len(body["buckets"]) == 24
assert sum(b["total"] for b in body["buckets"]) == 2
# Both seed events were created "now" → land in the newest bucket.
newest = body["buckets"][-1]
assert newest["ok"] == 1
assert newest["error"] == 1
@pytest.mark.asyncio
async def test_activity_rejects_bogus_hours(client):
resp = await client.get("/api/downloads/activity?hours=bogus")
assert resp.status_code == 400