diff --git a/src/multi_swarm/dashboard/nicegui_app.py b/src/multi_swarm/dashboard/nicegui_app.py index 43a72ae..7280528 100644 --- a/src/multi_swarm/dashboard/nicegui_app.py +++ b/src/multi_swarm/dashboard/nicegui_app.py @@ -4,6 +4,12 @@ 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. + +Palette Inter FC: +- Nero: #000000 (sfondo) +- Dark navy: #010E80 (heritage Inter, surface) +- Blu primary: #1E5BC6 +- Blu secondary: #0068A8 """ from __future__ import annotations @@ -13,6 +19,7 @@ import os from pathlib import Path from typing import Any +import plotly.graph_objects as go # type: ignore[import-untyped] from nicegui import app, ui from multi_swarm.dashboard.data import ( @@ -26,12 +33,90 @@ from multi_swarm.dashboard.data import ( DB_PATH = os.environ.get("DB_PATH", "./runs.db") REFRESH_INTERVAL_S = 3.0 +# --- Inter FC palette --- +COLOR_BG = "#000000" +COLOR_SURFACE = "#010E80" +COLOR_PRIMARY = "#1E5BC6" +COLOR_SECONDARY = "#0068A8" +COLOR_ACCENT = "#FFD700" +COLOR_TEXT = "#E8EEFC" +COLOR_TEXT_MUTED = "#8FA3D4" + _STATUS_BADGE: dict[str, tuple[str, str]] = { "running": ("🟢 running", "positive"), "completed": ("✅ completed", "positive"), "failed": ("❌ failed", "negative"), } +_CUSTOM_CSS = f""" + +""" + + +def _apply_theme() -> None: + ui.add_head_html(_CUSTOM_CSS) + ui.dark_mode().enable() + ui.colors( + primary=COLOR_PRIMARY, + secondary=COLOR_SECONDARY, + accent=COLOR_ACCENT, + dark=COLOR_BG, + dark_page=COLOR_BG, + positive="#00C853", + negative="#D32F2F", + info=COLOR_SECONDARY, + warning="#FFB300", + ) + + +def _build_header(active: str) -> None: + with ui.header().classes("items-center justify-between q-px-md"): + with ui.row().classes("items-center gap-6"): + ui.label("⚫🔵 Multi-Swarm Coevolutivo").classes("text-h6").style( + f"color: {COLOR_TEXT}; font-weight: 700;" + ) + for path, label in (("/", "Overview"), ("/convergence", "GA Convergence")): + cls = "nav-link active" if active == path else "nav-link" + ui.link(label, path).classes(cls) + ui.label(f"DB: {Path(DB_PATH).resolve().name}").classes("text-caption").style( + f"color: {COLOR_TEXT_MUTED};" + ) + def _runs_options() -> dict[str, str]: repo = get_repo(DB_PATH) @@ -78,18 +163,90 @@ def _snapshot(run_id: str) -> dict[str, Any]: "median_fit": median_fit, "parse_success": parse_success, "config": cfg, + "gens_df": gens, } +def _convergence_figure(gens_df: Any) -> go.Figure: + fig = go.Figure() + if gens_df.empty: + fig.add_annotation( + text="Nessuna generazione registrata", x=0.5, y=0.5, showarrow=False, + font={"color": COLOR_TEXT_MUTED, "size": 14}, + ) + else: + fig.add_trace( + go.Scatter( + x=gens_df["generation_idx"], y=gens_df["fitness_max"], + name="max", mode="lines+markers", + line={"color": COLOR_PRIMARY, "width": 3}, + marker={"size": 9, "color": COLOR_PRIMARY}, + ) + ) + fig.add_trace( + go.Scatter( + x=gens_df["generation_idx"], y=gens_df["fitness_p90"], + name="p90", mode="lines+markers", + line={"color": COLOR_ACCENT, "width": 2, "dash": "dot"}, + marker={"size": 7, "color": COLOR_ACCENT}, + ) + ) + fig.add_trace( + go.Scatter( + x=gens_df["generation_idx"], y=gens_df["fitness_median"], + name="median", mode="lines+markers", + line={"color": COLOR_SECONDARY, "width": 2}, + marker={"size": 7, "color": COLOR_SECONDARY}, + ) + ) + fig.update_layout( + template="plotly_dark", + paper_bgcolor=COLOR_BG, + plot_bgcolor=COLOR_BG, + font={"color": COLOR_TEXT}, + xaxis={"title": "generation", "gridcolor": "rgba(30, 91, 198, 0.2)", "dtick": 1}, + yaxis={"title": "fitness", "gridcolor": "rgba(30, 91, 198, 0.2)"}, + title={"text": "Fitness convergence", "font": {"color": COLOR_TEXT, "size": 18}}, + legend={"bgcolor": "rgba(1, 14, 128, 0.6)", "bordercolor": COLOR_PRIMARY, "borderwidth": 1}, + margin={"l": 50, "r": 30, "t": 50, "b": 50}, + ) + return fig + + +def _entropy_figure(gens_df: Any) -> go.Figure: + fig = go.Figure() + if not gens_df.empty: + fig.add_trace( + go.Scatter( + x=gens_df["generation_idx"], y=gens_df["entropy"], + mode="lines+markers", + line={"color": COLOR_PRIMARY, "width": 3}, + marker={"size": 9, "color": COLOR_PRIMARY}, + name="entropy", + ) + ) + fig.add_hline( + y=0.5, line_dash="dash", line_color=COLOR_ACCENT, + annotation_text="gate threshold (0.5)", + annotation_font_color=COLOR_ACCENT, + ) + fig.update_layout( + template="plotly_dark", + paper_bgcolor=COLOR_BG, + plot_bgcolor=COLOR_BG, + font={"color": COLOR_TEXT}, + xaxis={"title": "generation", "gridcolor": "rgba(30, 91, 198, 0.2)", "dtick": 1}, + yaxis={"title": "entropy", "gridcolor": "rgba(30, 91, 198, 0.2)"}, + title={"text": "Diversity (fitness entropy)", "font": {"color": COLOR_TEXT, "size": 18}}, + margin={"l": 50, "r": 30, "t": 50, "b": 50}, + ) + return fig + + @ui.page("/") def index() -> None: - ui.add_head_html( - '' - ) - - 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") + _apply_theme() + _build_header(active="/") options = _runs_options() if not options: @@ -98,40 +255,34 @@ def index() -> None: 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") + 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") + ui.button("🔄 Refresh", on_click=lambda: refresh()).props("outline color=primary") - # --- 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") + gen_bar = ui.linear_progress(0.0, show_value=False).props("size=20px color=primary") eval_label = ui.label("Evaluations: 0/0 (0.0%)") - eval_bar = ui.linear_progress(0.0, show_value=False).props("size=20px color=green") + eval_bar = ui.linear_progress(0.0, show_value=False).props("size=20px color=accent") - # --- 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") + ui.label("Top fitness").classes("text-caption") top_lbl = ui.label("—").classes("text-h4") with ui.card().classes("flex-grow metric-card"): - ui.label("Median fitness").classes("text-caption text-grey") + ui.label("Median fitness").classes("text-caption") median_lbl = ui.label("—").classes("text-h4") with ui.card().classes("flex-grow metric-card"): - ui.label("Parse success").classes("text-caption text-grey") + ui.label("Parse success").classes("text-caption") parse_lbl = ui.label("—").classes("text-h4") with ui.card().classes("flex-grow metric-card"): - ui.label("Cost (USD)").classes("text-caption text-grey") + ui.label("Cost (USD)").classes("text-caption") 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: —") @@ -176,8 +327,91 @@ def index() -> None: refresh() select.on_value_change(on_select_change) + ui.timer(REFRESH_INTERVAL_S, refresh) + refresh() - # Auto-refresh ogni N secondi + +@ui.page("/convergence") +def convergence() -> None: + _apply_theme() + _build_header(active="/convergence") + + 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))} + + 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" + ) + gen_count_lbl = ui.label("Gens: 0/0").classes("text-body1").style( + f"color: {COLOR_PRIMARY}; font-weight: 600;" + ) + ui.button("🔄 Refresh", on_click=lambda: refresh()).props("outline color=primary") + + fitness_plot = ui.plotly(_convergence_figure(generations_df(get_repo(DB_PATH), state["run_id"]))).classes("w-full") + entropy_plot = ui.plotly(_entropy_figure(generations_df(get_repo(DB_PATH), state["run_id"]))).classes("w-full q-mt-md") + + ui.separator() + ui.label("Tabella generazioni").classes("text-subtitle1 q-mt-md") + gens_table = ui.table( + columns=[ + {"name": "generation_idx", "label": "gen", "field": "generation_idx", "sortable": True}, + {"name": "n_genomes", "label": "n", "field": "n_genomes"}, + {"name": "fitness_max", "label": "max", "field": "fitness_max"}, + {"name": "fitness_p90", "label": "p90", "field": "fitness_p90"}, + {"name": "fitness_median", "label": "median", "field": "fitness_median"}, + {"name": "entropy", "label": "entropy", "field": "entropy"}, + {"name": "completed_at", "label": "completed", "field": "completed_at"}, + ], + rows=[], + row_key="generation_idx", + ).classes("w-full") + + def refresh() -> None: + run_id = select.value + if not run_id: + return + try: + gens = generations_df(get_repo(DB_PATH), run_id) + ov = get_run_overview(get_repo(DB_PATH), run_id) + except Exception as e: # noqa: BLE001 + ui.notify(f"Errore: {e}", type="negative") + return + + n_gens = int(ov["config"].get("n_generations", 0)) + gens_done = int(gens["completed_at"].notna().sum()) if not gens.empty else 0 + gen_count_lbl.text = f"Gens: {gens_done}/{n_gens}" + + fitness_plot.update_figure(_convergence_figure(gens)) + entropy_plot.update_figure(_entropy_figure(gens)) + + if gens.empty: + gens_table.rows = [] + else: + display_cols = [ + "generation_idx", "n_genomes", + "fitness_max", "fitness_p90", "fitness_median", + "entropy", "completed_at", + ] + gens_table.rows = [ + { + col: (round(v, 6) if isinstance(v, float) else v) + for col, v in row.items() + if col in display_cols + } + for _, row in gens.iterrows() + ] + gens_table.update() + + def on_select_change() -> None: + state["run_id"] = select.value + refresh() + + select.on_value_change(on_select_change) ui.timer(REFRESH_INTERVAL_S, refresh) refresh() @@ -190,6 +424,7 @@ def main() -> None: title="Multi-Swarm Dashboard", reload=False, show=False, + dark=True, )