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
This commit is contained in:
@@ -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(<HighlightedLogRenderer log={log} />);
|
||||
|
||||
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(<HighlightedLogRenderer log={log} />);
|
||||
|
||||
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(<HighlightedLogRenderer log={log} />);
|
||||
|
||||
expect(container.innerHTML).toEqual(log.highlightedBody);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,33 @@
|
||||
import { Token } from 'prismjs';
|
||||
|
||||
import { LogListModel } from './processing';
|
||||
|
||||
export const HighlightedLogRenderer = ({ log }: { log: LogListModel }) => {
|
||||
return (
|
||||
<>
|
||||
{log.highlightedBodyTokens.map((token, i) => (
|
||||
<LogToken token={token} key={i} />
|
||||
))}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const LogToken = ({ token }: { token: Token | string }) => {
|
||||
if (typeof token === 'string') {
|
||||
return token;
|
||||
}
|
||||
if (Array.isArray(token.content)) {
|
||||
return (
|
||||
<span className={`token ${token.type}`}>
|
||||
{token.content.map((subToken, i) => (
|
||||
<LogToken key={i} token={subToken} />
|
||||
))}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<span className={`token ${token.type}`}>
|
||||
{typeof token.content === 'string' ? token.content : <LogToken token={token.content} />}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
@@ -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 <span className="field log-syntax-highlight" dangerouslySetInnerHTML={{ __html: log.highlightedBody }} />;
|
||||
return (
|
||||
<span className="field log-syntax-highlight">
|
||||
<HighlightedLogRenderer log={log} />
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
export function getGridTemplateColumns(dimensions: LogFieldDimension[], displayedFields: string[]) {
|
||||
|
||||
@@ -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 (
|
||||
<div className={styles.logLineWrapper}>
|
||||
{!syntaxHighlighting ? (
|
||||
<div className="field no-highlighting">{log.body}</div>
|
||||
) : (
|
||||
<div className={logStyles.logLine}>
|
||||
<div className={logStyles.wrappedLogLine}>
|
||||
<div className="field log-syntax-highlight" dangerouslySetInnerHTML={{ __html: log.highlightedBody }} />
|
||||
</div>
|
||||
<div className={logStyles.logLine}>
|
||||
<div className={logStyles.wrappedLogLine}>
|
||||
{log.hasAnsi ? (
|
||||
<span className="field no-highlighting">
|
||||
<LogMessageAnsi value={log.body} />
|
||||
</span>
|
||||
) : (
|
||||
<>
|
||||
{!syntaxHighlighting && <div className="field no-highlighting">{log.body}</div>}
|
||||
{syntaxHighlighting && (
|
||||
<div className="field log-syntax-highlight">{<HighlightedLogRenderer log={log} />}</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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<string | Token> | 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 <LogLineDetailsLog />, 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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user