Skip to content

Commit f1ed7fd

Browse files
committed
fix(ui): pretty-print and syntax-highlight JSON event payloads (#789)
LOG/hlog payloads are stored as JSON strings inside VARCHAR columns. The Events detail view now expands nested JSON (payload, data_extra) and shows Pretty/Raw tabs with colored syntax highlighting via stable CSS classes (json-hl-*) that work in dangerouslySetInnerHTML without relying on Tailwind content scan. MessageModal and OTLP log modals share the same jsonDisplay helpers.
1 parent 828ac1d commit f1ed7fd

7 files changed

Lines changed: 333 additions & 117 deletions

File tree

src/ui/src/App.css

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -146,6 +146,41 @@
146146
}
147147
}
148148

149+
/* JSON syntax highlighting (dangerouslySetInnerHTML) — plain CSS so Tailwind
150+
content scan does not need to see class names inside TS string literals. */
151+
.json-hl-key {
152+
color: oklch(0.52 0.16 245);
153+
}
154+
.json-hl-str {
155+
color: oklch(0.48 0.14 155);
156+
}
157+
.json-hl-num {
158+
color: oklch(0.58 0.16 75);
159+
}
160+
.json-hl-bool {
161+
color: oklch(0.52 0.17 295);
162+
}
163+
.json-hl-null,
164+
.json-hl-punct {
165+
color: oklch(0.48 0.015 255);
166+
}
167+
.dark .json-hl-key {
168+
color: oklch(0.74 0.14 245);
169+
}
170+
.dark .json-hl-str {
171+
color: oklch(0.72 0.14 155);
172+
}
173+
.dark .json-hl-num {
174+
color: oklch(0.80 0.14 75);
175+
}
176+
.dark .json-hl-bool {
177+
color: oklch(0.76 0.14 295);
178+
}
179+
.dark .json-hl-null,
180+
.dark .json-hl-punct {
181+
color: oklch(0.72 0.015 255);
182+
}
183+
149184
/* uPlot legend: small circular swatches (Grafana-style) instead of 1em squares */
150185
.uplot .u-legend .u-marker {
151186
width: 7px;

src/ui/src/dashboard/MessageModal.tsx

Lines changed: 9 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -7,51 +7,19 @@ import { FloatingWindow } from '@/components/ui/floating-window'
77
import { ScrollArea } from '@/components/ui/scroll-area'
88
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
99
import { displayDstIp, displaySrcIp } from '@/lib/ipAliasDisplay'
10+
import {
11+
escapeHtml,
12+
highlightJSON,
13+
isJsonDisplayable,
14+
payloadAsString,
15+
} from '@/lib/jsonDisplay'
1016
import { useLocale } from '@/components/locale/locale-provider'
1117

12-
function escapeHtml(str) {
13-
if (typeof str !== 'string') return str
14-
return str
15-
.replace(/&/g, '&')
16-
.replace(/</g, '&lt;')
17-
.replace(/>/g, '&gt;')
18-
.replace(/"/g, '&quot;')
19-
}
20-
21-
function isJSON(str) {
22-
if (!str) return false
23-
const trimmed = str.trim()
24-
return (trimmed.startsWith('{') && trimmed.endsWith('}')) ||
25-
(trimmed.startsWith('[') && trimmed.endsWith(']'))
26-
}
27-
2818
// Tailwind v4 scans source files for class strings — keeping literal classnames
2919
// here so the compiler picks them up:
3020
// text-sky-500 text-violet-500 text-amber-500 text-emerald-500
3121
// text-destructive text-muted-foreground font-semibold font-medium
3222

33-
function highlightJSON(payload) {
34-
try {
35-
const obj = JSON.parse(payload)
36-
const formatted = JSON.stringify(obj, null, 2)
37-
let html = escapeHtml(formatted)
38-
html = html.replace(/&quot;([^&]+)&quot;(\s*:)/g,
39-
'<span class="text-sky-500">&quot;$1&quot;</span><span class="text-muted-foreground">$2</span>')
40-
html = html.replace(/: &quot;([^&]*)&quot;/g,
41-
': <span class="text-emerald-500">&quot;$1&quot;</span>')
42-
html = html.replace(/: (-?\d+\.?\d*)/g,
43-
': <span class="text-amber-500">$1</span>')
44-
html = html.replace(/: (true|false)/g,
45-
': <span class="text-violet-500">$1</span>')
46-
html = html.replace(/: (null)/g,
47-
': <span class="text-muted-foreground">$1</span>')
48-
html = html.replace(/([{}\[\]])/g, '<span class="text-muted-foreground">$1</span>')
49-
return html
50-
} catch {
51-
return escapeHtml(payload)
52-
}
53-
}
54-
5523
function highlightSIP(payload) {
5624
if (!payload) return '(no payload)'
5725
const lines = payload.replace(/\r\n/g, '\n').replace(/\r/g, '\n').split('\n')
@@ -233,9 +201,9 @@ export default function MessageModal({ modal, onClose, timeZone }) {
233201
if (!modal) return null
234202

235203
const { uuid, loading, data, error, messageContext, modalKey } = modal
236-
const payload = data?.payload || ''
237-
const payloadIsJson = isJSON(payload)
238-
const payloadHtml = payloadIsJson ? highlightJSON(payload) : highlightSIP(payload)
204+
const payload = payloadAsString(data?.payload)
205+
const payloadIsJson = isJsonDisplayable(data?.payload ?? payload)
206+
const payloadHtml = payloadIsJson ? highlightJSON(data?.payload ?? payload) : highlightSIP(payload)
239207

240208
const handleDecode = async () => {
241209
if (!uuid || decoding) return

src/ui/src/dashboard/OTLPLogRowModal.tsx

Lines changed: 19 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -3,46 +3,14 @@ import { useMemo, useState } from 'react'
33
import { FloatingWindow } from '@/components/ui/floating-window'
44
import { ScrollArea } from '@/components/ui/scroll-area'
55
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
6+
import {
7+
highlightJSON,
8+
isJsonDisplayable,
9+
serializeRowForDisplay,
10+
} from '@/lib/jsonDisplay'
611
import { cn } from '@/lib/utils'
712
import { useLocale } from '@/components/locale/locale-provider'
813

9-
function escapeHtml(str) {
10-
if (typeof str !== 'string') return str
11-
return str
12-
.replace(/&/g, '&amp;')
13-
.replace(/</g, '&lt;')
14-
.replace(/>/g, '&gt;')
15-
.replace(/"/g, '&quot;')
16-
}
17-
18-
function isJSON(str) {
19-
if (!str) return false
20-
const trimmed = String(str).trim()
21-
return (trimmed.startsWith('{') && trimmed.endsWith('}')) || (trimmed.startsWith('[') && trimmed.endsWith(']'))
22-
}
23-
24-
function highlightJSON(payload) {
25-
try {
26-
const obj = JSON.parse(payload)
27-
const formatted = JSON.stringify(obj, null, 2)
28-
let html = escapeHtml(formatted)
29-
html = html.replace(/&quot;([^&]+)&quot;(\s*:)/g,
30-
'<span class="text-sky-500">&quot;$1&quot;</span><span class="text-muted-foreground">$2</span>')
31-
html = html.replace(/: &quot;([^&]*)&quot;/g,
32-
': <span class="text-emerald-500">&quot;$1&quot;</span>')
33-
html = html.replace(/: (-?\d+\.?\d*)/g,
34-
': <span class="text-amber-500">$1</span>')
35-
html = html.replace(/: (true|false)/g,
36-
': <span class="text-violet-500">$1</span>')
37-
html = html.replace(/: (null)/g,
38-
': <span class="text-muted-foreground">$1</span>')
39-
html = html.replace(/([{}\[\]])/g, '<span class="text-muted-foreground">$1</span>')
40-
return html
41-
} catch {
42-
return escapeHtml(payload)
43-
}
44-
}
45-
4614
function severityFromNumber(n) {
4715
if (n == null || Number.isNaN(n)) return ''
4816
const v = Number(n)
@@ -152,12 +120,7 @@ export default function OTLPLogRowModal({ modal, timeZone, onClose }) {
152120
else display = '—'
153121
entries.push({ key: k, display })
154122
}
155-
let text
156-
try {
157-
text = JSON.stringify(row, null, 2)
158-
} catch {
159-
text = String(row)
160-
}
123+
const text = serializeRowForDisplay(row)
161124
const body = row?.body ?? row?.BODY ?? ''
162125
return { detailEntries: entries, jsonText: text, bodyText: String(body ?? '') }
163126
}, [row, timeZone, locale])
@@ -170,7 +133,9 @@ export default function OTLPLogRowModal({ modal, timeZone, onClose }) {
170133

171134
const sev = resolvedSeverityLabel(row)
172135
const badgeCls = severityBadgeClass(sev)
173-
const prettyHtml = isJSON(jsonText) ? highlightJSON(jsonText) : escapeHtml(jsonText)
136+
const prettyHtml = highlightJSON(jsonText)
137+
const bodyIsJson = isJsonDisplayable(bodyText)
138+
const bodyPrettyHtml = bodyIsJson ? highlightJSON(bodyText) : ''
174139

175140
return (
176141
<FloatingWindow
@@ -208,9 +173,16 @@ export default function OTLPLogRowModal({ modal, timeZone, onClose }) {
208173
<div>
209174
<span className="text-[10px] font-semibold uppercase tracking-wider text-muted-foreground">Body</span>
210175
<ScrollArea className="mt-1 max-h-[140px] rounded-sm border border-border bg-muted/20">
211-
<pre className="whitespace-pre-wrap break-words p-2 font-mono text-[11px] text-foreground">
212-
{bodyText}
213-
</pre>
176+
{bodyIsJson ? (
177+
<pre
178+
className="whitespace-pre p-2 font-mono text-[11px] leading-relaxed"
179+
dangerouslySetInnerHTML={{ __html: bodyPrettyHtml }}
180+
/>
181+
) : (
182+
<pre className="whitespace-pre-wrap break-words p-2 font-mono text-[11px] text-foreground">
183+
{bodyText}
184+
</pre>
185+
)}
214186
</ScrollArea>
215187
</div>
216188
) : null}

src/ui/src/dashboard/TransactionModal.tsx

Lines changed: 95 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,13 @@ import {
2222
} from '@/components/ui/table'
2323
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
2424
import { displayDstIp, displaySrcIp } from '@/lib/ipAliasDisplay'
25+
import {
26+
eventPayloadField,
27+
formatJsonField,
28+
highlightJSON,
29+
isJsonDisplayable,
30+
serializeRowForDisplay,
31+
} from '@/lib/jsonDisplay'
2532
import { cn } from '@/lib/utils'
2633
import { newModalKey } from '@/lib/modalKey'
2734
import { useLocale } from '@/components/locale/locale-provider'
@@ -291,6 +298,11 @@ function formatEventsCell(value, col, timeZone, locale) {
291298
const s = formatDateTime(value, locale, timeZone)
292299
return s || '—'
293300
}
301+
const payloadLike = col.keys?.some((k) => k === 'payload' || k === 'message' || k === 'data' || k === 'body')
302+
if (payloadLike && isJsonDisplayable(value)) {
303+
const pretty = formatJsonField(value).replace(/\s+/g, ' ')
304+
return pretty.length > 200 ? `${pretty.slice(0, 197)}…` : pretty
305+
}
294306
if (typeof value === 'object') {
295307
try {
296308
const j = JSON.stringify(value)
@@ -303,16 +315,85 @@ function formatEventsCell(value, col, timeZone, locale) {
303315
return s.length > 200 ? `${s.slice(0, 197)}…` : s
304316
}
305317

306-
function serializeRowJSONForWindow(row) {
307-
try {
308-
return JSON.stringify(
309-
row,
310-
(_k, v) => (typeof v === 'bigint' ? v.toString() : v),
311-
2,
312-
)
313-
} catch (e) {
314-
return `Error serializing row: ${e?.message || e}`
315-
}
318+
function EventRecordDetail({ row }) {
319+
const [recordTab, setRecordTab] = React.useState('pretty')
320+
const [payloadTab, setPayloadTab] = React.useState('pretty')
321+
const payloadVal = eventPayloadField(row)
322+
const payloadIsJson = isJsonDisplayable(payloadVal)
323+
const recordText = serializeRowForDisplay(row)
324+
const recordPrettyHtml = highlightJSON(recordText)
325+
const payloadPrettyHtml = highlightJSON(payloadVal)
326+
327+
return (
328+
<div className="flex min-h-0 flex-1 flex-col gap-3 overflow-hidden">
329+
{payloadIsJson ? (
330+
<div className="shrink-0 space-y-1">
331+
<span className="text-[10px] font-semibold uppercase tracking-wider text-muted-foreground">
332+
Payload
333+
</span>
334+
<Tabs value={payloadTab} onValueChange={setPayloadTab} className="flex flex-col gap-2">
335+
<TabsList variant="line" className="h-8 w-fit justify-start">
336+
<TabsTrigger value="pretty" className="text-[11px]">
337+
Pretty
338+
</TabsTrigger>
339+
<TabsTrigger value="raw" className="text-[11px]">
340+
Raw
341+
</TabsTrigger>
342+
</TabsList>
343+
<TabsContent value="pretty" className="mt-0">
344+
<ScrollArea className="max-h-[220px] rounded-md border border-border bg-muted/40">
345+
<pre
346+
className="whitespace-pre p-2 font-mono text-[11px] leading-relaxed"
347+
dangerouslySetInnerHTML={{ __html: payloadPrettyHtml || '(no payload)' }}
348+
/>
349+
</ScrollArea>
350+
</TabsContent>
351+
<TabsContent value="raw" className="mt-0">
352+
<ScrollArea className="max-h-[220px] rounded-md border border-border bg-muted/40">
353+
<pre className="whitespace-pre-wrap break-all p-2 font-mono text-[11px] leading-relaxed text-foreground">
354+
{formatJsonField(payloadVal)}
355+
</pre>
356+
</ScrollArea>
357+
</TabsContent>
358+
</Tabs>
359+
</div>
360+
) : null}
361+
<div className="flex min-h-0 flex-1 flex-col gap-1 overflow-hidden">
362+
<span className="shrink-0 text-[10px] font-semibold uppercase tracking-wider text-muted-foreground">
363+
Full record
364+
</span>
365+
<Tabs
366+
value={recordTab}
367+
onValueChange={setRecordTab}
368+
className="flex min-h-0 flex-1 flex-col overflow-hidden"
369+
>
370+
<TabsList variant="line" className="h-8 w-fit shrink-0 justify-start">
371+
<TabsTrigger value="pretty" className="text-[11px]">
372+
Pretty
373+
</TabsTrigger>
374+
<TabsTrigger value="raw" className="text-[11px]">
375+
Raw
376+
</TabsTrigger>
377+
</TabsList>
378+
<TabsContent value="pretty" className="mt-0 min-h-0 flex-1 overflow-hidden">
379+
<ScrollArea className="min-h-0 flex-1 rounded-md border border-border bg-muted/40">
380+
<pre
381+
className="whitespace-pre p-2 font-mono text-[11px] leading-relaxed"
382+
dangerouslySetInnerHTML={{ __html: recordPrettyHtml || '(empty)' }}
383+
/>
384+
</ScrollArea>
385+
</TabsContent>
386+
<TabsContent value="raw" className="mt-0 min-h-0 flex-1 overflow-hidden">
387+
<ScrollArea className="min-h-0 flex-1 rounded-md border border-border bg-muted/40">
388+
<pre className="whitespace-pre-wrap break-words p-2 font-mono text-[11px] leading-relaxed text-foreground">
389+
{recordText}
390+
</pre>
391+
</ScrollArea>
392+
</TabsContent>
393+
</Tabs>
394+
</div>
395+
</div>
396+
)
316397
}
317398

318399
function EventsTab({ data, isLoading, err, emptyLabel, timeZone, locale }) {
@@ -408,15 +489,8 @@ function EventsTab({ data, isLoading, err, emptyLabel, timeZone, locale }) {
408489
minHeight={280}
409490
className="flex min-h-0 flex-col"
410491
>
411-
<div className="flex min-h-0 flex-1 flex-col gap-2 overflow-hidden px-3 pb-3 pt-0">
412-
<p className="shrink-0 text-[10px] text-muted-foreground">
413-
Full record including data_extra (JSON).
414-
</p>
415-
<ScrollArea className="min-h-0 flex-1 rounded-md border border-border bg-muted/40">
416-
<pre className="whitespace-pre-wrap break-words p-3 font-mono text-[11px] leading-relaxed text-foreground">
417-
{serializeRowJSONForWindow(detailRow)}
418-
</pre>
419-
</ScrollArea>
492+
<div className="flex min-h-0 flex-1 flex-col overflow-hidden px-3 pb-3 pt-0">
493+
<EventRecordDetail row={detailRow} />
420494
</div>
421495
</FloatingWindow>
422496
) : null}
@@ -539,15 +613,8 @@ function OtlpLogsTab({
539613
minHeight={320}
540614
className="flex min-h-0 flex-col"
541615
>
542-
<div className="flex min-h-0 flex-1 flex-col gap-2 overflow-hidden px-3 pb-3 pt-0">
543-
<p className="shrink-0 text-[10px] text-muted-foreground">
544-
Full record (JSON), including fields not shown in the table.
545-
</p>
546-
<ScrollArea className="min-h-0 flex-1 rounded-md border border-border bg-muted/40">
547-
<pre className="whitespace-pre-wrap break-words p-3 font-mono text-[11px] leading-relaxed text-foreground">
548-
{serializeRowJSONForWindow(detailRow)}
549-
</pre>
550-
</ScrollArea>
616+
<div className="flex min-h-0 flex-1 flex-col overflow-hidden px-3 pb-3 pt-0">
617+
<EventRecordDetail row={detailRow} />
551618
</div>
552619
</FloatingWindow>
553620
) : null}

0 commit comments

Comments
 (0)