diff --git a/public/app/features/logs/components/__mocks__/logRow.ts b/public/app/features/logs/components/__mocks__/logRow.ts index 09aa16998da..9efe2ee97eb 100644 --- a/public/app/features/logs/components/__mocks__/logRow.ts +++ b/public/app/features/logs/components/__mocks__/logRow.ts @@ -46,6 +46,7 @@ export const createLogLine = ( order: LogsSortOrder.Descending, timeZone: 'browser', virtualization: undefined, + wrapLogMessage: true, } ): LogListModel => { const logs = preProcessLogs([createLogRow(overrides)], processOptions); diff --git a/public/app/features/logs/components/panel/LogLine.test.tsx b/public/app/features/logs/components/panel/LogLine.test.tsx index 6a2152b2e9f..bdbbf74b2f2 100644 --- a/public/app/features/logs/components/panel/LogLine.test.tsx +++ b/public/app/features/logs/components/panel/LogLine.test.tsx @@ -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'); + }); +}); diff --git a/public/app/features/logs/components/panel/LogLine.tsx b/public/app/features/logs/components/panel/LogLine.tsx index a8d87abc92f..3e7ac11bc96 100644 --- a/public/app/features/logs/components/panel/LogLine.tsx +++ b/public/app/features/logs/components/panel/LogLine.tsx @@ -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 */}
; }; -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; @@ -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', }), diff --git a/public/app/features/logs/components/panel/LogList.tsx b/public/app/features/logs/components/panel/LogList.tsx index 58a82266bcc..acdbc0dc593 100644 --- a/public/app/features/logs/components/panel/LogList.tsx +++ b/public/app/features/logs/components/panel/LogList.tsx @@ -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({ diff --git a/public/app/features/logs/components/panel/processing.test.ts b/public/app/features/logs/components/panel/processing.test.ts index 72461861e8b..129d97451da 100644 --- a/public/app/features/logs/components/panel/processing.test.ts +++ b/public/app/features/logs/components/panel/processing.test.ts @@ -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 } ); }); diff --git a/public/app/features/logs/components/panel/processing.ts b/public/app/features/logs/components/panel/processing.ts index f891f89b097..60e51ca91d4 100644 --- a/public/app/features/logs/components/panel/processing.ts +++ b/public/app/features/logs/components/panel/processing.ts @@ -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); diff --git a/public/app/features/logs/components/panel/virtualization.test.ts b/public/app/features/logs/components/panel/virtualization.test.ts index b474332616c..ff1789ad50e 100644 --- a/public/app/features/logs/components/panel/virtualization.test.ts +++ b/public/app/features/logs/components/panel/virtualization.test.ts @@ -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( diff --git a/public/app/features/logs/components/panel/virtualization.ts b/public/app/features/logs/components/panel/virtualization.ts index fd4a0280fe6..2f9b6b6773a 100644 --- a/public/app/features/logs/components/panel/virtualization.ts +++ b/public/app/features/logs/components/panel/virtualization.ts @@ -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);