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:
co-authored by
Claude Sonnet 5
parent
a03ec1fb93
commit
4ace654019
@@ -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.
|
|
||||||
@@ -1,24 +0,0 @@
|
|||||||
# 062 — Browser tab title says "webapp" instead of "ottyl"
|
|
||||||
|
|
||||||
## Summary
|
|
||||||
|
|
||||||
`webapp/index.html:7` has `<title>webapp</title>`. 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 `<title>webapp</title>` to `<title>ottyl</title>` 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.
|
|
||||||
@@ -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<name>...)`, 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.
|
|
||||||
@@ -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 `<input>`
|
|
||||||
(`: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.
|
|
||||||
@@ -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
|
|
||||||
`<HelpIcon sectionId="filtering">` 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 `<RegexHelpIcon>` (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.
|
|
||||||
@@ -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.
|
|
||||||
`<FilterBar value={filter} onChange={setFilter} .../>` (`: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.
|
|
||||||
+1
-1
@@ -4,7 +4,7 @@
|
|||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>webapp</title>
|
<title>ottyl</title>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
|||||||
+37
-3
@@ -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.
|
/// 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() {
|
function StandaloneLog() {
|
||||||
const [logs, setLogs] = useState<LogEntry[]>([])
|
const [logs, setLogs] = useState<LogEntry[]>([])
|
||||||
@@ -311,7 +333,7 @@ function StandaloneLog() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const [nextBuckets, nextLogs] = await Promise.all([
|
const [nextBuckets, nextLogs, nextParams] = await Promise.all([
|
||||||
fetchTimeline({
|
fetchTimeline({
|
||||||
startMs: effectiveRange?.startMs,
|
startMs: effectiveRange?.startMs,
|
||||||
endMs: effectiveRange?.endMs,
|
endMs: effectiveRange?.endMs,
|
||||||
@@ -327,10 +349,13 @@ function StandaloneLog() {
|
|||||||
sortBy: sortSettings.sortBy ?? undefined,
|
sortBy: sortSettings.sortBy ?? undefined,
|
||||||
sortMissingFallback: sortSettings.sortBy ? sortSettings.sortMissingFallback : 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) {
|
if (!cancelled) {
|
||||||
setBuckets(nextBuckets)
|
setBuckets(nextBuckets)
|
||||||
setLogs(nextLogs)
|
setLogs(nextLogs)
|
||||||
|
updateParamsIfChanged(setParams, nextParams)
|
||||||
setFilterError(null)
|
setFilterError(null)
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -352,7 +377,12 @@ function StandaloneLog() {
|
|||||||
return (
|
return (
|
||||||
<StandalonePage>
|
<StandalonePage>
|
||||||
<div className="flex h-full min-h-0 flex-col">
|
<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">
|
<div className="flex min-h-0 flex-1 flex-col">
|
||||||
<LogTimeline
|
<LogTimeline
|
||||||
buckets={buckets}
|
buckets={buckets}
|
||||||
@@ -458,7 +488,7 @@ function FullApp() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const [nextStats, nextBuckets, nextLogs] = await Promise.all([
|
const [nextStats, nextBuckets, nextLogs, nextParams] = await Promise.all([
|
||||||
fetchStats(),
|
fetchStats(),
|
||||||
fetchTimeline({
|
fetchTimeline({
|
||||||
startMs: effectiveRange?.startMs,
|
startMs: effectiveRange?.startMs,
|
||||||
@@ -475,11 +505,14 @@ function FullApp() {
|
|||||||
sortBy: sortSettings.sortBy ?? undefined,
|
sortBy: sortSettings.sortBy ?? undefined,
|
||||||
sortMissingFallback: sortSettings.sortBy ? sortSettings.sortMissingFallback : 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) {
|
if (!cancelled) {
|
||||||
setStats(nextStats)
|
setStats(nextStats)
|
||||||
setBuckets(nextBuckets)
|
setBuckets(nextBuckets)
|
||||||
setLogs(nextLogs)
|
setLogs(nextLogs)
|
||||||
|
updateParamsIfChanged(setParams, nextParams)
|
||||||
setFilterError(null)
|
setFilterError(null)
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -508,6 +541,7 @@ function FullApp() {
|
|||||||
value={filter}
|
value={filter}
|
||||||
onChange={setFilter}
|
onChange={setFilter}
|
||||||
error={filterError}
|
error={filterError}
|
||||||
|
activeSummary={describeActiveState(activePreset, sortSettings)}
|
||||||
collapseToggle={
|
collapseToggle={
|
||||||
<>
|
<>
|
||||||
<CollapseToggle collapsed={logViewerCollapsed} onToggle={() => setLogViewerCollapsed(!logViewerCollapsed)} />
|
<CollapseToggle collapsed={logViewerCollapsed} onToggle={() => setLogViewerCollapsed(!logViewerCollapsed)} />
|
||||||
|
|||||||
@@ -153,6 +153,9 @@ export function Dashboards({ range, activePreset }: { range: TimeRange | null; a
|
|||||||
const [saveError, setSaveError] = useState<string | null>(null)
|
const [saveError, setSaveError] = useState<string | null>(null)
|
||||||
// Last-persisted shape for the current draft, so the autosave effect can skip when nothing actually changed.
|
// Last-persisted shape for the current draft, so the autosave effect can skip when nothing actually changed.
|
||||||
const lastSavedRef = useRef<string | null>(null)
|
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')
|
const [collapsed, setCollapsed] = useSectionCollapsed('dashboards')
|
||||||
|
|
||||||
async function loadDashboards(profileName: string, selectAfterLoad?: string) {
|
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
|
// 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(() => {
|
useEffect(() => {
|
||||||
const found = dashboards.find((d) => d.id === selectedId) ?? null
|
const found = dashboards.find((d) => d.id === selectedId) ?? null
|
||||||
setDraft(found)
|
setDraft(found)
|
||||||
lastSavedRef.current = found ? JSON.stringify(dashboardSaveShape(found)) : null
|
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.
|
// 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(() => {
|
useEffect(() => {
|
||||||
@@ -198,9 +211,14 @@ export function Dashboards({ range, activePreset }: { range: TimeRange | null; a
|
|||||||
setSaveStatus('saving')
|
setSaveStatus('saving')
|
||||||
try {
|
try {
|
||||||
const saved = await saveDashboard(activeProfile, dashboardId, dashboardSaveShape(draft))
|
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)))
|
setDashboards((prev) => prev.map((d) => (d.id === saved.id ? saved : d)))
|
||||||
|
// 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)
|
setDraft(saved)
|
||||||
|
}
|
||||||
setSaveStatus('saved')
|
setSaveStatus('saved')
|
||||||
setSaveError(null)
|
setSaveError(null)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import type { ReactNode } from 'react'
|
import type { ReactNode } from 'react'
|
||||||
import { useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
import { HelpIcon } from './HelpIcon'
|
import { HelpIcon } from './HelpIcon'
|
||||||
import { RegexHelpIcon } from './RegexHelpIcon'
|
import { RegexHelpIcon } from './RegexHelpIcon'
|
||||||
|
|
||||||
@@ -13,14 +13,22 @@ export function FilterBar({
|
|||||||
onChange,
|
onChange,
|
||||||
error,
|
error,
|
||||||
collapseToggle,
|
collapseToggle,
|
||||||
|
activeSummary,
|
||||||
}: {
|
}: {
|
||||||
value: string
|
value: string
|
||||||
onChange: (value: string) => void
|
onChange: (value: string) => void
|
||||||
error?: string | null
|
error?: string | null
|
||||||
collapseToggle?: ReactNode
|
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)
|
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 (
|
return (
|
||||||
<form
|
<form
|
||||||
className="flex flex-col gap-1 border-b border-border px-3 py-2"
|
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">
|
<div className="flex items-center gap-2">
|
||||||
<HelpIcon sectionId="filtering" label="Help: Filtering" />
|
<span className="flex items-center gap-1">
|
||||||
<RegexHelpIcon label="Help: regex(...) syntax" />
|
<HelpIcon sectionId="filtering" label="Help: filter syntax" />
|
||||||
|
<span className="text-xs text-text-subtle">Filter syntax</span>
|
||||||
|
</span>
|
||||||
{collapseToggle}
|
{collapseToggle}
|
||||||
<input
|
<input
|
||||||
className={queryInputClassName}
|
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}
|
value={draft}
|
||||||
onChange={(e) => setDraft(e.target.value)}
|
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
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
className="rounded border border-border-soft px-3 py-1 text-sm text-text-secondary hover:bg-surface-2"
|
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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
{error && <p className="text-xs text-danger-text">{error}</p>}
|
{error && <p className="text-xs text-danger-text">{error}</p>}
|
||||||
|
{!error && !draft && activeSummary && <p className="text-xs text-text-subtle">Active: {activeSummary}</p>}
|
||||||
</form>
|
</form>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -333,7 +333,11 @@ export function Params({ params, onDeclared }: { params: ParamDeclaration[]; onD
|
|||||||
? ', elapsed since previous'
|
? ', elapsed since previous'
|
||||||
: p.kind === 'boolean'
|
: 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>
|
</button>
|
||||||
))}
|
))}
|
||||||
|
|||||||
Reference in New Issue
Block a user