Merge ticket/014-json-log-structured-display
This commit is contained in:
@@ -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.
|
||||
@@ -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 && (
|
||||
|
||||
Reference in New Issue
Block a user