Phase 3: dashboards and alerting

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.
This commit is contained in:
2026-08-13 17:29:38 -07:00
parent fb5049a747
commit 9435115ab7
88 changed files with 7463 additions and 298 deletions
+10 -1
View File
@@ -4,6 +4,15 @@ server {
index index.html;
location / {
try_files $uri $uri/ /index.html;
# adapter-static writes prerendered routes as flat <route>.html
# files (e.g. /dashboards -> dashboards.html, confirmed by
# actually inspecting the build output), not <route>/index.html --
# $uri.html has to be in this chain or a request for exactly
# "/dashboards" falls straight through to the SPA fallback and
# skips the prerendered page it should be serving. 200.html (not
# index.html) is the fallback shell for genuinely dynamic routes
# (dashboards/[id] etc.) -- named differently so it doesn't
# collide with "/" -> index.html, which really is prerendered.
try_files $uri $uri.html $uri/ /200.html;
}
}
+26
View File
@@ -7,6 +7,10 @@
"": {
"name": "web",
"version": "0.0.1",
"dependencies": {
"gridstack": "^11.5.0",
"uplot": "^1.6.32"
},
"devDependencies": {
"@sveltejs/adapter-static": "^3.0.10",
"@sveltejs/kit": "^2.63.0",
@@ -631,6 +635,22 @@
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"node_modules/gridstack": {
"version": "11.5.1",
"resolved": "https://registry.npmjs.org/gridstack/-/gridstack-11.5.1.tgz",
"integrity": "sha512-qgbH65F6TtyKyi9t6fCkrxLhiobgYR3RBjnK0AzZl+YO7hreMVlsZ1MFbkPV0+7ZhjXdvcaRSZp3UA1yAJqYBQ==",
"funding": [
{
"type": "paypal",
"url": "https://www.paypal.me/alaind831"
},
{
"type": "venmo",
"url": "https://www.venmo.com/adumesny"
}
],
"license": "MIT"
},
"node_modules/is-reference": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/is-reference/-/is-reference-3.0.3.tgz",
@@ -1229,6 +1249,12 @@
"node": ">=14.17"
}
},
"node_modules/uplot": {
"version": "1.6.32",
"resolved": "https://registry.npmjs.org/uplot/-/uplot-1.6.32.tgz",
"integrity": "sha512-KIMVnG68zvu5XXUbC4LQEPnhwOxBuLyW1AHtpm6IKTXImkbLgkMy+jabjLgSLMasNuGGzQm/ep3tOkyTxpiQIw==",
"license": "MIT"
},
"node_modules/vite": {
"version": "8.2.1",
"resolved": "https://registry.npmjs.org/vite/-/vite-8.2.1.tgz",
+4
View File
@@ -19,5 +19,9 @@
"svelte-check": "^4.6.0",
"typescript": "^6.0.3",
"vite": "^8.0.16"
},
"dependencies": {
"gridstack": "^11.5.0",
"uplot": "^1.6.32"
}
}
+147
View File
@@ -0,0 +1,147 @@
<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>
+93
View File
@@ -0,0 +1,93 @@
<script lang="ts">
// Extracted from the root query page in Phase 3 so the dashboard panel
// editor and the alert rule editor can reuse the same input --
// deliberately just the input+run affordance, not results/history,
// which differ per consumer.
import type { Language } from '$lib/api';
let {
query = $bindable(''),
language = $bindable<Language>(''),
onRun,
loading = false,
placeholder = 'service=api | where status>=500 | stats count by host | sort -count'
}: {
query: string;
language: Language;
onRun: () => void;
loading?: boolean;
placeholder?: string;
} = $props();
// Client-side mirror of the backend's auto-detect heuristic
// (api/internal/querylang/planner.looksLikeSQL) -- purely a UI hint,
// the server does its own detection independently and is the
// authority on what actually runs.
function detectedLanguage(q: string): 'sql' | 'spl' {
return /^\s*select\b/i.test(q) ? 'sql' : 'spl';
}
let detected = $derived(detectedLanguage(query));
let effectiveLanguage = $derived(language === '' ? detected : language);
function onKeydown(e: KeyboardEvent) {
// Cmd/Ctrl+Enter runs the query -- textarea's own Enter key needs
// to stay newline-for-pipe-stage-formatting, so this isn't a bare
// Enter binding.
if ((e.metaKey || e.ctrlKey) && e.key === 'Enter') {
e.preventDefault();
onRun();
}
}
</script>
<div class="query-bar">
<textarea bind:value={query} onkeydown={onKeydown} rows="4" spellcheck="false" {placeholder}></textarea>
<div class="controls">
<label>
Language:
<select bind:value={language}>
<option value="">Auto ({detected})</option>
<option value="spl">Pipe syntax</option>
<option value="sql">SQL</option>
</select>
</label>
<span class="detected-badge" class:sql={effectiveLanguage === 'sql'}>
{effectiveLanguage === 'sql' ? 'SQL' : 'pipe syntax'}
</span>
<button onclick={onRun} disabled={loading || query.trim() === ''}>
{loading ? 'Running…' : 'Run query'}
</button>
<span class="hint">⌘/Ctrl+Enter to run</span>
</div>
</div>
<style>
.query-bar textarea {
width: 100%;
font-family: monospace;
font-size: 0.9rem;
box-sizing: border-box;
}
.controls {
margin-top: 0.5rem;
display: flex;
align-items: center;
gap: 0.75rem;
flex-wrap: wrap;
}
.detected-badge {
font-size: 0.75rem;
padding: 0.15rem 0.5rem;
border-radius: 1rem;
background: #eef;
color: #224;
}
.detected-badge.sql {
background: #fee;
color: #422;
}
.hint {
font-size: 0.8rem;
color: #777;
}
</style>
+242
View File
@@ -0,0 +1,242 @@
// First real API-client module -- previously each route did its own
// inline fetch(). Introduced in Phase 3 because the surface triples
// (query + dashboards + panels + export/import); still zero-dependency,
// a thin fetch wrapper, not a generated client.
export const apiBase = import.meta.env.VITE_API_BASE_URL ?? 'http://localhost:8080';
export const alertingBase = import.meta.env.VITE_ALERTING_API_BASE_URL ?? 'http://localhost:8081';
export type Language = '' | 'sql' | 'spl';
export type QueryResult = { columns: string[]; rows: unknown[][] };
export type VizType = 'table' | 'line' | 'bar' | 'single_stat' | 'top_n';
export type Panel = {
id: string;
dashboard_id: string;
title: string;
query: string;
query_language: Language;
viz_type: VizType;
viz_config: Record<string, string>;
position_x: number;
position_y: number;
width: number;
height: number;
earliest_override: string | null;
latest_override: string | null;
sort_order: number;
};
export type Dashboard = {
id: string;
name: string;
description: string;
default_earliest: string;
default_latest: string;
created_at: string;
updated_at: string;
panels: Panel[] | null;
};
class ApiError extends Error {}
async function requestFrom<T>(base: string, path: string, init?: RequestInit): Promise<T> {
const res = await fetch(`${base}${path}`, {
headers: { 'Content-Type': 'application/json' },
...init
});
if (!res.ok) {
let message = `request failed with status ${res.status}`;
try {
const body = await res.json();
if (body?.error) message = body.error;
} catch {
// non-JSON error body -- keep the generic message
}
throw new ApiError(message);
}
if (res.status === 204) return undefined as T;
return res.json();
}
function request<T>(path: string, init?: RequestInit): Promise<T> {
return requestFrom(apiBase, path, init);
}
// alerting is a separate service (its own base URL) -- see
// /docs/phase-3-alerting-design.md's component boundary.
function alertingRequest<T>(path: string, init?: RequestInit): Promise<T> {
return requestFrom(alertingBase, path, init);
}
export function runQuery(query: string, language: Language): Promise<QueryResult> {
return request('/query', { method: 'POST', body: JSON.stringify({ query, language }) });
}
export function listDashboards(): Promise<Dashboard[]> {
return request('/dashboards').then((d) => (d as Dashboard[]) ?? []);
}
export function getDashboard(id: string): Promise<Dashboard> {
return request(`/dashboards/${id}`);
}
export function createDashboard(input: {
name: string;
description?: string;
default_earliest?: string;
default_latest?: string;
}): Promise<Dashboard> {
return request('/dashboards', { method: 'POST', body: JSON.stringify(input) });
}
export function updateDashboard(
id: string,
input: { name: string; description?: string; default_earliest?: string; default_latest?: string }
): Promise<Dashboard> {
return request(`/dashboards/${id}`, { method: 'PUT', body: JSON.stringify(input) });
}
export function deleteDashboard(id: string): Promise<void> {
return request(`/dashboards/${id}`, { method: 'DELETE' });
}
export function addPanel(dashboardId: string, panel: Partial<Panel>): Promise<Panel> {
return request(`/dashboards/${dashboardId}/panels`, { method: 'POST', body: JSON.stringify(panel) });
}
export function updatePanel(dashboardId: string, panel: Partial<Panel>): Promise<Panel> {
return request(`/dashboards/${dashboardId}/panels/${panel.id}`, {
method: 'PUT',
body: JSON.stringify(panel)
});
}
export function deletePanel(dashboardId: string, panelId: string): Promise<void> {
return request(`/dashboards/${dashboardId}/panels/${panelId}`, { method: 'DELETE' });
}
export function exportDashboard(id: string): Promise<Dashboard> {
return request(`/dashboards/${id}/export`);
}
export function importDashboard(dashboard: Dashboard): Promise<Dashboard> {
return request('/dashboards/import', { method: 'POST', body: JSON.stringify(dashboard) });
}
// resolveTimeRange applies the override-or-default rule from
// /docs/phase-3-dashboard-design.md's "Time-range mechanics": a panel's
// own earliest/latest override wins if set, otherwise the dashboard's
// default applies.
export function resolveTimeRange(
dashboard: Pick<Dashboard, 'default_earliest' | 'default_latest'>,
panel: Pick<Panel, 'earliest_override' | 'latest_override'>
): { earliest: string; latest: string } {
return {
earliest: panel.earliest_override ?? dashboard.default_earliest,
latest: panel.latest_override ?? dashboard.default_latest
};
}
// injectTimeRange prepends earliest=/latest= as leading base_search
// terms -- works because they're ordinary implicit-AND terms in Phase
// 2's grammar, order-independent. Never used for raw-SQL panels (the
// dashboards API rejects query_language: "sql" on panels entirely, so
// this never has to handle that case).
//
// "now" is a UI-only sentinel (the default_latest value shown in the
// time-range picker), not a token the query language understands --
// time_expr only accepts a quoted absolute timestamp or a "-N unit"
// relative offset (see /docs/query-language-design.md). Emitting a
// literal `latest=now` produces a real compile error ("expected a
// quoted absolute timestamp or a relative offset"), caught by actually
// running this against the live stack. Omitting the latest= clause
// entirely is the query language's own way of saying "no upper bound",
// which is exactly what "now" means here.
export function injectTimeRange(query: string, earliest: string, latest: string): string {
const clauses = [`earliest=${earliest}`];
if (latest && latest !== 'now') clauses.push(`latest=${latest}`);
return `${clauses.join(' ')} ${query}`;
}
// --- alerting ---------------------------------------------------------
export type ConditionType = 'threshold' | 'absence';
export type Comparator = 'gt' | 'gte' | 'lt' | 'lte' | 'eq' | 'ne';
export type NotificationKind = 'webhook' | 'slack' | 'pagerduty';
export type AlertRuleState = 'ok' | 'pending' | 'firing';
export type NotificationTarget = {
id: string;
name: string;
kind: NotificationKind;
webhook_url: string;
};
export type AlertRule = {
id: string;
name: string;
description: string;
query: string;
query_language: Language;
condition_type: ConditionType;
comparator?: Comparator;
threshold_value?: number;
eval_interval_seconds: number;
for_minutes: number;
renotify_interval_minutes?: number;
notification_target_id: string;
enabled: boolean;
state: {
state: AlertRuleState;
last_evaluated_at?: string;
last_eval_status: 'ok' | 'error';
last_error?: string;
last_value?: number;
consecutive_errors: number;
};
};
export type DeliveryLogEntry = {
id: number;
event_type: 'firing' | 'resolved';
status: 'pending' | 'sent' | 'failed' | 'retrying';
attempt_count: number;
last_error?: string;
response_status?: number;
created_at: string;
};
export function listRules(): Promise<AlertRule[]> {
return alertingRequest<AlertRule[]>('/rules').then((r) => r ?? []);
}
export function getRule(id: string): Promise<AlertRule> {
return alertingRequest(`/rules/${id}`);
}
export function createRule(input: Partial<AlertRule>): Promise<AlertRule> {
return alertingRequest('/rules', { method: 'POST', body: JSON.stringify(input) });
}
export function deleteRule(id: string): Promise<void> {
return alertingRequest(`/rules/${id}`, { method: 'DELETE' });
}
export function listDeliveries(ruleId: string): Promise<DeliveryLogEntry[]> {
return alertingRequest<DeliveryLogEntry[]>(`/rules/${ruleId}/deliveries`).then((d) => d ?? []);
}
export function listNotificationTargets(): Promise<NotificationTarget[]> {
return alertingRequest<NotificationTarget[]>('/targets').then((t) => t ?? []);
}
export function createNotificationTarget(input: {
name: string;
kind: NotificationKind;
webhook_url: string;
}): Promise<NotificationTarget> {
return alertingRequest('/targets', { method: 'POST', body: JSON.stringify(input) });
}
+28
View File
@@ -1,6 +1,9 @@
<script lang="ts">
import favicon from '$lib/assets/favicon.svg';
// Phase 3: dashboards + alerts routes added, so there's now more than
// one page -- a minimal nav replaces the previous "no nav, one page"
// layout.
let { children } = $props();
</script>
@@ -9,4 +12,29 @@
<link rel="icon" href={favicon} />
</svelte:head>
<nav>
<a href="/">Query</a>
<a href="/dashboards">Dashboards</a>
<a href="/alerts">Alerts</a>
</nav>
{@render children()}
<style>
nav {
font-family: system-ui, sans-serif;
max-width: 960px;
margin: 1rem auto 0;
padding: 0 1rem;
display: flex;
gap: 1.25rem;
}
nav a {
color: #06c;
text-decoration: none;
font-size: 0.9rem;
}
nav a:hover {
text-decoration: underline;
}
</style>
+10 -92
View File
@@ -2,12 +2,14 @@
// Phase 2: single unified query page. Replaces Phase 0/1's two
// separate pages (raw-SQL-only /query, free-text-only /search) --
// see /docs/query-language-design.md and /docs/query-language-reference.md.
// Phase 3: query bar extracted into $lib/QueryBar.svelte (reused by
// the dashboard panel editor and alert rule editor), fetch calls
// moved into $lib/api.ts.
import ResultsTable from '$lib/ResultsTable.svelte';
import QueryBar from '$lib/QueryBar.svelte';
import { runQuery as apiRunQuery, type Language } from '$lib/api';
const apiBase = import.meta.env.VITE_API_BASE_URL ?? 'http://localhost:8080';
type Language = '' | 'sql' | 'spl';
type HistoryEntry = { query: string; language: Language; at: number };
const HISTORY_KEY = 'sentry.queryHistory';
@@ -22,16 +24,6 @@
let hasRun = $state(false);
let history = $state<HistoryEntry[]>(loadHistory());
// Client-side mirror of the backend's auto-detect heuristic
// (api/internal/querylang/planner.looksLikeSQL) -- purely a UI hint,
// the server does its own detection independently and is the
// authority on what actually runs.
function detectedLanguage(q: string): 'sql' | 'spl' {
return /^\s*select\b/i.test(q) ? 'sql' : 'spl';
}
let detected = $derived(detectedLanguage(query));
let effectiveLanguage = $derived(language === '' ? detected : language);
function loadHistory(): HistoryEntry[] {
if (typeof sessionStorage === 'undefined') return [];
try {
@@ -61,20 +53,9 @@
loading = true;
error = '';
try {
const res = await fetch(`${apiBase}/query`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ query, language })
});
const body = await res.json();
if (!res.ok) {
error = body?.error ?? `request failed with status ${res.status}`;
columns = [];
rows = [];
return;
}
columns = body.columns ?? [];
rows = body.rows ?? [];
const result = await apiRunQuery(query, language);
columns = result.columns ?? [];
rows = result.rows ?? [];
saveHistory({ query, language, at: Date.now() });
} catch (e) {
error = e instanceof Error ? e.message : String(e);
@@ -85,16 +66,6 @@
hasRun = true;
}
}
function onKeydown(e: KeyboardEvent) {
// Cmd/Ctrl+Enter runs the query -- textarea's own Enter key needs
// to stay newline-for-pipe-stage-formatting, so this isn't a bare
// Enter binding.
if ((e.metaKey || e.ctrlKey) && e.key === 'Enter') {
e.preventDefault();
runQuery();
}
}
</script>
<main>
@@ -102,35 +73,10 @@
<p>
One query bar for both filter/stats queries and free-text search — see
<code>/docs/query-language-reference.md</code> in the repo for the full syntax, or the cheat
sheet below.
sheet below. Build reusable queries into a <a href="/dashboards">dashboard</a>.
</p>
<textarea
bind:value={query}
onkeydown={onKeydown}
rows="4"
cols="100"
spellcheck="false"
placeholder={'service=api | where status>=500 | stats count by host | sort -count'}
></textarea>
<div class="controls">
<label>
Language:
<select bind:value={language}>
<option value="">Auto ({detected})</option>
<option value="spl">Pipe syntax</option>
<option value="sql">SQL</option>
</select>
</label>
<span class="detected-badge" class:sql={effectiveLanguage === 'sql'}>
{effectiveLanguage === 'sql' ? 'SQL' : 'pipe syntax'}
</span>
<button onclick={runQuery} disabled={loading || query.trim() === ''}>
{loading ? 'Running…' : 'Run query'}
</button>
<span class="hint">⌘/Ctrl+Enter to run</span>
</div>
<QueryBar bind:query bind:language onRun={runQuery} {loading} />
{#if error}
<p class="error">Error: {error}</p>
@@ -179,34 +125,6 @@
margin: 2rem auto;
padding: 0 1rem;
}
textarea {
width: 100%;
font-family: monospace;
font-size: 0.9rem;
box-sizing: border-box;
}
.controls {
margin-top: 0.5rem;
display: flex;
align-items: center;
gap: 0.75rem;
flex-wrap: wrap;
}
.detected-badge {
font-size: 0.75rem;
padding: 0.15rem 0.5rem;
border-radius: 1rem;
background: #eef;
color: #224;
}
.detected-badge.sql {
background: #fee;
color: #422;
}
.hint {
font-size: 0.8rem;
color: #777;
}
.error {
color: #b00020;
}
+132
View File
@@ -0,0 +1,132 @@
<script lang="ts">
import { listRules, deleteRule, type AlertRule } from '$lib/api';
let rules = $state<AlertRule[]>([]);
let loading = $state(true);
let error = $state('');
async function load() {
loading = true;
try {
rules = await listRules();
} catch (e) {
error = e instanceof Error ? e.message : String(e);
} finally {
loading = false;
}
}
load();
async function remove(id: string) {
try {
await deleteRule(id);
await load();
} catch (e) {
error = e instanceof Error ? e.message : String(e);
}
}
function conditionSummary(r: AlertRule): string {
if (r.condition_type === 'absence') return 'absence (query returns zero rows)';
const symbols: Record<string, string> = { gt: '>', gte: '>=', lt: '<', lte: '<=', eq: '==', ne: '!=' };
return `${symbols[r.comparator ?? ''] ?? r.comparator} ${r.threshold_value}`;
}
</script>
<main>
<h1>Alerts</h1>
{#if error}<p class="error">Error: {error}</p>{/if}
<a class="new-rule" href="/alerts/new">+ New rule</a>
{#if loading}
<p>Loading…</p>
{:else if rules.length === 0}
<p>No alert rules yet.</p>
{:else}
<table>
<thead>
<tr>
<th>Name</th>
<th>Condition</th>
<th>State</th>
<th>Enabled</th>
<th></th>
</tr>
</thead>
<tbody>
{#each rules as r (r.id)}
<tr>
<td><a href={`/alerts/${r.id}`}>{r.name}</a></td>
<td><code>{conditionSummary(r)}</code></td>
<td>
<span class="state" class:firing={r.state.state === 'firing'} class:pending={r.state.state === 'pending'}>
{r.state.state}
</span>
{#if r.state.last_eval_status === 'error'}
<span class="eval-error" title={r.state.last_error}>eval error</span>
{/if}
</td>
<td>{r.enabled ? 'yes' : 'no'}</td>
<td><button class="delete" onclick={() => remove(r.id)}>Delete</button></td>
</tr>
{/each}
</tbody>
</table>
{/if}
</main>
<style>
main {
font-family: system-ui, sans-serif;
max-width: 960px;
margin: 2rem auto;
padding: 0 1rem;
}
.error {
color: #b00020;
}
.new-rule {
display: inline-block;
margin-bottom: 1rem;
color: #06c;
text-decoration: none;
}
table {
border-collapse: collapse;
width: 100%;
}
th,
td {
border-bottom: 1px solid #eee;
padding: 0.4rem 0.6rem;
text-align: left;
font-size: 0.9rem;
}
.state {
font-size: 0.75rem;
padding: 0.1rem 0.5rem;
border-radius: 1rem;
background: #eee;
}
.state.pending {
background: #ffe9b3;
}
.state.firing {
background: #fdd;
color: #900;
}
.eval-error {
margin-left: 0.4rem;
font-size: 0.75rem;
color: #b00020;
}
.delete {
color: #b00020;
background: none;
border: 1px solid #b00020;
border-radius: 4px;
padding: 0.15rem 0.5rem;
cursor: pointer;
}
</style>
+3
View File
@@ -0,0 +1,3 @@
// No route params, data comes from a client-side fetch -- same shape as
// the dashboards list page's +page.ts.
export const prerender = true;
+188
View File
@@ -0,0 +1,188 @@
<script lang="ts">
import { page } from '$app/state';
import { getRule, deleteRule, listDeliveries, type AlertRule, type DeliveryLogEntry } from '$lib/api';
const ruleId = page.params.id!;
let rule = $state<AlertRule | null>(null);
let deliveries = $state<DeliveryLogEntry[]>([]);
let loading = $state(true);
let error = $state('');
async function load() {
loading = true;
error = '';
try {
rule = await getRule(ruleId);
deliveries = await listDeliveries(ruleId);
} catch (e) {
error = e instanceof Error ? e.message : String(e);
} finally {
loading = false;
}
}
load();
async function remove() {
await deleteRule(ruleId);
window.location.href = '/alerts';
}
function conditionSummary(r: AlertRule): string {
if (r.condition_type === 'absence') return 'query returns zero rows in its own time window';
const symbols: Record<string, string> = { gt: '>', gte: '>=', lt: '<', lte: '<=', eq: '==', ne: '!=' };
return `first row's value ${symbols[r.comparator ?? ''] ?? r.comparator} ${r.threshold_value}`;
}
</script>
<main>
{#if loading}
<p>Loading…</p>
{:else if !rule}
<p class="error">Error: {error}</p>
{:else}
<div class="header">
<h1>{rule.name}</h1>
<button class="delete" onclick={remove}>Delete rule</button>
</div>
{#if rule.description}<p class="desc">{rule.description}</p>{/if}
{#if error}<p class="error">Error: {error}</p>{/if}
<section class="summary">
<div>
<span class="label">State</span>
<span
class="state"
class:firing={rule.state.state === 'firing'}
class:pending={rule.state.state === 'pending'}
>
{rule.state.state}
</span>
</div>
<div><span class="label">Condition</span> {rule.condition_type}{conditionSummary(rule)}</div>
<div><span class="label">Query</span> <code>{rule.query}</code></div>
<div><span class="label">Evaluation interval</span> {rule.eval_interval_seconds}s</div>
<div><span class="label">Debounce (for)</span> {rule.for_minutes}m</div>
<div><span class="label">Enabled</span> {rule.enabled ? 'yes' : 'no'}</div>
{#if rule.state.last_eval_status === 'error'}
<div class="eval-error">
<span class="label">Last evaluation error</span> {rule.state.last_error}
({rule.state.consecutive_errors} consecutive)
</div>
{:else if rule.state.last_value !== undefined}
<div><span class="label">Last observed value</span> {rule.state.last_value}</div>
{/if}
</section>
<h2>Delivery log</h2>
<p class="hint">Most recent first — this is "why didn't I get paged."</p>
{#if deliveries.length === 0}
<p>No deliveries yet.</p>
{:else}
<table>
<thead>
<tr>
<th>When</th>
<th>Event</th>
<th>Status</th>
<th>Attempts</th>
<th>Response</th>
<th>Error</th>
</tr>
</thead>
<tbody>
{#each deliveries as d (d.id)}
<tr>
<td>{new Date(d.created_at).toLocaleString()}</td>
<td>{d.event_type}</td>
<td>{d.status}</td>
<td>{d.attempt_count}</td>
<td>{d.response_status ?? '—'}</td>
<td class="error-cell">{d.last_error ?? ''}</td>
</tr>
{/each}
</tbody>
</table>
{/if}
{/if}
</main>
<style>
main {
font-family: system-ui, sans-serif;
max-width: 900px;
margin: 2rem auto;
padding: 0 1rem;
}
.header {
display: flex;
align-items: center;
justify-content: space-between;
}
.desc {
color: #555;
}
.error {
color: #b00020;
}
.summary {
border: 1px solid #ddd;
border-radius: 6px;
padding: 0.75rem 1rem;
margin: 1rem 0;
display: flex;
flex-direction: column;
gap: 0.4rem;
font-size: 0.9rem;
}
.label {
font-weight: 600;
margin-right: 0.4rem;
}
.state {
font-size: 0.75rem;
padding: 0.1rem 0.5rem;
border-radius: 1rem;
background: #eee;
}
.state.pending {
background: #ffe9b3;
}
.state.firing {
background: #fdd;
color: #900;
}
.eval-error {
color: #b00020;
}
.hint {
font-size: 0.8rem;
color: #777;
}
.delete {
color: #b00020;
background: none;
border: 1px solid #b00020;
border-radius: 4px;
padding: 0.15rem 0.5rem;
cursor: pointer;
}
table {
border-collapse: collapse;
width: 100%;
}
th,
td {
border-bottom: 1px solid #eee;
padding: 0.3rem 0.5rem;
text-align: left;
font-size: 0.85rem;
}
.error-cell {
color: #b00020;
max-width: 20rem;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
</style>
+4
View File
@@ -0,0 +1,4 @@
// The rule ID param doesn't exist at build time -- same dynamic-route
// shape as dashboards/[id], served from adapter-static's fallback shell.
export const prerender = false;
export const ssr = false;
+241
View File
@@ -0,0 +1,241 @@
<script lang="ts">
import QueryBar from '$lib/QueryBar.svelte';
import {
listNotificationTargets,
createNotificationTarget,
createRule,
type NotificationTarget,
type NotificationKind,
type ConditionType,
type Comparator,
type Language
} from '$lib/api';
let name = $state('');
let description = $state('');
let query = $state('');
let language = $state<Language>('');
let conditionType = $state<ConditionType>('threshold');
let comparator = $state<Comparator>('gt');
let thresholdValue = $state('100');
let evalIntervalSeconds = $state('60');
let forMinutes = $state('0');
let renotifyIntervalMinutes = $state('');
let targets = $state<NotificationTarget[]>([]);
let targetId = $state('');
let showNewTarget = $state(false);
let newTargetName = $state('');
let newTargetKind = $state<NotificationKind>('webhook');
let newTargetURL = $state('');
let error = $state('');
let submitting = $state(false);
async function loadTargets() {
try {
targets = await listNotificationTargets();
if (targets.length > 0 && !targetId) targetId = targets[0].id;
} catch (e) {
// Every other data-loading call on this page's siblings
// (dashboards, alerts list, rule detail) wraps its fetch in
// try/catch -- this one didn't, and an unhandled rejection here
// (e.g. alerting unreachable) crashes the prerendering build
// entirely rather than just showing an error, found by actually
// running `docker build` for web.
error = e instanceof Error ? e.message : String(e);
}
}
loadTargets();
async function submitNewTarget() {
if (!newTargetName.trim() || !newTargetURL.trim()) return;
try {
const t = await createNotificationTarget({ name: newTargetName, kind: newTargetKind, webhook_url: newTargetURL });
await loadTargets();
targetId = t.id;
showNewTarget = false;
newTargetName = '';
newTargetURL = '';
} catch (e) {
error = e instanceof Error ? e.message : String(e);
}
}
async function submit() {
error = '';
if (!name.trim() || !query.trim() || !targetId) {
error = 'name, query, and a notification target are all required';
return;
}
submitting = true;
try {
const payload: Record<string, unknown> = {
name,
description,
query,
query_language: language,
condition_type: conditionType,
eval_interval_seconds: Number(evalIntervalSeconds),
for_minutes: Number(forMinutes),
notification_target_id: targetId
};
if (conditionType === 'threshold') {
payload.comparator = comparator;
payload.threshold_value = Number(thresholdValue);
}
if (renotifyIntervalMinutes.trim() !== '') {
payload.renotify_interval_minutes = Number(renotifyIntervalMinutes);
}
const rule = await createRule(payload);
window.location.href = `/alerts/${rule.id}`;
} catch (e) {
error = e instanceof Error ? e.message : String(e);
} finally {
submitting = false;
}
}
</script>
<main>
<h1>New alert rule</h1>
{#if error}<p class="error">Error: {error}</p>{/if}
<label class="field">
Name
<input bind:value={name} placeholder="High error rate" />
</label>
<label class="field">
Description
<input bind:value={description} placeholder="optional" />
</label>
<QueryBar bind:query bind:language onRun={() => {}} placeholder="service=api | where status>=500 | stats count" />
<p class="hint">
For <code>threshold</code> rules, the query must resolve to exactly one row (e.g.
<code>| stats count</code>). For <code>absence</code> rules, the query's own <code>earliest=</code>
defines the window being checked for zero results.
</p>
<div class="row">
<label>
Condition
<select bind:value={conditionType}>
<option value="threshold">Threshold</option>
<option value="absence">Absence</option>
</select>
</label>
{#if conditionType === 'threshold'}
<label>
Comparator
<select bind:value={comparator}>
<option value="gt">&gt;</option>
<option value="gte">&gt;=</option>
<option value="lt">&lt;</option>
<option value="lte">&lt;=</option>
<option value="eq">==</option>
<option value="ne">!=</option>
</select>
</label>
<label>
Threshold value
<input type="number" bind:value={thresholdValue} />
</label>
{/if}
</div>
<div class="row">
<label>
Evaluation interval (seconds)
<input type="number" min="30" bind:value={evalIntervalSeconds} />
</label>
<label>
Debounce, "for" minutes
<input type="number" min="0" bind:value={forMinutes} />
</label>
<label>
Renotify interval (minutes, optional)
<input type="number" min="1" bind:value={renotifyIntervalMinutes} placeholder="never" />
</label>
</div>
<div class="row">
<label>
Notification target
<select bind:value={targetId}>
{#each targets as t (t.id)}
<option value={t.id}>{t.name} ({t.kind})</option>
{/each}
</select>
</label>
<button type="button" onclick={() => (showNewTarget = !showNewTarget)}>
{showNewTarget ? 'Cancel' : '+ New target'}
</button>
</div>
{#if showNewTarget}
<div class="new-target">
<input placeholder="Target name" bind:value={newTargetName} />
<select bind:value={newTargetKind}>
<option value="webhook">Generic webhook</option>
<option value="slack">Slack</option>
<option value="pagerduty">PagerDuty</option>
</select>
<input placeholder="https://..." bind:value={newTargetURL} />
<button type="button" onclick={submitNewTarget}>Add target</button>
</div>
{/if}
<button class="submit" onclick={submit} disabled={submitting}>
{submitting ? 'Creating…' : 'Create rule'}
</button>
</main>
<style>
main {
font-family: system-ui, sans-serif;
max-width: 720px;
margin: 2rem auto;
padding: 0 1rem;
}
.error {
color: #b00020;
}
.field {
display: block;
margin-bottom: 0.75rem;
font-size: 0.85rem;
}
.field input {
display: block;
width: 100%;
box-sizing: border-box;
margin-top: 0.2rem;
}
.hint {
font-size: 0.8rem;
color: #777;
}
.row {
display: flex;
gap: 1rem;
align-items: flex-end;
margin: 1rem 0;
flex-wrap: wrap;
}
.row label {
font-size: 0.85rem;
display: flex;
flex-direction: column;
gap: 0.2rem;
}
.new-target {
display: flex;
gap: 0.5rem;
margin-bottom: 1rem;
}
.submit {
margin-top: 1rem;
padding: 0.4rem 1rem;
}
</style>
+4
View File
@@ -0,0 +1,4 @@
// A static path segment ("new"), not a dynamic route -- SvelteKit
// resolves this before matching /alerts/[id], so "new" never collides
// with a rule ID lookup. No route params, prerenderable like the list page.
export const prerender = true;
+147
View File
@@ -0,0 +1,147 @@
<script lang="ts">
import {
listDashboards,
createDashboard,
deleteDashboard,
importDashboard,
type Dashboard
} from '$lib/api';
let dashboards = $state<Dashboard[]>([]);
let loading = $state(true);
let error = $state('');
let newName = $state('');
async function load() {
loading = true;
try {
dashboards = await listDashboards();
} catch (e) {
error = e instanceof Error ? e.message : String(e);
} finally {
loading = false;
}
}
load();
async function create() {
if (!newName.trim()) return;
try {
await createDashboard({ name: newName.trim() });
newName = '';
await load();
} catch (e) {
error = e instanceof Error ? e.message : String(e);
}
}
async function remove(id: string) {
try {
await deleteDashboard(id);
await load();
} catch (e) {
error = e instanceof Error ? e.message : String(e);
}
}
async function onImportFile(e: Event) {
const input = e.target as HTMLInputElement;
const file = input.files?.[0];
if (!file) return;
try {
const text = await file.text();
await importDashboard(JSON.parse(text));
await load();
} catch (e) {
error = e instanceof Error ? e.message : String(e);
} finally {
input.value = '';
}
}
</script>
<main>
<h1>Dashboards</h1>
{#if error}<p class="error">Error: {error}</p>{/if}
<div class="create-row">
<input
placeholder="New dashboard name"
bind:value={newName}
onkeydown={(e) => e.key === 'Enter' && create()}
/>
<button onclick={create} disabled={!newName.trim()}>Create</button>
<label class="import-label">
Import JSON
<input type="file" accept="application/json" onchange={onImportFile} hidden />
</label>
</div>
{#if loading}
<p>Loading…</p>
{:else if dashboards.length === 0}
<p>No dashboards yet.</p>
{:else}
<ul class="dashboard-list">
{#each dashboards as d (d.id)}
<li>
<a href={`/dashboards/${d.id}`}>{d.name}</a>
{#if d.description}<span class="desc">{d.description}</span>{/if}
<button class="delete" onclick={() => remove(d.id)}>Delete</button>
</li>
{/each}
</ul>
{/if}
</main>
<style>
main {
font-family: system-ui, sans-serif;
max-width: 960px;
margin: 2rem auto;
padding: 0 1rem;
}
.error {
color: #b00020;
}
.create-row {
display: flex;
align-items: center;
gap: 0.5rem;
margin-bottom: 1.5rem;
}
.import-label {
cursor: pointer;
color: #06c;
font-size: 0.85rem;
}
.dashboard-list {
list-style: none;
padding: 0;
}
.dashboard-list li {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.5rem 0;
border-bottom: 1px solid #eee;
}
.dashboard-list a {
font-weight: 600;
color: #06c;
text-decoration: none;
}
.desc {
color: #777;
font-size: 0.85rem;
}
.delete {
margin-left: auto;
color: #b00020;
background: none;
border: 1px solid #b00020;
border-radius: 4px;
padding: 0.15rem 0.5rem;
cursor: pointer;
}
</style>
+3
View File
@@ -0,0 +1,3 @@
// No route params, data comes from a client-side fetch -- same shape as
// the root query page's +page.ts.
export const prerender = true;
+362
View File
@@ -0,0 +1,362 @@
<script lang="ts">
import { page } from '$app/state';
import { GridStack, type GridStackNode } from 'gridstack';
import 'gridstack/dist/gridstack.min.css';
import QueryBar from '$lib/QueryBar.svelte';
import PanelViz from '$lib/PanelViz.svelte';
import {
getDashboard,
updateDashboard,
deleteDashboard as apiDeleteDashboard,
addPanel,
deletePanel as apiDeletePanel,
updatePanel as apiUpdatePanel,
exportDashboard,
runQuery,
resolveTimeRange,
injectTimeRange,
type Dashboard,
type Panel,
type VizType,
type Language,
type QueryResult
} from '$lib/api';
const dashboardId = page.params.id!;
let dashboard = $state<Dashboard | null>(null);
let loading = $state(true);
let error = $state('');
let earliestInput = $state('-1h');
let latestInput = $state('now');
let panelResults = $state<Record<string, QueryResult>>({});
let panelErrors = $state<Record<string, string>>({});
let gridEl: HTMLDivElement | undefined = $state();
let grid: GridStack | undefined;
let showAddPanel = $state(false);
let newTitle = $state('');
let newQuery = $state('');
let newLanguage = $state<Language>('');
let newVizType = $state<VizType>('table');
async function load() {
loading = true;
error = '';
try {
dashboard = await getDashboard(dashboardId);
earliestInput = dashboard.default_earliest;
latestInput = dashboard.default_latest;
await runAllPanels();
} catch (e) {
error = e instanceof Error ? e.message : String(e);
} finally {
loading = false;
}
}
load();
async function runPanel(panel: Panel) {
if (!dashboard) return;
const { earliest, latest } = resolveTimeRange(dashboard, panel);
try {
const result = await runQuery(injectTimeRange(panel.query, earliest, latest), panel.query_language);
panelResults = { ...panelResults, [panel.id]: result };
if (panelErrors[panel.id]) {
const rest = { ...panelErrors };
delete rest[panel.id];
panelErrors = rest;
}
} catch (e) {
// A broken panel query shouldn't take down the rest of the
// dashboard -- per /docs/phase-3-dashboard-design.md's "panel
// execution" section, panels load and error independently.
panelErrors = { ...panelErrors, [panel.id]: e instanceof Error ? e.message : String(e) };
}
}
async function runAllPanels() {
if (!dashboard?.panels) return;
await Promise.all(dashboard.panels.map(runPanel));
}
async function applyTimeRange() {
if (!dashboard) return;
dashboard = await updateDashboard(dashboardId, {
name: dashboard.name,
description: dashboard.description,
default_earliest: earliestInput,
default_latest: latestInput
});
await runAllPanels();
}
function nextY(): number {
if (!dashboard?.panels || dashboard.panels.length === 0) return 0;
return Math.max(...dashboard.panels.map((p) => p.position_y + p.height));
}
async function submitAddPanel() {
if (!newQuery.trim()) return;
try {
await addPanel(dashboardId, {
title: newTitle,
query: newQuery,
query_language: newLanguage,
viz_type: newVizType,
position_x: 0,
position_y: nextY(),
width: 6,
height: 4
});
showAddPanel = false;
newTitle = '';
newQuery = '';
newLanguage = '';
newVizType = 'table';
await load();
} catch (e) {
error = e instanceof Error ? e.message : String(e);
}
}
async function removePanel(panelId: string) {
try {
await apiDeletePanel(dashboardId, panelId);
await load();
} catch (e) {
error = e instanceof Error ? e.message : String(e);
}
}
async function removeDashboard() {
await apiDeleteDashboard(dashboardId);
window.location.href = '/dashboards';
}
async function doExport() {
const doc = await exportDashboard(dashboardId);
const blob = new Blob([JSON.stringify(doc, null, 2)], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `${doc.name.replace(/\s+/g, '-').toLowerCase() || 'dashboard'}.json`;
a.click();
URL.revokeObjectURL(url);
}
// Persists drag/resize moves back to the API. Re-initialized whenever
// the *set* of panels changes (add/remove/reload) -- not on every
// result update, which would disrupt an in-progress drag.
function setupGrid() {
if (!gridEl || !dashboard?.panels) return;
grid?.destroy(false);
grid = GridStack.init({ float: true, cellHeight: 60, column: 12 }, gridEl);
grid.on('change', (_event: Event, items: GridStackNode[]) => {
for (const item of items) {
const panel = dashboard?.panels?.find((p) => p.id === item.id);
if (!panel) continue;
apiUpdatePanel(dashboardId, {
...panel,
position_x: item.x ?? panel.position_x,
position_y: item.y ?? panel.position_y,
width: item.w ?? panel.width,
height: item.h ?? panel.height
}).catch(() => {
// Best-effort persistence -- a failed position save isn't
// worth surfacing as a page-level error; the layout is
// still usable for the current session either way.
});
}
});
}
let panelIds = $derived(dashboard?.panels?.map((p) => p.id).join(',') ?? '');
$effect(() => {
// Deliberately no queueMicrotask here: $effect in Svelte 5 already
// runs after the DOM has committed the render that triggered it
// (unlike $effect.pre), so the {#each} block's grid-stack-item
// elements already exist by the time this runs. An earlier version
// wrapped this in queueMicrotask "to be safe" and that extra hop
// raced against Svelte's own DOM-update scheduling -- GridStack.init
// sometimes ran before or after the elements existed depending on
// scheduling order, silently initializing against zero items.
panelIds;
if (dashboard?.panels && dashboard.panels.length > 0) {
setupGrid();
}
});
</script>
<main>
{#if loading}
<p>Loading…</p>
{:else if !dashboard}
<p class="error">Error: {error}</p>
{:else}
<div class="header">
<h1>{dashboard.name}</h1>
<div class="header-actions">
<button onclick={doExport}>Export JSON</button>
<button class="delete" onclick={removeDashboard}>Delete dashboard</button>
</div>
</div>
{#if dashboard.description}<p class="desc">{dashboard.description}</p>{/if}
{#if error}<p class="error">Error: {error}</p>{/if}
<div class="time-range">
<label>Earliest <input bind:value={earliestInput} placeholder="-1h" /></label>
<label>Latest <input bind:value={latestInput} placeholder="now" /></label>
<button onclick={applyTimeRange}>Apply to all panels</button>
<span class="hint">Per-panel overrides win over this default -- see the panel editor.</span>
</div>
{#if dashboard.panels && dashboard.panels.length > 0}
<div class="grid-stack" bind:this={gridEl}>
{#each dashboard.panels as panel (panel.id)}
<div
class="grid-stack-item"
{...{
'gs-id': panel.id,
'gs-x': panel.position_x,
'gs-y': panel.position_y,
'gs-w': panel.width,
'gs-h': panel.height
}}
>
<div class="grid-stack-item-content panel">
<div class="panel-header">
<span class="panel-title">{panel.title || panel.query}</span>
<button class="panel-delete" onclick={() => removePanel(panel.id)}>×</button>
</div>
{#if panelErrors[panel.id]}
<p class="error">Error: {panelErrors[panel.id]}</p>
{:else if panelResults[panel.id]}
<PanelViz
result={panelResults[panel.id]}
vizType={panel.viz_type}
vizConfig={panel.viz_config}
/>
{:else}
<p>Loading…</p>
{/if}
</div>
</div>
{/each}
</div>
{:else}
<p>No panels yet. Add one below.</p>
{/if}
<div class="add-panel">
<button onclick={() => (showAddPanel = !showAddPanel)}>
{showAddPanel ? 'Cancel' : '+ Add panel'}
</button>
{#if showAddPanel}
<div class="add-panel-form">
<input placeholder="Panel title" bind:value={newTitle} />
<QueryBar bind:query={newQuery} bind:language={newLanguage} onRun={submitAddPanel} />
<label>
Visualization:
<select bind:value={newVizType}>
<option value="table">Table</option>
<option value="line">Line chart</option>
<option value="bar">Bar chart</option>
<option value="single_stat">Single stat</option>
<option value="top_n">Top-N</option>
</select>
</label>
<button onclick={submitAddPanel} disabled={!newQuery.trim()}>Add panel</button>
</div>
{/if}
</div>
{/if}
</main>
<style>
main {
font-family: system-ui, sans-serif;
max-width: 1200px;
margin: 2rem auto;
padding: 0 1rem;
}
.header {
display: flex;
align-items: center;
justify-content: space-between;
}
.header-actions {
display: flex;
gap: 0.5rem;
}
.desc {
color: #555;
}
.error {
color: #b00020;
}
.time-range {
display: flex;
align-items: center;
gap: 0.75rem;
margin: 1rem 0;
flex-wrap: wrap;
}
.time-range input {
width: 6rem;
}
.hint {
font-size: 0.8rem;
color: #777;
}
.delete {
color: #b00020;
background: none;
border: 1px solid #b00020;
border-radius: 4px;
padding: 0.15rem 0.5rem;
cursor: pointer;
}
.panel {
border: 1px solid #ddd;
border-radius: 6px;
padding: 0.5rem 0.75rem;
height: 100%;
box-sizing: border-box;
overflow: auto;
background: white;
}
.panel-header {
display: flex;
align-items: center;
justify-content: space-between;
font-weight: 600;
margin-bottom: 0.5rem;
}
.panel-delete {
background: none;
border: none;
cursor: pointer;
font-size: 1rem;
color: #999;
}
.add-panel {
margin-top: 1.5rem;
}
.add-panel-form {
border: 1px solid #ddd;
border-radius: 6px;
padding: 1rem;
margin-top: 0.5rem;
display: flex;
flex-direction: column;
gap: 0.75rem;
max-width: 640px;
}
.add-panel-form input {
box-sizing: border-box;
}
</style>
+5
View File
@@ -0,0 +1,5 @@
// The dashboard ID param doesn't exist at build time, so this route can't
// be prerendered like the list page -- served from adapter-static's
// fallback shell (see vite.config.ts) and rendered fully client-side.
export const prerender = false;
export const ssr = false;
+10 -1
View File
@@ -9,7 +9,16 @@ export default defineConfig({
// Force runes mode for the project, except for libraries. Can be removed in svelte 6.
runes: ({ filename }) => filename.split(/[/\\]/).includes('node_modules') ? undefined : true
},
adapter: adapter()
// fallback: dashboards/[id] and alerts/[id] are dynamic routes
// whose params (dashboard/rule IDs) don't exist at build time, so
// they can't be prerendered like the root/list pages. Named
// 200.html, not index.html -- the root route ("/") is itself
// prerendered to index.html, and a same-named fallback silently
// overwrites it (confirmed by actually running the build: "Using
// index.html" produced the warning "Overwriting build/index.html
// with fallback page"). nginx.conf's try_files points at
// /200.html to match.
adapter: adapter({ fallback: '200.html' })
})
]
});