diff --git a/public/app/features/logs/components/__mocks__/logRow.ts b/public/app/features/logs/components/__mocks__/logRow.ts index b87c6436b74..09aa16998da 100644 --- a/public/app/features/logs/components/__mocks__/logRow.ts +++ b/public/app/features/logs/components/__mocks__/logRow.ts @@ -45,6 +45,7 @@ export const createLogLine = ( escape: false, order: LogsSortOrder.Descending, timeZone: 'browser', + virtualization: undefined, } ): LogListModel => { const logs = preProcessLogs([createLogRow(overrides)], processOptions); diff --git a/public/app/features/logs/components/panel/InfiniteScroll.tsx b/public/app/features/logs/components/panel/InfiniteScroll.tsx index a7cb1cde91c..cf5b0903fa8 100644 --- a/public/app/features/logs/components/panel/InfiniteScroll.tsx +++ b/public/app/features/logs/components/panel/InfiniteScroll.tsx @@ -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('idle'); @@ -61,7 +64,7 @@ export const InfiniteScroll = ({ const lastEvent = useRef(null); const countRef = useRef(0); const lastLogOfPage = useRef([]); - 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, ] ); diff --git a/public/app/features/logs/components/panel/LogLine.test.tsx b/public/app/features/logs/components/panel/LogLine.test.tsx index 42c05bb9bdc..6a2152b2e9f 100644 --- a/public/app/features/logs/components/panel/LogLine.test.tsx +++ b/public/app/features/logs/components/panel/LogLine.test.tsx @@ -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', () => { diff --git a/public/app/features/logs/components/panel/LogLine.tsx b/public/app/features/logs/components/panel/LogLine.tsx index d6eb485ff42..a8d87abc92f 100644 --- a/public/app/features/logs/components/panel/LogLine.tsx +++ b/public/app/features/logs/components/panel/LogLine.tsx @@ -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, log: LogListModel) => void; onOverflow?: (index: number, id: string, height?: number) => void; variant?: 'infinite-scroll'; + virtualization?: LogLineVirtualization; wrapLogMessage: boolean; } @@ -45,157 +46,201 @@ export const LogLine = ({ onOverflow, showTime, variant, + virtualization, wrapLogMessage, }: Props) => { - const { - detailsDisplayed, - dedupStrategy, - enableLogDetails, - fontSize, - hasLogsWithErrors, - hasSampledLogs, - onLogLineHover, - } = useLogListContext(); - const [collapsed, setCollapsed] = useState( - wrapLogMessage && log.collapsed !== undefined ? log.collapsed : undefined - ); - const logLineRef = useRef(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); - } - }); - - 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 handleMouseOver = useCallback(() => onLogLineHover?.(log), [log, onLogLineHover]); - - const handleExpandCollapse = useCallback(() => { - const newState = !collapsed; - setCollapsed(newState); - log.setCollapsedState(newState); - onOverflow?.(index, log.uid); - }, [collapsed, index, log, onOverflow]); - - const handleClick = useCallback( - (e: MouseEvent) => { - onClick(e, log); - }, - [log, onClick] - ); - - const detailsShown = detailsDisplayed(log); - return (
-
- - {dedupStrategy !== LogsDedupStrategy.none && ( -
- {log.duplicates && log.duplicates > 0 ? `${log.duplicates + 1}x` : null} -
- )} - {hasLogsWithErrors && ( -
- {log.hasError && ( - - - - )} -
- )} - {hasSampledLogs && ( -
- {log.isSampled && ( - - - - )} -
- )} - {/* 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 */} -
- -
-
- {collapsed === true && ( -
- -
- )} - {collapsed === false && ( -
- -
- )} +
); }; +interface LogLineComponentProps extends Omit { + height?: number | string; +} + +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( + wrapLogMessage && log.collapsed !== undefined ? log.collapsed : undefined + ); + const logLineRef = useRef(null); + const pinned = useLogIsPinned(log); + const permalinked = useLogIsPermalinked(log); + + 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); + } + }); + + 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 handleMouseOver = useCallback(() => onLogLineHover?.(log), [log, onLogLineHover]); + + const handleExpandCollapse = useCallback(() => { + const newState = !collapsed; + setCollapsed(newState); + log.setCollapsedState(newState); + onOverflow?.(index, log.uid); + }, [collapsed, index, log, onOverflow]); + + const handleClick = useCallback( + (e: MouseEvent) => { + onClick(e, log); + }, + [log, onClick] + ); + + const detailsShown = detailsDisplayed(log); + + return ( + <> +
+ + {dedupStrategy !== LogsDedupStrategy.none && ( +
+ {log.duplicates && log.duplicates > 0 ? `${log.duplicates + 1}x` : null} +
+ )} + {hasLogsWithErrors && ( +
+ {log.hasError && ( + + + + )} +
+ )} + {hasSampledLogs && ( +
+ {log.isSampled && ( + + + + )} +
+ )} + {/* 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 */} +
+ +
+
+ {collapsed === true && ( +
+ +
+ )} + {collapsed === false && ( +
+ +
+ )} + + ); + } +); +LogLineComponent.displayName = 'LogLineComponent'; + interface LogProps { displayedFields: string[]; log: LogListModel; @@ -311,7 +356,7 @@ export function getGridTemplateColumns(dimensions: LogFieldDimension[]) { } export type LogLineStyles = ReturnType; -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, }), }; diff --git a/public/app/features/logs/components/panel/LogList.tsx b/public/app/features/logs/components/panel/LogList.tsx index f0f056a1c7b..58a82266bcc 100644 --- a/public/app/features/logs/components/panel/LogList.tsx +++ b/public/app/features/logs/components/panel/LogList.tsx @@ -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(null); const scrollRef = useRef(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({ diff --git a/public/app/features/logs/components/panel/processing.test.ts b/public/app/features/logs/components/panel/processing.test.ts index 0d1f7d607d4..72461861e8b 100644 --- a/public/app/features/logs/components/panel/processing.test.ts +++ b/public/app/features/logs/components/panel/processing.test.ts @@ -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', () => { diff --git a/public/app/features/logs/components/panel/processing.ts b/public/app/features/logs/components/panel/processing.ts index 85520b17d4b..f891f89b097 100644 --- a/public/app/features/logs/components/panel/processing.ts +++ b/public/app/features/logs/components/panel/processing.ts @@ -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); diff --git a/public/app/features/logs/components/panel/virtualization.test.ts b/public/app/features/logs/components/panel/virtualization.test.ts index 575b6abeffa..b474332616c 100644 --- a/public/app/features/logs/components/panel/virtualization.test.ts +++ b/public/app/features/logs/components/panel/virtualization.test.ts @@ -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], diff --git a/public/app/features/logs/components/panel/virtualization.ts b/public/app/features/logs/components/panel/virtualization.ts index 3e9c819f49b..fd4a0280fe6 100644 --- a/public/app/features/logs/components/panel/virtualization.ts +++ b/public/app/features/logs/components/panel/virtualization.ts @@ -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 function init(theme: GrafanaTheme2, fontSize: LogListFontSize) { - let fontSizePx = theme.typography.fontSize; +export class LogLineVirtualization { + private ctx: CanvasRenderingContext2D | null = null; + private gridSize; + private paddingBottom; + private lineHeight; + private measurementMode: 'canvas' | 'dom' = 'canvas'; + private textWidthMap: Map; + private logLineSizesMap: Map; + private spanElement = document.createElement('span'); + readonly fontSize: LogListFontSize; - 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; - } + constructor(theme: GrafanaTheme2, fontSize: LogListFontSize) { + this.fontSize = fontSize; - const font = `${fontSizePx}px ${theme.typography.fontFamilyMonospace}`; - const letterSpacing = theme.typography.body.letterSpacing; - - initDOMmeasurement(font, letterSpacing); - initCanvasMeasurement(font, letterSpacing); - - gridSize = theme.spacing.gridSize; - paddingBottom = gridSize * 0.75; - - widthMap = new Map(); - resetLogLineSizes(); - - determineMeasurementMode(); - - return true; -} - -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'; - } -} - -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; - } -} - -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; - } -} - -let widthMap = new Map(); -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 storedWidth = widthMap.get(key); - if (storedWidth) { - return storedWidth; - } - - const width = measurementMode === 'canvas' ? ctx.measureText(text).width : measureTextWidthWithDOM(text); - widthMap.set(key, width); - - return width; -} - -function measureTextWidthWithDOM(text: string) { - span.textContent = text; - - document.body.appendChild(span); - const width = span.getBoundingClientRect().width; - document.body.removeChild(span); - - return width; -} - -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'); - - // Skip unnecessary measurements - if (textLines.length === 1 && text.length < firstLineCharsLength) { - return { - lines: 1, - height: getLineHeight() + paddingBottom, - }; - } - - 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; - } - logLines += 1; - start += testLogLine.length; + 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; } + + this.gridSize = theme.spacing.gridSize; + this.paddingBottom = this.gridSize * 0.75; + this.logLineSizesMap = new Map(); + this.textWidthMap = new Map(); + + const font = `${fontSizePx}px ${theme.typography.fontFamilyMonospace}`; + const letterSpacing = theme.typography.body.letterSpacing; + + this.initDOMmeasurement(font, letterSpacing); + this.initCanvasMeasurement(font, letterSpacing); + this.determineMeasurementMode(); } - const height = logLines * getLineHeight() + paddingBottom; + getLineHeight = () => this.lineHeight; + getGridSize = () => this.gridSize; + getPaddingBottom = () => this.paddingBottom; - return { - lines: logLines, - height, + // 2/3 of the viewport height + getTruncationLineCount = () => Math.round(window.innerHeight / this.getLineHeight() / 1.5); + + getTruncationLength = (container: HTMLDivElement | null) => { + const availableWidth = container ? getLogContainerWidth(container) : window.innerWidth; + return (availableWidth / this.measureTextWidth('e')) * this.getTruncationLineCount(); + }; + + 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'; + } + }; + + 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; + } + }; + + 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; + } + }; + + measureTextWidth = (text: string): number => { + if (!this.ctx) { + throw new Error(`Measuring context canvas is not initialized. Call init() before.`); + } + const key = text.length; + + const storedWidth = this.textWidthMap.get(key); + if (storedWidth) { + return storedWidth; + } + + const width = + this.measurementMode === 'canvas' ? this.ctx.measureText(text).width : this.measureTextWidthWithDOM(text); + this.textWidthMap.set(key, width); + + return width; + }; + + measureTextWidthWithDOM = (text: string) => { + this.spanElement.textContent = text; + + document.body.appendChild(this.spanElement); + const width = this.spanElement.getBoundingClientRect().width; + document.body.removeChild(this.spanElement); + + return width; + }; + + 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, + }; + } + + 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; + } + } + + const height = logLines * this.getLineHeight() + this.paddingBottom; + + return { + lines: logLines, + height, + }; + }; + + calculateFieldDimensions = (logs: LogListModel[], displayedFields: string[] = []) => { + if (!logs.length) { + return []; + } + let timestampWidth = 0; + let levelWidth = 0; + const fieldWidths: Record = {}; + 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]; + } + } + 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(); + }; + + storeLogLineSize = (id: string, container: HTMLDivElement, height: number) => { + const key = `${id}_${getLogContainerWidth(container)}_${this.fontSize}`; + this.logLineSizesMap.set(key, 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 = {}; - 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(); -export function resetLogLineSizes() { - logLineSizesMap = new Map(); -} - -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'; }