Skip to content

Commit 55f3939

Browse files
committed
dev
1 parent 3404fc8 commit 55f3939

File tree

3 files changed

+292
-158
lines changed

3 files changed

+292
-158
lines changed

dashboard/app.py

Lines changed: 128 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -67,40 +67,125 @@ def load_env_with_custom_path():
6767
uk-chart .apexcharts-canvas {
6868
width: 100% !important;
6969
height: 100% !important;
70+
}
71+
/* Sparklines should fill their grid cell for consistent alignment */
72+
/* We now size sparkline charts via JS by detecting chart.sparkline.enabled */
73+
.sparkline-cell uk-chart[id^="sparkline-"] {
74+
min-height: 32px !important;
75+
height: 32px !important;
76+
width: 260px !important;
77+
}
78+
.sparkline-cell uk-chart[id^="sparkline-"] .apexcharts-canvas {
79+
width: 100% !important;
80+
height: 100% !important;
81+
}
82+
.sparkline-container {
83+
display: flex;
84+
justify-content: center;
85+
align-items: center;
86+
width: 100%;
87+
height: 100%;
7088
}
7189
"""),
7290
Script("""
73-
function initializeCharts() {
74-
const elements = document.querySelectorAll('uk-chart:not([data-chart-initialized])');
75-
76-
elements.forEach(function(element) {
77-
const script = element.querySelector('script[type="application/json"]');
78-
if (script) {
91+
const __chartInitState = {
92+
observer: null,
93+
seen: new WeakSet(),
94+
};
95+
96+
function renderChartElement(el) {
97+
const script = el.querySelector('script[type="application/json"]');
98+
if (!script) return false;
99+
if (el.hasAttribute('data-chart-initialized')) return true;
100+
try {
101+
const config = JSON.parse(script.textContent);
102+
if (config.yaxis && config.yaxis[1]) {
103+
config.yaxis[1].labels = config.yaxis[1].labels || {};
104+
config.yaxis[1].labels.formatter = function(val) { return Math.round(val * 100) + '%'; };
105+
}
106+
const isSpark = !!(config.chart && config.chart.sparkline && config.chart.sparkline.enabled);
107+
el.style.display = 'block';
108+
el.style.width = '100%';
109+
el.style.minHeight = isSpark ? '32px' : '300px';
110+
el.style.height = isSpark ? '32px' : 'auto';
111+
// Ensure ApexCharts receives an explicit width to avoid 0-width renders
112+
const width = Math.max(220, Math.floor(el.getBoundingClientRect().width || el.clientWidth || 0));
113+
config.chart = config.chart || {};
114+
config.chart.width = width;
115+
config.chart.height = isSpark ? 32 : (config.chart.height || 300);
116+
if (typeof ApexCharts === 'undefined') return false;
117+
const chart = new ApexCharts(el, config);
118+
chart.render().then(() => el.setAttribute('data-chart-initialized', 'true'));
119+
return true;
120+
} catch (e) {
121+
console.error('renderChartElement error', e, el);
122+
return false;
123+
}
124+
}
125+
126+
function ensureChartObserver() {
127+
if (__chartInitState.observer) return __chartInitState.observer;
128+
__chartInitState.observer = new IntersectionObserver((entries) => {
129+
entries.forEach((entry) => {
130+
const el = entry.target;
131+
if (!entry.isIntersecting) return;
132+
if (__chartInitState.seen.has(el) || el.hasAttribute('data-chart-initialized')) {
133+
__chartInitState.observer.unobserve(el);
134+
return;
135+
}
136+
const script = el.querySelector('script[type="application/json"]');
137+
if (!script) return;
79138
try {
80139
const config = JSON.parse(script.textContent);
81-
82-
// Add percentage formatting to score axis (second y-axis)
83140
if (config.yaxis && config.yaxis[1]) {
84141
config.yaxis[1].labels = config.yaxis[1].labels || {};
85-
config.yaxis[1].labels.formatter = function(val) {
86-
return Math.round(val * 100) + '%';
87-
};
142+
config.yaxis[1].labels.formatter = function(val) { return Math.round(val * 100) + '%'; };
88143
}
89-
90-
// Ensure chart has dimensions
91-
element.style.minHeight = '300px';
92-
element.style.width = '100%';
93-
element.style.display = 'block';
94-
95-
const chart = new ApexCharts(element, config);
96-
chart.render().then(() => {
97-
element.setAttribute('data-chart-initialized', 'true');
144+
const isSpark = !!(config.chart && config.chart.sparkline && config.chart.sparkline.enabled);
145+
el.style.display = 'block';
146+
el.style.width = '100%';
147+
el.style.minHeight = isSpark ? '32px' : '300px';
148+
el.style.height = isSpark ? '32px' : 'auto';
149+
// Explicit width
150+
const width = Math.max(220, Math.floor(el.getBoundingClientRect().width || el.clientWidth || 0));
151+
config.chart = config.chart || {};
152+
config.chart.width = width;
153+
config.chart.height = isSpark ? 32 : (config.chart.height || 300);
154+
requestAnimationFrame(() => {
155+
const chart = new ApexCharts(el, config);
156+
chart.render().then(() => {
157+
el.setAttribute('data-chart-initialized', 'true');
158+
__chartInitState.seen.add(el);
159+
__chartInitState.observer.unobserve(el);
160+
});
98161
});
99162
} catch (e) {
100-
console.error('Error initializing chart:', e, element);
163+
console.error('Error initializing chart:', e, el);
101164
}
165+
});
166+
}, { root: null, rootMargin: '200px 0px', threshold: 0.01 });
167+
return __chartInitState.observer;
168+
}
169+
170+
function initializeCharts() {
171+
// Prefer immediate render inside anomaly list to avoid races
172+
const anomalyContainer = document.getElementById('anomaly-list');
173+
if (anomalyContainer) {
174+
const charts = Array.from(anomalyContainer.querySelectorAll('uk-chart:not([data-chart-initialized])'));
175+
let rendered = 0;
176+
charts.forEach((el) => { if (renderChartElement(el)) rendered++; });
177+
if (rendered < charts.length) {
178+
// Retry after a short delay (e.g. if ApexCharts not ready yet)
179+
setTimeout(() => {
180+
charts.forEach((el) => { if (!el.hasAttribute('data-chart-initialized')) renderChartElement(el); });
181+
}, 300);
102182
}
103-
});
183+
return; // Skip observer path for anomalies page
184+
}
185+
186+
// Fallback: use lazy observer for other pages
187+
const observer = ensureChartObserver();
188+
document.querySelectorAll('uk-chart:not([data-chart-initialized])').forEach((el) => observer.observe(el));
104189
}
105190
106191
// Wait for both DOM and ApexCharts to be ready
@@ -123,6 +208,27 @@ def load_env_with_custom_path():
123208
// Re-initialize charts after HTMX requests
124209
document.addEventListener('htmx:afterSwap', initializeCharts);
125210
document.addEventListener('htmx:afterSettle', initializeCharts);
211+
// Debug: log counts and row metadata after HTMX updates
212+
document.addEventListener('htmx:afterSettle', function() {
213+
try {
214+
const container = document.getElementById('anomaly-list');
215+
if (!container) return;
216+
const rows = Array.from(container.querySelectorAll('[data-row]'));
217+
const charts = Array.from(container.querySelectorAll('uk-chart'));
218+
const visibleCharts = charts.filter(c => c.offsetParent !== null);
219+
console.log('[anomstack] rows:', rows.length, 'charts:', charts.length, 'visibleCharts:', visibleCharts.length);
220+
rows.slice(0, 5).forEach(r => {
221+
const ds = r.dataset || {};
222+
console.log('[row]', ds.row, ds.metric, ds.ts);
223+
});
224+
const initialized = container.querySelectorAll('uk-chart[data-chart-initialized]');
225+
console.log('[anomstack] initialized charts:', initialized.length);
226+
// Force initialize any visible charts not initialized yet (safety net)
227+
if (visibleCharts.length && initialized.length < visibleCharts.length) {
228+
visibleCharts.forEach((el) => { if (!el.hasAttribute('data-chart-initialized')) renderChartElement(el); });
229+
}
230+
} catch (e) { console.warn('debug error', e); }
231+
});
126232
"""),
127233
Script(POSTHOG_SCRIPT) if posthog_api_key else None,
128234
Link(

dashboard/charts.py

Lines changed: 85 additions & 56 deletions
Original file line numberDiff line numberDiff line change
@@ -278,7 +278,7 @@ def create_chart_placeholder(metric_name, index, batch_name) -> Card:
278278

279279
@staticmethod
280280
def create_sparkline(df_metric: pd.DataFrame, anomaly_timestamp: pd.Timestamp = None, dark_mode=False):
281-
"""Create a sparkline chart for a metric.
281+
"""Create an interactive ApexChart sparkline for a metric.
282282
283283
Args:
284284
df_metric (pd.DataFrame): The metric data.
@@ -290,13 +290,14 @@ def create_sparkline(df_metric: pd.DataFrame, anomaly_timestamp: pd.Timestamp =
290290
"""
291291
colors = ChartStyle.get_colors(dark_mode)
292292

293-
# Convert timestamps to milliseconds
293+
# Convert timestamps to milliseconds for ApexCharts
294294
timestamps = [int(pd.to_datetime(ts).timestamp() * 1000) for ts in df_metric["metric_timestamp"]]
295295

296-
# Prepare data (ensure native Python types for JSON serialization)
296+
# Prepare main metric data (ensure native Python types for JSON serialization)
297297
value_data = [[int(ts), float(val)] for ts, val in zip(timestamps, df_metric["metric_value"])]
298298
score_data = [[int(ts), float(val)] for ts, val in zip(timestamps, df_metric["metric_score"])]
299299

300+
# Build series array
300301
series = [
301302
{
302303
"name": "Value",
@@ -306,89 +307,117 @@ def create_sparkline(df_metric: pd.DataFrame, anomaly_timestamp: pd.Timestamp =
306307
"color": colors["primary"]
307308
},
308309
{
309-
"name": "Score",
310-
"type": "line",
310+
"name": "Score",
311+
"type": "line",
311312
"data": score_data,
312313
"yAxisIndex": 1,
313314
"color": colors["secondary"]
314315
}
315316
]
316317

317-
# Add specific anomaly marker if provided
318+
# Add alert markers if they exist (on value axis)
319+
alert_data = df_metric[df_metric["metric_alert"] == 1]
320+
if not alert_data.empty:
321+
alert_timestamps = [int(pd.to_datetime(ts).timestamp() * 1000) for ts in alert_data["metric_timestamp"]]
322+
alert_values = [[int(ts), float(val)] for ts, val in zip(alert_timestamps, alert_data["metric_value"])]
323+
series.append({
324+
"name": "Alert",
325+
"type": "scatter",
326+
"data": alert_values,
327+
"yAxisIndex": 0, # Value axis
328+
"color": colors["alert"]
329+
})
330+
331+
# Add LLM alert markers if they exist (on value axis)
332+
llm_alert_data = df_metric[df_metric["metric_llmalert"] == 1]
333+
if not llm_alert_data.empty:
334+
llm_timestamps = [int(pd.to_datetime(ts).timestamp() * 1000) for ts in llm_alert_data["metric_timestamp"]]
335+
llm_values = [[int(ts), float(val)] for ts, val in zip(llm_timestamps, llm_alert_data["metric_value"])]
336+
series.append({
337+
"name": "LLM Alert",
338+
"type": "scatter",
339+
"data": llm_values,
340+
"yAxisIndex": 0, # Value axis
341+
"color": colors["llmalert"]
342+
})
343+
344+
# Add specific anomaly marker for this particular timestamp if provided
318345
if anomaly_timestamp is not None:
319346
anomaly_point = df_metric[df_metric["metric_timestamp"] == anomaly_timestamp]
320347
if not anomaly_point.empty:
321-
alert_color = (
322-
colors["llmalert"]
323-
if anomaly_point["metric_llmalert"].iloc[0] == 1
348+
anomaly_ts = int(pd.to_datetime(anomaly_timestamp).timestamp() * 1000)
349+
anomaly_value = float(anomaly_point["metric_value"].iloc[0])
350+
351+
# Choose color based on alert type for this specific anomaly
352+
specific_color = (
353+
colors["llmalert"]
354+
if anomaly_point["metric_llmalert"].iloc[0] == 1
324355
else colors["alert"]
325356
)
326-
anomaly_ts = int(pd.to_datetime(anomaly_timestamp).timestamp() * 1000)
357+
327358
series.append({
328-
"name": "Alert",
359+
"name": "This Anomaly",
329360
"type": "scatter",
330-
"data": [[int(anomaly_ts), float(anomaly_point["metric_value"].iloc[0])]],
361+
"data": [[anomaly_ts, anomaly_value]],
331362
"yAxisIndex": 0,
332-
"color": alert_color
363+
"color": specific_color
333364
})
334365

366+
# Sparkline specific configuration
335367
chart_opts = {
336368
"chart": {
337369
"type": "line",
338-
"height": 50,
339-
"width": 200,
340-
"sparkline": {
341-
"enabled": True
342-
},
343-
"toolbar": {
344-
"show": False
345-
},
346-
"animations": {
347-
"enabled": False
348-
}
370+
"height": 32,
371+
"toolbar": {"show": False},
372+
"animations": {"enabled": False},
373+
"sparkline": {"enabled": True}
349374
},
350375
"series": series,
351-
"stroke": {
352-
"width": [1, 1, 0],
353-
"dashArray": [0, 3, 0]
354-
},
355-
"markers": {
356-
"size": [0, 0, 6]
376+
"xaxis": {
377+
"type": "datetime",
378+
"labels": {"show": False},
379+
"axisBorder": {"show": False},
380+
"axisTicks": {"show": False}
357381
},
358382
"yaxis": [
359-
{
360-
"show": False
361-
},
362-
{
363-
"show": False,
364-
"min": 0,
365-
"max": 1.05
366-
}
383+
{"show": False},
384+
{"show": False, "opposite": True, "min": 0, "max": 1}
367385
],
368-
"xaxis": {
369-
"type": "datetime",
370-
"labels": {
371-
"show": False
372-
},
373-
"axisBorder": {
374-
"show": False
375-
},
376-
"axisTicks": {
377-
"show": False
378-
}
386+
"stroke": {
387+
"width": [2, 1, 0, 0, 0], # Line widths for each series
388+
"dashArray": [0, 5, 0, 0, 0] # Score line dashed
379389
},
380-
"grid": {
381-
"show": False
390+
"markers": {
391+
"size": [0, 0, 6, 6, 10], # Larger markers for better visibility
392+
"colors": [colors["primary"], colors["secondary"], colors["alert"], colors["llmalert"], "inherit"],
393+
"strokeWidth": [0, 0, 2, 2, 3], # Thicker stroke for better visibility
394+
"strokeColors": ["#ffffff", "#ffffff", "#ffffff", "#ffffff", "#ffffff"]
382395
},
396+
"grid": {"show": False},
383397
"tooltip": {
384-
"enabled": False
398+
"theme": "dark" if dark_mode else "light",
399+
"enabled": True,
400+
"x": {"format": "dd MMM yyyy HH:mm"},
401+
"y": {
402+
"formatter": "function(val, opts) { return opts.seriesIndex === 1 ? (val * 100).toFixed(1) + '%' : val.toFixed(2); }"
403+
}
385404
},
386-
"legend": {
387-
"show": False
388-
}
405+
"legend": {"show": False},
406+
"colors": [colors["primary"], colors["secondary"], colors["alert"], colors["llmalert"], "inherit"]
389407
}
390408

391-
return ApexChart(opts=chart_opts)
409+
# Generate unique ID for sparkline using metric name + timestamp to avoid collisions
410+
try:
411+
metric_name = str(df_metric["metric_name"].iloc[0])
412+
except Exception:
413+
metric_name = "metric"
414+
safe_metric = metric_name.replace(":", "-").replace(" ", "-").replace(".", "-").replace("/", "-")
415+
safe_ts = (
416+
str(anomaly_timestamp).replace(":", "-").replace(" ", "-").replace(".", "-")
417+
if anomaly_timestamp is not None else "default"
418+
)
419+
sparkline_id = f"sparkline-{safe_metric}-{safe_ts}"
420+
return ApexChart(opts=chart_opts, id=sparkline_id)
392421

393422
@staticmethod
394423
def create_expanded_sparkline(df_metric: pd.DataFrame, anomaly_timestamp: pd.Timestamp = None, dark_mode=False):

0 commit comments

Comments
 (0)