Source management console: pause/resume, flag/clear, decision metrics
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) <noreply@anthropic.com>
This commit is contained in:
@@ -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 @@
|
||||
</div>
|
||||
|
||||
{:else if section === 'sources'}
|
||||
<h2>Source health</h2>
|
||||
<p class="sub2">{healthy} healthy · {resting} resting · {flagged} flagged · {sources.length} active</p>
|
||||
<h2>Sources</h2>
|
||||
<p class="sub2">{healthy} healthy · {resting} resting · {flagged} flagged · {paused} paused · {sources.length} total</p>
|
||||
<div class="filterchips">
|
||||
{#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)}
|
||||
<button class="chip" class:on={srcFilter === key} onclick={() => (srcFilter = key)}>{label}</button>
|
||||
{/each}
|
||||
</div>
|
||||
<div class="tablewrap">
|
||||
<table class="srctable">
|
||||
<thead>
|
||||
<tr><th>Source</th><th class="num">Served</th><th>Last success</th><th>Next poll</th><th class="num">Fails</th><th>Status</th></tr>
|
||||
<tr>
|
||||
<th>Source</th><th class="num">Served</th><th class="num">Accept</th><th class="num">Dup</th>
|
||||
<th>Last success</th><th>Next poll</th><th class="num">Fails</th><th>Status</th><th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each shownSources as s (s.id)}
|
||||
<tr class:warn={s.failures > 0} class:flag={s.review_flag}>
|
||||
<td class="sname">{s.name}{#if s.category}<span class="cat">{s.category}</span>{/if}</td>
|
||||
<tr class:warn={s.active && s.failures > 0} class:flag={s.review_flag} class:paused={!s.active}>
|
||||
<td class="sname">
|
||||
{s.name}{#if s.category}<span class="cat">{s.category}</span>{/if}
|
||||
{#if s.review_flag && s.review_reason}<span class="rr">“{s.review_reason}”</span>{/if}
|
||||
</td>
|
||||
<td class="num">{s.served}</td>
|
||||
<td class="num">{s.acceptance_rate != null ? s.acceptance_rate + '%' : '—'}</td>
|
||||
<td class="num">{s.duplicate_rate != null ? s.duplicate_rate + '%' : '—'}</td>
|
||||
<td class="dim">{s.last_success_at ? fwhen(s.last_success_at) : '—'}</td>
|
||||
<td class="dim">{fwhen(s.next_due_at)}</td>
|
||||
<td class="dim">{s.active ? fwhen(s.next_due_at) : '—'}</td>
|
||||
<td class="num">{s.failures || ''}</td>
|
||||
<td class="status">
|
||||
{#if s.failures > 0}<span class="bad" title={s.last_error || ''}>⚠ resting{#if s.review_flag} · review{/if}</span>
|
||||
{#if !s.active}<span class="paustxt">paused</span>
|
||||
{:else if s.failures > 0}<span class="bad" title={s.last_error || ''}>⚠ resting{#if s.review_flag} · review{/if}</span>
|
||||
{:else if s.review_flag}<span class="flagtxt">⚑ review</span>
|
||||
{:else}<span class="good">✓ ok</span>{/if}
|
||||
</td>
|
||||
<td class="rowactions">
|
||||
<button class="act" onclick={() => toggleActive(s)}>{s.active ? 'Pause' : 'Resume'}</button>
|
||||
<button class="act" onclick={() => toggleReview(s)}>{s.review_flag ? 'Clear' : 'Flag'}</button>
|
||||
</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<p class="legend2">“served” = accepted articles live from that source · times in your local zone</p>
|
||||
<p class="legend2">“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</p>
|
||||
|
||||
{:else if section === 'audience'}
|
||||
<section>
|
||||
@@ -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; }
|
||||
|
||||
@@ -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:
|
||||
|
||||
+21
-8
@@ -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")]
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user