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:
co-authored by
Joan
Joan Wortman
parent
648e68387e
commit
b63b4596a0
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user