Explore & Dashboard: New Refresh picker (#16505)

* Added RefreshButton

* Added RefreshSelect

* Added RefreshSelectButton

* Added RefreshPicker

* Removed the magic string Paused

* Minor style changes and using Off instead of Pause

* Added HeadlessSelect

* Added HeadlessSelect story

* Added SelectButton

* Removed RefreshSelectButton

* Added TimePicker and moved ClickOutsideWrapper to ui/components

* Added TimePickerPopOver

* Added react-calendar

* Missed yarn lock file

* Added inputs to popover

* Added TimePicker and RefreshPicker to DashNav

* Moved TimePicker and RefreshPicker to app/core

* Added react-calendar to app and removed from ui/components

* Fixed PopOver onClick

* Moved everything back to ui components because of typings problems

* Exporing RefreshPicker and TimePicker

* Added Apply and inputs

* Added typings

* Added TimePickerInput and logic

* Fixed parsing of string to Moments

* Fixed range string

* Styling and connecting the calendars and inputs

* Changed Calendar styling

* Added backward forward and zoom

* Fixed responsive styles

* Moved TimePicker and RefreshPicker into app core

* Renamed menuIsOpen to isOpen

* Changed from className={} to className=""

* Moved Popover to TimePickerOptionGroup

* Renamed all PopOver to Popover

* Renamed popOver to popover and some minor refactorings

* Renamed files with git mv

* Added ButtonSelect and refactored RefreshPicker

* Refactored TimePicker to use new ButtonSelect

* Removed HeadlessSelect as suggested

* fix: Fix typings and misc errors after rebase

* wip: Enable time picker on dashboard and add tooltip

* Merge branch 'master' into hugoh/new-timepicker-and-unified-component

# Conflicts:
#	packages/grafana-ui/package.json
#	packages/grafana-ui/src/components/Input/Input.test.tsx
#	packages/grafana-ui/src/components/Input/Input.tsx
#	packages/grafana-ui/src/utils/validate.ts
#	public/app/features/dashboard/panel_editor/QueryOptions.tsx
#	yarn.lock

* fix: Snapshot update

* Move TimePicker default options into the TimePicker as statics, pass the tooltipContent down the line when wanted and wrap the button in a tooltip element

* fix: Override internal state prop if we provide one in a prop

* Updated snapshots

* Let dashnav control refreshPicker state

* feat: Add a stringToMs function

* wip: RefreshPicker

* wip: Move RefreshPicker to @grafana/ui

* wip: Move TimePicker to @grafana/ui

* wip: Remove comments

* wip: Add refreshPicker to explore

* wip: Use default intervals if the prop is missing

* wip: Nicer way of setting defaults

* fix: Control the select component

* wip: Add onMoveForward/onMoveBack

* Remove code related to the new time picker and refresh picker from dashnav

* Fix: Typings after merge

* chore: Minor fix after merge

* chore: Remove _.map usage

* chore: Moved refresh-picker logic out of the refresh picker since it will work a little differently in explore and dashboards until we have replaced the TimeSrv

* feat: Add an Interval component to @grafana/ui

* chore: Remove intervalId from redux state and move setInterval logic from ExploreToolbar to its own Interval component

* feat: Add refreshInterval to Explore's URL state

* feat: Pick up refreshInterval from url on page load

* fix: Set default refreshInterval when no value can be retained from URL

* fix: Update test initial state with refreshInterval

* fix: Handle URLs before RefreshPicker

* fix: Move RefreshInterval to url position 3 since the segments can take multiple positions

* fix: A better way of detecting urls without RefreshInterval in Explore

* chore: Some Explore typings

* fix: Attach refresh picker to interval picker

* chore: Sass fix for refresh button border radius

* fix: Remove refreshInterval from URL

* fix: Intervals now start when previous interval is finished

* fix: Use clearTimeout instead of clearInterval

* fix: Make sure there's a delay set before adding a timeout when we have slow explore queries

* wip: Add refresh picker to dashboard

* feat: Add util for removing keys with empty values

* feat: RefreshPicker in dashboards and tmp rem out old RefreshPicker

* fix: Remove the jumpy:ness in the refreshpicker

* Changed placement and made it hide when your in dashboard settings

* chore: Move logic related to refresh picker out of DashNav to its own component

* feat: Add tooltip to refreshpicker

* fix: Fix bug with refreshpicker not updating when setting to 'off'

* fix: Make it possible to override refresh intervals using the dashboard intervals

* chore: Change name of Interval to SetInterval to align with ecmascripts naming since its basically the same but declarative and async

* fix: Use default intervals when auto refresh is empty in dashboard settings

* fix: Hide time/interval picker when hidden is true on the model, such as on the home dashboard

* fix: Interval picker will have to handle location changes since timeSrv wont

* RefreshPicker: Refactoring refresh picker

* RefreshPicker: minor refactoring
This commit is contained in:
Johannes Schill
2019-04-16 09:15:23 +02:00
committed by Torkel Ödegaard
parent d23f50ab23
commit 406ef962fc
59 changed files with 2554 additions and 540 deletions

View File

@@ -28,6 +28,7 @@
"moment": "^2.22.2", "moment": "^2.22.2",
"papaparse": "^4.6.3", "papaparse": "^4.6.3",
"react": "^16.8.4", "react": "^16.8.4",
"react-calendar": "^2.18.1",
"react-color": "^2.17.0", "react-color": "^2.17.0",
"react-custom-scrollbars": "^4.2.1", "react-custom-scrollbars": "^4.2.1",
"react-dom": "^16.8.4", "react-dom": "^16.8.4",

View File

@@ -22,7 +22,7 @@ export class ClickOutsideWrapper extends PureComponent<Props, State> {
window.removeEventListener('click', this.onOutsideClick, false); window.removeEventListener('click', this.onOutsideClick, false);
} }
onOutsideClick = event => { onOutsideClick = (event: any) => {
const domNode = ReactDOM.findDOMNode(this) as Element; const domNode = ReactDOM.findDOMNode(this) as Element;
if (!domNode || !domNode.contains(event.target)) { if (!domNode || !domNode.contains(event.target)) {

View File

@@ -0,0 +1,32 @@
import React from 'react';
import { storiesOf } from '@storybook/react';
import { action } from '@storybook/addon-actions';
import { withCenteredStory } from '../../utils/storybook/withCenteredStory';
import { UseState } from '../../utils/storybook/UseState';
import { RefreshPicker } from './RefreshPicker';
const RefreshSelectStories = storiesOf('UI/RefreshPicker', module);
RefreshSelectStories.addDecorator(withCenteredStory);
RefreshSelectStories.add('default', () => {
return (
<UseState initialState={'1h'}>
{(value, updateValue) => {
return (
<RefreshPicker
tooltip="Hello world"
value={value}
intervals={['5s', '10s', '30s', '1m', '5m', '15m', '30m', '1h', '2h', '1d']}
onIntervalChanged={interval => {
action('onIntervalChanged fired')(interval);
}}
onRefresh={() => {
action('onRefresh fired')();
}}
/>
);
}}
</UseState>
);
});

View File

@@ -0,0 +1,80 @@
import React, { PureComponent } from 'react';
import classNames from 'classnames';
import { SelectOptionItem, ButtonSelect, Tooltip } from '@grafana/ui';
export const offOption = { label: 'Off', value: '' };
export const defaultIntervals = ['5s', '10s', '30s', '1m', '5m', '15m', '30m', '1h', '2h', '1d'];
export interface Props {
intervals?: string[];
onRefresh: () => any;
onIntervalChanged: (interval: string) => void;
value?: string;
tooltip: string;
}
export class RefreshPicker extends PureComponent<Props> {
static defaultProps = {
intervals: defaultIntervals,
};
constructor(props: Props) {
super(props);
}
hasNoIntervals = () => {
const { intervals } = this.props;
// Current implementaion returns an array with length of 1 consisting of
// an empty string when auto-refresh is empty in dashboard settings
if (!intervals || intervals.length < 1 || (intervals.length === 1 && intervals[0] === '')) {
return true;
}
return false;
};
intervalsToOptions = (intervals: string[] = defaultIntervals): SelectOptionItem[] => {
const options = intervals.map(interval => ({ label: interval, value: interval }));
options.unshift(offOption);
return options;
};
onChangeSelect = (item: SelectOptionItem) => {
const { onIntervalChanged } = this.props;
if (onIntervalChanged) {
onIntervalChanged(item.value);
}
};
render() {
const { onRefresh, intervals, tooltip, value } = this.props;
const options = this.intervalsToOptions(this.hasNoIntervals() ? defaultIntervals : intervals);
const currentValue = value || '';
const selectedValue = options.find(item => item.value === currentValue) || offOption;
const cssClasses = classNames({
'refresh-picker': true,
'refresh-picker--refreshing': selectedValue.label !== offOption.label,
});
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--refresh" onClick={onRefresh}>
<i className="fa fa-refresh" />
</button>
</Tooltip>
<ButtonSelect
className="navbar-button--attached btn--radius-left-0"
value={selectedValue}
label={selectedValue.label}
options={options}
onChange={this.onChangeSelect}
maxMenuHeight={380}
/>
</div>
</div>
);
}
}

View File

@@ -0,0 +1,28 @@
.refresh-picker {
position: relative;
display: none;
.refresh-picker-buttons {
display: flex;
}
.gf-form-input--form-dropdown {
position: static;
}
.gf-form-select-box__menu {
position: absolute;
left: 0;
width: 100%;
}
&--refreshing {
.select-button-value {
color: $orange;
}
}
@include media-breakpoint-up(md) {
display: block;
}
}

View File

@@ -0,0 +1,41 @@
import React from 'react';
import { storiesOf } from '@storybook/react';
import { action } from '@storybook/addon-actions';
import { withKnobs, object, text } from '@storybook/addon-knobs';
import { withCenteredStory } from '../../utils/storybook/withCenteredStory';
import { UseState } from '../../utils/storybook/UseState';
import { SelectOptionItem } from './Select';
import { ButtonSelect } from './ButtonSelect';
const ButtonSelectStories = storiesOf('UI/Select/ButtonSelect', module);
ButtonSelectStories.addDecorator(withCenteredStory).addDecorator(withKnobs);
ButtonSelectStories.add('default', () => {
const intialState: SelectOptionItem = { label: 'A label', value: 'A value' };
const value = object<SelectOptionItem>('Selected Value:', intialState);
const options = object<SelectOptionItem[]>('Options:', [
intialState,
{ label: 'Another label', value: 'Another value' },
]);
return (
<UseState initialState={value}>
{(value, updateValue) => {
return (
<ButtonSelect
value={value}
options={options}
onChange={value => {
action('onChanged fired')(value);
updateValue(value);
}}
label={value.label ? value.label : ''}
className="refresh-select"
iconClass={text('iconClass', 'fa fa-clock-o fa-fw')}
/>
);
}}
</UseState>
);
});

View File

@@ -0,0 +1,88 @@
import React, { PureComponent } from 'react';
import Select, { SelectOptionItem } from './Select';
import { PopperContent } from '@grafana/ui/src/components/Tooltip/PopperController';
interface ButtonComponentProps {
label: string | undefined;
className: string | undefined;
iconClass?: string;
}
const ButtonComponent = (buttonProps: ButtonComponentProps) => (props: any) => {
const { label, className, iconClass } = buttonProps;
return (
<button
ref={props.innerRef}
className={`btn navbar-button navbar-button--tight ${className}`}
onClick={props.selectProps.menuIsOpen ? props.selectProps.onMenuClose : props.selectProps.onMenuOpen}
onBlur={props.selectProps.onMenuClose}
>
<div className="select-button">
{iconClass && <i className={`select-button-icon ${iconClass}`} />}
<span className="select-button-value">{label ? label : ''}</span>
<i className="fa fa-caret-down fa-fw" />
</div>
</button>
);
};
export interface Props {
className: string | undefined;
options: SelectOptionItem[];
value: SelectOptionItem;
label?: string;
iconClass?: string;
components?: any;
maxMenuHeight?: number;
onChange: (item: SelectOptionItem) => void;
tooltipContent?: PopperContent<any>;
isMenuOpen?: boolean;
onOpenMenu?: () => void;
onCloseMenu?: () => void;
}
export class ButtonSelect extends PureComponent<Props> {
onChange = (item: SelectOptionItem) => {
const { onChange } = this.props;
onChange(item);
};
render() {
const {
className,
options,
value,
label,
iconClass,
components,
maxMenuHeight,
tooltipContent,
isMenuOpen,
onOpenMenu,
onCloseMenu,
} = this.props;
const combinedComponents = {
...components,
Control: ButtonComponent({ label, className, iconClass }),
};
return (
<Select
autoFocus
backspaceRemovesValue={false}
isClearable={false}
isSearchable={false}
options={options}
onChange={this.onChange}
defaultValue={value}
maxMenuHeight={maxMenuHeight}
components={combinedComponents}
className="gf-form-select-box-button-select"
tooltipContent={tooltipContent}
isOpen={isMenuOpen}
onOpenMenu={onOpenMenu}
onCloseMenu={onCloseMenu}
/>
);
}
}

View File

@@ -17,6 +17,8 @@ import IndicatorsContainer from './IndicatorsContainer';
import NoOptionsMessage from './NoOptionsMessage'; import NoOptionsMessage from './NoOptionsMessage';
import resetSelectStyles from './resetSelectStyles'; import resetSelectStyles from './resetSelectStyles';
import { CustomScrollbar } from '../CustomScrollbar/CustomScrollbar'; import { CustomScrollbar } from '../CustomScrollbar/CustomScrollbar';
import { PopperContent } from '@grafana/ui/src/components/Tooltip/PopperController';
import { Tooltip } from '@grafana/ui';
export interface SelectOptionItem { export interface SelectOptionItem {
label?: string; label?: string;
@@ -26,7 +28,7 @@ export interface SelectOptionItem {
[key: string]: any; [key: string]: any;
} }
interface CommonProps { export interface CommonProps {
defaultValue?: any; defaultValue?: any;
getOptionLabel?: (item: SelectOptionItem) => string; getOptionLabel?: (item: SelectOptionItem) => string;
getOptionValue?: (item: SelectOptionItem) => string; getOptionValue?: (item: SelectOptionItem) => string;
@@ -42,13 +44,18 @@ interface CommonProps {
openMenuOnFocus?: boolean; openMenuOnFocus?: boolean;
onBlur?: () => void; onBlur?: () => void;
maxMenuHeight?: number; maxMenuHeight?: number;
isLoading: boolean; isLoading?: boolean;
noOptionsMessage?: () => string; noOptionsMessage?: () => string;
isMulti?: boolean; isMulti?: boolean;
backspaceRemovesValue: boolean; backspaceRemovesValue?: boolean;
isOpen?: boolean;
components?: any;
tooltipContent?: PopperContent<any>;
onOpenMenu?: () => void;
onCloseMenu?: () => void;
} }
interface SelectProps { export interface SelectProps {
options: SelectOptionItem[]; options: SelectOptionItem[];
} }
@@ -58,6 +65,26 @@ interface AsyncProps {
loadingMessage?: () => string; loadingMessage?: () => string;
} }
const wrapInTooltip = (
component: React.ReactElement,
tooltipContent: PopperContent<any> | undefined,
isMenuOpen: boolean | undefined
) => {
const showTooltip = isMenuOpen ? false : undefined;
if (tooltipContent) {
return (
<Tooltip show={showTooltip} content={tooltipContent} placement="bottom">
<div>
{/* div needed for tooltip */}
{component}
</div>
</Tooltip>
);
} else {
return <div>{component}</div>;
}
};
export const MenuList = (props: any) => { export const MenuList = (props: any) => {
return ( return (
<components.MenuList {...props}> <components.MenuList {...props}>
@@ -81,6 +108,28 @@ export class Select extends PureComponent<CommonProps & SelectProps> {
isLoading: false, isLoading: false,
backspaceRemovesValue: true, backspaceRemovesValue: true,
maxMenuHeight: 300, maxMenuHeight: 300,
menuIsOpen: false,
components: {
Option: SelectOption,
SingleValue,
IndicatorsContainer,
MenuList,
Group: SelectOptionGroup,
},
};
onOpenMenu = () => {
const { onOpenMenu } = this.props;
if (onOpenMenu) {
onOpenMenu();
}
};
onCloseMenu = () => {
const { onCloseMenu } = this.props;
if (onCloseMenu) {
onCloseMenu();
}
}; };
render() { render() {
@@ -105,6 +154,9 @@ export class Select extends PureComponent<CommonProps & SelectProps> {
onBlur, onBlur,
maxMenuHeight, maxMenuHeight,
noOptionsMessage, noOptionsMessage,
isOpen,
components,
tooltipContent,
} = this.props; } = this.props;
let widthClass = ''; let widthClass = '';
@@ -113,18 +165,12 @@ export class Select extends PureComponent<CommonProps & SelectProps> {
} }
const selectClassNames = classNames('gf-form-input', 'gf-form-input--form-dropdown', widthClass, className); const selectClassNames = classNames('gf-form-input', 'gf-form-input--form-dropdown', widthClass, className);
const selectComponents = { ...Select.defaultProps.components, ...components };
return ( return wrapInTooltip(
<ReactSelect <ReactSelect
classNamePrefix="gf-form-select-box" classNamePrefix="gf-form-select-box"
className={selectClassNames} className={selectClassNames}
components={{ components={selectComponents}
Option: SelectOption,
SingleValue,
IndicatorsContainer,
MenuList,
Group: SelectOptionGroup,
}}
defaultValue={defaultValue} defaultValue={defaultValue}
value={value} value={value}
getOptionLabel={getOptionLabel} getOptionLabel={getOptionLabel}
@@ -145,7 +191,12 @@ export class Select extends PureComponent<CommonProps & SelectProps> {
noOptionsMessage={noOptionsMessage} noOptionsMessage={noOptionsMessage}
isMulti={isMulti} isMulti={isMulti}
backspaceRemovesValue={backspaceRemovesValue} backspaceRemovesValue={backspaceRemovesValue}
/> menuIsOpen={isOpen}
onMenuOpen={this.onOpenMenu}
onMenuClose={this.onCloseMenu}
/>,
tooltipContent,
isOpen
); );
} }
} }
@@ -190,6 +241,7 @@ export class AsyncSelect extends PureComponent<CommonProps & AsyncProps> {
openMenuOnFocus, openMenuOnFocus,
maxMenuHeight, maxMenuHeight,
isMulti, isMulti,
tooltipContent,
} = this.props; } = this.props;
let widthClass = ''; let widthClass = '';
@@ -199,7 +251,7 @@ export class AsyncSelect extends PureComponent<CommonProps & AsyncProps> {
const selectClassNames = classNames('gf-form-input', 'gf-form-input--form-dropdown', widthClass, className); const selectClassNames = classNames('gf-form-input', 'gf-form-input--form-dropdown', widthClass, className);
return ( return wrapInTooltip(
<ReactAsyncSelect <ReactAsyncSelect
classNamePrefix="gf-form-select-box" classNamePrefix="gf-form-select-box"
className={selectClassNames} className={selectClassNames}
@@ -231,7 +283,9 @@ export class AsyncSelect extends PureComponent<CommonProps & AsyncProps> {
maxMenuHeight={maxMenuHeight} maxMenuHeight={maxMenuHeight}
isMulti={isMulti} isMulti={isMulti}
backspaceRemovesValue={backspaceRemovesValue} backspaceRemovesValue={backspaceRemovesValue}
/> />,
tooltipContent,
false
); );
} }
} }

View File

@@ -6,7 +6,7 @@ import { components } from '@torkelo/react-select';
import { OptionProps } from 'react-select/lib/components/Option'; import { OptionProps } from 'react-select/lib/components/Option';
// https://github.com/JedWatson/react-select/issues/3038 // https://github.com/JedWatson/react-select/issues/3038
interface ExtendedOptionProps extends OptionProps<any> { export interface ExtendedOptionProps extends OptionProps<any> {
data: { data: {
description?: string; description?: string;
imgUrl?: string; imgUrl?: string;

View File

@@ -189,3 +189,7 @@ $select-input-bg-disabled: $input-bg-disabled;
padding-right: 2px; padding-right: 2px;
} }
} }
.gf-form-select-box-button-select {
height: auto;
}

View File

@@ -0,0 +1,49 @@
import { PureComponent } from 'react';
import { stringToMs } from '../../utils/string';
interface Props {
func: () => any; // TODO
interval: string;
}
export class SetInterval extends PureComponent<Props> {
private intervalId = 0;
componentDidMount() {
this.addInterval();
}
componentDidUpdate(prevProps: Props) {
const { interval } = this.props;
if (interval !== prevProps.interval) {
this.clearInterval();
this.addInterval();
}
}
componentWillUnmount() {
this.clearInterval();
}
addInterval = () => {
const { func, interval } = this.props;
if (interval) {
func().then(() => {
if (interval) {
this.intervalId = window.setTimeout(() => {
this.addInterval();
}, stringToMs(interval));
}
});
}
};
clearInterval = () => {
window.clearTimeout(this.intervalId);
};
render() {
return null;
}
}

View File

@@ -0,0 +1,220 @@
import React from 'react';
import moment, { Moment } from 'moment';
import { storiesOf } from '@storybook/react';
import { action } from '@storybook/addon-actions';
import { TimePicker } from './TimePicker';
import { UseState } from '../../utils/storybook/UseState';
import { withRighAlignedStory } from '../../utils/storybook/withRightAlignedStory';
const TimePickerStories = storiesOf('UI/TimePicker', module);
export const popoverOptions = {
'0': [
{
from: 'now-2d',
to: 'now',
display: 'Last 2 days',
section: 0,
active: false,
},
{
from: 'now-7d',
to: 'now',
display: 'Last 7 days',
section: 0,
active: false,
},
{
from: 'now-30d',
to: 'now',
display: 'Last 30 days',
section: 0,
active: false,
},
{
from: 'now-90d',
to: 'now',
display: 'Last 90 days',
section: 0,
active: false,
},
{
from: 'now-6M',
to: 'now',
display: 'Last 6 months',
section: 0,
active: false,
},
{
from: 'now-1y',
to: 'now',
display: 'Last 1 year',
section: 0,
active: false,
},
{
from: 'now-2y',
to: 'now',
display: 'Last 2 years',
section: 0,
active: false,
},
{
from: 'now-5y',
to: 'now',
display: 'Last 5 years',
section: 0,
active: false,
},
],
'1': [
{
from: 'now-1d/d',
to: 'now-1d/d',
display: 'Yesterday',
section: 1,
active: false,
},
{
from: 'now-2d/d',
to: 'now-2d/d',
display: 'Day before yesterday',
section: 1,
active: false,
},
{
from: 'now-7d/d',
to: 'now-7d/d',
display: 'This day last week',
section: 1,
active: false,
},
{
from: 'now-1w/w',
to: 'now-1w/w',
display: 'Previous week',
section: 1,
active: false,
},
{
from: 'now-1M/M',
to: 'now-1M/M',
display: 'Previous month',
section: 1,
active: false,
},
{
from: 'now-1y/y',
to: 'now-1y/y',
display: 'Previous year',
section: 1,
active: false,
},
],
'2': [
{
from: 'now/d',
to: 'now/d',
display: 'Today',
section: 2,
active: true,
},
{
from: 'now/d',
to: 'now',
display: 'Today so far',
section: 2,
active: false,
},
{
from: 'now/w',
to: 'now/w',
display: 'This week',
section: 2,
active: false,
},
{
from: 'now/w',
to: 'now',
display: 'This week so far',
section: 2,
active: false,
},
{
from: 'now/M',
to: 'now/M',
display: 'This month',
section: 2,
active: false,
},
{
from: 'now/M',
to: 'now',
display: 'This month so far',
section: 2,
active: false,
},
{
from: 'now/y',
to: 'now/y',
display: 'This year',
section: 2,
active: false,
},
{
from: 'now/y',
to: 'now',
display: 'This year so far',
section: 2,
active: false,
},
],
};
TimePickerStories.addDecorator(withRighAlignedStory);
TimePickerStories.add('default', () => {
return (
<UseState
initialState={{
from: moment(),
to: moment(),
raw: { from: 'now-6h' as string | Moment, to: 'now' as string | Moment },
}}
>
{(value, updateValue) => {
return (
<TimePicker
isTimezoneUtc={false}
value={value}
tooltipContent="TimePicker tooltip"
selectOptions={[
{ from: 'now-5m', to: 'now', display: 'Last 5 minutes', section: 3, active: false },
{ from: 'now-15m', to: 'now', display: 'Last 15 minutes', section: 3, active: false },
{ from: 'now-30m', to: 'now', display: 'Last 30 minutes', section: 3, active: false },
{ from: 'now-1h', to: 'now', display: 'Last 1 hour', section: 3, active: false },
{ from: 'now-3h', to: 'now', display: 'Last 3 hours', section: 3, active: false },
{ from: 'now-6h', to: 'now', display: 'Last 6 hours', section: 3, active: false },
{ from: 'now-12h', to: 'now', display: 'Last 12 hours', section: 3, active: false },
{ from: 'now-24h', to: 'now', display: 'Last 24 hours', section: 3, active: false },
]}
popoverOptions={popoverOptions}
onChange={timeRange => {
action('onChange fired')(timeRange);
updateValue(timeRange);
}}
onMoveBackward={() => {
action('onMoveBackward fired')();
}}
onMoveForward={() => {
action('onMoveForward fired')();
}}
onZoom={() => {
action('onZoom fired')();
}}
/>
);
}}
</UseState>
);
});

View File

@@ -0,0 +1,300 @@
import React, { PureComponent } from 'react';
import moment from 'moment';
import { TimeRange, TimeOptions, TimeOption, SelectOptionItem } from '@grafana/ui';
import { ButtonSelect } from '@grafana/ui/src/components/Select/ButtonSelect';
import { mapTimeOptionToTimeRange, mapTimeRangeToRangeString } from './time';
import { Props as TimePickerPopoverProps } from './TimePickerPopover';
import { TimePickerOptionGroup } from './TimePickerOptionGroup';
import { PopperContent } from '@grafana/ui/src/components/Tooltip/PopperController';
import { Timezone } from '../../../../../public/app/core/utils/datemath';
export interface Props {
value: TimeRange;
isTimezoneUtc: boolean;
popoverOptions: TimeOptions;
selectOptions: TimeOption[];
timezone?: Timezone;
onChange: (timeRange: TimeRange) => void;
onMoveBackward: () => void;
onMoveForward: () => void;
onZoom: () => void;
tooltipContent?: PopperContent<any>;
}
const defaultSelectOptions = [
{ from: 'now-5m', to: 'now', display: 'Last 5 minutes', section: 3, active: false },
{ from: 'now-15m', to: 'now', display: 'Last 15 minutes', section: 3, active: false },
{ from: 'now-30m', to: 'now', display: 'Last 30 minutes', section: 3, active: false },
{ from: 'now-1h', to: 'now', display: 'Last 1 hour', section: 3, active: false },
{ from: 'now-3h', to: 'now', display: 'Last 3 hours', section: 3, active: false },
{ from: 'now-6h', to: 'now', display: 'Last 6 hours', section: 3, active: false },
{ from: 'now-12h', to: 'now', display: 'Last 12 hours', section: 3, active: false },
{ from: 'now-24h', to: 'now', display: 'Last 24 hours', section: 3, active: false },
];
const defaultPopoverOptions = {
'0': [
{
from: 'now-2d',
to: 'now',
display: 'Last 2 days',
section: 0,
active: false,
},
{
from: 'now-7d',
to: 'now',
display: 'Last 7 days',
section: 0,
active: false,
},
{
from: 'now-30d',
to: 'now',
display: 'Last 30 days',
section: 0,
active: false,
},
{
from: 'now-90d',
to: 'now',
display: 'Last 90 days',
section: 0,
active: false,
},
{
from: 'now-6M',
to: 'now',
display: 'Last 6 months',
section: 0,
active: false,
},
{
from: 'now-1y',
to: 'now',
display: 'Last 1 year',
section: 0,
active: false,
},
{
from: 'now-2y',
to: 'now',
display: 'Last 2 years',
section: 0,
active: false,
},
{
from: 'now-5y',
to: 'now',
display: 'Last 5 years',
section: 0,
active: false,
},
],
'1': [
{
from: 'now-1d/d',
to: 'now-1d/d',
display: 'Yesterday',
section: 1,
active: false,
},
{
from: 'now-2d/d',
to: 'now-2d/d',
display: 'Day before yesterday',
section: 1,
active: false,
},
{
from: 'now-7d/d',
to: 'now-7d/d',
display: 'This day last week',
section: 1,
active: false,
},
{
from: 'now-1w/w',
to: 'now-1w/w',
display: 'Previous week',
section: 1,
active: false,
},
{
from: 'now-1M/M',
to: 'now-1M/M',
display: 'Previous month',
section: 1,
active: false,
},
{
from: 'now-1y/y',
to: 'now-1y/y',
display: 'Previous year',
section: 1,
active: false,
},
],
'2': [
{
from: 'now/d',
to: 'now/d',
display: 'Today',
section: 2,
active: true,
},
{
from: 'now/d',
to: 'now',
display: 'Today so far',
section: 2,
active: false,
},
{
from: 'now/w',
to: 'now/w',
display: 'This week',
section: 2,
active: false,
},
{
from: 'now/w',
to: 'now',
display: 'This week so far',
section: 2,
active: false,
},
{
from: 'now/M',
to: 'now/M',
display: 'This month',
section: 2,
active: false,
},
{
from: 'now/M',
to: 'now',
display: 'This month so far',
section: 2,
active: false,
},
{
from: 'now/y',
to: 'now/y',
display: 'This year',
section: 2,
active: false,
},
{
from: 'now/y',
to: 'now',
display: 'This year so far',
section: 2,
active: false,
},
],
};
export interface State {
isMenuOpen: boolean;
}
export class TimePicker extends PureComponent<Props, State> {
static defaultSelectOptions = defaultSelectOptions;
static defaultPopoverOptions = defaultPopoverOptions;
state: State = {
isMenuOpen: false,
};
mapTimeOptionsToSelectOptionItems = (selectOptions: TimeOption[]) => {
const { value, popoverOptions, isTimezoneUtc, timezone } = this.props;
const options = selectOptions.map(timeOption => {
return { label: timeOption.display, value: timeOption };
});
const popoverProps: TimePickerPopoverProps = {
value,
options: popoverOptions,
isTimezoneUtc,
timezone,
};
return [
{
label: 'Custom',
expanded: true,
options,
onPopoverOpen: () => undefined,
onPopoverClose: (timeRange: TimeRange) => this.onPopoverClose(timeRange),
popoverProps,
},
];
};
onSelectChanged = (item: SelectOptionItem) => {
const { isTimezoneUtc, onChange, timezone } = this.props;
onChange(mapTimeOptionToTimeRange(item.value, isTimezoneUtc, timezone));
};
onChangeMenuOpenState = (isOpen: boolean) => {
this.setState({
isMenuOpen: isOpen,
});
};
onOpenMenu = () => this.onChangeMenuOpenState(true);
onCloseMenu = () => this.onChangeMenuOpenState(false);
onPopoverClose = (timeRange: TimeRange) => {
const { onChange } = this.props;
onChange(timeRange);
// Here we should also close the Select but no sure how to solve this without introducing state in this component
// Edit: State introduced
this.onCloseMenu();
};
render() {
const {
selectOptions: selectTimeOptions,
value,
onMoveBackward,
onMoveForward,
onZoom,
tooltipContent,
} = this.props;
const options = this.mapTimeOptionsToSelectOptionItems(selectTimeOptions);
const rangeString = mapTimeRangeToRangeString(value);
const isAbsolute = moment.isMoment(value.raw.to);
return (
<div className="time-picker">
<div className="time-picker-buttons">
{isAbsolute && (
<button className="btn navbar-button navbar-button--tight" onClick={onMoveBackward}>
<i className="fa fa-chevron-left" />
</button>
)}
<ButtonSelect
className="time-picker-button-select"
value={value}
label={rangeString}
options={options}
onChange={this.onSelectChanged}
components={{ Group: TimePickerOptionGroup }}
iconClass={'fa fa-clock-o fa-fw'}
tooltipContent={tooltipContent}
isMenuOpen={this.state.isMenuOpen}
onOpenMenu={this.onOpenMenu}
onCloseMenu={this.onCloseMenu}
/>
{isAbsolute && (
<button className="btn navbar-button navbar-button--tight" onClick={onMoveForward}>
<i className="fa fa-chevron-right" />
</button>
)}
<button className="btn navbar-button navbar-button--zoom" onClick={onZoom}>
<i className="fa fa-search-minus" />
</button>
</div>
</div>
);
}
}

View File

@@ -0,0 +1,29 @@
import React from 'react';
import { storiesOf } from '@storybook/react';
import { action } from '@storybook/addon-actions';
import { Moment } from 'moment';
import { withCenteredStory } from '../../utils/storybook/withCenteredStory';
import { TimePickerCalendar } from './TimePickerCalendar';
import { UseState } from '../../utils/storybook/UseState';
const TimePickerCalendarStories = storiesOf('UI/TimePicker/TimePickerCalendar', module);
TimePickerCalendarStories.addDecorator(withCenteredStory);
TimePickerCalendarStories.add('default', () => (
<UseState initialState={'now-6h' as string | Moment}>
{(value, updateValue) => {
return (
<TimePickerCalendar
isTimezoneUtc={false}
value={value}
onChange={timeRange => {
action('onChange fired')(timeRange);
updateValue(timeRange);
}}
/>
);
}}
</UseState>
));

View File

@@ -0,0 +1,46 @@
import React, { PureComponent } from 'react';
import Calendar from 'react-calendar/dist/entry.nostyle';
import moment, { Moment } from 'moment';
import { TimeFragment } from '@grafana/ui';
import { Timezone } from '../../../../../public/app/core/utils/datemath';
import { stringToMoment } from './time';
export interface Props {
value: TimeFragment;
isTimezoneUtc: boolean;
roundup?: boolean;
timezone?: Timezone;
onChange: (value: Moment) => void;
}
export class TimePickerCalendar extends PureComponent<Props> {
onCalendarChange = (date: Date | Date[]) => {
const { onChange } = this.props;
if (Array.isArray(date)) {
return;
}
onChange(moment(date));
};
render() {
const { value, isTimezoneUtc, roundup, timezone } = this.props;
const dateValue = moment.isMoment(value)
? value.toDate()
: stringToMoment(value, isTimezoneUtc, roundup, timezone).toDate();
const calendarValue = dateValue instanceof Date && !isNaN(dateValue.getTime()) ? dateValue : moment().toDate();
return (
<Calendar
value={calendarValue}
next2Label={null}
prev2Label={null}
className="time-picker-calendar"
tileClassName="time-picker-calendar-tile"
onChange={this.onCalendarChange}
/>
);
}
}

View File

@@ -0,0 +1,60 @@
import React, { PureComponent, ChangeEvent } from 'react';
import moment from 'moment';
import { TimeFragment, TIME_FORMAT, Input } from '@grafana/ui';
import { stringToMoment, isValidTimeString } from './time';
export interface Props {
value: TimeFragment;
isTimezoneUtc: boolean;
roundup?: boolean;
timezone?: string;
onChange: (value: string, isValid: boolean) => void;
}
export class TimePickerInput extends PureComponent<Props> {
isValid = (value: string) => {
const { isTimezoneUtc } = this.props;
if (value.indexOf('now') !== -1) {
const isValid = isValidTimeString(value);
return isValid;
}
const parsed = stringToMoment(value, isTimezoneUtc);
const isValid = parsed.isValid();
return isValid;
};
onChange = (event: ChangeEvent<HTMLInputElement>) => {
const { onChange } = this.props;
const value = event.target.value;
onChange(value, this.isValid(value));
};
valueToString = (value: TimeFragment) => {
if (moment.isMoment(value)) {
return value.format(TIME_FORMAT);
} else {
return value;
}
};
render() {
const { value } = this.props;
const valueString = this.valueToString(value);
const error = !this.isValid(valueString);
return (
<Input
type="text"
onChange={this.onChange}
onBlur={this.onChange}
hideErrorMessage={true}
value={valueString}
className={`time-picker-input${error ? '-error' : ''}`}
/>
);
}
}

View File

@@ -0,0 +1,51 @@
import React, { ComponentType } from 'react';
import { storiesOf } from '@storybook/react';
import moment from 'moment';
import { action } from '@storybook/addon-actions';
import { TimePickerOptionGroup } from './TimePickerOptionGroup';
import { TimeRange } from '../../types/time';
import { withRighAlignedStory } from '../../utils/storybook/withRightAlignedStory';
import { popoverOptions } from './TimePicker.story';
const TimePickerOptionGroupStories = storiesOf('UI/TimePicker/TimePickerOptionGroup', module);
TimePickerOptionGroupStories.addDecorator(withRighAlignedStory);
const data = {
isPopoverOpen: false,
onPopoverOpen: () => {
action('onPopoverOpen fired')();
},
onPopoverClose: (timeRange: TimeRange) => {
action('onPopoverClose fired')(timeRange);
},
popoverProps: {
value: { from: moment(), to: moment(), raw: { from: 'now/d', to: 'now/d' } },
options: popoverOptions,
isTimezoneUtc: false,
onChange: (timeRange: TimeRange) => {
action('onChange fired')(timeRange);
},
},
};
TimePickerOptionGroupStories.add('default', () => (
<TimePickerOptionGroup
clearValue={() => {}}
className={''}
cx={() => {}}
getStyles={(name, props) => ({})}
getValue={() => {}}
hasValue
isMulti={false}
options={[]}
selectOption={() => {}}
selectProps={''}
setValue={(value, action) => {}}
label={'Custom'}
children={null}
Heading={(null as any) as ComponentType<any>}
data={data}
/>
));

View File

@@ -0,0 +1,66 @@
import React, { PureComponent, createRef } from 'react';
import { GroupProps } from 'react-select/lib/components/Group';
import { Popper } from '@grafana/ui/src/components/Tooltip/Popper';
import { Props as TimePickerProps, TimePickerPopover } from './TimePickerPopover';
import { TimeRange } from '@grafana/ui';
export interface DataProps {
onPopoverOpen: () => void;
onPopoverClose: (timeRange: TimeRange) => void;
popoverProps: TimePickerProps;
}
interface Props extends GroupProps<any> {
data: DataProps;
}
interface State {
isPopoverOpen: boolean;
}
export class TimePickerOptionGroup extends PureComponent<Props, State> {
pickerTriggerRef = createRef<HTMLDivElement>();
state: State = { isPopoverOpen: false };
onClick = () => {
this.setState({ isPopoverOpen: true });
this.props.data.onPopoverOpen();
};
render() {
const { children, label } = this.props;
const { isPopoverOpen } = this.state;
const { onPopoverClose } = this.props.data;
const popover = TimePickerPopover;
const popoverElement = React.createElement(popover, {
...this.props.data.popoverProps,
onChange: (timeRange: TimeRange) => {
onPopoverClose(timeRange);
this.setState({ isPopoverOpen: false });
},
});
return (
<>
<div className="gf-form-select-box__option-group">
<div className="gf-form-select-box__option-group__header" ref={this.pickerTriggerRef} onClick={this.onClick}>
<span className="flex-grow-1">{label}</span>
<i className="fa fa-calendar fa-fw" />
</div>
{children}
</div>
<div>
{this.pickerTriggerRef.current && (
<Popper
show={isPopoverOpen}
content={popoverElement}
referenceElement={this.pickerTriggerRef.current}
placement={'left-start'}
wrapperClassName="time-picker-popover-popper"
/>
)}
</div>
</>
);
}
}

View File

@@ -0,0 +1,37 @@
import React from 'react';
import { action } from '@storybook/addon-actions';
import moment, { Moment } from 'moment';
import { storiesOf } from '@storybook/react';
import { withCenteredStory } from '../../utils/storybook/withCenteredStory';
import { TimePickerPopover } from './TimePickerPopover';
import { UseState } from '../../utils/storybook/UseState';
import { popoverOptions } from './TimePicker.story';
const TimePickerPopoverStories = storiesOf('UI/TimePicker/TimePickerPopover', module);
TimePickerPopoverStories.addDecorator(withCenteredStory);
TimePickerPopoverStories.add('default', () => (
<UseState
initialState={{
from: moment(),
to: moment(),
raw: { from: 'now-6h' as string | Moment, to: 'now' as string | Moment },
}}
>
{(value, updateValue) => {
return (
<TimePickerPopover
value={value}
isTimezoneUtc={false}
onChange={timeRange => {
action('onChange fired')(timeRange);
updateValue(timeRange);
}}
options={popoverOptions}
/>
);
}}
</UseState>
));

View File

@@ -0,0 +1,169 @@
import React, { Component, SyntheticEvent } from 'react';
import { TimeRange, TimeOptions, TimeOption } from '@grafana/ui';
import { Moment } from 'moment';
import { TimePickerCalendar } from './TimePickerCalendar';
import { TimePickerInput } from './TimePickerInput';
import { mapTimeOptionToTimeRange } from './time';
import { Timezone } from '../../../../../public/app/core/utils/datemath';
export interface Props {
value: TimeRange;
options: TimeOptions;
isTimezoneUtc: boolean;
timezone?: Timezone;
onChange?: (timeRange: TimeRange) => void;
}
export interface State {
value: TimeRange;
isFromInputValid: boolean;
isToInputValid: boolean;
}
export class TimePickerPopover extends Component<Props, State> {
static popoverClassName = 'time-picker-popover';
constructor(props: Props) {
super(props);
this.state = { value: props.value, isFromInputValid: true, isToInputValid: true };
}
onFromInputChanged = (value: string, valid: boolean) => {
this.setState({
value: { ...this.state.value, raw: { ...this.state.value.raw, from: value } },
isFromInputValid: valid,
});
};
onToInputChanged = (value: string, valid: boolean) => {
this.setState({
value: { ...this.state.value, raw: { ...this.state.value.raw, to: value } },
isToInputValid: valid,
});
};
onFromCalendarChanged = (value: Moment) => {
this.setState({
value: { ...this.state.value, raw: { ...this.state.value.raw, from: value } },
});
};
onToCalendarChanged = (value: Moment) => {
this.setState({
value: { ...this.state.value, raw: { ...this.state.value.raw, to: value } },
});
};
onTimeOptionClick = (timeOption: TimeOption) => {
const { isTimezoneUtc, timezone, onChange } = this.props;
if (onChange) {
onChange(mapTimeOptionToTimeRange(timeOption, isTimezoneUtc, timezone));
}
};
onApplyClick = () => {
const { onChange } = this.props;
if (onChange) {
onChange(this.state.value);
}
};
render() {
const { options, isTimezoneUtc, timezone } = this.props;
const { isFromInputValid, isToInputValid, value } = this.state;
const isValid = isFromInputValid && isToInputValid;
return (
<div className={TimePickerPopover.popoverClassName}>
<div className="time-picker-popover-box">
<div className="time-picker-popover-box-header">
<span className="time-picker-popover-box-title">Quick ranges</span>
</div>
<div className="time-picker-popover-box-body">
{Object.keys(options).map(key => {
return (
<ul key={`popover-quickranges-${key}`}>
{options[key].map(timeOption => (
<li
key={`popover-timeoption-${timeOption.from}-${timeOption.to}`}
className={timeOption.active ? 'active' : ''}
>
<a
onClick={(event: SyntheticEvent) => {
event.preventDefault();
this.onTimeOptionClick(timeOption);
}}
>
{timeOption.display}
</a>
</li>
))}
</ul>
);
})}
</div>
</div>
<div className="time-picker-popover-box">
<div className="time-picker-popover-box-header">
<span className="time-picker-popover-box-title">Custom range</span>
</div>
<div className="time-picker-popover-box-body">
<div className="time-picker-popover-box-body-custom-ranges">
<div className="time-picker-popover-box-body-custom-ranges-input">
<span>From:</span>
<TimePickerInput
isTimezoneUtc={isTimezoneUtc}
roundup={false}
timezone={timezone}
value={value.raw.from}
onChange={this.onFromInputChanged}
/>
</div>
<div className="time-picker-popover-box-body-custom-ranges-calendar">
<TimePickerCalendar
isTimezoneUtc={isTimezoneUtc}
roundup={false}
timezone={timezone}
value={value.raw.from}
onChange={this.onFromCalendarChanged}
/>
</div>
</div>
<div className="time-picker-popover-box-body-custom-ranges">
<div className="time-picker-popover-box-body-custom-ranges-input">
<span>To:</span>
<TimePickerInput
isTimezoneUtc={isTimezoneUtc}
roundup={true}
timezone={timezone}
value={value.raw.to}
onChange={this.onToInputChanged}
/>
</div>
<div className="time-picker-popover-box-body-custom-ranges-calendar">
<TimePickerCalendar
isTimezoneUtc={isTimezoneUtc}
roundup={true}
timezone={timezone}
value={value.raw.to}
onChange={this.onToCalendarChanged}
/>
</div>
</div>
</div>
<div className="time-picker-popover-box-footer">
<button
type="submit"
className="btn gf-form-btn btn-success"
disabled={!isValid}
onClick={this.onApplyClick}
>
Apply
</button>
</div>
</div>
</div>
);
}
}

View File

@@ -0,0 +1,189 @@
.time-picker {
display: flex;
flex-flow: column nowrap;
.time-picker-buttons {
display: flex;
}
}
.time-picker-popover-popper {
z-index: $zindex-timepicker-popover;
}
.time-picker-popover {
display: flex;
flex-flow: row nowrap;
justify-content: space-around;
border: 1px solid $popover-border-color;
border-radius: $border-radius;
background-color: $popover-border-color;
color: $popover-color;
.time-picker-popover-box {
max-width: 500px;
padding: 20px;
ul {
padding-right: $spacer;
padding-top: $spacer;
list-style-type: none;
li {
line-height: 22px;
display: list-item;
text-align: left;
}
li.active {
border-bottom: 1px solid $blue;
font-weight: $font-weight-semi-bold;
}
}
.time-picker-popover-box-body {
display: flex;
flex-flow: row nowrap;
justify-content: space-around;
}
}
.time-picker-popover-box-title {
font-size: $font-size-lg;
font-weight: $font-weight-semi-bold;
}
.time-picker-popover-box:first-child {
border-right: 1px ridge;
}
.time-picker-popover-box-body-custom-ranges:first-child {
margin-right: $spacer;
}
.time-picker-popover-box-body-custom-ranges-input {
display: flex;
flex-flow: row nowrap;
align-items: center;
margin: $spacer 0;
.our-custom-wrapper-class {
margin-left: $spacer;
width: 100%;
.time-picker-input-error {
box-shadow: inset 0 0px 5px $red;
}
}
}
.time-picker-popover-box-footer {
display: flex;
flex-flow: row nowrap;
justify-content: flex-end;
margin-top: $spacer;
}
}
.time-picker-calendar {
border: 1px solid $popover-border-color;
max-width: 220px;
color: $black;
.react-calendar__navigation__label,
.react-calendar__navigation__arrow,
.react-calendar__navigation {
color: $input-color;
background-color: $input-bg;
border: 0;
}
.react-calendar__month-view__weekdays {
background-color: $popover-border-color;
text-align: center;
abbr {
border: 0;
text-decoration: none;
cursor: default;
color: $popover-color;
font-weight: $font-weight-semi-bold;
}
}
.time-picker-calendar-tile {
color: $input-color;
background-color: $input-bg;
border: 0;
line-height: 22px;
}
button.time-picker-calendar-tile:hover {
font-weight: $font-weight-semi-bold;
}
.react-calendar__navigation__label,
.react-calendar__navigation > button:focus,
.time-picker-calendar-tile:focus {
outline: 0;
}
.react-calendar__tile--now {
color: $orange;
}
.react-calendar__tile--active {
color: $blue;
font-weight: $font-weight-semi-bold;
}
}
@media only screen and (max-width: 1116px) {
.time-picker-popover {
margin-left: $spacer;
display: flex;
flex-flow: column nowrap;
.time-picker-popover-box {
padding: $spacer / 2 $spacer;
.time-picker-popover-box-title {
font-size: $font-size-md;
font-weight: $font-weight-semi-bold;
}
}
.time-picker-popover-box:first-child {
border-right: none;
border-bottom: 1px ridge;
}
.time-picker-popover-box:last-child {
.time-picker-popover-box-body {
display: flex;
flex-flow: column nowrap;
.time-picker-popover-box-body-custom-ranges:first-child {
margin: 0;
}
}
}
.time-picker-popover-box-footer {
display: flex;
flex-flow: row nowrap;
justify-content: flex-end;
margin-top: $spacer;
}
}
.time-picker-calendar {
max-width: 500px;
width: 100%;
}
}
@media only screen and (max-width: 746px) {
.time-picker-popover {
margin-top: 48px;
}
}

View File

@@ -0,0 +1,44 @@
import moment, { Moment } from 'moment';
import { TimeOption, TimeRange, TIME_FORMAT } from '@grafana/ui';
import * as dateMath from '../../../../../public/app/core/utils/datemath';
import { describeTimeRange } from '../../../../../public/app/core/utils/rangeutil';
export const mapTimeOptionToTimeRange = (
timeOption: TimeOption,
isTimezoneUtc: boolean,
timezone?: dateMath.Timezone
): TimeRange => {
const fromMoment = stringToMoment(timeOption.from, isTimezoneUtc, false, timezone);
const toMoment = stringToMoment(timeOption.to, isTimezoneUtc, true, timezone);
return { from: fromMoment, to: toMoment, raw: { from: timeOption.from, to: timeOption.to } };
};
export const stringToMoment = (
value: string,
isTimezoneUtc: boolean,
roundUp?: boolean,
timezone?: dateMath.Timezone
): Moment => {
if (value.indexOf('now') !== -1) {
if (!dateMath.isValid(value)) {
return moment();
}
const parsed = dateMath.parse(value, roundUp, timezone);
return parsed || moment();
}
if (isTimezoneUtc) {
return moment.utc(value, TIME_FORMAT);
}
return moment(value, TIME_FORMAT);
};
export const mapTimeRangeToRangeString = (timeRange: TimeRange): string => {
return describeTimeRange(timeRange.raw);
};
export const isValidTimeString = (text: string) => dateMath.isValid(text);

View File

@@ -13,11 +13,18 @@ export const Tooltip = ({ children, theme, ...controllerProps }: TooltipProps) =
return ( return (
<PopperController {...controllerProps}> <PopperController {...controllerProps}>
{(showPopper, hidePopper, popperProps) => { {(showPopper, hidePopper, popperProps) => {
{
/* Override internal 'show' state if passed in as prop */
}
const payloadProps = {
...popperProps,
show: controllerProps.show !== undefined ? controllerProps.show : popperProps.show,
};
return ( return (
<> <>
{tooltipTriggerRef.current && ( {tooltipTriggerRef.current && (
<Popper <Popper
{...popperProps} {...payloadProps}
onMouseEnter={showPopper} onMouseEnter={showPopper}
onMouseLeave={hidePopper} onMouseLeave={hidePopper}
referenceElement={tooltipTriggerRef.current} referenceElement={tooltipTriggerRef.current}

View File

@@ -12,3 +12,5 @@
@import 'EmptySearchResult/EmptySearchResult'; @import 'EmptySearchResult/EmptySearchResult';
@import 'FormField/FormField'; @import 'FormField/FormField';
@import 'BarGauge/BarGauge'; @import 'BarGauge/BarGauge';
@import 'RefreshPicker/RefreshPicker';
@import 'TimePicker/TimePicker';

View File

@@ -12,6 +12,7 @@ export { Select, AsyncSelect, SelectOptionItem } from './Select/Select';
export { IndicatorsContainer } from './Select/IndicatorsContainer'; export { IndicatorsContainer } from './Select/IndicatorsContainer';
export { NoOptionsMessage } from './Select/NoOptionsMessage'; export { NoOptionsMessage } from './Select/NoOptionsMessage';
export { default as resetSelectStyles } from './Select/resetSelectStyles'; export { default as resetSelectStyles } from './Select/resetSelectStyles';
export { ButtonSelect } from './Select/ButtonSelect';
// Forms // Forms
export { FormLabel } from './FormLabel/FormLabel'; export { FormLabel } from './FormLabel/FormLabel';
@@ -31,6 +32,10 @@ export { PieChart, PieChartType } from './PieChart/PieChart';
export { UnitPicker } from './UnitPicker/UnitPicker'; export { UnitPicker } from './UnitPicker/UnitPicker';
export { StatsPicker } from './StatsPicker/StatsPicker'; export { StatsPicker } from './StatsPicker/StatsPicker';
export { Input, InputStatus } from './Input/Input'; export { Input, InputStatus } from './Input/Input';
export { RefreshPicker } from './RefreshPicker/RefreshPicker';
// Renderless
export { SetInterval } from './SetInterval/SetInterval';
export { Table } from './Table/Table'; export { Table } from './Table/Table';
export { TableInputCSV } from './Table/TableInputCSV'; export { TableInputCSV } from './Table/TableInputCSV';
@@ -41,6 +46,6 @@ export { Gauge } from './Gauge/Gauge';
export { Graph } from './Graph/Graph'; export { Graph } from './Graph/Graph';
export { BarGauge } from './BarGauge/BarGauge'; export { BarGauge } from './BarGauge/BarGauge';
export { VizRepeater } from './VizRepeater/VizRepeater'; export { VizRepeater } from './VizRepeater/VizRepeater';
export { ClickOutsideWrapper } from './ClickOutsideWrapper/ClickOutsideWrapper';
export * from './SingleStatShared/shared'; export * from './SingleStatShared/shared';
export { CallToActionCard } from './CallToActionCard/CallToActionCard'; export { CallToActionCard } from './CallToActionCard/CallToActionCard';

View File

@@ -171,6 +171,7 @@ $zindex-tooltip: ${theme.zIndex.tooltip};
$zindex-modal-backdrop: ${theme.zIndex.modalBackdrop}; $zindex-modal-backdrop: ${theme.zIndex.modalBackdrop};
$zindex-modal: ${theme.zIndex.modal}; $zindex-modal: ${theme.zIndex.modal};
$zindex-typeahead: ${theme.zIndex.typeahead}; $zindex-typeahead: ${theme.zIndex.typeahead};
$zindex-timepicker-popover: 1070;
// Buttons // Buttons
// //

View File

@@ -15,3 +15,19 @@ export interface IntervalValues {
interval: string; // 10s,5m interval: string; // 10s,5m
intervalMs: number; intervalMs: number;
} }
export interface TimeOption {
from: string;
to: string;
display: string;
section: number;
active: boolean;
}
export interface TimeOptions {
[key: string]: TimeOption[];
}
export type TimeFragment = string | Moment;
export const TIME_FORMAT = 'YYYY-MM-DD HH:mm:ss';

View File

@@ -12,3 +12,4 @@ export * from './logs';
export * from './labels'; export * from './labels';
export { getMappedValue } from './valueMappings'; export { getMappedValue } from './valueMappings';
export * from './validate'; export * from './validate';
export * from './object';

View File

@@ -0,0 +1,8 @@
export const objRemoveUndefined = (obj: any) => {
return Object.keys(obj).reduce((acc: any, key) => {
if (obj[key] !== undefined) {
acc[key] = obj[key];
}
return acc;
}, {});
};

View File

@@ -0,0 +1,20 @@
import React from 'react';
import { RenderFunction } from '@storybook/react';
const RightAlignedStory: React.FunctionComponent<{}> = ({ children }) => {
return (
<div
style={{
height: '100vh ',
display: 'flex',
alignItems: 'flex-start',
justifyContent: 'flex-end',
marginRight: '20px',
}}
>
{children}
</div>
);
};
export const withRighAlignedStory = (story: RenderFunction) => <RightAlignedStory>{story()}</RightAlignedStory>;

View File

@@ -1,4 +1,4 @@
import { stringToJsRegex } from '@grafana/ui'; import { stringToJsRegex, stringToMs } from '@grafana/ui';
describe('stringToJsRegex', () => { describe('stringToJsRegex', () => {
it('should parse the valid regex value', () => { it('should parse the valid regex value', () => {
@@ -13,3 +13,41 @@ describe('stringToJsRegex', () => {
}).toThrow(); }).toThrow();
}); });
}); });
describe('stringToMs', () => {
it('should return zero if no input', () => {
const output = stringToMs('');
expect(output).toBe(0);
});
it('should return its input, as int, if no unit is supplied', () => {
const output = stringToMs('1000');
expect(output).toBe(1000);
});
it('should convert 3s to 3000', () => {
const output = stringToMs('3s');
expect(output).toBe(3000);
});
it('should convert 2m to 120000', () => {
const output = stringToMs('2m');
expect(output).toBe(120000);
});
it('should convert 2h to 7200000', () => {
const output = stringToMs('2h');
expect(output).toBe(7200000);
});
it('should convert 2d to 172800000', () => {
const output = stringToMs('2d');
expect(output).toBe(172800000);
});
it('should throw on unsupported unit', () => {
expect(() => {
stringToMs('1y');
}).toThrow();
});
});

View File

@@ -1,3 +1,5 @@
import { SelectOptionItem } from './../components/Select/Select';
export function stringToJsRegex(str: string): RegExp { export function stringToJsRegex(str: string): RegExp {
if (str[0] !== '/') { if (str[0] !== '/') {
return new RegExp('^' + str + '$'); return new RegExp('^' + str + '$');
@@ -11,3 +13,39 @@ export function stringToJsRegex(str: string): RegExp {
return new RegExp(match[1], match[2]); return new RegExp(match[1], match[2]);
} }
export function stringToMs(str: string): number {
if (!str) {
return 0;
}
const nr = parseInt(str, 10);
const unit = str.substr(String(nr).length);
const s = 1000;
const m = s * 60;
const h = m * 60;
const d = h * 24;
switch (unit) {
case 's':
return nr * s;
case 'm':
return nr * m;
case 'h':
return nr * h;
case 'd':
return nr * d;
default:
if (!unit) {
return isNaN(nr) ? 0 : nr;
}
throw new Error('Not supported unit: ' + unit);
}
}
export function getIntervalFromString(strInterval: string): SelectOptionItem {
return {
label: strInterval,
value: stringToMs(strInterval),
};
}

View File

@@ -4,86 +4,88 @@ exports[`TeamPicker renders correctly 1`] = `
<div <div
className="user-picker" className="user-picker"
> >
<div <div>
className="css-0 gf-form-input gf-form-input--form-dropdown"
onKeyDown={[Function]}
>
<div <div
className="css-0 gf-form-select-box__control" className="css-0 gf-form-input gf-form-input--form-dropdown"
onMouseDown={[Function]} onKeyDown={[Function]}
onTouchEnd={[Function]}
> >
<div <div
className="css-0 gf-form-select-box__value-container" className="css-0 gf-form-select-box__control"
onMouseDown={[Function]}
onTouchEnd={[Function]}
> >
<div <div
className="css-0 gf-form-select-box__placeholder" className="css-0 gf-form-select-box__value-container"
>
Select a team
</div>
<div
className="css-0"
> >
<div <div
className="gf-form-select-box__input" className="css-0 gf-form-select-box__placeholder"
style={ >
Object { Select a team
"display": "inline-block", </div>
} <div
} className="css-0"
> >
<input
aria-autocomplete="list"
autoCapitalize="none"
autoComplete="off"
autoCorrect="off"
disabled={false}
id="react-select-2-input"
onBlur={[Function]}
onChange={[Function]}
onFocus={[Function]}
spellCheck="false"
style={
Object {
"background": 0,
"border": 0,
"boxSizing": "content-box",
"color": "inherit",
"fontSize": "inherit",
"opacity": 1,
"outline": 0,
"padding": 0,
"width": "1px",
}
}
tabIndex="0"
type="text"
value=""
/>
<div <div
className="gf-form-select-box__input"
style={ style={
Object { Object {
"height": 0, "display": "inline-block",
"left": 0,
"overflow": "scroll",
"position": "absolute",
"top": 0,
"visibility": "hidden",
"whiteSpace": "pre",
} }
} }
> >
<input
aria-autocomplete="list"
autoCapitalize="none"
autoComplete="off"
autoCorrect="off"
disabled={false}
id="react-select-2-input"
onBlur={[Function]}
onChange={[Function]}
onFocus={[Function]}
spellCheck="false"
style={
Object {
"background": 0,
"border": 0,
"boxSizing": "content-box",
"color": "inherit",
"fontSize": "inherit",
"opacity": 1,
"outline": 0,
"padding": 0,
"width": "1px",
}
}
tabIndex="0"
type="text"
value=""
/>
<div
style={
Object {
"height": 0,
"left": 0,
"overflow": "scroll",
"position": "absolute",
"top": 0,
"visibility": "hidden",
"whiteSpace": "pre",
}
}
>
</div>
</div> </div>
</div> </div>
</div> </div>
</div> <div
<div className="css-0 gf-form-select-box__indicators"
className="css-0 gf-form-select-box__indicators" >
> <span
<span className="gf-form-select-box__select-arrow "
className="gf-form-select-box__select-arrow " />
/> </div>
</div> </div>
</div> </div>
</div> </div>

View File

@@ -4,86 +4,88 @@ exports[`UserPicker renders correctly 1`] = `
<div <div
className="user-picker" className="user-picker"
> >
<div <div>
className="css-0 gf-form-input gf-form-input--form-dropdown"
onKeyDown={[Function]}
>
<div <div
className="css-0 gf-form-select-box__control" className="css-0 gf-form-input gf-form-input--form-dropdown"
onMouseDown={[Function]} onKeyDown={[Function]}
onTouchEnd={[Function]}
> >
<div <div
className="css-0 gf-form-select-box__value-container" className="css-0 gf-form-select-box__control"
onMouseDown={[Function]}
onTouchEnd={[Function]}
> >
<div <div
className="css-0 gf-form-select-box__placeholder" className="css-0 gf-form-select-box__value-container"
>
Select user
</div>
<div
className="css-0"
> >
<div <div
className="gf-form-select-box__input" className="css-0 gf-form-select-box__placeholder"
style={ >
Object { Select user
"display": "inline-block", </div>
} <div
} className="css-0"
> >
<input
aria-autocomplete="list"
autoCapitalize="none"
autoComplete="off"
autoCorrect="off"
disabled={false}
id="react-select-2-input"
onBlur={[Function]}
onChange={[Function]}
onFocus={[Function]}
spellCheck="false"
style={
Object {
"background": 0,
"border": 0,
"boxSizing": "content-box",
"color": "inherit",
"fontSize": "inherit",
"opacity": 1,
"outline": 0,
"padding": 0,
"width": "1px",
}
}
tabIndex="0"
type="text"
value=""
/>
<div <div
className="gf-form-select-box__input"
style={ style={
Object { Object {
"height": 0, "display": "inline-block",
"left": 0,
"overflow": "scroll",
"position": "absolute",
"top": 0,
"visibility": "hidden",
"whiteSpace": "pre",
} }
} }
> >
<input
aria-autocomplete="list"
autoCapitalize="none"
autoComplete="off"
autoCorrect="off"
disabled={false}
id="react-select-2-input"
onBlur={[Function]}
onChange={[Function]}
onFocus={[Function]}
spellCheck="false"
style={
Object {
"background": 0,
"border": 0,
"boxSizing": "content-box",
"color": "inherit",
"fontSize": "inherit",
"opacity": 1,
"outline": 0,
"padding": 0,
"width": "1px",
}
}
tabIndex="0"
type="text"
value=""
/>
<div
style={
Object {
"height": 0,
"left": 0,
"overflow": "scroll",
"position": "absolute",
"top": 0,
"visibility": "hidden",
"whiteSpace": "pre",
}
}
>
</div>
</div> </div>
</div> </div>
</div> </div>
</div> <div
<div className="css-0 gf-form-select-box__indicators"
className="css-0 gf-form-select-box__indicators" >
> <span
<span className="gf-form-select-box__select-arrow "
className="gf-form-select-box__select-arrow " />
/> </div>
</div> </div>
</div> </div>
</div> </div>

View File

@@ -1,9 +1,10 @@
// @ts-ignore
import _ from 'lodash'; import _ from 'lodash';
import moment from 'moment'; import moment from 'moment';
const units = ['y', 'M', 'w', 'd', 'h', 'm', 's']; const units = ['y', 'M', 'w', 'd', 'h', 'm', 's'];
type Timezone = 'utc'; export type Timezone = 'utc';
/** /**
* Parses different types input to a moment instance. There is a specific formatting language that can be used * Parses different types input to a moment instance. There is a specific formatting language that can be used
@@ -88,7 +89,8 @@ export function isValid(text: string | moment.Moment): boolean {
* @param time * @param time
* @param roundUp If true it will round the time to endOf time unit, otherwise to startOf time unit. * @param roundUp If true it will round the time to endOf time unit, otherwise to startOf time unit.
*/ */
export function parseDateMath(mathString: string, time: moment.Moment, roundUp?: boolean): moment.Moment | undefined { // TODO: Had to revert Andrejs `time: moment.Moment` to `time: any`
export function parseDateMath(mathString: string, time: any, roundUp?: boolean): moment.Moment | undefined {
const dateTime = time; const dateTime = time;
let i = 0; let i = 0;
const len = mathString.length; const len = mathString.length;

View File

@@ -46,7 +46,7 @@ describe('state functions', () => {
}); });
it('returns a valid Explore state from a compact URL parameter', () => { it('returns a valid Explore state from a compact URL parameter', () => {
const paramValue = '%5B"now-1h","now","Local",%7B"expr":"metric"%7D%5D'; const paramValue = '%5B"now-1h","now","Local","5m",%7B"expr":"metric"%7D,"ui"%5D';
expect(parseUrlState(paramValue)).toMatchObject({ expect(parseUrlState(paramValue)).toMatchObject({
datasource: 'Local', datasource: 'Local',
queries: [{ expr: 'metric' }], queries: [{ expr: 'metric' }],

View File

@@ -59,7 +59,7 @@ export async function getExploreUrl(
) { ) {
let exploreDatasource = panelDatasource; let exploreDatasource = panelDatasource;
let exploreTargets: DataQuery[] = panelTargets; let exploreTargets: DataQuery[] = panelTargets;
let url; let url: string;
// Mixed datasources need to choose only one datasource // Mixed datasources need to choose only one datasource
if (panelDatasource.meta.id === 'mixed' && panelTargets) { if (panelDatasource.meta.id === 'mixed' && panelTargets) {
@@ -191,7 +191,12 @@ export const safeParseJson = (text: string) => {
export function parseUrlState(initial: string | undefined): ExploreUrlState { export function parseUrlState(initial: string | undefined): ExploreUrlState {
const parsed = safeParseJson(initial); const parsed = safeParseJson(initial);
const errorResult = { datasource: null, queries: [], range: DEFAULT_RANGE, ui: DEFAULT_UI_STATE }; const errorResult = {
datasource: null,
queries: [],
range: DEFAULT_RANGE,
ui: DEFAULT_UI_STATE,
};
if (!parsed) { if (!parsed) {
return errorResult; return errorResult;

View File

@@ -1,3 +1,4 @@
// @ts-ignore
import _ from 'lodash'; import _ from 'lodash';
import moment from 'moment'; import moment from 'moment';
@@ -5,7 +6,7 @@ import { RawTimeRange } from '@grafana/ui';
import * as dateMath from './datemath'; import * as dateMath from './datemath';
const spans = { const spans: { [key: string]: { display: string; section?: number } } = {
s: { display: 'second' }, s: { display: 'second' },
m: { display: 'minute' }, m: { display: 'minute' },
h: { display: 'hour' }, h: { display: 'hour' },
@@ -63,12 +64,12 @@ const rangeOptions = [
const absoluteFormat = 'MMM D, YYYY HH:mm:ss'; const absoluteFormat = 'MMM D, YYYY HH:mm:ss';
const rangeIndex = {}; const rangeIndex: any = {};
_.each(rangeOptions, frame => { _.each(rangeOptions, (frame: any) => {
rangeIndex[frame.from + ' to ' + frame.to] = frame; rangeIndex[frame.from + ' to ' + frame.to] = frame;
}); });
export function getRelativeTimesList(timepickerSettings, currentDisplay) { export function getRelativeTimesList(timepickerSettings: any, currentDisplay: any) {
const groups = _.groupBy(rangeOptions, (option: any) => { const groups = _.groupBy(rangeOptions, (option: any) => {
option.active = option.display === currentDisplay; option.active = option.display === currentDisplay;
return option.section; return option.section;
@@ -84,7 +85,7 @@ export function getRelativeTimesList(timepickerSettings, currentDisplay) {
return groups; return groups;
} }
function formatDate(date) { function formatDate(date: any) {
return date.format(absoluteFormat); return date.format(absoluteFormat);
} }
@@ -144,12 +145,12 @@ export function describeTimeRange(range: RawTimeRange): string {
if (moment.isMoment(range.from)) { if (moment.isMoment(range.from)) {
const toMoment = dateMath.parse(range.to, true); const toMoment = dateMath.parse(range.to, true);
return formatDate(range.from) + ' to ' + toMoment.fromNow(); return toMoment ? formatDate(range.from) + ' to ' + toMoment.fromNow() : '';
} }
if (moment.isMoment(range.to)) { if (moment.isMoment(range.to)) {
const from = dateMath.parse(range.from, false); const from = dateMath.parse(range.from, false);
return from.fromNow() + ' to ' + formatDate(range.to); return from ? from.fromNow() + ' to ' + formatDate(range.to) : '';
} }
if (range.to.toString() === 'now') { if (range.to.toString() === 'now') {

View File

@@ -9,6 +9,7 @@ import { PlaylistSrv } from 'app/features/playlist/playlist_srv';
// Components // Components
import { DashNavButton } from './DashNavButton'; import { DashNavButton } from './DashNavButton';
import { DashNavTimeControls } from './DashNavTimeControls';
import { Tooltip } from '@grafana/ui'; import { Tooltip } from '@grafana/ui';
// State // State
@@ -16,8 +17,9 @@ import { updateLocation } from 'app/core/actions';
// Types // Types
import { DashboardModel } from '../../state'; import { DashboardModel } from '../../state';
import { StoreState } from 'app/types';
export interface Props { export interface OwnProps {
dashboard: DashboardModel; dashboard: DashboardModel;
editview: string; editview: string;
isEditing: boolean; isEditing: boolean;
@@ -27,6 +29,12 @@ export interface Props {
onAddPanel: () => void; onAddPanel: () => void;
} }
export interface StateProps {
location: any;
}
type Props = StateProps & OwnProps;
export class DashNav extends PureComponent<Props> { export class DashNav extends PureComponent<Props> {
timePickerEl: HTMLElement; timePickerEl: HTMLElement;
timepickerCmp: AngularComponent; timepickerCmp: AngularComponent;
@@ -39,7 +47,6 @@ export class DashNav extends PureComponent<Props> {
componentDidMount() { componentDidMount() {
const loader = getAngularLoader(); const loader = getAngularLoader();
const template = const template =
'<gf-time-picker class="gf-timepicker-nav" dashboard="dashboard" ng-if="!dashboard.timepicker.hidden" />'; '<gf-time-picker class="gf-timepicker-nav" dashboard="dashboard" ng-if="!dashboard.timepicker.hidden" />';
const scopeProps = { dashboard: this.props.dashboard }; const scopeProps = { dashboard: this.props.dashboard };
@@ -161,12 +168,10 @@ export class DashNav extends PureComponent<Props> {
} }
render() { render() {
const { dashboard, onAddPanel } = this.props; const { dashboard, onAddPanel, location } = this.props;
const { canStar, canSave, canShare, showSettings, isStarred } = dashboard.meta; const { canStar, canSave, canShare, showSettings, isStarred } = dashboard.meta;
const { snapshot } = dashboard; const { snapshot } = dashboard;
const snapshotUrl = snapshot && snapshot.originalUrl; const snapshotUrl = snapshot && snapshot.originalUrl;
return ( return (
<div className="navbar"> <div className="navbar">
{this.isInFullscreenOrSettings && this.renderBackButton()} {this.isInFullscreenOrSettings && this.renderBackButton()}
@@ -255,13 +260,20 @@ export class DashNav extends PureComponent<Props> {
/> />
</div> </div>
<div className="gf-timepicker-nav" ref={element => (this.timePickerEl = element)} /> {!dashboard.timepicker.hidden && (
<div className="navbar-buttons">
<DashNavTimeControls dashboard={dashboard} location={location} updateLocation={updateLocation} />
<div className="gf-timepicker-nav" ref={element => (this.timePickerEl = element)} />
</div>
)}
</div> </div>
); );
} }
} }
const mapStateToProps = () => ({}); const mapStateToProps = (state: StoreState) => ({
location: state.location,
});
const mapDispatchToProps = { const mapDispatchToProps = {
updateLocation, updateLocation,

View File

@@ -0,0 +1,53 @@
// Libaries
import React, { Component } from 'react';
// Types
import { DashboardModel } from '../../state';
import { LocationState } from 'app/types';
// State
import { updateLocation } from 'app/core/actions';
// Components
import { RefreshPicker } from '@grafana/ui';
// Utils & Services
import { getTimeSrv, TimeSrv } from 'app/features/dashboard/services/TimeSrv';
export interface Props {
dashboard: DashboardModel;
updateLocation: typeof updateLocation;
location: LocationState;
}
export class DashNavTimeControls extends Component<Props> {
timeSrv: TimeSrv = getTimeSrv();
get refreshParamInUrl(): string {
return this.props.location.query.refresh as string;
}
onChangeRefreshInterval = (interval: string) => {
this.timeSrv.setAutoRefresh(interval);
this.forceUpdate();
};
onRefresh = () => {
this.timeSrv.refreshDashboard();
return Promise.resolve();
};
render() {
const { dashboard } = this.props;
const intervals = dashboard.timepicker.refresh_intervals;
return (
<RefreshPicker
onIntervalChanged={this.onChangeRefreshInterval}
onRefresh={this.onRefresh}
value={dashboard.refresh}
intervals={intervals}
tooltip="Refresh dashboard"
/>
);
}
}

View File

@@ -108,7 +108,7 @@ export class TimePickerCtrl {
this.timeOptions = rangeUtil.getRelativeTimesList(this.panel, this.rangeString); this.timeOptions = rangeUtil.getRelativeTimesList(this.panel, this.rangeString);
this.refresh = { this.refresh = {
value: this.dashboard.refresh, value: this.dashboard.refresh,
options: _.map(this.panel.refresh_intervals, (interval: any) => { options: this.panel.refresh_intervals.map((interval: any) => {
return { text: interval, value: interval }; return { text: interval, value: interval };
}), }),
}; };

View File

@@ -1,27 +1,25 @@
<div class="navbar-buttons"> <button class="btn navbar-button navbar-button--tight" ng-click='ctrl.move(-1)' ng-if="ctrl.isAbsolute">
<button class="btn navbar-button navbar-button--tight" ng-click='ctrl.move(-1)' ng-if="ctrl.isAbsolute"> <i class="fa fa-chevron-left"></i>
<i class="fa fa-chevron-left"></i> </button>
</button>
<button bs-tooltip="ctrl.tooltip" data-placement="bottom" ng-click="ctrl.openDropdown()" class="btn navbar-button gf-timepicker-nav-btn"> <button bs-tooltip="ctrl.tooltip" data-placement="bottom" ng-click="ctrl.openDropdown()" class="btn navbar-button gf-timepicker-nav-btn">
<i class="fa fa-clock-o"></i> <i class="fa fa-clock-o"></i>
<span ng-bind="ctrl.rangeString"></span> <span ng-bind="ctrl.rangeString"></span>
<span ng-show="ctrl.isUtc" class="gf-timepicker-utc">UTC</span> <span ng-show="ctrl.isUtc" class="gf-timepicker-utc">UTC</span>
<span ng-show="ctrl.dashboard.refresh" class="text-warning">&nbsp; Refresh every {{ctrl.dashboard.refresh}}</span> <!-- <span ng-show="ctrl.dashboard.refresh" class="text-warning">&nbsp; Refresh every {{ctrl.dashboard.refresh}}</span> -->
</button> </button>
<button class="btn navbar-button navbar-button--tight" ng-click='ctrl.move(1)' ng-if="ctrl.isAbsolute"> <button class="btn navbar-button navbar-button--tight" ng-click='ctrl.move(1)' ng-if="ctrl.isAbsolute">
<i class="fa fa-chevron-right"></i> <i class="fa fa-chevron-right"></i>
</button> </button>
<button class="btn navbar-button navbar-button--zoom" bs-tooltip="'Time range zoom out <br> CTRL+Z'" data-placement="bottom" ng-click='ctrl.zoom(2)'> <button class="btn navbar-button navbar-button--zoom" bs-tooltip="'Time range zoom out <br> CTRL+Z'" data-placement="bottom" ng-click='ctrl.zoom(2)'>
<i class="fa fa-search-minus"></i> <i class="fa fa-search-minus"></i>
</button> </button>
<button class="btn navbar-button navbar-button--refresh" ng-click="ctrl.timeSrv.refreshDashboard()"> <!-- <button class="btn navbar-button navbar-button--refresh" ng-click="ctrl.timeSrv.refreshDashboard()">
<i class="fa fa-refresh"></i> <i class="fa fa-refresh"></i>
</button> </button> -->
</div>
<div ng-if="ctrl.isOpen" class="gf-timepicker-dropdown"> <div ng-if="ctrl.isOpen" class="gf-timepicker-dropdown">
<div class="popover-box"> <div class="popover-box">
@@ -75,7 +73,7 @@
<datepicker ng-model="ctrl.absolute.toJs" class="gf-timepicker-component" show-weeks="false" starting-day="ctrl.firstDayOfWeek" ng-change="ctrl.absoluteToChanged()"></datepicker> <datepicker ng-model="ctrl.absolute.toJs" class="gf-timepicker-component" show-weeks="false" starting-day="ctrl.firstDayOfWeek" ng-change="ctrl.absoluteToChanged()"></datepicker>
</div> </div>
<label class="small">Refreshing every:</label> <!-- <label class="small">Refreshing every:</label>
<div class="gf-form-inline"> <div class="gf-form-inline">
<div class="gf-form max-width-28"> <div class="gf-form max-width-28">
<select ng-model="ctrl.refresh.value" class="gf-form-input input-medium" ng-options="f.value as f.text for f in ctrl.refresh.options"></select> <select ng-model="ctrl.refresh.value" class="gf-form-input input-medium" ng-options="f.value as f.text for f in ctrl.refresh.options"></select>
@@ -83,7 +81,7 @@
<div class="gf-form"> <div class="gf-form">
<button type="submit" class="btn gf-form-btn btn-primary" ng-click="ctrl.applyCustom();" ng-disabled="!timeForm.$valid">Apply</button> <button type="submit" class="btn gf-form-btn btn-primary" ng-click="ctrl.applyCustom();" ng-disabled="!timeForm.$valid">Apply</button>
</div> </div>
</div> </div> -->
</form> </form>
</div> </div>
</div> </div>

View File

@@ -9,7 +9,7 @@ import templateSrv from 'app/features/templating/template_srv';
import { DashboardModel } from 'app/features/dashboard/state/DashboardModel'; import { DashboardModel } from 'app/features/dashboard/state/DashboardModel';
import { PanelModel } from 'app/features/dashboard/state/PanelModel'; import { PanelModel } from 'app/features/dashboard/state/PanelModel';
import { ClickOutsideWrapper } from 'app/core/components/ClickOutsideWrapper/ClickOutsideWrapper'; import { ClickOutsideWrapper } from '@grafana/ui';
export interface Props { export interface Props {
panel: PanelModel; panel: PanelModel;

View File

@@ -124,6 +124,7 @@ export class TimeSrv {
setAutoRefresh(interval) { setAutoRefresh(interval) {
this.dashboard.refresh = interval; this.dashboard.refresh = interval;
this.cancelNextRefresh(); this.cancelNextRefresh();
if (interval) { if (interval) {
const intervalMs = kbn.interval_to_ms(interval); const intervalMs = kbn.interval_to_ms(interval);
@@ -135,15 +136,17 @@ export class TimeSrv {
); );
} }
// update url // update url inside timeout to so that a digest happens after (called from react)
const params = this.$location.search(); this.$timeout(() => {
if (interval) { const params = this.$location.search();
params.refresh = interval; if (interval) {
this.$location.search(params); params.refresh = interval;
} else if (params.refresh) { this.$location.search(params);
delete params.refresh; } else if (params.refresh) {
this.$location.search(params); delete params.refresh;
} this.$location.search(params);
}
});
} }
refreshDashboard() { refreshDashboard() {

View File

@@ -117,7 +117,6 @@ export class Explore extends React.PureComponent<ExploreProps> {
const initialQueries: DataQuery[] = ensureQueries(queries); const initialQueries: DataQuery[] = ensureQueries(queries);
const initialRange = { from: parseTime(range.from), to: parseTime(range.to) }; const initialRange = { from: parseTime(range.from), to: parseTime(range.to) };
const width = this.el ? this.el.offsetWidth : 0; const width = this.el ? this.el.offsetWidth : 0;
// initialize the whole explore first time we mount and if browser history contains a change in datasource // initialize the whole explore first time we mount and if browser history contains a change in datasource
if (!initialized) { if (!initialized) {
this.props.initializeExplore( this.props.initializeExplore(

View File

@@ -3,12 +3,19 @@ import { connect } from 'react-redux';
import { hot } from 'react-hot-loader'; import { hot } from 'react-hot-loader';
import { ExploreId } from 'app/types/explore'; import { ExploreId } from 'app/types/explore';
import { DataSourceSelectItem, RawTimeRange, TimeRange } from '@grafana/ui'; import { DataSourceSelectItem, RawTimeRange, TimeRange, ClickOutsideWrapper } from '@grafana/ui';
import { DataSourcePicker } from 'app/core/components/Select/DataSourcePicker'; import { DataSourcePicker } from 'app/core/components/Select/DataSourcePicker';
import { StoreState } from 'app/types/store'; import { StoreState } from 'app/types/store';
import { changeDatasource, clearQueries, splitClose, runQueries, splitOpen } from './state/actions'; import {
changeDatasource,
clearQueries,
splitClose,
runQueries,
splitOpen,
changeRefreshInterval,
} from './state/actions';
import TimePicker from './TimePicker'; import TimePicker from './TimePicker';
import { ClickOutsideWrapper } from 'app/core/components/ClickOutsideWrapper/ClickOutsideWrapper'; import { RefreshPicker, SetInterval } from '@grafana/ui';
enum IconSide { enum IconSide {
left = 'left', left = 'left',
@@ -51,20 +58,22 @@ interface StateProps {
range: RawTimeRange; range: RawTimeRange;
selectedDatasource: DataSourceSelectItem; selectedDatasource: DataSourceSelectItem;
splitted: boolean; splitted: boolean;
refreshInterval: string;
} }
interface DispatchProps { interface DispatchProps {
changeDatasource: typeof changeDatasource; changeDatasource: typeof changeDatasource;
clearAll: typeof clearQueries; clearAll: typeof clearQueries;
runQuery: typeof runQueries; runQueries: typeof runQueries;
closeSplit: typeof splitClose; closeSplit: typeof splitClose;
split: typeof splitOpen; split: typeof splitOpen;
changeRefreshInterval: typeof changeRefreshInterval;
} }
type Props = StateProps & DispatchProps & OwnProps; type Props = StateProps & DispatchProps & OwnProps;
export class UnConnectedExploreToolbar extends PureComponent<Props, {}> { export class UnConnectedExploreToolbar extends PureComponent<Props, {}> {
constructor(props) { constructor(props: Props) {
super(props); super(props);
} }
@@ -77,23 +86,32 @@ export class UnConnectedExploreToolbar extends PureComponent<Props, {}> {
}; };
onRunQuery = () => { onRunQuery = () => {
this.props.runQuery(this.props.exploreId); return this.props.runQueries(this.props.exploreId);
}; };
onCloseTimePicker = () => { onCloseTimePicker = () => {
this.props.timepickerRef.current.setState({ isOpen: false }); this.props.timepickerRef.current.setState({ isOpen: false });
}; };
onChangeRefreshInterval = (item: string) => {
const { changeRefreshInterval, exploreId } = this.props;
changeRefreshInterval(exploreId, item);
};
render() { render() {
const { const {
datasourceMissing, datasourceMissing,
exploreDatasources, exploreDatasources,
closeSplit,
exploreId, exploreId,
loading, loading,
range, range,
selectedDatasource, selectedDatasource,
splitted, splitted,
timepickerRef, timepickerRef,
refreshInterval,
onChangeTime,
split,
} = this.props; } = this.props;
return ( return (
@@ -109,7 +127,7 @@ export class UnConnectedExploreToolbar extends PureComponent<Props, {}> {
)} )}
</div> </div>
{splitted && ( {splitted && (
<a className="explore-toolbar-header-close" onClick={() => this.props.closeSplit(exploreId)}> <a className="explore-toolbar-header-close" onClick={() => closeSplit(exploreId)}>
<i className="fa fa-times fa-fw" /> <i className="fa fa-times fa-fw" />
</a> </a>
)} )}
@@ -133,7 +151,7 @@ export class UnConnectedExploreToolbar extends PureComponent<Props, {}> {
{createResponsiveButton({ {createResponsiveButton({
splitted, splitted,
title: 'Split', title: 'Split',
onClick: this.props.split, onClick: split,
iconClassName: 'fa fa-fw fa-columns icon-margin-right', iconClassName: 'fa fa-fw fa-columns icon-margin-right',
iconSide: IconSide.left, iconSide: IconSide.left,
})} })}
@@ -141,9 +159,18 @@ export class UnConnectedExploreToolbar extends PureComponent<Props, {}> {
) : null} ) : null}
<div className="explore-toolbar-content-item timepicker"> <div className="explore-toolbar-content-item timepicker">
<ClickOutsideWrapper onClick={this.onCloseTimePicker}> <ClickOutsideWrapper onClick={this.onCloseTimePicker}>
<TimePicker ref={timepickerRef} range={range} onChangeTime={this.props.onChangeTime} /> <TimePicker ref={timepickerRef} range={range} onChangeTime={onChangeTime} />
</ClickOutsideWrapper> </ClickOutsideWrapper>
<RefreshPicker
onIntervalChanged={this.onChangeRefreshInterval}
onRefresh={this.onRunQuery}
value={refreshInterval}
tooltip="Refresh"
/>
{refreshInterval && <SetInterval func={this.onRunQuery} interval={refreshInterval} />}
</div> </div>
<div className="explore-toolbar-content-item"> <div className="explore-toolbar-content-item">
<button className="btn navbar-button navbar-button--no-icon" onClick={this.onClearAll}> <button className="btn navbar-button navbar-button--no-icon" onClick={this.onClearAll}>
Clear All Clear All
@@ -169,7 +196,14 @@ export class UnConnectedExploreToolbar extends PureComponent<Props, {}> {
const mapStateToProps = (state: StoreState, { exploreId }: OwnProps): StateProps => { const mapStateToProps = (state: StoreState, { exploreId }: OwnProps): StateProps => {
const splitted = state.explore.split; const splitted = state.explore.split;
const exploreItem = state.explore[exploreId]; const exploreItem = state.explore[exploreId];
const { datasourceInstance, datasourceMissing, exploreDatasources, queryTransactions, range } = exploreItem; const {
datasourceInstance,
datasourceMissing,
exploreDatasources,
queryTransactions,
range,
refreshInterval,
} = exploreItem;
const selectedDatasource = datasourceInstance const selectedDatasource = datasourceInstance
? exploreDatasources.find(datasource => datasource.name === datasourceInstance.name) ? exploreDatasources.find(datasource => datasource.name === datasourceInstance.name)
: undefined; : undefined;
@@ -182,13 +216,15 @@ const mapStateToProps = (state: StoreState, { exploreId }: OwnProps): StateProps
range, range,
selectedDatasource, selectedDatasource,
splitted, splitted,
refreshInterval,
}; };
}; };
const mapDispatchToProps: DispatchProps = { const mapDispatchToProps: DispatchProps = {
changeDatasource, changeDatasource,
changeRefreshInterval,
clearAll: clearQueries, clearAll: clearQueries,
runQuery: runQueries, runQueries,
closeSplit: splitClose, closeSplit: splitClose,
split: splitOpen, split: splitOpen,
}; };

View File

@@ -66,10 +66,19 @@ export interface ChangeTimePayload {
range: TimeRange; range: TimeRange;
} }
export interface ChangeRefreshIntervalPayload {
exploreId: ExploreId;
refreshInterval: string;
}
export interface ClearQueriesPayload { export interface ClearQueriesPayload {
exploreId: ExploreId; exploreId: ExploreId;
} }
export interface ClearRefreshIntervalPayload {
exploreId: ExploreId;
}
export interface HighlightLogsExpressionPayload { export interface HighlightLogsExpressionPayload {
exploreId: ExploreId; exploreId: ExploreId;
expressions: string[]; expressions: string[];
@@ -240,6 +249,13 @@ export const changeSizeAction = actionCreatorFactory<ChangeSizePayload>('explore
*/ */
export const changeTimeAction = actionCreatorFactory<ChangeTimePayload>('explore/CHANGE_TIME').create(); export const changeTimeAction = actionCreatorFactory<ChangeTimePayload>('explore/CHANGE_TIME').create();
/**
* Change the time range of Explore. Usually called from the Timepicker or a graph interaction.
*/
export const changeRefreshIntervalAction = actionCreatorFactory<ChangeRefreshIntervalPayload>(
'explore/CHANGE_REFRESH_INTERVAL'
).create();
/** /**
* Clear all queries and results. * Clear all queries and results.
*/ */

View File

@@ -35,7 +35,12 @@ const setup = (updateOverides?: Partial<ExploreUpdateState>) => {
const eventBridge = {} as Emitter; const eventBridge = {} as Emitter;
const ui = { dedupStrategy: LogsDedupStrategy.none, showingGraph: false, showingLogs: false, showingTable: false }; const ui = { dedupStrategy: LogsDedupStrategy.none, showingGraph: false, showingLogs: false, showingTable: false };
const range = { from: 'now', to: 'now' }; const range = { from: 'now', to: 'now' };
const urlState: ExploreUrlState = { datasource: 'some-datasource', queries: [], range, ui }; const urlState: ExploreUrlState = {
datasource: 'some-datasource',
queries: [],
range,
ui,
};
const updateDefaults = makeInitialUpdateState(); const updateDefaults = makeInitialUpdateState();
const update = { ...updateDefaults, ...updateOverides }; const update = { ...updateDefaults, ...updateOverides };
const initialState = { const initialState = {
@@ -50,6 +55,10 @@ const setup = (updateOverides?: Partial<ExploreUpdateState>) => {
queries: [] as DataQuery[], queries: [] as DataQuery[],
range, range,
ui, ui,
refreshInterval: {
label: 'Off',
value: 0,
},
}, },
}, },
}; };

View File

@@ -22,6 +22,7 @@ import {
import { updateLocation } from 'app/core/actions'; import { updateLocation } from 'app/core/actions';
// Types // Types
import { ResultGetter } from 'app/types/explore';
import { ThunkResult } from 'app/types'; import { ThunkResult } from 'app/types';
import { import {
RawTimeRange, RawTimeRange,
@@ -44,6 +45,8 @@ import {
import { import {
updateDatasourceInstanceAction, updateDatasourceInstanceAction,
changeQueryAction, changeQueryAction,
changeRefreshIntervalAction,
ChangeRefreshIntervalPayload,
changeSizeAction, changeSizeAction,
ChangeSizePayload, ChangeSizePayload,
changeTimeAction, changeTimeAction,
@@ -164,7 +167,7 @@ export function changeSize(
} }
/** /**
* Change the time range of Explore. Usually called from the Timepicker or a graph interaction. * Change the time range of Explore. Usually called from the Time picker or a graph interaction.
*/ */
export function changeTime(exploreId: ExploreId, range: TimeRange): ThunkResult<void> { export function changeTime(exploreId: ExploreId, range: TimeRange): ThunkResult<void> {
return dispatch => { return dispatch => {
@@ -173,6 +176,16 @@ export function changeTime(exploreId: ExploreId, range: TimeRange): ThunkResult<
}; };
} }
/**
* Change the refresh interval of Explore. Called from the Refresh picker.
*/
export function changeRefreshInterval(
exploreId: ExploreId,
refreshInterval: string
): ActionOf<ChangeRefreshIntervalPayload> {
return changeRefreshIntervalAction({ exploreId, refreshInterval });
}
/** /**
* Clear all queries and results. * Clear all queries and results.
*/ */
@@ -526,7 +539,7 @@ export function queryTransactionSuccess(
/** /**
* Main action to run queries and dispatches sub-actions based on which result viewers are active * Main action to run queries and dispatches sub-actions based on which result viewers are active
*/ */
export function runQueries(exploreId: ExploreId, ignoreUIState = false): ThunkResult<void> { export function runQueries(exploreId: ExploreId, ignoreUIState = false): ThunkResult<Promise<any>> {
return (dispatch, getState) => { return (dispatch, getState) => {
const { const {
datasourceInstance, datasourceInstance,
@@ -543,13 +556,13 @@ export function runQueries(exploreId: ExploreId, ignoreUIState = false): ThunkRe
if (datasourceError) { if (datasourceError) {
// let's not run any queries if data source is in a faulty state // let's not run any queries if data source is in a faulty state
return; return Promise.resolve();
} }
if (!hasNonEmptyQuery(queries)) { if (!hasNonEmptyQuery(queries)) {
dispatch(clearQueriesAction({ exploreId })); dispatch(clearQueriesAction({ exploreId }));
dispatch(stateSave()); // Remember to saves to state and update location dispatch(stateSave()); // Remember to saves to state and update location
return; return Promise.resolve();
} }
// Some datasource's query builders allow per-query interval limits, // Some datasource's query builders allow per-query interval limits,
@@ -558,41 +571,46 @@ export function runQueries(exploreId: ExploreId, ignoreUIState = false): ThunkRe
dispatch(runQueriesAction({ exploreId })); dispatch(runQueriesAction({ exploreId }));
// Keep table queries first since they need to return quickly // Keep table queries first since they need to return quickly
if ((ignoreUIState || showingTable) && supportsTable) { const tableQueriesPromise =
dispatch( (ignoreUIState || showingTable) && supportsTable
runQueriesForType( ? dispatch(
exploreId, runQueriesForType(
'Table', exploreId,
{ 'Table',
interval, {
format: 'table', interval,
instant: true, format: 'table',
valueWithRefId: true, instant: true,
}, valueWithRefId: true,
(data: any) => data[0] },
) (data: any[]) => data[0]
); )
} )
if ((ignoreUIState || showingGraph) && supportsGraph) { : undefined;
dispatch( const typeQueriesPromise =
runQueriesForType( (ignoreUIState || showingGraph) && supportsGraph
exploreId, ? dispatch(
'Graph', runQueriesForType(
{ exploreId,
interval, 'Graph',
format: 'time_series', {
instant: false, interval,
maxDataPoints: containerWidth, format: 'time_series',
}, instant: false,
makeTimeSeriesList maxDataPoints: containerWidth,
) },
); makeTimeSeriesList
} )
if ((ignoreUIState || showingLogs) && supportsLogs) { )
dispatch(runQueriesForType(exploreId, 'Logs', { interval, format: 'logs' })); : undefined;
} const logsQueriesPromise =
(ignoreUIState || showingLogs) && supportsLogs
? dispatch(runQueriesForType(exploreId, 'Logs', { interval, format: 'logs' }))
: undefined;
dispatch(stateSave()); dispatch(stateSave());
return Promise.all([tableQueriesPromise, typeQueriesPromise, logsQueriesPromise]);
}; };
} }
@@ -607,14 +625,13 @@ function runQueriesForType(
exploreId: ExploreId, exploreId: ExploreId,
resultType: ResultType, resultType: ResultType,
queryOptions: QueryOptions, queryOptions: QueryOptions,
resultGetter?: any resultGetter?: ResultGetter
): ThunkResult<void> { ): ThunkResult<void> {
return async (dispatch, getState) => { return async (dispatch, getState) => {
const { datasourceInstance, eventBridge, queries, queryIntervals, range, scanning } = getState().explore[exploreId]; const { datasourceInstance, eventBridge, queries, queryIntervals, range, scanning } = getState().explore[exploreId];
const datasourceId = datasourceInstance.meta.id; const datasourceId = datasourceInstance.meta.id;
// Run all queries concurrently // Run all queries concurrently
queries.forEach(async (query, rowIndex) => { const queryPromises = queries.map(async (query, rowIndex) => {
const transaction = buildQueryTransaction( const transaction = buildQueryTransaction(
query, query,
rowIndex, rowIndex,
@@ -638,6 +655,8 @@ function runQueriesForType(
dispatch(queryTransactionFailure(exploreId, transaction.id, response, datasourceId)); dispatch(queryTransactionFailure(exploreId, transaction.id, response, datasourceId));
} }
}); });
return Promise.all(queryPromises);
}; };
} }
@@ -814,7 +833,6 @@ export function refreshExplore(exploreId: ExploreId): ThunkResult<void> {
const { datasource, queries, range, ui } = urlState; const { datasource, queries, range, ui } = urlState;
const refreshQueries = queries.map(q => ({ ...q, ...generateEmptyQuery(itemState.queries) })); const refreshQueries = queries.map(q => ({ ...q, ...generateEmptyQuery(itemState.queries) }));
const refreshRange = { from: parseTime(range.from), to: parseTime(range.to) }; const refreshRange = { from: parseTime(range.from), to: parseTime(range.to) };
// need to refresh datasource // need to refresh datasource
if (update.datasource) { if (update.datasource) {
const initialQueries = ensureQueries(queries); const initialQueries = ensureQueries(queries);

View File

@@ -515,6 +515,44 @@ describe('Explore reducer', () => {
}); });
}); });
describe('and refreshInterval differs', () => {
it('then it should return update refreshInterval', () => {
const { initalState, serializedUrlState } = setup();
const expectedState = {
...initalState,
left: {
...initalState.left,
update: {
...initalState.left.update,
refreshInterval: true,
},
},
};
const stateWithDifferentDataSource = {
...initalState,
left: {
...initalState.left,
urlState: {
...initalState.left.urlState,
refreshInterval: '5s',
},
},
};
reducerTester()
.givenReducer(exploreReducer, stateWithDifferentDataSource)
.whenActionIsDispatched(
updateLocation({
query: {
left: serializedUrlState,
},
path: '/explore',
})
)
.thenStateShouldEqual(expectedState);
});
});
describe('and nothing differs', () => { describe('and nothing differs', () => {
fit('then it should return update ui', () => { fit('then it should return update ui', () => {
const { initalState, serializedUrlState } = setup(); const { initalState, serializedUrlState } = setup();

View File

@@ -27,6 +27,7 @@ import {
changeQueryAction, changeQueryAction,
changeSizeAction, changeSizeAction,
changeTimeAction, changeTimeAction,
changeRefreshIntervalAction,
clearQueriesAction, clearQueriesAction,
highlightLogsExpressionAction, highlightLogsExpressionAction,
initializeExploreAction, initializeExploreAction,
@@ -67,6 +68,7 @@ export const makeInitialUpdateState = (): ExploreUpdateState => ({
range: false, range: false,
ui: false, ui: false,
}); });
/** /**
* Returns a fresh Explore area state * Returns a fresh Explore area state
*/ */
@@ -101,10 +103,11 @@ export const makeExploreItemState = (): ExploreItemState => ({
/** /**
* Global Explore state that handles multiple Explore areas and the split state * Global Explore state that handles multiple Explore areas and the split state
*/ */
export const initialExploreItemState = makeExploreItemState();
export const initialExploreState: ExploreState = { export const initialExploreState: ExploreState = {
split: null, split: null,
left: makeExploreItemState(), left: initialExploreItemState,
right: makeExploreItemState(), right: initialExploreItemState,
}; };
/** /**
@@ -175,6 +178,16 @@ export const itemReducer = reducerFactory<ExploreItemState>({} as ExploreItemSta
return { ...state, range: action.payload.range }; return { ...state, range: action.payload.range };
}, },
}) })
.addMapper({
filter: changeRefreshIntervalAction,
mapper: (state, action): ExploreItemState => {
const { refreshInterval } = action.payload;
return {
...state,
refreshInterval: refreshInterval,
};
},
})
.addMapper({ .addMapper({
filter: clearQueriesAction, filter: clearQueriesAction,
mapper: (state): ExploreItemState => { mapper: (state): ExploreItemState => {
@@ -580,7 +593,11 @@ export const updateChildRefreshState = (
const urlState = parseUrlState(queryState); const urlState = parseUrlState(queryState);
if (!state.urlState || path !== '/explore') { if (!state.urlState || path !== '/explore') {
// we only want to refresh when browser back/forward // we only want to refresh when browser back/forward
return { ...state, urlState, update: { datasource: false, queries: false, range: false, ui: false } }; return {
...state,
urlState,
update: { datasource: false, queries: false, range: false, ui: false },
};
} }
const datasource = _.isEqual(urlState ? urlState.datasource : '', state.urlState.datasource) === false; const datasource = _.isEqual(urlState ? urlState.datasource : '', state.urlState.datasource) === false;

View File

@@ -84,12 +84,22 @@ exports[`Render when feature toggle editorsCanAdmin is turned off should not ren
autoFocus={false} autoFocus={false}
backspaceRemovesValue={true} backspaceRemovesValue={true}
className="gf-form-select-box__control--menu-right" className="gf-form-select-box__control--menu-right"
components={
Object {
"Group": [Function],
"IndicatorsContainer": [Function],
"MenuList": [Function],
"Option": [Function],
"SingleValue": [Function],
}
}
isClearable={false} isClearable={false}
isDisabled={false} isDisabled={false}
isLoading={false} isLoading={false}
isMulti={false} isMulti={false}
isSearchable={false} isSearchable={false}
maxMenuHeight={300} maxMenuHeight={300}
menuIsOpen={false}
onChange={[Function]} onChange={[Function]}
openMenuOnFocus={false} openMenuOnFocus={false}
options={ options={
@@ -160,12 +170,22 @@ exports[`Render when feature toggle editorsCanAdmin is turned on should render p
autoFocus={false} autoFocus={false}
backspaceRemovesValue={true} backspaceRemovesValue={true}
className="gf-form-select-box__control--menu-right" className="gf-form-select-box__control--menu-right"
components={
Object {
"Group": [Function],
"IndicatorsContainer": [Function],
"MenuList": [Function],
"Option": [Function],
"SingleValue": [Function],
}
}
isClearable={false} isClearable={false}
isDisabled={false} isDisabled={false}
isLoading={false} isLoading={false}
isMulti={false} isMulti={false}
isSearchable={false} isSearchable={false}
maxMenuHeight={300} maxMenuHeight={300}
menuIsOpen={false}
onChange={[Function]} onChange={[Function]}
openMenuOnFocus={false} openMenuOnFocus={false}
options={ options={

View File

@@ -13,86 +13,88 @@ Array [
> >
Aggregation Aggregation
</label> </label>
<div <div>
className="css-0 gf-form-input gf-form-input--form-dropdown width-15"
onKeyDown={[Function]}
>
<div <div
className="css-0 gf-form-select-box__control" className="css-0 gf-form-input gf-form-input--form-dropdown width-15"
onMouseDown={[Function]} onKeyDown={[Function]}
onTouchEnd={[Function]}
> >
<div <div
className="css-0 gf-form-select-box__value-container" className="css-0 gf-form-select-box__control"
onMouseDown={[Function]}
onTouchEnd={[Function]}
> >
<div <div
className="css-0 gf-form-select-box__placeholder" className="css-0 gf-form-select-box__value-container"
>
Select Reducer
</div>
<div
className="css-0"
> >
<div <div
className="gf-form-select-box__input" className="css-0 gf-form-select-box__placeholder"
style={ >
Object { Select Reducer
"display": "inline-block", </div>
} <div
} className="css-0"
> >
<input
aria-autocomplete="list"
autoCapitalize="none"
autoComplete="off"
autoCorrect="off"
disabled={false}
id="react-select-2-input"
onBlur={[Function]}
onChange={[Function]}
onFocus={[Function]}
spellCheck="false"
style={
Object {
"background": 0,
"border": 0,
"boxSizing": "content-box",
"color": "inherit",
"fontSize": "inherit",
"opacity": 1,
"outline": 0,
"padding": 0,
"width": "1px",
}
}
tabIndex="0"
type="text"
value=""
/>
<div <div
className="gf-form-select-box__input"
style={ style={
Object { Object {
"height": 0, "display": "inline-block",
"left": 0,
"overflow": "scroll",
"position": "absolute",
"top": 0,
"visibility": "hidden",
"whiteSpace": "pre",
} }
} }
> >
<input
aria-autocomplete="list"
autoCapitalize="none"
autoComplete="off"
autoCorrect="off"
disabled={false}
id="react-select-2-input"
onBlur={[Function]}
onChange={[Function]}
onFocus={[Function]}
spellCheck="false"
style={
Object {
"background": 0,
"border": 0,
"boxSizing": "content-box",
"color": "inherit",
"fontSize": "inherit",
"opacity": 1,
"outline": 0,
"padding": 0,
"width": "1px",
}
}
tabIndex="0"
type="text"
value=""
/>
<div
style={
Object {
"height": 0,
"left": 0,
"overflow": "scroll",
"position": "absolute",
"top": 0,
"visibility": "hidden",
"whiteSpace": "pre",
}
}
>
</div>
</div> </div>
</div> </div>
</div> </div>
</div> <div
<div className="css-0 gf-form-select-box__indicators"
className="css-0 gf-form-select-box__indicators" >
> <span
<span className="gf-form-select-box__select-arrow "
className="gf-form-select-box__select-arrow " />
/> </div>
</div> </div>
</div> </div>
</div> </div>

View File

@@ -13,41 +13,43 @@ Array [
> >
Service Service
</span> </span>
<div <div>
className="css-0 gf-form-input gf-form-input--form-dropdown width-15"
onKeyDown={[Function]}
>
<div <div
className="css-0 gf-form-select-box__control" className="css-0 gf-form-input gf-form-input--form-dropdown width-15"
onMouseDown={[Function]} onKeyDown={[Function]}
onTouchEnd={[Function]}
> >
<div <div
className="css-0 gf-form-select-box__value-container" className="css-0 gf-form-select-box__control"
onMouseDown={[Function]}
onTouchEnd={[Function]}
> >
<div <div
className="css-0 gf-form-select-box__placeholder" className="css-0 gf-form-select-box__value-container"
> >
Select Services <div
className="css-0 gf-form-select-box__placeholder"
>
Select Services
</div>
<input
className="css-14uuagi"
disabled={false}
id="react-select-2-input"
onBlur={[Function]}
onChange={[Function]}
onFocus={[Function]}
readOnly={true}
tabIndex="0"
value=""
/>
</div>
<div
className="css-0 gf-form-select-box__indicators"
>
<span
className="gf-form-select-box__select-arrow "
/>
</div> </div>
<input
className="css-14uuagi"
disabled={false}
id="react-select-2-input"
onBlur={[Function]}
onChange={[Function]}
onFocus={[Function]}
readOnly={true}
tabIndex="0"
value=""
/>
</div>
<div
className="css-0 gf-form-select-box__indicators"
>
<span
className="gf-form-select-box__select-arrow "
/>
</div> </div>
</div> </div>
</div> </div>
@@ -71,86 +73,88 @@ Array [
> >
Metric Metric
</span> </span>
<div <div>
className="css-0 gf-form-input gf-form-input--form-dropdown width-26"
onKeyDown={[Function]}
>
<div <div
className="css-0 gf-form-select-box__control" className="css-0 gf-form-input gf-form-input--form-dropdown width-26"
onMouseDown={[Function]} onKeyDown={[Function]}
onTouchEnd={[Function]}
> >
<div <div
className="css-0 gf-form-select-box__value-container" className="css-0 gf-form-select-box__control"
onMouseDown={[Function]}
onTouchEnd={[Function]}
> >
<div <div
className="css-0 gf-form-select-box__placeholder" className="css-0 gf-form-select-box__value-container"
>
Select Metric
</div>
<div
className="css-0"
> >
<div <div
className="gf-form-select-box__input" className="css-0 gf-form-select-box__placeholder"
style={ >
Object { Select Metric
"display": "inline-block", </div>
} <div
} className="css-0"
> >
<input
aria-autocomplete="list"
autoCapitalize="none"
autoComplete="off"
autoCorrect="off"
disabled={false}
id="react-select-3-input"
onBlur={[Function]}
onChange={[Function]}
onFocus={[Function]}
spellCheck="false"
style={
Object {
"background": 0,
"border": 0,
"boxSizing": "content-box",
"color": "inherit",
"fontSize": "inherit",
"opacity": 1,
"outline": 0,
"padding": 0,
"width": "1px",
}
}
tabIndex="0"
type="text"
value=""
/>
<div <div
className="gf-form-select-box__input"
style={ style={
Object { Object {
"height": 0, "display": "inline-block",
"left": 0,
"overflow": "scroll",
"position": "absolute",
"top": 0,
"visibility": "hidden",
"whiteSpace": "pre",
} }
} }
> >
<input
aria-autocomplete="list"
autoCapitalize="none"
autoComplete="off"
autoCorrect="off"
disabled={false}
id="react-select-3-input"
onBlur={[Function]}
onChange={[Function]}
onFocus={[Function]}
spellCheck="false"
style={
Object {
"background": 0,
"border": 0,
"boxSizing": "content-box",
"color": "inherit",
"fontSize": "inherit",
"opacity": 1,
"outline": 0,
"padding": 0,
"width": "1px",
}
}
tabIndex="0"
type="text"
value=""
/>
<div
style={
Object {
"height": 0,
"left": 0,
"overflow": "scroll",
"position": "absolute",
"top": 0,
"visibility": "hidden",
"whiteSpace": "pre",
}
}
>
</div>
</div> </div>
</div> </div>
</div> </div>
</div> <div
<div className="css-0 gf-form-select-box__indicators"
className="css-0 gf-form-select-box__indicators" >
> <span
<span className="gf-form-select-box__select-arrow "
className="gf-form-select-box__select-arrow " />
/> </div>
</div> </div>
</div> </div>
</div> </div>
@@ -181,86 +185,88 @@ Array [
> >
Aggregation Aggregation
</label> </label>
<div <div>
className="css-0 gf-form-input gf-form-input--form-dropdown width-15"
onKeyDown={[Function]}
>
<div <div
className="css-0 gf-form-select-box__control" className="css-0 gf-form-input gf-form-input--form-dropdown width-15"
onMouseDown={[Function]} onKeyDown={[Function]}
onTouchEnd={[Function]}
> >
<div <div
className="css-0 gf-form-select-box__value-container" className="css-0 gf-form-select-box__control"
onMouseDown={[Function]}
onTouchEnd={[Function]}
> >
<div <div
className="css-0 gf-form-select-box__placeholder" className="css-0 gf-form-select-box__value-container"
>
Select Reducer
</div>
<div
className="css-0"
> >
<div <div
className="gf-form-select-box__input" className="css-0 gf-form-select-box__placeholder"
style={ >
Object { Select Reducer
"display": "inline-block", </div>
} <div
} className="css-0"
> >
<input
aria-autocomplete="list"
autoCapitalize="none"
autoComplete="off"
autoCorrect="off"
disabled={false}
id="react-select-4-input"
onBlur={[Function]}
onChange={[Function]}
onFocus={[Function]}
spellCheck="false"
style={
Object {
"background": 0,
"border": 0,
"boxSizing": "content-box",
"color": "inherit",
"fontSize": "inherit",
"opacity": 1,
"outline": 0,
"padding": 0,
"width": "1px",
}
}
tabIndex="0"
type="text"
value=""
/>
<div <div
className="gf-form-select-box__input"
style={ style={
Object { Object {
"height": 0, "display": "inline-block",
"left": 0,
"overflow": "scroll",
"position": "absolute",
"top": 0,
"visibility": "hidden",
"whiteSpace": "pre",
} }
} }
> >
<input
aria-autocomplete="list"
autoCapitalize="none"
autoComplete="off"
autoCorrect="off"
disabled={false}
id="react-select-4-input"
onBlur={[Function]}
onChange={[Function]}
onFocus={[Function]}
spellCheck="false"
style={
Object {
"background": 0,
"border": 0,
"boxSizing": "content-box",
"color": "inherit",
"fontSize": "inherit",
"opacity": 1,
"outline": 0,
"padding": 0,
"width": "1px",
}
}
tabIndex="0"
type="text"
value=""
/>
<div
style={
Object {
"height": 0,
"left": 0,
"overflow": "scroll",
"position": "absolute",
"top": 0,
"visibility": "hidden",
"whiteSpace": "pre",
}
}
>
</div>
</div> </div>
</div> </div>
</div> </div>
</div> <div
<div className="css-0 gf-form-select-box__indicators"
className="css-0 gf-form-select-box__indicators" >
> <span
<span className="gf-form-select-box__select-arrow "
className="gf-form-select-box__select-arrow " />
/> </div>
</div> </div>
</div> </div>
</div> </div>
@@ -293,90 +299,92 @@ Array [
> >
Alignment Period Alignment Period
</label> </label>
<div <div>
className="css-0 gf-form-input gf-form-input--form-dropdown width-15"
onKeyDown={[Function]}
>
<div <div
className="css-0 gf-form-select-box__control" className="css-0 gf-form-input gf-form-input--form-dropdown width-15"
onMouseDown={[Function]} onKeyDown={[Function]}
onTouchEnd={[Function]}
> >
<div <div
className="css-0 gf-form-select-box__value-container gf-form-select-box__value-container--has-value" className="css-0 gf-form-select-box__control"
onMouseDown={[Function]}
onTouchEnd={[Function]}
> >
<div <div
className="css-0 gf-form-select-box__single-value" className="css-0 gf-form-select-box__value-container gf-form-select-box__value-container--has-value"
> >
<div <div
className="gf-form-select-box__img-value" className="css-0 gf-form-select-box__single-value"
> >
stackdriver auto
</div>
</div>
<div
className="css-0"
>
<div
className="gf-form-select-box__input"
style={
Object {
"display": "inline-block",
}
}
>
<input
aria-autocomplete="list"
autoCapitalize="none"
autoComplete="off"
autoCorrect="off"
disabled={false}
id="react-select-5-input"
onBlur={[Function]}
onChange={[Function]}
onFocus={[Function]}
spellCheck="false"
style={
Object {
"background": 0,
"border": 0,
"boxSizing": "content-box",
"color": "inherit",
"fontSize": "inherit",
"opacity": 1,
"outline": 0,
"padding": 0,
"width": "1px",
}
}
tabIndex="0"
type="text"
value=""
/>
<div <div
className="gf-form-select-box__img-value"
>
stackdriver auto
</div>
</div>
<div
className="css-0"
>
<div
className="gf-form-select-box__input"
style={ style={
Object { Object {
"height": 0, "display": "inline-block",
"left": 0,
"overflow": "scroll",
"position": "absolute",
"top": 0,
"visibility": "hidden",
"whiteSpace": "pre",
} }
} }
> >
<input
aria-autocomplete="list"
autoCapitalize="none"
autoComplete="off"
autoCorrect="off"
disabled={false}
id="react-select-5-input"
onBlur={[Function]}
onChange={[Function]}
onFocus={[Function]}
spellCheck="false"
style={
Object {
"background": 0,
"border": 0,
"boxSizing": "content-box",
"color": "inherit",
"fontSize": "inherit",
"opacity": 1,
"outline": 0,
"padding": 0,
"width": "1px",
}
}
tabIndex="0"
type="text"
value=""
/>
<div
style={
Object {
"height": 0,
"left": 0,
"overflow": "scroll",
"position": "absolute",
"top": 0,
"visibility": "hidden",
"whiteSpace": "pre",
}
}
>
</div>
</div> </div>
</div> </div>
</div> </div>
</div> <div
<div className="css-0 gf-form-select-box__indicators"
className="css-0 gf-form-select-box__indicators" >
> <span
<span className="gf-form-select-box__select-arrow "
className="gf-form-select-box__select-arrow " />
/> </div>
</div> </div>
</div> </div>
</div> </div>

View File

@@ -250,6 +250,11 @@ export interface ExploreItemState {
*/ */
hiddenLogLevels?: LogLevel[]; hiddenLogLevels?: LogLevel[];
/**
* How often query should be refreshed
*/
refreshInterval?: string;
urlState: ExploreUrlState; urlState: ExploreUrlState;
update: ExploreUpdateState; update: ExploreUpdateState;

View File

@@ -174,6 +174,7 @@ $zindex-tooltip: 1030;
$zindex-modal-backdrop: 1040; $zindex-modal-backdrop: 1040;
$zindex-modal: 1050; $zindex-modal: 1050;
$zindex-typeahead: 1060; $zindex-typeahead: 1060;
$zindex-timepicker-popover: 1070;
// Buttons // Buttons
// //

View File

@@ -50,6 +50,16 @@
opacity: 0.65; opacity: 0.65;
@include box-shadow(none); @include box-shadow(none);
} }
&--radius-left-0 {
border-top-left-radius: 0;
border-bottom-left-radius: 0;
}
&--radius-right-0 {
border-top-right-radius: 0;
border-bottom-right-radius: 0;
}
} }
// Button Sizes // Button Sizes

View File

@@ -29,6 +29,7 @@
.navbar-button--share, .navbar-button--share,
.navbar-button--settings, .navbar-button--settings,
.navbar-page-btn .fa-caret-down, .navbar-page-btn .fa-caret-down,
.refresh-picker,
.gf-timepicker-nav { .gf-timepicker-nav {
display: none; display: none;
} }
@@ -135,6 +136,16 @@
} }
} }
&--refresh {
padding-left: 8px;
padding-right: 8px;
}
&--attached {
margin-left: 0;
border-radius: 0 2px 2px 0;
}
&--tight { &--tight {
padding: 7px 4px; padding: 7px 4px;

View File

@@ -7967,6 +7967,13 @@ get-stream@^4.0.0, get-stream@^4.1.0:
dependencies: dependencies:
pump "^3.0.0" pump "^3.0.0"
get-user-locale@^1.1.1:
version "1.1.1"
resolved "https://registry.yarnpkg.com/get-user-locale/-/get-user-locale-1.1.1.tgz#edff0a8bbd6aa3ed0ca30cc441e1acd111543b7f"
integrity sha512-KuA+vMhsY+rSPK8hrmOvf7xXIMTs+L06RkgZ83jawZHSEqPLafZtQ63d3waXW3r8z6EQ49I/trraNncWM+s/2g==
dependencies:
lodash.once "^4.1.1"
get-value@^2.0.3, get-value@^2.0.6: get-value@^2.0.3, get-value@^2.0.6:
version "2.0.6" version "2.0.6"
resolved "https://registry.yarnpkg.com/get-value/-/get-value-2.0.6.tgz#dc15ca1c672387ca76bd37ac0a395ba2042a2c28" resolved "https://registry.yarnpkg.com/get-value/-/get-value-2.0.6.tgz#dc15ca1c672387ca76bd37ac0a395ba2042a2c28"
@@ -11229,6 +11236,11 @@ meow@^3.3.0, meow@^3.7.0:
redent "^1.0.0" redent "^1.0.0"
trim-newlines "^1.0.0" trim-newlines "^1.0.0"
merge-class-names@^1.1.1:
version "1.1.1"
resolved "https://registry.yarnpkg.com/merge-class-names/-/merge-class-names-1.1.1.tgz#3bd2f38eb5418c464a0fef615484fdf6c8932256"
integrity sha512-+UUWBUoFw9QLY/UlBKU/xk9h6OhyG3BUDDuF2eIJcxmusWb/uedvNpZGkysqMw5b/ds+wkX7NJTDSdUuRsCNyA==
merge-deep@^3.0.2: merge-deep@^3.0.2:
version "3.0.2" version "3.0.2"
resolved "https://registry.yarnpkg.com/merge-deep/-/merge-deep-3.0.2.tgz#f39fa100a4f1bd34ff29f7d2bf4508fbb8d83ad2" resolved "https://registry.yarnpkg.com/merge-deep/-/merge-deep-3.0.2.tgz#f39fa100a4f1bd34ff29f7d2bf4508fbb8d83ad2"
@@ -13930,6 +13942,16 @@ react-addons-create-fragment@^15.5.3:
loose-envify "^1.3.1" loose-envify "^1.3.1"
object-assign "^4.1.0" object-assign "^4.1.0"
react-calendar@^2.18.1:
version "2.18.1"
resolved "https://registry.yarnpkg.com/react-calendar/-/react-calendar-2.18.1.tgz#f8ef9468d8566aa0d47d9d70c88917bb2030bcb9"
integrity sha512-J3tVim1gLpnsCOaeez+z4QJB5oK6UYLJj5TSMOStSJBvkWMEcTzj7bq7yCJJCNLUg2Vd3i11gJXish0LUFhXaw==
dependencies:
get-user-locale "^1.1.1"
merge-class-names "^1.1.1"
prop-types "^15.6.0"
react-lifecycles-compat "^3.0.4"
react-clientside-effect@^1.2.0: react-clientside-effect@^1.2.0:
version "1.2.0" version "1.2.0"
resolved "https://registry.yarnpkg.com/react-clientside-effect/-/react-clientside-effect-1.2.0.tgz#db823695f75e9616a5e4dd6d908e5ea627fb2516" resolved "https://registry.yarnpkg.com/react-clientside-effect/-/react-clientside-effect-1.2.0.tgz#db823695f75e9616a5e4dd6d908e5ea627fb2516"