From f61372e28ecf5e64e0f2989fdf336a729ed30824 Mon Sep 17 00:00:00 2001 From: Cayce Date: Mon, 19 Oct 2026 21:57:31 +0000 Subject: [PATCH] =?UTF-8?q?monitor:=20static=20status=20page=20=E2=80=94?= =?UTF-8?q?=20health,=20queue,=20gates,=20audit=20tail?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - self-contained HTML (no external assets/JS), everything html-escaped - daemon health from daemon_runs (last status + last run per daemon) - proposal queue counts by state; human-gate counters (mail drafts awaiting approval, content draft/approved) - audit tail: actor/action/result only — no detail payloads, no addresses, no subjects, no content bodies (tests assert the negatives) - DaemonRun bookkeeping like every other daemon tests: 3 new (sections render, leak-negatives, XSS escape) — 44 total --- cayceops/components/monitor/__init__.py | 1 + cayceops/components/monitor/monitor.py | 160 ++++++++++++++++++++++++ tests/test_monitor.py | 69 ++++++++++ 3 files changed, 230 insertions(+) create mode 100644 cayceops/components/monitor/__init__.py create mode 100644 cayceops/components/monitor/monitor.py create mode 100644 tests/test_monitor.py diff --git a/cayceops/components/monitor/__init__.py b/cayceops/components/monitor/__init__.py new file mode 100644 index 0000000..6d0ac07 --- /dev/null +++ b/cayceops/components/monitor/__init__.py @@ -0,0 +1 @@ +"""monitor package.""" diff --git a/cayceops/components/monitor/monitor.py b/cayceops/components/monitor/monitor.py new file mode 100644 index 0000000..e82f018 --- /dev/null +++ b/cayceops/components/monitor/monitor.py @@ -0,0 +1,160 @@ +"""monitor — static status page builder. + +Reads the shared database and renders a self-contained HTML file (no external +assets, no JS, everything escaped). Contains NO sensitive data: daemon health, +proposal queue counts, and recent audit activity (actor/action/result only — +no email bodies, no detail payloads, no URLs). +""" + +from __future__ import annotations + +import html +from datetime import UTC, datetime + +from sqlalchemy import func, select + +from cayceops.config import Config +from cayceops.db import session_scope +from cayceops.models import ( + AuditEntry, + ContentItem, + DaemonRun, + MailMessage, + Proposal, + ProposalStatus, +) + +DAEMONS = ("scanner", "executor", "maild", "publisher", "monitor") + +_STYLE = """""" + + +def _fmt_ts(dt) -> str: + return dt.strftime("%Y-%m-%d %H:%M:%SZ") if dt else "—" + + +def _daemon_rows(sess) -> str: + rows = [] + for name in DAEMONS: + last = ( + sess.query(DaemonRun) + .filter(DaemonRun.daemon == name) + .order_by(DaemonRun.started_at.desc()) + .first() + ) + if last is None: + rows.append(f"{name}never run—") + else: + cls = "ok" if last.status == "ok" else "error" + rows.append( + f"{html.escape(name)}" + f"{html.escape(last.status)}" + f"{_fmt_ts(last.finished_at or last.started_at)}" + ) + return "" + "".join(rows) + "
daemonlast statuslast run
" + + +def _queue_cards(sess) -> str: + counts = dict(sess.query(Proposal.status, func.count(Proposal.id)).group_by(Proposal.status).all()) + cards = [ + f'
{html.escape(status.value)}: {counts.get(status, 0)}
' + for status in ProposalStatus + ] + return "".join(cards) + + +def _recent_audit(sess, limit: int = 20) -> str: + rows = [] + for entry in sess.query(AuditEntry).order_by(AuditEntry.ts.desc()).limit(limit).all(): + cls = "ok" if entry.result == "ok" else "error" + rows.append( + f"{_fmt_ts(entry.ts)}{html.escape(entry.actor)}" + f"{html.escape(entry.action)}{html.escape(entry.result)}" + ) + body = "".join(rows) or "no activity yet" + return ( + "" + + body + + "
time (utc)actoractionresult
" + ) + + +def _build(cfg: Config, sess) -> str: + now = datetime.now(UTC) + mail_unprocessed = ( + sess.query(func.count(MailMessage.id)) + .filter( + MailMessage.direction == "in", + MailMessage.draft_status.in_(["drafted", "none"]), + MailMessage.classification.isnot(None), + ) + .scalar() + ) + content_pending = ( + sess.query(func.count(ContentItem.id)) + .filter(ContentItem.status.in_(["draft", "approved"])) + .scalar() + ) + generated = _fmt_ts(now) + + page = f""" +cayce-ops status{_STYLE} + +

