Merge ticket/024-popout-tab-independent-rolling-range-and-controls

Resolves conflicts with tickets 006 (header-click collapse) and 009
(all-dashboards print view) in CollapsibleSection.tsx, Dashboards.tsx,
and Metrics.tsx: dropped a spurious onToggle addition from the
worktree's stale base that duplicated already-merged header-collapse
behavior, keeping only the activePreset plumbing; wired activePreset
through Dashboards' non-empty-state header too, which the source
branch (based on a pre-print-view commit) never had to touch.

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 19:44:28 +01:00
co-authored by Claude Sonnet 5
5 changed files with 136 additions and 39 deletions
@@ -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.
+110 -12
View File
@@ -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 <StandalonePage>{section === 'metrics' ? <Metrics range={range} /> : <Dashboards range={range} />}</StandalonePage>
/// 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<TimelineBucket[]>([])
const [range, setRange] = useState<TimeRange | null>(
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<TimeRangePresetId | null>(
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 (
<StandalonePage>
<div className="flex h-full min-h-0 flex-col overflow-y-auto">
<LogTimeline
buckets={buckets}
range={range}
onSelectRange={handleManualRangeSelect}
activePreset={activePreset}
onSelectPreset={selectPreset}
/>
{section === 'metrics' ? (
<Metrics range={range} activePreset={activePreset} />
) : (
<Dashboards range={range} activePreset={activePreset} />
)}
</div>
</StandalonePage>
)
}
/// 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 <StandaloneLog />
if (section === 'params') return <StandaloneParams />
if (section === 'metrics' || section === 'dashboards') return <StandaloneRanged section={section} range={standaloneRange} />
if (section === 'metrics' || section === 'dashboards')
return <StandaloneRanged section={section} initialPresetId={presetId} initialFixedRange={fixedRange} />
return <FullApp />
}
@@ -404,8 +502,8 @@ function FullApp() {
</div>
{!logViewerCollapsed && <ResizeHandle onResize={handleLogViewerResize} onResizeEnd={handleLogViewerResizeEnd} />}
<Params params={params} onDeclared={loadParams} />
<Metrics range={range} />
<Dashboards range={range} />
<Metrics range={range} activePreset={activePreset} />
<Dashboards range={range} activePreset={activePreset} />
<Alerts />
</div>
</div>
+19 -8
View File
@@ -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 (
<button
type="button"
onClick={(e) => {
e.stopPropagation()
window.open(popOutUrl(section, range), '_blank')
window.open(popOutUrl(section, { range, presetId: activePreset }), '_blank')
}}
title="Open in new tab"
aria-label="Open in new tab"
+4 -4
View File
@@ -1,5 +1,5 @@
import { useEffect, useRef, useState } from 'react'
import type { TimeRange } from './LogTimeline'
import type { TimeRange, TimeRangePresetId } from './LogTimeline'
import { PanelChart } from './PanelChart'
import {
ApiError,
@@ -142,7 +142,7 @@ function AllDashboardsView({ dashboards, range }: { dashboards: Dashboard[]; ran
)
}
export function Dashboards({ range }: { range: TimeRange | null }) {
export function Dashboards({ range, activePreset }: { range: TimeRange | null; activePreset?: TimeRangePresetId | null }) {
const [activeProfile, setActiveProfile] = useState<string | null>(null)
const [dashboards, setDashboards] = useState<Dashboard[]>([])
const [selectedId, setSelectedId] = useState<string | null>(null)
@@ -278,7 +278,7 @@ export function Dashboards({ range }: { range: TimeRange | null }) {
</div>
<div className="flex items-center gap-1.5">
<CollapseToggle collapsed={collapsed} />
<PopOutButton section="dashboards" range={range} />
<PopOutButton section="dashboards" range={range} activePreset={activePreset} />
</div>
</div>
<div className="p-3 text-xs text-text-faint">
@@ -304,7 +304,7 @@ export function Dashboards({ range }: { range: TimeRange | null }) {
</div>
<div className="flex items-center gap-1.5">
<CollapseToggle collapsed={collapsed} />
<PopOutButton section="dashboards" range={range} />
<PopOutButton section="dashboards" range={range} activePreset={activePreset} />
</div>
</div>
{error && !collapsed && (
+3 -3
View File
@@ -1,5 +1,5 @@
import { useEffect, useState } from 'react'
import type { TimeRange } from './LogTimeline'
import type { TimeRange, TimeRangePresetId } from './LogTimeline'
import {
ApiError,
declareMetric,
@@ -78,7 +78,7 @@ function sparkline(values: BucketValue[], range: TimeRange | null): string {
return slots.map((count) => SPARK_CHARS[Math.round((count / max) * (SPARK_CHARS.length - 1))]).join('')
}
export function Metrics({ range }: { range: TimeRange | null }) {
export function Metrics({ range, activePreset }: { range: TimeRange | null; activePreset?: TimeRangePresetId | null }) {
const [metrics, setMetrics] = useState<MetricDeclaration[]>([])
const [selected, setSelected] = useState<string | null>(null)
const [bucketValues, setBucketValues] = useState<BucketValue[]>([])
@@ -198,7 +198,7 @@ export function Metrics({ range }: { range: TimeRange | null }) {
</div>
<div className="flex items-center gap-1.5">
<CollapseToggle collapsed={collapsed} />
<PopOutButton section="metrics" range={range} />
<PopOutButton section="metrics" range={range} activePreset={activePreset} />
</div>
</div>
<div className="flex gap-4 p-3" hidden={collapsed}>