Add Grafana-style time range presets: All, 5m, 15m, 1h, 24h (ticket 024)

LogTimeline gets a preset button row (All + four relative windows)
next to the existing drag-select interaction. App.tsx lifts the
active preset alongside `range`; a relative preset re-resolves to a
fresh {startMs, endMs} on the same 2s cadence the app already polls
on, so it visibly rolls forward rather than freezing at selection
time. Dragging on the timeline (or picking a different preset)
overrides back to a fixed range, matching today's drag behavior.

The rolling re-resolution reads/writes `range` via a ref inside the
poll loop instead of depending on `range` directly, since a naive
`setRange` inside an effect keyed on `range` would restart the effect
(and its interval) every tick — turning "poll every 2s" into "poll as
fast as React can re-render." Verified live: exactly 3 /api/stats
calls over ~6.2s with a rolling preset active, and console/network
free of errors.

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 15:57:58 +01:00
co-authored by Claude Sonnet 5
parent 363018793f
commit b89fda39a0
3 changed files with 119 additions and 13 deletions
+57 -6
View File
@@ -1,7 +1,7 @@
import { useEffect, useState } from 'react' import { useEffect, useRef, useState } from 'react'
import { NavMenu } from './components/NavMenu' import { NavMenu } from './components/NavMenu'
import { FilterBar } from './components/FilterBar' import { FilterBar } from './components/FilterBar'
import { LogTimeline, type TimeRange } from './components/LogTimeline' import { LogTimeline, computePresetRange, type TimeRange, type TimeRangePresetId } from './components/LogTimeline'
import { LogPreview } from './components/LogPreview' import { LogPreview } from './components/LogPreview'
import { Metrics } from './components/Metrics' import { Metrics } from './components/Metrics'
import { Dashboards } from './components/Dashboards' import { Dashboards } from './components/Dashboards'
@@ -15,18 +15,57 @@ export default function App() {
const [logs, setLogs] = useState<LogEntry[]>([]) const [logs, setLogs] = useState<LogEntry[]>([])
const [buckets, setBuckets] = useState<TimelineBucket[]>([]) const [buckets, setBuckets] = useState<TimelineBucket[]>([])
const [range, setRange] = useState<TimeRange | null>(null) const [range, setRange] = useState<TimeRange | null>(null)
const [activePreset, setActivePreset] = useState<TimeRangePresetId | null>('all')
const [filter, setFilter] = useState('') const [filter, setFilter] = useState('')
const [filterError, setFilterError] = useState<string | null>(null) const [filterError, setFilterError] = useState<string | null>(null)
// Read from inside the poll loop below without making it a dependency —
// a rolling preset's own re-resolution (ticket 024) updates `range`, and
// depending on `range` directly would restart the effect (and its
// interval) every tick, turning "poll every POLL_MS" into "poll as fast as
// React can re-render."
const rangeRef = useRef(range)
useEffect(() => {
rangeRef.current = range
}, [range])
function selectPreset(id: TimeRangePresetId) {
setActivePreset(id)
setRange(computePresetRange(id))
}
// A drag on LogTimeline always yields a fixed (non-rolling) range,
// overriding whatever preset was active — same as picking a preset
// overrides a previous drag.
function handleManualRangeSelect(next: TimeRange | null) {
if (next === null) {
selectPreset('all')
} else {
setActivePreset(null)
setRange(next)
}
}
useEffect(() => { useEffect(() => {
let cancelled = false let cancelled = false
async function poll() { async function poll() {
// Rolling presets "move with time": re-resolve on this same polling
// cadence rather than freezing the window at selection time.
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 { try {
const [nextStats, nextBuckets, nextLogs] = await Promise.all([ const [nextStats, nextBuckets, nextLogs] = await Promise.all([
fetchStats(), fetchStats(),
fetchTimeline({ startMs: range?.startMs, endMs: range?.endMs, filter: filter || undefined }), fetchTimeline({ startMs: effectiveRange?.startMs, endMs: effectiveRange?.endMs, filter: filter || undefined }),
fetchLogs({ startMs: range?.startMs, endMs: range?.endMs, limit: 1000, filter: filter || undefined }), fetchLogs({ startMs: effectiveRange?.startMs, endMs: effectiveRange?.endMs, limit: 1000, filter: filter || undefined }),
]) ])
if (!cancelled) { if (!cancelled) {
setStats(nextStats) setStats(nextStats)
@@ -47,7 +86,13 @@ export default function App() {
cancelled = true cancelled = true
clearInterval(id) clearInterval(id)
} }
}, [range, filter]) // Intentionally excludes `range`: a manual drag (which does change
// `activePreset` to null) still gets an immediate poll; only a second
// drag made while already in fixed-range mode waits for the next
// interval tick, which is an acceptable trade-off for not restarting
// this effect (and its interval) every rolling-preset tick.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [filter, activePreset])
return ( return (
<DocsNavProvider> <DocsNavProvider>
@@ -55,7 +100,13 @@ export default function App() {
<NavMenu stats={stats} /> <NavMenu stats={stats} />
<div className="flex min-w-0 flex-1 flex-col"> <div className="flex min-w-0 flex-1 flex-col">
<FilterBar value={filter} onChange={setFilter} error={filterError} /> <FilterBar value={filter} onChange={setFilter} error={filterError} />
<LogTimeline buckets={buckets} range={range} onSelectRange={setRange} /> <LogTimeline
buckets={buckets}
range={range}
onSelectRange={handleManualRangeSelect}
activePreset={activePreset}
onSelectPreset={selectPreset}
/>
<LogPreview logs={logs} /> <LogPreview logs={logs} />
<Metrics range={range} /> <Metrics range={range} />
<Dashboards range={range} /> <Dashboards range={range} />
+8 -2
View File
@@ -130,8 +130,14 @@ const SECTIONS: DocSection[] = [
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<p> <p>
The timeline above the log table renders log volume per bucket over the active window. Drag across it to The timeline above the log table renders log volume per bucket over the active window. Drag across it to
select a fixed time range — it narrows the log table, metrics, and dashboards below to that window; click select a fixed time range — it narrows the log table, metrics, and dashboards below to that window.
"Clear selection" to go back to the default (rolling "all time") view. </p>
<p>
The preset buttons (<Code>All</Code>, <Code>5m</Code>, <Code>15m</Code>, <Code>1h</Code>,{' '}
<Code>24h</Code>) are Grafana-style shortcuts for the same thing: <Code>All</Code> clears the range
entirely, and a relative preset is <em>rolling</em> — its window keeps advancing to stay "last N minutes
from now" rather than freezing at the moment you picked it. Dragging on the timeline while a preset is
active switches back to a fixed (non-rolling) range, same as dragging normally would.
</p> </p>
</div> </div>
), ),
+54 -5
View File
@@ -7,14 +7,42 @@ export interface TimeRange {
endMs: number endMs: number
} }
/// A Grafana-style relative time-range preset (ticket 024). `'all'` clears
/// the range (today's implicit default); the rest resolve to a window
/// ending "now" at the moment they're (re-)computed — see `computePresetRange`.
export type TimeRangePresetId = 'all' | '5m' | '15m' | '1h' | '24h'
export const TIME_RANGE_PRESETS: { id: TimeRangePresetId; label: string; ms: number | null }[] = [
{ id: 'all', label: 'All', ms: null },
{ id: '5m', label: '5m', ms: 5 * 60 * 1000 },
{ id: '15m', label: '15m', ms: 15 * 60 * 1000 },
{ id: '1h', label: '1h', ms: 60 * 60 * 1000 },
{ id: '24h', label: '24h', ms: 24 * 60 * 60 * 1000 },
]
/// Resolves a preset to a concrete `{startMs, endMs}` window as of *now* —
/// callers re-invoke this on the same cadence they already poll on to keep a
/// rolling preset's window moving with time (see `App.tsx`), rather than
/// freezing it at selection time.
export function computePresetRange(id: TimeRangePresetId): TimeRange | null {
const preset = TIME_RANGE_PRESETS.find((p) => p.id === id)
if (!preset || preset.ms == null) return null
const now = Date.now()
return { startMs: now - preset.ms, endMs: now }
}
export function LogTimeline({ export function LogTimeline({
buckets, buckets,
range, range,
onSelectRange, onSelectRange,
activePreset,
onSelectPreset,
}: { }: {
buckets: TimelineBucket[] buckets: TimelineBucket[]
range: TimeRange | null range: TimeRange | null
onSelectRange: (range: TimeRange | null) => void onSelectRange: (range: TimeRange | null) => void
activePreset: TimeRangePresetId | null
onSelectPreset: (id: TimeRangePresetId) => void
}) { }) {
const containerRef = useRef<HTMLDivElement>(null) const containerRef = useRef<HTMLDivElement>(null)
const [dragStartX, setDragStartX] = useState<number | null>(null) const [dragStartX, setDragStartX] = useState<number | null>(null)
@@ -64,11 +92,32 @@ export function LogTimeline({
Timeline Timeline
<HelpIcon sectionId="timeline" label="Help: Timeline" /> <HelpIcon sectionId="timeline" label="Help: Timeline" />
</span> </span>
{range && ( <div className="flex items-center gap-1.5">
<button className="underline hover:text-text-secondary" onClick={() => onSelectRange(null)}> <div className="flex items-center gap-1">
Clear selection {TIME_RANGE_PRESETS.map((preset) => (
</button> <button
)} key={preset.id}
type="button"
onClick={() => onSelectPreset(preset.id)}
className={`rounded border px-1.5 py-0.5 text-[11px] ${
activePreset === preset.id
? 'border-accent bg-accent/10 text-accent-text'
: 'border-border-soft text-text-muted hover:bg-surface-2'
}`}
>
{preset.label}
</button>
))}
</div>
{/* A dragged (custom, non-preset) selection — presets already show
their own active state above, so this only appears for the
"fixed range from a drag" case. */}
{range && activePreset === null && (
<button className="underline hover:text-text-secondary" onClick={() => onSelectPreset('all')}>
Clear selection
</button>
)}
</div>
</div> </div>
<div <div
ref={containerRef} ref={containerRef}