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,
)