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
298 lines
11 KiB
TypeScript
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>
|
|
)
|
|
}
|