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:
@@ -21,11 +21,15 @@ const (
|
|||||||
VizBar VizType = "bar"
|
VizBar VizType = "bar"
|
||||||
VizSingleStat VizType = "single_stat"
|
VizSingleStat VizType = "single_stat"
|
||||||
VizTopN VizType = "top_n"
|
VizTopN VizType = "top_n"
|
||||||
|
// VizHeatmap is Phase 5's addition (log-volume-over-time patterns) --
|
||||||
|
// same "query already produced the right rows, only UI framing
|
||||||
|
// differs" shape as VizTopN, no new execution path.
|
||||||
|
VizHeatmap VizType = "heatmap"
|
||||||
)
|
)
|
||||||
|
|
||||||
func validVizType(v VizType) bool {
|
func validVizType(v VizType) bool {
|
||||||
switch v {
|
switch v {
|
||||||
case VizTable, VizLine, VizBar, VizSingleStat, VizTopN:
|
case VizTable, VizLine, VizBar, VizSingleStat, VizTopN, VizHeatmap:
|
||||||
return true
|
return true
|
||||||
default:
|
default:
|
||||||
return false
|
return false
|
||||||
@@ -77,7 +81,7 @@ func validatePanel(p *Panel) error {
|
|||||||
return fmt.Errorf("raw-SQL panels are not supported -- dashboards only support pipe-syntax queries, since the dashboard time-range picker is injected as leading query terms")
|
return fmt.Errorf("raw-SQL panels are not supported -- dashboards only support pipe-syntax queries, since the dashboard time-range picker is injected as leading query terms")
|
||||||
}
|
}
|
||||||
if !validVizType(p.VizType) {
|
if !validVizType(p.VizType) {
|
||||||
return fmt.Errorf("viz_type must be one of table, line, bar, single_stat, top_n, got %q", p.VizType)
|
return fmt.Errorf("viz_type must be one of table, line, bar, single_stat, top_n, heatmap, got %q", p.VizType)
|
||||||
}
|
}
|
||||||
if len(p.VizConfig) == 0 {
|
if len(p.VizConfig) == 0 {
|
||||||
p.VizConfig = json.RawMessage(`{}`)
|
p.VizConfig = json.RawMessage(`{}`)
|
||||||
|
|||||||
@@ -0,0 +1,9 @@
|
|||||||
|
-- Phase 5 added a heatmap panel type (api/dashboards/types.go's
|
||||||
|
-- validVizType()) but missed updating the DB-level check constraint
|
||||||
|
-- that mirrors it, so heatmap panels passed Go validation and then
|
||||||
|
-- failed on insert. Postgres has no ALTER CHECK, so drop and recreate.
|
||||||
|
ALTER TABLE dashboard_panels DROP CONSTRAINT dashboard_panels_viz_type_check;
|
||||||
|
|
||||||
|
ALTER TABLE dashboard_panels
|
||||||
|
ADD CONSTRAINT dashboard_panels_viz_type_check
|
||||||
|
CHECK (viz_type IN ('table', 'line', 'bar', 'single_stat', 'top_n', 'heatmap'));
|
||||||
+72
-131
@@ -1,147 +1,88 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
// Dispatches a query result to the right rendering for a panel's
|
// Dispatches a query result to the right rendering for a panel's
|
||||||
// viz_type. table/top_n reuse ResultsTable.svelte (top_n is "table,
|
// viz_type. "table" still reuses ResultsTable.svelte; every chart
|
||||||
// but the query already did sort/head" -- same execution path per
|
// type is Phase 5's ECharts layer ($lib/charts), replacing Phase 3's
|
||||||
// /docs/phase-3-dashboard-design.md). line/bar use uPlot.
|
// direct uPlot usage. top_n's *execution* is unchanged (see
|
||||||
import uPlot from 'uplot';
|
// api/dashboards/types.go's VizType doc comment -- the query itself
|
||||||
import 'uplot/dist/uPlot.min.css';
|
// 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 ResultsTable from '$lib/ResultsTable.svelte';
|
||||||
|
import {
|
||||||
|
TimeSeriesChart,
|
||||||
|
BarChart,
|
||||||
|
TopN,
|
||||||
|
Heatmap,
|
||||||
|
SingleStat,
|
||||||
|
pivot,
|
||||||
|
buildDrillDownQuery,
|
||||||
|
drillDownUrl
|
||||||
|
} from '$lib/charts';
|
||||||
import type { QueryResult, VizType } from '$lib/api';
|
import type { QueryResult, VizType } from '$lib/api';
|
||||||
|
|
||||||
let {
|
let {
|
||||||
result,
|
result,
|
||||||
vizType,
|
vizType,
|
||||||
vizConfig = {}
|
vizConfig = {},
|
||||||
}: { result: QueryResult; vizType: VizType; vizConfig?: Record<string, string> } = $props();
|
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 isTimeSeries = $derived.by(() => {
|
||||||
let chart: uPlot | undefined;
|
if (vizType !== 'line' && vizType !== 'bar') return false;
|
||||||
|
return pivot(result.columns, result.rows, { xColumn: vizConfig.x_column }).isTime;
|
||||||
// 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();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// A dashboard panel's real width isn't known at first render --
|
function handleDrillDown(point: { seriesName?: string; name: string; value: unknown }, isTime: boolean) {
|
||||||
// gridstack.js sizes the parent .grid-stack-item via its own layout
|
if (!query) return;
|
||||||
// pass, which can land after this component's own effect runs. Found
|
// value is [x, y] for line/bar (ECharts passes the raw data tuple
|
||||||
// by actually adding a bar-chart panel and inspecting the rendered
|
// back), or just the category label for TopN/Heatmap.
|
||||||
// canvas: it came out 74px wide (chartEl.clientWidth measured before
|
const xValue = Array.isArray(point.value) ? point.value[0] : point.name;
|
||||||
// gridstack finished sizing the container), not the container's real
|
const target = buildDrillDownQuery(query, {
|
||||||
// ~540px. A ResizeObserver re-renders whenever chartEl's actual size
|
seriesColumn: vizConfig.series_column,
|
||||||
// changes, which fixes both that initial race and, as a side benefit,
|
seriesName: point.seriesName,
|
||||||
// keeps the chart correctly sized when a panel is drag-resized later.
|
xValue,
|
||||||
$effect(() => {
|
isTime
|
||||||
if (!chartEl) return;
|
});
|
||||||
const observer = new ResizeObserver(() => renderChart());
|
goto(drillDownUrl(target));
|
||||||
observer.observe(chartEl);
|
}
|
||||||
return () => observer.disconnect();
|
|
||||||
});
|
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>
|
</script>
|
||||||
|
|
||||||
{#if vizType === 'table' || vizType === 'top_n'}
|
{#if vizType === 'table'}
|
||||||
<ResultsTable columns={result.columns} rows={result.rows} hasRun={true} />
|
<ResultsTable columns={result.columns} rows={result.rows} hasRun={true} />
|
||||||
{:else if vizType === 'single_stat'}
|
{:else if vizType === 'single_stat'}
|
||||||
<div class="single-stat">{result.rows[0]?.[0] ?? '—'}</div>
|
<SingleStat {result} config={vizConfig} />
|
||||||
{:else}
|
{:else if vizType === 'heatmap'}
|
||||||
<div bind:this={chartEl} class="chart"></div>
|
<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}
|
{/if}
|
||||||
|
|
||||||
<style>
|
|
||||||
.single-stat {
|
|
||||||
font-size: 2.5rem;
|
|
||||||
font-weight: 600;
|
|
||||||
padding: 1rem 0;
|
|
||||||
}
|
|
||||||
.chart {
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
|||||||
@@ -0,0 +1,54 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
// Bar chart, category or time x-axis, optionally stacked
|
||||||
|
// (config.stacked -- multiple series sharing one `stack` group,
|
||||||
|
// ECharts' native stacking, no manual cumulative-sum math needed).
|
||||||
|
import EChart from './EChart.svelte';
|
||||||
|
import { readChartTokens, baseOption, SERIES_PALETTE } from './theme';
|
||||||
|
import { pivot } from './pivot';
|
||||||
|
import type { QueryResult } from '$lib/api';
|
||||||
|
import type { EChartsOption } from './setup';
|
||||||
|
|
||||||
|
let {
|
||||||
|
result,
|
||||||
|
config = {},
|
||||||
|
onDrillDown,
|
||||||
|
height = '260px'
|
||||||
|
}: {
|
||||||
|
result: QueryResult;
|
||||||
|
config?: { x_column?: string; value_column?: string; series_column?: string; stacked?: string };
|
||||||
|
onDrillDown?: (point: { seriesName?: string; name: string; value: unknown }) => void;
|
||||||
|
height?: string;
|
||||||
|
} = $props();
|
||||||
|
|
||||||
|
let option = $derived.by((): EChartsOption => {
|
||||||
|
const t = readChartTokens();
|
||||||
|
const p = pivot(result.columns, result.rows, {
|
||||||
|
xColumn: config.x_column,
|
||||||
|
valueColumn: config.value_column,
|
||||||
|
seriesColumn: config.series_column
|
||||||
|
});
|
||||||
|
const multi = p.series.length > 1;
|
||||||
|
const stacked = config.stacked === 'true';
|
||||||
|
|
||||||
|
return {
|
||||||
|
...baseOption(t),
|
||||||
|
color: SERIES_PALETTE,
|
||||||
|
legend: multi ? { ...baseOption(t).legend, show: true } : { show: false },
|
||||||
|
xAxis: {
|
||||||
|
...baseOption(t).xAxis,
|
||||||
|
type: p.isTime ? 'time' : 'category',
|
||||||
|
data: p.isTime ? undefined : p.categories
|
||||||
|
},
|
||||||
|
yAxis: { ...baseOption(t).yAxis, type: 'value' },
|
||||||
|
series: p.series.map((s) => ({
|
||||||
|
type: 'bar',
|
||||||
|
name: s.name,
|
||||||
|
data: s.data,
|
||||||
|
stack: stacked ? 'total' : undefined,
|
||||||
|
barMaxWidth: 28
|
||||||
|
}))
|
||||||
|
};
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<EChart {option} {height} onPointClick={(pt) => onDrillDown?.(pt)} />
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
// Base wrapper every chart type in this directory builds on. Owns the
|
||||||
|
// echarts instance lifecycle (init/resize/dispose) and the two shared
|
||||||
|
// interaction patterns every chart type needs identically: emitting a
|
||||||
|
// click (for drill-down) and a dataZoom range (for the global
|
||||||
|
// time-range picker) as plain callback props, not custom DOM events --
|
||||||
|
// keeps call sites (PanelViz, dashboards) working with normal
|
||||||
|
// function props instead of addEventListener-style wiring.
|
||||||
|
import { echarts, type EChartsOption } from './setup';
|
||||||
|
import { getTheme } from '$lib/theme.svelte';
|
||||||
|
import type { EChartsType } from 'echarts/core';
|
||||||
|
|
||||||
|
let {
|
||||||
|
option,
|
||||||
|
height = '100%',
|
||||||
|
onPointClick,
|
||||||
|
onZoom
|
||||||
|
}: {
|
||||||
|
option: EChartsOption;
|
||||||
|
height?: string;
|
||||||
|
onPointClick?: (params: { seriesName?: string; name: string; value: unknown }) => void;
|
||||||
|
onZoom?: (range: { startValue?: number; endValue?: number }) => void;
|
||||||
|
} = $props();
|
||||||
|
|
||||||
|
let el: HTMLDivElement | undefined = $state();
|
||||||
|
let chart: EChartsType | undefined;
|
||||||
|
|
||||||
|
function render() {
|
||||||
|
if (!el) return;
|
||||||
|
if (!chart) {
|
||||||
|
chart = echarts.init(el, undefined, { renderer: 'canvas' });
|
||||||
|
chart.on('click', (params) => {
|
||||||
|
onPointClick?.({ seriesName: params.seriesName, name: String(params.name), value: params.value });
|
||||||
|
});
|
||||||
|
chart.on('datazoom', () => {
|
||||||
|
if (!chart || !onZoom) return;
|
||||||
|
// finished/batch events both land here; read the resolved
|
||||||
|
// window back off the model rather than trusting whichever
|
||||||
|
// shape this particular event fired with.
|
||||||
|
const opt = chart.getOption() as { dataZoom?: { startValue?: number; endValue?: number }[] };
|
||||||
|
const dz = opt.dataZoom?.[0];
|
||||||
|
if (dz) onZoom({ startValue: dz.startValue, endValue: dz.endValue });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
chart.setOption(option, { notMerge: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
option;
|
||||||
|
getTheme(); // re-render on theme change -- token colors baked into `option` upstream need a fresh read
|
||||||
|
render();
|
||||||
|
});
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
if (!el) return;
|
||||||
|
const observer = new ResizeObserver(() => chart?.resize());
|
||||||
|
observer.observe(el);
|
||||||
|
return () => observer.disconnect();
|
||||||
|
});
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
return () => {
|
||||||
|
chart?.dispose();
|
||||||
|
chart = undefined;
|
||||||
|
};
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div bind:this={el} class="echart" style:height></div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.echart {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
// Two-dimensional heatmap -- the shape a "log volume over time"
|
||||||
|
// panel needs: two grouping columns (e.g. hour-of-day x day, or
|
||||||
|
// service x severity) plus a count column, from a
|
||||||
|
// `stats count by a, b` query. visualMap drives the color scale from
|
||||||
|
// --color-surface (no data) up through --color-accent (most data) --
|
||||||
|
// reuses the brand accent rather than introducing an unrelated third
|
||||||
|
// color scale, since a heatmap's color IS its data encoding, not a
|
||||||
|
// severity signal.
|
||||||
|
import EChart from './EChart.svelte';
|
||||||
|
import { readChartTokens, baseOption } from './theme';
|
||||||
|
import type { QueryResult } from '$lib/api';
|
||||||
|
import type { EChartsOption } from './setup';
|
||||||
|
|
||||||
|
let {
|
||||||
|
result,
|
||||||
|
config = {},
|
||||||
|
onDrillDown,
|
||||||
|
height = '260px'
|
||||||
|
}: {
|
||||||
|
result: QueryResult;
|
||||||
|
config?: { x_column?: string; y_column?: string; value_column?: string };
|
||||||
|
onDrillDown?: (point: { seriesName?: string; name: string; value: unknown }) => void;
|
||||||
|
height?: string;
|
||||||
|
} = $props();
|
||||||
|
|
||||||
|
let option = $derived.by((): EChartsOption => {
|
||||||
|
const t = readChartTokens();
|
||||||
|
const { columns, rows } = result;
|
||||||
|
if (columns.length < 2 || rows.length === 0) {
|
||||||
|
return { ...baseOption(t), xAxis: { type: 'category', data: [] }, yAxis: { type: 'category', data: [] }, series: [] };
|
||||||
|
}
|
||||||
|
const xIdx = config.x_column ? columns.indexOf(config.x_column) : 0;
|
||||||
|
const yIdx = config.y_column ? columns.indexOf(config.y_column) : 1;
|
||||||
|
const valueIdx = config.value_column
|
||||||
|
? columns.indexOf(config.value_column)
|
||||||
|
: (columns.findIndex((_, i) => i !== xIdx && i !== yIdx) ?? 2);
|
||||||
|
|
||||||
|
const xCats: string[] = [];
|
||||||
|
const yCats: string[] = [];
|
||||||
|
const data: [number, number, number][] = [];
|
||||||
|
let max = 0;
|
||||||
|
for (const row of rows) {
|
||||||
|
const xVal = String(row[xIdx] ?? '');
|
||||||
|
const yVal = String(row[yIdx] ?? '');
|
||||||
|
if (!xCats.includes(xVal)) xCats.push(xVal);
|
||||||
|
if (!yCats.includes(yVal)) yCats.push(yVal);
|
||||||
|
const v = typeof row[valueIdx] === 'number' ? (row[valueIdx] as number) : Number(row[valueIdx]) || 0;
|
||||||
|
max = Math.max(max, v);
|
||||||
|
data.push([xCats.indexOf(xVal), yCats.indexOf(yVal), v]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...baseOption(t),
|
||||||
|
grid: { ...baseOption(t).grid, right: 16 },
|
||||||
|
xAxis: { ...baseOption(t).xAxis, type: 'category', data: xCats, splitArea: { show: true } },
|
||||||
|
yAxis: { ...baseOption(t).yAxis, type: 'category', data: yCats, splitArea: { show: true } },
|
||||||
|
visualMap: {
|
||||||
|
min: 0,
|
||||||
|
max: max || 1,
|
||||||
|
show: false,
|
||||||
|
inRange: { color: [t.surfaceRaised, t.accent] }
|
||||||
|
},
|
||||||
|
series: [
|
||||||
|
{
|
||||||
|
type: 'heatmap',
|
||||||
|
data,
|
||||||
|
itemStyle: { borderColor: t.surface, borderWidth: 2 },
|
||||||
|
emphasis: { itemStyle: { borderColor: t.text, borderWidth: 1 } }
|
||||||
|
}
|
||||||
|
]
|
||||||
|
};
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<EChart {option} {height} onPointClick={(pt) => onDrillDown?.(pt)} />
|
||||||
@@ -0,0 +1,109 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
// Big number + sparkline + trend, from one time-series-shaped result
|
||||||
|
// (the same {columns, rows} a line panel gets -- the last value is
|
||||||
|
// "now", the series is the sparkline, first-vs-last is the trend).
|
||||||
|
// Trend color is neutral by default (whether "up" is good or bad
|
||||||
|
// depends entirely on the metric -- request rate vs. error rate mean
|
||||||
|
// opposite things) -- config.higher_is_worse opts a panel into
|
||||||
|
// coloring an increase as the "error" severity tier instead of
|
||||||
|
// leaving it neutral, for exactly the panels (error rate, queue
|
||||||
|
// depth) where that framing is actually true.
|
||||||
|
import { readChartTokens } from './theme';
|
||||||
|
import { pivot } from './pivot';
|
||||||
|
import type { QueryResult } from '$lib/api';
|
||||||
|
|
||||||
|
let {
|
||||||
|
result,
|
||||||
|
config = {}
|
||||||
|
}: {
|
||||||
|
result: QueryResult;
|
||||||
|
config?: { x_column?: string; value_column?: string; higher_is_worse?: string; unit?: string };
|
||||||
|
} = $props();
|
||||||
|
|
||||||
|
let stat = $derived.by(() => {
|
||||||
|
const p = pivot(result.columns, result.rows, { xColumn: config.x_column, valueColumn: config.value_column });
|
||||||
|
const series = p.series[0]?.data ?? [];
|
||||||
|
const values = series.map((d) => d[1]);
|
||||||
|
const current = values.length ? values[values.length - 1] : null;
|
||||||
|
const first = values.length ? values[0] : null;
|
||||||
|
const delta = current !== null && first !== null && first !== 0 ? ((current - first) / Math.abs(first)) * 100 : null;
|
||||||
|
return { values, current, delta };
|
||||||
|
});
|
||||||
|
|
||||||
|
let t = $derived(readChartTokens());
|
||||||
|
|
||||||
|
let trendColor = $derived.by(() => {
|
||||||
|
if (stat.delta === null || stat.delta === 0) return t.textMuted;
|
||||||
|
const worse = config.higher_is_worse === 'true' ? stat.delta > 0 : stat.delta < 0;
|
||||||
|
return worse ? t.sevError : t.sevInfo;
|
||||||
|
});
|
||||||
|
|
||||||
|
function sparklinePath(values: number[], w: number, h: number): string {
|
||||||
|
if (values.length < 2) return '';
|
||||||
|
const min = Math.min(...values);
|
||||||
|
const max = Math.max(...values);
|
||||||
|
const range = max - min || 1;
|
||||||
|
return values
|
||||||
|
.map((v, i) => {
|
||||||
|
const x = (i / (values.length - 1)) * w;
|
||||||
|
const y = h - ((v - min) / range) * h;
|
||||||
|
return `${i === 0 ? 'M' : 'L'}${x.toFixed(1)},${y.toFixed(1)}`;
|
||||||
|
})
|
||||||
|
.join(' ');
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="single-stat">
|
||||||
|
<div class="value">
|
||||||
|
{stat.current !== null ? stat.current.toLocaleString() : '—'}
|
||||||
|
{#if config.unit}<span class="unit">{config.unit}</span>{/if}
|
||||||
|
{#if stat.delta !== null}
|
||||||
|
<span class="trend" style:color={trendColor}>
|
||||||
|
{stat.delta >= 0 ? '▲' : '▼'} {Math.abs(stat.delta).toFixed(1)}%
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{#if stat.values.length > 1}
|
||||||
|
<svg class="sparkline" viewBox="0 0 120 32" preserveAspectRatio="none">
|
||||||
|
<path d={sparklinePath(stat.values, 120, 28)} fill="none" stroke={t.accent} stroke-width="2" />
|
||||||
|
<circle
|
||||||
|
cx={120}
|
||||||
|
cy={28 - ((stat.values[stat.values.length - 1] - Math.min(...stat.values)) / (Math.max(...stat.values) - Math.min(...stat.values) || 1)) * 28}
|
||||||
|
r="2.5"
|
||||||
|
fill={t.accent}
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.single-stat {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-2);
|
||||||
|
padding: var(--space-2) 0;
|
||||||
|
}
|
||||||
|
.value {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: var(--text-2xl);
|
||||||
|
font-weight: var(--font-weight-bold);
|
||||||
|
color: var(--color-text);
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: var(--space-2);
|
||||||
|
}
|
||||||
|
.unit {
|
||||||
|
font-size: var(--text-md);
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
}
|
||||||
|
.trend {
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
font-weight: var(--font-weight-medium);
|
||||||
|
margin-left: auto;
|
||||||
|
}
|
||||||
|
.sparkline {
|
||||||
|
width: 100%;
|
||||||
|
height: 2rem;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
// Multi-series time-series line, with legend-toggle (native ECharts
|
||||||
|
// legend behavior -- click a series name to hide/show it) and
|
||||||
|
// zoom/pan (dataZoom's slider + mouse-wheel/drag "inside" zoom) that
|
||||||
|
// reports the zoomed range back via onZoom, for the caller to feed
|
||||||
|
// into the dashboard's global time-range picker.
|
||||||
|
import EChart from './EChart.svelte';
|
||||||
|
import { readChartTokens, baseOption, SERIES_PALETTE } from './theme';
|
||||||
|
import { pivot } from './pivot';
|
||||||
|
import type { QueryResult } from '$lib/api';
|
||||||
|
import type { EChartsOption } from './setup';
|
||||||
|
|
||||||
|
let {
|
||||||
|
result,
|
||||||
|
config = {},
|
||||||
|
onDrillDown,
|
||||||
|
onZoom,
|
||||||
|
height = '260px'
|
||||||
|
}: {
|
||||||
|
result: QueryResult;
|
||||||
|
config?: { x_column?: string; value_column?: string; series_column?: string };
|
||||||
|
onDrillDown?: (point: { seriesName?: string; name: string; value: unknown }) => void;
|
||||||
|
onZoom?: (range: { startValue?: number; endValue?: number }) => void;
|
||||||
|
height?: string;
|
||||||
|
} = $props();
|
||||||
|
|
||||||
|
let option = $derived.by((): EChartsOption => {
|
||||||
|
const t = readChartTokens();
|
||||||
|
const p = pivot(result.columns, result.rows, {
|
||||||
|
xColumn: config.x_column,
|
||||||
|
valueColumn: config.value_column,
|
||||||
|
seriesColumn: config.series_column
|
||||||
|
});
|
||||||
|
const multi = p.series.length > 1;
|
||||||
|
|
||||||
|
return {
|
||||||
|
...baseOption(t),
|
||||||
|
color: SERIES_PALETTE,
|
||||||
|
legend: multi ? { ...baseOption(t).legend, show: true } : { show: false },
|
||||||
|
xAxis: {
|
||||||
|
...baseOption(t).xAxis,
|
||||||
|
type: p.isTime ? 'time' : 'category',
|
||||||
|
data: p.isTime ? undefined : p.categories
|
||||||
|
},
|
||||||
|
yAxis: { ...baseOption(t).yAxis, type: 'value' },
|
||||||
|
dataZoom: p.isTime
|
||||||
|
? [
|
||||||
|
{ type: 'inside', xAxisIndex: 0 },
|
||||||
|
{ type: 'slider', xAxisIndex: 0, height: 16, bottom: 2, borderColor: t.border, fillerColor: `${t.accent}22` }
|
||||||
|
]
|
||||||
|
: undefined,
|
||||||
|
series: p.series.map((s) => ({
|
||||||
|
type: 'line',
|
||||||
|
name: s.name,
|
||||||
|
data: s.data,
|
||||||
|
showSymbol: s.data.length < 80,
|
||||||
|
symbolSize: 5,
|
||||||
|
smooth: false,
|
||||||
|
lineStyle: { width: 2 },
|
||||||
|
areaStyle: multi ? undefined : { opacity: 0.12 }
|
||||||
|
}))
|
||||||
|
};
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<EChart
|
||||||
|
{option}
|
||||||
|
{height}
|
||||||
|
onPointClick={(pt) => onDrillDown?.(pt)}
|
||||||
|
onZoom={(range) => onZoom?.(range)}
|
||||||
|
/>
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
// Horizontal bar, ranked. Mirrors table/top_n's existing execution
|
||||||
|
// model (see api/dashboards/types.go's VizType doc comment): the
|
||||||
|
// query itself already did `stats ... | sort -count | head N`, so
|
||||||
|
// this only reframes already-ranked rows, no client-side re-sorting.
|
||||||
|
import EChart from './EChart.svelte';
|
||||||
|
import { readChartTokens, baseOption } from './theme';
|
||||||
|
import type { QueryResult } from '$lib/api';
|
||||||
|
import type { EChartsOption } from './setup';
|
||||||
|
|
||||||
|
let {
|
||||||
|
result,
|
||||||
|
config = {},
|
||||||
|
onDrillDown,
|
||||||
|
height = '260px'
|
||||||
|
}: {
|
||||||
|
result: QueryResult;
|
||||||
|
config?: { label_column?: string; value_column?: string };
|
||||||
|
onDrillDown?: (point: { seriesName?: string; name: string; value: unknown }) => void;
|
||||||
|
height?: string;
|
||||||
|
} = $props();
|
||||||
|
|
||||||
|
let option = $derived.by((): EChartsOption => {
|
||||||
|
const t = readChartTokens();
|
||||||
|
const { columns, rows } = result;
|
||||||
|
if (columns.length === 0 || rows.length === 0) {
|
||||||
|
return { ...baseOption(t), xAxis: { type: 'value' }, yAxis: { type: 'category', data: [] }, series: [] };
|
||||||
|
}
|
||||||
|
const labelIdx = config.label_column ? columns.indexOf(config.label_column) : 0;
|
||||||
|
const valueIdx = config.value_column
|
||||||
|
? columns.indexOf(config.value_column)
|
||||||
|
: (columns.findIndex((_, i) => i !== labelIdx && typeof rows[0][i] === 'number') ?? 1);
|
||||||
|
|
||||||
|
// Reversed: ECharts' category axis draws bottom-to-top, but a
|
||||||
|
// ranked "top N" list reads top-to-bottom -- reversing the arrays
|
||||||
|
// (rather than yAxis.inverse, which also flips axis-line
|
||||||
|
// placement) keeps rank #1 visually on top without touching
|
||||||
|
// anything else about the axis.
|
||||||
|
const labels = rows.map((r) => String(r[labelIdx] ?? '')).reverse();
|
||||||
|
const values = rows.map((r) => (typeof r[valueIdx] === 'number' ? (r[valueIdx] as number) : Number(r[valueIdx]) || 0)).reverse();
|
||||||
|
|
||||||
|
return {
|
||||||
|
...baseOption(t),
|
||||||
|
grid: { ...baseOption(t).grid, left: 8 },
|
||||||
|
xAxis: { ...baseOption(t).yAxis, type: 'value' },
|
||||||
|
yAxis: {
|
||||||
|
...baseOption(t).xAxis,
|
||||||
|
type: 'category',
|
||||||
|
data: labels,
|
||||||
|
axisLabel: { ...baseOption(t).xAxis.axisLabel, width: 120, overflow: 'truncate' }
|
||||||
|
},
|
||||||
|
series: [
|
||||||
|
{
|
||||||
|
type: 'bar',
|
||||||
|
data: values,
|
||||||
|
color: t.accent,
|
||||||
|
barMaxWidth: 20,
|
||||||
|
label: { show: true, position: 'right', color: t.textMuted, fontFamily: t.fontMono, fontSize: 11 }
|
||||||
|
}
|
||||||
|
]
|
||||||
|
};
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<EChart {option} {height} onPointClick={(pt) => onDrillDown?.(pt)} />
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
// Turns a clicked chart data point back into the raw-log query that
|
||||||
|
// produced it -- the "click to drill into query" affordance. Building
|
||||||
|
// this generally (rather than special-casing one panel's query) means
|
||||||
|
// stripping any aggregation stage rather than trying to parse/rewrite
|
||||||
|
// arbitrary pipe syntax: a panel's query is typically
|
||||||
|
// `service=api status>=500 | stats count by host, timestamp`, and the
|
||||||
|
// aggregated `count` a chart point represents doesn't exist as a real
|
||||||
|
// log row -- the useful drill-down is "show me the raw rows that fed
|
||||||
|
// this bucket", which means the *pre-aggregation* filter plus whatever
|
||||||
|
// grouping value was clicked, not the full original query.
|
||||||
|
|
||||||
|
export type DrillDownTarget = { query: string; earliest?: string; latest?: string };
|
||||||
|
|
||||||
|
const STATS_STAGE = /\|\s*stats\b/i;
|
||||||
|
|
||||||
|
function baseFilterQuery(query: string): string {
|
||||||
|
const idx = query.search(STATS_STAGE);
|
||||||
|
return (idx >= 0 ? query.slice(0, idx) : query).trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Quotes a value for use as a bare `field="value"` filter term --
|
||||||
|
// query-language string literals are double-quoted with backslash
|
||||||
|
// escapes, same convention field=value filters already use.
|
||||||
|
function quote(value: string): string {
|
||||||
|
return `"${value.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildDrillDownQuery(
|
||||||
|
panelQuery: string,
|
||||||
|
point: { seriesName?: string; seriesColumn?: string; xValue: number | string; isTime: boolean; bucketMs?: number }
|
||||||
|
): DrillDownTarget {
|
||||||
|
let query = baseFilterQuery(panelQuery);
|
||||||
|
|
||||||
|
if (point.seriesColumn && point.seriesName) {
|
||||||
|
query = `${query} ${point.seriesColumn}=${quote(point.seriesName)}`.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!point.isTime) {
|
||||||
|
// Non-time x axis (e.g. grouped by host) -- nothing more to add,
|
||||||
|
// the series filter above (or the x value itself, if there's no
|
||||||
|
// separate series column) already narrows it enough.
|
||||||
|
return { query };
|
||||||
|
}
|
||||||
|
|
||||||
|
const center = typeof point.xValue === 'number' ? point.xValue : Date.parse(String(point.xValue));
|
||||||
|
if (Number.isNaN(center)) return { query };
|
||||||
|
|
||||||
|
// Half the bucket width on each side when known (a clicked bar/point
|
||||||
|
// represents that whole bucket); otherwise a flat 5-minute window --
|
||||||
|
// wide enough to catch a clicked point's neighborhood without
|
||||||
|
// silently becoming "show me the whole day" like the default range
|
||||||
|
// would.
|
||||||
|
const halfWindowMs = point.bucketMs ? point.bucketMs / 2 : 5 * 60_000;
|
||||||
|
const earliest = new Date(center - halfWindowMs).toISOString();
|
||||||
|
const latest = new Date(center + halfWindowMs).toISOString();
|
||||||
|
return { query, earliest, latest };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Builds the URL the Search page's drill-down effect reads
|
||||||
|
// (?q=&earliest=&latest=) -- kept separate from buildDrillDownQuery so
|
||||||
|
// callers that already have a DrillDownTarget from elsewhere (not just
|
||||||
|
// a chart click) can link to it too.
|
||||||
|
export function drillDownUrl(target: DrillDownTarget): string {
|
||||||
|
const params = new URLSearchParams({ q: target.query });
|
||||||
|
if (target.earliest) params.set('earliest', target.earliest);
|
||||||
|
if (target.latest) params.set('latest', target.latest);
|
||||||
|
return `/?${params.toString()}`;
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
export { default as TimeSeriesChart } from './TimeSeriesChart.svelte';
|
||||||
|
export { default as BarChart } from './BarChart.svelte';
|
||||||
|
export { default as TopN } from './TopN.svelte';
|
||||||
|
export { default as Heatmap } from './Heatmap.svelte';
|
||||||
|
export { default as SingleStat } from './SingleStat.svelte';
|
||||||
|
export { pivot, parseTimeValue } from './pivot';
|
||||||
|
export { readChartTokens, baseOption, SERIES_PALETTE } from './theme';
|
||||||
|
export { buildDrillDownQuery, drillDownUrl, type DrillDownTarget } from './drilldown';
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
// Shapes a QueryResult ({columns, rows} -- the pipe-language's one
|
||||||
|
// tabular output shape, unchanged since Phase 2) into what a chart
|
||||||
|
// needs. No backend/query-language change was needed for multi-series
|
||||||
|
// output: a `stats count by service, timestamp`-style query already
|
||||||
|
// returns "long" rows (one row per service+timestamp pair) -- pivoting
|
||||||
|
// that into one series per distinct `service` value is frontend work,
|
||||||
|
// the same way PanelViz already turned {columns, rows} into a single
|
||||||
|
// uPlot series in Phase 3. viz_config's series_column key (new, but
|
||||||
|
// viz_config was already an opaque Record<string,string> the backend
|
||||||
|
// just stores/returns -- see Panel.viz_config -- so this needed no
|
||||||
|
// schema change either) tells the pivot which column to group by.
|
||||||
|
|
||||||
|
// Deliberately narrow: matches ingest's own emitted format
|
||||||
|
// ("2026-08-13T20:20:24..."), not a general ISO-8601 parser. See
|
||||||
|
// PanelViz.svelte's original doc comment on why Date.parse() alone is
|
||||||
|
// too lenient to use as a "does this look like a timestamp" check.
|
||||||
|
const isoTimestampPrefix = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}/;
|
||||||
|
|
||||||
|
export function parseTimeValue(v: unknown): number | null {
|
||||||
|
if (typeof v === 'number') return v * 1000;
|
||||||
|
if (typeof v !== 'string' || !isoTimestampPrefix.test(v)) return null;
|
||||||
|
const parsed = Date.parse(v);
|
||||||
|
return Number.isNaN(parsed) ? null : parsed;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type PivotedSeries = {
|
||||||
|
name: string;
|
||||||
|
data: [number | string, number][];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type Pivoted = {
|
||||||
|
isTime: boolean;
|
||||||
|
categories: string[]; // populated when !isTime -- category axis labels in row order
|
||||||
|
series: PivotedSeries[];
|
||||||
|
};
|
||||||
|
|
||||||
|
function columnIndex(columns: string[], name: string | undefined, fallback: number): number {
|
||||||
|
if (!name) return fallback;
|
||||||
|
const i = columns.indexOf(name);
|
||||||
|
return i >= 0 ? i : fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function pivot(
|
||||||
|
columns: string[],
|
||||||
|
rows: unknown[][],
|
||||||
|
config: { xColumn?: string; valueColumn?: string; seriesColumn?: string }
|
||||||
|
): Pivoted {
|
||||||
|
if (columns.length === 0 || rows.length === 0) {
|
||||||
|
return { isTime: false, categories: [], series: [] };
|
||||||
|
}
|
||||||
|
|
||||||
|
const xIdx = columnIndex(columns, config.xColumn, 0);
|
||||||
|
const seriesIdx = config.seriesColumn ? columns.indexOf(config.seriesColumn) : -1;
|
||||||
|
// Value column default: first numeric-looking column that isn't x/series.
|
||||||
|
// findIndex returns -1 (not null/undefined) when nothing matches, so a
|
||||||
|
// `??` fallback here never fires -- must check for -1 explicitly. This
|
||||||
|
// is the only-one-column case (e.g. a bare `stats count`, single_stat's
|
||||||
|
// most common query shape): xIdx defaults to 0, excluding the sole
|
||||||
|
// column from the search, so findIndex always returns -1 and the value
|
||||||
|
// silently read as `undefined` -> 0 without this check.
|
||||||
|
const foundValueIdx = columns.findIndex((_, i) => i !== xIdx && i !== seriesIdx && typeof rows[0][i] !== 'string');
|
||||||
|
const valueIdx =
|
||||||
|
config.valueColumn && columns.includes(config.valueColumn)
|
||||||
|
? columns.indexOf(config.valueColumn)
|
||||||
|
: foundValueIdx !== -1
|
||||||
|
? foundValueIdx
|
||||||
|
: (columns.length > 1 ? 1 : xIdx);
|
||||||
|
|
||||||
|
const isTime = rows.every((r) => parseTimeValue(r[xIdx]) !== null);
|
||||||
|
const categories: string[] = [];
|
||||||
|
const seen = new Map<string, PivotedSeries>();
|
||||||
|
|
||||||
|
for (const row of rows) {
|
||||||
|
const xRaw = row[xIdx];
|
||||||
|
const x: number | string = isTime ? (parseTimeValue(xRaw) as number) : String(xRaw ?? '');
|
||||||
|
if (!isTime && !categories.includes(String(x))) categories.push(String(x));
|
||||||
|
|
||||||
|
const seriesName = seriesIdx >= 0 ? String(row[seriesIdx] ?? '') : columns[valueIdx];
|
||||||
|
let s = seen.get(seriesName);
|
||||||
|
if (!s) {
|
||||||
|
s = { name: seriesName, data: [] };
|
||||||
|
seen.set(seriesName, s);
|
||||||
|
}
|
||||||
|
const raw = row[valueIdx];
|
||||||
|
const value = typeof raw === 'number' ? raw : Number(raw) || 0;
|
||||||
|
s.data.push([x, value]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { isTime, categories, series: [...seen.values()] };
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
// Modular ECharts registration -- pulling in `echarts` (the full bundle)
|
||||||
|
// would ship every chart type/component ECharts has ever shipped,
|
||||||
|
// against the whole reason it was picked over hand-rolled D3 for the
|
||||||
|
// bundle-size tradeoff (see the Phase 5 charting-library review). This
|
||||||
|
// registers only what Sentry's five chart types actually use: line/bar
|
||||||
|
// (time-series, stacked bar, top-N, the single-stat sparkline) and
|
||||||
|
// heatmap, plus tooltip/legend/grid/dataZoom/visualMap and the canvas
|
||||||
|
// renderer. Imported once, here, not per-component -- echarts.use() is
|
||||||
|
// idempotent but there's no reason to repeat the list five times.
|
||||||
|
import * as echarts from 'echarts/core';
|
||||||
|
import { LineChart, BarChart, HeatmapChart } from 'echarts/charts';
|
||||||
|
import {
|
||||||
|
TooltipComponent,
|
||||||
|
GridComponent,
|
||||||
|
LegendComponent,
|
||||||
|
DataZoomComponent,
|
||||||
|
VisualMapComponent,
|
||||||
|
MarkLineComponent
|
||||||
|
} from 'echarts/components';
|
||||||
|
import { CanvasRenderer } from 'echarts/renderers';
|
||||||
|
|
||||||
|
echarts.use([
|
||||||
|
LineChart,
|
||||||
|
BarChart,
|
||||||
|
HeatmapChart,
|
||||||
|
TooltipComponent,
|
||||||
|
GridComponent,
|
||||||
|
LegendComponent,
|
||||||
|
DataZoomComponent,
|
||||||
|
VisualMapComponent,
|
||||||
|
MarkLineComponent,
|
||||||
|
CanvasRenderer
|
||||||
|
]);
|
||||||
|
|
||||||
|
export { echarts };
|
||||||
|
export type EChartsOption = echarts.ComposeOption<
|
||||||
|
| import('echarts/charts').LineSeriesOption
|
||||||
|
| import('echarts/charts').BarSeriesOption
|
||||||
|
| import('echarts/charts').HeatmapSeriesOption
|
||||||
|
| import('echarts/components').TooltipComponentOption
|
||||||
|
| import('echarts/components').GridComponentOption
|
||||||
|
| import('echarts/components').LegendComponentOption
|
||||||
|
| import('echarts/components').DataZoomComponentOption
|
||||||
|
| import('echarts/components').VisualMapComponentOption
|
||||||
|
| import('echarts/components').MarkLineComponentOption
|
||||||
|
>;
|
||||||
@@ -0,0 +1,121 @@
|
|||||||
|
// ECharts renders to canvas, not the DOM -- it needs concrete color
|
||||||
|
// values, not CSS var() references. This reads the real resolved value
|
||||||
|
// of each token this module cares about directly off <html> so charts
|
||||||
|
// always match whatever theme/density is currently active instead of
|
||||||
|
// carrying a second, hand-maintained copy of the palette that can drift
|
||||||
|
// from tokens.css.
|
||||||
|
|
||||||
|
export type ChartTokens = {
|
||||||
|
text: string;
|
||||||
|
textMuted: string;
|
||||||
|
border: string;
|
||||||
|
surface: string;
|
||||||
|
surfaceRaised: string;
|
||||||
|
accent: string;
|
||||||
|
sevQuiet: string;
|
||||||
|
sevInfo: string;
|
||||||
|
sevWarn: string;
|
||||||
|
sevError: string;
|
||||||
|
sevCritical: string;
|
||||||
|
fontUI: string;
|
||||||
|
fontMono: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
function cssVar(name: string): string {
|
||||||
|
return getComputedStyle(document.documentElement).getPropertyValue(name).trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Dark-mode literal fallback for prerendering/SSR, where `document`
|
||||||
|
// doesn't exist -- adapter-static prerenders every route (including
|
||||||
|
// dev/charts) at build time, and each chart's `option` is a $derived
|
||||||
|
// that runs during that pass same as it does in the browser. The real
|
||||||
|
// values always take over immediately once the client mounts; this
|
||||||
|
// only has to look reasonable for the static HTML shell, not be
|
||||||
|
// theme-accurate (prerendering can't know the visitor's theme choice
|
||||||
|
// anyway).
|
||||||
|
const SSR_FALLBACK: ChartTokens = {
|
||||||
|
text: '#f0f0f1',
|
||||||
|
textMuted: '#85888d',
|
||||||
|
border: '#2a2c2f',
|
||||||
|
surface: '#17181a',
|
||||||
|
surfaceRaised: '#1e2023',
|
||||||
|
accent: '#3fb6ff',
|
||||||
|
sevQuiet: '#85888d',
|
||||||
|
sevInfo: '#4c8dff',
|
||||||
|
sevWarn: '#f5c242',
|
||||||
|
sevError: '#ff6a39',
|
||||||
|
sevCritical: '#ff2d78',
|
||||||
|
fontUI: 'Overpass, sans-serif',
|
||||||
|
fontMono: 'Overpass Mono, monospace'
|
||||||
|
};
|
||||||
|
|
||||||
|
export function readChartTokens(): ChartTokens {
|
||||||
|
if (typeof document === 'undefined') return SSR_FALLBACK;
|
||||||
|
return {
|
||||||
|
text: cssVar('--color-text'),
|
||||||
|
textMuted: cssVar('--color-text-muted'),
|
||||||
|
border: cssVar('--color-border'),
|
||||||
|
surface: cssVar('--color-surface'),
|
||||||
|
surfaceRaised: cssVar('--color-surface-raised'),
|
||||||
|
accent: cssVar('--color-accent'),
|
||||||
|
sevQuiet: cssVar('--color-sev-quiet'),
|
||||||
|
sevInfo: cssVar('--color-sev-info'),
|
||||||
|
sevWarn: cssVar('--color-sev-warn'),
|
||||||
|
sevError: cssVar('--color-sev-error'),
|
||||||
|
sevCritical: cssVar('--color-sev-critical'),
|
||||||
|
fontUI: cssVar('--font-ui'),
|
||||||
|
fontMono: cssVar('--font-mono')
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// A fixed categorical palette for multi-series charts (hosts, services,
|
||||||
|
// etc. -- data that isn't severity-shaped, so severity's blue/amber/
|
||||||
|
// orange/magenta ramp doesn't apply). Chosen to stay distinguishable
|
||||||
|
// from the four severity colors above (no orange/magenta/amber-gold
|
||||||
|
// here) so a legend never makes a non-severity series look like it's
|
||||||
|
// signaling a severity. Colorblind-conscious: alternates hue and
|
||||||
|
// lightness, not just hue.
|
||||||
|
export const SERIES_PALETTE = [
|
||||||
|
'#3fb6ff', // accent blue
|
||||||
|
'#6fd6b0', // teal-green
|
||||||
|
'#b48cff', // violet
|
||||||
|
'#5c8dff', // periwinkle
|
||||||
|
'#4dd0e1', // cyan
|
||||||
|
'#8bc34a', // olive-green
|
||||||
|
'#7986cb', // indigo
|
||||||
|
'#4db6ac' // seafoam
|
||||||
|
];
|
||||||
|
|
||||||
|
// Shared base option every chart type extends -- background transparent
|
||||||
|
// (the card behind it supplies --color-surface), grid inset for axis
|
||||||
|
// labels, tooltip/legend/axis text all pulled from tokens so nothing is
|
||||||
|
// hardcoded per chart type.
|
||||||
|
export function baseOption(t: ChartTokens) {
|
||||||
|
return {
|
||||||
|
backgroundColor: 'transparent',
|
||||||
|
textStyle: { fontFamily: t.fontUI, color: t.text },
|
||||||
|
grid: { left: 48, right: 16, top: 28, bottom: 28, containLabel: true },
|
||||||
|
tooltip: {
|
||||||
|
backgroundColor: t.surfaceRaised,
|
||||||
|
borderColor: t.border,
|
||||||
|
borderWidth: 1,
|
||||||
|
textStyle: { color: t.text, fontFamily: t.fontMono, fontSize: 12 },
|
||||||
|
extraCssText: 'box-shadow: var(--shadow-md); border-radius: 6px;'
|
||||||
|
},
|
||||||
|
legend: {
|
||||||
|
textStyle: { color: t.textMuted, fontFamily: t.fontUI, fontSize: 12 },
|
||||||
|
inactiveColor: t.border,
|
||||||
|
top: 0
|
||||||
|
},
|
||||||
|
xAxis: {
|
||||||
|
axisLine: { lineStyle: { color: t.border } },
|
||||||
|
axisLabel: { color: t.textMuted, fontFamily: t.fontMono, fontSize: 11 },
|
||||||
|
splitLine: { show: false }
|
||||||
|
},
|
||||||
|
yAxis: {
|
||||||
|
axisLine: { show: false },
|
||||||
|
axisLabel: { color: t.textMuted, fontFamily: t.fontMono, fontSize: 11 },
|
||||||
|
splitLine: { lineStyle: { color: t.border, type: 'dashed' as const } }
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,190 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
// Fixture data + a large-N stress case, per Phase 5 task 4's "test
|
||||||
|
// with a fixture dataset large enough to expose rendering perf
|
||||||
|
// issues" requirement -- not just a handful of rows. Render time is
|
||||||
|
// measured and shown next to each large chart so the perf claim in
|
||||||
|
// /docs/design-system.md is backed by a number produced here, not an
|
||||||
|
// assumption.
|
||||||
|
import { TimeSeriesChart, BarChart, TopN, Heatmap, SingleStat } from '$lib/charts';
|
||||||
|
import { Card } from '$lib/components/ui';
|
||||||
|
import type { QueryResult } from '$lib/api';
|
||||||
|
|
||||||
|
const SERVICES = ['api', 'ingest', 'alerting', 'enterprise-auth', 'search', 'clickhouse'];
|
||||||
|
const HOSTS = ['host-01', 'host-02', 'host-03', 'host-04', 'host-05', 'host-06', 'host-07', 'host-08'];
|
||||||
|
|
||||||
|
function iso(ms: number): string {
|
||||||
|
return new Date(ms).toISOString();
|
||||||
|
}
|
||||||
|
|
||||||
|
function realisticTimeSeries(): QueryResult {
|
||||||
|
// 3 services, one point every 3 minutes over 24h -- ~480 points/series,
|
||||||
|
// 1440 rows total, the shape a real "error count by service over the
|
||||||
|
// default 24h dashboard range" panel actually returns.
|
||||||
|
const now = Date.now();
|
||||||
|
const rows: unknown[][] = [];
|
||||||
|
for (const service of SERVICES.slice(0, 3)) {
|
||||||
|
let base = 5 + Math.random() * 10;
|
||||||
|
for (let i = 480; i >= 0; i--) {
|
||||||
|
base = Math.max(0, base + (Math.random() - 0.5) * 3);
|
||||||
|
rows.push([iso(now - i * 3 * 60_000), service, Math.round(base)]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { columns: ['timestamp', 'service', 'count'], rows };
|
||||||
|
}
|
||||||
|
|
||||||
|
function largeTimeSeries(): QueryResult {
|
||||||
|
// 6 series x 5000 points = 30,000 rows -- well past what a
|
||||||
|
// realistic dashboard panel would ever show, deliberately, to
|
||||||
|
// find the rendering ceiling rather than assume one.
|
||||||
|
const now = Date.now();
|
||||||
|
const rows: unknown[][] = [];
|
||||||
|
for (const service of SERVICES) {
|
||||||
|
let base = 20 + Math.random() * 30;
|
||||||
|
for (let i = 5000; i >= 0; i--) {
|
||||||
|
base = Math.max(0, base + (Math.random() - 0.5) * 4);
|
||||||
|
rows.push([iso(now - i * 15_000), service, Math.round(base)]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { columns: ['timestamp', 'service', 'count'], rows };
|
||||||
|
}
|
||||||
|
|
||||||
|
function stackedBar(): QueryResult {
|
||||||
|
const rows: unknown[][] = [];
|
||||||
|
for (const host of HOSTS) {
|
||||||
|
for (const sev of ['INFO', 'WARN', 'ERROR', 'FATAL']) {
|
||||||
|
const base = sev === 'INFO' ? 200 : sev === 'WARN' ? 40 : sev === 'ERROR' ? 12 : 2;
|
||||||
|
rows.push([host, sev, Math.round(base + Math.random() * base * 0.6)]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { columns: ['host', 'severity', 'count'], rows };
|
||||||
|
}
|
||||||
|
|
||||||
|
function topN(): QueryResult {
|
||||||
|
const rows = SERVICES.map((s, i) => [s, 1200 - i * 180 + Math.round(Math.random() * 60)])
|
||||||
|
.sort((a, b) => (b[1] as number) - (a[1] as number));
|
||||||
|
return { columns: ['service', 'count'], rows };
|
||||||
|
}
|
||||||
|
|
||||||
|
function heatmapData(): QueryResult {
|
||||||
|
// hour-of-day x day-of-week log volume -- the canonical "when do
|
||||||
|
// we get paged" heatmap.
|
||||||
|
const days = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'];
|
||||||
|
const rows: unknown[][] = [];
|
||||||
|
for (let h = 0; h < 24; h++) {
|
||||||
|
for (const day of days) {
|
||||||
|
const businessHours = h >= 9 && h <= 18 && day !== 'Sat' && day !== 'Sun';
|
||||||
|
const base = businessHours ? 80 : 15;
|
||||||
|
rows.push([String(h).padStart(2, '0'), day, Math.round(base + Math.random() * base * 0.8)]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { columns: ['hour', 'day', 'count'], rows };
|
||||||
|
}
|
||||||
|
|
||||||
|
function singleStatSeries(): QueryResult {
|
||||||
|
const now = Date.now();
|
||||||
|
const rows: unknown[][] = [];
|
||||||
|
let base = 12;
|
||||||
|
for (let i = 60; i >= 0; i--) {
|
||||||
|
base = Math.max(0, base + (Math.random() - 0.5) * 2);
|
||||||
|
rows.push([iso(now - i * 60_000), Math.round(base * 10) / 10]);
|
||||||
|
}
|
||||||
|
return { columns: ['timestamp', 'p99_latency_ms'], rows };
|
||||||
|
}
|
||||||
|
|
||||||
|
function timeit<T>(fn: () => T): [T, number] {
|
||||||
|
const start = performance.now();
|
||||||
|
const result = fn();
|
||||||
|
return [result, performance.now() - start];
|
||||||
|
}
|
||||||
|
|
||||||
|
const [realisticTS, realisticTSMs] = timeit(realisticTimeSeries);
|
||||||
|
const [largeTS, largeTSGenMs] = timeit(largeTimeSeries);
|
||||||
|
const [stacked] = timeit(stackedBar);
|
||||||
|
const [ranked] = timeit(topN);
|
||||||
|
const [heat] = timeit(heatmapData);
|
||||||
|
const [statSeries] = timeit(singleStatSeries);
|
||||||
|
|
||||||
|
let largeRenderMs = $state<number | null>(null);
|
||||||
|
$effect(() => {
|
||||||
|
// EChart's own render happens inside the component's effect, one
|
||||||
|
// tick after mount -- measuring from here via rAF brackets the
|
||||||
|
// actual paint, not just this page's synchronous data prep above.
|
||||||
|
const start = performance.now();
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
largeRenderMs = performance.now() - start;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:head><title>Chart fixtures — dev</title></svelte:head>
|
||||||
|
|
||||||
|
<main>
|
||||||
|
<h1>Chart layer fixtures</h1>
|
||||||
|
<p class="lede">
|
||||||
|
Not part of the app's nav — a living test page for every chart type in <code>$lib/charts</code>,
|
||||||
|
including a large-N dataset for the perf-verification Phase 5 task 4 asked for. Fixture
|
||||||
|
generation: realistic time-series {realisticTSMs.toFixed(1)}ms for {realisticTS.rows.length} rows;
|
||||||
|
large time-series {largeTSGenMs.toFixed(1)}ms for {largeTS.rows.length} rows.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="grid">
|
||||||
|
<Card title="Time-series — multi-series overlay, legend toggle, zoom/pan (realistic: 3 services × ~480pts)">
|
||||||
|
<TimeSeriesChart result={realisticTS} config={{ series_column: 'service' }} />
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card title="Bar — stacked by severity">
|
||||||
|
<BarChart result={stacked} config={{ series_column: 'severity', stacked: 'true' }} />
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card title="Single-stat — sparkline + trend">
|
||||||
|
<SingleStat result={statSeries} config={{ unit: 'ms', higher_is_worse: 'true' }} />
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card title="Heatmap — hour × day log volume">
|
||||||
|
<Heatmap result={heat} config={{ x_column: 'hour', y_column: 'day', value_column: 'count' }} />
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card title="Top-N — ranked horizontal bar">
|
||||||
|
<TopN result={ranked} />
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h2>Perf stress case</h2>
|
||||||
|
<p class="lede">
|
||||||
|
{largeTS.rows.length.toLocaleString()} rows, {SERVICES.length} series, canvas renderer.
|
||||||
|
{#if largeRenderMs !== null}
|
||||||
|
First two painted frames after mount: <strong>{largeRenderMs.toFixed(0)}ms</strong>.
|
||||||
|
{/if}
|
||||||
|
Try zooming (drag on the chart or the bottom slider) and toggling a legend entry — both should
|
||||||
|
stay responsive at this volume.
|
||||||
|
</p>
|
||||||
|
<Card>
|
||||||
|
<TimeSeriesChart result={largeTS} config={{ series_column: 'service' }} height="360px" />
|
||||||
|
</Card>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
main {
|
||||||
|
max-width: 75rem;
|
||||||
|
}
|
||||||
|
h1 {
|
||||||
|
font-size: var(--text-xl);
|
||||||
|
margin-bottom: var(--space-2);
|
||||||
|
}
|
||||||
|
h2 {
|
||||||
|
font-size: var(--text-lg);
|
||||||
|
margin: var(--space-6) 0 var(--space-2);
|
||||||
|
}
|
||||||
|
.lede {
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
max-width: 70ch;
|
||||||
|
}
|
||||||
|
.grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(22rem, 1fr));
|
||||||
|
gap: var(--space-4);
|
||||||
|
margin-top: var(--space-4);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
// Not linked from the app's nav or command palette -- a living fixture
|
||||||
|
// page for verifying the chart layer (rendering, theme/density
|
||||||
|
// reactivity, and performance at realistic data volumes) without a live
|
||||||
|
// backend. Kept in the repo rather than thrown away after Phase 5's
|
||||||
|
// build pass: any future chart change can be sanity-checked here first.
|
||||||
|
export const prerender = true;
|
||||||
Reference in New Issue
Block a user