Anchor log viewer scroll to visible content and add 1m/2m timeline presets
The log table no longer jumps when a sliding window prunes rows from the front: scroll position now tracks the same visible row instead of holding scrollTop constant in pixels. Also adds 1m/2m to the timeline quick-range presets. 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
070e835fa4
commit
ae4e550c1f
@@ -82,9 +82,51 @@ function ColumnPicker({
|
||||
)
|
||||
}
|
||||
|
||||
/// Keeps the scroll position anchored to the same visible row across a `logs` update (e.g. a sliding time window
|
||||
/// pruning old rows from the front), instead of the browser's default of holding `scrollTop` constant in pixels —
|
||||
/// which would otherwise scroll the view to different content whenever rows are removed above it.
|
||||
function useScrollAnchor(containerRef: React.RefObject<HTMLDivElement | null>, deps: unknown[]) {
|
||||
const pendingAnchor = useRef<{ key: string; offsetTop: number } | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
const container = containerRef.current
|
||||
if (!container) return
|
||||
|
||||
if (pendingAnchor.current) {
|
||||
const { key, offsetTop } = pendingAnchor.current
|
||||
const row = container.querySelector(`[data-row-key="${CSS.escape(key)}"]`)
|
||||
if (row) {
|
||||
const rowTop = row.getBoundingClientRect().top - container.getBoundingClientRect().top
|
||||
container.scrollTop += rowTop - offsetTop
|
||||
}
|
||||
pendingAnchor.current = null
|
||||
}
|
||||
|
||||
// Runs right before the DOM mutates for the next update (React calls a layout effect's cleanup
|
||||
// synchronously before re-running it), so this still sees the pre-update DOM — the same idea as
|
||||
// `getSnapshotBeforeUpdate`, expressed with a plain effect.
|
||||
return () => {
|
||||
const containerRect = container.getBoundingClientRect()
|
||||
for (const row of container.querySelectorAll('[data-row-key]')) {
|
||||
const rect = row.getBoundingClientRect()
|
||||
if (rect.bottom > containerRect.top) {
|
||||
pendingAnchor.current = {
|
||||
key: row.getAttribute('data-row-key')!,
|
||||
offsetTop: rect.top - containerRect.top,
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, deps)
|
||||
}
|
||||
|
||||
export function LogPreview({ logs, params }: { logs: LogEntry[]; params: ParamDeclaration[] }) {
|
||||
const paramNames = params.map((d) => d.name)
|
||||
const [visibleParamColumns, setVisibleParamColumns] = useState<string[]>(loadVisibleParamColumns)
|
||||
const scrollContainerRef = useRef<HTMLDivElement>(null)
|
||||
useScrollAnchor(scrollContainerRef, [logs])
|
||||
|
||||
const visible = new Set(visibleParamColumns)
|
||||
|
||||
@@ -110,7 +152,7 @@ export function LogPreview({ logs, params }: { logs: LogEntry[]; params: ParamDe
|
||||
</div>
|
||||
<ColumnPicker paramNames={paramNames} visible={visible} onToggle={toggleColumn} />
|
||||
</div>
|
||||
<div className="flex-1 overflow-auto">
|
||||
<div ref={scrollContainerRef} className="flex-1 overflow-auto">
|
||||
<table className="w-full border-collapse text-left text-xs">
|
||||
<thead className="sticky top-0 bg-surface-0 text-text-subtle">
|
||||
<tr>
|
||||
@@ -126,7 +168,7 @@ export function LogPreview({ logs, params }: { logs: LogEntry[]; params: ParamDe
|
||||
</thead>
|
||||
<tbody>
|
||||
{logs.map((log) => (
|
||||
<tr key={log.sequence} className="border-t border-border-faint hover:bg-surface-1/60">
|
||||
<tr key={log.sequence} data-row-key={log.sequence} className="border-t border-border-faint hover:bg-surface-1/60">
|
||||
<td className="whitespace-nowrap px-3 py-1 text-text-subtle">{log.sequence}</td>
|
||||
<td className="whitespace-nowrap px-3 py-1 text-text-subtle">
|
||||
{new Date(log.timestamp_ms).toLocaleTimeString()}
|
||||
|
||||
@@ -9,10 +9,12 @@ export interface TimeRange {
|
||||
}
|
||||
|
||||
// `'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' | '5m' | '15m' | '1h' | '24h'
|
||||
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 },
|
||||
|
||||
Reference in New Issue
Block a user