feat(dashboard): NiceGUI port — pagina Overview con auto-refresh live

Prima fase migrazione da Streamlit a NiceGUI. Pagina indice riproduce
l'Overview con:
- run selector reattivo
- 2 progress bar live (generations, evaluations) con WebSocket push
- 4 metric card (top fitness, median, parse success %, cost)
- timer auto-refresh ogni 3s (no click manuale)
- status badge colorato (running/completed/failed)
- config JSON code block

Avvio: uv run python -m multi_swarm.dashboard.nicegui_app (porta 8080)
Streamlit resta attivo su 8501 durante la migrazione. Backend invariato:
riusa dashboard/data.py senza modifiche.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-11 22:28:19 +02:00
parent 7b790b1bc3
commit 5f28884974
3 changed files with 993 additions and 0 deletions
+197
View File
@@ -0,0 +1,197 @@
"""NiceGUI dashboard — port progressivo da Streamlit.
Avvio: ``uv run python -m multi_swarm.dashboard.nicegui_app``
Default port 8080. Streamlit resta su 8501 durante la migrazione.
Riusa ``dashboard.data`` (Repository helpers) senza modifiche al backend.
"""
from __future__ import annotations
import json
import os
from pathlib import Path
from typing import Any
from nicegui import app, ui
from multi_swarm.dashboard.data import (
evaluations_df,
generations_df,
get_repo,
get_run_overview,
list_runs_df,
)
DB_PATH = os.environ.get("DB_PATH", "./runs.db")
REFRESH_INTERVAL_S = 3.0
_STATUS_BADGE: dict[str, tuple[str, str]] = {
"running": ("🟢 running", "positive"),
"completed": ("✅ completed", "positive"),
"failed": ("❌ failed", "negative"),
}
def _runs_options() -> dict[str, str]:
repo = get_repo(DB_PATH)
runs = list_runs_df(repo)
if runs.empty:
return {}
return {
row["id"]: f"{row['name']}{row['status']} ({row['started_at'][:16]})"
for _, row in runs.iterrows()
}
def _snapshot(run_id: str) -> dict[str, Any]:
repo = get_repo(DB_PATH)
ov = get_run_overview(repo, run_id)
evals = evaluations_df(repo, run_id)
gens = generations_df(repo, run_id)
cfg = ov["config"]
pop_size = int(cfg.get("population_size", 0))
n_gens = int(cfg.get("n_generations", 0))
evals_total = max(pop_size * n_gens, 1)
evals_done = len(evals)
gens_done = int(gens["completed_at"].notna().sum()) if not gens.empty else 0
top_fit = float(evals["fitness"].max()) if evals_done else float("nan")
median_fit = float(evals["fitness"].median()) if evals_done else float("nan")
parse_success = (
100.0 * float(evals["parse_error"].isna().sum()) / evals_done if evals_done else 0.0
)
return {
"status": ov["status"],
"name": cfg.get("run_name", ""),
"started_at": ov["started_at"],
"completed_at": ov["completed_at"] or "",
"cost_usd": ov["total_cost_usd"],
"pop_size": pop_size,
"n_gens": n_gens,
"evals_done": evals_done,
"evals_total": evals_total,
"gens_done": gens_done,
"top_fit": top_fit,
"median_fit": median_fit,
"parse_success": parse_success,
"config": cfg,
}
@ui.page("/")
def index() -> None:
ui.add_head_html(
'<style>.metric-card{padding:12px;border-radius:8px;background:#1e293b;color:#fff;text-align:center}</style>'
)
with ui.header().classes("items-center justify-between"):
ui.label("Multi-Swarm Coevolutivo — NiceGUI Dashboard").classes("text-h6")
ui.label(f"DB: {Path(DB_PATH).resolve()}").classes("text-caption")
options = _runs_options()
if not options:
ui.label("Nessuna run nel database.").classes("text-h5")
return
state: dict[str, Any] = {"run_id": next(iter(options))}
# --- Run selector ---
with ui.row().classes("w-full items-center gap-4 q-mb-md"):
select = ui.select(
options=options,
value=state["run_id"],
label="Run",
).classes("flex-grow")
status_badge = ui.badge("", color="primary").classes("text-body1 q-pa-sm")
ui.button("🔄 Refresh", on_click=lambda: refresh()).props("outline")
# --- Progress bars ---
with ui.card().classes("w-full"):
ui.label("Progresso run").classes("text-subtitle1")
gen_label = ui.label("Generations: 0/0")
gen_bar = ui.linear_progress(0.0, show_value=False).props("size=20px color=cyan")
eval_label = ui.label("Evaluations: 0/0 (0.0%)")
eval_bar = ui.linear_progress(0.0, show_value=False).props("size=20px color=green")
# --- Metrics grid ---
with ui.row().classes("w-full gap-4"):
with ui.card().classes("flex-grow metric-card"):
ui.label("Top fitness").classes("text-caption text-grey")
top_lbl = ui.label("").classes("text-h4")
with ui.card().classes("flex-grow metric-card"):
ui.label("Median fitness").classes("text-caption text-grey")
median_lbl = ui.label("").classes("text-h4")
with ui.card().classes("flex-grow metric-card"):
ui.label("Parse success").classes("text-caption text-grey")
parse_lbl = ui.label("").classes("text-h4")
with ui.card().classes("flex-grow metric-card"):
ui.label("Cost (USD)").classes("text-caption text-grey")
cost_lbl = ui.label("").classes("text-h4")
# --- Times + config ---
with ui.row().classes("w-full gap-4 q-mt-md"):
started_lbl = ui.label("Started: —")
completed_lbl = ui.label("Completed: —")
ui.separator()
ui.label("Config").classes("text-subtitle1")
cfg_code = ui.code("", language="json").classes("w-full")
def refresh() -> None:
run_id = select.value
if not run_id:
return
try:
s = _snapshot(run_id)
except Exception as e: # noqa: BLE001
ui.notify(f"Errore: {e}", type="negative")
return
text, color = _STATUS_BADGE.get(s["status"], (s["status"], "primary"))
status_badge.text = text
status_badge.props(f"color={color}")
gen_frac = min(s["gens_done"] / max(s["n_gens"], 1), 1.0)
eval_frac = min(s["evals_done"] / s["evals_total"], 1.0)
gen_bar.value = gen_frac
eval_bar.value = eval_frac
gen_label.text = f"Generations: {s['gens_done']}/{s['n_gens']}"
eval_label.text = (
f"Evaluations: {s['evals_done']}/{s['evals_total']} ({100 * eval_frac:.1f}%)"
)
top_lbl.text = f"{s['top_fit']:.4f}" if s["evals_done"] else ""
median_lbl.text = f"{s['median_fit']:.4f}" if s["evals_done"] else ""
parse_lbl.text = f"{s['parse_success']:.1f}%" if s["evals_done"] else ""
cost_lbl.text = f"${s['cost_usd']:.4f}"
started_lbl.text = f"Started: {s['started_at']}"
completed_lbl.text = f"Completed: {s['completed_at']}"
cfg_code.content = json.dumps(s["config"], indent=2)
def on_select_change() -> None:
state["run_id"] = select.value
refresh()
select.on_value_change(on_select_change)
# Auto-refresh ogni N secondi
ui.timer(REFRESH_INTERVAL_S, refresh)
refresh()
def main() -> None:
app.on_startup(lambda: print(f"DB: {Path(DB_PATH).resolve()}"))
ui.run(
host="0.0.0.0",
port=8080,
title="Multi-Swarm Dashboard",
reload=False,
show=False,
)
if __name__ in {"__main__", "__mp_main__"}:
main()