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
+54 -5
View File
@@ -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<HTMLDivElement>(null)
const [dragStartX, setDragStartX] = useState<number | null>(null)
@@ -64,11 +92,32 @@ export function LogTimeline({
Timeline
<HelpIcon sectionId="timeline" label="Help: Timeline" />
</span>
{range && (
<button className="underline hover:text-text-secondary" onClick={() => onSelectRange(null)}>
Clear selection
</button>
)}
<div className="flex items-center gap-1.5">
<div className="flex items-center gap-1">
{TIME_RANGE_PRESETS.map((preset) => (
<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
ref={containerRef}