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; raw: RawTimeRange;
} }
export interface AbsoluteTimeRange {
from: number;
to: number;
}
export interface IntervalValues { export interface IntervalValues {
interval: string; // 10s,5m interval: string; // 10s,5m
intervalMs: number; 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 { export interface TimeOption {
from: string; from: string;
to: string; to: string;

View File

@@ -1,18 +1,17 @@
// Libraries // Libraries
import _ from 'lodash'; import _ from 'lodash';
import moment, { Moment } from 'moment';
// Services & Utils // Services & Utils
import * as dateMath from 'app/core/utils/datemath'; import * as dateMath from 'app/core/utils/datemath';
import { renderUrl } from 'app/core/utils/url'; import { renderUrl } from 'app/core/utils/url';
import kbn from 'app/core/utils/kbn'; import kbn from 'app/core/utils/kbn';
import store from 'app/core/store'; 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 TableModel, { mergeTablesIntoModel } from 'app/core/table_model';
import { getNextRefIdChar } from './query'; import { getNextRefIdChar } from './query';
// Types // 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 TimeSeries from 'app/core/time_series2';
import { import {
ExploreUrlState, ExploreUrlState,
@@ -104,7 +103,7 @@ export function buildQueryTransaction(
rowIndex: number, rowIndex: number,
resultType: ResultType, resultType: ResultType,
queryOptions: QueryOptions, queryOptions: QueryOptions,
range: RawTimeRange, range: TimeRange,
queryIntervals: QueryIntervals, queryIntervals: QueryIntervals,
scanning: boolean scanning: boolean
): QueryTransaction { ): QueryTransaction {
@@ -131,12 +130,8 @@ export function buildQueryTransaction(
intervalMs, intervalMs,
panelId, panelId,
targets: configuredQueries, // Datasources rely on DataQueries being passed under the targets key. targets: configuredQueries, // Datasources rely on DataQueries being passed under the targets key.
range: { range,
from: dateMath.parse(range.from, false), rangeRaw: range.raw,
to: dateMath.parse(range.to, true),
raw: range,
},
rangeRaw: range,
scopedVars: { scopedVars: {
__interval: { text: interval, value: interval }, __interval: { text: interval, value: interval },
__interval_ms: { text: intervalMs, value: intervalMs }, __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) { if (!resolution) {
return { interval: '1s', intervalMs: 1000 }; return { interval: '1s', intervalMs: 1000 };
} }
const absoluteRange: RawTimeRange = { return kbn.calculateInterval(range, resolution, lowLimit);
from: parseDate(range.from, false),
to: parseDate(range.to, true),
};
return kbn.calculateInterval(absoluteRange, resolution, lowLimit);
} }
export const makeTimeSeriesList: ResultGetter = (dataList, transaction, allTransactions) => { export const makeTimeSeriesList: ResultGetter = (dataList, transaction, allTransactions) => {
@@ -395,3 +385,51 @@ export const getQueryKeys = (queries: DataQuery[], datasourceInstance: DataSourc
return queryKeys; 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 LogsContainer from './LogsContainer';
import QueryRows from './QueryRows'; import QueryRows from './QueryRows';
import TableContainer from './TableContainer'; import TableContainer from './TableContainer';
import TimePicker, { parseTime } from './TimePicker'; import TimePicker from './TimePicker';
// Actions // Actions
import { import {
@@ -31,15 +31,29 @@ import {
} from './state/actions'; } from './state/actions';
// Types // Types
import { RawTimeRange, TimeRange, DataQuery, ExploreStartPageProps, ExploreDataSourceApi } from '@grafana/ui'; import { RawTimeRange, DataQuery, ExploreStartPageProps, ExploreDataSourceApi } from '@grafana/ui';
import { ExploreItemState, ExploreUrlState, RangeScanner, ExploreId, ExploreUpdateState } from 'app/types/explore'; import {
ExploreItemState,
ExploreUrlState,
RangeScanner,
ExploreId,
ExploreUpdateState,
ExploreUIState,
} from 'app/types/explore';
import { StoreState } from 'app/types'; 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 { Emitter } from 'app/core/utils/emitter';
import { ExploreToolbar } from './ExploreToolbar'; import { ExploreToolbar } from './ExploreToolbar';
import { scanStopAction } from './state/actionTypes'; import { scanStopAction } from './state/actionTypes';
import { NoDataSourceCallToAction } from './NoDataSourceCallToAction'; import { NoDataSourceCallToAction } from './NoDataSourceCallToAction';
import { FadeIn } from 'app/core/components/Animations/FadeIn'; import { FadeIn } from 'app/core/components/Animations/FadeIn';
import { getTimeZone } from '../profile/state/selectors';
interface ExploreProps { interface ExploreProps {
StartPage?: ComponentClass<ExploreStartPageProps>; StartPage?: ComponentClass<ExploreStartPageProps>;
@@ -53,7 +67,6 @@ interface ExploreProps {
initializeExplore: typeof initializeExplore; initializeExplore: typeof initializeExplore;
initialized: boolean; initialized: boolean;
modifyQueries: typeof modifyQueries; modifyQueries: typeof modifyQueries;
range: RawTimeRange;
update: ExploreUpdateState; update: ExploreUpdateState;
reconnectDatasource: typeof reconnectDatasource; reconnectDatasource: typeof reconnectDatasource;
refreshExplore: typeof refreshExplore; refreshExplore: typeof refreshExplore;
@@ -69,7 +82,10 @@ interface ExploreProps {
supportsLogs: boolean | null; supportsLogs: boolean | null;
supportsTable: boolean | null; supportsTable: boolean | null;
queryKeys: string[]; queryKeys: string[];
urlState: ExploreUrlState; initialDatasource: string;
initialQueries: DataQuery[];
initialRange: RawTimeRange;
initialUI: ExploreUIState;
} }
/** /**
@@ -111,12 +127,9 @@ export class Explore extends React.PureComponent<ExploreProps> {
} }
componentDidMount() { componentDidMount() {
const { exploreId, urlState, initialized } = this.props; const { initialized, exploreId, initialDatasource, initialQueries, initialRange, initialUI } = 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 width = this.el ? this.el.offsetWidth : 0; const width = this.el ? this.el.offsetWidth : 0;
// initialize the whole explore first time we mount and if browser history contains a change in datasource // initialize the whole explore first time we mount and if browser history contains a change in datasource
if (!initialized) { if (!initialized) {
this.props.initializeExplore( this.props.initializeExplore(
@@ -126,7 +139,7 @@ export class Explore extends React.PureComponent<ExploreProps> {
initialRange, initialRange,
width, width,
this.exploreEvents, this.exploreEvents,
ui initialUI
); );
} }
} }
@@ -143,7 +156,7 @@ export class Explore extends React.PureComponent<ExploreProps> {
this.el = el; this.el = el;
}; };
onChangeTime = (range: TimeRange, changedByScanner?: boolean) => { onChangeTime = (range: RawTimeRange, changedByScanner?: boolean) => {
if (this.props.scanning && !changedByScanner) { if (this.props.scanning && !changedByScanner) {
this.onStopScanning(); this.onStopScanning();
} }
@@ -286,6 +299,7 @@ function mapStateToProps(state: StoreState, { exploreId }: ExploreProps) {
const explore = state.explore; const explore = state.explore;
const { split } = explore; const { split } = explore;
const item: ExploreItemState = explore[exploreId]; const item: ExploreItemState = explore[exploreId];
const timeZone = getTimeZone(state.user);
const { const {
StartPage, StartPage,
datasourceError, datasourceError,
@@ -293,7 +307,6 @@ function mapStateToProps(state: StoreState, { exploreId }: ExploreProps) {
datasourceLoading, datasourceLoading,
datasourceMissing, datasourceMissing,
initialized, initialized,
range,
showingStartPage, showingStartPage,
supportsGraph, supportsGraph,
supportsLogs, supportsLogs,
@@ -302,6 +315,13 @@ function mapStateToProps(state: StoreState, { exploreId }: ExploreProps) {
urlState, urlState,
update, update,
} = item; } = 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 { return {
StartPage, StartPage,
datasourceError, datasourceError,
@@ -309,15 +329,17 @@ function mapStateToProps(state: StoreState, { exploreId }: ExploreProps) {
datasourceLoading, datasourceLoading,
datasourceMissing, datasourceMissing,
initialized, initialized,
range,
showingStartPage, showingStartPage,
split, split,
supportsGraph, supportsGraph,
supportsLogs, supportsLogs,
supportsTable, supportsTable,
queryKeys, queryKeys,
urlState,
update, update,
initialDatasource,
initialQueries,
initialRange,
initialUI,
}; };
} }

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,74 +1,238 @@
import React from 'react'; import React from 'react';
import { shallow } from 'enzyme'; import { shallow } from 'enzyme';
import sinon from 'sinon'; 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 * 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 />', () => { describe('<TimePicker />', () => {
it('renders closed with default values', () => { it('render default values when closed and relative time range', () => {
const rangeString = rangeUtil.describeTimeRange(DEFAULT_RANGE); const range = fromRaw(DEFAULT_RANGE);
const wrapper = shallow(<TimePicker />); const wrapper = shallow(<TimePicker range={range} />);
expect(wrapper.find('.timepicker-rangestring').text()).toBe(rangeString); expect(wrapper.state('fromRaw')).toBe(DEFAULT_RANGE.from);
expect(wrapper.find('.gf-timepicker-dropdown').exists()).toBe(false); 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', () => { it('render default values when closed, utc and relative time range', () => {
const range = { const range = fromRaw(DEFAULT_RANGE);
from: 'now-7h', const wrapper = shallow(<TimePicker range={range} isUtc />);
to: 'now', expect(wrapper.state('fromRaw')).toBe(DEFAULT_RANGE.from);
}; expect(wrapper.state('toRaw')).toBe(DEFAULT_RANGE.to);
const rangeString = rangeUtil.describeTimeRange(range); 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 />); const wrapper = shallow(<TimePicker range={range} isOpen />);
expect(wrapper.find('.timepicker-rangestring').text()).toBe(rangeString); expect(wrapper.state('fromRaw')).toBe(DEFAULT_RANGE.from);
expect(wrapper.state('fromRaw')).toBe(range.from); expect(wrapper.state('toRaw')).toBe(DEFAULT_RANGE.to);
expect(wrapper.state('toRaw')).toBe(range.to); expect(wrapper.find('.timepicker-rangestring').text()).toBe('Last 6 hours');
expect(wrapper.find('.timepicker-from').props().value).toBe(range.from); expect(wrapper.find('.gf-timepicker-dropdown').exists()).toBeTruthy();
expect(wrapper.find('.timepicker-to').props().value).toBe(range.to); 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 = { const range = {
from: '1', from: moment.utc(1),
to: '1000', to: moment.utc(1000),
raw: {
from: moment.utc(1),
to: moment.utc(1000),
},
}; };
const rangeString = rangeUtil.describeTimeRange({ const localRange = {
from: parseTime(range.from, true), from: moment(1),
to: parseTime(range.to, true), to: moment(1000),
}); raw: {
const wrapper = shallow(<TimePicker range={range} isUtc isOpen />); 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('fromRaw')).toBe('1970-01-01 00:00:00');
expect(wrapper.state('toRaw')).toBe('1970-01-01 00:00:01'); 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-from').props().value).toBe('1970-01-01 00:00:00');
expect(wrapper.find('.timepicker-to').props().value).toBe('1970-01-01 00:00:01'); 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', () => { it('moves ranges backward by half the range on left arrow click when utc', () => {
const range = { const rawRange = {
from: '2000', from: moment.utc(2000),
to: '4000', to: moment.utc(4000),
raw: {
from: moment.utc(2000),
to: moment.utc(4000),
},
}; };
const rangeString = rangeUtil.describeTimeRange({ const range = fromRaw(rawRange);
from: parseTime(range.from, true),
to: parseTime(range.to, true),
});
const onChangeTime = sinon.spy(); const onChangeTime = sinon.spy();
const wrapper = shallow(<TimePicker range={range} isUtc isOpen onChangeTime={onChangeTime} />); const wrapper = shallow(<TimePicker range={range} isUtc isOpen onChangeTime={onChangeTime} />);
expect(wrapper.state('fromRaw')).toBe('1970-01-01 00:00:02'); expect(wrapper.state('fromRaw')).toBe('1970-01-01 00:00:02');
expect(wrapper.state('toRaw')).toBe('1970-01-01 00:00:04'); 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'); 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('fromRaw')).toBe('1970-01-01 00:00:01');
expect(wrapper.state('toRaw')).toBe('1970-01-01 00:00:03'); expect(wrapper.state('toRaw')).toBe('1970-01-01 00:00:03');
wrapper.find('.timepicker-right').simulate('click'); wrapper.find('.timepicker-right').simulate('click');
expect(wrapper.state('fromRaw')).toBe('1970-01-01 00:00:02'); expect(onChangeTime.calledOnce).toBeTruthy();
expect(wrapper.state('toRaw')).toBe('1970-01-01 00:00:04'); 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 React, { PureComponent } from 'react';
import moment from 'moment'; import moment from 'moment';
import * as dateMath from 'app/core/utils/datemath';
import * as rangeUtil from 'app/core/utils/rangeutil'; import * as rangeUtil from 'app/core/utils/rangeutil';
import { Input, RawTimeRange, TimeRange } from '@grafana/ui'; import { Input, RawTimeRange, TimeRange, TIME_FORMAT } 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);
}
interface TimePickerProps { interface TimePickerProps {
isOpen?: boolean; isOpen?: boolean;
isUtc?: boolean; isUtc?: boolean;
range?: RawTimeRange; range: TimeRange;
onChangeTime?: (range: RawTimeRange, scanning?: boolean) => void; onChangeTime?: (range: RawTimeRange, scanning?: boolean) => void;
} }
@@ -46,17 +15,40 @@ interface TimePickerState {
isUtc: boolean; isUtc: boolean;
rangeString: string; rangeString: string;
refreshInterval?: string; refreshInterval?: string;
initialRange?: RawTimeRange; initialRange: RawTimeRange;
// Input-controlled text, keep these in a shape that is human-editable // Input-controlled text, keep these in a shape that is human-editable
fromRaw: string; fromRaw: string;
toRaw: 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. * 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. * or on Moment objects.
* Internally the component needs to keep a string representation in `fromRaw` * Internally the component needs to keep a string representation in `fromRaw`
* and `toRaw` for the controlled inputs. * and `toRaw` for the controlled inputs.
@@ -69,89 +61,68 @@ export default class TimePicker extends PureComponent<TimePickerProps, TimePicke
constructor(props) { constructor(props) {
super(props); super(props);
const { range, isUtc, isOpen } = props;
const rawRange = getRaw(props.isUtc, range);
this.state = { this.state = {
isOpen: props.isOpen, isOpen: isOpen,
isUtc: props.isUtc, isUtc: isUtc,
rangeString: '', rangeString: rangeUtil.describeTimeRange(range.raw),
fromRaw: '', fromRaw: rawRange.from,
toRaw: '', toRaw: rawRange.to,
initialRange: DEFAULT_RANGE, initialRange: range.raw,
refreshInterval: '', refreshInterval: '',
}; };
} //Temp solution... How do detect if ds supports table format? } //Temp solution... How do detect if ds supports table format?
static getDerivedStateFromProps(props, state) { static getDerivedStateFromProps(props: TimePickerProps, state: TimePickerState) {
if (state.initialRange && state.initialRange === props.range) { if (
state.initialRange &&
state.initialRange.from === props.range.raw.from &&
state.initialRange.to === props.range.raw.to
) {
return state; return state;
} }
const from = props.range ? props.range.from : DEFAULT_RANGE.from; const { range } = props;
const to = props.range ? props.range.to : DEFAULT_RANGE.to; const rawRange = getRaw(props.isUtc, range);
// Ensure internal string format
const fromRaw = parseTime(from, props.isUtc, true);
const toRaw = parseTime(to, props.isUtc, true);
const range = {
from: fromRaw,
to: toRaw,
};
return { return {
...state, ...state,
fromRaw, fromRaw: rawRange.from,
toRaw, toRaw: rawRange.to,
initialRange: props.range, initialRange: range.raw,
rangeString: rangeUtil.describeTimeRange(range), rangeString: rangeUtil.describeTimeRange(range.raw),
}; };
} }
move(direction: number, scanning?: boolean): RawTimeRange { move(direction: number, scanning?: boolean): RawTimeRange {
const { onChangeTime } = this.props; const { onChangeTime, range: origRange } = this.props;
const { fromRaw, toRaw } = this.state; const range = {
const from = dateMath.parse(fromRaw, false); from: moment.utc(origRange.from),
const to = dateMath.parse(toRaw, true); to: moment.utc(origRange.to),
const step = scanning ? 1 : 2; };
const timespan = (to.valueOf() - from.valueOf()) / step; const timespan = (range.to.valueOf() - range.from.valueOf()) / 2;
let to, from;
let nextTo, nextFrom;
if (direction === -1) { if (direction === -1) {
nextTo = to.valueOf() - timespan; to = range.to.valueOf() - timespan;
nextFrom = from.valueOf() - timespan; from = range.from.valueOf() - timespan;
} else if (direction === 1) { } else if (direction === 1) {
nextTo = to.valueOf() + timespan; to = range.to.valueOf() + timespan;
nextFrom = from.valueOf() + timespan; from = range.from.valueOf() + timespan;
if (nextTo > Date.now() && to.valueOf() < Date.now()) {
nextTo = Date.now();
nextFrom = from.valueOf();
}
} else { } else {
nextTo = to.valueOf(); to = range.to.valueOf();
nextFrom = from.valueOf(); from = range.from.valueOf();
} }
const nextRange = { const nextTimeRange = {
from: moment(nextFrom), from: this.props.isUtc ? moment.utc(from) : moment(from),
to: moment(nextTo), to: this.props.isUtc ? moment.utc(to) : moment(to),
}; };
if (onChangeTime) {
const nextTimeRange: TimeRange = { onChangeTime(nextTimeRange);
raw: nextRange, }
from: nextRange.from, return nextTimeRange;
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;
} }
handleChangeFrom = e => { handleChangeFrom = e => {
@@ -167,16 +138,25 @@ export default class TimePicker extends PureComponent<TimePickerProps, TimePicke
}; };
handleClickApply = () => { handleClickApply = () => {
const { onChangeTime } = this.props; const { onChangeTime, isUtc } = this.props;
let range; let rawRange;
this.setState( this.setState(
state => { state => {
const { toRaw, fromRaw } = this.state; const { toRaw, fromRaw } = this.state;
range = { rawRange = {
from: dateMath.parse(fromRaw, false), from: fromRaw,
to: dateMath.parse(toRaw, true), 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 { return {
isOpen: false, isOpen: false,
rangeString, rangeString,
@@ -184,7 +164,7 @@ export default class TimePicker extends PureComponent<TimePickerProps, TimePicke
}, },
() => { () => {
if (onChangeTime) { if (onChangeTime) {
onChangeTime(range); onChangeTime(rawRange);
} }
} }
); );
@@ -201,16 +181,20 @@ export default class TimePicker extends PureComponent<TimePickerProps, TimePicke
handleClickRelativeOption = range => { handleClickRelativeOption = range => {
const { onChangeTime } = this.props; const { onChangeTime } = this.props;
const rangeString = rangeUtil.describeTimeRange(range); const rangeString = rangeUtil.describeTimeRange(range);
const rawRange = {
from: range.from,
to: range.to,
};
this.setState( this.setState(
{ {
toRaw: range.to, toRaw: rawRange.to,
fromRaw: range.from, fromRaw: rawRange.from,
isOpen: false, isOpen: false,
rangeString, rangeString,
}, },
() => { () => {
if (onChangeTime) { if (onChangeTime) {
onChangeTime(range); onChangeTime(rawRange);
} }
} }
); );

View File

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

View File

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

View File

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

View File

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

View File

@@ -3,6 +3,7 @@ import config from 'app/core/config';
export const initialState: UserState = { export const initialState: UserState = {
orgId: config.bootData.user.orgId, orgId: config.bootData.user.orgId,
timeZone: config.bootData.user.timezone,
}; };
export const userReducer = (state = initialState, action: any): UserState => { 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 { Value } from 'slate';
import { import {
RawTimeRange, RawTimeRange,
TimeRange,
DataQuery, DataQuery,
DataQueryResponseData, DataQueryResponseData,
DataSourceSelectItem, DataSourceSelectItem,
@@ -10,6 +9,7 @@ import {
QueryHint, QueryHint,
ExploreStartPageProps, ExploreStartPageProps,
LogLevel, LogLevel,
TimeRange,
} from '@grafana/ui'; } from '@grafana/ui';
import { Emitter, TimeSeries } from 'app/core/core'; 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. * 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. * 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 { export interface UserState {
orgId: number; orgId: number;
timeZone: string;
} }