// 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 }) }