diff --git a/Makefile b/Makefile
index be3bb368..b091b03d 100644
--- a/Makefile
+++ b/Makefile
@@ -326,7 +326,7 @@ dagster-cleanup-menu:
# DASHBOARD OPERATIONS
# =============================================================================
-.PHONY: dashboard dashboardd dashboard-uvicorn dashboardd-uvicorn dashboard-local-dev kill-dashboardd seed-local-db
+.PHONY: dashboard dashboardd dashboard-uvicorn dashboardd-uvicorn dashboard-local-dev kill-dashboardd seed-local-db seed-local-db-all seed-local-db-custom
# start dashboard locally
dashboard:
@@ -371,7 +371,14 @@ dashboard-local-dev:
# kill any running dashboard process
kill-dashboardd:
- kill $(shell ps aux | grep dashboard/app.py | grep -v grep | awk '{print $$2}') $(shell lsof -ti :5000)
+ @pids="$$(pgrep -f 'dashboard/app.py' 2>/dev/null) $$(pgrep -f 'uvicorn dashboard.app:app' 2>/dev/null) $$(lsof -ti :5003 2>/dev/null) $$(lsof -ti :8080 2>/dev/null) $$(lsof -ti :5000 2>/dev/null)"; \
+ pids="$$(printf '%s\n' $$pids | tr ' ' '\n' | sed '/^$$/d' | sort -u | tr '\n' ' ')"; \
+ if [ -n "$$pids" ]; then \
+ echo "🛑 Stopping dashboard processes: $$pids"; \
+ kill $$pids; \
+ else \
+ echo "No dashboard processes found"; \
+ fi
# =============================================================================
# TESTING & QUALITY
diff --git a/dashboard/app.py b/dashboard/app.py
index f3dd4126..f067da84 100644
--- a/dashboard/app.py
+++ b/dashboard/app.py
@@ -12,6 +12,7 @@
import logging
import os
+from pathlib import Path
from dotenv import load_dotenv
from fasthtml.common import Link, Script, fast_app, serve
@@ -19,12 +20,11 @@
from dashboard.constants import POSTHOG_SCRIPT
from dashboard.state import AppState
+from anomstack.version import get_git_commit_hash
# load the environment variables with custom env file support
def load_env_with_custom_path():
"""Load environment variables from custom path or default .env file."""
- from pathlib import Path
-
env_file_path = os.getenv("ANOMSTACK_ENV_FILE_PATH")
if env_file_path:
@@ -44,6 +44,8 @@ def load_env_with_custom_path():
load_env_with_custom_path()
log = logging.getLogger("anomstack_dashboard")
+STYLESHEET_PATH = Path(__file__).parent / "static" / "styles.css"
+STYLESHEET_VERSION = f"{get_git_commit_hash()}-{int(STYLESHEET_PATH.stat().st_mtime)}"
# PostHog script generation function (lazy loading to ensure env vars are available)
def get_posthog_script():
@@ -68,7 +70,12 @@ def get_posthog_script():
# Define the app
app, rt = fast_app(
hdrs=(
- Theme.blue.headers(),
+ Theme.blue.headers(
+ mode="auto",
+ radii=ThemeRadii.lg,
+ shadows=ThemeShadows.md,
+ font=ThemeFont.default,
+ ),
Script(src="https://cdn.plot.ly/plotly-2.32.0.min.js"),
Script(posthog_script) if posthog_script else None,
Link(
@@ -76,8 +83,13 @@ def get_posthog_script():
type="image/svg+xml",
href="data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIyNCIgaGVpZ2h0PSIyNCIgdmlld0JveD0iMCAwIDI0IDI0IiBmaWxsPSJub25lIiBzdHJva2U9ImN1cnJlbnRDb2xvciIgc3Ryb2tlLXdpZHRoPSIyIiBzdHJva2UtbGluZWNhcD0icm91bmQiIHN0cm9rZS1saW5lam9pbj0icm91bmQiIGNsYXNzPSJsdWNpZGUgbHVjaWRlLWNoYXJ0LWxpbmUiPjxwYXRoIGQ9Ik0zIDN2MTZhMiAyIDAgMCAwIDIgMmgxNiIvPjxwYXRoIGQ9Im0xOSA5LTUgNS00LTQtMyAzIi8+PC9zdmc+",
),
- Link(rel="stylesheet", href="dashboard/static/styles.css"),
+ Link(
+ rel="stylesheet",
+ href=f"/dashboard/static/styles.css?v={STYLESHEET_VERSION}",
+ ),
),
+ title="Anomstack",
+ bodykw={"class": "antialiased"},
debug=os.getenv("ANOMSTACK_DASHBOARD_DEBUG", "false").lower() == "true",
log=log,
)
diff --git a/dashboard/charts.py b/dashboard/charts.py
index ef8af10c..5ae13ed6 100644
--- a/dashboard/charts.py
+++ b/dashboard/charts.py
@@ -7,6 +7,8 @@
"""
+from __future__ import annotations
+
from fasthtml.common import Div, P
from monsterui.all import Card, DivLAligned, Loading, LoadingT, TextPresets
import pandas as pd
@@ -40,6 +42,7 @@ def get_chart_config():
"responsive": True,
"scrollZoom": False,
"staticPlot": False,
+ "doubleClick": "reset",
}
@staticmethod
@@ -72,13 +75,13 @@ def create_expanded_chart(df_metric, chart_index):
"modeBarButtonsToRemove": [
"select2d",
"lasso2d",
+ "toggleSpikelines",
],
"responsive": True,
"scrollZoom": True,
"staticPlot": False,
"fillFrame": True,
"displaylogo": False,
- "modeBarButtonsToAdd": [],
"toImageButtonOptions": {
"format": "png",
"filename": f"metric_chart_{chart_index}",
@@ -150,6 +153,7 @@ def create_sparkline(df_metric: pd.DataFrame, anomaly_timestamp: pd.Timestamp =
str: The HTML for the sparkline.
"""
colors = ChartStyle.get_colors(app.state.dark_mode)
+ line_shape = ChartStyle.get_line_shape(df_metric)
fig = make_subplots(specs=[[{"secondary_y": True}]])
# Add the main line
@@ -161,8 +165,12 @@ def create_sparkline(df_metric: pd.DataFrame, anomaly_timestamp: pd.Timestamp =
mode="lines",
line=dict(
color=colors["primary"],
- width=1,
+ width=1.5,
+ shape=line_shape,
+ smoothing=0.7 if line_shape == "spline" else 0,
),
+ fill="tozeroy" if df_metric["metric_value"].min() >= 0 else None,
+ fillcolor=colors["primary_fill"],
showlegend=False,
connectgaps=True,
),
@@ -180,6 +188,8 @@ def create_sparkline(df_metric: pd.DataFrame, anomaly_timestamp: pd.Timestamp =
color=colors["secondary"],
width=1,
dash="dot",
+ shape=line_shape,
+ smoothing=0.7 if line_shape == "spline" else 0,
),
showlegend=False,
connectgaps=True,
@@ -207,6 +217,7 @@ def create_sparkline(df_metric: pd.DataFrame, anomaly_timestamp: pd.Timestamp =
color=alert_color,
size=8,
symbol="diamond",
+ line=dict(width=1.5, color=colors["background_solid"]),
),
showlegend=False,
),
@@ -214,9 +225,8 @@ def create_sparkline(df_metric: pd.DataFrame, anomaly_timestamp: pd.Timestamp =
)
fig.update_layout(
- height=50,
- width=200,
- margin=dict(l=0, r=0, t=0, b=0),
+ height=72,
+ margin=dict(l=0, r=0, t=4, b=0),
xaxis=dict(
showgrid=False,
showticklabels=False,
@@ -235,6 +245,12 @@ def create_sparkline(df_metric: pd.DataFrame, anomaly_timestamp: pd.Timestamp =
),
paper_bgcolor="rgba(0,0,0,0)",
plot_bgcolor="rgba(0,0,0,0)",
+ hovermode="x unified",
+ hoverlabel=dict(
+ bgcolor=colors["background_solid"],
+ bordercolor=colors["border"],
+ font=dict(color=colors["text"], size=11),
+ ),
modebar=dict(
remove=[
"zoom",
@@ -267,6 +283,7 @@ def create_expanded_sparkline(df_metric: pd.DataFrame, anomaly_timestamp: pd.Tim
str: The HTML for the expanded sparkline.
"""
colors = ChartStyle.get_colors(app.state.dark_mode)
+ line_shape = ChartStyle.get_line_shape(df_metric)
fig = make_subplots(specs=[[{"secondary_y": True}]])
# Add the main line
@@ -279,11 +296,16 @@ def create_expanded_sparkline(df_metric: pd.DataFrame, anomaly_timestamp: pd.Tim
line=dict(
color=colors["primary"],
width=3,
+ shape=line_shape,
+ smoothing=0.7 if line_shape == "spline" else 0,
),
marker=dict(
- size=4,
+ size=5,
color=colors["primary"],
+ line=dict(width=1.5, color=colors["background_solid"]),
),
+ fill="tozeroy" if df_metric["metric_value"].min() >= 0 else None,
+ fillcolor=colors["primary_fill"],
showlegend=True,
connectgaps=True,
),
@@ -301,6 +323,8 @@ def create_expanded_sparkline(df_metric: pd.DataFrame, anomaly_timestamp: pd.Tim
color=colors["secondary"],
width=2,
dash="dot",
+ shape=line_shape,
+ smoothing=0.7 if line_shape == "spline" else 0,
),
showlegend=True,
connectgaps=True,
@@ -321,9 +345,9 @@ def create_expanded_sparkline(df_metric: pd.DataFrame, anomaly_timestamp: pd.Tim
mode="markers",
marker=dict(
size=12,
- color="red",
- symbol="circle",
- line=dict(width=2, color="white"),
+ color=colors["alert"],
+ symbol="diamond",
+ line=dict(width=2, color=colors["background_solid"]),
),
showlegend=True,
),
@@ -341,8 +365,9 @@ def create_expanded_sparkline(df_metric: pd.DataFrame, anomaly_timestamp: pd.Tim
mode="markers",
marker=dict(
size=8,
- color="orange",
+ color=colors["alert"],
symbol="diamond",
+ line=dict(width=1.5, color=colors["background_solid"]),
),
showlegend=True,
),
@@ -360,8 +385,9 @@ def create_expanded_sparkline(df_metric: pd.DataFrame, anomaly_timestamp: pd.Tim
mode="markers",
marker=dict(
size=8,
- color="purple",
+ color=colors["llmalert"],
symbol="star",
+ line=dict(width=1.5, color=colors["background_solid"]),
),
showlegend=True,
),
@@ -391,18 +417,27 @@ def create_expanded_sparkline(df_metric: pd.DataFrame, anomaly_timestamp: pd.Tim
paper_bgcolor=colors["background"],
plot_bgcolor=colors["background"],
font=dict(color=colors["text"]),
+ hovermode="x unified",
legend=dict(
orientation="h",
yanchor="bottom",
y=1.02,
- xanchor="right",
- x=1
+ xanchor="left",
+ x=0,
+ bgcolor=colors["background_solid"],
+ bordercolor=colors["border"],
+ borderwidth=1,
),
title=dict(
text="Anomaly Detail View",
x=0.5,
font=dict(size=16)
- )
+ ),
+ hoverlabel=dict(
+ bgcolor=colors["background_solid"],
+ bordercolor=colors["border"],
+ font=dict(color=colors["text"]),
+ ),
)
# Enhanced config for expanded view
@@ -411,6 +446,7 @@ def create_expanded_sparkline(df_metric: pd.DataFrame, anomaly_timestamp: pd.Tim
"modeBarButtonsToRemove": [
"select2d",
"lasso2d",
+ "toggleSpikelines",
],
"responsive": True,
"scrollZoom": True,
@@ -443,6 +479,7 @@ def create_single_anomaly_expanded_chart(df_metric: pd.DataFrame, anomaly_timest
str: The HTML for the expanded chart.
"""
colors = ChartStyle.get_colors(app.state.dark_mode)
+ line_shape = ChartStyle.get_line_shape(df_metric)
fig = make_subplots(specs=[[{"secondary_y": True}]])
# Add the main line (full time series)
@@ -455,11 +492,16 @@ def create_single_anomaly_expanded_chart(df_metric: pd.DataFrame, anomaly_timest
line=dict(
color=colors["primary"],
width=3,
+ shape=line_shape,
+ smoothing=0.7 if line_shape == "spline" else 0,
),
marker=dict(
- size=4,
+ size=5,
color=colors["primary"],
+ line=dict(width=1.5, color=colors["background_solid"]),
),
+ fill="tozeroy" if df_metric["metric_value"].min() >= 0 else None,
+ fillcolor=colors["primary_fill"],
showlegend=True,
connectgaps=True,
),
@@ -477,6 +519,8 @@ def create_single_anomaly_expanded_chart(df_metric: pd.DataFrame, anomaly_timest
color=colors["secondary"],
width=2,
dash="dot",
+ shape=line_shape,
+ smoothing=0.7 if line_shape == "spline" else 0,
),
showlegend=True,
connectgaps=True,
@@ -511,7 +555,7 @@ def create_single_anomaly_expanded_chart(df_metric: pd.DataFrame, anomaly_timest
size=15,
color=marker_color,
symbol=marker_symbol,
- line=dict(width=3, color="white"),
+ line=dict(width=3, color=colors["background_solid"]),
),
showlegend=True,
),
@@ -541,18 +585,27 @@ def create_single_anomaly_expanded_chart(df_metric: pd.DataFrame, anomaly_timest
paper_bgcolor=colors["background"],
plot_bgcolor=colors["background"],
font=dict(color=colors["text"]),
+ hovermode="x unified",
legend=dict(
orientation="h",
yanchor="bottom",
y=1.02,
- xanchor="right",
- x=1
+ xanchor="left",
+ x=0,
+ bgcolor=colors["background_solid"],
+ bordercolor=colors["border"],
+ borderwidth=1,
),
title=dict(
text="Single Anomaly Detail View",
x=0.5,
font=dict(size=16)
- )
+ ),
+ hoverlabel=dict(
+ bgcolor=colors["background_solid"],
+ bordercolor=colors["border"],
+ font=dict(color=colors["text"]),
+ ),
)
# Enhanced config for expanded view
@@ -561,6 +614,7 @@ def create_single_anomaly_expanded_chart(df_metric: pd.DataFrame, anomaly_timest
"modeBarButtonsToRemove": [
"select2d",
"lasso2d",
+ "toggleSpikelines",
],
"responsive": True,
"scrollZoom": True,
@@ -589,31 +643,58 @@ class ChartStyle:
def get_colors(dark_mode: bool) -> dict:
"""Return theme-based colors."""
return {
- "background": "#1a1a1a" if dark_mode else "white",
- "text": "#e5e7eb" if dark_mode else "#64748b",
- "grid": "rgba(255,255,255,0.1)" if dark_mode else "rgba(0,0,0,0.1)",
- "primary": "#3b82f6" if dark_mode else "#2563eb",
- "secondary": "#9ca3af" if dark_mode else "#64748b",
- "alert": "#ef4444" if dark_mode else "#dc2626",
- "llmalert": "#f97316" if dark_mode else "#fb923c",
- "change": "#fb923c" if dark_mode else "#f97316",
+ "background": "rgba(15,23,42,0.0)" if dark_mode else "rgba(255,255,255,0.0)",
+ "background_solid": "#111827" if dark_mode else "#ffffff",
+ "text": "#dbe4f0" if dark_mode else "#334155",
+ "muted_text": "#94a3b8" if dark_mode else "#64748b",
+ "grid": "rgba(148,163,184,0.12)" if dark_mode else "rgba(148,163,184,0.12)",
+ "border": "rgba(148,163,184,0.22)" if dark_mode else "rgba(148,163,184,0.18)",
+ "primary": "#60a5fa" if dark_mode else "#2563eb",
+ "primary_fill": "rgba(96,165,250,0.14)" if dark_mode else "rgba(37,99,235,0.12)",
+ "primary_glow": "rgba(96,165,250,0.24)" if dark_mode else "rgba(37,99,235,0.18)",
+ "secondary": "rgba(148,163,184,0.92)" if dark_mode else "rgba(71,85,105,0.72)",
+ "alert": "#f43f5e" if dark_mode else "#e11d48",
+ "llmalert": "#f59e0b" if dark_mode else "#d97706",
+ "change": "#14b8a6" if dark_mode else "#0f766e",
}
@staticmethod
- def get_common_styling(colors: dict) -> tuple:
+ def get_common_styling(colors: dict, compact: bool = False) -> tuple:
"""Return common styling configurations."""
- common_font = dict(size=10, color=colors["text"])
- common_title_font = dict(size=12, color=colors["text"])
+ common_font = dict(size=9 if compact else 10, color=colors["muted_text"])
+ common_title_font = dict(size=11 if compact else 12, color=colors["text"])
common_grid = dict(
showgrid=True,
- gridwidth=1,
+ gridwidth=0.8,
gridcolor=colors["grid"],
zeroline=False,
tickfont=common_font,
title_font=common_title_font,
+ linecolor=colors["border"],
+ tickcolor=colors["border"],
+ automargin=True,
)
return common_font, common_title_font, common_grid
+ @staticmethod
+ def get_line_shape(df: pd.DataFrame) -> str:
+ """Return a smoother line shape for denser time series."""
+ return "spline" if len(df) >= 12 else "linear"
+
+ @staticmethod
+ def get_marker_mode(
+ df: pd.DataFrame,
+ show_markers: bool,
+ small_charts: bool,
+ base_mode: str = "lines",
+ ) -> str:
+ """Keep compact charts clean by only showing dense point markers when useful."""
+ if not show_markers:
+ return base_mode
+ if small_charts and len(df) > 32:
+ return base_mode
+ return f"{base_mode}+markers"
+
def plot_time_series(
df,
@@ -639,105 +720,138 @@ def plot_time_series(
"""
df["metric_llmalert"] = df["metric_llmalert"].clip(upper=1)
colors = ChartStyle.get_colors(dark_mode)
- _, _, common_grid = ChartStyle.get_common_styling(colors)
+ _, _, common_grid = ChartStyle.get_common_styling(colors, compact=small_charts)
# Define height based on size toggle
- height = 250 if small_charts else 400
+ height = 230 if small_charts else 400
# Create figure with secondary y-axis
fig = make_subplots(specs=[[{"secondary_y": True}]])
# Add main metric value trace
- _add_main_metric_trace(fig, df, colors, show_markers, line_width, show_legend)
+ _add_main_metric_trace(fig, df, colors, show_markers, line_width, show_legend, small_charts)
# Add metric score trace
- _add_score_trace(fig, df, colors, line_width, show_legend)
+ _add_score_trace(fig, df, colors, line_width, show_legend, small_charts)
# Add alert and change markers if they exist
- _add_condition_traces(fig, df, colors, line_width, show_legend)
+ _add_condition_traces(fig, df, colors, line_width, show_legend, small_charts)
+
+ # Add a subtle endpoint marker so the latest value anchors the eye.
+ _add_latest_value_trace(fig, df, colors, small_charts)
# Update axes
- _update_axes_and_layout(fig, common_grid, colors, show_legend, height)
+ _update_axes_and_layout(fig, common_grid, colors, show_legend, height, small_charts)
return fig
-def _add_main_metric_trace(fig, df, colors, show_markers, line_width, show_legend):
+def _add_main_metric_trace(fig, df, colors, show_markers, line_width, show_legend, small_charts):
"""Add the main metric value trace to the figure."""
+ line_shape = ChartStyle.get_line_shape(df)
+ allow_fill = df["metric_value"].min() >= 0
+ marker_mode = ChartStyle.get_marker_mode(df, show_markers, small_charts)
+ marker_size = max(line_width + (1 if small_charts else 2), 4 if small_charts else 5)
fig.add_trace(
go.Scatter(
x=df["metric_timestamp"],
y=df["metric_value"],
name="Value",
- mode="lines" + ("+markers" if show_markers else ""),
- line=dict(color=colors["primary"], width=line_width),
+ mode=marker_mode,
+ line=dict(
+ color=colors["primary"],
+ width=line_width + (0.35 if small_charts else 0.7),
+ shape=line_shape,
+ smoothing=0.55 if line_shape == "spline" else 0,
+ ),
marker=(
- dict(size=line_width + 4, color=colors["primary"], symbol="circle")
- if show_markers
+ dict(
+ size=marker_size,
+ color=colors["primary"],
+ symbol="circle",
+ opacity=0.72 if small_charts else 0.82,
+ line=dict(width=1.1, color=colors["background_solid"]),
+ )
+ if marker_mode.endswith("+markers")
else None
),
+ fill="tozeroy" if allow_fill else None,
+ fillcolor=colors["primary_fill"] if allow_fill else None,
showlegend=show_legend,
connectgaps=True,
hovertemplate=(
- f'Value: %{{y:.2f}}
'
- f"Time: %{{x}}"
+ "Value %{y:.2f}
"
+ "%{x|%b %d, %Y %H:%M}"
),
),
secondary_y=False,
)
-def _add_score_trace(fig, df, colors, line_width, show_legend):
+def _add_score_trace(fig, df, colors, line_width, show_legend, small_charts):
"""Add the metric score trace to the figure."""
+ line_shape = ChartStyle.get_line_shape(df)
fig.add_trace(
go.Scatter(
x=df["metric_timestamp"],
y=df["metric_score"],
name="Score",
- line=dict(color=colors["secondary"], width=line_width, dash="dot"),
+ mode="lines",
+ line=dict(
+ color=colors["secondary"],
+ width=max(line_width - 0.5, 1.1 if small_charts else 1.5),
+ dash="solid",
+ shape=line_shape,
+ smoothing=0.45 if line_shape == "spline" else 0,
+ ),
showlegend=show_legend,
connectgaps=True,
hovertemplate=(
- f'Score: %{{y:.1%}}
'
- f"Time: %{{x}}"
+ "Score %{y:.1%}
"
+ "%{x|%b %d, %Y %H:%M}"
),
),
secondary_y=True,
)
-def _add_condition_traces(fig, df, colors, line_width, show_legend):
+def _add_condition_traces(fig, df, colors, line_width, show_legend, small_charts):
"""Add alert and change markers to the figure."""
+ explanation_data = (
+ df["anomaly_explanation"].fillna("")
+ if "anomaly_explanation" in df.columns
+ else pd.Series([""] * len(df), index=df.index)
+ )
for condition, props in {
"metric_alert": dict(
name="Alert",
color=colors["alert"],
+ symbol="diamond",
hovertemplate=(
- f''
- f"Alert
"
- "Time: %{x}"
- ""
+ "Alert
"
+ "Value %{y:.2f}
"
+ "%{x|%b %d, %Y %H:%M}"
),
),
"metric_llmalert": dict(
name="LLM Alert",
color=colors["llmalert"],
+ symbol="star-diamond",
hovertemplate=(
- f''
- f"LLM Alert
"
- "Time: %{x}
"
- "Details: %{customdata}"
- ""
+ "LLM Alert
"
+ "Value %{y:.2f}
"
+ "%{x|%b %d, %Y %H:%M}
"
+ "%{customdata}"
),
),
"metric_change": dict(
name="Change",
color=colors["change"],
+ symbol="diamond-open",
hovertemplate=(
- f''
- f"Change
"
- "Time: %{x}"
- ""
+ "Change
"
+ "Value %{y:.2f}
"
+ "%{x|%b %d, %Y %H:%M}"
),
),
}.items():
@@ -746,57 +860,141 @@ def _add_condition_traces(fig, df, colors, line_width, show_legend):
fig.add_trace(
go.Scatter(
x=condition_df["metric_timestamp"],
- y=condition_df[condition],
+ y=condition_df["metric_value"],
mode="markers",
name=props["name"],
- marker=dict(color=props["color"], size=line_width + 4, symbol="circle"),
+ marker=dict(
+ color=props["color"],
+ size=line_width + (6 if small_charts else 7),
+ symbol=props["symbol"],
+ line=dict(width=1.5, color=colors["background_solid"]),
+ ),
showlegend=show_legend,
- customdata=condition_df["anomaly_explanation"],
+ customdata=explanation_data.loc[condition_df.index],
hovertemplate=props["hovertemplate"],
),
- secondary_y=True,
+ secondary_y=False,
)
-def _update_axes_and_layout(fig, common_grid, colors, show_legend, height):
+def _add_latest_value_trace(fig, df, colors, small_charts):
+ """Highlight the latest unflagged datapoint with a subtle halo."""
+ latest_value = df.dropna(subset=["metric_value"]).tail(1)
+ if latest_value.empty:
+ return
+
+ latest_row = latest_value.iloc[0]
+ is_flagged = any(
+ latest_row.get(flag, 0) == 1 for flag in ("metric_alert", "metric_llmalert", "metric_change")
+ )
+ if is_flagged:
+ return
+
+ x = [latest_row["metric_timestamp"]]
+ y = [latest_row["metric_value"]]
+ halo_size = 14 if small_charts else 16
+ point_size = 5 if small_charts else 6
+ fig.add_trace(
+ go.Scatter(
+ x=x,
+ y=y,
+ mode="markers",
+ hoverinfo="skip",
+ showlegend=False,
+ marker=dict(size=halo_size, color=colors["primary_glow"], symbol="circle"),
+ ),
+ secondary_y=False,
+ )
+ fig.add_trace(
+ go.Scatter(
+ x=x,
+ y=y,
+ mode="markers",
+ hovertemplate=(
+ "Latest value %{y:.2f}
"
+ "%{x|%b %d, %Y %H:%M}"
+ ),
+ showlegend=False,
+ marker=dict(
+ size=point_size,
+ color=colors["primary"],
+ symbol="circle",
+ line=dict(width=1.2, color=colors["background_solid"]),
+ ),
+ ),
+ secondary_y=False,
+ )
+
+
+def _update_axes_and_layout(fig, common_grid, colors, show_legend, height, small_charts):
"""Update axes and layout of the figure."""
+ compact_grid = {**common_grid, "showgrid": False}
+ x_grid = compact_grid if small_charts else common_grid
+
# Update axes
- fig.update_xaxes(**common_grid)
- fig.update_yaxes(title_text="Value", secondary_y=False, **common_grid)
+ fig.update_xaxes(
+ **x_grid,
+ showspikes=True,
+ spikemode="across",
+ spikesnap="cursor",
+ spikecolor=colors["grid"],
+ spikethickness=1,
+ showline=False,
+ title_text=None if small_charts else "Time",
+ showticklabels=True,
+ nticks=5 if small_charts else 8,
+ )
fig.update_yaxes(
- title_text="Score",
+ title_text=None if small_charts else "Value",
+ secondary_y=False,
+ **common_grid,
+ showline=False,
+ showticklabels=not small_charts,
+ nticks=4 if not small_charts else 3,
+ )
+ fig.update_yaxes(
+ title_text=None if small_charts else "Score",
secondary_y=True,
showgrid=False,
range=[0, 1.05],
tickformat=".0%",
- **{k: v for k, v in common_grid.items() if k != "showgrid"},
+ showticklabels=not small_charts,
+ **{k: v for k, v in common_grid.items() if k not in {"showgrid", "gridwidth", "gridcolor"}},
)
# Update layout
fig.update_layout(
plot_bgcolor=colors["background"],
paper_bgcolor=colors["background"],
- hovermode="closest",
- hoverdistance=100,
+ hovermode="x unified",
+ hoverdistance=60,
+ spikedistance=1000,
showlegend=show_legend,
legend=(
dict(
orientation="h",
yanchor="bottom",
- y=1.02,
- xanchor="left",
- x=0,
- bgcolor=colors["background"],
+ y=1.02 if not small_charts else 1.01,
+ xanchor="right" if small_charts else "left",
+ x=1 if small_charts else 0,
+ bgcolor=colors["background_solid"],
+ bordercolor=colors["border"],
+ borderwidth=1,
font=dict(color=colors["text"]),
)
if show_legend
else None
),
- margin=dict(t=5, b=5, l=5, r=5),
+ margin=dict(
+ t=12 if small_charts else 18,
+ b=24 if small_charts else 40,
+ l=14 if small_charts else 44,
+ r=12 if small_charts else 34,
+ ),
height=height,
hoverlabel=dict(
- bgcolor="white",
- bordercolor="white",
- font=dict(size=12),
+ bgcolor=colors["background_solid"],
+ bordercolor=colors["border"],
+ font=dict(size=11 if small_charts else 12, color=colors["text"]),
),
)
diff --git a/dashboard/components/batch.py b/dashboard/components/batch.py
index dc6f9ca3..1f419085 100644
--- a/dashboard/components/batch.py
+++ b/dashboard/components/batch.py
@@ -7,20 +7,26 @@
"""
+from __future__ import annotations
+
from fasthtml.common import A, Div, Li, P
from monsterui.all import (
Button,
ButtonT,
Card,
+ CodeSpan,
DividerLine,
DivLAligned,
DropDownNavContainer,
+ H3,
NavHeaderLi,
+ Subtitle,
TextPresets,
UkIcon,
)
from dashboard.app import app
+from dashboard.presentation import format_batch_name, format_metric_count
def create_batches_dropdown(batch_name: str) -> DropDownNavContainer:
@@ -37,7 +43,7 @@ def create_batches_dropdown(batch_name: str) -> DropDownNavContainer:
*[
Li(
A(
- name,
+ format_batch_name(name),
hx_get=f"/batch/{name}",
hx_push_url=f"/batch/{name}",
hx_target="#main-content",
@@ -51,7 +57,7 @@ def create_batches_dropdown(batch_name: str) -> DropDownNavContainer:
)
-def create_batch_card(batch_name: str, stats: dict) -> Card:
+def create_batch_card(batch_name: str, stats: dict):
"""Create a card displaying batch information.
Args:
@@ -59,47 +65,47 @@ def create_batch_card(batch_name: str, stats: dict) -> Card:
stats (dict): The statistics for the batch.
Returns:
- Card: The card.
+ A linked batch card.
"""
- metric_info = [
- (UkIcon("activity", cls="text-blue-500"), f"{stats['unique_metrics']} metrics"),
- (UkIcon("clock", cls="text-green-500"), f"{stats['latest_timestamp']}"),
- (
- UkIcon("bar-chart", cls="text-purple-500"),
- f"Avg Score: {stats['avg_score']:.1%}",
- ),
- (UkIcon("alert-circle", cls="text-red-500"), f"{stats['alert_count']} alerts"),
+ stat_blocks = [
+ ("Metrics", format_metric_count(stats["unique_metrics"]), UkIcon("activity")),
+ ("Freshness", stats["latest_timestamp"], UkIcon("clock")),
+ ("Avg score", f"{stats['avg_score']:.1%}", UkIcon("bar-chart")),
+ ("Alerts", f"{int(stats['alert_count'])}", UkIcon("alert-circle")),
]
metric_divs = [
- DivLAligned(
- icon,
- P(text, cls=TextPresets.muted_sm),
- cls="space-x-2",
+ Div(
+ DivLAligned(icon, P(label, cls="batch-stat-label"), cls="space-x-2"),
+ P(value, cls="batch-stat-value"),
+ cls="batch-stat-block",
)
- for icon, text in metric_info
+ for label, value, icon in stat_blocks
]
- return Card(
- DivLAligned(
+ return A(
+ Card(
Div(
- Button(
- batch_name,
- hx_get=f"/batch/{batch_name}",
- hx_push_url=f"/batch/{batch_name}",
- hx_target="#main-content",
- hx_indicator="#loading",
- cls=(ButtonT.primary, "w-full"),
- ),
- DividerLine(),
- DivLAligned(
- Div(*metric_divs, cls="space-y-1"),
- ),
+ CodeSpan(batch_name),
+ H3(format_batch_name(batch_name), cls="mt-3 mb-1"),
+ Subtitle("Metrics, freshness, and recent anomaly activity"),
+ Div(DividerLine(), cls="my-4"),
+ Div(*metric_divs, cls="batch-card-stats"),
cls="w-full",
),
- cls="w-full",
+ footer=DivLAligned(
+ P("Open batch"),
+ UkIcon("arrow-right"),
+ cls="space-x-2 justify-center batch-card-footer",
+ ),
+ cls="batch-card",
),
- cls="px-2 py-0.5 hover:border-primary transition-colors duration-200",
+ href=f"/batch/{batch_name}",
+ hx_get=f"/batch/{batch_name}",
+ hx_push_url=f"/batch/{batch_name}",
+ hx_target="#main-content",
+ hx_indicator="#loading",
+ cls="batch-card-link",
)
diff --git a/dashboard/components/common.py b/dashboard/components/common.py
index 0bf4998f..98d0ca50 100644
--- a/dashboard/components/common.py
+++ b/dashboard/components/common.py
@@ -7,13 +7,32 @@
"""
-from fasthtml.common import Div
-from monsterui.all import Card, DivFullySpaced
+from __future__ import annotations
+from fasthtml.common import Div, P
+from monsterui.all import Card, CodeSpan, DivFullySpaced, H2, Subtitle
+
+from dashboard.app import app
+from dashboard.presentation import format_batch_name, get_filtered_metric_stats
from .search import create_last_n_form, create_search_form
from .toolbar import create_toolbar_buttons
+def create_controls_summary(batch_name: str, hx_swap_oob: str | None = None) -> Div:
+ """Create the summary pills shown above the batch filters."""
+ total_metrics = len(app.state.stats_cache.get(batch_name, []))
+ visible_metrics = len(get_filtered_metric_stats(batch_name))
+ current_window = app.state.last_n.get(batch_name, "90n")
+
+ return Div(
+ P(f"{visible_metrics} of {total_metrics} metrics shown", cls="dashboard-pill"),
+ P(f"Window {current_window}", cls="dashboard-pill"),
+ cls="controls-pills",
+ id=f"controls-summary-{batch_name}",
+ hx_swap_oob=hx_swap_oob,
+ )
+
+
def create_controls(batch_name: str) -> Card:
"""Create the main controls for the dashboard.
@@ -24,21 +43,32 @@ def create_controls(batch_name: str) -> Card:
Card: The card.
"""
return Card(
- DivFullySpaced(
+ Div(
+ DivFullySpaced(
+ Div(
+ CodeSpan(batch_name),
+ H2(format_batch_name(batch_name), cls="mt-3 mb-1 text-2xl"),
+ Subtitle("Browse metrics, adjust the chart window, and inspect anomalies."),
+ cls="space-y-1",
+ ),
+ create_toolbar_buttons(batch_name),
+ cls="controls-header",
+ ),
+ create_controls_summary(batch_name),
Div(
Div(
- Div(
- create_toolbar_buttons(batch_name),
- Div(
- create_search_form(batch_name),
- create_last_n_form(batch_name),
- cls="flex flex-col space-y-2 md:flex-row md:space-y-0 md:space-x-4 mt-4",
- ),
- cls="flex flex-col w-full",
- ),
+ P("Filter metrics", cls="controls-label"),
+ create_search_form(batch_name),
+ cls="space-y-2 min-w-0",
+ ),
+ Div(
+ P("Time window", cls="controls-label"),
+ create_last_n_form(batch_name),
+ cls="space-y-2",
),
- cls="w-full",
+ cls="controls-form-grid",
),
+ cls="space-y-5",
),
- cls="mb-4 uk-padding-small py-2 shadow-sm",
+ cls="controls-card mb-5",
)
diff --git a/dashboard/components/header.py b/dashboard/components/header.py
index be6be353..d7934e93 100644
--- a/dashboard/components/header.py
+++ b/dashboard/components/header.py
@@ -7,6 +7,8 @@
"""
+from __future__ import annotations
+
from fasthtml.common import H2, A, Div, P
from monsterui.all import DivLAligned, TextPresets, UkIcon
diff --git a/dashboard/components/search.py b/dashboard/components/search.py
index 660e95ad..c0a4fe15 100644
--- a/dashboard/components/search.py
+++ b/dashboard/components/search.py
@@ -28,10 +28,10 @@ def create_search_form(batch_name: str) -> Form:
Input(
type="search",
name="search",
- placeholder="Search metrics...",
+ placeholder="Filter metrics by name",
value=current_search,
- cls="uk-input uk-form-small rounded-md border-gray-200 w-full md:w-[220px]",
- uk_tooltip="Filter metrics by name",
+ cls="uk-input rounded-md border-gray-200 w-full",
+ uk_tooltip="Filter metrics by metric name",
autocomplete="off",
aria_label="Search metrics",
hx_get=f"/batch/{batch_name}/search",
@@ -40,11 +40,10 @@ def create_search_form(batch_name: str) -> Form:
hx_trigger="input changed delay:300ms, search",
hx_indicator="#loading",
hx_sync="closest form:abort",
- _="on htmx:afterRequest if event.detail.successful console.log('Search completed')",
),
id=f"search-form-{batch_name}",
onsubmit="return false;",
- cls="w-full md:w-auto",
+ cls="w-full",
)
@@ -66,9 +65,10 @@ def create_last_n_form(batch_name: str) -> Form:
name="last_n",
value=current_last_n,
pattern=r"^\d+[nNhmd]$",
- title="Use format: 90n (observations), 24h (hours), 45m (minutes), 7d (days)",
- cls="uk-input uk-form-small rounded-md border-gray-200 w-full md:w-[110px]",
- uk_tooltip="Filter by last N observations or time period (e.g., 90n, 24h, 45m, 7d)",
+ title="Use format like 90n, 24h, 45m, or 7d",
+ cls="uk-input rounded-md border-gray-200 w-full md:w-[124px]",
+ uk_tooltip="Window for recent observations, for example 90n, 24h, 45m, or 7d",
+ aria_label="Time window",
hx_trigger="change delay:500ms",
hx_post=f"/batch/{batch_name}/update-n",
hx_target="#charts-container",
diff --git a/dashboard/components/settings.py b/dashboard/components/settings.py
index 3c544fea..ed023d42 100644
--- a/dashboard/components/settings.py
+++ b/dashboard/components/settings.py
@@ -7,6 +7,8 @@
"""
+from __future__ import annotations
+
from fasthtml.common import A, Li
from monsterui.all import DropDownNavContainer, NavDividerLi, NavHeaderLi
@@ -46,27 +48,27 @@ def create_settings_dropdown(batch_name: str) -> DropDownNavContainer:
"""
buttons = [
(
- "large charts" if app.state.small_charts else "small charts",
+ "Use spacious charts" if app.state.small_charts else "Use compact charts",
"toggle-size",
"Toggle between compact and full-size chart views",
),
(
- "two columns" if not app.state.two_columns else "one column",
+ "Use two columns" if not app.state.two_columns else "Use one column",
"toggle-columns",
"Display charts in one or two columns",
),
(
- "hide markers" if app.state.show_markers else "show markers",
+ "Hide markers" if app.state.show_markers else "Show markers",
"toggle-markers",
"Show/hide data point markers on the charts",
),
(
- "hide legend" if app.state.show_legend else "show legend",
+ "Hide legend" if app.state.show_legend else "Show legend",
"toggle-legend",
"Display chart legends",
),
(
- "normal lines" if not app.state.line_width == 2 else "narrow lines",
+ "Use normal lines" if app.state.line_width != 2 else "Use narrow lines",
"toggle-line-width",
"Toggle between narrow and normal line thickness",
),
@@ -81,7 +83,7 @@ def create_settings_dropdown(batch_name: str) -> DropDownNavContainer:
[
NavDividerLi(),
create_settings_button(
- "dark mode" if not app.state.dark_mode else "light mode",
+ "Use dark mode" if not app.state.dark_mode else "Use light mode",
batch_name,
"toggle-theme",
"Switch between light and dark color themes",
diff --git a/dashboard/components/toolbar.py b/dashboard/components/toolbar.py
index f30c1087..6fa8b192 100644
--- a/dashboard/components/toolbar.py
+++ b/dashboard/components/toolbar.py
@@ -7,7 +7,9 @@
"""
-from fasthtml.common import Div
+from __future__ import annotations
+
+from fasthtml.common import Div, P
from monsterui.all import Button, ButtonT, DivLAligned, UkIcon
from .batch import create_batches_dropdown
@@ -25,39 +27,57 @@ def create_toolbar_buttons(batch_name: str) -> Div:
"""
return Div(
Button(
- DivLAligned(UkIcon("home")),
+ DivLAligned(
+ UkIcon("home"),
+ P("Home", cls="hidden xl:inline"),
+ cls="space-x-2",
+ ),
hx_get="/",
hx_push_url="/",
hx_target="#main-content",
- cls=ButtonT.secondary,
+ hx_indicator="#loading",
+ cls=(ButtonT.secondary, "toolbar-btn"),
uk_tooltip="Return to homepage",
+ aria_label="Return to homepage",
),
Button(
DivLAligned(UkIcon("menu")),
- cls=ButtonT.secondary,
+ cls=(ButtonT.secondary, "toolbar-btn"),
uk_tooltip="Select metric batch to display",
+ aria_label="Open metric batch selector",
),
create_batches_dropdown(batch_name),
Button(
DivLAligned(UkIcon("settings")),
- cls=ButtonT.secondary,
+ cls=(ButtonT.secondary, "toolbar-btn"),
uk_tooltip="Customize chart display settings",
+ aria_label="Open chart display settings",
),
create_settings_dropdown(batch_name),
Button(
- DivLAligned(UkIcon("refresh-ccw")),
+ DivLAligned(
+ UkIcon("refresh-ccw"),
+ P("Refresh", cls="hidden xl:inline"),
+ cls="space-x-2",
+ ),
hx_get=f"/batch/{batch_name}/refresh",
hx_target="#main-content",
- cls=ButtonT.secondary,
+ cls=(ButtonT.secondary, "toolbar-btn"),
uk_tooltip="Refresh metrics data from source",
+ aria_label="Refresh metrics data",
),
Button(
- DivLAligned(UkIcon("alert-circle")),
+ DivLAligned(
+ UkIcon("alert-circle"),
+ P("Anomalies", cls="hidden xl:inline"),
+ cls="space-x-2",
+ ),
hx_get=f"/batch/{batch_name}/anomalies",
hx_push_url=f"/batch/{batch_name}/anomalies",
hx_target="#main-content",
- cls=ButtonT.secondary,
+ cls=(ButtonT.secondary, "toolbar-btn"),
uk_tooltip="View anomaly list",
+ aria_label="View anomaly list",
),
- cls="flex items-center space-x-2 flex-wrap",
+ cls="toolbar-stack",
)
diff --git a/dashboard/presentation.py b/dashboard/presentation.py
new file mode 100644
index 00000000..7ee733be
--- /dev/null
+++ b/dashboard/presentation.py
@@ -0,0 +1,44 @@
+"""
+dashboard/presentation.py
+
+Presentation helpers for the dashboard UI.
+"""
+
+from dashboard.app import app
+
+
+def format_batch_name(batch_name: str) -> str:
+ """Convert internal batch ids into friendlier display labels."""
+ return batch_name.replace("_", " ").replace("-", " ").strip().title()
+
+
+def format_metric_count(count: int) -> str:
+ """Return a metric count label with simple pluralization."""
+ return f"{count} metric" if count == 1 else f"{count} metrics"
+
+
+def get_filtered_metric_stats(batch_name: str) -> list[tuple[int, dict]]:
+ """Return metric stats together with their original indices after filtering."""
+ stats = app.state.stats_cache.get(batch_name, [])
+ indexed_stats = list(enumerate(stats))
+
+ search_term = app.state.search_term.get(batch_name, "").strip().lower()
+ if not search_term:
+ return indexed_stats
+
+ return [
+ (index, stat)
+ for index, stat in indexed_stats
+ if search_term in stat["metric_name"].lower()
+ ]
+
+
+def summarize_batches(batch_stats: dict, active_batch_names: list[str]) -> dict:
+ """Create summary stats for the homepage overview cards."""
+ active_stats = [batch_stats[name] for name in active_batch_names]
+ return {
+ "active_batches": len(active_batch_names),
+ "configured_batches": len(batch_stats),
+ "tracked_metrics": sum(stat["unique_metrics"] for stat in active_stats),
+ "alerts": int(sum(stat["alert_count"] for stat in active_stats)),
+ }
diff --git a/dashboard/routes/batch.py b/dashboard/routes/batch.py
index 75ca5676..3812f483 100644
--- a/dashboard/routes/batch.py
+++ b/dashboard/routes/batch.py
@@ -7,7 +7,7 @@
"""
-from fasthtml.common import H4, Div, P, Request, Safe, Script, Style, Table, Td, Th, Tr
+from fasthtml.common import H4, Div, P, Request, Safe, Script, Title
from monsterui.all import Button, ButtonT, Card, DivLAligned, UkIcon
import pandas as pd
@@ -17,6 +17,7 @@
from dashboard.components import create_controls
from dashboard.constants import DEFAULT_LAST_N, DEFAULT_LOAD_N_CHARTS
from dashboard.data import get_data
+from dashboard.presentation import get_filtered_metric_stats
from monsterui.all import Modal, ModalTitle
@@ -40,8 +41,19 @@ def get_batch_data(batch_name: str) -> pd.DataFrame:
return pd.DataFrame(data=[], columns=["metric_name", "metric_timestamp", "metric_value"])
+def get_chart_grid_classes() -> str:
+ """Return the responsive grid classes for metric charts."""
+ if app.state.two_columns:
+ return "grid grid-cols-1 gap-5 xl:grid-cols-2"
+ return "grid grid-cols-1 gap-5"
+
+
@rt("/batch/{batch_name}")
-def get_batch_view(batch_name: str, initial_load: int = DEFAULT_LOAD_N_CHARTS):
+def get_batch_view(
+ batch_name: str,
+ request: Request,
+ initial_load: int = DEFAULT_LOAD_N_CHARTS,
+):
"""Get the batch view.
Args:
@@ -55,47 +67,50 @@ def get_batch_view(batch_name: str, initial_load: int = DEFAULT_LOAD_N_CHARTS):
app.state.df_cache[batch_name] = get_batch_data(batch_name)
app.state.calculate_metric_stats(batch_name)
- metric_stats = app.state.stats_cache[batch_name]
- remaining_metrics = len(metric_stats) - initial_load
+ visible_metric_stats = get_filtered_metric_stats(batch_name)
+ remaining_metrics = max(len(visible_metric_stats) - initial_load, 0)
script = Script(
- f"""
- document.querySelectorAll('.top-nav li').forEach(li => {{
- li.classList.remove('uk-active');
- if (li.querySelector('a').textContent.trim() === '{batch_name}') {{
- li.classList.add('uk-active');
- }}
- }});
- window.scrollTo({{ top: 0, behavior: 'smooth' }});
-
- // Force scrollbar to always be visible to prevent layout shifts
- function forceScrollbar() {{
- document.documentElement.style.overflowY = 'scroll';
- document.body.style.overflowY = 'scroll';
- }}
-
- // Apply immediately and after any dynamic content loads
- forceScrollbar();
- document.addEventListener('htmx:afterSwap', forceScrollbar);
- document.addEventListener('htmx:afterSettle', forceScrollbar);
-
- // Also apply after a short delay to catch any late-loading content
- setTimeout(forceScrollbar, 100);
- setTimeout(forceScrollbar, 500);
- setTimeout(forceScrollbar, 1000);
- """
+ "window.scrollTo({ top: 0, behavior: 'smooth' });"
)
- load_next = min(DEFAULT_LOAD_N_CHARTS, remaining_metrics)
- return Div(
+ if not visible_metric_stats:
+ content = Div(
+ create_controls(batch_name),
+ Card(
+ P(
+ "No metrics match the current filters. Clear the search box or widen the time window.",
+ cls="text-muted-foreground",
+ ),
+ cls="empty-batches-card",
+ ),
+ Div(id="charts-container", cls=get_chart_grid_classes()),
+ Div(id="load-more-container"),
+ script,
+ )
+ is_htmx = request.headers.get("HX-Request") == "true"
+ if is_htmx:
+ return content
+ return (
+ Title("Anomstack"),
+ Div(
+ Safe('Loading...'),
+ id="loading",
+ cls="loading-indicator",
+ ),
+ Div(content, id="main-content"),
+ )
+
+ load_next = min(DEFAULT_LOAD_N_CHARTS, remaining_metrics) if remaining_metrics else 0
+ content = Div(
create_controls(batch_name),
Div(
*[
- ChartManager.create_chart_placeholder(stat["metric_name"], i, batch_name)
- for i, stat in enumerate(metric_stats[:initial_load])
+ ChartManager.create_chart_placeholder(stat["metric_name"], original_index, batch_name)
+ for original_index, stat in visible_metric_stats[:initial_load]
],
id="charts-container",
- cls=f"grid grid-cols-{2 if app.state.two_columns else 1} gap-4",
+ cls=get_chart_grid_classes(),
),
Div(
Button(
@@ -112,6 +127,18 @@ def get_batch_view(batch_name: str, initial_load: int = DEFAULT_LOAD_N_CHARTS):
),
script,
)
+ is_htmx = request.headers.get("HX-Request") == "true"
+ if is_htmx:
+ return content
+ return (
+ Title("Anomstack"),
+ Div(
+ Safe('Loading...'),
+ id="loading",
+ cls="loading-indicator",
+ ),
+ Div(content, id="main-content"),
+ )
@rt("/batch/{batch_name}/chart/{chart_index}")
@@ -144,74 +171,28 @@ def get(batch_name: str, chart_index: int):
return Div(
Card(
- Style(
- """
- .uk-card-header { padding: 1rem; }
- .uk-card-body { padding: 1rem; }
- .chart-card-container { position: relative; }
- .chart-expand-btn {
- position: absolute;
- top: 0.75rem;
- right: 0.75rem;
- z-index: 20;
- background: rgba(255, 255, 255, 0.9);
- border: 1px solid #d1d5db;
- border-radius: 0.375rem;
- padding: 0.5rem;
- cursor: pointer;
- transition: all 0.2s ease;
- display: flex;
- align-items: center;
- justify-content: center;
- box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
- min-width: 36px;
- min-height: 36px;
- backdrop-filter: blur(4px);
- }
- .chart-expand-btn:hover {
- background: rgba(255, 255, 255, 1);
- border-color: #9ca3af;
- box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2);
- transform: translateY(-1px);
- }
- /* Mobile optimizations */
- @media (max-width: 768px) {
- .chart-expand-btn {
- top: 0.5rem;
- right: 0.5rem;
- min-width: 40px;
- min-height: 40px;
- padding: 0.625rem;
- }
- }
- .dark .chart-expand-btn {
- background: rgba(17, 24, 39, 0.95);
- border-color: #374151;
- color: #e5e7eb;
- }
- .dark .chart-expand-btn:hover {
- background: rgba(17, 24, 39, 1);
- }
- """
+ Div(
+ Safe(app.state.chart_cache[batch_name][chart_index]),
+ cls="chart-figure",
),
- Safe(app.state.chart_cache[batch_name][chart_index]),
# Expand button overlay positioned on entire card
Button(
UkIcon("expand", height=16, width=16),
cls="chart-expand-btn",
uk_toggle=f"target: #{modal_id}",
title="Click to expand chart",
- type="button"
+ type="button",
+ aria_label=f"Expand chart for {metric_name}",
),
header=Div(
H4(metric_name, cls="mb-1"),
DivLAligned(
P(
f"Anomaly Rate: {anomaly_rate:.1%}",
- cls="text-sm text-muted-foreground",
+ cls="chart-stat-pill",
),
- P(f"Avg Score: {avg_score:.1%}", cls="text-sm text-muted-foreground"),
- style="gap: 1rem;",
+ P(f"Avg Score: {avg_score:.1%}", cls="chart-stat-pill"),
+ cls="chart-summary",
),
),
id=f"chart-{chart_index}",
@@ -228,6 +209,7 @@ def get(batch_name: str, chart_index: int):
type="button",
uk_close="",
title="Close expanded view",
+ aria_label=f"Close expanded chart for {metric_name}",
style="position: absolute; top: 15px; right: 15px; border: none; background: rgba(255,255,255,0.9); border-radius: 50%; width: 40px; height: 40px; display: flex; align-items: center; justify-content: center; box-shadow: 0 2px 8px rgba(0,0,0,0.1);"
),
cls="uk-modal-header",
@@ -292,12 +274,12 @@ def get_expanded_chart(batch_name: str, chart_index: int):
return Div(
Safe(expanded_fig),
- cls="w-full"
+ cls="chart-figure chart-figure-expanded w-full"
)
@rt("/batch/{batch_name}/refresh")
-def refresh_batch(batch_name: str):
+def refresh_batch(batch_name: str, request: Request):
"""Refresh data for a specific batch.
Args:
@@ -315,11 +297,32 @@ def refresh_batch(batch_name: str):
del app.state.chart_cache[batch_name]
# Return the batch view with fresh data
- return get_batch_view(batch_name)
+ return get_batch_view(batch_name, request)
+
+
+def _format_anomaly_value(value) -> str:
+ """Format anomaly values for compact UI display."""
+ if pd.isna(value):
+ return "N/A"
+ return f"{value:,.2f}"
+
+
+def _get_anomaly_kind(row: pd.Series) -> tuple[str, str]:
+ """Return display label and CSS class for the anomaly type."""
+ if row.get("metric_llmalert", 0) == 1:
+ return "LLM alert", "anomaly-type-pill anomaly-type-llm"
+ if row.get("metric_change", 0) == 1:
+ return "Change", "anomaly-type-pill anomaly-type-change"
+ return "Alert", "anomaly-type-pill anomaly-type-alert"
@rt("/batch/{batch_name}/anomalies")
-def get_anomaly_list(batch_name: str, page: int = 1, per_page: int = 50):
+def get_anomaly_list(
+ batch_name: str,
+ request: Request,
+ page: int = 1,
+ per_page: int = 50,
+):
"""Get the anomaly list view for a batch.
Args:
@@ -349,10 +352,40 @@ def get_anomaly_list(batch_name: str, page: int = 1, per_page: int = 50):
# Calculate pagination
total_anomalies = len(df_anomalies)
+ if total_anomalies == 0:
+ content = Div(
+ create_controls(batch_name),
+ Card(
+ Div(
+ H4("Anomalies", cls="mb-1"),
+ P(
+ "No anomalies were found for the current metric window.",
+ cls="text-muted-foreground",
+ ),
+ cls="anomaly-empty-card",
+ ),
+ cls="mb-4",
+ ),
+ id="anomaly-list",
+ )
+ is_htmx = request.headers.get("HX-Request") == "true"
+ if is_htmx:
+ return content
+ return (
+ Title("Anomstack"),
+ Div(
+ Safe('Loading...'),
+ id="loading",
+ cls="loading-indicator",
+ ),
+ Div(content, id="main-content"),
+ )
+
total_pages = (total_anomalies + per_page - 1) // per_page
page = max(1, min(page, total_pages)) # Ensure page is within valid range
start_idx = (page - 1) * per_page
end_idx = min(start_idx + per_page, total_anomalies)
+ visible_start = start_idx + 1
# Get the current page of anomalies
df_page = df_anomalies.iloc[start_idx:end_idx]
@@ -408,72 +441,87 @@ def get_anomaly_list(batch_name: str, page: int = 1, per_page: int = 50):
# Create unique modal ID for this anomaly
anomaly_modal_id = f"anomaly-modal-{feedback_key}"
-
+ anomaly_kind, anomaly_kind_cls = _get_anomaly_kind(row)
+ metric_score = row.get("metric_score")
+ score_label = f"{metric_score:.0%}" if pd.notna(metric_score) else "N/A"
+
rows.append(
- Tr(
- Td(
+ Card(
+ Div(
Div(
- metric_name,
- cls="truncate max-w-[120px] sm:max-w-[180px] mx-auto",
- uk_tooltip=metric_name,
+ Div(
+ P(anomaly_kind, cls=anomaly_kind_cls),
+ P(f"Score {score_label}", cls="anomaly-inline-pill"),
+ P(
+ f"Value {_format_anomaly_value(row['metric_value'])}",
+ cls="anomaly-inline-pill",
+ ),
+ cls="anomaly-inline-pills",
+ ),
+ P(metric_name, cls="anomaly-metric-name", uk_tooltip=metric_name),
+ P(
+ timestamp.strftime("%Y-%m-%d %H:%M:%S"),
+ cls="anomaly-metric-time",
+ ),
+ cls="anomaly-meta-stack",
),
- cls="font-medium text-center w-full",
- ),
- Td(
- timestamp.strftime("%Y-%m-%d %H:%M:%S"),
- cls="text-muted-foreground text-center sm:w-[160px] w-[100px] hidden md:table-cell",
- ),
- Td(
Div(
- Safe(fig),
- Style(
- "svg { display: block; margin: auto; height: 100% !important; width: 100% !important; }"
+ Div(
+ Safe(fig),
+ cls="chart-figure anomaly-sparkline-shell",
),
- cls="absolute inset-0 flex justify-center items-center h-full w-full p-0 m-0",
+ cls="anomaly-sparkline-panel",
),
- cls="w-[300px] h-[50px] text-center p-0 m-0 relative overflow-hidden",
- ),
- Td(
DivLAligned(
- Button(
- UkIcon("thumbs-up", cls="sm:w-5 sm:h-5 w-4 h-4"),
- hx_post=f"/batch/{batch_name}/anomaly/{metric_name}/{timestamp}/thumbs-up",
- hx_target=f"#feedback-{feedback_key}",
- hx_swap="outerHTML",
- cls=(ButtonT.primary if feedback == "positive" else ButtonT.secondary)
- + " sm:p-2 p-1",
- id=f"feedback-{feedback_key}-positive",
+ Div(
+ P("Review", cls="anomaly-section-label"),
+ DivLAligned(
+ Button(
+ UkIcon("thumbs-up", cls="sm:w-5 sm:h-5 w-4 h-4"),
+ hx_post=f"/batch/{batch_name}/anomaly/{metric_name}/{timestamp}/thumbs-up",
+ hx_target=f"#feedback-{feedback_key}",
+ hx_swap="outerHTML",
+ cls=(ButtonT.primary if feedback == "positive" else ButtonT.secondary)
+ + " sm:p-2 p-1",
+ id=f"feedback-{feedback_key}-positive",
+ aria_label=f"Mark anomaly on {metric_name} at {timestamp} as correct",
+ ),
+ Button(
+ UkIcon("thumbs-down", cls="sm:w-5 sm:h-5 w-4 h-4"),
+ hx_post=f"/batch/{batch_name}/anomaly/{metric_name}/{timestamp}/thumbs-down",
+ hx_target=f"#feedback-{feedback_key}",
+ hx_swap="outerHTML",
+ cls=(ButtonT.primary if feedback == "negative" else ButtonT.secondary)
+ + " sm:p-2 p-1",
+ id=f"feedback-{feedback_key}-negative",
+ aria_label=f"Mark anomaly on {metric_name} at {timestamp} as incorrect",
+ ),
+ cls="space-x-1 sm:space-x-2 justify-center",
+ id=f"feedback-{feedback_key}",
+ ),
+ cls="anomaly-feedback-panel",
),
Button(
- UkIcon("thumbs-down", cls="sm:w-5 sm:h-5 w-4 h-4"),
- hx_post=f"/batch/{batch_name}/anomaly/{metric_name}/{timestamp}/thumbs-down",
- hx_target=f"#feedback-{feedback_key}",
- hx_swap="outerHTML",
- cls=(ButtonT.primary if feedback == "negative" else ButtonT.secondary)
- + " sm:p-2 p-1",
- id=f"feedback-{feedback_key}-negative",
+ DivLAligned(
+ P("Inspect"),
+ UkIcon("expand", height=16, width=16),
+ cls="space-x-2 justify-center",
+ ),
+ cls=(ButtonT.secondary, "anomaly-expand-btn"),
+ uk_toggle=f"target: #{anomaly_modal_id}",
+ title="Expand chart",
+ type="button",
+ aria_label=f"Expand anomaly chart for {metric_name}",
+ style="width: 100%;",
),
- cls="space-x-1 sm:space-x-2 justify-center",
- id=f"feedback-{feedback_key}",
- ),
- cls="w-[120px] text-center",
- ),
- Td(
- Button(
- UkIcon("expand", height=16, width=16),
- cls="bg-white/90 border border-gray-300 rounded-md p-2 hover:bg-white hover:shadow-md hover:border-gray-400 transition-all duration-200",
- uk_toggle=f"target: #{anomaly_modal_id}",
- title="Expand chart",
- type="button",
- style="width: 36px; height: 36px; display: flex; align-items: center; justify-content: center;"
+ cls="anomaly-actions-panel",
),
- cls="w-[60px] text-center align-middle",
- style="vertical-align: middle; padding: 8px;"
+ cls="anomaly-row-layout",
),
- cls="hover:bg-muted/50 transition-colors",
+ cls="anomaly-row-card",
)
)
-
+
# Create modal for this anomaly
modals.append(
Modal(
@@ -490,6 +538,7 @@ def get_anomaly_list(batch_name: str, page: int = 1, per_page: int = 50):
cls=(ButtonT.primary if feedback == "positive" else ButtonT.secondary) + " p-2",
id=f"modal-feedback-{feedback_key}-positive",
title="Good catch",
+ aria_label=f"Mark anomaly on {metric_name} at {timestamp} as correct",
),
Button(
UkIcon("thumbs-down", cls="w-4 h-4"),
@@ -499,6 +548,7 @@ def get_anomaly_list(batch_name: str, page: int = 1, per_page: int = 50):
cls=(ButtonT.primary if feedback == "negative" else ButtonT.secondary) + " p-2",
id=f"modal-feedback-{feedback_key}-negative",
title="False alarm",
+ aria_label=f"Mark anomaly on {metric_name} at {timestamp} as incorrect",
),
cls="space-x-2",
id=f"modal-feedback-{feedback_key}",
@@ -510,6 +560,7 @@ def get_anomaly_list(batch_name: str, page: int = 1, per_page: int = 50):
type="button",
uk_close="",
title="Close expanded view",
+ aria_label=f"Close expanded anomaly view for {metric_name}",
style="position: absolute; top: 15px; right: 15px; border: none; background: rgba(255,255,255,0.9); border-radius: 50%; width: 40px; height: 40px; display: flex; align-items: center; justify-content: center; box-shadow: 0 2px 8px rgba(0,0,0,0.1); font-size: 24px; font-weight: bold; color: #666; line-height: 1;"
),
cls="uk-modal-header",
@@ -562,9 +613,16 @@ def get_anomaly_list(batch_name: str, page: int = 1, per_page: int = 50):
cls=ButtonT.secondary,
disabled=page <= 1,
),
- P(
- f"Page {page} of {total_pages}",
- cls="text-sm text-muted-foreground mx-4",
+ Div(
+ P(
+ f"Page {page} of {total_pages}",
+ cls="text-sm text-muted-foreground",
+ ),
+ P(
+ f"Showing {visible_start}-{end_idx} of {total_anomalies}",
+ cls="text-sm text-muted-foreground",
+ ),
+ cls="anomaly-pagination-copy",
),
Button(
UkIcon("chevron-right"),
@@ -573,45 +631,48 @@ def get_anomaly_list(batch_name: str, page: int = 1, per_page: int = 50):
cls=ButtonT.secondary,
disabled=page >= total_pages,
),
- cls="justify-center mt-4",
+ cls="justify-center mt-4 anomaly-pagination",
),
)
- return Div(
+ content = Div(
create_controls(batch_name),
Card(
- Div(
- Table(
- Tr(
- Th("Metric", cls="font-medium text-center sm:w-[180px] w-[120px]"),
- Th(
- "Timestamp",
- cls="font-medium text-center sm:w-[160px] w-[100px] hidden sm:table-cell",
- ),
- Th("Trend", cls="sm:w-[300px] w-[140px] text-center"),
- Th("Feedback", cls="sm:w-[120px] w-[80px] font-medium text-center"),
- Th("Expand", cls="w-[60px] font-medium text-center"),
- cls="border-b",
- ),
- *rows,
- cls="w-full divide-y min-w-full table-fixed",
- ),
- cls="overflow-x-auto -mx-4 sm:mx-0",
- ),
+ Div(*rows, cls="anomaly-list"),
header=Div(
- H4("Anomalies", cls="mb-1"),
+ H4("Recent anomalies", cls="mb-1"),
P(
- f"Showing {len(rows)} of {total_anomalies} anomalies",
+ "Scan the local context sparkline, then open the full chart when you need detail.",
cls="text-sm text-muted-foreground",
),
+ Div(
+ P(
+ f"Showing {visible_start}-{end_idx} of {total_anomalies}",
+ cls="dashboard-pill",
+ ),
+ P(f"{total_pages} pages", cls="dashboard-pill"),
+ cls="controls-pills mt-3",
+ ),
),
- cls="mb-4",
+ cls="mb-4 anomaly-list-shell",
),
pagination,
# Add all the modals
*modals,
id="anomaly-list",
)
+ is_htmx = request.headers.get("HX-Request") == "true"
+ if is_htmx:
+ return content
+ return (
+ Title("Anomstack"),
+ Div(
+ Safe('Loading...'),
+ id="loading",
+ cls="loading-indicator",
+ ),
+ Div(content, id="main-content"),
+ )
@rt("/batch/{batch_name}/anomaly/{metric_name}/{timestamp}/thumbs-up", methods=["POST"])
@@ -939,5 +1000,5 @@ def get_expanded_anomaly_chart(batch_name: str, metric_name: str, timestamp: str
return Div(
Safe(expanded_fig),
- cls="w-full"
+ cls="chart-figure chart-figure-expanded w-full"
)
diff --git a/dashboard/routes/index.py b/dashboard/routes/index.py
index 2c7c44b7..50cffaa6 100644
--- a/dashboard/routes/index.py
+++ b/dashboard/routes/index.py
@@ -7,10 +7,12 @@
"""
+from __future__ import annotations
+
import logging
from fasthtml.common import H2, A, Div, P, Request, Safe, Script, Title
-from monsterui.all import Button, ButtonT, Card, DivLAligned, TextPresets, UkIcon
+from monsterui.all import Button, ButtonT, Card, Container, DivFullySpaced, DivLAligned, Grid, H3, Section, Subtitle, TextPresets, UkIcon
import pandas as pd
from dashboard.app import app, rt
@@ -18,6 +20,7 @@
from dashboard.components import create_batch_card
from dashboard.constants import DEFAULT_LAST_N
from dashboard.data import get_data
+from dashboard.presentation import summarize_batches
log = logging.getLogger("anomstack_dashboard")
@@ -61,22 +64,79 @@ def get_sorted_batch_stats() -> tuple:
df = get_batch_data(batch_name)
batch_stats[batch_name] = calculate_batch_stats(df, batch_name)
- filtered_batches = {
- name: stats for name, stats in batch_stats.items() if stats["latest_timestamp"] != "No Data"
- }
+ active_batch_names = [
+ name for name, stats in batch_stats.items() if stats["latest_timestamp"] != "No data"
+ ]
+ empty_batch_names = [
+ name for name, stats in batch_stats.items() if stats["latest_timestamp"] == "No data"
+ ]
sorted_batch_names = sorted(
- filtered_batches.keys(),
+ active_batch_names,
key=lambda x: (
- -filtered_batches[x]["alert_count"],
- -filtered_batches[x]["avg_score"],
+ -batch_stats[x]["alert_count"],
+ -batch_stats[x]["avg_score"],
),
)
+ empty_batch_names.sort()
+
+ return batch_stats, sorted_batch_names, empty_batch_names
+
+
+def create_overview_cards(batch_stats: dict, sorted_batch_names: list) -> Grid:
+ """Create the homepage overview cards."""
+ summary = summarize_batches(batch_stats, sorted_batch_names)
+ cards = [
+ ("Active batches", summary["active_batches"], "Batches with recent metric data."),
+ ("Configured batches", summary["configured_batches"], "All enabled dashboard batches."),
+ ("Tracked metrics", summary["tracked_metrics"], "Unique metrics across active batches."),
+ ("Alerts in view", summary["alerts"], f"Across the default {DEFAULT_LAST_N} window."),
+ ]
+
+ return Grid(
+ *[
+ Card(
+ P(label, cls="overview-label"),
+ H3(str(value), cls="overview-value"),
+ P(help_text, cls=TextPresets.muted_sm),
+ cls="overview-card",
+ )
+ for label, value, help_text in cards
+ ],
+ cols_min=1,
+ cols_max=4,
+ cols_md=2,
+ cols_lg=4,
+ cols_xl=4,
+ cls="dashboard-overview-grid",
+ )
- return batch_stats, sorted_batch_names
+def create_empty_batches_section(empty_batch_names: list) -> Card | None:
+ """Create a secondary section for configured batches that have no data."""
+ if not empty_batch_names:
+ return None
+
+ return Card(
+ DivFullySpaced(
+ Div(
+ H3("Configured but waiting for data"),
+ Subtitle(
+ "These metric batches are enabled in config but do not have recent rows in the current data source."
+ ),
+ cls="space-y-1",
+ ),
+ P(f"{len(empty_batch_names)} pending", cls="dashboard-pill"),
+ ),
+ Div(
+ *[P(name.replace('_', ' ').replace('-', ' ').title(), cls="dashboard-pill") for name in empty_batch_names],
+ cls="no-data-pills",
+ ),
+ cls="empty-batches-card",
+ )
-def create_main_content(batch_stats: dict, sorted_batch_names: list) -> Div:
+
+def create_main_content(batch_stats: dict, sorted_batch_names: list, empty_batch_names: list) -> Div:
"""Create the main dashboard content.
Args:
@@ -87,55 +147,91 @@ def create_main_content(batch_stats: dict, sorted_batch_names: list) -> Div:
Div: The main dashboard content.
"""
return Div(
- Card(
- DivLAligned(
- Div(
- H2("Anomstack", cls="text-2xl font-bold pl-2"),
- P(
- "Painless open source anomaly detection for your metrics 📈📉🚀",
- cls="text-muted-foreground pl-2",
- ),
- cls="flex flex-col",
- ),
- DivLAligned(
- Button(
- DivLAligned(UkIcon("refresh-ccw"), cls="space-x-2"),
- cls=ButtonT.ghost,
- hx_post="/refresh-all",
- hx_target="#main-content",
- hx_indicator="#loading",
- uk_tooltip="Refresh all",
- ),
- A(
- UkIcon("github"),
- href="https://github.com/andrewm4894/anomstack",
- cls=ButtonT.ghost,
- uk_tooltip="View the source code on GitHub",
- target="_blank",
+ Container(
+ Section(
+ Card(
+ DivFullySpaced(
+ Div(
+ P("Dashboard", cls="overview-label"),
+ H2("Anomstack", cls="text-3xl font-bold mt-2"),
+ Subtitle("Painless open source anomaly detection for your metrics."),
+ cls="space-y-1",
+ ),
+ DivLAligned(
+ Button(
+ DivLAligned(
+ UkIcon("refresh-ccw"),
+ P("Refresh data", cls="hidden sm:inline"),
+ cls="space-x-2",
+ ),
+ cls=ButtonT.primary,
+ hx_post="/refresh-all",
+ hx_target="#main-content",
+ hx_indicator="#loading",
+ uk_tooltip="Refresh all batch summaries",
+ aria_label="Refresh all batch summaries",
+ ),
+ A(
+ DivLAligned(
+ UkIcon("github"),
+ P("GitHub", cls="hidden sm:inline"),
+ cls="space-x-2",
+ ),
+ href="https://github.com/andrewm4894/anomstack",
+ cls=ButtonT.secondary,
+ uk_tooltip="View the source code on GitHub",
+ target="_blank",
+ ),
+ cls="space-x-2",
+ ),
+ cls="homepage-hero-header",
),
- cls="space-x-2",
+ create_overview_cards(batch_stats, sorted_batch_names),
+ cls="dashboard-hero-card",
),
- cls="flex justify-between items-center mb-6",
+ cls="pt-6 pb-4",
),
(
- Div(
- DivLAligned(
- UkIcon("alert-triangle"),
- P(
- "No metric batches found. Is Dagster running?",
- cls=TextPresets.muted_sm,
+ Section(
+ DivFullySpaced(
+ Div(
+ H3("Active metric batches"),
+ Subtitle("Sorted by anomaly activity so the noisiest sources rise to the top."),
+ cls="space-y-1",
),
- cls="space-x-2 p-2 bg-yellow-50 text-yellow-700 rounded-md",
+ P(f"{len(sorted_batch_names)} live", cls="dashboard-pill"),
+ ),
+ Grid(
+ *[create_batch_card(name, batch_stats[name]) for name in sorted_batch_names],
+ cols_min=1,
+ cols_max=3,
+ cols_md=2,
+ cols_lg=3,
+ cols_xl=3,
+ cls="gap-5",
),
- cls="mb-6",
+ cls="space-y-4",
)
- if not app.state.metric_batches
- else Div(
- *[create_batch_card(name, batch_stats[name]) for name in sorted_batch_names],
- cls="homepage-grid",
+ if sorted_batch_names
+ else Section(
+ Card(
+ DivLAligned(
+ UkIcon("alert-triangle"),
+ P(
+ "No metric batches with recent data were found. Start Dagster or point the dashboard at a populated database.",
+ cls=TextPresets.muted_sm,
+ ),
+ cls="space-x-2",
+ ),
+ cls="empty-batches-card",
+ ),
+ cls="space-y-4",
)
),
- cls="p-2",
+ Section(create_empty_batches_section(empty_batch_names), cls="space-y-4")
+ if empty_batch_names
+ else None,
+ cls="dashboard-shell",
),
id="main-content",
)
@@ -155,7 +251,7 @@ def post(request: Request):
app.state.df_cache.clear()
app.state.stats_cache.clear()
app.state.chart_cache.clear()
- return [index(request)]
+ return index(request)
except Exception as e:
log.error(f"Error refreshing all batch data: {e}")
return []
@@ -175,14 +271,16 @@ def index(request: Request):
script = Script(
f"""
- if ({'true' if app.state.dark_mode else 'false'}) {{
- document.body.classList.add('dark-mode');
- }}
+ (() => {{
+ const isDark = {'true' if app.state.dark_mode else 'false'};
+ document.documentElement.classList.toggle('dark', isDark);
+ document.body.classList.toggle('dark-mode', isDark);
+ }})();
"""
)
- batch_stats, sorted_batch_names = get_sorted_batch_stats()
- main_content = create_main_content(batch_stats, sorted_batch_names)
+ batch_stats, sorted_batch_names, empty_batch_names = get_sorted_batch_stats()
+ main_content = create_main_content(batch_stats, sorted_batch_names, empty_batch_names)
if is_htmx:
return main_content
diff --git a/dashboard/routes/search.py b/dashboard/routes/search.py
index 5ca2a60e..9cbd2d1f 100644
--- a/dashboard/routes/search.py
+++ b/dashboard/routes/search.py
@@ -12,8 +12,16 @@
from dashboard.app import app, rt
from dashboard.data import get_data
+from dashboard.presentation import get_filtered_metric_stats
-from .batch import DEFAULT_LOAD_N_CHARTS, ChartManager
+from .batch import DEFAULT_LOAD_N_CHARTS, ChartManager, get_chart_grid_classes
+
+
+def _controls_summary(batch_name: str):
+ """Import the controls summary lazily to avoid component import cycles."""
+ from dashboard.components.common import create_controls_summary
+
+ return create_controls_summary(batch_name, hx_swap_oob="outerHTML")
@rt("/batch/{batch_name}/search")
@@ -27,78 +35,57 @@ def get(batch_name: str, search: str = "") -> FT:
Returns:
FT: The search results container with out-of-band load more button.
"""
- import re
-
- app.state.search_term[batch_name] = search
+ app.state.search_term[batch_name] = search.strip()
if batch_name not in app.state.stats_cache:
app.state.calculate_metric_stats(batch_name)
- try:
- pattern = re.compile(search, re.IGNORECASE) if search else None
- filtered_stats_with_indices = [
- (i, stat)
- for i, stat in enumerate(app.state.stats_cache[batch_name])
- if not pattern or pattern.search(stat["metric_name"])
- ]
-
- if not filtered_stats_with_indices:
- return Div(
- P("No matching metrics found", cls="text-muted-foreground p-4 text-center"),
- # Add out-of-band load more container
- Div(
- id="load-more-container",
- hx_swap_oob="true",
- ),
- id="charts-container",
- cls=f"grid grid-cols-{2 if app.state.two_columns else 1} gap-4",
- )
-
- remaining_metrics = len(filtered_stats_with_indices) - DEFAULT_LOAD_N_CHARTS
- load_next = min(DEFAULT_LOAD_N_CHARTS, remaining_metrics)
-
- # Create chart placeholders
- chart_placeholders = [
- ChartManager.create_chart_placeholder(stat["metric_name"], original_index, batch_name)
- for original_index, stat in filtered_stats_with_indices[:DEFAULT_LOAD_N_CHARTS]
- ]
-
- # Create load more button if needed
- load_more_button = Div(
- Button(
- f"Load next {load_next} of {remaining_metrics}",
- hx_get=f"/batch/{batch_name}/load-more/{DEFAULT_LOAD_N_CHARTS}",
- hx_target="#charts-container",
- hx_swap="beforeend",
- hx_indicator="#loading",
- cls=ButtonT.secondary,
- style="width: 100%; margin-top: 1rem;",
- disabled=remaining_metrics <= 0,
- )
- if remaining_metrics > 0
- else "",
- id="load-more-container",
- hx_swap_oob="true",
- )
+ filtered_stats_with_indices = get_filtered_metric_stats(batch_name)
+ if not filtered_stats_with_indices:
return Div(
- *chart_placeholders,
- load_more_button,
- id="charts-container",
- cls=f"grid grid-cols-{2 if app.state.two_columns else 1} gap-4",
- )
-
- except re.error:
- return Div(
- P("Invalid search pattern", cls="text-red-500 p-4 text-center"),
- # Add out-of-band load more container
+ P("No matching metrics found", cls="text-muted-foreground p-4 text-center"),
+ _controls_summary(batch_name),
Div(
id="load-more-container",
hx_swap_oob="true",
),
id="charts-container",
- cls=f"grid grid-cols-{2 if app.state.two_columns else 1} gap-4",
+ cls=get_chart_grid_classes(),
+ )
+
+ remaining_metrics = max(len(filtered_stats_with_indices) - DEFAULT_LOAD_N_CHARTS, 0)
+ load_next = min(DEFAULT_LOAD_N_CHARTS, remaining_metrics) if remaining_metrics else 0
+
+ chart_placeholders = [
+ ChartManager.create_chart_placeholder(stat["metric_name"], original_index, batch_name)
+ for original_index, stat in filtered_stats_with_indices[:DEFAULT_LOAD_N_CHARTS]
+ ]
+
+ load_more_button = Div(
+ Button(
+ f"Load next {load_next} of {remaining_metrics}",
+ hx_get=f"/batch/{batch_name}/load-more/{DEFAULT_LOAD_N_CHARTS}",
+ hx_target="#charts-container",
+ hx_swap="beforeend",
+ hx_indicator="#loading",
+ cls=ButtonT.secondary,
+ style="width: 100%; margin-top: 1rem;",
+ disabled=remaining_metrics <= 0,
)
+ if remaining_metrics > 0
+ else "",
+ id="load-more-container",
+ hx_swap_oob="true",
+ )
+
+ return Div(
+ *chart_placeholders,
+ _controls_summary(batch_name),
+ load_more_button,
+ id="charts-container",
+ cls=get_chart_grid_classes(),
+ )
@rt("/batch/{batch_name}/load-more/{start_index}")
@@ -112,17 +99,14 @@ def get(batch_name: str, start_index: int):
Returns:
list: The list of charts.
"""
- metric_stats = app.state.stats_cache[batch_name]
- remaining_metrics = len(metric_stats) - (start_index + 10)
- load_next = min(10, remaining_metrics)
+ metric_stats = get_filtered_metric_stats(batch_name)
+ remaining_metrics = max(len(metric_stats) - (start_index + DEFAULT_LOAD_N_CHARTS), 0)
+ load_next = min(DEFAULT_LOAD_N_CHARTS, remaining_metrics) if remaining_metrics else 0
return [
*[
- ChartManager.create_chart_placeholder(stat["metric_name"], i, batch_name)
- for i, stat in enumerate(
- metric_stats[start_index : start_index + 10],
- start=start_index,
- )
+ ChartManager.create_chart_placeholder(stat["metric_name"], original_index, batch_name)
+ for original_index, stat in metric_stats[start_index : start_index + DEFAULT_LOAD_N_CHARTS]
],
Div(
Button(
@@ -131,7 +115,7 @@ def get(batch_name: str, start_index: int):
if remaining_metrics > 0
else "No more metrics"
),
- hx_get=f"/batch/{batch_name}/load-more/{start_index + 10}",
+ hx_get=f"/batch/{batch_name}/load-more/{start_index + DEFAULT_LOAD_N_CHARTS}",
hx_target="#charts-container",
hx_swap="beforeend",
hx_indicator="#loading",
@@ -169,19 +153,20 @@ def post(batch_name: str, last_n: str = "90n"):
app.state.calculate_metric_stats(batch_name)
# Return only the charts container content
- metric_stats = app.state.stats_cache[batch_name]
- remaining_metrics = len(metric_stats) - DEFAULT_LOAD_N_CHARTS
- load_next = min(DEFAULT_LOAD_N_CHARTS, remaining_metrics)
+ metric_stats = get_filtered_metric_stats(batch_name)
+ remaining_metrics = max(len(metric_stats) - DEFAULT_LOAD_N_CHARTS, 0)
+ load_next = min(DEFAULT_LOAD_N_CHARTS, remaining_metrics) if remaining_metrics else 0
return [
Div(
*[
- ChartManager.create_chart_placeholder(stat["metric_name"], i, batch_name)
- for i, stat in enumerate(metric_stats[:DEFAULT_LOAD_N_CHARTS])
+ ChartManager.create_chart_placeholder(stat["metric_name"], original_index, batch_name)
+ for original_index, stat in metric_stats[:DEFAULT_LOAD_N_CHARTS]
],
id="charts-container",
- cls=f"grid grid-cols-{2 if app.state.two_columns else 1} gap-4",
+ cls=get_chart_grid_classes(),
),
+ _controls_summary(batch_name),
Div(
Button(
f"Load next {load_next} of {remaining_metrics}",
diff --git a/dashboard/routes/toggles.py b/dashboard/routes/toggles.py
index fdce1a06..a78c9cc3 100644
--- a/dashboard/routes/toggles.py
+++ b/dashboard/routes/toggles.py
@@ -7,7 +7,9 @@
"""
-from fasthtml.common import Div, Script
+from __future__ import annotations
+
+from fasthtml.common import Div, Request, Script
from dashboard.app import app, rt
@@ -15,50 +17,61 @@
@rt("/batch/{batch_name}/toggle-size")
-def post(batch_name: str):
+def post(batch_name: str, request: Request):
"""Toggle chart size."""
app.state.small_charts = not app.state.small_charts
app.state.chart_cache.clear()
- return get_batch_view(batch_name, initial_load=10)
+ return get_batch_view(batch_name, request, initial_load=10)
@rt("/batch/{batch_name}/toggle-columns")
-def post(batch_name: str):
+def post(batch_name: str, request: Request):
"""Toggle number of columns."""
app.state.two_columns = not app.state.two_columns
- return get_batch_view(batch_name, initial_load=10)
+ return get_batch_view(batch_name, request, initial_load=10)
@rt("/batch/{batch_name}/toggle-markers")
-def post(batch_name: str):
+def post(batch_name: str, request: Request):
"""Toggle chart markers."""
app.state.show_markers = not app.state.show_markers
app.state.chart_cache.clear()
- return get_batch_view(batch_name, initial_load=10)
+ return get_batch_view(batch_name, request, initial_load=10)
@rt("/batch/{batch_name}/toggle-legend")
-def post(batch_name: str):
+def post(batch_name: str, request: Request):
"""Toggle chart legend."""
app.state.show_legend = not app.state.show_legend
app.state.chart_cache.clear()
- return get_batch_view(batch_name, initial_load=10)
+ return get_batch_view(batch_name, request, initial_load=10)
@rt("/batch/{batch_name}/toggle-line-width")
-def post(batch_name: str):
+def post(batch_name: str, request: Request):
"""Toggle line width."""
app.state.narrow_lines = not getattr(app.state, "narrow_lines", False)
app.state.line_width = 1 if app.state.narrow_lines else 2
app.state.chart_cache.clear()
- return get_batch_view(batch_name, initial_load=10)
+ return get_batch_view(batch_name, request, initial_load=10)
@rt("/batch/{batch_name}/toggle-theme")
-def post(batch_name: str) -> Div:
+def post(batch_name: str, request: Request) -> Div:
"""Toggle theme."""
app.state.dark_mode = not app.state.dark_mode
app.state.chart_cache.clear()
- script = Script("document.body.classList.toggle('dark-mode');")
- response = get_batch_view(batch_name, initial_load=10)
+ mode = "dark" if app.state.dark_mode else "light"
+ script = Script(
+ f"""
+ (() => {{
+ const franken = JSON.parse(localStorage.getItem("__FRANKEN__") || "{{}}");
+ franken.mode = "{mode}";
+ localStorage.setItem("__FRANKEN__", JSON.stringify(franken));
+ document.documentElement.classList.toggle("dark", {str(app.state.dark_mode).lower()});
+ document.body.classList.toggle("dark-mode", {str(app.state.dark_mode).lower()});
+ }})();
+ """
+ )
+ response = get_batch_view(batch_name, request, initial_load=10)
return Div(script, response)
diff --git a/dashboard/state.py b/dashboard/state.py
index 3c1b4c1e..9b437345 100644
--- a/dashboard/state.py
+++ b/dashboard/state.py
@@ -70,7 +70,7 @@ def __init__(self):
self.small_charts = True
self.dark_mode = False
self.two_columns = True
- self.show_markers = True
+ self.show_markers = False
self.last_n = {}
self.line_width = 2
self.show_legend = False
diff --git a/dashboard/static/styles.css b/dashboard/static/styles.css
index ae828f42..22d0cda7 100644
--- a/dashboard/static/styles.css
+++ b/dashboard/static/styles.css
@@ -360,3 +360,613 @@ body.dark-mode .uk-toggle-switch::before {
align-items: center;
justify-content: center;
}
+
+.dashboard-shell {
+ padding-bottom: 2rem;
+}
+
+.dashboard-hero-card {
+ padding: 1.25rem;
+ border: 1px solid rgba(148, 163, 184, 0.22);
+ background:
+ radial-gradient(circle at top right, rgba(59, 130, 246, 0.12), transparent 30%),
+ linear-gradient(180deg, rgba(248, 250, 252, 0.92), rgba(255, 255, 255, 0.98));
+}
+
+.homepage-hero-header {
+ gap: 1rem;
+ align-items: flex-start;
+ margin-bottom: 1.25rem;
+}
+
+.dashboard-overview-grid {
+ margin-top: 0.5rem;
+}
+
+.overview-card {
+ border: 1px solid rgba(148, 163, 184, 0.2);
+ min-height: 100%;
+}
+
+.overview-label,
+.controls-label,
+.batch-stat-label {
+ font-size: 0.75rem;
+ font-weight: 600;
+ letter-spacing: 0.08em;
+ text-transform: uppercase;
+ color: #64748b;
+}
+
+.overview-value {
+ font-size: 1.875rem;
+ line-height: 1.1;
+ margin: 0.25rem 0;
+}
+
+.dashboard-pill {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ gap: 0.375rem;
+ width: fit-content;
+ padding: 0.35rem 0.75rem;
+ border-radius: 9999px;
+ border: 1px solid rgba(148, 163, 184, 0.25);
+ background: rgba(241, 245, 249, 0.9);
+ color: #334155;
+ font-size: 0.85rem;
+ font-weight: 500;
+}
+
+.empty-batches-card {
+ padding: 1.1rem;
+ border: 1px dashed rgba(148, 163, 184, 0.4);
+ background: rgba(248, 250, 252, 0.85);
+}
+
+.no-data-pills {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 0.6rem;
+ margin-top: 1rem;
+}
+
+.batch-card {
+ height: 100%;
+ border: 1px solid rgba(148, 163, 184, 0.2);
+ transition:
+ transform 0.18s ease,
+ border-color 0.18s ease,
+ box-shadow 0.18s ease;
+}
+
+.batch-card:hover {
+ transform: translateY(-2px);
+ border-color: rgba(37, 99, 235, 0.4);
+ box-shadow: 0 18px 40px rgba(15, 23, 42, 0.08);
+}
+
+.batch-card-link {
+ display: block;
+ height: 100%;
+ color: inherit;
+ text-decoration: none;
+}
+
+.batch-card-link:hover,
+.batch-card-link:focus,
+.batch-card-link:active {
+ color: inherit;
+ text-decoration: none;
+}
+
+.batch-card-link:hover .batch-card,
+.batch-card-link:focus-visible .batch-card {
+ transform: translateY(-2px);
+ border-color: rgba(37, 99, 235, 0.4);
+ box-shadow: 0 18px 40px rgba(15, 23, 42, 0.08);
+}
+
+.batch-card-link:focus-visible .batch-card {
+ outline: 2px solid rgba(37, 99, 235, 0.45);
+ outline-offset: 3px;
+}
+
+.batch-card-stats {
+ display: grid;
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ gap: 0.9rem;
+}
+
+.batch-stat-block {
+ padding: 0.85rem;
+ border-radius: 0.9rem;
+ border: 1px solid rgba(148, 163, 184, 0.16);
+ background: rgba(248, 250, 252, 0.85);
+}
+
+.batch-stat-value {
+ margin-top: 0.35rem;
+ font-size: 1rem;
+ font-weight: 600;
+ color: #0f172a;
+}
+
+.batch-card-footer {
+ width: 100%;
+ font-weight: 600;
+ color: #2563eb;
+}
+
+.controls-card {
+ padding: 1.1rem;
+ border: 1px solid rgba(148, 163, 184, 0.18);
+}
+
+.controls-header {
+ gap: 1rem;
+ align-items: flex-start;
+}
+
+.controls-pills {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 0.6rem;
+}
+
+.controls-form-grid {
+ display: grid;
+ grid-template-columns: minmax(0, 1fr) auto;
+ gap: 1rem;
+ align-items: end;
+}
+
+.toolbar-stack {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 0.5rem;
+ justify-content: flex-end;
+}
+
+.toolbar-btn {
+ min-height: 40px;
+}
+
+.chart-summary {
+ display: flex;
+ gap: 0.65rem;
+ flex-wrap: wrap;
+ margin-top: 0.75rem;
+}
+
+.chart-stat-pill {
+ margin: 0;
+ padding: 0.3rem 0.65rem;
+ border-radius: 9999px;
+ border: 1px solid rgba(148, 163, 184, 0.22);
+ background: rgba(248, 250, 252, 0.92);
+ color: #475569;
+ font-size: 0.8rem;
+}
+
+.chart-card-container {
+ position: relative;
+ overflow: hidden;
+ border: 1px solid rgba(148, 163, 184, 0.18);
+ box-shadow: 0 14px 30px rgba(15, 23, 42, 0.06);
+}
+
+.chart-card-container .uk-card-header,
+.chart-card-container .uk-card-body {
+ padding: 1rem;
+}
+
+.chart-figure {
+ position: relative;
+ overflow: hidden;
+ border-radius: 1.1rem;
+ border: 1px solid rgba(148, 163, 184, 0.16);
+ padding: 0.45rem 0.55rem 0.35rem;
+ background:
+ radial-gradient(circle at top right, rgba(59, 130, 246, 0.08), transparent 28%),
+ linear-gradient(180deg, rgba(248, 250, 252, 0.96), rgba(255, 255, 255, 0.98));
+ box-shadow:
+ inset 0 1px 0 rgba(255, 255, 255, 0.75),
+ 0 8px 18px rgba(15, 23, 42, 0.04);
+}
+
+.chart-figure-expanded {
+ padding: 0.7rem 0.8rem 0.55rem;
+}
+
+.chart-figure .js-plotly-plot,
+.chart-figure .plot-container,
+.chart-figure .svg-container {
+ width: 100% !important;
+}
+
+.chart-figure .svg-container,
+.chart-figure .main-svg {
+ border-radius: 0.9rem;
+}
+
+.chart-figure .modebar {
+ top: 0.75rem !important;
+ right: 0.75rem !important;
+ padding: 0.2rem !important;
+ border-radius: 999px !important;
+ border: 1px solid rgba(148, 163, 184, 0.22) !important;
+ background: rgba(255, 255, 255, 0.88) !important;
+ backdrop-filter: blur(14px);
+ box-shadow: 0 10px 24px rgba(15, 23, 42, 0.12);
+}
+
+.chart-figure .modebar-group {
+ display: flex !important;
+ gap: 0.15rem;
+ padding: 0 !important;
+}
+
+.chart-figure .modebar-btn {
+ width: 32px !important;
+ height: 32px !important;
+ border-radius: 999px !important;
+ color: #475569 !important;
+}
+
+.chart-figure .modebar-btn:hover {
+ background: rgba(37, 99, 235, 0.08) !important;
+ color: #0f172a !important;
+}
+
+.chart-expand-btn {
+ position: absolute;
+ top: 0.85rem;
+ right: 0.85rem;
+ z-index: 20;
+ min-width: 38px;
+ min-height: 38px;
+ padding: 0.5rem;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ border-radius: 999px;
+ border: 1px solid rgba(148, 163, 184, 0.24);
+ background: rgba(255, 255, 255, 0.88);
+ backdrop-filter: blur(14px);
+ box-shadow: 0 8px 20px rgba(15, 23, 42, 0.12);
+ transition:
+ transform 0.18s ease,
+ box-shadow 0.18s ease,
+ border-color 0.18s ease,
+ background-color 0.18s ease;
+}
+
+.chart-expand-btn:hover {
+ transform: translateY(-1px);
+ border-color: rgba(37, 99, 235, 0.35);
+ background: rgba(255, 255, 255, 0.98);
+ box-shadow: 0 14px 28px rgba(15, 23, 42, 0.16);
+}
+
+.anomaly-list-shell {
+ padding: 0.5rem;
+ border: 1px solid rgba(148, 163, 184, 0.18);
+}
+
+.anomaly-empty-card {
+ padding: 1.2rem;
+}
+
+.anomaly-list {
+ display: grid;
+ gap: 1rem;
+}
+
+.anomaly-row-card {
+ border: 1px solid rgba(148, 163, 184, 0.16);
+ transition:
+ transform 0.18s ease,
+ border-color 0.18s ease,
+ box-shadow 0.18s ease;
+}
+
+.anomaly-row-card:hover {
+ transform: translateY(-1px);
+ border-color: rgba(37, 99, 235, 0.24);
+ box-shadow: 0 16px 32px rgba(15, 23, 42, 0.06);
+}
+
+.anomaly-row-layout {
+ display: grid;
+ grid-template-columns: minmax(0, 1.35fr) minmax(320px, 2.2fr) minmax(180px, auto);
+ gap: 1rem 1.25rem;
+ align-items: center;
+}
+
+.anomaly-meta-stack {
+ min-width: 0;
+}
+
+.anomaly-inline-pills {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 0.45rem;
+ margin-bottom: 0.75rem;
+}
+
+.anomaly-inline-pill,
+.anomaly-type-pill {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ width: fit-content;
+ padding: 0.28rem 0.68rem;
+ border-radius: 9999px;
+ font-size: 0.78rem;
+ font-weight: 600;
+ border: 1px solid rgba(148, 163, 184, 0.2);
+}
+
+.anomaly-inline-pill {
+ background: rgba(248, 250, 252, 0.92);
+ color: #475569;
+}
+
+.anomaly-type-alert {
+ background: rgba(225, 29, 72, 0.08);
+ color: #be123c;
+ border-color: rgba(225, 29, 72, 0.18);
+}
+
+.anomaly-type-llm {
+ background: rgba(217, 119, 6, 0.08);
+ color: #b45309;
+ border-color: rgba(217, 119, 6, 0.18);
+}
+
+.anomaly-type-change {
+ background: rgba(15, 118, 110, 0.08);
+ color: #0f766e;
+ border-color: rgba(15, 118, 110, 0.18);
+}
+
+.anomaly-metric-name {
+ font-size: 1rem;
+ font-weight: 600;
+ line-height: 1.35;
+ color: #0f172a;
+ overflow-wrap: anywhere;
+}
+
+.anomaly-metric-time {
+ margin-top: 0.35rem;
+ color: #64748b;
+ font-size: 0.9rem;
+}
+
+.anomaly-sparkline-panel {
+ min-width: 0;
+}
+
+.anomaly-sparkline-shell {
+ min-height: 92px;
+ padding: 0.55rem 0.65rem 0.45rem;
+}
+
+.anomaly-sparkline-shell svg {
+ display: block;
+ width: 100% !important;
+ height: 100% !important;
+}
+
+.anomaly-feedback-panel,
+.anomaly-actions-panel {
+ display: flex;
+ flex-direction: column;
+ gap: 0.65rem;
+}
+
+.anomaly-section-label {
+ font-size: 0.72rem;
+ font-weight: 700;
+ letter-spacing: 0.08em;
+ text-transform: uppercase;
+ color: #64748b;
+}
+
+.anomaly-feedback-panel > div {
+ justify-content: flex-start;
+}
+
+.anomaly-expand-btn {
+ min-height: 42px;
+}
+
+.anomaly-pagination {
+ gap: 1rem;
+ align-items: center;
+}
+
+.anomaly-pagination-copy {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ gap: 0.15rem;
+ min-width: 170px;
+}
+
+@media (max-width: 1024px) {
+ .controls-header {
+ flex-direction: column;
+ }
+
+ .toolbar-stack {
+ justify-content: flex-start;
+ }
+
+ .anomaly-row-layout {
+ grid-template-columns: 1fr;
+ }
+
+ .anomaly-feedback-panel > div {
+ justify-content: center;
+ }
+
+ .anomaly-actions-panel {
+ width: 100%;
+ }
+}
+
+@media (max-width: 768px) {
+ .dashboard-hero-card,
+ .controls-card,
+ .empty-batches-card {
+ padding: 1rem;
+ }
+
+ .chart-expand-btn {
+ top: 0.7rem;
+ right: 0.7rem;
+ min-width: 40px;
+ min-height: 40px;
+ }
+
+ .batch-card-stats {
+ grid-template-columns: 1fr;
+ }
+
+ .controls-form-grid {
+ grid-template-columns: 1fr;
+ }
+
+ .overview-value {
+ font-size: 1.55rem;
+ }
+
+ .anomaly-list-shell {
+ padding: 0.25rem;
+ }
+
+ .anomaly-sparkline-shell {
+ min-height: 86px;
+ }
+
+ .anomaly-pagination {
+ gap: 0.75rem;
+ }
+}
+
+body.dark-mode .dashboard-hero-card,
+body.dark-mode .overview-card,
+body.dark-mode .controls-card,
+body.dark-mode .batch-card,
+body.dark-mode .chart-card-container,
+body.dark-mode .chart-figure,
+body.dark-mode .anomaly-list-shell,
+body.dark-mode .anomaly-row-card,
+body.dark-mode .batch-stat-block,
+body.dark-mode .empty-batches-card,
+body.dark-mode .chart-stat-pill,
+body.dark-mode .dashboard-pill {
+ border-color: rgba(71, 85, 105, 0.55);
+}
+
+body.dark-mode .dashboard-hero-card {
+ background:
+ radial-gradient(circle at top right, rgba(96, 165, 250, 0.12), transparent 30%),
+ linear-gradient(180deg, rgba(15, 23, 42, 0.94), rgba(15, 23, 42, 0.98));
+}
+
+body.dark-mode .overview-card,
+body.dark-mode .controls-card,
+body.dark-mode .batch-card,
+body.dark-mode .anomaly-list-shell,
+body.dark-mode .anomaly-row-card,
+body.dark-mode .batch-stat-block,
+body.dark-mode .empty-batches-card,
+body.dark-mode .chart-stat-pill,
+body.dark-mode .dashboard-pill {
+ background: rgba(15, 23, 42, 0.82);
+ color: #e2e8f0;
+}
+
+body.dark-mode .chart-card-container {
+ box-shadow: 0 16px 32px rgba(2, 6, 23, 0.34);
+}
+
+body.dark-mode .anomaly-row-card:hover {
+ border-color: rgba(96, 165, 250, 0.28);
+ box-shadow: 0 16px 32px rgba(2, 6, 23, 0.24);
+}
+
+body.dark-mode .chart-figure {
+ background:
+ radial-gradient(circle at top right, rgba(96, 165, 250, 0.08), transparent 30%),
+ linear-gradient(180deg, rgba(15, 23, 42, 0.76), rgba(2, 6, 23, 0.9));
+ box-shadow:
+ inset 0 1px 0 rgba(148, 163, 184, 0.08),
+ 0 10px 20px rgba(2, 6, 23, 0.24);
+}
+
+body.dark-mode .chart-figure .modebar {
+ background: rgba(15, 23, 42, 0.84) !important;
+ border-color: rgba(71, 85, 105, 0.7) !important;
+}
+
+body.dark-mode .chart-figure .modebar-btn {
+ color: #cbd5e1 !important;
+}
+
+body.dark-mode .chart-figure .modebar-btn:hover {
+ background: rgba(96, 165, 250, 0.12) !important;
+ color: #f8fafc !important;
+}
+
+body.dark-mode .chart-expand-btn {
+ background: rgba(15, 23, 42, 0.86);
+ border-color: rgba(71, 85, 105, 0.7);
+ color: #e2e8f0;
+}
+
+body.dark-mode .chart-expand-btn:hover {
+ background: rgba(15, 23, 42, 0.96);
+}
+
+body.dark-mode .anomaly-inline-pill {
+ background: rgba(15, 23, 42, 0.72);
+ color: #cbd5e1;
+}
+
+body.dark-mode .anomaly-type-alert {
+ background: rgba(244, 63, 94, 0.14);
+ color: #fda4af;
+ border-color: rgba(244, 63, 94, 0.26);
+}
+
+body.dark-mode .anomaly-type-llm {
+ background: rgba(245, 158, 11, 0.14);
+ color: #fcd34d;
+ border-color: rgba(245, 158, 11, 0.26);
+}
+
+body.dark-mode .anomaly-type-change {
+ background: rgba(20, 184, 166, 0.14);
+ color: #5eead4;
+ border-color: rgba(20, 184, 166, 0.26);
+}
+
+body.dark-mode .batch-stat-value,
+body.dark-mode .overview-value,
+body.dark-mode .anomaly-metric-name {
+ color: #f8fafc;
+}
+
+body.dark-mode .overview-label,
+body.dark-mode .controls-label,
+body.dark-mode .batch-stat-label,
+body.dark-mode .anomaly-section-label,
+body.dark-mode .anomaly-metric-time {
+ color: #94a3b8;
+}
diff --git a/requirements-dashboard.txt b/requirements-dashboard.txt
index bf6ac727..6b669e50 100644
--- a/requirements-dashboard.txt
+++ b/requirements-dashboard.txt
@@ -7,8 +7,8 @@ duckdb
pandas
plotly
jinja2
-python-fasthtml
-MonsterUI
+python-fasthtml>=0.13.3
+MonsterUI>=1.0.45
sqlglot
requests
dagster
diff --git a/requirements.txt b/requirements.txt
index c0dd6e93..4d4cdc00 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -16,7 +16,7 @@ google-cloud-bigquery
Jinja2
libsql-experimental
matplotlib
-MonsterUI
+MonsterUI>=1.0.45
numpy
oscrypto
pandas
@@ -25,7 +25,7 @@ plotly
posthog
pyod @ git+https://github.com/yzhao062/pyod.git@master
python-dotenv
-python-fasthtml
+python-fasthtml>=0.13.3
pyyaml
seaborn
slack_sdk