import { css } from '@emotion/css'; import { isEqual } from 'lodash'; import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { AbsoluteTimeRange, GrafanaTheme2, LogsSortOrder } from '@grafana/data'; import { config, reportInteraction } from '@grafana/runtime'; import { DataQuery, TimeZone } from '@grafana/schema'; import { Button, Icon, Spinner, useTheme2 } from '@grafana/ui'; import { TOP_BAR_LEVEL_HEIGHT } from 'app/core/components/AppChrome/types'; import { t, Trans } from 'app/core/internationalization'; import { LogsNavigationPages } from './LogsNavigationPages'; type Props = { absoluteRange: AbsoluteTimeRange; timeZone: TimeZone; queries: DataQuery[]; loading: boolean; visibleRange: AbsoluteTimeRange; logsSortOrder?: LogsSortOrder | null; onChangeTime: (range: AbsoluteTimeRange) => void; scrollToTopLogs: () => void; scrollToBottomLogs?: () => void; addResultsToCache: () => void; clearCache: () => void; }; export type LogsPage = { logsRange: AbsoluteTimeRange; queryRange: AbsoluteTimeRange; }; function LogsNavigation({ absoluteRange, logsSortOrder, timeZone, loading, onChangeTime, scrollToTopLogs, scrollToBottomLogs, visibleRange, queries, clearCache, addResultsToCache, }: Props) { const [pages, setPages] = useState([]); // These refs are to determine, if we want to clear up logs navigation when totally new query is run const expectedQueriesRef = useRef(); const expectedRangeRef = useRef(); // This ref is to store range span for future queres based on firstly selected time range // e.g. if last 5 min selected, always run 5 min range const rangeSpanRef = useRef(0); const currentPageIndex = useMemo( () => pages.findIndex((page) => { return page.queryRange.to === absoluteRange.to; }), [absoluteRange.to, pages] ); const oldestLogsFirst = logsSortOrder === LogsSortOrder.Ascending; const onFirstPage = oldestLogsFirst ? currentPageIndex === pages.length - 1 : currentPageIndex === 0; const onLastPage = oldestLogsFirst ? currentPageIndex === 0 : currentPageIndex === pages.length - 1; const theme = useTheme2(); const styles = getStyles(theme, oldestLogsFirst); // Main effect to set pages and index useEffect(() => { const newPage = { logsRange: visibleRange, queryRange: absoluteRange }; let newPages: LogsPage[] = []; // We want to start new pagination if queries change or if absolute range is different than expected if (!isEqual(expectedRangeRef.current, absoluteRange) || !isEqual(expectedQueriesRef.current, queries)) { clearCache(); setPages([newPage]); expectedQueriesRef.current = queries; rangeSpanRef.current = absoluteRange.to - absoluteRange.from; } else { setPages((pages) => { // Remove duplicates with new query newPages = pages.filter((page) => !isEqual(newPage.queryRange, page.queryRange)); // Sort pages based on logsOrder so they visually align with displayed logs newPages = [...newPages, newPage].sort((a, b) => sortPages(a, b, logsSortOrder)); return newPages; }); } }, [visibleRange, absoluteRange, logsSortOrder, queries, clearCache, addResultsToCache]); const changeTime = useCallback( ({ from, to }: AbsoluteTimeRange) => { addResultsToCache(); expectedRangeRef.current = { from, to }; onChangeTime({ from, to }); }, [onChangeTime, addResultsToCache] ); const sortPages = (a: LogsPage, b: LogsPage, logsSortOrder?: LogsSortOrder | null) => { if (logsSortOrder === LogsSortOrder.Ascending) { return a.queryRange.to > b.queryRange.to ? 1 : -1; } return a.queryRange.to > b.queryRange.to ? -1 : 1; }; const olderLogsButton = ( ); const newerLogsButton = ( ); const onPageClick = useCallback( (page: LogsPage, pageNumber: number) => { reportInteraction('grafana_explore_logs_pagination_clicked', { pageType: 'page', pageNumber, }); changeTime({ from: page.queryRange.from, to: page.queryRange.to }); scrollToTopLogs(); }, [changeTime, scrollToTopLogs] ); const onScrollToTopClick = useCallback(() => { reportInteraction('grafana_explore_logs_scroll_top_clicked'); scrollToTopLogs(); }, [scrollToTopLogs]); const onScrollToBottomClick = useCallback(() => { reportInteraction('grafana_explore_logs_scroll_bottom_clicked'); scrollToBottomLogs?.(); }, [scrollToBottomLogs]); return (
{!config.featureToggles.logsInfiniteScrolling && ( <> {oldestLogsFirst ? olderLogsButton : newerLogsButton} {oldestLogsFirst ? newerLogsButton : olderLogsButton} )} {scrollToBottomLogs && ( )}
); } export default memo(LogsNavigation); const getStyles = (theme: GrafanaTheme2, oldestLogsFirst: boolean) => { const navContainerHeight = `calc(100vh - 2*${theme.spacing(2)} - 2*${TOP_BAR_LEVEL_HEIGHT}px)`; return { navContainer: css({ maxHeight: navContainerHeight, width: oldestLogsFirst && !config.featureToggles.newLogsPanel ? '58px' : 'auto', display: 'flex', flexDirection: 'column', justifyContent: config.featureToggles.logsInfiniteScrolling ? 'flex-end' : oldestLogsFirst ? 'flex-start' : 'space-between', position: 'sticky', top: theme.spacing(2), right: 0, }), navButton: css({ width: '58px', height: '68px', display: 'flex', flexDirection: 'column', justifyContent: 'center', alignItems: 'center', lineHeight: 1, }), navButtonContent: css({ display: 'flex', flexDirection: 'column', justifyContent: 'center', alignItems: 'center', width: '100%', height: '100%', whiteSpace: 'normal', }), scrollToBottomButton: css({ width: '40px', height: '40px', display: 'flex', flexDirection: 'column', justifyContent: 'center', alignItems: 'center', position: 'absolute', top: 0, }), scrollToTopButton: css({ width: '40px', height: '40px', display: 'flex', flexDirection: 'column', justifyContent: 'center', alignItems: 'center', marginTop: theme.spacing(1), }), }; };