Files
ottyl/webapp/src/components/Metrics.tsx
T
Hugh KaznowskiandClaude Sonnet 5 466c81fa94 Add DELETE /api/metrics/{name} to allow deleting a declared metric (ticket 051)
Wires the engine's existing RemoveMetricDeclaration message (previously
only used internally by delete_profile's cleanup) up to a new DELETE
route, persists the removal into the active profile so it survives a
restart, and adds a delete affordance in the Metrics UI.

Decisions:
- Bucket-value history under the deleted metric's name is purged, not
  left orphaned (engine.rs's new remove_metric_declaration reuses
  update_metric's clear_metric_data helper). This is what lets a
  dashboard panel or PromQL query still referencing the metric by name
  fail with a readable "unknown metric" error instead of silently
  returning stale data.
- evaluate_promql now errors when a metric has neither a declaration nor
  any bucket data, since a metric ingested via --metrics-url scraping
  never gets a MetricDeclaration but must stay queryable. Verified with
  a seeded fixture (promql_query_against_deleted_metric_fails_with_readable_error
  in src/api.rs).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XFUNZwCvz3kcod3352ZR2o
2026-09-06 09:41:07 +01:00

413 lines
17 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, 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<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, activePreset }: { range: TimeRange | null; activePreset?: TimeRangePresetId | null }) {
const [metrics, setMetrics] = useState<MetricDeclaration[]>([])
const [params, setParams] = useState<ParamDeclaration[]>([])
const [selected, setSelected] = useState<string | null>(null)
const [bucketValues, setBucketValues] = useState<BucketValue[]>([])
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<string[]>([])
const [valueParam, setValueParam] = useState<string>('')
const [submitError, setSubmitError] = useState<string | null>(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<string | null>(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 (
<div className="border-b border-border text-sm">
<div
className={sectionHeaderInteractiveClassName}
role="button"
tabIndex={0}
aria-expanded={!collapsed}
onClick={() => setCollapsed(!collapsed)}
onKeyDown={handleSectionHeaderKeyDown(() => setCollapsed(!collapsed))}
>
<div className={sectionHeaderLeftClassName}>
Metrics
<HelpIcon sectionId="metrics" label="Help: Metrics" />
</div>
<div className="flex items-center gap-1.5">
<CollapseToggle collapsed={collapsed} />
<PopOutButton section="metrics" range={range} activePreset={activePreset} />
</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 flex items-center gap-1 text-xs text-text-subtle">
Message match regex
<RegexHelpIcon label="Help: Message match regex syntax" />
</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>
<label className="mb-1 block text-xs text-text-subtle">
Label params
{params.length === 0 && (
<span className="ml-1 text-text-faint">(declare a param in the Params panel below first)</span>
)}
</label>
<div className="max-h-32 space-y-0.5 overflow-auto rounded border border-border-soft bg-surface-1 p-1.5">
{params.length === 0 ? (
<span className="text-xs text-text-faint">No params declared yet.</span>
) : (
params.map((p) => (
<label key={p.name} className="flex items-center gap-1.5 text-xs text-text-secondary">
<input
type="checkbox"
checked={labelParams.includes(p.name)}
onChange={() => toggleLabelParam(p.name)}
/>
<span className="font-mono">{p.name}</span>
<span className="text-text-faint">({p.elapsed_since_last ? 'elapsed since previous' : p.kind})</span>
</label>
))
)}
</div>
</div>
<div>
<label
className="mb-1 block text-xs text-text-subtle"
title="At most one param becomes the counter's per-event captured value (used by histogram()/gauge()) instead of a label. An 'elapsed since previous event' param (declared in the Params panel) is computed automatically — elapsed time since the previous match with the same labels — rather than parsed from its capture group."
>
Value param
</label>
<select
className="w-full rounded border border-border-soft bg-surface-1 px-2 py-1 text-xs text-text focus:border-border-strong focus:outline-none"
value={valueParam}
onChange={(e) => setValueParam(e.target.value)}
>
<option value="">(none — occurrence count)</option>
{params.map((p) => (
<option key={p.name} value={p.name}>
{p.name} ({p.elapsed_since_last ? 'elapsed since previous' : p.kind})
</option>
))}
</select>
</div>
{editingName && (
<div className="rounded border border-accent bg-accent/5 px-2 py-1 text-xs text-accent-text">
Editing "{editingName}"
</div>
)}
{submitError && <div className="rounded border border-danger-border bg-danger-bg/50 px-2 py-1 text-xs text-danger-text">{submitError}</div>}
<div className="flex items-center gap-2">
<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 ? (editingName ? 'Saving…' : 'Declaring…') : editingName ? 'Save changes' : 'Declare metric'}
</button>
{editingName && (
<button
type="button"
onClick={resetForm}
disabled={submitting}
className="rounded border border-border-soft px-3 py-1 text-sm text-text-secondary hover:bg-surface-2 disabled:opacity-50"
>
Cancel
</button>
)}
</div>
</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) => (
<div
key={m.name}
className={`flex items-center gap-0.5 rounded border pl-2 pr-1 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'
}`}
>
<button type="button" onClick={() => selectMetric(m)} title={m.message_match_regex}>
{m.name} ({m.label_params.join(', ') || 'no params'})
</button>
<button
type="button"
onClick={() => handleDelete(m.name)}
className="rounded px-1 text-text-faint hover:bg-danger-bg/50 hover:text-danger-text"
aria-label={`Delete metric ${m.name}`}
title={`Delete metric "${m.name}"`}
>
×
</button>
</div>
))}
</div>
{selected && (
<div>
<div className="mb-1 text-xs text-text-subtle">
{selected} — {bucketValues.length} occurrence{bucketValues.length === 1 ? '' : 's'} in range
</div>
{selectedMetric && (
<div className="mb-2 space-y-1 rounded border border-border-soft bg-surface-1 px-2 py-1.5 text-xs text-text-secondary">
<div>
<span className="text-text-subtle">Regex: </span>
<span className="font-mono">{selectedMetric.message_match_regex}</span>
</div>
<div>
<span className="text-text-subtle">Label params: </span>
{selectedMetric.label_params.length === 0 ? (
<span className="text-text-faint">none</span>
) : (
<span className="font-mono">{selectedMetric.label_params.join(', ')}</span>
)}
</div>
<div>
<span className="text-text-subtle">Value param: </span>
<span className="font-mono">{selectedMetric.value_param ?? <span className="text-text-faint">none</span>}</span>
</div>
</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>
)
}