From cb101aa5da3c73a4b2586284b1bf8e9edc9b08fd Mon Sep 17 00:00:00 2001 From: Hugh Kaznowski Date: Sat, 5 Sep 2026 19:31:19 +0100 Subject: [PATCH] Render JSON log lines as collapsible key/value trees (ticket 014) LogPreview.tsx now detects when a log line's raw text parses as a JSON object and renders it as a collapsible tree instead of raw text; any other line (non-JSON or invalid JSON) keeps today's plain-text rendering unchanged. Client-side only, no backend changes. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01XFUNZwCvz3kcod3352ZR2o --- tickets/014-json-log-structured-display.md | 10 --- webapp/src/components/LogPreview.tsx | 91 +++++++++++++++++++++- 2 files changed, 90 insertions(+), 11 deletions(-) delete mode 100644 tickets/014-json-log-structured-display.md diff --git a/tickets/014-json-log-structured-display.md b/tickets/014-json-log-structured-display.md deleted file mode 100644 index c0dde72..0000000 --- a/tickets/014-json-log-structured-display.md +++ /dev/null @@ -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. diff --git a/webapp/src/components/LogPreview.tsx b/webapp/src/components/LogPreview.tsx index 61506b8..42e6049 100644 --- a/webapp/src/components/LogPreview.tsx +++ b/webapp/src/components/LogPreview.tsx @@ -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 | 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. @@ -181,7 +268,9 @@ export function LogPreview({ logs, params }: { logs: LogEntry[]; params: ParamDe ) })} - {log.text} + + + ))} {logs.length === 0 && (