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) {