Files
ottyl/webapp/src/components/LogTimeline.tsx
T
Hugh KaznowskiandClaude Sonnet 5 de48e218a1 Use sharp/angular tops for timeline event spikes
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
2026-09-05 18:37:27 +01:00

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>
)
}