Skip to content

Commit cd0f5a1

Browse files
committed
feat: enhance dashboard UX with improved anomaly list interactions
Major improvements to dashboard user experience: 🎨 Dashboard Layout & UI: - Simplify dashboard-local-dev command to use comprehensive data by default - Fix scrollbar persistence issue across chart loading - Add persistent scrollbar to prevent layout shifts during dynamic content loading 📊 Anomaly List Enhancements: - Move expand buttons from sparkline overlay to dedicated 'Expand' column - Add proper table headers and alignment for all columns - Improve expand button styling and positioning 🖼️ Expanded Modal Improvements: - Add thumbs up/down feedback buttons to modal headers - Implement dual feedback updates (modal + table row) via HTMX out-of-band swaps - Fix modal close button (X) visibility and positioning - Ensure feedback given in modal reflects in anomaly list view 🎯 Chart & Sparkline Features: - Create focused single anomaly expanded charts - Maintain consistent color coding between sparklines and expanded views - Add proper modal scrolling and overflow handling - Implement single anomaly detail view with context preservation 🔧 Technical Improvements: - Add Request import for HTMX header detection - Implement smart modal vs table button styling - Add proper error handling for undefined feedback states - Improve button alignment and responsive design The dashboard now provides a seamless experience for reviewing and rating anomalies with consistent feedback across list and expanded views.
1 parent e6e8ec1 commit cd0f5a1

File tree

4 files changed

+706
-77
lines changed

4 files changed

+706
-77
lines changed

Makefile

Lines changed: 1 addition & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -355,31 +355,16 @@ seed-local-db-custom:
355355
@echo " Default DB_PATH: tmpdata/anomstack-custom.db"
356356
source venv/bin/activate && python scripts/development/seed_local_db.py --metric-batches "$(or $(BATCHES),python_ingest_simple)" --db-path "$(or $(DB_PATH),tmpdata/anomstack-custom.db)" --hours 72 --interval 10 --force
357357

358-
# start dashboard in local development mode with seeded database
358+
# start dashboard in local development mode with seeded database (all batches)
359359
dashboard-local-dev:
360360
@echo "🚀 Starting dashboard in local development mode..."
361-
@echo "📊 Using local development environment with seeded dummy data"
362-
@if [ ! -f "tmpdata/anomstack-local-dev.db" ]; then \
363-
echo "⚠️ Local dev database not found. Creating it first..."; \
364-
$(MAKE) seed-local-db; \
365-
fi
366-
source venv/bin/activate && ANOMSTACK_ENV_FILE_PATH=profiles/local-dev.env uvicorn dashboard.app:app --host 0.0.0.0 --port 5003 --reload
367-
368-
dashboard-local-dev-all:
369-
@echo "🚀 Starting dashboard in local development mode (ALL batches)..."
370361
@echo "📊 Using comprehensive database with python_ingest_simple, netdata, posthog, yfinance, AND currency metrics"
371362
@if [ ! -f "tmpdata/anomstack-all.db" ]; then \
372363
echo "⚠️ All-batches database not found. Creating it first..."; \
373364
$(MAKE) seed-local-db-all; \
374365
fi
375366
source venv/bin/activate && ANOMSTACK_ENV_FILE_PATH=profiles/local-dev-all.env uvicorn dashboard.app:app --host 0.0.0.0 --port 5003 --reload
376367

377-
dashboard-local-dev-custom:
378-
@echo "🚀 Starting dashboard in local development mode (custom database)..."
379-
@echo "💡 Use: make dashboard-local-dev-custom DB_PATH='tmpdata/my.db'"
380-
@echo " Default DB_PATH: tmpdata/anomstack-custom.db"
381-
source venv/bin/activate && ANOMSTACK_DUCKDB_PATH="$(or $(DB_PATH),tmpdata/anomstack-custom.db)" uvicorn dashboard.app:app --host 0.0.0.0 --port 5003 --reload
382-
383368
# kill any running dashboard process
384369
kill-dashboardd:
385370
kill $(shell ps aux | grep dashboard/app.py | grep -v grep | awk '{print $$2}') $(shell lsof -ti :5000)

dashboard/charts.py

Lines changed: 326 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -255,6 +255,332 @@ def create_sparkline(df_metric: pd.DataFrame, anomaly_timestamp: pd.Timestamp =
255255
config=dict(displayModeBar=False),
256256
)
257257

