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, escape: false,
order: LogsSortOrder.Descending, order: LogsSortOrder.Descending,
timeZone: 'browser', timeZone: 'browser',
virtualization: undefined,
} }
): LogListModel => { ): LogListModel => {
const logs = preProcessLogs([createLogRow(overrides)], processOptions); const logs = preProcessLogs([createLogRow(overrides)], processOptions);

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

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

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

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

@ -5,7 +5,7 @@ import { createLogLine, createLogRow } from '../__mocks__/logRow';
import { LogListFontSize } from './LogList'; import { LogListFontSize } from './LogList';
import { LogListModel, preProcessLogs } from './processing'; import { LogListModel, preProcessLogs } from './processing';
import { getTruncationLength, init } from './virtualization'; import { LogLineVirtualization } from './virtualization';
describe('preProcessLogs', () => { describe('preProcessLogs', () => {
let logFmtLog: LogRowModel, nginxLog: LogRowModel, jsonLog: LogRowModel; let logFmtLog: LogRowModel, nginxLog: LogRowModel, jsonLog: LogRowModel;
@ -168,13 +168,16 @@ describe('preProcessLogs', () => {
}); });
describe.each(fontSizes)('Collapsible log lines', (fontSize: LogListFontSize) => { 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(() => { beforeEach(() => {
init(createTheme(), fontSize); virtualization = new LogLineVirtualization(createTheme(), fontSize);
container = document.createElement('div'); container = document.createElement('div');
jest.spyOn(container, 'clientWidth', 'get').mockReturnValue(200); jest.spyOn(container, 'clientWidth', 'get').mockReturnValue(200);
entry = new Array(2 * getTruncationLength(null)).fill('e').join(''); entry = new Array(2 * virtualization.getTruncationLength(null)).fill('e').join('');
longLog = createLogLine({ entry, labels: { field: 'value' } }); longLog = createLogLine(
{ entry, labels: { field: 'value' } },
{ escape: false, order: LogsSortOrder.Descending, timeZone: 'browser', virtualization }
);
}); });
test('Long lines that are not truncated are not modified', () => { 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 { FieldDef, getAllFields } from '../logParser';
import { generateLogGrammar, generateTextMatchGrammar } from './grammar'; import { generateLogGrammar, generateTextMatchGrammar } from './grammar';
import { getTruncationLength } from './virtualization'; import { LogLineVirtualization } from './virtualization';
const TRUNCATION_DEFAULT_LENGTH = 50000;
export class LogListModel implements LogRowModel { export class LogListModel implements LogRowModel {
collapsed: boolean | undefined = undefined; collapsed: boolean | undefined = undefined;
@ -44,11 +46,13 @@ export class LogListModel implements LogRowModel {
private _highlightedBody: string | undefined = undefined; private _highlightedBody: string | undefined = undefined;
private _fields: FieldDef[] | undefined = undefined; private _fields: FieldDef[] | undefined = undefined;
private _getFieldLinks: GetFieldLinksFn | 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 // LogRowModel
this.datasourceType = log.datasourceType; this.datasourceType = log.datasourceType;
this.dataFrame = log.dataFrame; this.dataFrame = log.dataFrame;
this.datasourceUid = log.datasourceUid;
this.duplicates = log.duplicates; this.duplicates = log.duplicates;
this.entry = log.entry; this.entry = log.entry;
this.entryFieldIndex = log.entryFieldIndex; this.entryFieldIndex = log.entryFieldIndex;
@ -68,7 +72,6 @@ export class LogListModel implements LogRowModel {
this.timeUtc = log.timeUtc; this.timeUtc = log.timeUtc;
this.uid = log.uid; this.uid = log.uid;
this.uniqueLabels = log.uniqueLabels; this.uniqueLabels = log.uniqueLabels;
this.datasourceUid = log.datasourceUid;
// LogListModel // LogListModel
this.displayLevel = logLevelToDisplayLevel(log.logLevel); this.displayLevel = logLevelToDisplayLevel(log.logLevel);
@ -78,6 +81,7 @@ export class LogListModel implements LogRowModel {
timeZone, timeZone,
defaultWithMS: true, defaultWithMS: true,
}); });
this._virtualization = virtualization;
let raw = log.raw; let raw = log.raw;
if (escape && log.hasUnescapedContent) { if (escape && log.hasUnescapedContent) {
@ -88,7 +92,9 @@ export class LogListModel implements LogRowModel {
get body(): string { get body(): string {
if (this._body === undefined) { 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; return this._body;
} }
@ -136,7 +142,10 @@ export class LogListModel implements LogRowModel {
displayedFields.length > 0 displayedFields.length > 0
? displayedFields.map((field) => this.getDisplayedFieldValue(field)).join('').length ? displayedFields.map((field) => this.getDisplayedFieldValue(field)).join('').length
: this.raw.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) { if (this.collapsed === undefined || collapsed === undefined) {
this.collapsed = collapsed; this.collapsed = collapsed;
} }
@ -162,15 +171,16 @@ export interface PreProcessOptions {
getFieldLinks?: GetFieldLinksFn; getFieldLinks?: GetFieldLinksFn;
order: LogsSortOrder; order: LogsSortOrder;
timeZone: string; timeZone: string;
virtualization?: LogLineVirtualization;
} }
export const preProcessLogs = ( export const preProcessLogs = (
logs: LogRowModel[], logs: LogRowModel[],
{ escape, getFieldLinks, order, timeZone }: PreProcessOptions, { escape, getFieldLinks, order, timeZone, virtualization }: PreProcessOptions,
grammar?: Grammar grammar?: Grammar
): LogListModel[] => { ): LogListModel[] => {
const orderedLogs = sortLogRows(logs, order); 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 { interface PreProcessLogOptions {
@ -178,6 +188,7 @@ interface PreProcessLogOptions {
getFieldLinks?: GetFieldLinksFn; getFieldLinks?: GetFieldLinksFn;
grammar?: Grammar; grammar?: Grammar;
timeZone: string; timeZone: string;
virtualization?: LogLineVirtualization;
} }
const preProcessLog = (log: LogRowModel, options: PreProcessLogOptions): LogListModel => { const preProcessLog = (log: LogRowModel, options: PreProcessLogOptions): LogListModel => {
return new LogListModel(log, options); 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 { LOG_LINE_BODY_FIELD_NAME } from '../LogDetailsBody';
import { createLogLine } from '../__mocks__/logRow'; import { createLogLine } from '../__mocks__/logRow';
import { LogListModel } from './processing'; import { LogListModel } from './processing';
import { import { LogLineVirtualization, getLogLineSize, DisplayOptions } from './virtualization';
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',
};
describe('Virtualization', () => { describe('Virtualization', () => {
let log: LogListModel, container: HTMLDivElement; 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(() => { 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'); container = document.createElement('div');
jest.spyOn(container, 'clientWidth', 'get').mockReturnValue(CONTAINER_SIZE); jest.spyOn(container, 'clientWidth', 'get').mockReturnValue(CONTAINER_SIZE);
init(createTheme(), 'default'); LETTER_WIDTH = virtualization.measureTextWidth('e');
LETTER_WIDTH = measureTextWidth('e');
TWO_LINES_OF_CHARACTERS = (CONTAINER_SIZE / LETTER_WIDTH) * 1.5; TWO_LINES_OF_CHARACTERS = (CONTAINER_SIZE / LETTER_WIDTH) * 1.5;
}); });
describe('getLogLineSize', () => { describe('getLogLineSize', () => {
test('Returns the a single line if the display mode is unwrapped', () => { 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); expect(size).toBe(SINGLE_LINE_HEIGHT);
}); });
test('Returns the a single line if the line is not loaded yet', () => { test('Returns the a single line if the line is not loaded yet', () => {
const logs = [log]; const logs = [log];
const size = getLogLineSize( const size = getLogLineSize(
virtualization,
logs, logs,
container, container,
[], [],
@ -63,42 +63,66 @@ describe('Virtualization', () => {
// Very small container // Very small container
log.collapsed = true; log.collapsed = true;
jest.spyOn(container, 'clientWidth', 'get').mockReturnValue(10); jest.spyOn(container, 'clientWidth', 'get').mockReturnValue(10);
const size = getLogLineSize([log], container, [], { ...defaultOptions, wrap: true, showTime: true }, 0); const size = getLogLineSize(
expect(size).toBe((getTruncationLineCount() + 1) * LINE_HEIGHT); 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) => { 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); expect(size).toBe(SINGLE_LINE_HEIGHT);
}); });
test('Measures a multi-line log line with no displayed time', () => { test('Measures a multi-line log line with no displayed time', () => {
log = createLogLine({ log = createLogLine(
labels: { place: 'luna' }, {
entry: new Array(TWO_LINES_OF_CHARACTERS).fill('e').join(''), labels: { place: 'luna' },
logLevel: undefined, 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); expect(size).toBe(TWO_LINES_HEIGHT);
}); });
test('Measures a multi-line log line with level, controls, and displayed time', () => { 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 // Two lines for the log and one extra for level and time
expect(size).toBe(THREE_LINES_HEIGHT); expect(size).toBe(THREE_LINES_HEIGHT);
}); });
test('Measures a multi-line log line with displayed fields', () => { test('Measures a multi-line log line with displayed fields', () => {
log = createLogLine({ 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(''), labels: { place: 'very very long value for the displayed field that causes a new line' },
logLevel: undefined, entry: new Array(TWO_LINES_OF_CHARACTERS).fill('e').join(''),
}); logLevel: undefined,
},
{ escape: false, order: LogsSortOrder.Descending, timeZone: 'browser', virtualization }
);
const size = getLogLineSize( const size = getLogLineSize(
virtualization,
[log], [log],
container, container,
['place', LOG_LINE_BODY_FIELD_NAME], ['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', () => { 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 // Only renders a short displayed field, so a single line
expect(size).toBe(SINGLE_LINE_HEIGHT); expect(size).toBe(SINGLE_LINE_HEIGHT);
}); });
test('Measures a multi-line log line with duplicates', () => { 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; 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 // Two lines for the log and one extra for duplicates
expect(size).toBe(THREE_LINES_HEIGHT); expect(size).toBe(THREE_LINES_HEIGHT);
}); });
test('Measures a multi-line log line with errors', () => { 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 // Two lines for the log and one extra for the error icon
expect(size).toBe(THREE_LINES_HEIGHT); expect(size).toBe(THREE_LINES_HEIGHT);
}); });
test('Measures a multi-line sampled log line', () => { 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 // Two lines for the log and one extra for the sampled icon
expect(size).toBe(THREE_LINES_HEIGHT); expect(size).toBe(THREE_LINES_HEIGHT);
}); });
@ -145,29 +209,34 @@ describe('Virtualization', () => {
test('Adds an extra line for the expand/collapse controls if present', () => { test('Adds an extra line for the expand/collapse controls if present', () => {
jest.spyOn(log, 'updateCollapsedState').mockImplementation(() => undefined); jest.spyOn(log, 'updateCollapsedState').mockImplementation(() => undefined);
log.collapsed = false; 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); expect(size).toBe(TWO_LINES_HEIGHT);
}); });
}); });
describe('With small font size', () => { describe('With small font size', () => {
const virtualization = new LogLineVirtualization(createTheme(), 'small');
beforeEach(() => { beforeEach(() => {
init(createTheme(), 'small'); LETTER_WIDTH = virtualization.measureTextWidth('e');
LETTER_WIDTH = measureTextWidth('e');
TWO_LINES_OF_CHARACTERS = (CONTAINER_SIZE / LETTER_WIDTH) * 1.5; TWO_LINES_OF_CHARACTERS = (CONTAINER_SIZE / LETTER_WIDTH) * 1.5;
}); });
test('Measures a multi-line log line with displayed fields', () => { 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; const SMALL_THREE_LINES_HEIGHT = 3 * SMALL_LINE_HEIGHT + PADDING_BOTTOM;
log = createLogLine({ 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(''), labels: { place: 'very very long value for the displayed field that causes a new line' },
logLevel: undefined, entry: new Array(TWO_LINES_OF_CHARACTERS).fill('e').join(''),
}); logLevel: undefined,
},
{ escape: false, order: LogsSortOrder.Descending, timeZone: 'browser', virtualization }
);
const size = getLogLineSize( const size = getLogLineSize(
virtualization,
[log], [log],
container, container,
['place', LOG_LINE_BODY_FIELD_NAME], ['place', LOG_LINE_BODY_FIELD_NAME],

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

Loading…
Cancel
Save