Merge branch 'worktree-agent-a1b2ebf28f9c558d0'
# Conflicts: # webapp/src/App.tsx
This commit is contained in:
+2
-1
@@ -4,6 +4,7 @@ import { FilterBar } from './components/FilterBar'
|
||||
import { LogTimeline, type TimeRange } from './components/LogTimeline'
|
||||
import { LogPreview } from './components/LogPreview'
|
||||
import { ComingSoon } from './components/ComingSoon'
|
||||
import { Metrics } from './components/Metrics'
|
||||
import { fetchLogs, fetchStats, fetchTimeline, type LogEntry, type Stats, type TimelineBucket } from './lib/api'
|
||||
|
||||
const POLL_MS = 2000
|
||||
@@ -54,7 +55,7 @@ export default function App() {
|
||||
<FilterBar value={filter} onChange={setFilter} error={filterError} />
|
||||
<LogTimeline buckets={buckets} range={range} onSelectRange={setRange} />
|
||||
<LogPreview logs={logs} />
|
||||
<ComingSoon title="Metrics" />
|
||||
<Metrics range={range} />
|
||||
<ComingSoon title="Dashboards" />
|
||||
</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
|
||||
}
|
||||
|
||||
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> {
|
||||
const res = await fetch(path)
|
||||
if (!res.ok) {
|
||||
@@ -60,6 +92,32 @@ async function getJson<T>(path: string): 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: {
|
||||
sinceSequence?: number
|
||||
startMs?: number
|
||||
@@ -97,3 +155,30 @@ export function fetchStats(): Promise<Stats> {
|
||||
export function fetchParamDeclarations(): Promise<ParamDeclaration[]> {
|
||||
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