cayce-ops · status

+

generated {generated} · static page, no sensitive data by design

+ +

daemon health

+{_daemon_rows(sess)} + +

proposal queue

+{_queue_cards(sess)} + +

gates

+
mail drafts awaiting approval: {mail_unprocessed}
+
content items draft/approved: {content_pending}
+ +

recent audit activity

+{_recent_audit(sess)} + +

audit entries: {sess.scalar(select(func.count(AuditEntry.id)))} total

+""" + + import os + + os.makedirs(os.path.dirname(cfg.monitor_output) or ".", exist_ok=True) + with open(cfg.monitor_output, "w", encoding="utf-8") as fh: + fh.write(page) + return cfg.monitor_output + + +def build(cfg: Config | None = None) -> int: + """CLI entry: render the status page inside a DaemonRun bookkeeping wrap.""" + from cayceops.config import load + from cayceops.db import engine + + cfg = cfg or load() + try: + with session_scope() as sess: + run_row = DaemonRun(daemon="monitor") + sess.add(run_row) + sess.flush() + try: + out = _build(cfg, sess) + except Exception as exc: # noqa: BLE001 — daemon boundary + run_row.status = "error" + run_row.detail = {"error": str(exc)[:300]} + run_row.finished_at = datetime.now(UTC) + return 1 + run_row.status = "ok" + run_row.detail = {"output": out} + run_row.finished_at = datetime.now(UTC) + print(out) + return 0 + finally: + engine().dispose() diff --git a/tests/test_monitor.py b/tests/test_monitor.py new file mode 100644 index 0000000..a6e2222 --- /dev/null +++ b/tests/test_monitor.py @@ -0,0 +1,69 @@ +"""monitor tests — page renders, no sensitive data, escapes input.""" + +from __future__ import annotations + +from cayceops.components.monitor import monitor +from cayceops.models import ContentItem, DaemonRun, MailMessage, Proposal + + +def _seed(sess): + sess.add(DaemonRun(daemon="scanner", status="ok", detail={"proposed": 3})) + sess.add(Proposal(kind="affiliate", title="Title with ")) + sess.add( + MailMessage( + direction="in", + from_addr="secret-person@private.example.org", + subject="private subject", + classification="approval", + draft_status="drafted", + ) + ) + sess.add(ContentItem(theme="atm-fees", channel="nostr", caption="caption", status="draft")) + sess.flush() + + +def test_build_renders_all_sections(cfg, sess, tmp_path): + _seed(sess) + out = tmp_path / "status.html" + monitor._build(cfg(CAYCE_OPS_MONITOR_OUTPUT=str(out)), sess) + page = out.read_text() + + assert "daemon health" in page + assert "proposal queue" in page + assert "recent audit activity" in page + assert "scanner" in page + assert "proposed: 1" in page + assert "mail drafts awaiting approval: 1" in page + + +def test_build_leaks_no_sensitive_data(cfg, sess, tmp_path): + _seed(sess) + out = tmp_path / "status.html" + monitor._build(cfg(CAYCE_OPS_MONITOR_OUTPUT=str(out)), sess) + page = out.read_text() + + assert "secret-person@private.example.org" not in page # no addresses + assert "private subject" not in page # no subjects + assert "caption" not in page # no content bodies + assert "Title with" not in page # proposal titles not rendered + + +def test_build_escapes_injected_content(cfg, sess, tmp_path): + from datetime import UTC, datetime + + from cayceops.models import AuditEntry + + sess.add( + AuditEntry( + ts=datetime.now(UTC), + actor="", + action="probe", + detail={}, + ) + ) + sess.flush() + out = tmp_path / "status.html" + monitor._build(cfg(CAYCE_OPS_MONITOR_OUTPUT=str(out)), sess) + page = out.read_text() + assert "" not in page + assert "<script>x</script>" in page