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:
@@ -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()
|
||||
Reference in New Issue
Block a user