New Logs Panel: Support multiple virtualization instances for different font sizes (#107023)

* LogList: memoize styles

* virtualization: refactor to support multiple instances

* LogList: refactor to support multiple virtualization instances

* Update tests

* LogLine: unfocus test

* virtualization: refactor class to use the provided font size

* LogLine: split component to reduce re-renders
pull/106661/head^2
Matias Chomicki 6 days ago committed by GitHub
parent c4c2510a04
commit 18757952eb
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
  1. 1
      public/app/features/logs/components/__mocks__/logRow.ts
  2. 7
      public/app/features/logs/components/panel/InfiniteScroll.tsx
  3. 19
      public/app/features/logs/components/panel/LogLine.test.tsx
  4. 325
      public/app/features/logs/components/panel/LogLine.tsx
  5. 43
      public/app/features/logs/components/panel/LogList.tsx
  6. 13
      public/app/features/logs/components/panel/processing.test.ts
  7. 25
      public/app/features/logs/components/panel/processing.ts
  8. 195
      public/app/features/logs/components/panel/virtualization.test.ts
  9. 423
      public/app/features/logs/components/panel/virtualization.ts

@ -45,6 +45,7 @@ export const createLogLine = (
escape: false,
order: LogsSortOrder.Descending,
timeZone: 'browser',
virtualization: undefined,
}
): LogListModel => {
const logs = preProcessLogs([createLogRow(overrides)], processOptions);

@ -12,6 +12,7 @@ import { canScrollBottom, getVisibleRange, ScrollDirection, shouldLoadMore } fro
import { getStyles, LogLine } from './LogLine';
import { LogLineMessage } from './LogLineMessage';
import { LogListModel } from './processing';
import { LogLineVirtualization } from './virtualization';
interface ChildrenProps {
itemCount: number;
@ -33,6 +34,7 @@ interface Props {
sortOrder: LogsSortOrder;
timeRange: TimeRange;
timeZone: string;
virtualization: LogLineVirtualization;
wrapLogMessage: boolean;
}
@ -51,6 +53,7 @@ export const InfiniteScroll = ({
sortOrder,
timeRange,
timeZone,
virtualization,
wrapLogMessage,
}: Props) => {
const [infiniteLoaderState, setInfiniteLoaderState] = useState<InfiniteLoaderState>('idle');
@ -61,7 +64,7 @@ export const InfiniteScroll = ({
const lastEvent = useRef<Event | WheelEvent | null>(null);
const countRef = useRef(0);
const lastLogOfPage = useRef<string[]>([]);
const styles = useStyles2(getStyles);
const styles = useStyles2(getStyles, virtualization);
useEffect(() => {
// Logs have not changed, ignore effect
@ -156,6 +159,7 @@ export const InfiniteScroll = ({
style={style}
styles={styles}
variant={getLogLineVariant(logs, index, lastLogOfPage.current)}
virtualization={virtualization}
wrapLogMessage={wrapLogMessage}
onOverflow={handleOverflow}
/>
@ -171,6 +175,7 @@ export const InfiniteScroll = ({
showTime,
sortOrder,
styles,
virtualization,
wrapLogMessage,
]
);

@ -12,13 +12,13 @@ import { LogListContextProvider } from './LogListContext';
import { LogListSearchContext } from './LogListSearchContext';
import { defaultProps } from './__mocks__/LogListContext';
import { LogListModel } from './processing';
import { getTruncationLength } from './virtualization';
import { LogLineVirtualization } from './virtualization';
jest.mock('./LogListContext');
jest.mock('./virtualization');
const theme = createTheme();
const styles = getStyles(theme);
const virtualization = new LogLineVirtualization(theme, 'default');
const styles = getStyles(theme, virtualization);
const contextProps = {
...defaultProps,
app: CoreApp.Unknown,
@ -34,7 +34,10 @@ const fontSizes: LogListFontSize[] = ['default', 'small'];
describe.each(fontSizes)('LogLine', (fontSize: LogListFontSize) => {
let log: LogListModel, defaultProps: Props;
beforeEach(() => {
log = createLogLine({ labels: { place: 'luna' }, entry: `log message 1` });
log = createLogLine(
{ labels: { place: 'luna' }, entry: `log message 1` },
{ escape: false, order: LogsSortOrder.Descending, timeZone: 'browser', virtualization }
);
contextProps.logs = [log];
contextProps.fontSize = fontSize;
defaultProps = {
@ -219,8 +222,12 @@ describe.each(fontSizes)('LogLine', (fontSize: LogListFontSize) => {
describe('Collapsible log lines', () => {
beforeEach(() => {
log = createLogLine({ labels: { place: 'luna' }, entry: `log message 1` });
jest.mocked(getTruncationLength).mockReturnValue(5);
const virtualization = new LogLineVirtualization(theme, 'default');
jest.spyOn(virtualization, 'getTruncationLength').mockReturnValue(5);
log = createLogLine(
{ labels: { place: 'luna' }, entry: `log message 1` },
{ escape: false, order: LogsSortOrder.Descending, timeZone: 'browser', virtualization }
);
});
test('Logs are not collapsed by default', () => {

@ -17,9 +17,9 @@ import { LogListModel } from './processing';
import {
FIELD_GAP_MULTIPLIER,
hasUnderOrOverflow,
getLineHeight,
LogFieldDimension,
getTruncationLineCount,
LogLineVirtualization,
DEFAULT_LINE_HEIGHT,
} from './virtualization';
export interface Props {
@ -32,6 +32,7 @@ export interface Props {
onClick: (e: MouseEvent<HTMLElement>, log: LogListModel) => void;
onOverflow?: (index: number, id: string, height?: number) => void;
variant?: 'infinite-scroll';
virtualization?: LogLineVirtualization;
wrapLogMessage: boolean;
}
@ -45,156 +46,200 @@ export const LogLine = ({
onOverflow,
showTime,
variant,
virtualization,
wrapLogMessage,
}: Props) => {
const {
detailsDisplayed,
dedupStrategy,
enableLogDetails,
fontSize,
hasLogsWithErrors,
hasSampledLogs,
onLogLineHover,
} = useLogListContext();
const [collapsed, setCollapsed] = useState<boolean | undefined>(
wrapLogMessage && log.collapsed !== undefined ? log.collapsed : undefined
return (
<div style={style}>
<LogLineComponent
displayedFields={displayedFields}
height={style.height}
index={index}
log={log}
styles={styles}
onClick={onClick}
onOverflow={onOverflow}
showTime={showTime}
variant={variant}
virtualization={virtualization}
wrapLogMessage={wrapLogMessage}
/>
</div>
);
const logLineRef = useRef<HTMLDivElement | null>(null);
const pinned = useLogIsPinned(log);
const permalinked = useLogIsPermalinked(log);
};
useEffect(() => {
if (!onOverflow || !logLineRef.current) {
return;
}
const calculatedHeight = typeof style.height === 'number' ? style.height : undefined;
const actualHeight = hasUnderOrOverflow(logLineRef.current, calculatedHeight, log.collapsed);
if (actualHeight) {
onOverflow(index, log.uid, actualHeight);
}
});
interface LogLineComponentProps extends Omit<Props, 'style'> {
height?: number | string;
}
useEffect(() => {
if (!wrapLogMessage) {
setCollapsed(undefined);
} else if (collapsed === undefined && log.collapsed !== undefined) {
setCollapsed(log.collapsed);
} else if (collapsed !== undefined && log.collapsed === undefined) {
setCollapsed(log.collapsed);
}
}, [collapsed, log.collapsed, wrapLogMessage]);
const LogLineComponent = memo(
({
displayedFields,
height,
index,
log,
styles,
onClick,
onOverflow,
showTime,
variant,
virtualization,
wrapLogMessage,
}: LogLineComponentProps) => {
const {
detailsDisplayed,
dedupStrategy,
enableLogDetails,
fontSize,
hasLogsWithErrors,
hasSampledLogs,
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 permalinked = useLogIsPermalinked(log);
const handleMouseOver = useCallback(() => onLogLineHover?.(log), [log, onLogLineHover]);
useEffect(() => {
if (!onOverflow || !logLineRef.current || !virtualization || !height) {
return;
}
const calculatedHeight = typeof height === 'number' ? height : undefined;
const actualHeight = hasUnderOrOverflow(virtualization, logLineRef.current, calculatedHeight, log.collapsed);
if (actualHeight) {
onOverflow(index, log.uid, actualHeight);
}
});
const handleExpandCollapse = useCallback(() => {
const newState = !collapsed;
setCollapsed(newState);
log.setCollapsedState(newState);
onOverflow?.(index, log.uid);
}, [collapsed, index, log, onOverflow]);
useEffect(() => {
if (!wrapLogMessage) {
setCollapsed(undefined);
} else if (collapsed === undefined && log.collapsed !== undefined) {
setCollapsed(log.collapsed);
} else if (collapsed !== undefined && log.collapsed === undefined) {
setCollapsed(log.collapsed);
}
}, [collapsed, log.collapsed, wrapLogMessage]);
const handleClick = useCallback(
(e: MouseEvent<HTMLElement>) => {
onClick(e, log);
},
[log, onClick]
);
const handleMouseOver = useCallback(() => onLogLineHover?.(log), [log, onLogLineHover]);
const detailsShown = detailsDisplayed(log);
const handleExpandCollapse = useCallback(() => {
const newState = !collapsed;
setCollapsed(newState);
log.setCollapsedState(newState);
onOverflow?.(index, log.uid);
}, [collapsed, index, log, onOverflow]);
return (
<div style={style}>
<div
className={`${styles.logLine} ${variant ?? ''} ${pinned ? styles.pinnedLogLine : ''} ${permalinked ? styles.permalinkedLogLine : ''} ${detailsShown ? styles.detailsDisplayed : ''} ${fontSize === 'small' ? styles.fontSizeSmall : ''}`}
ref={onOverflow ? logLineRef : undefined}
onMouseEnter={handleMouseOver}
onFocus={handleMouseOver}
>
<LogLineMenu styles={styles} log={log} />
{dedupStrategy !== LogsDedupStrategy.none && (
<div className={`${styles.duplicates}`}>
{log.duplicates && log.duplicates > 0 ? `${log.duplicates + 1}x` : null}
const handleClick = useCallback(
(e: MouseEvent<HTMLElement>) => {
onClick(e, log);
},
[log, onClick]
);
const detailsShown = detailsDisplayed(log);
return (
<>
<div
className={`${styles.logLine} ${variant ?? ''} ${pinned ? styles.pinnedLogLine : ''} ${permalinked ? styles.permalinkedLogLine : ''} ${detailsShown ? styles.detailsDisplayed : ''} ${fontSize === 'small' ? styles.fontSizeSmall : ''}`}
ref={onOverflow ? logLineRef : undefined}
onMouseEnter={handleMouseOver}
onFocus={handleMouseOver}
>
<LogLineMenu styles={styles} log={log} />
{dedupStrategy !== LogsDedupStrategy.none && (
<div className={`${styles.duplicates}`}>
{log.duplicates && log.duplicates > 0 ? `${log.duplicates + 1}x` : null}
</div>
)}
{hasLogsWithErrors && (
<div className={`${styles.hasError}`}>
{log.hasError && (
<Tooltip
content={t('logs.log-line.tooltip-error', 'Error: {{errorMessage}}', {
errorMessage: log.errorMessage,
})}
placement="right"
theme="error"
>
<Icon
className={styles.logIconError}
name="exclamation-triangle"
aria-label={t('logs.log-line.has-error', 'Has errors')}
size="xs"
/>
</Tooltip>
)}
</div>
)}
{hasSampledLogs && (
<div className={`${styles.isSampled}`}>
{log.isSampled && (
<Tooltip content={log.sampledMessage ?? ''} placement="right" theme="info">
<Icon
className={styles.logIconInfo}
name="info-circle"
size="xs"
aria-label={t('logs.log-line.is-sampled', 'Is sampled')}
/>
</Tooltip>
)}
</div>
)}
{/* A button element could be used but in Safari it prevents text selection. Fallback available for a11y in LogLineMenu */}
{/* eslint-disable-next-line jsx-a11y/no-static-element-interactions, jsx-a11y/click-events-have-key-events */}
<div
className={`${wrapLogMessage ? styles.wrappedLogLine : `${styles.unwrappedLogLine} unwrapped-log-line`} ${collapsed === true ? styles.collapsedLogLine : ''} ${enableLogDetails ? styles.clickable : ''}`}
style={
collapsed && virtualization
? { maxHeight: `${virtualization.getTruncationLineCount() * virtualization.getLineHeight()}px` }
: undefined
}
onClick={handleClick}
>
<Log
displayedFields={displayedFields}
log={log}
showTime={showTime}
styles={styles}
wrapLogMessage={wrapLogMessage}
/>
</div>
)}
{hasLogsWithErrors && (
<div className={`${styles.hasError}`}>
{log.hasError && (
<Tooltip
content={t('logs.log-line.tooltip-error', 'Error: {{errorMessage}}', {
errorMessage: log.errorMessage,
})}
placement="right"
theme="error"
>
<Icon
className={styles.logIconError}
name="exclamation-triangle"
aria-label={t('logs.log-line.has-error', 'Has errors')}
size="xs"
/>
</Tooltip>
)}
</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>
)}
{hasSampledLogs && (
<div className={`${styles.isSampled}`}>
{log.isSampled && (
<Tooltip content={log.sampledMessage ?? ''} placement="right" theme="info">
<Icon
className={styles.logIconInfo}
name="info-circle"
size="xs"
aria-label={t('logs.log-line.is-sampled', 'Is sampled')}
/>
</Tooltip>
)}
{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>
)}
{/* A button element could be used but in Safari it prevents text selection. Fallback available for a11y in LogLineMenu */}
{/* eslint-disable-next-line jsx-a11y/no-static-element-interactions, jsx-a11y/click-events-have-key-events */}
<div
className={`${wrapLogMessage ? styles.wrappedLogLine : `${styles.unwrappedLogLine} unwrapped-log-line`} ${collapsed === true ? styles.collapsedLogLine : ''} ${enableLogDetails ? styles.clickable : ''}`}
style={collapsed ? { maxHeight: `${getTruncationLineCount() * getLineHeight()}px` } : undefined}
onClick={handleClick}
>
<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>
);
};
</>
);
}
);
LogLineComponent.displayName = 'LogLineComponent';
interface LogProps {
displayedFields: string[];
@ -311,7 +356,7 @@ export function getGridTemplateColumns(dimensions: LogFieldDimension[]) {
}
export type LogLineStyles = ReturnType<typeof getStyles>;
export const getStyles = (theme: GrafanaTheme2) => {
export const getStyles = (theme: GrafanaTheme2, virtualization?: LogLineVirtualization) => {
const colors = {
critical: '#B877D9',
error: theme.colors.error.text,
@ -404,7 +449,7 @@ export const getStyles = (theme: GrafanaTheme2) => {
backgroundColor: tinycolor(theme.colors.info.transparent).setAlpha(0.25).toString(),
}),
menuIcon: css({
height: getLineHeight(),
height: virtualization?.getLineHeight() ?? DEFAULT_LINE_HEIGHT,
margin: 0,
padding: theme.spacing(0, 0, 0, 0.5),
}),
@ -501,7 +546,7 @@ export const getStyles = (theme: GrafanaTheme2) => {
}),
expandCollapseControlButton: css({
fontWeight: theme.typography.fontWeightLight,
height: getLineHeight(),
height: virtualization?.getLineHeight() ?? DEFAULT_LINE_HEIGHT,
margin: 0,
}),
};

@ -20,7 +20,7 @@ import {
TimeRange,
} from '@grafana/data';
import { Trans, t } from '@grafana/i18n';
import { ConfirmModal, Icon, PopoverContent, useTheme2 } from '@grafana/ui';
import { ConfirmModal, Icon, PopoverContent, useStyles2, useTheme2 } from '@grafana/ui';
import { PopoverMenu } from 'app/features/explore/Logs/PopoverMenu';
import { GetFieldLinksFn } from 'app/plugins/panel/logs/types';
@ -35,15 +35,7 @@ import { LogListSearchContextProvider, useLogListSearchContext } from './LogList
import { preProcessLogs, LogListModel } from './processing';
import { useKeyBindings } from './useKeyBindings';
import { usePopoverMenu } from './usePopoverMenu';
import {
calculateFieldDimensions,
getLogLineSize,
init as initVirtualization,
LogFieldDimension,
resetLogLineSizes,
ScrollToLogsEvent,
storeLogLineSize,
} from './virtualization';
import { LogLineVirtualization, getLogLineSize, LogFieldDimension, ScrollToLogsEvent } from './virtualization';
export interface Props {
app: CoreApp;
@ -251,11 +243,12 @@ const LogListComponent = ({
const widthRef = useRef(containerElement.clientWidth);
const wrapperRef = useRef<HTMLDivElement | null>(null);
const scrollRef = useRef<HTMLDivElement | null>(null);
const virtualization = useMemo(() => new LogLineVirtualization(theme, fontSize), [theme, fontSize]);
const dimensions = useMemo(
() => (wrapLogMessage ? [] : calculateFieldDimensions(processedLogs, displayedFields)),
[displayedFields, processedLogs, wrapLogMessage]
() => (wrapLogMessage ? [] : virtualization.calculateFieldDimensions(processedLogs, displayedFields)),
[displayedFields, processedLogs, virtualization, wrapLogMessage]
);
const styles = getStyles(dimensions, { showTime }, theme);
const styles = useStyles2(getStyles, dimensions, { showTime });
const widthContainer = wrapperRef.current ?? containerElement;
const {
closePopoverMenu,
@ -276,10 +269,6 @@ const LogListComponent = ({
}, 25);
}, []);
useEffect(() => {
initVirtualization(theme, fontSize);
}, [fontSize, theme]);
useEffect(() => {
const subscription = eventBus.subscribe(ScrollToLogsEvent, (e: ScrollToLogsEvent) =>
handleScrollToEvent(e, logs.length, listRef.current)
@ -292,11 +281,15 @@ const LogListComponent = ({
return;
}
setProcessedLogs(
preProcessLogs(logs, { getFieldLinks, escape: forceEscape ?? false, order: sortOrder, timeZone }, grammar)
preProcessLogs(
logs,
{ getFieldLinks, escape: forceEscape ?? false, order: sortOrder, timeZone, virtualization },
grammar
)
);
resetLogLineSizes();
virtualization.resetLogLineSizes();
listRef.current?.resetAfterIndex(0);
}, [forceEscape, getFieldLinks, grammar, loading, logs, sortOrder, timeZone]);
}, [forceEscape, getFieldLinks, grammar, loading, logs, sortOrder, timeZone, virtualization]);
useEffect(() => {
listRef.current?.resetAfterIndex(0);
@ -329,7 +322,7 @@ const LogListComponent = ({
const handleOverflow = useCallback(
(index: number, id: string, height?: number) => {
if (height !== undefined) {
storeLogLineSize(id, widthContainer, height, fontSize);
virtualization.storeLogLineSize(id, widthContainer, height);
}
if (index === overflowIndexRef.current) {
return;
@ -337,7 +330,7 @@ const LogListComponent = ({
overflowIndexRef.current = index < overflowIndexRef.current ? index : overflowIndexRef.current;
debouncedResetAfterIndex(overflowIndexRef.current);
},
[debouncedResetAfterIndex, fontSize, widthContainer]
[debouncedResetAfterIndex, virtualization, widthContainer]
);
const handleScrollPosition = useCallback(() => {
@ -434,6 +427,7 @@ const LogListComponent = ({
timeRange={timeRange}
timeZone={timeZone}
setInitialScrollPosition={handleScrollPosition}
virtualization={virtualization}
wrapLogMessage={wrapLogMessage}
>
{({ getItemKey, itemCount, onItemsRendered, Renderer }) => (
@ -441,8 +435,7 @@ const LogListComponent = ({
className={styles.logList}
height={listHeight}
itemCount={itemCount}
itemSize={getLogLineSize.bind(null, filteredLogs, widthContainer, displayedFields, {
fontSize,
itemSize={getLogLineSize.bind(null, virtualization, filteredLogs, widthContainer, displayedFields, {
hasLogsWithErrors,
hasSampledLogs,
showDuplicates: dedupStrategy !== LogsDedupStrategy.none,
@ -476,7 +469,7 @@ const LogListComponent = ({
);
};
function getStyles(dimensions: LogFieldDimension[], { showTime }: { showTime: boolean }, theme: GrafanaTheme2) {
function getStyles(theme: GrafanaTheme2, dimensions: LogFieldDimension[], { showTime }: { showTime: boolean }) {
const columns = showTime ? dimensions : dimensions.filter((_, index) => index > 0);
return {
logList: css({

@ -5,7 +5,7 @@ import { createLogLine, createLogRow } from '../__mocks__/logRow';
import { LogListFontSize } from './LogList';
import { LogListModel, preProcessLogs } from './processing';
import { getTruncationLength, init } from './virtualization';
import { LogLineVirtualization } from './virtualization';
describe('preProcessLogs', () => {
let logFmtLog: LogRowModel, nginxLog: LogRowModel, jsonLog: LogRowModel;
@ -168,13 +168,16 @@ describe('preProcessLogs', () => {
});
describe.each(fontSizes)('Collapsible log lines', (fontSize: LogListFontSize) => {
let longLog: LogListModel, entry: string, container: HTMLDivElement;
let longLog: LogListModel, entry: string, container: HTMLDivElement, virtualization: LogLineVirtualization;
beforeEach(() => {
init(createTheme(), fontSize);
virtualization = new LogLineVirtualization(createTheme(), fontSize);
container = document.createElement('div');
jest.spyOn(container, 'clientWidth', 'get').mockReturnValue(200);
entry = new Array(2 * getTruncationLength(null)).fill('e').join('');
longLog = createLogLine({ entry, labels: { field: 'value' } });
entry = new Array(2 * virtualization.getTruncationLength(null)).fill('e').join('');
longLog = createLogLine(
{ entry, labels: { field: 'value' } },
{ escape: false, order: LogsSortOrder.Descending, timeZone: 'browser', virtualization }
);
});
test('Long lines that are not truncated are not modified', () => {

@ -8,7 +8,9 @@ import { LOG_LINE_BODY_FIELD_NAME } from '../LogDetailsBody';
import { FieldDef, getAllFields } from '../logParser';
import { generateLogGrammar, generateTextMatchGrammar } from './grammar';
import { getTruncationLength } from './virtualization';
import { LogLineVirtualization } from './virtualization';
const TRUNCATION_DEFAULT_LENGTH = 50000;
export class LogListModel implements LogRowModel {
collapsed: boolean | undefined = undefined;
@ -44,11 +46,13 @@ export class LogListModel implements LogRowModel {
private _highlightedBody: string | undefined = undefined;
private _fields: FieldDef[] | undefined = undefined;
private _getFieldLinks: GetFieldLinksFn | undefined = undefined;
private _virtualization?: LogLineVirtualization;
constructor(log: LogRowModel, { escape, getFieldLinks, grammar, timeZone }: PreProcessLogOptions) {
constructor(log: LogRowModel, { escape, getFieldLinks, grammar, timeZone, virtualization }: PreProcessLogOptions) {
// LogRowModel
this.datasourceType = log.datasourceType;
this.dataFrame = log.dataFrame;
this.datasourceUid = log.datasourceUid;
this.duplicates = log.duplicates;
this.entry = log.entry;
this.entryFieldIndex = log.entryFieldIndex;
@ -68,7 +72,6 @@ export class LogListModel implements LogRowModel {
this.timeUtc = log.timeUtc;
this.uid = log.uid;
this.uniqueLabels = log.uniqueLabels;
this.datasourceUid = log.datasourceUid;
// LogListModel
this.displayLevel = logLevelToDisplayLevel(log.logLevel);
@ -78,6 +81,7 @@ export class LogListModel implements LogRowModel {
timeZone,
defaultWithMS: true,
});
this._virtualization = virtualization;
let raw = log.raw;
if (escape && log.hasUnescapedContent) {
@ -88,7 +92,9 @@ export class LogListModel implements LogRowModel {
get body(): string {
if (this._body === undefined) {
this._body = this.collapsed ? this.raw.substring(0, getTruncationLength(null)) : this.raw;
this._body = this.collapsed
? this.raw.substring(0, this._virtualization?.getTruncationLength(null) ?? TRUNCATION_DEFAULT_LENGTH)
: this.raw;
}
return this._body;
}
@ -136,7 +142,10 @@ export class LogListModel implements LogRowModel {
displayedFields.length > 0
? displayedFields.map((field) => this.getDisplayedFieldValue(field)).join('').length
: this.raw.length;
const collapsed = lineLength >= getTruncationLength(container) ? true : undefined;
const collapsed =
lineLength >= (this._virtualization?.getTruncationLength(container) ?? TRUNCATION_DEFAULT_LENGTH)
? true
: undefined;
if (this.collapsed === undefined || collapsed === undefined) {
this.collapsed = collapsed;
}
@ -162,15 +171,16 @@ export interface PreProcessOptions {
getFieldLinks?: GetFieldLinksFn;
order: LogsSortOrder;
timeZone: string;
virtualization?: LogLineVirtualization;
}
export const preProcessLogs = (
logs: LogRowModel[],
{ escape, getFieldLinks, order, timeZone }: PreProcessOptions,
{ escape, getFieldLinks, order, timeZone, virtualization }: PreProcessOptions,
grammar?: Grammar
): LogListModel[] => {
const orderedLogs = sortLogRows(logs, order);
return orderedLogs.map((log) => preProcessLog(log, { escape, getFieldLinks, grammar, timeZone }));
return orderedLogs.map((log) => preProcessLog(log, { escape, getFieldLinks, grammar, timeZone, virtualization }));
};
interface PreProcessLogOptions {
@ -178,6 +188,7 @@ interface PreProcessLogOptions {
getFieldLinks?: GetFieldLinksFn;
grammar?: Grammar;
timeZone: string;
virtualization?: LogLineVirtualization;
}
const preProcessLog = (log: LogRowModel, options: PreProcessLogOptions): LogListModel => {
return new LogListModel(log, options);

@ -1,55 +1,55 @@
import { createTheme } from '@grafana/data';
import { createTheme, LogsSortOrder } 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,
getTruncationLineCount,
DisplayOptions,
} 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;
let TWO_LINES_OF_CHARACTERS: number;
const defaultOptions: DisplayOptions = {
wrap: false,
showTime: false,
showDuplicates: false,
hasLogsWithErrors: false,
hasSampledLogs: false,
fontSize: 'default',
};
import { LogLineVirtualization, getLogLineSize, DisplayOptions } from './virtualization';
describe('Virtualization', () => {
let log: LogListModel, container: HTMLDivElement;
let virtualization = new LogLineVirtualization(createTheme(), 'default');
const PADDING_BOTTOM = 6;
const LINE_HEIGHT = virtualization.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;
let TWO_LINES_OF_CHARACTERS: number;
const defaultOptions: DisplayOptions = {
wrap: false,
showTime: false,
showDuplicates: false,
hasLogsWithErrors: false,
hasSampledLogs: false,
};
beforeEach(() => {
log = createLogLine({ labels: { place: 'luna' }, entry: `log message 1` });
log = createLogLine(
{ labels: { place: 'luna' }, entry: `log message 1` },
{ escape: false, order: LogsSortOrder.Descending, timeZone: 'browser', virtualization }
);
//virtualization = new LogLineVirtualization(createTheme(), 'default');
container = document.createElement('div');
jest.spyOn(container, 'clientWidth', 'get').mockReturnValue(CONTAINER_SIZE);
init(createTheme(), 'default');
LETTER_WIDTH = measureTextWidth('e');
LETTER_WIDTH = virtualization.measureTextWidth('e');
TWO_LINES_OF_CHARACTERS = (CONTAINER_SIZE / LETTER_WIDTH) * 1.5;
});
describe('getLogLineSize', () => {
test('Returns the a single line if the display mode is unwrapped', () => {
const size = getLogLineSize([log], container, [], { ...defaultOptions, showTime: true }, 0);
const size = getLogLineSize(virtualization, [log], container, [], { ...defaultOptions, 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(
virtualization,
logs,
container,
[],
@ -63,42 +63,66 @@ describe('Virtualization', () => {
// Very small container
log.collapsed = true;
jest.spyOn(container, 'clientWidth', 'get').mockReturnValue(10);
const size = getLogLineSize([log], container, [], { ...defaultOptions, wrap: true, showTime: true }, 0);
expect(size).toBe((getTruncationLineCount() + 1) * LINE_HEIGHT);
const size = getLogLineSize(
virtualization,
[log],
container,
[],
{ ...defaultOptions, wrap: true, showTime: true },
0
);
expect(size).toBe((virtualization.getTruncationLineCount() + 1) * LINE_HEIGHT);
});
test.each([true, false])('Measures a log line with controls %s and displayed time %s', (showTime: boolean) => {
const size = getLogLineSize([log], container, [], { ...defaultOptions, wrap: true, showTime }, 0);
const size = getLogLineSize(virtualization, [log], container, [], { ...defaultOptions, wrap: true, showTime }, 0);
expect(size).toBe(SINGLE_LINE_HEIGHT);
});
test('Measures a multi-line log line with no displayed time', () => {
log = createLogLine({
labels: { place: 'luna' },
entry: new Array(TWO_LINES_OF_CHARACTERS).fill('e').join(''),
logLevel: undefined,
});
log = createLogLine(
{
labels: { place: 'luna' },
entry: new Array(TWO_LINES_OF_CHARACTERS).fill('e').join(''),
logLevel: undefined,
},
{ escape: false, order: LogsSortOrder.Descending, timeZone: 'browser', virtualization }
);
const size = getLogLineSize([log], container, [], { ...defaultOptions, wrap: true }, 0);
const size = getLogLineSize(virtualization, [log], container, [], { ...defaultOptions, wrap: true }, 0);
expect(size).toBe(TWO_LINES_HEIGHT);
});
test('Measures a multi-line log line with level, controls, and displayed time', () => {
log = createLogLine({ labels: { place: 'luna' }, entry: new Array(TWO_LINES_OF_CHARACTERS).fill('e').join('') });
log = createLogLine(
{ labels: { place: 'luna' }, entry: new Array(TWO_LINES_OF_CHARACTERS).fill('e').join('') },
{ escape: false, order: LogsSortOrder.Descending, timeZone: 'browser', virtualization }
);
const size = getLogLineSize([log], container, [], { ...defaultOptions, wrap: true, showTime: true }, 0);
const size = getLogLineSize(
virtualization,
[log],
container,
[],
{ ...defaultOptions, wrap: 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', () => {
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,
});
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,
},
{ escape: false, order: LogsSortOrder.Descending, timeZone: 'browser', virtualization }
);
const size = getLogLineSize(
virtualization,
[log],
container,
['place', LOG_LINE_BODY_FIELD_NAME],
@ -110,34 +134,74 @@ describe('Virtualization', () => {
});
test('Measures displayed fields in a log line with level, controls, and displayed time', () => {
log = createLogLine({ labels: { place: 'luna' }, entry: new Array(TWO_LINES_OF_CHARACTERS).fill('e').join('') });
log = createLogLine(
{ labels: { place: 'luna' }, entry: new Array(TWO_LINES_OF_CHARACTERS).fill('e').join('') },
{ escape: false, order: LogsSortOrder.Descending, timeZone: 'browser', virtualization }
);
const size = getLogLineSize([log], container, ['place'], { ...defaultOptions, wrap: true, showTime: true }, 0);
const size = getLogLineSize(
virtualization,
[log],
container,
['place'],
{ ...defaultOptions, wrap: true, showTime: true },
0
);
// Only renders a short displayed field, so a single line
expect(size).toBe(SINGLE_LINE_HEIGHT);
});
test('Measures a multi-line log line with duplicates', () => {
log = createLogLine({ labels: { place: 'luna' }, entry: new Array(TWO_LINES_OF_CHARACTERS).fill('e').join('') });
log = createLogLine(
{ labels: { place: 'luna' }, entry: new Array(TWO_LINES_OF_CHARACTERS).fill('e').join('') },
{ escape: false, order: LogsSortOrder.Descending, timeZone: 'browser', virtualization }
);
log.duplicates = 1;
const size = getLogLineSize([log], container, [], { ...defaultOptions, wrap: true, showDuplicates: true }, 0);
const size = getLogLineSize(
virtualization,
[log],
container,
[],
{ ...defaultOptions, wrap: true, showDuplicates: true },
0
);
// Two lines for the log and one extra for duplicates
expect(size).toBe(THREE_LINES_HEIGHT);
});
test('Measures a multi-line log line with errors', () => {
log = createLogLine({ labels: { place: 'luna' }, entry: new Array(TWO_LINES_OF_CHARACTERS).fill('e').join('') });
log = createLogLine(
{ labels: { place: 'luna' }, entry: new Array(TWO_LINES_OF_CHARACTERS).fill('e').join('') },
{ escape: false, order: LogsSortOrder.Descending, timeZone: 'browser', virtualization }
);
const size = getLogLineSize([log], container, [], { ...defaultOptions, wrap: true, hasLogsWithErrors: true }, 0);
const size = getLogLineSize(
virtualization,
[log],
container,
[],
{ ...defaultOptions, wrap: true, hasLogsWithErrors: true },
0
);
// Two lines for the log and one extra for the error icon
expect(size).toBe(THREE_LINES_HEIGHT);
});
test('Measures a multi-line sampled log line', () => {
log = createLogLine({ labels: { place: 'luna' }, entry: new Array(TWO_LINES_OF_CHARACTERS).fill('e').join('') });
log = createLogLine(
{ labels: { place: 'luna' }, entry: new Array(TWO_LINES_OF_CHARACTERS).fill('e').join('') },
{ escape: false, order: LogsSortOrder.Descending, timeZone: 'browser', virtualization }
);
const size = getLogLineSize([log], container, [], { ...defaultOptions, wrap: true, hasSampledLogs: true }, 0);
const size = getLogLineSize(
virtualization,
[log],
container,
[],
{ ...defaultOptions, wrap: true, hasSampledLogs: true },
0
);
// Two lines for the log and one extra for the sampled icon
expect(size).toBe(THREE_LINES_HEIGHT);
});
@ -145,29 +209,34 @@ describe('Virtualization', () => {
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, [], { ...defaultOptions, wrap: true }, 0);
const size = getLogLineSize(virtualization, [log], container, [], { ...defaultOptions, wrap: true }, 0);
expect(size).toBe(TWO_LINES_HEIGHT);
});
});
describe('With small font size', () => {
const virtualization = new LogLineVirtualization(createTheme(), 'small');
beforeEach(() => {
init(createTheme(), 'small');
LETTER_WIDTH = measureTextWidth('e');
LETTER_WIDTH = virtualization.measureTextWidth('e');
TWO_LINES_OF_CHARACTERS = (CONTAINER_SIZE / LETTER_WIDTH) * 1.5;
});
test('Measures a multi-line log line with displayed fields', () => {
const SMALL_LINE_HEIGHT = getLineHeight();
const SMALL_LINE_HEIGHT = virtualization.getLineHeight();
const SMALL_THREE_LINES_HEIGHT = 3 * SMALL_LINE_HEIGHT + PADDING_BOTTOM;
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,
});
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,
},
{ escape: false, order: LogsSortOrder.Descending, timeZone: 'browser', virtualization }
);
const size = getLogLineSize(
virtualization,
[log],
container,
['place', LOG_LINE_BODY_FIELD_NAME],

@ -7,167 +7,237 @@ import { LOG_LINE_BODY_FIELD_NAME } from '../LogDetailsBody';
import { LogListFontSize } from './LogList';
import { LogListModel } from './processing';
let ctx: CanvasRenderingContext2D | null = null;
let gridSize = 8;
let paddingBottom = gridSize * 0.75;
let lineHeight = 22;
let measurementMode: 'canvas' | 'dom' = 'canvas';
const iconWidth = 24;
export const LOG_LIST_MIN_WIDTH = 35 * gridSize;
export const LOG_LIST_MIN_WIDTH = 35 * 8;
// Controls the space between fields in the log line, timestamp, level, displayed fields, and log line body
export const FIELD_GAP_MULTIPLIER = 1.5;
export const getLineHeight = () => lineHeight;
export const DEFAULT_LINE_HEIGHT = 22;
export class LogLineVirtualization {
private ctx: CanvasRenderingContext2D | null = null;
private gridSize;
private paddingBottom;
private lineHeight;
private measurementMode: 'canvas' | 'dom' = 'canvas';
private textWidthMap: Map<number, number>;
private logLineSizesMap: Map<string, number>;
private spanElement = document.createElement('span');
readonly fontSize: LogListFontSize;
constructor(theme: GrafanaTheme2, fontSize: LogListFontSize) {
this.fontSize = fontSize;
let fontSizePx;
if (fontSize === 'default') {
fontSizePx = theme.typography.fontSize;
this.lineHeight = theme.typography.fontSize * theme.typography.body.lineHeight;
} else {
fontSizePx =
typeof theme.typography.bodySmall.fontSize === 'string' && theme.typography.bodySmall.fontSize.includes('rem')
? theme.typography.fontSize * parseFloat(theme.typography.bodySmall.fontSize)
: parseInt(theme.typography.bodySmall.fontSize, 10);
this.lineHeight = fontSizePx * theme.typography.bodySmall.lineHeight;
}
export function init(theme: GrafanaTheme2, fontSize: LogListFontSize) {
let fontSizePx = theme.typography.fontSize;
this.gridSize = theme.spacing.gridSize;
this.paddingBottom = this.gridSize * 0.75;
this.logLineSizesMap = new Map<string, number>();
this.textWidthMap = new Map<number, number>();
if (fontSize === 'default') {
lineHeight = theme.typography.fontSize * theme.typography.body.lineHeight;
} else {
fontSizePx =
typeof theme.typography.bodySmall.fontSize === 'string' && theme.typography.bodySmall.fontSize.includes('rem')
? theme.typography.fontSize * parseFloat(theme.typography.bodySmall.fontSize)
: parseInt(theme.typography.bodySmall.fontSize, 10);
lineHeight = fontSizePx * theme.typography.bodySmall.lineHeight;
}
const font = `${fontSizePx}px ${theme.typography.fontFamilyMonospace}`;
const letterSpacing = theme.typography.body.letterSpacing;
const font = `${fontSizePx}px ${theme.typography.fontFamilyMonospace}`;
const letterSpacing = theme.typography.body.letterSpacing;
this.initDOMmeasurement(font, letterSpacing);
this.initCanvasMeasurement(font, letterSpacing);
this.determineMeasurementMode();
}
initDOMmeasurement(font, letterSpacing);
initCanvasMeasurement(font, letterSpacing);
getLineHeight = () => this.lineHeight;
getGridSize = () => this.gridSize;
getPaddingBottom = () => this.paddingBottom;
gridSize = theme.spacing.gridSize;
paddingBottom = gridSize * 0.75;
// 2/3 of the viewport height
getTruncationLineCount = () => Math.round(window.innerHeight / this.getLineHeight() / 1.5);
widthMap = new Map<number, number>();
resetLogLineSizes();
getTruncationLength = (container: HTMLDivElement | null) => {
const availableWidth = container ? getLogContainerWidth(container) : window.innerWidth;
return (availableWidth / this.measureTextWidth('e')) * this.getTruncationLineCount();
};
determineMeasurementMode();
determineMeasurementMode = () => {
if (!this.ctx) {
this.measurementMode = 'dom';
return;
}
const canvasCharWidth = this.ctx.measureText('e').width;
const domCharWidth = this.measureTextWidthWithDOM('e');
const diff = domCharWidth - canvasCharWidth;
if (diff >= 0.1) {
console.warn('Virtualized log list: falling back to DOM for measurement');
this.measurementMode = 'dom';
}
};
return true;
}
initCanvasMeasurement = (font: string, letterSpacing: string | undefined) => {
const canvas = document.createElement('canvas');
this.ctx = canvas.getContext('2d');
if (!this.ctx) {
return;
}
this.ctx.font = font;
this.ctx.fontKerning = 'normal';
this.ctx.fontStretch = 'normal';
this.ctx.fontVariantCaps = 'normal';
this.ctx.textRendering = 'optimizeLegibility';
if (letterSpacing) {
this.ctx.letterSpacing = letterSpacing;
}
};
function determineMeasurementMode() {
if (!ctx) {
measurementMode = 'dom';
return;
}
const canvasCharWidth = ctx.measureText('e').width;
const domCharWidth = measureTextWidthWithDOM('e');
const diff = domCharWidth - canvasCharWidth;
if (diff >= 0.1) {
console.warn('Virtualized log list: falling back to DOM for measurement');
measurementMode = 'dom';
}
}
initDOMmeasurement = (font: string, letterSpacing: string | undefined) => {
this.spanElement.style.font = font;
this.spanElement.style.visibility = 'hidden';
this.spanElement.style.position = 'absolute';
this.spanElement.style.wordBreak = 'break-all';
if (letterSpacing) {
this.spanElement.style.letterSpacing = letterSpacing;
}
};
function initCanvasMeasurement(font: string, letterSpacing: string | undefined) {
const canvas = document.createElement('canvas');
ctx = canvas.getContext('2d');
if (!ctx) {
return;
}
ctx.font = font;
ctx.fontKerning = 'normal';
ctx.fontStretch = 'normal';
ctx.fontVariantCaps = 'normal';
ctx.textRendering = 'optimizeLegibility';
if (letterSpacing) {
ctx.letterSpacing = letterSpacing;
}
}
measureTextWidth = (text: string): number => {
if (!this.ctx) {
throw new Error(`Measuring context canvas is not initialized. Call init() before.`);
}
const key = text.length;
const span = document.createElement('span');
function initDOMmeasurement(font: string, letterSpacing: string | undefined) {
span.style.font = font;
span.style.visibility = 'hidden';
span.style.position = 'absolute';
span.style.wordBreak = 'break-all';
if (letterSpacing) {
span.style.letterSpacing = letterSpacing;
}
}
const storedWidth = this.textWidthMap.get(key);
if (storedWidth) {
return storedWidth;
}
let widthMap = new Map<number, number>();
export function measureTextWidth(text: string): number {
if (!ctx) {
throw new Error(`Measuring context canvas is not initialized. Call init() before.`);
}
const key = text.length;
const width =
this.measurementMode === 'canvas' ? this.ctx.measureText(text).width : this.measureTextWidthWithDOM(text);
this.textWidthMap.set(key, width);
const storedWidth = widthMap.get(key);
if (storedWidth) {
return storedWidth;
}
return width;
};
const width = measurementMode === 'canvas' ? ctx.measureText(text).width : measureTextWidthWithDOM(text);
widthMap.set(key, width);
measureTextWidthWithDOM = (text: string) => {
this.spanElement.textContent = text;
return width;
}
document.body.appendChild(this.spanElement);
const width = this.spanElement.getBoundingClientRect().width;
document.body.removeChild(this.spanElement);
function measureTextWidthWithDOM(text: string) {
span.textContent = text;
return width;
};
document.body.appendChild(span);
const width = span.getBoundingClientRect().width;
document.body.removeChild(span);
measureTextHeight = (text: string, maxWidth: number, beforeWidth = 0) => {
let logLines = 0;
const charWidth = this.measureTextWidth('e');
let logLineCharsLength = Math.round(maxWidth / charWidth);
const firstLineCharsLength = Math.floor((maxWidth - beforeWidth) / charWidth) - 2 * charWidth;
const textLines = text.split('\n');
// Skip unnecessary measurements
if (textLines.length === 1 && text.length < firstLineCharsLength) {
return {
lines: 1,
height: this.getLineHeight() + this.paddingBottom,
};
}
return width;
}
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;
do {
testLogLine = textLine.substring(start, start + logLineCharsLength - delta);
let measuredLine = testLogLine;
if (logLines > 0) {
measuredLine.trimStart();
}
width = this.measureTextWidth(measuredLine);
delta += 1;
} while (width >= availableWidth);
if (beforeWidth) {
beforeWidth = 0;
}
logLines += 1;
start += testLogLine.length;
}
}
export function measureTextHeight(text: string, maxWidth: number, beforeWidth = 0) {
let logLines = 0;
const charWidth = measureTextWidth('e');
let logLineCharsLength = Math.round(maxWidth / charWidth);
const firstLineCharsLength = Math.floor((maxWidth - beforeWidth) / charWidth) - 2 * charWidth;
const textLines = text.split('\n');
const height = logLines * this.getLineHeight() + this.paddingBottom;
// Skip unnecessary measurements
if (textLines.length === 1 && text.length < firstLineCharsLength) {
return {
lines: 1,
height: getLineHeight() + paddingBottom,
lines: logLines,
height,
};
}
};
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;
do {
testLogLine = textLine.substring(start, start + logLineCharsLength - delta);
let measuredLine = testLogLine;
if (logLines > 0) {
measuredLine.trimStart();
}
width = measureTextWidth(measuredLine);
delta += 1;
} while (width >= availableWidth);
if (beforeWidth) {
beforeWidth = 0;
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 = this.measureTextWidth(logs[i].timestamp);
if (width > timestampWidth) {
timestampWidth = Math.round(width);
}
width = this.measureTextWidth(logs[i].displayLevel);
if (width > levelWidth) {
levelWidth = Math.round(width);
}
for (const field of displayedFields) {
width = this.measureTextWidth(logs[i].getDisplayedFieldValue(field));
fieldWidths[field] = !fieldWidths[field] || width > fieldWidths[field] ? Math.round(width) : fieldWidths[field];
}
logLines += 1;
start += testLogLine.length;
}
}
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;
};
resetLogLineSizes = () => {
this.logLineSizesMap = new Map<string, number>();
};
const height = logLines * getLineHeight() + paddingBottom;
storeLogLineSize = (id: string, container: HTMLDivElement, height: number) => {
const key = `${id}_${getLogContainerWidth(container)}_${this.fontSize}`;
this.logLineSizesMap.set(key, height);
};
return {
lines: logLines,
height,
retrieveLogLineSize = (id: string, container: HTMLDivElement) => {
const key = `${id}_${getLogContainerWidth(container)}_${this.fontSize}`;
return this.logLineSizesMap.get(key);
};
}
export interface DisplayOptions {
fontSize: LogListFontSize;
hasLogsWithErrors?: boolean;
hasSampledLogs?: boolean;
showDuplicates: boolean;
@ -176,10 +246,11 @@ export interface DisplayOptions {
}
export function getLogLineSize(
virtualization: LogLineVirtualization,
logs: LogListModel[],
container: HTMLDivElement | null,
displayedFields: string[],
{ fontSize, hasLogsWithErrors, hasSampledLogs, showDuplicates, showTime, wrap }: DisplayOptions,
{ hasLogsWithErrors, hasSampledLogs, showDuplicates, showTime, wrap }: DisplayOptions,
index: number
) {
if (!container) {
@ -187,31 +258,31 @@ export function getLogLineSize(
}
// !logs[index] means the line is not yet loaded by infinite scrolling
if (!wrap || !logs[index]) {
return getLineHeight() + paddingBottom;
return virtualization.getLineHeight() + virtualization.getPaddingBottom();
}
// 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 (getTruncationLineCount() + 1) * getLineHeight();
return (virtualization.getTruncationLineCount() + 1) * virtualization.getLineHeight();
}
const storedSize = retrieveLogLineSize(logs[index].uid, container, fontSize);
const storedSize = virtualization.retrieveLogLineSize(logs[index].uid, container);
if (storedSize) {
return storedSize;
}
let textToMeasure = '';
const gap = gridSize * FIELD_GAP_MULTIPLIER;
const iconsGap = gridSize * 0.5;
const gap = virtualization.getGridSize() * FIELD_GAP_MULTIPLIER;
const iconsGap = virtualization.getGridSize() * 0.5;
let optionsWidth = 0;
if (showDuplicates) {
optionsWidth += gridSize * 4.5 + iconsGap;
optionsWidth += virtualization.getGridSize() * 4.5 + iconsGap;
}
if (hasLogsWithErrors) {
optionsWidth += gridSize * 2 + iconsGap;
optionsWidth += virtualization.getGridSize() * 2 + iconsGap;
}
if (hasSampledLogs) {
optionsWidth += gridSize * 2 + iconsGap;
optionsWidth += virtualization.getGridSize() * 2 + iconsGap;
}
if (showTime) {
optionsWidth += gap;
@ -229,9 +300,9 @@ export function getLogLineSize(
textToMeasure += ansicolor.strip(logs[index].body);
}
const { height } = measureTextHeight(textToMeasure, getLogContainerWidth(container), optionsWidth);
const { height } = virtualization.measureTextHeight(textToMeasure, getLogContainerWidth(container), optionsWidth);
// When the log is collapsed, add an extra line for the expand/collapse control
return logs[index].collapsed === false ? height + getLineHeight() : height;
return logs[index].collapsed === false ? height + virtualization.getLineHeight() : height;
}
export interface LogFieldDimension {
@ -239,80 +310,31 @@ export interface LogFieldDimension {
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(logs[i].getDisplayedFieldValue(field));
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;
};
// 2/3 of the viewport height
export const getTruncationLineCount = () => Math.round(window.innerHeight / getLineHeight() / 1.5);
export function getTruncationLength(container: HTMLDivElement | null) {
const availableWidth = container ? getLogContainerWidth(container) : window.innerWidth;
return (availableWidth / measureTextWidth('e')) * getTruncationLineCount();
}
export function hasUnderOrOverflow(
virtualization: LogLineVirtualization,
element: HTMLDivElement,
calculatedHeight?: number,
collapsed?: boolean
): number | null {
if (collapsed !== undefined && calculatedHeight) {
calculatedHeight -= getLineHeight();
calculatedHeight -= virtualization.getLineHeight();
}
const height = calculatedHeight ?? element.clientHeight;
if (element.scrollHeight > height) {
return collapsed !== undefined ? element.scrollHeight + getLineHeight() : element.scrollHeight;
return collapsed !== undefined ? element.scrollHeight + virtualization.getLineHeight() : element.scrollHeight;
}
const child = element.children[1];
if (child instanceof HTMLDivElement && child.clientHeight < height) {
return collapsed !== undefined ? child.clientHeight + getLineHeight() : child.clientHeight;
return collapsed !== undefined ? child.clientHeight + virtualization.getLineHeight() : child.clientHeight;
}
return null;
}
const logLineMenuIconWidth = 24;
const scrollBarWidth = getScrollbarWidth();
export function getLogContainerWidth(container: HTMLDivElement) {
return container.clientWidth - scrollBarWidth - iconWidth;
return container.clientWidth - scrollBarWidth - logLineMenuIconWidth;
}
export function getScrollbarWidth() {
@ -331,21 +353,6 @@ export function getScrollbarWidth() {
return width;
}
let logLineSizesMap = new Map<string, number>();
export function resetLogLineSizes() {
logLineSizesMap = new Map<string, number>();
}
export function storeLogLineSize(id: string, container: HTMLDivElement, height: number, fontSize: LogListFontSize) {
const key = `${id}_${getLogContainerWidth(container)}_${fontSize}`;
logLineSizesMap.set(key, height);
}
export function retrieveLogLineSize(id: string, container: HTMLDivElement, fontSize: LogListFontSize) {
const key = `${id}_${getLogContainerWidth(container)}_${fontSize}`;
return logLineSizesMap.get(key);
}
export interface ScrollToLogsEventPayload {
scrollTo: 'top' | 'bottom';
}

Loading…
Cancel
Save