Files
ottyl/webapp/src/components/Metrics.tsx
T
Hugh KaznowskiandClaude Sonnet 5 abc061a7b1 Add contextual "?" help icons linking into Docs (ticket 021)
New HelpIcon component + a DocsNavProvider context (webapp/src/lib/docsNav.tsx)
let any component request "open Docs to section X", bumping a nonce each
time so a repeat click re-triggers even if Docs is already open to that
section. NavMenu forwards that request into DocsPanel as a `target` prop,
which expands the right accordion section, scrolls it into view, and
flashes it (a brief accent-tinted background/border, settling back after
1.2s) — verified visually with a live server + Playwright. Opening Docs via
the plain NavMenu button explicitly clears any pending target, so that path
is unchanged: no forced section, no flash.

Added a "?" icon to FilterBar, LogTimeline, LogPreview, Metrics, Dashboards,
and NavMenu's own Settings/Presets panels, plus new Docs sections for
Timeline, Log preview, Settings, and Presets (previously undocumented).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XFUNZwCvz3kcod3352ZR2o
2026-09-05 15:49:49 +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 { 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<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-border p-3 text-sm">
<div className="mb-2 flex items-center gap-1.5 text-xs text-text-subtle">
Metrics
<HelpIcon sectionId="metrics" label="Help: 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-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>
)
}