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

289 lines
9.4 KiB
TypeScript

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<LogsPage[]>([]);
// These refs are to determine, if we want to clear up logs navigation when totally new query is run
const expectedQueriesRef = useRef<DataQuery[]>();
const expectedRangeRef = useRef<AbsoluteTimeRange>();
// 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 = (
<Button
data-testid="olderLogsButton"
className={styles.navButton}
variant="secondary"
onClick={() => {
//If we are not on the last page, use next page's range
reportInteraction('grafana_explore_logs_pagination_clicked', {
pageType: 'olderLogsButton',
});
if (!onLastPage) {
const indexChange = oldestLogsFirst ? -1 : 1;
changeTime({
from: pages[currentPageIndex + indexChange].queryRange.from,
to: pages[currentPageIndex + indexChange].queryRange.to,
});
} else {
//If we are on the last page, create new range
changeTime({ from: visibleRange.from - rangeSpanRef.current, to: visibleRange.from });
}
scrollToTopLogs();
}}
disabled={loading}
>
<div className={styles.navButtonContent}>
{loading ? <Spinner /> : <Icon name={oldestLogsFirst ? 'angle-up' : 'angle-down'} size="lg" />}
<Trans i18nKey={'logs.logs-navigation.older-logs'}>Older logs</Trans>
</div>
</Button>
);
const newerLogsButton = (
<Button
data-testid="newerLogsButton"
className={styles.navButton}
variant="secondary"
onClick={() => {
reportInteraction('grafana_explore_logs_pagination_clicked', {
pageType: 'newerLogsButton',
});
//If we are not on the first page, use previous page's range
if (!onFirstPage) {
const indexChange = oldestLogsFirst ? 1 : -1;
changeTime({
from: pages[currentPageIndex + indexChange].queryRange.from,
to: pages[currentPageIndex + indexChange].queryRange.to,
});
}
scrollToTopLogs();
//If we are on the first page, button is disabled and we do nothing
}}
disabled={loading || onFirstPage}
>
<div className={styles.navButtonContent}>
{loading && <Spinner />}
{onFirstPage || loading ? null : <Icon name={oldestLogsFirst ? 'angle-down' : 'angle-up'} size="lg" />}
{onFirstPage
? t('logs.logs-navigation.start-of-range', 'Start of range')
: t('logs.logs-navigation.newer-logs', 'Newer logs')}
</div>
</Button>
);
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 (
<div className={styles.navContainer}>
{!config.featureToggles.logsInfiniteScrolling && (
<>
{oldestLogsFirst ? olderLogsButton : newerLogsButton}
<LogsNavigationPages
pages={pages}
currentPageIndex={currentPageIndex}
oldestLogsFirst={oldestLogsFirst}
timeZone={timeZone}
loading={loading}
onClick={onPageClick}
/>
{oldestLogsFirst ? newerLogsButton : olderLogsButton}
</>
)}
{scrollToBottomLogs && (
<Button
data-testid="scrollToBottom"
className={styles.scrollToBottomButton}
variant="secondary"
onClick={onScrollToBottomClick}
title={t('logs.logs-navigation.scroll-bottom', 'Scroll to bottom')}
>
<Icon name="arrow-down" size="lg" />
</Button>
)}
<Button
data-testid="scrollToTop"
className={styles.scrollToTopButton}
variant="secondary"
onClick={onScrollToTopClick}
title={t('logs.logs-navigation.scroll-top', 'Scroll to top')}
>
<Icon name="arrow-up" size="lg" />
</Button>
</div>
);
}
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),
}),
};
};