New Logs Panel: Parse JSON by default and show highlighted log in details (#107953)

* processing: sanitize highlighted log before highlighting

* processing: prettify json by default

* LogLineDetails: render prettified log

* processing: implement newlines truncation

* Remove log

* Linting issues

* Linting

* Update unit tests

* processing: use correct property

* Formatting

* processing: reflect updated collapse state

* processing: add test case

* processing: use lossless json to parse

* LogLine: receive logs for inline details stats

* Remove log

* LogListModel: specialize clone for loglinedetails
This commit is contained in:
Matias Chomicki
2025-07-10 18:07:27 +00:00
committed by GitHub
parent b1fcdb9c09
commit 86aa70322b
10 changed files with 195 additions and 14 deletions
+1
View File
@@ -355,6 +355,7 @@
"leven": "^4.0.0",
"lodash": "4.17.21",
"logfmt": "^1.3.2",
"lossless-json": "^4.1.1",
"lru-cache": "11.1.0",
"lru-memoize": "^1.1.0",
"lucene": "^2.1.1",
@@ -154,6 +154,7 @@ export const InfiniteScroll = ({
displayedFields={displayedFields}
index={index}
log={logs[index]}
logs={logs}
onClick={onClick}
showTime={showTime}
style={style}
@@ -45,6 +45,7 @@ describe.each(fontSizes)('LogLine', (fontSize: LogListFontSize) => {
displayedFields: [],
index: 0,
log,
logs: [log],
onClick: jest.fn(),
showTime: true,
style: {},
@@ -27,6 +27,7 @@ export interface Props {
displayedFields: string[];
index: number;
log: LogListModel;
logs: LogListModel[];
showTime: boolean;
style: CSSProperties;
styles: LogLineStyles;
@@ -41,6 +42,7 @@ export const LogLine = ({
displayedFields,
index,
log,
logs,
style,
styles,
onClick,
@@ -57,6 +59,7 @@ export const LogLine = ({
height={style.height}
index={index}
log={log}
logs={logs}
styles={styles}
onClick={onClick}
onOverflow={onOverflow}
@@ -79,6 +82,7 @@ const LogLineComponent = memo(
height,
index,
log,
logs,
styles,
onClick,
onOverflow,
@@ -237,7 +241,7 @@ const LogLineComponent = memo(
</Button>
</div>
)}
{detailsMode === 'inline' && detailsShown && <InlineLogLineDetails logs={[]} />}
{detailsMode === 'inline' && detailsShown && <InlineLogLineDetails logs={logs} />}
</>
);
}
@@ -13,6 +13,7 @@ import { createLogLineLinks } from '../logParser';
import { LogLineDetailsDisplayedFields } from './LogLineDetailsDisplayedFields';
import { LabelWithLinks, LogLineDetailsFields, LogLineDetailsLabelFields } from './LogLineDetailsFields';
import { LogLineDetailsHeader } from './LogLineDetailsHeader';
import { LogLineDetailsLog } from './LogLineDetailsLog';
import { useLogListContext } from './LogListContext';
import { LogListModel } from './processing';
@@ -102,7 +103,7 @@ export const LogLineDetailsComponent = ({ log, logs }: LogLineDetailsComponentPr
isOpen={logLineOpen}
onToggle={(isOpen: boolean) => handleToggle('logLineOpen', isOpen)}
>
<div className={styles.logLineWrapper}>{log.raw}</div>
<LogLineDetailsLog log={log} />
</ControlledCollapse>
{displayedFields.length > 0 && setDisplayedFields && (
<ControlledCollapse
@@ -189,8 +190,4 @@ const getStyles = (theme: GrafanaTheme2) => ({
componentWrapper: css({
padding: theme.spacing(0, 1, 1, 1),
}),
logLineWrapper: css({
maxHeight: '50vh',
overflow: 'auto',
}),
});
@@ -0,0 +1,44 @@
import { css } from '@emotion/css';
import { memo, useMemo } from 'react';
import { useStyles2 } from '@grafana/ui';
import { getStyles } from './LogLine';
import { useLogListContext } from './LogListContext';
import { LogListModel } from './processing';
interface Props {
log: LogListModel;
}
export const LogLineDetailsLog = memo(({ log: originalLog }: Props) => {
const { syntaxHighlighting } = useLogListContext();
const logStyles = useStyles2(getStyles);
const log = useMemo(() => {
const log = originalLog.clone();
return log;
}, [originalLog]);
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>
)}
</div>
);
});
LogLineDetailsLog.displayName = 'LogLineDetailsLog';
const styles = {
logLineWrapper: css({
maxHeight: '50vh',
overflow: 'auto',
}),
};
@@ -140,6 +140,36 @@ describe('preProcessLogs', () => {
);
expect(logListModel.getDisplayedFieldValue(LOG_LINE_BODY_FIELD_NAME, true)).toBe('log message 1');
});
test('Prettifies JSON', () => {
const entry = '{"key": "value", "otherKey": "otherValue"}';
const logListModel = createLogLine(
{ entry },
{
escape: false,
order: LogsSortOrder.Descending,
timeZone: 'browser',
wrapLogMessage: false, // unwrapped
}
);
expect(logListModel.entry).toBe(entry);
expect(logListModel.body).not.toBe(entry);
});
test('Uses lossless parsing', () => {
const entry = '{"number": 90071992547409911}';
const logListModel = createLogLine(
{ entry },
{
escape: false,
order: LogsSortOrder.Descending,
timeZone: 'browser',
wrapLogMessage: false, // unwrapped
}
);
expect(logListModel.entry).toBe(entry);
expect(logListModel.body).toContain('90071992547409911');
});
});
test('Orders logs', () => {
@@ -243,6 +273,35 @@ describe('preProcessLogs', () => {
expect(entry).toContain(longLog.body);
});
test('Sets the collapsed state based on the new lines count', () => {
const entry = new Array(virtualization.getTruncationLineCount()).fill('test\n').join('');
const multilineLog = createLogLine(
{ entry, labels: { field: 'value' } },
{ escape: false, order: LogsSortOrder.Descending, timeZone: 'browser', virtualization, wrapLogMessage: true }
);
expect(multilineLog.collapsed).toBeUndefined();
multilineLog.updateCollapsedState([], container);
expect(multilineLog.collapsed).toBe(true);
expect(entry).toContain(multilineLog.body);
});
test('Correctly counts new lines', () => {
const entry = new Array(virtualization.getTruncationLineCount() - 1).fill('test\n').join('');
const multilineLog = createLogLine(
{ entry, labels: { field: 'value' } },
{ escape: false, order: LogsSortOrder.Descending, timeZone: 'browser', virtualization, wrapLogMessage: true }
);
expect(multilineLog.collapsed).toBeUndefined();
multilineLog.updateCollapsedState([], container);
expect(multilineLog.collapsed).toBeUndefined();
});
test('Considers the displayed fields to set the collapsed state', () => {
// Make container half of the size
jest.spyOn(container, 'clientWidth', 'get').mockReturnValue(100);
@@ -255,6 +314,22 @@ describe('preProcessLogs', () => {
expect(longLog.collapsed).toBeUndefined();
});
test('Considers new lines in displayed fields to set the collapsed state', () => {
const entry = new Array(virtualization.getTruncationLineCount() - 1).fill('test\n').join('');
const field = 'test\n';
const multilineLog = createLogLine(
{ entry, labels: { field } },
{ escape: false, order: LogsSortOrder.Descending, timeZone: 'browser', virtualization, wrapLogMessage: true }
);
expect(multilineLog.collapsed).toBeUndefined();
multilineLog.updateCollapsedState(['field', LOG_LINE_BODY_FIELD_NAME], container);
expect(multilineLog.collapsed).toBe(true);
expect(entry).toContain(multilineLog.body);
});
test('Updates the body based on the collapsed state', () => {
expect(longLog.collapsed).toBeUndefined();
expect(longLog.body).toBe(entry);
@@ -1,7 +1,8 @@
import ansicolor from 'ansicolor';
import { parse, stringify } from 'lossless-json';
import Prism, { Grammar } from 'prismjs';
import { DataFrame, dateTimeFormat, Labels, LogLevel, LogRowModel, LogsSortOrder } from '@grafana/data';
import { DataFrame, dateTimeFormat, Labels, LogLevel, LogRowModel, LogsSortOrder, textUtil } from '@grafana/data';
import { GetFieldLinksFn } from 'app/plugins/panel/logs/types';
import { checkLogsError, checkLogsSampled, escapeUnescapedString, sortLogRows } from '../../utils';
@@ -97,8 +98,23 @@ export class LogListModel implements LogRowModel {
this.raw = raw;
}
clone() {
const clone = Object.assign(Object.create(Object.getPrototypeOf(this)), this);
// 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;
return clone;
}
get body(): string {
if (this._body === undefined) {
try {
const parsed = stringify(parse(this.raw), undefined, 2);
if (parsed) {
this.raw = parsed;
}
} catch (error) {}
this._body = this.collapsed
? this.raw.substring(0, this._virtualization?.getTruncationLength(null) ?? TRUNCATION_DEFAULT_LENGTH)
: this.raw;
@@ -124,7 +140,11 @@ export class LogListModel implements LogRowModel {
if (this._highlightedBody === undefined) {
this._grammar = this._grammar ?? generateLogGrammar(this);
const extraGrammar = generateTextMatchGrammar(this.searchWords, this._currentSearch);
this._highlightedBody = Prism.highlight(this.body, { ...extraGrammar, ...this._grammar }, 'lokiql');
this._highlightedBody = Prism.highlight(
textUtil.sanitize(this.body),
{ ...extraGrammar, ...this._grammar },
'lokiql'
);
}
return this._highlightedBody;
}
@@ -154,16 +174,27 @@ export class LogListModel implements LogRowModel {
}
updateCollapsedState(displayedFields: string[], container: HTMLDivElement | null) {
const lineLength =
const line =
displayedFields.length > 0
? displayedFields.map((field) => this.getDisplayedFieldValue(field, true)).join('').length
: this.entry.length;
const collapsed =
lineLength >= (this._virtualization?.getTruncationLength(container) ?? TRUNCATION_DEFAULT_LENGTH)
? displayedFields.map((field) => this.getDisplayedFieldValue(field, true)).join('')
: this.body;
// Length truncation
let collapsed =
line.length >= (this._virtualization?.getTruncationLength(container) ?? TRUNCATION_DEFAULT_LENGTH)
? true
: undefined;
// Newlines truncation
if (!collapsed && this._virtualization) {
const truncationLimit = this._virtualization.getTruncationLineCount();
collapsed = countNewLines(line, truncationLimit) >= truncationLimit ? true : collapsed;
}
if (this.collapsed === undefined || collapsed === undefined) {
this.collapsed = collapsed;
this._body = undefined;
this._highlightedBody = undefined;
}
return this.collapsed;
}
@@ -226,3 +257,23 @@ function logLevelToDisplayLevel(level = '') {
return level;
}
}
function countNewLines(log: string, limit = Infinity) {
let count = 0;
for (let i = 0; i < log.length; ++i) {
// No need to iterate further
if (count > Infinity) {
return count;
}
if (log[i] === '\n') {
count += 1;
} else if (log[i] === '\r') {
count += 1;
// skip LF in CRLF
if (log[i] === '\n') {
i += 1;
}
}
}
return count;
}
@@ -55,7 +55,6 @@ export class LogLineVirtualization {
getGridSize = () => this.gridSize;
getPaddingBottom = () => this.paddingBottom;
// 2/3 of the viewport height
getTruncationLineCount = () => Math.round(window.innerHeight / this.getLineHeight() / 1.5);
getTruncationLength = (container: HTMLDivElement | null) => {
+8
View File
@@ -18355,6 +18355,7 @@ __metadata:
leven: "npm:^4.0.0"
lodash: "npm:4.17.21"
logfmt: "npm:^1.3.2"
lossless-json: "npm:^4.1.1"
lru-cache: "npm:11.1.0"
lru-memoize: "npm:^1.1.0"
lucene: "npm:^2.1.1"
@@ -22160,6 +22161,13 @@ __metadata:
languageName: node
linkType: hard
"lossless-json@npm:^4.1.1":
version: 4.1.1
resolution: "lossless-json@npm:4.1.1"
checksum: 10/1e22e9e4ad8ebf169567a8c24553403bfd7abc33c141f4918f1b9626417f80477b849e6175a315a224e0c68072759c69a0e332a925de80269a7f9720af3a7fbd
languageName: node
linkType: hard
"loupe@npm:^3.1.0, loupe@npm:^3.1.1, loupe@npm:^3.1.2":
version: 3.1.3
resolution: "loupe@npm:3.1.3"