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 <zizzpudding@gmail.com>
Co-authored-by: Joan Wortman <joanwortman@Joans-MacBook-Air-2.local>
This commit is contained in:
Matias Chomicki
2025-03-26 12:48:26 +01:00
committed by GitHub
co-authored by Joan Joan Wortman
parent 648e68387e
commit b63b4596a0
9 changed files with 423 additions and 111 deletions
@@ -45,7 +45,6 @@ export const createLogLine = (
escape: false,
order: LogsSortOrder.Descending,
timeZone: 'browser',
wrap: false,
}
): LogListModel => {
const logs = preProcessLogs([createLogRow(overrides)], processOptions);
@@ -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();
});
@@ -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 = ({
>
<LogLineMenu styles={styles} log={log} />
<div className={`${wrapLogMessage ? styles.wrappedLogLine : `${styles.unwrappedLogLine} unwrapped-log-line`}`}>
<Log displayedFields={displayedFields} log={log} showTime={showTime} styles={styles} />
<Log
displayedFields={displayedFields}
log={log}
showTime={showTime}
styles={styles}
wrapLogMessage={wrapLogMessage}
/>
</div>
</div>
);
@@ -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 && <span className={`${styles.timestamp} level-${log.logLevel} field`}>{log.timestamp}</span>}
<span className={`${styles.level} level-${log.logLevel} field`}>{log.displayLevel}</span>
{
// When logs are unwrapped, we want an empty column space to align with other log lines.
}
{(log.displayLevel || !wrapLogMessage) && (
<span className={`${styles.level} level-${log.logLevel} field`}>{log.displayLevel}</span>
)}
{displayedFields.length > 0 ? (
displayedFields.map((field) => (
<span className="field" title={field} key={field}>
{getDisplayedFieldValue(field, log)}
</span>
))
displayedFields.map((field) =>
field === LOG_LINE_BODY_FIELD_NAME ? (
<LogLineBody log={log} key={field} />
) : (
<span className="field" title={field} key={field}>
{getDisplayedFieldValue(field, log)}
</span>
)
)
) : (
<span className="field">{log.body}</span>
<LogLineBody log={log} />
)}
</>
);
};
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 (
<span className="field">
<LogMessageAnsi value={log.body} highlight={highlight} />
</span>
);
}
return <span className="field log-syntax-highlight" dangerouslySetInnerHTML={{ __html: log.highlightedBody }} />;
};
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<typeof getStyles>;
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),
},
@@ -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(() => {
@@ -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);
}
);
});
@@ -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,
};
};
@@ -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');
});
});
@@ -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<string, number> = {};
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;
};
@@ -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<string, number> = {};
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) {