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?
pull/105045/head
Matias Chomicki 8 months ago committed by GitHub
parent 9f3e894b8e
commit 441e45d88a
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
  1. 1
      public/app/features/explore/Logs/Logs.tsx
  2. 2
      public/app/features/logs/components/panel/InfiniteScroll.tsx
  3. 102
      public/app/features/logs/components/panel/LogLine.test.tsx
  4. 123
      public/app/features/logs/components/panel/LogLine.tsx
  5. 23
      public/app/features/logs/components/panel/LogList.tsx
  6. 2
      public/app/features/logs/components/panel/__mocks__/LogListContext.tsx
  7. 60
      public/app/features/logs/components/panel/processing.test.ts
  8. 167
      public/app/features/logs/components/panel/processing.ts
  9. 119
      public/app/features/logs/components/panel/virtualization.test.ts
  10. 35
      public/app/features/logs/components/panel/virtualization.ts
  11. 26
      public/locales/en-US/grafana.json

@ -1151,6 +1151,7 @@ const UnthemedLogs: React.FunctionComponent<Props> = (props: Props) => {
filterLevels={filterLevels}
getFieldLinks={getFieldLinks}
getRowContextQuery={getRowContextQuery}
loading={loading}
loadMore={loadMoreLogs}
logOptionsStorageKey={SETTING_KEY_ROOT}
logs={dedupedRows}

@ -23,7 +23,7 @@ interface ChildrenProps {
interface Props {
children: (props: ChildrenProps) => ReactNode;
displayedFields: string[];
handleOverflow: (index: number, id: string, height: number) => void;
handleOverflow: (index: number, id: string, height?: number) => void;
loadMore?: (range: AbsoluteTimeRange) => void;
logs: LogListModel[];
scrollElement: HTMLDivElement | null;

@ -9,8 +9,10 @@ import { createLogLine } from '../__mocks__/logRow';
import { getStyles, LogLine } from './LogLine';
import { LogListContextProvider } from './LogListContext';
import { LogListModel } from './processing';
import { getTruncationLength } from './virtualization';
jest.mock('./LogListContext');
jest.mock('./virtualization');
const theme = createTheme();
const styles = getStyles(theme);
@ -97,6 +99,25 @@ describe('LogLine', () => {
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', () => {
test('Renders a log line menu', async () => {
render(
@ -180,4 +201,85 @@ describe('LogLine', () => {
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 { CSSProperties, useCallback, useEffect, useRef } from 'react';
import { CSSProperties, useCallback, useEffect, useRef, useState } from 'react';
import tinycolor from 'tinycolor2';
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 { LogMessageAnsi } from '../LogMessageAnsi';
@ -10,7 +12,13 @@ import { LogMessageAnsi } from '../LogMessageAnsi';
import { LogLineMenu } from './LogLineMenu';
import { useLogIsPinned, useLogListContext } from './LogListContext';
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 {
displayedFields: string[];
@ -19,7 +27,7 @@ interface Props {
showTime: boolean;
style: CSSProperties;
styles: LogLineStyles;
onOverflow?: (index: number, id: string, height: number) => void;
onOverflow?: (index: number, id: string, height?: number) => void;
variant?: 'infinite-scroll';
wrapLogMessage: boolean;
}
@ -36,41 +44,78 @@ export const LogLine = ({
wrapLogMessage,
}: Props) => {
const { onLogLineHover } = useLogListContext();
const [collapsed, setCollapsed] = useState<boolean | undefined>(
wrapLogMessage && log.collapsed !== undefined ? log.collapsed : undefined
);
const logLineRef = useRef<HTMLDivElement | null>(null);
const pinned = useLogIsPinned(log);
const handleMouseOver = useCallback(() => {
onLogLineHover?.(log);
}, [log, onLogLineHover]);
useEffect(() => {
if (!onOverflow || !logLineRef.current) {
return;
}
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) {
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 (
<div
style={style}
className={`${styles.logLine} ${variant ?? ''} ${pinned ? styles.pinnedLogLine : ''}`}
ref={onOverflow ? logLineRef : undefined}
onMouseOver={handleMouseOver}
>
<LogLineMenu styles={styles} log={log} />
<div className={`${wrapLogMessage ? styles.wrappedLogLine : `${styles.unwrappedLogLine} unwrapped-log-line`}`}>
<Log
displayedFields={displayedFields}
log={log}
showTime={showTime}
styles={styles}
wrapLogMessage={wrapLogMessage}
/>
<div style={style}>
<div
className={`${styles.logLine} ${variant ?? ''} ${pinned ? styles.pinnedLogLine : ''}`}
ref={onOverflow ? logLineRef : undefined}
onMouseEnter={handleMouseOver}
>
<LogLineMenu styles={styles} log={log} />
<div
className={`${wrapLogMessage ? styles.wrappedLogLine : `${styles.unwrappedLogLine} unwrapped-log-line`} ${collapsed === true ? styles.collapsedLogLine : ''}`}
>
<Log
displayedFields={displayedFields}
log={log}
showTime={showTime}
styles={styles}
wrapLogMessage={wrapLogMessage}
/>
</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>
);
};
@ -99,7 +144,7 @@ const Log = ({ displayedFields, log, showTime, styles, wrapLogMessage }: LogProp
<LogLineBody log={log} key={field} />
) : (
<span className="field" title={field} key={field}>
{getDisplayedFieldValue(field, log)}
{log.getDisplayedFieldValue(field)}
</span>
)
)
@ -131,20 +176,6 @@ const LogLineBody = ({ log }: { log: LogListModel }) => {
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[]) {
const columns = dimensions.map((dimension) => dimension.width).join('px ');
return `${columns}px 1fr`;
@ -172,6 +203,7 @@ export const getStyles = (theme: GrafanaTheme2) => {
fontFamily: theme.typography.fontFamilyMonospace,
fontSize: theme.typography.fontSize,
wordBreak: 'break-all',
cursor: 'pointer',
'&:hover': {
background: `hsla(0, 0%, 0%, 0.2)`,
},
@ -283,5 +315,18 @@ export const getStyles = (theme: GrafanaTheme2) => {
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;
grammar?: Grammar;
initialScrollPosition?: 'top' | 'bottom';
loading?: boolean;
loadMore?: (range: AbsoluteTimeRange) => void;
logOptionsStorageKey?: string;
logs: LogRowModel[];
@ -88,6 +89,7 @@ export const LogList = ({
getRowContextQuery,
grammar,
initialScrollPosition = 'top',
loading,
loadMore,
logOptionsStorageKey,
logs,
@ -140,6 +142,7 @@ export const LogList = ({
getFieldLinks={getFieldLinks}
grammar={grammar}
initialScrollPosition={initialScrollPosition}
loading={loading}
loadMore={loadMore}
logs={logs}
showControls={showControls}
@ -156,6 +159,7 @@ const LogListComponent = ({
getFieldLinks,
grammar,
initialScrollPosition = 'top',
loading,
loadMore,
logs,
showControls,
@ -189,15 +193,19 @@ const LogListComponent = ({
}, [eventBus, logs.length]);
useEffect(() => {
if (loading) {
return;
}
setProcessedLogs(
preProcessLogs(logs, { getFieldLinks, escape: forceEscape ?? false, order: sortOrder, timeZone }, grammar)
);
}, [forceEscape, getFieldLinks, grammar, logs, sortOrder, timeZone]);
resetLogLineSizes();
listRef.current?.resetAfterIndex(0);
}, [forceEscape, getFieldLinks, grammar, loading, logs, sortOrder, timeZone]);
useEffect(() => {
resetLogLineSizes();
listRef.current?.resetAfterIndex(0);
}, [wrapLogMessage, processedLogs]);
}, [wrapLogMessage]);
useEffect(() => {
const handleResize = debounce(() => {
@ -214,17 +222,16 @@ const LogListComponent = ({
if (widthRef.current === containerElement.clientWidth) {
return;
}
resetLogLineSizes();
listRef.current?.resetAfterIndex(0);
widthRef.current = containerElement.clientWidth;
listRef.current?.resetAfterIndex(0);
});
const handleOverflow = useCallback(
(index: number, id: string, height: number) => {
if (containerElement) {
(index: number, id: string, height?: number) => {
if (containerElement && height !== undefined) {
storeLogLineSize(id, containerElement, height);
listRef.current?.resetAfterIndex(index);
}
listRef.current?.resetAfterIndex(index);
},
[containerElement]
);

@ -82,6 +82,7 @@ export const LogListContextProvider = ({
filterLevels = [],
getRowContextQuery = jest.fn(),
logSupportsContext = jest.fn(),
onLogLineHover,
onPermalinkClick = jest.fn(),
onPinLine = jest.fn(),
onOpenContext = jest.fn(),
@ -102,6 +103,7 @@ export const LogListContextProvider = ({
filterLevels,
getRowContextQuery,
logSupportsContext,
onLogLineHover,
onPermalinkClick,
onPinLine,
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 { getTruncationLength, init } from './virtualization';
describe('preProcessLogs', () => {
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).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 { 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 { escapeUnescapedString, sortLogRows } from '../../utils';
import { LOG_LINE_BODY_FIELD_NAME } from '../LogDetailsBody';
import { FieldDef, getAllFields } from '../logParser';
import { generateLogGrammar } from './grammar';
import { getTruncationLength } from './virtualization';
export interface LogListModel extends LogRowModel {
body: string;
_highlightedBody: string;
highlightedBody: string;
export class LogListModel implements LogRowModel {
collapsed: boolean | undefined = undefined;
datasourceType: string | undefined;
dataFrame: DataFrame;
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;
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 {
@ -30,45 +149,17 @@ export const preProcessLogs = (
grammar?: Grammar
): LogListModel[] => {
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 {
escape: boolean;
getFieldLinks?: GetFieldLinksFn;
grammar?: Grammar;
timeZone: string;
}
const preProcessLog = (
log: LogRowModel,
{ 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,
};
const preProcessLog = (log: LogRowModel, options: PreProcessLogOptions): LogListModel => {
return new LogListModel(log, options);
};
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 { getDisplayedFieldValue } from './LogLine';
import { LogListModel } from './processing';
let ctx: CanvasRenderingContext2D | null = null;
@ -168,6 +167,13 @@ export function getLogLineSize(
if (!wrap || !logs[index]) {
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);
if (storedSize) {
return storedSize;
@ -189,14 +195,15 @@ export function getLogLineSize(
textToMeasure += logs[index].displayLevel ?? '';
}
for (const field of displayedFields) {
textToMeasure = getDisplayedFieldValue(field, logs[index]) + textToMeasure;
textToMeasure = logs[index].getDisplayedFieldValue(field) + textToMeasure;
}
if (!displayedFields.length) {
textToMeasure += logs[index].body;
}
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 {
@ -221,7 +228,7 @@ export const calculateFieldDimensions = (logs: LogListModel[], displayedFields:
levelWidth = Math.round(width);
}
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];
}
}
@ -248,14 +255,28 @@ export const calculateFieldDimensions = (logs: LogListModel[], displayedFields:
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;
if (element.scrollHeight > height) {
return element.scrollHeight;
return collapsed !== undefined ? element.scrollHeight + getLineHeight() : element.scrollHeight;
}
const child = element.children[1];
if (child instanceof HTMLDivElement && child.clientHeight < height) {
return child.clientHeight;
return collapsed !== undefined ? child.clientHeight + getLineHeight() : child.clientHeight;
}
return null;
}

@ -404,7 +404,7 @@
},
"alert-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"
},
"alert-manager-manual-routing": {
@ -1384,7 +1384,7 @@
"new-recording-rule": "New recording rule",
"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"
}
},
@ -2065,14 +2065,14 @@
"unknown-rule-type": "Unknown rule type"
},
"rule-list-errors": {
"button-errors_one": "{{count}} error",
"button-errors_one": "{{count}} errors",
"button-errors_other": "{{count}} errors",
"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-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-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",
"unknown-error": "Unknown error."
},
@ -2099,7 +2099,7 @@
}
},
"rule-stats": {
"error_one": "{{count}} error",
"error_one": "{{count}} errors",
"error_other": "{{count}} errors",
"firing": "{{alertingStats}} firing",
"inactive": "{{normalStats}} normal",
@ -4033,7 +4033,7 @@
},
"library-viz-panel-info": {
"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"
},
"name-already-exists-error": {
@ -4152,7 +4152,7 @@
"save-variables-label-update-default-variable-values": "Update default variable values"
},
"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:",
"cancel": "Cancel",
"dashboard-name": "Dashboard name",
@ -5666,9 +5666,9 @@
"refresh": "Refresh"
},
"query-inspector": {
"count-frames_one": "{{count}} frame, ",
"count-frames_one": "{{count}} frames, ",
"count-frames_other": "{{count}} frames, ",
"count-rows_one": "{{count}} row",
"count-rows_one": "{{count}} rows",
"count-rows_other": "{{count}} rows",
"query-inspector": "Query inspector",
"text-loading-query-inspector": "Loading query inspector..."
@ -5885,7 +5885,7 @@
},
"library-panel-info": {
"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"
},
"library-panels-search": {
@ -5919,7 +5919,7 @@
"dashboard-name": "Dashboard name",
"discard": "Discard",
"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>",
"placeholder-search-affected-dashboards": "Search affected dashboards",
"update-all": "Update all"
@ -6029,6 +6029,10 @@
"log-labels-list": {
"log-line": "log line"
},
"log-line": {
"show-less": "show less",
"show-more": "show more"
},
"log-line-menu": {
"copy-link": "Copy link to log line",
"copy-log": "Copy log line",

Loading…
Cancel
Save