Explore: Support user timezone (#16469)

Explore now uses the timezone of the user to decide if local browser 
time or UTC should be used. 
- Now uses TimeRange instead of RawTimeRange in explore item
state tree and only parsing actual time in a few action
handlers.
- Time picker should now properly handle moving back/forward and
apply time range when both utc and non utc time zone.
- URL range representation is changed from YYYY-MM-DD HH:mm:ss
to epoch ms.
- Now uses AbsoluteTimeRange in graph component instead of moment.
- Makes a copy of the time range passed to timeSrv to make sure immutability
of explore time range when for example elasticsearch test datasources uses
timeSrv and sets a time range of last 1 min.
- Various refactorings and cleanup.

Closes #12812
This commit is contained in:
Marcus Efraimsson
2019-04-29 18:28:41 +02:00
committed by GitHub
parent 7dbe719fda
commit 02cb7ff436
21 changed files with 560 additions and 256 deletions

View File

@@ -11,11 +11,30 @@ export interface TimeRange {
raw: RawTimeRange;
}
export interface AbsoluteTimeRange {
from: number;
to: number;
}
export interface IntervalValues {
interval: string; // 10s,5m
intervalMs: number;
}
export interface TimeZone {
raw: string;
isUtc: boolean;
}
export const parseTimeZone = (raw: string): TimeZone => {
return {
raw,
isUtc: raw === 'utc',
};
};
export const DefaultTimeZone = parseTimeZone('browser');
export interface TimeOption {
from: string;
to: string;

View File

@@ -1,18 +1,17 @@
// Libraries
import _ from 'lodash';
import moment, { Moment } from 'moment';
// Services & Utils
import * as dateMath from 'app/core/utils/datemath';
import { renderUrl } from 'app/core/utils/url';
import kbn from 'app/core/utils/kbn';
import store from 'app/core/store';
import { parse as parseDate } from 'app/core/utils/datemath';
import { colors } from '@grafana/ui';
import TableModel, { mergeTablesIntoModel } from 'app/core/table_model';
import { getNextRefIdChar } from './query';
// Types
import { RawTimeRange, IntervalValues, DataQuery, DataSourceApi } from '@grafana/ui';
import { colors, TimeRange, RawTimeRange, TimeZone, IntervalValues, DataQuery, DataSourceApi } from '@grafana/ui';
import TimeSeries from 'app/core/time_series2';
import {
ExploreUrlState,
@@ -104,7 +103,7 @@ export function buildQueryTransaction(
rowIndex: number,
resultType: ResultType,
queryOptions: QueryOptions,
range: RawTimeRange,
range: TimeRange,
queryIntervals: QueryIntervals,
scanning: boolean
): QueryTransaction {
@@ -131,12 +130,8 @@ export function buildQueryTransaction(
intervalMs,
panelId,
targets: configuredQueries, // Datasources rely on DataQueries being passed under the targets key.
range: {
from: dateMath.parse(range.from, false),
to: dateMath.parse(range.to, true),
raw: range,
},
rangeRaw: range,
range,
rangeRaw: range.raw,
scopedVars: {
__interval: { text: interval, value: interval },
__interval_ms: { text: intervalMs, value: intervalMs },
@@ -315,17 +310,12 @@ export function calculateResultsFromQueryTransactions(
};
}
export function getIntervals(range: RawTimeRange, lowLimit: string, resolution: number): IntervalValues {
export function getIntervals(range: TimeRange, lowLimit: string, resolution: number): IntervalValues {
if (!resolution) {
return { interval: '1s', intervalMs: 1000 };
}
const absoluteRange: RawTimeRange = {
from: parseDate(range.from, false),
to: parseDate(range.to, true),
};
return kbn.calculateInterval(absoluteRange, resolution, lowLimit);
return kbn.calculateInterval(range, resolution, lowLimit);
}
export const makeTimeSeriesList: ResultGetter = (dataList, transaction, allTransactions) => {
@@ -395,3 +385,51 @@ export const getQueryKeys = (queries: DataQuery[], datasourceInstance: DataSourc
return queryKeys;
};
export const getTimeRange = (timeZone: TimeZone, rawRange: RawTimeRange): TimeRange => {
return {
from: dateMath.parse(rawRange.from, false, timeZone.raw as any),
to: dateMath.parse(rawRange.to, true, timeZone.raw as any),
raw: rawRange,
};
};
const parseRawTime = (value): Moment | string => {
if (value === null) {
return null;
}
if (value.indexOf('now') !== -1) {
return value;
}
if (value.length === 8) {
return moment.utc(value, 'YYYYMMDD');
}
if (value.length === 15) {
return moment.utc(value, 'YYYYMMDDTHHmmss');
}
// Backward compatibility
if (value.length === 19) {
return moment.utc(value, 'YYYY-MM-DD HH:mm:ss');
}
if (!isNaN(value)) {
const epoch = parseInt(value, 10);
return moment.utc(epoch);
}
return null;
};
export const getTimeRangeFromUrl = (range: RawTimeRange, timeZone: TimeZone): TimeRange => {
const raw = {
from: parseRawTime(range.from),
to: parseRawTime(range.to),
};
return {
from: dateMath.parse(raw.from, false, timeZone.raw as any),
to: dateMath.parse(raw.to, true, timeZone.raw as any),
raw,
};
};

View File

@@ -16,7 +16,7 @@ import GraphContainer from './GraphContainer';
import LogsContainer from './LogsContainer';
import QueryRows from './QueryRows';
import TableContainer from './TableContainer';
import TimePicker, { parseTime } from './TimePicker';
import TimePicker from './TimePicker';
// Actions
import {
@@ -31,15 +31,29 @@ import {
} from './state/actions';
// Types
import { RawTimeRange, TimeRange, DataQuery, ExploreStartPageProps, ExploreDataSourceApi } from '@grafana/ui';
import { ExploreItemState, ExploreUrlState, RangeScanner, ExploreId, ExploreUpdateState } from 'app/types/explore';
import { RawTimeRange, DataQuery, ExploreStartPageProps, ExploreDataSourceApi } from '@grafana/ui';
import {
ExploreItemState,
ExploreUrlState,
RangeScanner,
ExploreId,
ExploreUpdateState,
ExploreUIState,
} from 'app/types/explore';
import { StoreState } from 'app/types';
import { LAST_USED_DATASOURCE_KEY, ensureQueries, DEFAULT_RANGE, DEFAULT_UI_STATE } from 'app/core/utils/explore';
import {
LAST_USED_DATASOURCE_KEY,
ensureQueries,
DEFAULT_RANGE,
DEFAULT_UI_STATE,
getTimeRangeFromUrl,
} from 'app/core/utils/explore';
import { Emitter } from 'app/core/utils/emitter';
import { ExploreToolbar } from './ExploreToolbar';
import { scanStopAction } from './state/actionTypes';
import { NoDataSourceCallToAction } from './NoDataSourceCallToAction';
import { FadeIn } from 'app/core/components/Animations/FadeIn';
import { getTimeZone } from '../profile/state/selectors';
interface ExploreProps {
StartPage?: ComponentClass<ExploreStartPageProps>;
@@ -53,7 +67,6 @@ interface ExploreProps {
initializeExplore: typeof initializeExplore;
initialized: boolean;
modifyQueries: typeof modifyQueries;
range: RawTimeRange;
update: ExploreUpdateState;
reconnectDatasource: typeof reconnectDatasource;
refreshExplore: typeof refreshExplore;
@@ -69,7 +82,10 @@ interface ExploreProps {
supportsLogs: boolean | null;
supportsTable: boolean | null;
queryKeys: string[];
urlState: ExploreUrlState;
initialDatasource: string;
initialQueries: DataQuery[];
initialRange: RawTimeRange;
initialUI: ExploreUIState;
}
/**
@@ -111,12 +127,9 @@ export class Explore extends React.PureComponent<ExploreProps> {
}
componentDidMount() {
const { exploreId, urlState, initialized } = this.props;
const { datasource, queries, range = DEFAULT_RANGE, ui = DEFAULT_UI_STATE } = (urlState || {}) as ExploreUrlState;
const initialDatasource = datasource || store.get(LAST_USED_DATASOURCE_KEY);
const initialQueries: DataQuery[] = ensureQueries(queries);
const initialRange = { from: parseTime(range.from), to: parseTime(range.to) };
const { initialized, exploreId, initialDatasource, initialQueries, initialRange, initialUI } = this.props;
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
if (!initialized) {
this.props.initializeExplore(
@@ -126,7 +139,7 @@ export class Explore extends React.PureComponent<ExploreProps> {
initialRange,
width,
this.exploreEvents,
ui
initialUI
);
}
}
@@ -143,7 +156,7 @@ export class Explore extends React.PureComponent<ExploreProps> {
this.el = el;
};
onChangeTime = (range: TimeRange, changedByScanner?: boolean) => {
onChangeTime = (range: RawTimeRange, changedByScanner?: boolean) => {
if (this.props.scanning && !changedByScanner) {
this.onStopScanning();
}
@@ -286,6 +299,7 @@ function mapStateToProps(state: StoreState, { exploreId }: ExploreProps) {
const explore = state.explore;
const { split } = explore;
const item: ExploreItemState = explore[exploreId];
const timeZone = getTimeZone(state.user);
const {
StartPage,
datasourceError,
@@ -293,7 +307,6 @@ function mapStateToProps(state: StoreState, { exploreId }: ExploreProps) {
datasourceLoading,
datasourceMissing,
initialized,
range,
showingStartPage,
supportsGraph,
supportsLogs,
@@ -302,6 +315,13 @@ function mapStateToProps(state: StoreState, { exploreId }: ExploreProps) {
urlState,
update,
} = item;
const { datasource, queries, range: urlRange, ui } = (urlState || {}) as ExploreUrlState;
const initialDatasource = datasource || store.get(LAST_USED_DATASOURCE_KEY);
const initialQueries: DataQuery[] = ensureQueries(queries);
const initialRange = urlRange ? getTimeRangeFromUrl(urlRange, timeZone).raw : DEFAULT_RANGE;
const initialUI = ui || DEFAULT_UI_STATE;
return {
StartPage,
datasourceError,
@@ -309,15 +329,17 @@ function mapStateToProps(state: StoreState, { exploreId }: ExploreProps) {
datasourceLoading,
datasourceMissing,
initialized,
range,
showingStartPage,
split,
supportsGraph,
supportsLogs,
supportsTable,
queryKeys,
urlState,
update,
initialDatasource,
initialQueries,
initialRange,
initialUI,
};
}

View File

@@ -3,7 +3,7 @@ import { connect } from 'react-redux';
import { hot } from 'react-hot-loader';
import { ExploreId } from 'app/types/explore';
import { DataSourceSelectItem, RawTimeRange, TimeRange, ClickOutsideWrapper } from '@grafana/ui';
import { DataSourceSelectItem, RawTimeRange, ClickOutsideWrapper, TimeZone, TimeRange } from '@grafana/ui';
import { DataSourcePicker } from 'app/core/components/Select/DataSourcePicker';
import { StoreState } from 'app/types/store';
import {
@@ -15,6 +15,7 @@ import {
changeRefreshInterval,
} from './state/actions';
import TimePicker from './TimePicker';
import { getTimeZone } from '../profile/state/selectors';
import { RefreshPicker, SetInterval } from '@grafana/ui';
enum IconSide {
@@ -48,14 +49,15 @@ const createResponsiveButton = (options: {
interface OwnProps {
exploreId: ExploreId;
timepickerRef: React.RefObject<TimePicker>;
onChangeTime: (range: TimeRange, changedByScanner?: boolean) => void;
onChangeTime: (range: RawTimeRange, changedByScanner?: boolean) => void;
}
interface StateProps {
datasourceMissing: boolean;
exploreDatasources: DataSourceSelectItem[];
loading: boolean;
range: RawTimeRange;
range: TimeRange;
timeZone: TimeZone;
selectedDatasource: DataSourceSelectItem;
splitted: boolean;
refreshInterval: string;
@@ -106,6 +108,7 @@ export class UnConnectedExploreToolbar extends PureComponent<Props, {}> {
exploreId,
loading,
range,
timeZone,
selectedDatasource,
splitted,
timepickerRef,
@@ -159,7 +162,7 @@ export class UnConnectedExploreToolbar extends PureComponent<Props, {}> {
) : null}
<div className="explore-toolbar-content-item timepicker">
<ClickOutsideWrapper onClick={this.onCloseTimePicker}>
<TimePicker ref={timepickerRef} range={range} onChangeTime={onChangeTime} />
<TimePicker ref={timepickerRef} range={range} isUtc={timeZone.isUtc} onChangeTime={onChangeTime} />
</ClickOutsideWrapper>
<RefreshPicker
@@ -214,6 +217,7 @@ const mapStateToProps = (state: StoreState, { exploreId }: OwnProps): StateProps
exploreDatasources,
loading,
range,
timeZone: getTimeZone(state.user),
selectedDatasource,
splitted,
refreshInterval,

View File

@@ -2,12 +2,14 @@ import React from 'react';
import { shallow } from 'enzyme';
import { Graph } from './Graph';
import { mockData } from './__mocks__/mockData';
import { DefaultTimeZone } from '@grafana/ui';
const setup = (propOverrides?: object) => {
const props = {
size: { width: 10, height: 20 },
data: mockData().slice(0, 19),
range: { from: 'now-6h', to: 'now' },
range: { from: 0, to: 1 },
timeZone: DefaultTimeZone,
...propOverrides,
};

View File

@@ -1,14 +1,12 @@
import $ from 'jquery';
import React, { PureComponent } from 'react';
import moment from 'moment';
import 'vendor/flot/jquery.flot';
import 'vendor/flot/jquery.flot.time';
import 'vendor/flot/jquery.flot.selection';
import 'vendor/flot/jquery.flot.stack';
import { RawTimeRange } from '@grafana/ui';
import * as dateMath from 'app/core/utils/datemath';
import { TimeZone, AbsoluteTimeRange } from '@grafana/ui';
import TimeSeries from 'app/core/time_series2';
import Legend from './Legend';
@@ -78,10 +76,11 @@ interface GraphProps {
height?: number;
width?: number;
id?: string;
range: RawTimeRange;
range: AbsoluteTimeRange;
timeZone: TimeZone;
split?: boolean;
userOptions?: any;
onChangeTime?: (range: RawTimeRange) => void;
onChangeTime?: (range: AbsoluteTimeRange) => void;
onToggleSeries?: (alias: string, hiddenSeries: Set<string>) => void;
}
@@ -133,27 +132,20 @@ export class Graph extends PureComponent<GraphProps, GraphState> {
}
onPlotSelected = (event, ranges) => {
if (this.props.onChangeTime) {
const range = {
from: moment(ranges.xaxis.from),
to: moment(ranges.xaxis.to),
};
this.props.onChangeTime(range);
const { onChangeTime } = this.props;
if (onChangeTime) {
this.props.onChangeTime({
from: ranges.xaxis.from,
to: ranges.xaxis.to,
});
}
};
getDynamicOptions() {
const { range, width } = this.props;
const { range, width, timeZone } = this.props;
const ticks = (width || 0) / 100;
let { from, to } = range;
if (!moment.isMoment(from)) {
from = dateMath.parse(from, false);
}
if (!moment.isMoment(to)) {
to = dateMath.parse(to, true);
}
const min = from.valueOf();
const max = to.valueOf();
const min = range.from;
const max = range.to;
return {
xaxis: {
mode: 'time',
@@ -161,7 +153,7 @@ export class Graph extends PureComponent<GraphProps, GraphState> {
max: max,
label: 'Datetime',
ticks: ticks,
timezone: 'browser',
timezone: timeZone.raw,
timeformat: time_format(ticks, min, max),
},
};

View File

@@ -1,7 +1,8 @@
import React, { PureComponent } from 'react';
import { hot } from 'react-hot-loader';
import { connect } from 'react-redux';
import { TimeRange, RawTimeRange } from '@grafana/ui';
import moment from 'moment';
import { TimeRange, TimeZone, AbsoluteTimeRange } from '@grafana/ui';
import { ExploreId, ExploreItemState } from 'app/types/explore';
import { StoreState } from 'app/types';
@@ -9,12 +10,14 @@ import { StoreState } from 'app/types';
import { toggleGraph, changeTime } from './state/actions';
import Graph from './Graph';
import Panel from './Panel';
import { getTimeZone } from '../profile/state/selectors';
interface GraphContainerProps {
exploreId: ExploreId;
graphResult?: any[];
loading: boolean;
range: RawTimeRange;
range: TimeRange;
timeZone: TimeZone;
showingGraph: boolean;
showingTable: boolean;
split: boolean;
@@ -28,13 +31,20 @@ export class GraphContainer extends PureComponent<GraphContainerProps> {
this.props.toggleGraph(this.props.exploreId, this.props.showingGraph);
};
onChangeTime = (timeRange: TimeRange) => {
this.props.changeTime(this.props.exploreId, timeRange);
onChangeTime = (absRange: AbsoluteTimeRange) => {
const { exploreId, timeZone, changeTime } = this.props;
const range = {
from: timeZone.isUtc ? moment.utc(absRange.from) : moment(absRange.from),
to: timeZone.isUtc ? moment.utc(absRange.to) : moment(absRange.to),
};
changeTime(exploreId, range);
};
render() {
const { exploreId, graphResult, loading, showingGraph, showingTable, range, split, width } = this.props;
const { exploreId, graphResult, loading, showingGraph, showingTable, range, split, width, timeZone } = this.props;
const graphHeight = showingGraph && showingTable ? 200 : 400;
const timeRange = { from: range.from.valueOf(), to: range.to.valueOf() };
if (!graphResult) {
return null;
@@ -47,7 +57,8 @@ export class GraphContainer extends PureComponent<GraphContainerProps> {
height={graphHeight}
id={`explore-graph-${exploreId}`}
onChangeTime={this.onChangeTime}
range={range}
range={timeRange}
timeZone={timeZone}
split={split}
width={width}
/>
@@ -62,7 +73,7 @@ function mapStateToProps(state: StoreState, { exploreId }) {
const item: ExploreItemState = explore[exploreId];
const { graphResult, queryTransactions, range, showingGraph, showingTable } = item;
const loading = queryTransactions.some(qt => qt.resultType === 'Graph' && !qt.done);
return { graphResult, loading, range, showingGraph, showingTable, split };
return { graphResult, loading, range, showingGraph, showingTable, split, timeZone: getTimeZone(state.user) };
}
const mapDispatchToProps = {

View File

@@ -2,7 +2,7 @@ import _ from 'lodash';
import React, { PureComponent } from 'react';
import * as rangeUtil from 'app/core/utils/rangeutil';
import { RawTimeRange, Switch, LogLevel } from '@grafana/ui';
import { RawTimeRange, Switch, LogLevel, TimeZone, TimeRange, AbsoluteTimeRange } from '@grafana/ui';
import TimeSeries from 'app/core/time_series2';
import { LogsDedupDescription, LogsDedupStrategy, LogsModel, LogsMetaKind } from 'app/core/logs_model';
@@ -48,12 +48,13 @@ interface Props {
exploreId: string;
highlighterExpressions: string[];
loading: boolean;
range?: RawTimeRange;
range: TimeRange;
timeZone: TimeZone;
scanning?: boolean;
scanRange?: RawTimeRange;
dedupStrategy: LogsDedupStrategy;
hiddenLogLevels: Set<LogLevel>;
onChangeTime?: (range: RawTimeRange) => void;
onChangeTime?: (range: AbsoluteTimeRange) => void;
onClickLabel?: (label: string, value: string) => void;
onStartScanning?: () => void;
onStopScanning?: () => void;
@@ -156,6 +157,7 @@ export default class Logs extends PureComponent<Props, State> {
loading = false,
onClickLabel,
range,
timeZone,
scanning,
scanRange,
width,
@@ -191,6 +193,10 @@ export default class Logs extends PureComponent<Props, State> {
// React profiler becomes unusable if we pass all rows to all rows and their labels, using getter instead
const getRows = () => processedRows;
const timeSeries = data.series.map(series => new TimeSeries(series));
const absRange = {
from: range.from.valueOf(),
to: range.to.valueOf(),
};
return (
<div className="logs-panel">
@@ -199,7 +205,8 @@ export default class Logs extends PureComponent<Props, State> {
data={timeSeries}
height={100}
width={width}
range={range}
range={absRange}
timeZone={timeZone}
id={`explore-logs-graph-${exploreId}`}
onChangeTime={this.props.onChangeTime}
onToggleSeries={this.onToggleLogLevel}

View File

@@ -1,7 +1,7 @@
import React, { PureComponent } from 'react';
import { hot } from 'react-hot-loader';
import { connect } from 'react-redux';
import { RawTimeRange, TimeRange, LogLevel } from '@grafana/ui';
import { RawTimeRange, TimeRange, LogLevel, TimeZone, AbsoluteTimeRange } from '@grafana/ui';
import { ExploreId, ExploreItemState } from 'app/types/explore';
import { LogsModel, LogsDedupStrategy } from 'app/core/logs_model';
@@ -12,6 +12,7 @@ import Logs from './Logs';
import Panel from './Panel';
import { toggleLogLevelAction } from 'app/features/explore/state/actionTypes';
import { deduplicatedLogsSelector, exploreItemUIStateSelector } from 'app/features/explore/state/selectors';
import { getTimeZone } from '../profile/state/selectors';
interface LogsContainerProps {
exploreId: ExploreId;
@@ -19,11 +20,12 @@ interface LogsContainerProps {
logsHighlighterExpressions?: string[];
logsResult?: LogsModel;
dedupedResult?: LogsModel;
onChangeTime: (range: TimeRange) => void;
onChangeTime: (range: AbsoluteTimeRange) => void;
onClickLabel: (key: string, value: string) => void;
onStartScanning: () => void;
onStopScanning: () => void;
range: RawTimeRange;
range: TimeRange;
timeZone: TimeZone;
scanning?: boolean;
scanRange?: RawTimeRange;
showingLogs: boolean;
@@ -64,6 +66,7 @@ export class LogsContainer extends PureComponent<LogsContainerProps> {
onStartScanning,
onStopScanning,
range,
timeZone,
showingLogs,
scanning,
scanRange,
@@ -88,6 +91,7 @@ export class LogsContainer extends PureComponent<LogsContainerProps> {
onDedupStrategyChange={this.handleDedupStrategyChange}
onToggleLogLevel={this.hangleToggleLogLevel}
range={range}
timeZone={timeZone}
scanning={scanning}
scanRange={scanRange}
width={width}
@@ -106,6 +110,7 @@ function mapStateToProps(state: StoreState, { exploreId }) {
const { showingLogs, dedupStrategy } = exploreItemUIStateSelector(item);
const hiddenLogLevels = new Set(item.hiddenLogLevels);
const dedupedResult = deduplicatedLogsSelector(item);
const timeZone = getTimeZone(state.user);
return {
loading,
@@ -115,6 +120,7 @@ function mapStateToProps(state: StoreState, { exploreId }) {
scanRange,
showingLogs,
range,
timeZone,
dedupStrategy,
hiddenLogLevels,
dedupedResult,

View File

@@ -1,5 +1,6 @@
// Libraries
import React, { PureComponent } from 'react';
import moment from 'moment';
// Services
import { getAngularLoader, AngularComponent } from 'app/core/services/AngularLoader';
@@ -7,7 +8,7 @@ import { getTimeSrv } from 'app/features/dashboard/services/TimeSrv';
// Types
import { Emitter } from 'app/core/utils/emitter';
import { RawTimeRange, DataQuery } from '@grafana/ui';
import { DataQuery, TimeRange } from '@grafana/ui';
import 'app/features/plugins/plugin_loader';
interface QueryEditorProps {
@@ -17,7 +18,7 @@ interface QueryEditorProps {
onQueryChange?: (value: DataQuery) => void;
initialQuery: DataQuery;
exploreEvents: Emitter;
range: RawTimeRange;
range: TimeRange;
}
export default class QueryEditor extends PureComponent<QueryEditorProps, any> {
@@ -62,10 +63,13 @@ export default class QueryEditor extends PureComponent<QueryEditorProps, any> {
}
}
initTimeSrv(range) {
initTimeSrv(range: TimeRange) {
const timeSrv = getTimeSrv();
timeSrv.init({
time: range,
time: {
from: moment(range.from),
to: moment(range.to),
},
refresh: false,
getTimezone: () => 'utc',
timeRangeUpdated: () => console.log('refreshDashboard!'),

View File

@@ -14,14 +14,7 @@ import { changeQuery, modifyQueries, runQueries, addQueryRow } from './state/act
// Types
import { StoreState } from 'app/types';
import {
RawTimeRange,
DataQuery,
ExploreDataSourceApi,
QueryHint,
QueryFixAction,
DataSourceStatus,
} from '@grafana/ui';
import { DataQuery, ExploreDataSourceApi, QueryHint, QueryFixAction, DataSourceStatus, TimeRange } from '@grafana/ui';
import { QueryTransaction, HistoryItem, ExploreItemState, ExploreId } from 'app/types/explore';
import { Emitter } from 'app/core/utils/emitter';
import { highlightLogsExpressionAction, removeQueryRowAction } from './state/actionTypes';
@@ -48,7 +41,7 @@ interface QueryRowProps {
modifyQueries: typeof modifyQueries;
queryTransactions: QueryTransaction[];
exploreEvents: Emitter;
range: RawTimeRange;
range: TimeRange;
removeQueryRowAction: typeof removeQueryRowAction;
runQueries: typeof runQueries;
}

View File

@@ -1,74 +1,238 @@
import React from 'react';
import { shallow } from 'enzyme';
import sinon from 'sinon';
import moment from 'moment';
import * as dateMath from 'app/core/utils/datemath';
import * as rangeUtil from 'app/core/utils/rangeutil';
import TimePicker, { DEFAULT_RANGE, parseTime } from './TimePicker';
import TimePicker from './TimePicker';
import { RawTimeRange, TimeRange, TIME_FORMAT } from '@grafana/ui';
const DEFAULT_RANGE = {
from: 'now-6h',
to: 'now',
};
const fromRaw = (rawRange: RawTimeRange): TimeRange => {
const raw = {
from: moment.isMoment(rawRange.from) ? moment(rawRange.from) : rawRange.from,
to: moment.isMoment(rawRange.to) ? moment(rawRange.to) : rawRange.to,
};
return {
from: dateMath.parse(raw.from, false),
to: dateMath.parse(raw.to, true),
raw: rawRange,
};
};
describe('<TimePicker />', () => {
it('renders closed with default values', () => {
const rangeString = rangeUtil.describeTimeRange(DEFAULT_RANGE);
const wrapper = shallow(<TimePicker />);
expect(wrapper.find('.timepicker-rangestring').text()).toBe(rangeString);
expect(wrapper.find('.gf-timepicker-dropdown').exists()).toBe(false);
it('render default values when closed and relative time range', () => {
const range = fromRaw(DEFAULT_RANGE);
const wrapper = shallow(<TimePicker range={range} />);
expect(wrapper.state('fromRaw')).toBe(DEFAULT_RANGE.from);
expect(wrapper.state('toRaw')).toBe(DEFAULT_RANGE.to);
expect(wrapper.find('.timepicker-rangestring').text()).toBe('Last 6 hours');
expect(wrapper.find('.gf-timepicker-dropdown').exists()).toBeFalsy();
expect(wrapper.find('.gf-timepicker-utc').exists()).toBeFalsy();
});
it('renders with relative range', () => {
const range = {
from: 'now-7h',
to: 'now',
};
const rangeString = rangeUtil.describeTimeRange(range);
it('render default values when closed, utc and relative time range', () => {
const range = fromRaw(DEFAULT_RANGE);
const wrapper = shallow(<TimePicker range={range} isUtc />);
expect(wrapper.state('fromRaw')).toBe(DEFAULT_RANGE.from);
expect(wrapper.state('toRaw')).toBe(DEFAULT_RANGE.to);
expect(wrapper.find('.timepicker-rangestring').text()).toBe('Last 6 hours');
expect(wrapper.find('.gf-timepicker-dropdown').exists()).toBeFalsy();
expect(wrapper.find('.gf-timepicker-utc').exists()).toBeTruthy();
});
it('renders default values when open and relative range', () => {
const range = fromRaw(DEFAULT_RANGE);
const wrapper = shallow(<TimePicker range={range} isOpen />);
expect(wrapper.find('.timepicker-rangestring').text()).toBe(rangeString);
expect(wrapper.state('fromRaw')).toBe(range.from);
expect(wrapper.state('toRaw')).toBe(range.to);
expect(wrapper.find('.timepicker-from').props().value).toBe(range.from);
expect(wrapper.find('.timepicker-to').props().value).toBe(range.to);
expect(wrapper.state('fromRaw')).toBe(DEFAULT_RANGE.from);
expect(wrapper.state('toRaw')).toBe(DEFAULT_RANGE.to);
expect(wrapper.find('.timepicker-rangestring').text()).toBe('Last 6 hours');
expect(wrapper.find('.gf-timepicker-dropdown').exists()).toBeTruthy();
expect(wrapper.find('.gf-timepicker-utc').exists()).toBeFalsy();
expect(wrapper.find('.timepicker-from').props().value).toBe(DEFAULT_RANGE.from);
expect(wrapper.find('.timepicker-to').props().value).toBe(DEFAULT_RANGE.to);
});
it('renders with epoch (millies) range converted to ISO-ish', () => {
it('renders default values when open, utc and relative range', () => {
const range = fromRaw(DEFAULT_RANGE);
const wrapper = shallow(<TimePicker range={range} isOpen isUtc />);
expect(wrapper.state('fromRaw')).toBe(DEFAULT_RANGE.from);
expect(wrapper.state('toRaw')).toBe(DEFAULT_RANGE.to);
expect(wrapper.find('.timepicker-rangestring').text()).toBe('Last 6 hours');
expect(wrapper.find('.gf-timepicker-dropdown').exists()).toBeTruthy();
expect(wrapper.find('.gf-timepicker-utc').exists()).toBeTruthy();
expect(wrapper.find('.timepicker-from').props().value).toBe(DEFAULT_RANGE.from);
expect(wrapper.find('.timepicker-to').props().value).toBe(DEFAULT_RANGE.to);
});
it('apply with absolute range and non-utc', () => {
const range = {
from: '1',
to: '1000',
from: moment.utc(1),
to: moment.utc(1000),
raw: {
from: moment.utc(1),
to: moment.utc(1000),
},
};
const rangeString = rangeUtil.describeTimeRange({
from: parseTime(range.from, true),
to: parseTime(range.to, true),
});
const wrapper = shallow(<TimePicker range={range} isUtc isOpen />);
const localRange = {
from: moment(1),
to: moment(1000),
raw: {
from: moment(1),
to: moment(1000),
},
};
const expectedRangeString = rangeUtil.describeTimeRange(localRange);
const onChangeTime = sinon.spy();
const wrapper = shallow(<TimePicker range={range} isOpen onChangeTime={onChangeTime} />);
expect(wrapper.state('fromRaw')).toBe(localRange.from.format(TIME_FORMAT));
expect(wrapper.state('toRaw')).toBe(localRange.to.format(TIME_FORMAT));
expect(wrapper.state('initialRange')).toBe(range.raw);
expect(wrapper.find('.timepicker-rangestring').text()).toBe(expectedRangeString);
expect(wrapper.find('.timepicker-from').props().value).toBe(localRange.from.format(TIME_FORMAT));
expect(wrapper.find('.timepicker-to').props().value).toBe(localRange.to.format(TIME_FORMAT));
wrapper.find('button.gf-form-btn').simulate('click');
expect(onChangeTime.calledOnce).toBeTruthy();
expect(onChangeTime.getCall(0).args[0].from.valueOf()).toBe(0);
expect(onChangeTime.getCall(0).args[0].to.valueOf()).toBe(1000);
expect(wrapper.state('isOpen')).toBeFalsy();
expect(wrapper.state('rangeString')).toBe(expectedRangeString);
});
it('apply with absolute range and utc', () => {
const range = {
from: moment.utc(1),
to: moment.utc(1000),
raw: {
from: moment.utc(1),
to: moment.utc(1000),
},
};
const onChangeTime = sinon.spy();
const wrapper = shallow(<TimePicker range={range} isUtc isOpen onChangeTime={onChangeTime} />);
expect(wrapper.state('fromRaw')).toBe('1970-01-01 00:00:00');
expect(wrapper.state('toRaw')).toBe('1970-01-01 00:00:01');
expect(wrapper.find('.timepicker-rangestring').text()).toBe(rangeString);
expect(wrapper.state('initialRange')).toBe(range.raw);
expect(wrapper.find('.timepicker-rangestring').text()).toBe('Jan 1, 1970 00:00:00 to Jan 1, 1970 00:00:01');
expect(wrapper.find('.timepicker-from').props().value).toBe('1970-01-01 00:00:00');
expect(wrapper.find('.timepicker-to').props().value).toBe('1970-01-01 00:00:01');
wrapper.find('button.gf-form-btn').simulate('click');
expect(onChangeTime.calledOnce).toBeTruthy();
expect(onChangeTime.getCall(0).args[0].from.valueOf()).toBe(0);
expect(onChangeTime.getCall(0).args[0].to.valueOf()).toBe(1000);
expect(wrapper.state('isOpen')).toBeFalsy();
expect(wrapper.state('rangeString')).toBe('Jan 1, 1970 00:00:00 to Jan 1, 1970 00:00:01');
});
it('moves ranges forward and backward by half the range on arrow click', () => {
const range = {
from: '2000',
to: '4000',
it('moves ranges backward by half the range on left arrow click when utc', () => {
const rawRange = {
from: moment.utc(2000),
to: moment.utc(4000),
raw: {
from: moment.utc(2000),
to: moment.utc(4000),
},
};
const rangeString = rangeUtil.describeTimeRange({
from: parseTime(range.from, true),
to: parseTime(range.to, true),
});
const range = fromRaw(rawRange);
const onChangeTime = sinon.spy();
const wrapper = shallow(<TimePicker range={range} isUtc isOpen onChangeTime={onChangeTime} />);
expect(wrapper.state('fromRaw')).toBe('1970-01-01 00:00:02');
expect(wrapper.state('toRaw')).toBe('1970-01-01 00:00:04');
expect(wrapper.find('.timepicker-rangestring').text()).toBe(rangeString);
expect(wrapper.find('.timepicker-from').props().value).toBe('1970-01-01 00:00:02');
expect(wrapper.find('.timepicker-to').props().value).toBe('1970-01-01 00:00:04');
wrapper.find('.timepicker-left').simulate('click');
expect(onChangeTime.calledOnce).toBe(true);
expect(onChangeTime.calledOnce).toBeTruthy();
expect(onChangeTime.getCall(0).args[0].from.valueOf()).toBe(1000);
expect(onChangeTime.getCall(0).args[0].to.valueOf()).toBe(3000);
});
it('moves ranges backward by half the range on left arrow click when not utc', () => {
const range = {
from: moment.utc(2000),
to: moment.utc(4000),
raw: {
from: moment.utc(2000),
to: moment.utc(4000),
},
};
const localRange = {
from: moment(2000),
to: moment(4000),
raw: {
from: moment(2000),
to: moment(4000),
},
};
const onChangeTime = sinon.spy();
const wrapper = shallow(<TimePicker range={range} isUtc={false} isOpen onChangeTime={onChangeTime} />);
expect(wrapper.state('fromRaw')).toBe(localRange.from.format(TIME_FORMAT));
expect(wrapper.state('toRaw')).toBe(localRange.to.format(TIME_FORMAT));
wrapper.find('.timepicker-left').simulate('click');
expect(onChangeTime.calledOnce).toBeTruthy();
expect(onChangeTime.getCall(0).args[0].from.valueOf()).toBe(1000);
expect(onChangeTime.getCall(0).args[0].to.valueOf()).toBe(3000);
});
it('moves ranges forward by half the range on right arrow click when utc', () => {
const range = {
from: moment.utc(1000),
to: moment.utc(3000),
raw: {
from: moment.utc(1000),
to: moment.utc(3000),
},
};
const onChangeTime = sinon.spy();
const wrapper = shallow(<TimePicker range={range} isUtc isOpen onChangeTime={onChangeTime} />);
expect(wrapper.state('fromRaw')).toBe('1970-01-01 00:00:01');
expect(wrapper.state('toRaw')).toBe('1970-01-01 00:00:03');
wrapper.find('.timepicker-right').simulate('click');
expect(wrapper.state('fromRaw')).toBe('1970-01-01 00:00:02');
expect(wrapper.state('toRaw')).toBe('1970-01-01 00:00:04');
expect(onChangeTime.calledOnce).toBeTruthy();
expect(onChangeTime.getCall(0).args[0].from.valueOf()).toBe(2000);
expect(onChangeTime.getCall(0).args[0].to.valueOf()).toBe(4000);
});
it('moves ranges forward by half the range on right arrow click when not utc', () => {
const range = {
from: moment.utc(1000),
to: moment.utc(3000),
raw: {
from: moment.utc(1000),
to: moment.utc(3000),
},
};
const localRange = {
from: moment(1000),
to: moment(3000),
raw: {
from: moment(1000),
to: moment(3000),
},
};
const onChangeTime = sinon.spy();
const wrapper = shallow(<TimePicker range={range} isOpen onChangeTime={onChangeTime} />);
expect(wrapper.state('fromRaw')).toBe(localRange.from.format(TIME_FORMAT));
expect(wrapper.state('toRaw')).toBe(localRange.to.format(TIME_FORMAT));
wrapper.find('.timepicker-right').simulate('click');
expect(onChangeTime.calledOnce).toBeTruthy();
expect(onChangeTime.getCall(0).args[0].from.valueOf()).toBe(2000);
expect(onChangeTime.getCall(0).args[0].to.valueOf()).toBe(4000);
});
});

View File

@@ -1,43 +1,12 @@
import React, { PureComponent } from 'react';
import moment from 'moment';
import * as dateMath from 'app/core/utils/datemath';
import * as rangeUtil from 'app/core/utils/rangeutil';
import { Input, RawTimeRange, TimeRange } from '@grafana/ui';
const DATE_FORMAT = 'YYYY-MM-DD HH:mm:ss';
export const DEFAULT_RANGE = {
from: 'now-6h',
to: 'now',
};
/**
* Return a human-editable string of either relative (inludes "now") or absolute local time (in the shape of DATE_FORMAT).
* @param value Epoch or relative time
*/
export function parseTime(value: string | moment.Moment, isUtc = false, ensureString = false): string | moment.Moment {
if (moment.isMoment(value)) {
if (ensureString) {
return value.format(DATE_FORMAT);
}
return value;
}
if ((value as string).indexOf('now') !== -1) {
return value;
}
let time: any = value;
// Possible epoch
if (!isNaN(time)) {
time = parseInt(time, 10);
}
time = isUtc ? moment.utc(time) : moment(time);
return time.format(DATE_FORMAT);
}
import { Input, RawTimeRange, TimeRange, TIME_FORMAT } from '@grafana/ui';
interface TimePickerProps {
isOpen?: boolean;
isUtc?: boolean;
range?: RawTimeRange;
range: TimeRange;
onChangeTime?: (range: RawTimeRange, scanning?: boolean) => void;
}
@@ -46,17 +15,40 @@ interface TimePickerState {
isUtc: boolean;
rangeString: string;
refreshInterval?: string;
initialRange?: RawTimeRange;
initialRange: RawTimeRange;
// Input-controlled text, keep these in a shape that is human-editable
fromRaw: string;
toRaw: string;
}
const getRaw = (isUtc: boolean, range: any) => {
const rawRange = {
from: range.raw.from,
to: range.raw.to,
};
if (moment.isMoment(rawRange.from)) {
if (!isUtc) {
rawRange.from = rawRange.from.local();
}
rawRange.from = rawRange.from.format(TIME_FORMAT);
}
if (moment.isMoment(rawRange.to)) {
if (!isUtc) {
rawRange.to = rawRange.to.local();
}
rawRange.to = rawRange.to.format(TIME_FORMAT);
}
return rawRange;
};
/**
* TimePicker with dropdown menu for relative dates.
*
* Initialize with a range that is either based on relative time strings,
* Initialize with a range that is either based on relative rawRange.strings,
* or on Moment objects.
* Internally the component needs to keep a string representation in `fromRaw`
* and `toRaw` for the controlled inputs.
@@ -69,89 +61,68 @@ export default class TimePicker extends PureComponent<TimePickerProps, TimePicke
constructor(props) {
super(props);
const { range, isUtc, isOpen } = props;
const rawRange = getRaw(props.isUtc, range);
this.state = {
isOpen: props.isOpen,
isUtc: props.isUtc,
rangeString: '',
fromRaw: '',
toRaw: '',
initialRange: DEFAULT_RANGE,
isOpen: isOpen,
isUtc: isUtc,
rangeString: rangeUtil.describeTimeRange(range.raw),
fromRaw: rawRange.from,
toRaw: rawRange.to,
initialRange: range.raw,
refreshInterval: '',
};
} //Temp solution... How do detect if ds supports table format?
static getDerivedStateFromProps(props, state) {
if (state.initialRange && state.initialRange === props.range) {
static getDerivedStateFromProps(props: TimePickerProps, state: TimePickerState) {
if (
state.initialRange &&
state.initialRange.from === props.range.raw.from &&
state.initialRange.to === props.range.raw.to
) {
return state;
}
const from = props.range ? props.range.from : DEFAULT_RANGE.from;
const to = props.range ? props.range.to : DEFAULT_RANGE.to;
// Ensure internal string format
const fromRaw = parseTime(from, props.isUtc, true);
const toRaw = parseTime(to, props.isUtc, true);
const range = {
from: fromRaw,
to: toRaw,
};
const { range } = props;
const rawRange = getRaw(props.isUtc, range);
return {
...state,
fromRaw,
toRaw,
initialRange: props.range,
rangeString: rangeUtil.describeTimeRange(range),
fromRaw: rawRange.from,
toRaw: rawRange.to,
initialRange: range.raw,
rangeString: rangeUtil.describeTimeRange(range.raw),
};
}
move(direction: number, scanning?: boolean): RawTimeRange {
const { onChangeTime } = this.props;
const { fromRaw, toRaw } = this.state;
const from = dateMath.parse(fromRaw, false);
const to = dateMath.parse(toRaw, true);
const step = scanning ? 1 : 2;
const timespan = (to.valueOf() - from.valueOf()) / step;
let nextTo, nextFrom;
const { onChangeTime, range: origRange } = this.props;
const range = {
from: moment.utc(origRange.from),
to: moment.utc(origRange.to),
};
const timespan = (range.to.valueOf() - range.from.valueOf()) / 2;
let to, from;
if (direction === -1) {
nextTo = to.valueOf() - timespan;
nextFrom = from.valueOf() - timespan;
to = range.to.valueOf() - timespan;
from = range.from.valueOf() - timespan;
} else if (direction === 1) {
nextTo = to.valueOf() + timespan;
nextFrom = from.valueOf() + timespan;
if (nextTo > Date.now() && to.valueOf() < Date.now()) {
nextTo = Date.now();
nextFrom = from.valueOf();
}
to = range.to.valueOf() + timespan;
from = range.from.valueOf() + timespan;
} else {
nextTo = to.valueOf();
nextFrom = from.valueOf();
to = range.to.valueOf();
from = range.from.valueOf();
}
const nextRange = {
from: moment(nextFrom),
to: moment(nextTo),
const nextTimeRange = {
from: this.props.isUtc ? moment.utc(from) : moment(from),
to: this.props.isUtc ? moment.utc(to) : moment(to),
};
const nextTimeRange: TimeRange = {
raw: nextRange,
from: nextRange.from,
to: nextRange.to,
};
this.setState(
{
rangeString: rangeUtil.describeTimeRange(nextRange),
fromRaw: nextRange.from.format(DATE_FORMAT),
toRaw: nextRange.to.format(DATE_FORMAT),
},
() => {
onChangeTime(nextTimeRange, scanning);
}
);
return nextRange;
if (onChangeTime) {
onChangeTime(nextTimeRange);
}
return nextTimeRange;
}
handleChangeFrom = e => {
@@ -167,16 +138,25 @@ export default class TimePicker extends PureComponent<TimePickerProps, TimePicke
};
handleClickApply = () => {
const { onChangeTime } = this.props;
let range;
const { onChangeTime, isUtc } = this.props;
let rawRange;
this.setState(
state => {
const { toRaw, fromRaw } = this.state;
range = {
from: dateMath.parse(fromRaw, false),
to: dateMath.parse(toRaw, true),
rawRange = {
from: fromRaw,
to: toRaw,
};
const rangeString = rangeUtil.describeTimeRange(range);
if (rawRange.from.indexOf('now') === -1) {
rawRange.from = isUtc ? moment.utc(rawRange.from, TIME_FORMAT) : moment(rawRange.from, TIME_FORMAT);
}
if (rawRange.to.indexOf('now') === -1) {
rawRange.to = isUtc ? moment.utc(rawRange.to, TIME_FORMAT) : moment(rawRange.to, TIME_FORMAT);
}
const rangeString = rangeUtil.describeTimeRange(rawRange);
return {
isOpen: false,
rangeString,
@@ -184,7 +164,7 @@ export default class TimePicker extends PureComponent<TimePickerProps, TimePicke
},
() => {
if (onChangeTime) {
onChangeTime(range);
onChangeTime(rawRange);
}
}
);
@@ -201,16 +181,20 @@ export default class TimePicker extends PureComponent<TimePickerProps, TimePicke
handleClickRelativeOption = range => {
const { onChangeTime } = this.props;
const rangeString = rangeUtil.describeTimeRange(range);
const rawRange = {
from: range.from,
to: range.to,
};
this.setState(
{
toRaw: range.to,
fromRaw: range.from,
toRaw: rawRange.to,
fromRaw: rawRange.from,
isOpen: false,
rangeString,
},
() => {
if (onChangeTime) {
onChangeTime(range);
onChangeTime(rawRange);
}
}
);

View File

@@ -2,12 +2,12 @@
import { Emitter } from 'app/core/core';
import {
RawTimeRange,
TimeRange,
DataQuery,
DataSourceSelectItem,
DataSourceApi,
QueryFixAction,
LogLevel,
TimeRange,
} from '@grafana/ui/src/types';
import {
ExploreId,
@@ -89,7 +89,7 @@ export interface InitializeExplorePayload {
containerWidth: number;
eventBridge: Emitter;
queries: DataQuery[];
range: RawTimeRange;
range: TimeRange;
ui: ExploreUIState;
}

View File

@@ -1,3 +1,4 @@
import moment from 'moment';
import { refreshExplore, testDatasource, loadDatasource } from './actions';
import { ExploreId, ExploreUrlState, ExploreUpdateState } from 'app/types';
import { thunkTester } from 'test/core/thunk/thunkTester';
@@ -18,6 +19,7 @@ import { Emitter } from 'app/core/core';
import { ActionOf } from 'app/core/redux/actionCreatorFactory';
import { makeInitialUpdateState } from './reducers';
import { DataQuery } from '@grafana/ui/src/types/datasource';
import { DefaultTimeZone, RawTimeRange } from '@grafana/ui';
jest.mock('app/features/plugins/datasource_srv', () => ({
getDatasourceSrv: () => ({
@@ -29,21 +31,39 @@ jest.mock('app/features/plugins/datasource_srv', () => ({
}),
}));
const t = moment.utc();
const testRange = {
from: t,
to: t,
raw: {
from: t,
to: t,
},
};
jest.mock('app/core/utils/explore', () => ({
...jest.requireActual('app/core/utils/explore'),
getTimeRangeFromUrl: (range: RawTimeRange) => testRange,
}));
const setup = (updateOverides?: Partial<ExploreUpdateState>) => {
const exploreId = ExploreId.left;
const containerWidth = 1920;
const eventBridge = {} as Emitter;
const ui = { dedupStrategy: LogsDedupStrategy.none, showingGraph: false, showingLogs: false, showingTable: false };
const range = { from: 'now', to: 'now' };
const timeZone = DefaultTimeZone;
const range = testRange;
const urlState: ExploreUrlState = {
datasource: 'some-datasource',
queries: [],
range,
range: range.raw,
ui,
};
const updateDefaults = makeInitialUpdateState();
const update = { ...updateDefaults, ...updateOverides };
const initialState = {
user: {
timeZone,
},
explore: {
[exploreId]: {
initialized: true,
@@ -77,7 +97,7 @@ describe('refreshExplore', () => {
describe('when explore is initialized', () => {
describe('and update datasource is set', () => {
it('then it should dispatch initializeExplore', async () => {
const { exploreId, ui, range, initialState, containerWidth, eventBridge } = setup({ datasource: true });
const { exploreId, ui, initialState, containerWidth, eventBridge } = setup({ datasource: true });
const dispatchedActions = await thunkTester(initialState)
.givenThunk(refreshExplore)
@@ -90,7 +110,10 @@ describe('refreshExplore', () => {
expect(payload.containerWidth).toEqual(containerWidth);
expect(payload.eventBridge).toEqual(eventBridge);
expect(payload.queries.length).toBe(1); // Queries have generated keys hard to expect on
expect(payload.range).toEqual(range);
expect(payload.range.from).toEqual(testRange.from);
expect(payload.range.to).toEqual(testRange.to);
expect(payload.range.raw.from).toEqual(testRange.raw.from);
expect(payload.range.raw.to).toEqual(testRange.raw.to);
expect(payload.ui).toEqual(ui);
});
});

View File

@@ -1,5 +1,6 @@
// Libraries
import _ from 'lodash';
import moment from 'moment';
// Services & Utils
import store from 'app/core/store';
@@ -16,6 +17,8 @@ import {
buildQueryTransaction,
serializeStateToUrlParam,
parseUrlState,
getTimeRange,
getTimeRangeFromUrl,
} from 'app/core/utils/explore';
// Actions
@@ -26,12 +29,12 @@ import { ResultGetter } from 'app/types/explore';
import { ThunkResult } from 'app/types';
import {
RawTimeRange,
TimeRange,
DataSourceApi,
DataQuery,
DataSourceSelectItem,
QueryHint,
QueryFixAction,
TimeRange,
} from '@grafana/ui/src/types';
import {
ExploreId,
@@ -83,7 +86,7 @@ import {
} from './actionTypes';
import { ActionOf, ActionCreator } from 'app/core/redux/actionCreatorFactory';
import { LogsDedupStrategy } from 'app/core/logs_model';
import { parseTime } from '../TimePicker';
import { getTimeZone } from 'app/features/profile/state/selectors';
/**
* Updates UI state and save it to the URL
@@ -169,8 +172,10 @@ export function changeSize(
/**
* 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> {
return dispatch => {
export function changeTime(exploreId: ExploreId, rawRange: RawTimeRange): ThunkResult<void> {
return (dispatch, getState) => {
const timeZone = getTimeZone(getState().user);
const range = getTimeRange(timeZone, rawRange);
dispatch(changeTimeAction({ exploreId, range }));
dispatch(runQueries(exploreId));
};
@@ -235,12 +240,14 @@ export function initializeExplore(
exploreId: ExploreId,
datasourceName: string,
queries: DataQuery[],
range: RawTimeRange,
rawRange: RawTimeRange,
containerWidth: number,
eventBridge: Emitter,
ui: ExploreUIState
): ThunkResult<void> {
return async dispatch => {
return async (dispatch, getState) => {
const timeZone = getTimeZone(getState().user);
const range = getTimeRange(timeZone, rawRange);
dispatch(loadExploreDatasourcesAndSetDatasource(exploreId, datasourceName));
dispatch(
initializeExploreAction({
@@ -723,6 +730,23 @@ export function splitOpen(): ThunkResult<void> {
};
}
const toRawTimeRange = (range: TimeRange): RawTimeRange => {
let from = range.raw.from;
if (moment.isMoment(from)) {
from = from.valueOf().toString(10);
}
let to = range.raw.to;
if (moment.isMoment(to)) {
to = to.valueOf().toString(10);
}
return {
from,
to,
};
};
/**
* Saves Explore state to URL using the `left` and `right` parameters.
* If split view is not active, `right` will not be set.
@@ -734,7 +758,7 @@ export function stateSave(): ThunkResult<void> {
const leftUrlState: ExploreUrlState = {
datasource: left.datasourceInstance.name,
queries: left.queries.map(clearQueryKeys),
range: left.range,
range: toRawTimeRange(left.range),
ui: {
showingGraph: left.showingGraph,
showingLogs: left.showingLogs,
@@ -747,7 +771,7 @@ export function stateSave(): ThunkResult<void> {
const rightUrlState: ExploreUrlState = {
datasource: right.datasourceInstance.name,
queries: right.queries.map(clearQueryKeys),
range: right.range,
range: toRawTimeRange(right.range),
ui: {
showingGraph: right.showingGraph,
showingLogs: right.showingLogs,
@@ -830,19 +854,20 @@ export function refreshExplore(exploreId: ExploreId): ThunkResult<void> {
}
const { urlState, update, containerWidth, eventBridge } = itemState;
const { datasource, queries, range, ui } = urlState;
const { datasource, queries, range: urlRange, ui } = urlState;
const refreshQueries = queries.map(q => ({ ...q, ...generateEmptyQuery(itemState.queries) }));
const refreshRange = { from: parseTime(range.from), to: parseTime(range.to) };
const timeZone = getTimeZone(getState().user);
const range = getTimeRangeFromUrl(urlRange, timeZone);
// need to refresh datasource
if (update.datasource) {
const initialQueries = ensureQueries(queries);
const initialRange = { from: parseTime(range.from), to: parseTime(range.to) };
dispatch(initializeExplore(exploreId, datasource, initialQueries, initialRange, containerWidth, eventBridge, ui));
dispatch(initializeExplore(exploreId, datasource, initialQueries, range, containerWidth, eventBridge, ui));
return;
}
if (update.range) {
dispatch(changeTimeAction({ exploreId, range: refreshRange as TimeRange }));
dispatch(changeTimeAction({ exploreId, range }));
}
// need to refresh ui state

View File

@@ -86,7 +86,11 @@ export const makeExploreItemState = (): ExploreItemState => ({
initialized: false,
queryTransactions: [],
queryIntervals: { interval: '15s', intervalMs: DEFAULT_GRAPH_INTERVAL },
range: DEFAULT_RANGE,
range: {
from: null,
to: null,
raw: DEFAULT_RANGE,
},
scanning: false,
scanRange: null,
showingGraph: true,

View File

@@ -3,6 +3,7 @@ import config from 'app/core/config';
export const initialState: UserState = {
orgId: config.bootData.user.orgId,
timeZone: config.bootData.user.timezone,
};
export const userReducer = (state = initialState, action: any): UserState => {

View File

@@ -0,0 +1,4 @@
import { UserState } from 'app/types';
import { parseTimeZone } from '@grafana/ui';
export const getTimeZone = (state: UserState) => parseTimeZone(state.timeZone);

View File

@@ -2,7 +2,6 @@ import { ComponentClass } from 'react';
import { Value } from 'slate';
import {
RawTimeRange,
TimeRange,
DataQuery,
DataQueryResponseData,
DataSourceSelectItem,
@@ -10,6 +9,7 @@ import {
QueryHint,
ExploreStartPageProps,
LogLevel,
TimeRange,
} from '@grafana/ui';
import { Emitter, TimeSeries } from 'app/core/core';
@@ -189,7 +189,7 @@ export interface ExploreItemState {
/**
* Time range for this Explore. Managed by the time picker and used by all query runs.
*/
range: TimeRange | RawTimeRange;
range: TimeRange;
/**
* Scanner function that calculates a new range, triggers a query run, and returns the new range.
*/

View File

@@ -46,4 +46,5 @@ export interface UsersState {
export interface UserState {
orgId: number;
timeZone: string;
}