Add Metrics UI: declare metrics and view derived bucket values (ticket 012)
Replaces the Metrics ComingSoon placeholder with a form for declaring metrics (name, message_match_regex, param matchers) and a list/detail view of declared metrics whose bucket values refetch on selection or active-range change (no continuous polling, since declare's backfill already resolves synchronously with the POST). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XFUNZwCvz3kcod3352ZR2o
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
57d23c62e9
commit
5974070645
+2
-1
@@ -4,6 +4,7 @@ import { FilterBar } from './components/FilterBar'
|
|||||||
import { LogTimeline, type TimeRange } from './components/LogTimeline'
|
import { LogTimeline, type TimeRange } from './components/LogTimeline'
|
||||||
import { LogPreview } from './components/LogPreview'
|
import { LogPreview } from './components/LogPreview'
|
||||||
import { ComingSoon } from './components/ComingSoon'
|
import { ComingSoon } from './components/ComingSoon'
|
||||||
|
import { Metrics } from './components/Metrics'
|
||||||
import { fetchLogs, fetchStats, fetchTimeline, type LogEntry, type Stats, type TimelineBucket } from './lib/api'
|
import { fetchLogs, fetchStats, fetchTimeline, type LogEntry, type Stats, type TimelineBucket } from './lib/api'
|
||||||
|
|
||||||
const POLL_MS = 2000
|
const POLL_MS = 2000
|
||||||
@@ -52,7 +53,7 @@ export default function App() {
|
|||||||
<FilterBar value={filter} onChange={setFilter} />
|
<FilterBar value={filter} onChange={setFilter} />
|
||||||
<LogTimeline buckets={buckets} range={range} onSelectRange={setRange} />
|
<LogTimeline buckets={buckets} range={range} onSelectRange={setRange} />
|
||||||
<LogPreview logs={visibleLogs} />
|
<LogPreview logs={visibleLogs} />
|
||||||
<ComingSoon title="Metrics" />
|
<Metrics range={range} />
|
||||||
<ComingSoon title="Dashboards" />
|
<ComingSoon title="Dashboards" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,298 @@
|
|||||||
|
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'
|
||||||
|
|
||||||
|
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-neutral-800 p-3 text-sm">
|
||||||
|
<div className="mb-2 text-xs text-neutral-500">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-neutral-500">Name</label>
|
||||||
|
<input
|
||||||
|
className="w-full rounded border border-neutral-700 bg-neutral-900 px-2 py-1 text-sm text-neutral-100 placeholder:text-neutral-500 focus:border-neutral-500 focus:outline-none"
|
||||||
|
value={name}
|
||||||
|
onChange={(e) => setName(e.target.value)}
|
||||||
|
placeholder="requests"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="mb-1 block text-xs text-neutral-500">Message match regex</label>
|
||||||
|
<input
|
||||||
|
className="w-full rounded border border-neutral-700 bg-neutral-900 px-2 py-1 font-mono text-sm text-neutral-100 placeholder:text-neutral-500 focus:border-neutral-500 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-neutral-500">
|
||||||
|
<span>Params</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={addMatcher}
|
||||||
|
className="rounded border border-neutral-700 px-2 py-0.5 text-neutral-300 hover:bg-neutral-800"
|
||||||
|
>
|
||||||
|
+ 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-neutral-700 bg-neutral-900 px-1.5 py-1 text-xs text-neutral-100 placeholder:text-neutral-500 focus:border-neutral-500 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-neutral-700 bg-neutral-900 px-1.5 py-1 text-xs text-neutral-100 placeholder:text-neutral-500 focus:border-neutral-500 focus:outline-none"
|
||||||
|
value={matcher.capture_group}
|
||||||
|
onChange={(e) => updateMatcher(i, { capture_group: e.target.value })}
|
||||||
|
placeholder="capture_group"
|
||||||
|
/>
|
||||||
|
<select
|
||||||
|
className="rounded border border-neutral-700 bg-neutral-900 px-1 py-1 text-xs text-neutral-100 focus:border-neutral-500 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>
|
||||||
|
</select>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => removeMatcher(i)}
|
||||||
|
disabled={matchers.length === 1}
|
||||||
|
className="rounded border border-neutral-700 px-1.5 py-1 text-xs text-neutral-400 hover:bg-neutral-800 disabled:opacity-30"
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{submitError && <div className="rounded border border-red-900 bg-red-950/50 px-2 py-1 text-xs text-red-400">{submitError}</div>}
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={submitting}
|
||||||
|
className="rounded border border-neutral-700 px-3 py-1 text-sm text-neutral-300 hover:bg-neutral-800 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{submitting ? 'Declaring…' : 'Declare metric'}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="mb-1 text-xs text-neutral-500">Declared metrics</div>
|
||||||
|
<div className="mb-3 flex flex-wrap gap-1">
|
||||||
|
{metrics.length === 0 && <span className="text-xs text-neutral-600">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-sky-500 bg-sky-500/10 text-sky-300'
|
||||||
|
: 'border-neutral-700 text-neutral-300 hover:bg-neutral-800'
|
||||||
|
}`}
|
||||||
|
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-neutral-500">
|
||||||
|
{selected} — {bucketValues.length} occurrence{bucketValues.length === 1 ? '' : 's'} in range
|
||||||
|
</div>
|
||||||
|
{groups.length === 0 ? (
|
||||||
|
<div className="text-xs text-neutral-600">No matching values in the active range.</div>
|
||||||
|
) : (
|
||||||
|
<div className="max-h-56 overflow-auto rounded border border-neutral-800">
|
||||||
|
<table className="w-full border-collapse text-xs">
|
||||||
|
<thead className="sticky top-0 bg-neutral-900 text-neutral-500">
|
||||||
|
<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-neutral-800 text-neutral-300">
|
||||||
|
<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-sky-400">{sparkline(group.values, range)}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -43,6 +43,38 @@ export interface Stats {
|
|||||||
last_timestamp_ms: number | null
|
last_timestamp_ms: number | null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface MetricParamMatcher {
|
||||||
|
param_name: string
|
||||||
|
capture_group: string
|
||||||
|
kind: 'string' | 'number' | 'timestamp'
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MetricDeclaration {
|
||||||
|
name: string
|
||||||
|
message_match_regex: string
|
||||||
|
params: MetricParamMatcher[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BucketValue {
|
||||||
|
labels: Record<string, MetricPrimitive>
|
||||||
|
sequence: number
|
||||||
|
timestamp_ms: number
|
||||||
|
value: MetricPrimitive
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Error shape returned by API handlers on a 4xx/5xx response:
|
||||||
|
/// `{ "error": "..." }` (see `ErrorDto` on the backend).
|
||||||
|
export interface ApiErrorBody {
|
||||||
|
error: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ApiError extends Error {
|
||||||
|
constructor(message: string) {
|
||||||
|
super(message)
|
||||||
|
this.name = 'ApiError'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function getJson<T>(path: string): Promise<T> {
|
async function getJson<T>(path: string): Promise<T> {
|
||||||
const res = await fetch(path)
|
const res = await fetch(path)
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
@@ -51,6 +83,32 @@ async function getJson<T>(path: string): Promise<T> {
|
|||||||
return res.json() as Promise<T>
|
return res.json() as Promise<T>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// POSTs a JSON body to `path`. On a non-ok response, attempts to parse the
|
||||||
|
/// body as `{ "error": "..." }` (the shape every declaration endpoint uses
|
||||||
|
/// for validation failures) and throws an `ApiError` with that message so
|
||||||
|
/// callers can surface it inline; falls back to a generic message if the
|
||||||
|
/// body isn't in that shape.
|
||||||
|
async function postJson<T>(path: string, body: unknown): Promise<T> {
|
||||||
|
const res = await fetch(path, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
})
|
||||||
|
if (!res.ok) {
|
||||||
|
let message = `${path} failed: ${res.status}`
|
||||||
|
try {
|
||||||
|
const errorBody = (await res.json()) as Partial<ApiErrorBody>
|
||||||
|
if (errorBody && typeof errorBody.error === 'string') {
|
||||||
|
message = errorBody.error
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// response body wasn't JSON; fall back to the generic message
|
||||||
|
}
|
||||||
|
throw new ApiError(message)
|
||||||
|
}
|
||||||
|
return res.json() as Promise<T>
|
||||||
|
}
|
||||||
|
|
||||||
export function fetchLogs(params: {
|
export function fetchLogs(params: {
|
||||||
sinceSequence?: number
|
sinceSequence?: number
|
||||||
startMs?: number
|
startMs?: number
|
||||||
@@ -84,3 +142,30 @@ export function fetchStats(): Promise<Stats> {
|
|||||||
export function fetchParamDeclarations(): Promise<ParamDeclaration[]> {
|
export function fetchParamDeclarations(): Promise<ParamDeclaration[]> {
|
||||||
return getJson('/api/params')
|
return getJson('/api/params')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function fetchMetricDeclarations(): Promise<MetricDeclaration[]> {
|
||||||
|
return getJson('/api/metrics')
|
||||||
|
}
|
||||||
|
|
||||||
|
export function declareMetric(request: MetricDeclaration): Promise<MetricDeclaration> {
|
||||||
|
return postJson('/api/metrics', request)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function fetchMetricBucketValues(
|
||||||
|
name: string,
|
||||||
|
params: {
|
||||||
|
labels?: string
|
||||||
|
sinceSequence?: number
|
||||||
|
startMs?: number
|
||||||
|
endMs?: number
|
||||||
|
limit?: number
|
||||||
|
} = {},
|
||||||
|
): Promise<BucketValue[]> {
|
||||||
|
const search = new URLSearchParams()
|
||||||
|
if (params.labels !== undefined) search.set('labels', params.labels)
|
||||||
|
if (params.sinceSequence !== undefined) search.set('since_sequence', String(params.sinceSequence))
|
||||||
|
if (params.startMs !== undefined) search.set('start_ms', String(params.startMs))
|
||||||
|
if (params.endMs !== undefined) search.set('end_ms', String(params.endMs))
|
||||||
|
if (params.limit !== undefined) search.set('limit', String(params.limit))
|
||||||
|
return getJson(`/api/metrics/${encodeURIComponent(name)}?${search.toString()}`)
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user