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
+138
View File
@@ -0,0 +1,138 @@
"""Streamlit entry point for the Cerbero Bite dashboard.
Launch with::
cerbero-bite gui
or directly::
uv run streamlit run src/cerbero_bite/gui/main.py \
--server.address 127.0.0.1 \
--server.port 8765 \
--server.headless true
The dashboard is **read-mostly**: it reads SQLite + the audit log and
never imports ``runtime/`` modules. Each Streamlit page is in
``gui/pages/`` and Streamlit auto-discovers them.
"""
from __future__ import annotations
import os
from pathlib import Path
import streamlit as st
from cerbero_bite.gui.data_layer import (
DEFAULT_AUDIT_PATH,
DEFAULT_DB_PATH,
humanize_age,
humanize_dt,
load_engine_snapshot,
)
PAGE_TITLE = "Cerbero Bite — Dashboard"
PAGE_ICON = "🐺"
# ---------------------------------------------------------------------------
# Path resolution
# ---------------------------------------------------------------------------
def _resolve_paths() -> tuple[Path, Path]:
"""Read DB / audit paths from env (settable by ``cerbero-bite gui``)."""
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
# ---------------------------------------------------------------------------
# Sidebar
# ---------------------------------------------------------------------------
_HEALTH_BADGES: dict[str, tuple[str, str]] = {
"running": ("🟢", "RUNNING"),
"degraded": ("🟡", "DEGRADED"),
"killed": ("🔴", "KILL SWITCH"),
"stopped": ("", "STOPPED"),
"unknown": ("", "UNKNOWN"),
}
def _render_sidebar(db_path: Path, audit_path: Path) -> None:
snap = load_engine_snapshot(db_path=db_path)
icon, label = _HEALTH_BADGES.get(snap.health, ("", "UNKNOWN"))
st.sidebar.markdown(f"### {icon} {label}")
if snap.kill_switch_armed:
st.sidebar.error(
f"**Kill switch armed**\n\n"
f"reason: {snap.kill_reason or ''}\n\n"
f"since: {humanize_dt(snap.kill_at)}"
)
st.sidebar.metric(
"Last health check",
humanize_age(snap.last_health_check_age_s),
)
st.sidebar.metric("Open positions", snap.open_positions)
st.sidebar.caption(f"config: `{snap.config_version or ''}`")
st.sidebar.divider()
st.sidebar.caption("Read-only • localhost only")
st.sidebar.caption(f"db: `{db_path}`")
st.sidebar.caption(f"audit: `{audit_path}`")
# ---------------------------------------------------------------------------
# Home page
# ---------------------------------------------------------------------------
def main() -> None:
st.set_page_config(
page_title=PAGE_TITLE,
page_icon=PAGE_ICON,
layout="wide",
initial_sidebar_state="expanded",
)
db_path, audit_path = _resolve_paths()
_render_sidebar(db_path, audit_path)
st.title(f"{PAGE_ICON} Cerbero Bite")
st.caption(
"Rule-based ETH credit-spread engine — read-only dashboard"
)
st.markdown(
"""
Use the sidebar to navigate:
- **Status** — engine health, kill switch, open positions, audit anchor
- **Audit** — live audit log stream + chain integrity verification
The dashboard reads `data/state.sqlite` and `data/audit.log` directly;
it never calls MCP services or the broker. All write actions remain
on the CLI for now.
"""
)
snap = load_engine_snapshot(db_path=db_path)
cols = st.columns(4)
cols[0].metric("Health", _HEALTH_BADGES[snap.health][1])
cols[1].metric(
"Kill switch",
"ARMED" if snap.kill_switch_armed else "DISARMED",
)
cols[2].metric("Open positions", snap.open_positions)
cols[3].metric(
"Last health check",
humanize_age(snap.last_health_check_age_s),
)
if __name__ == "__main__":
main()