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 { CollapseToggle, PopOutButton, sectionHeaderClassName, sectionHeaderLeftClassName, useSectionCollapsed } from './CollapsibleSection' import { HelpIcon } from './HelpIcon' const EMPTY_MATCHER: MetricParamMatcher = { param_name: '', capture_group: '', kind: 'string' } // One row in the labels -> occurrence count table; `values` keeps the raw points for the sparkline rendering. 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 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) const [collapsed, setCollapsed] = useSectionCollapsed('metrics') 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 move when the selected metric or range changes, so no interval is needed here like `App.tsx`'s log/timeline polling. 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
setCollapsed(!collapsed)} />
) }