feat(gui): Phase A — read-only Streamlit dashboard (Status + Audit)

Implements the foundation of the local observation dashboard described
in docs/11-gui-streamlit.md:

* gui/data_layer.py — read-only wrappers over Repository (system_state,
  open positions) and audit_log (tail iteration, chain verify). The GUI
  never imports runtime/ nor calls MCP services.
* gui/main.py — Streamlit entry point with sidebar (engine health
  badge, kill switch banner, last health check age), home overview.
* gui/pages/1_📊_Status.py — engine status with colored health banner,
  kill switch detail, audit anchor, open positions table.
* gui/pages/2_🔍_Audit.py — live audit log stream (newest-first),
  event filters, hash-chain integrity verify button.
* cli.py gui — replaces the placeholder with os.execvpe to
  `python -m streamlit run` bound to 127.0.0.1, --browser.gatherUsageStats
  false; --db / --audit paths exported via env to the GUI process.
* pyproject.toml — N999 ignore for src/cerbero_bite/gui/pages/* (Streamlit
  auto-discovers pages whose filename contains numbers and emoji icons).

Smoke test: GUI launches, HTTP 200 on / and /_stcore/health, data layer
correctly reflects current testnet state (engine=running, kill_switch
disarmed, 0 open positions, audit chain integra 7 entries).

353/353 tests still pass; ruff clean; mypy strict src clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-30 12:07:23 +02:00
parent abf5a140e2
commit 1af983aff1
6 changed files with 684 additions and 3 deletions
+121
View File
@@ -0,0 +1,121 @@
"""Audit page — live audit log stream + chain integrity verification."""
from __future__ import annotations
import json
import os
from collections import Counter
from pathlib import Path
import streamlit as st
from cerbero_bite.gui.data_layer import (
DEFAULT_AUDIT_PATH,
DEFAULT_DB_PATH,
humanize_dt,
load_audit_chain_status,
load_audit_tail,
)
def _resolve_paths() -> tuple[Path, Path]:
db_path = Path(os.environ.get("CERBERO_BITE_GUI_DB", DEFAULT_DB_PATH))
audit_path = Path(os.environ.get("CERBERO_BITE_GUI_AUDIT", DEFAULT_AUDIT_PATH))
return db_path, audit_path
def render() -> None:
st.title("🔍 Audit")
st.caption(
"Append-only hash-chained audit log "
"(`data/audit.log`). Reading is non-mutating."
)
_, audit_path = _resolve_paths()
col_l, col_r = st.columns([1, 2])
with col_l:
st.subheader("Chain integrity")
if st.button("Verify chain", type="primary"):
with st.spinner("Walking the chain…"):
status = load_audit_chain_status(audit_path=audit_path)
if status.ok:
st.success(
f"✅ chain integra fino a {status.entries_verified} eventi"
)
else:
st.error(
f"❌ tampering rilevato\n\n```\n{status.error}\n```"
)
else:
st.caption(
"Click to recompute every line's hash and verify the prev-hash "
"linkage. Mismatch → CRITICAL alert in production."
)
with col_r:
st.subheader("Filters")
limit = st.slider(
"Last N entries",
min_value=10,
max_value=500,
value=100,
step=10,
)
# Build event list from the available tail
all_recent = load_audit_tail(audit_path=audit_path, limit=limit)
events_present = sorted({e.event for e in all_recent})
event_filter = st.selectbox(
"Event filter",
options=["(all)", *events_present],
index=0,
)
st.divider()
# Statistics strip
counter: Counter[str] = Counter(e.event for e in all_recent)
if counter:
cols = st.columns(min(len(counter), 6))
for col, (event, count) in zip(cols, counter.most_common(6), strict=False):
col.metric(event, count)
st.divider()
# Filtered tail
filtered = (
all_recent
if event_filter == "(all)"
else [e for e in all_recent if e.event == event_filter]
)
st.subheader(f"Last entries ({len(filtered)} shown)")
if not filtered:
st.info("No matching audit entries.")
return
rows = []
for entry in filtered:
try:
payload_pretty = json.dumps(
entry.payload, ensure_ascii=False, sort_keys=True
)
except (TypeError, ValueError):
payload_pretty = str(entry.payload)
rows.append(
{
"timestamp": humanize_dt(entry.timestamp),
"event": entry.event,
"payload": payload_pretty,
"hash": (
f"{entry.hash[:8]}{entry.hash[-8:]}"
if len(entry.hash) > 16
else entry.hash
),
}
)
st.dataframe(rows, use_container_width=True, hide_index=True)
render()