Remove the rounded-t Tailwind class from timeline bars so spike peaks render as sharp, flat-topped rectangles instead of rounded corners, making bucket boundaries clearer to read. Verified in the browser in both light and dark theme. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XFUNZwCvz3kcod3352ZR2o
214 lines
8.8 KiB
TypeScript
214 lines
8.8 KiB
TypeScript
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<HTMLDivElement>(null)
|
|
const [dragStartX, setDragStartX] = useState<number | null>(null)
|
|
const [dragCurrentX, setDragCurrentX] = useState<number | null>(null)
|
|
const [hoverX, setHoverX] = useState<number | null>(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 (
|
|
<div className="border-b border-border">
|
|
<div className={sectionHeaderClassName}>
|
|
<span className={sectionHeaderLeftClassName}>
|
|
Timeline
|
|
<HelpIcon sectionId="timeline" label="Help: Timeline" />
|
|
</span>
|
|
<div className="flex items-center gap-1.5 text-xs text-text-subtle">
|
|
<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>
|
|
{/* Only shown for a dragged (non-preset) selection — presets already show their own active state above. */}
|
|
{range && activePreset === null && (
|
|
<button className="underline hover:text-text-secondary" onClick={() => onSelectPreset('all')}>
|
|
Clear selection
|
|
</button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
<div className="p-3">
|
|
<div
|
|
ref={containerRef}
|
|
className="relative h-24 w-full cursor-crosshair select-none overflow-hidden rounded bg-surface-1"
|
|
onMouseDown={handleMouseDown}
|
|
onMouseMove={handleMouseMove}
|
|
onMouseUp={handleMouseUp}
|
|
onMouseLeave={() => {
|
|
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. */}
|
|
<div className="absolute inset-x-1 bottom-0 top-0">
|
|
{buckets.map((b) => (
|
|
<div
|
|
key={b.start_ms}
|
|
className="absolute bottom-0 min-w-[2px] bg-accent/70"
|
|
style={{
|
|
left: `${((b.start_ms - domainStart) / domainSpan) * 100}%`,
|
|
width: `${(bucketMs / domainSpan) * 100}%`,
|
|
height: `${(b.count / maxCount) * 100}%`,
|
|
}}
|
|
/>
|
|
))}
|
|
</div>
|
|
{firstBucket && (
|
|
<div className="pointer-events-none absolute left-1 top-1 whitespace-nowrap text-[10px] text-text-subtle">
|
|
#0 · {formatTimestamp(firstBucket.start_ms)}
|
|
</div>
|
|
)}
|
|
{lastBucket && (
|
|
<div className="pointer-events-none absolute right-1 top-1 whitespace-nowrap text-[10px] text-text-subtle">
|
|
#{buckets.length - 1} · {formatTimestamp(lastBucket.start_ms)}
|
|
</div>
|
|
)}
|
|
{selRect && (
|
|
<div
|
|
className="pointer-events-none absolute top-0 h-full border border-accent bg-accent/20"
|
|
style={{ left: selRect.left - containerLeft, width: selRect.width }}
|
|
/>
|
|
)}
|
|
{dragging && (
|
|
<div
|
|
className="pointer-events-none absolute top-1 z-10 -translate-x-1/2 whitespace-nowrap rounded border border-accent bg-surface-2 px-1.5 py-0.5 text-[11px] text-text-secondary shadow"
|
|
style={{ left: (selRect?.left ?? 0) - containerLeft + (selRect?.width ?? 0) / 2 }}
|
|
>
|
|
{formatTimestamp(xToMs(dragStartX!))} → {formatTimestamp(xToMs(dragCurrentX!))}
|
|
</div>
|
|
)}
|
|
{!dragging && hoverBucket && hoverX !== null && (
|
|
<div
|
|
className="pointer-events-none absolute top-1 z-10 -translate-x-1/2 whitespace-nowrap rounded border border-border-soft bg-surface-2 px-1.5 py-0.5 text-[11px] text-text-muted shadow"
|
|
style={{ left: hoverX - containerLeft }}
|
|
>
|
|
{formatTimestamp(hoverBucket.start_ms)} — {hoverBucket.count}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|