Merge ticket/014-json-log-structured-display

This commit is contained in:
Hugh Kaznowski
2026-09-05 19:39:04 +01:00
2 changed files with 90 additions and 11 deletions
@@ -1,10 +0,0 @@
# Render JSON log lines as structured key/value trees
Follow-up to the JSON log ingestion design decision (repowise decision fdb47034ad954e3ea0b898fdc2750c32).
When an ingested log line's raw text parses as a JSON object, display it in the log viewer as a collapsible key/value tree instead of a raw string. Lines that aren't valid JSON objects keep rendering as plain text, unchanged.
## Acceptance criteria
- LogPreview.tsx detects a JSON-object log line (starts with `{` and parses) and renders it as a collapsible key/value tree.
- Non-JSON (or invalid-JSON) lines are unaffected — same raw-text rendering as today.
- Detection/parsing happens client-side against the existing raw `text` field; no backend API change required for this ticket.
+90 -1
View File
@@ -82,6 +82,93 @@ function ColumnPicker({
)
}
/// 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.
@@ -181,7 +268,9 @@ export function LogPreview({ logs, params }: { logs: LogEntry[]; params: ParamDe
</td>
)
})}
<td className="px-3 py-1 text-text">{log.text}</td>
<td className="px-3 py-1 text-text">
<LogMessage text={log.text} />
</td>
</tr>
))}
{logs.length === 0 && (