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"
|
||||
VizSingleStat VizType = "single_stat"
|
||||
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 {
|
||||
switch v {
|
||||
case VizTable, VizLine, VizBar, VizSingleStat, VizTopN:
|
||||
case VizTable, VizLine, VizBar, VizSingleStat, VizTopN, VizHeatmap:
|
||||
return true
|
||||
default:
|
||||
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")
|
||||
}
|
||||
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 {
|
||||
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'));
|
||||
+69
-128
@@ -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;
|
||||
let isTimeSeries = $derived.by(() => {
|
||||
if (vizType !== 'line' && vizType !== 'bar') return false;
|
||||
return pivot(result.columns, result.rows, { xColumn: vizConfig.x_column }).isTime;
|
||||
});
|
||||
|
||||
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
|
||||
);
|
||||
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));
|
||||
}
|
||||
|
||||
$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 --
|
||||
// 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 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>
|
||||
|
||||
@@ -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