diff --git a/dashboard/app.py b/dashboard/app.py
index 493351c..ba711e8 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,180 @@ 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;
+}
+/* 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("""
+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);
+ 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';
+ // 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, 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
+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);
+// 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(
rel="icon",
diff --git a/dashboard/charts.py b/dashboard/charts.py
index ef8af10..0a5101b 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,248 @@ def create_chart_placeholder(metric_name, index, batch_name) -> Card:
)
@staticmethod
- def create_sparkline(df_metric: pd.DataFrame, anomaly_timestamp: pd.Timestamp = None) -> str:
- """Create a sparkline chart for a metric.
+ def create_sparkline(df_metric: pd.DataFrame, anomaly_timestamp: pd.Timestamp = None, dark_mode=False):
+ """Create an interactive ApexChart sparkline 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 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"]
+ },
+ {
+ "name": "Score",
+ "type": "line",
+ "data": score_data,
+ "yAxisIndex": 1,
+ "color": colors["secondary"]
+ }
+ ]
+
+ # 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:
- # Determine alert type from the specific anomaly point (not the last row)
- 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"]
)
- 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),
+
+ series.append({
+ "name": "This Anomaly",
+ "type": "scatter",
+ "data": [[anomaly_ts, anomaly_value]],
+ "yAxisIndex": 0,
+ "color": specific_color
+ })
+
+ # Sparkline specific configuration
+ chart_opts = {
+ "chart": {
+ "type": "line",
+ "height": 32,
+ "toolbar": {"show": False},
+ "animations": {"enabled": False},
+ "sparkline": {"enabled": True}
+ },
+ "series": series,
+ "xaxis": {
+ "type": "datetime",
+ "labels": {"show": False},
+ "axisBorder": {"show": False},
+ "axisTicks": {"show": False}
+ },
+ "yaxis": [
+ {"show": False},
+ {"show": False, "opposite": True, "min": 0, "max": 1}
+ ],
+ "stroke": {
+ "width": [2, 1, 0, 0, 0], # Line widths for each series
+ "dashArray": [0, 5, 0, 0, 0] # Score line dashed
+ },
+ "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": {
+ "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},
+ "colors": [colors["primary"], colors["secondary"], colors["alert"], colors["llmalert"], "inherit"]
+ }
+
+ # 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) -> 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 +553,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..33fefae 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"
)
@@ -319,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:
@@ -360,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")
- fig = ChartManager.create_sparkline(df_metric, anomaly_timestamp=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 = (
@@ -409,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(fig),
- 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)},
)
)
@@ -516,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
@@ -547,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}")
@@ -581,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",
)
@@ -935,9 +949,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"
)
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)