New Params panel (Log viewer / Params / Metrics / Dashboards) declares a bare param via POST /api/params, mirroring Metrics' declare-then-list pattern including inline 400 error surfacing. Lifted the params list from LogPreview's own effect into App.tsx so declaring a param there updates the Columns picker immediately, without a page reload. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XFUNZwCvz3kcod3352ZR2o
428 lines
14 KiB
TypeScript
428 lines
14 KiB
TypeScript
/// `MetricPrimitive` is a Rust enum with derived `Serialize`, so its JSON
|
|
/// form is externally tagged, e.g. `{ "String": "info" }`, `{ "Number":
|
|
/// { "Int": 5 } }`, `{ "Timestamp": 1710000000000 }`, `{ "Duration": 1500 }`
|
|
/// (milliseconds, ticket 023).
|
|
export type MetricPrimitive =
|
|
| { String: string }
|
|
| { Number: { Int: number } | { Float: number } }
|
|
| { Timestamp: number }
|
|
| { Duration: number }
|
|
|
|
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 ('Int' in value.Number) return String(value.Number.Int)
|
|
return String(value.Number.Float)
|
|
}
|
|
|
|
export interface LogEntry {
|
|
sequence: number
|
|
timestamp_ms: number
|
|
text: string
|
|
/// Derived param values (declared via `POST /api/params`) that matched
|
|
/// this line's text, keyed by param name. 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
|
|
kind: 'string' | 'number' | 'timestamp' | 'duration'
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
/** Whether a param contributes to a bucket's labels or is its captured value (ticket 022). */
|
|
export type ParamRole = 'label' | 'value'
|
|
|
|
export interface MetricParamMatcher {
|
|
param_name: string
|
|
capture_group: string
|
|
kind: 'string' | 'number' | 'timestamp' | 'duration'
|
|
/** Defaults to 'label' server-side when omitted. */
|
|
role?: ParamRole
|
|
}
|
|
|
|
export interface MetricDeclaration {
|
|
name: string
|
|
message_match_regex: string
|
|
params: MetricParamMatcher[]
|
|
/** Fixed labels applied on a match/non-match, on top of any captured params (ticket 025). */
|
|
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:
|
|
/// `{ "error": "..." }` (see `ErrorDto` on the backend).
|
|
export interface ApiErrorBody {
|
|
error: string
|
|
}
|
|
|
|
export class ApiError extends Error {
|
|
constructor(message: string) {
|
|
super(message)
|
|
this.name = 'ApiError'
|
|
}
|
|
}
|
|
|
|
export type ColorScheme =
|
|
| 'light'
|
|
| 'dark'
|
|
| 'system'
|
|
| 'grey'
|
|
| 'red'
|
|
| 'green'
|
|
| 'blue'
|
|
| 'yellow'
|
|
| 'pink'
|
|
| 'orange'
|
|
|
|
export interface Settings {
|
|
color_scheme: ColorScheme
|
|
active_profile: string | null
|
|
/** Maximum ingested logs kept in memory; `null` means unlimited. */
|
|
max_logs: number | null
|
|
/** Id of the selected custom theme entry (ticket 033), or `null` if `color_scheme` alone applies. */
|
|
custom_theme_id: string | null
|
|
}
|
|
|
|
export type CssEntryKind = 'imported' | 'linked'
|
|
|
|
export interface CssEntry {
|
|
id: string
|
|
kind: CssEntryKind
|
|
filename: string | null
|
|
path_or_url: string | null
|
|
/** Whether this entry is a selectable colour-scheme theme (ticket 033) rather than arbitrary CSS. */
|
|
is_theme: boolean
|
|
}
|
|
|
|
export interface ProfileSummary {
|
|
name: string
|
|
param_count: number
|
|
metric_count: number
|
|
dashboard_count: number
|
|
}
|
|
|
|
/// One chart-ready time series returned by `GET /api/query` (ticket 013):
|
|
/// a label combination plus its `(timestamp_ms, value)` points, sorted by
|
|
/// timestamp. See `PromqlSeriesDto` on the backend.
|
|
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[]
|
|
}
|
|
|
|
/// Comparison operator shared by `AlertCondition::Threshold`/`Ratio` and
|
|
/// filter-query metric comparisons (see `CompareOp` on the backend).
|
|
export type CompareOp = 'lt' | 'gt' | 'le' | 'ge' | 'eq' | 'ne'
|
|
|
|
/// What triggers an alert rule (ticket 017/018). Externally tagged on
|
|
/// `kind`, matching `AlertCondition`'s `#[serde(tag = "kind")]`.
|
|
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 }
|
|
|
|
/// Where a fired alert is delivered (ticket 019). Only one sink kind exists
|
|
/// today, but this stays a tagged union to match the backend.
|
|
export type AlertSink = { kind: 'ntfy'; topic: string; server: string | null }
|
|
|
|
export interface AlertRule {
|
|
id: string
|
|
name: string
|
|
enabled: boolean
|
|
condition: AlertCondition
|
|
sink: AlertSink
|
|
}
|
|
|
|
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>
|
|
}
|
|
|
|
/// POSTs a JSON body to `path`. On a non-ok response, attempts to parse the
|
|
/// body as `{ "error": "..." }` (the shape every declaration endpoint uses
|
|
/// for validation failures) and throws an `ApiError` with that message so
|
|
/// callers can surface it inline; falls back to a generic message if the
|
|
/// body isn't in that shape.
|
|
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>
|
|
}
|
|
|
|
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
|
|
}): 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)
|
|
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 function fetchMetricDeclarations(): Promise<MetricDeclaration[]> {
|
|
return getJson('/api/metrics')
|
|
}
|
|
|
|
export function declareMetric(request: MetricDeclaration): Promise<MetricDeclaration> {
|
|
return postJson('/api/metrics', request)
|
|
}
|
|
|
|
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: {
|
|
colorScheme?: ColorScheme
|
|
/** Omit to leave unchanged; `null` clears the cap back to unlimited. */
|
|
maxLogs?: number | null
|
|
/** Omit to leave unchanged; `null` clears the selection (falls back to `colorScheme`). */
|
|
customThemeId?: string | null
|
|
}): Promise<Settings> {
|
|
const body: Record<string, unknown> = { color_scheme: update.colorScheme }
|
|
if ('maxLogs' in update) body.max_logs = update.maxLogs
|
|
if ('customThemeId' in update) body.custom_theme_id = update.customThemeId
|
|
return sendJson('/api/settings', 'PATCH', body)
|
|
}
|
|
|
|
export function fetchCssEntries(): Promise<CssEntry[]> {
|
|
return getJson('/api/settings/css')
|
|
}
|
|
|
|
export function importCss(filename: string, content: string, isTheme = false): Promise<CssEntry> {
|
|
return sendJson('/api/settings/css', 'POST', { filename, content, is_theme: isTheme })
|
|
}
|
|
|
|
export function linkCss(pathOrUrl: string, isTheme = false): Promise<CssEntry> {
|
|
return sendJson('/api/settings/css/link', 'POST', { path_or_url: pathOrUrl, is_theme: isTheme })
|
|
}
|
|
|
|
export function deleteCssEntry(id: string): Promise<void> {
|
|
return sendJson(`/api/settings/css/${encodeURIComponent(id)}`, 'DELETE')
|
|
}
|
|
|
|
export function cssContentUrl(id: string): string {
|
|
return `/api/settings/css/${encodeURIComponent(id)}/content`
|
|
}
|
|
|
|
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')
|
|
}
|
|
|
|
/// Evaluates a PromQL query (ticket 013) via `GET /api/query`. Throws on a
|
|
/// malformed query (`400` with `{ "error": "..." }`), so callers should catch
|
|
/// and surface the message inline (e.g. per-panel) 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; sink: AlertSink }): Promise<AlertRule> {
|
|
return postJson('/api/settings/alerts', rule)
|
|
}
|
|
|
|
export function updateAlertRule(
|
|
id: string,
|
|
rule: { name: string; enabled: boolean; condition: AlertCondition; sink: AlertSink },
|
|
): 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 to `topic`/`server` (ticket 020) without
|
|
/// requiring the rule to be saved first. Throws `ApiError` on failure (e.g.
|
|
/// ntfy unreachable) 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 })
|
|
}
|