mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
Explore: Synchronise time ranges in split mode (#19274)
* Explore: create connected sync button when screen is splitted * Explore: create attachable button to TimePicker * WIP/Explore: set up redux boilerplate for synced state * WIP/Explore: add toggling functionality to sync buttons * WIP/Explore: Fix styling issue * First pass solution working * Explore: Clean up, update comments * Explore: refactore Timepicker, remove newly introduced class names * Explore: refactore ExploreTimeControls * Explore: more semantic variables naming * Explore: run query on syncable item when synced times activated * Explore: Add tooltip to sync times button * Explore: Remove typo * Explore: check exploreId * Explore: refactor ExploreTimeControls * Explore: refactor to include suggested changes * Explore: Create TimeSyncButton component, update colors * Explore: Toggle tooltip, use stylesFactory
This commit is contained in:
@@ -1,5 +1,8 @@
|
||||
// Libraries
|
||||
import React, { PureComponent, createRef } from 'react';
|
||||
import { css } from 'emotion';
|
||||
import memoizeOne from 'memoize-one';
|
||||
import classNames from 'classnames';
|
||||
|
||||
// Components
|
||||
import { ButtonSelect } from '../Select/ButtonSelect';
|
||||
@@ -11,15 +14,41 @@ import { ClickOutsideWrapper } from '../ClickOutsideWrapper/ClickOutsideWrapper'
|
||||
import { isDateTime, DateTime } from '@grafana/data';
|
||||
import { rangeUtil } from '@grafana/data';
|
||||
import { rawToTimeRange } from './time';
|
||||
import { withTheme } from '../../themes/ThemeContext';
|
||||
|
||||
// Types
|
||||
import { TimeRange, TimeOption, TimeZone, TIME_FORMAT, SelectableValue } from '@grafana/data';
|
||||
import { dateMath } from '@grafana/data';
|
||||
import { TimeRange, TimeOption, TimeZone, TIME_FORMAT, SelectableValue, dateMath } from '@grafana/data';
|
||||
import { GrafanaTheme } from '../../types/theme';
|
||||
import { Themeable } from '../../types';
|
||||
|
||||
export interface Props {
|
||||
const getStyles = memoizeOne((theme: GrafanaTheme) => {
|
||||
return {
|
||||
timePickerSynced: css`
|
||||
label: timePickerSynced;
|
||||
border-color: ${theme.colors.orangeDark};
|
||||
background-image: none;
|
||||
background-color: transparent;
|
||||
color: ${theme.colors.orangeDark};
|
||||
&:focus,
|
||||
:hover {
|
||||
color: ${theme.colors.orangeDark};
|
||||
background-image: none;
|
||||
background-color: transparent;
|
||||
}
|
||||
`,
|
||||
noRightBorderStyle: css`
|
||||
label: noRightBorderStyle;
|
||||
border-right: 0;
|
||||
`,
|
||||
};
|
||||
});
|
||||
|
||||
export interface Props extends Themeable {
|
||||
value: TimeRange;
|
||||
selectOptions: TimeOption[];
|
||||
timeZone?: TimeZone;
|
||||
timeSyncButton?: JSX.Element;
|
||||
isSynced?: boolean;
|
||||
onChange: (timeRange: TimeRange) => void;
|
||||
onMoveBackward: () => void;
|
||||
onMoveForward: () => void;
|
||||
@@ -70,7 +99,7 @@ const defaultZoomOutTooltip = () => {
|
||||
export interface State {
|
||||
isCustomOpen: boolean;
|
||||
}
|
||||
export class TimePicker extends PureComponent<Props, State> {
|
||||
class UnThemedTimePicker extends PureComponent<Props, State> {
|
||||
pickerTriggerRef = createRef<HTMLDivElement>();
|
||||
|
||||
state: State = {
|
||||
@@ -120,7 +149,19 @@ export class TimePicker extends PureComponent<Props, State> {
|
||||
};
|
||||
|
||||
render() {
|
||||
const { selectOptions: selectTimeOptions, value, onMoveBackward, onMoveForward, onZoom, timeZone } = this.props;
|
||||
const {
|
||||
selectOptions: selectTimeOptions,
|
||||
value,
|
||||
onMoveBackward,
|
||||
onMoveForward,
|
||||
onZoom,
|
||||
timeZone,
|
||||
timeSyncButton,
|
||||
isSynced,
|
||||
theme,
|
||||
} = this.props;
|
||||
|
||||
const styles = getStyles(theme);
|
||||
const { isCustomOpen } = this.state;
|
||||
const options = this.mapTimeOptionsToSelectableValues(selectTimeOptions);
|
||||
const currentOption = options.find(item => isTimeOptionEqualToTimeRange(item.value, value));
|
||||
@@ -152,7 +193,10 @@ export class TimePicker extends PureComponent<Props, State> {
|
||||
</button>
|
||||
)}
|
||||
<ButtonSelect
|
||||
className="time-picker-button-select"
|
||||
className={classNames('time-picker-button-select', {
|
||||
[`btn--radius-right-0 ${styles.noRightBorderStyle}`]: timeSyncButton,
|
||||
[styles.timePickerSynced]: timeSyncButton ? isSynced : null,
|
||||
})}
|
||||
value={currentOption}
|
||||
label={label}
|
||||
options={options}
|
||||
@@ -161,6 +205,9 @@ export class TimePicker extends PureComponent<Props, State> {
|
||||
iconClass={'fa fa-clock-o fa-fw'}
|
||||
tooltipContent={<TimePickerTooltipContent timeRange={value} />}
|
||||
/>
|
||||
|
||||
{timeSyncButton}
|
||||
|
||||
{isAbsolute && (
|
||||
<button className="btn navbar-button navbar-button--tight" onClick={onMoveForward}>
|
||||
<i className="fa fa-chevron-right" />
|
||||
@@ -195,3 +242,5 @@ const TimePickerTooltipContent = ({ timeRange }: { timeRange: TimeRange }) => (
|
||||
function isTimeOptionEqualToTimeRange(option: TimeOption, range: TimeRange): boolean {
|
||||
return range.raw.from === option.from && range.raw.to === option.to;
|
||||
}
|
||||
|
||||
export const TimePicker = withTheme(UnThemedTimePicker);
|
||||
|
||||
@@ -89,6 +89,7 @@ interface ExploreProps {
|
||||
mode: ExploreMode;
|
||||
initialUI: ExploreUIState;
|
||||
isLive: boolean;
|
||||
syncedTimes: boolean;
|
||||
updateTimeRange: typeof updateTimeRange;
|
||||
graphResult?: GraphSeriesXY[];
|
||||
loading?: boolean;
|
||||
@@ -178,7 +179,6 @@ export class Explore extends React.PureComponent<ExploreProps> {
|
||||
|
||||
onChangeTime = (rawRange: RawTimeRange) => {
|
||||
const { updateTimeRange, exploreId } = this.props;
|
||||
|
||||
updateTimeRange({ exploreId, rawRange });
|
||||
};
|
||||
|
||||
@@ -218,7 +218,7 @@ export class Explore extends React.PureComponent<ExploreProps> {
|
||||
};
|
||||
|
||||
onUpdateTimeRange = (absoluteRange: AbsoluteTimeRange) => {
|
||||
const { updateTimeRange, exploreId } = this.props;
|
||||
const { exploreId, updateTimeRange } = this.props;
|
||||
updateTimeRange({ exploreId, absoluteRange });
|
||||
};
|
||||
|
||||
@@ -263,6 +263,7 @@ export class Explore extends React.PureComponent<ExploreProps> {
|
||||
showingTable,
|
||||
timeZone,
|
||||
queryResponse,
|
||||
syncedTimes,
|
||||
} = this.props;
|
||||
const exploreClass = split ? 'explore explore-split' : 'explore';
|
||||
const styles = getStyles();
|
||||
@@ -326,6 +327,7 @@ export class Explore extends React.PureComponent<ExploreProps> {
|
||||
<LogsContainer
|
||||
width={width}
|
||||
exploreId={exploreId}
|
||||
syncedTimes={syncedTimes}
|
||||
onClickLabel={this.onClickLabel}
|
||||
onStartScanning={this.onStartScanning}
|
||||
onStopScanning={this.onStopScanning}
|
||||
@@ -350,7 +352,7 @@ const getTimeRangeFromUrlMemoized = memoizeOne(getTimeRangeFromUrl);
|
||||
|
||||
function mapStateToProps(state: StoreState, { exploreId }: ExploreProps): Partial<ExploreProps> {
|
||||
const explore = state.explore;
|
||||
const { split } = explore;
|
||||
const { split, syncedTimes } = explore;
|
||||
const item: ExploreItemState = explore[exploreId];
|
||||
const timeZone = getTimeZone(state.user);
|
||||
const {
|
||||
@@ -421,6 +423,7 @@ function mapStateToProps(state: StoreState, { exploreId }: ExploreProps): Partia
|
||||
absoluteRange,
|
||||
queryResponse,
|
||||
originPanelId,
|
||||
syncedTimes,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ import { TimeRange, TimeOption, TimeZone, RawTimeRange, dateTimeForTimeZone } fr
|
||||
|
||||
// Components
|
||||
import { TimePicker } from '@grafana/ui';
|
||||
import { TimeSyncButton } from './TimeSyncButton';
|
||||
|
||||
// Utils & Services
|
||||
import { defaultSelectOptions } from '@grafana/ui/src/components/TimePicker/TimePicker';
|
||||
@@ -18,6 +19,9 @@ export interface Props {
|
||||
exploreId: ExploreId;
|
||||
range: TimeRange;
|
||||
timeZone: TimeZone;
|
||||
splitted: boolean;
|
||||
syncedTimes: boolean;
|
||||
onChangeTimeSync: () => void;
|
||||
onChangeTime: (range: RawTimeRange) => void;
|
||||
}
|
||||
|
||||
@@ -67,18 +71,18 @@ export class ExploreTimeControls extends Component<Props> {
|
||||
};
|
||||
|
||||
render() {
|
||||
const { range, timeZone } = this.props;
|
||||
const { range, timeZone, splitted, syncedTimes, onChangeTimeSync } = this.props;
|
||||
const timeSyncButton = splitted ? <TimeSyncButton onClick={onChangeTimeSync} isSynced={syncedTimes} /> : null;
|
||||
const timePickerCommonProps = {
|
||||
value: range,
|
||||
onChange: this.onChangeTimePicker,
|
||||
timeZone,
|
||||
onMoveBackward: this.onMoveBack,
|
||||
onMoveForward: this.onMoveForward,
|
||||
onZoom: this.onZoom,
|
||||
selectOptions: this.setActiveTimeOption(defaultSelectOptions, range.raw),
|
||||
};
|
||||
|
||||
return (
|
||||
<TimePicker
|
||||
value={range}
|
||||
onChange={this.onChangeTimePicker}
|
||||
timeZone={timeZone}
|
||||
onMoveBackward={this.onMoveBack}
|
||||
onMoveForward={this.onMoveForward}
|
||||
onZoom={this.onZoom}
|
||||
selectOptions={this.setActiveTimeOption(defaultSelectOptions, range.raw)}
|
||||
/>
|
||||
);
|
||||
return <TimePicker {...timePickerCommonProps} timeSyncButton={timeSyncButton} isSynced={syncedTimes} />;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,6 +25,7 @@ import {
|
||||
splitClose,
|
||||
runQueries,
|
||||
splitOpen,
|
||||
syncTimes,
|
||||
changeRefreshInterval,
|
||||
changeMode,
|
||||
clearOrigin,
|
||||
@@ -60,6 +61,7 @@ interface StateProps {
|
||||
timeZone: TimeZone;
|
||||
selectedDatasource: DataSourceSelectItem;
|
||||
splitted: boolean;
|
||||
syncedTimes: boolean;
|
||||
refreshInterval: string;
|
||||
supportedModes: ExploreMode[];
|
||||
selectedMode: ExploreMode;
|
||||
@@ -77,6 +79,7 @@ interface DispatchProps {
|
||||
runQueries: typeof runQueries;
|
||||
closeSplit: typeof splitClose;
|
||||
split: typeof splitOpen;
|
||||
syncTimes: typeof syncTimes;
|
||||
changeRefreshInterval: typeof changeRefreshInterval;
|
||||
changeMode: typeof changeMode;
|
||||
clearOrigin: typeof clearOrigin;
|
||||
@@ -112,6 +115,11 @@ export class UnConnectedExploreToolbar extends PureComponent<Props, {}> {
|
||||
changeMode(exploreId, mode);
|
||||
};
|
||||
|
||||
onChangeTimeSync = () => {
|
||||
const { syncTimes, exploreId } = this.props;
|
||||
syncTimes(exploreId);
|
||||
};
|
||||
|
||||
returnToPanel = async ({ withChanges = false } = {}) => {
|
||||
const { originPanelId } = this.props;
|
||||
|
||||
@@ -148,6 +156,7 @@ export class UnConnectedExploreToolbar extends PureComponent<Props, {}> {
|
||||
timeZone,
|
||||
selectedDatasource,
|
||||
splitted,
|
||||
syncedTimes,
|
||||
refreshInterval,
|
||||
onChangeTime,
|
||||
split,
|
||||
@@ -259,6 +268,9 @@ export class UnConnectedExploreToolbar extends PureComponent<Props, {}> {
|
||||
range={range}
|
||||
timeZone={timeZone}
|
||||
onChangeTime={onChangeTime}
|
||||
splitted={splitted}
|
||||
syncedTimes={syncedTimes}
|
||||
onChangeTimeSync={this.onChangeTimeSync}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
@@ -305,6 +317,7 @@ export class UnConnectedExploreToolbar extends PureComponent<Props, {}> {
|
||||
|
||||
const mapStateToProps = (state: StoreState, { exploreId }: OwnProps): StateProps => {
|
||||
const splitted = state.explore.split;
|
||||
const syncedTimes = state.explore.syncedTimes;
|
||||
const exploreItem: ExploreItemState = state.explore[exploreId];
|
||||
const {
|
||||
datasourceInstance,
|
||||
@@ -343,6 +356,7 @@ const mapStateToProps = (state: StoreState, { exploreId }: OwnProps): StateProps
|
||||
isPaused,
|
||||
originPanelId,
|
||||
queries,
|
||||
syncedTimes,
|
||||
datasourceLoading,
|
||||
};
|
||||
};
|
||||
@@ -355,6 +369,7 @@ const mapDispatchToProps: DispatchProps = {
|
||||
runQueries,
|
||||
closeSplit: splitClose,
|
||||
split: splitOpen,
|
||||
syncTimes,
|
||||
changeMode: changeMode,
|
||||
clearOrigin,
|
||||
};
|
||||
|
||||
@@ -47,6 +47,7 @@ interface LogsContainerProps {
|
||||
isLive: boolean;
|
||||
updateTimeRange: typeof updateTimeRange;
|
||||
range: TimeRange;
|
||||
syncedTimes: boolean;
|
||||
absoluteRange: AbsoluteTimeRange;
|
||||
isPaused: boolean;
|
||||
}
|
||||
@@ -54,7 +55,6 @@ interface LogsContainerProps {
|
||||
export class LogsContainer extends PureComponent<LogsContainerProps> {
|
||||
onChangeTime = (absoluteRange: AbsoluteTimeRange) => {
|
||||
const { exploreId, updateTimeRange } = this.props;
|
||||
|
||||
updateTimeRange({ exploreId, absoluteRange });
|
||||
};
|
||||
|
||||
|
||||
60
public/app/features/explore/TimeSyncButton.tsx
Normal file
60
public/app/features/explore/TimeSyncButton.tsx
Normal file
@@ -0,0 +1,60 @@
|
||||
import React from 'react';
|
||||
import classNames from 'classnames';
|
||||
import { css } from 'emotion';
|
||||
|
||||
import { GrafanaTheme, useTheme, stylesFactory } from '@grafana/ui';
|
||||
|
||||
//Components
|
||||
import { Tooltip } from '@grafana/ui';
|
||||
|
||||
const getStyles = stylesFactory((theme: GrafanaTheme) => {
|
||||
return {
|
||||
timePickerSynced: css`
|
||||
label: timePickerSynced;
|
||||
border-color: ${theme.colors.orangeDark};
|
||||
background-image: none;
|
||||
background-color: transparent;
|
||||
color: ${theme.colors.orangeDark};
|
||||
&:focus,
|
||||
:hover {
|
||||
color: ${theme.colors.orangeDark};
|
||||
background-image: none;
|
||||
background-color: transparent;
|
||||
}
|
||||
`,
|
||||
noRightBorderStyle: css`
|
||||
label: noRightBorderStyle;
|
||||
border-right: 0;
|
||||
`,
|
||||
};
|
||||
});
|
||||
|
||||
interface TimeSyncButtonProps {
|
||||
isSynced: boolean;
|
||||
onClick: () => void;
|
||||
}
|
||||
|
||||
export function TimeSyncButton(props: TimeSyncButtonProps) {
|
||||
const { onClick, isSynced } = props;
|
||||
const theme = useTheme();
|
||||
const styles = getStyles(theme);
|
||||
|
||||
const syncTimesTooltip = () => {
|
||||
const { isSynced } = props;
|
||||
const tooltip = isSynced ? 'Unsync all views' : 'Sync all views to this time range';
|
||||
return <>{tooltip}</>;
|
||||
};
|
||||
|
||||
return (
|
||||
<Tooltip content={syncTimesTooltip} placement="bottom">
|
||||
<button
|
||||
className={classNames('btn navbar-button navbar-button--attached', {
|
||||
[styles.timePickerSynced]: isSynced,
|
||||
})}
|
||||
onClick={() => onClick()}
|
||||
>
|
||||
<i className="fa fa-link" />
|
||||
</button>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
@@ -13,6 +13,7 @@ import { actionCreatorFactory, ActionOf } from 'app/core/redux/actionCreatorFact
|
||||
export enum ActionTypes {
|
||||
SplitOpen = 'explore/SPLIT_OPEN',
|
||||
ResetExplore = 'explore/RESET_EXPLORE',
|
||||
SyncTimes = 'explore/SYNC_TIMES',
|
||||
}
|
||||
export interface SplitOpenAction {
|
||||
type: ActionTypes.SplitOpen;
|
||||
@@ -26,6 +27,10 @@ export interface ResetExploreAction {
|
||||
payload: {};
|
||||
}
|
||||
|
||||
export interface SyncTimesAction {
|
||||
type: ActionTypes.SyncTimes;
|
||||
payload: { syncedTimes: boolean };
|
||||
}
|
||||
/** Lower order actions
|
||||
*
|
||||
*/
|
||||
@@ -165,6 +170,10 @@ export interface SplitOpenPayload {
|
||||
itemState: ExploreItemState;
|
||||
}
|
||||
|
||||
export interface SyncTimesPayload {
|
||||
syncedTimes: boolean;
|
||||
}
|
||||
|
||||
export interface ToggleTablePayload {
|
||||
exploreId: ExploreId;
|
||||
}
|
||||
@@ -352,6 +361,7 @@ export const splitCloseAction = actionCreatorFactory<SplitCloseActionPayload>('e
|
||||
*/
|
||||
export const splitOpenAction = actionCreatorFactory<SplitOpenPayload>('explore/SPLIT_OPEN').create();
|
||||
|
||||
export const syncTimesAction = actionCreatorFactory<SyncTimesPayload>('explore/SYNC_TIMES').create();
|
||||
/**
|
||||
* Update state of Explores UI elements (panels visiblity and deduplication strategy)
|
||||
*/
|
||||
@@ -410,4 +420,5 @@ export type HigherOrderAction =
|
||||
| ActionOf<SplitCloseActionPayload>
|
||||
| SplitOpenAction
|
||||
| ResetExploreAction
|
||||
| SyncTimesAction
|
||||
| ActionOf<any>;
|
||||
|
||||
@@ -71,6 +71,7 @@ import {
|
||||
queryStreamUpdatedAction,
|
||||
queryStoreSubscriptionAction,
|
||||
clearOriginAction,
|
||||
syncTimesAction,
|
||||
} from './actionTypes';
|
||||
import { ActionOf, ActionCreator } from 'app/core/redux/actionCreatorFactory';
|
||||
import { getTimeZone } from 'app/features/profile/state/selectors';
|
||||
@@ -182,12 +183,19 @@ export const updateTimeRange = (options: {
|
||||
rawRange?: RawTimeRange;
|
||||
absoluteRange?: AbsoluteTimeRange;
|
||||
}): ThunkResult<void> => {
|
||||
return dispatch => {
|
||||
dispatch(updateTime({ ...options }));
|
||||
dispatch(runQueries(options.exploreId));
|
||||
return (dispatch, getState) => {
|
||||
const { syncedTimes } = getState().explore;
|
||||
if (syncedTimes) {
|
||||
dispatch(updateTime({ ...options, exploreId: ExploreId.left }));
|
||||
dispatch(runQueries(ExploreId.left));
|
||||
dispatch(updateTime({ ...options, exploreId: ExploreId.right }));
|
||||
dispatch(runQueries(ExploreId.right));
|
||||
} else {
|
||||
dispatch(updateTime({ ...options }));
|
||||
dispatch(runQueries(options.exploreId));
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Change the refresh interval of Explore. Called from the Refresh picker.
|
||||
*/
|
||||
@@ -674,6 +682,25 @@ export function splitOpen(): ThunkResult<void> {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Syncs time interval, if they are not synced on both panels in a split mode.
|
||||
* Unsyncs time interval, if they are synced on both panels in a split mode.
|
||||
*/
|
||||
export function syncTimes(exploreId: ExploreId): ThunkResult<void> {
|
||||
return (dispatch, getState) => {
|
||||
if (exploreId === ExploreId.left) {
|
||||
const leftState = getState().explore.left;
|
||||
dispatch(updateTimeRange({ exploreId: ExploreId.right, rawRange: leftState.range.raw }));
|
||||
} else {
|
||||
const rightState = getState().explore.right;
|
||||
dispatch(updateTimeRange({ exploreId: ExploreId.left, rawRange: rightState.range.raw }));
|
||||
}
|
||||
const isTimeSynced = getState().explore.syncedTimes;
|
||||
dispatch(syncTimesAction({ syncedTimes: !isTimeSynced }));
|
||||
dispatch(stateSave());
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates action to collapse graph/logs/table panel. When panel is collapsed,
|
||||
* queries won't be run
|
||||
|
||||
@@ -129,6 +129,7 @@ export const createEmptyQueryResponse = (): PanelData => ({
|
||||
export const initialExploreItemState = makeExploreItemState();
|
||||
export const initialExploreState: ExploreState = {
|
||||
split: null,
|
||||
syncedTimes: false,
|
||||
left: initialExploreItemState,
|
||||
right: initialExploreItemState,
|
||||
};
|
||||
@@ -727,6 +728,9 @@ export const exploreReducer = (state = initialExploreState, action: HigherOrderA
|
||||
case ActionTypes.SplitOpen: {
|
||||
return { ...state, split: true, right: { ...action.payload.itemState } };
|
||||
}
|
||||
case ActionTypes.SyncTimes: {
|
||||
return { ...state, syncedTimes: action.payload.syncedTimes };
|
||||
}
|
||||
|
||||
case ActionTypes.ResetExplore: {
|
||||
if (action.payload.force || !Number.isInteger(state.left.originPanelId)) {
|
||||
|
||||
@@ -130,6 +130,10 @@ export interface ExploreState {
|
||||
* True if split view is active.
|
||||
*/
|
||||
split: boolean;
|
||||
/**
|
||||
* True if time interval for panels are synced. Only possible with split mode.
|
||||
*/
|
||||
syncedTimes: boolean;
|
||||
/**
|
||||
* Explore state of the left split (left is default in non-split view).
|
||||
*/
|
||||
|
||||
@@ -72,9 +72,11 @@ export const mockExploreState = (options: any = {}) => {
|
||||
range,
|
||||
};
|
||||
const split: boolean = options.split || false;
|
||||
const syncedTimes: boolean = options.syncedTimes || false;
|
||||
const explore: ExploreState = {
|
||||
left,
|
||||
right,
|
||||
syncedTimes,
|
||||
split,
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user