2025-06-10 17:59:01 +08:00
|
|
|
import { ReactNode, useCallback, useEffect, useRef, useState, MouseEvent } from 'react';
|
2025-02-14 19:52:34 +08:00
|
|
|
import { usePrevious } from 'react-use';
|
|
|
|
|
import { ListChildComponentProps, ListOnItemsRenderedProps } from 'react-window';
|
|
|
|
|
|
|
|
|
|
import { AbsoluteTimeRange, LogsSortOrder, TimeRange } from '@grafana/data';
|
2025-05-23 16:34:35 +08:00
|
|
|
import { TFunction, useTranslate } from '@grafana/i18n';
|
2025-02-14 19:52:34 +08:00
|
|
|
import { config, reportInteraction } from '@grafana/runtime';
|
2025-02-28 00:34:02 +08:00
|
|
|
import { Spinner, useStyles2 } from '@grafana/ui';
|
2025-02-14 19:52:34 +08:00
|
|
|
|
|
|
|
|
import { canScrollBottom, getVisibleRange, ScrollDirection, shouldLoadMore } from '../InfiniteScroll';
|
|
|
|
|
|
2025-02-27 18:31:55 +08:00
|
|
|
import { getStyles, LogLine } from './LogLine';
|
2025-02-14 19:52:34 +08:00
|
|
|
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;
|
2025-02-27 18:31:55 +08:00
|
|
|
displayedFields: string[];
|
2025-05-07 06:26:54 +08:00
|
|
|
handleOverflow: (index: number, id: string, height?: number) => void;
|
2025-02-14 19:52:34 +08:00
|
|
|
loadMore?: (range: AbsoluteTimeRange) => void;
|
|
|
|
|
logs: LogListModel[];
|
2025-06-10 17:59:01 +08:00
|
|
|
onClick: (e: MouseEvent<HTMLElement>, log: LogListModel) => void;
|
2025-02-14 19:52:34 +08:00
|
|
|
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,
|
2025-02-27 18:31:55 +08:00
|
|
|
displayedFields,
|
2025-02-14 19:52:34 +08:00
|
|
|
handleOverflow,
|
|
|
|
|
loadMore,
|
|
|
|
|
logs,
|
2025-05-21 01:28:35 +08:00
|
|
|
onClick,
|
2025-02-14 19:52:34 +08:00
|
|
|
scrollElement,
|
|
|
|
|
setInitialScrollPosition,
|
|
|
|
|
showTime,
|
|
|
|
|
sortOrder,
|
|
|
|
|
timeRange,
|
|
|
|
|
timeZone,
|
|
|
|
|
wrapLogMessage,
|
|
|
|
|
}: Props) => {
|
|
|
|
|
const [infiniteLoaderState, setInfiniteLoaderState] = useState<InfiniteLoaderState>('idle');
|
|
|
|
|
const [autoScroll, setAutoScroll] = useState(false);
|
|
|
|
|
const prevLogs = usePrevious(logs);
|
|
|
|
|
const prevSortOrder = usePrevious(sortOrder);
|
|
|
|
|
const lastScroll = useRef<number>(scrollElement?.scrollTop || 0);
|
|
|
|
|
const lastEvent = useRef<Event | WheelEvent | null>(null);
|
|
|
|
|
const countRef = useRef(0);
|
|
|
|
|
const lastLogOfPage = useRef<string[]>([]);
|
2025-02-28 00:34:02 +08:00
|
|
|
const styles = useStyles2(getStyles);
|
2025-02-14 19:52:34 +08:00
|
|
|
|
|
|
|
|
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]);
|
|
|
|
|
|
2025-05-15 15:17:14 +08:00
|
|
|
const { t } = useTranslate();
|
|
|
|
|
|
2025-02-14 19:52:34 +08:00
|
|
|
const Renderer = useCallback(
|
|
|
|
|
({ index, style }: ListChildComponentProps) => {
|
|
|
|
|
if (!logs[index] && infiniteLoaderState !== 'idle') {
|
|
|
|
|
return (
|
2025-02-27 18:31:55 +08:00
|
|
|
<LogLineMessage
|
|
|
|
|
style={style}
|
|
|
|
|
styles={styles}
|
|
|
|
|
onClick={infiniteLoaderState === 'pre-scroll' ? onLoadMore : undefined}
|
|
|
|
|
>
|
2025-05-15 15:17:14 +08:00
|
|
|
{getMessageFromInfiniteLoaderState(infiniteLoaderState, sortOrder, t)}
|
2025-02-14 19:52:34 +08:00
|
|
|
</LogLineMessage>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
return (
|
|
|
|
|
<LogLine
|
2025-02-27 18:31:55 +08:00
|
|
|
displayedFields={displayedFields}
|
2025-02-14 19:52:34 +08:00
|
|
|
index={index}
|
|
|
|
|
log={logs[index]}
|
2025-05-21 01:28:35 +08:00
|
|
|
onClick={onClick}
|
2025-02-14 19:52:34 +08:00
|
|
|
showTime={showTime}
|
|
|
|
|
style={style}
|
2025-02-27 18:31:55 +08:00
|
|
|
styles={styles}
|
2025-02-14 19:52:34 +08:00
|
|
|
variant={getLogLineVariant(logs, index, lastLogOfPage.current)}
|
|
|
|
|
wrapLogMessage={wrapLogMessage}
|
|
|
|
|
onOverflow={handleOverflow}
|
|
|
|
|
/>
|
|
|
|
|
);
|
|
|
|
|
},
|
2025-02-27 18:31:55 +08:00
|
|
|
[
|
|
|
|
|
displayedFields,
|
|
|
|
|
handleOverflow,
|
|
|
|
|
infiniteLoaderState,
|
|
|
|
|
logs,
|
2025-05-21 01:28:35 +08:00
|
|
|
onClick,
|
2025-02-27 18:31:55 +08:00
|
|
|
onLoadMore,
|
|
|
|
|
showTime,
|
|
|
|
|
sortOrder,
|
|
|
|
|
styles,
|
|
|
|
|
wrapLogMessage,
|
2025-05-15 15:17:14 +08:00
|
|
|
t,
|
2025-02-27 18:31:55 +08:00
|
|
|
]
|
2025-02-14 19:52:34 +08:00
|
|
|
);
|
|
|
|
|
|
|
|
|
|
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 })}</>;
|
|
|
|
|
};
|
|
|
|
|
|
2025-05-15 15:17:14 +08:00
|
|
|
function getMessageFromInfiniteLoaderState(state: InfiniteLoaderState, order: LogsSortOrder, t: TFunction) {
|
2025-02-14 19:52:34 +08:00
|
|
|
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...')}{' '}
|
|
|
|
|
<Spinner inline />
|
|
|
|
|
</>
|
|
|
|
|
);
|
|
|
|
|
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;
|
|
|
|
|
}
|