From b63b4596a05008b5b66117c7713dce056f796fc5 Mon Sep 17 00:00:00 2001 From: Matias Chomicki Date: Wed, 26 Mar 2025 12:48:26 +0100 Subject: [PATCH] New Logs Panel: Log line tokenization and syntax highlighting (#101401) * Logs: preprocess and highlight with Prism * Processing: highlight logs on demand * LogList: reset sizes when wrap changes * Extend base grammar to support numbers, dates, strings, and fields * Refactor grammar * LogList: fix size recalculation after logs update * LogLine: add ansi support * Processing: Use raw instead of entry * Capitalize level * Remove colors from timestamps * added highlight colors * Fix oopsies * LogLine: Remove repeated css attribute * Log grammar: update log token string * Formatting * LogLine: fix underflow detection * Grammar: fine tune, add durations * LogLine: change rgba colors to theme colors * Update tests * Grammar: add uids * LogLine: dont render empty logLevel when logs are wrapped * Virtualization: fix calculation when level is unknown * Remove console log * Chore: fix log line message * LogLine: remove opacity change on hover * LogLine: tweak colors and highlight urls * Tweak colors, remove numbers * Remove unnecessary selector * Fix imports * Chore: move dimensions code to virtualization * processing: add unit tests * Revert change * Grammar: add unit tests * Remove stale assertion * LogLine: define critical color * Fix alpha dependency --------- Co-authored-by: Joan Co-authored-by: Joan Wortman --- .../logs/components/__mocks__/logRow.ts | 1 - .../logs/components/panel/LogLine.test.tsx | 8 +- .../logs/components/panel/LogLine.tsx | 116 ++++++++++---- .../logs/components/panel/LogList.tsx | 14 +- .../logs/components/panel/grammar.test.ts | 87 +++++++++++ .../features/logs/components/panel/grammar.ts | 25 ++++ .../logs/components/panel/processing.test.ts | 141 ++++++++++++++++++ .../logs/components/panel/processing.ts | 84 +++-------- .../logs/components/panel/virtualization.ts | 58 ++++++- 9 files changed, 423 insertions(+), 111 deletions(-) create mode 100644 public/app/features/logs/components/panel/grammar.test.ts create mode 100644 public/app/features/logs/components/panel/grammar.ts create mode 100644 public/app/features/logs/components/panel/processing.test.ts diff --git a/public/app/features/logs/components/__mocks__/logRow.ts b/public/app/features/logs/components/__mocks__/logRow.ts index d7777eed731..b87c6436b74 100644 --- a/public/app/features/logs/components/__mocks__/logRow.ts +++ b/public/app/features/logs/components/__mocks__/logRow.ts @@ -45,7 +45,6 @@ export const createLogLine = ( escape: false, order: LogsSortOrder.Descending, timeZone: 'browser', - wrap: false, } ): 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 15640f13ea8..774bd22612d 100644 --- a/public/app/features/logs/components/panel/LogLine.test.tsx +++ b/public/app/features/logs/components/panel/LogLine.test.tsx @@ -15,7 +15,7 @@ const styles = getStyles(theme); describe('LogLine', () => { let log: LogListModel; beforeEach(() => { - log = createLogLine({ labels: { place: 'luna' } }); + log = createLogLine({ labels: { place: 'luna' }, entry: `log message 1` }); }); test('Renders a log line', () => { @@ -31,7 +31,7 @@ describe('LogLine', () => { /> ); expect(screen.getByText(log.timestamp)).toBeInTheDocument(); - expect(screen.getByText(log.body)).toBeInTheDocument(); + expect(screen.getByText('log message 1')).toBeInTheDocument(); }); test('Renders a log line with no timestamp', () => { @@ -47,7 +47,7 @@ describe('LogLine', () => { /> ); expect(screen.queryByText(log.timestamp)).not.toBeInTheDocument(); - expect(screen.getByText(log.body)).toBeInTheDocument(); + expect(screen.getByText('log message 1')).toBeInTheDocument(); }); test('Renders a log line with displayed fields', () => { @@ -80,7 +80,7 @@ describe('LogLine', () => { /> ); expect(screen.getByText(log.timestamp)).toBeInTheDocument(); - expect(screen.getByText(log.body)).toBeInTheDocument(); + expect(screen.getByText('log message 1')).toBeInTheDocument(); expect(screen.getByText('luna')).toBeInTheDocument(); }); diff --git a/public/app/features/logs/components/panel/LogLine.tsx b/public/app/features/logs/components/panel/LogLine.tsx index 58004ccc456..9c26236b9b4 100644 --- a/public/app/features/logs/components/panel/LogLine.tsx +++ b/public/app/features/logs/components/panel/LogLine.tsx @@ -5,11 +5,12 @@ import tinycolor from 'tinycolor2'; import { GrafanaTheme2 } from '@grafana/data'; import { LOG_LINE_BODY_FIELD_NAME } from '../LogDetailsBody'; +import { LogMessageAnsi } from '../LogMessageAnsi'; import { LogLineMenu } from './LogLineMenu'; import { useLogIsPinned } from './LogListContext'; -import { LogFieldDimension, LogListModel } from './processing'; -import { FIELD_GAP_MULTIPLIER, hasUnderOrOverflow, getLineHeight } from './virtualization'; +import { LogListModel } from './processing'; +import { FIELD_GAP_MULTIPLIER, hasUnderOrOverflow, getLineHeight, LogFieldDimension } from './virtualization'; interface Props { displayedFields: string[]; @@ -56,7 +57,13 @@ export const LogLine = ({ >
- +
); @@ -67,26 +74,51 @@ interface LogProps { log: LogListModel; showTime: boolean; styles: LogLineStyles; + wrapLogMessage: boolean; } -const Log = ({ displayedFields, log, showTime, styles }: LogProps) => { +const Log = ({ displayedFields, log, showTime, styles, wrapLogMessage }: LogProps) => { return ( <> {showTime && {log.timestamp}} - {log.displayLevel} + { + // When logs are unwrapped, we want an empty column space to align with other log lines. + } + {(log.displayLevel || !wrapLogMessage) && ( + {log.displayLevel} + )} {displayedFields.length > 0 ? ( - displayedFields.map((field) => ( - - {getDisplayedFieldValue(field, log)} - - )) + displayedFields.map((field) => + field === LOG_LINE_BODY_FIELD_NAME ? ( + + ) : ( + + {getDisplayedFieldValue(field, log)} + + ) + ) ) : ( - {log.body} + )} ); }; +const LogLineBody = ({ log }: { log: LogListModel }) => { + if (log.hasAnsi) { + const needsHighlighter = + log.searchWords && log.searchWords.length > 0 && log.searchWords[0] && log.searchWords[0].length > 0; + const highlight = needsHighlighter ? { searchWords: log.searchWords ?? [], highlightClassName: '' } : undefined; + return ( + + + + ); + } + + return ; +}; + export function getDisplayedFieldValue(fieldName: string, log: LogListModel): string { if (fieldName === LOG_LINE_BODY_FIELD_NAME) { return log.body; @@ -110,16 +142,18 @@ export type LogLineStyles = ReturnType; export const getStyles = (theme: GrafanaTheme2) => { const colors = { critical: '#B877D9', - error: '#FF5286', + error: '#f22f44', warning: '#FBAD37', debug: '#6CCF8E', trace: '#6ed0e0', info: '#6E9FFF', + metadata: theme.colors.text.primary, + parsedField: theme.colors.text.primary, }; return { logLine: css({ - color: theme.colors.text.primary, + color: tinycolor(theme.colors.text.secondary).setAlpha(0.75).toRgbString(), display: 'flex', gap: theme.spacing(0.5), flexDirection: 'row', @@ -127,7 +161,7 @@ export const getStyles = (theme: GrafanaTheme2) => { fontSize: theme.typography.fontSize, wordBreak: 'break-all', '&:hover': { - opacity: 0.7, + background: `hsla(0, 0%, 0%, 0.1)`, }, '&.infinite-scroll': { '&::before': { @@ -140,6 +174,37 @@ export const getStyles = (theme: GrafanaTheme2) => { width: '100%', }, }, + '& .log-syntax-highlight': { + '.log-token-string': { + color: tinycolor(theme.colors.text.secondary).setAlpha(0.75).toRgbString(), + }, + '.log-token-duration': { + color: theme.colors.success.text, + }, + '.log-token-size': { + color: theme.colors.success.text, + }, + '.log-token-uuid': { + color: theme.colors.success.text, + }, + '.log-token-key': { + color: colors.parsedField, + opacity: 0.9, + fontWeight: theme.typography.fontWeightMedium, + }, + '.log-token-json-key': { + color: colors.parsedField, + opacity: 0.9, + fontWeight: theme.typography.fontWeightMedium, + }, + '.log-token-label': { + color: colors.metadata, + fontWeight: theme.typography.fontWeightBold, + }, + '.log-token-method': { + color: theme.colors.info.shade, + }, + }, }), pinnedLogLine: css({ backgroundColor: tinycolor(theme.colors.info.transparent).setAlpha(0.25).toString(), @@ -151,30 +216,16 @@ export const getStyles = (theme: GrafanaTheme2) => { }), logLineMessage: css({ fontFamily: theme.typography.fontFamily, - textAlign: 'center', + justifyContent: 'center', }), timestamp: css({ - color: theme.colors.text.secondary, + color: theme.colors.text.disabled, display: 'inline-block', - '&.level-critical': { - color: colors.critical, - }, - '&.level-error': { - color: colors.error, - }, - '&.level-info': { - color: colors.info, - }, - '&.level-warning': { - color: colors.warning, - }, - '&.level-debug': { - color: colors.debug, - }, }), level: css({ color: theme.colors.text.secondary, fontWeight: theme.typography.fontWeightBold, + textTransform: 'uppercase', display: 'inline-block', '&.level-critical': { color: colors.critical, @@ -207,8 +258,9 @@ export const getStyles = (theme: GrafanaTheme2) => { paddingBottom: theme.spacing(0.75), }), wrappedLogLine: css({ - whiteSpace: 'pre-wrap', + alignSelf: 'flex-start', paddingBottom: theme.spacing(0.75), + whiteSpace: 'pre-wrap', '& .field': { marginRight: theme.spacing(FIELD_GAP_MULTIPLIER), }, diff --git a/public/app/features/logs/components/panel/LogList.tsx b/public/app/features/logs/components/panel/LogList.tsx index ae8234bd060..3d645e917ff 100644 --- a/public/app/features/logs/components/panel/LogList.tsx +++ b/public/app/features/logs/components/panel/LogList.tsx @@ -20,10 +20,12 @@ import { InfiniteScroll } from './InfiniteScroll'; import { getGridTemplateColumns } from './LogLine'; import { GetRowContextQueryFn } from './LogLineMenu'; import { LogListContext } from './LogListContext'; -import { preProcessLogs, LogListModel, calculateFieldDimensions, LogFieldDimension } from './processing'; +import { preProcessLogs, LogListModel } from './processing'; import { + calculateFieldDimensions, getLogLineSize, init as initVirtualization, + LogFieldDimension, resetLogLineSizes, ScrollToLogsEvent, storeLogLineSize, @@ -99,11 +101,13 @@ export const LogList = ({ }, [eventBus, logs.length]); useEffect(() => { - setProcessedLogs( - preProcessLogs(logs, { getFieldLinks, wrap: wrapLogMessage, escape: forceEscape, order: sortOrder, timeZone }) - ); + setProcessedLogs(preProcessLogs(logs, { getFieldLinks, escape: forceEscape, order: sortOrder, timeZone })); + }, [forceEscape, getFieldLinks, logs, sortOrder, timeZone]); + + useEffect(() => { + resetLogLineSizes(); listRef.current?.resetAfterIndex(0); - }, [forceEscape, getFieldLinks, logs, sortOrder, timeZone, wrapLogMessage]); + }, [wrapLogMessage, processedLogs]); useEffect(() => { const handleResize = debounce(() => { diff --git a/public/app/features/logs/components/panel/grammar.test.ts b/public/app/features/logs/components/panel/grammar.test.ts new file mode 100644 index 00000000000..c50cafbbd4a --- /dev/null +++ b/public/app/features/logs/components/panel/grammar.test.ts @@ -0,0 +1,87 @@ +import Prism, { Token } from 'prismjs'; + +import { createLogLine } from '../__mocks__/logRow'; + +import { generateLogGrammar } from './grammar'; + +describe('generateLogGrammar', () => { + function generateScenario(entry: string) { + const log = createLogLine({ labels: { place: 'luna', source: 'logs' }, entry }); + const grammar = generateLogGrammar(log); + const tokens = Prism.tokenize(log.entry, grammar); + return { log, grammar, tokens }; + } + + test('Identifies uuid tokens', () => { + const { tokens } = generateScenario('15f77b91-aedb-48d2-a551-ca4a7927cea4'); + if (tokens[0] instanceof Token) { + expect(tokens).toHaveLength(1); + expect(tokens[0].content).toBe('15f77b91-aedb-48d2-a551-ca4a7927cea4'); + expect(tokens[0].type).toBe('log-token-uuid'); + } + expect.hasAssertions(); + }); + + test('Identifies json keys and quoted values', () => { + const { tokens } = generateScenario('{"key":"value", "key2":"value2"}'); + if (tokens[1] instanceof Token) { + expect(tokens[1].content).toBe('"key"'); + expect(tokens[1].type).toBe('log-token-json-key'); + } + if (tokens[3] instanceof Token) { + expect(tokens[3].content).toBe('"value"'); + expect(tokens[3].type).toBe('log-token-string'); + } + if (tokens[5] instanceof Token) { + expect(tokens[5].content).toBe('"key2"'); + expect(tokens[5].type).toBe('log-token-json-key'); + } + if (tokens[7] instanceof Token) { + expect(tokens[7].content).toBe('"value2"'); + expect(tokens[7].type).toBe('log-token-string'); + } + expect.assertions(8); + }); + + test('Identifies sizes', () => { + const { tokens } = generateScenario('1mb 2 KB'); + if (tokens[0] instanceof Token) { + expect(tokens[0].content).toBe('1mb'); + expect(tokens[0].type).toBe('log-token-size'); + } + if (tokens[2] instanceof Token) { + expect(tokens[2].content).toBe('2 KB'); + expect(tokens[2].type).toBe('log-token-size'); + } + expect.assertions(4); + }); + + test('Identifies durations', () => { + const { tokens } = generateScenario('1ms 2µs 1h'); + if (tokens[0] instanceof Token) { + expect(tokens[0].content).toBe('1ms'); + expect(tokens[0].type).toBe('log-token-duration'); + } + if (tokens[2] instanceof Token) { + expect(tokens[2].content).toBe('2µs'); + expect(tokens[2].type).toBe('log-token-duration'); + } + if (tokens[4] instanceof Token) { + expect(tokens[4].content).toBe('1h'); + expect(tokens[4].type).toBe('log-token-duration'); + } + expect.assertions(6); + }); + + test.each(['GET', 'POST', 'PUT', 'DELETE', 'PATCH', 'HEAD', 'OPTIONS', 'TRACE', 'CONNECT'])( + 'Identifies HTTP methods', + (method: string) => { + const { tokens } = generateScenario(`200 "${method} /whatever HTTP/1.1" 295`); + if (tokens[1] instanceof Token) { + expect(tokens[1].content).toBe(method); + expect(tokens[1].type).toBe('log-token-method'); + } + expect.assertions(2); + } + ); +}); diff --git a/public/app/features/logs/components/panel/grammar.ts b/public/app/features/logs/components/panel/grammar.ts new file mode 100644 index 00000000000..05686f81ddd --- /dev/null +++ b/public/app/features/logs/components/panel/grammar.ts @@ -0,0 +1,25 @@ +import { Grammar } from 'prismjs'; + +import { LogListModel } from './processing'; + +// The Logs grammar is used for highlight in the logs panel +export const logsGrammar: Grammar = { + 'log-token-uuid': /[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-5][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}/g, + 'log-token-json-key': /"(\b|\B)[\w-]+"(?=\s*:)/gi, + 'log-token-key': /(\b|\B)[\w_]+(?=\s*=)/gi, + 'log-token-size': /(?:\b|")\d+\.{0,1}\d*\s*[kKmMGgtTPp]*[bB]{1}(?:"|\b)/g, + 'log-token-duration': /(?:\b)\d+(\.\d+)?(ns|µs|ms|s|m|h|d)(?:\b)/g, + 'log-token-method': /\b(GET|POST|PUT|DELETE|PATCH|HEAD|OPTIONS|TRACE|CONNECT)\b/g, + 'log-token-string': /"(?!:)([^'"])*?"(?!:)/g, +}; + +export const generateLogGrammar = (log: LogListModel) => { + const labels = Object.keys(log.labels).concat(log.fields.map((field) => field.keys[0])); + const logGrammar: Grammar = { + 'log-token-label': new RegExp(`\\b(${labels.join('|')})(?:[=:]{1})\\b`, 'g'), + }; + return { + ...logGrammar, + ...logsGrammar, + }; +}; diff --git a/public/app/features/logs/components/panel/processing.test.ts b/public/app/features/logs/components/panel/processing.test.ts new file mode 100644 index 00000000000..e8f9071c4e3 --- /dev/null +++ b/public/app/features/logs/components/panel/processing.test.ts @@ -0,0 +1,141 @@ +import { Field, FieldType, LogLevel, LogRowModel, LogsSortOrder, toDataFrame } from '@grafana/data'; + +import { createLogRow } from '../__mocks__/logRow'; + +import { LogListModel, preProcessLogs } from './processing'; + +describe('preProcessLogs', () => { + let logFmtLog: LogRowModel, nginxLog: LogRowModel, jsonLog: LogRowModel; + let processedLogs: LogListModel[]; + + beforeEach(() => { + const getFieldLinks = jest.fn().mockImplementationOnce((field: Field) => ({ + href: '/link', + title: 'link', + target: '_blank', + origin: field, + })); + logFmtLog = createLogRow({ + uid: '1', + timeEpochMs: 3, + labels: { level: 'warn', logger: 'interceptor' }, + entry: `logger=interceptor t=2025-03-18T08:58:34.820119602Z level=warn msg="calling resource store as the service without id token or marking it as the service identity" subject=:0 uid=43eb4c92-18a0-4060-be96-37af854f0830`, + logLevel: LogLevel.warning, + rowIndex: 0, + dataFrame: toDataFrame({ + refId: 'A', + fields: [ + { name: 'Time', type: FieldType.time, values: [3, 2, 1] }, + { + name: 'Line', + type: FieldType.string, + values: ['log message 1', 'log message 2', 'log message 3'], + }, + { + name: 'labels', + type: FieldType.other, + values: [ + { level: 'warn', logger: 'interceptor' }, + { method: 'POST', status: '200' }, + { kind: 'Event', stage: 'ResponseComplete' }, + ], + }, + { + name: 'link', + type: FieldType.string, + config: { + links: [ + { + title: 'link1', + url: 'https://example.com', + }, + ], + }, + values: ['link'], + }, + ], + }), + }); + nginxLog = createLogRow({ + uid: '2', + timeEpochMs: 2, + labels: { method: 'POST', status: '200' }, + 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, + }); + jsonLog = createLogRow({ + uid: '3', + timeEpochMs: 1, + labels: { kind: 'Event', stage: 'ResponseComplete' }, + entry: `{"kind":"Event","apiVersion":"audit.k8s.io/v1","level":"Request","auditID":"2052d577-3391-4fe7-9fe2-4d6c8cbe398f","stage":"ResponseComplete","requestURI":"/api/v1/test","verb":"list","user":{"username":"system:apiserver","uid":"6f35feec-4522-4f21-8289-668e336967b5","groups":["system:authenticated","system:masters"]},"sourceIPs":["::1"],"userAgent":"kube-apiserver/v1.31.5 (linux/amd64) kubernetes/test","objectRef":{"resource":"resourcequotas","namespace":"test","apiVersion":"v1"},"responseStatus":{"metadata":{},"code":200},"requestReceivedTimestamp":"2025-03-18T08:58:34.940093Z"}`, + logLevel: LogLevel.error, + }); + processedLogs = preProcessLogs([logFmtLog, nginxLog, jsonLog], { + escape: false, + getFieldLinks, + order: LogsSortOrder.Descending, + timeZone: 'browser', + }); + }); + + test('Orders logs', () => { + expect(processedLogs[0].uid).toBe('1'); + expect(processedLogs[1].uid).toBe('2'); + expect(processedLogs[2].uid).toBe('3'); + }); + + test('Sets the display level level', () => { + expect(processedLogs[0].displayLevel).toBe('warn'); + expect(processedLogs[1].displayLevel).toBe('crit'); + expect(processedLogs[2].displayLevel).toBe('error'); + }); + + test('Sets the log fields links', () => { + expect(processedLogs[0].fields).toEqual([ + { + fieldIndex: 3, + keys: ['link'], + links: { + href: '/link', + origin: { + config: { + links: [ + { + title: 'link1', + url: 'https://example.com', + }, + ], + }, + index: 3, + name: 'link', + type: 'string', + values: ['link'], + }, + target: '_blank', + title: 'link', + }, + values: ['link'], + }, + ]); + expect(processedLogs[1].fields).toEqual([]); + expect(processedLogs[2].fields).toEqual([]); + }); + + test('Highlights tokens in log lines', () => { + expect(processedLogs[0].highlightedBody).toContain('log-token-label'); + expect(processedLogs[0].highlightedBody).toContain('log-token-key'); + expect(processedLogs[0].highlightedBody).toContain('log-token-string'); + expect(processedLogs[0].highlightedBody).toContain('log-token-uuid'); + expect(processedLogs[0].highlightedBody).not.toContain('log-token-method'); + expect(processedLogs[0].highlightedBody).not.toContain('log-token-json-key'); + + expect(processedLogs[1].highlightedBody).toContain('log-token-method'); + expect(processedLogs[1].highlightedBody).toContain('log-token-key'); + expect(processedLogs[1].highlightedBody).toContain('log-token-string'); + expect(processedLogs[1].highlightedBody).not.toContain('log-token-json-key'); + + expect(processedLogs[2].highlightedBody).toContain('log-token-json-key'); + expect(processedLogs[2].highlightedBody).toContain('log-token-string'); + expect(processedLogs[2].highlightedBody).not.toContain('log-token-method'); + }); +}); diff --git a/public/app/features/logs/components/panel/processing.ts b/public/app/features/logs/components/panel/processing.ts index ef2c2c81b12..f536264831d 100644 --- a/public/app/features/logs/components/panel/processing.ts +++ b/public/app/features/logs/components/panel/processing.ts @@ -1,53 +1,44 @@ +import Prism from 'prismjs'; + import { dateTimeFormat, LogLevel, LogRowModel, LogsSortOrder } from '@grafana/data'; import { escapeUnescapedString, sortLogRows } from '../../utils'; -import { LOG_LINE_BODY_FIELD_NAME } from '../LogDetailsBody'; import { FieldDef, getAllFields } from '../logParser'; -import { getDisplayedFieldValue } from './LogLine'; import { GetFieldLinksFn } from './LogList'; -import { measureTextWidth } from './virtualization'; +import { generateLogGrammar } from './grammar'; export interface LogListModel extends LogRowModel { body: string; + _highlightedBody: string; + highlightedBody: string; displayLevel: string; fields: FieldDef[]; timestamp: string; } -export interface LogFieldDimension { - field: string; - width: number; -} - export interface PreProcessOptions { escape: boolean; getFieldLinks?: GetFieldLinksFn; order: LogsSortOrder; timeZone: string; - wrap: boolean; } export const preProcessLogs = ( logs: LogRowModel[], - { escape, getFieldLinks, order, timeZone, wrap }: PreProcessOptions + { escape, getFieldLinks, order, timeZone }: PreProcessOptions ): LogListModel[] => { const orderedLogs = sortLogRows(logs, order); - return orderedLogs.map((log) => preProcessLog(log, { escape, expanded: false, getFieldLinks, timeZone, wrap })); + return orderedLogs.map((log) => preProcessLog(log, { escape, getFieldLinks, timeZone })); }; interface PreProcessLogOptions { escape: boolean; - expanded: boolean; // Not yet implemented getFieldLinks?: GetFieldLinksFn; timeZone: string; - wrap: boolean; } -const preProcessLog = ( - log: LogRowModel, - { escape, expanded, getFieldLinks, timeZone, wrap }: PreProcessLogOptions -): LogListModel => { - let body = log.entry; +const preProcessLog = (log: LogRowModel, { escape, getFieldLinks, timeZone }: PreProcessLogOptions): LogListModel => { + let body = log.raw; const timestamp = dateTimeFormat(log.timeEpochMs, { timeZone, defaultWithMS: true, @@ -56,14 +47,19 @@ const preProcessLog = ( if (escape && log.hasUnescapedContent) { body = escapeUnescapedString(body); } - // With wrapping disabled, we want to turn it into a single-line log entry unless the line is expanded - if (!wrap && !expanded) { - body = body.replace(/(\r\n|\n|\r)/g, ''); - } + // Turn it into a single-line log entry for the list + body = body.replace(/(\r\n|\n|\r)/g, ''); return { ...log, body, + _highlightedBody: '', + get highlightedBody() { + if (!this._highlightedBody) { + this._highlightedBody = Prism.highlight(body, generateLogGrammar(this), 'lokiql'); + } + return this._highlightedBody; + }, displayLevel: logLevelToDisplayLevel(log.logLevel), fields: getAllFields(log, getFieldLinks), timestamp, @@ -82,47 +78,3 @@ function logLevelToDisplayLevel(level = '') { return level; } } - -export const calculateFieldDimensions = (logs: LogListModel[], displayedFields: string[] = []) => { - if (!logs.length) { - return []; - } - let timestampWidth = 0; - let levelWidth = 0; - const fieldWidths: Record = {}; - for (let i = 0; i < logs.length; i++) { - let width = measureTextWidth(logs[i].timestamp); - if (width > timestampWidth) { - timestampWidth = Math.round(width); - } - width = measureTextWidth(logs[i].displayLevel); - if (width > levelWidth) { - levelWidth = Math.round(width); - } - for (const field of displayedFields) { - width = measureTextWidth(getDisplayedFieldValue(field, logs[i])); - fieldWidths[field] = !fieldWidths[field] || width > fieldWidths[field] ? Math.round(width) : fieldWidths[field]; - } - } - const dimensions: LogFieldDimension[] = [ - { - field: 'timestamp', - width: timestampWidth, - }, - { - field: 'level', - width: levelWidth, - }, - ]; - 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], - }); - } - return dimensions; -}; diff --git a/public/app/features/logs/components/panel/virtualization.ts b/public/app/features/logs/components/panel/virtualization.ts index 873e7555c58..b9aee82273d 100644 --- a/public/app/features/logs/components/panel/virtualization.ts +++ b/public/app/features/logs/components/panel/virtualization.ts @@ -1,5 +1,7 @@ import { BusEventWithPayload, GrafanaTheme2 } from '@grafana/data'; +import { LOG_LINE_BODY_FIELD_NAME } from '../LogDetailsBody'; + import { getDisplayedFieldValue } from './LogLine'; import { LogListModel } from './processing'; @@ -118,12 +120,12 @@ export function measureTextHeight(text: string, maxWidth: number, beforeWidth = }; } + const availableWidth = maxWidth - beforeWidth; for (const textLine of textLines) { for (let start = 0; start < textLine.length; ) { let testLogLine: string; let width = 0; let delta = 0; - let availableWidth = maxWidth - beforeWidth; do { testLogLine = textLine.substring(start, start + logLineCharsLength - delta); width = measureTextWidth(testLogLine); @@ -176,9 +178,10 @@ export function getLogLineSize( optionsWidth += gap; textToMeasure += logs[index].timestamp; } - if (logs[index].logLevel) { + // When logs are unwrapped, we want an empty column space to align with other log lines. + if (logs[index].displayLevel || !wrap) { optionsWidth += gap; - textToMeasure += logs[index].logLevel; + textToMeasure += logs[index].displayLevel ?? ''; } for (const field of displayedFields) { textToMeasure = getDisplayedFieldValue(field, logs[index]) + textToMeasure; @@ -191,6 +194,55 @@ export function getLogLineSize( return height; } +export interface LogFieldDimension { + field: string; + width: number; +} + +export const calculateFieldDimensions = (logs: LogListModel[], displayedFields: string[] = []) => { + if (!logs.length) { + return []; + } + let timestampWidth = 0; + let levelWidth = 0; + const fieldWidths: Record = {}; + for (let i = 0; i < logs.length; i++) { + let width = measureTextWidth(logs[i].timestamp); + if (width > timestampWidth) { + timestampWidth = Math.round(width); + } + width = measureTextWidth(logs[i].displayLevel); + if (width > levelWidth) { + levelWidth = Math.round(width); + } + for (const field of displayedFields) { + width = measureTextWidth(getDisplayedFieldValue(field, logs[i])); + fieldWidths[field] = !fieldWidths[field] || width > fieldWidths[field] ? Math.round(width) : fieldWidths[field]; + } + } + const dimensions: LogFieldDimension[] = [ + { + field: 'timestamp', + width: timestampWidth, + }, + { + field: 'level', + width: levelWidth, + }, + ]; + 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], + }); + } + return dimensions; +}; + export function hasUnderOrOverflow(element: HTMLDivElement, calculatedHeight?: number): number | null { const height = calculatedHeight ?? element.clientHeight; if (element.scrollHeight > height) {