New Logs Panel: Handle long lines (#103171)

* LogLine: introduce truncation limit

* Processing: turn LogListModel into a class

* LogLine: introduce collapsed/expanded state and read from model

* Virtualization: correct extra line for controls in size

* LogLine: fix collapsed state initialization

* Add new criteria to define long lines to truncate

* Virtualization: better truncation limit

* Check collapsed state based on container size

* Update function name

* Extract translations

* LogLine: update unit test

* virtualization: add unit test

* processing: update unit test

* Fix focused test

* processing: process fields on demand

* Logs: consider loading state from explore

* LogList: improve resize and recalculation

* chore: build?
This commit is contained in:
Matias Chomicki
2025-05-07 01:26:54 +03:00
committed by GitHub
parent 9f3e894b8e
commit 441e45d88a
11 changed files with 555 additions and 107 deletions
@@ -1151,6 +1151,7 @@ const UnthemedLogs: React.FunctionComponent<Props> = (props: Props) => {
filterLevels={filterLevels} filterLevels={filterLevels}
getFieldLinks={getFieldLinks} getFieldLinks={getFieldLinks}
getRowContextQuery={getRowContextQuery} getRowContextQuery={getRowContextQuery}
loading={loading}
loadMore={loadMoreLogs} loadMore={loadMoreLogs}
logOptionsStorageKey={SETTING_KEY_ROOT} logOptionsStorageKey={SETTING_KEY_ROOT}
logs={dedupedRows} logs={dedupedRows}
@@ -23,7 +23,7 @@ interface ChildrenProps {
interface Props { interface Props {
children: (props: ChildrenProps) => ReactNode; children: (props: ChildrenProps) => ReactNode;
displayedFields: string[]; displayedFields: string[];
handleOverflow: (index: number, id: string, height: number) => void; handleOverflow: (index: number, id: string, height?: number) => void;
loadMore?: (range: AbsoluteTimeRange) => void; loadMore?: (range: AbsoluteTimeRange) => void;
logs: LogListModel[]; logs: LogListModel[];
scrollElement: HTMLDivElement | null; scrollElement: HTMLDivElement | null;
@@ -9,8 +9,10 @@ import { createLogLine } from '../__mocks__/logRow';
import { getStyles, LogLine } from './LogLine'; import { getStyles, LogLine } from './LogLine';
import { LogListContextProvider } from './LogListContext'; import { LogListContextProvider } from './LogListContext';
import { LogListModel } from './processing'; import { LogListModel } from './processing';
import { getTruncationLength } from './virtualization';
jest.mock('./LogListContext'); jest.mock('./LogListContext');
jest.mock('./virtualization');
const theme = createTheme(); const theme = createTheme();
const styles = getStyles(theme); const styles = getStyles(theme);
@@ -97,6 +99,25 @@ describe('LogLine', () => {
expect(screen.getByText('luna')).toBeInTheDocument(); expect(screen.getByText('luna')).toBeInTheDocument();
}); });
test('Reports mouse over events', async () => {
const onLogLineHover = jest.fn();
render(
<LogListContextProvider {...contextProps} onLogLineHover={onLogLineHover}>
<LogLine
displayedFields={[]}
index={0}
log={log}
showTime={true}
style={{}}
styles={styles}
wrapLogMessage={false}
/>
</LogListContextProvider>
);
await userEvent.hover(screen.getByText('log message 1'));
expect(onLogLineHover).toHaveBeenCalledTimes(1);
});
describe('Log line menu', () => { describe('Log line menu', () => {
test('Renders a log line menu', async () => { test('Renders a log line menu', async () => {
render( render(
@@ -180,4 +201,85 @@ describe('LogLine', () => {
expect(screen.queryByText(log.entry)).not.toBeInTheDocument(); expect(screen.queryByText(log.entry)).not.toBeInTheDocument();
}); });
}); });
describe('Collapsible log lines', () => {
beforeEach(() => {
log = createLogLine({ labels: { place: 'luna' }, entry: `log message 1` });
jest.mocked(getTruncationLength).mockReturnValue(5);
});
test('Logs are not collapsed by default', () => {
render(
<LogLine
displayedFields={[]}
index={0}
log={log}
showTime={true}
style={{}}
styles={styles}
wrapLogMessage={true}
/>
);
expect(screen.queryByText('show less')).not.toBeInTheDocument();
expect(screen.queryByText('show more')).not.toBeInTheDocument();
});
test('Logs are not collapsible when unwrapped', () => {
log.collapsed = true;
render(
<LogLine
displayedFields={[]}
index={0}
log={log}
showTime={true}
style={{}}
styles={styles}
// Unwrapped logs
wrapLogMessage={false}
/>
);
expect(screen.queryByText('show less')).not.toBeInTheDocument();
expect(screen.queryByText('show more')).not.toBeInTheDocument();
});
test('Long logs can be collapsed and expanded', async () => {
log.collapsed = true;
render(
<LogLine
displayedFields={[]}
index={0}
log={log}
showTime={true}
style={{}}
styles={styles}
wrapLogMessage={true}
/>
);
expect(screen.getByText('show more')).toBeVisible();
await userEvent.click(screen.getByText('show more'));
expect(await screen.findByText('show less')).toBeInTheDocument();
await userEvent.click(screen.getByText('show less'));
expect(await screen.findByText('show more')).toBeInTheDocument();
});
test('When the collapsed state changes invokes a callback to update virtualized sizes', async () => {
log.collapsed = true;
const onOverflow = jest.fn();
render(
<LogLine
displayedFields={[]}
index={0}
log={log}
onOverflow={onOverflow}
showTime={true}
style={{}}
styles={styles}
wrapLogMessage={true}
/>
);
await userEvent.click(await screen.findByText('show more'));
await userEvent.click(await screen.findByText('show less'));
expect(onOverflow).toHaveBeenCalledTimes(2);
});
});
}); });
@@ -1,8 +1,10 @@
import { css } from '@emotion/css'; import { css } from '@emotion/css';
import { CSSProperties, useCallback, useEffect, useRef } from 'react'; import { CSSProperties, useCallback, useEffect, useRef, useState } from 'react';
import tinycolor from 'tinycolor2'; import tinycolor from 'tinycolor2';
import { GrafanaTheme2 } from '@grafana/data'; import { GrafanaTheme2 } from '@grafana/data';
import { Button } from '@grafana/ui';
import { t } from 'app/core/internationalization';
import { LOG_LINE_BODY_FIELD_NAME } from '../LogDetailsBody'; import { LOG_LINE_BODY_FIELD_NAME } from '../LogDetailsBody';
import { LogMessageAnsi } from '../LogMessageAnsi'; import { LogMessageAnsi } from '../LogMessageAnsi';
@@ -10,7 +12,13 @@ import { LogMessageAnsi } from '../LogMessageAnsi';
import { LogLineMenu } from './LogLineMenu'; import { LogLineMenu } from './LogLineMenu';
import { useLogIsPinned, useLogListContext } from './LogListContext'; import { useLogIsPinned, useLogListContext } from './LogListContext';
import { LogListModel } from './processing'; import { LogListModel } from './processing';
import { FIELD_GAP_MULTIPLIER, hasUnderOrOverflow, getLineHeight, LogFieldDimension } from './virtualization'; import {
FIELD_GAP_MULTIPLIER,
hasUnderOrOverflow,
getLineHeight,
LogFieldDimension,
TRUNCATION_LINE_COUNT,
} from './virtualization';
interface Props { interface Props {
displayedFields: string[]; displayedFields: string[];
@@ -19,7 +27,7 @@ interface Props {
showTime: boolean; showTime: boolean;
style: CSSProperties; style: CSSProperties;
styles: LogLineStyles; styles: LogLineStyles;
onOverflow?: (index: number, id: string, height: number) => void; onOverflow?: (index: number, id: string, height?: number) => void;
variant?: 'infinite-scroll'; variant?: 'infinite-scroll';
wrapLogMessage: boolean; wrapLogMessage: boolean;
} }
@@ -36,41 +44,78 @@ export const LogLine = ({
wrapLogMessage, wrapLogMessage,
}: Props) => { }: Props) => {
const { onLogLineHover } = useLogListContext(); const { onLogLineHover } = useLogListContext();
const [collapsed, setCollapsed] = useState<boolean | undefined>(
wrapLogMessage && log.collapsed !== undefined ? log.collapsed : undefined
);
const logLineRef = useRef<HTMLDivElement | null>(null); const logLineRef = useRef<HTMLDivElement | null>(null);
const pinned = useLogIsPinned(log); const pinned = useLogIsPinned(log);
const handleMouseOver = useCallback(() => {
onLogLineHover?.(log);
}, [log, onLogLineHover]);
useEffect(() => { useEffect(() => {
if (!onOverflow || !logLineRef.current) { if (!onOverflow || !logLineRef.current) {
return; return;
} }
const calculatedHeight = typeof style.height === 'number' ? style.height : undefined; const calculatedHeight = typeof style.height === 'number' ? style.height : undefined;
const actualHeight = hasUnderOrOverflow(logLineRef.current, calculatedHeight); const actualHeight = hasUnderOrOverflow(logLineRef.current, calculatedHeight, log.collapsed);
if (actualHeight) { if (actualHeight) {
onOverflow(index, log.uid, actualHeight); onOverflow(index, log.uid, actualHeight);
} }
}, [index, log.uid, onOverflow, style.height]); }, [index, log.collapsed, log.uid, onOverflow, style.height]);
const handleMouseOver = useCallback(() => onLogLineHover?.(log), [log, onLogLineHover]);
const handleExpandCollapse = useCallback(() => {
const newState = !collapsed;
setCollapsed(newState);
log.setCollapsedState(newState);
onOverflow?.(index, log.uid);
}, [collapsed, index, log, onOverflow]);
return ( return (
<div <div style={style}>
style={style} <div
className={`${styles.logLine} ${variant ?? ''} ${pinned ? styles.pinnedLogLine : ''}`} className={`${styles.logLine} ${variant ?? ''} ${pinned ? styles.pinnedLogLine : ''}`}
ref={onOverflow ? logLineRef : undefined} ref={onOverflow ? logLineRef : undefined}
onMouseOver={handleMouseOver} onMouseEnter={handleMouseOver}
> >
<LogLineMenu styles={styles} log={log} /> <LogLineMenu styles={styles} log={log} />
<div className={`${wrapLogMessage ? styles.wrappedLogLine : `${styles.unwrappedLogLine} unwrapped-log-line`}`}> <div
<Log className={`${wrapLogMessage ? styles.wrappedLogLine : `${styles.unwrappedLogLine} unwrapped-log-line`} ${collapsed === true ? styles.collapsedLogLine : ''}`}
displayedFields={displayedFields} >
log={log} <Log
showTime={showTime} displayedFields={displayedFields}
styles={styles} log={log}
wrapLogMessage={wrapLogMessage} showTime={showTime}
/> styles={styles}
wrapLogMessage={wrapLogMessage}
/>
</div>
</div> </div>
{collapsed === true && (
<div className={styles.expandCollapseControl}>
<Button
variant="primary"
fill="text"
size="sm"
className={styles.expandCollapseControlButton}
onClick={handleExpandCollapse}
>
{t('logs.log-line.show-more', 'show more')}
</Button>
</div>
)}
{collapsed === false && (
<div className={styles.expandCollapseControl}>
<Button
variant="primary"
fill="text"
size="sm"
className={styles.expandCollapseControlButton}
onClick={handleExpandCollapse}
>
{t('logs.log-line.show-less', 'show less')}
</Button>
</div>
)}
</div> </div>
); );
}; };
@@ -99,7 +144,7 @@ const Log = ({ displayedFields, log, showTime, styles, wrapLogMessage }: LogProp
<LogLineBody log={log} key={field} /> <LogLineBody log={log} key={field} />
) : ( ) : (
<span className="field" title={field} key={field}> <span className="field" title={field} key={field}>
{getDisplayedFieldValue(field, log)} {log.getDisplayedFieldValue(field)}
</span> </span>
) )
) )
@@ -131,20 +176,6 @@ const LogLineBody = ({ log }: { log: LogListModel }) => {
return <span className="field log-syntax-highlight" dangerouslySetInnerHTML={{ __html: log.highlightedBody }} />; 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;
}
if (log.labels[fieldName] != null) {
return log.labels[fieldName];
}
const field = log.fields.find((field) => {
return field.keys[0] === fieldName;
});
return field ? field.values.toString() : '';
}
export function getGridTemplateColumns(dimensions: LogFieldDimension[]) { export function getGridTemplateColumns(dimensions: LogFieldDimension[]) {
const columns = dimensions.map((dimension) => dimension.width).join('px '); const columns = dimensions.map((dimension) => dimension.width).join('px ');
return `${columns}px 1fr`; return `${columns}px 1fr`;
@@ -172,6 +203,7 @@ export const getStyles = (theme: GrafanaTheme2) => {
fontFamily: theme.typography.fontFamilyMonospace, fontFamily: theme.typography.fontFamilyMonospace,
fontSize: theme.typography.fontSize, fontSize: theme.typography.fontSize,
wordBreak: 'break-all', wordBreak: 'break-all',
cursor: 'pointer',
'&:hover': { '&:hover': {
background: `hsla(0, 0%, 0%, 0.2)`, background: `hsla(0, 0%, 0%, 0.2)`,
}, },
@@ -283,5 +315,18 @@ export const getStyles = (theme: GrafanaTheme2) => {
marginRight: 0, marginRight: 0,
}, },
}), }),
collapsedLogLine: css({
maxHeight: `${TRUNCATION_LINE_COUNT * getLineHeight()}px`,
overflow: 'hidden',
}),
expandCollapseControl: css({
display: 'flex',
justifyContent: 'center',
}),
expandCollapseControlButton: css({
fontWeight: theme.typography.fontWeightLight,
height: getLineHeight(),
margin: 0,
}),
}; };
}; };
@@ -47,6 +47,7 @@ interface Props {
getRowContextQuery?: GetRowContextQueryFn; getRowContextQuery?: GetRowContextQueryFn;
grammar?: Grammar; grammar?: Grammar;
initialScrollPosition?: 'top' | 'bottom'; initialScrollPosition?: 'top' | 'bottom';
loading?: boolean;
loadMore?: (range: AbsoluteTimeRange) => void; loadMore?: (range: AbsoluteTimeRange) => void;
logOptionsStorageKey?: string; logOptionsStorageKey?: string;
logs: LogRowModel[]; logs: LogRowModel[];
@@ -88,6 +89,7 @@ export const LogList = ({
getRowContextQuery, getRowContextQuery,
grammar, grammar,
initialScrollPosition = 'top', initialScrollPosition = 'top',
loading,
loadMore, loadMore,
logOptionsStorageKey, logOptionsStorageKey,
logs, logs,
@@ -140,6 +142,7 @@ export const LogList = ({
getFieldLinks={getFieldLinks} getFieldLinks={getFieldLinks}
grammar={grammar} grammar={grammar}
initialScrollPosition={initialScrollPosition} initialScrollPosition={initialScrollPosition}
loading={loading}
loadMore={loadMore} loadMore={loadMore}
logs={logs} logs={logs}
showControls={showControls} showControls={showControls}
@@ -156,6 +159,7 @@ const LogListComponent = ({
getFieldLinks, getFieldLinks,
grammar, grammar,
initialScrollPosition = 'top', initialScrollPosition = 'top',
loading,
loadMore, loadMore,
logs, logs,
showControls, showControls,
@@ -189,15 +193,19 @@ const LogListComponent = ({
}, [eventBus, logs.length]); }, [eventBus, logs.length]);
useEffect(() => { useEffect(() => {
if (loading) {
return;
}
setProcessedLogs( setProcessedLogs(
preProcessLogs(logs, { getFieldLinks, escape: forceEscape ?? false, order: sortOrder, timeZone }, grammar) preProcessLogs(logs, { getFieldLinks, escape: forceEscape ?? false, order: sortOrder, timeZone }, grammar)
); );
}, [forceEscape, getFieldLinks, grammar, logs, sortOrder, timeZone]);
useEffect(() => {
resetLogLineSizes(); resetLogLineSizes();
listRef.current?.resetAfterIndex(0); listRef.current?.resetAfterIndex(0);
}, [wrapLogMessage, processedLogs]); }, [forceEscape, getFieldLinks, grammar, loading, logs, sortOrder, timeZone]);
useEffect(() => {
listRef.current?.resetAfterIndex(0);
}, [wrapLogMessage]);
useEffect(() => { useEffect(() => {
const handleResize = debounce(() => { const handleResize = debounce(() => {
@@ -214,17 +222,16 @@ const LogListComponent = ({
if (widthRef.current === containerElement.clientWidth) { if (widthRef.current === containerElement.clientWidth) {
return; return;
} }
resetLogLineSizes();
listRef.current?.resetAfterIndex(0);
widthRef.current = containerElement.clientWidth; widthRef.current = containerElement.clientWidth;
listRef.current?.resetAfterIndex(0);
}); });
const handleOverflow = useCallback( const handleOverflow = useCallback(
(index: number, id: string, height: number) => { (index: number, id: string, height?: number) => {
if (containerElement) { if (containerElement && height !== undefined) {
storeLogLineSize(id, containerElement, height); storeLogLineSize(id, containerElement, height);
listRef.current?.resetAfterIndex(index);
} }
listRef.current?.resetAfterIndex(index);
}, },
[containerElement] [containerElement]
); );
@@ -82,6 +82,7 @@ export const LogListContextProvider = ({
filterLevels = [], filterLevels = [],
getRowContextQuery = jest.fn(), getRowContextQuery = jest.fn(),
logSupportsContext = jest.fn(), logSupportsContext = jest.fn(),
onLogLineHover,
onPermalinkClick = jest.fn(), onPermalinkClick = jest.fn(),
onPinLine = jest.fn(), onPinLine = jest.fn(),
onOpenContext = jest.fn(), onOpenContext = jest.fn(),
@@ -102,6 +103,7 @@ export const LogListContextProvider = ({
filterLevels, filterLevels,
getRowContextQuery, getRowContextQuery,
logSupportsContext, logSupportsContext,
onLogLineHover,
onPermalinkClick, onPermalinkClick,
onPinLine, onPinLine,
onOpenContext, onOpenContext,
@@ -1,8 +1,10 @@
import { Field, FieldType, LogLevel, LogRowModel, LogsSortOrder, toDataFrame } from '@grafana/data'; import { createTheme, Field, FieldType, LogLevel, LogRowModel, LogsSortOrder, toDataFrame } from '@grafana/data';
import { createLogRow } from '../__mocks__/logRow'; import { LOG_LINE_BODY_FIELD_NAME } from '../LogDetailsBody';
import { createLogLine, createLogRow } from '../__mocks__/logRow';
import { LogListModel, preProcessLogs } from './processing'; import { LogListModel, preProcessLogs } from './processing';
import { getTruncationLength, init } from './virtualization';
describe('preProcessLogs', () => { describe('preProcessLogs', () => {
let logFmtLog: LogRowModel, nginxLog: LogRowModel, jsonLog: LogRowModel; let logFmtLog: LogRowModel, nginxLog: LogRowModel, jsonLog: LogRowModel;
@@ -138,4 +140,58 @@ describe('preProcessLogs', () => {
expect(processedLogs[2].highlightedBody).toContain('log-token-string'); expect(processedLogs[2].highlightedBody).toContain('log-token-string');
expect(processedLogs[2].highlightedBody).not.toContain('log-token-method'); expect(processedLogs[2].highlightedBody).not.toContain('log-token-method');
}); });
test('Returns displayed field values', () => {
expect(processedLogs[0].getDisplayedFieldValue('logger')).toBe('interceptor');
expect(processedLogs[1].getDisplayedFieldValue('method')).toBe('POST');
expect(processedLogs[2].getDisplayedFieldValue('kind')).toBe('Event');
expect(processedLogs[0].getDisplayedFieldValue(LOG_LINE_BODY_FIELD_NAME)).toBe(processedLogs[0].body);
expect(processedLogs[1].getDisplayedFieldValue(LOG_LINE_BODY_FIELD_NAME)).toBe(processedLogs[1].body);
expect(processedLogs[2].getDisplayedFieldValue(LOG_LINE_BODY_FIELD_NAME)).toBe(processedLogs[2].body);
});
describe('Collapsible log lines', () => {
let longLog: LogListModel, entry: string, container: HTMLDivElement;
beforeEach(() => {
init(createTheme());
container = document.createElement('div');
jest.spyOn(container, 'clientWidth', 'get').mockReturnValue(200);
entry = new Array(2 * getTruncationLength(null)).fill('e').join('');
longLog = createLogLine({ entry });
});
test('Long lines that are not truncated are not modified', () => {
expect(longLog.body).toBe(entry);
expect(longLog.highlightedBody).toBe(entry);
});
test('Sets the collapsed state based on the container size', () => {
// Make container half of the size
jest.spyOn(container, 'clientWidth', 'get').mockReturnValue(100);
expect(longLog.collapsed).toBeUndefined();
longLog.updateCollapsedState([], container);
expect(longLog.collapsed).toBe(true);
expect(longLog.body).not.toBe(entry);
expect(entry).toContain(longLog.body);
});
test('Updates the body based on the collapsed state', () => {
expect(longLog.collapsed).toBeUndefined();
expect(longLog.body).toBe(entry);
longLog.setCollapsedState(true);
expect(longLog.collapsed).toBe(true);
expect(longLog.body).not.toBe(entry);
expect(entry).toContain(longLog.body);
longLog.setCollapsedState(false);
expect(longLog.collapsed).toBe(false);
expect(longLog.body).toBe(entry);
});
});
}); });
@@ -1,20 +1,139 @@
import Prism, { Grammar } from 'prismjs'; import Prism, { Grammar } from 'prismjs';
import { dateTimeFormat, LogLevel, LogRowModel, LogsSortOrder } from '@grafana/data'; import { DataFrame, dateTimeFormat, Labels, LogLevel, LogRowModel, LogsSortOrder } from '@grafana/data';
import { GetFieldLinksFn } from 'app/plugins/panel/logs/types'; import { GetFieldLinksFn } from 'app/plugins/panel/logs/types';
import { escapeUnescapedString, sortLogRows } from '../../utils'; import { escapeUnescapedString, sortLogRows } from '../../utils';
import { LOG_LINE_BODY_FIELD_NAME } from '../LogDetailsBody';
import { FieldDef, getAllFields } from '../logParser'; import { FieldDef, getAllFields } from '../logParser';
import { generateLogGrammar } from './grammar'; import { generateLogGrammar } from './grammar';
import { getTruncationLength } from './virtualization';
export interface LogListModel extends LogRowModel { export class LogListModel implements LogRowModel {
body: string; collapsed: boolean | undefined = undefined;
_highlightedBody: string; datasourceType: string | undefined;
highlightedBody: string; dataFrame: DataFrame;
displayLevel: string; displayLevel: string;
fields: FieldDef[]; duplicates: number | undefined;
entry: string;
entryFieldIndex: number;
hasAnsi: boolean;
hasUnescapedContent: boolean;
labels: Labels;
logLevel: LogLevel;
raw: string;
rowIndex: number;
rowId?: string | undefined;
searchWords: string[] | undefined;
timestamp: string; timestamp: string;
timeFromNow: string;
timeEpochMs: number;
timeEpochNs: string;
timeLocal: string;
timeUtc: string;
uid: string;
uniqueLabels: Labels | undefined;
private _body: string | undefined = undefined;
private _grammar?: Grammar;
private _highlightedBody: string | undefined = undefined;
private _fields: FieldDef[] | undefined = undefined;
private _getFieldLinks: GetFieldLinksFn | undefined = undefined;
constructor(log: LogRowModel, { escape, getFieldLinks, grammar, timeZone }: PreProcessLogOptions) {
// LogRowModel
this.datasourceType = log.datasourceType;
this.dataFrame = log.dataFrame;
this.duplicates = log.duplicates;
this.entry = log.entry;
this.entryFieldIndex = log.entryFieldIndex;
this.hasAnsi = log.hasAnsi;
this.hasUnescapedContent = log.hasUnescapedContent;
this.labels = log.labels;
this.logLevel = log.logLevel;
this.rowIndex = log.rowIndex;
this.rowId = log.rowId;
this.searchWords = log.searchWords;
this.timeFromNow = log.timeFromNow;
this.timeEpochMs = log.timeEpochMs;
this.timeEpochNs = log.timeEpochNs;
this.timeLocal = log.timeLocal;
this.timeUtc = log.timeUtc;
this.uid = log.uid;
this.uniqueLabels = log.uniqueLabels;
// LogListModel
this.displayLevel = logLevelToDisplayLevel(log.logLevel);
this._getFieldLinks = getFieldLinks;
this._grammar = grammar;
this.timestamp = dateTimeFormat(log.timeEpochMs, {
timeZone,
defaultWithMS: true,
});
let raw = log.raw;
if (escape && log.hasUnescapedContent) {
raw = escapeUnescapedString(raw);
}
this.raw = raw;
}
get body(): string {
if (this._body === undefined) {
let body = this.collapsed ? this.raw.substring(0, getTruncationLength(null)) : this.raw;
// Turn it into a single-line log entry for the list
this._body = body.replace(/(\r\n|\n|\r)/g, '');
}
return this._body;
}
get fields(): FieldDef[] {
if (this._fields === undefined) {
this._fields = getAllFields(this, this._getFieldLinks);
}
return this._fields;
}
get highlightedBody() {
if (this._highlightedBody === undefined) {
this._grammar = this._grammar ?? generateLogGrammar(this);
this._highlightedBody = Prism.highlight(this.body, this._grammar, 'lokiql');
}
return this._highlightedBody;
}
getDisplayedFieldValue(fieldName: string): string {
if (fieldName === LOG_LINE_BODY_FIELD_NAME) {
return this.body;
}
if (this.labels[fieldName] != null) {
return this.labels[fieldName];
}
const field = this.fields.find((field) => {
return field.keys[0] === fieldName;
});
return field ? field.values.toString() : '';
}
updateCollapsedState(displayedFields: string[], container: HTMLDivElement | null) {
const lineLength =
displayedFields.map((field) => this.getDisplayedFieldValue(field)).join('').length + this.raw.length;
const collapsed = lineLength >= getTruncationLength(container) ? true : undefined;
if (this.collapsed === undefined || collapsed === undefined) {
this.collapsed = collapsed;
}
return this.collapsed;
}
setCollapsedState(collapsed: boolean) {
if (this.collapsed !== collapsed) {
this._body = undefined;
this._highlightedBody = undefined;
}
this.collapsed = collapsed;
}
} }
export interface PreProcessOptions { export interface PreProcessOptions {
@@ -30,45 +149,17 @@ export const preProcessLogs = (
grammar?: Grammar grammar?: Grammar
): LogListModel[] => { ): LogListModel[] => {
const orderedLogs = sortLogRows(logs, order); const orderedLogs = sortLogRows(logs, order);
return orderedLogs.map((log) => preProcessLog(log, { escape, getFieldLinks, timeZone }, grammar)); return orderedLogs.map((log) => preProcessLog(log, { escape, getFieldLinks, grammar, timeZone }));
}; };
interface PreProcessLogOptions { interface PreProcessLogOptions {
escape: boolean; escape: boolean;
getFieldLinks?: GetFieldLinksFn; getFieldLinks?: GetFieldLinksFn;
grammar?: Grammar;
timeZone: string; timeZone: string;
} }
const preProcessLog = ( const preProcessLog = (log: LogRowModel, options: PreProcessLogOptions): LogListModel => {
log: LogRowModel, return new LogListModel(log, options);
{ escape, getFieldLinks, timeZone }: PreProcessLogOptions,
grammar?: Grammar
): LogListModel => {
let body = log.raw;
const timestamp = dateTimeFormat(log.timeEpochMs, {
timeZone,
defaultWithMS: true,
});
if (escape && log.hasUnescapedContent) {
body = escapeUnescapedString(body);
}
// 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, grammar ? grammar : generateLogGrammar(this), 'lokiql');
}
return this._highlightedBody;
},
displayLevel: logLevelToDisplayLevel(log.logLevel),
fields: getAllFields(log, getFieldLinks),
timestamp,
};
}; };
function logLevelToDisplayLevel(level = '') { function logLevelToDisplayLevel(level = '') {
@@ -0,0 +1,119 @@
import { createTheme } from '@grafana/data';
import { LOG_LINE_BODY_FIELD_NAME } from '../LogDetailsBody';
import { createLogLine } from '../__mocks__/logRow';
import { LogListModel } from './processing';
import { getLineHeight, getLogLineSize, init, measureTextWidth, TRUNCATION_LINE_COUNT } from './virtualization';
const PADDING_BOTTOM = 6;
const LINE_HEIGHT = getLineHeight();
const SINGLE_LINE_HEIGHT = LINE_HEIGHT + PADDING_BOTTOM;
const TWO_LINES_HEIGHT = 2 * LINE_HEIGHT + PADDING_BOTTOM;
const THREE_LINES_HEIGHT = 3 * LINE_HEIGHT + PADDING_BOTTOM;
let LETTER_WIDTH: number;
let CONTAINER_SIZE = 200;
describe('Virtualization', () => {
let log: LogListModel, container: HTMLDivElement;
beforeEach(() => {
log = createLogLine({ labels: { place: 'luna' }, entry: `log message 1` });
container = document.createElement('div');
jest.spyOn(container, 'clientWidth', 'get').mockReturnValue(CONTAINER_SIZE);
init(createTheme());
LETTER_WIDTH = measureTextWidth('e');
});
describe('getLogLineSize', () => {
test('Returns the a single line if the display mode is unwrapped', () => {
const size = getLogLineSize([log], container, [], { wrap: false, showControls: true, showTime: true }, 0);
expect(size).toBe(SINGLE_LINE_HEIGHT);
});
test('Returns the a single line if the line is not loaded yet', () => {
const logs = [log];
const size = getLogLineSize(
logs,
container,
[],
{ wrap: true, showControls: true, showTime: true },
logs.length + 1
);
expect(size).toBe(SINGLE_LINE_HEIGHT);
});
test('Returns the size of a truncated long line', () => {
// Very small container
log.collapsed = true;
jest.spyOn(container, 'clientWidth', 'get').mockReturnValue(10);
const size = getLogLineSize([log], container, [], { wrap: true, showControls: true, showTime: true }, 0);
expect(size).toBe((TRUNCATION_LINE_COUNT + 1) * LINE_HEIGHT);
});
test.each([
[false, false],
[true, false],
[false, true],
[true, true],
])('Measures a log line with controls %s and displayed time %s', (showControls: boolean, showTime: boolean) => {
const size = getLogLineSize([log], container, [], { wrap: true, showControls, showTime }, 0);
expect(size).toBe(SINGLE_LINE_HEIGHT);
});
test('Measures a multi-line log line with no controls and no displayed time', () => {
const TWO_LINES_OF_CHARACTERS = (CONTAINER_SIZE / LETTER_WIDTH) * 1.5;
log = createLogLine({
labels: { place: 'luna' },
entry: new Array(TWO_LINES_OF_CHARACTERS).fill('e').join(''),
logLevel: undefined,
});
const size = getLogLineSize([log], container, [], { wrap: true, showControls: false, showTime: false }, 0);
expect(size).toBe(TWO_LINES_HEIGHT);
});
test('Measures a multi-line log line with level, controls, and displayed time', () => {
const TWO_LINES_OF_CHARACTERS = (CONTAINER_SIZE / LETTER_WIDTH) * 1.5;
log = createLogLine({ labels: { place: 'luna' }, entry: new Array(TWO_LINES_OF_CHARACTERS).fill('e').join('') });
const size = getLogLineSize([log], container, [], { wrap: true, showControls: true, showTime: true }, 0);
// Two lines for the log and one extra for level and time
expect(size).toBe(THREE_LINES_HEIGHT);
});
test('Measures a multi-line log line with displayed fields', () => {
const TWO_LINES_OF_CHARACTERS = (CONTAINER_SIZE / LETTER_WIDTH) * 1.5;
log = createLogLine({
labels: { place: 'very very long value for the displayed field that causes a new line' },
entry: new Array(TWO_LINES_OF_CHARACTERS).fill('e').join(''),
logLevel: undefined,
});
const size = getLogLineSize(
[log],
container,
['place', LOG_LINE_BODY_FIELD_NAME],
{ wrap: true, showControls: false, showTime: false },
0
);
// Two lines for the log and one extra for the displayed fields
expect(size).toBe(THREE_LINES_HEIGHT);
});
test('Measures displayed fields in a log line with level, controls, and displayed time', () => {
const TWO_LINES_OF_CHARACTERS = (CONTAINER_SIZE / LETTER_WIDTH) * 2;
log = createLogLine({ labels: { place: 'luna' }, entry: new Array(TWO_LINES_OF_CHARACTERS).fill('e').join('') });
const size = getLogLineSize([log], container, ['place'], { wrap: true, showControls: true, showTime: true }, 0);
// Only renders a short displayed field, so a single line
expect(size).toBe(SINGLE_LINE_HEIGHT);
});
test('Adds an extra line for the expand/collapse controls if present', () => {
jest.spyOn(log, 'updateCollapsedState').mockImplementation(() => undefined);
log.collapsed = false;
const size = getLogLineSize([log], container, [], { wrap: true, showControls: false, showTime: false }, 0);
expect(size).toBe(TWO_LINES_HEIGHT);
});
});
});
@@ -2,7 +2,6 @@ import { BusEventWithPayload, GrafanaTheme2 } from '@grafana/data';
import { LOG_LINE_BODY_FIELD_NAME } from '../LogDetailsBody'; import { LOG_LINE_BODY_FIELD_NAME } from '../LogDetailsBody';
import { getDisplayedFieldValue } from './LogLine';
import { LogListModel } from './processing'; import { LogListModel } from './processing';
let ctx: CanvasRenderingContext2D | null = null; let ctx: CanvasRenderingContext2D | null = null;
@@ -168,6 +167,13 @@ export function getLogLineSize(
if (!wrap || !logs[index]) { if (!wrap || !logs[index]) {
return lineHeight + paddingBottom; return lineHeight + paddingBottom;
} }
// If a long line is collapsed, we show the line count + an extra line for the expand/collapse control
logs[index].updateCollapsedState(displayedFields, container);
if (logs[index].collapsed) {
return (TRUNCATION_LINE_COUNT + 1) * lineHeight;
}
const storedSize = retrieveLogLineSize(logs[index].uid, container); const storedSize = retrieveLogLineSize(logs[index].uid, container);
if (storedSize) { if (storedSize) {
return storedSize; return storedSize;
@@ -189,14 +195,15 @@ export function getLogLineSize(
textToMeasure += logs[index].displayLevel ?? ''; textToMeasure += logs[index].displayLevel ?? '';
} }
for (const field of displayedFields) { for (const field of displayedFields) {
textToMeasure = getDisplayedFieldValue(field, logs[index]) + textToMeasure; textToMeasure = logs[index].getDisplayedFieldValue(field) + textToMeasure;
} }
if (!displayedFields.length) { if (!displayedFields.length) {
textToMeasure += logs[index].body; textToMeasure += logs[index].body;
} }
const { height } = measureTextHeight(textToMeasure, getLogContainerWidth(container), optionsWidth); const { height } = measureTextHeight(textToMeasure, getLogContainerWidth(container), optionsWidth);
return height; // When the log is collapsed, add an extra line for the expand/collapse control
return logs[index].collapsed === false ? height + lineHeight : height;
} }
export interface LogFieldDimension { export interface LogFieldDimension {
@@ -221,7 +228,7 @@ export const calculateFieldDimensions = (logs: LogListModel[], displayedFields:
levelWidth = Math.round(width); levelWidth = Math.round(width);
} }
for (const field of displayedFields) { for (const field of displayedFields) {
width = measureTextWidth(getDisplayedFieldValue(field, logs[i])); width = measureTextWidth(logs[i].getDisplayedFieldValue(field));
fieldWidths[field] = !fieldWidths[field] || width > fieldWidths[field] ? Math.round(width) : fieldWidths[field]; fieldWidths[field] = !fieldWidths[field] || width > fieldWidths[field] ? Math.round(width) : fieldWidths[field];
} }
} }
@@ -248,14 +255,28 @@ export const calculateFieldDimensions = (logs: LogListModel[], displayedFields:
return dimensions; return dimensions;
}; };
export function hasUnderOrOverflow(element: HTMLDivElement, calculatedHeight?: number): number | null { // 2/3 of the viewport height
export const TRUNCATION_LINE_COUNT = Math.round(window.innerHeight / getLineHeight() / 1.5);
export function getTruncationLength(container: HTMLDivElement | null) {
const availableWidth = container ? getLogContainerWidth(container) : window.innerWidth;
return (availableWidth / measureTextWidth('e')) * TRUNCATION_LINE_COUNT;
}
export function hasUnderOrOverflow(
element: HTMLDivElement,
calculatedHeight?: number,
collapsed?: boolean
): number | null {
if (collapsed !== undefined && calculatedHeight) {
calculatedHeight -= getLineHeight();
}
const height = calculatedHeight ?? element.clientHeight; const height = calculatedHeight ?? element.clientHeight;
if (element.scrollHeight > height) { if (element.scrollHeight > height) {
return element.scrollHeight; return collapsed !== undefined ? element.scrollHeight + getLineHeight() : element.scrollHeight;
} }
const child = element.children[1]; const child = element.children[1];
if (child instanceof HTMLDivElement && child.clientHeight < height) { if (child instanceof HTMLDivElement && child.clientHeight < height) {
return child.clientHeight; return collapsed !== undefined ? child.clientHeight + getLineHeight() : child.clientHeight;
} }
return null; return null;
} }
+15 -11
View File
@@ -404,7 +404,7 @@
}, },
"alert-labels": { "alert-labels": {
"aria-label-labels": "Labels", "aria-label-labels": "Labels",
"common-labels-count_one": "+{{count}} common label", "common-labels-count_one": "+{{count}} common labels",
"common-labels-count_other": "+{{count}} common labels" "common-labels-count_other": "+{{count}} common labels"
}, },
"alert-manager-manual-routing": { "alert-manager-manual-routing": {
@@ -1384,7 +1384,7 @@
"new-recording-rule": "New recording rule", "new-recording-rule": "New recording rule",
"title": "Grafana-managed" "title": "Grafana-managed"
}, },
"loading-rules_one": "Loading rules from {{count}} source", "loading-rules_one": "Loading rules from {{count}} sources",
"loading-rules_other": "Loading rules from {{count}} sources" "loading-rules_other": "Loading rules from {{count}} sources"
} }
}, },
@@ -2065,14 +2065,14 @@
"unknown-rule-type": "Unknown rule type" "unknown-rule-type": "Unknown rule type"
}, },
"rule-list-errors": { "rule-list-errors": {
"button-errors_one": "{{count}} error", "button-errors_one": "{{count}} errors",
"button-errors_other": "{{count}} errors", "button-errors_other": "{{count}} errors",
"cloud-rulessource-errors-title-errors-loading-rules": "Errors loading rules", "cloud-rulessource-errors-title-errors-loading-rules": "Errors loading rules",
"failed-to-load-grafana-rules-config": "Failed to load Grafana rules config:", "failed-to-load-grafana-rules-config": "Failed to load Grafana rules config:",
"failed-to-load-grafana-rules-state": "Failed to load Grafana rules state:", "failed-to-load-grafana-rules-state": "Failed to load Grafana rules state:",
"failed-to-load-rules-config": "Failed to load rules config from <2>{{dataSource}}</2>", "failed-to-load-rules-config": "Failed to load rules config from <2>{{dataSource}}</2>",
"failed-to-load-rules-state": "Failed to load rules state from <2>{{dataSource}}</2>", "failed-to-load-rules-state": "Failed to load rules state from <2>{{dataSource}}</2>",
"more-errors_one": "{{count}} more error", "more-errors_one": "{{count}} more errors",
"more-errors_other": "{{count}} more errors", "more-errors_other": "{{count}} more errors",
"unknown-error": "Unknown error." "unknown-error": "Unknown error."
}, },
@@ -2099,7 +2099,7 @@
} }
}, },
"rule-stats": { "rule-stats": {
"error_one": "{{count}} error", "error_one": "{{count}} errors",
"error_other": "{{count}} errors", "error_other": "{{count}} errors",
"firing": "{{alertingStats}} firing", "firing": "{{alertingStats}} firing",
"inactive": "{{normalStats}} normal", "inactive": "{{normalStats}} normal",
@@ -4033,7 +4033,7 @@
}, },
"library-viz-panel-info": { "library-viz-panel-info": {
"last-edited": "{{timeAgo}} by<person />", "last-edited": "{{timeAgo}} by<person />",
"usage-count_one": "Used on {{count}} dashboard", "usage-count_one": "Used on {{count}} dashboards",
"usage-count_other": "Used on {{count}} dashboards" "usage-count_other": "Used on {{count}} dashboards"
}, },
"name-already-exists-error": { "name-already-exists-error": {
@@ -4152,7 +4152,7 @@
"save-variables-label-update-default-variable-values": "Update default variable values" "save-variables-label-update-default-variable-values": "Update default variable values"
}, },
"save-library-viz-panel-modal": { "save-library-viz-panel-modal": {
"affected-dashboards_one": "This update will affect <1>{{count}} dashboard.</1> The following dashboard using the panel will be affected:", "affected-dashboards_one": "This update will affect <1>{{count}} dashboards.</1> The following dashboards using the panel will be affected:",
"affected-dashboards_other": "This update will affect <1>{{count}} dashboards.</1> The following dashboards using the panel will be affected:", "affected-dashboards_other": "This update will affect <1>{{count}} dashboards.</1> The following dashboards using the panel will be affected:",
"cancel": "Cancel", "cancel": "Cancel",
"dashboard-name": "Dashboard name", "dashboard-name": "Dashboard name",
@@ -5666,9 +5666,9 @@
"refresh": "Refresh" "refresh": "Refresh"
}, },
"query-inspector": { "query-inspector": {
"count-frames_one": "{{count}} frame, ", "count-frames_one": "{{count}} frames, ",
"count-frames_other": "{{count}} frames, ", "count-frames_other": "{{count}} frames, ",
"count-rows_one": "{{count}} row", "count-rows_one": "{{count}} rows",
"count-rows_other": "{{count}} rows", "count-rows_other": "{{count}} rows",
"query-inspector": "Query inspector", "query-inspector": "Query inspector",
"text-loading-query-inspector": "Loading query inspector..." "text-loading-query-inspector": "Loading query inspector..."
@@ -5885,7 +5885,7 @@
}, },
"library-panel-info": { "library-panel-info": {
"last-edited": "Last edited on {{timeAgo}} by<person />", "last-edited": "Last edited on {{timeAgo}} by<person />",
"usage-count_one": "Used on {{count}} dashboard", "usage-count_one": "Used on {{count}} dashboards",
"usage-count_other": "Used on {{count}} dashboards" "usage-count_other": "Used on {{count}} dashboards"
}, },
"library-panels-search": { "library-panels-search": {
@@ -5919,7 +5919,7 @@
"dashboard-name": "Dashboard name", "dashboard-name": "Dashboard name",
"discard": "Discard", "discard": "Discard",
"loading-connected-dashboards": "Loading connected dashboards...", "loading-connected-dashboards": "Loading connected dashboards...",
"num-affected_one": "This update will affect <1>{{count}} dashboard.</1>", "num-affected_one": "This update will affect <1>{{count}} dashboards.</1>",
"num-affected_other": "This update will affect <1>{{count}} dashboards.</1>", "num-affected_other": "This update will affect <1>{{count}} dashboards.</1>",
"placeholder-search-affected-dashboards": "Search affected dashboards", "placeholder-search-affected-dashboards": "Search affected dashboards",
"update-all": "Update all" "update-all": "Update all"
@@ -6029,6 +6029,10 @@
"log-labels-list": { "log-labels-list": {
"log-line": "log line" "log-line": "log line"
}, },
"log-line": {
"show-less": "show less",
"show-more": "show more"
},
"log-line-menu": { "log-line-menu": {
"copy-link": "Copy link to log line", "copy-link": "Copy link to log line",
"copy-log": "Copy log line", "copy-log": "Copy log line",