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
+298
View File
@@ -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<string, string>
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<string, LabelGroup>()
for (const value of values) {
const key = labelsKey(value.labels)
let group = groups.get(key)
if (!group) {
const labels: Record<string, string> = {}
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<number>(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<MetricDeclaration[]>([])
const [selected, setSelected] = useState<string | null>(null)
const [bucketValues, setBucketValues] = useState<BucketValue[]>([])
const [name, setName] = useState('')
const [regex, setRegex] = useState('')
const [matchers, setMatchers] = useState<MetricParamMatcher[]>([{ ...EMPTY_MATCHER }])
const [submitError, setSubmitError] = useState<string | null>(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<MetricParamMatcher>) {
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 (
<div className="border-b border-neutral-800 p-3 text-sm">
<div className="mb-2 text-xs text-neutral-500">Metrics</div>
<div className="flex gap-4">
<form onSubmit={handleSubmit} className="w-80 shrink-0 space-y-2">
<div>
<label className="mb-1 block text-xs text-neutral-500">Name</label>
<input
className="w-full rounded border border-neutral-700 bg-neutral-900 px-2 py-1 text-sm text-neutral-100 placeholder:text-neutral-500 focus:border-neutral-500 focus:outline-none"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="requests"
required
/>
</div>
<div>
<label className="mb-1 block text-xs text-neutral-500">Message match regex</label>
<input
className="w-full rounded border border-neutral-700 bg-neutral-900 px-2 py-1 font-mono text-sm text-neutral-100 placeholder:text-neutral-500 focus:border-neutral-500 focus:outline-none"
value={regex}
onChange={(e) => setRegex(e.target.value)}
placeholder={String.raw`request path=(?P<path>\S+) duration=(?P<duration>\d+)`}
required
/>
</div>
<div>
<div className="mb-1 flex items-center justify-between text-xs text-neutral-500">
<span>Params</span>
<button
type="button"
onClick={addMatcher}
className="rounded border border-neutral-700 px-2 py-0.5 text-neutral-300 hover:bg-neutral-800"
>
+ Add
</button>
</div>
<div className="space-y-1">
{matchers.map((matcher, i) => (
<div key={i} className="flex items-center gap-1">
<input
className="w-24 min-w-0 flex-1 rounded border border-neutral-700 bg-neutral-900 px-1.5 py-1 text-xs text-neutral-100 placeholder:text-neutral-500 focus:border-neutral-500 focus:outline-none"
value={matcher.param_name}
onChange={(e) => updateMatcher(i, { param_name: e.target.value })}
placeholder="param_name"
/>
<input
className="w-24 min-w-0 flex-1 rounded border border-neutral-700 bg-neutral-900 px-1.5 py-1 text-xs text-neutral-100 placeholder:text-neutral-500 focus:border-neutral-500 focus:outline-none"
value={matcher.capture_group}
onChange={(e) => updateMatcher(i, { capture_group: e.target.value })}
placeholder="capture_group"
/>
<select
className="rounded border border-neutral-700 bg-neutral-900 px-1 py-1 text-xs text-neutral-100 focus:border-neutral-500 focus:outline-none"
value={matcher.kind}
onChange={(e) => updateMatcher(i, { kind: e.target.value as MetricParamMatcher['kind'] })}
>
<option value="string">string</option>
<option value="number">number</option>
<option value="timestamp">timestamp</option>
</select>
<button
type="button"
onClick={() => 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"
>
×
</button>
</div>
))}
</div>
</div>
{submitError && <div className="rounded border border-red-900 bg-red-950/50 px-2 py-1 text-xs text-red-400">{submitError}</div>}
<button
type="submit"
disabled={submitting}
className="rounded border border-neutral-700 px-3 py-1 text-sm text-neutral-300 hover:bg-neutral-800 disabled:opacity-50"
>
{submitting ? 'Declaring…' : 'Declare metric'}
</button>
</form>
<div className="min-w-0 flex-1">
<div className="mb-1 text-xs text-neutral-500">Declared metrics</div>
<div className="mb-3 flex flex-wrap gap-1">
{metrics.length === 0 && <span className="text-xs text-neutral-600">No metrics declared yet.</span>}
{metrics.map((m) => (
<button
key={m.name}
onClick={() => 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'})
</button>
))}
</div>
{selected && (
<div>
<div className="mb-1 text-xs text-neutral-500">
{selected} — {bucketValues.length} occurrence{bucketValues.length === 1 ? '' : 's'} in range
</div>
{groups.length === 0 ? (
<div className="text-xs text-neutral-600">No matching values in the active range.</div>
) : (
<div className="max-h-56 overflow-auto rounded border border-neutral-800">
<table className="w-full border-collapse text-xs">
<thead className="sticky top-0 bg-neutral-900 text-neutral-500">
<tr>
<th className="px-2 py-1 text-left font-normal">Labels</th>
<th className="px-2 py-1 text-right font-normal">Count</th>
<th className="px-2 py-1 text-left font-normal">Trend</th>
</tr>
</thead>
<tbody>
{groups.map((group) => (
<tr key={group.key} className="border-t border-neutral-800 text-neutral-300">
<td className="px-2 py-1 font-mono">
{Object.entries(group.labels)
.map(([k, v]) => `${k}=${v}`)
.join(', ') || '(no labels)'}
</td>
<td className="px-2 py-1 text-right">{group.count}</td>
<td className="px-2 py-1 font-mono text-sky-400">{sparkline(group.values, range)}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
)}
</div>
</div>
</div>
)
}