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