Files
ottyl/webapp/src/components/LogPreview.tsx
T
Hugh KaznowskiandClaude Sonnet 5 78a6672ced Move log sort selector from Log Preview to Timeline header
The "Sort by" / "fall back to ottyl_seq" control now lives in
LogTimeline's header next to the time-range presets, since the setting
actually drives the shared range/log fetch rather than being a
Log-Preview-only concern. LogSortSettings and its load/save helpers
moved into LogTimeline.tsx alongside SortPicker; the localStorage key
was renamed to ottyl.logTimeline.sort, with loadLogSortSettings falling
back to the old ottyl.logPreview.sort key so an existing choice isn't
lost on first load after the move. LogPreview now just renders logs in
whatever order the caller already sorted them.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XFUNZwCvz3kcod3352ZR2o
2026-09-06 09:34:46 +01:00

298 lines
11 KiB
TypeScript

import { useEffect, useRef, useState } from 'react'
import { metricPrimitiveToDisplayString, type LogEntry, type ParamDeclaration } from '../lib/api'
import { sectionHeaderClassName, sectionHeaderLeftClassName } from './CollapsibleSection'
import { HelpIcon } from './HelpIcon'
// Namespaced under `ottyl.` so future per-viewer preferences elsewhere in the app won't collide with this key.
const VISIBLE_PARAM_COLUMNS_STORAGE_KEY = 'ottyl.logPreview.visibleParamColumns'
function loadVisibleParamColumns(): string[] {
try {
const raw = localStorage.getItem(VISIBLE_PARAM_COLUMNS_STORAGE_KEY)
if (!raw) return []
const parsed = JSON.parse(raw)
return Array.isArray(parsed) ? parsed.filter((v): v is string => typeof v === 'string') : []
} catch {
return []
}
}
function saveVisibleParamColumns(columns: string[]) {
try {
localStorage.setItem(VISIBLE_PARAM_COLUMNS_STORAGE_KEY, JSON.stringify(columns))
} catch {
// best-effort — private browsing or full storage just means the selection won't persist across reloads
}
}
function ColumnPicker({
paramNames,
visible,
onToggle,
}: {
paramNames: string[]
visible: Set<string>
onToggle: (name: string) => void
}) {
const [open, setOpen] = useState(false)
const ref = useRef<HTMLDivElement>(null)
useEffect(() => {
if (!open) return
function onClickOutside(e: MouseEvent) {
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false)
}
document.addEventListener('mousedown', onClickOutside)
return () => document.removeEventListener('mousedown', onClickOutside)
}, [open])
return (
<div ref={ref} className="relative">
<button
type="button"
onClick={() => setOpen((v) => !v)}
className="rounded border border-border-soft px-2 py-1 text-xs text-text-secondary hover:bg-surface-2"
>
Columns{visible.size > 0 ? ` (${visible.size})` : ''}
</button>
{open && (
<div className="absolute right-0 z-10 mt-1 w-56 rounded border border-border-soft bg-surface-0 p-2 shadow-lg">
{paramNames.length === 0 ? (
<div className="px-1 py-1 text-xs text-text-subtle">No declared params yet.</div>
) : (
<ul className="flex max-h-64 flex-col gap-1 overflow-auto">
{paramNames.map((name) => (
<li key={name}>
<label className="flex cursor-pointer items-center gap-2 rounded px-1 py-0.5 text-xs text-text hover:bg-surface-1">
<input
type="checkbox"
checked={visible.has(name)}
onChange={() => onToggle(name)}
className="accent-text-muted"
/>
{name}
</label>
</li>
))}
</ul>
)}
</div>
)}
</div>
)
}
/// Attempts to parse a log line's raw text as a JSON object (not array/primitive), for structured
/// tree rendering. Returns null for anything that isn't valid JSON or doesn't parse to a plain object,
/// so those lines keep rendering as plain text.
function tryParseJsonObject(text: string): Record<string, unknown> | null {
const trimmed = text.trim()
if (!trimmed.startsWith('{')) return null
try {
const parsed = JSON.parse(trimmed)
if (parsed !== null && typeof parsed === 'object' && !Array.isArray(parsed)) {
return parsed as Record<string, unknown>
}
return null
} catch {
return null
}
}
/// Renders a single JSON value: primitives inline, objects/arrays as collapsible subtrees.
function JsonValueView({ value }: { value: unknown }) {
if (value === null) return <span className="text-text-subtle">null</span>
if (Array.isArray(value)) {
return <JsonNode entries={value.map((v, i) => [String(i), v] as const)} bracket={['[', ']']} />
}
if (typeof value === 'object') {
return <JsonNode entries={Object.entries(value as Record<string, unknown>)} bracket={['{', '}']} />
}
if (typeof value === 'string') return <span className="text-text">"{value}"</span>
return <span className="text-text-muted">{String(value)}</span>
}
function JsonNode({
entries,
bracket,
}: {
entries: ReadonlyArray<readonly [string, unknown]>
bracket: [string, string]
}) {
const [expanded, setExpanded] = useState(true)
if (entries.length === 0) {
return (
<span className="text-text-subtle">
{bracket[0]}
{bracket[1]}
</span>
)
}
return (
<span>
<button
type="button"
onClick={() => setExpanded((v) => !v)}
className="mr-1 inline-block w-3 select-none text-text-subtle hover:text-text-muted"
aria-label={expanded ? 'Collapse' : 'Expand'}
>
{expanded ? '▾' : '▸'}
</button>
{expanded ? (
<span>
{bracket[0]}
<span className="ml-3 block border-l border-border-faint pl-2">
{entries.map(([key, value]) => (
<span key={key} className="block">
<span className="text-text-secondary">{key}</span>
<span className="text-text-subtle">: </span>
<JsonValueView value={value} />
</span>
))}
</span>
{bracket[1]}
</span>
) : (
<span className="text-text-subtle">{bracket[0]}…{bracket[1]}</span>
)}
</span>
)
}
/// Renders a log line's message: a collapsible key/value tree when the raw text parses as a JSON
/// object, otherwise the raw text unchanged.
function LogMessage({ text }: { text: string }) {
const parsed = tryParseJsonObject(text)
if (!parsed) return <>{text}</>
return <JsonValueView value={parsed} />
}
/// 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[]
/** Ticket 045/053: already sorted by the caller's `fetchLogs` call per its own sort setting (owned and rendered up in `LogTimeline`'s header now) — this component just renders whatever order it's given. */
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)
function toggleColumn(name: string) {
setVisibleParamColumns((current) => {
const next = current.includes(name)
? current.filter((c) => c !== name)
: [...current, name]
saveVisibleParamColumns(next)
return next
})
}
// Filter out stale localStorage entries for since-removed params, so they don't leave a dangling column.
const columns = visibleParamColumns.filter((name) => paramNames.includes(name))
return (
<div className="flex flex-1 flex-col overflow-hidden">
<div className={sectionHeaderClassName}>
<div className={sectionHeaderLeftClassName}>
Log preview
<HelpIcon sectionId="log-preview" label="Help: Log preview" />
</div>
<div className="flex items-center gap-3">
<ColumnPicker paramNames={paramNames} visible={visible} onToggle={toggleColumn} />
</div>
</div>
<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>
<th className="w-16 px-3 py-1 font-normal">Seq</th>
<th className="w-40 px-3 py-1 font-normal">Timestamp</th>
{columns.map((name) => (
<th key={name} className="w-32 px-3 py-1 font-normal">
{name}
</th>
))}
<th className="px-3 py-1 font-normal">Message</th>
</tr>
</thead>
<tbody>
{logs.map((log) => (
<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()}
</td>
{columns.map((name) => {
const value = log.params[name]
return (
<td key={name} className="whitespace-nowrap px-3 py-1 text-text-muted">
{value ? metricPrimitiveToDisplayString(value) : '—'}
</td>
)
})}
<td className="px-3 py-1 text-text">
<LogMessage text={log.text} />
</td>
</tr>
))}
{logs.length === 0 && (
<tr>
<td colSpan={3 + columns.length} className="px-3 py-6 text-center text-text-faint">
No logs yet — pipe something into ottyl.
</td>
</tr>
)}
</tbody>
</table>
</div>
</div>
)
}