diff --git a/webapp/src/App.tsx b/webapp/src/App.tsx index a6e019c..04e96cd 100644 --- a/webapp/src/App.tsx +++ b/webapp/src/App.tsx @@ -4,6 +4,7 @@ import { FilterBar } from './components/FilterBar' import { LogTimeline, type TimeRange } from './components/LogTimeline' import { LogPreview } from './components/LogPreview' import { ComingSoon } from './components/ComingSoon' +import { Metrics } from './components/Metrics' import { fetchLogs, fetchStats, fetchTimeline, type LogEntry, type Stats, type TimelineBucket } from './lib/api' const POLL_MS = 2000 @@ -54,7 +55,7 @@ export default function App() { - + diff --git a/webapp/src/components/Metrics.tsx b/webapp/src/components/Metrics.tsx new file mode 100644 index 0000000..afa3dc9 --- /dev/null +++ b/webapp/src/components/Metrics.tsx @@ -0,0 +1,298 @@ +import { useEffect, useState } from 'react' +import type { TimeRange } from './LogTimeline' +import { + ApiError, + declareMetric, + fetchMetricBucketValues, + fetchMetricDeclarations, + metricPrimitiveToDisplayString, + type BucketValue, + type MetricDeclaration, + type MetricParamMatcher, +} from '../lib/api' + +const EMPTY_MATCHER: MetricParamMatcher = { param_name: '', capture_group: '', kind: 'string' } + +/// One row in the "labels -> occurrence count" table for the selected +/// metric's bucket values over the active range. `values` keeps the raw +/// points (used for the sparkline-ish rendering) alongside the count. +interface LabelGroup { + key: string + labels: Record + count: number + values: BucketValue[] +} + +function labelsKey(labels: BucketValue['labels']): string { + return JSON.stringify( + Object.entries(labels) + .map(([k, v]) => [k, metricPrimitiveToDisplayString(v)]) + .sort(([a], [b]) => a.localeCompare(b)), + ) +} + +function groupByLabels(values: BucketValue[]): LabelGroup[] { + const groups = new Map() + for (const value of values) { + const key = labelsKey(value.labels) + let group = groups.get(key) + if (!group) { + const labels: Record = {} + for (const [k, v] of Object.entries(value.labels)) { + labels[k] = metricPrimitiveToDisplayString(v) + } + group = { key, labels, count: 0, values: [] } + groups.set(key, group) + } + group.count += 1 + group.values.push(value) + } + return Array.from(groups.values()).sort((a, b) => b.count - a.count) +} + +/// A crude text sparkline: buckets a group's values into a fixed number of +/// slots across the visible range and renders relative bar heights using +/// block characters. Not a real chart — that's ticket 014's job. +const SPARK_CHARS = ' ▁▂▃▄▅▆▇█' +function sparkline(values: BucketValue[], range: TimeRange | null): string { + if (values.length === 0) return '' + const timestamps = values.map((v) => v.timestamp_ms) + const start = range?.startMs ?? Math.min(...timestamps) + const end = range?.endMs ?? Math.max(...timestamps) + const span = Math.max(1, end - start) + const slotCount = 24 + const slots = new Array(slotCount).fill(0) + for (const t of timestamps) { + const ratio = Math.min(1, Math.max(0, (t - start) / span)) + const idx = Math.min(slotCount - 1, Math.floor(ratio * slotCount)) + slots[idx] += 1 + } + const max = Math.max(1, ...slots) + return slots.map((count) => SPARK_CHARS[Math.round((count / max) * (SPARK_CHARS.length - 1))]).join('') +} + +export function Metrics({ range }: { range: TimeRange | null }) { + const [metrics, setMetrics] = useState([]) + const [selected, setSelected] = useState(null) + const [bucketValues, setBucketValues] = useState([]) + + const [name, setName] = useState('') + const [regex, setRegex] = useState('') + const [matchers, setMatchers] = useState([{ ...EMPTY_MATCHER }]) + const [submitError, setSubmitError] = useState(null) + const [submitting, setSubmitting] = useState(false) + + async function loadMetrics() { + try { + const declarations = await fetchMetricDeclarations() + setMetrics(declarations) + } catch { + // engine not reachable yet; leave the previous list in place + } + } + + useEffect(() => { + loadMetrics() + }, []) + + // Refetch-on-change rather than continuous polling: bucket values only + // need to move when the selected metric or the active time range + // changes, and a declaration's backfill (ticket 003) is already reflected + // by the time `declareMetric`'s promise resolves, so there's no need for + // an interval here the way `App.tsx`'s log/timeline polling has. + useEffect(() => { + if (!selected) { + setBucketValues([]) + return + } + let cancelled = false + fetchMetricBucketValues(selected, { startMs: range?.startMs, endMs: range?.endMs, limit: 5000 }) + .then((values) => { + if (!cancelled) setBucketValues(values) + }) + .catch(() => { + if (!cancelled) setBucketValues([]) + }) + return () => { + cancelled = true + } + }, [selected, range]) + + function updateMatcher(index: number, patch: Partial) { + setMatchers((prev) => prev.map((m, i) => (i === index ? { ...m, ...patch } : m))) + } + + function addMatcher() { + setMatchers((prev) => [...prev, { ...EMPTY_MATCHER }]) + } + + function removeMatcher(index: number) { + setMatchers((prev) => prev.filter((_, i) => i !== index)) + } + + async function handleSubmit(e: React.FormEvent) { + e.preventDefault() + setSubmitError(null) + setSubmitting(true) + try { + const declared = await declareMetric({ + name, + message_match_regex: regex, + params: matchers.filter((m) => m.param_name && m.capture_group), + }) + setName('') + setRegex('') + setMatchers([{ ...EMPTY_MATCHER }]) + await loadMetrics() + setSelected(declared.name) + } catch (err) { + setSubmitError(err instanceof ApiError ? err.message : 'Failed to declare metric') + } finally { + setSubmitting(false) + } + } + + const groups = groupByLabels(bucketValues) + + return ( + + Metrics + + + + Name + setName(e.target.value)} + placeholder="requests" + required + /> + + + Message match regex + setRegex(e.target.value)} + placeholder={String.raw`request path=(?P\S+) duration=(?P\d+)`} + required + /> + + + + Params + + + Add + + + + {matchers.map((matcher, i) => ( + + updateMatcher(i, { param_name: e.target.value })} + placeholder="param_name" + /> + updateMatcher(i, { capture_group: e.target.value })} + placeholder="capture_group" + /> + updateMatcher(i, { kind: e.target.value as MetricParamMatcher['kind'] })} + > + string + number + timestamp + + removeMatcher(i)} + disabled={matchers.length === 1} + className="rounded border border-neutral-700 px-1.5 py-1 text-xs text-neutral-400 hover:bg-neutral-800 disabled:opacity-30" + > + × + + + ))} + + + {submitError && {submitError}} + + {submitting ? 'Declaring…' : 'Declare metric'} + + + + + Declared metrics + + {metrics.length === 0 && No metrics declared yet.} + {metrics.map((m) => ( + setSelected(m.name)} + className={`rounded border px-2 py-1 text-xs ${ + selected === m.name + ? 'border-sky-500 bg-sky-500/10 text-sky-300' + : 'border-neutral-700 text-neutral-300 hover:bg-neutral-800' + }`} + title={m.message_match_regex} + > + {m.name} ({m.params.map((p) => p.param_name).join(', ') || 'no params'}) + + ))} + + + {selected && ( + + + {selected} — {bucketValues.length} occurrence{bucketValues.length === 1 ? '' : 's'} in range + + {groups.length === 0 ? ( + No matching values in the active range. + ) : ( + + + + + Labels + Count + Trend + + + + {groups.map((group) => ( + + + {Object.entries(group.labels) + .map(([k, v]) => `${k}=${v}`) + .join(', ') || '(no labels)'} + + {group.count} + {sparkline(group.values, range)} + + ))} + + + + )} + + )} + + + + ) +} diff --git a/webapp/src/lib/api.ts b/webapp/src/lib/api.ts index eddaf5f..264a6b2 100644 --- a/webapp/src/lib/api.ts +++ b/webapp/src/lib/api.ts @@ -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 + 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(path: string): Promise { const res = await fetch(path) if (!res.ok) { @@ -60,6 +92,32 @@ async function getJson(path: string): Promise { return res.json() as Promise } +/// 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(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 +} + export function fetchLogs(params: { sinceSequence?: number startMs?: number @@ -97,3 +155,30 @@ export function fetchStats(): Promise { export function fetchParamDeclarations(): Promise { return getJson('/api/params') } + +export function fetchMetricDeclarations(): Promise { + return getJson('/api/metrics') +} + +export function declareMetric(request: MetricDeclaration): Promise { + return postJson('/api/metrics', request) +} + +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()}`) +}