From 02cb7ff4368f3c4cc3aebb1ebc4e9adf06954399 Mon Sep 17 00:00:00 2001 From: Marcus Efraimsson Date: Mon, 29 Apr 2019 18:28:41 +0200 Subject: [PATCH] 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 --- packages/grafana-ui/src/types/time.ts | 19 ++ public/app/core/utils/explore.ts | 72 ++++-- public/app/features/explore/Explore.tsx | 54 ++-- .../app/features/explore/ExploreToolbar.tsx | 12 +- public/app/features/explore/Graph.test.tsx | 4 +- public/app/features/explore/Graph.tsx | 36 +-- .../app/features/explore/GraphContainer.tsx | 25 +- public/app/features/explore/Logs.tsx | 15 +- public/app/features/explore/LogsContainer.tsx | 12 +- public/app/features/explore/QueryEditor.tsx | 12 +- public/app/features/explore/QueryRow.tsx | 11 +- .../app/features/explore/TimePicker.test.tsx | 244 +++++++++++++++--- public/app/features/explore/TimePicker.tsx | 198 +++++++------- .../app/features/explore/state/actionTypes.ts | 4 +- .../features/explore/state/actions.test.ts | 31 ++- public/app/features/explore/state/actions.ts | 51 +++- public/app/features/explore/state/reducers.ts | 6 +- public/app/features/profile/state/reducers.ts | 1 + .../app/features/profile/state/selectors.ts | 4 + public/app/types/explore.ts | 4 +- public/app/types/user.ts | 1 + 21 files changed, 560 insertions(+), 256 deletions(-) create mode 100644 public/app/features/profile/state/selectors.ts diff --git a/packages/grafana-ui/src/types/time.ts b/packages/grafana-ui/src/types/time.ts index 2bc22485b5c..d6c524ac19a 100644 --- a/packages/grafana-ui/src/types/time.ts +++ b/packages/grafana-ui/src/types/time.ts @@ -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; diff --git a/public/app/core/utils/explore.ts b/public/app/core/utils/explore.ts index cfc53ae450b..36c5cdcece8 100644 --- a/public/app/core/utils/explore.ts +++ b/public/app/core/utils/explore.ts @@ -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, + }; +}; diff --git a/public/app/features/explore/Explore.tsx b/public/app/features/explore/Explore.tsx index 45deefedcce..b5863a27457 100644 --- a/public/app/features/explore/Explore.tsx +++ b/public/app/features/explore/Explore.tsx @@ -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; @@ -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 { } 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 { initialRange, width, this.exploreEvents, - ui + initialUI ); } } @@ -143,7 +156,7 @@ export class Explore extends React.PureComponent { 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, }; } diff --git a/public/app/features/explore/ExploreToolbar.tsx b/public/app/features/explore/ExploreToolbar.tsx index 0ee34958f4f..8a9ae5c3057 100644 --- a/public/app/features/explore/ExploreToolbar.tsx +++ b/public/app/features/explore/ExploreToolbar.tsx @@ -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; - 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 { exploreId, loading, range, + timeZone, selectedDatasource, splitted, timepickerRef, @@ -159,7 +162,7 @@ export class UnConnectedExploreToolbar extends PureComponent { ) : null}
- + { 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, }; diff --git a/public/app/features/explore/Graph.tsx b/public/app/features/explore/Graph.tsx index 7600d8725bc..f9c48fc92c7 100644 --- a/public/app/features/explore/Graph.tsx +++ b/public/app/features/explore/Graph.tsx @@ -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) => void; } @@ -133,27 +132,20 @@ export class Graph extends PureComponent { } 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 { max: max, label: 'Datetime', ticks: ticks, - timezone: 'browser', + timezone: timeZone.raw, timeformat: time_format(ticks, min, max), }, }; diff --git a/public/app/features/explore/GraphContainer.tsx b/public/app/features/explore/GraphContainer.tsx index 161750143d9..ec3f330c616 100644 --- a/public/app/features/explore/GraphContainer.tsx +++ b/public/app/features/explore/GraphContainer.tsx @@ -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 { 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 { 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 = { diff --git a/public/app/features/explore/Logs.tsx b/public/app/features/explore/Logs.tsx index 486af10ff91..86a4c55bee6 100644 --- a/public/app/features/explore/Logs.tsx +++ b/public/app/features/explore/Logs.tsx @@ -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; - 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 { loading = false, onClickLabel, range, + timeZone, scanning, scanRange, width, @@ -191,6 +193,10 @@ export default class Logs extends PureComponent { // 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 (
@@ -199,7 +205,8 @@ export default class Logs extends PureComponent { data={timeSeries} height={100} width={width} - range={range} + range={absRange} + timeZone={timeZone} id={`explore-logs-graph-${exploreId}`} onChangeTime={this.props.onChangeTime} onToggleSeries={this.onToggleLogLevel} diff --git a/public/app/features/explore/LogsContainer.tsx b/public/app/features/explore/LogsContainer.tsx index bb4833f4200..54d910367d4 100644 --- a/public/app/features/explore/LogsContainer.tsx +++ b/public/app/features/explore/LogsContainer.tsx @@ -1,7 +1,7 @@ import React, { PureComponent } from 'react'; import { hot } from 'react-hot-loader'; import { connect } from 'react-redux'; -import { 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 { onStartScanning, onStopScanning, range, + timeZone, showingLogs, scanning, scanRange, @@ -88,6 +91,7 @@ export class LogsContainer extends PureComponent { 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, diff --git a/public/app/features/explore/QueryEditor.tsx b/public/app/features/explore/QueryEditor.tsx index d158f6bb9f3..b271b0d9649 100644 --- a/public/app/features/explore/QueryEditor.tsx +++ b/public/app/features/explore/QueryEditor.tsx @@ -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 { @@ -62,10 +63,13 @@ export default class QueryEditor extends PureComponent { } } - 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!'), diff --git a/public/app/features/explore/QueryRow.tsx b/public/app/features/explore/QueryRow.tsx index ef5dda9b1da..25c00a2b6ea 100644 --- a/public/app/features/explore/QueryRow.tsx +++ b/public/app/features/explore/QueryRow.tsx @@ -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; } diff --git a/public/app/features/explore/TimePicker.test.tsx b/public/app/features/explore/TimePicker.test.tsx index cadcfbb668c..09b8287673c 100644 --- a/public/app/features/explore/TimePicker.test.tsx +++ b/public/app/features/explore/TimePicker.test.tsx @@ -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('', () => { - it('renders closed with default values', () => { - const rangeString = rangeUtil.describeTimeRange(DEFAULT_RANGE); - const wrapper = shallow(); - 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(); + 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(); + 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(); - 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(); + 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(); + 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(); + 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(); 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(); 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(); + 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(); 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(); + 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); }); }); diff --git a/public/app/features/explore/TimePicker.tsx b/public/app/features/explore/TimePicker.tsx index 27e1afe4364..b5b0ef09d64 100644 --- a/public/app/features/explore/TimePicker.tsx +++ b/public/app/features/explore/TimePicker.tsx @@ -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 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 { - 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 { if (onChangeTime) { - onChangeTime(range); + onChangeTime(rawRange); } } ); @@ -201,16 +181,20 @@ export default class TimePicker extends PureComponent { 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); } } ); diff --git a/public/app/features/explore/state/actionTypes.ts b/public/app/features/explore/state/actionTypes.ts index ccbf9426ca7..aec004286a8 100644 --- a/public/app/features/explore/state/actionTypes.ts +++ b/public/app/features/explore/state/actionTypes.ts @@ -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; } diff --git a/public/app/features/explore/state/actions.test.ts b/public/app/features/explore/state/actions.test.ts index f0165f7269c..64aaa9b6342 100644 --- a/public/app/features/explore/state/actions.test.ts +++ b/public/app/features/explore/state/actions.test.ts @@ -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) => { 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); }); }); diff --git a/public/app/features/explore/state/actions.ts b/public/app/features/explore/state/actions.ts index 5657d38ca1e..0a430725155 100644 --- a/public/app/features/explore/state/actions.ts +++ b/public/app/features/explore/state/actions.ts @@ -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 { - return dispatch => { +export function changeTime(exploreId: ExploreId, rawRange: RawTimeRange): ThunkResult { + 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 { - 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 { }; } +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 { 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 { 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 { } 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 diff --git a/public/app/features/explore/state/reducers.ts b/public/app/features/explore/state/reducers.ts index 9ae21076ff0..c63b5fe4a1c 100644 --- a/public/app/features/explore/state/reducers.ts +++ b/public/app/features/explore/state/reducers.ts @@ -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, diff --git a/public/app/features/profile/state/reducers.ts b/public/app/features/profile/state/reducers.ts index dc6e841449e..b2236387ab8 100644 --- a/public/app/features/profile/state/reducers.ts +++ b/public/app/features/profile/state/reducers.ts @@ -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 => { diff --git a/public/app/features/profile/state/selectors.ts b/public/app/features/profile/state/selectors.ts new file mode 100644 index 00000000000..83279093fc4 --- /dev/null +++ b/public/app/features/profile/state/selectors.ts @@ -0,0 +1,4 @@ +import { UserState } from 'app/types'; +import { parseTimeZone } from '@grafana/ui'; + +export const getTimeZone = (state: UserState) => parseTimeZone(state.timeZone); diff --git a/public/app/types/explore.ts b/public/app/types/explore.ts index 28fe642bdca..c057785bb90 100644 --- a/public/app/types/explore.ts +++ b/public/app/types/explore.ts @@ -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. */ diff --git a/public/app/types/user.ts b/public/app/types/user.ts index 7691558ce90..535a6a3cc05 100644 --- a/public/app/types/user.ts +++ b/public/app/types/user.ts @@ -46,4 +46,5 @@ export interface UsersState { export interface UserState { orgId: number; + timeZone: string; }