Files
ottyl/webapp/src/components/Metrics.tsx
T
2026-09-05 18:00:56 +01:00

313 lines
13 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<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 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)
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<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-border text-sm">
<div className={sectionHeaderClassName}>
<div className={sectionHeaderLeftClassName}>
Metrics
<HelpIcon sectionId="metrics" label="Help: Metrics" />
</div>
<div className="flex items-center gap-1.5">
<CollapseToggle collapsed={collapsed} onToggle={() => setCollapsed(!collapsed)} />
<PopOutButton section="metrics" range={range} />
</div>
</div>
<div className="flex gap-4 p-3" hidden={collapsed}>
<form onSubmit={handleSubmit} className="w-80 shrink-0 space-y-2">
<div>
<label className="mb-1 block text-xs text-text-subtle">Name</label>
<input
className="w-full rounded border border-border-soft bg-surface-1 px-2 py-1 text-sm text-text placeholder:text-text-subtle focus:border-border-strong focus:outline-none"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="requests"
required
/>
</div>
<div>
<label className="mb-1 block text-xs text-text-subtle">Message match regex</label>
<input
className="w-full rounded border border-border-soft bg-surface-1 px-2 py-1 font-mono text-sm text-text placeholder:text-text-subtle focus:border-border-strong 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-text-subtle">
<span>Params</span>
<button
type="button"
onClick={addMatcher}
className="rounded border border-border-soft px-2 py-0.5 text-text-secondary hover:bg-surface-2"
>
+ 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-border-soft bg-surface-1 px-1.5 py-1 text-xs text-text placeholder:text-text-subtle focus:border-border-strong 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-border-soft bg-surface-1 px-1.5 py-1 text-xs text-text placeholder:text-text-subtle focus:border-border-strong focus:outline-none"
value={matcher.capture_group}
onChange={(e) => updateMatcher(i, { capture_group: e.target.value })}
placeholder="capture_group"
/>
<select
className="rounded border border-border-soft bg-surface-1 px-1 py-1 text-xs text-text focus:border-border-strong 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>
<option value="duration">duration</option>
</select>
<select
className="rounded border border-border-soft bg-surface-1 px-1 py-1 text-xs text-text focus:border-border-strong focus:outline-none"
value={matcher.role ?? 'label'}
onChange={(e) => updateMatcher(i, { role: e.target.value as MetricParamMatcher['role'] })}
title="Label params identify a bucket; at most one Value param becomes the counter's per-event captured value (used by histogram()/gauge()). A Value param of kind 'duration' is derived automatically — elapsed time since the previous match with the same labels — rather than parsed from the capture group."
>
<option value="label">label</option>
<option value="value">value</option>
</select>
<button
type="button"
onClick={() => removeMatcher(i)}
disabled={matchers.length === 1}
className="rounded border border-border-soft px-1.5 py-1 text-xs text-text-muted hover:bg-surface-2 disabled:opacity-30"
>
×
</button>
</div>
))}
</div>
</div>
{submitError && <div className="rounded border border-danger-border bg-danger-bg/50 px-2 py-1 text-xs text-danger-text">{submitError}</div>}
<button
type="submit"
disabled={submitting}
className="rounded border border-border-soft px-3 py-1 text-sm text-text-secondary hover:bg-surface-2 disabled:opacity-50"
>
{submitting ? 'Declaring…' : 'Declare metric'}
</button>
</form>
<div className="min-w-0 flex-1">
<div className="mb-1 text-xs text-text-subtle">Declared metrics</div>
<div className="mb-3 flex flex-wrap gap-1">
{metrics.length === 0 && <span className="text-xs text-text-faint">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-accent bg-accent/10 text-accent-text'
: 'border-border-soft text-text-secondary hover:bg-surface-2'
}`}
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-text-subtle">
{selected} — {bucketValues.length} occurrence{bucketValues.length === 1 ? '' : 's'} in range
</div>
{groups.length === 0 ? (
<div className="text-xs text-text-faint">No matching values in the active range.</div>
) : (
<div className="max-h-56 overflow-auto rounded border border-border">
<table className="w-full border-collapse text-xs">
<thead className="sticky top-0 bg-surface-1 text-text-subtle">
<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-border text-text-secondary">
<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-accent-text">{sparkline(group.values, range)}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
)}
</div>
</div>
</div>
)
}