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' import { HelpIcon } from './HelpIcon' 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
setName(e.target.value)} placeholder="requests" required />
setRegex(e.target.value)} placeholder={String.raw`request path=(?P\S+) duration=(?P\d+)`} required />
Params
{matchers.map((matcher, i) => (
updateMatcher(i, { param_name: e.target.value })} placeholder="param_name" /> updateMatcher(i, { capture_group: e.target.value })} placeholder="capture_group" />
))}
{submitError &&
{submitError}
}
Declared metrics
{metrics.length === 0 && No metrics declared yet.} {metrics.map((m) => ( ))}
{selected && (
{selected} — {bucketValues.length} occurrence{bucketValues.length === 1 ? '' : 's'} in range
{groups.length === 0 ? (
No matching values in the active range.
) : (
{groups.map((group) => ( ))}
Labels Count Trend
{Object.entries(group.labels) .map(([k, v]) => `${k}=${v}`) .join(', ') || '(no labels)'} {group.count} {sparkline(group.values, range)}
)}
)}
) }