mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
Explore: Polish UI of logs navigation (#33910)
* Update UI * Update UI, return spinner * Add title to Scroll to top button * Update public/app/features/explore/Logs.tsx Co-authored-by: Gábor Farkas <gabor.farkas@gmail.com> * Update public/app/features/explore/LogsNavigation.test.tsx Co-authored-by: Gábor Farkas <gabor.farkas@gmail.com> * Remove unnecessary memoization * Update public/app/features/explore/LogsNavigationPages.tsx Co-authored-by: Gábor Farkas <gabor.farkas@gmail.com>
This commit is contained in:
parent
2ded2aef71
commit
9ace76a718
@ -48,6 +48,7 @@ export const getLogRowStyles = stylesFactory((theme: GrafanaTheme, logLevel?: Lo
|
|||||||
font-family: ${theme.typography.fontFamily.monospace};
|
font-family: ${theme.typography.fontFamily.monospace};
|
||||||
font-size: ${theme.typography.size.sm};
|
font-size: ${theme.typography.size.sm};
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
overflow: hidden;
|
||||||
`,
|
`,
|
||||||
context: css`
|
context: css`
|
||||||
label: context;
|
label: context;
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
import React, { PureComponent } from 'react';
|
import React, { PureComponent, createRef } from 'react';
|
||||||
import { css } from '@emotion/css';
|
import { css } from '@emotion/css';
|
||||||
import { capitalize } from 'lodash';
|
import { capitalize } from 'lodash';
|
||||||
import memoizeOne from 'memoize-one';
|
import memoizeOne from 'memoize-one';
|
||||||
@ -29,7 +29,6 @@ import {
|
|||||||
InlineSwitch,
|
InlineSwitch,
|
||||||
withTheme,
|
withTheme,
|
||||||
stylesFactory,
|
stylesFactory,
|
||||||
CustomScrollbar,
|
|
||||||
} from '@grafana/ui';
|
} from '@grafana/ui';
|
||||||
import store from 'app/core/store';
|
import store from 'app/core/store';
|
||||||
import { dedupLogRows, filterLogLevels } from 'app/core/logs_model';
|
import { dedupLogRows, filterLogLevels } from 'app/core/logs_model';
|
||||||
@ -83,6 +82,7 @@ interface State {
|
|||||||
export class UnthemedLogs extends PureComponent<Props, State> {
|
export class UnthemedLogs extends PureComponent<Props, State> {
|
||||||
flipOrderTimer: NodeJS.Timeout;
|
flipOrderTimer: NodeJS.Timeout;
|
||||||
cancelFlippingTimer: NodeJS.Timeout;
|
cancelFlippingTimer: NodeJS.Timeout;
|
||||||
|
topLogsRef = createRef<HTMLDivElement>();
|
||||||
|
|
||||||
state: State = {
|
state: State = {
|
||||||
showLabels: store.getBool(SETTINGS_KEYS.showLabels, false),
|
showLabels: store.getBool(SETTINGS_KEYS.showLabels, false),
|
||||||
@ -222,6 +222,8 @@ export class UnthemedLogs extends PureComponent<Props, State> {
|
|||||||
return filterLogLevels(logRows, new Set(hiddenLogLevels));
|
return filterLogLevels(logRows, new Set(hiddenLogLevels));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
scrollToTopLogs = () => this.topLogsRef.current?.scrollIntoView();
|
||||||
|
|
||||||
render() {
|
render() {
|
||||||
const {
|
const {
|
||||||
logRows,
|
logRows,
|
||||||
@ -280,7 +282,7 @@ export class UnthemedLogs extends PureComponent<Props, State> {
|
|||||||
showLines={false}
|
showLines={false}
|
||||||
onUpdateTimeRange={onChangeTime}
|
onUpdateTimeRange={onChangeTime}
|
||||||
/>
|
/>
|
||||||
<div className={styles.logOptions}>
|
<div className={styles.logOptions} ref={this.topLogsRef}>
|
||||||
<InlineFieldRow>
|
<InlineFieldRow>
|
||||||
<InlineField label="Time" transparent>
|
<InlineField label="Time" transparent>
|
||||||
<InlineSwitch value={showTime} onChange={this.onChangeTime} transparent />
|
<InlineSwitch value={showTime} onChange={this.onChangeTime} transparent />
|
||||||
@ -327,7 +329,7 @@ export class UnthemedLogs extends PureComponent<Props, State> {
|
|||||||
clearDetectedFields={this.clearDetectedFields}
|
clearDetectedFields={this.clearDetectedFields}
|
||||||
/>
|
/>
|
||||||
<div className={styles.logsSection}>
|
<div className={styles.logsSection}>
|
||||||
<CustomScrollbar autoHide>
|
<div className={styles.logRows}>
|
||||||
<LogRows
|
<LogRows
|
||||||
logRows={logRows}
|
logRows={logRows}
|
||||||
deduplicatedRows={dedupedRows}
|
deduplicatedRows={dedupedRows}
|
||||||
@ -348,7 +350,7 @@ export class UnthemedLogs extends PureComponent<Props, State> {
|
|||||||
onClickShowDetectedField={this.showDetectedField}
|
onClickShowDetectedField={this.showDetectedField}
|
||||||
onClickHideDetectedField={this.hideDetectedField}
|
onClickHideDetectedField={this.hideDetectedField}
|
||||||
/>
|
/>
|
||||||
</CustomScrollbar>
|
</div>
|
||||||
<LogsNavigation
|
<LogsNavigation
|
||||||
logsSortOrder={logsSortOrder}
|
logsSortOrder={logsSortOrder}
|
||||||
visibleRange={visibleRange}
|
visibleRange={visibleRange}
|
||||||
@ -357,6 +359,7 @@ export class UnthemedLogs extends PureComponent<Props, State> {
|
|||||||
onChangeTime={onChangeTime}
|
onChangeTime={onChangeTime}
|
||||||
loading={loading}
|
loading={loading}
|
||||||
queries={queries}
|
queries={queries}
|
||||||
|
scrollToTopLogs={this.scrollToTopLogs}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
{!loading && !hasData && !scanning && (
|
{!loading && !hasData && !scanning && (
|
||||||
@ -410,7 +413,10 @@ const getStyles = stylesFactory((theme: GrafanaTheme) => {
|
|||||||
logsSection: css`
|
logsSection: css`
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: row;
|
flex-direction: row;
|
||||||
max-height: 95vh;
|
justify-content: space-between;
|
||||||
|
`,
|
||||||
|
logRows: css`
|
||||||
|
overflow-x: scroll;
|
||||||
`,
|
`,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
@ -1,13 +1,11 @@
|
|||||||
import React, { PureComponent } from 'react';
|
import React, { PureComponent } from 'react';
|
||||||
import { hot } from 'react-hot-loader';
|
import { hot } from 'react-hot-loader';
|
||||||
import { connect, ConnectedProps } from 'react-redux';
|
import { connect, ConnectedProps } from 'react-redux';
|
||||||
|
import { css } from 'emotion';
|
||||||
import { Collapse } from '@grafana/ui';
|
import { Collapse } from '@grafana/ui';
|
||||||
|
|
||||||
import { AbsoluteTimeRange, Field, LogRowModel, RawTimeRange } from '@grafana/data';
|
import { AbsoluteTimeRange, Field, LogRowModel, RawTimeRange } from '@grafana/data';
|
||||||
|
|
||||||
import { ExploreId, ExploreItemState } from 'app/types/explore';
|
import { ExploreId, ExploreItemState } from 'app/types/explore';
|
||||||
import { StoreState } from 'app/types';
|
import { StoreState } from 'app/types';
|
||||||
|
|
||||||
import { splitOpen } from './state/main';
|
import { splitOpen } from './state/main';
|
||||||
import { updateTimeRange } from './state/time';
|
import { updateTimeRange } from './state/time';
|
||||||
import { getTimeZone } from '../profile/state/selectors';
|
import { getTimeZone } from '../profile/state/selectors';
|
||||||
@ -85,6 +83,16 @@ export class LogsContainer extends PureComponent<PropsFromRedux & LogsContainerP
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// We need to override css overflow of divs in Collapse element to enable sticky Logs navigation
|
||||||
|
const styleOverridesForStickyNavigation = css`
|
||||||
|
& > div {
|
||||||
|
overflow: visible;
|
||||||
|
& > div {
|
||||||
|
overflow: visible;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<LogsCrossFadeTransition visible={isLive}>
|
<LogsCrossFadeTransition visible={isLive}>
|
||||||
@ -104,7 +112,7 @@ export class LogsContainer extends PureComponent<PropsFromRedux & LogsContainerP
|
|||||||
</Collapse>
|
</Collapse>
|
||||||
</LogsCrossFadeTransition>
|
</LogsCrossFadeTransition>
|
||||||
<LogsCrossFadeTransition visible={!isLive}>
|
<LogsCrossFadeTransition visible={!isLive}>
|
||||||
<Collapse label="Logs" loading={loading} isOpen>
|
<Collapse label="Logs" loading={loading} isOpen className={styleOverridesForStickyNavigation}>
|
||||||
<Logs
|
<Logs
|
||||||
logRows={logRows}
|
logRows={logRows}
|
||||||
logsMeta={logsMeta}
|
logsMeta={logsMeta}
|
||||||
|
@ -3,10 +3,10 @@ import { render, screen } from '@testing-library/react';
|
|||||||
import { LogsSortOrder } from '@grafana/data';
|
import { LogsSortOrder } from '@grafana/data';
|
||||||
import LogsNavigation from './LogsNavigation';
|
import LogsNavigation from './LogsNavigation';
|
||||||
|
|
||||||
type LogsNavigationrProps = ComponentProps<typeof LogsNavigation>;
|
type LogsNavigationProps = ComponentProps<typeof LogsNavigation>;
|
||||||
|
|
||||||
const setup = (propOverrides?: object) => {
|
const setup = (propOverrides?: object) => {
|
||||||
const props: LogsNavigationrProps = {
|
const props: LogsNavigationProps = {
|
||||||
absoluteRange: { from: 1619081645930, to: 1619081945930 },
|
absoluteRange: { from: 1619081645930, to: 1619081945930 },
|
||||||
timeZone: 'local',
|
timeZone: 'local',
|
||||||
queries: [],
|
queries: [],
|
||||||
@ -14,6 +14,7 @@ const setup = (propOverrides?: object) => {
|
|||||||
logsSortOrder: undefined,
|
logsSortOrder: undefined,
|
||||||
visibleRange: { from: 1619081941000, to: 1619081945930 },
|
visibleRange: { from: 1619081941000, to: 1619081945930 },
|
||||||
onChangeTime: jest.fn(),
|
onChangeTime: jest.fn(),
|
||||||
|
scrollToTopLogs: jest.fn(),
|
||||||
...propOverrides,
|
...propOverrides,
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -21,24 +22,41 @@ const setup = (propOverrides?: object) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
describe('LogsNavigation', () => {
|
describe('LogsNavigation', () => {
|
||||||
it('should render fetch logs button on bottom when default logs order', () => {
|
it('should always render 3 navigation buttons', () => {
|
||||||
setup();
|
setup();
|
||||||
expect(screen.getByTestId('fetchLogsBottom')).toBeInTheDocument();
|
expect(screen.getByTestId('newerLogsButton')).toBeInTheDocument();
|
||||||
expect(screen.queryByTestId('fetchLogsTop')).not.toBeInTheDocument();
|
expect(screen.getByTestId('olderLogsButton')).toBeInTheDocument();
|
||||||
|
expect(screen.getByTestId('scrollToTop')).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
it('should render fetch logs button on top when flipped logs order', () => {
|
|
||||||
setup({ logsSortOrder: LogsSortOrder.Ascending });
|
it('should render 3 navigation buttons in correct order when default logs order', () => {
|
||||||
expect(screen.getByTestId('fetchLogsTop')).toBeInTheDocument();
|
const { container } = setup();
|
||||||
expect(screen.queryByTestId('fetchLogsBottom')).not.toBeInTheDocument();
|
const expectedOrder = ['newerLogsButton', 'olderLogsButton', 'scrollToTop'];
|
||||||
|
const elements = container.querySelectorAll(
|
||||||
|
'[data-testid=newerLogsButton],[data-testid=olderLogsButton],[data-testid=scrollToTop]'
|
||||||
|
);
|
||||||
|
expect(Array.from(elements).map((el) => el.getAttribute('data-testid'))).toMatchObject(expectedOrder);
|
||||||
});
|
});
|
||||||
it('should disable button to fetch logs when loading', () => {
|
|
||||||
|
it('should render 3 navigation buttons in correect order when flipped logs order', () => {
|
||||||
|
const { container } = setup({ logsSortOrder: LogsSortOrder.Ascending });
|
||||||
|
const expectedOrder = ['olderLogsButton', 'newerLogsButton', 'scrollToTop'];
|
||||||
|
const elements = container.querySelectorAll(
|
||||||
|
'[data-testid=newerLogsButton],[data-testid=olderLogsButton],[data-testid=scrollToTop]'
|
||||||
|
);
|
||||||
|
expect(Array.from(elements).map((el) => el.getAttribute('data-testid'))).toMatchObject(expectedOrder);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should disable fetch buttons when logs are loading', () => {
|
||||||
setup({ loading: true });
|
setup({ loading: true });
|
||||||
const button = screen.getByTestId('fetchLogsBottom');
|
const olderLogsButton = screen.getByTestId('olderLogsButton');
|
||||||
expect(button).toBeInTheDocument();
|
const newerLogsButton = screen.getByTestId('newerLogsButton');
|
||||||
expect(button).toBeDisabled();
|
expect(olderLogsButton).toBeDisabled();
|
||||||
|
expect(newerLogsButton).toBeDisabled();
|
||||||
});
|
});
|
||||||
it('should render logs page with correct range', () => {
|
|
||||||
|
it('should render logs navigation pages section', () => {
|
||||||
setup();
|
setup();
|
||||||
expect(screen.getByText(/02:59:05 — 02:59:01/i)).toBeInTheDocument();
|
expect(screen.getByTestId('logsNavigationPages')).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
@ -1,17 +1,9 @@
|
|||||||
import React, { memo, useState, useEffect, useRef } from 'react';
|
import React, { memo, useState, useEffect, useRef } from 'react';
|
||||||
import classNames from 'classnames';
|
|
||||||
import { isEqual } from 'lodash';
|
import { isEqual } from 'lodash';
|
||||||
import { css } from 'emotion';
|
import { css } from 'emotion';
|
||||||
import {
|
import { LogsSortOrder, AbsoluteTimeRange, TimeZone, DataQuery, GrafanaTheme } from '@grafana/data';
|
||||||
LogsSortOrder,
|
import { Button, Icon, Spinner, useTheme, stylesFactory } from '@grafana/ui';
|
||||||
AbsoluteTimeRange,
|
import { LogsNavigationPages } from './LogsNavigationPages';
|
||||||
dateTimeFormat,
|
|
||||||
systemDateFormats,
|
|
||||||
TimeZone,
|
|
||||||
DataQuery,
|
|
||||||
GrafanaTheme,
|
|
||||||
} from '@grafana/data';
|
|
||||||
import { Button, Icon, Spinner, useTheme, stylesFactory, CustomScrollbar } from '@grafana/ui';
|
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
absoluteRange: AbsoluteTimeRange;
|
absoluteRange: AbsoluteTimeRange;
|
||||||
@ -21,9 +13,10 @@ type Props = {
|
|||||||
visibleRange?: AbsoluteTimeRange;
|
visibleRange?: AbsoluteTimeRange;
|
||||||
logsSortOrder?: LogsSortOrder | null;
|
logsSortOrder?: LogsSortOrder | null;
|
||||||
onChangeTime: (range: AbsoluteTimeRange) => void;
|
onChangeTime: (range: AbsoluteTimeRange) => void;
|
||||||
|
scrollToTopLogs: () => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
type LogsPage = {
|
export type LogsPage = {
|
||||||
logsRange: AbsoluteTimeRange;
|
logsRange: AbsoluteTimeRange;
|
||||||
queryRange: AbsoluteTimeRange;
|
queryRange: AbsoluteTimeRange;
|
||||||
};
|
};
|
||||||
@ -34,6 +27,7 @@ function LogsNavigation({
|
|||||||
timeZone,
|
timeZone,
|
||||||
loading,
|
loading,
|
||||||
onChangeTime,
|
onChangeTime,
|
||||||
|
scrollToTopLogs,
|
||||||
visibleRange = absoluteRange,
|
visibleRange = absoluteRange,
|
||||||
queries = [],
|
queries = [],
|
||||||
}: Props) {
|
}: Props) {
|
||||||
@ -47,6 +41,12 @@ function LogsNavigation({
|
|||||||
// e.g. if last 5 min selected, always run 5 min range
|
// e.g. if last 5 min selected, always run 5 min range
|
||||||
const rangeSpanRef = useRef(0);
|
const rangeSpanRef = useRef(0);
|
||||||
|
|
||||||
|
const oldestLogsFirst = logsSortOrder === LogsSortOrder.Ascending;
|
||||||
|
const onFirstPage = currentPageIndex === 0;
|
||||||
|
const onLastPage = currentPageIndex === pages.length - 1;
|
||||||
|
const theme = useTheme();
|
||||||
|
const styles = getStyles(theme, oldestLogsFirst, loading);
|
||||||
|
|
||||||
// Main effect to set pages and index
|
// Main effect to set pages and index
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const newPage = { logsRange: visibleRange, queryRange: absoluteRange };
|
const newPage = { logsRange: visibleRange, queryRange: absoluteRange };
|
||||||
@ -86,85 +86,77 @@ function LogsNavigation({
|
|||||||
return a.queryRange.to > b.queryRange.to ? -1 : 1;
|
return a.queryRange.to > b.queryRange.to ? -1 : 1;
|
||||||
};
|
};
|
||||||
|
|
||||||
const formatTime = (time: number) => {
|
const olderLogsButton = (
|
||||||
return `${dateTimeFormat(time, {
|
<Button
|
||||||
format: systemDateFormats.interval.second,
|
data-testid="olderLogsButton"
|
||||||
timeZone: timeZone,
|
className={styles.navButton}
|
||||||
})}`;
|
variant="secondary"
|
||||||
};
|
onClick={() => {
|
||||||
|
//If we are not on the last page, use next page's range
|
||||||
const createPageContent = (page: LogsPage, index: number) => {
|
if (!onLastPage) {
|
||||||
if (currentPageIndex === index && loading) {
|
changeTime({
|
||||||
return <Spinner />;
|
from: pages[currentPageIndex + 1].queryRange.from,
|
||||||
|
to: pages[currentPageIndex + 1].queryRange.to,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
const topContent = formatTime(oldestLogsFirst ? page.logsRange.from : page.logsRange.to);
|
//If we are on the last page, create new range
|
||||||
const bottomContent = formatTime(oldestLogsFirst ? page.logsRange.to : page.logsRange.from);
|
changeTime({ from: visibleRange.from - rangeSpanRef.current, to: visibleRange.from });
|
||||||
return `${topContent} — ${bottomContent}`;
|
}}
|
||||||
};
|
disabled={loading}
|
||||||
|
>
|
||||||
|
<div className={styles.navButtonContent}>
|
||||||
|
{loading ? <Spinner /> : <Icon name={oldestLogsFirst ? 'angle-up' : 'angle-down'} size="lg" />}
|
||||||
|
Older logs
|
||||||
|
</div>
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
|
||||||
|
const newerLogsButton = (
|
||||||
|
<Button
|
||||||
|
data-testid="newerLogsButton"
|
||||||
|
className={styles.navButton}
|
||||||
|
variant="secondary"
|
||||||
|
onClick={() => {
|
||||||
|
//If we are not on the first page, use previous page's range
|
||||||
|
if (!onFirstPage) {
|
||||||
|
changeTime({
|
||||||
|
from: pages[currentPageIndex - 1].queryRange.from,
|
||||||
|
to: pages[currentPageIndex - 1].queryRange.to,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
//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 ? 'Start of range' : 'Newer logs'}
|
||||||
|
</div>
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
|
||||||
const oldestLogsFirst = logsSortOrder === LogsSortOrder.Ascending;
|
|
||||||
const theme = useTheme();
|
|
||||||
const styles = getStyles(theme, oldestLogsFirst, loading);
|
|
||||||
return (
|
return (
|
||||||
<div className={styles.navContainer}>
|
<div className={styles.navContainer}>
|
||||||
{/*
|
{oldestLogsFirst ? olderLogsButton : newerLogsButton}
|
||||||
* We are going to have 2 buttons - on the top and bottom - Oldest and Newest.
|
<LogsNavigationPages
|
||||||
* Therefore I have at the moment duplicated the same code, but in the future iteration, it ill be updated
|
pages={pages}
|
||||||
*/}
|
currentPageIndex={currentPageIndex}
|
||||||
{oldestLogsFirst && (
|
oldestLogsFirst={oldestLogsFirst}
|
||||||
|
timeZone={timeZone}
|
||||||
|
loading={loading}
|
||||||
|
changeTime={changeTime}
|
||||||
|
/>
|
||||||
|
{oldestLogsFirst ? newerLogsButton : olderLogsButton}
|
||||||
<Button
|
<Button
|
||||||
data-testid="fetchLogsTop"
|
data-testid="scrollToTop"
|
||||||
className={styles.navButton}
|
className={styles.scrollToTopButton}
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
onClick={() => {
|
onClick={scrollToTopLogs}
|
||||||
// the range is based on initally selected range
|
title="Scroll to top"
|
||||||
changeTime({ from: visibleRange.from - rangeSpanRef.current, to: visibleRange.from });
|
|
||||||
}}
|
|
||||||
disabled={loading}
|
|
||||||
>
|
>
|
||||||
<div className={styles.navButtonContent}>
|
<Icon name="arrow-up" size="lg" />
|
||||||
{loading ? <Spinner /> : <Icon name="angle-up" size="lg" />}
|
|
||||||
Older logs
|
|
||||||
</div>
|
|
||||||
</Button>
|
</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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -178,6 +170,9 @@ const getStyles = stylesFactory((theme: GrafanaTheme, oldestLogsFirst: boolean,
|
|||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
justify-content: ${oldestLogsFirst ? 'flex-start' : 'space-between'};
|
justify-content: ${oldestLogsFirst ? 'flex-start' : 'space-between'};
|
||||||
|
position: sticky;
|
||||||
|
top: ${theme.spacing.md};
|
||||||
|
right: 0;
|
||||||
`,
|
`,
|
||||||
navButton: css`
|
navButton: css`
|
||||||
width: 58px;
|
width: 58px;
|
||||||
@ -197,55 +192,14 @@ const getStyles = stylesFactory((theme: GrafanaTheme, oldestLogsFirst: boolean,
|
|||||||
height: 100%;
|
height: 100%;
|
||||||
white-space: normal;
|
white-space: normal;
|
||||||
`,
|
`,
|
||||||
pagesWrapper: css`
|
scrollToTopButton: css`
|
||||||
height: 100%;
|
width: 40px;
|
||||||
padding-left: ${theme.spacing.xs};
|
height: 40px;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
overflow-y: scroll;
|
justify-content: center;
|
||||||
`,
|
|
||||||
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;
|
align-items: center;
|
||||||
background: ${theme.colors.textWeak};
|
margin-top: ${theme.spacing.sm};
|
||||||
`,
|
|
||||||
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;
|
|
||||||
`,
|
`,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
45
public/app/features/explore/LogsNavigationPages.test.tsx
Normal file
45
public/app/features/explore/LogsNavigationPages.test.tsx
Normal file
@ -0,0 +1,45 @@
|
|||||||
|
import React, { ComponentProps } from 'react';
|
||||||
|
import { render, screen } from '@testing-library/react';
|
||||||
|
import { LogsNavigationPages } from './LogsNavigationPages';
|
||||||
|
|
||||||
|
type LogsNavigationPagesProps = ComponentProps<typeof LogsNavigationPages>;
|
||||||
|
|
||||||
|
const setup = (propOverrides?: object) => {
|
||||||
|
const props: LogsNavigationPagesProps = {
|
||||||
|
pages: [
|
||||||
|
{
|
||||||
|
logsRange: { from: 1619081941000, to: 1619081945930 },
|
||||||
|
queryRange: { from: 1619081645930, to: 1619081945930 },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
logsRange: { from: 1619081951000, to: 1619081955930 },
|
||||||
|
queryRange: { from: 1619081655930, to: 1619081955930 },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
currentPageIndex: 0,
|
||||||
|
oldestLogsFirst: false,
|
||||||
|
timeZone: 'local',
|
||||||
|
loading: false,
|
||||||
|
changeTime: jest.fn(),
|
||||||
|
...propOverrides,
|
||||||
|
};
|
||||||
|
|
||||||
|
return render(<LogsNavigationPages {...props} />);
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('LogsNavigationPages', () => {
|
||||||
|
it('should render logs navigation pages', () => {
|
||||||
|
setup();
|
||||||
|
expect(screen.getByTestId('logsNavigationPages')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
it('should render logs pages with correct range if normal order', () => {
|
||||||
|
setup();
|
||||||
|
expect(screen.getByText(/02:59:05 — 02:59:01/i)).toBeInTheDocument();
|
||||||
|
expect(screen.getByText(/02:59:15 — 02:59:11/i)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
it('should render logs pages with correct range if flipped order', () => {
|
||||||
|
setup({ oldestLogsFirst: true });
|
||||||
|
expect(screen.getByText(/02:59:11 — 02:59:15/i)).toBeInTheDocument();
|
||||||
|
expect(screen.getByText(/02:59:01 — 02:59:05/i)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
121
public/app/features/explore/LogsNavigationPages.tsx
Normal file
121
public/app/features/explore/LogsNavigationPages.tsx
Normal file
@ -0,0 +1,121 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { css, cx } from 'emotion';
|
||||||
|
import { dateTimeFormat, systemDateFormats, TimeZone, GrafanaTheme, AbsoluteTimeRange } from '@grafana/data';
|
||||||
|
import { useTheme, stylesFactory, CustomScrollbar, Spinner } from '@grafana/ui';
|
||||||
|
import { LogsPage } from './LogsNavigation';
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
pages: LogsPage[];
|
||||||
|
currentPageIndex: number;
|
||||||
|
oldestLogsFirst: boolean;
|
||||||
|
timeZone: TimeZone;
|
||||||
|
loading: boolean;
|
||||||
|
changeTime: (range: AbsoluteTimeRange) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function LogsNavigationPages({
|
||||||
|
pages,
|
||||||
|
currentPageIndex,
|
||||||
|
oldestLogsFirst,
|
||||||
|
timeZone,
|
||||||
|
loading,
|
||||||
|
changeTime,
|
||||||
|
}: Props) {
|
||||||
|
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 theme = useTheme();
|
||||||
|
const styles = getStyles(theme, loading);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<CustomScrollbar autoHide>
|
||||||
|
<div className={styles.pagesWrapper} data-testid="logsNavigationPages">
|
||||||
|
<div className={styles.pagesContainer}>
|
||||||
|
{pages.map((page: LogsPage, index: number) => (
|
||||||
|
<div
|
||||||
|
data-testid={`page${index + 1}`}
|
||||||
|
className={styles.page}
|
||||||
|
key={page.queryRange.to}
|
||||||
|
onClick={() => !loading && changeTime({ from: page.queryRange.from, to: page.queryRange.to })}
|
||||||
|
>
|
||||||
|
<div className={cx(styles.line, { selectedBg: currentPageIndex === index })} />
|
||||||
|
<div className={cx(styles.time, { selectedText: currentPageIndex === index })}>
|
||||||
|
{createPageContent(page, index)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CustomScrollbar>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const getStyles = stylesFactory((theme: GrafanaTheme, loading: boolean) => {
|
||||||
|
return {
|
||||||
|
pagesWrapper: css`
|
||||||
|
height: 100%;
|
||||||
|
padding-left: ${theme.spacing.xs};
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow-y: scroll;
|
||||||
|
&::after {
|
||||||
|
content: '';
|
||||||
|
display: block;
|
||||||
|
background: repeating-linear-gradient(
|
||||||
|
135deg,
|
||||||
|
${theme.colors.bg1},
|
||||||
|
${theme.colors.bg1} 5px,
|
||||||
|
${theme.colors.bg2} 5px,
|
||||||
|
${theme.colors.bg2} 15px
|
||||||
|
);
|
||||||
|
width: 3px;
|
||||||
|
height: inherit;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
`,
|
||||||
|
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;
|
||||||
|
`,
|
||||||
|
};
|
||||||
|
});
|
Loading…
Reference in New Issue
Block a user