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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TeyhRfMYjMditek6geDRoQ
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
f323847748
commit
0c41048e0f
@@ -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<string, MetricPrimitive>
|
|
||||||
}
|
|
||||||
|
|
||||||
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<string, string> | null
|
|
||||||
unmatched_labels?: Record<string, string> | null
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface BucketValue {
|
|
||||||
labels: Record<string, MetricPrimitive>
|
|
||||||
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<string, string>
|
|
||||||
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<T>(path: string): Promise<T> {
|
|
||||||
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<T>
|
|
||||||
}
|
|
||||||
|
|
||||||
// Throws `ApiError` (not `Error`) with the parsed `{ "error": "..." }` message so callers can surface it inline.
|
|
||||||
async function postJson<T>(path: string, body: unknown): Promise<T> {
|
|
||||||
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<ApiErrorBody>
|
|
||||||
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<T>
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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<T>(path: string, body: unknown): Promise<T> {
|
|
||||||
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<ApiErrorBody>
|
|
||||||
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<T>
|
|
||||||
}
|
|
||||||
|
|
||||||
async function sendJson<T>(path: string, method: string, body?: unknown): Promise<T> {
|
|
||||||
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<T>
|
|
||||||
}
|
|
||||||
|
|
||||||
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<LogEntry[]> {
|
|
||||||
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<TimelineBucket[]> {
|
|
||||||
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<Stats> {
|
|
||||||
return getJson('/api/stats')
|
|
||||||
}
|
|
||||||
|
|
||||||
export function fetchParamDeclarations(): Promise<ParamDeclaration[]> {
|
|
||||||
return getJson('/api/params')
|
|
||||||
}
|
|
||||||
|
|
||||||
export function declareParam(request: ParamDeclaration): Promise<ParamDeclaration> {
|
|
||||||
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<ClearParamsResult> {
|
|
||||||
return sendJson('/api/params', 'DELETE')
|
|
||||||
}
|
|
||||||
|
|
||||||
export function fetchMetricDeclarations(): Promise<MetricDeclaration[]> {
|
|
||||||
return getJson('/api/metrics')
|
|
||||||
}
|
|
||||||
|
|
||||||
export function declareMetric(request: MetricDeclaration): Promise<MetricDeclaration> {
|
|
||||||
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<MetricDeclaration> {
|
|
||||||
return putJson(`/api/metrics/${encodeURIComponent(existingName)}`, request)
|
|
||||||
}
|
|
||||||
|
|
||||||
export function deleteMetric(name: string): Promise<void> {
|
|
||||||
return sendJson(`/api/metrics/${encodeURIComponent(name)}`, 'DELETE')
|
|
||||||
}
|
|
||||||
|
|
||||||
export function fetchMetricBucketValues(
|
|
||||||
name: string,
|
|
||||||
params: {
|
|
||||||
labels?: string
|
|
||||||
sinceSequence?: number
|
|
||||||
startMs?: number
|
|
||||||
endMs?: number
|
|
||||||
limit?: number
|
|
||||||
} = {},
|
|
||||||
): Promise<BucketValue[]> {
|
|
||||||
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<Settings> {
|
|
||||||
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<Settings> {
|
|
||||||
const body: Record<string, unknown> = { 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<string[]> {
|
|
||||||
return getJson('/api/themes')
|
|
||||||
}
|
|
||||||
|
|
||||||
export function themeCssUrl(name: string): string {
|
|
||||||
return `/api/themes/${encodeURIComponent(name)}`
|
|
||||||
}
|
|
||||||
|
|
||||||
export function fetchProfiles(): Promise<ProfileSummary[]> {
|
|
||||||
return getJson('/api/settings/profiles')
|
|
||||||
}
|
|
||||||
|
|
||||||
export function createProfile(name: string): Promise<ProfileSummary> {
|
|
||||||
return sendJson('/api/settings/profiles', 'POST', { name })
|
|
||||||
}
|
|
||||||
|
|
||||||
export function deleteProfile(name: string): Promise<void> {
|
|
||||||
return sendJson(`/api/settings/profiles/${encodeURIComponent(name)}`, 'DELETE')
|
|
||||||
}
|
|
||||||
|
|
||||||
export function activateProfile(name: string): Promise<Settings> {
|
|
||||||
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<PromqlSeries[]> {
|
|
||||||
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<Dashboard[]> {
|
|
||||||
return getJson(`/api/settings/profiles/${encodeURIComponent(profileName)}/dashboards`)
|
|
||||||
}
|
|
||||||
|
|
||||||
export function createDashboard(profileName: string, name: string): Promise<Dashboard> {
|
|
||||||
return sendJson(`/api/settings/profiles/${encodeURIComponent(profileName)}/dashboards`, 'POST', { name })
|
|
||||||
}
|
|
||||||
|
|
||||||
export function saveDashboard(
|
|
||||||
profileName: string,
|
|
||||||
dashboardId: string,
|
|
||||||
update: { name: string; panels: Panel[] },
|
|
||||||
): Promise<Dashboard> {
|
|
||||||
return sendJson(
|
|
||||||
`/api/settings/profiles/${encodeURIComponent(profileName)}/dashboards/${encodeURIComponent(dashboardId)}`,
|
|
||||||
'PUT',
|
|
||||||
update,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export function deleteDashboard(profileName: string, dashboardId: string): Promise<void> {
|
|
||||||
return sendJson(
|
|
||||||
`/api/settings/profiles/${encodeURIComponent(profileName)}/dashboards/${encodeURIComponent(dashboardId)}`,
|
|
||||||
'DELETE',
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export function fetchAlertRules(): Promise<AlertRule[]> {
|
|
||||||
return getJson('/api/settings/alerts')
|
|
||||||
}
|
|
||||||
|
|
||||||
export function createAlertRule(rule: { name: string; enabled: boolean; condition: AlertCondition }): Promise<AlertRule> {
|
|
||||||
return postJson('/api/settings/alerts', rule)
|
|
||||||
}
|
|
||||||
|
|
||||||
export function updateAlertRule(
|
|
||||||
id: string,
|
|
||||||
rule: { name: string; enabled: boolean; condition: AlertCondition },
|
|
||||||
): Promise<AlertRule> {
|
|
||||||
return sendJson(`/api/settings/alerts/${encodeURIComponent(id)}`, 'PUT', rule)
|
|
||||||
}
|
|
||||||
|
|
||||||
export function deleteAlertRule(id: string): Promise<void> {
|
|
||||||
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<void> {
|
|
||||||
return sendJson('/api/settings/alerts/test', 'POST', { topic, server })
|
|
||||||
}
|
|
||||||
@@ -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<AlertRule[]> {
|
||||||
|
return getJson('/api/settings/alerts')
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createAlertRule(rule: { name: string; enabled: boolean; condition: AlertCondition }): Promise<AlertRule> {
|
||||||
|
return postJson('/api/settings/alerts', rule)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function updateAlertRule(
|
||||||
|
id: string,
|
||||||
|
rule: { name: string; enabled: boolean; condition: AlertCondition },
|
||||||
|
): Promise<AlertRule> {
|
||||||
|
return sendJson(`/api/settings/alerts/${encodeURIComponent(id)}`, 'PUT', rule)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deleteAlertRule(id: string): Promise<void> {
|
||||||
|
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<void> {
|
||||||
|
return sendJson('/api/settings/alerts/test', 'POST', { topic, server })
|
||||||
|
}
|
||||||
@@ -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<string, string>
|
||||||
|
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<PromqlSeries[]> {
|
||||||
|
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<Dashboard[]> {
|
||||||
|
return getJson(`/api/settings/profiles/${encodeURIComponent(profileName)}/dashboards`)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createDashboard(profileName: string, name: string): Promise<Dashboard> {
|
||||||
|
return sendJson(`/api/settings/profiles/${encodeURIComponent(profileName)}/dashboards`, 'POST', { name })
|
||||||
|
}
|
||||||
|
|
||||||
|
export function saveDashboard(
|
||||||
|
profileName: string,
|
||||||
|
dashboardId: string,
|
||||||
|
update: { name: string; panels: Panel[] },
|
||||||
|
): Promise<Dashboard> {
|
||||||
|
return sendJson(
|
||||||
|
`/api/settings/profiles/${encodeURIComponent(profileName)}/dashboards/${encodeURIComponent(dashboardId)}`,
|
||||||
|
'PUT',
|
||||||
|
update,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deleteDashboard(profileName: string, dashboardId: string): Promise<void> {
|
||||||
|
return sendJson(
|
||||||
|
`/api/settings/profiles/${encodeURIComponent(profileName)}/dashboards/${encodeURIComponent(dashboardId)}`,
|
||||||
|
'DELETE',
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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'
|
||||||
@@ -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<string, MetricPrimitive>
|
||||||
|
}
|
||||||
|
|
||||||
|
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<LogEntry[]> {
|
||||||
|
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<TimelineBucket[]> {
|
||||||
|
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<Stats> {
|
||||||
|
return getJson('/api/stats')
|
||||||
|
}
|
||||||
@@ -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<string, string> | null
|
||||||
|
unmatched_labels?: Record<string, string> | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BucketValue {
|
||||||
|
labels: Record<string, MetricPrimitive>
|
||||||
|
sequence: number
|
||||||
|
timestamp_ms: number
|
||||||
|
value: MetricPrimitive
|
||||||
|
}
|
||||||
|
|
||||||
|
export function fetchMetricDeclarations(): Promise<MetricDeclaration[]> {
|
||||||
|
return getJson('/api/metrics')
|
||||||
|
}
|
||||||
|
|
||||||
|
export function declareMetric(request: MetricDeclaration): Promise<MetricDeclaration> {
|
||||||
|
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<MetricDeclaration> {
|
||||||
|
return putJson(`/api/metrics/${encodeURIComponent(existingName)}`, request)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deleteMetric(name: string): Promise<void> {
|
||||||
|
return sendJson(`/api/metrics/${encodeURIComponent(name)}`, 'DELETE')
|
||||||
|
}
|
||||||
|
|
||||||
|
export function fetchMetricBucketValues(
|
||||||
|
name: string,
|
||||||
|
params: {
|
||||||
|
labels?: string
|
||||||
|
sinceSequence?: number
|
||||||
|
startMs?: number
|
||||||
|
endMs?: number
|
||||||
|
limit?: number
|
||||||
|
} = {},
|
||||||
|
): Promise<BucketValue[]> {
|
||||||
|
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()}`)
|
||||||
|
}
|
||||||
@@ -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<ParamDeclaration[]> {
|
||||||
|
return getJson('/api/params')
|
||||||
|
}
|
||||||
|
|
||||||
|
export function declareParam(request: ParamDeclaration): Promise<ParamDeclaration> {
|
||||||
|
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<ClearParamsResult> {
|
||||||
|
return sendJson('/api/params', 'DELETE')
|
||||||
|
}
|
||||||
@@ -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<Settings> {
|
||||||
|
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<Settings> {
|
||||||
|
const body: Record<string, unknown> = { 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<string[]> {
|
||||||
|
return getJson('/api/themes')
|
||||||
|
}
|
||||||
|
|
||||||
|
export function themeCssUrl(name: string): string {
|
||||||
|
return `/api/themes/${encodeURIComponent(name)}`
|
||||||
|
}
|
||||||
|
|
||||||
|
export function fetchProfiles(): Promise<ProfileSummary[]> {
|
||||||
|
return getJson('/api/settings/profiles')
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createProfile(name: string): Promise<ProfileSummary> {
|
||||||
|
return sendJson('/api/settings/profiles', 'POST', { name })
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deleteProfile(name: string): Promise<void> {
|
||||||
|
return sendJson(`/api/settings/profiles/${encodeURIComponent(name)}`, 'DELETE')
|
||||||
|
}
|
||||||
|
|
||||||
|
export function activateProfile(name: string): Promise<Settings> {
|
||||||
|
return sendJson(`/api/settings/profiles/${encodeURIComponent(name)}/activate`, 'POST')
|
||||||
|
}
|
||||||
@@ -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<string, string | number | boolean | undefined>): 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<T>(path: string): Promise<T> {
|
||||||
|
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<T>
|
||||||
|
}
|
||||||
|
|
||||||
|
// Throws `ApiError` (not `Error`) with the parsed `{ "error": "..." }` message so callers can surface it inline.
|
||||||
|
export async function postJson<T>(path: string, body: unknown): Promise<T> {
|
||||||
|
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<ApiErrorBody>
|
||||||
|
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<T>
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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<T>(path: string, body: unknown): Promise<T> {
|
||||||
|
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<ApiErrorBody>
|
||||||
|
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<T>
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function sendJson<T>(path: string, method: string, body?: unknown): Promise<T> {
|
||||||
|
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<T>
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user