diff --git a/tickets/058-declared-params-not-refreshed-live-in-ui.md b/tickets/058-declared-params-not-refreshed-live-in-ui.md
deleted file mode 100644
index 6a417a5..0000000
--- a/tickets/058-declared-params-not-refreshed-live-in-ui.md
+++ /dev/null
@@ -1,42 +0,0 @@
-# 058 — Newly auto-declared params don't appear in the UI until a manual refresh
-
-## Summary
-
-`auto_declare_params` / `auto_declare_log_stream_params` (`src/engine.rs:383`,
-`:451`) can add new param declarations on any ingested line, but the
-frontend only ever fetches the param list once, on mount
-(`webapp/src/App.tsx:278-284`, `fetchParamDeclarations().then(setParams)`).
-The main poll loop (`App.tsx:300-350`, interval from
-`webapp/src/lib/polling.ts`) refetches timeline entries and logs every
-tick but never re-fetches param declarations — the only other place
-`setParams` is called is the `onDeclared` callback fired by a user's own
-manual edit in `Params.tsx`. So a param that gets auto-declared mid-run
-from freshly ingested lines is invisible in the Params panel until the
-page is reloaded.
-
-There is no push infrastructure (SSE/websocket) anywhere in the
-codebase; all live-refresh today is plain polling.
-
-## Scope
-
-- Add a periodic (or piggybacked on the existing poll loop) refetch of
- param declarations in `App.tsx`, e.g. call `fetchParamDeclarations()`
- alongside the existing timeline/log poll in the loop at
- `App.tsx:300-350`, or on its own lighter-weight interval if refetching
- every tick is wasteful.
-- Only update state when the declaration list actually changed, to avoid
- needlessly re-rendering `Params.tsx` on every poll tick.
-- Out of scope: introducing SSE/websocket push infrastructure — polling
- is consistent with how the rest of the app already refreshes.
-
-## Acceptance criteria
-
-- Ingesting new lines that trigger auto-declaration of a previously
- unseen param causes that param to appear in the Params UI within one
- poll interval, without a page reload.
-- Existing manual param declaration/edit flows (`Params.tsx`'s
- `onDeclared`) are unaffected.
-
-## Depends on
-
-None.
diff --git a/tickets/062-app-title-should-be-ottyl.md b/tickets/062-app-title-should-be-ottyl.md
deleted file mode 100644
index d777e27..0000000
--- a/tickets/062-app-title-should-be-ottyl.md
+++ /dev/null
@@ -1,24 +0,0 @@
-# 062 — Browser tab title says "webapp" instead of "ottyl"
-
-## Summary
-
-`webapp/index.html:7` has `
webapp`. Nothing else sets a
-user-visible title — no `document.title` assignment exists in
-`App.tsx`, and `webapp/package.json`'s `"name": "webapp"` is a separate
-dev-tooling field not surfaced to users. The only fix needed is the
-`index.html` title tag.
-
-## Scope
-
-- Change `webapp` to `ottyl` in
- `webapp/index.html`.
-- Out of scope: renaming the `webapp` directory or `package.json` name
- field — those are internal and not user-facing.
-
-## Acceptance criteria
-
-- The browser tab shows "ottyl" when the dashboard is open.
-
-## Depends on
-
-None.
diff --git a/tickets/063-params-list-drop-redundant-group-suffix.md b/tickets/063-params-list-drop-redundant-group-suffix.md
deleted file mode 100644
index 63eb5d5..0000000
--- a/tickets/063-params-list-drop-redundant-group-suffix.md
+++ /dev/null
@@ -1,38 +0,0 @@
-# 063 — Declared params list shows a redundant "group name" alongside type
-
-## Summary
-
-`webapp/src/components/Params.tsx:331-337` renders each param as
-`{p.name} ({p.kind}, ...)`, where the third segment is one of `elapsed
-since previous`, nothing (for booleans), `json field {path}`, or `group
-{p.capture_group}`. There is no distinct "group" concept in the domain
-model — `src/dto.rs`, `src/profile.rs`, and `src/engine.rs` have no
-`group` field at all. What the UI calls "group" is actually
-`capture_group` (`dto.rs:22`, `profile.rs:69`), the regex named-capture
-group used to extract the param's value. By convention, params are
-declared with `(?P...)`, so `capture_group` is almost always
-identical to `p.name` — displaying it is redundant noise in the common
-case (`name (type, name)`).
-
-## Scope
-
-- Change the default rendering to `name (type)`.
-- Only append the extra suffix when it's actually informative:
- - Keep `json field {path}` and `elapsed since previous` suffixes —
- these carry information not implied by `name`/`type`.
- - For the capture-group case, only show it when
- `p.capture_group !== p.name` (i.e. the regex group name genuinely
- differs from the declared param name); otherwise omit it.
-- Out of scope: changing the underlying `capture_group` field or param
- declaration model — this is a display-only fix.
-
-## Acceptance criteria
-
-- A param whose capture group matches its name renders as `name (type)`.
-- A param whose capture group differs from its name still shows that
- distinction (e.g. `name (type, group other_name)`).
-- `json field` and `elapsed since previous` suffixes are unchanged.
-
-## Depends on
-
-None.
diff --git a/tickets/064-promql-panel-input-clobbered-by-autosave.md b/tickets/064-promql-panel-input-clobbered-by-autosave.md
deleted file mode 100644
index 29a4ff2..0000000
--- a/tickets/064-promql-panel-input-clobbered-by-autosave.md
+++ /dev/null
@@ -1,48 +0,0 @@
-# 064 — Typing a panel's PromQL query gets clobbered by in-flight autosave responses
-
-## Summary
-
-In `webapp/src/components/Dashboards.tsx`, the panel query ``
-(`:408-411`) is bound directly to `draft.panels[...].promql` /
-`updatePanel`, and `draft` is autosaved via a 600ms-debounced effect
-(`AUTOSAVE_DEBOUNCE_MS = 600`, line 34; effect at `:190-214`). On save
-success, `setDraft(saved)` (`:203`) unconditionally replaces the entire
-local draft with the server's response — including whatever `promql`
-string was in flight at request time. If the user types more characters
-after the debounce fires but before the save response returns, those
-keystrokes exist only in local `draft` state and get overwritten the
-instant the stale `saved` response resolves, producing the reported
-"characters inserted then removed" behavior. A second effect
-(`Dashboards.tsx:184-188`) also resets `draft` whenever `dashboards`
-changes — which the same save's `setDashboards` call triggers —
-compounding the race.
-
-## Scope
-
-- Stop unconditionally overwriting in-flight-edited fields on autosave
- response. Options (pick one, document the choice):
- - Track which panel/field is actively focused/being edited and skip
- overwriting that specific field when reconciling `saved` into
- `draft`.
- - Only merge server-assigned fields (ids, timestamps) from `saved`
- into `draft` rather than replacing `draft` wholesale.
- - Guard the reconciliation with a "pending edits since request was
- sent" check, applying `saved` only when no newer local edit
- occurred after the request was issued.
-- Also fix the related staleness effect at `Dashboards.tsx:184-188` so
- it doesn't clobber in-progress edits when `dashboards` changes as a
- side effect of the same save.
-- This does not require adopting Redux or another state library — the
- fix is local to this component's save/edit reconciliation.
-
-## Acceptance criteria
-
-- Typing continuously in a panel's PromQL input while an autosave
- round-trip is in flight never drops or reorders characters.
-- Autosave still persists the query to the backend and other
- server-assigned fields (ids, etc.) still get reconciled into local
- state correctly.
-
-## Depends on
-
-None.
diff --git a/tickets/065-filter-bar-help-should-describe-filter-syntax-not-regex.md b/tickets/065-filter-bar-help-should-describe-filter-syntax-not-regex.md
deleted file mode 100644
index 5b2dd52..0000000
--- a/tickets/065-filter-bar-help-should-describe-filter-syntax-not-regex.md
+++ /dev/null
@@ -1,41 +0,0 @@
-# 065 — Filter bar's help/prompt foregrounds regex syntax instead of the filter bar's own syntax
-
-## Summary
-
-A comprehensive filter-bar syntax reference already exists — operators,
-`[param]` comparisons, datetime formats, worked examples — behind the
-`` in `webapp/src/components/Docs.tsx:76-140`,
-wired up at `FilterBar.tsx:32-34`. The problem is emphasis, not missing
-docs: the input's `placeholder` (`FilterBar.tsx:38`) leads with
-`regex("error") and ottyl_seq<5 or ottyl_ts>12:00`, making regex look
-like the primary syntax, and a second `` (a pure regex
-cheat-sheet, `webapp/src/components/RegexHelpIcon.tsx`) sits more
-prominently next to the input than the actual filter-syntax help icon.
-A user skimming the bar sees "regex" twice before noticing the filter
-bar has its own broader query language.
-
-## Scope
-
-- Change the input `placeholder` at `FilterBar.tsx:38` to lead with a
- non-regex filter-bar example (e.g.
- `ottyl_ts>-15m and requests[duration]>200`), keeping a `regex(...)`
- clause further in the example rather than first if desired.
-- Reorder/re-emphasize the two help icons so the general filter-syntax
- `HelpIcon` (→ `Docs.tsx`'s filtering section) reads as the primary
- affordance, with the regex-specific cheat sheet secondary/clearly
- labeled as being about regex specifically, not the filter bar as a
- whole.
-- Out of scope: rewriting the content of `Docs.tsx`'s filtering section
- — it's already accurate and thorough; this is about surfacing it
- correctly, not authoring new documentation.
-
-## Acceptance criteria
-
-- The filter bar's placeholder text no longer leads with a bare
- `regex(...)` example as the first thing a user sees.
-- The general filter-syntax help is at least as discoverable/prominent
- as the regex-only cheat sheet.
-
-## Depends on
-
-None.
diff --git a/tickets/066-filter-bar-value-doesnt-reflect-active-preset-or-sort.md b/tickets/066-filter-bar-value-doesnt-reflect-active-preset-or-sort.md
deleted file mode 100644
index cf7540e..0000000
--- a/tickets/066-filter-bar-value-doesnt-reflect-active-preset-or-sort.md
+++ /dev/null
@@ -1,53 +0,0 @@
-# 066 — Filter bar text doesn't reflect the active time-range preset or sort order
-
-## Summary
-
-In `webapp/src/App.tsx`, `filter` (the raw typed filter text, `:264`),
-`activePreset`/`range` (`:262-263`, set via `selectPreset` /
-`handleManualRangeSelect`, `:286-298`), and `sortSettings` (`:266`) are
-three entirely separate pieces of state.
-`` (`:355`) only
-ever reflects the manually-typed `filter` string — selecting a
-quick-pick time-range preset or changing the sort order never touches
-`filter`, so the filter bar can show empty/stale text even though a
-preset or sort order is actively constraining or ordering what's
-displayed. Separately, `FilterBar.tsx:22`
-(`const [draft, setDraft] = useState(value)`) initializes its internal
-`draft` from `value` only once on mount and never resyncs if `value`
-changes externally afterward (e.g. a programmatic clear) — a related
-staleness bug worth fixing alongside this.
-
-## Scope
-
-- Decide and implement one of:
- - Synthesize a read-only textual summary of the active preset/sort
- state and display it in or alongside the filter bar when the user
- hasn't typed a manual filter (e.g. showing the equivalent
- `ottyl_ts>-Xm`-style clause for the active preset).
- - Make preset-selection and sort-order actions actually write into
- the `filter` query string itself (e.g. `selectPreset` appends/
- updates an `ottyl_ts` clause), so `filter` is always the single
- source of truth for what's displayed in the bar.
- Document the choice in the PR — this is a real design decision, not
- just a bug fix.
-- Fix `FilterBar.tsx:22` so the internal `draft` resyncs when the
- `value` prop changes externally (not just on mount), so programmatic
- filter changes (from whichever approach above, or an external clear)
- are reflected in the input.
-- Out of scope: changing how presets/sort settings are computed or
- stored server-side — this is about surfacing existing state in the
- filter bar's displayed value.
-
-## Acceptance criteria
-
-- Selecting a quick-pick time-range preset causes the filter bar to
- visibly reflect that a time constraint is active (either via a
- synthesized display or an actual written filter clause).
-- Changing sort order is likewise visible via the filter bar's
- displayed state.
-- The filter bar's input value stays in sync with externally-driven
- changes to the active filter, not just the user's own typing.
-
-## Depends on
-
-None.
diff --git a/webapp/index.html b/webapp/index.html
index faa932c..62dc54a 100644
--- a/webapp/index.html
+++ b/webapp/index.html
@@ -4,7 +4,7 @@
- webapp
+ ottyl
diff --git a/webapp/src/App.tsx b/webapp/src/App.tsx
index fa6b5f4..15c00f6 100644
--- a/webapp/src/App.tsx
+++ b/webapp/src/App.tsx
@@ -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([])
@@ -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 (
-
+
setLogViewerCollapsed(!logViewerCollapsed)} />
diff --git a/webapp/src/components/Dashboards.tsx b/webapp/src/components/Dashboards.tsx
index bd7cb64..1d138a5 100644
--- a/webapp/src/components/Dashboards.tsx
+++ b/webapp/src/components/Dashboards.tsx
@@ -153,6 +153,9 @@ export function Dashboards({ range, activePreset }: { range: TimeRange | null; a
const [saveError, setSaveError] = useState(null)
// Last-persisted shape for the current draft, so the autosave effect can skip when nothing actually changed.
const lastSavedRef = useRef(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(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) {
diff --git a/webapp/src/components/FilterBar.tsx b/webapp/src/components/FilterBar.tsx
index d7418dc..b472e37 100644
--- a/webapp/src/components/FilterBar.tsx
+++ b/webapp/src/components/FilterBar.tsx
@@ -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 (
)
}
diff --git a/webapp/src/components/Params.tsx b/webapp/src/components/Params.tsx
index 2765cc7..e03c67b 100644
--- a/webapp/src/components/Params.tsx
+++ b/webapp/src/components/Params.tsx
@@ -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}`
+ : ''}
)
))}