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
313 lines
13 KiB
TypeScript
313 lines
13 KiB
TypeScript
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>
|
||
)
|
||
}
|