import { useRef, useState } from 'react' import type { TimelineBucket } from '../lib/api' import { sectionHeaderClassName, sectionHeaderLeftClassName } from './CollapsibleSection' import { HelpIcon } from './HelpIcon' export interface TimeRange { startMs: number endMs: number } // `'all'` clears the range (the implicit default); the rest resolve to a window ending "now" at the moment they're (re-)computed — see `computePresetRange`. export type TimeRangePresetId = 'all' | '1m' | '2m' | '5m' | '15m' | '1h' | '24h' export const TIME_RANGE_PRESETS: { id: TimeRangePresetId; label: string; ms: number | null }[] = [ { id: 'all', label: 'All', ms: null }, { id: '1m', label: '1m', ms: 60 * 1000 }, { id: '2m', label: '2m', ms: 2 * 60 * 1000 }, { 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 }, ] // Callers re-invoke this on their existing poll cadence (see `App.tsx`) to keep a rolling preset's window moving with time, 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 } } /// Buckets a fixed range into this many slots, so e.g. a rolling `5m` preset always requests a resolution fine enough to visibly slide instead of a handful of coarse, jumpy bars. const TIMELINE_TARGET_BUCKETS = 60 /// Callers with an explicit (non-`all`) range pass this as `bucketMs` to `fetchTimeline` — `LogTimeline` derives the same value from its own `domainSpan` when laying out bars, so requested and rendered resolution always agree. export function computeBucketMs(domainSpanMs: number): number { return Math.max(1000, Math.round(domainSpanMs / TIMELINE_TARGET_BUCKETS)) } function formatTimestamp(ms: number): string { return new Date(ms).toLocaleTimeString() } 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) const [dragCurrentX, setDragCurrentX] = useState(null) const [hoverX, setHoverX] = useState(null) const maxCount = Math.max(1, ...buckets.map((b) => b.count)) const domainStart = range?.startMs ?? buckets[0]?.start_ms ?? 0 const domainEnd = range?.endMs ?? Date.now() const domainSpan = Math.max(1, domainEnd - domainStart) // Matches what the caller requested via `computeBucketMs` for a fixed range; with no range ('all') there's no such request, so fall back to the actual spacing between returned buckets. const bucketMs = range ? computeBucketMs(domainSpan) : buckets.length >= 2 ? buckets[1].start_ms - buckets[0].start_ms : domainSpan const firstBucket = buckets[0] ?? null const lastBucket = buckets[buckets.length - 1] ?? null function xToMs(x: number) { const rect = containerRef.current?.getBoundingClientRect() if (!rect) return domainStart const ratio = Math.min(1, Math.max(0, (x - rect.left) / rect.width)) return Math.round(domainStart + ratio * domainSpan) } function bucketAtOrBefore(ms: number): TimelineBucket | null { let result: TimelineBucket | null = null for (const b of buckets) { if (b.start_ms <= ms) result = b else break } return result ?? firstBucket } function handleMouseDown(e: React.MouseEvent) { setDragStartX(e.clientX) setDragCurrentX(e.clientX) } function handleMouseMove(e: React.MouseEvent) { if (dragStartX !== null) { setDragCurrentX(e.clientX) } else { setHoverX(e.clientX) } } function handleMouseUp() { if (dragStartX !== null && dragCurrentX !== null && Math.abs(dragCurrentX - dragStartX) > 3) { const a = xToMs(Math.min(dragStartX, dragCurrentX)) const b = xToMs(Math.max(dragStartX, dragCurrentX)) onSelectRange({ startMs: a, endMs: b }) } setDragStartX(null) setDragCurrentX(null) } const dragging = dragStartX !== null && dragCurrentX !== null const selRect = dragging ? { left: Math.min(dragStartX!, dragCurrentX!), width: Math.abs(dragCurrentX! - dragStartX!), } : null const hoverBucket = !dragging && hoverX !== null ? bucketAtOrBefore(xToMs(hoverX)) : null const containerLeft = containerRef.current?.getBoundingClientRect().left ?? 0 return (
Timeline
{TIME_RANGE_PRESETS.map((preset) => ( ))}
{/* Only shown for a dragged (non-preset) selection — presets already show their own active state above. */} {range && activePreset === null && ( )}
{ setDragStartX(null) setDragCurrentX(null) setHoverX(null) }} > {/* Positioned by actual elapsed time within the domain (not evenly spread via flex), so a rolling preset like `5m` visibly slides — a bucket's left/width tracks its real place in the window instead of jumping to a fixed slot as buckets enter/leave it. */}
{buckets.map((b) => (
))}
{firstBucket && (
#0 · {formatTimestamp(firstBucket.start_ms)}
)} {lastBucket && (
#{buckets.length - 1} · {formatTimestamp(lastBucket.start_ms)}
)} {selRect && (
)} {dragging && (
{formatTimestamp(xToMs(dragStartX!))} → {formatTimestamp(xToMs(dragCurrentX!))}
)} {!dragging && hoverBucket && hoverX !== null && (
{formatTimestamp(hoverBucket.start_ms)} — {hoverBucket.count}
)}
) }