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