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 onToggle: (name: string) => void }) { const [open, setOpen] = useState(false) const ref = useRef(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 (
{open && (
{paramNames.length === 0 ? (
No declared params yet.
) : (
    {paramNames.map((name) => (
  • ))}
)}
)}
) } /// 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 | 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 } 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 null if (Array.isArray(value)) { return [String(i), v] as const)} bracket={['[', ']']} /> } if (typeof value === 'object') { return )} bracket={['{', '}']} /> } if (typeof value === 'string') return "{value}" return {String(value)} } function JsonNode({ entries, bracket, }: { entries: ReadonlyArray bracket: [string, string] }) { const [expanded, setExpanded] = useState(true) if (entries.length === 0) { return ( {bracket[0]} {bracket[1]} ) } return ( {expanded ? ( {bracket[0]} {entries.map(([key, value]) => ( {key} : ))} {bracket[1]} ) : ( {bracket[0]}…{bracket[1]} )} ) } /// 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 } /// 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, 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(loadVisibleParamColumns) const scrollContainerRef = useRef(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 (
Log preview
{columns.map((name) => ( ))} {logs.map((log) => ( {columns.map((name) => { const value = log.params[name] return ( ) })} ))} {logs.length === 0 && ( )}
Seq Timestamp {name} Message
{log.sequence} {new Date(log.timestamp_ms).toLocaleTimeString()} {value ? metricPrimitiveToDisplayString(value) : '—'}
No logs yet — pipe something into ottyl.
) }