diff --git a/package.json b/package.json
index 97b0264a180..617104b7a1f 100644
--- a/package.json
+++ b/package.json
@@ -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",
diff --git a/public/app/features/logs/components/panel/InfiniteScroll.tsx b/public/app/features/logs/components/panel/InfiniteScroll.tsx
index cf5b0903fa8..c6c310ec88c 100644
--- a/public/app/features/logs/components/panel/InfiniteScroll.tsx
+++ b/public/app/features/logs/components/panel/InfiniteScroll.tsx
@@ -154,6 +154,7 @@ export const InfiniteScroll = ({
displayedFields={displayedFields}
index={index}
log={logs[index]}
+ logs={logs}
onClick={onClick}
showTime={showTime}
style={style}
diff --git a/public/app/features/logs/components/panel/LogLine.test.tsx b/public/app/features/logs/components/panel/LogLine.test.tsx
index 3ab836aaa18..e31ba38cabf 100644
--- a/public/app/features/logs/components/panel/LogLine.test.tsx
+++ b/public/app/features/logs/components/panel/LogLine.test.tsx
@@ -45,6 +45,7 @@ describe.each(fontSizes)('LogLine', (fontSize: LogListFontSize) => {
displayedFields: [],
index: 0,
log,
+ logs: [log],
onClick: jest.fn(),
showTime: true,
style: {},
diff --git a/public/app/features/logs/components/panel/LogLine.tsx b/public/app/features/logs/components/panel/LogLine.tsx
index 9f96fb3a3b7..7bc7edd9cd6 100644
--- a/public/app/features/logs/components/panel/LogLine.tsx
+++ b/public/app/features/logs/components/panel/LogLine.tsx
@@ -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(
)}
- {detailsMode === 'inline' && detailsShown && }
+ {detailsMode === 'inline' && detailsShown && }
>
);
}
diff --git a/public/app/features/logs/components/panel/LogLineDetailsComponent.tsx b/public/app/features/logs/components/panel/LogLineDetailsComponent.tsx
index 0024860a93a..424266d7b09 100644
--- a/public/app/features/logs/components/panel/LogLineDetailsComponent.tsx
+++ b/public/app/features/logs/components/panel/LogLineDetailsComponent.tsx
@@ -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)}
>
-
{log.raw}
+
{displayedFields.length > 0 && setDisplayedFields && (
({
componentWrapper: css({
padding: theme.spacing(0, 1, 1, 1),
}),
- logLineWrapper: css({
- maxHeight: '50vh',
- overflow: 'auto',
- }),
});
diff --git a/public/app/features/logs/components/panel/LogLineDetailsLog.tsx b/public/app/features/logs/components/panel/LogLineDetailsLog.tsx
new file mode 100644
index 00000000000..259d7ca27c5
--- /dev/null
+++ b/public/app/features/logs/components/panel/LogLineDetailsLog.tsx
@@ -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 (
+
+ {!syntaxHighlighting ? (
+
{log.body}
+ ) : (
+
+ )}
+
+ );
+});
+
+LogLineDetailsLog.displayName = 'LogLineDetailsLog';
+
+const styles = {
+ logLineWrapper: css({
+ maxHeight: '50vh',
+ overflow: 'auto',
+ }),
+};
diff --git a/public/app/features/logs/components/panel/processing.test.ts b/public/app/features/logs/components/panel/processing.test.ts
index 906d2f4a7c5..7ef61741624 100644
--- a/public/app/features/logs/components/panel/processing.test.ts
+++ b/public/app/features/logs/components/panel/processing.test.ts
@@ -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);
diff --git a/public/app/features/logs/components/panel/processing.ts b/public/app/features/logs/components/panel/processing.ts
index 60e51ca91d4..d46b09db490 100644
--- a/public/app/features/logs/components/panel/processing.ts
+++ b/public/app/features/logs/components/panel/processing.ts
@@ -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 , 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;
+}
diff --git a/public/app/features/logs/components/panel/virtualization.ts b/public/app/features/logs/components/panel/virtualization.ts
index ecb475c5bcf..bf84f5fe3ff 100644
--- a/public/app/features/logs/components/panel/virtualization.ts
+++ b/public/app/features/logs/components/panel/virtualization.ts
@@ -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) => {
diff --git a/yarn.lock b/yarn.lock
index 913b64a68a1..58e016dab57 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -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"