New Logs Panel: fix unwrapped logs display (#107391)

* LogLine: remove new lines in unwrapped mode

* Displayed fields: fix displayed fields when the body is included

* LogLine: prevent overflows from extremely long lines with incorrect width measurement

* Update tests

* virtualization: test calculateFieldDimensions

* getGridTemplateColumns: consider displayed fields to set the grid sizes

* LogList: hide overflowing fields in unwrapped mode

* processing: extract regex

* LogLine: improve hover and selected state

* virtualization: strip ansi color codes for measurement

* Update tests

* LogLine: improve log line pre-resize state

* Revert "LogLine: improve log line pre-resize state"

This reverts commit a6b4ddded5.

* LogLine: improve hover/active color
pull/107451/head
Matias Chomicki 3 weeks ago committed by GitHub
parent 974a2c47f9
commit 855f133d1e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
  1. 1
      public/app/features/logs/components/__mocks__/logRow.ts
  2. 92
      public/app/features/logs/components/panel/LogLine.test.tsx
  3. 21
      public/app/features/logs/components/panel/LogLine.tsx
  4. 15
      public/app/features/logs/components/panel/LogList.tsx
  5. 49
      public/app/features/logs/components/panel/processing.test.ts
  6. 46
      public/app/features/logs/components/panel/processing.ts
  7. 55
      public/app/features/logs/components/panel/virtualization.test.ts
  8. 10
      public/app/features/logs/components/panel/virtualization.ts

@ -46,6 +46,7 @@ export const createLogLine = (
order: LogsSortOrder.Descending,
timeZone: 'browser',
virtualization: undefined,
wrapLogMessage: true,
}
): LogListModel => {
const logs = preProcessLogs([createLogRow(overrides)], processOptions);

@ -6,7 +6,7 @@ import { CoreApp, createTheme, LogsDedupStrategy, LogsSortOrder } from '@grafana
import { LOG_LINE_BODY_FIELD_NAME } from '../LogDetailsBody';
import { createLogLine } from '../__mocks__/logRow';
import { getStyles, LogLine, Props } from './LogLine';
import { getGridTemplateColumns, getStyles, LogLine, Props } from './LogLine';
import { LogListFontSize } from './LogList';
import { LogListContextProvider } from './LogListContext';
import { LogListSearchContext } from './LogListSearchContext';
@ -36,7 +36,7 @@ describe.each(fontSizes)('LogLine', (fontSize: LogListFontSize) => {
beforeEach(() => {
log = createLogLine(
{ labels: { place: 'luna' }, entry: `log message 1` },
{ escape: false, order: LogsSortOrder.Descending, timeZone: 'browser', virtualization }
{ escape: false, order: LogsSortOrder.Descending, timeZone: 'browser', virtualization, wrapLogMessage: true }
);
contextProps.logs = [log];
contextProps.fontSize = fontSize;
@ -226,7 +226,7 @@ describe.each(fontSizes)('LogLine', (fontSize: LogListFontSize) => {
jest.spyOn(virtualization, 'getTruncationLength').mockReturnValue(5);
log = createLogLine(
{ labels: { place: 'luna' }, entry: `log message 1` },
{ escape: false, order: LogsSortOrder.Descending, timeZone: 'browser', virtualization }
{ escape: false, order: LogsSortOrder.Descending, timeZone: 'browser', virtualization, wrapLogMessage: true }
);
});
@ -425,3 +425,89 @@ describe.each(fontSizes)('LogLine', (fontSize: LogListFontSize) => {
});
});
});
describe('getGridTemplateColumns', () => {
test('Gets the template columns for the default visualization mode', () => {
expect(
getGridTemplateColumns(
[
{
field: 'timestamp',
width: 23,
},
{
field: 'level',
width: 4,
},
],
[]
)
).toBe('23px 4px 1fr');
});
test('Gets the template columns when displayed fields are used', () => {
expect(
getGridTemplateColumns(
[
{
field: 'timestamp',
width: 23,
},
{
field: 'level',
width: 4,
},
],
['field']
)
).toBe('23px 4px');
});
test('Gets the template columns when displayed fields are used', () => {
expect(
getGridTemplateColumns(
[
{
field: 'timestamp',
width: 23,
},
{
field: 'level',
width: 4,
},
{
field: 'field',
width: 4,
},
],
['field']
)
).toBe('23px 4px 4px');
});
test('Gets the template columns when displayed fields are used', () => {
expect(
getGridTemplateColumns(
[
{
field: 'timestamp',
width: 23,
},
{
field: 'level',
width: 4,
},
{
field: 'field',
width: 4,
},
{
field: LOG_LINE_BODY_FIELD_NAME,
width: 20,
},
],
['field']
)
).toBe('23px 4px 4px 20px');
});
});

@ -192,7 +192,7 @@ const LogLineComponent = memo(
{/* A button element could be used but in Safari it prevents text selection. Fallback available for a11y in LogLineMenu */}
{/* eslint-disable-next-line jsx-a11y/no-static-element-interactions, jsx-a11y/click-events-have-key-events */}
<div
className={`${wrapLogMessage ? styles.wrappedLogLine : `${styles.unwrappedLogLine} unwrapped-log-line`} ${collapsed === true ? styles.collapsedLogLine : ''} ${enableLogDetails ? styles.clickable : ''}`}
className={`${styles.fieldsWrapper} ${detailsShown ? styles.detailsDisplayed : ''} ${wrapLogMessage ? styles.wrappedLogLine : `${styles.unwrappedLogLine} unwrapped-log-line`} ${collapsed === true ? styles.collapsedLogLine : ''} ${enableLogDetails ? styles.clickable : ''}`}
style={
collapsed && virtualization
? { maxHeight: `${virtualization.getTruncationLineCount() * virtualization.getLineHeight()}px` }
@ -350,9 +350,10 @@ const LogLineBody = ({ log, styles }: { log: LogListModel; styles: LogLineStyles
return <span className="field log-syntax-highlight" dangerouslySetInnerHTML={{ __html: log.highlightedBody }} />;
};
export function getGridTemplateColumns(dimensions: LogFieldDimension[]) {
export function getGridTemplateColumns(dimensions: LogFieldDimension[], displayedFields: string[]) {
const columns = dimensions.map((dimension) => dimension.width).join('px ');
return `${columns}px 1fr`;
const logLineWidth = displayedFields.length > 0 ? '' : ' 1fr';
return `${columns}px${logLineWidth}`;
}
export type LogLineStyles = ReturnType<typeof getStyles>;
@ -368,6 +369,8 @@ export const getStyles = (theme: GrafanaTheme2, virtualization?: LogLineVirtuali
parsedField: theme.colors.text.primary,
};
const hoverColor = tinycolor(theme.colors.background.canvas).darken(4).toRgbString();
return {
logLine: css({
color: tinycolor(theme.colors.text.secondary).setAlpha(0.75).toRgbString(),
@ -379,7 +382,7 @@ export const getStyles = (theme: GrafanaTheme2, virtualization?: LogLineVirtuali
lineHeight: theme.typography.body.lineHeight,
wordBreak: 'break-all',
'&:hover': {
background: theme.isDark ? `hsla(0, 0%, 0%, 0.3)` : `hsla(0, 0%, 0%, 0.1)`,
background: hoverColor,
},
'&.infinite-scroll': {
'&::before': {
@ -440,7 +443,7 @@ export const getStyles = (theme: GrafanaTheme2, virtualization?: LogLineVirtuali
lineHeight: theme.typography.bodySmall.lineHeight,
}),
detailsDisplayed: css({
background: theme.isDark ? `hsla(0, 0%, 0%, 0.5)` : `hsla(0, 0%, 0%, 0.1)`,
background: hoverColor,
}),
pinnedLogLine: css({
backgroundColor: tinycolor(theme.colors.info.transparent).setAlpha(0.25).toString(),
@ -525,6 +528,9 @@ export const getStyles = (theme: GrafanaTheme2, virtualization?: LogLineVirtuali
gridColumnGap: theme.spacing(FIELD_GAP_MULTIPLIER),
whiteSpace: 'pre',
paddingBottom: theme.spacing(0.75),
'& .field': {
overflow: 'hidden',
},
}),
wrappedLogLine: css({
alignSelf: 'flex-start',
@ -537,6 +543,11 @@ export const getStyles = (theme: GrafanaTheme2, virtualization?: LogLineVirtuali
marginRight: 0,
},
}),
fieldsWrapper: css({
'&:hover': {
background: hoverColor,
},
}),
collapsedLogLine: css({
overflow: 'hidden',
}),

@ -248,7 +248,7 @@ const LogListComponent = ({
() => (wrapLogMessage ? [] : virtualization.calculateFieldDimensions(processedLogs, displayedFields)),
[displayedFields, processedLogs, virtualization, wrapLogMessage]
);
const styles = useStyles2(getStyles, dimensions, { showTime });
const styles = useStyles2(getStyles, dimensions, displayedFields, { showTime });
const widthContainer = wrapperRef.current ?? containerElement;
const {
closePopoverMenu,
@ -283,13 +283,13 @@ const LogListComponent = ({
setProcessedLogs(
preProcessLogs(
logs,
{ getFieldLinks, escape: forceEscape ?? false, order: sortOrder, timeZone, virtualization },
{ getFieldLinks, escape: forceEscape ?? false, order: sortOrder, timeZone, virtualization, wrapLogMessage },
grammar
)
);
virtualization.resetLogLineSizes();
listRef.current?.resetAfterIndex(0);
}, [forceEscape, getFieldLinks, grammar, loading, logs, sortOrder, timeZone, virtualization]);
}, [forceEscape, getFieldLinks, grammar, loading, logs, sortOrder, timeZone, virtualization, wrapLogMessage]);
useEffect(() => {
listRef.current?.resetAfterIndex(0);
@ -469,13 +469,18 @@ const LogListComponent = ({
);
};
function getStyles(theme: GrafanaTheme2, dimensions: LogFieldDimension[], { showTime }: { showTime: boolean }) {
function getStyles(
theme: GrafanaTheme2,
dimensions: LogFieldDimension[],
displayedFields: string[],
{ showTime }: { showTime: boolean }
) {
const columns = showTime ? dimensions : dimensions.filter((_, index) => index > 0);
return {
logList: css({
'& .unwrapped-log-line': {
display: 'grid',
gridTemplateColumns: getGridTemplateColumns(columns),
gridTemplateColumns: getGridTemplateColumns(columns, displayedFields),
},
}),
logListContainer: css({

@ -79,6 +79,7 @@ describe('preProcessLogs', () => {
getFieldLinks,
order: LogsSortOrder.Descending,
timeZone: 'browser',
wrapLogMessage: true,
});
});
@ -92,9 +93,53 @@ describe('preProcessLogs', () => {
entry: `35.191.12.195 - accounts.google.com:test@grafana.com [18/Mar/2025:08:58:38 +0000] 200 "POST /grafana/api/ds/query?ds_type=prometheus&requestId=SQR461 HTTP/1.1" 59460 "https://test.example.com/?orgId=1" "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/134.0.0.0 Safari/537.36" "95.91.240.90, 34.107.247.24"`,
logLevel: LogLevel.critical,
});
const logListModel = new LogListModel(logRowModel, { escape: false, timeZone: 'browser ' });
const logListModel = new LogListModel(logRowModel, { escape: false, timeZone: 'browser ', wrapLogMessage: true });
expect(logListModel).toMatchObject(logRowModel);
});
test('Unwrapped log lines strip new lines', () => {
const logListModel = createLogLine(
{ labels: { place: `lu\nna` }, entry: `log\n message\n 1` },
{
escape: false,
order: LogsSortOrder.Descending,
timeZone: 'browser',
wrapLogMessage: false, // unwrapped
}
);
expect(logListModel.getDisplayedFieldValue('place')).toBe('luna');
expect(logListModel.body).toBe('log message 1');
});
test('Wrapped log lines do not modify new lines', () => {
const logListModel = createLogLine(
{ labels: { place: `lu\nna` }, entry: `log\n message\n 1` },
{
escape: false,
order: LogsSortOrder.Descending,
timeZone: 'browser',
wrapLogMessage: true, // wrapped
}
);
expect(logListModel.getDisplayedFieldValue('place')).toBe(logListModel.labels['place']);
expect(logListModel.body).toBe(logListModel.raw);
});
test('Strips ansi colors for measurement', () => {
const logListModel = createLogLine(
{ entry: `log \u001B[31mmessage\u001B[0m 1` },
{
escape: false,
order: LogsSortOrder.Descending,
timeZone: 'browser',
wrapLogMessage: true,
}
);
expect(logListModel.getDisplayedFieldValue(LOG_LINE_BODY_FIELD_NAME, false)).toBe(
`log \u001B[31mmessage\u001B[0m 1`
);
expect(logListModel.getDisplayedFieldValue(LOG_LINE_BODY_FIELD_NAME, true)).toBe('log message 1');
});
});
test('Orders logs', () => {
@ -176,7 +221,7 @@ describe('preProcessLogs', () => {
entry = new Array(2 * virtualization.getTruncationLength(null)).fill('e').join('');
longLog = createLogLine(
{ entry, labels: { field: 'value' } },
{ escape: false, order: LogsSortOrder.Descending, timeZone: 'browser', virtualization }
{ escape: false, order: LogsSortOrder.Descending, timeZone: 'browser', virtualization, wrapLogMessage: true }
);
});

@ -1,3 +1,4 @@
import ansicolor from 'ansicolor';
import Prism, { Grammar } from 'prismjs';
import { DataFrame, dateTimeFormat, Labels, LogLevel, LogRowModel, LogsSortOrder } from '@grafana/data';
@ -11,6 +12,7 @@ import { generateLogGrammar, generateTextMatchGrammar } from './grammar';
import { LogLineVirtualization } from './virtualization';
const TRUNCATION_DEFAULT_LENGTH = 50000;
const NEWLINES_REGEX = /(\r\n|\n|\r)/g;
export class LogListModel implements LogRowModel {
collapsed: boolean | undefined = undefined;
@ -47,8 +49,12 @@ export class LogListModel implements LogRowModel {
private _fields: FieldDef[] | undefined = undefined;
private _getFieldLinks: GetFieldLinksFn | undefined = undefined;
private _virtualization?: LogLineVirtualization;
private _wrapLogMessage: boolean;
constructor(log: LogRowModel, { escape, getFieldLinks, grammar, timeZone, virtualization }: PreProcessLogOptions) {
constructor(
log: LogRowModel,
{ escape, getFieldLinks, grammar, timeZone, virtualization, wrapLogMessage }: PreProcessLogOptions
) {
// LogRowModel
this.datasourceType = log.datasourceType;
this.dataFrame = log.dataFrame;
@ -82,6 +88,7 @@ export class LogListModel implements LogRowModel {
defaultWithMS: true,
});
this._virtualization = virtualization;
this._wrapLogMessage = wrapLogMessage;
let raw = log.raw;
if (escape && log.hasUnescapedContent) {
@ -95,6 +102,9 @@ export class LogListModel implements LogRowModel {
this._body = this.collapsed
? this.raw.substring(0, this._virtualization?.getTruncationLength(null) ?? TRUNCATION_DEFAULT_LENGTH)
: this.raw;
if (!this._wrapLogMessage) {
this._body = this._body.replace(NEWLINES_REGEX, '');
}
}
return this._body;
}
@ -123,25 +133,31 @@ export class LogListModel implements LogRowModel {
return checkLogsSampled(this);
}
getDisplayedFieldValue(fieldName: string): string {
getDisplayedFieldValue(fieldName: string, stripAnsi = false): string {
if (fieldName === LOG_LINE_BODY_FIELD_NAME) {
return this.body;
return stripAnsi ? ansicolor.strip(this.body) : this.body;
}
let fieldValue = '';
if (this.labels[fieldName] != null) {
return this.labels[fieldName];
}
const field = this.fields.find((field) => {
return field.keys[0] === fieldName;
});
fieldValue = this.labels[fieldName];
} else {
const field = this.fields.find((field) => {
return field.keys[0] === fieldName;
});
return field ? field.values.toString() : '';
fieldValue = field ? field.values.toString() : '';
}
if (!this._wrapLogMessage) {
return fieldValue.replace(NEWLINES_REGEX, '');
}
return fieldValue;
}
updateCollapsedState(displayedFields: string[], container: HTMLDivElement | null) {
const lineLength =
displayedFields.length > 0
? displayedFields.map((field) => this.getDisplayedFieldValue(field)).join('').length
: this.raw.length;
? displayedFields.map((field) => this.getDisplayedFieldValue(field, true)).join('').length
: this.entry.length;
const collapsed =
lineLength >= (this._virtualization?.getTruncationLength(container) ?? TRUNCATION_DEFAULT_LENGTH)
? true
@ -172,15 +188,18 @@ export interface PreProcessOptions {
order: LogsSortOrder;
timeZone: string;
virtualization?: LogLineVirtualization;
wrapLogMessage: boolean;
}
export const preProcessLogs = (
logs: LogRowModel[],
{ escape, getFieldLinks, order, timeZone, virtualization }: PreProcessOptions,
{ escape, getFieldLinks, order, timeZone, virtualization, wrapLogMessage }: PreProcessOptions,
grammar?: Grammar
): LogListModel[] => {
const orderedLogs = sortLogRows(logs, order);
return orderedLogs.map((log) => preProcessLog(log, { escape, getFieldLinks, grammar, timeZone, virtualization }));
return orderedLogs.map((log) =>
preProcessLog(log, { escape, getFieldLinks, grammar, timeZone, virtualization, wrapLogMessage })
);
};
interface PreProcessLogOptions {
@ -189,6 +208,7 @@ interface PreProcessLogOptions {
grammar?: Grammar;
timeZone: string;
virtualization?: LogLineVirtualization;
wrapLogMessage: boolean;
}
const preProcessLog = (log: LogRowModel, options: PreProcessLogOptions): LogListModel => {
return new LogListModel(log, options);

@ -3,7 +3,7 @@ import { createTheme, LogsSortOrder } from '@grafana/data';
import { LOG_LINE_BODY_FIELD_NAME } from '../LogDetailsBody';
import { createLogLine } from '../__mocks__/logRow';
import { LogListModel } from './processing';
import { LogListModel, PreProcessOptions } from './processing';
import { LogLineVirtualization, getLogLineSize, DisplayOptions } from './virtualization';
describe('Virtualization', () => {
@ -28,12 +28,16 @@ describe('Virtualization', () => {
hasSampledLogs: false,
};
const preProcessOptions: PreProcessOptions = {
escape: false,
order: LogsSortOrder.Descending,
timeZone: 'browser',
virtualization,
wrapLogMessage: true,
};
beforeEach(() => {
log = createLogLine(
{ labels: { place: 'luna' }, entry: `log message 1` },
{ escape: false, order: LogsSortOrder.Descending, timeZone: 'browser', virtualization }
);
//virtualization = new LogLineVirtualization(createTheme(), 'default');
log = createLogLine({ labels: { place: 'luna' }, entry: `log message 1` }, preProcessOptions);
container = document.createElement('div');
jest.spyOn(container, 'clientWidth', 'get').mockReturnValue(CONTAINER_SIZE);
LETTER_WIDTH = virtualization.measureTextWidth('e');
@ -86,7 +90,7 @@ describe('Virtualization', () => {
entry: new Array(TWO_LINES_OF_CHARACTERS).fill('e').join(''),
logLevel: undefined,
},
{ escape: false, order: LogsSortOrder.Descending, timeZone: 'browser', virtualization }
preProcessOptions
);
const size = getLogLineSize(virtualization, [log], container, [], { ...defaultOptions, wrap: true }, 0);
@ -96,7 +100,7 @@ describe('Virtualization', () => {
test('Measures a multi-line log line with level, controls, and displayed time', () => {
log = createLogLine(
{ labels: { place: 'luna' }, entry: new Array(TWO_LINES_OF_CHARACTERS).fill('e').join('') },
{ escape: false, order: LogsSortOrder.Descending, timeZone: 'browser', virtualization }
preProcessOptions
);
const size = getLogLineSize(
@ -118,7 +122,7 @@ describe('Virtualization', () => {
entry: new Array(TWO_LINES_OF_CHARACTERS).fill('e').join(''),
logLevel: undefined,
},
{ escape: false, order: LogsSortOrder.Descending, timeZone: 'browser', virtualization }
preProcessOptions
);
const size = getLogLineSize(
@ -136,7 +140,7 @@ describe('Virtualization', () => {
test('Measures displayed fields in a log line with level, controls, and displayed time', () => {
log = createLogLine(
{ labels: { place: 'luna' }, entry: new Array(TWO_LINES_OF_CHARACTERS).fill('e').join('') },
{ escape: false, order: LogsSortOrder.Descending, timeZone: 'browser', virtualization }
preProcessOptions
);
const size = getLogLineSize(
@ -154,7 +158,7 @@ describe('Virtualization', () => {
test('Measures a multi-line log line with duplicates', () => {
log = createLogLine(
{ labels: { place: 'luna' }, entry: new Array(TWO_LINES_OF_CHARACTERS).fill('e').join('') },
{ escape: false, order: LogsSortOrder.Descending, timeZone: 'browser', virtualization }
preProcessOptions
);
log.duplicates = 1;
@ -173,7 +177,7 @@ describe('Virtualization', () => {
test('Measures a multi-line log line with errors', () => {
log = createLogLine(
{ labels: { place: 'luna' }, entry: new Array(TWO_LINES_OF_CHARACTERS).fill('e').join('') },
{ escape: false, order: LogsSortOrder.Descending, timeZone: 'browser', virtualization }
preProcessOptions
);
const size = getLogLineSize(
@ -191,7 +195,7 @@ describe('Virtualization', () => {
test('Measures a multi-line sampled log line', () => {
log = createLogLine(
{ labels: { place: 'luna' }, entry: new Array(TWO_LINES_OF_CHARACTERS).fill('e').join('') },
{ escape: false, order: LogsSortOrder.Descending, timeZone: 'browser', virtualization }
preProcessOptions
);
const size = getLogLineSize(
@ -214,6 +218,29 @@ describe('Virtualization', () => {
});
});
describe('calculateFieldDimensions', () => {
test('Measures displayed fields including the log line body', () => {
expect(virtualization.calculateFieldDimensions([log], ['place', LOG_LINE_BODY_FIELD_NAME])).toEqual([
{
field: 'timestamp',
width: 23,
},
{
field: 'level',
width: 4,
},
{
field: 'place',
width: 4,
},
{
field: '___LOG_LINE_BODY___',
width: 13,
},
]);
});
});
describe('With small font size', () => {
const virtualization = new LogLineVirtualization(createTheme(), 'small');
@ -232,7 +259,7 @@ describe('Virtualization', () => {
entry: new Array(TWO_LINES_OF_CHARACTERS).fill('e').join(''),
logLevel: undefined,
},
{ escape: false, order: LogsSortOrder.Descending, timeZone: 'browser', virtualization }
preProcessOptions
);
const size = getLogLineSize(

@ -2,8 +2,6 @@ import ansicolor from 'ansicolor';
import { BusEventWithPayload, GrafanaTheme2 } from '@grafana/data';
import { LOG_LINE_BODY_FIELD_NAME } from '../LogDetailsBody';
import { LogListFontSize } from './LogList';
import { LogListModel } from './processing';
@ -195,7 +193,7 @@ export class LogLineVirtualization {
levelWidth = Math.round(width);
}
for (const field of displayedFields) {
width = this.measureTextWidth(logs[i].getDisplayedFieldValue(field));
width = this.measureTextWidth(logs[i].getDisplayedFieldValue(field, true));
fieldWidths[field] = !fieldWidths[field] || width > fieldWidths[field] ? Math.round(width) : fieldWidths[field];
}
}
@ -210,10 +208,6 @@ export class LogLineVirtualization {
},
];
for (const field in fieldWidths) {
// Skip the log line when it's a displayed field
if (field === LOG_LINE_BODY_FIELD_NAME) {
continue;
}
dimensions.push({
field,
width: fieldWidths[field],
@ -294,7 +288,7 @@ export function getLogLineSize(
textToMeasure += logs[index].displayLevel ?? '';
}
for (const field of displayedFields) {
textToMeasure = logs[index].getDisplayedFieldValue(field) + textToMeasure;
textToMeasure = logs[index].getDisplayedFieldValue(field, true) + textToMeasure;
}
if (!displayedFields.length) {
textToMeasure += ansicolor.strip(logs[index].body);

Loading…
Cancel
Save