diff --git a/public/app/features/logs/components/InfiniteScroll.test.tsx b/public/app/features/logs/components/InfiniteScroll.test.tsx index 62cdecb329c..249ed391185 100644 --- a/public/app/features/logs/components/InfiniteScroll.test.tsx +++ b/public/app/features/logs/components/InfiniteScroll.test.tsx @@ -107,11 +107,12 @@ function setup( return { element, events, scrollTo, wheel }; } +const originalState = config.featureToggles.logsInfiniteScrolling; beforeAll(() => { config.featureToggles.logsInfiniteScrolling = true; }); afterAll(() => { - config.featureToggles.logsInfiniteScrolling = false; + config.featureToggles.logsInfiniteScrolling = originalState; }); describe('InfiniteScroll', () => { diff --git a/public/app/features/logs/components/panel/InfiniteScroll.test.tsx b/public/app/features/logs/components/panel/InfiniteScroll.test.tsx new file mode 100644 index 00000000000..d981cfa5027 --- /dev/null +++ b/public/app/features/logs/components/panel/InfiniteScroll.test.tsx @@ -0,0 +1,352 @@ +import { act, render, screen } from '@testing-library/react'; +import { VariableSizeList } from 'react-window'; + +import { createTheme, dateTimeForTimeZone, rangeUtil } from '@grafana/data'; +import { config } from '@grafana/runtime'; +import { LogsSortOrder } from '@grafana/schema'; + +import { ScrollDirection, SCROLLING_THRESHOLD } from '../InfiniteScroll'; +import { createLogLine } from '../mocks/logRow'; + +import { InfiniteScroll, InfiniteScrollMode, Props } from './InfiniteScroll'; +import { LogListModel } from './processing'; +import { LogLineVirtualization } from './virtualization'; + +const defaultTz = 'browser'; + +const absoluteRange = { + from: 1702578600000, + to: 1702578900000, +}; +const defaultRange = rangeUtil.convertRawToRange({ + from: dateTimeForTimeZone(defaultTz, absoluteRange.from), + to: dateTimeForTimeZone(defaultTz, absoluteRange.to), +}); + +const theme = createTheme(); +const virtualization = new LogLineVirtualization(theme, 'default'); +const defaultProps: Omit = { + loadMore: jest.fn(), + timeRange: defaultRange, + logs: [], + sortOrder: LogsSortOrder.Descending, + timeZone: 'browser', + displayedFields: [], + handleOverflow: jest.fn(), + infiniteScrollMode: 'interval', + onClick: jest.fn(), + setInitialScrollPosition: jest.fn(), + showTime: false, + virtualization, + wrapLogMessage: false, +}; + +function setup( + loadMoreMock: () => void, + startPosition: number, + logs: LogListModel[], + order: LogsSortOrder, + infiniteScrollMode: InfiniteScrollMode = 'interval' +) { + const { element, events } = getMockElement(startPosition); + + function scrollTo(position: number, timeStamp?: number) { + element.scrollTop = position; + + act(() => { + const event = new Event('scroll'); + if (timeStamp) { + jest.spyOn(event, 'timeStamp', 'get').mockReturnValue(timeStamp); + } + events['scroll'](event); + }); + + // When scrolling top, we wait for the user to reach the top, and then for a new scrolling event + // in the same direction before triggering a new query. + if (position === 0) { + wheel(-1); + } + } + function wheel(deltaY: number, timeStamp?: number) { + element.scrollTop += deltaY; + if (element.scrollTop < 0) { + element.scrollTop = 0; + } + + act(() => { + const event = new WheelEvent('wheel', { deltaY }); + if (timeStamp) { + jest.spyOn(event, 'timeStamp', 'get').mockReturnValue(timeStamp); + } + events['wheel'](event); + }); + } + + render( + + {({ getItemKey, itemCount, onItemsRendered, Renderer }) => ( + virtualization.getLineHeight()} + itemKey={getItemKey} + layout="vertical" + onItemsRendered={onItemsRendered} + style={{ overflow: 'scroll' }} + width="100%" + > + {Renderer} + + )} + + ); + + return { element, events, scrollTo, wheel }; +} + +const originalState = config.featureToggles.logsInfiniteScrolling; +beforeAll(() => { + config.featureToggles.logsInfiniteScrolling = true; +}); +afterAll(() => { + config.featureToggles.logsInfiniteScrolling = originalState; +}); + +describe('InfiniteScroll', () => { + describe.each([LogsSortOrder.Descending, LogsSortOrder.Ascending])( + 'When the sort order is descending', + (order: LogsSortOrder) => { + let logs: LogListModel[]; + beforeEach(() => { + logs = createLogs(absoluteRange.from + 2 * SCROLLING_THRESHOLD, absoluteRange.to - 2 * SCROLLING_THRESHOLD); + }); + + test.each([ + ['top', 10, 0], + ['bottom', 50, 60], + ])( + 'Requests more logs when scrolling %s', + async (direction: string, startPosition: number, endPosition: number) => { + const loadMoreMock = jest.fn(); + const { scrollTo } = setup(loadMoreMock, startPosition, logs, order); + + expect(await screen.findByText('log line 1')).toBeInTheDocument(); + + scrollTo(endPosition - 1, 1); + scrollTo(endPosition, 600); + + expect(loadMoreMock).toHaveBeenCalled(); + expect(await screen.findByTestId('Spinner')).toBeInTheDocument(); + } + ); + + test.each([ + ['up', -5, 0], + ['down', 5, 60], + ])( + 'Requests more logs when moving the mousewheel %s', + async (_: string, deltaY: number, startPosition: number) => { + const loadMoreMock = jest.fn(); + const { wheel } = setup(loadMoreMock, startPosition, logs, order); + + expect(await screen.findByText('log line 1')).toBeInTheDocument(); + + wheel(deltaY, 1); + wheel(deltaY, 600); + + expect(loadMoreMock).toHaveBeenCalled(); + expect(await screen.findByTestId('Spinner')).toBeInTheDocument(); + } + ); + + test('Does not request more logs when there is no scroll', async () => { + const loadMoreMock = jest.fn(); + const { scrollTo, element } = setup(loadMoreMock, 0, logs, order); + + expect(await screen.findByText('log line 1')).toBeInTheDocument(); + element.clientHeight = 40; + element.scrollHeight = element.clientHeight; + + scrollTo(39, 1); + scrollTo(40, 600); + + expect(loadMoreMock).not.toHaveBeenCalled(); + expect(screen.queryByTestId('Spinner')).not.toBeInTheDocument(); + }); + + test('Requests newer logs from the most recent timestamp', async () => { + const startPosition = order === LogsSortOrder.Descending ? 10 : 50; // Scroll top + const endPosition = order === LogsSortOrder.Descending ? 0 : 60; // Scroll bottom + + const loadMoreMock = jest.fn(); + const { scrollTo } = setup(loadMoreMock, startPosition, logs, order); + + expect(await screen.findByText('log line 1')).toBeInTheDocument(); + + scrollTo(endPosition - 1, 1); + scrollTo(endPosition, 600); + + expect(loadMoreMock).toHaveBeenCalledWith( + { + from: logs[logs.length - 1].timeEpochMs, + to: absoluteRange.to, + }, + order === LogsSortOrder.Descending ? -1 : 1 + ); + }); + + test('Requests older logs from the oldest timestamp', async () => { + const startPosition = order === LogsSortOrder.Ascending ? 10 : 50; // Scroll top + const endPosition = order === LogsSortOrder.Ascending ? 0 : 60; // Scroll bottom + + const loadMoreMock = jest.fn(); + const { scrollTo } = setup(loadMoreMock, startPosition, logs, order); + + expect(await screen.findByText('log line 1')).toBeInTheDocument(); + + scrollTo(endPosition - 1, 1); + scrollTo(endPosition, 600); + + expect(loadMoreMock).toHaveBeenCalledWith( + { + from: absoluteRange.from, + to: logs[0].timeEpochMs, + }, + order === LogsSortOrder.Ascending ? -1 : 1 + ); + }); + + describe('With absolute range matching visible range', () => { + test('It does not request more when scrolling bottom', async () => { + // Visible range matches the current range + const logs = createLogs(absoluteRange.from, absoluteRange.to); + const loadMoreMock = jest.fn(); + const { scrollTo } = setup(loadMoreMock, 50, logs, order); + + expect(await screen.findByText('log line 1')).toBeInTheDocument(); + + scrollTo(59, 1); + scrollTo(60, 600); + + expect(loadMoreMock).not.toHaveBeenCalled(); + expect(screen.queryByTestId('Spinner')).not.toBeInTheDocument(); + expect(await screen.findByText('End of the selected time range.')).toBeInTheDocument(); + }); + }); + + describe('With relative range matching visible range', () => { + test('It does not request more when scrolling bottom', async () => { + // Visible range matches the current range + const logs = createLogs(absoluteRange.from, absoluteRange.to); + const loadMoreMock = jest.fn(); + const { scrollTo } = setup(loadMoreMock, 50, logs, order); + + expect(await screen.findByText('log line 1')).toBeInTheDocument(); + + scrollTo(59, 1); + scrollTo(60, 600); + + expect(loadMoreMock).not.toHaveBeenCalled(); + expect(screen.queryByTestId('Spinner')).not.toBeInTheDocument(); + expect(await screen.findByText('End of the selected time range.')).toBeInTheDocument(); + }); + }); + + describe('Chain of events', () => { + test('Ingnores chains of events', async () => { + const loadMoreMock = jest.fn(); + const { wheel } = setup(loadMoreMock, 57, logs, order); + + expect(await screen.findByText('log line 1')).toBeInTheDocument(); + + const timeStamps = [1, 2, 3, 4]; + timeStamps.forEach((timeStamp) => { + wheel(1, timeStamp); + }); + + expect(loadMoreMock).not.toHaveBeenCalled(); + }); + + test('Detects when chain of events ends', async () => { + const loadMoreMock = jest.fn(); + const { wheel } = setup(loadMoreMock, 57, logs, order); + + expect(await screen.findByText('log line 1')).toBeInTheDocument(); + + const timeStamps = [1, 2, 3, 600, 1]; + timeStamps.forEach((timeStamp) => { + wheel(1, timeStamp); + }); + + expect(loadMoreMock).toHaveBeenCalledTimes(1); + }); + + test('Detects when the user wants to scroll', async () => { + const loadMoreMock = jest.fn(); + const { wheel } = setup(loadMoreMock, 57, logs, order); + + expect(await screen.findByText('log line 1')).toBeInTheDocument(); + + for (let i = 0; i <= 25; i++) { + wheel(1, 399 * i + 399); + } + + expect(loadMoreMock).toHaveBeenCalledTimes(1); + }); + }); + + describe('With scroll mode unlimited', () => { + test('Allows infinite scroll in the top direction', async () => { + const loadMoreMock = jest.fn(); + const { wheel } = setup(loadMoreMock, 0, logs, order, 'unlimited'); + + expect(await screen.findByText('log line 1')).toBeInTheDocument(); + + wheel(-5, 1); + wheel(-5, 600); + + expect(loadMoreMock).toHaveBeenCalledWith(expect.anything(), ScrollDirection.Top); + }); + }); + } + ); +}); + +function createLogs(from: number, to: number) { + const rows = [ + createLogLine({ entry: 'log line 1', uid: 'log-1' }), + createLogLine({ entry: 'log line 2', uid: 'log-22' }), + ]; + // Time field + rows[0].dataFrame.fields[0].values = [from, to]; + rows[0].timeEpochMs = from; + rows[1].dataFrame.fields[0].values = [from, to]; + rows[1].timeEpochMs = to; + return rows; +} + +// JSDOM doesn't support layout, so we will mock the expected attribute values for the test cases. +function getMockElement(scrollTop: number) { + const events: Record void> = {}; + const element = { + addEventListener: (event: string, callback: (e: Event | WheelEvent) => void) => { + events[event] = callback; + }, + removeEventListener: jest.fn(), + stopImmediatePropagation: jest.fn(), + scrollHeight: 100, + clientHeight: 40, + scrollTop, + scrollTo: jest.fn(), + scroll: jest.fn(), + }; + + return { element, events }; +} diff --git a/public/app/features/logs/components/panel/InfiniteScroll.tsx b/public/app/features/logs/components/panel/InfiniteScroll.tsx index c696f2670b3..6b64bc7e209 100644 --- a/public/app/features/logs/components/panel/InfiniteScroll.tsx +++ b/public/app/features/logs/components/panel/InfiniteScroll.tsx @@ -21,7 +21,7 @@ interface ChildrenProps { Renderer: (props: ListChildComponentProps) => ReactNode; } -interface Props { +export interface Props { children: (props: ChildrenProps) => ReactNode; displayedFields: string[]; handleOverflow: (index: number, id: string, height?: number) => void; @@ -30,7 +30,7 @@ interface Props { logs: LogListModel[]; onClick: (e: MouseEvent, log: LogListModel) => void; scrollElement: HTMLDivElement | null; - setInitialScrollPosition: () => void; + setInitialScrollPosition: (log?: LogListModel) => void; showTime: boolean; sortOrder: LogsSortOrder; timeRange: TimeRange; @@ -72,6 +72,7 @@ export const InfiniteScroll = ({ const lastLogOfPage = useRef([]); const styles = useStyles2(getStyles, virtualization); const resetStateTimeout = useRef | null>(null); + const scrollToLogLineRef = useRef(undefined); useEffect(() => { // Logs have not changed, ignore effect @@ -84,6 +85,9 @@ export const InfiniteScroll = ({ setInfiniteLoaderState( logs.length === prevLogs.length && infiniteScrollMode === 'interval' ? 'out-of-bounds' : 'idle' ); + if (scrollToLogLineRef.current) { + setAutoScroll(true); + } } else { lastLogOfPage.current = []; setAutoScroll(true); @@ -98,7 +102,8 @@ export const InfiniteScroll = ({ useEffect(() => { if (autoScroll) { - setInitialScrollPosition(); + setInitialScrollPosition(scrollToLogLineRef.current); + scrollToLogLineRef.current = undefined; setAutoScroll(false); } }, [autoScroll, setInitialScrollPosition]); @@ -116,6 +121,7 @@ export const InfiniteScroll = ({ if (scrollDirection === ScrollDirection.Bottom) { lastLogOfPage.current.push(logs[logs.length - 1].uid); } else { + scrollToLogLineRef.current = logs[0]; lastLogOfPage.current.push(logs[0].uid); } setInfiniteLoaderState('loading'); diff --git a/public/app/features/logs/components/panel/LogLineDetails.tsx b/public/app/features/logs/components/panel/LogLineDetails.tsx index 7a4e5a2e425..3887c9dda4b 100644 --- a/public/app/features/logs/components/panel/LogLineDetails.tsx +++ b/public/app/features/logs/components/panel/LogLineDetails.tsx @@ -65,7 +65,7 @@ export const LogLineDetails = memo(({ containerElement, focusLogLine, logs, onRe LogLineDetails.displayName = 'LogLineDetails'; const LogLineDetailsTabs = memo(({ focusLogLine, logs }: Pick) => { - const { closeDetails, noInteractions, showDetails, toggleDetails } = useLogListContext(); + const { app, closeDetails, noInteractions, showDetails, toggleDetails } = useLogListContext(); const [currentLog, setCurrentLog] = useState(showDetails[0]); const previousShowDetails = usePrevious(showDetails); const styles = useStyles2(getStyles, 'sidebar'); @@ -75,6 +75,7 @@ const LogLineDetailsTabs = memo(({ focusLogLine, logs }: Pick { - const { noInteractions } = useLogListContext(); + const { app, noInteractions } = useLogListContext(); const styles = useStyles2(getStyles, 'inline'); const scrollRef = useRef(null); @@ -141,9 +142,10 @@ export const InlineLogLineDetails = memo(({ logs, log }: InlineLogLineDetailsPro if (!noInteractions) { reportInteraction('logs_log_line_details_displayed', { mode: 'inline', + app, }); } - }, [noInteractions]); + }, [app, noInteractions]); const saveScroll = useCallback(() => { saveDetailsScrollPosition(log, scrollRef.current?.scrollTop ?? 0); diff --git a/public/app/features/logs/components/panel/LogList.tsx b/public/app/features/logs/components/panel/LogList.tsx index df0c8b58fd5..b9c55ab1b80 100644 --- a/public/app/features/logs/components/panel/LogList.tsx +++ b/public/app/features/logs/components/panel/LogList.tsx @@ -245,11 +245,7 @@ const LogListComponent = ({ wrapLogMessage, } = useLogListContext(); const [processedLogs, setProcessedLogs] = useState([]); - const [listHeight, setListHeight] = useState( - app === CoreApp.Explore - ? Math.max(window.innerHeight * 0.8, containerElement.clientHeight) - : containerElement.clientHeight - ); + const [listHeight, setListHeight] = useState(getListHeight(containerElement, app)); const theme = useTheme2(); const listRef = useRef(null); const widthRef = useRef(containerElement.clientWidth); @@ -327,28 +323,21 @@ const LogListComponent = ({ listRef.current?.resetAfterIndex(0); }, [wrapLogMessage, showDetails, displayedFields, dedupStrategy]); - useEffect(() => { - const handleResize = debounce(() => { - setListHeight( - (app === CoreApp.Explore - ? Math.max(window.innerHeight * 0.8, containerElement.clientHeight) - : containerElement.clientHeight) - (searchVisible ? LOG_LIST_SEARCH_HEIGHT : 0) - ); - }, 50); - window.addEventListener('resize', handleResize); - handleResize(); - return () => { - window.removeEventListener('resize', handleResize); - }; - }, [app, containerElement.clientHeight, searchVisible]); + useLayoutEffect(() => { + if (widthRef.current !== widthContainer.clientWidth) { + widthRef.current = widthContainer.clientWidth; + debouncedResetAfterIndex(0); + } + }); useLayoutEffect(() => { - if (widthRef.current === widthContainer.clientWidth) { - return; - } - widthRef.current = widthContainer.clientWidth; - debouncedResetAfterIndex(0); - }); + const handleResize = debounce(() => { + setListHeight(getListHeight(containerElement, app, searchVisible)); + }, 50); + const observer = new ResizeObserver(() => handleResize()); + observer.observe(containerElement); + return () => observer.disconnect(); + }, [app, containerElement, searchVisible]); const overflowIndexRef = useRef(Infinity); const handleOverflow = useCallback( @@ -365,16 +354,20 @@ const LogListComponent = ({ [debouncedResetAfterIndex, virtualization, widthContainer] ); - const handleScrollPosition = useCallback(() => { - if (permalinkedLogId) { - const index = processedLogs.findIndex((log) => log.uid === permalinkedLogId); - if (index >= 0) { - listRef.current?.scrollToItem(index, 'start'); - return; + const handleScrollPosition = useCallback( + (log?: LogListModel) => { + const scrollToUID = log ? log.uid : permalinkedLogId; + if (scrollToUID) { + const index = processedLogs.findIndex((log) => log.uid === scrollToUID); + if (index >= 0) { + listRef.current?.scrollToItem(index, 'start'); + return; + } } - } - listRef.current?.scrollToItem(initialScrollPosition === 'top' ? 0 : processedLogs.length - 1); - }, [initialScrollPosition, permalinkedLogId, processedLogs]); + listRef.current?.scrollToItem(initialScrollPosition === 'top' ? 0 : processedLogs.length - 1); + }, + [initialScrollPosition, permalinkedLogId, processedLogs] + ); if (!containerElement || listHeight == null) { // Wait for container to be rendered @@ -552,3 +545,11 @@ function handleScrollToEvent(event: ScrollToLogsEvent, logs: LogListModel[], lis } } } + +function getListHeight(containerElement: HTMLDivElement, app: CoreApp, searchVisible = false) { + return ( + (app === CoreApp.Explore + ? Math.max(window.innerHeight * 0.8, containerElement.clientHeight) + : containerElement.clientHeight) - (searchVisible ? LOG_LIST_SEARCH_HEIGHT : 0) + ); +}