Skip to content

Commit 0ad5977

Browse files
authored
Merge pull request #794 from sipcapture/fix/789-event-detail-duplicate-payload
fix(ui): stop duplicating JSON payload in event detail view (#789)
2 parents 2dacaaa + ec15b3a commit 0ad5977

4 files changed

Lines changed: 99 additions & 20 deletions

File tree

src/ui/src/dashboard/TransactionModal.tsx

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -24,9 +24,11 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
2424
import { displayDstIp, displaySrcIp } from '@/lib/ipAliasDisplay'
2525
import {
2626
eventPayloadField,
27+
eventPayloadFieldKey,
2728
formatJsonField,
2829
highlightJSON,
2930
isJsonDisplayable,
31+
rowWithoutEventPayload,
3032
serializeRowForDisplay,
3133
} from '@/lib/jsonDisplay'
3234
import { cn } from '@/lib/utils'
@@ -318,18 +320,23 @@ function formatEventsCell(value, col, timeZone, locale) {
318320
function EventRecordDetail({ row }) {
319321
const [recordTab, setRecordTab] = React.useState('pretty')
320322
const [payloadTab, setPayloadTab] = React.useState('pretty')
323+
const payloadKey = eventPayloadFieldKey(row)
321324
const payloadVal = eventPayloadField(row)
322325
const payloadIsJson = isJsonDisplayable(payloadVal)
323-
const recordText = serializeRowForDisplay(row)
324-
const recordPrettyHtml = highlightJSON(recordText)
326+
// When the payload is shown in its own panel, keep uuid/timestamp/src_ip/etc.
327+
// in the lower panel only so the same JSON blob is not rendered twice.
328+
const metaRow = payloadIsJson && payloadKey ? rowWithoutEventPayload(row) : row
329+
const recordText = serializeRowForDisplay(metaRow)
330+
const recordPrettyHtml = highlightJSON(metaRow)
325331
const payloadPrettyHtml = highlightJSON(payloadVal)
332+
const payloadLabel = payloadKey ? payloadKey.replace(/_/g, ' ') : 'payload'
326333

327334
return (
328335
<div className="flex min-h-0 flex-1 flex-col gap-3 overflow-hidden">
329336
{payloadIsJson ? (
330337
<div className="shrink-0 space-y-1">
331338
<span className="text-[10px] font-semibold uppercase tracking-wider text-muted-foreground">
332-
Payload
339+
{payloadLabel}
333340
</span>
334341
<Tabs value={payloadTab} onValueChange={setPayloadTab} className="flex flex-col gap-2">
335342
<TabsList variant="line" className="h-8 w-fit justify-start">
@@ -360,7 +367,7 @@ function EventRecordDetail({ row }) {
360367
) : null}
361368
<div className="flex min-h-0 flex-1 flex-col gap-1 overflow-hidden">
362369
<span className="shrink-0 text-[10px] font-semibold uppercase tracking-wider text-muted-foreground">
363-
Full record
370+
{payloadIsJson ? 'Other fields' : 'Full record'}
364371
</span>
365372
<Tabs
366373
value={recordTab}

src/ui/src/lib/jsonDisplay.test.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@ import {
33
formatJsonField,
44
highlightJSON,
55
isJsonDisplayable,
6+
parseJsonDeep,
7+
rowWithoutEventPayload,
68
serializeRowForDisplay,
79
} from './jsonDisplay'
810

@@ -44,4 +46,19 @@ describe('jsonDisplay', () => {
4446
expect(html).toContain('json-hl-bool')
4547
expect(html).toContain('json-hl-null')
4648
})
49+
50+
it('parses double-encoded JSON strings from hlog()', () => {
51+
const inner = '{"level":"INFO","msg":"hello"}'
52+
const wrapped = JSON.stringify(inner)
53+
expect(parseJsonDeep(wrapped)).toEqual({ level: 'INFO', msg: 'hello' })
54+
expect(isJsonDisplayable(wrapped)).toBe(true)
55+
expect(formatJsonField(wrapped)).toContain('"level": "INFO"')
56+
})
57+
58+
it('rowWithoutEventPayload omits only the populated payload column', () => {
59+
const row = { uuid: '1', payload: '{"x":1}', session_id: 'a@b' }
60+
const meta = rowWithoutEventPayload(row)
61+
expect(meta).toEqual({ uuid: '1', session_id: 'a@b' })
62+
expect(meta.payload).toBeUndefined()
63+
})
4764
})

src/ui/src/lib/jsonDisplay.ts

Lines changed: 70 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -22,27 +22,47 @@ export function escapeHtml(str: string): string {
2222

2323
export function looksLikeJsonString(str: string): boolean {
2424
const t = str.trim()
25-
return (t.startsWith('{') && t.endsWith('}')) || (t.startsWith('[') && t.endsWith(']'))
25+
return (
26+
(t.startsWith('{') && t.endsWith('}')) ||
27+
(t.startsWith('[') && t.endsWith(']')) ||
28+
(t.startsWith('"') && t.endsWith('"'))
29+
)
30+
}
31+
32+
/**
33+
* Parse JSON that may be stored as a string, including double-encoded hlog()
34+
* payloads (`"{\"cause\":...}"`). Returns the inner object/array, or null when
35+
* the input is not structured JSON.
36+
*/
37+
export function parseJsonDeep(input: unknown, maxDepth = 3): unknown {
38+
let v: unknown = input
39+
for (let i = 0; i <= maxDepth; i++) {
40+
if (v !== null && typeof v === 'object') return v
41+
if (typeof v !== 'string') return null
42+
const t = v.trim()
43+
if (!looksLikeJsonString(t)) return null
44+
try {
45+
v = JSON.parse(t)
46+
} catch {
47+
return null
48+
}
49+
}
50+
return null
2651
}
2752

2853
/** Parse a JSON string; return the original value when it is not JSON text. */
2954
export function parseJsonLoose(val: unknown): unknown {
3055
if (val == null) return val
3156
if (typeof val === 'object') return val
3257
if (typeof val !== 'string') return val
33-
const t = val.trim()
34-
if (!looksLikeJsonString(t)) return val
35-
try {
36-
return JSON.parse(t)
37-
} catch {
38-
return val
39-
}
58+
const deep = parseJsonDeep(val)
59+
return deep ?? val
4060
}
4161

4262
export function isJsonDisplayable(val: unknown): boolean {
4363
if (val == null || val === '') return false
4464
if (typeof val === 'object') return true
45-
if (typeof val === 'string') return looksLikeJsonString(val)
65+
if (typeof val === 'string') return parseJsonDeep(val) !== null
4666
return false
4767
}
4868

@@ -92,10 +112,23 @@ export function serializeRowForDisplay(row: Record<string, unknown>): string {
92112
}
93113
}
94114

115+
function formatJsonForHighlight(payload: unknown): string {
116+
if (payload !== null && typeof payload === 'object' && !Array.isArray(payload)) {
117+
return JSON.stringify(payload, expandEmbeddedJsonReplacer, 2)
118+
}
119+
const parsed = parseJsonLoose(payload)
120+
if (parsed !== payload) {
121+
return JSON.stringify(parsed, null, 2)
122+
}
123+
if (typeof payload === 'string') {
124+
return payload
125+
}
126+
return JSON.stringify(parsed, null, 2)
127+
}
128+
95129
export function highlightJSON(payload: unknown): string {
96130
try {
97-
const obj = parseJsonLoose(payload)
98-
const formatted = JSON.stringify(obj, null, 2)
131+
const formatted = formatJsonForHighlight(payload)
99132
let html = escapeHtml(formatted)
100133
// Value highlighting before keys — the key pass wraps colons and breaks later ": …" patterns.
101134
html = html.replace(
@@ -116,12 +149,34 @@ export function highlightJSON(payload: unknown): string {
116149
}
117150
}
118151

152+
/** Primary payload column names on LOG / event rows (first match wins). */
153+
export const EVENT_PAYLOAD_FIELD_KEYS = ['payload', 'message', 'data', 'body'] as const
154+
119155
/** First non-empty payload-like field on a LOG / event row. */
120156
export function eventPayloadField(row: Record<string, unknown> | null | undefined): unknown {
121-
if (!row || typeof row !== 'object') return ''
122-
for (const k of ['payload', 'message', 'data', 'body']) {
157+
const key = eventPayloadFieldKey(row)
158+
return key ? row[key] : ''
159+
}
160+
161+
/** Which payload column is populated on this row, if any. */
162+
export function eventPayloadFieldKey(
163+
row: Record<string, unknown> | null | undefined,
164+
): (typeof EVENT_PAYLOAD_FIELD_KEYS)[number] | null {
165+
if (!row || typeof row !== 'object') return null
166+
for (const k of EVENT_PAYLOAD_FIELD_KEYS) {
123167
const v = row[k]
124-
if (v != null && v !== '') return v
168+
if (v != null && v !== '') return k
125169
}
126-
return ''
170+
return null
171+
}
172+
173+
/** Row copy without the primary payload column (for metadata-only panels). */
174+
export function rowWithoutEventPayload(
175+
row: Record<string, unknown>,
176+
): Record<string, unknown> {
177+
const key = eventPayloadFieldKey(row)
178+
if (!key) return row
179+
const copy = { ...row }
180+
delete copy[key]
181+
return copy
127182
}

src/version.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ import (
2424
// Version information for homer-core
2525
var (
2626
// VERSION_APPLICATION is the application version
27-
VERSION_APPLICATION = "11.0.244"
27+
VERSION_APPLICATION = "11.0.245"
2828

2929
// BuildDate is the build date
3030
BuildDate = ""

0 commit comments

Comments
 (0)