349 lines
11 KiB
TypeScript
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;
|
|
}
|