LogsNavigation.tsx 7.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229
  1. import { css } from '@emotion/css';
  2. import { isEqual } from 'lodash';
  3. import React, { memo, useState, useEffect, useRef } from 'react';
  4. import { LogsSortOrder, AbsoluteTimeRange, TimeZone, DataQuery, GrafanaTheme2 } from '@grafana/data';
  5. import { reportInteraction } from '@grafana/runtime';
  6. import { Button, Icon, Spinner, useTheme2 } from '@grafana/ui';
  7. import { LogsNavigationPages } from './LogsNavigationPages';
  8. type Props = {
  9. absoluteRange: AbsoluteTimeRange;
  10. timeZone: TimeZone;
  11. queries: DataQuery[];
  12. loading: boolean;
  13. visibleRange: AbsoluteTimeRange;
  14. logsSortOrder?: LogsSortOrder | null;
  15. onChangeTime: (range: AbsoluteTimeRange) => void;
  16. scrollToTopLogs: () => void;
  17. addResultsToCache: () => void;
  18. clearCache: () => void;
  19. };
  20. export type LogsPage = {
  21. logsRange: AbsoluteTimeRange;
  22. queryRange: AbsoluteTimeRange;
  23. };
  24. function LogsNavigation({
  25. absoluteRange,
  26. logsSortOrder,
  27. timeZone,
  28. loading,
  29. onChangeTime,
  30. scrollToTopLogs,
  31. visibleRange,
  32. queries,
  33. clearCache,
  34. addResultsToCache,
  35. }: Props) {
  36. const [pages, setPages] = useState<LogsPage[]>([]);
  37. const [currentPageIndex, setCurrentPageIndex] = useState(0);
  38. // These refs are to determine, if we want to clear up logs navigation when totally new query is run
  39. const expectedQueriesRef = useRef<DataQuery[]>();
  40. const expectedRangeRef = useRef<AbsoluteTimeRange>();
  41. // This ref is to store range span for future queres based on firstly selected time range
  42. // e.g. if last 5 min selected, always run 5 min range
  43. const rangeSpanRef = useRef(0);
  44. const oldestLogsFirst = logsSortOrder === LogsSortOrder.Ascending;
  45. const onFirstPage = oldestLogsFirst ? currentPageIndex === pages.length - 1 : currentPageIndex === 0;
  46. const onLastPage = oldestLogsFirst ? currentPageIndex === 0 : currentPageIndex === pages.length - 1;
  47. const theme = useTheme2();
  48. const styles = getStyles(theme, oldestLogsFirst, loading);
  49. // Main effect to set pages and index
  50. useEffect(() => {
  51. const newPage = { logsRange: visibleRange, queryRange: absoluteRange };
  52. let newPages: LogsPage[] = [];
  53. // We want to start new pagination if queries change or if absolute range is different than expected
  54. if (!isEqual(expectedRangeRef.current, absoluteRange) || !isEqual(expectedQueriesRef.current, queries)) {
  55. clearCache();
  56. setPages([newPage]);
  57. setCurrentPageIndex(0);
  58. expectedQueriesRef.current = queries;
  59. rangeSpanRef.current = absoluteRange.to - absoluteRange.from;
  60. } else {
  61. setPages((pages) => {
  62. // Remove duplicates with new query
  63. newPages = pages.filter((page) => !isEqual(newPage.queryRange, page.queryRange));
  64. // Sort pages based on logsOrder so they visually align with displayed logs
  65. newPages = [...newPages, newPage].sort((a, b) => sortPages(a, b, logsSortOrder));
  66. // Set new pages
  67. return newPages;
  68. });
  69. // Set current page index
  70. const index = newPages.findIndex((page) => page.queryRange.to === absoluteRange.to);
  71. setCurrentPageIndex(index);
  72. }
  73. addResultsToCache();
  74. }, [visibleRange, absoluteRange, logsSortOrder, queries, clearCache, addResultsToCache]);
  75. useEffect(() => {
  76. clearCache();
  77. // We can't enforce the eslint rule here because we only want to run when component is mounted.
  78. // eslint-disable-next-line react-hooks/exhaustive-deps
  79. }, []);
  80. const changeTime = ({ from, to }: AbsoluteTimeRange) => {
  81. expectedRangeRef.current = { from, to };
  82. onChangeTime({ from, to });
  83. };
  84. const sortPages = (a: LogsPage, b: LogsPage, logsSortOrder?: LogsSortOrder | null) => {
  85. if (logsSortOrder === LogsSortOrder.Ascending) {
  86. return a.queryRange.to > b.queryRange.to ? 1 : -1;
  87. }
  88. return a.queryRange.to > b.queryRange.to ? -1 : 1;
  89. };
  90. const olderLogsButton = (
  91. <Button
  92. data-testid="olderLogsButton"
  93. className={styles.navButton}
  94. variant="secondary"
  95. onClick={() => {
  96. //If we are not on the last page, use next page's range
  97. reportInteraction('grafana_explore_logs_pagination_clicked', {
  98. pageType: 'olderLogsButton',
  99. });
  100. if (!onLastPage) {
  101. const indexChange = oldestLogsFirst ? -1 : 1;
  102. changeTime({
  103. from: pages[currentPageIndex + indexChange].queryRange.from,
  104. to: pages[currentPageIndex + indexChange].queryRange.to,
  105. });
  106. } else {
  107. //If we are on the last page, create new range
  108. changeTime({ from: visibleRange.from - rangeSpanRef.current, to: visibleRange.from });
  109. }
  110. }}
  111. disabled={loading}
  112. >
  113. <div className={styles.navButtonContent}>
  114. {loading ? <Spinner /> : <Icon name={oldestLogsFirst ? 'angle-up' : 'angle-down'} size="lg" />}
  115. Older logs
  116. </div>
  117. </Button>
  118. );
  119. const newerLogsButton = (
  120. <Button
  121. data-testid="newerLogsButton"
  122. className={styles.navButton}
  123. variant="secondary"
  124. onClick={() => {
  125. reportInteraction('grafana_explore_logs_pagination_clicked', {
  126. pageType: 'newerLogsButton',
  127. });
  128. //If we are not on the first page, use previous page's range
  129. if (!onFirstPage) {
  130. const indexChange = oldestLogsFirst ? 1 : -1;
  131. changeTime({
  132. from: pages[currentPageIndex + indexChange].queryRange.from,
  133. to: pages[currentPageIndex + indexChange].queryRange.to,
  134. });
  135. }
  136. //If we are on the first page, button is disabled and we do nothing
  137. }}
  138. disabled={loading || onFirstPage}
  139. >
  140. <div className={styles.navButtonContent}>
  141. {loading && <Spinner />}
  142. {onFirstPage || loading ? null : <Icon name={oldestLogsFirst ? 'angle-down' : 'angle-up'} size="lg" />}
  143. {onFirstPage ? 'Start of range' : 'Newer logs'}
  144. </div>
  145. </Button>
  146. );
  147. return (
  148. <div className={styles.navContainer}>
  149. {oldestLogsFirst ? olderLogsButton : newerLogsButton}
  150. <LogsNavigationPages
  151. pages={pages}
  152. currentPageIndex={currentPageIndex}
  153. oldestLogsFirst={oldestLogsFirst}
  154. timeZone={timeZone}
  155. loading={loading}
  156. changeTime={changeTime}
  157. />
  158. {oldestLogsFirst ? newerLogsButton : olderLogsButton}
  159. <Button
  160. data-testid="scrollToTop"
  161. className={styles.scrollToTopButton}
  162. variant="secondary"
  163. onClick={scrollToTopLogs}
  164. title="Scroll to top"
  165. >
  166. <Icon name="arrow-up" size="lg" />
  167. </Button>
  168. </div>
  169. );
  170. }
  171. export default memo(LogsNavigation);
  172. const getStyles = (theme: GrafanaTheme2, oldestLogsFirst: boolean, loading: boolean) => {
  173. return {
  174. navContainer: css`
  175. max-height: 95vh;
  176. display: flex;
  177. flex-direction: column;
  178. justify-content: ${oldestLogsFirst ? 'flex-start' : 'space-between'};
  179. position: sticky;
  180. top: ${theme.spacing(2)};
  181. right: 0;
  182. `,
  183. navButton: css`
  184. width: 58px;
  185. height: 68px;
  186. display: flex;
  187. flex-direction: column;
  188. justify-content: center;
  189. align-items: center;
  190. line-height: 1;
  191. `,
  192. navButtonContent: css`
  193. display: flex;
  194. flex-direction: column;
  195. justify-content: center;
  196. align-items: center;
  197. width: 100%;
  198. height: 100%;
  199. white-space: normal;
  200. `,
  201. scrollToTopButton: css`
  202. width: 40px;
  203. height: 40px;
  204. display: flex;
  205. flex-direction: column;
  206. justify-content: center;
  207. align-items: center;
  208. margin-top: ${theme.spacing(1)};
  209. `,
  210. };
  211. };