From e30931c219aecbe2120cc53913619c6f3a75817b Mon Sep 17 00:00:00 2001 From: Matias Chomicki Date: Fri, 29 Aug 2025 20:33:21 +0200 Subject: [PATCH] Log Line: Add custom highlight renderer (#110335) * LogLine: implement custom highlight renderer * Log line: fully replace highlighted body with highlight tokens * processing: update test * Add unit test * Add deeply nested JSON test * processing: update clone function * Scroll to log line: find by uid * LogLineDetailsLog: check if the log contains ansi * Prettier --- .../panel/HighlightedLogRenderer.test.tsx | 208 ++++++++++++++++++ .../panel/HighlightedLogRenderer.tsx | 33 +++ .../logs/components/panel/LogLine.tsx | 7 +- .../components/panel/LogLineDetailsLog.tsx | 26 ++- .../logs/components/panel/LogList.tsx | 2 +- .../logs/components/panel/processing.test.ts | 52 +++-- .../logs/components/panel/processing.ts | 27 ++- 7 files changed, 324 insertions(+), 31 deletions(-) create mode 100644 public/app/features/logs/components/panel/HighlightedLogRenderer.test.tsx create mode 100644 public/app/features/logs/components/panel/HighlightedLogRenderer.tsx diff --git a/public/app/features/logs/components/panel/HighlightedLogRenderer.test.tsx b/public/app/features/logs/components/panel/HighlightedLogRenderer.test.tsx new file mode 100644 index 00000000000..272bb0e2ade --- /dev/null +++ b/public/app/features/logs/components/panel/HighlightedLogRenderer.test.tsx @@ -0,0 +1,208 @@ +import { render } from '@testing-library/react'; + +import { LogsSortOrder } from '@grafana/data'; + +import { createLogLine } from '../mocks/logRow'; + +import { HighlightedLogRenderer } from './HighlightedLogRenderer'; + +describe('HighlightedLogRenderer', () => { + test.each([ + [false, false], + [true, false], + [false, true], + [true, true], + ])('Serializes JSON to the same string', (wrapLogMessage: boolean, prettifyJSON: boolean) => { + const log = createLogLine( + { + entry: `{ + "_entry": "log text [149843146]", + "counter": "11203", + "float": "12.53", + "wave": 0.8090169943751789, + "label": "val3", + "level": "info", + "array": ["1", 2, { "test": "test" }], +}`, + }, + { + escape: false, + order: LogsSortOrder.Descending, + timeZone: 'browser', + wrapLogMessage, + prettifyJSON, + } + ); + + const { container } = render(); + + expect(container.innerHTML).toEqual(log.highlightedBody); + }); + + test.each([ + [false, false], + [true, false], + [false, true], + [true, true], + ])('Serializes deeply nested JSON to the same string', (wrapLogMessage: boolean, prettifyJSON: boolean) => { + const log = createLogLine( + { + entry: `{ + "id": "user_12345", + "profile": { + "name": { + "first": "Alice", + "last": "Example" + }, + "contact": { + "email": "alice@example.com", + "phone": "+1-111-1234", + "addresses": [ + { + "type": "home", + "location": { + "street": "123 Maple St", + "city": "Springfield", + "geo": { + "lat": 40.7128, + "lng": -74.0060, + "timezone": { + "id": "America/New_York", + "offset": -5 + } + } + } + }, + { + "type": "work", + "location": { + "street": "456 Oak Ave", + "city": "Metropolis", + "geo": { + "lat": 37.7749, + "lng": -122.4194, + "timezone": { + "id": "America/Los_Angeles", + "offset": -8 + } + } + } + } + ] + } + }, + "account": { + "createdAt": "2023-11-10T08:30:00Z", + "lastLogin": "2025-08-29T15:12:00Z", + "settings": { + "notifications": { + "email": true, + "sms": false, + "categories": [ + { + "name": "security", + "enabled": true + }, + { + "name": "marketing", + "enabled": false + } + ] + }, + "theme": { + "mode": "dark", + "colors": { + "background": "#1e1e1e", + "text": "#ffffff", + "highlights": { + "primary": "#ff4081", + "secondary": "#82b1ff" + } + } + } + } + }, + "activity": [ + { + "type": "login", + "timestamp": "2025-08-29T15:12:00Z", + "ip": "192.168.1.10", + "device": { + "type": "desktop", + "os": { + "name": "macOS", + "version": "14.2" + }, + "browser": { + "name": "Chrome", + "version": "126.0.6478.56" + } + } + }, + { + "type": "purchase", + "timestamp": "2025-08-28T18:45:00Z", + "details": { + "orderId": "order_98765", + "items": [ + { + "productId": "prod_111", + "name": "Wireless Keyboard", + "price": 79.99, + "quantity": 1 + }, + { + "productId": "prod_222", + "name": "Ergonomic Mouse", + "price": 49.99, + "quantity": 2 + } + ], + "shipping": { + "carrier": "UPS", + "status": "delivered", + "estimatedDelivery": "2025-08-30T14:00:00Z" + } + } + } + ] +}`, + }, + { + escape: false, + order: LogsSortOrder.Descending, + timeZone: 'browser', + wrapLogMessage, + prettifyJSON, + } + ); + + const { container } = render(); + + expect(container.innerHTML).toEqual(log.highlightedBody); + }); + + test.each([ + [false, false], + [true, false], + [false, true], + [true, true], + ])('Serializes JSON to the same string', (wrapLogMessage: boolean, prettifyJSON: boolean) => { + const log = createLogLine( + { + entry: `_entry="log text [149843146]" counter=11203 float=12.53 wave=0.8090169943751789 label=val3 level=info`, + }, + { + escape: false, + order: LogsSortOrder.Descending, + timeZone: 'browser', + wrapLogMessage, + prettifyJSON, + } + ); + + const { container } = render(); + + expect(container.innerHTML).toEqual(log.highlightedBody); + }); +}); diff --git a/public/app/features/logs/components/panel/HighlightedLogRenderer.tsx b/public/app/features/logs/components/panel/HighlightedLogRenderer.tsx new file mode 100644 index 00000000000..2016bc84cbc --- /dev/null +++ b/public/app/features/logs/components/panel/HighlightedLogRenderer.tsx @@ -0,0 +1,33 @@ +import { Token } from 'prismjs'; + +import { LogListModel } from './processing'; + +export const HighlightedLogRenderer = ({ log }: { log: LogListModel }) => { + return ( + <> + {log.highlightedBodyTokens.map((token, i) => ( + + ))} + + ); +}; + +const LogToken = ({ token }: { token: Token | string }) => { + if (typeof token === 'string') { + return token; + } + if (Array.isArray(token.content)) { + return ( + + {token.content.map((subToken, i) => ( + + ))} + + ); + } + return ( + + {typeof token.content === 'string' ? token.content : } + + ); +}; diff --git a/public/app/features/logs/components/panel/LogLine.tsx b/public/app/features/logs/components/panel/LogLine.tsx index fc40b8b8939..a6a326d487d 100644 --- a/public/app/features/logs/components/panel/LogLine.tsx +++ b/public/app/features/logs/components/panel/LogLine.tsx @@ -10,6 +10,7 @@ import { Button, Icon, Tooltip } from '@grafana/ui'; import { LOG_LINE_BODY_FIELD_NAME } from '../LogDetailsBody'; import { LogMessageAnsi } from '../LogMessageAnsi'; +import { HighlightedLogRenderer } from './HighlightedLogRenderer'; import { InlineLogLineDetails } from './LogLineDetails'; import { LogLineMenu } from './LogLineMenu'; import { useLogIsPermalinked, useLogIsPinned, useLogListContext } from './LogListContext'; @@ -393,7 +394,11 @@ const LogLineBody = ({ log, styles }: { log: LogListModel; styles: LogLineStyles ); } - return ; + return ( + + + + ); }; export function getGridTemplateColumns(dimensions: LogFieldDimension[], displayedFields: string[]) { diff --git a/public/app/features/logs/components/panel/LogLineDetailsLog.tsx b/public/app/features/logs/components/panel/LogLineDetailsLog.tsx index a4e4b960053..1f8955c44cb 100644 --- a/public/app/features/logs/components/panel/LogLineDetailsLog.tsx +++ b/public/app/features/logs/components/panel/LogLineDetailsLog.tsx @@ -3,6 +3,9 @@ import { memo, useMemo } from 'react'; import { useStyles2 } from '@grafana/ui'; +import { LogMessageAnsi } from '../LogMessageAnsi'; + +import { HighlightedLogRenderer } from './HighlightedLogRenderer'; import { getStyles } from './LogLine'; import { LogListModel } from './processing'; @@ -20,15 +23,22 @@ export const LogLineDetailsLog = memo(({ log: originalLog, syntaxHighlighting }: return (
- {!syntaxHighlighting ? ( -
{log.body}
- ) : ( -
-
-
-
+
+
+ {log.hasAnsi ? ( + + + + ) : ( + <> + {!syntaxHighlighting &&
{log.body}
} + {syntaxHighlighting && ( +
{}
+ )} + + )}
- )} +
); }); diff --git a/public/app/features/logs/components/panel/LogList.tsx b/public/app/features/logs/components/panel/LogList.tsx index 5ff50f22286..b98d4a3d86d 100644 --- a/public/app/features/logs/components/panel/LogList.tsx +++ b/public/app/features/logs/components/panel/LogList.tsx @@ -399,7 +399,7 @@ const LogListComponent = ({ const focusLogLine = useCallback( (log: LogListModel) => { - const index = filteredLogs.indexOf(log); + const index = filteredLogs.findIndex((filteredLog) => filteredLog.uid === log.uid); if (index >= 0) { debouncedScrollToItem(index, 'start'); } diff --git a/public/app/features/logs/components/panel/processing.test.ts b/public/app/features/logs/components/panel/processing.test.ts index 2537a46c2f3..0b69cd15f7a 100644 --- a/public/app/features/logs/components/panel/processing.test.ts +++ b/public/app/features/logs/components/panel/processing.test.ts @@ -283,21 +283,47 @@ describe('preProcessLogs', () => { }); test('Highlights tokens in log lines', () => { - expect(processedLogs[0].highlightedBody).toContain('log-token-label'); - expect(processedLogs[0].highlightedBody).toContain('log-token-key'); - expect(processedLogs[0].highlightedBody).toContain('log-token-string'); - expect(processedLogs[0].highlightedBody).toContain('log-token-uuid'); - expect(processedLogs[0].highlightedBody).not.toContain('log-token-method'); - expect(processedLogs[0].highlightedBody).not.toContain('log-token-json-key'); + expect(processedLogs[0].highlightedBodyTokens).toEqual( + expect.arrayContaining([expect.objectContaining({ type: 'log-token-label' })]) + ); + expect(processedLogs[0].highlightedBodyTokens).toEqual( + expect.arrayContaining([expect.objectContaining({ type: 'log-token-key' })]) + ); + expect(processedLogs[0].highlightedBodyTokens).toEqual( + expect.arrayContaining([expect.objectContaining({ type: 'log-token-string' })]) + ); + expect(processedLogs[0].highlightedBodyTokens).toEqual( + expect.arrayContaining([expect.objectContaining({ type: 'log-token-uuid' })]) + ); + expect(processedLogs[0].highlightedBodyTokens).not.toEqual( + expect.arrayContaining([expect.objectContaining({ type: 'log-token-method' })]) + ); + expect(processedLogs[0].highlightedBodyTokens).not.toEqual( + expect.arrayContaining([expect.objectContaining({ type: 'log-token-json-key' })]) + ); - expect(processedLogs[1].highlightedBody).toContain('log-token-method'); - expect(processedLogs[1].highlightedBody).toContain('log-token-key'); - expect(processedLogs[1].highlightedBody).toContain('log-token-string'); - expect(processedLogs[1].highlightedBody).not.toContain('log-token-json-key'); + expect(processedLogs[1].highlightedBodyTokens).toEqual( + expect.arrayContaining([expect.objectContaining({ type: 'log-token-method' })]) + ); + expect(processedLogs[1].highlightedBodyTokens).toEqual( + expect.arrayContaining([expect.objectContaining({ type: 'log-token-key' })]) + ); + expect(processedLogs[1].highlightedBodyTokens).toEqual( + expect.arrayContaining([expect.objectContaining({ type: 'log-token-string' })]) + ); + expect(processedLogs[1].highlightedBodyTokens).not.toEqual( + expect.arrayContaining([expect.objectContaining({ type: 'log-token-json-key' })]) + ); - expect(processedLogs[2].highlightedBody).toContain('log-token-json-key'); - expect(processedLogs[2].highlightedBody).toContain('log-token-string'); - expect(processedLogs[2].highlightedBody).not.toContain('log-token-method'); + expect(processedLogs[2].highlightedBodyTokens).toEqual( + expect.arrayContaining([expect.objectContaining({ type: 'log-token-json-key' })]) + ); + expect(processedLogs[2].highlightedBodyTokens).toEqual( + expect.arrayContaining([expect.objectContaining({ type: 'log-token-string' })]) + ); + expect(processedLogs[2].highlightedBodyTokens).not.toEqual( + expect.arrayContaining([expect.objectContaining({ type: 'log-token-method' })]) + ); }); test('Returns displayed field values', () => { diff --git a/public/app/features/logs/components/panel/processing.ts b/public/app/features/logs/components/panel/processing.ts index 60d9909a52f..dbb060a744c 100644 --- a/public/app/features/logs/components/panel/processing.ts +++ b/public/app/features/logs/components/panel/processing.ts @@ -1,6 +1,6 @@ import ansicolor from 'ansicolor'; import { LosslessNumber, parse, stringify } from 'lossless-json'; -import Prism, { Grammar } from 'prismjs'; +import Prism, { Grammar, Token } from 'prismjs'; import { DataFrame, @@ -10,7 +10,6 @@ import { LogRowModel, LogsSortOrder, systemDateFormats, - textUtil, } from '@grafana/data'; import { config } from '@grafana/runtime'; import { GetFieldLinksFn } from 'app/plugins/panel/logs/types'; @@ -59,6 +58,7 @@ export class LogListModel implements LogRowModel { private _currentSearch: string | undefined = undefined; private _grammar?: Grammar; private _highlightedBody: string | undefined = undefined; + private _highlightTokens: Array | undefined = undefined; private _fields: FieldDef[] | undefined = undefined; private _getFieldLinks: GetFieldLinksFn | undefined = undefined; private _prettifyJSON: boolean; @@ -120,7 +120,7 @@ export class LogListModel implements LogRowModel { // Unless this function is required outside of , we create a wrapped clone, so new lines are not stripped. clone._wrapLogMessage = true; clone._body = undefined; - clone._highlightedBody = undefined; + clone._highlightTokens = undefined; return clone; } @@ -161,14 +161,25 @@ export class LogListModel implements LogRowModel { get highlightedBody() { if (this._highlightedBody === undefined) { // Body is accessed first to trigger the getter code before generateLogGrammar() - const sanitizedBody = textUtil.sanitize(this.body); + const body = this.body; this._grammar = this._grammar ?? generateLogGrammar(this); const extraGrammar = generateTextMatchGrammar(this.searchWords, this._currentSearch); - this._highlightedBody = Prism.highlight(sanitizedBody, { ...extraGrammar, ...this._grammar }, 'lokiql'); + this._highlightedBody = Prism.highlight(body, { ...extraGrammar, ...this._grammar }, 'logs'); } return this._highlightedBody; } + get highlightedBodyTokens() { + if (this._highlightTokens === undefined) { + // Body is accessed first to trigger the getter code before generateLogGrammar() + const body = this.body; + this._grammar = this._grammar ?? generateLogGrammar(this); + const extraGrammar = generateTextMatchGrammar(this.searchWords, this._currentSearch); + this._highlightTokens = Prism.tokenize(body, { ...extraGrammar, ...this._grammar }); + } + return this._highlightTokens; + } + get isJSON() { return this._json; } @@ -223,21 +234,21 @@ export class LogListModel implements LogRowModel { if (this.collapsed === undefined || collapsed === undefined) { this.collapsed = collapsed; this._body = undefined; - this._highlightedBody = undefined; + this._highlightTokens = undefined; } } setCollapsedState(collapsed: boolean) { if (this.collapsed !== collapsed) { this._body = undefined; - this._highlightedBody = undefined; + this._highlightTokens = undefined; } this.collapsed = collapsed; } setCurrentSearch(search: string | undefined) { this._currentSearch = search; - this._highlightedBody = undefined; + this._highlightTokens = undefined; } }