Carry over rolling preset id to popout tabs and add range controls to standalone Metrics/Dashboards
Popped-out tabs now carry the active TimeRangePresetId (e.g. "5m") via a `preset` query param instead of only resolved absolute startMs/endMs, so a rolling preset keeps independently sliding in the new tab on its own polling cadence rather than freezing to the source tab's window at open time. A fixed/dragged range is still carried over as absolute bounds. StandaloneRanged (metrics/dashboards standalone pages) now owns its own preset/range state and renders LogTimeline for its own preset buttons and drag-to-select UI, instead of only ever showing a frozen, uncontrollable range. log and params standalone pages are unchanged. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XFUNZwCvz3kcod3352ZR2o
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
e6e5bc8f50
commit
7463050357
+110
-12
@@ -1,7 +1,14 @@
|
|||||||
import { useEffect, useRef, useState, type ReactNode } from 'react'
|
import { useEffect, useRef, useState, type ReactNode } from 'react'
|
||||||
import { NavMenu } from './components/NavMenu'
|
import { NavMenu } from './components/NavMenu'
|
||||||
import { FilterBar } from './components/FilterBar'
|
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 { LogPreview } from './components/LogPreview'
|
||||||
import { Params } from './components/Params'
|
import { Params } from './components/Params'
|
||||||
import { Metrics } from './components/Metrics'
|
import { Metrics } from './components/Metrics'
|
||||||
@@ -22,16 +29,18 @@ import { DocsNavProvider } from './lib/docsNav'
|
|||||||
|
|
||||||
const POLL_MS = 2000
|
const POLL_MS = 2000
|
||||||
|
|
||||||
/// 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.
|
/// 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; range: TimeRange | null } {
|
function parsePopOutQuery(): { section: PopOutSectionId | null; presetId: TimeRangePresetId | null; fixedRange: TimeRange | null } {
|
||||||
const query = new URLSearchParams(window.location.search)
|
const query = new URLSearchParams(window.location.search)
|
||||||
const section = query.get('section') as PopOutSectionId | null
|
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 startMs = Number(query.get('startMs'))
|
||||||
const endMs = Number(query.get('endMs'))
|
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 }
|
? { startMs, endMs }
|
||||||
: null
|
: null
|
||||||
return { section, range }
|
return { section, presetId, fixedRange }
|
||||||
}
|
}
|
||||||
|
|
||||||
function StandalonePage({ children }: { children: ReactNode }) {
|
function StandalonePage({ children }: { children: ReactNode }) {
|
||||||
@@ -65,9 +74,97 @@ function StandaloneParams() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Metrics/Dashboards own their own data fetching — they only need the range carried over via query params.
|
/// 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, range }: { section: 'metrics' | 'dashboards'; range: TimeRange | null }) {
|
function StandaloneRanged({
|
||||||
return <StandalonePage>{section === 'metrics' ? <Metrics range={range} /> : <Dashboards range={range} />}</StandalonePage>
|
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.
|
/// 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.
|
||||||
@@ -172,10 +269,11 @@ function StandaloneLog() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
const { section, range: standaloneRange } = parsePopOutQuery()
|
const { section, presetId, fixedRange } = parsePopOutQuery()
|
||||||
if (section === 'log') return <StandaloneLog />
|
if (section === 'log') return <StandaloneLog />
|
||||||
if (section === 'params') return <StandaloneParams />
|
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 />
|
return <FullApp />
|
||||||
}
|
}
|
||||||
@@ -301,8 +399,8 @@ function FullApp() {
|
|||||||
<LogPreview logs={logs} params={params} />
|
<LogPreview logs={logs} params={params} />
|
||||||
</div>
|
</div>
|
||||||
<Params params={params} onDeclared={loadParams} />
|
<Params params={params} onDeclared={loadParams} />
|
||||||
<Metrics range={range} />
|
<Metrics range={range} activePreset={activePreset} />
|
||||||
<Dashboards range={range} />
|
<Dashboards range={range} activePreset={activePreset} />
|
||||||
<Alerts />
|
<Alerts />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useState } from 'react'
|
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
|
/// 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.
|
/// distinct band rather than blending into the section body below it.
|
||||||
@@ -64,21 +64,32 @@ export function CollapseToggle({ collapsed, onToggle }: { collapsed: boolean; on
|
|||||||
/// The four primary sections poppable into their own tab; matches the `?section=` query param a standalone page reads.
|
/// 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'
|
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 })
|
const query = new URLSearchParams({ section })
|
||||||
if (range) {
|
if (options?.presetId != null) {
|
||||||
query.set('startMs', String(range.startMs))
|
query.set('preset', options.presetId)
|
||||||
query.set('endMs', String(range.endMs))
|
} else if (options?.range) {
|
||||||
|
query.set('startMs', String(options.range.startMs))
|
||||||
|
query.set('endMs', String(options.range.endMs))
|
||||||
}
|
}
|
||||||
return `?${query.toString()}`
|
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.
|
/// `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 }: { section: PopOutSectionId; range?: TimeRange | null }) {
|
export function PopOutButton({
|
||||||
|
section,
|
||||||
|
range,
|
||||||
|
activePreset,
|
||||||
|
}: {
|
||||||
|
section: PopOutSectionId
|
||||||
|
range?: TimeRange | null
|
||||||
|
activePreset?: TimeRangePresetId | null
|
||||||
|
}) {
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => window.open(popOutUrl(section, range), '_blank')}
|
onClick={() => window.open(popOutUrl(section, { range, presetId: activePreset }), '_blank')}
|
||||||
title="Open in new tab"
|
title="Open in new tab"
|
||||||
aria-label="Open in new tab"
|
aria-label="Open in new tab"
|
||||||
className="flex h-5 w-5 shrink-0 items-center justify-center rounded-full border border-border-soft bg-surface-2 text-xs leading-none text-text-muted hover:border-border-strong hover:text-text-secondary"
|
className="flex h-5 w-5 shrink-0 items-center justify-center rounded-full border border-border-soft bg-surface-2 text-xs leading-none text-text-muted hover:border-border-strong hover:text-text-secondary"
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useEffect, useRef, useState } from 'react'
|
import { useEffect, useRef, useState } from 'react'
|
||||||
import type { TimeRange } from './LogTimeline'
|
import type { TimeRange, TimeRangePresetId } from './LogTimeline'
|
||||||
import { PanelChart } from './PanelChart'
|
import { PanelChart } from './PanelChart'
|
||||||
import {
|
import {
|
||||||
ApiError,
|
ApiError,
|
||||||
@@ -95,7 +95,7 @@ function AutosaveStatus({ status, error }: { status: SaveStatus; error: string |
|
|||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
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 [activeProfile, setActiveProfile] = useState<string | null>(null)
|
||||||
const [dashboards, setDashboards] = useState<Dashboard[]>([])
|
const [dashboards, setDashboards] = useState<Dashboard[]>([])
|
||||||
const [selectedId, setSelectedId] = useState<string | null>(null)
|
const [selectedId, setSelectedId] = useState<string | null>(null)
|
||||||
@@ -224,7 +224,7 @@ export function Dashboards({ range }: { range: TimeRange | null }) {
|
|||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-1.5">
|
<div className="flex items-center gap-1.5">
|
||||||
<CollapseToggle collapsed={collapsed} onToggle={() => setCollapsed(!collapsed)} />
|
<CollapseToggle collapsed={collapsed} onToggle={() => setCollapsed(!collapsed)} />
|
||||||
<PopOutButton section="dashboards" range={range} />
|
<PopOutButton section="dashboards" range={range} activePreset={activePreset} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="p-3 text-xs text-text-faint">
|
<div className="p-3 text-xs text-text-faint">
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
import type { TimeRange } from './LogTimeline'
|
import type { TimeRange, TimeRangePresetId } from './LogTimeline'
|
||||||
import {
|
import {
|
||||||
ApiError,
|
ApiError,
|
||||||
declareMetric,
|
declareMetric,
|
||||||
@@ -69,7 +69,7 @@ function sparkline(values: BucketValue[], range: TimeRange | null): string {
|
|||||||
return slots.map((count) => SPARK_CHARS[Math.round((count / max) * (SPARK_CHARS.length - 1))]).join('')
|
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 [metrics, setMetrics] = useState<MetricDeclaration[]>([])
|
||||||
const [selected, setSelected] = useState<string | null>(null)
|
const [selected, setSelected] = useState<string | null>(null)
|
||||||
const [bucketValues, setBucketValues] = useState<BucketValue[]>([])
|
const [bucketValues, setBucketValues] = useState<BucketValue[]>([])
|
||||||
@@ -158,7 +158,7 @@ export function Metrics({ range }: { range: TimeRange | null }) {
|
|||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-1.5">
|
<div className="flex items-center gap-1.5">
|
||||||
<CollapseToggle collapsed={collapsed} onToggle={() => setCollapsed(!collapsed)} />
|
<CollapseToggle collapsed={collapsed} onToggle={() => setCollapsed(!collapsed)} />
|
||||||
<PopOutButton section="metrics" range={range} />
|
<PopOutButton section="metrics" range={range} activePreset={activePreset} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-4 p-3" hidden={collapsed}>
|
<div className="flex gap-4 p-3" hidden={collapsed}>
|
||||||
|
|||||||
Reference in New Issue
Block a user