From 0c41048e0fbd505d3c7fd35505e81c58baa0fe56 Mon Sep 17 00:00:00 2001 From: Hugh Kaznowski Date: Mon, 7 Sep 2026 10:54:47 +0100 Subject: [PATCH] Split lib/api.ts by REST domain and add buildSearchParams helper api.ts had grown to 35 exported symbols in one 432-line file covering every backend endpoint. Split into webapp/src/lib/api/{shared,logs, params,metrics,dashboards,alerts,settings}.ts, re-exported from lib/api/index.ts as a barrel so none of the ~10 importing files needed their import paths changed (TS/bundler module resolution falls back to a directory's index.ts when the sibling .ts file is gone). Also added buildSearchParams to replace fetchLogs/fetchTimeline's repeated hand-written `if (x !== undefined) search.set(...)` blocks. Left fetchMetricBucketValues/fetchPromqlSeries's query-building untouched (byte-for-byte) since the ticket scoped the helper to fetchLogs/fetchTimeline only. Reimplemented directly against current main (ticket 089) after the worktree agent's branch was based on a stale pre-theme-removal state and reintroduced CSS-upload endpoints main had deliberately deleted. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01TeyhRfMYjMditek6geDRoQ --- webapp/src/lib/api.ts | 432 ------------------------------- webapp/src/lib/api/alerts.ts | 41 +++ webapp/src/lib/api/dashboards.ts | 62 +++++ webapp/src/lib/api/index.ts | 7 + webapp/src/lib/api/logs.ts | 85 ++++++ webapp/src/lib/api/metrics.ts | 57 ++++ webapp/src/lib/api/params.ts | 37 +++ webapp/src/lib/api/settings.ts | 67 +++++ webapp/src/lib/api/shared.ts | 103 ++++++++ 9 files changed, 459 insertions(+), 432 deletions(-) delete mode 100644 webapp/src/lib/api.ts create mode 100644 webapp/src/lib/api/alerts.ts create mode 100644 webapp/src/lib/api/dashboards.ts create mode 100644 webapp/src/lib/api/index.ts create mode 100644 webapp/src/lib/api/logs.ts create mode 100644 webapp/src/lib/api/metrics.ts create mode 100644 webapp/src/lib/api/params.ts create mode 100644 webapp/src/lib/api/settings.ts create mode 100644 webapp/src/lib/api/shared.ts diff --git a/webapp/src/lib/api.ts b/webapp/src/lib/api.ts deleted file mode 100644 index 9589d02..0000000 --- a/webapp/src/lib/api.ts +++ /dev/null @@ -1,432 +0,0 @@ -// Externally-tagged JSON matching the Rust `MetricPrimitive` enum's derived `Serialize`, e.g. `{ "Duration": 1500 }` (milliseconds). -export type MetricPrimitive = - | { String: string } - | { Number: { Int: number } | { Float: number } } - | { Timestamp: number } - | { Duration: number } - | { Boolean: boolean } - -export function metricPrimitiveToDisplayString(value: MetricPrimitive): string { - if ('String' in value) return value.String - if ('Timestamp' in value) return String(value.Timestamp) - if ('Duration' in value) return `${value.Duration}ms` - if ('Boolean' in value) return String(value.Boolean) - if ('Int' in value.Number) return String(value.Number.Int) - return String(value.Number.Float) -} - -export interface LogEntry { - sequence: number - timestamp_ms: number - text: string - // A param with no match on this line is simply absent from the map. - params: Record -} - -export interface ParamDeclaration { - name: string - message_match_regex: string - capture_group: string - /** Alternative to `capture_group`: a dot-separated JSON field path (e.g. "foo.bar"), read from lines that parse as a JSON object. When set (non-empty), takes precedence over `capture_group`. */ - json_field_path?: string | null - kind: 'string' | 'number' | 'timestamp' | 'duration' | 'boolean' - /** A third extraction source, distinct from `capture_group`/`json_field_path`: when true, this param's value is never parsed from text — it's computed as elapsed time since the previous event sharing the same label set. Only meaningful paired with `kind: 'duration'`. */ - elapsed_since_last?: boolean - /** Set only for the built-in `ottyl_seq`/`ottyl_ts`/`ottyl_fmt` names, synthesized into `GET /api/params` for discoverability — they're usable in the filter bar (and `ottyl_seq`/`ottyl_ts` in dashboard PromQL queries) without ever being declared, and can't be edited or removed. */ - reserved?: boolean -} - -export interface TimelineBucket { - start_ms: number - count: number -} - -export interface Stats { - log_count: number - first_sequence: number | null - last_sequence: number | null - first_timestamp_ms: number | null - last_timestamp_ms: number | null - /** Ticket 011: running approximate byte size of all currently-ingested logs. */ - approx_bytes_total: number - /** Ticket 073: highest ingestion-to-engine-processing lag (ms) seen within the trailing 5s. */ - ingest_lag_high_watermark_ms: number -} - -export interface MetricDeclaration { - name: string - message_match_regex: string - /** Names of already-declared `ParamDeclaration`s (see the Params panel) whose values become this metric's bucket labels. */ - label_params: string[] - /** Name of an already-declared param (if any) whose value becomes this metric's per-event bucket value instead of a label. */ - value_param?: string | null - /** Fixed labels applied on a match/non-match, on top of any captured params. */ - matched_labels?: Record | null - unmatched_labels?: Record | null -} - -export interface BucketValue { - labels: Record - sequence: number - timestamp_ms: number - value: MetricPrimitive -} - -// Error shape returned by API handlers on a 4xx/5xx response (see `ErrorDto` on the backend). -export interface ApiErrorBody { - error: string -} - -export class ApiError extends Error { - constructor(message: string) { - super(message) - this.name = 'ApiError' - } -} - -/** Name of a theme embedded in this build (see `GET /api/themes`), or `"system"` to follow the OS light/dark preference. */ -export type Theme = string - -export interface Settings { - theme: Theme - active_profile: string | null - /** Maximum ingested logs kept in memory; `null` means unlimited. */ - max_logs: number | null - /** Ntfy topic every alert rule delivers to; `null` until configured. */ - ntfy_topic: string | null - /** Ntfy server override; `null` means `https://ntfy.sh`. */ - ntfy_server: string | null -} - -export interface ProfileSummary { - name: string - param_count: number - metric_count: number - dashboard_count: number -} - -// One chart-ready time series from `GET /api/query`: a label combination plus its `(timestamp_ms, value)` points, sorted by timestamp. -export interface PromqlSeries { - labels: Record - points: [number, number][] -} - -export type ChartType = 'bar' | 'line' | 'pie' - -export interface Panel { - id: string - title: string - promql: string - chart_type: ChartType -} - -export interface Dashboard { - id: string - name: string - panels: Panel[] -} - -export type CompareOp = 'lt' | 'gt' | 'le' | 'ge' | 'eq' | 'ne' - -// Externally tagged on `kind`, matching `AlertCondition`'s `#[serde(tag = "kind")]` on the backend. -export type AlertCondition = - | { kind: 'immediate'; query: string } - | { kind: 'threshold'; query: string; op: CompareOp; threshold: number; window_ms: number } - | { kind: 'newvalue'; field_regex: string; capture_group: string } - | { kind: 'ratio'; query_a: string; query_b: string; op: CompareOp; ratio: number; window_ms: number } - -export interface AlertRule { - id: string - name: string - enabled: boolean - condition: AlertCondition -} - -async function getJson(path: string): Promise { - const res = await fetch(path) - if (!res.ok) { - let message = `${path} failed: ${res.status}` - try { - const body: unknown = await res.json() - if (body && typeof body === 'object' && 'error' in body && typeof (body as { error: unknown }).error === 'string') { - message = (body as { error: string }).error - } - } catch { - // body wasn't JSON (or had no `error` field) — fall back to the generic message - } - throw new Error(message) - } - return res.json() as Promise -} - -// Throws `ApiError` (not `Error`) with the parsed `{ "error": "..." }` message so callers can surface it inline. -async function postJson(path: string, body: unknown): Promise { - const res = await fetch(path, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(body), - }) - if (!res.ok) { - let message = `${path} failed: ${res.status}` - try { - const errorBody = (await res.json()) as Partial - if (errorBody && typeof errorBody.error === 'string') { - message = errorBody.error - } - } catch { - // response body wasn't JSON; fall back to the generic message - } - throw new ApiError(message) - } - return res.json() as Promise -} - -// Same error handling as `postJson` (throws `ApiError` with the parsed message) but for a PUT — used where updating in place, rather than creating, is the operation. -async function putJson(path: string, body: unknown): Promise { - const res = await fetch(path, { - method: 'PUT', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(body), - }) - if (!res.ok) { - let message = `${path} failed: ${res.status}` - try { - const errorBody = (await res.json()) as Partial - if (errorBody && typeof errorBody.error === 'string') { - message = errorBody.error - } - } catch { - // response body wasn't JSON; fall back to the generic message - } - throw new ApiError(message) - } - return res.json() as Promise -} - -async function sendJson(path: string, method: string, body?: unknown): Promise { - const res = await fetch(path, { - method, - headers: body !== undefined ? { 'Content-Type': 'application/json' } : undefined, - body: body !== undefined ? JSON.stringify(body) : undefined, - }) - if (!res.ok) { - let message = `${path} failed: ${res.status}` - try { - const data = await res.json() - if (data?.error) message = data.error - } catch { - // response body wasn't JSON (or was empty) — fall back to the generic message - } - throw new Error(message) - } - if (res.status === 204) return undefined as T - return res.json() as Promise -} - -export function fetchLogs(params: { - sinceSequence?: number - startMs?: number - endMs?: number - limit?: number - filter?: string - /** Ticket 045: an already-declared param name to order entries by, instead of the default (`ottyl_ts`-tracking) order. */ - sortBy?: string - /** Ticket 045: only meaningful alongside `sortBy`. `true` (default) orders lines missing that param by `ottyl_seq`, appended after the sorted lines; `false` drops them. */ - sortMissingFallback?: boolean -}): Promise { - const search = new URLSearchParams() - if (params.sinceSequence !== undefined) search.set('since_sequence', String(params.sinceSequence)) - if (params.startMs !== undefined) search.set('start_ms', String(params.startMs)) - if (params.endMs !== undefined) search.set('end_ms', String(params.endMs)) - if (params.limit !== undefined) search.set('limit', String(params.limit)) - if (params.filter) search.set('filter', params.filter) - if (params.sortBy) search.set('sort_by', params.sortBy) - if (params.sortMissingFallback !== undefined) search.set('sort_missing_fallback', String(params.sortMissingFallback)) - return getJson(`/api/logs?${search.toString()}`) -} - -export function fetchTimeline(params: { - startMs?: number - endMs?: number - bucketMs?: number - filter?: string -}): Promise { - const search = new URLSearchParams() - if (params.startMs !== undefined) search.set('start_ms', String(params.startMs)) - if (params.endMs !== undefined) search.set('end_ms', String(params.endMs)) - if (params.bucketMs !== undefined) search.set('bucket_ms', String(params.bucketMs)) - if (params.filter) search.set('filter', params.filter) - return getJson(`/api/timeline?${search.toString()}`) -} - -export function fetchStats(): Promise { - return getJson('/api/stats') -} - -export function fetchParamDeclarations(): Promise { - return getJson('/api/params') -} - -export function declareParam(request: ParamDeclaration): Promise { - return postJson('/api/params', request) -} - -export interface ClearParamsResult { - /** How many param declarations (manual + auto-derived) were removed. */ - removed: number - /** How many params were immediately re-derived from log text still retained by the engine. */ - reextracted: number - /** How many retained log lines were re-scanned — lines already evicted by `max_logs` before the clear can't regain their params, so this can be smaller than the total lines ever ingested. */ - logs_considered: number -} - -// Clears every declared param (ticket 052) and re-derives whatever the engine's still-retained log -// text supports — see `ClearParamsResult`'s fields for what came back. -export function clearParams(): Promise { - return sendJson('/api/params', 'DELETE') -} - -export function fetchMetricDeclarations(): Promise { - return getJson('/api/metrics') -} - -export function declareMetric(request: MetricDeclaration): Promise { - return postJson('/api/metrics', request) -} - -// Updates the metric declaration named `existingName` in place (`existingName` may differ from `request.name` to rename it), rather than declaring a second metric or failing on a duplicate-name conflict. -export function updateMetric(existingName: string, request: MetricDeclaration): Promise { - return putJson(`/api/metrics/${encodeURIComponent(existingName)}`, request) -} - -export function deleteMetric(name: string): Promise { - return sendJson(`/api/metrics/${encodeURIComponent(name)}`, 'DELETE') -} - -export function fetchMetricBucketValues( - name: string, - params: { - labels?: string - sinceSequence?: number - startMs?: number - endMs?: number - limit?: number - } = {}, -): Promise { - const search = new URLSearchParams() - if (params.labels !== undefined) search.set('labels', params.labels) - if (params.sinceSequence !== undefined) search.set('since_sequence', String(params.sinceSequence)) - if (params.startMs !== undefined) search.set('start_ms', String(params.startMs)) - if (params.endMs !== undefined) search.set('end_ms', String(params.endMs)) - if (params.limit !== undefined) search.set('limit', String(params.limit)) - return getJson(`/api/metrics/${encodeURIComponent(name)}?${search.toString()}`) -} - -export function fetchSettings(): Promise { - return getJson('/api/settings') -} - -export function updateSettings(update: { - theme?: Theme - /** Omit to leave unchanged; `null` clears the cap back to unlimited. */ - maxLogs?: number | null - /** Omit to leave unchanged; `null` clears the configured topic. */ - ntfyTopic?: string | null - /** Omit to leave unchanged; `null` falls back to `https://ntfy.sh`. */ - ntfyServer?: string | null -}): Promise { - const body: Record = { theme: update.theme } - if ('maxLogs' in update) body.max_logs = update.maxLogs - if ('ntfyTopic' in update) body.ntfy_topic = update.ntfyTopic - if ('ntfyServer' in update) body.ntfy_server = update.ntfyServer - return sendJson('/api/settings', 'PATCH', body) -} - -/** Names of the themes embedded in this build (see `themes/` at the repo root). */ -export function fetchThemes(): Promise { - return getJson('/api/themes') -} - -export function themeCssUrl(name: string): string { - return `/api/themes/${encodeURIComponent(name)}` -} - -export function fetchProfiles(): Promise { - return getJson('/api/settings/profiles') -} - -export function createProfile(name: string): Promise { - return sendJson('/api/settings/profiles', 'POST', { name }) -} - -export function deleteProfile(name: string): Promise { - return sendJson(`/api/settings/profiles/${encodeURIComponent(name)}`, 'DELETE') -} - -export function activateProfile(name: string): Promise { - return sendJson(`/api/settings/profiles/${encodeURIComponent(name)}/activate`, 'POST') -} - -// Throws on a malformed query (400); callers should catch and surface the message inline rather than letting it crash the page. -export function fetchPromqlSeries(params: { - promql: string - startMs?: number - endMs?: number -}): Promise { - const search = new URLSearchParams() - search.set('promql', params.promql) - if (params.startMs !== undefined) search.set('start_ms', String(params.startMs)) - if (params.endMs !== undefined) search.set('end_ms', String(params.endMs)) - return getJson(`/api/query?${search.toString()}`) -} - -export function fetchDashboards(profileName: string): Promise { - return getJson(`/api/settings/profiles/${encodeURIComponent(profileName)}/dashboards`) -} - -export function createDashboard(profileName: string, name: string): Promise { - return sendJson(`/api/settings/profiles/${encodeURIComponent(profileName)}/dashboards`, 'POST', { name }) -} - -export function saveDashboard( - profileName: string, - dashboardId: string, - update: { name: string; panels: Panel[] }, -): Promise { - return sendJson( - `/api/settings/profiles/${encodeURIComponent(profileName)}/dashboards/${encodeURIComponent(dashboardId)}`, - 'PUT', - update, - ) -} - -export function deleteDashboard(profileName: string, dashboardId: string): Promise { - return sendJson( - `/api/settings/profiles/${encodeURIComponent(profileName)}/dashboards/${encodeURIComponent(dashboardId)}`, - 'DELETE', - ) -} - -export function fetchAlertRules(): Promise { - return getJson('/api/settings/alerts') -} - -export function createAlertRule(rule: { name: string; enabled: boolean; condition: AlertCondition }): Promise { - return postJson('/api/settings/alerts', rule) -} - -export function updateAlertRule( - id: string, - rule: { name: string; enabled: boolean; condition: AlertCondition }, -): Promise { - return sendJson(`/api/settings/alerts/${encodeURIComponent(id)}`, 'PUT', rule) -} - -export function deleteAlertRule(id: string): Promise { - return sendJson(`/api/settings/alerts/${encodeURIComponent(id)}`, 'DELETE') -} - -// Delivers a test notification without requiring the rule to be saved first; throws `ApiError` on failure so callers can surface it inline. -export function sendTestAlertNotification(topic: string, server: string | null): Promise { - return sendJson('/api/settings/alerts/test', 'POST', { topic, server }) -} diff --git a/webapp/src/lib/api/alerts.ts b/webapp/src/lib/api/alerts.ts new file mode 100644 index 0000000..bfda2a7 --- /dev/null +++ b/webapp/src/lib/api/alerts.ts @@ -0,0 +1,41 @@ +import { getJson, postJson, sendJson } from './shared' + +export type CompareOp = 'lt' | 'gt' | 'le' | 'ge' | 'eq' | 'ne' + +// Externally tagged on `kind`, matching `AlertCondition`'s `#[serde(tag = "kind")]` on the backend. +export type AlertCondition = + | { kind: 'immediate'; query: string } + | { kind: 'threshold'; query: string; op: CompareOp; threshold: number; window_ms: number } + | { kind: 'newvalue'; field_regex: string; capture_group: string } + | { kind: 'ratio'; query_a: string; query_b: string; op: CompareOp; ratio: number; window_ms: number } + +export interface AlertRule { + id: string + name: string + enabled: boolean + condition: AlertCondition +} + +export function fetchAlertRules(): Promise { + return getJson('/api/settings/alerts') +} + +export function createAlertRule(rule: { name: string; enabled: boolean; condition: AlertCondition }): Promise { + return postJson('/api/settings/alerts', rule) +} + +export function updateAlertRule( + id: string, + rule: { name: string; enabled: boolean; condition: AlertCondition }, +): Promise { + return sendJson(`/api/settings/alerts/${encodeURIComponent(id)}`, 'PUT', rule) +} + +export function deleteAlertRule(id: string): Promise { + return sendJson(`/api/settings/alerts/${encodeURIComponent(id)}`, 'DELETE') +} + +// Delivers a test notification without requiring the rule to be saved first; throws `ApiError` on failure so callers can surface it inline. +export function sendTestAlertNotification(topic: string, server: string | null): Promise { + return sendJson('/api/settings/alerts/test', 'POST', { topic, server }) +} diff --git a/webapp/src/lib/api/dashboards.ts b/webapp/src/lib/api/dashboards.ts new file mode 100644 index 0000000..9aa127d --- /dev/null +++ b/webapp/src/lib/api/dashboards.ts @@ -0,0 +1,62 @@ +import { getJson, sendJson } from './shared' + +// One chart-ready time series from `GET /api/query`: a label combination plus its `(timestamp_ms, value)` points, sorted by timestamp. +export interface PromqlSeries { + labels: Record + points: [number, number][] +} + +export type ChartType = 'bar' | 'line' | 'pie' + +export interface Panel { + id: string + title: string + promql: string + chart_type: ChartType +} + +export interface Dashboard { + id: string + name: string + panels: Panel[] +} + +// Throws on a malformed query (400); callers should catch and surface the message inline rather than letting it crash the page. +export function fetchPromqlSeries(params: { + promql: string + startMs?: number + endMs?: number +}): Promise { + const search = new URLSearchParams() + search.set('promql', params.promql) + if (params.startMs !== undefined) search.set('start_ms', String(params.startMs)) + if (params.endMs !== undefined) search.set('end_ms', String(params.endMs)) + return getJson(`/api/query?${search.toString()}`) +} + +export function fetchDashboards(profileName: string): Promise { + return getJson(`/api/settings/profiles/${encodeURIComponent(profileName)}/dashboards`) +} + +export function createDashboard(profileName: string, name: string): Promise { + return sendJson(`/api/settings/profiles/${encodeURIComponent(profileName)}/dashboards`, 'POST', { name }) +} + +export function saveDashboard( + profileName: string, + dashboardId: string, + update: { name: string; panels: Panel[] }, +): Promise { + return sendJson( + `/api/settings/profiles/${encodeURIComponent(profileName)}/dashboards/${encodeURIComponent(dashboardId)}`, + 'PUT', + update, + ) +} + +export function deleteDashboard(profileName: string, dashboardId: string): Promise { + return sendJson( + `/api/settings/profiles/${encodeURIComponent(profileName)}/dashboards/${encodeURIComponent(dashboardId)}`, + 'DELETE', + ) +} diff --git a/webapp/src/lib/api/index.ts b/webapp/src/lib/api/index.ts new file mode 100644 index 0000000..9deea0b --- /dev/null +++ b/webapp/src/lib/api/index.ts @@ -0,0 +1,7 @@ +export * from './shared' +export * from './logs' +export * from './params' +export * from './metrics' +export * from './dashboards' +export * from './alerts' +export * from './settings' diff --git a/webapp/src/lib/api/logs.ts b/webapp/src/lib/api/logs.ts new file mode 100644 index 0000000..b8c8eba --- /dev/null +++ b/webapp/src/lib/api/logs.ts @@ -0,0 +1,85 @@ +import { buildSearchParams, getJson } from './shared' + +// Externally-tagged JSON matching the Rust `MetricPrimitive` enum's derived `Serialize`, e.g. `{ "Duration": 1500 }` (milliseconds). +export type MetricPrimitive = + | { String: string } + | { Number: { Int: number } | { Float: number } } + | { Timestamp: number } + | { Duration: number } + | { Boolean: boolean } + +export function metricPrimitiveToDisplayString(value: MetricPrimitive): string { + if ('String' in value) return value.String + if ('Timestamp' in value) return String(value.Timestamp) + if ('Duration' in value) return `${value.Duration}ms` + if ('Boolean' in value) return String(value.Boolean) + if ('Int' in value.Number) return String(value.Number.Int) + return String(value.Number.Float) +} + +export interface LogEntry { + sequence: number + timestamp_ms: number + text: string + // A param with no match on this line is simply absent from the map. + params: Record +} + +export interface TimelineBucket { + start_ms: number + count: number +} + +export interface Stats { + log_count: number + first_sequence: number | null + last_sequence: number | null + first_timestamp_ms: number | null + last_timestamp_ms: number | null + /** Ticket 011: running approximate byte size of all currently-ingested logs. */ + approx_bytes_total: number + /** Ticket 073: highest ingestion-to-engine-processing lag (ms) seen within the trailing 5s. */ + ingest_lag_high_watermark_ms: number +} + +export function fetchLogs(params: { + sinceSequence?: number + startMs?: number + endMs?: number + limit?: number + filter?: string + /** Ticket 045: an already-declared param name to order entries by, instead of the default (`ottyl_ts`-tracking) order. */ + sortBy?: string + /** Ticket 045: only meaningful alongside `sortBy`. `true` (default) orders lines missing that param by `ottyl_seq`, appended after the sorted lines; `false` drops them. */ + sortMissingFallback?: boolean +}): Promise { + const search = buildSearchParams({ + since_sequence: params.sinceSequence, + start_ms: params.startMs, + end_ms: params.endMs, + limit: params.limit, + filter: params.filter, + sort_by: params.sortBy, + sort_missing_fallback: params.sortMissingFallback, + }) + return getJson(`/api/logs?${search.toString()}`) +} + +export function fetchTimeline(params: { + startMs?: number + endMs?: number + bucketMs?: number + filter?: string +}): Promise { + const search = buildSearchParams({ + start_ms: params.startMs, + end_ms: params.endMs, + bucket_ms: params.bucketMs, + filter: params.filter, + }) + return getJson(`/api/timeline?${search.toString()}`) +} + +export function fetchStats(): Promise { + return getJson('/api/stats') +} diff --git a/webapp/src/lib/api/metrics.ts b/webapp/src/lib/api/metrics.ts new file mode 100644 index 0000000..1933123 --- /dev/null +++ b/webapp/src/lib/api/metrics.ts @@ -0,0 +1,57 @@ +import { getJson, postJson, putJson, sendJson } from './shared' +import type { MetricPrimitive } from './logs' + +export interface MetricDeclaration { + name: string + message_match_regex: string + /** Names of already-declared `ParamDeclaration`s (see the Params panel) whose values become this metric's bucket labels. */ + label_params: string[] + /** Name of an already-declared param (if any) whose value becomes this metric's per-event bucket value instead of a label. */ + value_param?: string | null + /** Fixed labels applied on a match/non-match, on top of any captured params. */ + matched_labels?: Record | null + unmatched_labels?: Record | null +} + +export interface BucketValue { + labels: Record + sequence: number + timestamp_ms: number + value: MetricPrimitive +} + +export function fetchMetricDeclarations(): Promise { + return getJson('/api/metrics') +} + +export function declareMetric(request: MetricDeclaration): Promise { + return postJson('/api/metrics', request) +} + +// Updates the metric declaration named `existingName` in place (`existingName` may differ from `request.name` to rename it), rather than declaring a second metric or failing on a duplicate-name conflict. +export function updateMetric(existingName: string, request: MetricDeclaration): Promise { + return putJson(`/api/metrics/${encodeURIComponent(existingName)}`, request) +} + +export function deleteMetric(name: string): Promise { + return sendJson(`/api/metrics/${encodeURIComponent(name)}`, 'DELETE') +} + +export function fetchMetricBucketValues( + name: string, + params: { + labels?: string + sinceSequence?: number + startMs?: number + endMs?: number + limit?: number + } = {}, +): Promise { + const search = new URLSearchParams() + if (params.labels !== undefined) search.set('labels', params.labels) + if (params.sinceSequence !== undefined) search.set('since_sequence', String(params.sinceSequence)) + if (params.startMs !== undefined) search.set('start_ms', String(params.startMs)) + if (params.endMs !== undefined) search.set('end_ms', String(params.endMs)) + if (params.limit !== undefined) search.set('limit', String(params.limit)) + return getJson(`/api/metrics/${encodeURIComponent(name)}?${search.toString()}`) +} diff --git a/webapp/src/lib/api/params.ts b/webapp/src/lib/api/params.ts new file mode 100644 index 0000000..cc8d13d --- /dev/null +++ b/webapp/src/lib/api/params.ts @@ -0,0 +1,37 @@ +import { getJson, postJson, sendJson } from './shared' + +export interface ParamDeclaration { + name: string + message_match_regex: string + capture_group: string + /** Alternative to `capture_group`: a dot-separated JSON field path (e.g. "foo.bar"), read from lines that parse as a JSON object. When set (non-empty), takes precedence over `capture_group`. */ + json_field_path?: string | null + kind: 'string' | 'number' | 'timestamp' | 'duration' | 'boolean' + /** A third extraction source, distinct from `capture_group`/`json_field_path`: when true, this param's value is never parsed from text — it's computed as elapsed time since the previous event sharing the same label set. Only meaningful paired with `kind: 'duration'`. */ + elapsed_since_last?: boolean + /** Set only for the built-in `ottyl_seq`/`ottyl_ts`/`ottyl_fmt` names, synthesized into `GET /api/params` for discoverability — they're usable in the filter bar (and `ottyl_seq`/`ottyl_ts` in dashboard PromQL queries) without ever being declared, and can't be edited or removed. */ + reserved?: boolean +} + +export function fetchParamDeclarations(): Promise { + return getJson('/api/params') +} + +export function declareParam(request: ParamDeclaration): Promise { + return postJson('/api/params', request) +} + +export interface ClearParamsResult { + /** How many param declarations (manual + auto-derived) were removed. */ + removed: number + /** How many params were immediately re-derived from log text still retained by the engine. */ + reextracted: number + /** How many retained log lines were re-scanned — lines already evicted by `max_logs` before the clear can't regain their params, so this can be smaller than the total lines ever ingested. */ + logs_considered: number +} + +// Clears every declared param (ticket 052) and re-derives whatever the engine's still-retained log +// text supports — see `ClearParamsResult`'s fields for what came back. +export function clearParams(): Promise { + return sendJson('/api/params', 'DELETE') +} diff --git a/webapp/src/lib/api/settings.ts b/webapp/src/lib/api/settings.ts new file mode 100644 index 0000000..192a1bb --- /dev/null +++ b/webapp/src/lib/api/settings.ts @@ -0,0 +1,67 @@ +import { getJson, sendJson } from './shared' + +/** Name of a theme embedded in this build (see `GET /api/themes`), or `"system"` to follow the OS light/dark preference. */ +export type Theme = string + +export interface Settings { + theme: Theme + active_profile: string | null + /** Maximum ingested logs kept in memory; `null` means unlimited. */ + max_logs: number | null + /** Ntfy topic every alert rule delivers to; `null` until configured. */ + ntfy_topic: string | null + /** Ntfy server override; `null` means `https://ntfy.sh`. */ + ntfy_server: string | null +} + +export interface ProfileSummary { + name: string + param_count: number + metric_count: number + dashboard_count: number +} + +export function fetchSettings(): Promise { + return getJson('/api/settings') +} + +export function updateSettings(update: { + theme?: Theme + /** Omit to leave unchanged; `null` clears the cap back to unlimited. */ + maxLogs?: number | null + /** Omit to leave unchanged; `null` clears the configured topic. */ + ntfyTopic?: string | null + /** Omit to leave unchanged; `null` falls back to `https://ntfy.sh`. */ + ntfyServer?: string | null +}): Promise { + const body: Record = { theme: update.theme } + if ('maxLogs' in update) body.max_logs = update.maxLogs + if ('ntfyTopic' in update) body.ntfy_topic = update.ntfyTopic + if ('ntfyServer' in update) body.ntfy_server = update.ntfyServer + return sendJson('/api/settings', 'PATCH', body) +} + +/** Names of the themes embedded in this build (see `themes/` at the repo root). */ +export function fetchThemes(): Promise { + return getJson('/api/themes') +} + +export function themeCssUrl(name: string): string { + return `/api/themes/${encodeURIComponent(name)}` +} + +export function fetchProfiles(): Promise { + return getJson('/api/settings/profiles') +} + +export function createProfile(name: string): Promise { + return sendJson('/api/settings/profiles', 'POST', { name }) +} + +export function deleteProfile(name: string): Promise { + return sendJson(`/api/settings/profiles/${encodeURIComponent(name)}`, 'DELETE') +} + +export function activateProfile(name: string): Promise { + return sendJson(`/api/settings/profiles/${encodeURIComponent(name)}/activate`, 'POST') +} diff --git a/webapp/src/lib/api/shared.ts b/webapp/src/lib/api/shared.ts new file mode 100644 index 0000000..324dce0 --- /dev/null +++ b/webapp/src/lib/api/shared.ts @@ -0,0 +1,103 @@ +// Error shape returned by API handlers on a 4xx/5xx response (see `ErrorDto` on the backend). +export interface ApiErrorBody { + error: string +} + +export class ApiError extends Error { + constructor(message: string) { + super(message) + this.name = 'ApiError' + } +} + +// Builds a `URLSearchParams` from an object of optional query fields, skipping any that are +// `undefined` (falsy strings like `''` are also skipped, matching the hand-written `if` checks +// this replaces). +export function buildSearchParams(fields: Record): URLSearchParams { + const search = new URLSearchParams() + for (const [key, value] of Object.entries(fields)) { + if (value !== undefined && value !== '') search.set(key, String(value)) + } + return search +} + +export async function getJson(path: string): Promise { + const res = await fetch(path) + if (!res.ok) { + let message = `${path} failed: ${res.status}` + try { + const body: unknown = await res.json() + if (body && typeof body === 'object' && 'error' in body && typeof (body as { error: unknown }).error === 'string') { + message = (body as { error: string }).error + } + } catch { + // body wasn't JSON (or had no `error` field) — fall back to the generic message + } + throw new Error(message) + } + return res.json() as Promise +} + +// Throws `ApiError` (not `Error`) with the parsed `{ "error": "..." }` message so callers can surface it inline. +export async function postJson(path: string, body: unknown): Promise { + const res = await fetch(path, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }) + if (!res.ok) { + let message = `${path} failed: ${res.status}` + try { + const errorBody = (await res.json()) as Partial + if (errorBody && typeof errorBody.error === 'string') { + message = errorBody.error + } + } catch { + // response body wasn't JSON; fall back to the generic message + } + throw new ApiError(message) + } + return res.json() as Promise +} + +// Same error handling as `postJson` (throws `ApiError` with the parsed message) but for a PUT — used where updating in place, rather than creating, is the operation. +export async function putJson(path: string, body: unknown): Promise { + const res = await fetch(path, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }) + if (!res.ok) { + let message = `${path} failed: ${res.status}` + try { + const errorBody = (await res.json()) as Partial + if (errorBody && typeof errorBody.error === 'string') { + message = errorBody.error + } + } catch { + // response body wasn't JSON; fall back to the generic message + } + throw new ApiError(message) + } + return res.json() as Promise +} + +export async function sendJson(path: string, method: string, body?: unknown): Promise { + const res = await fetch(path, { + method, + headers: body !== undefined ? { 'Content-Type': 'application/json' } : undefined, + body: body !== undefined ? JSON.stringify(body) : undefined, + }) + if (!res.ok) { + let message = `${path} failed: ${res.status}` + try { + const data = await res.json() + if (data?.error) message = data.error + } catch { + // response body wasn't JSON (or was empty) — fall back to the generic message + } + throw new Error(message) + } + if (res.status === 204) return undefined as T + return res.json() as Promise +}