- 076: gate auto-declared log-stream column/message params (`ParamDeclaration::log_stream_gated`) behind `is_log_stream_line` at extraction time, since their fixed-offset regex has no content anchor and previously extracted garbage from continuation lines and the header row itself (the regex crate doesn't support lookaround, so this couldn't be baked into the regex text). - 074: end-to-end regression test confirms sorting a real `log stream` capture by a log-stream column (e.g. PID) now actually reorders results, since 076's fix removes the garbage values that were corrupting the sort helper index; also add a "Building sort index…" indicator in the timeline UI for a slow first-time sort. - 072: attach the reserved `ottyl_fmt` pseudo-param to each `LogEntryDto`'s params so it can be picked as a Log Preview column (previously listed as pickable but always blank). - 073: track a rolling 5s high-watermark of ingestion-to-engine-processing lag and surface it in the nav stats popover next to approx_bytes_total. - 071: group the regex(...) help button back with the filter bar's other controls, and restructure the Filtering docs section into scannable per-feature subsections (also fixes stale log_type/process/pid mention). Tickets 070 (CLI scoping) and 075 (API unresponsiveness investigation) are left in place per their own scope — both are explicitly conversation/investigation tasks, not implementation tickets. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01A1ZJ3SrayzLzVndhVq9BmS
472 lines
17 KiB
TypeScript
472 lines
17 KiB
TypeScript
// 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'
|
|
}
|
|
}
|
|
|
|
export type ColorScheme =
|
|
| 'light'
|
|
| 'dark'
|
|
| 'system'
|
|
| 'red'
|
|
| 'green'
|
|
| 'blue'
|
|
| 'yellow'
|
|
| 'pink'
|
|
| 'orange'
|
|
| 'light_green'
|
|
| 'light_red'
|
|
| 'light_blue'
|
|
| 'light_yellow'
|
|
| 'light_pink'
|
|
| 'light_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, or `null` if `color_scheme` alone applies. */
|
|
custom_theme_id: string | 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 type CssEntryKind = 'imported' | 'linked'
|
|
|
|
export interface CssEntry {
|
|
id: string
|
|
kind: CssEntryKind
|
|
filename: string | null
|
|
path_or_url: 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: {
|
|
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
|
|
/** 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> = { color_scheme: update.colorScheme }
|
|
if ('maxLogs' in update) body.max_logs = update.maxLogs
|
|
if ('customThemeId' in update) body.custom_theme_id = update.customThemeId
|
|
if ('ntfyTopic' in update) body.ntfy_topic = update.ntfyTopic
|
|
if ('ntfyServer' in update) body.ntfy_server = update.ntfyServer
|
|
return sendJson('/api/settings', 'PATCH', body)
|
|
}
|
|
|
|
export function fetchCssEntries(): Promise<CssEntry[]> {
|
|
return getJson('/api/settings/css')
|
|
}
|
|
|
|
export function importCss(filename: string, content: string): Promise<CssEntry> {
|
|
return sendJson('/api/settings/css', 'POST', { filename, content })
|
|
}
|
|
|
|
export function linkCss(pathOrUrl: string): Promise<CssEntry> {
|
|
return sendJson('/api/settings/css/link', 'POST', { path_or_url: pathOrUrl })
|
|
}
|
|
|
|
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')
|
|
}
|
|
|
|
// 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 })
|
|
}
|