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;
}
}