Merge ticket/022-edit-existing-metric-declarations
This commit is contained in:
@@ -6,6 +6,7 @@ import {
|
||||
fetchMetricBucketValues,
|
||||
fetchMetricDeclarations,
|
||||
metricPrimitiveToDisplayString,
|
||||
updateMetric,
|
||||
type BucketValue,
|
||||
type MetricDeclaration,
|
||||
type MetricParamMatcher,
|
||||
@@ -88,6 +89,8 @@ export function Metrics({ range }: { range: TimeRange | null }) {
|
||||
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 {
|
||||
@@ -133,29 +136,51 @@ export function Metrics({ range }: { range: TimeRange | null }) {
|
||||
setMatchers((prev) => prev.filter((_, i) => i !== index))
|
||||
}
|
||||
|
||||
function resetForm() {
|
||||
setEditingName(null)
|
||||
setName('')
|
||||
setRegex('')
|
||||
setMatchers([{ ...EMPTY_MATCHER }])
|
||||
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)
|
||||
setMatchers(metric.params.length > 0 ? metric.params.map((p) => ({ ...p })) : [{ ...EMPTY_MATCHER }])
|
||||
setSubmitError(null)
|
||||
}
|
||||
|
||||
function selectMetric(metric: MetricDeclaration) {
|
||||
setSelected(metric.name)
|
||||
editMetric(metric)
|
||||
}
|
||||
|
||||
async function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault()
|
||||
setSubmitError(null)
|
||||
setSubmitting(true)
|
||||
try {
|
||||
const declared = await declareMetric({
|
||||
const request: MetricDeclaration = {
|
||||
name,
|
||||
message_match_regex: regex,
|
||||
params: matchers.filter((m) => m.param_name && m.capture_group),
|
||||
})
|
||||
setName('')
|
||||
setRegex('')
|
||||
setMatchers([{ ...EMPTY_MATCHER }])
|
||||
}
|
||||
const saved = editingName ? await updateMetric(editingName, request) : await declareMetric(request)
|
||||
resetForm()
|
||||
await loadMetrics()
|
||||
setSelected(declared.name)
|
||||
setSelected(saved.name)
|
||||
} catch (err) {
|
||||
setSubmitError(err instanceof ApiError ? err.message : 'Failed to declare metric')
|
||||
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">
|
||||
@@ -258,14 +283,31 @@ export function Metrics({ range }: { range: TimeRange | null }) {
|
||||
))}
|
||||
</div>
|
||||
</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>}
|
||||
<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>
|
||||
<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">
|
||||
@@ -275,7 +317,7 @@ export function Metrics({ range }: { range: TimeRange | null }) {
|
||||
{metrics.map((m) => (
|
||||
<button
|
||||
key={m.name}
|
||||
onClick={() => setSelected(m.name)}
|
||||
onClick={() => selectMetric(m)}
|
||||
className={`rounded border px-2 py-1 text-xs ${
|
||||
selected === m.name
|
||||
? 'border-accent bg-accent/10 text-accent-text'
|
||||
@@ -293,6 +335,26 @@ export function Metrics({ range }: { range: TimeRange | null }) {
|
||||
<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">Params: </span>
|
||||
{selectedMetric.params.length === 0 ? (
|
||||
<span className="text-text-faint">none</span>
|
||||
) : (
|
||||
<span className="font-mono">
|
||||
{selectedMetric.params
|
||||
.map((p) => `${p.param_name}←${p.capture_group} (${p.kind}, ${p.role ?? 'label'})`)
|
||||
.join(', ')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{groups.length === 0 ? (
|
||||
<div className="text-xs text-text-faint">No matching values in the active range.</div>
|
||||
) : (
|
||||
|
||||
@@ -205,6 +205,28 @@ async function postJson<T>(path: string, body: unknown): Promise<T> {
|
||||
return res.json() as Promise<T>
|
||||
}
|
||||
|
||||
// Same error handling as `postJson` (throws `ApiError` with the parsed message) but for a PUT — used where updating in place, rather than creating, is the operation.
|
||||
async function putJson<T>(path: string, body: unknown): Promise<T> {
|
||||
const res = await fetch(path, {
|
||||
method: 'PUT',
|
||||
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>
|
||||
}
|
||||
|
||||
async function sendJson<T>(path: string, method: string, body?: unknown): Promise<T> {
|
||||
const res = await fetch(path, {
|
||||
method,
|
||||
@@ -275,6 +297,11 @@ export function declareMetric(request: MetricDeclaration): Promise<MetricDeclara
|
||||
return postJson('/api/metrics', request)
|
||||
}
|
||||
|
||||
// Updates the metric declaration named `existingName` in place (`existingName` may differ from `request.name` to rename it), rather than declaring a second metric or failing on a duplicate-name conflict.
|
||||
export function updateMetric(existingName: string, request: MetricDeclaration): Promise<MetricDeclaration> {
|
||||
return putJson(`/api/metrics/${encodeURIComponent(existingName)}`, request)
|
||||
}
|
||||
|
||||
export function fetchMetricBucketValues(
|
||||
name: string,
|
||||
params: {
|
||||
|
||||
Reference in New Issue
Block a user