Saved, shareable multi-panel dashboards (table/line/bar/single-stat panels via gridstack + uPlot, global + per-panel time range, JSON export/import) and threshold/absence alert rules with an ok/pending/firing evaluator and webhook/Slack/PagerDuty delivery. - New /metadata component: Postgres control-plane store for dashboards, panels, notification targets, alert rules/state, and delivery log -- see docs/phase-3-dashboard-design.md for why ClickHouse's MergeTree family isn't a fit for this access pattern (needs real row-level locking and read-your-writes consistency). - api/internal/dashboards: dashboard/panel CRUD, pure -- panel query execution stays client-side, reusing the existing /query endpoint. - New /alerting service: rule/target CRUD, a ticker-driven evaluator (claim-then-evaluate concurrency control, transactional-outbox delivery, query errors and threshold zero-rows never coerced into a false transition) and webhook/Slack/PagerDuty delivery with retry/backoff. See docs/phase-3-alerting-design.md for the full state-machine design and the four correctness properties it implements. - web: /dashboards and /alerts UIs; cli: sentryctl dashboards/alerts list/get/apply, seeding a future Terraform provider's JSON contract. - hack/alert-load-test: 500 rules against real ClickHouse data, real measured results in docs/phase-3-runbook.md. Five real bugs found by actually running this against a live stack (documented in the runbook, not just fixed silently): a latent Phase 2 bug where ClickHouse rejected the timestamp format used for earliest=/latest= queries; a "now" literal token injected into query text; a GridStack/uPlot layout-timing race; JS's Date.parse being too lenient to use as a timestamp-detection heuristic; a rule's "enabled" field silently defaulting to false when omitted; and the evaluator's claim-batch-size and worker-pool-concurrency defaulting to the same value, causing 500 concurrently-due rules to take 125s to cycle through instead of the configured 60s.
148 lines
4.9 KiB
Svelte
148 lines
4.9 KiB
Svelte
<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';
|
|
import ResultsTable from '$lib/ResultsTable.svelte';
|
|
import type { QueryResult, VizType } from '$lib/api';
|
|
|
|
let {
|
|
result,
|
|
vizType,
|
|
vizConfig = {}
|
|
}: { result: QueryResult; vizType: VizType; vizConfig?: Record<string, string> } = $props();
|
|
|
|
let chartEl: HTMLDivElement | undefined = $state();
|
|
let chart: uPlot | undefined;
|
|
|
|
// ISO-8601-ish prefix ("2026-08-13T20:20:24...") -- the shape Sentry's
|
|
// own timestamp column actually comes back as. Deliberately narrow:
|
|
// found by actually rendering a `stats count by host` bar chart that
|
|
// JS's built-in Date.parse() is far too lenient to use as a "does
|
|
// this look like a timestamp" check -- Date.parse("host-06") returns
|
|
// a real (bogus) timestamp rather than NaN, which silently misrouted
|
|
// a categorical `host` column onto a numeric time axis and rendered
|
|
// unreadable giant tick labels instead of host names.
|
|
const isoTimestampPrefix = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}/;
|
|
|
|
// Prefers a real numeric/time x-axis when the column parses cleanly
|
|
// (e.g. a timestamp column); falls back to row index with the raw
|
|
// value as a tick label otherwise (e.g. a `stats ... by host` grouping
|
|
// column, which is categorical text).
|
|
function resolveXAxis(columns: string[], rows: unknown[][], columnName: string | undefined) {
|
|
const idx = columnName ? Math.max(columns.indexOf(columnName), 0) : 0;
|
|
const labels = rows.map((r) => String(r[idx] ?? ''));
|
|
const asSeconds = rows.map((r) => {
|
|
const v = r[idx];
|
|
if (typeof v === 'number') return v;
|
|
if (typeof v !== 'string' || !isoTimestampPrefix.test(v)) return NaN;
|
|
const parsed = Date.parse(v);
|
|
return Number.isNaN(parsed) ? NaN : parsed / 1000;
|
|
});
|
|
const allNumeric = asSeconds.every((n) => !Number.isNaN(n));
|
|
return allNumeric
|
|
? { values: asSeconds, labels, categorical: false }
|
|
: { values: rows.map((_, i) => i), labels, categorical: true };
|
|
}
|
|
|
|
function resolveValueColumn(columns: string[], columnName: string | undefined): number {
|
|
if (columnName) {
|
|
const i = columns.indexOf(columnName);
|
|
if (i >= 0) return i;
|
|
}
|
|
// default: second column if there is one (first is usually the
|
|
// grouping/x column), otherwise the only column there is.
|
|
return columns.length > 1 ? 1 : 0;
|
|
}
|
|
|
|
function renderChart() {
|
|
if (!chartEl) return;
|
|
chart?.destroy();
|
|
chart = undefined;
|
|
if (vizType !== 'line' && vizType !== 'bar') return;
|
|
|
|
const { columns, rows } = result;
|
|
if (columns.length === 0 || rows.length === 0) return;
|
|
|
|
const x = resolveXAxis(columns, rows, vizConfig.x_column);
|
|
const valueIdx = resolveValueColumn(columns, vizConfig.value_column);
|
|
const values = rows.map((r) => {
|
|
const v = r[valueIdx];
|
|
return typeof v === 'number' ? v : Number(v) || 0;
|
|
});
|
|
|
|
chart = new uPlot(
|
|
{
|
|
width: chartEl.clientWidth || 400,
|
|
height: 220,
|
|
legend: { show: false },
|
|
series: [
|
|
{},
|
|
{
|
|
label: columns[valueIdx],
|
|
stroke: '#06c',
|
|
fill: vizType === 'bar' ? '#06c33' : undefined,
|
|
width: vizType === 'bar' ? 0 : 2,
|
|
paths: vizType === 'bar' ? uPlot.paths.bars!({ size: [0.6] }) : undefined
|
|
}
|
|
],
|
|
axes: [
|
|
{
|
|
values: (_u, ticks) =>
|
|
ticks.map((t) => (x.categorical ? (x.labels[t] ?? '') : String(t)))
|
|
},
|
|
{}
|
|
]
|
|
},
|
|
[x.values, values],
|
|
chartEl
|
|
);
|
|
}
|
|
|
|
$effect(() => {
|
|
// Re-render whenever the result or viz settings change.
|
|
result;
|
|
vizType;
|
|
vizConfig;
|
|
renderChart();
|
|
return () => chart?.destroy();
|
|
});
|
|
|
|
// 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();
|
|
});
|
|
</script>
|
|
|
|
{#if vizType === 'table' || vizType === 'top_n'}
|
|
<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>
|
|
{/if}
|
|
|
|
<style>
|
|
.single-stat {
|
|
font-size: 2.5rem;
|
|
font-weight: 600;
|
|
padding: 1rem 0;
|
|
}
|
|
.chart {
|
|
width: 100%;
|
|
}
|
|
</style>
|