From 3347334b95adbef2c4d9b0ce2b51265d942b457e Mon Sep 17 00:00:00 2001 From: andrewm4894 Date: Thu, 7 Aug 2025 14:12:12 +0100 Subject: [PATCH 1/3] feat: migrate dashboard charts from Plotly to Monster UI ApexCharts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Complete migration of anomstack dashboard from Plotly.js to Monster UI's native ApexChart components while maintaining all existing functionality and improving user experience. Key changes: - Replace Plotly with ApexCharts across all chart types (regular, expanded, sparklines) - Implement dual y-axes with percentage-formatted score axis (0%-100%) - Add properly sized alert markers at 100% line for anomaly detection - Ensure metric value dots match blue line color for visual consistency - Fix JSON serialization issues with pandas data types - Add client-side percentage formatting via JavaScript - Improve chart dimensions and responsive design - Remove debug statements and optimize chart initialization - Maintain all interactive features (zoom, tooltips, themes, legends) Technical improvements: - Use native Monster UI ApexChart components instead of raw HTML injection - Eliminate circular imports and improve code organization - Reduce codebase complexity while preserving full functionality - Better integration with Monster UI design system 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- dashboard/app.py | 71 ++- dashboard/charts.py | 1022 ++++++++++++++----------------------- dashboard/routes/batch.py | 31 +- 3 files changed, 465 insertions(+), 659 deletions(-) diff --git a/dashboard/app.py b/dashboard/app.py index 493351c..9651583 100644 --- a/dashboard/app.py +++ b/dashboard/app.py @@ -14,7 +14,7 @@ import os from dotenv import load_dotenv -from fasthtml.common import Link, Script, fast_app, serve +from fasthtml.common import Link, Script, Style, fast_app, serve from monsterui.all import * from dashboard.constants import POSTHOG_SCRIPT @@ -56,7 +56,74 @@ def load_env_with_custom_path(): app, rt = fast_app( hdrs=( Theme.blue.headers(), - Script(src="https://cdn.plot.ly/plotly-2.32.0.min.js"), + Script(src="https://cdn.jsdelivr.net/npm/apexcharts@latest"), + Style(""" +uk-chart { + display: block !important; + width: 100% !important; + min-height: 300px !important; + height: auto !important; +} +uk-chart .apexcharts-canvas { + width: 100% !important; + height: 100% !important; +} + """), + Script(""" +function initializeCharts() { + const elements = document.querySelectorAll('uk-chart:not([data-chart-initialized])'); + + elements.forEach(function(element) { + const script = element.querySelector('script[type="application/json"]'); + if (script) { + try { + const config = JSON.parse(script.textContent); + + // Add percentage formatting to score axis (second y-axis) + if (config.yaxis && config.yaxis[1]) { + config.yaxis[1].labels = config.yaxis[1].labels || {}; + config.yaxis[1].labels.formatter = function(val) { + return Math.round(val * 100) + '%'; + }; + } + + // Ensure chart has dimensions + element.style.minHeight = '300px'; + element.style.width = '100%'; + element.style.display = 'block'; + + const chart = new ApexCharts(element, config); + chart.render().then(() => { + element.setAttribute('data-chart-initialized', 'true'); + }); + } catch (e) { + console.error('Error initializing chart:', e, element); + } + } + }); +} + +// Wait for both DOM and ApexCharts to be ready +document.addEventListener('DOMContentLoaded', function() { + // Check if ApexCharts is loaded + if (typeof ApexCharts !== 'undefined') { + initializeCharts(); + } else { + // Wait a bit more for ApexCharts to load + setTimeout(() => { + if (typeof ApexCharts !== 'undefined') { + initializeCharts(); + } else { + console.error('ApexCharts library not loaded'); + } + }, 1000); + } +}); + +// Re-initialize charts after HTMX requests +document.addEventListener('htmx:afterSwap', initializeCharts); +document.addEventListener('htmx:afterSettle', initializeCharts); + """), Script(POSTHOG_SCRIPT) if posthog_api_key else None, Link( rel="icon", diff --git a/dashboard/charts.py b/dashboard/charts.py index ef8af10..d24ec9c 100644 --- a/dashboard/charts.py +++ b/dashboard/charts.py @@ -8,12 +8,10 @@ """ from fasthtml.common import Div, P -from monsterui.all import Card, DivLAligned, Loading, LoadingT, TextPresets +from monsterui.all import Card, DivLAligned, Loading, LoadingT, TextPresets, ApexChart import pandas as pd -import plotly.graph_objects as go -from plotly.subplots import make_subplots - -from dashboard.app import app +import json +from datetime import datetime class ChartManager: @@ -22,92 +20,232 @@ class ChartManager: """ @staticmethod - def get_chart_config(): - """Return the common chart configuration.""" + def get_chart_config(small_charts=False, dark_mode=False): + """Return the common ApexChart configuration.""" return { - "displayModeBar": False, - "displaylogo": False, - "modeBarButtonsToRemove": [ - "zoom2d", - "pan2d", - "select2d", - "lasso2d", - "zoomIn2d", - "zoomOut2d", - "autoScale2d", - "resetScale2d", - ], - "responsive": True, - "scrollZoom": False, - "staticPlot": False, + "chart": { + "type": "line", + "height": 250 if small_charts else 400, + "zoom": { + "enabled": True + }, + "toolbar": { + "show": False + }, + "animations": { + "enabled": False + } + }, + "theme": { + "mode": "dark" if dark_mode else "light" + }, + "responsive": [{ + "breakpoint": 480, + "options": { + "chart": { + "height": 200 + } + } + }] } @staticmethod - def create_chart(df_metric, chart_index): + def create_chart(df_metric, chart_index, small_charts=False, dark_mode=False, show_markers=True, line_width=2, show_legend=False): """ - Create a chart for a given metric. + Create a chart for a given metric using ApexChart. """ - return plot_time_series( + chart_opts = ChartManager._build_time_series_chart( df_metric, - small_charts=app.state.small_charts, - dark_mode=app.state.dark_mode, - show_markers=app.state.show_markers, - line_width=app.state.line_width, - show_legend=app.state.show_legend, - ).to_html( - div_id=f"plotly-chart-{chart_index}", - include_plotlyjs=False, - full_html=False, - config=ChartManager.get_chart_config(), + small_charts=small_charts, + dark_mode=dark_mode, + show_markers=show_markers, + line_width=line_width, + show_legend=show_legend, + ) + + return ApexChart( + opts=chart_opts, + id=f"apex-chart-{chart_index}" ) @staticmethod - def create_expanded_chart(df_metric, chart_index): - """ - Create an expanded chart for modal display with enhanced interactivity. - """ - # Enhanced config for expanded view - enhanced_config = { - "displayModeBar": True, - "modeBarButtonsToRemove": [ - "select2d", - "lasso2d", + def _build_time_series_chart(df_metric, small_charts=False, dark_mode=False, show_markers=True, line_width=2, show_legend=False): + """Build ApexChart configuration for time series data.""" + colors = ChartStyle.get_colors(dark_mode) + + # Convert timestamps to milliseconds for ApexCharts + timestamps = [int(pd.to_datetime(ts).timestamp() * 1000) for ts in df_metric["metric_timestamp"]] + + # Prepare main metric data (ensure native Python types for JSON serialization) + value_data = [[int(ts), float(val)] for ts, val in zip(timestamps, df_metric["metric_value"])] + score_data = [[int(ts), float(val)] for ts, val in zip(timestamps, df_metric["metric_score"])] + + # Build series array + series = [ + { + "name": "Value", + "type": "line", + "data": value_data, + "yAxisIndex": 0, + "color": colors["primary"], + "marker": { + "size": 4 if show_markers else 0 + } + }, + { + "name": "Score", + "type": "line", + "data": score_data, + "yAxisIndex": 1, + "color": colors["secondary"], + "marker": { + "size": 0 # Never show markers on score line + } + } + ] + + # Add alert markers if they exist (on score axis at y=1) + alert_data = df_metric[df_metric["metric_alert"] == 1] + if not alert_data.empty: + alert_timestamps = [int(pd.to_datetime(ts).timestamp() * 1000) for ts in alert_data["metric_timestamp"]] + # Put all alert markers at y=1 on the score axis + alert_values = [[int(ts), 1.0] for ts in alert_timestamps] + series.append({ + "name": "Alert", + "type": "line", + "data": alert_values, + "yAxisIndex": 1, # Score axis + "color": colors["alert"], + "fill": { + "opacity": 1 + }, + "stroke": { + "width": 0, # No line, just markers + "lineCap": "round" + } + }) + + # Add LLM alert markers if they exist (on score axis at y=1) + llm_alert_data = df_metric[df_metric["metric_llmalert"] == 1] + if not llm_alert_data.empty: + llm_timestamps = [int(pd.to_datetime(ts).timestamp() * 1000) for ts in llm_alert_data["metric_timestamp"]] + # Put all LLM alert markers at y=1 on the score axis + llm_values = [[int(ts), 1.0] for ts in llm_timestamps] + series.append({ + "name": "LLM Alert", + "type": "line", + "data": llm_values, + "yAxisIndex": 1, # Score axis + "color": colors["llmalert"], + "fill": { + "opacity": 1 + }, + "stroke": { + "width": 0, # No line, just markers + "lineCap": "round" + } + }) + + # Build chart configuration + config = ChartManager.get_chart_config(small_charts, dark_mode) + config.update({ + "series": series, + "xaxis": { + "type": "datetime", + "labels": { + "format": "HH:mm", + "style": { + "colors": colors["text"] + } + } + }, + "yaxis": [ + { + "title": { + "text": "Value", + "style": { + "color": colors["text"] + } + }, + "labels": { + "style": { + "colors": colors["text"] + } + } + }, + { + "opposite": True, + "title": { + "text": "Score", + "style": { + "color": colors["text"] + } + }, + "labels": { + "style": { + "colors": colors["text"] + } + }, + "min": 0, + "max": 1 + } ], - "responsive": True, - "scrollZoom": True, - "staticPlot": False, - "fillFrame": True, - "displaylogo": False, - "modeBarButtonsToAdd": [], - "toImageButtonOptions": { - "format": "png", - "filename": f"metric_chart_{chart_index}", - "height": 600, - "scale": 1 + "stroke": { + "width": [line_width, line_width, 0, 0], # Line widths for each series + "dashArray": [0, 5, 0, 0] # Dash patterns (score line dashed) + }, + "markers": { + "size": [4 if show_markers else 0, 0, 4, 4], # Much smaller alert markers + "colors": [colors["primary"], "undefined", colors["alert"], colors["llmalert"]], # Value markers same as blue line + "strokeWidth": [1, 0, 1, 1], + "strokeColors": ["#ffffff", "#ffffff", "#ffffff", "#ffffff"], + "hover": { + "size": [6 if show_markers else 0, 0, 6, 6] # Smaller hover too + } + }, + "legend": { + "show": show_legend, + "position": "top", + "horizontalAlign": "left", + "labels": { + "colors": colors["text"] + } + }, + "grid": { + "borderColor": colors["grid"] + }, + "tooltip": { + "theme": "dark" if dark_mode else "light", + "x": { + "format": "dd MMM yyyy HH:mm" + } } - } + }) - fig = plot_time_series( + return config + + @staticmethod + def create_expanded_chart(df_metric, chart_index, dark_mode=False, show_markers=True, line_width=2): + """ + Create an expanded chart for modal display with enhanced interactivity. + """ + chart_opts = ChartManager._build_time_series_chart( df_metric, small_charts=False, # Always use large size for expanded view - dark_mode=app.state.dark_mode, - show_markers=app.state.show_markers, - line_width=app.state.line_width, + dark_mode=dark_mode, + show_markers=show_markers, + line_width=line_width, show_legend=True, # Always show legend in expanded view ) - # Update layout for expanded view with larger height and full width - fig.update_layout( - height=600, - autosize=True, - margin=dict(l=40, r=40, t=40, b=40) - ) + # Override chart height for expanded view + chart_opts["chart"]["height"] = 600 + chart_opts["chart"]["toolbar"]["show"] = True # Show toolbar in expanded view + chart_opts["chart"]["zoom"]["enabled"] = True - return fig.to_html( - div_id=f"plotly-chart-expanded-{chart_index}", - include_plotlyjs=False, - full_html=False, - config=enhanced_config, + return ApexChart( + opts=chart_opts, + id=f"apex-chart-expanded-{chart_index}" ) @staticmethod @@ -139,447 +277,219 @@ def create_chart_placeholder(metric_name, index, batch_name) -> Card: ) @staticmethod - def create_sparkline(df_metric: pd.DataFrame, anomaly_timestamp: pd.Timestamp = None) -> str: + def create_sparkline(df_metric: pd.DataFrame, anomaly_timestamp: pd.Timestamp = None, dark_mode=False): """Create a sparkline chart for a metric. Args: df_metric (pd.DataFrame): The metric data. anomaly_timestamp (pd.Timestamp): The specific timestamp of the anomaly to mark. + dark_mode (bool): Whether to use dark mode styling. Returns: - str: The HTML for the sparkline. + ApexChart: The sparkline chart component. """ - colors = ChartStyle.get_colors(app.state.dark_mode) - fig = make_subplots(specs=[[{"secondary_y": True}]]) - - # Add the main line - fig.add_trace( - go.Scatter( - x=df_metric["metric_timestamp"], - y=df_metric["metric_value"], - name="Value", - mode="lines", - line=dict( - color=colors["primary"], - width=1, - ), - showlegend=False, - connectgaps=True, - ), - secondary_y=False, - ) - - # Add the score line - fig.add_trace( - go.Scatter( - x=df_metric["metric_timestamp"], - y=df_metric["metric_score"], - name="Score", - mode="lines", - line=dict( - color=colors["secondary"], - width=1, - dash="dot", - ), - showlegend=False, - connectgaps=True, - ), - secondary_y=True, - ) - - # Add marker for the specific anomaly point if provided + colors = ChartStyle.get_colors(dark_mode) + + # Convert timestamps to milliseconds + timestamps = [int(pd.to_datetime(ts).timestamp() * 1000) for ts in df_metric["metric_timestamp"]] + + # Prepare data (ensure native Python types for JSON serialization) + value_data = [[int(ts), float(val)] for ts, val in zip(timestamps, df_metric["metric_value"])] + score_data = [[int(ts), float(val)] for ts, val in zip(timestamps, df_metric["metric_score"])] + + series = [ + { + "name": "Value", + "type": "line", + "data": value_data, + "yAxisIndex": 0, + "color": colors["primary"] + }, + { + "name": "Score", + "type": "line", + "data": score_data, + "yAxisIndex": 1, + "color": colors["secondary"] + } + ] + + # Add specific anomaly marker if provided if anomaly_timestamp is not None: anomaly_point = df_metric[df_metric["metric_timestamp"] == anomaly_timestamp] if not anomaly_point.empty: - # Determine alert type from the specific anomaly point (not the last row) alert_color = ( colors["llmalert"] if anomaly_point["metric_llmalert"].iloc[0] == 1 else colors["alert"] ) - fig.add_trace( - go.Scatter( - x=[anomaly_point["metric_timestamp"].iloc[0]], - y=[anomaly_point["metric_value"].iloc[0]], - name="Alert", - mode="markers", - marker=dict( - color=alert_color, - size=8, - symbol="diamond", - ), - showlegend=False, - ), - secondary_y=False, - ) - - fig.update_layout( - height=50, - width=200, - margin=dict(l=0, r=0, t=0, b=0), - xaxis=dict( - showgrid=False, - showticklabels=False, - zeroline=False, - ), - yaxis=dict( - showgrid=False, - showticklabels=False, - zeroline=False, - ), - yaxis2=dict( - showgrid=False, - showticklabels=False, - zeroline=False, - range=[0, 1.05], - ), - paper_bgcolor="rgba(0,0,0,0)", - plot_bgcolor="rgba(0,0,0,0)", - modebar=dict( - remove=[ - "zoom", - "pan", - "select", - "lasso", - "zoomIn", - "zoomOut", - "autoScale", - "resetScale", - ] - ), - ) - - return fig.to_html( - full_html=False, - include_plotlyjs=False, - config=dict(displayModeBar=False), - ) + anomaly_ts = int(pd.to_datetime(anomaly_timestamp).timestamp() * 1000) + series.append({ + "name": "Alert", + "type": "scatter", + "data": [[int(anomaly_ts), float(anomaly_point["metric_value"].iloc[0])]], + "yAxisIndex": 0, + "color": alert_color + }) + + chart_opts = { + "chart": { + "type": "line", + "height": 50, + "width": 200, + "sparkline": { + "enabled": True + }, + "toolbar": { + "show": False + }, + "animations": { + "enabled": False + } + }, + "series": series, + "stroke": { + "width": [1, 1, 0], + "dashArray": [0, 3, 0] + }, + "markers": { + "size": [0, 0, 6] + }, + "yaxis": [ + { + "show": False + }, + { + "show": False, + "min": 0, + "max": 1.05 + } + ], + "xaxis": { + "type": "datetime", + "labels": { + "show": False + }, + "axisBorder": { + "show": False + }, + "axisTicks": { + "show": False + } + }, + "grid": { + "show": False + }, + "tooltip": { + "enabled": False + }, + "legend": { + "show": False + } + } + + return ApexChart(opts=chart_opts) @staticmethod - def create_expanded_sparkline(df_metric: pd.DataFrame, anomaly_timestamp: pd.Timestamp = None) -> str: + def create_expanded_sparkline(df_metric: pd.DataFrame, anomaly_timestamp: pd.Timestamp = None, dark_mode=False): """Create an expanded sparkline chart for a specific anomaly. Args: df_metric (pd.DataFrame): The metric data. anomaly_timestamp (pd.Timestamp): The specific timestamp of the anomaly to highlight. + dark_mode (bool): Whether to use dark mode styling. Returns: - str: The HTML for the expanded sparkline. + ApexChart: The expanded sparkline chart. """ - colors = ChartStyle.get_colors(app.state.dark_mode) - fig = make_subplots(specs=[[{"secondary_y": True}]]) - - # Add the main line - fig.add_trace( - go.Scatter( - x=df_metric["metric_timestamp"], - y=df_metric["metric_value"], - name="Value", - mode="lines+markers", - line=dict( - color=colors["primary"], - width=3, - ), - marker=dict( - size=4, - color=colors["primary"], - ), - showlegend=True, - connectgaps=True, - ), - secondary_y=False, - ) - - # Add the score line - fig.add_trace( - go.Scatter( - x=df_metric["metric_timestamp"], - y=df_metric["metric_score"], - name="Score", - mode="lines", - line=dict( - color=colors["secondary"], - width=2, - dash="dot", - ), - showlegend=True, - connectgaps=True, - ), - secondary_y=True, + chart_opts = ChartManager._build_time_series_chart( + df_metric, + small_charts=False, + dark_mode=dark_mode, + show_markers=True, + line_width=3, + show_legend=True, ) - - # Add marker for the specific anomaly point if provided + + # Override settings for expanded view + chart_opts["chart"]["height"] = 500 + chart_opts["chart"]["toolbar"]["show"] = True + chart_opts["title"] = { + "text": "Anomaly Detail View", + "align": "center" + } + + # Add specific anomaly marker if provided if anomaly_timestamp is not None: - # Find the data point closest to the anomaly timestamp anomaly_row = df_metric[df_metric["metric_timestamp"] == anomaly_timestamp] if not anomaly_row.empty: - fig.add_trace( - go.Scatter( - x=[anomaly_timestamp], - y=[anomaly_row["metric_value"].iloc[0]], - name="Anomaly", - mode="markers", - marker=dict( - size=12, - color="red", - symbol="circle", - line=dict(width=2, color="white"), - ), - showlegend=True, - ), - secondary_y=False, - ) - - # Add alert markers for all anomalies - alert_data = df_metric[df_metric["metric_alert"] == 1] - if not alert_data.empty: - fig.add_trace( - go.Scatter( - x=alert_data["metric_timestamp"], - y=alert_data["metric_value"], - name="Alert", - mode="markers", - marker=dict( - size=8, - color="orange", - symbol="diamond", - ), - showlegend=True, - ), - secondary_y=False, - ) - - # Add LLM alert markers - llm_alert_data = df_metric[df_metric["metric_llmalert"] == 1] - if not llm_alert_data.empty: - fig.add_trace( - go.Scatter( - x=llm_alert_data["metric_timestamp"], - y=llm_alert_data["metric_value"], - name="LLM Alert", - mode="markers", - marker=dict( - size=8, - color="purple", - symbol="star", - ), - showlegend=True, - ), - secondary_y=False, - ) - - fig.update_layout( - height=500, - margin=dict(l=60, r=60, t=60, b=60), - xaxis=dict( - showgrid=True, - showticklabels=True, - title="Time", - ), - yaxis=dict( - showgrid=True, - showticklabels=True, - title="Value", - ), - yaxis2=dict( - showgrid=False, - showticklabels=True, - title="Score", - range=[0, 1.05], - side="right", - ), - paper_bgcolor=colors["background"], - plot_bgcolor=colors["background"], - font=dict(color=colors["text"]), - legend=dict( - orientation="h", - yanchor="bottom", - y=1.02, - xanchor="right", - x=1 - ), - title=dict( - text="Anomaly Detail View", - x=0.5, - font=dict(size=16) - ) - ) - - # Enhanced config for expanded view - enhanced_config = { - "displayModeBar": True, - "modeBarButtonsToRemove": [ - "select2d", - "lasso2d", - ], - "responsive": True, - "scrollZoom": True, - "staticPlot": False, - "fillFrame": True, - "displaylogo": False, - "toImageButtonOptions": { - "format": "png", - "filename": "anomaly_chart", - "height": 500, - "scale": 1 - } - } - - return fig.to_html( - full_html=False, - include_plotlyjs=False, - config=enhanced_config, - ) + anomaly_ts = int(pd.to_datetime(anomaly_timestamp).timestamp() * 1000) + chart_opts["series"].append({ + "name": "Selected Anomaly", + "type": "scatter", + "data": [[int(anomaly_ts), float(anomaly_row["metric_value"].iloc[0])]], + "yAxisIndex": 0, + "color": "#ff0000" + }) + # Update marker sizes to include the new series + chart_opts["markers"]["size"].append(12) + + return ApexChart(opts=chart_opts) @staticmethod - def create_single_anomaly_expanded_chart(df_metric: pd.DataFrame, anomaly_timestamp: pd.Timestamp = None) -> str: + def create_single_anomaly_expanded_chart(df_metric: pd.DataFrame, anomaly_timestamp: pd.Timestamp = None, dark_mode=False): """Create an expanded chart showing full time series but highlighting only one specific anomaly. Args: df_metric (pd.DataFrame): The metric data. anomaly_timestamp (pd.Timestamp): The specific timestamp of the anomaly to highlight. + dark_mode (bool): Whether to use dark mode styling. Returns: - str: The HTML for the expanded chart. + ApexChart: The expanded chart component. """ - colors = ChartStyle.get_colors(app.state.dark_mode) - fig = make_subplots(specs=[[{"secondary_y": True}]]) - - # Add the main line (full time series) - fig.add_trace( - go.Scatter( - x=df_metric["metric_timestamp"], - y=df_metric["metric_value"], - name="Value", - mode="lines+markers", - line=dict( - color=colors["primary"], - width=3, - ), - marker=dict( - size=4, - color=colors["primary"], - ), - showlegend=True, - connectgaps=True, - ), - secondary_y=False, - ) - - # Add the score line (full time series) - fig.add_trace( - go.Scatter( - x=df_metric["metric_timestamp"], - y=df_metric["metric_score"], - name="Score", - mode="lines", - line=dict( - color=colors["secondary"], - width=2, - dash="dot", - ), - showlegend=True, - connectgaps=True, - ), - secondary_y=True, + colors = ChartStyle.get_colors(dark_mode) + + chart_opts = ChartManager._build_time_series_chart( + df_metric, + small_charts=False, + dark_mode=dark_mode, + show_markers=True, + line_width=3, + show_legend=True, ) - - # Add marker for ONLY the specific anomaly point clicked + + # Override settings for single anomaly view + chart_opts["chart"]["height"] = 500 + chart_opts["chart"]["toolbar"]["show"] = True + chart_opts["title"] = { + "text": "Single Anomaly Detail View", + "align": "center" + } + + # Add ONLY the specific anomaly marker if provided if anomaly_timestamp is not None: - # Find the data point closest to the anomaly timestamp anomaly_row = df_metric[df_metric["metric_timestamp"] == anomaly_timestamp] if not anomaly_row.empty: - # Use the EXACT same color logic as the sparkline (lines 195-199 in create_sparkline) + # Use same color logic as sparkline alert_color = ( colors["llmalert"] if anomaly_row["metric_llmalert"].iloc[0] == 1 else colors["alert"] ) - # Use same symbol as sparkline (diamond) - marker_color = alert_color - marker_symbol = "diamond" - alert_name = "Selected Alert" - - fig.add_trace( - go.Scatter( - x=[anomaly_timestamp], - y=[anomaly_row["metric_value"].iloc[0]], - name=alert_name, - mode="markers", - marker=dict( - size=15, - color=marker_color, - symbol=marker_symbol, - line=dict(width=3, color="white"), - ), - showlegend=True, - ), - secondary_y=False, - ) - - fig.update_layout( - height=500, - margin=dict(l=60, r=60, t=60, b=60), - xaxis=dict( - showgrid=True, - showticklabels=True, - title="Time", - ), - yaxis=dict( - showgrid=True, - showticklabels=True, - title="Value", - ), - yaxis2=dict( - showgrid=False, - showticklabels=True, - title="Score", - range=[0, 1.05], - side="right", - ), - paper_bgcolor=colors["background"], - plot_bgcolor=colors["background"], - font=dict(color=colors["text"]), - legend=dict( - orientation="h", - yanchor="bottom", - y=1.02, - xanchor="right", - x=1 - ), - title=dict( - text="Single Anomaly Detail View", - x=0.5, - font=dict(size=16) - ) - ) - - # Enhanced config for expanded view - enhanced_config = { - "displayModeBar": True, - "modeBarButtonsToRemove": [ - "select2d", - "lasso2d", - ], - "responsive": True, - "scrollZoom": True, - "staticPlot": False, - "fillFrame": True, - "displaylogo": False, - "toImageButtonOptions": { - "format": "png", - "filename": "single_anomaly_chart", - "height": 500, - "scale": 1 - } - } - - return fig.to_html( - full_html=False, - include_plotlyjs=False, - config=enhanced_config, - ) + anomaly_ts = int(pd.to_datetime(anomaly_timestamp).timestamp() * 1000) + chart_opts["series"].append({ + "name": "Selected Alert", + "type": "scatter", + "data": [[int(anomaly_ts), float(anomaly_row["metric_value"].iloc[0])]], + "yAxisIndex": 0, + "color": alert_color + }) + # Update marker sizes to include the new series + chart_opts["markers"]["size"].append(15) + + return ApexChart(opts=chart_opts) class ChartStyle: @@ -614,189 +524,3 @@ def get_common_styling(colors: dict) -> tuple: ) return common_font, common_title_font, common_grid - -def plot_time_series( - df, - small_charts=False, - dark_mode=False, - show_markers=True, - line_width=2, - show_legend=False, -) -> go.Figure: - """ - Plot a time series with metric value and metric score. - - Args: - df: DataFrame with metric data - small_charts: Whether to use small chart size - dark_mode: Whether to use dark mode styling - show_markers: Whether to show line markers - line_width: Width of the lines (1-10) - show_legend: Whether to show the legend - - Returns: - go.Figure: The plotly figure - """ - df["metric_llmalert"] = df["metric_llmalert"].clip(upper=1) - colors = ChartStyle.get_colors(dark_mode) - _, _, common_grid = ChartStyle.get_common_styling(colors) - - # Define height based on size toggle - height = 250 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 metric score trace - _add_score_trace(fig, df, colors, line_width, show_legend) - - # Add alert and change markers if they exist - _add_condition_traces(fig, df, colors, line_width, show_legend) - - # Update axes - _update_axes_and_layout(fig, common_grid, colors, show_legend, height) - - return fig - - -def _add_main_metric_trace(fig, df, colors, show_markers, line_width, show_legend): - """Add the main metric value trace to the figure.""" - 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), - marker=( - dict(size=line_width + 4, color=colors["primary"], symbol="circle") - if show_markers - else None - ), - showlegend=show_legend, - connectgaps=True, - hovertemplate=( - f'Value: %{{y:.2f}}
' - f"Time: %{{x}}
" - ), - ), - secondary_y=False, - ) - - -def _add_score_trace(fig, df, colors, line_width, show_legend): - """Add the metric score trace to the figure.""" - 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"), - showlegend=show_legend, - connectgaps=True, - hovertemplate=( - f'Score: %{{y:.1%}}
' - f"Time: %{{x}}
" - ), - ), - secondary_y=True, - ) - - -def _add_condition_traces(fig, df, colors, line_width, show_legend): - """Add alert and change markers to the figure.""" - for condition, props in { - "metric_alert": dict( - name="Alert", - color=colors["alert"], - hovertemplate=( - f'' - f"Alert
" - "Time: %{x}" - "
" - ), - ), - "metric_llmalert": dict( - name="LLM Alert", - color=colors["llmalert"], - hovertemplate=( - f'' - f"LLM Alert
" - "Time: %{x}
" - "Details: %{customdata}" - "
" - ), - ), - "metric_change": dict( - name="Change", - color=colors["change"], - hovertemplate=( - f'' - f"Change
" - "Time: %{x}" - "
" - ), - ), - }.items(): - condition_df = df[df[condition] == 1] - if not condition_df.empty: - fig.add_trace( - go.Scatter( - x=condition_df["metric_timestamp"], - y=condition_df[condition], - mode="markers", - name=props["name"], - marker=dict(color=props["color"], size=line_width + 4, symbol="circle"), - showlegend=show_legend, - customdata=condition_df["anomaly_explanation"], - hovertemplate=props["hovertemplate"], - ), - secondary_y=True, - ) - - -def _update_axes_and_layout(fig, common_grid, colors, show_legend, height): - """Update axes and layout of the figure.""" - # Update axes - fig.update_xaxes(**common_grid) - fig.update_yaxes(title_text="Value", secondary_y=False, **common_grid) - fig.update_yaxes( - title_text="Score", - secondary_y=True, - showgrid=False, - range=[0, 1.05], - tickformat=".0%", - **{k: v for k, v in common_grid.items() if k != "showgrid"}, - ) - - # Update layout - fig.update_layout( - plot_bgcolor=colors["background"], - paper_bgcolor=colors["background"], - hovermode="closest", - hoverdistance=100, - showlegend=show_legend, - legend=( - dict( - orientation="h", - yanchor="bottom", - y=1.02, - xanchor="left", - x=0, - bgcolor=colors["background"], - font=dict(color=colors["text"]), - ) - if show_legend - else None - ), - margin=dict(t=5, b=5, l=5, r=5), - height=height, - hoverlabel=dict( - bgcolor="white", - bordercolor="white", - font=dict(size=12), - ), - ) diff --git a/dashboard/routes/batch.py b/dashboard/routes/batch.py index 75ca567..58116cc 100644 --- a/dashboard/routes/batch.py +++ b/dashboard/routes/batch.py @@ -8,6 +8,7 @@ """ from fasthtml.common import H4, Div, P, Request, Safe, Script, Style, Table, Td, Th, Tr +from fastcore.xml import to_xml from monsterui.all import Button, ButtonT, Card, DivLAligned, UkIcon import pandas as pd @@ -137,7 +138,15 @@ def get(batch_name: str, chart_index: int): if chart_index not in app.state.chart_cache[batch_name]: df_metric = df[df["metric_name"] == metric_name] df_metric = extract_metadata(df_metric, "anomaly_explanation") - fig = ChartManager.create_chart(df_metric, chart_index) + fig = ChartManager.create_chart( + df_metric, + chart_index, + small_charts=app.state.small_charts, + dark_mode=app.state.dark_mode, + show_markers=app.state.show_markers, + line_width=app.state.line_width, + show_legend=app.state.show_legend + ) app.state.chart_cache[batch_name][chart_index] = fig modal_id = f"modal-{batch_name}-{chart_index}" @@ -194,7 +203,7 @@ def get(batch_name: str, chart_index: int): } """ ), - Safe(app.state.chart_cache[batch_name][chart_index]), + Safe(to_xml(app.state.chart_cache[batch_name][chart_index], indent=False)), # Expand button overlay positioned on entire card Button( UkIcon("expand", height=16, width=16), @@ -288,10 +297,16 @@ def get_expanded_chart(batch_name: str, chart_index: int): df_metric = extract_metadata(df_metric, "anomaly_explanation") # Create expanded chart with enhanced configuration - expanded_fig = ChartManager.create_expanded_chart(df_metric, chart_index) + expanded_fig = ChartManager.create_expanded_chart( + df_metric, + chart_index, + dark_mode=app.state.dark_mode, + show_markers=app.state.show_markers, + line_width=app.state.line_width + ) return Div( - Safe(expanded_fig), + Safe(to_xml(expanded_fig, indent=False)), cls="w-full" ) @@ -367,7 +382,7 @@ def get_anomaly_list(batch_name: str, page: int = 1, per_page: int = 50): # Get the metric data for this anomaly df_metric = df[df["metric_name"] == metric_name].copy() df_metric = df_metric.sort_values("metric_timestamp") - fig = ChartManager.create_sparkline(df_metric, anomaly_timestamp=timestamp) + fig = ChartManager.create_sparkline(df_metric, anomaly_timestamp=timestamp, dark_mode=app.state.dark_mode) # Create safe feedback key by replacing problematic characters safe_metric = ( @@ -425,7 +440,7 @@ def get_anomaly_list(batch_name: str, page: int = 1, per_page: int = 50): ), Td( Div( - Safe(fig), + Safe(to_xml(fig, indent=False)), Style( "svg { display: block; margin: auto; height: 100% !important; width: 100% !important; }" ), @@ -935,9 +950,9 @@ def get_expanded_anomaly_chart(batch_name: str, metric_name: str, timestamp: str df_metric = extract_metadata(df_metric, "anomaly_explanation") # Create expanded view with full time series but only highlight the single clicked anomaly - expanded_fig = ChartManager.create_single_anomaly_expanded_chart(df_metric, anomaly_timestamp) + expanded_fig = ChartManager.create_single_anomaly_expanded_chart(df_metric, anomaly_timestamp, dark_mode=app.state.dark_mode) return Div( - Safe(expanded_fig), + Safe(to_xml(expanded_fig, indent=False)), cls="w-full" ) From 3404fc8e3fa871fba8db7a2afe713a550a7f00c0 Mon Sep 17 00:00:00 2001 From: andrewm4894 Date: Thu, 7 Aug 2025 14:36:51 +0100 Subject: [PATCH 2/3] fix: update dashboard test for ApexCharts configuration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Update test_chart_manager_config_and_colors to verify ApexCharts configuration keys instead of Plotly configuration keys after migration. 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- tests/test_dashboard.py | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/tests/test_dashboard.py b/tests/test_dashboard.py index 50037c2..5b79659 100644 --- a/tests/test_dashboard.py +++ b/tests/test_dashboard.py @@ -41,16 +41,15 @@ def test_format_time_ago_outputs(): def test_chart_manager_config_and_colors(): config = ChartManager.get_chart_config() expected_keys = [ - "displayModeBar", - "displaylogo", - "modeBarButtonsToRemove", + "chart", + "theme", "responsive", - "scrollZoom", - "staticPlot", ] for key in expected_keys: assert key in config - assert config["displaylogo"] is False + assert config["chart"]["type"] == "line" + assert config["chart"]["toolbar"]["show"] is False + assert config["theme"]["mode"] == "light" light = ChartStyle.get_colors(False) dark = ChartStyle.get_colors(True) From 55f39393a21b23a95ec008c80a99eb441f8687fc Mon Sep 17 00:00:00 2001 From: andrewm4894 Date: Fri, 8 Aug 2025 15:47:11 +0100 Subject: [PATCH 3/3] dev --- dashboard/app.py | 150 +++++++++++++++++++++++++++++------ dashboard/charts.py | 141 +++++++++++++++++++-------------- dashboard/routes/batch.py | 159 +++++++++++++++++++------------------- 3 files changed, 292 insertions(+), 158 deletions(-) diff --git a/dashboard/app.py b/dashboard/app.py index 9651583..ba711e8 100644 --- a/dashboard/app.py +++ b/dashboard/app.py @@ -67,40 +67,125 @@ def load_env_with_custom_path(): uk-chart .apexcharts-canvas { width: 100% !important; height: 100% !important; +} +/* Sparklines should fill their grid cell for consistent alignment */ +/* We now size sparkline charts via JS by detecting chart.sparkline.enabled */ +.sparkline-cell uk-chart[id^="sparkline-"] { + min-height: 32px !important; + height: 32px !important; + width: 260px !important; +} +.sparkline-cell uk-chart[id^="sparkline-"] .apexcharts-canvas { + width: 100% !important; + height: 100% !important; +} +.sparkline-container { + display: flex; + justify-content: center; + align-items: center; + width: 100%; + height: 100%; } """), Script(""" -function initializeCharts() { - const elements = document.querySelectorAll('uk-chart:not([data-chart-initialized])'); - - elements.forEach(function(element) { - const script = element.querySelector('script[type="application/json"]'); - if (script) { +const __chartInitState = { + observer: null, + seen: new WeakSet(), +}; + +function renderChartElement(el) { + const script = el.querySelector('script[type="application/json"]'); + if (!script) return false; + if (el.hasAttribute('data-chart-initialized')) return true; + try { + const config = JSON.parse(script.textContent); + if (config.yaxis && config.yaxis[1]) { + config.yaxis[1].labels = config.yaxis[1].labels || {}; + config.yaxis[1].labels.formatter = function(val) { return Math.round(val * 100) + '%'; }; + } + const isSpark = !!(config.chart && config.chart.sparkline && config.chart.sparkline.enabled); + el.style.display = 'block'; + el.style.width = '100%'; + el.style.minHeight = isSpark ? '32px' : '300px'; + el.style.height = isSpark ? '32px' : 'auto'; + // Ensure ApexCharts receives an explicit width to avoid 0-width renders + const width = Math.max(220, Math.floor(el.getBoundingClientRect().width || el.clientWidth || 0)); + config.chart = config.chart || {}; + config.chart.width = width; + config.chart.height = isSpark ? 32 : (config.chart.height || 300); + if (typeof ApexCharts === 'undefined') return false; + const chart = new ApexCharts(el, config); + chart.render().then(() => el.setAttribute('data-chart-initialized', 'true')); + return true; + } catch (e) { + console.error('renderChartElement error', e, el); + return false; + } +} + +function ensureChartObserver() { + if (__chartInitState.observer) return __chartInitState.observer; + __chartInitState.observer = new IntersectionObserver((entries) => { + entries.forEach((entry) => { + const el = entry.target; + if (!entry.isIntersecting) return; + if (__chartInitState.seen.has(el) || el.hasAttribute('data-chart-initialized')) { + __chartInitState.observer.unobserve(el); + return; + } + const script = el.querySelector('script[type="application/json"]'); + if (!script) return; try { const config = JSON.parse(script.textContent); - - // Add percentage formatting to score axis (second y-axis) if (config.yaxis && config.yaxis[1]) { config.yaxis[1].labels = config.yaxis[1].labels || {}; - config.yaxis[1].labels.formatter = function(val) { - return Math.round(val * 100) + '%'; - }; + config.yaxis[1].labels.formatter = function(val) { return Math.round(val * 100) + '%'; }; } - - // Ensure chart has dimensions - element.style.minHeight = '300px'; - element.style.width = '100%'; - element.style.display = 'block'; - - const chart = new ApexCharts(element, config); - chart.render().then(() => { - element.setAttribute('data-chart-initialized', 'true'); + const isSpark = !!(config.chart && config.chart.sparkline && config.chart.sparkline.enabled); + el.style.display = 'block'; + el.style.width = '100%'; + el.style.minHeight = isSpark ? '32px' : '300px'; + el.style.height = isSpark ? '32px' : 'auto'; + // Explicit width + const width = Math.max(220, Math.floor(el.getBoundingClientRect().width || el.clientWidth || 0)); + config.chart = config.chart || {}; + config.chart.width = width; + config.chart.height = isSpark ? 32 : (config.chart.height || 300); + requestAnimationFrame(() => { + const chart = new ApexCharts(el, config); + chart.render().then(() => { + el.setAttribute('data-chart-initialized', 'true'); + __chartInitState.seen.add(el); + __chartInitState.observer.unobserve(el); + }); }); } catch (e) { - console.error('Error initializing chart:', e, element); + console.error('Error initializing chart:', e, el); } + }); + }, { root: null, rootMargin: '200px 0px', threshold: 0.01 }); + return __chartInitState.observer; +} + +function initializeCharts() { + // Prefer immediate render inside anomaly list to avoid races + const anomalyContainer = document.getElementById('anomaly-list'); + if (anomalyContainer) { + const charts = Array.from(anomalyContainer.querySelectorAll('uk-chart:not([data-chart-initialized])')); + let rendered = 0; + charts.forEach((el) => { if (renderChartElement(el)) rendered++; }); + if (rendered < charts.length) { + // Retry after a short delay (e.g. if ApexCharts not ready yet) + setTimeout(() => { + charts.forEach((el) => { if (!el.hasAttribute('data-chart-initialized')) renderChartElement(el); }); + }, 300); } - }); + return; // Skip observer path for anomalies page + } + + // Fallback: use lazy observer for other pages + const observer = ensureChartObserver(); + document.querySelectorAll('uk-chart:not([data-chart-initialized])').forEach((el) => observer.observe(el)); } // Wait for both DOM and ApexCharts to be ready @@ -123,6 +208,27 @@ def load_env_with_custom_path(): // Re-initialize charts after HTMX requests document.addEventListener('htmx:afterSwap', initializeCharts); document.addEventListener('htmx:afterSettle', initializeCharts); +// Debug: log counts and row metadata after HTMX updates +document.addEventListener('htmx:afterSettle', function() { + try { + const container = document.getElementById('anomaly-list'); + if (!container) return; + const rows = Array.from(container.querySelectorAll('[data-row]')); + const charts = Array.from(container.querySelectorAll('uk-chart')); + const visibleCharts = charts.filter(c => c.offsetParent !== null); + console.log('[anomstack] rows:', rows.length, 'charts:', charts.length, 'visibleCharts:', visibleCharts.length); + rows.slice(0, 5).forEach(r => { + const ds = r.dataset || {}; + console.log('[row]', ds.row, ds.metric, ds.ts); + }); + const initialized = container.querySelectorAll('uk-chart[data-chart-initialized]'); + console.log('[anomstack] initialized charts:', initialized.length); + // Force initialize any visible charts not initialized yet (safety net) + if (visibleCharts.length && initialized.length < visibleCharts.length) { + visibleCharts.forEach((el) => { if (!el.hasAttribute('data-chart-initialized')) renderChartElement(el); }); + } + } catch (e) { console.warn('debug error', e); } +}); """), Script(POSTHOG_SCRIPT) if posthog_api_key else None, Link( diff --git a/dashboard/charts.py b/dashboard/charts.py index d24ec9c..0a5101b 100644 --- a/dashboard/charts.py +++ b/dashboard/charts.py @@ -278,7 +278,7 @@ def create_chart_placeholder(metric_name, index, batch_name) -> Card: @staticmethod def create_sparkline(df_metric: pd.DataFrame, anomaly_timestamp: pd.Timestamp = None, dark_mode=False): - """Create a sparkline chart for a metric. + """Create an interactive ApexChart sparkline for a metric. Args: df_metric (pd.DataFrame): The metric data. @@ -290,13 +290,14 @@ def create_sparkline(df_metric: pd.DataFrame, anomaly_timestamp: pd.Timestamp = """ colors = ChartStyle.get_colors(dark_mode) - # Convert timestamps to milliseconds + # Convert timestamps to milliseconds for ApexCharts timestamps = [int(pd.to_datetime(ts).timestamp() * 1000) for ts in df_metric["metric_timestamp"]] - # Prepare data (ensure native Python types for JSON serialization) + # Prepare main metric data (ensure native Python types for JSON serialization) value_data = [[int(ts), float(val)] for ts, val in zip(timestamps, df_metric["metric_value"])] score_data = [[int(ts), float(val)] for ts, val in zip(timestamps, df_metric["metric_score"])] + # Build series array series = [ { "name": "Value", @@ -306,89 +307,117 @@ def create_sparkline(df_metric: pd.DataFrame, anomaly_timestamp: pd.Timestamp = "color": colors["primary"] }, { - "name": "Score", - "type": "line", + "name": "Score", + "type": "line", "data": score_data, "yAxisIndex": 1, "color": colors["secondary"] } ] - # Add specific anomaly marker if provided + # Add alert markers if they exist (on value axis) + alert_data = df_metric[df_metric["metric_alert"] == 1] + if not alert_data.empty: + alert_timestamps = [int(pd.to_datetime(ts).timestamp() * 1000) for ts in alert_data["metric_timestamp"]] + alert_values = [[int(ts), float(val)] for ts, val in zip(alert_timestamps, alert_data["metric_value"])] + series.append({ + "name": "Alert", + "type": "scatter", + "data": alert_values, + "yAxisIndex": 0, # Value axis + "color": colors["alert"] + }) + + # Add LLM alert markers if they exist (on value axis) + llm_alert_data = df_metric[df_metric["metric_llmalert"] == 1] + if not llm_alert_data.empty: + llm_timestamps = [int(pd.to_datetime(ts).timestamp() * 1000) for ts in llm_alert_data["metric_timestamp"]] + llm_values = [[int(ts), float(val)] for ts, val in zip(llm_timestamps, llm_alert_data["metric_value"])] + series.append({ + "name": "LLM Alert", + "type": "scatter", + "data": llm_values, + "yAxisIndex": 0, # Value axis + "color": colors["llmalert"] + }) + + # Add specific anomaly marker for this particular timestamp if provided if anomaly_timestamp is not None: anomaly_point = df_metric[df_metric["metric_timestamp"] == anomaly_timestamp] if not anomaly_point.empty: - alert_color = ( - colors["llmalert"] - if anomaly_point["metric_llmalert"].iloc[0] == 1 + anomaly_ts = int(pd.to_datetime(anomaly_timestamp).timestamp() * 1000) + anomaly_value = float(anomaly_point["metric_value"].iloc[0]) + + # Choose color based on alert type for this specific anomaly + specific_color = ( + colors["llmalert"] + if anomaly_point["metric_llmalert"].iloc[0] == 1 else colors["alert"] ) - anomaly_ts = int(pd.to_datetime(anomaly_timestamp).timestamp() * 1000) + series.append({ - "name": "Alert", + "name": "This Anomaly", "type": "scatter", - "data": [[int(anomaly_ts), float(anomaly_point["metric_value"].iloc[0])]], + "data": [[anomaly_ts, anomaly_value]], "yAxisIndex": 0, - "color": alert_color + "color": specific_color }) + # Sparkline specific configuration chart_opts = { "chart": { "type": "line", - "height": 50, - "width": 200, - "sparkline": { - "enabled": True - }, - "toolbar": { - "show": False - }, - "animations": { - "enabled": False - } + "height": 32, + "toolbar": {"show": False}, + "animations": {"enabled": False}, + "sparkline": {"enabled": True} }, "series": series, - "stroke": { - "width": [1, 1, 0], - "dashArray": [0, 3, 0] - }, - "markers": { - "size": [0, 0, 6] + "xaxis": { + "type": "datetime", + "labels": {"show": False}, + "axisBorder": {"show": False}, + "axisTicks": {"show": False} }, "yaxis": [ - { - "show": False - }, - { - "show": False, - "min": 0, - "max": 1.05 - } + {"show": False}, + {"show": False, "opposite": True, "min": 0, "max": 1} ], - "xaxis": { - "type": "datetime", - "labels": { - "show": False - }, - "axisBorder": { - "show": False - }, - "axisTicks": { - "show": False - } + "stroke": { + "width": [2, 1, 0, 0, 0], # Line widths for each series + "dashArray": [0, 5, 0, 0, 0] # Score line dashed }, - "grid": { - "show": False + "markers": { + "size": [0, 0, 6, 6, 10], # Larger markers for better visibility + "colors": [colors["primary"], colors["secondary"], colors["alert"], colors["llmalert"], "inherit"], + "strokeWidth": [0, 0, 2, 2, 3], # Thicker stroke for better visibility + "strokeColors": ["#ffffff", "#ffffff", "#ffffff", "#ffffff", "#ffffff"] }, + "grid": {"show": False}, "tooltip": { - "enabled": False + "theme": "dark" if dark_mode else "light", + "enabled": True, + "x": {"format": "dd MMM yyyy HH:mm"}, + "y": { + "formatter": "function(val, opts) { return opts.seriesIndex === 1 ? (val * 100).toFixed(1) + '%' : val.toFixed(2); }" + } }, - "legend": { - "show": False - } + "legend": {"show": False}, + "colors": [colors["primary"], colors["secondary"], colors["alert"], colors["llmalert"], "inherit"] } - return ApexChart(opts=chart_opts) + # Generate unique ID for sparkline using metric name + timestamp to avoid collisions + try: + metric_name = str(df_metric["metric_name"].iloc[0]) + except Exception: + metric_name = "metric" + safe_metric = metric_name.replace(":", "-").replace(" ", "-").replace(".", "-").replace("/", "-") + safe_ts = ( + str(anomaly_timestamp).replace(":", "-").replace(" ", "-").replace(".", "-") + if anomaly_timestamp is not None else "default" + ) + sparkline_id = f"sparkline-{safe_metric}-{safe_ts}" + return ApexChart(opts=chart_opts, id=sparkline_id) @staticmethod def create_expanded_sparkline(df_metric: pd.DataFrame, anomaly_timestamp: pd.Timestamp = None, dark_mode=False): diff --git a/dashboard/routes/batch.py b/dashboard/routes/batch.py index 58116cc..33fefae 100644 --- a/dashboard/routes/batch.py +++ b/dashboard/routes/batch.py @@ -334,7 +334,7 @@ def refresh_batch(batch_name: str): @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, page: int = 1, per_page: int = 20): """Get the anomaly list view for a batch. Args: @@ -375,14 +375,16 @@ def get_anomaly_list(batch_name: str, page: int = 1, per_page: int = 50): # Create table rows and modals rows = [] modals = [] - for _, row in df_page.iterrows(): + for row_idx, (_, row) in enumerate(df_page.iterrows(), start=1): metric_name = row["metric_name"] timestamp = row["metric_timestamp"] # Get the metric data for this anomaly df_metric = df[df["metric_name"] == metric_name].copy() df_metric = df_metric.sort_values("metric_timestamp") + log.info(f"Creating sparkline for {metric_name} with {len(df_metric)} data points") fig = ChartManager.create_sparkline(df_metric, anomaly_timestamp=timestamp, dark_mode=app.state.dark_mode) + log.info(f"Sparkline created: {type(fig)}") # Create safe feedback key by replacing problematic characters safe_metric = ( @@ -424,68 +426,74 @@ 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}" - rows.append( - Tr( - Td( - Div( - metric_name, - cls="truncate max-w-[120px] sm:max-w-[180px] mx-auto", - uk_tooltip=metric_name, - ), - 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(to_xml(fig, indent=False)), - Style( - "svg { display: block; margin: auto; height: 100% !important; width: 100% !important; }" - ), - cls="absolute inset-0 flex justify-center items-center h-full w-full p-0 m-0", + # Build cells directly so they can be placed into a single parent grid + row_cells = [ + # Metric name + Div( + metric_name, + cls="text-xs font-medium text-center px-1 overflow-hidden h-10 flex items-center justify-center border-b", + uk_tooltip=metric_name, + style="white-space: nowrap; text-overflow: ellipsis;", + ), + # Timestamp (hidden on mobile) + Div( + timestamp.strftime("%Y-%m-%d %H:%M:%S"), + cls="text-xs text-muted-foreground text-center h-10 items-center justify-center border-b invisible md:visible md:flex", + ), + # Sparkline + Div( + fig, + cls="sparkline-cell relative flex justify-center items-center h-10 border-b overflow-hidden", + ), + # Feedback buttons + Div( + DivLAligned( + Button( + UkIcon("thumbs-up", cls="w-3 h-3"), + 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) + + " px-2 py-1 text-xs", + id=f"feedback-{feedback_key}-positive", ), - 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", - ), - 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", - ), - cls="space-x-1 sm:space-x-2 justify-center", - id=f"feedback-{feedback_key}", + Button( + UkIcon("thumbs-down", cls="w-3 h-3"), + 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) + + " px-2 py-1 text-xs", + id=f"feedback-{feedback_key}-negative", ), - cls="w-[120px] text-center", + cls="space-x-1 justify-center", + id=f"feedback-{feedback_key}", ), - Td( + cls="h-10 flex items-center justify-center border-b", + ), + # Expand button + Div( 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", + UkIcon("expand", height=12, width=12), + cls="bg-white/90 border border-gray-300 rounded-md p-1 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="w-[60px] text-center align-middle", - style="vertical-align: middle; padding: 8px;" + hx_get=f"/batch/{batch_name}/anomaly/{metric_name}/{timestamp}/expanded", + hx_target=f"#expanded-anomaly-chart-{feedback_key}", + hx_swap="innerHTML", + title="Expand chart", + type="button", + style="width: 24px; height: 24px; display: flex; align-items: center; justify-content: center;" ), - cls="hover:bg-muted/50 transition-colors", + cls="h-10 flex items-center justify-center border-b", + ), + ] + + # Wrap the 5 cells into a single grid row to avoid layout drift + rows.append( + Div( + *row_cells, + cls="grid grid-cols-[1fr_1fr_2fr_1fr_1fr] gap-2 px-2 items-center justify-items-center place-items-center", + **{"data-metric": metric_name, "data-ts": timestamp.strftime("%Y-%m-%d %H:%M:%S"), "data-row": str(row_idx)}, ) ) @@ -531,11 +539,9 @@ def get_anomaly_list(batch_name: str, page: int = 1, per_page: int = 50): style="position: relative; padding: 20px 160px 20px 20px;" ), Div( - # Expanded chart will be loaded here + # Expanded chart will be loaded on demand when clicking expand Div( id=f"expanded-anomaly-chart-{feedback_key}", - hx_get=f"/batch/{batch_name}/anomaly/{metric_name}/{timestamp}/expanded", - hx_trigger="load", cls="min-h-96" ), # Anomaly information @@ -562,7 +568,7 @@ def get_anomaly_list(batch_name: str, page: int = 1, per_page: int = 50): ), id=anomaly_modal_id, cls="uk-modal-full" - ) + ) ) log.info(f"Created {len(rows)} table rows and {len(modals)} modals for page {page}") @@ -596,34 +602,27 @@ def get_anomaly_list(batch_name: str, page: int = 1, per_page: int = 50): 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", - ), + Div( + Div("Metric", cls="font-medium text-center text-xs uppercase tracking-wide text-muted-foreground"), + Div("Timestamp", cls="font-medium text-center text-xs uppercase tracking-wide text-muted-foreground"), + Div("Trend", cls="font-medium text-center text-xs uppercase tracking-wide text-muted-foreground"), + Div("Feedback", cls="font-medium text-center text-xs uppercase tracking-wide text-muted-foreground"), + Div("Expand", cls="font-medium text-center text-xs uppercase tracking-wide text-muted-foreground"), + cls="grid grid-cols-[1fr_1fr_2fr_1fr_1fr] gap-3 px-3 py-2 border-b bg-gray-50 sticky top-0 z-10", + ), + Div( *rows, - cls="w-full divide-y min-w-full table-fixed", + cls="divide-y", ), cls="overflow-x-auto -mx-4 sm:mx-0", ), header=Div( H4("Anomalies", cls="mb-1"), - P( - f"Showing {len(rows)} of {total_anomalies} anomalies", - cls="text-sm text-muted-foreground", - ), + P(f"Showing {len(rows)} of {total_anomalies} anomalies", cls="text-sm text-muted-foreground"), ), cls="mb-4", ), pagination, - # Add all the modals *modals, id="anomaly-list", )