Resolve tickets 058, 062-066: frontend UI fixes

- 058: poll loop now refetches param declarations so auto-declared
  params appear live without a page reload
- 062: browser tab title says "ottyl" instead of "webapp"
- 063: declared params list omits the redundant capture-group suffix
  when it matches the param name
- 064: dashboard autosave no longer clobbers in-flight PromQL edits;
  reconciliation is guarded against edits made after the save request
  was sent
- 065: filter bar placeholder/help icons lead with filter-bar syntax
  instead of regex
- 066: filter bar surfaces the active time-range preset/sort order and
  resyncs its draft when the value prop changes externally

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A1ZJ3SrayzLzVndhVq9BmS
This commit is contained in:
Hugh Kaznowski
2026-09-06 13:04:26 +01:00
co-authored by Claude Sonnet 5
parent a03ec1fb93
commit 4ace654019
11 changed files with 83 additions and 258 deletions
+37 -3
View File
@@ -254,6 +254,28 @@ function StandaloneRanged({
)
}
/// Ticket 058: only replaces `params` state when the declaration list actually changed, so a param
/// list refetched on every poll tick doesn't force `Params.tsx` to re-render when nothing changed.
function updateParamsIfChanged(
setParams: (updater: (current: ParamDeclaration[]) => ParamDeclaration[]) => void,
next: ParamDeclaration[],
) {
setParams((current) => (JSON.stringify(current) === JSON.stringify(next) ? current : next))
}
/// Ticket 066: text shown under the filter bar when the user hasn't typed a manual filter, so an active preset/sort order is still visible somewhere near the filter input instead of leaving it looking empty/stale.
function describeActiveState(activePreset: TimeRangePresetId | null, sortSettings: LogSortSettings): string | null {
const parts: string[] = []
if (activePreset && activePreset !== 'all') {
const preset = TIME_RANGE_PRESETS.find((p) => p.id === activePreset)
parts.push(`last ${preset?.label ?? activePreset}`)
}
if (sortSettings.sortBy) {
parts.push(`sorted by ${sortSettings.sortBy}`)
}
return parts.length > 0 ? parts.join(', ') : null
}
/// The log viewer has its own `LogTimeline`, so it needs its own copy of App's log/timeline polling state instead of a carried-over range.
function StandaloneLog() {
const [logs, setLogs] = useState<LogEntry[]>([])
@@ -311,7 +333,7 @@ function StandaloneLog() {
)
}
try {
const [nextBuckets, nextLogs] = await Promise.all([
const [nextBuckets, nextLogs, nextParams] = await Promise.all([
fetchTimeline({
startMs: effectiveRange?.startMs,
endMs: effectiveRange?.endMs,
@@ -327,10 +349,13 @@ function StandaloneLog() {
sortBy: sortSettings.sortBy ?? undefined,
sortMissingFallback: sortSettings.sortBy ? sortSettings.sortMissingFallback : undefined,
}),
// Ticket 058: params can get auto-declared from freshly ingested lines mid-run, not just from a user's own edit.
fetchParamDeclarations(),
])
if (!cancelled) {
setBuckets(nextBuckets)
setLogs(nextLogs)
updateParamsIfChanged(setParams, nextParams)
setFilterError(null)
}
} catch (err) {
@@ -352,7 +377,12 @@ function StandaloneLog() {
return (
<StandalonePage>
<div className="flex h-full min-h-0 flex-col">
<FilterBar value={filter} onChange={setFilter} error={filterError} />
<FilterBar
value={filter}
onChange={setFilter}
error={filterError}
activeSummary={describeActiveState(activePreset, sortSettings)}
/>
<div className="flex min-h-0 flex-1 flex-col">
<LogTimeline
buckets={buckets}
@@ -458,7 +488,7 @@ function FullApp() {
)
}
try {
const [nextStats, nextBuckets, nextLogs] = await Promise.all([
const [nextStats, nextBuckets, nextLogs, nextParams] = await Promise.all([
fetchStats(),
fetchTimeline({
startMs: effectiveRange?.startMs,
@@ -475,11 +505,14 @@ function FullApp() {
sortBy: sortSettings.sortBy ?? undefined,
sortMissingFallback: sortSettings.sortBy ? sortSettings.sortMissingFallback : undefined,
}),
// Ticket 058: params can get auto-declared from freshly ingested lines mid-run, not just from a user's own edit.
fetchParamDeclarations(),
])
if (!cancelled) {
setStats(nextStats)
setBuckets(nextBuckets)
setLogs(nextLogs)
updateParamsIfChanged(setParams, nextParams)
setFilterError(null)
}
} catch (err) {
@@ -508,6 +541,7 @@ function FullApp() {
value={filter}
onChange={setFilter}
error={filterError}
activeSummary={describeActiveState(activePreset, sortSettings)}
collapseToggle={
<>
<CollapseToggle collapsed={logViewerCollapsed} onToggle={() => setLogViewerCollapsed(!logViewerCollapsed)} />
+21 -3
View File
@@ -153,6 +153,9 @@ export function Dashboards({ range, activePreset }: { range: TimeRange | null; a
const [saveError, setSaveError] = useState<string | null>(null)
// Last-persisted shape for the current draft, so the autosave effect can skip when nothing actually changed.
const lastSavedRef = useRef<string | null>(null)
// Ticket 064: latest `draft`, read inside the autosave timeout's async callback so a save response can tell
// whether the user kept typing after the request went out, instead of unconditionally overwriting `draft`.
const draftRef = useRef<Dashboard | null>(draft)
const [collapsed, setCollapsed] = useSectionCollapsed('dashboards')
async function loadDashboards(profileName: string, selectAfterLoad?: string) {
@@ -181,11 +184,21 @@ export function Dashboards({ range, activePreset }: { range: TimeRange | null; a
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
useEffect(() => {
draftRef.current = draft
}, [draft])
// Deliberately keyed only on `selectedId`, not `dashboards` — this effect's job is to load a *newly selected*
// dashboard's content into `draft`. Keying it on `dashboards` too meant every autosave's own `setDashboards`
// call (below) re-ran this effect and clobbered whatever the user had typed since the save was kicked off
// (ticket 064). Reading `dashboards` from the closure here still sees the just-loaded array, since
// `setSelectedId`/`setDashboards` from `loadDashboards` land in the same render.
useEffect(() => {
const found = dashboards.find((d) => d.id === selectedId) ?? null
setDraft(found)
lastSavedRef.current = found ? JSON.stringify(dashboardSaveShape(found)) : null
}, [selectedId, dashboards])
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [selectedId])
// Skips entirely once the draft matches what's already persisted — covers both re-selecting an unchanged dashboard and this effect's own post-save re-render.
useEffect(() => {
@@ -198,9 +211,14 @@ export function Dashboards({ range, activePreset }: { range: TimeRange | null; a
setSaveStatus('saving')
try {
const saved = await saveDashboard(activeProfile, dashboardId, dashboardSaveShape(draft))
lastSavedRef.current = JSON.stringify(dashboardSaveShape(saved))
lastSavedRef.current = snapshot
setDashboards((prev) => prev.map((d) => (d.id === saved.id ? saved : d)))
setDraft(saved)
// Only reconcile `saved` into `draft` if nothing newer was typed while the request was in flight —
// otherwise this stale response would overwrite those in-progress keystrokes (ticket 064).
const current = draftRef.current
if (current && current.id === dashboardId && JSON.stringify(dashboardSaveShape(current)) === snapshot) {
setDraft(saved)
}
setSaveStatus('saved')
setSaveError(null)
} catch (err) {
+19 -4
View File
@@ -1,5 +1,5 @@
import type { ReactNode } from 'react'
import { useState } from 'react'
import { useEffect, useState } from 'react'
import { HelpIcon } from './HelpIcon'
import { RegexHelpIcon } from './RegexHelpIcon'
@@ -13,14 +13,22 @@ export function FilterBar({
onChange,
error,
collapseToggle,
activeSummary,
}: {
value: string
onChange: (value: string) => void
error?: string | null
collapseToggle?: ReactNode
/** Ticket 066: a read-only textual summary of the active time-range preset / sort order, shown below the input when the user hasn't typed a manual filter — the filter text itself stays the single source of truth for what's actually sent to the server. */
activeSummary?: string | null
}) {
const [draft, setDraft] = useState(value)
// Resync when `value` changes externally (e.g. a programmatic clear) rather than only reading it once on mount.
useEffect(() => {
setDraft(value)
}, [value])
return (
<form
className="flex flex-col gap-1 border-b border-border px-3 py-2"
@@ -30,15 +38,21 @@ export function FilterBar({
}}
>
<div className="flex items-center gap-2">
<HelpIcon sectionId="filtering" label="Help: Filtering" />
<RegexHelpIcon label="Help: regex(...) syntax" />
<span className="flex items-center gap-1">
<HelpIcon sectionId="filtering" label="Help: filter syntax" />
<span className="text-xs text-text-subtle">Filter syntax</span>
</span>
{collapseToggle}
<input
className={queryInputClassName}
placeholder='regex("error") and ottyl_seq<5 or ottyl_ts>12:00'
placeholder='ottyl_ts>-15m and requests[duration]>200 or regex("error")'
value={draft}
onChange={(e) => setDraft(e.target.value)}
/>
<span className="flex items-center gap-1" title="Cheat sheet for regex(...) clauses specifically">
<RegexHelpIcon label="Help: regex(...) syntax" />
<span className="text-xs text-text-faint">regex(...)</span>
</span>
<button
type="submit"
className="rounded border border-border-soft px-3 py-1 text-sm text-text-secondary hover:bg-surface-2"
@@ -47,6 +61,7 @@ export function FilterBar({
</button>
</div>
{error && <p className="text-xs text-danger-text">{error}</p>}
{!error && !draft && activeSummary && <p className="text-xs text-text-subtle">Active: {activeSummary}</p>}
</form>
)
}
+5 -1
View File
@@ -333,7 +333,11 @@ export function Params({ params, onDeclared }: { params: ParamDeclaration[]; onD
? ', elapsed since previous'
: p.kind === 'boolean'
? ''
: `, ${p.json_field_path ? `json field ${p.json_field_path}` : `group ${p.capture_group}`}`}
: p.json_field_path
? `, json field ${p.json_field_path}`
: p.capture_group !== p.name
? `, group ${p.capture_group}`
: ''}
)
</button>
))}