From 84bc5b0267657fd0751c3dd7f83d0a43f8e38015 Mon Sep 17 00:00:00 2001 From: jay Date: Mon, 8 Jun 2026 14:04:40 -0400 Subject: [PATCH] Source management console: pause/resume, flag/clear, decision metrics MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Turn the Sources tab into a real management console (per Codex): * source_health now lists ALL sources (active + paused) with backing metrics: served / accepted_total / total_articles / duplicates + acceptance & duplicate rates + review_reason, alongside last success/attempt, next poll, failures. * Admin endpoints (gated, 404 on missing): POST sources/{id}/active (pause/ resume) and /review (flag/clear with reason). * Pausing only stops future polling — the feed query has no active filter, so a paused source's accepted articles stay live. * Frontend: metric table + Paused filter + per-row Pause/Resume & Flag/Clear (optimistic, revert on failure). Attention 'resting' now scoped to active. Retire/Delete intentionally deferred (distinct lifecycle state, later). Co-Authored-By: Claude Opus 4.8 (1M context) --- frontend/src/routes/admin/+page.svelte | 74 +++++++++++++++++++++----- goodnews/api.py | 34 ++++++++++++ goodnews/queries.py | 29 +++++++--- tests/test_admin.py | 42 +++++++++++++++ tests/test_dashboard.py | 6 +-- 5 files changed, 160 insertions(+), 25 deletions(-) diff --git a/frontend/src/routes/admin/+page.svelte b/frontend/src/routes/admin/+page.svelte index f774ec9..ad8cd71 100644 --- a/frontend/src/routes/admin/+page.svelte +++ b/frontend/src/routes/admin/+page.svelte @@ -49,19 +49,43 @@ } let sources = $derived(stats?.sources ?? []); - let healthy = $derived(sources.filter((s) => !s.failures && !s.review_flag).length); - let resting = $derived(sources.filter((s) => s.failures > 0).length); + let healthy = $derived(sources.filter((s) => s.active && !s.failures && !s.review_flag).length); + let resting = $derived(sources.filter((s) => s.active && s.failures > 0).length); let flagged = $derived(sources.filter((s) => s.review_flag).length); + let paused = $derived(sources.filter((s) => !s.active).length); // Sources table filter. let srcFilter = $state('all'); let shownSources = $derived( - srcFilter === 'healthy' ? sources.filter((s) => !s.failures && !s.review_flag) - : srcFilter === 'resting' ? sources.filter((s) => s.failures > 0) + srcFilter === 'healthy' ? sources.filter((s) => s.active && !s.failures && !s.review_flag) + : srcFilter === 'resting' ? sources.filter((s) => s.active && s.failures > 0) : srcFilter === 'flagged' ? sources.filter((s) => s.review_flag) - : sources + : srcFilter === 'paused' ? sources.filter((s) => !s.active) + : sources ); + // Pause/resume (stops polling only; existing articles stay live) + flag/clear. + // Optimistic against the deep-reactive stats; revert on failure. + async function toggleActive(s) { + const next = !s.active; + s.active = next ? 1 : 0; + try { await postJSON(`/api/admin/sources/${s.id}/active`, { active: next }); } + catch { s.active = next ? 0 : 1; } + } + async function toggleReview(s) { + if (s.review_flag) { + const prevR = s.review_reason; + s.review_flag = 0; s.review_reason = null; + try { await postJSON(`/api/admin/sources/${s.id}/review`, { flag: false }); } + catch { s.review_flag = 1; s.review_reason = prevR; } + } else { + const reason = (prompt('Flag this source for review — optional note:') || '').trim(); + s.review_flag = 1; s.review_reason = reason || null; + try { await postJSON(`/api/admin/sources/${s.id}/review`, { flag: true, reason: reason || null }); } + catch { s.review_flag = 0; s.review_reason = null; } + } + } + // Feedback inbox: filter + read/unread + delete. let fbCat = $state('all'); // 'all' | 'unread' | a category let shownFeedback = $derived( @@ -218,37 +242,50 @@ {:else if section === 'sources'} -

Source health

-

{healthy} healthy · {resting} resting · {flagged} flagged · {sources.length} active

