Add Params declaration UI (ticket 034)

New Params panel (Log viewer / Params / Metrics / Dashboards) declares a
bare param via POST /api/params, mirroring Metrics' declare-then-list
pattern including inline 400 error surfacing. Lifted the params list from
LogPreview's own effect into App.tsx so declaring a param there updates
the Columns picker immediately, without a page reload.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XFUNZwCvz3kcod3352ZR2o
This commit is contained in:
Hugh Kaznowski
2026-09-05 16:43:53 +01:00
co-authored by Claude Sonnet 5
parent 41c9a3660b
commit dd1a0a234d
6 changed files with 147 additions and 68 deletions
-41
View File
@@ -1,41 +0,0 @@
# 034 — Params declaration UI
## Summary
`POST /api/params` (ticket 004) already lets a param be declared — name,
`message_match_regex`, `capture_group`, `kind` — and `GET /api/params`
(`fetchParams`, `webapp/src/lib/api.ts:245`) already feeds LogPreview's
"Columns" picker (`webapp/src/components/LogPreview.tsx:63`) so a declared
param can be shown as a log table column. But nothing in the webapp can
*create* one — the only way to declare a param today is a raw `POST
/api/params` call. Add a UI for it, following the same declare-then-list
pattern `Metrics` already uses for `POST /api/metrics`
(`webapp/src/components/Metrics.tsx`).
## Scope
- A "Params" section in the primary UI (alongside Log viewer / Metrics /
Dashboards in `App.tsx`), listing currently-declared params (name, regex,
capture group, kind) via `fetchParams`.
- A declare form: name, `message_match_regex`, `capture_group`, kind
(string/number/timestamp — mirror `MetricPrimitiveKind`), submitting via a
new `declareParam` call to `POST /api/params`.
- Surface `400` validation errors from the backend (bad regex, unknown
capture group) inline on the form, matching how `Metrics`' declare form
reports its own validation errors.
- After a successful declare, refresh the params list so it (and
LogPreview's Columns picker) immediately reflects the new param without a
page reload.
- Contextual "?" help icon on the section, matching [021](021-docs-contextual-help.md)'s pattern.
## Acceptance criteria
- A param can be declared from the UI and immediately appears in LogPreview's
Columns picker without a page reload.
- An invalid regex or unknown capture group shows the backend's error message
on the form instead of failing silently.
- The declared-params list matches `GET /api/params` after a page reload.
## Depends on
- [021 — Docs contextual help](021-docs-contextual-help.md)
+26 -2
View File
@@ -3,10 +3,20 @@ import { NavMenu } from './components/NavMenu'
import { FilterBar } from './components/FilterBar' import { FilterBar } from './components/FilterBar'
import { LogTimeline, computePresetRange, type TimeRange, type TimeRangePresetId } from './components/LogTimeline' import { LogTimeline, computePresetRange, type TimeRange, type TimeRangePresetId } from './components/LogTimeline'
import { LogPreview } from './components/LogPreview' import { LogPreview } from './components/LogPreview'
import { Params } from './components/Params'
import { Metrics } from './components/Metrics' import { Metrics } from './components/Metrics'
import { Dashboards } from './components/Dashboards' import { Dashboards } from './components/Dashboards'
import { Alerts } from './components/Alerts' import { Alerts } from './components/Alerts'
import { fetchLogs, fetchStats, fetchTimeline, type LogEntry, type Stats, type TimelineBucket } from './lib/api' import {
fetchLogs,
fetchParamDeclarations,
fetchStats,
fetchTimeline,
type LogEntry,
type ParamDeclaration,
type Stats,
type TimelineBucket,
} from './lib/api'
import { DocsNavProvider } from './lib/docsNav' import { DocsNavProvider } from './lib/docsNav'
const POLL_MS = 2000 const POLL_MS = 2000
@@ -14,6 +24,7 @@ const POLL_MS = 2000
export default function App() { export default function App() {
const [stats, setStats] = useState<Stats | null>(null) const [stats, setStats] = useState<Stats | null>(null)
const [logs, setLogs] = useState<LogEntry[]>([]) const [logs, setLogs] = useState<LogEntry[]>([])
const [params, setParams] = useState<ParamDeclaration[]>([])
const [buckets, setBuckets] = useState<TimelineBucket[]>([]) const [buckets, setBuckets] = useState<TimelineBucket[]>([])
const [range, setRange] = useState<TimeRange | null>(null) const [range, setRange] = useState<TimeRange | null>(null)
const [activePreset, setActivePreset] = useState<TimeRangePresetId | null>('all') const [activePreset, setActivePreset] = useState<TimeRangePresetId | null>('all')
@@ -30,6 +41,18 @@ export default function App() {
rangeRef.current = range rangeRef.current = range
}, [range]) }, [range])
async function loadParams() {
try {
setParams(await fetchParamDeclarations())
} catch {
// engine not reachable yet; leave the previous list in place
}
}
useEffect(() => {
loadParams()
}, [])
function selectPreset(id: TimeRangePresetId) { function selectPreset(id: TimeRangePresetId) {
setActivePreset(id) setActivePreset(id)
setRange(computePresetRange(id)) setRange(computePresetRange(id))
@@ -108,7 +131,8 @@ export default function App() {
activePreset={activePreset} activePreset={activePreset}
onSelectPreset={selectPreset} onSelectPreset={selectPreset}
/> />
<LogPreview logs={logs} /> <LogPreview logs={logs} params={params} />
<Params params={params} onDeclared={loadParams} />
<Metrics range={range} /> <Metrics range={range} />
<Dashboards range={range} /> <Dashboards range={range} />
<Alerts /> <Alerts />
+3 -3
View File
@@ -116,9 +116,9 @@ const SECTIONS: DocSection[] = [
the log table and as a comparable value in the filter bar. the log table and as a comparable value in the filter bar.
</p> </p>
<p> <p>
Declaring a param independent of a metric is supported by the API (<Code>POST /api/params</Code>), used The Params panel declares a bare param directly (<Code>POST /api/params</Code>) — the same request the
today via the Metrics panel's declaration form and read back by the log preview's column picker; there Metrics panel's declaration form makes per param matcher. Declaring one there immediately updates the log
isn't yet a standalone "declare a bare param" form in the UI. preview's column picker, with no page reload needed.
</p> </p>
</div> </div>
), ),
+3 -22
View File
@@ -1,9 +1,5 @@
import { useEffect, useRef, useState } from 'react' import { useEffect, useRef, useState } from 'react'
import { import { metricPrimitiveToDisplayString, type LogEntry, type ParamDeclaration } from '../lib/api'
fetchParamDeclarations,
metricPrimitiveToDisplayString,
type LogEntry,
} from '../lib/api'
import { HelpIcon } from './HelpIcon' import { HelpIcon } from './HelpIcon'
/// localStorage key for LogPreview's selected param columns. Namespaced /// localStorage key for LogPreview's selected param columns. Namespaced
@@ -89,25 +85,10 @@ function ColumnPicker({
) )
} }
export function LogPreview({ logs }: { logs: LogEntry[] }) { export function LogPreview({ logs, params }: { logs: LogEntry[]; params: ParamDeclaration[] }) {
const [paramNames, setParamNames] = useState<string[]>([]) const paramNames = params.map((d) => d.name)
const [visibleParamColumns, setVisibleParamColumns] = useState<string[]>(loadVisibleParamColumns) const [visibleParamColumns, setVisibleParamColumns] = useState<string[]>(loadVisibleParamColumns)
useEffect(() => {
let cancelled = false
fetchParamDeclarations()
.then((declarations) => {
if (!cancelled) setParamNames(declarations.map((d) => d.name))
})
.catch(() => {
// params API not reachable yet; column picker just stays empty until
// the next successful fetch.
})
return () => {
cancelled = true
}
}, [])
const visible = new Set(visibleParamColumns) const visible = new Set(visibleParamColumns)
function toggleColumn(name: string) { function toggleColumn(name: string) {
+111
View File
@@ -0,0 +1,111 @@
import { useState } from 'react'
import { ApiError, declareParam, type ParamDeclaration } from '../lib/api'
import { HelpIcon } from './HelpIcon'
const EMPTY_KIND: ParamDeclaration['kind'] = 'string'
export function Params({ params, onDeclared }: { params: ParamDeclaration[]; onDeclared: () => void }) {
const [name, setName] = useState('')
const [regex, setRegex] = useState('')
const [captureGroup, setCaptureGroup] = useState('')
const [kind, setKind] = useState<ParamDeclaration['kind']>(EMPTY_KIND)
const [submitError, setSubmitError] = useState<string | null>(null)
const [submitting, setSubmitting] = useState(false)
async function handleSubmit(e: React.FormEvent) {
e.preventDefault()
setSubmitError(null)
setSubmitting(true)
try {
await declareParam({ name, message_match_regex: regex, capture_group: captureGroup, kind })
setName('')
setRegex('')
setCaptureGroup('')
setKind(EMPTY_KIND)
onDeclared()
} catch (err) {
setSubmitError(err instanceof ApiError ? err.message : 'Failed to declare param')
} finally {
setSubmitting(false)
}
}
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">
Params
<HelpIcon sectionId="parameters" label="Help: Params" />
</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="path"
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+)`}
required
/>
</div>
<div className="flex items-end gap-1">
<div className="min-w-0 flex-1">
<label className="mb-1 block text-xs text-text-subtle">Capture group</label>
<input
className="w-full min-w-0 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={captureGroup}
onChange={(e) => setCaptureGroup(e.target.value)}
placeholder="path"
required
/>
</div>
<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={kind}
onChange={(e) => setKind(e.target.value as ParamDeclaration['kind'])}
>
<option value="string">string</option>
<option value="number">number</option>
<option value="timestamp">timestamp</option>
<option value="duration">duration</option>
</select>
</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 param'}
</button>
</form>
<div className="min-w-0 flex-1">
<div className="mb-1 text-xs text-text-subtle">Declared params</div>
<div className="flex flex-wrap gap-1">
{params.length === 0 && <span className="text-xs text-text-faint">No params declared yet.</span>}
{params.map((p) => (
<span
key={p.name}
className="rounded border border-border-soft px-2 py-1 text-xs text-text-secondary"
title={p.message_match_regex}
>
{p.name} ({p.kind}, group {p.capture_group})
</span>
))}
</div>
</div>
</div>
</div>
)
}
+4
View File
@@ -273,6 +273,10 @@ export function fetchParamDeclarations(): Promise<ParamDeclaration[]> {
return getJson('/api/params') return getJson('/api/params')
} }
export function declareParam(request: ParamDeclaration): Promise<ParamDeclaration> {
return postJson('/api/params', request)
}
export function fetchMetricDeclarations(): Promise<MetricDeclaration[]> { export function fetchMetricDeclarations(): Promise<MetricDeclaration[]> {
return getJson('/api/metrics') return getJson('/api/metrics')
} }