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"
| daemon | last status | last run |
|---|
| time (utc) | actor | action | result |
|---|
generated {generated} · static page, no sensitive data by design
+ +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