Explore: Update live tail buttons (#19143)

This commit is contained in:
Andrej Ocenas
2019-09-17 11:25:12 +02:00
committed by GitHub
parent 494b4aaf88
commit 359404eb77
13 changed files with 365 additions and 138 deletions

View File

@@ -26,6 +26,7 @@
"@types/enzyme-adapter-react-16": "1.0.5",
"@types/expect-puppeteer": "3.3.1",
"@types/file-saver": "2.0.1",
"@types/hoist-non-react-statics": "3.3.0",
"@types/is-hotkey": "0.1.1",
"@types/jest": "24.0.13",
"@types/jquery": "1.10.35",
@@ -208,6 +209,7 @@
"eventemitter3": "2.0.3",
"fast-text-encoding": "^1.0.0",
"file-saver": "1.3.8",
"hoist-non-react-statics": "3.3.0",
"immutable": "3.8.2",
"is-hotkey": "0.1.4",
"jquery": "3.4.1",

View File

@@ -1,24 +1,48 @@
import React, { PureComponent } from 'react';
import classNames from 'classnames';
import { SelectableValue } from '@grafana/data';
import { css } from 'emotion';
import { Tooltip } from '../Tooltip/Tooltip';
import { ButtonSelect } from '../Select/ButtonSelect';
import memoizeOne from 'memoize-one';
import { GrafanaTheme } from '../../types';
import { withTheme } from '../../themes';
export const offOption = { label: 'Off', value: '' };
export const liveOption = { label: 'Live', value: 'LIVE' };
export const defaultIntervals = ['5s', '10s', '30s', '1m', '5m', '15m', '30m', '1h', '2h', '1d'];
export const isLive = (refreshInterval: string): boolean => refreshInterval === liveOption.value;
const getStyles = memoizeOne((theme: GrafanaTheme) => {
return {
selectButton: css`
label: selectButton;
.select-button-value {
color: ${theme.colors.orange};
}
`,
};
});
export interface Props {
intervals?: string[];
onRefresh: () => any;
onRefresh?: () => any;
onIntervalChanged: (interval: string) => void;
value?: string;
tooltip: string;
tooltip?: string;
hasLiveOption?: boolean;
// You can supply your own refresh button element. In that case onRefresh and tooltip are ignored.
refreshButton?: React.ReactNode;
buttonSelectClassName?: string;
theme: GrafanaTheme;
}
export class RefreshPicker extends PureComponent<Props> {
export class RefreshPickerBase extends PureComponent<Props> {
// Make it exported as static properties to be easier to access. The global exports need to be accessed by direct
// import of this source file which won't work if this was installed as package.
static offOption = offOption;
static liveOption = liveOption;
constructor(props: Props) {
super(props);
}
@@ -46,10 +70,11 @@ export class RefreshPicker extends PureComponent<Props> {
};
render() {
const { onRefresh, intervals, tooltip, value } = this.props;
const { onRefresh, intervals, tooltip, value, refreshButton, buttonSelectClassName, theme } = this.props;
const options = this.intervalsToOptions(intervals);
const currentValue = value || '';
const selectedValue = options.find(item => item.value === currentValue) || offOption;
const styles = getStyles(theme);
const cssClasses = classNames({
'refresh-picker': true,
@@ -60,13 +85,20 @@ export class RefreshPicker extends PureComponent<Props> {
return (
<div className={cssClasses}>
<div className="refresh-picker-buttons">
<Tooltip placement="top" content={tooltip}>
<button className="btn btn--radius-right-0 navbar-button navbar-button--border-right-0" onClick={onRefresh}>
<i className="fa fa-refresh" />
</button>
</Tooltip>
{refreshButton ? (
refreshButton
) : (
<Tooltip placement="top" content={tooltip!}>
<button
className="btn btn--radius-right-0 navbar-button navbar-button--border-right-0"
onClick={onRefresh!}
>
<i className="fa fa-refresh" />
</button>
</Tooltip>
)}
<ButtonSelect
className="navbar-button--attached btn--radius-left-0$"
className={classNames('navbar-button--attached', styles.selectButton, buttonSelectClassName)}
value={selectedValue}
label={selectedValue.label}
options={options}
@@ -78,3 +110,11 @@ export class RefreshPicker extends PureComponent<Props> {
);
}
}
export const RefreshPicker = withTheme<
Props,
{
offOption: typeof RefreshPickerBase.offOption;
liveOption: typeof RefreshPickerBase.liveOption;
}
>(RefreshPickerBase);

View File

@@ -20,10 +20,6 @@
width: 100%;
}
.select-button-value {
color: $orange;
}
&--off {
.select-button-value {
display: none;

View File

@@ -1,4 +1,6 @@
import React from 'react';
import React, { useContext } from 'react';
import hoistNonReactStatics from 'hoist-non-react-statics';
import { getTheme } from './getTheme';
import { GrafanaThemeType, Themeable } from '../types/theme';
@@ -8,13 +10,18 @@ type Subtract<T, K> = Omit<T, keyof K>;
// Use Grafana Dark theme by default
export const ThemeContext = React.createContext(getTheme(GrafanaThemeType.Dark));
export const withTheme = <P extends Themeable>(Component: React.ComponentType<P>) => {
export const withTheme = <P extends Themeable, S extends {} = {}>(Component: React.ComponentType<P>) => {
const WithTheme: React.FunctionComponent<Subtract<P, Themeable>> = props => {
// @ts-ignore
return <ThemeContext.Consumer>{theme => <Component {...props} theme={theme} />}</ThemeContext.Consumer>;
};
WithTheme.displayName = `WithTheme(${Component.displayName})`;
return WithTheme;
hoistNonReactStatics(WithTheme, Component);
type Hoisted = typeof WithTheme & S;
return WithTheme as Hoisted;
};
export function useTheme() {
return useContext(ThemeContext);
}

View File

@@ -1,5 +1,5 @@
import { ThemeContext, withTheme } from './ThemeContext';
import { ThemeContext, withTheme, useTheme } from './ThemeContext';
import { getTheme, mockTheme } from './getTheme';
import { selectThemeVariant } from './selectThemeVariant';
export { ThemeContext, withTheme, mockTheme, getTheme, selectThemeVariant };
export { ThemeContext, withTheme, mockTheme, getTheme, selectThemeVariant, useTheme };

View File

@@ -8,7 +8,7 @@ import { TimeRange, TimeOption, TimeZone, RawTimeRange, dateTimeForTimeZone } fr
// State
// Components
import { TimePicker, RefreshPicker, SetInterval } from '@grafana/ui';
import { TimePicker } from '@grafana/ui';
// Utils & Services
import { defaultSelectOptions } from '@grafana/ui/src/components/TimePicker/TimePicker';
@@ -16,14 +16,8 @@ import { getShiftedTimeRange, getZoomedTimeRange } from 'app/core/utils/timePick
export interface Props {
exploreId: ExploreId;
hasLiveOption: boolean;
isLive: boolean;
loading: boolean;
range: TimeRange;
refreshInterval: string;
timeZone: TimeZone;
onRunQuery: () => void;
onChangeRefreshInterval: (interval: string) => void;
onChangeTime: (range: RawTimeRange) => void;
}
@@ -73,40 +67,18 @@ export class ExploreTimeControls extends Component<Props> {
};
render() {
const {
hasLiveOption,
isLive,
loading,
range,
refreshInterval,
timeZone,
onRunQuery,
onChangeRefreshInterval,
} = this.props;
const { range, timeZone } = this.props;
return (
<>
{!isLive && (
<TimePicker
value={range}
onChange={this.onChangeTimePicker}
timeZone={timeZone}
onMoveBackward={this.onMoveBack}
onMoveForward={this.onMoveForward}
onZoom={this.onZoom}
selectOptions={this.setActiveTimeOption(defaultSelectOptions, range.raw)}
/>
)}
<RefreshPicker
onIntervalChanged={onChangeRefreshInterval}
onRefresh={onRunQuery}
value={refreshInterval}
tooltip="Refresh"
hasLiveOption={hasLiveOption}
/>
{refreshInterval && <SetInterval func={onRunQuery} interval={refreshInterval} loading={loading} />}
</>
<TimePicker
value={range}
onChange={this.onChangeTimePicker}
timeZone={timeZone}
onMoveBackward={this.onMoveBack}
onMoveForward={this.onMoveForward}
onZoom={this.onZoom}
selectOptions={this.setActiveTimeOption(defaultSelectOptions, range.raw)}
/>
);
}
}

View File

@@ -5,8 +5,17 @@ import { hot } from 'react-hot-loader';
import memoizeOne from 'memoize-one';
import classNames from 'classnames';
import { ExploreId, ExploreMode } from 'app/types/explore';
import { DataSourceSelectItem, ToggleButtonGroup, ToggleButton, DataQuery, Tooltip, ButtonSelect } from '@grafana/ui';
import { ExploreId, ExploreItemState, ExploreMode } from 'app/types/explore';
import {
DataSourceSelectItem,
ToggleButtonGroup,
ToggleButton,
DataQuery,
Tooltip,
ButtonSelect,
RefreshPicker,
SetInterval,
} from '@grafana/ui';
import { RawTimeRange, TimeZone, TimeRange, SelectableValue } from '@grafana/data';
import { DataSourcePicker } from 'app/core/components/Select/DataSourcePicker';
import { StoreState } from 'app/types/store';
@@ -20,44 +29,15 @@ import {
changeMode,
clearOrigin,
} from './state/actions';
import { changeRefreshIntervalAction, setPausedStateAction } from './state/actionTypes';
import { updateLocation } from 'app/core/actions';
import { getTimeZone } from '../profile/state/selectors';
import { getDashboardSrv } from '../dashboard/services/DashboardSrv';
import kbn from '../../core/utils/kbn';
import { ExploreTimeControls } from './ExploreTimeControls';
enum IconSide {
left = 'left',
right = 'right',
}
const createResponsiveButton = (options: {
splitted: boolean;
title: string;
onClick: () => void;
buttonClassName?: string;
iconClassName?: string;
iconSide?: IconSide;
disabled?: boolean;
}) => {
const defaultOptions = {
iconSide: IconSide.left,
};
const props = { ...options, defaultOptions };
const { title, onClick, buttonClassName, iconClassName, splitted, iconSide, disabled } = props;
return (
<button
className={`btn navbar-button ${buttonClassName ? buttonClassName : ''}`}
onClick={onClick}
disabled={disabled || false}
>
{iconClassName && iconSide === IconSide.left ? <i className={`${iconClassName}`} /> : null}
<span className="btn-title">{!splitted ? title : ''}</span>
{iconClassName && iconSide === IconSide.right ? <i className={`${iconClassName}`} /> : null}
</button>
);
};
import { LiveTailButton } from './LiveTailButton';
import { ResponsiveButton } from './ResponsiveButton';
import { RunButton } from './RunButton';
interface OwnProps {
exploreId: ExploreId;
@@ -77,6 +57,7 @@ interface StateProps {
selectedModeOption: SelectableValue<ExploreMode>;
hasLiveOption: boolean;
isLive: boolean;
isPaused: boolean;
originPanelId: number;
queries: DataQuery[];
}
@@ -91,6 +72,8 @@ interface DispatchProps {
changeMode: typeof changeMode;
clearOrigin: typeof clearOrigin;
updateLocation: typeof updateLocation;
changeRefreshIntervalAction: typeof changeRefreshIntervalAction;
setPausedStateAction: typeof setPausedStateAction;
}
type Props = StateProps & DispatchProps & OwnProps;
@@ -147,6 +130,28 @@ export class UnConnectedExploreToolbar extends PureComponent<Props, {}> {
});
};
stopLive = () => {
const { exploreId } = this.props;
// TODO referencing this from perspective of refresh picker when there is designated button for it now is not
// great. Needs another refactor.
this.props.changeRefreshIntervalAction({ exploreId, refreshInterval: RefreshPicker.offOption.value });
};
startLive = () => {
const { exploreId } = this.props;
this.props.changeRefreshIntervalAction({ exploreId, refreshInterval: RefreshPicker.liveOption.value });
};
pauseLive = () => {
const { exploreId } = this.props;
this.props.setPausedStateAction({ exploreId, isPaused: true });
};
resumeLive = () => {
const { exploreId } = this.props;
this.props.setPausedStateAction({ exploreId, isPaused: false });
};
render() {
const {
datasourceMissing,
@@ -165,6 +170,7 @@ export class UnConnectedExploreToolbar extends PureComponent<Props, {}> {
selectedModeOption,
hasLiveOption,
isLive,
isPaused,
originPanelId,
} = this.props;
@@ -249,30 +255,25 @@ export class UnConnectedExploreToolbar extends PureComponent<Props, {}> {
{exploreId === 'left' && !splitted ? (
<div className="explore-toolbar-content-item">
{createResponsiveButton({
splitted,
title: 'Split',
onClick: split,
iconClassName: 'fa fa-fw fa-columns icon-margin-right',
iconSide: IconSide.left,
disabled: isLive,
})}
<ResponsiveButton
splitted={splitted}
title="Split"
onClick={split}
iconClassName="fa fa-fw fa-columns icon-margin-right"
disabled={isLive}
/>
</div>
) : null}
<div className="explore-toolbar-content-item">
<ExploreTimeControls
exploreId={exploreId}
hasLiveOption={hasLiveOption}
isLive={isLive}
loading={loading}
range={range}
refreshInterval={refreshInterval}
timeZone={timeZone}
onChangeTime={onChangeTime}
onChangeRefreshInterval={this.onChangeRefreshInterval}
onRunQuery={this.onRunQuery}
/>
</div>
{!isLive && (
<div className="explore-toolbar-content-item">
<ExploreTimeControls
exploreId={exploreId}
range={range}
timeZone={timeZone}
onChangeTime={onChangeTime}
/>
</div>
)}
<div className="explore-toolbar-content-item">
<button className="btn navbar-button" onClick={this.onClearAll}>
@@ -280,16 +281,27 @@ export class UnConnectedExploreToolbar extends PureComponent<Props, {}> {
</button>
</div>
<div className="explore-toolbar-content-item">
{createResponsiveButton({
splitted,
title: 'Run Query',
onClick: this.onRunQuery,
buttonClassName: 'navbar-button--secondary',
iconClassName:
loading && !isLive ? 'fa fa-spinner fa-fw fa-spin run-icon' : 'fa fa-level-down fa-fw run-icon',
iconSide: IconSide.right,
})}
<RunButton
refreshInterval={refreshInterval}
onChangeRefreshInterval={this.onChangeRefreshInterval}
splitted={splitted}
loading={loading || (isLive && !isPaused)}
onRun={this.onRunQuery}
showDropdown={!isLive}
/>
{refreshInterval && <SetInterval func={this.onRunQuery} interval={refreshInterval} loading={loading} />}
</div>
{hasLiveOption && (
<LiveTailButton
isLive={isLive}
isPaused={isPaused}
start={this.startLive}
pause={this.pauseLive}
resume={this.resumeLive}
stop={this.stopLive}
/>
)}
</div>
</div>
</div>
@@ -334,7 +346,7 @@ const getModeOptionsMemoized = memoizeOne(
const mapStateToProps = (state: StoreState, { exploreId }: OwnProps): StateProps => {
const splitted = state.explore.split;
const exploreItem = state.explore[exploreId];
const exploreItem: ExploreItemState = state.explore[exploreId];
const {
datasourceInstance,
datasourceMissing,
@@ -345,6 +357,7 @@ const mapStateToProps = (state: StoreState, { exploreId }: OwnProps): StateProps
supportedModes,
mode,
isLive,
isPaused,
originPanelId,
queries,
} = exploreItem;
@@ -369,6 +382,7 @@ const mapStateToProps = (state: StoreState, { exploreId }: OwnProps): StateProps
selectedModeOption,
hasLiveOption,
isLive,
isPaused,
originPanelId,
queries,
};
@@ -384,6 +398,8 @@ const mapDispatchToProps: DispatchProps = {
split: splitOpen,
changeMode: changeMode,
clearOrigin,
changeRefreshIntervalAction,
setPausedStateAction,
};
export const ExploreToolbar = hot(module)(

View File

@@ -0,0 +1,104 @@
import React from 'react';
import classNames from 'classnames';
import { css } from 'emotion';
import memoizeOne from 'memoize-one';
import { GrafanaTheme, GrafanaThemeType, useTheme } from '@grafana/ui';
import tinycolor from 'tinycolor2';
const orangeDark = '#FF780A';
const orangeDarkLighter = tinycolor(orangeDark)
.lighten(10)
.toString();
const orangeLight = '#ED5700';
const orangeLightLighter = tinycolor(orangeLight)
.lighten(10)
.toString();
const getStyles = memoizeOne((theme: GrafanaTheme) => {
const orange = theme.type === GrafanaThemeType.Dark ? orangeDark : orangeLight;
const orangeLighter = theme.type === GrafanaThemeType.Dark ? orangeDarkLighter : orangeLightLighter;
const textColor = theme.type === GrafanaThemeType.Dark ? theme.colors.white : theme.colors.black;
return {
noRightBorderStyle: css`
label: noRightBorderStyle;
border-right: 0;
`,
isLive: css`
label: isLive;
border-color: ${orange};
color: ${orange};
background: transparent;
&:focus {
border-color: ${orange};
color: ${orange};
}
&:active,
&:hover {
border-color: ${orangeLighter};
color: ${orangeLighter};
}
`,
isPaused: css`
label: isPaused;
border-color: ${orange};
background: transparent;
animation: pulse 2s ease-out 0s infinite normal forwards;
&:focus {
border-color: ${orange};
}
&:active,
&:hover {
border-color: ${orangeLighter};
}
@keyframes pulse {
0% {
color: ${textColor};
}
50% {
color: ${orange};
}
100% {
color: ${textColor};
}
}
`,
};
});
type LiveTailButtonProps = {
start: () => void;
stop: () => void;
pause: () => void;
resume: () => void;
isLive: boolean;
isPaused: boolean;
};
export function LiveTailButton(props: LiveTailButtonProps) {
const { start, pause, resume, isLive, isPaused, stop } = props;
const theme = useTheme();
const styles = getStyles(theme);
const onClickMain = isLive ? (isPaused ? resume : pause) : start;
return (
<div className="explore-toolbar-content-item">
<button
className={classNames('btn navbar-button', {
[`btn--radius-right-0 ${styles.noRightBorderStyle}`]: isLive,
[styles.isLive]: isLive && !isPaused,
[styles.isPaused]: isLive && isPaused,
})}
onClick={onClickMain}
>
<i className={classNames('fa', isPaused || !isLive ? 'fa-play' : 'fa-pause')} />
&nbsp; Live tailing
</button>
{isLive && (
<button className={`btn navbar-button navbar-button--attached ${styles.isLive}`} onClick={stop}>
<i className={'fa fa-stop'} />
</button>
)}
</div>
);
}

View File

@@ -1,7 +1,7 @@
import React, { PureComponent } from 'react';
import { hot } from 'react-hot-loader';
import { connect } from 'react-redux';
import { DataSourceApi, Collapse } from '@grafana/ui';
import { DataSourceApi, Collapse, RefreshPicker } from '@grafana/ui';
import {
RawTimeRange,
@@ -26,7 +26,6 @@ import {
import { deduplicatedLogsSelector, exploreItemUIStateSelector } from 'app/features/explore/state/selectors';
import { getTimeZone } from '../profile/state/selectors';
import { LiveLogsWithTheme } from './LiveLogs';
import { offOption } from '@grafana/ui/src/components/RefreshPicker/RefreshPicker';
import { Logs } from './Logs';
interface LogsContainerProps {
@@ -65,7 +64,7 @@ export class LogsContainer extends PureComponent<LogsContainerProps> {
onStopLive = () => {
const { exploreId } = this.props;
this.props.stopLive({ exploreId, refreshInterval: offOption.value });
this.props.stopLive({ exploreId, refreshInterval: RefreshPicker.offOption.value });
};
onPause = () => {

View File

@@ -0,0 +1,36 @@
import React from 'react';
export enum IconSide {
left = 'left',
right = 'right',
}
type Props = {
splitted: boolean;
title: string;
onClick: () => void;
buttonClassName?: string;
iconClassName?: string;
iconSide?: IconSide;
disabled?: boolean;
};
export const ResponsiveButton = (props: Props) => {
const defaultProps = {
iconSide: IconSide.left,
};
props = { ...defaultProps, ...props };
const { title, onClick, buttonClassName, iconClassName, splitted, iconSide, disabled } = props;
return (
<button
className={`btn navbar-button ${buttonClassName ? buttonClassName : ''}`}
onClick={onClick}
disabled={disabled || false}
>
{iconClassName && iconSide === IconSide.left ? <i className={`${iconClassName}`} /> : null}
<span className="btn-title">{!splitted ? title : ''}</span>
{iconClassName && iconSide === IconSide.right ? <i className={`${iconClassName}`} /> : null}
</button>
);
};

View File

@@ -0,0 +1,52 @@
import React from 'react';
import { RefreshPicker } from '@grafana/ui';
import memoizeOne from 'memoize-one';
import { css } from 'emotion';
import { ResponsiveButton } from './ResponsiveButton';
const getStyles = memoizeOne(() => {
return {
selectButtonOverride: css`
label: selectButtonOverride;
.select-button-value {
color: white !important;
}
`,
};
});
type Props = {
splitted: boolean;
loading: boolean;
onRun: () => void;
refreshInterval: string;
onChangeRefreshInterval: (interval: string) => void;
showDropdown: boolean;
};
export function RunButton(props: Props) {
const { splitted, loading, onRun, onChangeRefreshInterval, refreshInterval, showDropdown } = props;
const styles = getStyles();
const runButton = (
<ResponsiveButton
splitted={splitted}
title="Run Query"
onClick={onRun}
buttonClassName="navbar-button--secondary btn--radius-right-0 "
iconClassName={loading ? 'fa fa-spinner fa-fw fa-spin run-icon' : 'fa fa-refresh fa-fw'}
/>
);
if (showDropdown) {
return (
<RefreshPicker
onIntervalChanged={onChangeRefreshInterval}
value={refreshInterval}
buttonSelectClassName={`navbar-button--secondary ${styles.selectButtonOverride}`}
refreshButton={runButton}
/>
);
}
return runButton;
}

View File

@@ -6,10 +6,6 @@
margin-left: 0.25em;
}
.run-icon {
transform: rotate(90deg);
}
.datasource-picker {
.ds-picker {
min-width: 200px;

View File

@@ -3048,6 +3048,13 @@
"@types/minimatch" "*"
"@types/node" "*"
"@types/hoist-non-react-statics@3.3.0":
version "3.3.0"
resolved "https://registry.yarnpkg.com/@types/hoist-non-react-statics/-/hoist-non-react-statics-3.3.0.tgz#a59c0c995cc885bef1b8ec2241b114f9b35b517b"
integrity sha512-O2OGyW9wlO2bbDmZRH17MecArQfsIa1g//ve2IJk6BnmwEglFz5kdhP1BlgeqjVNH5IHIhsc83DWFo8StCe8+Q==
dependencies:
"@types/react" "*"
"@types/hoist-non-react-statics@^3.3.0":
version "3.3.1"
resolved "https://registry.yarnpkg.com/@types/hoist-non-react-statics/-/hoist-non-react-statics-3.3.1.tgz#1124aafe5118cb591977aeb1ceaaed1070eb039f"
@@ -9246,16 +9253,16 @@ hmac-drbg@^1.0.0:
minimalistic-assert "^1.0.0"
minimalistic-crypto-utils "^1.0.1"
hoist-non-react-statics@^2.3.1:
version "2.5.5"
resolved "https://registry.yarnpkg.com/hoist-non-react-statics/-/hoist-non-react-statics-2.5.5.tgz#c5903cf409c0dfd908f388e619d86b9c1174cb47"
hoist-non-react-statics@^3.1.0, hoist-non-react-statics@^3.3.0:
hoist-non-react-statics@3.3.0, hoist-non-react-statics@^3.1.0, hoist-non-react-statics@^3.3.0:
version "3.3.0"
resolved "https://registry.yarnpkg.com/hoist-non-react-statics/-/hoist-non-react-statics-3.3.0.tgz#b09178f0122184fb95acf525daaecb4d8f45958b"
dependencies:
react-is "^16.7.0"
hoist-non-react-statics@^2.3.1:
version "2.5.5"
resolved "https://registry.yarnpkg.com/hoist-non-react-statics/-/hoist-non-react-statics-2.5.5.tgz#c5903cf409c0dfd908f388e619d86b9c1174cb47"
homedir-polyfill@^1.0.1:
version "1.0.3"
resolved "https://registry.yarnpkg.com/homedir-polyfill/-/homedir-polyfill-1.0.3.tgz#743298cef4e5af3e194161fbadcc2151d3a058e8"