diff --git a/frontend/src/routes/admin/+page.svelte b/frontend/src/routes/admin/+page.svelte index f8c75ca..a0de8f5 100644 --- a/frontend/src/routes/admin/+page.svelte +++ b/frontend/src/routes/admin/+page.svelte @@ -22,6 +22,22 @@ try { await loadStats(); } catch { error = "Couldn't load stats."; } } + // Load errors: read/unread triage. Default view = unread; reading/marking clears them + // from the list (filter to see read/all). Re-load stats so the headline count tracks. + let ceFilter = $state('unread'); + async function loadClientErrors() { + try { clientErrors = await getJSON('/api/admin/client-errors?show=' + ceFilter); } catch { /* keep */ } + } + async function setCeFilter(f) { if (f === ceFilter) return; ceFilter = f; await loadClientErrors(); } + async function markCeRead(id, read) { + try { await postJSON(`/api/admin/client-errors/${id}/read`, { read }); await loadClientErrors(); await loadStats(); } + catch { /* ignore */ } + } + async function markAllCeRead() { + try { await postJSON('/api/admin/client-errors/read-all', {}); await loadClientErrors(); await loadStats(); } + catch { /* ignore */ } + } + onMount(async () => { if (!auth.ready) await refresh(); if (!auth.user || !auth.user.is_admin) { @@ -727,25 +743,39 @@
{ceFilter === 'unread' ? 'All clear — no unread load errors. ✨' : 'Nothing to show.'}
{/if} {:else if section === 'content'} @@ -1879,9 +1909,21 @@ .stat.alert { background: #f3e0e0; } .stat.alert .n { color: #9a3b3b; } + .ce-head { display: flex; align-items: baseline; gap: 14px; flex-wrap: wrap; } + .ce-tabs { display: inline-flex; align-items: center; gap: 6px; } + .ce-tab, .ce-allread { border: 1px solid var(--line); background: var(--surface); color: var(--muted); + border-radius: 999px; padding: 2px 12px; font-size: 0.78rem; font-family: var(--label); } + .ce-tab.on { background: var(--accent-soft); color: var(--accent-deep); border-color: var(--accent-soft); font-weight: 600; } + .ce-allread { margin-left: 6px; color: var(--accent-deep); } + .ce-allread:hover { background: var(--accent-soft); } + .ce-empty { color: var(--muted); font-size: 0.9rem; margin: 10px 0 0; } .cerrs { list-style: none; padding: 0; margin: 10px 0 0; display: flex; flex-direction: column; gap: 6px; } - .cerrs li { display: grid; grid-template-columns: auto 1fr auto; gap: 6px 12px; align-items: baseline; + .cerrs li { display: grid; grid-template-columns: auto 1fr auto auto; gap: 6px 12px; align-items: baseline; font-size: 0.82rem; padding: 8px 12px; background: var(--surface); border: 1px solid var(--line); border-radius: 8px; } + .cerrs li.isread { opacity: 0.5; } + .ce-mark { border: 1px solid var(--line); background: var(--surface); color: var(--muted); white-space: nowrap; + border-radius: 6px; padding: 1px 9px; font-size: 0.72rem; font-family: var(--label); } + .ce-mark:hover { border-color: var(--accent); color: var(--accent-deep); } .ce-when { color: var(--muted); white-space: nowrap; } .ce-reason { font-family: var(--label); color: #9a3b3b; } /* Layer tag — html-slow is the only incident-grade one, so it reads loudest. */ diff --git a/goodnews/api.py b/goodnews/api.py index 198146f..7b38dec 100644 --- a/goodnews/api.py +++ b/goodnews/api.py @@ -1185,15 +1185,37 @@ def create_app() -> FastAPI: return {"ok": True} @app.get("/api/admin/client-errors") - def admin_client_errors(request: Request) -> list[dict]: + def admin_client_errors(request: Request, + show: str = Query("unread", pattern="^(unread|read|all)$")) -> list[dict]: with get_conn() as conn: _require_admin(conn, request) + where = {"unread": "WHERE read_at IS NULL", "read": "WHERE read_at IS NOT NULL", "all": ""}[show] rows = conn.execute( - "SELECT reason, path, user_agent, app_version, created_at FROM client_errors ORDER BY id DESC LIMIT 20" + f"SELECT id, reason, path, user_agent, app_version, created_at, read_at " + f"FROM client_errors {where} ORDER BY id DESC LIMIT 50" ).fetchall() - # Bots stay visible in the list (tagged) but are excluded from the - # headline counts — see queries.admin_stats. - return [{**dict(r), "bot": queries.is_bot_ua(r["user_agent"])} for r in rows] + # Bots stay visible (tagged) but are excluded from the headline count — see admin_stats. + return [{**dict(r), "read": r["read_at"] is not None, + "bot": queries.is_bot_ua(r["user_agent"])} for r in rows] + + @app.post("/api/admin/client-errors/read-all") + def admin_client_errors_read_all(request: Request) -> dict: + with get_conn() as conn: + _require_admin(conn, request) + cur = conn.execute("UPDATE client_errors SET read_at = CURRENT_TIMESTAMP WHERE read_at IS NULL") + conn.commit() + return {"ok": True, "marked": cur.rowcount} + + @app.post("/api/admin/client-errors/{eid}/read") + def admin_client_error_read(eid: int, body: FeedbackReadBody, request: Request) -> dict: + with get_conn() as conn: + _require_admin(conn, request) + ts = "CURRENT_TIMESTAMP" if body.read else "NULL" + cur = conn.execute(f"UPDATE client_errors SET read_at = {ts} WHERE id = ?", (eid,)) + if cur.rowcount == 0: + raise HTTPException(status_code=404, detail="load error not found") + conn.commit() + return {"ok": True, "read": body.read} @app.post("/api/feedback") def submit_feedback(body: FeedbackBody, request: Request, background_tasks: BackgroundTasks) -> dict: diff --git a/goodnews/db.py b/goodnews/db.py index 7bae853..58187d9 100644 --- a/goodnews/db.py +++ b/goodnews/db.py @@ -651,6 +651,11 @@ def _migrate(conn: sqlite3.Connection) -> None: if fb_cols and "read_at" not in fb_cols: conn.execute("ALTER TABLE feedback ADD COLUMN read_at TEXT") + # client_errors.read_at — admin triages load-error beacons (default view = unread). + ce_cols = {row["name"] for row in conn.execute("PRAGMA table_info(client_errors)")} + if ce_cols and "read_at" not in ce_cols: + conn.execute("ALTER TABLE client_errors ADD COLUMN read_at TEXT") + # feedback_replies.message_html (rendered Markdown subset) added later. rep_cols = {row["name"] for row in conn.execute("PRAGMA table_info(feedback_replies)")} if rep_cols and "message_html" not in rep_cols: diff --git a/goodnews/queries.py b/goodnews/queries.py index cca3f48..43e514e 100644 --- a/goodnews/queries.py +++ b/goodnews/queries.py @@ -886,6 +886,10 @@ def admin_stats(conn: sqlite3.Connection, days: int = 30) -> dict: f"SELECT COUNT(*) FROM client_errors WHERE created_at>=date('now',?) AND {_NOT_BOT_SQL}", (since,), ), + # Drives the headline now: how many still need a look (clears as you mark them read). + "unread": scalar( + f"SELECT COUNT(*) FROM client_errors WHERE read_at IS NULL AND {_NOT_BOT_SQL}", + ), }, } diff --git a/tests/test_admin.py b/tests/test_admin.py index 253415c..3406c28 100644 --- a/tests/test_admin.py +++ b/tests/test_admin.py @@ -484,6 +484,37 @@ def test_client_error_telemetry(tmp_path, monkeypatch): assert stats["today"] == 1 and stats["window"] == 1 # bot excluded from both +def test_client_error_read_unread(tmp_path, monkeypatch): + app, api = _make(tmp_path, monkeypatch, admin_email="boss@x.com") + anon = TestClient(app) + for r in ("boot-timeout", "preloadError", "boot-slow"): + anon.post("/api/client-error", json={"reason": r, "path": "/"}) + tc = _signin(app, api, "boss@x.com") + # Default view is unread; all three start unread and drive the headline count. + unread = tc.get("/api/admin/client-errors").json() + assert len(unread) == 3 and all(not e["read"] for e in unread) + assert tc.get("/api/admin/stats").json()["client_errors"]["unread"] == 3 + # Mark one read → it leaves the unread view, appears under read, count drops. + eid = unread[0]["id"] + assert tc.post(f"/api/admin/client-errors/{eid}/read", json={"read": True}).json()["read"] is True + assert len(tc.get("/api/admin/client-errors?show=unread").json()) == 2 + rd = tc.get("/api/admin/client-errors?show=read").json() + assert len(rd) == 1 and rd[0]["id"] == eid and rd[0]["read"] is True + assert len(tc.get("/api/admin/client-errors?show=all").json()) == 3 + assert tc.get("/api/admin/stats").json()["client_errors"]["unread"] == 2 + # Toggling back restores it to unread. + assert tc.post(f"/api/admin/client-errors/{eid}/read", json={"read": False}).json()["read"] is False + assert len(tc.get("/api/admin/client-errors?show=unread").json()) == 3 + # Mark-all clears the unread view in one go. + assert tc.post("/api/admin/client-errors/read-all").json()["marked"] == 3 + assert tc.get("/api/admin/client-errors?show=unread").json() == [] + assert tc.get("/api/admin/stats").json()["client_errors"]["unread"] == 0 + # Unknown id 404s; both new routes are admin-gated. + assert tc.post("/api/admin/client-errors/99999/read", json={"read": True}).status_code == 404 + assert anon.post("/api/admin/client-errors/read-all").status_code == 401 + assert anon.post(f"/api/admin/client-errors/{eid}/read", json={"read": True}).status_code == 401 + + def test_wordsearch_theme_admin(tmp_path, monkeypatch): app, api = _make(tmp_path, monkeypatch, admin_email="boss@x.com") assert TestClient(app).get("/api/admin/wordsearch/themes").status_code == 401 # gated