Add a real charting layer and a heatmap panel type
Five chart types on ECharts (modular imports, not the full bundle):
TimeSeriesChart (multi-series, legend toggle), BarChart (incl.
stacked), SingleStat (big number + sparkline + trend), Heatmap, TopN.
Shared interactions: tooltips, dataZoom feeding the global time-range
picker, click-to-drill-into-query (drilldown.ts strips a panel's query
to its pre-stats filter and appends the clicked series/x-value as a
new filter term -- no backend change needed).
pivot.ts reshapes the query language's existing {columns, rows} tabular
output into per-series chart data client-side -- `stats count by
service, timestamp` already returns "long" rows, so multi-series
support needed zero query-language changes. theme.ts reads real
computed CSS custom properties so charts render in the active theme's
actual colors, with an SSR_FALLBACK for adapter-static's prerender pass
where `document` doesn't exist.
heatmap is the one narrow, justified backend change: a new VizType
needed to feed a new visualization, not a new query capability. Three
places had to change together, not two -- api/dashboards/types.go's
validator, web/src/lib/api.ts's union (previous commit), and the
dashboard_panels table's viz_type CHECK constraint
(migrations/0035_add_heatmap_viz_type.sql), which mirrors the Go
validator and doesn't update itself.
/dev/charts (unlisted, dev-only) is a synthetic fixture/perf-test route:
confirmed 50ms first-two-frames render time on a production build
against a 30,006-row/6-series stress case, and a 211,975-byte gzipped
chart chunk -- both real measurements behind the ECharts-over-
Observable-Plot-or-D3 choice, not estimates.
This commit is contained in:
+72
-131
@@ -1,147 +1,88 @@
|
||||
<script lang="ts">
|
||||
// Dispatches a query result to the right rendering for a panel's
|
||||
// viz_type. table/top_n reuse ResultsTable.svelte (top_n is "table,
|
||||
// but the query already did sort/head" -- same execution path per
|
||||
// /docs/phase-3-dashboard-design.md). line/bar use uPlot.
|
||||
import uPlot from 'uplot';
|
||||
import 'uplot/dist/uPlot.min.css';
|
||||
// viz_type. "table" still reuses ResultsTable.svelte; every chart
|
||||
// type is Phase 5's ECharts layer ($lib/charts), replacing Phase 3's
|
||||
// direct uPlot usage. top_n's *execution* is unchanged (see
|
||||
// api/dashboards/types.go's VizType doc comment -- the query itself
|
||||
// already did `stats ... | sort | head`), but now actually renders as
|
||||
// the ranked horizontal bar chart the name always implied, not a
|
||||
// plain table.
|
||||
import { goto } from '$app/navigation';
|
||||
import ResultsTable from '$lib/ResultsTable.svelte';
|
||||
import {
|
||||
TimeSeriesChart,
|
||||
BarChart,
|
||||
TopN,
|
||||
Heatmap,
|
||||
SingleStat,
|
||||
pivot,
|
||||
buildDrillDownQuery,
|
||||
drillDownUrl
|
||||
} from '$lib/charts';
|
||||
import type { QueryResult, VizType } from '$lib/api';
|
||||
|
||||
let {
|
||||
result,
|
||||
vizType,
|
||||
vizConfig = {}
|
||||
}: { result: QueryResult; vizType: VizType; vizConfig?: Record<string, string> } = $props();
|
||||
vizConfig = {},
|
||||
query,
|
||||
onZoom
|
||||
}: {
|
||||
result: QueryResult;
|
||||
vizType: VizType;
|
||||
vizConfig?: Record<string, string>;
|
||||
// Needed to build a drill-down query (see $lib/charts/drilldown.ts);
|
||||
// undefined call sites just lose drill-down, not error.
|
||||
query?: string;
|
||||
// Bubbles a time-series chart's zoom range up to the dashboard so
|
||||
// it can become the new global time range -- see
|
||||
// dashboards/[id]/+page.svelte's onZoom handler.
|
||||
onZoom?: (range: { earliest: string; latest: string }) => void;
|
||||
} = $props();
|
||||
|
||||
let chartEl: HTMLDivElement | undefined = $state();
|
||||
let chart: uPlot | undefined;
|
||||
|
||||
// ISO-8601-ish prefix ("2026-08-13T20:20:24...") -- the shape Sentry's
|
||||
// own timestamp column actually comes back as. Deliberately narrow:
|
||||
// found by actually rendering a `stats count by host` bar chart that
|
||||
// JS's built-in Date.parse() is far too lenient to use as a "does
|
||||
// this look like a timestamp" check -- Date.parse("host-06") returns
|
||||
// a real (bogus) timestamp rather than NaN, which silently misrouted
|
||||
// a categorical `host` column onto a numeric time axis and rendered
|
||||
// unreadable giant tick labels instead of host names.
|
||||
const isoTimestampPrefix = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}/;
|
||||
|
||||
// Prefers a real numeric/time x-axis when the column parses cleanly
|
||||
// (e.g. a timestamp column); falls back to row index with the raw
|
||||
// value as a tick label otherwise (e.g. a `stats ... by host` grouping
|
||||
// column, which is categorical text).
|
||||
function resolveXAxis(columns: string[], rows: unknown[][], columnName: string | undefined) {
|
||||
const idx = columnName ? Math.max(columns.indexOf(columnName), 0) : 0;
|
||||
const labels = rows.map((r) => String(r[idx] ?? ''));
|
||||
const asSeconds = rows.map((r) => {
|
||||
const v = r[idx];
|
||||
if (typeof v === 'number') return v;
|
||||
if (typeof v !== 'string' || !isoTimestampPrefix.test(v)) return NaN;
|
||||
const parsed = Date.parse(v);
|
||||
return Number.isNaN(parsed) ? NaN : parsed / 1000;
|
||||
});
|
||||
const allNumeric = asSeconds.every((n) => !Number.isNaN(n));
|
||||
return allNumeric
|
||||
? { values: asSeconds, labels, categorical: false }
|
||||
: { values: rows.map((_, i) => i), labels, categorical: true };
|
||||
}
|
||||
|
||||
function resolveValueColumn(columns: string[], columnName: string | undefined): number {
|
||||
if (columnName) {
|
||||
const i = columns.indexOf(columnName);
|
||||
if (i >= 0) return i;
|
||||
}
|
||||
// default: second column if there is one (first is usually the
|
||||
// grouping/x column), otherwise the only column there is.
|
||||
return columns.length > 1 ? 1 : 0;
|
||||
}
|
||||
|
||||
function renderChart() {
|
||||
if (!chartEl) return;
|
||||
chart?.destroy();
|
||||
chart = undefined;
|
||||
if (vizType !== 'line' && vizType !== 'bar') return;
|
||||
|
||||
const { columns, rows } = result;
|
||||
if (columns.length === 0 || rows.length === 0) return;
|
||||
|
||||
const x = resolveXAxis(columns, rows, vizConfig.x_column);
|
||||
const valueIdx = resolveValueColumn(columns, vizConfig.value_column);
|
||||
const values = rows.map((r) => {
|
||||
const v = r[valueIdx];
|
||||
return typeof v === 'number' ? v : Number(v) || 0;
|
||||
});
|
||||
|
||||
chart = new uPlot(
|
||||
{
|
||||
width: chartEl.clientWidth || 400,
|
||||
height: 220,
|
||||
legend: { show: false },
|
||||
series: [
|
||||
{},
|
||||
{
|
||||
label: columns[valueIdx],
|
||||
stroke: '#06c',
|
||||
fill: vizType === 'bar' ? '#06c33' : undefined,
|
||||
width: vizType === 'bar' ? 0 : 2,
|
||||
paths: vizType === 'bar' ? uPlot.paths.bars!({ size: [0.6] }) : undefined
|
||||
}
|
||||
],
|
||||
axes: [
|
||||
{
|
||||
values: (_u, ticks) =>
|
||||
ticks.map((t) => (x.categorical ? (x.labels[t] ?? '') : String(t)))
|
||||
},
|
||||
{}
|
||||
]
|
||||
},
|
||||
[x.values, values],
|
||||
chartEl
|
||||
);
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
// Re-render whenever the result or viz settings change.
|
||||
result;
|
||||
vizType;
|
||||
vizConfig;
|
||||
renderChart();
|
||||
return () => chart?.destroy();
|
||||
let isTimeSeries = $derived.by(() => {
|
||||
if (vizType !== 'line' && vizType !== 'bar') return false;
|
||||
return pivot(result.columns, result.rows, { xColumn: vizConfig.x_column }).isTime;
|
||||
});
|
||||
|
||||
// A dashboard panel's real width isn't known at first render --
|
||||
// gridstack.js sizes the parent .grid-stack-item via its own layout
|
||||
// pass, which can land after this component's own effect runs. Found
|
||||
// by actually adding a bar-chart panel and inspecting the rendered
|
||||
// canvas: it came out 74px wide (chartEl.clientWidth measured before
|
||||
// gridstack finished sizing the container), not the container's real
|
||||
// ~540px. A ResizeObserver re-renders whenever chartEl's actual size
|
||||
// changes, which fixes both that initial race and, as a side benefit,
|
||||
// keeps the chart correctly sized when a panel is drag-resized later.
|
||||
$effect(() => {
|
||||
if (!chartEl) return;
|
||||
const observer = new ResizeObserver(() => renderChart());
|
||||
observer.observe(chartEl);
|
||||
return () => observer.disconnect();
|
||||
});
|
||||
function handleDrillDown(point: { seriesName?: string; name: string; value: unknown }, isTime: boolean) {
|
||||
if (!query) return;
|
||||
// value is [x, y] for line/bar (ECharts passes the raw data tuple
|
||||
// back), or just the category label for TopN/Heatmap.
|
||||
const xValue = Array.isArray(point.value) ? point.value[0] : point.name;
|
||||
const target = buildDrillDownQuery(query, {
|
||||
seriesColumn: vizConfig.series_column,
|
||||
seriesName: point.seriesName,
|
||||
xValue,
|
||||
isTime
|
||||
});
|
||||
goto(drillDownUrl(target));
|
||||
}
|
||||
|
||||
function handleZoom(range: { startValue?: number; endValue?: number }) {
|
||||
if (!onZoom || range.startValue == null || range.endValue == null) return;
|
||||
onZoom({
|
||||
earliest: new Date(range.startValue).toISOString(),
|
||||
latest: new Date(range.endValue).toISOString()
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if vizType === 'table' || vizType === 'top_n'}
|
||||
{#if vizType === 'table'}
|
||||
<ResultsTable columns={result.columns} rows={result.rows} hasRun={true} />
|
||||
{:else if vizType === 'single_stat'}
|
||||
<div class="single-stat">{result.rows[0]?.[0] ?? '—'}</div>
|
||||
{:else}
|
||||
<div bind:this={chartEl} class="chart"></div>
|
||||
<SingleStat {result} config={vizConfig} />
|
||||
{:else if vizType === 'heatmap'}
|
||||
<Heatmap {result} config={vizConfig} onDrillDown={(pt) => handleDrillDown(pt, false)} />
|
||||
{:else if vizType === 'top_n'}
|
||||
<TopN {result} config={vizConfig} onDrillDown={(pt) => handleDrillDown(pt, false)} />
|
||||
{:else if vizType === 'line'}
|
||||
<TimeSeriesChart
|
||||
{result}
|
||||
config={vizConfig}
|
||||
onDrillDown={(pt) => handleDrillDown(pt, isTimeSeries)}
|
||||
onZoom={handleZoom}
|
||||
/>
|
||||
{:else if vizType === 'bar'}
|
||||
<BarChart {result} config={vizConfig} onDrillDown={(pt) => handleDrillDown(pt, isTimeSeries)} />
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.single-stat {
|
||||
font-size: 2.5rem;
|
||||
font-weight: 600;
|
||||
padding: 1rem 0;
|
||||
}
|
||||
.chart {
|
||||
width: 100%;
|
||||
}
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user