2025-04-01 10:38:02 +09:00

349 lines
11 KiB
TypeScript

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<LogRowModel[]>(rows);
const lastScroll = useRef<number>(scrollElement?.scrollTop || 0);
const lastEvent = useRef<Event | WheelEvent | null>(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 && <LoadingIndicator adjective={sortOrder === LogsSortOrder.Descending ? 'newer' : 'older'} />}
{!hideTopMessage && upperOutOfRange && outOfRangeMessage}
{sortOrder === LogsSortOrder.Ascending && app === CoreApp.Explore && loadMoreLogs && (
<Button className={styles.navButton} variant="secondary" onClick={loadOlderLogs} disabled={loading}>
<div className={styles.navButtonContent}>
<Icon name="angle-up" size="lg" />
<Trans i18nKey="logs.infinite-scroll.older-logs">Older logs</Trans>
</div>
</Button>
)}
{children}
{!hideBottomMessage && lowerOutOfRange && outOfRangeMessage}
{lowerLoading && <LoadingIndicator adjective={sortOrder === LogsSortOrder.Descending ? 'older' : 'newer'} />}
</>
);
};
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 = (
<div className={styles.messageContainer} data-testid="end-of-range">
End of the selected time range.
</div>
);
export enum ScrollDirection {
Top = -1,
Bottom = 1,
NoScroll = 0,
}
export function shouldLoadMore(
event: Event | WheelEvent,
lastEvent: Event | WheelEvent | null,
countRef: MutableRefObject<number>,
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<number>
) {
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;
}