“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)