feat(dashboard): NiceGUI GA Convergence + Inter FC dark theme
Aggiunta route /convergence con due grafici Plotly live: - Fitness convergence (max, p90, median) — auto-refresh ogni 3s - Diversity entropy con gate threshold 0.5 - Tabella generazioni ordinabile Applicata palette Inter FC su entrambe le pagine: - Sfondo nero #000000 - Surface dark navy #010E80 (heritage Inter) - Primary blu #1E5BC6, secondary blu #0068A8, accent oro #FFD700 Custom CSS via ui.add_head_html + ui.colors() quasar override. Header navigazione condiviso con link attivo evidenziato. Plotly: template plotly_dark + paper/plot bg neri + gridcolor rgba. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -4,6 +4,12 @@ Avvio: ``uv run python -m multi_swarm.dashboard.nicegui_app``
|
|||||||
Default port 8080. Streamlit resta su 8501 durante la migrazione.
|
Default port 8080. Streamlit resta su 8501 durante la migrazione.
|
||||||
|
|
||||||
Riusa ``dashboard.data`` (Repository helpers) senza modifiche al backend.
|
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
|
from __future__ import annotations
|
||||||
@@ -13,6 +19,7 @@ import os
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
|
import plotly.graph_objects as go # type: ignore[import-untyped]
|
||||||
from nicegui import app, ui
|
from nicegui import app, ui
|
||||||
|
|
||||||
from multi_swarm.dashboard.data import (
|
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")
|
DB_PATH = os.environ.get("DB_PATH", "./runs.db")
|
||||||
REFRESH_INTERVAL_S = 3.0
|
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]] = {
|
_STATUS_BADGE: dict[str, tuple[str, str]] = {
|
||||||
"running": ("🟢 running", "positive"),
|
"running": ("🟢 running", "positive"),
|
||||||
"completed": ("✅ completed", "positive"),
|
"completed": ("✅ completed", "positive"),
|
||||||
"failed": ("❌ failed", "negative"),
|
"failed": ("❌ failed", "negative"),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
_CUSTOM_CSS = f"""
|
||||||
|
<style>
|
||||||
|
body {{ background: {COLOR_BG} !important; color: {COLOR_TEXT}; }}
|
||||||
|
.q-page {{ background: {COLOR_BG} !important; }}
|
||||||
|
.q-card {{
|
||||||
|
background: linear-gradient(135deg, {COLOR_SURFACE} 0%, #000936 100%) !important;
|
||||||
|
color: {COLOR_TEXT} !important;
|
||||||
|
border: 1px solid {COLOR_PRIMARY}33;
|
||||||
|
box-shadow: 0 2px 8px {COLOR_PRIMARY}22;
|
||||||
|
}}
|
||||||
|
.metric-card {{
|
||||||
|
padding: 16px;
|
||||||
|
border-radius: 10px;
|
||||||
|
text-align: center;
|
||||||
|
}}
|
||||||
|
.metric-card .text-h4 {{ color: {COLOR_PRIMARY} !important; font-weight: 600; }}
|
||||||
|
.metric-card .text-caption {{ color: {COLOR_TEXT_MUTED} !important; }}
|
||||||
|
.q-header {{
|
||||||
|
background: linear-gradient(90deg, {COLOR_BG} 0%, {COLOR_SURFACE} 100%) !important;
|
||||||
|
border-bottom: 2px solid {COLOR_PRIMARY};
|
||||||
|
}}
|
||||||
|
.q-tab--active {{ color: {COLOR_PRIMARY} !important; }}
|
||||||
|
.q-tab__indicator {{ background: {COLOR_PRIMARY} !important; }}
|
||||||
|
.nav-link {{
|
||||||
|
color: {COLOR_TEXT} !important;
|
||||||
|
padding: 8px 16px;
|
||||||
|
border-radius: 6px;
|
||||||
|
text-decoration: none;
|
||||||
|
transition: background 0.2s;
|
||||||
|
}}
|
||||||
|
.nav-link:hover {{ background: {COLOR_PRIMARY}33; }}
|
||||||
|
.nav-link.active {{ background: {COLOR_PRIMARY}; color: #fff !important; }}
|
||||||
|
.q-linear-progress__track {{ background: {COLOR_BG} !important; }}
|
||||||
|
.q-separator {{ background: {COLOR_PRIMARY}44 !important; }}
|
||||||
|
.q-field--outlined .q-field__control {{ color: {COLOR_TEXT} !important; }}
|
||||||
|
code, pre {{ background: #000936 !important; color: {COLOR_TEXT} !important; }}
|
||||||
|
</style>
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
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]:
|
def _runs_options() -> dict[str, str]:
|
||||||
repo = get_repo(DB_PATH)
|
repo = get_repo(DB_PATH)
|
||||||
@@ -78,18 +163,90 @@ def _snapshot(run_id: str) -> dict[str, Any]:
|
|||||||
"median_fit": median_fit,
|
"median_fit": median_fit,
|
||||||
"parse_success": parse_success,
|
"parse_success": parse_success,
|
||||||
"config": cfg,
|
"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("/")
|
@ui.page("/")
|
||||||
def index() -> None:
|
def index() -> None:
|
||||||
ui.add_head_html(
|
_apply_theme()
|
||||||
'<style>.metric-card{padding:12px;border-radius:8px;background:#1e293b;color:#fff;text-align:center}</style>'
|
_build_header(active="/")
|
||||||
)
|
|
||||||
|
|
||||||
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()
|
options = _runs_options()
|
||||||
if not options:
|
if not options:
|
||||||
@@ -98,40 +255,34 @@ def index() -> None:
|
|||||||
|
|
||||||
state: dict[str, Any] = {"run_id": next(iter(options))}
|
state: dict[str, Any] = {"run_id": next(iter(options))}
|
||||||
|
|
||||||
# --- Run selector ---
|
|
||||||
with ui.row().classes("w-full items-center gap-4 q-mb-md"):
|
with ui.row().classes("w-full items-center gap-4 q-mb-md"):
|
||||||
select = ui.select(
|
select = ui.select(options=options, value=state["run_id"], label="Run").classes(
|
||||||
options=options,
|
"flex-grow"
|
||||||
value=state["run_id"],
|
)
|
||||||
label="Run",
|
|
||||||
).classes("flex-grow")
|
|
||||||
status_badge = ui.badge("…", color="primary").classes("text-body1 q-pa-sm")
|
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"):
|
with ui.card().classes("w-full"):
|
||||||
ui.label("Progresso run").classes("text-subtitle1")
|
ui.label("Progresso run").classes("text-subtitle1")
|
||||||
gen_label = ui.label("Generations: 0/0")
|
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_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.row().classes("w-full gap-4"):
|
||||||
with ui.card().classes("flex-grow metric-card"):
|
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")
|
top_lbl = ui.label("—").classes("text-h4")
|
||||||
with ui.card().classes("flex-grow metric-card"):
|
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")
|
median_lbl = ui.label("—").classes("text-h4")
|
||||||
with ui.card().classes("flex-grow metric-card"):
|
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")
|
parse_lbl = ui.label("—").classes("text-h4")
|
||||||
with ui.card().classes("flex-grow metric-card"):
|
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")
|
cost_lbl = ui.label("—").classes("text-h4")
|
||||||
|
|
||||||
# --- Times + config ---
|
|
||||||
with ui.row().classes("w-full gap-4 q-mt-md"):
|
with ui.row().classes("w-full gap-4 q-mt-md"):
|
||||||
started_lbl = ui.label("Started: —")
|
started_lbl = ui.label("Started: —")
|
||||||
completed_lbl = ui.label("Completed: —")
|
completed_lbl = ui.label("Completed: —")
|
||||||
@@ -176,8 +327,91 @@ def index() -> None:
|
|||||||
refresh()
|
refresh()
|
||||||
|
|
||||||
select.on_value_change(on_select_change)
|
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)
|
ui.timer(REFRESH_INTERVAL_S, refresh)
|
||||||
refresh()
|
refresh()
|
||||||
|
|
||||||
@@ -190,6 +424,7 @@ def main() -> None:
|
|||||||
title="Multi-Swarm Dashboard",
|
title="Multi-Swarm Dashboard",
|
||||||
reload=False,
|
reload=False,
|
||||||
show=False,
|
show=False,
|
||||||
|
dark=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user