diff --git a/package.json b/package.json index 2e6792aaee9..a53ab845d1e 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/packages/grafana-ui/src/components/RefreshPicker/RefreshPicker.tsx b/packages/grafana-ui/src/components/RefreshPicker/RefreshPicker.tsx index 92a55d71ac0..72f1ae34214 100644 --- a/packages/grafana-ui/src/components/RefreshPicker/RefreshPicker.tsx +++ b/packages/grafana-ui/src/components/RefreshPicker/RefreshPicker.tsx @@ -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 { +export class RefreshPickerBase extends PureComponent { + // 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 { }; 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 { return (
- - - + {refreshButton ? ( + refreshButton + ) : ( + + + + )} { ); } } + +export const RefreshPicker = withTheme< + Props, + { + offOption: typeof RefreshPickerBase.offOption; + liveOption: typeof RefreshPickerBase.liveOption; + } +>(RefreshPickerBase); diff --git a/packages/grafana-ui/src/components/RefreshPicker/_RefreshPicker.scss b/packages/grafana-ui/src/components/RefreshPicker/_RefreshPicker.scss index 3dff6721740..0146147d79a 100644 --- a/packages/grafana-ui/src/components/RefreshPicker/_RefreshPicker.scss +++ b/packages/grafana-ui/src/components/RefreshPicker/_RefreshPicker.scss @@ -20,10 +20,6 @@ width: 100%; } - .select-button-value { - color: $orange; - } - &--off { .select-button-value { display: none; diff --git a/packages/grafana-ui/src/themes/ThemeContext.tsx b/packages/grafana-ui/src/themes/ThemeContext.tsx index 11b3d79481a..ea9fcb1502f 100644 --- a/packages/grafana-ui/src/themes/ThemeContext.tsx +++ b/packages/grafana-ui/src/themes/ThemeContext.tsx @@ -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 = Omit; // Use Grafana Dark theme by default export const ThemeContext = React.createContext(getTheme(GrafanaThemeType.Dark)); -export const withTheme =

(Component: React.ComponentType

) => { +export const withTheme =

(Component: React.ComponentType

) => { const WithTheme: React.FunctionComponent> = props => { // @ts-ignore return {theme => }; }; 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); +} diff --git a/packages/grafana-ui/src/themes/index.ts b/packages/grafana-ui/src/themes/index.ts index 6d3e41e867e..89b8ab2877d 100644 --- a/packages/grafana-ui/src/themes/index.ts +++ b/packages/grafana-ui/src/themes/index.ts @@ -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 }; diff --git a/public/app/features/explore/ExploreTimeControls.tsx b/public/app/features/explore/ExploreTimeControls.tsx index 01c9f513975..53b1fbce389 100644 --- a/public/app/features/explore/ExploreTimeControls.tsx +++ b/public/app/features/explore/ExploreTimeControls.tsx @@ -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 { }; render() { - const { - hasLiveOption, - isLive, - loading, - range, - refreshInterval, - timeZone, - onRunQuery, - onChangeRefreshInterval, - } = this.props; + const { range, timeZone } = this.props; return ( - <> - {!isLive && ( - - )} - - - {refreshInterval && } - + ); } } diff --git a/public/app/features/explore/ExploreToolbar.tsx b/public/app/features/explore/ExploreToolbar.tsx index ba57b3030f7..1687e1b0475 100644 --- a/public/app/features/explore/ExploreToolbar.tsx +++ b/public/app/features/explore/ExploreToolbar.tsx @@ -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 ( - - ); -}; +import { LiveTailButton } from './LiveTailButton'; +import { ResponsiveButton } from './ResponsiveButton'; +import { RunButton } from './RunButton'; interface OwnProps { exploreId: ExploreId; @@ -77,6 +57,7 @@ interface StateProps { selectedModeOption: SelectableValue; 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 { }); }; + 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 { selectedModeOption, hasLiveOption, isLive, + isPaused, originPanelId, } = this.props; @@ -249,30 +255,25 @@ export class UnConnectedExploreToolbar extends PureComponent { {exploreId === 'left' && !splitted ? (

- {createResponsiveButton({ - splitted, - title: 'Split', - onClick: split, - iconClassName: 'fa fa-fw fa-columns icon-margin-right', - iconSide: IconSide.left, - disabled: isLive, - })} +
) : null} -
- -
+ {!isLive && ( +
+ +
+ )}
- {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, - })} + + {refreshInterval && }
+ + {hasLiveOption && ( + + )}
@@ -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)( diff --git a/public/app/features/explore/LiveTailButton.tsx b/public/app/features/explore/LiveTailButton.tsx new file mode 100644 index 00000000000..25897536b71 --- /dev/null +++ b/public/app/features/explore/LiveTailButton.tsx @@ -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 ( +
+ + {isLive && ( + + )} +
+ ); +} diff --git a/public/app/features/explore/LogsContainer.tsx b/public/app/features/explore/LogsContainer.tsx index 42d61d9b8bd..9d780b05d57 100644 --- a/public/app/features/explore/LogsContainer.tsx +++ b/public/app/features/explore/LogsContainer.tsx @@ -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 { onStopLive = () => { const { exploreId } = this.props; - this.props.stopLive({ exploreId, refreshInterval: offOption.value }); + this.props.stopLive({ exploreId, refreshInterval: RefreshPicker.offOption.value }); }; onPause = () => { diff --git a/public/app/features/explore/ResponsiveButton.tsx b/public/app/features/explore/ResponsiveButton.tsx new file mode 100644 index 00000000000..33aa2fedebf --- /dev/null +++ b/public/app/features/explore/ResponsiveButton.tsx @@ -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 ( + + ); +}; diff --git a/public/app/features/explore/RunButton.tsx b/public/app/features/explore/RunButton.tsx new file mode 100644 index 00000000000..b704d584cba --- /dev/null +++ b/public/app/features/explore/RunButton.tsx @@ -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 = ( + + ); + + if (showDropdown) { + return ( + + ); + } + return runButton; +} diff --git a/public/sass/pages/_explore.scss b/public/sass/pages/_explore.scss index df924a14bde..df8c8b1d96c 100644 --- a/public/sass/pages/_explore.scss +++ b/public/sass/pages/_explore.scss @@ -6,10 +6,6 @@ margin-left: 0.25em; } -.run-icon { - transform: rotate(90deg); -} - .datasource-picker { .ds-picker { min-width: 200px; diff --git a/yarn.lock b/yarn.lock index 4f0afd752ad..3493e5c28db 100644 --- a/yarn.lock +++ b/yarn.lock @@ -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"