Wire FilterBar to server-side filter query language (ticket 016)

Send the raw filter string to GET /api/logs and GET /api/timeline
instead of doing a naive client-side substring match, surface the
API's 400 parse error inline under the FilterBar input, and drop the
now-dead visibleLogs client-side filtering in App.tsx.

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 13:18:11 +01:00
co-authored by Claude Sonnet 5
parent 57d23c62e9
commit 10753cc9f8
3 changed files with 55 additions and 26 deletions
+11 -9
View File
@@ -14,6 +14,7 @@ export default function App() {
const [buckets, setBuckets] = useState<TimelineBucket[]>([])
const [range, setRange] = useState<TimeRange | null>(null)
const [filter, setFilter] = useState('')
const [filterError, setFilterError] = useState<string | null>(null)
useEffect(() => {
let cancelled = false
@@ -22,16 +23,19 @@ export default function App() {
try {
const [nextStats, nextBuckets, nextLogs] = await Promise.all([
fetchStats(),
fetchTimeline({ startMs: range?.startMs, endMs: range?.endMs }),
fetchLogs({ startMs: range?.startMs, endMs: range?.endMs, limit: 1000 }),
fetchTimeline({ startMs: range?.startMs, endMs: range?.endMs, filter: filter || undefined }),
fetchLogs({ startMs: range?.startMs, endMs: range?.endMs, limit: 1000, filter: filter || undefined }),
])
if (!cancelled) {
setStats(nextStats)
setBuckets(nextBuckets)
setLogs(nextLogs)
setFilterError(null)
}
} catch (err) {
if (!cancelled) {
setFilterError(err instanceof Error ? err.message : String(err))
}
} catch {
// engine not reachable yet; retry on next tick
}
}
@@ -41,17 +45,15 @@ export default function App() {
cancelled = true
clearInterval(id)
}
}, [range])
const visibleLogs = filter ? logs.filter((l) => l.text.includes(filter)) : logs
}, [range, filter])
return (
<div className="flex h-screen w-screen overflow-hidden">
<NavMenu stats={stats} />
<div className="flex min-w-0 flex-1 flex-col">
<FilterBar value={filter} onChange={setFilter} />
<FilterBar value={filter} onChange={setFilter} error={filterError} />
<LogTimeline buckets={buckets} range={range} onSelectRange={setRange} />
<LogPreview logs={visibleLogs} />
<LogPreview logs={logs} />
<ComingSoon title="Metrics" />
<ComingSoon title="Dashboards" />
</div>
+30 -16
View File
@@ -1,32 +1,46 @@
import { useState } from 'react'
/**
* Placeholder for the syntax-aware pest-parsed filter language described in the
* README. For now this only does a plain substring match, applied client-side.
* Input for the syntax-aware pest-parsed filter language described in the
* README (regex + metric comparisons combined with `and`/`or`). The raw
* filter string is sent to the server (`GET /api/logs` / `GET /api/timeline`
* `filter` param) and evaluated there; this component just manages the
* draft text and surfaces any parse error the server reports back.
*/
export function FilterBar({ value, onChange }: { value: string; onChange: (value: string) => void }) {
export function FilterBar({
value,
onChange,
error,
}: {
value: string
onChange: (value: string) => void
error?: string | null
}) {
const [draft, setDraft] = useState(value)
return (
<form
className="flex items-center gap-2 border-b border-neutral-800 px-3 py-2"
className="flex flex-col gap-1 border-b border-neutral-800 px-3 py-2"
onSubmit={(e) => {
e.preventDefault()
onChange(draft)
}}
>
<input
className="flex-1 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"
placeholder='regex("error") and sequence<5 or timestamp>12:00'
value={draft}
onChange={(e) => setDraft(e.target.value)}
/>
<button
type="submit"
className="rounded border border-neutral-700 px-3 py-1 text-sm text-neutral-300 hover:bg-neutral-800"
>
Filter
</button>
<div className="flex items-center gap-2">
<input
className="flex-1 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"
placeholder='regex("error") and sequence<5 or timestamp>12:00'
value={draft}
onChange={(e) => setDraft(e.target.value)}
/>
<button
type="submit"
className="rounded border border-neutral-700 px-3 py-1 text-sm text-neutral-300 hover:bg-neutral-800"
>
Filter
</button>
</div>
{error && <p className="text-xs text-red-500">{error}</p>}
</form>
)
}
+14 -1
View File
@@ -46,7 +46,16 @@ export interface Stats {
async function getJson<T>(path: string): Promise<T> {
const res = await fetch(path)
if (!res.ok) {
throw new Error(`${path} failed: ${res.status}`)
let message = `${path} failed: ${res.status}`
try {
const body: unknown = await res.json()
if (body && typeof body === 'object' && 'error' in body && typeof (body as { error: unknown }).error === 'string') {
message = (body as { error: string }).error
}
} catch {
// body wasn't JSON (or had no `error` field) — fall back to the generic message
}
throw new Error(message)
}
return res.json() as Promise<T>
}
@@ -56,12 +65,14 @@ export function fetchLogs(params: {
startMs?: number
endMs?: number
limit?: number
filter?: string
}): Promise<LogEntry[]> {
const search = new URLSearchParams()
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))
if (params.filter) search.set('filter', params.filter)
return getJson(`/api/logs?${search.toString()}`)
}
@@ -69,11 +80,13 @@ export function fetchTimeline(params: {
startMs?: number
endMs?: number
bucketMs?: number
filter?: string
}): Promise<TimelineBucket[]> {
const search = new URLSearchParams()
if (params.startMs !== undefined) search.set('start_ms', String(params.startMs))
if (params.endMs !== undefined) search.set('end_ms', String(params.endMs))
if (params.bucketMs !== undefined) search.set('bucket_ms', String(params.bucketMs))
if (params.filter) search.set('filter', params.filter)
return getJson(`/api/timeline?${search.toString()}`)
}