import { ReactNode, useCallback, useEffect, useRef, useState } from 'react'; import { usePrevious } from 'react-use'; import { ListChildComponentProps, ListOnItemsRenderedProps } from 'react-window'; import { AbsoluteTimeRange, LogsSortOrder, TimeRange } from '@grafana/data'; import { config, reportInteraction } from '@grafana/runtime'; import { Spinner, useStyles2 } from '@grafana/ui'; import { t } from 'app/core/internationalization'; import { canScrollBottom, getVisibleRange, ScrollDirection, shouldLoadMore } from '../InfiniteScroll'; import { getStyles, LogLine } from './LogLine'; import { LogLineMessage } from './LogLineMessage'; import { LogListModel } from './processing'; interface ChildrenProps { itemCount: number; getItemKey: (index: number) => string; onItemsRendered: (props: ListOnItemsRenderedProps) => void; Renderer: (props: ListChildComponentProps) => ReactNode; } interface Props { children: (props: ChildrenProps) => ReactNode; displayedFields: string[]; handleOverflow: (index: number, id: string, height: number) => void; loadMore?: (range: AbsoluteTimeRange) => void; logs: LogListModel[]; scrollElement: HTMLDivElement | null; setInitialScrollPosition: () => void; showTime: boolean; sortOrder: LogsSortOrder; timeRange: TimeRange; timeZone: string; wrapLogMessage: boolean; } type InfiniteLoaderState = 'idle' | 'out-of-bounds' | 'pre-scroll' | 'loading'; export const InfiniteScroll = ({ children, displayedFields, handleOverflow, loadMore, logs, scrollElement, setInitialScrollPosition, showTime, sortOrder, timeRange, timeZone, wrapLogMessage, }: Props) => { const [infiniteLoaderState, setInfiniteLoaderState] = useState('idle'); const [autoScroll, setAutoScroll] = useState(false); const prevLogs = usePrevious(logs); const prevSortOrder = usePrevious(sortOrder); const lastScroll = useRef(scrollElement?.scrollTop || 0); const lastEvent = useRef(null); const countRef = useRef(0); const lastLogOfPage = useRef([]); const styles = useStyles2(getStyles); useEffect(() => { // Logs have not changed, ignore effect if (!prevLogs || prevLogs === logs) { return; } // New logs are from infinite scrolling if (infiniteLoaderState === 'loading') { // out-of-bounds if no new logs returned setInfiniteLoaderState(logs.length === prevLogs.length ? 'out-of-bounds' : 'idle'); } else { lastLogOfPage.current = []; setAutoScroll(true); } }, [infiniteLoaderState, logs, prevLogs]); useEffect(() => { if (prevSortOrder && prevSortOrder !== sortOrder) { setInfiniteLoaderState('idle'); } }, [prevSortOrder, sortOrder]); useEffect(() => { if (autoScroll) { setInitialScrollPosition(); setAutoScroll(false); } }, [autoScroll, setInitialScrollPosition]); const onLoadMore = useCallback(() => { const newRange = canScrollBottom(getVisibleRange(logs), timeRange, timeZone, sortOrder); if (!newRange) { setInfiniteLoaderState('out-of-bounds'); return; } lastLogOfPage.current.push(logs[logs.length - 1].uid); setInfiniteLoaderState('loading'); loadMore?.(newRange); reportInteraction('grafana_logs_infinite_scrolling', { direction: 'bottom', sort_order: sortOrder, }); }, [loadMore, logs, sortOrder, timeRange, timeZone]); useEffect(() => { if (!scrollElement || !loadMore || !config.featureToggles.logsInfiniteScrolling) { return; } function handleScroll(event: Event | WheelEvent) { if (!scrollElement || !loadMore || !logs.length || infiniteLoaderState !== 'pre-scroll') { return; } const scrollDirection = shouldLoadMore(event, lastEvent.current, countRef, scrollElement, lastScroll.current); lastEvent.current = event; lastScroll.current = scrollElement.scrollTop; if (scrollDirection === ScrollDirection.Bottom) { onLoadMore(); } } scrollElement.addEventListener('scroll', handleScroll); scrollElement.addEventListener('wheel', handleScroll); return () => { scrollElement.removeEventListener('scroll', handleScroll); scrollElement.removeEventListener('wheel', handleScroll); }; }, [infiniteLoaderState, loadMore, logs.length, onLoadMore, scrollElement]); const Renderer = useCallback( ({ index, style }: ListChildComponentProps) => { if (!logs[index] && infiniteLoaderState !== 'idle') { return ( {getMessageFromInfiniteLoaderState(infiniteLoaderState, sortOrder)} ); } return ( ); }, [ displayedFields, handleOverflow, infiniteLoaderState, logs, onLoadMore, showTime, sortOrder, styles, wrapLogMessage, ] ); const onItemsRendered = useCallback( (props: ListOnItemsRenderedProps) => { if (!scrollElement || infiniteLoaderState === 'loading' || infiniteLoaderState === 'out-of-bounds') { return; } if (scrollElement.scrollHeight <= scrollElement.clientHeight) { return; } const lastLogIndex = logs.length - 1; const preScrollIndex = logs.length - 2; if (props.visibleStopIndex >= lastLogIndex) { setInfiniteLoaderState('pre-scroll'); } else if (props.visibleStartIndex < preScrollIndex) { setInfiniteLoaderState('idle'); } }, [infiniteLoaderState, logs.length, scrollElement] ); const getItemKey = useCallback((index: number) => (logs[index] ? logs[index].uid : index.toString()), [logs]); const itemCount = logs.length && loadMore && infiniteLoaderState !== 'idle' ? logs.length + 1 : logs.length; return <>{children({ getItemKey, itemCount, onItemsRendered, Renderer })}; }; function getMessageFromInfiniteLoaderState(state: InfiniteLoaderState, order: LogsSortOrder) { switch (state) { case 'out-of-bounds': return t('logs.infinite-scroll.end-of-range', 'End of the selected time range.'); case 'loading': return ( <> {order === LogsSortOrder.Ascending ? t('logs.infinite-scroll.load-newer', 'Loading newer logs...') : t('logs.infinite-scroll.load-older', 'Loading older logs...')}{' '} ); case 'pre-scroll': return t('logs.infinite-scroll.load-more', 'Scroll to load more'); default: return null; } } function getLogLineVariant(logs: LogListModel[], index: number, lastLogOfPage: string[]) { if (!lastLogOfPage.length || !logs[index - 1]) { return undefined; } const prevLog = logs[index - 1]; for (const uid of lastLogOfPage) { if (prevLog.uid === uid) { // First log of an infinite scrolling page return 'infinite-scroll'; } } return undefined; }