admin: read/unread triage for load errors (unread by default, mark read/all)
The load-error log had no way to clear reviewed entries. Add a read_at column to
client_errors and a read/unread model mirroring the feedback inbox:
- GET /api/admin/client-errors?show=unread|read|all (default unread; returns id+read)
- POST /api/admin/client-errors/read-all (mark all unread read)
- POST /api/admin/client-errors/{id}/read {read: bool} (per-row toggle)
Headline stat is now "Unread load errors" (admin_stats.client_errors.unread), so the
red badge clears as you triage. Admin UI: Unread/Read/All tabs, a "Mark all read"
button, and a per-row ✓/↩ toggle; reading an entry drops it from the default view.
14-day auto-prune still bounds the table. Tests cover filter, toggle, mark-all,
404, and gating.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
+27
-5
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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}",
|
||||
),
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user