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:
@@ -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) => {
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user