From ce43db243b9896e9aae1d7658ae2130fd53f6a39 Mon Sep 17 00:00:00 2001 From: RemiBonnet Date: Thu, 24 Sep 2026 16:25:14 +0200 Subject: [PATCH 1/4] feat(service-logs): format JSON object messages --- .../format-object-log-message.spec.ts | 28 +++++++++++++++ .../format-object-log-message.ts | 13 +++++++ .../row-service-logs.spec.tsx | 36 +++++++++++++++++++ .../row-service-logs/row-service-logs.tsx | 4 ++- 4 files changed, 80 insertions(+), 1 deletion(-) create mode 100644 libs/domains/service-logs/feature/src/lib/list-service-logs/row-service-logs/format-object-log-message.spec.ts create mode 100644 libs/domains/service-logs/feature/src/lib/list-service-logs/row-service-logs/format-object-log-message.ts diff --git a/libs/domains/service-logs/feature/src/lib/list-service-logs/row-service-logs/format-object-log-message.spec.ts b/libs/domains/service-logs/feature/src/lib/list-service-logs/row-service-logs/format-object-log-message.spec.ts new file mode 100644 index 00000000000..8ffc7ec94ef --- /dev/null +++ b/libs/domains/service-logs/feature/src/lib/list-service-logs/row-service-logs/format-object-log-message.spec.ts @@ -0,0 +1,28 @@ +import { formatObjectLogMessage } from './format-object-log-message' + +describe('formatObjectLogMessage', () => { + it('formats a JSON object with two-space indentation', () => { + expect(formatObjectLogMessage('{"message":"Started","context":{"attempt":2},"ports":[80,443]}')).toBe(`{ + "message": "Started", + "context": { + "attempt": 2 + }, + "ports": [ + 80, + 443 + ] +}`) + }) + + it.each([ + ['invalid JSON', '{"message":"Started"'], + ['an array', '["Started"]'], + ['a string', '"Started"'], + ['a number', '42'], + ['a boolean', 'true'], + ['null', 'null'], + ['plain text', 'Started successfully'], + ])('keeps %s unchanged', (_description, message) => { + expect(formatObjectLogMessage(message)).toBe(message) + }) +}) diff --git a/libs/domains/service-logs/feature/src/lib/list-service-logs/row-service-logs/format-object-log-message.ts b/libs/domains/service-logs/feature/src/lib/list-service-logs/row-service-logs/format-object-log-message.ts new file mode 100644 index 00000000000..b628d853fe6 --- /dev/null +++ b/libs/domains/service-logs/feature/src/lib/list-service-logs/row-service-logs/format-object-log-message.ts @@ -0,0 +1,13 @@ +export function formatObjectLogMessage(message: string): string { + try { + const parsedMessage: unknown = JSON.parse(message) + + if (typeof parsedMessage !== 'object' || parsedMessage === null || Array.isArray(parsedMessage)) { + return message + } + + return JSON.stringify(parsedMessage, null, 2) + } catch { + return message + } +} diff --git a/libs/domains/service-logs/feature/src/lib/list-service-logs/row-service-logs/row-service-logs.spec.tsx b/libs/domains/service-logs/feature/src/lib/list-service-logs/row-service-logs/row-service-logs.spec.tsx index 32d8327fde6..fd756f7d505 100644 --- a/libs/domains/service-logs/feature/src/lib/list-service-logs/row-service-logs/row-service-logs.spec.tsx +++ b/libs/domains/service-logs/feature/src/lib/list-service-logs/row-service-logs/row-service-logs.spec.tsx @@ -67,6 +67,42 @@ describe('RowServiceLogs', () => { expect(screen.getByText('Test log message')).toBeInTheDocument() }) + it('renders JSON object messages with two-space indentation', () => { + const message = '{"message":"Started","context":{"attempt":2},"ports":[80,443]}' + const formattedMessage = `{ + "message": "Started", + "context": { + "attempt": 2 + }, + "ports": [ + 80, + 443 + ] +}` + + const { container } = renderRowServiceLogs({ ...mockLog, message }) + + expect(container.querySelector('[data-log-message="true"]')?.textContent).toBe(formattedMessage) + }) + + it('highlights search text in a formatted JSON object message', () => { + renderRowServiceLogs({ ...mockLog, message: '{"message":"Started"}' }, false, 'Started') + + expect(screen.getByText('Started').closest('mark')).toBeInTheDocument() + expect(screen.getByText(/"message":/).closest('[data-log-message="true"]')).toHaveTextContent( + '{ "message": "Started" }' + ) + }) + + it.each(['["Started"]', '"Started"', '42', 'true', 'null', '{"message":"Started"', 'Started successfully'])( + 'keeps non-object message %s unchanged', + (message) => { + const { container } = renderRowServiceLogs({ ...mockLog, message }) + + expect(container.querySelector('[data-log-message="true"]')?.textContent).toBe(message) + } + ) + it('toggles expanded state on click', async () => { const { userEvent } = renderRowServiceLogs() diff --git a/libs/domains/service-logs/feature/src/lib/list-service-logs/row-service-logs/row-service-logs.tsx b/libs/domains/service-logs/feature/src/lib/list-service-logs/row-service-logs/row-service-logs.tsx index da90d07cff6..dd3d7b99719 100644 --- a/libs/domains/service-logs/feature/src/lib/list-service-logs/row-service-logs/row-service-logs.tsx +++ b/libs/domains/service-logs/feature/src/lib/list-service-logs/row-service-logs/row-service-logs.tsx @@ -25,6 +25,7 @@ import { } from '@qovery/shared/util-js' import { mergeServiceLogsParams } from '../../search-service-logs/search-service-logs-utils' import { useServiceLogsContext } from '../service-logs-context/service-logs-context' +import { formatObjectLogMessage } from './format-object-log-message' import './style.scss' const timeZone = Intl.DateTimeFormat().resolvedOptions().timeZone @@ -173,6 +174,7 @@ export function RowServiceLogs({ log, hasMultipleContainers, highlightedText, se const levelLowercase = log.level?.toLowerCase() const isErrorOrCritical = levelLowercase === 'error' || levelLowercase === 'critical' + const message = formatObjectLogMessage(log.message) return ( <> @@ -264,7 +266,7 @@ export function RowServiceLogs({ log, hasMultipleContainers, highlightedText, se )} - {renderHighlightedMessage(log.message, highlightedText)} + {renderHighlightedMessage(message, highlightedText)} {isExpanded && ( From 3ba648e57628aea3ef35d649777d582d88664b64 Mon Sep 17 00:00:00 2001 From: RemiBonnet Date: Thu, 24 Sep 2026 16:49:05 +0200 Subject: [PATCH 2/4] fix(service-logs): preserve structured log content --- .../format-object-log-message.spec.ts | 40 ++++- .../format-object-log-message.ts | 137 +++++++++++++++++- .../row-service-logs.spec.tsx | 6 + .../row-service-logs/row-service-logs.tsx | 92 ++++++++---- 4 files changed, 239 insertions(+), 36 deletions(-) diff --git a/libs/domains/service-logs/feature/src/lib/list-service-logs/row-service-logs/format-object-log-message.spec.ts b/libs/domains/service-logs/feature/src/lib/list-service-logs/row-service-logs/format-object-log-message.spec.ts index 8ffc7ec94ef..1e7e7282780 100644 --- a/libs/domains/service-logs/feature/src/lib/list-service-logs/row-service-logs/format-object-log-message.spec.ts +++ b/libs/domains/service-logs/feature/src/lib/list-service-logs/row-service-logs/format-object-log-message.spec.ts @@ -1,8 +1,8 @@ -import { formatObjectLogMessage } from './format-object-log-message' +import { findHighlightRanges, formatObjectLogMessage } from './format-object-log-message' describe('formatObjectLogMessage', () => { it('formats a JSON object with two-space indentation', () => { - expect(formatObjectLogMessage('{"message":"Started","context":{"attempt":2},"ports":[80,443]}')).toBe(`{ + expect(formatObjectLogMessage('{"message":"Started","context":{"attempt":2},"ports":[80,443]}').message).toBe(`{ "message": "Started", "context": { "attempt": 2 @@ -14,6 +14,23 @@ describe('formatObjectLogMessage', () => { }`) }) + it('preserves numeric literals and duplicate keys', () => { + expect(formatObjectLogMessage('{"id":9007199254740993,"decimal":42.0,"exponent":1e3,"key":1,"key":2}').message) + .toBe(`{ + "id": 9007199254740993, + "decimal": 42.0, + "exponent": 1e3, + "key": 1, + "key": 2 +}`) + }) + + it('preserves whitespace and escaped characters inside strings', () => { + expect(formatObjectLogMessage('{"message":"keep spaces, braces { } and \\"quotes\\""}').message).toBe(`{ + "message": "keep spaces, braces { } and \\"quotes\\"" +}`) + }) + it.each([ ['invalid JSON', '{"message":"Started"'], ['an array', '["Started"]'], @@ -23,6 +40,23 @@ describe('formatObjectLogMessage', () => { ['null', 'null'], ['plain text', 'Started successfully'], ])('keeps %s unchanged', (_description, message) => { - expect(formatObjectLogMessage(message)).toBe(message) + expect(formatObjectLogMessage(message)).toEqual({ message }) + }) + + it('maps a raw compact match to its formatted range', () => { + const rawMessage = '{"message":"Started","attempt":2}' + const formattedLogMessage = formatObjectLogMessage(rawMessage) + const ranges = findHighlightRanges(rawMessage, formattedLogMessage, '"message":"Started"') + + expect(ranges).toEqual([{ start: 4, end: 24 }]) + expect(formattedLogMessage.message.slice(ranges[0]?.start, ranges[0]?.end)).toBe('"message": "Started"') + }) + + it('finds case-insensitive matches in plain-text messages', () => { + const rawMessage = 'Started successfully' + + expect(findHighlightRanges(rawMessage, formatObjectLogMessage(rawMessage), 'started')).toEqual([ + { start: 0, end: 7 }, + ]) }) }) diff --git a/libs/domains/service-logs/feature/src/lib/list-service-logs/row-service-logs/format-object-log-message.ts b/libs/domains/service-logs/feature/src/lib/list-service-logs/row-service-logs/format-object-log-message.ts index b628d853fe6..a4af4ca2697 100644 --- a/libs/domains/service-logs/feature/src/lib/list-service-logs/row-service-logs/format-object-log-message.ts +++ b/libs/domains/service-logs/feature/src/lib/list-service-logs/row-service-logs/format-object-log-message.ts @@ -1,13 +1,140 @@ -export function formatObjectLogMessage(message: string): string { +export interface FormattedLogMessage { + message: string + sourceMap?: number[] +} + +export interface HighlightRange { + start: number + end: number +} + +function nextNonWhitespaceCharacter(message: string, start: number) { + for (let index = start; index < message.length; index++) { + if (!/\s/.test(message[index] ?? '')) return message[index] + } + + return undefined +} + +function previousNonWhitespaceCharacter(message: string, start: number) { + for (let index = start; index >= 0; index--) { + if (!/\s/.test(message[index] ?? '')) return message[index] + } + + return undefined +} + +export function formatObjectLogMessage(message: string): FormattedLogMessage { try { const parsedMessage: unknown = JSON.parse(message) if (typeof parsedMessage !== 'object' || parsedMessage === null || Array.isArray(parsedMessage)) { - return message + return { message } } - - return JSON.stringify(parsedMessage, null, 2) } catch { - return message + return { message } + } + + let formattedMessage = '' + let indentation = 0 + let isInsideString = false + let isEscaped = false + const sourceMap = Array.from({ length: message.length }).fill(-1) + + const appendSourceCharacter = (character: string, sourceIndex: number) => { + sourceMap[sourceIndex] = formattedMessage.length + formattedMessage += character + } + + const appendNewLine = () => { + formattedMessage += `\n${' '.repeat(indentation)}` } + + for (let index = 0; index < message.length; index++) { + const character = message[index] ?? '' + + if (isInsideString) { + appendSourceCharacter(character, index) + + if (isEscaped) { + isEscaped = false + } else if (character === '\\') { + isEscaped = true + } else if (character === '"') { + isInsideString = false + } + + continue + } + + if (/\s/.test(character)) continue + + if (character === '"') { + isInsideString = true + appendSourceCharacter(character, index) + continue + } + + if (character === '{' || character === '[') { + appendSourceCharacter(character, index) + + const closingCharacter = character === '{' ? '}' : ']' + if (nextNonWhitespaceCharacter(message, index + 1) !== closingCharacter) { + indentation++ + appendNewLine() + } + continue + } + + if (character === '}' || character === ']') { + const openingCharacter = character === '}' ? '{' : '[' + if (previousNonWhitespaceCharacter(message, index - 1) !== openingCharacter) { + indentation-- + appendNewLine() + } + appendSourceCharacter(character, index) + continue + } + + appendSourceCharacter(character, index) + + if (character === ',') { + appendNewLine() + } else if (character === ':') { + formattedMessage += ' ' + } + } + + return { message: formattedMessage, sourceMap } +} + +export function findHighlightRanges( + rawMessage: string, + { message, sourceMap }: FormattedLogMessage, + searchTerm: string | null | undefined +): HighlightRange[] { + if (!searchTerm) return [] + + const ranges: HighlightRange[] = [] + const normalizedSearchTerm = searchTerm.toLowerCase() + const searchableMessage = sourceMap ? rawMessage : message + const normalizedMessage = searchableMessage.toLowerCase() + let matchIndex = normalizedMessage.indexOf(normalizedSearchTerm) + + while (matchIndex >= 0) { + if (!sourceMap) { + ranges.push({ start: matchIndex, end: matchIndex + searchTerm.length }) + } else { + const matchEnd = matchIndex + searchTerm.length + const mappedOffsets = sourceMap.slice(matchIndex, matchEnd).filter((offset) => offset >= 0) + + if (mappedOffsets.length > 0) { + ranges.push({ start: mappedOffsets[0] ?? 0, end: (mappedOffsets[mappedOffsets.length - 1] ?? 0) + 1 }) + } + } + + matchIndex = normalizedMessage.indexOf(normalizedSearchTerm, matchIndex + searchTerm.length) + } + + return ranges } diff --git a/libs/domains/service-logs/feature/src/lib/list-service-logs/row-service-logs/row-service-logs.spec.tsx b/libs/domains/service-logs/feature/src/lib/list-service-logs/row-service-logs/row-service-logs.spec.tsx index fd756f7d505..2217f34fee6 100644 --- a/libs/domains/service-logs/feature/src/lib/list-service-logs/row-service-logs/row-service-logs.spec.tsx +++ b/libs/domains/service-logs/feature/src/lib/list-service-logs/row-service-logs/row-service-logs.spec.tsx @@ -94,6 +94,12 @@ describe('RowServiceLogs', () => { ) }) + it('highlights a compact raw JSON match after formatting', () => { + renderRowServiceLogs({ ...mockLog, message: '{"message":"Started","attempt":2}' }, false, '"message":"Started"') + + expect(screen.getByText('"message": "Started"').closest('mark')).toBeInTheDocument() + }) + it.each(['["Started"]', '"Started"', '42', 'true', 'null', '{"message":"Started"', 'Started successfully'])( 'keeps non-object message %s unchanged', (message) => { diff --git a/libs/domains/service-logs/feature/src/lib/list-service-logs/row-service-logs/row-service-logs.tsx b/libs/domains/service-logs/feature/src/lib/list-service-logs/row-service-logs/row-service-logs.tsx index dd3d7b99719..fcc006eb91e 100644 --- a/libs/domains/service-logs/feature/src/lib/list-service-logs/row-service-logs/row-service-logs.tsx +++ b/libs/domains/service-logs/feature/src/lib/list-service-logs/row-service-logs/row-service-logs.tsx @@ -1,6 +1,6 @@ import { useNavigate, useParams, useSearch } from '@tanstack/react-router' import clsx from 'clsx' -import { type MouseEvent, type ReactNode, useCallback, useState } from 'react' +import { type MouseEvent, type ReactNode, useCallback, useMemo, useState } from 'react' import { type NormalizedServiceLog } from '@qovery/domains/service-logs/data-access' import { type AnyService } from '@qovery/domains/services/data-access' import { type ServiceLogsParams } from '@qovery/shared/router' @@ -25,7 +25,7 @@ import { } from '@qovery/shared/util-js' import { mergeServiceLogsParams } from '../../search-service-logs/search-service-logs-utils' import { useServiceLogsContext } from '../service-logs-context/service-logs-context' -import { formatObjectLogMessage } from './format-object-log-message' +import { type HighlightRange, findHighlightRanges, formatObjectLogMessage } from './format-object-log-message' import './style.scss' const timeZone = Intl.DateTimeFormat().resolvedOptions().timeZone @@ -37,11 +37,15 @@ const TRAILING_URL_PUNCTUATION_REGEX = /[),.;!?]+$/ function renderHighlightedText( text: string, - searchTerm: string | null | undefined, + textStart: number, + highlightRanges: HighlightRange[], key: string, renderAnsi = true ): ReactNode { - if (!searchTerm || !text.toLowerCase().includes(searchTerm.toLowerCase())) { + const textEnd = textStart + text.length + const ranges = highlightRanges.filter(({ start, end }) => start < textEnd && end > textStart) + + if (ranges.length === 0) { if (!renderAnsi) return text return ( @@ -51,27 +55,41 @@ function renderHighlightedText( ) } - const parts = text.split(new RegExp(`(${searchTerm.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')})`, 'gi')) + const parts = ranges.flatMap(({ start, end }, index) => { + const previousEnd = ranges[index - 1]?.end ?? textStart + const localStart = Math.max(start, textStart) - textStart + const localEnd = Math.min(end, textEnd) - textStart + return [ + { text: text.slice(Math.max(previousEnd, textStart) - textStart, localStart), highlighted: false }, + { text: text.slice(localStart, localEnd), highlighted: true }, + ] + }) + const lastEnd = Math.min(ranges[ranges.length - 1]?.end ?? textStart, textEnd) - textStart + parts.push({ text: text.slice(lastEnd), highlighted: false }) - return parts.map((part, index) => - part.toLowerCase() === searchTerm.toLowerCase() ? ( - - {part} - - ) : renderAnsi ? ( + return parts.map((part, index) => { + if (part.highlighted) { + return ( + + {part.text} + + ) + } + + if (!renderAnsi) return part.text + + return ( - {part} + {part.text} - ) : ( - part ) - ) + }) } export interface RowServiceLogsProps { @@ -122,7 +140,7 @@ export function RowServiceLogs({ log, hasMultipleContainers, highlightedText, se if (!isNginx && !isEnvoy) setIsExpanded(!isExpanded) } - const renderHighlightedMessage = (message: string, searchTerm: string | null | undefined) => { + const renderHighlightedMessage = (message: string, highlightRanges: HighlightRange[]) => { const content: ReactNode[] = [] let currentIndex = 0 @@ -132,7 +150,14 @@ export function RowServiceLogs({ log, hasMultipleContainers, highlightedText, se const startIndex = match.index ?? 0 if (startIndex > currentIndex) { - content.push(renderHighlightedText(message.slice(currentIndex, startIndex), searchTerm, `text-${currentIndex}`)) + content.push( + renderHighlightedText( + message.slice(currentIndex, startIndex), + currentIndex, + highlightRanges, + `text-${currentIndex}` + ) + ) } content.push( @@ -144,14 +169,19 @@ export function RowServiceLogs({ log, hasMultipleContainers, highlightedText, se aria-label={url} className="underline" > - {renderHighlightedText(url, searchTerm, `url-text-${startIndex}`, false)} + {renderHighlightedText(url, startIndex, highlightRanges, `url-text-${startIndex}`, false)} ) const trailingPunctuation = matchedUrl.slice(url.length) if (trailingPunctuation) { content.push( - renderHighlightedText(trailingPunctuation, searchTerm, `trailing-punctuation-${startIndex + url.length}`) + renderHighlightedText( + trailingPunctuation, + startIndex + url.length, + highlightRanges, + `trailing-punctuation-${startIndex + url.length}` + ) ) } @@ -159,7 +189,9 @@ export function RowServiceLogs({ log, hasMultipleContainers, highlightedText, se } if (currentIndex < message.length) { - content.push(renderHighlightedText(message.slice(currentIndex), searchTerm, `text-${currentIndex}`)) + content.push( + renderHighlightedText(message.slice(currentIndex), currentIndex, highlightRanges, `text-${currentIndex}`) + ) } return ( @@ -174,7 +206,11 @@ export function RowServiceLogs({ log, hasMultipleContainers, highlightedText, se const levelLowercase = log.level?.toLowerCase() const isErrorOrCritical = levelLowercase === 'error' || levelLowercase === 'critical' - const message = formatObjectLogMessage(log.message) + const formattedLogMessage = useMemo(() => formatObjectLogMessage(log.message), [log.message]) + const highlightRanges = useMemo( + () => findHighlightRanges(log.message, formattedLogMessage, highlightedText), + [formattedLogMessage, highlightedText, log.message] + ) return ( <> @@ -266,7 +302,7 @@ export function RowServiceLogs({ log, hasMultipleContainers, highlightedText, se )} - {renderHighlightedMessage(message, highlightedText)} + {renderHighlightedMessage(formattedLogMessage.message, highlightRanges)} {isExpanded && ( From 585a826ad4cd5f00cbfc6d735f2e60621e1c9e20 Mon Sep 17 00:00:00 2001 From: RemiBonnet Date: Thu, 24 Sep 2026 17:08:57 +0200 Subject: [PATCH 3/4] feat(service-logs): accent structured log keys --- .../format-object-log-message.spec.ts | 8 +++ .../format-object-log-message.ts | 19 ++++--- .../row-service-logs.spec.tsx | 28 ++++++++-- .../row-service-logs/row-service-logs.tsx | 51 ++++++++++++++----- 4 files changed, 84 insertions(+), 22 deletions(-) diff --git a/libs/domains/service-logs/feature/src/lib/list-service-logs/row-service-logs/format-object-log-message.spec.ts b/libs/domains/service-logs/feature/src/lib/list-service-logs/row-service-logs/format-object-log-message.spec.ts index 1e7e7282780..a6732874db1 100644 --- a/libs/domains/service-logs/feature/src/lib/list-service-logs/row-service-logs/format-object-log-message.spec.ts +++ b/libs/domains/service-logs/feature/src/lib/list-service-logs/row-service-logs/format-object-log-message.spec.ts @@ -31,6 +31,14 @@ describe('formatObjectLogMessage', () => { }`) }) + it('returns the formatted ranges for object keys', () => { + const formattedLogMessage = formatObjectLogMessage('{"message":"Started","context":{"attempt":2}}') + + expect( + formattedLogMessage.keyRanges?.map(({ start, end }) => formattedLogMessage.message.slice(start, end)) + ).toEqual(['"message"', '"context"', '"attempt"']) + }) + it.each([ ['invalid JSON', '{"message":"Started"'], ['an array', '["Started"]'], diff --git a/libs/domains/service-logs/feature/src/lib/list-service-logs/row-service-logs/format-object-log-message.ts b/libs/domains/service-logs/feature/src/lib/list-service-logs/row-service-logs/format-object-log-message.ts index a4af4ca2697..663a199685c 100644 --- a/libs/domains/service-logs/feature/src/lib/list-service-logs/row-service-logs/format-object-log-message.ts +++ b/libs/domains/service-logs/feature/src/lib/list-service-logs/row-service-logs/format-object-log-message.ts @@ -1,13 +1,14 @@ -export interface FormattedLogMessage { - message: string - sourceMap?: number[] -} - export interface HighlightRange { start: number end: number } +export interface FormattedLogMessage { + message: string + sourceMap?: number[] + keyRanges?: HighlightRange[] +} + function nextNonWhitespaceCharacter(message: string, start: number) { for (let index = start; index < message.length; index++) { if (!/\s/.test(message[index] ?? '')) return message[index] @@ -39,7 +40,9 @@ export function formatObjectLogMessage(message: string): FormattedLogMessage { let indentation = 0 let isInsideString = false let isEscaped = false + let currentStringStart = 0 const sourceMap = Array.from({ length: message.length }).fill(-1) + const keyRanges: HighlightRange[] = [] const appendSourceCharacter = (character: string, sourceIndex: number) => { sourceMap[sourceIndex] = formattedMessage.length @@ -62,6 +65,9 @@ export function formatObjectLogMessage(message: string): FormattedLogMessage { isEscaped = true } else if (character === '"') { isInsideString = false + if (nextNonWhitespaceCharacter(message, index + 1) === ':') { + keyRanges.push({ start: currentStringStart, end: formattedMessage.length }) + } } continue @@ -71,6 +77,7 @@ export function formatObjectLogMessage(message: string): FormattedLogMessage { if (character === '"') { isInsideString = true + currentStringStart = formattedMessage.length appendSourceCharacter(character, index) continue } @@ -105,7 +112,7 @@ export function formatObjectLogMessage(message: string): FormattedLogMessage { } } - return { message: formattedMessage, sourceMap } + return { message: formattedMessage, sourceMap, keyRanges } } export function findHighlightRanges( diff --git a/libs/domains/service-logs/feature/src/lib/list-service-logs/row-service-logs/row-service-logs.spec.tsx b/libs/domains/service-logs/feature/src/lib/list-service-logs/row-service-logs/row-service-logs.spec.tsx index 2217f34fee6..4f21498936b 100644 --- a/libs/domains/service-logs/feature/src/lib/list-service-logs/row-service-logs/row-service-logs.spec.tsx +++ b/libs/domains/service-logs/feature/src/lib/list-service-logs/row-service-logs/row-service-logs.spec.tsx @@ -85,19 +85,41 @@ describe('RowServiceLogs', () => { expect(container.querySelector('[data-log-message="true"]')?.textContent).toBe(formattedMessage) }) + it('renders JSON object keys with the accent color', () => { + const { container } = renderRowServiceLogs({ + ...mockLog, + message: '{"message":"Started","context":{"attempt":2}}', + }) + + expect(Array.from(container.querySelectorAll('.text-accent1')).map((element) => element.textContent)).toEqual([ + '"message"', + '"context"', + '"attempt"', + ]) + expect(container.querySelector('[data-log-message="true"]')).toHaveTextContent('"message": "Started"') + }) + it('highlights search text in a formatted JSON object message', () => { renderRowServiceLogs({ ...mockLog, message: '{"message":"Started"}' }, false, 'Started') expect(screen.getByText('Started').closest('mark')).toBeInTheDocument() - expect(screen.getByText(/"message":/).closest('[data-log-message="true"]')).toHaveTextContent( + expect(screen.getByText('Started').closest('[data-log-message="true"]')).toHaveTextContent( '{ "message": "Started" }' ) }) it('highlights a compact raw JSON match after formatting', () => { - renderRowServiceLogs({ ...mockLog, message: '{"message":"Started","attempt":2}' }, false, '"message":"Started"') + const { container } = renderRowServiceLogs( + { ...mockLog, message: '{"message":"Started","attempt":2}' }, + false, + '"message":"Started"' + ) - expect(screen.getByText('"message": "Started"').closest('mark')).toBeInTheDocument() + expect( + Array.from(container.querySelectorAll('mark')) + .map((element) => element.textContent) + .join('') + ).toBe('"message": "Started"') }) it.each(['["Started"]', '"Started"', '42', 'true', 'null', '{"message":"Started"', 'Started successfully'])( diff --git a/libs/domains/service-logs/feature/src/lib/list-service-logs/row-service-logs/row-service-logs.tsx b/libs/domains/service-logs/feature/src/lib/list-service-logs/row-service-logs/row-service-logs.tsx index fcc006eb91e..616fc52c37c 100644 --- a/libs/domains/service-logs/feature/src/lib/list-service-logs/row-service-logs/row-service-logs.tsx +++ b/libs/domains/service-logs/feature/src/lib/list-service-logs/row-service-logs/row-service-logs.tsx @@ -39,11 +39,12 @@ function renderHighlightedText( text: string, textStart: number, highlightRanges: HighlightRange[], + keyRanges: HighlightRange[], key: string, renderAnsi = true ): ReactNode { const textEnd = textStart + text.length - const ranges = highlightRanges.filter(({ start, end }) => start < textEnd && end > textStart) + const ranges = [...highlightRanges, ...keyRanges].filter(({ start, end }) => start < textEnd && end > textStart) if (ranges.length === 0) { if (!renderAnsi) return text @@ -55,17 +56,24 @@ function renderHighlightedText( ) } - const parts = ranges.flatMap(({ start, end }, index) => { - const previousEnd = ranges[index - 1]?.end ?? textStart - const localStart = Math.max(start, textStart) - textStart - const localEnd = Math.min(end, textEnd) - textStart - return [ - { text: text.slice(Math.max(previousEnd, textStart) - textStart, localStart), highlighted: false }, - { text: text.slice(localStart, localEnd), highlighted: true }, - ] + const boundaries = new Set([0, text.length]) + for (const { start, end } of ranges) { + boundaries.add(Math.max(start, textStart) - textStart) + boundaries.add(Math.min(end, textEnd) - textStart) + } + + const sortedBoundaries = Array.from(boundaries).sort((a, b) => a - b) + const parts = sortedBoundaries.slice(0, -1).map((start, index) => { + const end = sortedBoundaries[index + 1] ?? start + const globalStart = textStart + start + const globalEnd = textStart + end + + return { + text: text.slice(start, end), + highlighted: highlightRanges.some((range) => range.start < globalEnd && range.end > globalStart), + isKey: keyRanges.some((range) => range.start < globalEnd && range.end > globalStart), + } }) - const lastEnd = Math.min(ranges[ranges.length - 1]?.end ?? textStart, textEnd) - textStart - parts.push({ text: text.slice(lastEnd), highlighted: false }) return parts.map((part, index) => { if (part.highlighted) { @@ -82,6 +90,14 @@ function renderHighlightedText( ) } + if (part.isKey) { + return ( + + {part.text} + + ) + } + if (!renderAnsi) return part.text return ( @@ -141,6 +157,7 @@ export function RowServiceLogs({ log, hasMultipleContainers, highlightedText, se } const renderHighlightedMessage = (message: string, highlightRanges: HighlightRange[]) => { + const keyRanges = formattedLogMessage.keyRanges ?? [] const content: ReactNode[] = [] let currentIndex = 0 @@ -155,6 +172,7 @@ export function RowServiceLogs({ log, hasMultipleContainers, highlightedText, se message.slice(currentIndex, startIndex), currentIndex, highlightRanges, + keyRanges, `text-${currentIndex}` ) ) @@ -169,7 +187,7 @@ export function RowServiceLogs({ log, hasMultipleContainers, highlightedText, se aria-label={url} className="underline" > - {renderHighlightedText(url, startIndex, highlightRanges, `url-text-${startIndex}`, false)} + {renderHighlightedText(url, startIndex, highlightRanges, keyRanges, `url-text-${startIndex}`, false)} ) @@ -180,6 +198,7 @@ export function RowServiceLogs({ log, hasMultipleContainers, highlightedText, se trailingPunctuation, startIndex + url.length, highlightRanges, + keyRanges, `trailing-punctuation-${startIndex + url.length}` ) ) @@ -190,7 +209,13 @@ export function RowServiceLogs({ log, hasMultipleContainers, highlightedText, se if (currentIndex < message.length) { content.push( - renderHighlightedText(message.slice(currentIndex), currentIndex, highlightRanges, `text-${currentIndex}`) + renderHighlightedText( + message.slice(currentIndex), + currentIndex, + highlightRanges, + keyRanges, + `text-${currentIndex}` + ) ) } From 7ec1797a7810be49a6e643717a443ba137441ca2 Mon Sep 17 00:00:00 2001 From: RemiBonnet Date: Thu, 24 Sep 2026 17:51:32 +0200 Subject: [PATCH 4/4] perf(service-logs): scan highlight ranges linearly --- .../row-service-logs/row-service-logs.tsx | 20 ++++++++++++++++--- 1 file changed, 17 insertions(+), 3 deletions(-) diff --git a/libs/domains/service-logs/feature/src/lib/list-service-logs/row-service-logs/row-service-logs.tsx b/libs/domains/service-logs/feature/src/lib/list-service-logs/row-service-logs/row-service-logs.tsx index 616fc52c37c..12f82f0612f 100644 --- a/libs/domains/service-logs/feature/src/lib/list-service-logs/row-service-logs/row-service-logs.tsx +++ b/libs/domains/service-logs/feature/src/lib/list-service-logs/row-service-logs/row-service-logs.tsx @@ -44,7 +44,9 @@ function renderHighlightedText( renderAnsi = true ): ReactNode { const textEnd = textStart + text.length - const ranges = [...highlightRanges, ...keyRanges].filter(({ start, end }) => start < textEnd && end > textStart) + const visibleHighlightRanges = highlightRanges.filter(({ start, end }) => start < textEnd && end > textStart) + const visibleKeyRanges = keyRanges.filter(({ start, end }) => start < textEnd && end > textStart) + const ranges = [...visibleHighlightRanges, ...visibleKeyRanges] if (ranges.length === 0) { if (!renderAnsi) return text @@ -63,15 +65,27 @@ function renderHighlightedText( } const sortedBoundaries = Array.from(boundaries).sort((a, b) => a - b) + let highlightRangeIndex = 0 + let keyRangeIndex = 0 const parts = sortedBoundaries.slice(0, -1).map((start, index) => { const end = sortedBoundaries[index + 1] ?? start const globalStart = textStart + start const globalEnd = textStart + end + while ((visibleHighlightRanges[highlightRangeIndex]?.end ?? Infinity) <= globalStart) { + highlightRangeIndex++ + } + while ((visibleKeyRanges[keyRangeIndex]?.end ?? Infinity) <= globalStart) { + keyRangeIndex++ + } + + const highlightRange = visibleHighlightRanges[highlightRangeIndex] + const keyRange = visibleKeyRanges[keyRangeIndex] + return { text: text.slice(start, end), - highlighted: highlightRanges.some((range) => range.start < globalEnd && range.end > globalStart), - isKey: keyRanges.some((range) => range.start < globalEnd && range.end > globalStart), + highlighted: Boolean(highlightRange && highlightRange.start < globalEnd), + isKey: Boolean(keyRange && keyRange.start < globalEnd), } })