From b89fda39a05fb1dac742288eca89aa0760e5cb9d Mon Sep 17 00:00:00 2001 From: Hugh Kaznowski Date: Sat, 5 Sep 2026 15:57:58 +0100 Subject: [PATCH] Add Grafana-style time range presets: All, 5m, 15m, 1h, 24h (ticket 024) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_01XFUNZwCvz3kcod3352ZR2o --- webapp/src/App.tsx | 63 ++++++++++++++++++++++++--- webapp/src/components/Docs.tsx | 10 ++++- webapp/src/components/LogTimeline.tsx | 59 ++++++++++++++++++++++--- 3 files changed, 119 insertions(+), 13 deletions(-) diff --git a/webapp/src/App.tsx b/webapp/src/App.tsx index 07565e9..b7086be 100644 --- a/webapp/src/App.tsx +++ b/webapp/src/App.tsx @@ -1,7 +1,7 @@ -import { useEffect, useState } from 'react' +import { useEffect, useRef, useState } from 'react' import { NavMenu } from './components/NavMenu' 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 { Metrics } from './components/Metrics' import { Dashboards } from './components/Dashboards' @@ -15,18 +15,57 @@ export default function App() { const [logs, setLogs] = useState([]) const [buckets, setBuckets] = useState([]) const [range, setRange] = useState(null) + const [activePreset, setActivePreset] = useState('all') const [filter, setFilter] = useState('') const [filterError, setFilterError] = useState(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(() => { let cancelled = false 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 { const [nextStats, nextBuckets, nextLogs] = await Promise.all([ fetchStats(), - fetchTimeline({ startMs: range?.startMs, endMs: range?.endMs, filter: filter || undefined }), - fetchLogs({ startMs: range?.startMs, endMs: range?.endMs, limit: 1000, filter: filter || undefined }), + fetchTimeline({ startMs: effectiveRange?.startMs, endMs: effectiveRange?.endMs, filter: filter || undefined }), + fetchLogs({ startMs: effectiveRange?.startMs, endMs: effectiveRange?.endMs, limit: 1000, filter: filter || undefined }), ]) if (!cancelled) { setStats(nextStats) @@ -47,7 +86,13 @@ export default function App() { cancelled = true 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 ( @@ -55,7 +100,13 @@ export default function App() {
- + diff --git a/webapp/src/components/Docs.tsx b/webapp/src/components/Docs.tsx index 8129301..ae5e6bc 100644 --- a/webapp/src/components/Docs.tsx +++ b/webapp/src/components/Docs.tsx @@ -130,8 +130,14 @@ const SECTIONS: DocSection[] = [

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 - "Clear selection" to go back to the default (rolling "all time") view. + select a fixed time range — it narrows the log table, metrics, and dashboards below to that window. +

+

+ The preset buttons (All, 5m, 15m, 1h,{' '} + 24h) are Grafana-style shortcuts for the same thing: All clears the range + entirely, and a relative preset is rolling — 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.

), diff --git a/webapp/src/components/LogTimeline.tsx b/webapp/src/components/LogTimeline.tsx index b30d9b4..64d9232 100644 --- a/webapp/src/components/LogTimeline.tsx +++ b/webapp/src/components/LogTimeline.tsx @@ -7,14 +7,42 @@ export interface TimeRange { 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({ buckets, range, onSelectRange, + activePreset, + onSelectPreset, }: { buckets: TimelineBucket[] range: TimeRange | null onSelectRange: (range: TimeRange | null) => void + activePreset: TimeRangePresetId | null + onSelectPreset: (id: TimeRangePresetId) => void }) { const containerRef = useRef(null) const [dragStartX, setDragStartX] = useState(null) @@ -64,11 +92,32 @@ export function LogTimeline({ Timeline - {range && ( - - )} +
+
+ {TIME_RANGE_PRESETS.map((preset) => ( + + ))} +
+ {/* 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 && ( + + )} +