import { useEffect, useState } from 'react' import type { TimeRange, TimeRangePresetId } from './LogTimeline' import { ApiError, declareMetric, deleteMetric, fetchMetricBucketValues, fetchMetricDeclarations, fetchParamDeclarations, metricPrimitiveToDisplayString, updateMetric, type BucketValue, type MetricDeclaration, type ParamDeclaration, } from '../lib/api' import { CollapseToggle, handleSectionHeaderKeyDown, PopOutButton, sectionHeaderInteractiveClassName, sectionHeaderLeftClassName, useSectionCollapsed, } from './CollapsibleSection' import { HelpIcon } from './HelpIcon' import { RegexHelpIcon } from './RegexHelpIcon' // 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, activePreset }: { range: TimeRange | null; activePreset?: TimeRangePresetId | null }) { const [metrics, setMetrics] = useState([]) const [params, setParams] = useState([]) const [selected, setSelected] = useState(null) const [bucketValues, setBucketValues] = useState([]) const [name, setName] = useState('') const [regex, setRegex] = useState('') // A metric picks its label/value params from the params already declared in the Params panel // (ticket 037) rather than re-entering a regex/capture-group/kind per param. const [labelParams, setLabelParams] = useState([]) const [valueParam, setValueParam] = useState('') const [submitError, setSubmitError] = useState(null) const [submitting, setSubmitting] = useState(false) const [collapsed, setCollapsed] = useSectionCollapsed('metrics') // Name of the declaration currently loaded into the form for editing, or `null` when the form is declaring a brand-new metric. Kept separate from `selected` (which drives the bucket-values view below) so clicking a badge can populate the form *and* select it for the values table at the same time, without either hiding the other. const [editingName, setEditingName] = useState(null) async function loadMetrics() { try { const declarations = await fetchMetricDeclarations() setMetrics(declarations) } catch { // engine not reachable yet; leave the previous list in place } } async function loadParams() { try { setParams(await fetchParamDeclarations()) } catch { // engine not reachable yet; leave the previous list in place } } useEffect(() => { loadMetrics() loadParams() }, []) // 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 toggleLabelParam(paramName: string) { setLabelParams((prev) => prev.includes(paramName) ? prev.filter((name) => name !== paramName) : [...prev, paramName], ) } function resetForm() { setEditingName(null) setName('') setRegex('') setLabelParams([]) setValueParam('') setSubmitError(null) } // Loads `metric`'s current values into the declaration form for editing, in addition to (not instead of) selecting it for the bucket-values view below — both a click handler for a declared-metric badge, so the two behaviors are always available together. function editMetric(metric: MetricDeclaration) { setEditingName(metric.name) setName(metric.name) setRegex(metric.message_match_regex) setLabelParams(metric.label_params) setValueParam(metric.value_param ?? '') setSubmitError(null) } function selectMetric(metric: MetricDeclaration) { setSelected(metric.name) editMetric(metric) } // No confirm-before-delete dialog — matches the rest of this settings UI (e.g. CSS entry / profile // deletion in NavMenu.tsx), which also deletes directly on click rather than prompting first. async function handleDelete(metricName: string) { try { await deleteMetric(metricName) if (selected === metricName) setSelected(null) if (editingName === metricName) resetForm() await loadMetrics() } catch { // best-effort; the declared-metrics list simply keeps showing it if the delete failed } } async function handleSubmit(e: React.FormEvent) { e.preventDefault() setSubmitError(null) setSubmitting(true) try { const request: MetricDeclaration = { name, message_match_regex: regex, label_params: labelParams, value_param: valueParam || null, } const saved = editingName ? await updateMetric(editingName, request) : await declareMetric(request) resetForm() await loadMetrics() setSelected(saved.name) } catch (err) { setSubmitError(err instanceof ApiError ? err.message : `Failed to ${editingName ? 'update' : 'declare'} metric`) } finally { setSubmitting(false) } } const groups = groupByLabels(bucketValues) const selectedMetric = metrics.find((m) => m.name === selected) ?? null return (
setCollapsed(!collapsed)} onKeyDown={handleSectionHeaderKeyDown(() => setCollapsed(!collapsed))} >
Metrics
) }