diff --git a/tickets/024-popout-tab-independent-rolling-range-and-controls.md b/tickets/024-popout-tab-independent-rolling-range-and-controls.md deleted file mode 100644 index 223af9b..0000000 --- a/tickets/024-popout-tab-independent-rolling-range-and-controls.md +++ /dev/null @@ -1,12 +0,0 @@ -# Popped-out tabs should carry over the preset kind (not a frozen range) and have their own time-range controls - -Opening a section in a new tab (`PopOutButton` in `CollapsibleSection.tsx`) does not currently carry over the active colour scheme/theme — see ticket 025 for that bug and its fix; this ticket is scoped to time-range handling only. - -The time range handling is wrong for rolling presets. `popOutUrl` resolves the *current* `range` to absolute `startMs`/`endMs` query params at click time (`CollapsibleSection.tsx` ~line 101), and `StandaloneRanged` (`App.tsx` ~line 148) passes that frozen `{startMs, endMs}` straight into `Metrics`/`Dashboards` with no further updates. So opening a tab while "Last 5m" is active doesn't open a new tab that's *also* sliding its own independent 5-minute window — it opens a tab pinned forever to whatever absolute window happened to be resolved at that instant, and it has no time-range picker at all to change that afterward. - -## Acceptance criteria -- Opening a new tab carries over which preset was active (e.g. `5m`), not just its resolved absolute bounds — pass the preset id (`TimeRangePresetId`, `LogTimeline.tsx`) via query params instead of (or alongside) `startMs`/`endMs`. -- If the source section was on a rolling preset (anything but a manually dragged/fixed range), the new tab starts its own independently-sliding window of the same size (e.g. its own "last 5m"), recomputed on its own polling cadence — it must NOT track or freeze to the source tab's window, and changes in the source tab's range after the popout was opened have no effect on it. -- If the source section had a fixed/dragged range (not a preset), that fixed range is still carried over as absolute bounds, same as today. -- The popped-out standalone page (`StandaloneRanged` in `App.tsx`, covering `metrics` and `dashboards` sections) gets its own time-range controls (the same preset buttons / drag-to-select UI `LogTimeline.tsx` already provides elsewhere), rather than only ever displaying whatever range was passed in the URL with no way to change it afterward. -- The `log` and `params` standalone pages (which already have or don't need their own range picker) are unaffected. diff --git a/webapp/src/App.tsx b/webapp/src/App.tsx index 23fbca1..e796a7e 100644 --- a/webapp/src/App.tsx +++ b/webapp/src/App.tsx @@ -1,7 +1,14 @@ import { useEffect, useRef, useState, type PointerEvent as ReactPointerEvent, type ReactNode } from 'react' import { NavMenu } from './components/NavMenu' import { FilterBar } from './components/FilterBar' -import { LogTimeline, computeBucketMs, computePresetRange, type TimeRange, type TimeRangePresetId } from './components/LogTimeline' +import { + LogTimeline, + TIME_RANGE_PRESETS, + computeBucketMs, + computePresetRange, + type TimeRange, + type TimeRangePresetId, +} from './components/LogTimeline' import { LogPreview } from './components/LogPreview' import { Params } from './components/Params' import { Metrics } from './components/Metrics' @@ -101,16 +108,18 @@ function ResizeHandle({ onResize, onResizeEnd }: { onResize: (deltaY: number) => ) } -/// Reads the routing params `PopOutButton` writes: `section` picks which primary section to render full-page, and `startMs`/`endMs` carry over the range for sections with no `LogTimeline` of their own to pick one. -function parsePopOutQuery(): { section: PopOutSectionId | null; range: TimeRange | null } { +/// Reads the routing params `PopOutButton` writes: `section` picks which primary section to render full-page. `preset` carries over which rolling preset (e.g. `5m`) was active, so the popped-out tab can resolve and keep re-resolving its own independent window of the same size instead of freezing whatever absolute bounds happened to be active at click time. `startMs`/`endMs` carry over a fixed/dragged (non-preset) range as absolute bounds, same as before. +function parsePopOutQuery(): { section: PopOutSectionId | null; presetId: TimeRangePresetId | null; fixedRange: TimeRange | null } { const query = new URLSearchParams(window.location.search) const section = query.get('section') as PopOutSectionId | null + const presetParam = query.get('preset') + const presetId = TIME_RANGE_PRESETS.some((p) => p.id === presetParam) ? (presetParam as TimeRangePresetId) : null const startMs = Number(query.get('startMs')) const endMs = Number(query.get('endMs')) - const range = query.has('startMs') && query.has('endMs') && Number.isFinite(startMs) && Number.isFinite(endMs) + const fixedRange = query.has('startMs') && query.has('endMs') && Number.isFinite(startMs) && Number.isFinite(endMs) ? { startMs, endMs } : null - return { section, range } + return { section, presetId, fixedRange } } // Popped-out routes render outside NavMenu, which is otherwise the only place that calls `useSettingsPanel()` and applies the @@ -149,9 +158,97 @@ function StandaloneParams() { ) } -/// Metrics/Dashboards own their own data fetching — they only need the range carried over via query params. -function StandaloneRanged({ section, range }: { section: 'metrics' | 'dashboards'; range: TimeRange | null }) { - return {section === 'metrics' ? : } +/// Metrics/Dashboards own their own data fetching — they only need the range. Unlike the log viewer's standalone page, there's no pre-existing per-section timeline to seed buckets from, so this owns its own minimal timeline poll purely to drive `LogTimeline`'s picker UI (preset buttons + drag-to-select) and, when a preset was carried over, to keep independently re-resolving that rolling window on its own cadence — it never tracks or freezes to whatever the source tab's window does after this tab was opened. +function StandaloneRanged({ + section, + initialPresetId, + initialFixedRange, +}: { + section: 'metrics' | 'dashboards' + initialPresetId: TimeRangePresetId | null + initialFixedRange: TimeRange | null +}) { + const [buckets, setBuckets] = useState([]) + const [range, setRange] = useState( + initialPresetId ? computePresetRange(initialPresetId) : initialFixedRange, + ) + // No preset and no fixed range means the popout was opened with nothing carried over (e.g. a bare URL) — default to 'all', matching FullApp's own default. + const [activePreset, setActivePreset] = useState( + initialPresetId ?? (initialFixedRange ? null : 'all'), + ) + + const rangeRef = useRef(range) + useEffect(() => { + rangeRef.current = range + }, [range]) + + function selectPreset(id: TimeRangePresetId) { + setActivePreset(id) + setRange(computePresetRange(id)) + } + + function handleManualRangeSelect(next: TimeRange | null) { + if (next === null) { + selectPreset('all') + } else { + setActivePreset(null) + setRange(next) + } + } + + useEffect(() => { + let cancelled = false + + async function poll() { + // Rolling presets re-resolve on this same polling cadence instead of freezing the window at selection time — this is what makes a carried-over preset independently slide rather than track the source tab. + const effectiveRange = + activePreset && activePreset !== 'all' ? computePresetRange(activePreset) : rangeRef.current + if (activePreset && activePreset !== 'all') { + setRange((current) => + current && effectiveRange && current.startMs === effectiveRange.startMs && current.endMs === effectiveRange.endMs + ? current + : effectiveRange, + ) + } + try { + const nextBuckets = await fetchTimeline({ + startMs: effectiveRange?.startMs, + endMs: effectiveRange?.endMs, + bucketMs: effectiveRange ? computeBucketMs(effectiveRange.endMs - effectiveRange.startMs) : undefined, + }) + if (!cancelled) setBuckets(nextBuckets) + } catch { + // engine not reachable yet; leave the previous buckets in place + } + } + + poll() + const id = setInterval(poll, POLL_MS) + return () => { + cancelled = true + clearInterval(id) + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [activePreset]) + + return ( + +
+ + {section === 'metrics' ? ( + + ) : ( + + )} +
+
+ ) } /// 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. @@ -256,10 +353,11 @@ function StandaloneLog() { } export default function App() { - const { section, range: standaloneRange } = parsePopOutQuery() + const { section, presetId, fixedRange } = parsePopOutQuery() if (section === 'log') return if (section === 'params') return - if (section === 'metrics' || section === 'dashboards') return + if (section === 'metrics' || section === 'dashboards') + return return } @@ -404,8 +502,8 @@ function FullApp() { {!logViewerCollapsed && } - - + + diff --git a/webapp/src/components/CollapsibleSection.tsx b/webapp/src/components/CollapsibleSection.tsx index 0c9fabc..2471da9 100644 --- a/webapp/src/components/CollapsibleSection.tsx +++ b/webapp/src/components/CollapsibleSection.tsx @@ -1,5 +1,5 @@ import { useState } from 'react' -import type { TimeRange } from './LogTimeline' +import type { TimeRange, TimeRangePresetId } from './LogTimeline' /// Shared look for a primary section's title bar: shaded so it reads as a /// distinct band rather than blending into the section body below it. @@ -98,23 +98,34 @@ export function handleSectionHeaderKeyDown(onToggle: () => void) { /// The four primary sections poppable into their own tab; matches the `?section=` query param a standalone page reads. export type PopOutSectionId = 'log' | 'params' | 'metrics' | 'dashboards' -function popOutUrl(section: PopOutSectionId, range?: TimeRange | null): string { +/// A rolling preset (anything but a manually dragged/fixed range) is carried over by id so the popped-out tab can resolve and keep re-resolving its own independent window of the same size, instead of freezing whatever absolute bounds happened to be active at click time. A fixed/dragged range has no such id, so it's carried over as absolute bounds, same as before. +function popOutUrl(section: PopOutSectionId, options?: { range?: TimeRange | null; presetId?: TimeRangePresetId | null }): string { const query = new URLSearchParams({ section }) - if (range) { - query.set('startMs', String(range.startMs)) - query.set('endMs', String(range.endMs)) + if (options?.presetId != null) { + query.set('preset', options.presetId) + } else if (options?.range) { + query.set('startMs', String(options.range.startMs)) + query.set('endMs', String(options.range.endMs)) } return `?${query.toString()}` } -/// `range` is only needed for sections without their own LogTimeline — the popped-out page can't pick a range itself, so the current one is carried over via query params. -export function PopOutButton({ section, range }: { section: PopOutSectionId; range?: TimeRange | null }) { +/// `range`/`activePreset` are only needed for sections without their own LogTimeline — the popped-out page can't pick a range itself until it mounts, so the current one is carried over via query params. +export function PopOutButton({ + section, + range, + activePreset, +}: { + section: PopOutSectionId + range?: TimeRange | null + activePreset?: TimeRangePresetId | null +}) { return (