+

Sources

+

{healthy} healthy · {resting} resting · {flagged} flagged · {paused} paused · {sources.length} total

- {#each [['all', 'All'], ['healthy', 'Healthy'], ['resting', 'Resting'], ['flagged', 'Flagged']] as [key, label] (key)} + {#each [['all', 'All'], ['healthy', 'Healthy'], ['resting', 'Resting'], ['flagged', 'Flagged'], ['paused', 'Paused']] as [key, label] (key)} {/each}
- + + + + {#each shownSources as s (s.id)} - 0} class:flag={s.review_flag}> - + 0} class:flag={s.review_flag} class:paused={!s.active}> + + + - + + {/each}
SourceServedLast successNext pollFailsStatus
SourceServedAcceptDupLast successNext pollFailsStatusActions
{s.name}{#if s.category}{s.category}{/if}
+ {s.name}{#if s.category}{s.category}{/if} + {#if s.review_flag && s.review_reason}“{s.review_reason}”{/if} + {s.served}{s.acceptance_rate != null ? s.acceptance_rate + '%' : '—'}{s.duplicate_rate != null ? s.duplicate_rate + '%' : '—'} {s.last_success_at ? fwhen(s.last_success_at) : '—'}{fwhen(s.next_due_at)}{s.active ? fwhen(s.next_due_at) : '—'} {s.failures || ''} - {#if s.failures > 0}⚠ resting{#if s.review_flag} · review{/if} + {#if !s.active}paused + {:else if s.failures > 0}⚠ resting{#if s.review_flag} · review{/if} {:else if s.review_flag}⚑ review {:else}✓ ok{/if} + + +
-

“served” = accepted articles live from that source · times in your local zone

+

“served” = accepted, non-duplicate articles live · accept/dup % is of all ingested · pausing stops polling but keeps existing articles live · times in your local zone

{:else if section === 'audience'}
@@ -445,11 +482,20 @@ .srctable .dim { color: var(--muted); white-space: nowrap; font-size: 0.82rem; } .srctable .sname { font-weight: 600; color: var(--ink); } .srctable .sname .cat { display: block; font-weight: 400; font-size: 0.72rem; color: var(--muted); text-transform: capitalize; } + .srctable .sname .rr { display: block; font-weight: 400; font-size: 0.72rem; color: var(--accent-deep); font-style: italic; } .srctable tr.warn { background: #fdf3e3; } .srctable tr.flag { background: #eef4f8; } + .srctable tr.paused { opacity: 0.6; } .srctable .status .bad { color: #875a16; } .srctable .status .flagtxt { color: var(--accent-deep); } .srctable .status .good { color: #3f7048; } + .srctable .status .paustxt { color: var(--muted); font-style: italic; } + .srctable .rowactions { white-space: nowrap; } + .srctable .rowactions .act { + background: none; border: 1px solid var(--line); color: var(--accent-deep); + border-radius: 999px; padding: 3px 11px; font-size: 0.76rem; cursor: pointer; margin-right: 5px; + } + .srctable .rowactions .act:hover { border-color: var(--accent); } .count { color: var(--muted); font-weight: 400; font-size: 0.9rem; } ul.fb { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 10px; } diff --git a/goodnews/api.py b/goodnews/api.py index c40ebc6..89a980f 100644 --- a/goodnews/api.py +++ b/goodnews/api.py @@ -381,6 +381,15 @@ class FeedbackReadBody(BaseModel): read: bool = True +class SourceActiveBody(BaseModel): + active: bool = True + + +class SourceReviewBody(BaseModel): + flag: bool = False + reason: str | None = None + + _FEEDBACK_CATEGORIES = {"idea", "concern", "bug", "praise", "other"} # The only event kinds we record. All aggregate, non-personal. @@ -813,6 +822,31 @@ def create_app() -> FastAPI: conn.commit() return {"ok": True} + @app.post("/api/admin/sources/{sid}/active") + def admin_source_active(sid: int, body: SourceActiveBody, request: Request) -> dict: + # Pause/resume a feed. Pausing only stops future polling — its already- + # accepted articles stay live (the feed query doesn't filter on active). + with get_conn() as conn: + _require_admin(conn, request) + cur = conn.execute("UPDATE sources SET active = ? WHERE id = ?", (1 if body.active else 0, sid)) + if cur.rowcount == 0: + raise HTTPException(status_code=404, detail="source not found") + conn.commit() + return {"ok": True, "active": body.active} + + @app.post("/api/admin/sources/{sid}/review") + def admin_source_review(sid: int, body: SourceReviewBody, request: Request) -> dict: + with get_conn() as conn: + _require_admin(conn, request) + cur = conn.execute( + "UPDATE sources SET review_flag = ?, review_reason = ? WHERE id = ?", + (1 if body.flag else 0, (body.reason or None) if body.flag else None, sid), + ) + if cur.rowcount == 0: + raise HTTPException(status_code=404, detail="source not found") + conn.commit() + return {"ok": True, "flag": body.flag} + @app.get("/api/admin/stats") def admin_stats(request: Request) -> dict: with get_conn() as conn: diff --git a/goodnews/queries.py b/goodnews/queries.py index 68f3bee..982d82a 100644 --- a/goodnews/queries.py +++ b/goodnews/queries.py @@ -282,21 +282,28 @@ def content_stats(conn: sqlite3.Connection) -> dict: def source_health(conn: sqlite3.Connection) -> list[dict]: - """Per active source: failure streak, last error, accepted contribution, and - the computed next-poll time (so the backoff/'resting until' state is visible). + """Per source (active AND paused), the data an operator needs to manage feeds: + failure streak, last error/success/attempt, computed next poll, and the + backing metrics (served/accepted/total counts + acceptance & duplicate rates) + so Pause/Flag decisions aren't vibes-based. next_due_at = last attempt + MIN(cap, interval * (1 + consecutive_failures)), - mirroring feeds.due_source_rows; NULL last attempt means "due now". + mirroring feeds.due_source_rows; NULL last attempt means "due now". Paused + sources are included (their articles stay live; only polling stops). """ rows = conn.execute( """ SELECT s.id, s.name, s.default_category AS category, s.active, - s.consecutive_failures AS failures, s.review_flag, + s.consecutive_failures AS failures, s.review_flag, s.review_reason, s.poll_interval_minutes AS interval_minutes, s.last_success_at, s.last_error_at, substr(s.last_error, 1, 160) AS last_error, (SELECT MAX(r.finished_at) FROM ingest_runs r WHERE r.source_id = s.id AND r.finished_at IS NOT NULL) AS last_attempt, + (SELECT COUNT(*) FROM articles a WHERE a.source_id = s.id) AS total_articles, + (SELECT COUNT(*) FROM articles a JOIN article_scores sc ON sc.article_id = a.id + WHERE a.source_id = s.id AND sc.accepted = 1) AS accepted_total, + (SELECT COUNT(*) FROM articles a WHERE a.source_id = s.id AND a.duplicate_of IS NOT NULL) AS duplicates, (SELECT COUNT(*) FROM articles a JOIN article_scores sc ON sc.article_id = a.id WHERE a.source_id = s.id AND sc.accepted = 1 AND a.duplicate_of IS NULL) AS served, datetime( @@ -305,12 +312,18 @@ def source_health(conn: sqlite3.Connection) -> list[dict]: '+' || MIN(?, s.poll_interval_minutes * (1 + s.consecutive_failures)) || ' minutes' ) AS next_due_at FROM sources s - WHERE s.active = 1 - ORDER BY s.consecutive_failures DESC, served DESC, s.name + ORDER BY s.active DESC, s.consecutive_failures DESC, served DESC, s.name """, (MAX_BACKOFF_MINUTES,), ).fetchall() - return [dict(r) for r in rows] + out = [] + for r in rows: + d = dict(r) + total = d["total_articles"] or 0 + d["acceptance_rate"] = round(100 * d["accepted_total"] / total) if total else None + d["duplicate_rate"] = round(100 * d["duplicates"] / total) if total else None + out.append(d) + return out def _attention(content: dict, sources: list[dict], feedback_unread: int) -> list[dict]: @@ -320,7 +333,7 @@ def _attention(content: dict, sources: list[dict], feedback_unread: int) -> list items: list[dict] = [] n = lambda c: "" if c == 1 else "s" # noqa: E731 — tiny pluralizer - resting = [s for s in sources if (s.get("failures") or 0) > 0] + resting = [s for s in sources if s.get("active") and (s.get("failures") or 0) > 0] if resting: items.append({"level": "warn", "text": f"{len(resting)} source{n(len(resting))} backing off after failures"}) flagged = [s for s in sources if s.get("review_flag")] diff --git a/tests/test_admin.py b/tests/test_admin.py index 1d1a1e8..385eff6 100644 --- a/tests/test_admin.py +++ b/tests/test_admin.py @@ -55,3 +55,45 @@ def test_admin_stats_shape(tmp_path, monkeypatch): assert set(stats) >= {"visitors", "returning", "once", "top_articles", "top_groupings", "top_topics", "shares", "daily"} assert stats["top_articles"][0]["id"] == 1 and stats["top_articles"][0]["opens"] == 1 assert any(g["tag"] == "science" for g in stats["top_groupings"]) + + +def test_source_actions_pause_resume_flag(tmp_path, monkeypatch): + app, api = _make(tmp_path, monkeypatch, admin_email="boss@x.com") + tc = _signin(app, api, "boss@x.com") + # pause + assert tc.post("/api/admin/sources/1/active", json={"active": False}).json()["active"] is False + st = tc.get("/api/admin/stats").json() + src = next(s for s in st["sources"] if s["id"] == 1) + assert src["active"] == 0 # paused source still listed (manageable) + # resume + tc.post("/api/admin/sources/1/active", json={"active": True}) + # flag with reason, then clear + tc.post("/api/admin/sources/1/review", json={"flag": True, "reason": "spammy lately"}) + src = next(s for s in tc.get("/api/admin/stats").json()["sources"] if s["id"] == 1) + assert src["review_flag"] == 1 and src["review_reason"] == "spammy lately" + tc.post("/api/admin/sources/1/review", json={"flag": False}) + src = next(s for s in tc.get("/api/admin/stats").json()["sources"] if s["id"] == 1) + assert src["review_flag"] == 0 and src["review_reason"] is None + # 404 on missing + assert tc.post("/api/admin/sources/999/active", json={"active": False}).status_code == 404 + + +def test_source_actions_gated(tmp_path, monkeypatch): + app, api = _make(tmp_path, monkeypatch, admin_email="boss@x.com") + anon = TestClient(app) + assert anon.post("/api/admin/sources/1/active", json={"active": False}).status_code == 401 + assert anon.post("/api/admin/sources/1/review", json={"flag": True}).status_code == 401 + + +def test_source_health_includes_metrics(tmp_path, monkeypatch): + import sqlite3 + from goodnews import queries + app, api = _make(tmp_path, monkeypatch, admin_email="boss@x.com") + import os + c = sqlite3.connect(os.environ["GOODNEWS_DB"]); c.row_factory = sqlite3.Row + sh = queries.source_health(c) + s = sh[0] + for key in ("active", "served", "accepted_total", "total_articles", "duplicates", + "acceptance_rate", "duplicate_rate", "review_reason", "next_due_at"): + assert key in s, f"missing {key}" + assert s["served"] == 1 and s["acceptance_rate"] == 100 diff --git a/tests/test_dashboard.py b/tests/test_dashboard.py index 8c1507a..c729ca0 100644 --- a/tests/test_dashboard.py +++ b/tests/test_dashboard.py @@ -53,9 +53,9 @@ def test_attention_flags_resting_flagged_and_brief(): from goodnews import queries content = {"served": 100, "with_image": 90, "latest_brief_size": 5} sources = [ - {"failures": 3, "review_flag": 0}, - {"failures": 0, "review_flag": 1}, - {"failures": 0, "review_flag": 0}, + {"active": 1, "failures": 3, "review_flag": 0}, + {"active": 1, "failures": 0, "review_flag": 1}, + {"active": 1, "failures": 0, "review_flag": 0}, ] items = queries._attention(content, sources, feedback_unread=2) texts = " | ".join(i["text"] for i in items)