Explore: Add logs navigation to request more logs (#33259)

* Update layout for new logs navigation

* Add LogsNavigation component

* WIP clean up of css

* Add clear navigation and styling

* Add clearing to queyrRows run Query

* Make querying and displaying reliable

* Rename chunks to pages

* Refactor, remove logsNavigationCleared from navigation

* Rmove clear logs navigation

* Add flipping

* Update styling

* Add test

* Fix import

* Update Wrapper test

* Update public/sass/pages/_explore.scss

* Update public/app/features/explore/Logs.tsx

Co-authored-by: Giordano Ricci <me@giordanoricci.com>

* Add spinner to page to prevent jumpy time

* Create standard size for page

* Fix test

* Fix postioning and useRefs

* Add maxHeight

Co-authored-by: Giordano Ricci <me@giordanoricci.com>
This commit is contained in:
Ivana Huckova 2021-05-03 19:34:32 +02:00 committed by GitHub
parent fa866f1154
commit 0609b80fdc
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
8 changed files with 404 additions and 101 deletions

View File

@ -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<Props, State> {
allowDetails,
previewLimit,
getFieldLinks,
disableCustomHorizontalScroll,
logsSortOrder,
showDetectedFields,
onClickShowDetectedField,
@ -105,18 +101,13 @@ class UnThemedLogRows extends PureComponent<Props, State> {
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<Props, State> {
const getRowContext = this.props.getRowContext ? this.props.getRowContext : () => Promise.resolve([]);
return (
<div className={horizontalScrollWindow}>
<table className={logsRowsTable}>
<tbody>
{hasData &&
firstRows.map((row, index) => (
<LogRow
key={row.uid}
getRows={getRows}
getRowContext={getRowContext}
highlighterExpressions={highlighterExpressions}
row={row}
showContextToggle={showContextToggle}
showDuplicates={showDuplicates}
showLabels={showLabels}
showTime={showTime}
showDetectedFields={showDetectedFields}
wrapLogMessage={wrapLogMessage}
timeZone={timeZone}
allowDetails={allowDetails}
onClickFilterLabel={onClickFilterLabel}
onClickFilterOutLabel={onClickFilterOutLabel}
onClickShowDetectedField={onClickShowDetectedField}
onClickHideDetectedField={onClickHideDetectedField}
getFieldLinks={getFieldLinks}
logsSortOrder={logsSortOrder}
forceEscape={forceEscape}
/>
))}
{hasData &&
renderAll &&
lastRows.map((row, index) => (
<LogRow
key={row.uid}
getRows={getRows}
getRowContext={getRowContext}
row={row}
showContextToggle={showContextToggle}
showDuplicates={showDuplicates}
showLabels={showLabels}
showTime={showTime}
showDetectedFields={showDetectedFields}
wrapLogMessage={wrapLogMessage}
timeZone={timeZone}
allowDetails={allowDetails}
onClickFilterLabel={onClickFilterLabel}
onClickFilterOutLabel={onClickFilterOutLabel}
onClickShowDetectedField={onClickShowDetectedField}
onClickHideDetectedField={onClickHideDetectedField}
getFieldLinks={getFieldLinks}
logsSortOrder={logsSortOrder}
forceEscape={forceEscape}
/>
))}
{hasData && !renderAll && (
<tr>
<td colSpan={5}>Rendering {orderedRows.length - previewLimit!} rows...</td>
</tr>
)}
</tbody>
</table>
</div>
<table className={logsRowsTable}>
<tbody>
{hasData &&
firstRows.map((row, index) => (
<LogRow
key={row.uid}
getRows={getRows}
getRowContext={getRowContext}
highlighterExpressions={highlighterExpressions}
row={row}
showContextToggle={showContextToggle}
showDuplicates={showDuplicates}
showLabels={showLabels}
showTime={showTime}
showDetectedFields={showDetectedFields}
wrapLogMessage={wrapLogMessage}
timeZone={timeZone}
allowDetails={allowDetails}
onClickFilterLabel={onClickFilterLabel}
onClickFilterOutLabel={onClickFilterOutLabel}
onClickShowDetectedField={onClickShowDetectedField}
onClickHideDetectedField={onClickHideDetectedField}
getFieldLinks={getFieldLinks}
logsSortOrder={logsSortOrder}
forceEscape={forceEscape}
/>
))}
{hasData &&
renderAll &&
lastRows.map((row, index) => (
<LogRow
key={row.uid}
getRows={getRows}
getRowContext={getRowContext}
row={row}
showContextToggle={showContextToggle}
showDuplicates={showDuplicates}
showLabels={showLabels}
showTime={showTime}
showDetectedFields={showDetectedFields}
wrapLogMessage={wrapLogMessage}
timeZone={timeZone}
allowDetails={allowDetails}
onClickFilterLabel={onClickFilterLabel}
onClickFilterOutLabel={onClickFilterOutLabel}
onClickShowDetectedField={onClickShowDetectedField}
onClickHideDetectedField={onClickHideDetectedField}
getFieldLinks={getFieldLinks}
logsSortOrder={logsSortOrder}
forceEscape={forceEscape}
/>
))}
{hasData && !renderAll && (
<tr>
<td colSpan={5}>Rendering {orderedRows.length - previewLimit!} rows...</td>
</tr>
)}
</tbody>
</table>
);
}
}

View File

@ -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;

View File

@ -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<Props, State> {
onChangeTime,
getFieldLinks,
theme,
queries,
} = this.props;
const {
@ -310,7 +315,6 @@ export class UnthemedLogs extends PureComponent<Props, State> {
{isFlipping ? 'Flipping...' : 'Flip results order'}
</Button>
</div>
<LogsMetaRow
logRows={logRows}
meta={logsMeta || []}
@ -322,28 +326,39 @@ export class UnthemedLogs extends PureComponent<Props, State> {
onEscapeNewlines={this.onEscapeNewlines}
clearDetectedFields={this.clearDetectedFields}
/>
<LogRows
logRows={logRows}
deduplicatedRows={dedupedRows}
dedupStrategy={dedupStrategy}
getRowContext={this.props.getRowContext}
highlighterExpressions={highlighterExpressions}
onClickFilterLabel={onClickFilterLabel}
onClickFilterOutLabel={onClickFilterOutLabel}
showContextToggle={showContextToggle}
showLabels={showLabels}
showTime={showTime}
forceEscape={forceEscape}
wrapLogMessage={wrapLogMessage}
timeZone={timeZone}
getFieldLinks={getFieldLinks}
logsSortOrder={logsSortOrder}
showDetectedFields={showDetectedFields}
onClickShowDetectedField={this.showDetectedField}
onClickHideDetectedField={this.hideDetectedField}
/>
<div className={styles.logsSection}>
<CustomScrollbar autoHide>
<LogRows
logRows={logRows}
deduplicatedRows={dedupedRows}
dedupStrategy={dedupStrategy}
getRowContext={this.props.getRowContext}
highlighterExpressions={highlighterExpressions}
onClickFilterLabel={onClickFilterLabel}
onClickFilterOutLabel={onClickFilterOutLabel}
showContextToggle={showContextToggle}
showLabels={showLabels}
showTime={showTime}
forceEscape={forceEscape}
wrapLogMessage={wrapLogMessage}
timeZone={timeZone}
getFieldLinks={getFieldLinks}
logsSortOrder={logsSortOrder}
showDetectedFields={showDetectedFields}
onClickShowDetectedField={this.showDetectedField}
onClickHideDetectedField={this.hideDetectedField}
/>
</CustomScrollbar>
<LogsNavigation
logsSortOrder={logsSortOrder}
visibleRange={visibleRange}
absoluteRange={absoluteRange}
timeZone={timeZone}
onChangeTime={onChangeTime}
loading={loading}
queries={queries}
/>
</div>
{!loading && !hasData && !scanning && (
<div className={styles.noData}>
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;
`,
};
});

View File

@ -78,6 +78,7 @@ export class LogsContainer extends PureComponent<PropsFromRedux & LogsContainerP
width,
isLive,
exploreId,
queries,
} = this.props;
if (!logRows) {
@ -124,6 +125,7 @@ export class LogsContainer extends PureComponent<PropsFromRedux & LogsContainerP
width={width}
getRowContext={this.getLogRowContext}
getFieldLinks={this.getFieldLinks}
queries={queries}
/>
</Collapse>
</LogsCrossFadeTransition>
@ -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,
};
}

View File

@ -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<typeof LogsNavigation>;
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(<LogsNavigation {...props} />);
};
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();
});
});

View File

@ -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<LogsPage[]>([]);
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<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);
// 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 <Spinner />;
}
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 (
<div className={styles.navContainer}>
{/*
* 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 && (
<Button
data-testid="fetchLogsTop"
className={styles.navButton}
variant="secondary"
onClick={() => {
// the range is based on initally selected range
changeTime({ from: visibleRange.from - rangeSpanRef.current, to: visibleRange.from });
}}
disabled={loading}
>
<div className={styles.navButtonContent}>
{loading ? <Spinner /> : <Icon name="angle-up" size="lg" />}
Older logs
</div>
</Button>
)}
<CustomScrollbar autoHide>
<div className={styles.pagesWrapper}>
<div className={styles.pagesContainer}>
{pages.map((page: LogsPage, index) => (
<div
className={styles.page}
key={page.queryRange.to}
onClick={() => !loading && changeTime({ from: page.queryRange.from, to: page.queryRange.to })}
>
<div className={classNames(styles.line, { selectedBg: currentPageIndex === index })} />
<div className={classNames(styles.time, { selectedText: currentPageIndex === index })}>
{createPageContent(page, index)}
</div>
</div>
))}
</div>
<div className={styles.filler}></div>
</div>
</CustomScrollbar>
{!oldestLogsFirst && (
<Button
data-testid="fetchLogsBottom"
className={styles.navButton}
variant="secondary"
onClick={() => {
// the range is based on initally selected range
changeTime({ from: visibleRange.from - rangeSpanRef.current, to: visibleRange.from });
}}
disabled={loading}
>
<div className={styles.navButtonContent}>
Older logs
{loading ? <Spinner /> : <Icon name="angle-down" size="lg" />}
</div>
</Button>
)}
</div>
);
}
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;
`,
};
});

View File

@ -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);
});

View File

@ -35,7 +35,6 @@ export const LogsPanel: React.FunctionComponent<LogsPanelProps> = ({
wrapLogMessage={wrapLogMessage}
timeZone={timeZone}
allowDetails={true}
disableCustomHorizontalScroll={true}
logsSortOrder={sortOrder}
/>
</CustomScrollbar>