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:
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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) });
|
||||
}
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
@@ -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">></option>
|
||||
<option value="gte">>=</option>
|
||||
<option value="lt"><</option>
|
||||
<option value="lte"><=</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>
|
||||
@@ -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;
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
Reference in New Issue
Block a user