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"