// 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; 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(base: string, path: string, init?: RequestInit): Promise { 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(path: string, init?: RequestInit): Promise { 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(path: string, init?: RequestInit): Promise { return requestFrom(alertingBase, path, init); } export function runQuery(query: string, language: Language): Promise { return request('/query', { method: 'POST', body: JSON.stringify({ query, language }) }); } export function listDashboards(): Promise { return request('/dashboards').then((d) => (d as Dashboard[]) ?? []); } export function getDashboard(id: string): Promise { return request(`/dashboards/${id}`); } export function createDashboard(input: { name: string; description?: string; default_earliest?: string; default_latest?: string; }): Promise { 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 { return request(`/dashboards/${id}`, { method: 'PUT', body: JSON.stringify(input) }); } export function deleteDashboard(id: string): Promise { return request(`/dashboards/${id}`, { method: 'DELETE' }); } export function addPanel(dashboardId: string, panel: Partial): Promise { return request(`/dashboards/${dashboardId}/panels`, { method: 'POST', body: JSON.stringify(panel) }); } export function updatePanel(dashboardId: string, panel: Partial): Promise { return request(`/dashboards/${dashboardId}/panels/${panel.id}`, { method: 'PUT', body: JSON.stringify(panel) }); } export function deletePanel(dashboardId: string, panelId: string): Promise { return request(`/dashboards/${dashboardId}/panels/${panelId}`, { method: 'DELETE' }); } export function exportDashboard(id: string): Promise { return request(`/dashboards/${id}/export`); } export function importDashboard(dashboard: Dashboard): Promise { 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, panel: Pick ): { 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 { return alertingRequest('/rules').then((r) => r ?? []); } export function getRule(id: string): Promise { return alertingRequest(`/rules/${id}`); } export function createRule(input: Partial): Promise { return alertingRequest('/rules', { method: 'POST', body: JSON.stringify(input) }); } export function deleteRule(id: string): Promise { return alertingRequest(`/rules/${id}`, { method: 'DELETE' }); } export function listDeliveries(ruleId: string): Promise { return alertingRequest(`/rules/${ruleId}/deliveries`).then((d) => d ?? []); } export function listNotificationTargets(): Promise { return alertingRequest('/targets').then((t) => t ?? []); } export function createNotificationTarget(input: { name: string; kind: NotificationKind; webhook_url: string; }): Promise { return alertingRequest('/targets', { method: 'POST', body: JSON.stringify(input) }); }