Add selectable declared-param columns to LogPreview (ticket 015)
Extend GET /api/logs to derive each declared param's value per log line (regex + capture group + kind, matching that line's text) and include it as a params map on LogEntryDto, omitting entries that don't match rather than sending nulls. Add GET /api/params (list_params, mirroring list_metrics) so the frontend can discover currently-declared param names. In LogPreview, add a column-picker popover backed by that list, with the selection persisted in localStorage under `ottyl.logPreview.visibleParamColumns`. 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
e48c03218f
commit
7514052048
@@ -1,35 +1,176 @@
|
||||
import type { LogEntry } from '../lib/api'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import {
|
||||
fetchParamDeclarations,
|
||||
metricPrimitiveToDisplayString,
|
||||
type LogEntry,
|
||||
} from '../lib/api'
|
||||
|
||||
/// localStorage key for LogPreview's selected param columns. Namespaced
|
||||
/// under `ottyl.` since this is the app's first localStorage usage — kept
|
||||
/// distinct enough that future per-viewer preferences elsewhere in the app
|
||||
/// won't collide with it.
|
||||
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 — a private browsing mode 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])
|
||||
|
||||
export function LogPreview({ logs }: { logs: LogEntry[] }) {
|
||||
return (
|
||||
<div className="flex-1 overflow-auto">
|
||||
<table className="w-full border-collapse text-left text-xs">
|
||||
<thead className="sticky top-0 bg-neutral-950 text-neutral-500">
|
||||
<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>
|
||||
<th className="px-3 py-1 font-normal">Message</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{logs.map((log) => (
|
||||
<tr key={log.sequence} className="border-t border-neutral-900 hover:bg-neutral-900/60">
|
||||
<td className="whitespace-nowrap px-3 py-1 text-neutral-500">{log.sequence}</td>
|
||||
<td className="whitespace-nowrap px-3 py-1 text-neutral-500">
|
||||
{new Date(log.timestamp_ms).toLocaleTimeString()}
|
||||
</td>
|
||||
<td className="px-3 py-1 text-neutral-200">{log.text}</td>
|
||||
</tr>
|
||||
))}
|
||||
{logs.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={3} className="px-3 py-6 text-center text-neutral-600">
|
||||
No logs yet — pipe something into ottyl.
|
||||
</td>
|
||||
</tr>
|
||||
<div ref={ref} className="relative">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
className="rounded border border-neutral-700 px-2 py-1 text-xs text-neutral-300 hover:bg-neutral-800"
|
||||
>
|
||||
Columns{visible.size > 0 ? ` (${visible.size})` : ''}
|
||||
</button>
|
||||
{open && (
|
||||
<div className="absolute right-0 z-10 mt-1 w-56 rounded border border-neutral-700 bg-neutral-950 p-2 shadow-lg">
|
||||
{paramNames.length === 0 ? (
|
||||
<div className="px-1 py-1 text-xs text-neutral-500">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-neutral-200 hover:bg-neutral-900">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={visible.has(name)}
|
||||
onChange={() => onToggle(name)}
|
||||
className="accent-neutral-400"
|
||||
/>
|
||||
{name}
|
||||
</label>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function LogPreview({ logs }: { logs: LogEntry[] }) {
|
||||
const [paramNames, setParamNames] = useState<string[]>([])
|
||||
const [visibleParamColumns, setVisibleParamColumns] = useState<string[]>(loadVisibleParamColumns)
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
fetchParamDeclarations()
|
||||
.then((declarations) => {
|
||||
if (!cancelled) setParamNames(declarations.map((d) => d.name))
|
||||
})
|
||||
.catch(() => {
|
||||
// params API not reachable yet; column picker just stays empty until
|
||||
// the next successful fetch.
|
||||
})
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [])
|
||||
|
||||
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
|
||||
})
|
||||
}
|
||||
|
||||
// Only render columns for params that are both selected and still known to
|
||||
// exist, so a stale localStorage entry for a since-removed param doesn'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="flex justify-end border-b border-neutral-900 px-3 py-1">
|
||||
<ColumnPicker paramNames={paramNames} visible={visible} onToggle={toggleColumn} />
|
||||
</div>
|
||||
<div className="flex-1 overflow-auto">
|
||||
<table className="w-full border-collapse text-left text-xs">
|
||||
<thead className="sticky top-0 bg-neutral-950 text-neutral-500">
|
||||
<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} className="border-t border-neutral-900 hover:bg-neutral-900/60">
|
||||
<td className="whitespace-nowrap px-3 py-1 text-neutral-500">{log.sequence}</td>
|
||||
<td className="whitespace-nowrap px-3 py-1 text-neutral-500">
|
||||
{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-neutral-400">
|
||||
{value ? metricPrimitiveToDisplayString(value) : '—'}
|
||||
</td>
|
||||
)
|
||||
})}
|
||||
<td className="px-3 py-1 text-neutral-200">{log.text}</td>
|
||||
</tr>
|
||||
))}
|
||||
{logs.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={3 + columns.length} className="px-3 py-6 text-center text-neutral-600">
|
||||
No logs yet — pipe something into ottyl.
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user