import { css } from '@emotion/css'; import { ReactNode, MutableRefObject, useCallback, useEffect, useRef, useState } from 'react'; import { AbsoluteTimeRange, CoreApp, LogRowModel, TimeRange } from '@grafana/data'; import { convertRawToRange, isRelativeTime, isRelativeTimeRange } from '@grafana/data/src/datetime/rangeutil'; import { config, reportInteraction } from '@grafana/runtime'; import { LogsSortOrder, TimeZone } from '@grafana/schema'; import { Button, Icon } from '@grafana/ui'; import { Trans } from 'app/core/internationalization'; import { LoadingIndicator } from './LoadingIndicator'; export type Props = { app?: CoreApp; children: ReactNode; loading: boolean; loadMoreLogs?: (range: AbsoluteTimeRange) => void; range: TimeRange; rows: LogRowModel[]; scrollElement: HTMLDivElement | null; sortOrder: LogsSortOrder; timeZone: TimeZone; topScrollEnabled?: boolean; }; export const InfiniteScroll = ({ app, children, loading, loadMoreLogs, range, rows, scrollElement, sortOrder, timeZone, topScrollEnabled = false, }: Props) => { const [upperOutOfRange, setUpperOutOfRange] = useState(false); const [lowerOutOfRange, setLowerOutOfRange] = useState(false); const [upperLoading, setUpperLoading] = useState(false); const [lowerLoading, setLowerLoading] = useState(false); const rowsRef = useRef(rows); const lastScroll = useRef(scrollElement?.scrollTop || 0); const lastEvent = useRef(null); const countRef = useRef(0); // Reset messages when range/order/rows change useEffect(() => { setUpperOutOfRange(false); setLowerOutOfRange(false); }, [range, rows, sortOrder]); // Reset loading messages when loading stops useEffect(() => { if (!loading) { setUpperLoading(false); setLowerLoading(false); } }, [loading]); // Ensure bottom loader visibility useEffect(() => { if (lowerLoading && scrollElement) { scrollElement.scrollTo(0, scrollElement.scrollHeight - scrollElement.clientHeight); } }, [lowerLoading, scrollElement]); // Request came back with no new past rows useEffect(() => { if (rows !== rowsRef.current && rows.length === rowsRef.current.length && (upperLoading || lowerLoading)) { if (sortOrder === LogsSortOrder.Descending && lowerLoading) { setLowerOutOfRange(true); } else if (sortOrder === LogsSortOrder.Ascending && upperLoading) { setUpperOutOfRange(true); } } rowsRef.current = rows; }, [lowerLoading, rows, sortOrder, upperLoading]); useEffect(() => { if (!scrollElement || !loadMoreLogs) { return; } function handleScroll(event: Event | WheelEvent) { if (!scrollElement || !loadMoreLogs || !rows.length || loading || !config.featureToggles.logsInfiniteScrolling) { return; } const scrollDirection = shouldLoadMore(event, lastEvent.current, countRef, scrollElement, lastScroll.current); lastEvent.current = event; lastScroll.current = scrollElement.scrollTop; if (scrollDirection === ScrollDirection.NoScroll) { return; } event.stopImmediatePropagation(); if (scrollDirection === ScrollDirection.Top && topScrollEnabled) { scrollTop(); } else if (scrollDirection === ScrollDirection.Bottom) { scrollBottom(); } } function scrollTop() { const newRange = canScrollTop(getVisibleRange(rows), range, timeZone, sortOrder); if (!newRange) { setUpperOutOfRange(true); return; } setUpperOutOfRange(false); loadMoreLogs?.(newRange); setUpperLoading(true); reportInteraction('grafana_logs_infinite_scrolling', { direction: 'top', sort_order: sortOrder, }); } function scrollBottom() { const newRange = canScrollBottom(getVisibleRange(rows), range, timeZone, sortOrder); if (!newRange) { setLowerOutOfRange(true); return; } setLowerOutOfRange(false); loadMoreLogs?.(newRange); setLowerLoading(true); reportInteraction('grafana_logs_infinite_scrolling', { direction: 'bottom', sort_order: sortOrder, }); } scrollElement.addEventListener('scroll', handleScroll); scrollElement.addEventListener('wheel', handleScroll); return () => { scrollElement.removeEventListener('scroll', handleScroll); scrollElement.removeEventListener('wheel', handleScroll); }; }, [loadMoreLogs, loading, range, rows, scrollElement, sortOrder, timeZone, topScrollEnabled]); // We allow "now" to move when using relative time, so we hide the message so it doesn't flash. const hideTopMessage = sortOrder === LogsSortOrder.Descending && isRelativeTime(range.raw.to); const hideBottomMessage = sortOrder === LogsSortOrder.Ascending && isRelativeTime(range.raw.to); const loadOlderLogs = useCallback(() => { //If we are not on the last page, use next page's range reportInteraction('grafana_explore_logs_infinite_pagination_clicked', { pageType: 'olderLogsButton', }); const newRange = canScrollTop(getVisibleRange(rows), range, timeZone, sortOrder); if (!newRange) { setUpperOutOfRange(true); return; } setUpperOutOfRange(false); loadMoreLogs?.(newRange); setUpperLoading(true); scrollElement?.scroll({ behavior: 'auto', top: 0, }); }, [loadMoreLogs, range, rows, scrollElement, sortOrder, timeZone]); return ( <> {upperLoading && } {!hideTopMessage && upperOutOfRange && outOfRangeMessage} {sortOrder === LogsSortOrder.Ascending && app === CoreApp.Explore && loadMoreLogs && ( )} {children} {!hideBottomMessage && lowerOutOfRange && outOfRangeMessage} {lowerLoading && } ); }; const styles = { messageContainer: css({ textAlign: 'center', padding: 0.25, }), navButton: css({ width: '58px', height: '68px', display: 'flex', flexDirection: 'column', justifyContent: 'center', alignItems: 'center', lineHeight: '1', position: 'absolute', top: 0, right: -3, zIndex: 1, }), navButtonContent: css({ display: 'flex', flexDirection: 'column', justifyContent: 'center', alignItems: 'center', whiteSpace: 'normal', }), }; const outOfRangeMessage = (
End of the selected time range.
); export enum ScrollDirection { Top = -1, Bottom = 1, NoScroll = 0, } export function shouldLoadMore( event: Event | WheelEvent, lastEvent: Event | WheelEvent | null, countRef: MutableRefObject, element: HTMLDivElement, lastScroll: number ): ScrollDirection { // Disable behavior if there is no scroll if (element.scrollHeight <= element.clientHeight) { return ScrollDirection.NoScroll; } const delta = event instanceof WheelEvent ? event.deltaY : element.scrollTop - lastScroll; if (delta === 0) { return ScrollDirection.NoScroll; } const scrollDirection = delta < 0 ? ScrollDirection.Top : ScrollDirection.Bottom; const diff = scrollDirection === ScrollDirection.Top ? element.scrollTop : element.scrollHeight - element.scrollTop - element.clientHeight; if (diff > 1) { return ScrollDirection.NoScroll; } if (lastEvent && shouldIgnoreChainOfEvents(event, lastEvent, countRef)) { return ScrollDirection.NoScroll; } return scrollDirection; } function shouldIgnoreChainOfEvents( event: Event | WheelEvent, lastEvent: Event | WheelEvent, countRef: MutableRefObject ) { const deltaTime = event.timeStamp - lastEvent.timeStamp; // Not a chain of events if (deltaTime > 500) { countRef.current = 0; return false; } countRef.current++; // Likely trackpad if (deltaTime < 100) { // User likely to want more results if (countRef.current >= 180) { countRef.current = 0; return false; } return true; } // Likely mouse wheel if (deltaTime < 400) { // User likely to want more results if (countRef.current >= 25) { countRef.current = 0; return false; } } return true; } export function getVisibleRange(rows: LogRowModel[]) { const firstTimeStamp = rows[0].timeEpochMs; const lastTimeStamp = rows[rows.length - 1].timeEpochMs; const visibleRange = lastTimeStamp < firstTimeStamp ? { from: lastTimeStamp, to: firstTimeStamp } : { from: firstTimeStamp, to: lastTimeStamp }; return visibleRange; } function getPrevRange(visibleRange: AbsoluteTimeRange, currentRange: TimeRange) { return { from: currentRange.from.valueOf(), to: visibleRange.from }; } function getNextRange(visibleRange: AbsoluteTimeRange, currentRange: TimeRange, timeZone: TimeZone) { // When requesting new logs, update the current range if using relative time ranges. currentRange = updateCurrentRange(currentRange, timeZone); return { from: visibleRange.to, to: currentRange.to.valueOf() }; } export const SCROLLING_THRESHOLD = 1e3; // To get more logs, the difference between the visible range and the current range should be 1 second or more. function canScrollTop( visibleRange: AbsoluteTimeRange, currentRange: TimeRange, timeZone: TimeZone, sortOrder: LogsSortOrder ): AbsoluteTimeRange | undefined { if (sortOrder === LogsSortOrder.Descending) { // When requesting new logs, update the current range if using relative time ranges. currentRange = updateCurrentRange(currentRange, timeZone); const canScroll = currentRange.to.valueOf() - visibleRange.to > SCROLLING_THRESHOLD; return canScroll ? getNextRange(visibleRange, currentRange, timeZone) : undefined; } const canScroll = Math.abs(currentRange.from.valueOf() - visibleRange.from) > SCROLLING_THRESHOLD; return canScroll ? getPrevRange(visibleRange, currentRange) : undefined; } export function canScrollBottom( visibleRange: AbsoluteTimeRange, currentRange: TimeRange, timeZone: TimeZone, sortOrder: LogsSortOrder ): AbsoluteTimeRange | undefined { if (sortOrder === LogsSortOrder.Descending) { const canScroll = Math.abs(currentRange.from.valueOf() - visibleRange.from) > SCROLLING_THRESHOLD; return canScroll ? getPrevRange(visibleRange, currentRange) : undefined; } // When requesting new logs, update the current range if using relative time ranges. currentRange = updateCurrentRange(currentRange, timeZone); const canScroll = currentRange.to.valueOf() - visibleRange.to > SCROLLING_THRESHOLD; return canScroll ? getNextRange(visibleRange, currentRange, timeZone) : undefined; } // Given a TimeRange, returns a new instance if using relative time, or else the same. function updateCurrentRange(timeRange: TimeRange, timeZone: TimeZone) { return isRelativeTimeRange(timeRange.raw) ? convertRawToRange(timeRange.raw, timeZone) : timeRange; }