diff --git a/packages/grafana-ui/src/components/Logs/LogRows.tsx b/packages/grafana-ui/src/components/Logs/LogRows.tsx index 5c63cf0dd37..bba14cc56ee 100644 --- a/packages/grafana-ui/src/components/Logs/LogRows.tsx +++ b/packages/grafana-ui/src/components/Logs/LogRows.tsx @@ -24,9 +24,6 @@ export interface Props extends Themeable { logsSortOrder?: LogsSortOrder | null; allowDetails?: boolean; previewLimit?: number; - // Passed to fix problems with inactive scrolling in Logs Panel - // Can be removed when we unify scrolling for Panel and Explore - disableCustomHorizontalScroll?: boolean; forceEscape?: boolean; showDetectedFields?: string[]; showContextToggle?: (row?: LogRowModel) => boolean; @@ -97,7 +94,6 @@ class UnThemedLogRows extends PureComponent { allowDetails, previewLimit, getFieldLinks, - disableCustomHorizontalScroll, logsSortOrder, showDetectedFields, onClickShowDetectedField, @@ -105,18 +101,13 @@ class UnThemedLogRows extends PureComponent { forceEscape, } = this.props; const { renderAll } = this.state; - const { logsRowsTable, logsRowsHorizontalScroll } = getLogRowStyles(theme); + const { logsRowsTable } = getLogRowStyles(theme); const dedupedRows = deduplicatedRows ? deduplicatedRows : logRows; const hasData = logRows && logRows.length > 0; const dedupCount = dedupedRows ? dedupedRows.reduce((sum, row) => (row.duplicates ? sum + row.duplicates : sum), 0) : 0; const showDuplicates = dedupStrategy !== LogsDedupStrategy.none && dedupCount > 0; - - // For horizontal scrolling we can't use CustomScrollbar as it causes the problem with logs context - it is not visible - // for top log rows. Therefore we use CustomScrollbar only in LogsPanel and for Explore, we use custom css styling. - const horizontalScrollWindow = wrapLogMessage || disableCustomHorizontalScroll ? '' : logsRowsHorizontalScroll; - // Staged rendering const processedRows = dedupedRows ? dedupedRows : []; const orderedRows = logsSortOrder ? this.sortLogs(processedRows, logsSortOrder) : processedRows; @@ -128,67 +119,65 @@ class UnThemedLogRows extends PureComponent { const getRowContext = this.props.getRowContext ? this.props.getRowContext : () => Promise.resolve([]); return ( -
- - - {hasData && - firstRows.map((row, index) => ( - - ))} - {hasData && - renderAll && - lastRows.map((row, index) => ( - - ))} - {hasData && !renderAll && ( - - - - )} - -
Rendering {orderedRows.length - previewLimit!} rows...
-
+ + + {hasData && + firstRows.map((row, index) => ( + + ))} + {hasData && + renderAll && + lastRows.map((row, index) => ( + + ))} + {hasData && !renderAll && ( + + + + )} + +
Rendering {orderedRows.length - previewLimit!} rows...
); } } diff --git a/packages/grafana-ui/src/components/Logs/getLogRowStyles.ts b/packages/grafana-ui/src/components/Logs/getLogRowStyles.ts index 04ff3a531b6..816a61267fd 100644 --- a/packages/grafana-ui/src/components/Logs/getLogRowStyles.ts +++ b/packages/grafana-ui/src/components/Logs/getLogRowStyles.ts @@ -49,10 +49,6 @@ export const getLogRowStyles = stylesFactory((theme: GrafanaTheme, logLevel?: Lo font-size: ${theme.typography.size.sm}; width: 100%; `, - logsRowsHorizontalScroll: css` - label: logs-rows__horizontal-scroll; - overflow: auto; - `, context: css` label: context; visibility: hidden; diff --git a/public/app/features/explore/Logs.tsx b/public/app/features/explore/Logs.tsx index 4c48f2c9e18..5bed83e26ec 100644 --- a/public/app/features/explore/Logs.tsx +++ b/public/app/features/explore/Logs.tsx @@ -18,6 +18,7 @@ import { LinkModel, Field, GrafanaTheme, + DataQuery, } from '@grafana/data'; import { RadioButtonGroup, @@ -28,11 +29,13 @@ import { InlineSwitch, withTheme, stylesFactory, + CustomScrollbar, } from '@grafana/ui'; import store from 'app/core/store'; import { dedupLogRows, filterLogLevels } from 'app/core/logs_model'; import { ExploreGraphPanel } from './ExploreGraphPanel'; import { LogsMetaRow } from './LogsMetaRow'; +import LogsNavigation from './LogsNavigation'; import { RowContextOptions } from '@grafana/ui/src/components/Logs/LogRowContextProvider'; const SETTINGS_KEYS = { @@ -54,6 +57,7 @@ interface Props { timeZone: TimeZone; scanning?: boolean; scanRange?: RawTimeRange; + queries: DataQuery[]; showContextToggle?: (row?: LogRowModel) => boolean; onChangeTime: (range: AbsoluteTimeRange) => void; onClickFilterLabel?: (key: string, value: string) => void; @@ -237,6 +241,7 @@ export class UnthemedLogs extends PureComponent { onChangeTime, getFieldLinks, theme, + queries, } = this.props; const { @@ -310,7 +315,6 @@ export class UnthemedLogs extends PureComponent { {isFlipping ? 'Flipping...' : 'Flip results order'} - { onEscapeNewlines={this.onEscapeNewlines} clearDetectedFields={this.clearDetectedFields} /> - - - +
+ + + + +
{!loading && !hasData && !scanning && (
No logs found. @@ -392,5 +407,10 @@ const getStyles = stylesFactory((theme: GrafanaTheme) => { radioButtons: css` margin: 0 ${theme.spacing.sm}; `, + logsSection: css` + display: flex; + flex-direction: row; + max-height: 95vh; + `, }; }); diff --git a/public/app/features/explore/LogsContainer.tsx b/public/app/features/explore/LogsContainer.tsx index 4c236545466..f84d44e381f 100644 --- a/public/app/features/explore/LogsContainer.tsx +++ b/public/app/features/explore/LogsContainer.tsx @@ -78,6 +78,7 @@ export class LogsContainer extends PureComponent @@ -146,6 +148,7 @@ function mapStateToProps(state: StoreState, { exploreId }: { exploreId: string } isPaused, range, absoluteRange, + queries, } = item; const timeZone = getTimeZone(state.user); @@ -163,6 +166,7 @@ function mapStateToProps(state: StoreState, { exploreId }: { exploreId: string } isPaused, range, absoluteRange, + queries, }; } diff --git a/public/app/features/explore/LogsNavigation.test.tsx b/public/app/features/explore/LogsNavigation.test.tsx new file mode 100644 index 00000000000..9afa7153a74 --- /dev/null +++ b/public/app/features/explore/LogsNavigation.test.tsx @@ -0,0 +1,44 @@ +import React, { ComponentProps } from 'react'; +import { render, screen } from '@testing-library/react'; +import { LogsSortOrder } from '@grafana/data'; +import LogsNavigation from './LogsNavigation'; + +type LogsNavigationrProps = ComponentProps; + +const setup = (propOverrides?: object) => { + const props: LogsNavigationrProps = { + absoluteRange: { from: 1619081645930, to: 1619081945930 }, + timeZone: 'local', + queries: [], + loading: false, + logsSortOrder: undefined, + visibleRange: { from: 1619081941000, to: 1619081945930 }, + onChangeTime: jest.fn(), + ...propOverrides, + }; + + return render(); +}; + +describe('LogsNavigation', () => { + it('should render fetch logs button on bottom when default logs order', () => { + setup(); + expect(screen.getByTestId('fetchLogsBottom')).toBeInTheDocument(); + expect(screen.queryByTestId('fetchLogsTop')).not.toBeInTheDocument(); + }); + it('should render fetch logs button on top when flipped logs order', () => { + setup({ logsSortOrder: LogsSortOrder.Ascending }); + expect(screen.getByTestId('fetchLogsTop')).toBeInTheDocument(); + expect(screen.queryByTestId('fetchLogsBottom')).not.toBeInTheDocument(); + }); + it('should disable button to fetch logs when loading', () => { + setup({ loading: true }); + const button = screen.getByTestId('fetchLogsBottom'); + expect(button).toBeInTheDocument(); + expect(button).toBeDisabled(); + }); + it('should render logs page with correct range', () => { + setup(); + expect(screen.getByText(/02:59:05 — 02:59:01/i)).toBeInTheDocument(); + }); +}); diff --git a/public/app/features/explore/LogsNavigation.tsx b/public/app/features/explore/LogsNavigation.tsx new file mode 100644 index 00000000000..56b436d4783 --- /dev/null +++ b/public/app/features/explore/LogsNavigation.tsx @@ -0,0 +1,251 @@ +import React, { memo, useState, useEffect, useRef } from 'react'; +import classNames from 'classnames'; +import { isEqual } from 'lodash'; +import { css } from 'emotion'; +import { + LogsSortOrder, + AbsoluteTimeRange, + dateTimeFormat, + systemDateFormats, + TimeZone, + DataQuery, + GrafanaTheme, +} from '@grafana/data'; +import { Button, Icon, Spinner, useTheme, stylesFactory, CustomScrollbar } from '@grafana/ui'; + +type Props = { + absoluteRange: AbsoluteTimeRange; + timeZone: TimeZone; + queries: DataQuery[]; + loading: boolean; + visibleRange?: AbsoluteTimeRange; + logsSortOrder?: LogsSortOrder | null; + onChangeTime: (range: AbsoluteTimeRange) => void; +}; + +type LogsPage = { + logsRange: AbsoluteTimeRange; + queryRange: AbsoluteTimeRange; +}; + +function LogsNavigation({ + absoluteRange, + logsSortOrder, + timeZone, + loading, + onChangeTime, + visibleRange = absoluteRange, + queries = [], +}: Props) { + const [pages, setPages] = useState([]); + const [currentPageIndex, setCurrentPageIndex] = useState(0); + + // 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); + + // 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)) { + setPages([newPage]); + setCurrentPageIndex(0); + 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)); + // Set new pages + + return newPages; + }); + + // Set current page index + const index = newPages.findIndex((page) => page.queryRange.to === absoluteRange.to); + setCurrentPageIndex(index); + } + }, [visibleRange, absoluteRange, logsSortOrder, queries]); + + const changeTime = ({ from, to }: AbsoluteTimeRange) => { + expectedRangeRef.current = { from, to }; + onChangeTime({ from, to }); + }; + + 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 formatTime = (time: number) => { + return `${dateTimeFormat(time, { + format: systemDateFormats.interval.second, + timeZone: timeZone, + })}`; + }; + + const createPageContent = (page: LogsPage, index: number) => { + if (currentPageIndex === index && loading) { + return ; + } + const topContent = formatTime(oldestLogsFirst ? page.logsRange.from : page.logsRange.to); + const bottomContent = formatTime(oldestLogsFirst ? page.logsRange.to : page.logsRange.from); + return `${topContent} — ${bottomContent}`; + }; + + const oldestLogsFirst = logsSortOrder === LogsSortOrder.Ascending; + const theme = useTheme(); + const styles = getStyles(theme, oldestLogsFirst, loading); + return ( +
+ {/* + * We are going to have 2 buttons - on the top and bottom - Oldest and Newest. + * Therefore I have at the moment duplicated the same code, but in the future iteration, it ill be updated + */} + {oldestLogsFirst && ( + + )} + +
+
+ {pages.map((page: LogsPage, index) => ( +
!loading && changeTime({ from: page.queryRange.from, to: page.queryRange.to })} + > +
+
+ {createPageContent(page, index)} +
+
+ ))} +
+
+
+ + + {!oldestLogsFirst && ( + + )} +
+ ); +} + +export default memo(LogsNavigation); + +const getStyles = stylesFactory((theme: GrafanaTheme, oldestLogsFirst: boolean, loading: boolean) => { + return { + navContainer: css` + max-height: 95vh; + display: flex; + flex-direction: column; + justify-content: ${oldestLogsFirst ? 'flex-start' : 'space-between'}; + `, + navButton: css` + width: 58px; + height: 68px; + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; + line-height: 1; + `, + navButtonContent: css` + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; + width: 100%; + height: 100%; + white-space: normal; + `, + pagesWrapper: css` + height: 100%; + padding-left: ${theme.spacing.xs}; + display: flex; + flex-direction: column; + overflow-y: scroll; + `, + pagesContainer: css` + display: flex; + padding: 0; + flex-direction: column; + `, + page: css` + display: flex; + margin: ${theme.spacing.md} 0; + cursor: ${loading ? 'auto' : 'pointer'}; + white-space: normal; + .selectedBg { + background: ${theme.colors.bgBlue2}; + } + .selectedText { + color: ${theme.colors.bgBlue2}; + } + `, + line: css` + width: 3px; + height: 100%; + align-items: center; + background: ${theme.colors.textWeak}; + `, + time: css` + width: 60px; + min-height: 80px; + font-size: ${theme.typography.size.sm}; + padding-left: ${theme.spacing.xs}; + display: flex; + align-items: center; + `, + filler: css` + height: inherit; + background: repeating-linear-gradient( + 135deg, + ${theme.colors.bg1}, + ${theme.colors.bg1} 5px, + ${theme.colors.bg2} 5px, + ${theme.colors.bg2} 15px + ); + width: 3px; + margin-bottom: 8px; + `, + }; +}); diff --git a/public/app/features/explore/Wrapper.test.tsx b/public/app/features/explore/Wrapper.test.tsx index 415e4fe6ead..5f62c7f90aa 100644 --- a/public/app/features/explore/Wrapper.test.tsx +++ b/public/app/features/explore/Wrapper.test.tsx @@ -64,7 +64,7 @@ describe('Wrapper', () => { (datasources.loki.query as Mock).mockReturnValueOnce(makeLogsQueryResponse()); // Make sure we render the logs panel - await screen.findByText(/^Logs$/i); + await screen.findByText(/^Logs$/); // Make sure we render the log line await screen.findByText(/custom log line/i); @@ -169,7 +169,7 @@ describe('Wrapper', () => { // Make sure we render the logs panel await waitFor(() => { - const logsPanels = screen.getAllByText(/^Logs$/i); + const logsPanels = screen.getAllByText(/^Logs$/); expect(logsPanels.length).toBe(2); }); diff --git a/public/app/plugins/panel/logs/LogsPanel.tsx b/public/app/plugins/panel/logs/LogsPanel.tsx index 0476ba1933e..e263b8829b5 100644 --- a/public/app/plugins/panel/logs/LogsPanel.tsx +++ b/public/app/plugins/panel/logs/LogsPanel.tsx @@ -35,7 +35,6 @@ export const LogsPanel: React.FunctionComponent = ({ wrapLogMessage={wrapLogMessage} timeZone={timeZone} allowDetails={true} - disableCustomHorizontalScroll={true} logsSortOrder={sortOrder} />