258+
@staticmethod
259+
def create_expanded_sparkline(df_metric: pd.DataFrame, anomaly_timestamp: pd.Timestamp = None) -> str:
260+
"""Create an expanded sparkline chart for a specific anomaly.
261+
262+
Args:
263+
df_metric (pd.DataFrame): The metric data.
264+
anomaly_timestamp (pd.Timestamp): The specific timestamp of the anomaly to highlight.
265+
266+
Returns:
267+
str: The HTML for the expanded sparkline.
268+
"""
269+
colors = ChartStyle.get_colors(app.state.dark_mode)
270+
fig = make_subplots(specs=[[{"secondary_y": True}]])
271+
272+
# Add the main line
273+
fig.add_trace(
274+
go.Scatter(
275+
x=df_metric["metric_timestamp"],
276+
y=df_metric["metric_value"],
277+
name="Value",
278+
mode="lines+markers",
279+
line=dict(
280+
color=colors["primary"],
281+
width=3,
282+
),
283+
marker=dict(
284+
size=4,
285+
color=colors["primary"],
286+
),
287+
showlegend=True,
288+
connectgaps=True,
289+
),
290+
secondary_y=False,
291+
)
292+
293+
# Add the score line
294+
fig.add_trace(
295+
go.Scatter(
296+
x=df_metric["metric_timestamp"],
297+
y=df_metric["metric_score"],
298+
name="Score",
299+
mode="lines",
300+
line=dict(
301+
color=colors["secondary"],
302+
width=2,
303+
dash="dot",
304+
),
305+
showlegend=True,
306+
connectgaps=True,
307+
),
308+
secondary_y=True,
309+
)
310+
311+
# Add marker for the specific anomaly point if provided
312+
if anomaly_timestamp is not None:
313+
# Find the data point closest to the anomaly timestamp
314+
anomaly_row = df_metric[df_metric["metric_timestamp"] == anomaly_timestamp]
315+
if not anomaly_row.empty:
316+
fig.add_trace(
317+
go.Scatter(
318+
x=[anomaly_timestamp],
319+
y=[anomaly_row["metric_value"].iloc[0]],
320+
name="Anomaly",
321+
mode="markers",
322+
marker=dict(
323+
size=12,
324+
color="red",
325+
symbol="circle",
326+
line=dict(width=2, color="white"),
327+
),
328+
showlegend=True,
329+
),
330+
secondary_y=False,
331+
)
332+
333+
# Add alert markers for all anomalies
334+
alert_data = df_metric[df_metric["metric_alert"] == 1]
335+
if not alert_data.empty:
336+
fig.add_trace(
337+
go.Scatter(
338+
x=alert_data["metric_timestamp"],
339+
y=alert_data["metric_value"],
340+
name="Alert",
341+
mode="markers",
342+
marker=dict(
343+
size=8,
344+
color="orange",
345+
symbol="diamond",
346+
),
347+
showlegend=True,
348+
),
349+
secondary_y=False,
350+
)
351+
352+
# Add LLM alert markers
353+
llm_alert_data = df_metric[df_metric["metric_llmalert"] == 1]
354+
if not llm_alert_data.empty:
355+
fig.add_trace(
356+
go.Scatter(
357+
x=llm_alert_data["metric_timestamp"],
358+
y=llm_alert_data["metric_value"],
359+
name="LLM Alert",
360+
mode="markers",
361+
marker=dict(
362+
size=8,
363+
color="purple",
364+
symbol="star",
365+
),
366+
showlegend=True,
367+
),
368+
secondary_y=False,
369+
)
370+
371+
fig.update_layout(
372+
height=500,
373+
margin=dict(l=60, r=60, t=60, b=60),
374+
xaxis=dict(
375+
showgrid=True,
376+
showticklabels=True,
377+
title="Time",
378+
),
379+
yaxis=dict(
380+
showgrid=True,
381+
showticklabels=True,
382+
title="Value",
383+
),
384+
yaxis2=dict(
385+
showgrid=False,
386+
showticklabels=True,
387+
title="Score",
388+
range=[0, 1.05],
389+
side="right",
390+
),
391+
paper_bgcolor=colors["background"],
392+
plot_bgcolor=colors["background"],
393+
font=dict(color=colors["text"]),
394+
legend=dict(
395+
orientation="h",
396+
yanchor="bottom",
397+
y=1.02,
398+
xanchor="right",
399+
x=1
400+
),
401+
title=dict(
402+
text="Anomaly Detail View",
403+
x=0.5,
404+
font=dict(size=16)
405+
)
406+
)
407+
408+
# Enhanced config for expanded view
409+
enhanced_config = {
410+
"displayModeBar": True,
411+
"modeBarButtonsToRemove": [
412+
"select2d",
413+
"lasso2d",
414+
],
415+
"responsive": True,
416+
"scrollZoom": True,
417+
"staticPlot": False,
418+
"fillFrame": True,
419+
"displaylogo": False,
420+
"toImageButtonOptions": {
421+
"format": "png",
422+
"filename": "anomaly_chart",
423+
"height": 500,
424+
"scale": 1
425+
}
426+
}
427+
428+
return fig.to_html(
429+
full_html=False,
430+
include_plotlyjs=False,
431+
config=enhanced_config,
432+
)
433+
434+
@staticmethod
435+
def create_single_anomaly_expanded_chart(df_metric: pd.DataFrame, anomaly_timestamp: pd.Timestamp = None) -> str:
436+
"""Create an expanded chart showing full time series but highlighting only one specific anomaly.
437+
438+
Args:
439+
df_metric (pd.DataFrame): The metric data.
440+
anomaly_timestamp (pd.Timestamp): The specific timestamp of the anomaly to highlight.
441+
442+
Returns:
443+
str: The HTML for the expanded chart.
444+
"""
445+
colors = ChartStyle.get_colors(app.state.dark_mode)
446+
fig = make_subplots(specs=[[{"secondary_y": True}]])
447+
448+
# Add the main line (full time series)
449+
fig.add_trace(
450+
go.Scatter(
451+
x=df_metric["metric_timestamp"],
452+
y=df_metric["metric_value"],
453+
name="Value",
454+
mode="lines+markers",
455+
line=dict(
456+
color=colors["primary"],
457+
width=3,
458+
),
459+
marker=dict(
460+
size=4,
461+
color=colors["primary"],
462+
),
463+
showlegend=True,
464+
connectgaps=True,
465+
),
466+
secondary_y=False,
467+
)
468+
469+
# Add the score line (full time series)
470+
fig.add_trace(
471+
go.Scatter(
472+
x=df_metric["metric_timestamp"],
473+
y=df_metric["metric_score"],
474+
name="Score",
475+
mode="lines",
476+
line=dict(
477+
color=colors["secondary"],
478+
width=2,
479+
dash="dot",
480+
),
481+
showlegend=True,
482+
connectgaps=True,
483+
),
484+
secondary_y=True,
485+
)
486+
487+
# Add marker for ONLY the specific anomaly point clicked
488+
if anomaly_timestamp is not None:
489+
# Find the data point closest to the anomaly timestamp
490+
anomaly_row = df_metric[df_metric["metric_timestamp"] == anomaly_timestamp]
491+
if not anomaly_row.empty:
492+
# Use the EXACT same color logic as the sparkline (lines 195-199 in create_sparkline)
493+
alert_color = (
494+
colors["llmalert"]
495+
if anomaly_row["metric_llmalert"].iloc[0] == 1
496+
else colors["alert"]
497+
)
498+
499+
# Use same symbol as sparkline (diamond)
500+
marker_color = alert_color
501+
marker_symbol = "diamond"
502+
alert_name = "Selected Alert"
503+
504+
fig.add_trace(
505+
go.Scatter(
506+
x=[anomaly_timestamp],
507+
y=[anomaly_row["metric_value"].iloc[0]],
508+
name=alert_name,
509+
mode="markers",
510+
marker=dict(
511+
size=15,
512+
color=marker_color,
513+
symbol=marker_symbol,
514+
line=dict(width=3, color="white"),
515+
),
516+
showlegend=True,
517+
),
518+
secondary_y=False,
519+
)
520+
521+
fig.update_layout(
522+
height=500,
523+
margin=dict(l=60, r=60, t=60, b=60),
524+
xaxis=dict(
525+
showgrid=True,
526+
showticklabels=True,
527+
title="Time",
528+
),
529+
yaxis=dict(
530+
showgrid=True,
531+
showticklabels=True,
532+
title="Value",
533+
),
534+
yaxis2=dict(
535+
showgrid=False,
536+
showticklabels=True,
537+
title="Score",
538+
range=[0, 1.05],
539+
side="right",
540+
),
541+
paper_bgcolor=colors["background"],
542+
plot_bgcolor=colors["background"],
543+
font=dict(color=colors["text"]),
544+
legend=dict(
545+
orientation="h",
546+
yanchor="bottom",
547+
y=1.02,
548+
xanchor="right",
549+
x=1
550+
),
551+
title=dict(
552+
text="Single Anomaly Detail View",
553+
x=0.5,
554+
font=dict(size=16)
555+
)
556+
)
557+
558+
# Enhanced config for expanded view
559+
enhanced_config = {
560+
"displayModeBar": True,
561+
"modeBarButtonsToRemove": [
562+
"select2d",
563+
"lasso2d",
564+
],
565+
"responsive": True,
566+
"scrollZoom": True,
567+
"staticPlot": False,
568+
"fillFrame": True,
569+
"displaylogo": False,
570+
"toImageButtonOptions": {
571+
"format": "png",
572+
"filename": "single_anomaly_chart",
573+
"height": 500,
574+
"scale": 1
575+
}
576+
}
577+
578+
return fig.to_html(
579+
full_html=False,
580+
include_plotlyjs=False,
581+
config=enhanced_config,
582+
)
583+
258584

259585
class ChartStyle:
260586
"""Handle chart styling and theme-related configurations."""

0 commit comments

Comments
 (0)