mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
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:
committed by
GitHub
parent
7dbe719fda
commit
02cb7ff436
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -16,7 +16,7 @@ import GraphContainer from './GraphContainer';
|
||||
import LogsContainer from './LogsContainer';
|
||||
import QueryRows from './QueryRows';
|
||||
import TableContainer from './TableContainer';
|
||||
import TimePicker, { parseTime } from './TimePicker';
|
||||
import TimePicker from './TimePicker';
|
||||
|
||||
// Actions
|
||||
import {
|
||||
@@ -31,15 +31,29 @@ import {
|
||||
} from './state/actions';
|
||||
|
||||
// Types
|
||||
import { RawTimeRange, TimeRange, DataQuery, ExploreStartPageProps, ExploreDataSourceApi } from '@grafana/ui';
|
||||
import { ExploreItemState, ExploreUrlState, RangeScanner, ExploreId, ExploreUpdateState } from 'app/types/explore';
|
||||
import { RawTimeRange, DataQuery, ExploreStartPageProps, ExploreDataSourceApi } from '@grafana/ui';
|
||||
import {
|
||||
ExploreItemState,
|
||||
ExploreUrlState,
|
||||
RangeScanner,
|
||||
ExploreId,
|
||||
ExploreUpdateState,
|
||||
ExploreUIState,
|
||||
} from 'app/types/explore';
|
||||
import { StoreState } from 'app/types';
|
||||
import { LAST_USED_DATASOURCE_KEY, ensureQueries, DEFAULT_RANGE, DEFAULT_UI_STATE } from 'app/core/utils/explore';
|
||||
import {
|
||||
LAST_USED_DATASOURCE_KEY,
|
||||
ensureQueries,
|
||||
DEFAULT_RANGE,
|
||||
DEFAULT_UI_STATE,
|
||||
getTimeRangeFromUrl,
|
||||
} from 'app/core/utils/explore';
|
||||
import { Emitter } from 'app/core/utils/emitter';
|
||||
import { ExploreToolbar } from './ExploreToolbar';
|
||||
import { scanStopAction } from './state/actionTypes';
|
||||
import { NoDataSourceCallToAction } from './NoDataSourceCallToAction';
|
||||
import { FadeIn } from 'app/core/components/Animations/FadeIn';
|
||||
import { getTimeZone } from '../profile/state/selectors';
|
||||
|
||||
interface ExploreProps {
|
||||
StartPage?: ComponentClass<ExploreStartPageProps>;
|
||||
@@ -53,7 +67,6 @@ interface ExploreProps {
|
||||
initializeExplore: typeof initializeExplore;
|
||||
initialized: boolean;
|
||||
modifyQueries: typeof modifyQueries;
|
||||
range: RawTimeRange;
|
||||
update: ExploreUpdateState;
|
||||
reconnectDatasource: typeof reconnectDatasource;
|
||||
refreshExplore: typeof refreshExplore;
|
||||
@@ -69,7 +82,10 @@ interface ExploreProps {
|
||||
supportsLogs: boolean | null;
|
||||
supportsTable: boolean | null;
|
||||
queryKeys: string[];
|
||||
urlState: ExploreUrlState;
|
||||
initialDatasource: string;
|
||||
initialQueries: DataQuery[];
|
||||
initialRange: RawTimeRange;
|
||||
initialUI: ExploreUIState;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -111,12 +127,9 @@ export class Explore extends React.PureComponent<ExploreProps> {
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
const { exploreId, urlState, initialized } = this.props;
|
||||
const { datasource, queries, range = DEFAULT_RANGE, ui = DEFAULT_UI_STATE } = (urlState || {}) as ExploreUrlState;
|
||||
const initialDatasource = datasource || store.get(LAST_USED_DATASOURCE_KEY);
|
||||
const initialQueries: DataQuery[] = ensureQueries(queries);
|
||||
const initialRange = { from: parseTime(range.from), to: parseTime(range.to) };
|
||||
const { initialized, exploreId, initialDatasource, initialQueries, initialRange, initialUI } = this.props;
|
||||
const width = this.el ? this.el.offsetWidth : 0;
|
||||
|
||||
// initialize the whole explore first time we mount and if browser history contains a change in datasource
|
||||
if (!initialized) {
|
||||
this.props.initializeExplore(
|
||||
@@ -126,7 +139,7 @@ export class Explore extends React.PureComponent<ExploreProps> {
|
||||
initialRange,
|
||||
width,
|
||||
this.exploreEvents,
|
||||
ui
|
||||
initialUI
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -143,7 +156,7 @@ export class Explore extends React.PureComponent<ExploreProps> {
|
||||
this.el = el;
|
||||
};
|
||||
|
||||
onChangeTime = (range: TimeRange, changedByScanner?: boolean) => {
|
||||
onChangeTime = (range: RawTimeRange, changedByScanner?: boolean) => {
|
||||
if (this.props.scanning && !changedByScanner) {
|
||||
this.onStopScanning();
|
||||
}
|
||||
@@ -286,6 +299,7 @@ function mapStateToProps(state: StoreState, { exploreId }: ExploreProps) {
|
||||
const explore = state.explore;
|
||||
const { split } = explore;
|
||||
const item: ExploreItemState = explore[exploreId];
|
||||
const timeZone = getTimeZone(state.user);
|
||||
const {
|
||||
StartPage,
|
||||
datasourceError,
|
||||
@@ -293,7 +307,6 @@ function mapStateToProps(state: StoreState, { exploreId }: ExploreProps) {
|
||||
datasourceLoading,
|
||||
datasourceMissing,
|
||||
initialized,
|
||||
range,
|
||||
showingStartPage,
|
||||
supportsGraph,
|
||||
supportsLogs,
|
||||
@@ -302,6 +315,13 @@ function mapStateToProps(state: StoreState, { exploreId }: ExploreProps) {
|
||||
urlState,
|
||||
update,
|
||||
} = item;
|
||||
|
||||
const { datasource, queries, range: urlRange, ui } = (urlState || {}) as ExploreUrlState;
|
||||
const initialDatasource = datasource || store.get(LAST_USED_DATASOURCE_KEY);
|
||||
const initialQueries: DataQuery[] = ensureQueries(queries);
|
||||
const initialRange = urlRange ? getTimeRangeFromUrl(urlRange, timeZone).raw : DEFAULT_RANGE;
|
||||
const initialUI = ui || DEFAULT_UI_STATE;
|
||||
|
||||
return {
|
||||
StartPage,
|
||||
datasourceError,
|
||||
@@ -309,15 +329,17 @@ function mapStateToProps(state: StoreState, { exploreId }: ExploreProps) {
|
||||
datasourceLoading,
|
||||
datasourceMissing,
|
||||
initialized,
|
||||
range,
|
||||
showingStartPage,
|
||||
split,
|
||||
supportsGraph,
|
||||
supportsLogs,
|
||||
supportsTable,
|
||||
queryKeys,
|
||||
urlState,
|
||||
update,
|
||||
initialDatasource,
|
||||
initialQueries,
|
||||
initialRange,
|
||||
initialUI,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ import { connect } from 'react-redux';
|
||||
import { hot } from 'react-hot-loader';
|
||||
|
||||
import { ExploreId } from 'app/types/explore';
|
||||
import { DataSourceSelectItem, RawTimeRange, TimeRange, ClickOutsideWrapper } from '@grafana/ui';
|
||||
import { DataSourceSelectItem, RawTimeRange, ClickOutsideWrapper, TimeZone, TimeRange } from '@grafana/ui';
|
||||
import { DataSourcePicker } from 'app/core/components/Select/DataSourcePicker';
|
||||
import { StoreState } from 'app/types/store';
|
||||
import {
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
changeRefreshInterval,
|
||||
} from './state/actions';
|
||||
import TimePicker from './TimePicker';
|
||||
import { getTimeZone } from '../profile/state/selectors';
|
||||
import { RefreshPicker, SetInterval } from '@grafana/ui';
|
||||
|
||||
enum IconSide {
|
||||
@@ -48,14 +49,15 @@ const createResponsiveButton = (options: {
|
||||
interface OwnProps {
|
||||
exploreId: ExploreId;
|
||||
timepickerRef: React.RefObject<TimePicker>;
|
||||
onChangeTime: (range: TimeRange, changedByScanner?: boolean) => void;
|
||||
onChangeTime: (range: RawTimeRange, changedByScanner?: boolean) => void;
|
||||
}
|
||||
|
||||
interface StateProps {
|
||||
datasourceMissing: boolean;
|
||||
exploreDatasources: DataSourceSelectItem[];
|
||||
loading: boolean;
|
||||
range: RawTimeRange;
|
||||
range: TimeRange;
|
||||
timeZone: TimeZone;
|
||||
selectedDatasource: DataSourceSelectItem;
|
||||
splitted: boolean;
|
||||
refreshInterval: string;
|
||||
@@ -106,6 +108,7 @@ export class UnConnectedExploreToolbar extends PureComponent<Props, {}> {
|
||||
exploreId,
|
||||
loading,
|
||||
range,
|
||||
timeZone,
|
||||
selectedDatasource,
|
||||
splitted,
|
||||
timepickerRef,
|
||||
@@ -159,7 +162,7 @@ export class UnConnectedExploreToolbar extends PureComponent<Props, {}> {
|
||||
) : null}
|
||||
<div className="explore-toolbar-content-item timepicker">
|
||||
<ClickOutsideWrapper onClick={this.onCloseTimePicker}>
|
||||
<TimePicker ref={timepickerRef} range={range} onChangeTime={onChangeTime} />
|
||||
<TimePicker ref={timepickerRef} range={range} isUtc={timeZone.isUtc} onChangeTime={onChangeTime} />
|
||||
</ClickOutsideWrapper>
|
||||
|
||||
<RefreshPicker
|
||||
@@ -214,6 +217,7 @@ const mapStateToProps = (state: StoreState, { exploreId }: OwnProps): StateProps
|
||||
exploreDatasources,
|
||||
loading,
|
||||
range,
|
||||
timeZone: getTimeZone(state.user),
|
||||
selectedDatasource,
|
||||
splitted,
|
||||
refreshInterval,
|
||||
|
||||
@@ -2,12 +2,14 @@ import React from 'react';
|
||||
import { shallow } from 'enzyme';
|
||||
import { Graph } from './Graph';
|
||||
import { mockData } from './__mocks__/mockData';
|
||||
import { DefaultTimeZone } from '@grafana/ui';
|
||||
|
||||
const setup = (propOverrides?: object) => {
|
||||
const props = {
|
||||
size: { width: 10, height: 20 },
|
||||
data: mockData().slice(0, 19),
|
||||
range: { from: 'now-6h', to: 'now' },
|
||||
range: { from: 0, to: 1 },
|
||||
timeZone: DefaultTimeZone,
|
||||
...propOverrides,
|
||||
};
|
||||
|
||||
|
||||
@@ -1,14 +1,12 @@
|
||||
import $ from 'jquery';
|
||||
import React, { PureComponent } from 'react';
|
||||
import moment from 'moment';
|
||||
|
||||
import 'vendor/flot/jquery.flot';
|
||||
import 'vendor/flot/jquery.flot.time';
|
||||
import 'vendor/flot/jquery.flot.selection';
|
||||
import 'vendor/flot/jquery.flot.stack';
|
||||
|
||||
import { RawTimeRange } from '@grafana/ui';
|
||||
import * as dateMath from 'app/core/utils/datemath';
|
||||
import { TimeZone, AbsoluteTimeRange } from '@grafana/ui';
|
||||
import TimeSeries from 'app/core/time_series2';
|
||||
|
||||
import Legend from './Legend';
|
||||
@@ -78,10 +76,11 @@ interface GraphProps {
|
||||
height?: number;
|
||||
width?: number;
|
||||
id?: string;
|
||||
range: RawTimeRange;
|
||||
range: AbsoluteTimeRange;
|
||||
timeZone: TimeZone;
|
||||
split?: boolean;
|
||||
userOptions?: any;
|
||||
onChangeTime?: (range: RawTimeRange) => void;
|
||||
onChangeTime?: (range: AbsoluteTimeRange) => void;
|
||||
onToggleSeries?: (alias: string, hiddenSeries: Set<string>) => void;
|
||||
}
|
||||
|
||||
@@ -133,27 +132,20 @@ export class Graph extends PureComponent<GraphProps, GraphState> {
|
||||
}
|
||||
|
||||
onPlotSelected = (event, ranges) => {
|
||||
if (this.props.onChangeTime) {
|
||||
const range = {
|
||||
from: moment(ranges.xaxis.from),
|
||||
to: moment(ranges.xaxis.to),
|
||||
};
|
||||
this.props.onChangeTime(range);
|
||||
const { onChangeTime } = this.props;
|
||||
if (onChangeTime) {
|
||||
this.props.onChangeTime({
|
||||
from: ranges.xaxis.from,
|
||||
to: ranges.xaxis.to,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
getDynamicOptions() {
|
||||
const { range, width } = this.props;
|
||||
const { range, width, timeZone } = this.props;
|
||||
const ticks = (width || 0) / 100;
|
||||
let { from, to } = range;
|
||||
if (!moment.isMoment(from)) {
|
||||
from = dateMath.parse(from, false);
|
||||
}
|
||||
if (!moment.isMoment(to)) {
|
||||
to = dateMath.parse(to, true);
|
||||
}
|
||||
const min = from.valueOf();
|
||||
const max = to.valueOf();
|
||||
const min = range.from;
|
||||
const max = range.to;
|
||||
return {
|
||||
xaxis: {
|
||||
mode: 'time',
|
||||
@@ -161,7 +153,7 @@ export class Graph extends PureComponent<GraphProps, GraphState> {
|
||||
max: max,
|
||||
label: 'Datetime',
|
||||
ticks: ticks,
|
||||
timezone: 'browser',
|
||||
timezone: timeZone.raw,
|
||||
timeformat: time_format(ticks, min, max),
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import React, { PureComponent } from 'react';
|
||||
import { hot } from 'react-hot-loader';
|
||||
import { connect } from 'react-redux';
|
||||
import { TimeRange, RawTimeRange } from '@grafana/ui';
|
||||
import moment from 'moment';
|
||||
import { TimeRange, TimeZone, AbsoluteTimeRange } from '@grafana/ui';
|
||||
|
||||
import { ExploreId, ExploreItemState } from 'app/types/explore';
|
||||
import { StoreState } from 'app/types';
|
||||
@@ -9,12 +10,14 @@ import { StoreState } from 'app/types';
|
||||
import { toggleGraph, changeTime } from './state/actions';
|
||||
import Graph from './Graph';
|
||||
import Panel from './Panel';
|
||||
import { getTimeZone } from '../profile/state/selectors';
|
||||
|
||||
interface GraphContainerProps {
|
||||
exploreId: ExploreId;
|
||||
graphResult?: any[];
|
||||
loading: boolean;
|
||||
range: RawTimeRange;
|
||||
range: TimeRange;
|
||||
timeZone: TimeZone;
|
||||
showingGraph: boolean;
|
||||
showingTable: boolean;
|
||||
split: boolean;
|
||||
@@ -28,13 +31,20 @@ export class GraphContainer extends PureComponent<GraphContainerProps> {
|
||||
this.props.toggleGraph(this.props.exploreId, this.props.showingGraph);
|
||||
};
|
||||
|
||||
onChangeTime = (timeRange: TimeRange) => {
|
||||
this.props.changeTime(this.props.exploreId, timeRange);
|
||||
onChangeTime = (absRange: AbsoluteTimeRange) => {
|
||||
const { exploreId, timeZone, changeTime } = this.props;
|
||||
const range = {
|
||||
from: timeZone.isUtc ? moment.utc(absRange.from) : moment(absRange.from),
|
||||
to: timeZone.isUtc ? moment.utc(absRange.to) : moment(absRange.to),
|
||||
};
|
||||
|
||||
changeTime(exploreId, range);
|
||||
};
|
||||
|
||||
render() {
|
||||
const { exploreId, graphResult, loading, showingGraph, showingTable, range, split, width } = this.props;
|
||||
const { exploreId, graphResult, loading, showingGraph, showingTable, range, split, width, timeZone } = this.props;
|
||||
const graphHeight = showingGraph && showingTable ? 200 : 400;
|
||||
const timeRange = { from: range.from.valueOf(), to: range.to.valueOf() };
|
||||
|
||||
if (!graphResult) {
|
||||
return null;
|
||||
@@ -47,7 +57,8 @@ export class GraphContainer extends PureComponent<GraphContainerProps> {
|
||||
height={graphHeight}
|
||||
id={`explore-graph-${exploreId}`}
|
||||
onChangeTime={this.onChangeTime}
|
||||
range={range}
|
||||
range={timeRange}
|
||||
timeZone={timeZone}
|
||||
split={split}
|
||||
width={width}
|
||||
/>
|
||||
@@ -62,7 +73,7 @@ function mapStateToProps(state: StoreState, { exploreId }) {
|
||||
const item: ExploreItemState = explore[exploreId];
|
||||
const { graphResult, queryTransactions, range, showingGraph, showingTable } = item;
|
||||
const loading = queryTransactions.some(qt => qt.resultType === 'Graph' && !qt.done);
|
||||
return { graphResult, loading, range, showingGraph, showingTable, split };
|
||||
return { graphResult, loading, range, showingGraph, showingTable, split, timeZone: getTimeZone(state.user) };
|
||||
}
|
||||
|
||||
const mapDispatchToProps = {
|
||||
|
||||
@@ -2,7 +2,7 @@ import _ from 'lodash';
|
||||
import React, { PureComponent } from 'react';
|
||||
|
||||
import * as rangeUtil from 'app/core/utils/rangeutil';
|
||||
import { RawTimeRange, Switch, LogLevel } from '@grafana/ui';
|
||||
import { RawTimeRange, Switch, LogLevel, TimeZone, TimeRange, AbsoluteTimeRange } from '@grafana/ui';
|
||||
import TimeSeries from 'app/core/time_series2';
|
||||
|
||||
import { LogsDedupDescription, LogsDedupStrategy, LogsModel, LogsMetaKind } from 'app/core/logs_model';
|
||||
@@ -48,12 +48,13 @@ interface Props {
|
||||
exploreId: string;
|
||||
highlighterExpressions: string[];
|
||||
loading: boolean;
|
||||
range?: RawTimeRange;
|
||||
range: TimeRange;
|
||||
timeZone: TimeZone;
|
||||
scanning?: boolean;
|
||||
scanRange?: RawTimeRange;
|
||||
dedupStrategy: LogsDedupStrategy;
|
||||
hiddenLogLevels: Set<LogLevel>;
|
||||
onChangeTime?: (range: RawTimeRange) => void;
|
||||
onChangeTime?: (range: AbsoluteTimeRange) => void;
|
||||
onClickLabel?: (label: string, value: string) => void;
|
||||
onStartScanning?: () => void;
|
||||
onStopScanning?: () => void;
|
||||
@@ -156,6 +157,7 @@ export default class Logs extends PureComponent<Props, State> {
|
||||
loading = false,
|
||||
onClickLabel,
|
||||
range,
|
||||
timeZone,
|
||||
scanning,
|
||||
scanRange,
|
||||
width,
|
||||
@@ -191,6 +193,10 @@ export default class Logs extends PureComponent<Props, State> {
|
||||
// React profiler becomes unusable if we pass all rows to all rows and their labels, using getter instead
|
||||
const getRows = () => processedRows;
|
||||
const timeSeries = data.series.map(series => new TimeSeries(series));
|
||||
const absRange = {
|
||||
from: range.from.valueOf(),
|
||||
to: range.to.valueOf(),
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="logs-panel">
|
||||
@@ -199,7 +205,8 @@ export default class Logs extends PureComponent<Props, State> {
|
||||
data={timeSeries}
|
||||
height={100}
|
||||
width={width}
|
||||
range={range}
|
||||
range={absRange}
|
||||
timeZone={timeZone}
|
||||
id={`explore-logs-graph-${exploreId}`}
|
||||
onChangeTime={this.props.onChangeTime}
|
||||
onToggleSeries={this.onToggleLogLevel}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import React, { PureComponent } from 'react';
|
||||
import { hot } from 'react-hot-loader';
|
||||
import { connect } from 'react-redux';
|
||||
import { RawTimeRange, TimeRange, LogLevel } from '@grafana/ui';
|
||||
import { RawTimeRange, TimeRange, LogLevel, TimeZone, AbsoluteTimeRange } from '@grafana/ui';
|
||||
|
||||
import { ExploreId, ExploreItemState } from 'app/types/explore';
|
||||
import { LogsModel, LogsDedupStrategy } from 'app/core/logs_model';
|
||||
@@ -12,6 +12,7 @@ import Logs from './Logs';
|
||||
import Panel from './Panel';
|
||||
import { toggleLogLevelAction } from 'app/features/explore/state/actionTypes';
|
||||
import { deduplicatedLogsSelector, exploreItemUIStateSelector } from 'app/features/explore/state/selectors';
|
||||
import { getTimeZone } from '../profile/state/selectors';
|
||||
|
||||
interface LogsContainerProps {
|
||||
exploreId: ExploreId;
|
||||
@@ -19,11 +20,12 @@ interface LogsContainerProps {
|
||||
logsHighlighterExpressions?: string[];
|
||||
logsResult?: LogsModel;
|
||||
dedupedResult?: LogsModel;
|
||||
onChangeTime: (range: TimeRange) => void;
|
||||
onChangeTime: (range: AbsoluteTimeRange) => void;
|
||||
onClickLabel: (key: string, value: string) => void;
|
||||
onStartScanning: () => void;
|
||||
onStopScanning: () => void;
|
||||
range: RawTimeRange;
|
||||
range: TimeRange;
|
||||
timeZone: TimeZone;
|
||||
scanning?: boolean;
|
||||
scanRange?: RawTimeRange;
|
||||
showingLogs: boolean;
|
||||
@@ -64,6 +66,7 @@ export class LogsContainer extends PureComponent<LogsContainerProps> {
|
||||
onStartScanning,
|
||||
onStopScanning,
|
||||
range,
|
||||
timeZone,
|
||||
showingLogs,
|
||||
scanning,
|
||||
scanRange,
|
||||
@@ -88,6 +91,7 @@ export class LogsContainer extends PureComponent<LogsContainerProps> {
|
||||
onDedupStrategyChange={this.handleDedupStrategyChange}
|
||||
onToggleLogLevel={this.hangleToggleLogLevel}
|
||||
range={range}
|
||||
timeZone={timeZone}
|
||||
scanning={scanning}
|
||||
scanRange={scanRange}
|
||||
width={width}
|
||||
@@ -106,6 +110,7 @@ function mapStateToProps(state: StoreState, { exploreId }) {
|
||||
const { showingLogs, dedupStrategy } = exploreItemUIStateSelector(item);
|
||||
const hiddenLogLevels = new Set(item.hiddenLogLevels);
|
||||
const dedupedResult = deduplicatedLogsSelector(item);
|
||||
const timeZone = getTimeZone(state.user);
|
||||
|
||||
return {
|
||||
loading,
|
||||
@@ -115,6 +120,7 @@ function mapStateToProps(state: StoreState, { exploreId }) {
|
||||
scanRange,
|
||||
showingLogs,
|
||||
range,
|
||||
timeZone,
|
||||
dedupStrategy,
|
||||
hiddenLogLevels,
|
||||
dedupedResult,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
// Libraries
|
||||
import React, { PureComponent } from 'react';
|
||||
import moment from 'moment';
|
||||
|
||||
// Services
|
||||
import { getAngularLoader, AngularComponent } from 'app/core/services/AngularLoader';
|
||||
@@ -7,7 +8,7 @@ import { getTimeSrv } from 'app/features/dashboard/services/TimeSrv';
|
||||
|
||||
// Types
|
||||
import { Emitter } from 'app/core/utils/emitter';
|
||||
import { RawTimeRange, DataQuery } from '@grafana/ui';
|
||||
import { DataQuery, TimeRange } from '@grafana/ui';
|
||||
import 'app/features/plugins/plugin_loader';
|
||||
|
||||
interface QueryEditorProps {
|
||||
@@ -17,7 +18,7 @@ interface QueryEditorProps {
|
||||
onQueryChange?: (value: DataQuery) => void;
|
||||
initialQuery: DataQuery;
|
||||
exploreEvents: Emitter;
|
||||
range: RawTimeRange;
|
||||
range: TimeRange;
|
||||
}
|
||||
|
||||
export default class QueryEditor extends PureComponent<QueryEditorProps, any> {
|
||||
@@ -62,10 +63,13 @@ export default class QueryEditor extends PureComponent<QueryEditorProps, any> {
|
||||
}
|
||||
}
|
||||
|
||||
initTimeSrv(range) {
|
||||
initTimeSrv(range: TimeRange) {
|
||||
const timeSrv = getTimeSrv();
|
||||
timeSrv.init({
|
||||
time: range,
|
||||
time: {
|
||||
from: moment(range.from),
|
||||
to: moment(range.to),
|
||||
},
|
||||
refresh: false,
|
||||
getTimezone: () => 'utc',
|
||||
timeRangeUpdated: () => console.log('refreshDashboard!'),
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -1,74 +1,238 @@
|
||||
import React from 'react';
|
||||
import { shallow } from 'enzyme';
|
||||
import sinon from 'sinon';
|
||||
import moment from 'moment';
|
||||
|
||||
import * as dateMath from 'app/core/utils/datemath';
|
||||
import * as rangeUtil from 'app/core/utils/rangeutil';
|
||||
import TimePicker, { DEFAULT_RANGE, parseTime } from './TimePicker';
|
||||
import TimePicker from './TimePicker';
|
||||
import { RawTimeRange, TimeRange, TIME_FORMAT } from '@grafana/ui';
|
||||
|
||||
const DEFAULT_RANGE = {
|
||||
from: 'now-6h',
|
||||
to: 'now',
|
||||
};
|
||||
|
||||
const fromRaw = (rawRange: RawTimeRange): TimeRange => {
|
||||
const raw = {
|
||||
from: moment.isMoment(rawRange.from) ? moment(rawRange.from) : rawRange.from,
|
||||
to: moment.isMoment(rawRange.to) ? moment(rawRange.to) : rawRange.to,
|
||||
};
|
||||
|
||||
return {
|
||||
from: dateMath.parse(raw.from, false),
|
||||
to: dateMath.parse(raw.to, true),
|
||||
raw: rawRange,
|
||||
};
|
||||
};
|
||||
|
||||
describe('<TimePicker />', () => {
|
||||
it('renders closed with default values', () => {
|
||||
const rangeString = rangeUtil.describeTimeRange(DEFAULT_RANGE);
|
||||
const wrapper = shallow(<TimePicker />);
|
||||
expect(wrapper.find('.timepicker-rangestring').text()).toBe(rangeString);
|
||||
expect(wrapper.find('.gf-timepicker-dropdown').exists()).toBe(false);
|
||||
it('render default values when closed and relative time range', () => {
|
||||
const range = fromRaw(DEFAULT_RANGE);
|
||||
const wrapper = shallow(<TimePicker range={range} />);
|
||||
expect(wrapper.state('fromRaw')).toBe(DEFAULT_RANGE.from);
|
||||
expect(wrapper.state('toRaw')).toBe(DEFAULT_RANGE.to);
|
||||
expect(wrapper.find('.timepicker-rangestring').text()).toBe('Last 6 hours');
|
||||
expect(wrapper.find('.gf-timepicker-dropdown').exists()).toBeFalsy();
|
||||
expect(wrapper.find('.gf-timepicker-utc').exists()).toBeFalsy();
|
||||
});
|
||||
|
||||
it('renders with relative range', () => {
|
||||
const range = {
|
||||
from: 'now-7h',
|
||||
to: 'now',
|
||||
};
|
||||
const rangeString = rangeUtil.describeTimeRange(range);
|
||||
it('render default values when closed, utc and relative time range', () => {
|
||||
const range = fromRaw(DEFAULT_RANGE);
|
||||
const wrapper = shallow(<TimePicker range={range} isUtc />);
|
||||
expect(wrapper.state('fromRaw')).toBe(DEFAULT_RANGE.from);
|
||||
expect(wrapper.state('toRaw')).toBe(DEFAULT_RANGE.to);
|
||||
expect(wrapper.find('.timepicker-rangestring').text()).toBe('Last 6 hours');
|
||||
expect(wrapper.find('.gf-timepicker-dropdown').exists()).toBeFalsy();
|
||||
expect(wrapper.find('.gf-timepicker-utc').exists()).toBeTruthy();
|
||||
});
|
||||
|
||||
it('renders default values when open and relative range', () => {
|
||||
const range = fromRaw(DEFAULT_RANGE);
|
||||
const wrapper = shallow(<TimePicker range={range} isOpen />);
|
||||
expect(wrapper.find('.timepicker-rangestring').text()).toBe(rangeString);
|
||||
expect(wrapper.state('fromRaw')).toBe(range.from);
|
||||
expect(wrapper.state('toRaw')).toBe(range.to);
|
||||
expect(wrapper.find('.timepicker-from').props().value).toBe(range.from);
|
||||
expect(wrapper.find('.timepicker-to').props().value).toBe(range.to);
|
||||
expect(wrapper.state('fromRaw')).toBe(DEFAULT_RANGE.from);
|
||||
expect(wrapper.state('toRaw')).toBe(DEFAULT_RANGE.to);
|
||||
expect(wrapper.find('.timepicker-rangestring').text()).toBe('Last 6 hours');
|
||||
expect(wrapper.find('.gf-timepicker-dropdown').exists()).toBeTruthy();
|
||||
expect(wrapper.find('.gf-timepicker-utc').exists()).toBeFalsy();
|
||||
expect(wrapper.find('.timepicker-from').props().value).toBe(DEFAULT_RANGE.from);
|
||||
expect(wrapper.find('.timepicker-to').props().value).toBe(DEFAULT_RANGE.to);
|
||||
});
|
||||
|
||||
it('renders with epoch (millies) range converted to ISO-ish', () => {
|
||||
it('renders default values when open, utc and relative range', () => {
|
||||
const range = fromRaw(DEFAULT_RANGE);
|
||||
const wrapper = shallow(<TimePicker range={range} isOpen isUtc />);
|
||||
expect(wrapper.state('fromRaw')).toBe(DEFAULT_RANGE.from);
|
||||
expect(wrapper.state('toRaw')).toBe(DEFAULT_RANGE.to);
|
||||
expect(wrapper.find('.timepicker-rangestring').text()).toBe('Last 6 hours');
|
||||
expect(wrapper.find('.gf-timepicker-dropdown').exists()).toBeTruthy();
|
||||
expect(wrapper.find('.gf-timepicker-utc').exists()).toBeTruthy();
|
||||
expect(wrapper.find('.timepicker-from').props().value).toBe(DEFAULT_RANGE.from);
|
||||
expect(wrapper.find('.timepicker-to').props().value).toBe(DEFAULT_RANGE.to);
|
||||
});
|
||||
|
||||
it('apply with absolute range and non-utc', () => {
|
||||
const range = {
|
||||
from: '1',
|
||||
to: '1000',
|
||||
from: moment.utc(1),
|
||||
to: moment.utc(1000),
|
||||
raw: {
|
||||
from: moment.utc(1),
|
||||
to: moment.utc(1000),
|
||||
},
|
||||
};
|
||||
const rangeString = rangeUtil.describeTimeRange({
|
||||
from: parseTime(range.from, true),
|
||||
to: parseTime(range.to, true),
|
||||
});
|
||||
const wrapper = shallow(<TimePicker range={range} isUtc isOpen />);
|
||||
const localRange = {
|
||||
from: moment(1),
|
||||
to: moment(1000),
|
||||
raw: {
|
||||
from: moment(1),
|
||||
to: moment(1000),
|
||||
},
|
||||
};
|
||||
const expectedRangeString = rangeUtil.describeTimeRange(localRange);
|
||||
|
||||
const onChangeTime = sinon.spy();
|
||||
const wrapper = shallow(<TimePicker range={range} isOpen onChangeTime={onChangeTime} />);
|
||||
expect(wrapper.state('fromRaw')).toBe(localRange.from.format(TIME_FORMAT));
|
||||
expect(wrapper.state('toRaw')).toBe(localRange.to.format(TIME_FORMAT));
|
||||
expect(wrapper.state('initialRange')).toBe(range.raw);
|
||||
expect(wrapper.find('.timepicker-rangestring').text()).toBe(expectedRangeString);
|
||||
expect(wrapper.find('.timepicker-from').props().value).toBe(localRange.from.format(TIME_FORMAT));
|
||||
expect(wrapper.find('.timepicker-to').props().value).toBe(localRange.to.format(TIME_FORMAT));
|
||||
|
||||
wrapper.find('button.gf-form-btn').simulate('click');
|
||||
expect(onChangeTime.calledOnce).toBeTruthy();
|
||||
expect(onChangeTime.getCall(0).args[0].from.valueOf()).toBe(0);
|
||||
expect(onChangeTime.getCall(0).args[0].to.valueOf()).toBe(1000);
|
||||
|
||||
expect(wrapper.state('isOpen')).toBeFalsy();
|
||||
expect(wrapper.state('rangeString')).toBe(expectedRangeString);
|
||||
});
|
||||
|
||||
it('apply with absolute range and utc', () => {
|
||||
const range = {
|
||||
from: moment.utc(1),
|
||||
to: moment.utc(1000),
|
||||
raw: {
|
||||
from: moment.utc(1),
|
||||
to: moment.utc(1000),
|
||||
},
|
||||
};
|
||||
const onChangeTime = sinon.spy();
|
||||
const wrapper = shallow(<TimePicker range={range} isUtc isOpen onChangeTime={onChangeTime} />);
|
||||
expect(wrapper.state('fromRaw')).toBe('1970-01-01 00:00:00');
|
||||
expect(wrapper.state('toRaw')).toBe('1970-01-01 00:00:01');
|
||||
expect(wrapper.find('.timepicker-rangestring').text()).toBe(rangeString);
|
||||
expect(wrapper.state('initialRange')).toBe(range.raw);
|
||||
expect(wrapper.find('.timepicker-rangestring').text()).toBe('Jan 1, 1970 00:00:00 to Jan 1, 1970 00:00:01');
|
||||
expect(wrapper.find('.timepicker-from').props().value).toBe('1970-01-01 00:00:00');
|
||||
expect(wrapper.find('.timepicker-to').props().value).toBe('1970-01-01 00:00:01');
|
||||
|
||||
wrapper.find('button.gf-form-btn').simulate('click');
|
||||
expect(onChangeTime.calledOnce).toBeTruthy();
|
||||
expect(onChangeTime.getCall(0).args[0].from.valueOf()).toBe(0);
|
||||
expect(onChangeTime.getCall(0).args[0].to.valueOf()).toBe(1000);
|
||||
|
||||
expect(wrapper.state('isOpen')).toBeFalsy();
|
||||
expect(wrapper.state('rangeString')).toBe('Jan 1, 1970 00:00:00 to Jan 1, 1970 00:00:01');
|
||||
});
|
||||
|
||||
it('moves ranges forward and backward by half the range on arrow click', () => {
|
||||
const range = {
|
||||
from: '2000',
|
||||
to: '4000',
|
||||
it('moves ranges backward by half the range on left arrow click when utc', () => {
|
||||
const rawRange = {
|
||||
from: moment.utc(2000),
|
||||
to: moment.utc(4000),
|
||||
raw: {
|
||||
from: moment.utc(2000),
|
||||
to: moment.utc(4000),
|
||||
},
|
||||
};
|
||||
const rangeString = rangeUtil.describeTimeRange({
|
||||
from: parseTime(range.from, true),
|
||||
to: parseTime(range.to, true),
|
||||
});
|
||||
const range = fromRaw(rawRange);
|
||||
|
||||
const onChangeTime = sinon.spy();
|
||||
const wrapper = shallow(<TimePicker range={range} isUtc isOpen onChangeTime={onChangeTime} />);
|
||||
expect(wrapper.state('fromRaw')).toBe('1970-01-01 00:00:02');
|
||||
expect(wrapper.state('toRaw')).toBe('1970-01-01 00:00:04');
|
||||
expect(wrapper.find('.timepicker-rangestring').text()).toBe(rangeString);
|
||||
expect(wrapper.find('.timepicker-from').props().value).toBe('1970-01-01 00:00:02');
|
||||
expect(wrapper.find('.timepicker-to').props().value).toBe('1970-01-01 00:00:04');
|
||||
|
||||
wrapper.find('.timepicker-left').simulate('click');
|
||||
expect(onChangeTime.calledOnce).toBe(true);
|
||||
expect(onChangeTime.calledOnce).toBeTruthy();
|
||||
expect(onChangeTime.getCall(0).args[0].from.valueOf()).toBe(1000);
|
||||
expect(onChangeTime.getCall(0).args[0].to.valueOf()).toBe(3000);
|
||||
});
|
||||
|
||||
it('moves ranges backward by half the range on left arrow click when not utc', () => {
|
||||
const range = {
|
||||
from: moment.utc(2000),
|
||||
to: moment.utc(4000),
|
||||
raw: {
|
||||
from: moment.utc(2000),
|
||||
to: moment.utc(4000),
|
||||
},
|
||||
};
|
||||
const localRange = {
|
||||
from: moment(2000),
|
||||
to: moment(4000),
|
||||
raw: {
|
||||
from: moment(2000),
|
||||
to: moment(4000),
|
||||
},
|
||||
};
|
||||
|
||||
const onChangeTime = sinon.spy();
|
||||
const wrapper = shallow(<TimePicker range={range} isUtc={false} isOpen onChangeTime={onChangeTime} />);
|
||||
expect(wrapper.state('fromRaw')).toBe(localRange.from.format(TIME_FORMAT));
|
||||
expect(wrapper.state('toRaw')).toBe(localRange.to.format(TIME_FORMAT));
|
||||
|
||||
wrapper.find('.timepicker-left').simulate('click');
|
||||
expect(onChangeTime.calledOnce).toBeTruthy();
|
||||
expect(onChangeTime.getCall(0).args[0].from.valueOf()).toBe(1000);
|
||||
expect(onChangeTime.getCall(0).args[0].to.valueOf()).toBe(3000);
|
||||
});
|
||||
|
||||
it('moves ranges forward by half the range on right arrow click when utc', () => {
|
||||
const range = {
|
||||
from: moment.utc(1000),
|
||||
to: moment.utc(3000),
|
||||
raw: {
|
||||
from: moment.utc(1000),
|
||||
to: moment.utc(3000),
|
||||
},
|
||||
};
|
||||
|
||||
const onChangeTime = sinon.spy();
|
||||
const wrapper = shallow(<TimePicker range={range} isUtc isOpen onChangeTime={onChangeTime} />);
|
||||
expect(wrapper.state('fromRaw')).toBe('1970-01-01 00:00:01');
|
||||
expect(wrapper.state('toRaw')).toBe('1970-01-01 00:00:03');
|
||||
|
||||
wrapper.find('.timepicker-right').simulate('click');
|
||||
expect(wrapper.state('fromRaw')).toBe('1970-01-01 00:00:02');
|
||||
expect(wrapper.state('toRaw')).toBe('1970-01-01 00:00:04');
|
||||
expect(onChangeTime.calledOnce).toBeTruthy();
|
||||
expect(onChangeTime.getCall(0).args[0].from.valueOf()).toBe(2000);
|
||||
expect(onChangeTime.getCall(0).args[0].to.valueOf()).toBe(4000);
|
||||
});
|
||||
|
||||
it('moves ranges forward by half the range on right arrow click when not utc', () => {
|
||||
const range = {
|
||||
from: moment.utc(1000),
|
||||
to: moment.utc(3000),
|
||||
raw: {
|
||||
from: moment.utc(1000),
|
||||
to: moment.utc(3000),
|
||||
},
|
||||
};
|
||||
const localRange = {
|
||||
from: moment(1000),
|
||||
to: moment(3000),
|
||||
raw: {
|
||||
from: moment(1000),
|
||||
to: moment(3000),
|
||||
},
|
||||
};
|
||||
|
||||
const onChangeTime = sinon.spy();
|
||||
const wrapper = shallow(<TimePicker range={range} isOpen onChangeTime={onChangeTime} />);
|
||||
expect(wrapper.state('fromRaw')).toBe(localRange.from.format(TIME_FORMAT));
|
||||
expect(wrapper.state('toRaw')).toBe(localRange.to.format(TIME_FORMAT));
|
||||
|
||||
wrapper.find('.timepicker-right').simulate('click');
|
||||
expect(onChangeTime.calledOnce).toBeTruthy();
|
||||
expect(onChangeTime.getCall(0).args[0].from.valueOf()).toBe(2000);
|
||||
expect(onChangeTime.getCall(0).args[0].to.valueOf()).toBe(4000);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,43 +1,12 @@
|
||||
import React, { PureComponent } from 'react';
|
||||
import moment from 'moment';
|
||||
|
||||
import * as dateMath from 'app/core/utils/datemath';
|
||||
import * as rangeUtil from 'app/core/utils/rangeutil';
|
||||
import { Input, RawTimeRange, TimeRange } from '@grafana/ui';
|
||||
|
||||
const DATE_FORMAT = 'YYYY-MM-DD HH:mm:ss';
|
||||
export const DEFAULT_RANGE = {
|
||||
from: 'now-6h',
|
||||
to: 'now',
|
||||
};
|
||||
|
||||
/**
|
||||
* Return a human-editable string of either relative (inludes "now") or absolute local time (in the shape of DATE_FORMAT).
|
||||
* @param value Epoch or relative time
|
||||
*/
|
||||
export function parseTime(value: string | moment.Moment, isUtc = false, ensureString = false): string | moment.Moment {
|
||||
if (moment.isMoment(value)) {
|
||||
if (ensureString) {
|
||||
return value.format(DATE_FORMAT);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
if ((value as string).indexOf('now') !== -1) {
|
||||
return value;
|
||||
}
|
||||
let time: any = value;
|
||||
// Possible epoch
|
||||
if (!isNaN(time)) {
|
||||
time = parseInt(time, 10);
|
||||
}
|
||||
time = isUtc ? moment.utc(time) : moment(time);
|
||||
return time.format(DATE_FORMAT);
|
||||
}
|
||||
import { Input, RawTimeRange, TimeRange, TIME_FORMAT } from '@grafana/ui';
|
||||
|
||||
interface TimePickerProps {
|
||||
isOpen?: boolean;
|
||||
isUtc?: boolean;
|
||||
range?: RawTimeRange;
|
||||
range: TimeRange;
|
||||
onChangeTime?: (range: RawTimeRange, scanning?: boolean) => void;
|
||||
}
|
||||
|
||||
@@ -46,17 +15,40 @@ interface TimePickerState {
|
||||
isUtc: boolean;
|
||||
rangeString: string;
|
||||
refreshInterval?: string;
|
||||
initialRange?: RawTimeRange;
|
||||
initialRange: RawTimeRange;
|
||||
|
||||
// Input-controlled text, keep these in a shape that is human-editable
|
||||
fromRaw: string;
|
||||
toRaw: string;
|
||||
}
|
||||
|
||||
const getRaw = (isUtc: boolean, range: any) => {
|
||||
const rawRange = {
|
||||
from: range.raw.from,
|
||||
to: range.raw.to,
|
||||
};
|
||||
|
||||
if (moment.isMoment(rawRange.from)) {
|
||||
if (!isUtc) {
|
||||
rawRange.from = rawRange.from.local();
|
||||
}
|
||||
rawRange.from = rawRange.from.format(TIME_FORMAT);
|
||||
}
|
||||
|
||||
if (moment.isMoment(rawRange.to)) {
|
||||
if (!isUtc) {
|
||||
rawRange.to = rawRange.to.local();
|
||||
}
|
||||
rawRange.to = rawRange.to.format(TIME_FORMAT);
|
||||
}
|
||||
|
||||
return rawRange;
|
||||
};
|
||||
|
||||
/**
|
||||
* TimePicker with dropdown menu for relative dates.
|
||||
*
|
||||
* Initialize with a range that is either based on relative time strings,
|
||||
* Initialize with a range that is either based on relative rawRange.strings,
|
||||
* or on Moment objects.
|
||||
* Internally the component needs to keep a string representation in `fromRaw`
|
||||
* and `toRaw` for the controlled inputs.
|
||||
@@ -69,89 +61,68 @@ export default class TimePicker extends PureComponent<TimePickerProps, TimePicke
|
||||
constructor(props) {
|
||||
super(props);
|
||||
|
||||
const { range, isUtc, isOpen } = props;
|
||||
const rawRange = getRaw(props.isUtc, range);
|
||||
|
||||
this.state = {
|
||||
isOpen: props.isOpen,
|
||||
isUtc: props.isUtc,
|
||||
rangeString: '',
|
||||
fromRaw: '',
|
||||
toRaw: '',
|
||||
initialRange: DEFAULT_RANGE,
|
||||
isOpen: isOpen,
|
||||
isUtc: isUtc,
|
||||
rangeString: rangeUtil.describeTimeRange(range.raw),
|
||||
fromRaw: rawRange.from,
|
||||
toRaw: rawRange.to,
|
||||
initialRange: range.raw,
|
||||
refreshInterval: '',
|
||||
};
|
||||
} //Temp solution... How do detect if ds supports table format?
|
||||
|
||||
static getDerivedStateFromProps(props, state) {
|
||||
if (state.initialRange && state.initialRange === props.range) {
|
||||
static getDerivedStateFromProps(props: TimePickerProps, state: TimePickerState) {
|
||||
if (
|
||||
state.initialRange &&
|
||||
state.initialRange.from === props.range.raw.from &&
|
||||
state.initialRange.to === props.range.raw.to
|
||||
) {
|
||||
return state;
|
||||
}
|
||||
|
||||
const from = props.range ? props.range.from : DEFAULT_RANGE.from;
|
||||
const to = props.range ? props.range.to : DEFAULT_RANGE.to;
|
||||
|
||||
// Ensure internal string format
|
||||
const fromRaw = parseTime(from, props.isUtc, true);
|
||||
const toRaw = parseTime(to, props.isUtc, true);
|
||||
const range = {
|
||||
from: fromRaw,
|
||||
to: toRaw,
|
||||
};
|
||||
const { range } = props;
|
||||
const rawRange = getRaw(props.isUtc, range);
|
||||
|
||||
return {
|
||||
...state,
|
||||
fromRaw,
|
||||
toRaw,
|
||||
initialRange: props.range,
|
||||
rangeString: rangeUtil.describeTimeRange(range),
|
||||
fromRaw: rawRange.from,
|
||||
toRaw: rawRange.to,
|
||||
initialRange: range.raw,
|
||||
rangeString: rangeUtil.describeTimeRange(range.raw),
|
||||
};
|
||||
}
|
||||
|
||||
move(direction: number, scanning?: boolean): RawTimeRange {
|
||||
const { onChangeTime } = this.props;
|
||||
const { fromRaw, toRaw } = this.state;
|
||||
const from = dateMath.parse(fromRaw, false);
|
||||
const to = dateMath.parse(toRaw, true);
|
||||
const step = scanning ? 1 : 2;
|
||||
const timespan = (to.valueOf() - from.valueOf()) / step;
|
||||
|
||||
let nextTo, nextFrom;
|
||||
const { onChangeTime, range: origRange } = this.props;
|
||||
const range = {
|
||||
from: moment.utc(origRange.from),
|
||||
to: moment.utc(origRange.to),
|
||||
};
|
||||
const timespan = (range.to.valueOf() - range.from.valueOf()) / 2;
|
||||
let to, from;
|
||||
if (direction === -1) {
|
||||
nextTo = to.valueOf() - timespan;
|
||||
nextFrom = from.valueOf() - timespan;
|
||||
to = range.to.valueOf() - timespan;
|
||||
from = range.from.valueOf() - timespan;
|
||||
} else if (direction === 1) {
|
||||
nextTo = to.valueOf() + timespan;
|
||||
nextFrom = from.valueOf() + timespan;
|
||||
if (nextTo > Date.now() && to.valueOf() < Date.now()) {
|
||||
nextTo = Date.now();
|
||||
nextFrom = from.valueOf();
|
||||
}
|
||||
to = range.to.valueOf() + timespan;
|
||||
from = range.from.valueOf() + timespan;
|
||||
} else {
|
||||
nextTo = to.valueOf();
|
||||
nextFrom = from.valueOf();
|
||||
to = range.to.valueOf();
|
||||
from = range.from.valueOf();
|
||||
}
|
||||
|
||||
const nextRange = {
|
||||
from: moment(nextFrom),
|
||||
to: moment(nextTo),
|
||||
const nextTimeRange = {
|
||||
from: this.props.isUtc ? moment.utc(from) : moment(from),
|
||||
to: this.props.isUtc ? moment.utc(to) : moment(to),
|
||||
};
|
||||
|
||||
const nextTimeRange: TimeRange = {
|
||||
raw: nextRange,
|
||||
from: nextRange.from,
|
||||
to: nextRange.to,
|
||||
};
|
||||
|
||||
this.setState(
|
||||
{
|
||||
rangeString: rangeUtil.describeTimeRange(nextRange),
|
||||
fromRaw: nextRange.from.format(DATE_FORMAT),
|
||||
toRaw: nextRange.to.format(DATE_FORMAT),
|
||||
},
|
||||
() => {
|
||||
onChangeTime(nextTimeRange, scanning);
|
||||
}
|
||||
);
|
||||
|
||||
return nextRange;
|
||||
if (onChangeTime) {
|
||||
onChangeTime(nextTimeRange);
|
||||
}
|
||||
return nextTimeRange;
|
||||
}
|
||||
|
||||
handleChangeFrom = e => {
|
||||
@@ -167,16 +138,25 @@ export default class TimePicker extends PureComponent<TimePickerProps, TimePicke
|
||||
};
|
||||
|
||||
handleClickApply = () => {
|
||||
const { onChangeTime } = this.props;
|
||||
let range;
|
||||
const { onChangeTime, isUtc } = this.props;
|
||||
let rawRange;
|
||||
this.setState(
|
||||
state => {
|
||||
const { toRaw, fromRaw } = this.state;
|
||||
range = {
|
||||
from: dateMath.parse(fromRaw, false),
|
||||
to: dateMath.parse(toRaw, true),
|
||||
rawRange = {
|
||||
from: fromRaw,
|
||||
to: toRaw,
|
||||
};
|
||||
const rangeString = rangeUtil.describeTimeRange(range);
|
||||
|
||||
if (rawRange.from.indexOf('now') === -1) {
|
||||
rawRange.from = isUtc ? moment.utc(rawRange.from, TIME_FORMAT) : moment(rawRange.from, TIME_FORMAT);
|
||||
}
|
||||
|
||||
if (rawRange.to.indexOf('now') === -1) {
|
||||
rawRange.to = isUtc ? moment.utc(rawRange.to, TIME_FORMAT) : moment(rawRange.to, TIME_FORMAT);
|
||||
}
|
||||
|
||||
const rangeString = rangeUtil.describeTimeRange(rawRange);
|
||||
return {
|
||||
isOpen: false,
|
||||
rangeString,
|
||||
@@ -184,7 +164,7 @@ export default class TimePicker extends PureComponent<TimePickerProps, TimePicke
|
||||
},
|
||||
() => {
|
||||
if (onChangeTime) {
|
||||
onChangeTime(range);
|
||||
onChangeTime(rawRange);
|
||||
}
|
||||
}
|
||||
);
|
||||
@@ -201,16 +181,20 @@ export default class TimePicker extends PureComponent<TimePickerProps, TimePicke
|
||||
handleClickRelativeOption = range => {
|
||||
const { onChangeTime } = this.props;
|
||||
const rangeString = rangeUtil.describeTimeRange(range);
|
||||
const rawRange = {
|
||||
from: range.from,
|
||||
to: range.to,
|
||||
};
|
||||
this.setState(
|
||||
{
|
||||
toRaw: range.to,
|
||||
fromRaw: range.from,
|
||||
toRaw: rawRange.to,
|
||||
fromRaw: rawRange.from,
|
||||
isOpen: false,
|
||||
rangeString,
|
||||
},
|
||||
() => {
|
||||
if (onChangeTime) {
|
||||
onChangeTime(range);
|
||||
onChangeTime(rawRange);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import moment from 'moment';
|
||||
import { refreshExplore, testDatasource, loadDatasource } from './actions';
|
||||
import { ExploreId, ExploreUrlState, ExploreUpdateState } from 'app/types';
|
||||
import { thunkTester } from 'test/core/thunk/thunkTester';
|
||||
@@ -18,6 +19,7 @@ import { Emitter } from 'app/core/core';
|
||||
import { ActionOf } from 'app/core/redux/actionCreatorFactory';
|
||||
import { makeInitialUpdateState } from './reducers';
|
||||
import { DataQuery } from '@grafana/ui/src/types/datasource';
|
||||
import { DefaultTimeZone, RawTimeRange } from '@grafana/ui';
|
||||
|
||||
jest.mock('app/features/plugins/datasource_srv', () => ({
|
||||
getDatasourceSrv: () => ({
|
||||
@@ -29,21 +31,39 @@ jest.mock('app/features/plugins/datasource_srv', () => ({
|
||||
}),
|
||||
}));
|
||||
|
||||
const t = moment.utc();
|
||||
const testRange = {
|
||||
from: t,
|
||||
to: t,
|
||||
raw: {
|
||||
from: t,
|
||||
to: t,
|
||||
},
|
||||
};
|
||||
jest.mock('app/core/utils/explore', () => ({
|
||||
...jest.requireActual('app/core/utils/explore'),
|
||||
getTimeRangeFromUrl: (range: RawTimeRange) => testRange,
|
||||
}));
|
||||
|
||||
const setup = (updateOverides?: Partial<ExploreUpdateState>) => {
|
||||
const exploreId = ExploreId.left;
|
||||
const containerWidth = 1920;
|
||||
const eventBridge = {} as Emitter;
|
||||
const ui = { dedupStrategy: LogsDedupStrategy.none, showingGraph: false, showingLogs: false, showingTable: false };
|
||||
const range = { from: 'now', to: 'now' };
|
||||
const timeZone = DefaultTimeZone;
|
||||
const range = testRange;
|
||||
const urlState: ExploreUrlState = {
|
||||
datasource: 'some-datasource',
|
||||
queries: [],
|
||||
range,
|
||||
range: range.raw,
|
||||
ui,
|
||||
};
|
||||
const updateDefaults = makeInitialUpdateState();
|
||||
const update = { ...updateDefaults, ...updateOverides };
|
||||
const initialState = {
|
||||
user: {
|
||||
timeZone,
|
||||
},
|
||||
explore: {
|
||||
[exploreId]: {
|
||||
initialized: true,
|
||||
@@ -77,7 +97,7 @@ describe('refreshExplore', () => {
|
||||
describe('when explore is initialized', () => {
|
||||
describe('and update datasource is set', () => {
|
||||
it('then it should dispatch initializeExplore', async () => {
|
||||
const { exploreId, ui, range, initialState, containerWidth, eventBridge } = setup({ datasource: true });
|
||||
const { exploreId, ui, initialState, containerWidth, eventBridge } = setup({ datasource: true });
|
||||
|
||||
const dispatchedActions = await thunkTester(initialState)
|
||||
.givenThunk(refreshExplore)
|
||||
@@ -90,7 +110,10 @@ describe('refreshExplore', () => {
|
||||
expect(payload.containerWidth).toEqual(containerWidth);
|
||||
expect(payload.eventBridge).toEqual(eventBridge);
|
||||
expect(payload.queries.length).toBe(1); // Queries have generated keys hard to expect on
|
||||
expect(payload.range).toEqual(range);
|
||||
expect(payload.range.from).toEqual(testRange.from);
|
||||
expect(payload.range.to).toEqual(testRange.to);
|
||||
expect(payload.range.raw.from).toEqual(testRange.raw.from);
|
||||
expect(payload.range.raw.to).toEqual(testRange.raw.to);
|
||||
expect(payload.ui).toEqual(ui);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
// Libraries
|
||||
import _ from 'lodash';
|
||||
import moment from 'moment';
|
||||
|
||||
// Services & Utils
|
||||
import store from 'app/core/store';
|
||||
@@ -16,6 +17,8 @@ import {
|
||||
buildQueryTransaction,
|
||||
serializeStateToUrlParam,
|
||||
parseUrlState,
|
||||
getTimeRange,
|
||||
getTimeRangeFromUrl,
|
||||
} from 'app/core/utils/explore';
|
||||
|
||||
// Actions
|
||||
@@ -26,12 +29,12 @@ import { ResultGetter } from 'app/types/explore';
|
||||
import { ThunkResult } from 'app/types';
|
||||
import {
|
||||
RawTimeRange,
|
||||
TimeRange,
|
||||
DataSourceApi,
|
||||
DataQuery,
|
||||
DataSourceSelectItem,
|
||||
QueryHint,
|
||||
QueryFixAction,
|
||||
TimeRange,
|
||||
} from '@grafana/ui/src/types';
|
||||
import {
|
||||
ExploreId,
|
||||
@@ -83,7 +86,7 @@ import {
|
||||
} from './actionTypes';
|
||||
import { ActionOf, ActionCreator } from 'app/core/redux/actionCreatorFactory';
|
||||
import { LogsDedupStrategy } from 'app/core/logs_model';
|
||||
import { parseTime } from '../TimePicker';
|
||||
import { getTimeZone } from 'app/features/profile/state/selectors';
|
||||
|
||||
/**
|
||||
* Updates UI state and save it to the URL
|
||||
@@ -169,8 +172,10 @@ export function changeSize(
|
||||
/**
|
||||
* Change the time range of Explore. Usually called from the Time picker or a graph interaction.
|
||||
*/
|
||||
export function changeTime(exploreId: ExploreId, range: TimeRange): ThunkResult<void> {
|
||||
return dispatch => {
|
||||
export function changeTime(exploreId: ExploreId, rawRange: RawTimeRange): ThunkResult<void> {
|
||||
return (dispatch, getState) => {
|
||||
const timeZone = getTimeZone(getState().user);
|
||||
const range = getTimeRange(timeZone, rawRange);
|
||||
dispatch(changeTimeAction({ exploreId, range }));
|
||||
dispatch(runQueries(exploreId));
|
||||
};
|
||||
@@ -235,12 +240,14 @@ export function initializeExplore(
|
||||
exploreId: ExploreId,
|
||||
datasourceName: string,
|
||||
queries: DataQuery[],
|
||||
range: RawTimeRange,
|
||||
rawRange: RawTimeRange,
|
||||
containerWidth: number,
|
||||
eventBridge: Emitter,
|
||||
ui: ExploreUIState
|
||||
): ThunkResult<void> {
|
||||
return async dispatch => {
|
||||
return async (dispatch, getState) => {
|
||||
const timeZone = getTimeZone(getState().user);
|
||||
const range = getTimeRange(timeZone, rawRange);
|
||||
dispatch(loadExploreDatasourcesAndSetDatasource(exploreId, datasourceName));
|
||||
dispatch(
|
||||
initializeExploreAction({
|
||||
@@ -723,6 +730,23 @@ export function splitOpen(): ThunkResult<void> {
|
||||
};
|
||||
}
|
||||
|
||||
const toRawTimeRange = (range: TimeRange): RawTimeRange => {
|
||||
let from = range.raw.from;
|
||||
if (moment.isMoment(from)) {
|
||||
from = from.valueOf().toString(10);
|
||||
}
|
||||
|
||||
let to = range.raw.to;
|
||||
if (moment.isMoment(to)) {
|
||||
to = to.valueOf().toString(10);
|
||||
}
|
||||
|
||||
return {
|
||||
from,
|
||||
to,
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Saves Explore state to URL using the `left` and `right` parameters.
|
||||
* If split view is not active, `right` will not be set.
|
||||
@@ -734,7 +758,7 @@ export function stateSave(): ThunkResult<void> {
|
||||
const leftUrlState: ExploreUrlState = {
|
||||
datasource: left.datasourceInstance.name,
|
||||
queries: left.queries.map(clearQueryKeys),
|
||||
range: left.range,
|
||||
range: toRawTimeRange(left.range),
|
||||
ui: {
|
||||
showingGraph: left.showingGraph,
|
||||
showingLogs: left.showingLogs,
|
||||
@@ -747,7 +771,7 @@ export function stateSave(): ThunkResult<void> {
|
||||
const rightUrlState: ExploreUrlState = {
|
||||
datasource: right.datasourceInstance.name,
|
||||
queries: right.queries.map(clearQueryKeys),
|
||||
range: right.range,
|
||||
range: toRawTimeRange(right.range),
|
||||
ui: {
|
||||
showingGraph: right.showingGraph,
|
||||
showingLogs: right.showingLogs,
|
||||
@@ -830,19 +854,20 @@ export function refreshExplore(exploreId: ExploreId): ThunkResult<void> {
|
||||
}
|
||||
|
||||
const { urlState, update, containerWidth, eventBridge } = itemState;
|
||||
const { datasource, queries, range, ui } = urlState;
|
||||
const { datasource, queries, range: urlRange, ui } = urlState;
|
||||
const refreshQueries = queries.map(q => ({ ...q, ...generateEmptyQuery(itemState.queries) }));
|
||||
const refreshRange = { from: parseTime(range.from), to: parseTime(range.to) };
|
||||
const timeZone = getTimeZone(getState().user);
|
||||
const range = getTimeRangeFromUrl(urlRange, timeZone);
|
||||
|
||||
// need to refresh datasource
|
||||
if (update.datasource) {
|
||||
const initialQueries = ensureQueries(queries);
|
||||
const initialRange = { from: parseTime(range.from), to: parseTime(range.to) };
|
||||
dispatch(initializeExplore(exploreId, datasource, initialQueries, initialRange, containerWidth, eventBridge, ui));
|
||||
dispatch(initializeExplore(exploreId, datasource, initialQueries, range, containerWidth, eventBridge, ui));
|
||||
return;
|
||||
}
|
||||
|
||||
if (update.range) {
|
||||
dispatch(changeTimeAction({ exploreId, range: refreshRange as TimeRange }));
|
||||
dispatch(changeTimeAction({ exploreId, range }));
|
||||
}
|
||||
|
||||
// need to refresh ui state
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 => {
|
||||
|
||||
4
public/app/features/profile/state/selectors.ts
Normal file
4
public/app/features/profile/state/selectors.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
import { UserState } from 'app/types';
|
||||
import { parseTimeZone } from '@grafana/ui';
|
||||
|
||||
export const getTimeZone = (state: UserState) => parseTimeZone(state.timeZone);
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
@@ -46,4 +46,5 @@ export interface UsersState {
|
||||
|
||||
export interface UserState {
|
||||
orgId: number;
|
||||
timeZone: string;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user