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
|
/// 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 —
|
/// 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.
|
/// 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>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
<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>
|
</tr>
|
||||||
))}
|
))}
|
||||||
{logs.length === 0 && (
|
{logs.length === 0 && (
|
||||||
|
|||||||
Reference in New Issue
Block a user