Add Metrics UI: declare metrics and view derived bucket values (ticket 012)

Replaces the Metrics ComingSoon placeholder with a form for declaring
metrics (name, message_match_regex, param matchers) and a list/detail
view of declared metrics whose bucket values refetch on selection or
active-range change (no continuous polling, since declare's backfill
already resolves synchronously with the POST).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XFUNZwCvz3kcod3352ZR2o
This commit is contained in:
Hugh Kaznowski
2026-09-05 13:18:20 +01:00
co-authored by Claude Sonnet 5
parent 57d23c62e9
commit 5974070645
3 changed files with 385 additions and 1 deletions
+85
View File
@@ -43,6 +43,38 @@ export interface Stats {
last_timestamp_ms: number | null
}
export interface MetricParamMatcher {
param_name: string
capture_group: string
kind: 'string' | 'number' | 'timestamp'
}
export interface MetricDeclaration {
name: string
message_match_regex: string
params: MetricParamMatcher[]
}
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'
}
}
async function getJson<T>(path: string): Promise<T> {
const res = await fetch(path)
if (!res.ok) {
@@ -51,6 +83,32 @@ async function getJson<T>(path: string): Promise<T> {
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>
}
export function fetchLogs(params: {
sinceSequence?: number
startMs?: number
@@ -84,3 +142,30 @@ export function fetchStats(): Promise<Stats> {
export function fetchParamDeclarations(): Promise<ParamDeclaration[]> {
return getJson('/api/params')
}
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()}`)
}