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
413 lines
17 KiB
TypeScript
413 lines
17 KiB
TypeScript
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>
|
||
)
|
||
}
|