diff --git a/README.md b/README.md index 8c84bfd0e87..1d8a50ebc6c 100644 --- a/README.md +++ b/README.md @@ -163,5 +163,5 @@ plugin development. ## License -Grafana is distributed under [Apache 2.0 License](https://github.com/grafana/grafana/blob/master/LICENSE.md). +Grafana is distributed under [Apache 2.0 License](https://github.com/grafana/grafana/blob/master/LICENSE). diff --git a/packages/grafana-ui/src/components/BarGauge/BarGauge.tsx b/packages/grafana-ui/src/components/BarGauge/BarGauge.tsx index 99d2d061a11..a5a2190b8ca 100644 --- a/packages/grafana-ui/src/components/BarGauge/BarGauge.tsx +++ b/packages/grafana-ui/src/components/BarGauge/BarGauge.tsx @@ -3,10 +3,10 @@ import React, { PureComponent, CSSProperties, ReactNode } from 'react'; import tinycolor from 'tinycolor2'; // Utils -import { getColorFromHexRgbOrName, getThresholdForValue, DisplayValue } from '../../utils'; +import { getColorFromHexRgbOrName, getThresholdForValue } from '../../utils'; // Types -import { Themeable, TimeSeriesValue, Threshold, VizOrientation } from '../../types'; +import { DisplayValue, Themeable, TimeSeriesValue, Threshold, VizOrientation } from '../../types'; const BAR_SIZE_RATIO = 0.8; diff --git a/packages/grafana-ui/src/components/Gauge/Gauge.tsx b/packages/grafana-ui/src/components/Gauge/Gauge.tsx index f812cc971bc..8118fe82f19 100644 --- a/packages/grafana-ui/src/components/Gauge/Gauge.tsx +++ b/packages/grafana-ui/src/components/Gauge/Gauge.tsx @@ -1,10 +1,9 @@ import React, { PureComponent } from 'react'; import $ from 'jquery'; -import { Threshold, GrafanaThemeType } from '../../types'; import { getColorFromHexRgbOrName } from '../../utils'; -import { Themeable } from '../../index'; -import { DisplayValue } from '../../utils/displayValue'; + +import { DisplayValue, Threshold, GrafanaThemeType, Themeable } from '../../types'; export interface Props extends Themeable { height: number; diff --git a/packages/grafana-ui/src/types/data.ts b/packages/grafana-ui/src/types/data.ts index 69d5261ba3c..fcbf95f9f4a 100644 --- a/packages/grafana-ui/src/types/data.ts +++ b/packages/grafana-ui/src/types/data.ts @@ -76,3 +76,17 @@ export interface TableData { rows: any[][]; tags?: Tags; } + +export interface AnnotationEvent { + annotation?: any; + dashboardId?: number; + panelId?: number; + userId?: number; + time?: number; + timeEnd?: number; + isRegion?: boolean; + title?: string; + text?: string; + type?: string; + tags?: string; +} diff --git a/packages/grafana-ui/src/types/displayValue.ts b/packages/grafana-ui/src/types/displayValue.ts new file mode 100644 index 00000000000..65bf916b3f4 --- /dev/null +++ b/packages/grafana-ui/src/types/displayValue.ts @@ -0,0 +1,11 @@ +export interface DisplayValue { + text: string; // Show in the UI + numeric: number; // Use isNaN to check if it is a real number + color?: string; // color based on configs or Threshold + title?: string; +} + +export interface DecimalInfo { + decimals: number; + scaledDecimals: number; +} diff --git a/packages/grafana-ui/src/types/index.ts b/packages/grafana-ui/src/types/index.ts index c0aede431d0..1aec63c8690 100644 --- a/packages/grafana-ui/src/types/index.ts +++ b/packages/grafana-ui/src/types/index.ts @@ -6,3 +6,4 @@ export * from './datasource'; export * from './theme'; export * from './threshold'; export * from './input'; +export * from './displayValue'; diff --git a/packages/grafana-ui/src/utils/displayValue.test.ts b/packages/grafana-ui/src/utils/displayValue.test.ts index bf0e7a89bba..1849daa76d7 100644 --- a/packages/grafana-ui/src/utils/displayValue.test.ts +++ b/packages/grafana-ui/src/utils/displayValue.test.ts @@ -1,5 +1,5 @@ -import { getDisplayProcessor, getColorFromThreshold, DisplayProcessor, DisplayValue } from './displayValue'; -import { MappingType, ValueMapping } from '../types/panel'; +import { getDisplayProcessor, getColorFromThreshold, DisplayProcessor, getDecimalsForValue } from './displayValue'; +import { DisplayValue, MappingType, ValueMapping } from '../types'; function assertSame(input: any, processors: DisplayProcessor[], match: DisplayValue) { processors.forEach(processor => { @@ -144,6 +144,20 @@ describe('Format value', () => { expect(result.text).toEqual('10.0'); }); + it('should set auto decimals, 1 significant', () => { + const value = '1.23'; + const instance = getDisplayProcessor({ decimals: null }); + + expect(instance(value).text).toEqual('1.2'); + }); + + it('should set auto decimals, 2 significant', () => { + const value = '0.0245'; + const instance = getDisplayProcessor({ decimals: null }); + + expect(instance(value).text).toEqual('0.02'); + }); + it('should return mapped value if there are matching value mappings', () => { const valueMappings: ValueMapping[] = [ { id: 0, operator: '', text: '1-20', type: MappingType.RangeToText, from: '1', to: '20' }, @@ -155,3 +169,18 @@ describe('Format value', () => { expect(instance(value).text).toEqual('1-20'); }); }); + +describe('getDecimalsForValue()', () => { + it('should calculate reasonable decimals precision for given value', () => { + expect(getDecimalsForValue(1.01)).toEqual({ decimals: 1, scaledDecimals: 4 }); + expect(getDecimalsForValue(9.01)).toEqual({ decimals: 0, scaledDecimals: 2 }); + expect(getDecimalsForValue(1.1)).toEqual({ decimals: 1, scaledDecimals: 4 }); + expect(getDecimalsForValue(2)).toEqual({ decimals: 0, scaledDecimals: 2 }); + expect(getDecimalsForValue(20)).toEqual({ decimals: 0, scaledDecimals: 1 }); + expect(getDecimalsForValue(200)).toEqual({ decimals: 0, scaledDecimals: 0 }); + expect(getDecimalsForValue(2000)).toEqual({ decimals: 0, scaledDecimals: 0 }); + expect(getDecimalsForValue(20000)).toEqual({ decimals: 0, scaledDecimals: -2 }); + expect(getDecimalsForValue(200000)).toEqual({ decimals: 0, scaledDecimals: -3 }); + expect(getDecimalsForValue(200000000)).toEqual({ decimals: 0, scaledDecimals: -6 }); + }); +}); diff --git a/packages/grafana-ui/src/utils/displayValue.ts b/packages/grafana-ui/src/utils/displayValue.ts index 75191f34434..012505be700 100644 --- a/packages/grafana-ui/src/utils/displayValue.ts +++ b/packages/grafana-ui/src/utils/displayValue.ts @@ -1,21 +1,21 @@ -import { ValueMapping, Threshold } from '../types'; +// Libraries import _ from 'lodash'; -import { getValueFormat, DecimalCount } from './valueFormats/valueFormats'; -import { getMappedValue } from './valueMappings'; -import { GrafanaTheme, GrafanaThemeType } from '../types'; -import { getColorFromHexRgbOrName } from './namedColorsPalette'; import moment from 'moment'; -export interface DisplayValue { - text: string; // Show in the UI - numeric: number; // Use isNaN to check if it is a real number - color?: string; // color based on configs or Threshold -} +// Utils +import { getValueFormat } from './valueFormats/valueFormats'; +import { getMappedValue } from './valueMappings'; +import { getColorFromHexRgbOrName } from './namedColorsPalette'; + +// Types +import { Threshold, ValueMapping, DecimalInfo, DisplayValue, GrafanaTheme, GrafanaThemeType } from '../types'; +import { DecimalCount } from './valueFormats/valueFormats'; + +export type DisplayProcessor = (value: any) => DisplayValue; export interface DisplayValueOptions { unit?: string; decimals?: DecimalCount; - scaledDecimals?: DecimalCount; dateFormat?: string; // If set try to convert numbers to date color?: string; @@ -32,11 +32,10 @@ export interface DisplayValueOptions { theme?: GrafanaTheme; // Will pick 'dark' if not defined } -export type DisplayProcessor = (value: any) => DisplayValue; - export function getDisplayProcessor(options?: DisplayValueOptions): DisplayProcessor { if (options && !_.isEmpty(options)) { const formatFunc = getValueFormat(options.unit || 'none'); + return (value: any) => { const { prefix, suffix, mappings, thresholds, theme } = options; let color = options.color; @@ -47,12 +46,15 @@ export function getDisplayProcessor(options?: DisplayValueOptions): DisplayProce let shouldFormat = true; if (mappings && mappings.length > 0) { const mappedValue = getMappedValue(mappings, value); + if (mappedValue) { text = mappedValue.text; const v = toNumber(text); + if (!isNaN(v)) { numeric = v; } + shouldFormat = false; } } @@ -67,7 +69,19 @@ export function getDisplayProcessor(options?: DisplayValueOptions): DisplayProce if (!isNaN(numeric)) { if (shouldFormat && !_.isBoolean(value)) { - text = formatFunc(numeric, options.decimals, options.scaledDecimals, options.isUtc); + let decimals; + let scaledDecimals = 0; + + if (!options.decimals) { + const decimalInfo = getDecimalsForValue(value); + + decimals = decimalInfo.decimals; + scaledDecimals = decimalInfo.scaledDecimals; + } else { + decimals = options.decimals; + } + + text = formatFunc(numeric, decimals, scaledDecimals, options.isUtc); } if (thresholds && thresholds.length > 0) { color = getColorFromThreshold(numeric, thresholds, theme); @@ -143,3 +157,39 @@ export function getColorFromThreshold(value: number, thresholds: Threshold[], th // Use the first threshold as the default color return getColorFromHexRgbOrName(thresholds[0].color, themeType); } + +export function getDecimalsForValue(value: number): DecimalInfo { + const delta = value / 2; + let dec = -Math.floor(Math.log(delta) / Math.LN10); + + const magn = Math.pow(10, -dec); + const norm = delta / magn; // norm is between 1.0 and 10.0 + let size; + + if (norm < 1.5) { + size = 1; + } else if (norm < 3) { + size = 2; + // special case for 2.5, requires an extra decimal + if (norm > 2.25) { + size = 2.5; + ++dec; + } + } else if (norm < 7.5) { + size = 5; + } else { + size = 10; + } + + size *= magn; + + // reduce starting decimals if not needed + if (Math.floor(value) === value) { + dec = 0; + } + + const decimals = Math.max(0, dec); + const scaledDecimals = decimals - Math.floor(Math.log(size) / Math.LN10) + 2; + + return { decimals, scaledDecimals }; +} diff --git a/public/app/core/actions/location.ts b/public/app/core/actions/location.ts index 8669788fa16..34ab43da7d2 100644 --- a/public/app/core/actions/location.ts +++ b/public/app/core/actions/location.ts @@ -1,17 +1,4 @@ import { LocationUpdate } from 'app/types'; +import { actionCreatorFactory } from 'app/core/redux'; -export enum CoreActionTypes { - UpdateLocation = 'UPDATE_LOCATION', -} - -export type Action = UpdateLocationAction; - -export interface UpdateLocationAction { - type: CoreActionTypes.UpdateLocation; - payload: LocationUpdate; -} - -export const updateLocation = (location: LocationUpdate): UpdateLocationAction => ({ - type: CoreActionTypes.UpdateLocation, - payload: location, -}); +export const updateLocation = actionCreatorFactory('UPDATE_LOCATION').create(); diff --git a/public/app/core/reducers/location.ts b/public/app/core/reducers/location.ts index dff1ac8f5c1..04e8ab0fc66 100644 --- a/public/app/core/reducers/location.ts +++ b/public/app/core/reducers/location.ts @@ -1,7 +1,8 @@ -import { Action, CoreActionTypes } from 'app/core/actions/location'; import { LocationState } from 'app/types'; import { renderUrl } from 'app/core/utils/url'; import _ from 'lodash'; +import { reducerFactory } from 'app/core/redux'; +import { updateLocation } from 'app/core/actions'; export const initialState: LocationState = { url: '', @@ -12,9 +13,10 @@ export const initialState: LocationState = { lastUpdated: 0, }; -export const locationReducer = (state = initialState, action: Action): LocationState => { - switch (action.type) { - case CoreActionTypes.UpdateLocation: { +export const locationReducer = reducerFactory(initialState) + .addMapper({ + filter: updateLocation, + mapper: (state, action): LocationState => { const { path, routeParams, replace } = action.payload; let query = action.payload.query || state.query; @@ -31,8 +33,6 @@ export const locationReducer = (state = initialState, action: Action): LocationS replace: replace === true, lastUpdated: new Date().getTime(), }; - } - } - - return state; -}; + }, + }) + .create(); diff --git a/public/app/core/redux/actionCreatorFactory.ts b/public/app/core/redux/actionCreatorFactory.ts index df0f02f5c99..1205d1bcfd9 100644 --- a/public/app/core/redux/actionCreatorFactory.ts +++ b/public/app/core/redux/actionCreatorFactory.ts @@ -68,5 +68,9 @@ export const getNoPayloadActionCreatorMock = (creator: NoPayloadActionCreator): return mock; }; +export const mockActionCreator = (creator: ActionCreator) => { + return Object.assign(jest.fn(), creator); +}; + // Should only be used by tests export const resetAllActionCreatorTypes = () => (allActionCreators.length = 0); diff --git a/public/app/features/alerting/AlertRuleList.test.tsx b/public/app/features/alerting/AlertRuleList.test.tsx index e1db5c77839..61b59cc8523 100644 --- a/public/app/features/alerting/AlertRuleList.test.tsx +++ b/public/app/features/alerting/AlertRuleList.test.tsx @@ -3,6 +3,8 @@ import { shallow } from 'enzyme'; import { AlertRuleList, Props } from './AlertRuleList'; import { AlertRule, NavModel } from '../../types'; import appEvents from '../../core/app_events'; +import { mockActionCreator } from 'app/core/redux'; +import { updateLocation } from 'app/core/actions'; jest.mock('../../core/app_events', () => ({ emit: jest.fn(), @@ -12,7 +14,7 @@ const setup = (propOverrides?: object) => { const props: Props = { navModel: {} as NavModel, alertRules: [] as AlertRule[], - updateLocation: jest.fn(), + updateLocation: mockActionCreator(updateLocation), getAlertRulesAsync: jest.fn(), setSearchQuery: jest.fn(), togglePauseAlertRule: jest.fn(), diff --git a/public/app/features/annotations/all.ts b/public/app/features/annotations/all.ts index d0ef6abf9ed..ed2c65062be 100644 --- a/public/app/features/annotations/all.ts +++ b/public/app/features/annotations/all.ts @@ -1,7 +1,5 @@ import { AnnotationsSrv } from './annotations_srv'; import { eventEditor } from './event_editor'; import { EventManager } from './event_manager'; -import { AnnotationEvent } from './event'; import { annotationTooltipDirective } from './annotation_tooltip'; - -export { AnnotationsSrv, eventEditor, EventManager, AnnotationEvent, annotationTooltipDirective }; +export { AnnotationsSrv, eventEditor, EventManager, annotationTooltipDirective }; diff --git a/public/app/features/annotations/event.ts b/public/app/features/annotations/event.ts deleted file mode 100644 index de86b72c7c0..00000000000 --- a/public/app/features/annotations/event.ts +++ /dev/null @@ -1,11 +0,0 @@ -export class AnnotationEvent { - dashboardId: number; - panelId: number; - userId: number; - time: any; - timeEnd: any; - isRegion: boolean; - text: string; - type: string; - tags: string; -} diff --git a/public/app/features/annotations/event_editor.ts b/public/app/features/annotations/event_editor.ts index a88cc44a251..a6970790bb3 100644 --- a/public/app/features/annotations/event_editor.ts +++ b/public/app/features/annotations/event_editor.ts @@ -2,7 +2,7 @@ import _ from 'lodash'; import moment from 'moment'; import { coreModule } from 'app/core/core'; import { MetricsPanelCtrl } from 'app/plugins/sdk'; -import { AnnotationEvent } from './event'; +import { AnnotationEvent } from '@grafana/ui'; export class EventEditorCtrl { panelCtrl: MetricsPanelCtrl; diff --git a/public/app/features/annotations/event_manager.ts b/public/app/features/annotations/event_manager.ts index 6966d3cdc82..fbd7897ba92 100644 --- a/public/app/features/annotations/event_manager.ts +++ b/public/app/features/annotations/event_manager.ts @@ -1,5 +1,4 @@ import _ from 'lodash'; -import moment from 'moment'; import tinycolor from 'tinycolor2'; import { OK_COLOR, @@ -11,7 +10,7 @@ import { } from '@grafana/ui'; import { MetricsPanelCtrl } from 'app/plugins/sdk'; -import { AnnotationEvent } from './event'; +import { AnnotationEvent } from '@grafana/ui'; export class EventManager { event: AnnotationEvent; @@ -31,16 +30,17 @@ export class EventManager { updateTime(range) { if (!this.event) { - this.event = new AnnotationEvent(); + this.event = {}; this.event.dashboardId = this.panelCtrl.dashboard.id; this.event.panelId = this.panelCtrl.panel.id; } // update time - this.event.time = moment(range.from); + this.event.time = range.from; this.event.isRegion = false; + if (range.to) { - this.event.timeEnd = moment(range.to); + this.event.timeEnd = range.to; this.event.isRegion = true; } @@ -90,8 +90,8 @@ export class EventManager { annotations = [ { isRegion: true, - min: this.event.time.valueOf(), - timeEnd: this.event.timeEnd.valueOf(), + min: this.event.time, + timeEnd: this.event.timeEnd, text: this.event.text, eventType: '$__editing', editModel: this.event, @@ -100,7 +100,7 @@ export class EventManager { } else { annotations = [ { - min: this.event.time.valueOf(), + min: this.event.time, text: this.event.text, editModel: this.event, eventType: '$__editing', diff --git a/public/app/features/dashboard/containers/DashboardPage.test.tsx b/public/app/features/dashboard/containers/DashboardPage.test.tsx index e3b9e1ca77a..0489f7a59c1 100644 --- a/public/app/features/dashboard/containers/DashboardPage.test.tsx +++ b/public/app/features/dashboard/containers/DashboardPage.test.tsx @@ -3,8 +3,9 @@ import { shallow, ShallowWrapper } from 'enzyme'; import { DashboardPage, Props, State, mapStateToProps } from './DashboardPage'; import { DashboardModel } from '../state'; import { cleanUpDashboard } from '../state/actions'; -import { getNoPayloadActionCreatorMock, NoPayloadActionCreatorMock } from 'app/core/redux'; +import { getNoPayloadActionCreatorMock, NoPayloadActionCreatorMock, mockActionCreator } from 'app/core/redux'; import { DashboardRouteInfo, DashboardInitPhase } from 'app/types'; +import { updateLocation } from 'app/core/actions'; jest.mock('app/features/dashboard/components/DashboardSettings/SettingsCtrl', () => ({})); @@ -62,7 +63,7 @@ function dashboardPageScenario(description, scenarioFn: (ctx: ScenarioContext) = initPhase: DashboardInitPhase.NotStarted, isInitSlow: false, initDashboard: jest.fn(), - updateLocation: jest.fn(), + updateLocation: mockActionCreator(updateLocation), notifyApp: jest.fn(), cleanUpDashboard: ctx.cleanUpDashboardMock, dashboard: null, diff --git a/public/app/features/datasources/state/actions.ts b/public/app/features/datasources/state/actions.ts index 2e21b3066d1..1902bfec88d 100644 --- a/public/app/features/datasources/state/actions.ts +++ b/public/app/features/datasources/state/actions.ts @@ -4,10 +4,9 @@ import { getBackendSrv } from 'app/core/services/backend_srv'; import { getDatasourceSrv } from 'app/features/plugins/datasource_srv'; import { LayoutMode } from 'app/core/components/LayoutSelector/LayoutSelector'; import { updateLocation, updateNavIndex, UpdateNavIndexAction } from 'app/core/actions'; -import { UpdateLocationAction } from 'app/core/actions/location'; import { buildNavModel } from './navModel'; import { DataSourceSettings } from '@grafana/ui/src/types'; -import { Plugin, StoreState } from 'app/types'; +import { Plugin, StoreState, LocationUpdate } from 'app/types'; import { actionCreatorFactory } from 'app/core/redux'; import { ActionOf, noPayloadActionCreatorFactory } from 'app/core/redux/actionCreatorFactory'; @@ -32,12 +31,12 @@ export const setDataSourceName = actionCreatorFactory('SET_DATA_SOURCE_N export const setIsDefault = actionCreatorFactory('SET_IS_DEFAULT').create(); export type Action = - | UpdateLocationAction | UpdateNavIndexAction | ActionOf | ActionOf | ActionOf - | ActionOf; + | ActionOf + | ActionOf; type ThunkResult = ThunkAction; diff --git a/public/app/features/explore/Explore.tsx b/public/app/features/explore/Explore.tsx index 52c3e258f4a..6f19d5bfb91 100644 --- a/public/app/features/explore/Explore.tsx +++ b/public/app/features/explore/Explore.tsx @@ -1,7 +1,9 @@ // Libraries import React, { ComponentClass } from 'react'; import { hot } from 'react-hot-loader'; +// @ts-ignore import { connect } from 'react-redux'; +// @ts-ignore import _ from 'lodash'; import { AutoSizer } from 'react-virtualized'; @@ -18,11 +20,19 @@ import TableContainer from './TableContainer'; import TimePicker, { parseTime } from './TimePicker'; // Actions -import { changeSize, changeTime, initializeExplore, modifyQueries, scanStart, setQueries } from './state/actions'; +import { + changeSize, + changeTime, + initializeExplore, + modifyQueries, + scanStart, + setQueries, + refreshExplore, +} from './state/actions'; // Types import { RawTimeRange, TimeRange, DataQuery, ExploreStartPageProps, ExploreDataSourceApi } from '@grafana/ui'; -import { ExploreItemState, ExploreUrlState, RangeScanner, ExploreId } from 'app/types/explore'; +import { ExploreItemState, ExploreUrlState, RangeScanner, ExploreId, ExploreUpdateState } 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 { Emitter } from 'app/core/utils/emitter'; @@ -42,6 +52,8 @@ interface ExploreProps { initialized: boolean; modifyQueries: typeof modifyQueries; range: RawTimeRange; + update: ExploreUpdateState; + refreshExplore: typeof refreshExplore; scanner?: RangeScanner; scanning?: boolean; scanRange?: RawTimeRange; @@ -53,8 +65,8 @@ interface ExploreProps { supportsGraph: boolean | null; supportsLogs: boolean | null; supportsTable: boolean | null; - urlState: ExploreUrlState; queryKeys: string[]; + urlState: ExploreUrlState; } /** @@ -89,23 +101,22 @@ export class Explore extends React.PureComponent { */ timepickerRef: React.RefObject; - constructor(props) { + constructor(props: ExploreProps) { super(props); this.exploreEvents = new Emitter(); this.timepickerRef = React.createRef(); } - async componentDidMount() { - const { exploreId, initialized, urlState } = this.props; - // Don't initialize on split, but need to initialize urlparameters when present - if (!initialized) { - // Load URL state and parse range - 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; + 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 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( exploreId, initialDatasource, @@ -122,7 +133,11 @@ export class Explore extends React.PureComponent { this.exploreEvents.removeAllListeners(); } - getRef = el => { + componentDidUpdate(prevProps: ExploreProps) { + this.refreshExplore(); + } + + getRef = (el: any) => { this.el = el; }; @@ -142,7 +157,7 @@ export class Explore extends React.PureComponent { this.onModifyQueries({ type: 'ADD_FILTER', key, value }); }; - onModifyQueries = (action, index?: number) => { + onModifyQueries = (action: any, index?: number) => { const { datasourceInstance } = this.props; if (datasourceInstance && datasourceInstance.modifyQuery) { const modifier = (queries: DataQuery, modification: any) => datasourceInstance.modifyQuery(queries, modification); @@ -169,6 +184,14 @@ export class Explore extends React.PureComponent { this.props.scanStopAction({ exploreId: this.props.exploreId }); }; + refreshExplore = () => { + const { exploreId, update } = this.props; + + if (update.queries || update.ui || update.range || update.datasource) { + this.props.refreshExplore(exploreId); + } + }; + render() { const { StartPage, @@ -241,7 +264,7 @@ export class Explore extends React.PureComponent { } } -function mapStateToProps(state: StoreState, { exploreId }) { +function mapStateToProps(state: StoreState, { exploreId }: ExploreProps) { const explore = state.explore; const { split } = explore; const item: ExploreItemState = explore[exploreId]; @@ -258,6 +281,8 @@ function mapStateToProps(state: StoreState, { exploreId }) { supportsLogs, supportsTable, queryKeys, + urlState, + update, } = item; return { StartPage, @@ -273,6 +298,8 @@ function mapStateToProps(state: StoreState, { exploreId }) { supportsLogs, supportsTable, queryKeys, + urlState, + update, }; } @@ -281,6 +308,7 @@ const mapDispatchToProps = { changeTime, initializeExplore, modifyQueries, + refreshExplore, scanStart, scanStopAction, setQueries, diff --git a/public/app/features/explore/Wrapper.tsx b/public/app/features/explore/Wrapper.tsx index add2ffed235..e8b894bc53d 100644 --- a/public/app/features/explore/Wrapper.tsx +++ b/public/app/features/explore/Wrapper.tsx @@ -2,65 +2,37 @@ import React, { Component } from 'react'; import { hot } from 'react-hot-loader'; import { connect } from 'react-redux'; -import { updateLocation } from 'app/core/actions'; import { StoreState } from 'app/types'; -import { ExploreId, ExploreUrlState } from 'app/types/explore'; -import { parseUrlState } from 'app/core/utils/explore'; +import { ExploreId } from 'app/types/explore'; import ErrorBoundary from './ErrorBoundary'; import Explore from './Explore'; import { CustomScrollbar } from '@grafana/ui'; -import { initializeExploreSplitAction, resetExploreAction } from './state/actionTypes'; +import { resetExploreAction } from './state/actionTypes'; interface WrapperProps { - initializeExploreSplitAction: typeof initializeExploreSplitAction; split: boolean; - updateLocation: typeof updateLocation; resetExploreAction: typeof resetExploreAction; - urlStates: { [key: string]: string }; } export class Wrapper extends Component { - initialSplit: boolean; - urlStates: { [key: string]: ExploreUrlState }; - - constructor(props: WrapperProps) { - super(props); - this.urlStates = {}; - const { left, right } = props.urlStates; - if (props.urlStates.left) { - this.urlStates.leftState = parseUrlState(left); - } - if (props.urlStates.right) { - this.urlStates.rightState = parseUrlState(right); - this.initialSplit = true; - } - } - - componentDidMount() { - if (this.initialSplit) { - this.props.initializeExploreSplitAction(); - } - } - componentWillUnmount() { this.props.resetExploreAction(); } render() { const { split } = this.props; - const { leftState, rightState } = this.urlStates; return (
- + {split && ( - + )}
@@ -71,14 +43,11 @@ export class Wrapper extends Component { } const mapStateToProps = (state: StoreState) => { - const urlStates = state.location.query; const { split } = state.explore; - return { split, urlStates }; + return { split }; }; const mapDispatchToProps = { - initializeExploreSplitAction, - updateLocation, resetExploreAction, }; diff --git a/public/app/features/explore/state/actionTypes.ts b/public/app/features/explore/state/actionTypes.ts index 26758bb750d..8f3e1dbcf80 100644 --- a/public/app/features/explore/state/actionTypes.ts +++ b/public/app/features/explore/state/actionTypes.ts @@ -24,17 +24,11 @@ import { LogLevel } from 'app/core/logs_model'; * */ export enum ActionTypes { - InitializeExploreSplit = 'explore/INITIALIZE_EXPLORE_SPLIT', SplitClose = 'explore/SPLIT_CLOSE', SplitOpen = 'explore/SPLIT_OPEN', ResetExplore = 'explore/RESET_EXPLORE', } -export interface InitializeExploreSplitAction { - type: ActionTypes.InitializeExploreSplit; - payload: {}; -} - export interface SplitCloseAction { type: ActionTypes.SplitClose; payload: {}; @@ -154,10 +148,6 @@ export interface RemoveQueryRowPayload { index: number; } -export interface RunQueriesEmptyPayload { - exploreId: ExploreId; -} - export interface ScanStartPayload { exploreId: ExploreId; scanner: RangeScanner; @@ -259,11 +249,6 @@ export const initializeExploreAction = actionCreatorFactory('explore/REMOVE_QUERY_ROW').create(); export const runQueriesAction = noPayloadActionCreatorFactory('explore/RUN_QUERIES').create(); -export const runQueriesEmptyAction = actionCreatorFactory('explore/RUN_QUERIES_EMPTY').create(); /** * Start a scan for more results using the given scanner. @@ -411,12 +395,7 @@ export const toggleLogLevelAction = actionCreatorFactory( export const resetExploreAction = noPayloadActionCreatorFactory('explore/RESET_EXPLORE').create(); export const queriesImportedAction = actionCreatorFactory('explore/QueriesImported').create(); -export type HigherOrderAction = - | InitializeExploreSplitAction - | SplitCloseAction - | SplitOpenAction - | ResetExploreAction - | ActionOf; +export type HigherOrderAction = SplitCloseAction | SplitOpenAction | ResetExploreAction | ActionOf; export type Action = | ActionOf @@ -435,7 +414,6 @@ export type Action = | ActionOf | ActionOf | ActionOf - | ActionOf | ActionOf | ActionOf | ActionOf diff --git a/public/app/features/explore/state/actions.test.ts b/public/app/features/explore/state/actions.test.ts new file mode 100644 index 00000000000..4608c66c3ad --- /dev/null +++ b/public/app/features/explore/state/actions.test.ts @@ -0,0 +1,147 @@ +import { refreshExplore } from './actions'; +import { ExploreId, ExploreUrlState, ExploreUpdateState } from 'app/types'; +import { thunkTester } from 'test/core/thunk/thunkTester'; +import { LogsDedupStrategy } from 'app/core/logs_model'; +import { + initializeExploreAction, + InitializeExplorePayload, + changeTimeAction, + updateUIStateAction, + setQueriesAction, +} from './actionTypes'; +import { Emitter } from 'app/core/core'; +import { ActionOf } from 'app/core/redux/actionCreatorFactory'; +import { makeInitialUpdateState } from './reducers'; + +jest.mock('app/features/plugins/datasource_srv', () => ({ + getDatasourceSrv: () => ({ + getExternal: jest.fn().mockReturnValue([]), + get: jest.fn().mockReturnValue({ + testDatasource: jest.fn(), + init: jest.fn(), + }), + }), +})); + +const setup = (updateOverides?: Partial) => { + const exploreId = ExploreId.left; + const containerWidth = 1920; + const eventBridge = {} as Emitter; + const ui = { dedupStrategy: LogsDedupStrategy.none, showingGraph: false, showingLogs: false, showingTable: false }; + const range = { from: 'now', to: 'now' }; + const urlState: ExploreUrlState = { datasource: 'some-datasource', queries: [], range, ui }; + const updateDefaults = makeInitialUpdateState(); + const update = { ...updateDefaults, ...updateOverides }; + const initialState = { + explore: { + [exploreId]: { + initialized: true, + urlState, + containerWidth, + eventBridge, + update, + datasourceInstance: { name: 'some-datasource' }, + queries: [], + range, + ui, + }, + }, + }; + + return { + initialState, + exploreId, + range, + ui, + containerWidth, + eventBridge, + }; +}; + +describe('refreshExplore', () => { + describe('when explore is initialized', () => { + describe('and update datasource is set', () => { + it('then it should dispatch initializeExplore', () => { + const { exploreId, ui, range, initialState, containerWidth, eventBridge } = setup({ datasource: true }); + + thunkTester(initialState) + .givenThunk(refreshExplore) + .whenThunkIsDispatched(exploreId) + .thenDispatchedActionsAreEqual(dispatchedActions => { + const initializeExplore = dispatchedActions[0] as ActionOf; + const { type, payload } = initializeExplore; + + expect(type).toEqual(initializeExploreAction.type); + expect(payload.containerWidth).toEqual(containerWidth); + expect(payload.eventBridge).toEqual(eventBridge); + expect(payload.exploreDatasources).toEqual([]); + expect(payload.queries.length).toBe(1); // Queries have generated keys hard to expect on + expect(payload.range).toEqual(range); + expect(payload.ui).toEqual(ui); + + return true; + }); + }); + }); + + describe('and update range is set', () => { + it('then it should dispatch changeTimeAction', () => { + const { exploreId, range, initialState } = setup({ range: true }); + + thunkTester(initialState) + .givenThunk(refreshExplore) + .whenThunkIsDispatched(exploreId) + .thenDispatchedActionsAreEqual(dispatchedActions => { + expect(dispatchedActions[0].type).toEqual(changeTimeAction.type); + expect(dispatchedActions[0].payload).toEqual({ exploreId, range }); + + return true; + }); + }); + }); + + describe('and update ui is set', () => { + it('then it should dispatch updateUIStateAction', () => { + const { exploreId, initialState, ui } = setup({ ui: true }); + + thunkTester(initialState) + .givenThunk(refreshExplore) + .whenThunkIsDispatched(exploreId) + .thenDispatchedActionsAreEqual(dispatchedActions => { + expect(dispatchedActions[0].type).toEqual(updateUIStateAction.type); + expect(dispatchedActions[0].payload).toEqual({ ...ui, exploreId }); + + return true; + }); + }); + }); + + describe('and update queries is set', () => { + it('then it should dispatch setQueriesAction', () => { + const { exploreId, initialState } = setup({ queries: true }); + + thunkTester(initialState) + .givenThunk(refreshExplore) + .whenThunkIsDispatched(exploreId) + .thenDispatchedActionsAreEqual(dispatchedActions => { + expect(dispatchedActions[0].type).toEqual(setQueriesAction.type); + expect(dispatchedActions[0].payload).toEqual({ exploreId, queries: [] }); + + return true; + }); + }); + }); + }); + + describe('when update is not initialized', () => { + it('then it should not dispatch any actions', () => { + const exploreId = ExploreId.left; + const initialState = { explore: { [exploreId]: { initialized: false } } }; + + thunkTester(initialState) + .givenThunk(refreshExplore) + .whenThunkIsDispatched(exploreId) + .thenThereAreNoDispatchedActions(); + }); + }); +}); diff --git a/public/app/features/explore/state/actions.ts b/public/app/features/explore/state/actions.ts index fda8fe5eef4..a674404569e 100644 --- a/public/app/features/explore/state/actions.ts +++ b/public/app/features/explore/state/actions.ts @@ -16,6 +16,7 @@ import { updateHistory, buildQueryTransaction, serializeStateToUrlParam, + parseUrlState, } from 'app/core/utils/explore'; // Actions @@ -54,7 +55,6 @@ import { queryTransactionStartAction, queryTransactionSuccessAction, scanRangeAction, - runQueriesEmptyAction, scanStartAction, setQueriesAction, splitCloseAction, @@ -67,9 +67,11 @@ import { ToggleLogsPayload, ToggleTablePayload, updateUIStateAction, + runQueriesAction, } from './actionTypes'; import { ActionOf, ActionCreator } from 'app/core/redux/actionCreatorFactory'; import { LogsDedupStrategy } from 'app/core/logs_model'; +import { parseTime } from '../TimePicker'; type ThunkResult = ThunkAction; @@ -518,7 +520,7 @@ export function runQueries(exploreId: ExploreId, ignoreUIState = false) { } = getState().explore[exploreId]; if (!hasNonEmptyQuery(queries)) { - dispatch(runQueriesEmptyAction({ exploreId })); + dispatch(clearQueriesAction({ exploreId })); dispatch(stateSave()); // Remember to saves to state and update location return; } @@ -527,6 +529,7 @@ export function runQueries(exploreId: ExploreId, ignoreUIState = false) { // but we're using the datasource interval limit for now const interval = datasourceInstance.interval; + dispatch(runQueriesAction()); // Keep table queries first since they need to return quickly if ((ignoreUIState || showingTable) && supportsTable) { dispatch( @@ -657,11 +660,15 @@ export function splitClose(): ThunkResult { export function splitOpen(): ThunkResult { return (dispatch, getState) => { // Clone left state to become the right state - const leftState = getState().explore.left; + const leftState = getState().explore[ExploreId.left]; + const queryState = getState().location.query[ExploreId.left] as string; + const urlState = parseUrlState(queryState); const itemState = { ...leftState, queryTransactions: [], queries: leftState.queries.slice(), + exploreId: ExploreId.right, + urlState, }; dispatch(splitOpenAction({ itemState })); dispatch(stateSave()); @@ -766,3 +773,44 @@ export const changeDedupStrategy = (exploreId, dedupStrategy: LogsDedupStrategy) dispatch(updateExploreUIState(exploreId, { dedupStrategy })); }; }; + +export function refreshExplore(exploreId: ExploreId): ThunkResult { + return (dispatch, getState) => { + const itemState = getState().explore[exploreId]; + if (!itemState.initialized) { + return; + } + + const { urlState, update, containerWidth, eventBridge } = itemState; + const { datasource, queries, range, ui } = urlState; + const refreshQueries = queries.map(q => ({ ...q, ...generateEmptyQuery(itemState.queries) })); + const refreshRange = { from: parseTime(range.from), to: parseTime(range.to) }; + + // 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)); + return; + } + + if (update.range) { + dispatch(changeTimeAction({ exploreId, range: refreshRange as TimeRange })); + } + + // need to refresh ui state + if (update.ui) { + dispatch(updateUIStateAction({ ...ui, exploreId })); + } + + // need to refresh queries + if (update.queries) { + dispatch(setQueriesAction({ exploreId, queries: refreshQueries })); + } + + // always run queries when refresh is needed + if (update.queries || update.ui || update.range) { + dispatch(runQueries(exploreId)); + } + }; +} diff --git a/public/app/features/explore/state/reducers.test.ts b/public/app/features/explore/state/reducers.test.ts index 44079313c04..e0853cd3a11 100644 --- a/public/app/features/explore/state/reducers.test.ts +++ b/public/app/features/explore/state/reducers.test.ts @@ -1,9 +1,12 @@ -import { itemReducer, makeExploreItemState } from './reducers'; -import { ExploreId, ExploreItemState } from 'app/types/explore'; +import { itemReducer, makeExploreItemState, exploreReducer, makeInitialUpdateState } from './reducers'; +import { ExploreId, ExploreItemState, ExploreUrlState } from 'app/types/explore'; import { reducerTester } from 'test/core/redux/reducerTester'; import { scanStartAction, scanStopAction } from './actionTypes'; import { Reducer } from 'redux'; import { ActionOf } from 'app/core/redux/actionCreatorFactory'; +import { updateLocation } from 'app/core/actions/location'; +import { LogsDedupStrategy } from 'app/core/logs_model'; +import { serializeStateToUrlParam } from 'app/core/utils/explore'; describe('Explore item reducer', () => { describe('scanning', () => { @@ -45,3 +48,292 @@ describe('Explore item reducer', () => { }); }); }); + +export const setup = (urlStateOverrides?: any) => { + const update = makeInitialUpdateState(); + const urlStateDefaults: ExploreUrlState = { + datasource: 'some-datasource', + queries: [], + range: { + from: '', + to: '', + }, + ui: { + dedupStrategy: LogsDedupStrategy.none, + showingGraph: false, + showingTable: false, + showingLogs: false, + }, + }; + const urlState: ExploreUrlState = { ...urlStateDefaults, ...urlStateOverrides }; + const serializedUrlState = serializeStateToUrlParam(urlState); + const initalState = { split: false, left: { urlState, update }, right: { urlState, update } }; + + return { + initalState, + serializedUrlState, + }; +}; + +describe('Explore reducer', () => { + describe('when updateLocation is dispatched', () => { + describe('and payload does not contain a query', () => { + it('then it should just return state', () => { + reducerTester() + .givenReducer(exploreReducer, {}) + .whenActionIsDispatched(updateLocation({ query: null })) + .thenStateShouldEqual({}); + }); + }); + + describe('and payload contains a query', () => { + describe("but does not contain 'left'", () => { + it('then it should just return state', () => { + reducerTester() + .givenReducer(exploreReducer, {}) + .whenActionIsDispatched(updateLocation({ query: {} })) + .thenStateShouldEqual({}); + }); + }); + + describe("and query contains a 'right'", () => { + it('then it should add split in state', () => { + const { initalState, serializedUrlState } = setup(); + const expectedState = { ...initalState, split: true }; + + reducerTester() + .givenReducer(exploreReducer, initalState) + .whenActionIsDispatched( + updateLocation({ + query: { + left: serializedUrlState, + right: serializedUrlState, + }, + }) + ) + .thenStateShouldEqual(expectedState); + }); + }); + + describe("and query contains a 'left'", () => { + describe('but urlState is not set in state', () => { + it('then it should just add urlState and update in state', () => { + const { initalState, serializedUrlState } = setup(); + const stateWithoutUrlState = { ...initalState, left: { urlState: null } }; + const expectedState = { ...initalState }; + + reducerTester() + .givenReducer(exploreReducer, stateWithoutUrlState) + .whenActionIsDispatched( + updateLocation({ + query: { + left: serializedUrlState, + }, + path: '/explore', + }) + ) + .thenStateShouldEqual(expectedState); + }); + }); + + describe("but '/explore' is missing in path", () => { + it('then it should just add urlState and update in state', () => { + const { initalState, serializedUrlState } = setup(); + const expectedState = { ...initalState }; + + reducerTester() + .givenReducer(exploreReducer, initalState) + .whenActionIsDispatched( + updateLocation({ + query: { + left: serializedUrlState, + }, + path: '/dashboard', + }) + ) + .thenStateShouldEqual(expectedState); + }); + }); + + describe("and '/explore' is in path", () => { + describe('and datasource differs', () => { + it('then it should return update datasource', () => { + const { initalState, serializedUrlState } = setup(); + const expectedState = { + ...initalState, + left: { + ...initalState.left, + update: { + ...initalState.left.update, + datasource: true, + }, + }, + }; + const stateWithDifferentDataSource = { + ...initalState, + left: { + ...initalState.left, + urlState: { + ...initalState.left.urlState, + datasource: 'different datasource', + }, + }, + }; + + reducerTester() + .givenReducer(exploreReducer, stateWithDifferentDataSource) + .whenActionIsDispatched( + updateLocation({ + query: { + left: serializedUrlState, + }, + path: '/explore', + }) + ) + .thenStateShouldEqual(expectedState); + }); + }); + + describe('and range differs', () => { + it('then it should return update range', () => { + const { initalState, serializedUrlState } = setup(); + const expectedState = { + ...initalState, + left: { + ...initalState.left, + update: { + ...initalState.left.update, + range: true, + }, + }, + }; + const stateWithDifferentDataSource = { + ...initalState, + left: { + ...initalState.left, + urlState: { + ...initalState.left.urlState, + range: { + from: 'now', + to: 'now-6h', + }, + }, + }, + }; + + reducerTester() + .givenReducer(exploreReducer, stateWithDifferentDataSource) + .whenActionIsDispatched( + updateLocation({ + query: { + left: serializedUrlState, + }, + path: '/explore', + }) + ) + .thenStateShouldEqual(expectedState); + }); + }); + + describe('and queries differs', () => { + it('then it should return update queries', () => { + const { initalState, serializedUrlState } = setup(); + const expectedState = { + ...initalState, + left: { + ...initalState.left, + update: { + ...initalState.left.update, + queries: true, + }, + }, + }; + const stateWithDifferentDataSource = { + ...initalState, + left: { + ...initalState.left, + urlState: { + ...initalState.left.urlState, + queries: [{ expr: '{__filename__="some.log"}' }], + }, + }, + }; + + reducerTester() + .givenReducer(exploreReducer, stateWithDifferentDataSource) + .whenActionIsDispatched( + updateLocation({ + query: { + left: serializedUrlState, + }, + path: '/explore', + }) + ) + .thenStateShouldEqual(expectedState); + }); + }); + + describe('and ui differs', () => { + it('then it should return update ui', () => { + const { initalState, serializedUrlState } = setup(); + const expectedState = { + ...initalState, + left: { + ...initalState.left, + update: { + ...initalState.left.update, + ui: true, + }, + }, + }; + const stateWithDifferentDataSource = { + ...initalState, + left: { + ...initalState.left, + urlState: { + ...initalState.left.urlState, + ui: { + ...initalState.left.urlState.ui, + showingGraph: true, + }, + }, + }, + }; + + reducerTester() + .givenReducer(exploreReducer, stateWithDifferentDataSource) + .whenActionIsDispatched( + updateLocation({ + query: { + left: serializedUrlState, + }, + path: '/explore', + }) + ) + .thenStateShouldEqual(expectedState); + }); + }); + + describe('and nothing differs', () => { + fit('then it should return update ui', () => { + const { initalState, serializedUrlState } = setup(); + const expectedState = { ...initalState }; + + reducerTester() + .givenReducer(exploreReducer, initalState) + .whenActionIsDispatched( + updateLocation({ + query: { + left: serializedUrlState, + }, + path: '/explore', + }) + ) + .thenStateShouldEqual(expectedState); + }); + }); + }); + }); + }); + }); +}); diff --git a/public/app/features/explore/state/reducers.ts b/public/app/features/explore/state/reducers.ts index 32bfe09a96b..14aa5d61363 100644 --- a/public/app/features/explore/state/reducers.ts +++ b/public/app/features/explore/state/reducers.ts @@ -1,11 +1,15 @@ +// @ts-ignore +import _ from 'lodash'; import { calculateResultsFromQueryTransactions, generateEmptyQuery, getIntervals, ensureQueries, getQueryKeys, + parseUrlState, + DEFAULT_UI_STATE, } from 'app/core/utils/explore'; -import { ExploreItemState, ExploreState, QueryTransaction } from 'app/types/explore'; +import { ExploreItemState, ExploreState, QueryTransaction, ExploreId, ExploreUpdateState } from 'app/types/explore'; import { DataQuery } from '@grafana/ui/src/types'; import { HigherOrderAction, ActionTypes } from './actionTypes'; @@ -28,7 +32,6 @@ import { queryTransactionStartAction, queryTransactionSuccessAction, removeQueryRowAction, - runQueriesEmptyAction, scanRangeAction, scanStartAction, scanStopAction, @@ -40,6 +43,8 @@ import { updateUIStateAction, toggleLogLevelAction, } from './actionTypes'; +import { updateLocation } from 'app/core/actions/location'; +import { LocationUpdate } from 'app/types'; export const DEFAULT_RANGE = { from: 'now-6h', @@ -49,6 +54,12 @@ export const DEFAULT_RANGE = { // Millies step for helper bar charts const DEFAULT_GRAPH_INTERVAL = 15 * 1000; +export const makeInitialUpdateState = (): ExploreUpdateState => ({ + datasource: false, + queries: false, + range: false, + ui: false, +}); /** * Returns a fresh Explore area state */ @@ -76,6 +87,8 @@ export const makeExploreItemState = (): ExploreItemState => ({ supportsLogs: null, supportsTable: null, queryKeys: [], + urlState: null, + update: makeInitialUpdateState(), }); /** @@ -195,6 +208,7 @@ export const itemReducer = reducerFactory({} as ExploreItemSta initialized: true, queryKeys: getQueryKeys(queries, state.datasourceInstance), ...ui, + update: makeInitialUpdateState(), }; }, }) @@ -208,13 +222,23 @@ export const itemReducer = reducerFactory({} as ExploreItemSta .addMapper({ filter: loadDatasourceFailureAction, mapper: (state, action): ExploreItemState => { - return { ...state, datasourceError: action.payload.error, datasourceLoading: false }; + return { + ...state, + datasourceError: action.payload.error, + datasourceLoading: false, + update: makeInitialUpdateState(), + }; }, }) .addMapper({ filter: loadDatasourceMissingAction, mapper: (state): ExploreItemState => { - return { ...state, datasourceMissing: true, datasourceLoading: false }; + return { + ...state, + datasourceMissing: true, + datasourceLoading: false, + update: makeInitialUpdateState(), + }; }, }) .addMapper({ @@ -253,6 +277,7 @@ export const itemReducer = reducerFactory({} as ExploreItemSta datasourceError: null, logsHighlighterExpressions: undefined, queryTransactions: [], + update: makeInitialUpdateState(), }; }, }) @@ -262,7 +287,7 @@ export const itemReducer = reducerFactory({} as ExploreItemSta const { queries, queryTransactions } = state; const { modification, index, modifier } = action.payload; let nextQueries: DataQuery[]; - let nextQueryTransactions; + let nextQueryTransactions: QueryTransaction[]; if (index === undefined) { // Modify all queries nextQueries = queries.map((query, i) => ({ @@ -303,7 +328,12 @@ export const itemReducer = reducerFactory({} as ExploreItemSta filter: queryTransactionFailureAction, mapper: (state, action): ExploreItemState => { const { queryTransactions } = action.payload; - return { ...state, queryTransactions, showingStartPage: false }; + return { + ...state, + queryTransactions, + showingStartPage: false, + update: makeInitialUpdateState(), + }; }, }) .addMapper({ @@ -319,7 +349,12 @@ export const itemReducer = reducerFactory({} as ExploreItemSta // Append new transaction const nextQueryTransactions: QueryTransaction[] = [...remainingTransactions, transaction]; - return { ...state, queryTransactions: nextQueryTransactions, showingStartPage: false }; + return { + ...state, + queryTransactions: nextQueryTransactions, + showingStartPage: false, + update: makeInitialUpdateState(), + }; }, }) .addMapper({ @@ -333,7 +368,14 @@ export const itemReducer = reducerFactory({} as ExploreItemSta queryIntervals.intervalMs ); - return { ...state, ...results, history, queryTransactions, showingStartPage: false }; + return { + ...state, + ...results, + history, + queryTransactions, + showingStartPage: false, + update: makeInitialUpdateState(), + }; }, }) .addMapper({ @@ -367,12 +409,6 @@ export const itemReducer = reducerFactory({} as ExploreItemSta }; }, }) - .addMapper({ - filter: runQueriesEmptyAction, - mapper: (state): ExploreItemState => { - return { ...state, queryTransactions: [] }; - }, - }) .addMapper({ filter: scanRangeAction, mapper: (state, action): ExploreItemState => { @@ -396,6 +432,7 @@ export const itemReducer = reducerFactory({} as ExploreItemSta scanning: false, scanRange: undefined, scanner: undefined, + update: makeInitialUpdateState(), }; }, }) @@ -482,6 +519,41 @@ export const itemReducer = reducerFactory({} as ExploreItemSta }) .create(); +export const updateChildRefreshState = ( + state: Readonly, + payload: LocationUpdate, + exploreId: ExploreId +): ExploreItemState => { + const path = payload.path || ''; + const queryState = payload.query[exploreId] as string; + if (!queryState) { + return state; + } + + const urlState = parseUrlState(queryState); + if (!state.urlState || path !== '/explore') { + // we only want to refresh when browser back/forward + return { ...state, urlState, update: { datasource: false, queries: false, range: false, ui: false } }; + } + + const datasource = _.isEqual(urlState ? urlState.datasource : '', state.urlState.datasource) === false; + const queries = _.isEqual(urlState ? urlState.queries : [], state.urlState.queries) === false; + const range = _.isEqual(urlState ? urlState.range : DEFAULT_RANGE, state.urlState.range) === false; + const ui = _.isEqual(urlState ? urlState.ui : DEFAULT_UI_STATE, state.urlState.ui) === false; + + return { + ...state, + urlState, + update: { + ...state.update, + datasource, + queries, + range, + ui, + }, + }; +}; + /** * Global Explore reducer that handles multiple Explore areas (left and right). * Actions that have an `exploreId` get routed to the ExploreItemReducer. @@ -493,16 +565,30 @@ export const exploreReducer = (state = initialExploreState, action: HigherOrderA } case ActionTypes.SplitOpen: { - return { ...state, split: true, right: action.payload.itemState }; - } - - case ActionTypes.InitializeExploreSplit: { - return { ...state, split: true }; + return { ...state, split: true, right: { ...action.payload.itemState } }; } case ActionTypes.ResetExplore: { return initialExploreState; } + + case updateLocation.type: { + const { query } = action.payload; + if (!query || !query[ExploreId.left]) { + return state; + } + + const split = query[ExploreId.right] ? true : false; + const leftState = state[ExploreId.left]; + const rightState = state[ExploreId.right]; + + return { + ...state, + split, + [ExploreId.left]: updateChildRefreshState(leftState, action.payload, ExploreId.left), + [ExploreId.right]: updateChildRefreshState(rightState, action.payload, ExploreId.right), + }; + } } if (action.payload) { diff --git a/public/app/plugins/datasource/graphite/gfunc.ts b/public/app/plugins/datasource/graphite/gfunc.ts index 20cb884d617..15cb79f00b5 100644 --- a/public/app/plugins/datasource/graphite/gfunc.ts +++ b/public/app/plugins/datasource/graphite/gfunc.ts @@ -1,5 +1,6 @@ import _ from 'lodash'; import { isVersionGtOrEq } from 'app/core/utils/version'; +import { InterpolateFunction } from '@grafana/ui'; const index = {}; @@ -961,24 +962,30 @@ export class FuncInstance { this.updateText(); } - render(metricExp) { + render(metricExp: string, replaceVariables: InterpolateFunction): string { const str = this.def.name + '('; const parameters = _.map(this.params, (value, index) => { + const valueInterpolated = replaceVariables(value); let paramType; + if (index < this.def.params.length) { paramType = this.def.params[index].type; } else if (_.get(_.last(this.def.params), 'multiple')) { paramType = _.get(_.last(this.def.params), 'type'); } + // param types that should never be quoted if (_.includes(['value_or_series', 'boolean', 'int', 'float', 'node'], paramType)) { return value; } + // param types that might be quoted - if (_.includes(['int_or_interval', 'node_or_tag'], paramType) && _.isFinite(+value)) { - return _.toString(+value); + // To quote variables correctly we need to interpolate it to check if it contains a numeric or string value + if (_.includes(['int_or_interval', 'node_or_tag'], paramType) && _.isFinite(+valueInterpolated)) { + return _.toString(value); } + return "'" + value + "'"; }); diff --git a/public/app/plugins/datasource/graphite/graphite_query.ts b/public/app/plugins/datasource/graphite/graphite_query.ts index 6e863958b78..adbcde69ad7 100644 --- a/public/app/plugins/datasource/graphite/graphite_query.ts +++ b/public/app/plugins/datasource/graphite/graphite_query.ts @@ -18,6 +18,7 @@ export default class GraphiteQuery { constructor(datasource, target, templateSrv?, scopedVars?) { this.datasource = datasource; this.target = target; + this.templateSrv = templateSrv; this.parseTarget(); this.removeTagValue = '-- remove tag --'; @@ -160,7 +161,10 @@ export default class GraphiteQuery { } updateModelTarget(targets) { - // render query + const wrapFunction = (target: string, func: any) => { + return func.render(target, this.templateSrv.replace); + }; + if (!this.target.textEditor) { const metricPath = this.getSegmentPathUpTo(this.segments.length).replace(/\.select metric$/, ''); this.target.target = _.reduce(this.functions, wrapFunction, metricPath); @@ -302,10 +306,6 @@ export default class GraphiteQuery { } } -function wrapFunction(target, func) { - return func.render(target); -} - function renderTagString(tag) { return tag.key + tag.operator + tag.value; } diff --git a/public/app/plugins/datasource/graphite/specs/gfunc.test.ts b/public/app/plugins/datasource/graphite/specs/gfunc.test.ts index 1809adc0940..a1d888cea33 100644 --- a/public/app/plugins/datasource/graphite/specs/gfunc.test.ts +++ b/public/app/plugins/datasource/graphite/specs/gfunc.test.ts @@ -30,66 +30,92 @@ describe('when creating func instance from func names', () => { }); }); +function replaceVariablesDummy(str: string) { + return str; +} + describe('when rendering func instance', () => { it('should handle single metric param', () => { const func = gfunc.createFuncInstance('sumSeries'); - expect(func.render('hello.metric')).toEqual('sumSeries(hello.metric)'); + expect(func.render('hello.metric', replaceVariablesDummy)).toEqual('sumSeries(hello.metric)'); }); it('should include default params if options enable it', () => { const func = gfunc.createFuncInstance('scaleToSeconds', { withDefaultParams: true, }); - expect(func.render('hello')).toEqual('scaleToSeconds(hello, 1)'); + expect(func.render('hello', replaceVariablesDummy)).toEqual('scaleToSeconds(hello, 1)'); }); it('should handle int or interval params with number', () => { const func = gfunc.createFuncInstance('movingMedian'); func.params[0] = '5'; - expect(func.render('hello')).toEqual('movingMedian(hello, 5)'); + expect(func.render('hello', replaceVariablesDummy)).toEqual('movingMedian(hello, 5)'); }); it('should handle int or interval params with interval string', () => { const func = gfunc.createFuncInstance('movingMedian'); func.params[0] = '5min'; - expect(func.render('hello')).toEqual("movingMedian(hello, '5min')"); + expect(func.render('hello', replaceVariablesDummy)).toEqual("movingMedian(hello, '5min')"); }); it('should never quote boolean paramater', () => { const func = gfunc.createFuncInstance('sortByName'); func.params[0] = '$natural'; - expect(func.render('hello')).toEqual('sortByName(hello, $natural)'); + expect(func.render('hello', replaceVariablesDummy)).toEqual('sortByName(hello, $natural)'); }); it('should never quote int paramater', () => { const func = gfunc.createFuncInstance('maximumAbove'); func.params[0] = '$value'; - expect(func.render('hello')).toEqual('maximumAbove(hello, $value)'); + expect(func.render('hello', replaceVariablesDummy)).toEqual('maximumAbove(hello, $value)'); }); it('should never quote node paramater', () => { const func = gfunc.createFuncInstance('aliasByNode'); func.params[0] = '$node'; - expect(func.render('hello')).toEqual('aliasByNode(hello, $node)'); + expect(func.render('hello', replaceVariablesDummy)).toEqual('aliasByNode(hello, $node)'); }); it('should handle metric param and int param and string param', () => { const func = gfunc.createFuncInstance('groupByNode'); func.params[0] = 5; func.params[1] = 'avg'; - expect(func.render('hello.metric')).toEqual("groupByNode(hello.metric, 5, 'avg')"); + expect(func.render('hello.metric', replaceVariablesDummy)).toEqual("groupByNode(hello.metric, 5, 'avg')"); }); it('should handle function with no metric param', () => { const func = gfunc.createFuncInstance('randomWalk'); func.params[0] = 'test'; - expect(func.render(undefined)).toEqual("randomWalk('test')"); + expect(func.render(undefined, replaceVariablesDummy)).toEqual("randomWalk('test')"); }); it('should handle function multiple series params', () => { const func = gfunc.createFuncInstance('asPercent'); func.params[0] = '#B'; - expect(func.render('#A')).toEqual('asPercent(#A, #B)'); + expect(func.render('#A', replaceVariablesDummy)).toEqual('asPercent(#A, #B)'); + }); + + it('should not quote variables that have numeric value', () => { + const func = gfunc.createFuncInstance('movingAverage'); + func.params[0] = '$variable'; + + const replaceVariables = (str: string) => { + return str.replace('$variable', '60'); + }; + + expect(func.render('metric', replaceVariables)).toBe('movingAverage(metric, $variable)'); + }); + + it('should quote variables that have string value', () => { + const func = gfunc.createFuncInstance('movingAverage'); + func.params[0] = '$variable'; + + const replaceVariables = (str: string) => { + return str.replace('$variable', '10min'); + }; + + expect(func.render('metric', replaceVariables)).toBe("movingAverage(metric, '$variable')"); }); }); diff --git a/public/app/plugins/datasource/graphite/specs/graphite_query.test.ts b/public/app/plugins/datasource/graphite/specs/graphite_query.test.ts index 311dfec333a..241a21ab40d 100644 --- a/public/app/plugins/datasource/graphite/specs/graphite_query.test.ts +++ b/public/app/plugins/datasource/graphite/specs/graphite_query.test.ts @@ -1,5 +1,6 @@ import gfunc from '../gfunc'; import GraphiteQuery from '../graphite_query'; +import { TemplateSrvStub } from 'test/specs/helpers'; describe('Graphite query model', () => { const ctx: any = { @@ -9,7 +10,7 @@ describe('Graphite query model', () => { waitForFuncDefsLoaded: jest.fn().mockReturnValue(Promise.resolve(null)), createFuncInstance: gfunc.createFuncInstance, }, - templateSrv: {}, + templateSrv: new TemplateSrvStub(), targets: [], }; diff --git a/public/app/plugins/datasource/graphite/specs/query_ctrl.test.ts b/public/app/plugins/datasource/graphite/specs/query_ctrl.test.ts index d69f5235832..cb88c697d7d 100644 --- a/public/app/plugins/datasource/graphite/specs/query_ctrl.test.ts +++ b/public/app/plugins/datasource/graphite/specs/query_ctrl.test.ts @@ -1,6 +1,7 @@ import { uiSegmentSrv } from 'app/core/services/segment_srv'; import gfunc from '../gfunc'; import { GraphiteQueryCtrl } from '../query_ctrl'; +import { TemplateSrvStub } from 'test/specs/helpers'; describe('GraphiteQueryCtrl', () => { const ctx = { @@ -30,7 +31,7 @@ describe('GraphiteQueryCtrl', () => { {}, {}, new uiSegmentSrv({ trustAsHtml: html => html }, { highlightVariablesAsHtml: () => {} }), - {}, + new TemplateSrvStub(), {} ); }); @@ -291,7 +292,7 @@ describe('GraphiteQueryCtrl', () => { ctx.ctrl.target.target = "seriesByTag('tag1=value1', 'tag2!=~value2')"; ctx.ctrl.datasource.metricFindQuery = () => Promise.resolve([{ expandable: false }]); ctx.ctrl.parseTarget(); - ctx.ctrl.removeTag(0); + ctx.ctrl.tagChanged({ key: ctx.ctrl.removeTagValue }); }); it('should update tags', () => { diff --git a/public/app/plugins/datasource/prometheus/datasource.ts b/public/app/plugins/datasource/prometheus/datasource.ts index d9855a4925a..432a7fd6717 100644 --- a/public/app/plugins/datasource/prometheus/datasource.ts +++ b/public/app/plugins/datasource/prometheus/datasource.ts @@ -15,7 +15,7 @@ import { expandRecordingRules } from './language_utils'; // Types import { PromQuery } from './types'; -import { DataQueryOptions, DataSourceApi } from '@grafana/ui/src/types'; +import { DataQueryOptions, DataSourceApi, AnnotationEvent } from '@grafana/ui/src/types'; import { ExploreUrlState } from 'app/types/explore'; export class PrometheusDatasource implements DataSourceApi { @@ -355,10 +355,11 @@ export class PrometheusDatasource implements DataSourceApi { }) .value(); + const dupCheck = {}; for (const value of series.values) { const valueIsTrue = value[1] === '1'; // e.g. ALERTS if (valueIsTrue || annotation.useValueForTime) { - const event = { + const event: AnnotationEvent = { annotation: annotation, title: self.resultTransformer.renderTemplate(titleFormat, series.metric), tags: tags, @@ -366,9 +367,14 @@ export class PrometheusDatasource implements DataSourceApi { }; if (annotation.useValueForTime) { - event['time'] = Math.floor(parseFloat(value[1])); + const timestampValue = Math.floor(parseFloat(value[1])); + if (dupCheck[timestampValue]) { + continue; + } + dupCheck[timestampValue] = true; + event.time = timestampValue; } else { - event['time'] = Math.floor(parseFloat(value[0])) * 1000; + event.time = Math.floor(parseFloat(value[0])) * 1000; } eventList.push(event); diff --git a/public/app/plugins/panel/singlestat/module.ts b/public/app/plugins/panel/singlestat/module.ts index 5b75de44949..bfe9db835c4 100644 --- a/public/app/plugins/panel/singlestat/module.ts +++ b/public/app/plugins/panel/singlestat/module.ts @@ -3,6 +3,7 @@ import $ from 'jquery'; import 'vendor/flot/jquery.flot'; import 'vendor/flot/jquery.flot.gauge'; import 'app/features/panel/panellinks/link_srv'; +import { getDecimalsForValue } from '@grafana/ui'; import kbn from 'app/core/utils/kbn'; import config from 'app/core/config'; @@ -190,7 +191,7 @@ class SingleStatCtrl extends MetricsPanelCtrl { data.value = 0; data.valueRounded = 0; } else { - const decimalInfo = this.getDecimalsForValue(data.value); + const decimalInfo = getDecimalsForValue(data.value); const formatFunc = getValueFormat(this.panel.format); data.valueFormatted = formatFunc( @@ -243,47 +244,6 @@ class SingleStatCtrl extends MetricsPanelCtrl { this.render(); } - getDecimalsForValue(value) { - if (_.isNumber(this.panel.decimals)) { - return { decimals: this.panel.decimals, scaledDecimals: null }; - } - - const delta = value / 2; - let dec = -Math.floor(Math.log(delta) / Math.LN10); - - const magn = Math.pow(10, -dec); - const norm = delta / magn; // norm is between 1.0 and 10.0 - let size; - - if (norm < 1.5) { - size = 1; - } else if (norm < 3) { - size = 2; - // special case for 2.5, requires an extra decimal - if (norm > 2.25) { - size = 2.5; - ++dec; - } - } else if (norm < 7.5) { - size = 5; - } else { - size = 10; - } - - size *= magn; - - // reduce starting decimals if not needed - if (Math.floor(value) === value) { - dec = 0; - } - - const result: any = {}; - result.decimals = Math.max(0, dec); - result.scaledDecimals = result.decimals - Math.floor(Math.log(size) / Math.LN10) + 2; - - return result; - } - setValues(data) { data.flotpairs = []; @@ -319,15 +279,17 @@ class SingleStatCtrl extends MetricsPanelCtrl { data.value = this.series[0].stats[this.panel.valueName]; data.flotpairs = this.series[0].flotpairs; - const decimalInfo = this.getDecimalsForValue(data.value); + let decimals = this.panel.decimals; + let scaledDecimals = 0; - data.valueFormatted = formatFunc( - data.value, - decimalInfo.decimals, - decimalInfo.scaledDecimals, - this.dashboard.isTimezoneUtc() - ); - data.valueRounded = kbn.roundValue(data.value, decimalInfo.decimals); + if (!this.panel.decimals) { + const decimalInfo = getDecimalsForValue(data.value); + decimals = decimalInfo.decimals; + scaledDecimals = decimalInfo.scaledDecimals; + } + + data.valueFormatted = formatFunc(data.value, decimals, scaledDecimals, this.dashboard.isTimezoneUtc()); + data.valueRounded = kbn.roundValue(data.value, decimals); } // Add $__name variable for using in prefix or postfix diff --git a/public/app/types/explore.ts b/public/app/types/explore.ts index 27894200e51..aedd417b7d8 100644 --- a/public/app/types/explore.ts +++ b/public/app/types/explore.ts @@ -248,6 +248,17 @@ export interface ExploreItemState { * Currently hidden log series */ hiddenLogLevels?: LogLevel[]; + + urlState: ExploreUrlState; + + update: ExploreUpdateState; +} + +export interface ExploreUpdateState { + datasource: boolean; + queries: boolean; + range: boolean; + ui: boolean; } export interface ExploreUIState { diff --git a/public/test/core/thunk/thunkTester.ts b/public/test/core/thunk/thunkTester.ts new file mode 100644 index 00000000000..8315338c412 --- /dev/null +++ b/public/test/core/thunk/thunkTester.ts @@ -0,0 +1,64 @@ +import configureMockStore from 'redux-mock-store'; +import thunk from 'redux-thunk'; +import { ActionOf } from 'app/core/redux/actionCreatorFactory'; + +const mockStore = configureMockStore([thunk]); + +export interface ThunkGiven { + givenThunk: (thunkFunction: any) => ThunkWhen; +} + +export interface ThunkWhen { + whenThunkIsDispatched: (...args: any) => ThunkThen; +} + +export interface ThunkThen { + thenDispatchedActionsEqual: (actions: Array>) => ThunkWhen; + thenDispatchedActionsAreEqual: (callback: (actions: Array>) => boolean) => ThunkWhen; + thenThereAreNoDispatchedActions: () => ThunkWhen; +} + +export const thunkTester = (initialState: any): ThunkGiven => { + const store = mockStore(initialState); + let thunkUnderTest = null; + + const givenThunk = (thunkFunction: any): ThunkWhen => { + thunkUnderTest = thunkFunction; + + return instance; + }; + + function whenThunkIsDispatched(...args: any): ThunkThen { + store.dispatch(thunkUnderTest(...arguments)); + + return instance; + } + + const thenDispatchedActionsEqual = (actions: Array>): ThunkWhen => { + const resultingActions = store.getActions(); + expect(resultingActions).toEqual(actions); + + return instance; + }; + + const thenDispatchedActionsAreEqual = (callback: (dispathedActions: Array>) => boolean): ThunkWhen => { + const resultingActions = store.getActions(); + expect(callback(resultingActions)).toBe(true); + + return instance; + }; + + const thenThereAreNoDispatchedActions = () => { + return thenDispatchedActionsEqual([]); + }; + + const instance = { + givenThunk, + whenThunkIsDispatched, + thenDispatchedActionsEqual, + thenDispatchedActionsAreEqual, + thenThereAreNoDispatchedActions, + }; + + return instance; +}; diff --git a/public/test/specs/helpers.ts b/public/test/specs/helpers.ts index f9124773c97..8710754f04e 100644 --- a/public/test/specs/helpers.ts +++ b/public/test/specs/helpers.ts @@ -172,7 +172,7 @@ export function TemplateSrvStub(this: any) { this.variables = []; this.templateSettings = { interpolate: /\[\[([\s\S]+?)\]\]/g }; this.data = {}; - this.replace = function(text: string) { + this.replace = (text: string) => { return _.template(text, this.templateSettings)(this.data); }; this.init = () => {}; diff --git a/scripts/grunt/options/compress.js b/scripts/grunt/options/compress.js index 943a42bb571..bdd2feb851a 100644 --- a/scripts/grunt/options/compress.js +++ b/scripts/grunt/options/compress.js @@ -15,7 +15,7 @@ module.exports = function(config) { }, { expand: true, - src: ['LICENSE.md', 'README.md', 'NOTICE.md'], + src: ['LICENSE', 'README.md', 'NOTICE.md'], dest: '<%= pkg.name %>-<%= pkg.version %>/', } ]