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:
co-authored by
Claude Sonnet 5
parent
363018793f
commit
b89fda39a0
+57
-6
@@ -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} />
|
||||||
|
|||||||
@@ -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>
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
Reference in New Issue
Block a user