diff --git a/packages/grafana-ui/src/components/CustomScrollbar/CustomScrollbar.tsx b/packages/grafana-ui/src/components/CustomScrollbar/CustomScrollbar.tsx index cf1657e1c83..519e755b474 100644 --- a/packages/grafana-ui/src/components/CustomScrollbar/CustomScrollbar.tsx +++ b/packages/grafana-ui/src/components/CustomScrollbar/CustomScrollbar.tsx @@ -6,6 +6,7 @@ interface Props { autoHide?: boolean; autoHideTimeout?: number; autoHideDuration?: number; + autoMaxHeight?: string; hideTracksWhenNotNeeded?: boolean; } @@ -18,11 +19,12 @@ export class CustomScrollbar extends PureComponent { autoHide: true, autoHideTimeout: 200, autoHideDuration: 200, + autoMaxHeight: '100%', hideTracksWhenNotNeeded: false, }; render() { - const { customClassName, children, ...scrollProps } = this.props; + const { customClassName, children, autoMaxHeight } = this.props; return ( { // These autoHeightMin & autoHeightMax options affect firefox and chrome differently. // Before these where set to inhert but that caused problems with cut of legends in firefox autoHeightMin={'0'} - autoHeightMax={'100%'} + autoHeightMax={autoMaxHeight} renderTrackHorizontal={props =>
} renderTrackVertical={props =>
} renderThumbHorizontal={props =>
} renderThumbVertical={props =>
} renderView={props =>
} - {...scrollProps} > {children} diff --git a/packages/grafana-ui/src/components/CustomScrollbar/__snapshots__/CustomScrollbar.test.tsx.snap b/packages/grafana-ui/src/components/CustomScrollbar/__snapshots__/CustomScrollbar.test.tsx.snap index 0a7de5fcffe..60b4a2e0aa5 100644 --- a/packages/grafana-ui/src/components/CustomScrollbar/__snapshots__/CustomScrollbar.test.tsx.snap +++ b/packages/grafana-ui/src/components/CustomScrollbar/__snapshots__/CustomScrollbar.test.tsx.snap @@ -42,9 +42,7 @@ exports[`CustomScrollbar renders correctly 1`] = ` Object { "display": "none", "height": 6, - "opacity": 0, "position": "absolute", - "transition": "opacity 200ms", } } > @@ -64,9 +62,7 @@ exports[`CustomScrollbar renders correctly 1`] = ` style={ Object { "display": "none", - "opacity": 0, "position": "absolute", - "transition": "opacity 200ms", "width": 6, } } diff --git a/packages/grafana-ui/src/components/FormField/__snapshots__/FormField.test.tsx.snap b/packages/grafana-ui/src/components/FormField/__snapshots__/FormField.test.tsx.snap index e88ff774981..99eb0803149 100644 --- a/packages/grafana-ui/src/components/FormField/__snapshots__/FormField.test.tsx.snap +++ b/packages/grafana-ui/src/components/FormField/__snapshots__/FormField.test.tsx.snap @@ -2,7 +2,7 @@ exports[`Render should render component 1`] = `
{ return ( - {props.children} + {props.children} ); }; diff --git a/packages/grafana-ui/src/components/ThresholdsEditor/ThresholdsEditor.test.tsx b/packages/grafana-ui/src/components/ThresholdsEditor/ThresholdsEditor.test.tsx index 14f84e00f80..845ff5f6bf4 100644 --- a/packages/grafana-ui/src/components/ThresholdsEditor/ThresholdsEditor.test.tsx +++ b/packages/grafana-ui/src/components/ThresholdsEditor/ThresholdsEditor.test.tsx @@ -2,7 +2,6 @@ import React from 'react'; import { shallow } from 'enzyme'; import { ThresholdsEditor, Props } from './ThresholdsEditor'; -import { BasicGaugeColor } from '../../types'; const setup = (propOverrides?: object) => { const props: Props = { @@ -15,49 +14,160 @@ const setup = (propOverrides?: object) => { return shallow().instance() as ThresholdsEditor; }; +describe('Initialization', () => { + it('should add a base threshold if missing', () => { + const instance = setup(); + + expect(instance.state.thresholds).toEqual([{ index: 0, value: -Infinity, color: '#7EB26D' }]); + }); +}); + describe('Add threshold', () => { - it('should add threshold', () => { + it('should not add threshold at index 0', () => { const instance = setup(); instance.onAddThreshold(0); - expect(instance.state.thresholds).toEqual([{ index: 0, value: 50, color: 'rgb(127, 115, 64)' }]); + expect(instance.state.thresholds).toEqual([{ index: 0, value: -Infinity, color: '#7EB26D' }]); }); - it('should add another threshold above a first', () => { - const instance = setup({ - thresholds: [{ index: 0, value: 50, color: 'rgb(127, 115, 64)' }], - }); + it('should add threshold', () => { + const instance = setup(); instance.onAddThreshold(1); expect(instance.state.thresholds).toEqual([ - { index: 1, value: 75, color: 'rgb(170, 95, 61)' }, - { index: 0, value: 50, color: 'rgb(127, 115, 64)' }, + { index: 1, value: 50, color: '#EAB839' }, + { index: 0, value: -Infinity, color: '#7EB26D' }, + ]); + }); + + it('should add another threshold above a first', () => { + const instance = setup({ + thresholds: [{ index: 0, value: -Infinity, color: '#7EB26D' }, { index: 1, value: 50, color: '#EAB839' }], + }); + + instance.onAddThreshold(2); + + expect(instance.state.thresholds).toEqual([ + { index: 2, value: 75, color: '#6ED0E0' }, + { index: 1, value: 50, color: '#EAB839' }, + { index: 0, value: -Infinity, color: '#7EB26D' }, + ]); + }); + + it('should add another threshold between first and second index', () => { + const instance = setup({ + thresholds: [ + { index: 0, value: -Infinity, color: '#7EB26D' }, + { index: 1, value: 50, color: '#EAB839' }, + { index: 2, value: 75, color: '#6ED0E0' }, + ], + }); + + instance.onAddThreshold(2); + + expect(instance.state.thresholds).toEqual([ + { index: 3, value: 75, color: '#6ED0E0' }, + { index: 2, value: 62.5, color: '#EF843C' }, + { index: 1, value: 50, color: '#EAB839' }, + { index: 0, value: -Infinity, color: '#7EB26D' }, + ]); + }); +}); + +describe('Remove threshold', () => { + it('should not remove threshold at index 0', () => { + const thresholds = [ + { index: 0, value: -Infinity, color: '#7EB26D' }, + { index: 1, value: 50, color: '#EAB839' }, + { index: 2, value: 75, color: '#6ED0E0' }, + ]; + const instance = setup({ thresholds }); + + instance.onRemoveThreshold(thresholds[0]); + + expect(instance.state.thresholds).toEqual(thresholds); + }); + + it('should remove threshold', () => { + const thresholds = [ + { index: 0, value: -Infinity, color: '#7EB26D' }, + { index: 1, value: 50, color: '#EAB839' }, + { index: 2, value: 75, color: '#6ED0E0' }, + ]; + const instance = setup({ + thresholds, + }); + + instance.onRemoveThreshold(thresholds[1]); + + expect(instance.state.thresholds).toEqual([ + { index: 0, value: -Infinity, color: '#7EB26D' }, + { index: 1, value: 75, color: '#6ED0E0' }, ]); }); }); describe('change threshold value', () => { - it('should update value and resort rows', () => { + it('should not change threshold at index 0', () => { + const thresholds = [ + { index: 0, value: -Infinity, color: '#7EB26D' }, + { index: 1, value: 50, color: '#EAB839' }, + { index: 2, value: 75, color: '#6ED0E0' }, + ]; + const instance = setup({ thresholds }); + + const mockEvent = { target: { value: 12 } }; + + instance.onChangeThresholdValue(mockEvent, thresholds[0]); + + expect(instance.state.thresholds).toEqual(thresholds); + }); + + it('should update value', () => { const instance = setup(); - const mockThresholds = [ - { index: 0, value: 50, color: 'rgba(237, 129, 40, 0.89)' }, - { index: 1, value: 75, color: 'rgba(237, 129, 40, 0.89)' }, + const thresholds = [ + { index: 0, value: -Infinity, color: '#7EB26D' }, + { index: 1, value: 50, color: '#EAB839' }, + { index: 2, value: 75, color: '#6ED0E0' }, ]; instance.state = { - baseColor: BasicGaugeColor.Green, - thresholds: mockThresholds, + thresholds, }; const mockEvent = { target: { value: 78 } }; - instance.onChangeThresholdValue(mockEvent, mockThresholds[0]); + instance.onChangeThresholdValue(mockEvent, thresholds[1]); expect(instance.state.thresholds).toEqual([ - { index: 0, value: 78, color: 'rgba(237, 129, 40, 0.89)' }, - { index: 1, value: 75, color: 'rgba(237, 129, 40, 0.89)' }, + { index: 0, value: -Infinity, color: '#7EB26D' }, + { index: 1, value: 78, color: '#EAB839' }, + { index: 2, value: 75, color: '#6ED0E0' }, + ]); + }); +}); + +describe('on blur threshold value', () => { + it('should resort rows and update indexes', () => { + const instance = setup(); + const thresholds = [ + { index: 0, value: -Infinity, color: '#7EB26D' }, + { index: 1, value: 78, color: '#EAB839' }, + { index: 2, value: 75, color: '#6ED0E0' }, + ]; + + instance.state = { + thresholds, + }; + + instance.onBlur(); + + expect(instance.state.thresholds).toEqual([ + { index: 2, value: 78, color: '#EAB839' }, + { index: 1, value: 75, color: '#6ED0E0' }, + { index: 0, value: -Infinity, color: '#7EB26D' }, ]); }); }); diff --git a/packages/grafana-ui/src/components/ThresholdsEditor/ThresholdsEditor.tsx b/packages/grafana-ui/src/components/ThresholdsEditor/ThresholdsEditor.tsx index c635b9cb4f5..6e8d88051f3 100644 --- a/packages/grafana-ui/src/components/ThresholdsEditor/ThresholdsEditor.tsx +++ b/packages/grafana-ui/src/components/ThresholdsEditor/ThresholdsEditor.tsx @@ -1,9 +1,10 @@ import React, { PureComponent } from 'react'; -import tinycolor, { ColorInput } from 'tinycolor2'; +// import tinycolor, { ColorInput } from 'tinycolor2'; -import { Threshold, BasicGaugeColor } from '../../types'; +import { Threshold } from '../../types'; import { ColorPicker } from '../ColorPicker/ColorPicker'; import { PanelOptionsGroup } from '../PanelOptionsGroup/PanelOptionsGroup'; +import { colors } from '../../utils'; export interface Props { thresholds: Threshold[]; @@ -12,50 +13,43 @@ export interface Props { interface State { thresholds: Threshold[]; - baseColor: string; } export class ThresholdsEditor extends PureComponent { constructor(props: Props) { super(props); - this.state = { thresholds: props.thresholds, baseColor: BasicGaugeColor.Green }; + const thresholds: Threshold[] = + props.thresholds.length > 0 ? props.thresholds : [{ index: 0, value: -Infinity, color: colors[0] }]; + this.state = { thresholds }; } onAddThreshold = (index: number) => { - const maxValue = 100; // hardcoded for now before we add the base threshold - const minValue = 0; // hardcoded for now before we add the base threshold const { thresholds } = this.state; + const maxValue = 100; + const minValue = 0; + + if (index === 0) { + return; + } const newThresholds = thresholds.map(threshold => { if (threshold.index >= index) { - threshold = { - ...threshold, - index: threshold.index + 1, - }; + const index = threshold.index + 1; + threshold = { ...threshold, index }; } - return threshold; }); // Setting value to a value between the previous thresholds - let value; + const beforeThreshold = newThresholds.filter(t => t.index === index - 1 && t.index !== 0)[0]; + const afterThreshold = newThresholds.filter(t => t.index === index + 1 && t.index !== 0)[0]; + const beforeThresholdValue = beforeThreshold !== undefined ? beforeThreshold.value : minValue; + const afterThresholdValue = afterThreshold !== undefined ? afterThreshold.value : maxValue; + const value = afterThresholdValue - (afterThresholdValue - beforeThresholdValue) / 2; - if (index === 0 && thresholds.length === 0) { - value = maxValue - (maxValue - minValue) / 2; - } else if (index === 0 && thresholds.length > 0) { - value = newThresholds[index + 1].value - (newThresholds[index + 1].value - minValue) / 2; - } else if (index > newThresholds[newThresholds.length - 1].index) { - value = maxValue - (maxValue - newThresholds[index - 1].value) / 2; - } - - // Set a color that lies between the previous thresholds - let color; - if (index === 0 && thresholds.length === 0) { - color = tinycolor.mix(BasicGaugeColor.Green, BasicGaugeColor.Red, 50).toRgbString(); - } else { - color = tinycolor.mix(thresholds[index - 1].color as ColorInput, BasicGaugeColor.Red, 50).toRgbString(); - } + // Set a color + const color = colors.filter(c => newThresholds.some(t => t.color === c) === false)[0]; this.setState( { @@ -73,18 +67,40 @@ export class ThresholdsEditor extends PureComponent { }; onRemoveThreshold = (threshold: Threshold) => { + if (threshold.index === 0) { + return; + } + this.setState( - prevState => ({ thresholds: prevState.thresholds.filter(t => t !== threshold) }), + prevState => { + const newThresholds = prevState.thresholds.map(t => { + if (t.index > threshold.index) { + const index = t.index - 1; + t = { ...t, index }; + } + return t; + }); + + return { + thresholds: newThresholds.filter(t => t !== threshold), + }; + }, () => this.updateGauge() ); }; onChangeThresholdValue = (event: any, threshold: Threshold) => { + if (threshold.index === 0) { + return; + } + const { thresholds } = this.state; + const parsedValue = parseInt(event.target.value, 10); + const value = isNaN(parsedValue) ? null : parsedValue; const newThresholds = thresholds.map(t => { if (t === threshold) { - t = { ...t, value: event.target.value }; + t = { ...t, value: value as number }; } return t; @@ -114,7 +130,14 @@ export class ThresholdsEditor extends PureComponent { onChangeBaseColor = (color: string) => this.props.onChange(this.state.thresholds); onBlur = () => { - this.setState(prevState => ({ thresholds: this.sortThresholds(prevState.thresholds) })); + this.setState(prevState => { + const sortThresholds = this.sortThresholds([...prevState.thresholds]); + let index = sortThresholds.length - 1; + sortThresholds.forEach(t => { + t.index = index--; + }); + return { thresholds: sortThresholds }; + }); this.updateGauge(); }; @@ -129,92 +152,53 @@ export class ThresholdsEditor extends PureComponent { }); }; - renderThresholds() { - const { thresholds } = this.state; - - return thresholds.map((threshold, index) => { - return ( -
-
-
- {threshold.color && ( -
- this.onChangeThresholdColor(threshold, color)} - /> -
- )} -
- this.onChangeThresholdValue(event, threshold)} - value={threshold.value} - onBlur={this.onBlur} - /> -
this.onRemoveThreshold(threshold)} className="threshold-row-remove"> - -
-
-
- ); - }); - } - - renderIndicator() { - const { thresholds } = this.state; - - return thresholds.map((t, i) => { - return ( -
-
this.onAddThreshold(t.index + 1)} style={{ height: '50%', backgroundColor: t.color }} /> -
this.onAddThreshold(t.index)} style={{ height: '50%', backgroundColor: t.color }} /> -
- ); - }); - } - - renderBaseIndicator() { + renderInput = (threshold: Threshold) => { + const value = threshold.index === 0 ? 'Base' : threshold.value; return ( -
-
this.onAddThreshold(0)} - style={{ height: '100%', backgroundColor: BasicGaugeColor.Green }} - /> +
+ +
+ {threshold.color && ( +
+ this.onChangeThresholdColor(threshold, color)} /> +
+ )} +
+
+ this.onChangeThresholdValue(event, threshold)} + value={value} + onBlur={this.onBlur} + readOnly={threshold.index === 0} + /> +
+ {threshold.index > 0 && ( +
this.onRemoveThreshold(threshold)}> + +
+ )}
); - } - - renderBase() { - const baseColor = BasicGaugeColor.Green; - - return ( -
-
-
-
- this.onChangeBaseColor(color)} /> -
-
-
Base
-
-
- ); - } + }; render() { + const { thresholds } = this.state; + return (
-
- {this.renderIndicator()} - {this.renderBaseIndicator()} -
-
- {this.renderThresholds()} - {this.renderBase()} -
+ {thresholds.map((threshold, index) => { + return ( +
+
this.onAddThreshold(threshold.index + 1)}> + +
+
+
{this.renderInput(threshold)}
+
+ ); + })}
); diff --git a/packages/grafana-ui/src/components/ThresholdsEditor/_ThresholdsEditor.scss b/packages/grafana-ui/src/components/ThresholdsEditor/_ThresholdsEditor.scss index ff89a6b6ea6..f95ecac46a2 100644 --- a/packages/grafana-ui/src/components/ThresholdsEditor/_ThresholdsEditor.scss +++ b/packages/grafana-ui/src/components/ThresholdsEditor/_ThresholdsEditor.scss @@ -1,46 +1,99 @@ .thresholds { + margin-bottom: 10px; +} + +.thresholds-row { display: flex; + flex-direction: row; + height: 70px; } -.threshold-rows { - margin-left: 5px; +.thresholds-row:first-child > .thresholds-row-color-indicator { + border-top-left-radius: $border-radius; + border-top-right-radius: $border-radius; + overflow: hidden; } -.threshold-row { +.thresholds-row:last-child > .thresholds-row-color-indicator { + border-bottom-left-radius: $border-radius; + border-bottom-right-radius: $border-radius; + overflow: hidden; +} + +.thresholds-row-add-button { + align-self: center; + margin-right: 5px; + color: $green; + height: 24px; + width: 24px; + background-color: $green; + border-radius: 50%; display: flex; align-items: center; - margin-top: 3px; - padding: 5px; - - &::before { - font-family: 'FontAwesome'; - content: '\f0d9'; - color: $input-label-border-color; - } + justify-content: center; + cursor: pointer; } -.threshold-row-inner { - border: 1px solid $input-label-border-color; - border-radius: $border-radius; +.thresholds-row-add-button > i { + color: $white; +} + +.thresholds-row-color-indicator { + width: 10px; +} + +.thresholds-row-input { + margin-top: 49px; + margin-left: 2px; +} + +.thresholds-row-input-inner { display: flex; - overflow: hidden; - height: 37px; - - &--base { - width: auto; - } + justify-content: center; + flex-direction: row; + height: 42px; } -.threshold-row-color { - width: 36px; - border-right: 1px solid $input-label-border-color; +.thresholds-row-input-inner > div { + border-left: 1px solid $input-label-border-color; + border-top: 1px solid $input-label-border-color; + border-bottom: 1px solid $input-label-border-color; +} + +.thresholds-row-input-inner > *:nth-child(2) { + border-top-left-radius: $border-radius; + border-bottom-left-radius: $border-radius; +} + +.thresholds-row-input-inner > *:last-child { + border-top-right-radius: $border-radius; + border-bottom-right-radius: $border-radius; +} + +.thresholds-row-input-inner-arrow { + align-self: center; + width: 0; + height: 0; + border-top: 6px solid transparent; + border-bottom: 6px solid transparent; + border-right: 6px solid $input-label-border-color; +} + +.thresholds-row-input-inner-value > input { + height: 100%; + padding: 8px 10px; + width: 150px; +} + +.thresholds-row-input-inner-color { + width: 42px; display: flex; align-items: center; justify-content: center; background-color: $input-bg; } -.threshold-row-color-inner { +.thresholds-row-input-inner-color-colorpicker { border-radius: 10px; overflow: hidden; display: flex; @@ -48,56 +101,12 @@ box-shadow: 0 1px 4px rgba(0, 0, 0, 0.25); } -.threshold-row-input { - padding: 8px 10px; - width: 150px; -} - -.threshold-row-label { - background-color: $input-label-bg; - padding: 5px; - display: flex; - align-items: center; -} - -.threshold-row-add-label { - align-items: center; - display: flex; - padding: 5px 8px; -} - -.threshold-row-remove { +.thresholds-row-input-inner-remove { display: flex; align-items: center; justify-content: center; - height: 37px; - width: 37px; + height: 42px; + width: 42px; + background-color: $input-label-border-color; cursor: pointer; } - -.threshold-row-add { - border-right: $border-width solid $input-label-border-color; - display: flex; - align-items: center; - justify-content: center; - width: 36px; - background-color: $green; -} - -.threshold-row-label { - border-top-left-radius: 0; - border-bottom-left-radius: 0; -} - -.indicator-section { - width: 100%; - height: 50px; - cursor: pointer; -} - -.color-indicators { - width: 15px; - border-bottom-left-radius: $border-radius; - border-bottom-right-radius: $border-radius; - overflow: hidden; -} diff --git a/public/app/plugins/panel/gauge/MappingRow.tsx b/packages/grafana-ui/src/components/ValueMappingsEditor/MappingRow.tsx similarity index 69% rename from public/app/plugins/panel/gauge/MappingRow.tsx rename to packages/grafana-ui/src/components/ValueMappingsEditor/MappingRow.tsx index 47647b1b9ae..3da2cfc2d98 100644 --- a/public/app/plugins/panel/gauge/MappingRow.tsx +++ b/packages/grafana-ui/src/components/ValueMappingsEditor/MappingRow.tsx @@ -1,20 +1,22 @@ -import React, { PureComponent } from 'react'; -import { FormField, Label, MappingType, RangeMap, Select, ValueMap } from '@grafana/ui'; +import React, { ChangeEvent, PureComponent } from 'react'; -interface Props { - mapping: ValueMap | RangeMap; - updateMapping: (mapping) => void; - removeMapping: () => void; +import { MappingType, ValueMapping } from '../../types'; +import { FormField, Label, Select } from '..'; + +export interface Props { + valueMapping: ValueMapping; + updateValueMapping: (valueMapping: ValueMapping) => void; + removeValueMapping: () => void; } interface State { - from: string; + from?: string; id: number; operator: string; text: string; - to: string; + to?: string; type: MappingType; - value: string; + value?: string; } const mappingOptions = [ @@ -23,36 +25,34 @@ const mappingOptions = [ ]; export default class MappingRow extends PureComponent { - constructor(props) { + constructor(props: Props) { super(props); - this.state = { - ...props.mapping, - }; + this.state = { ...props.valueMapping }; } - onMappingValueChange = event => { + onMappingValueChange = (event: ChangeEvent) => { this.setState({ value: event.target.value }); }; - onMappingFromChange = event => { + onMappingFromChange = (event: ChangeEvent) => { this.setState({ from: event.target.value }); }; - onMappingToChange = event => { + onMappingToChange = (event: ChangeEvent) => { this.setState({ to: event.target.value }); }; - onMappingTextChange = event => { + onMappingTextChange = (event: ChangeEvent) => { this.setState({ text: event.target.value }); }; - onMappingTypeChange = mappingType => { + onMappingTypeChange = (mappingType: MappingType) => { this.setState({ type: mappingType }); }; updateMapping = () => { - this.props.updateMapping({ ...this.state }); + this.props.updateValueMapping({ ...this.state } as ValueMapping); }; renderRow() { @@ -65,7 +65,7 @@ export default class MappingRow extends PureComponent { label="From" labelWidth={4} inputProps={{ - onChange: event => this.onMappingFromChange(event), + onChange: (event: ChangeEvent) => this.onMappingFromChange(event), onBlur: () => this.updateMapping(), value: from, }} @@ -76,7 +76,7 @@ export default class MappingRow extends PureComponent { labelWidth={4} inputProps={{ onBlur: () => this.updateMapping, - onChange: event => this.onMappingToChange(event), + onChange: (event: ChangeEvent) => this.onMappingToChange(event), value: to, }} inputWidth={8} @@ -101,7 +101,7 @@ export default class MappingRow extends PureComponent { labelWidth={4} inputProps={{ onBlur: () => this.updateMapping, - onChange: event => this.onMappingValueChange(event), + onChange: (event: ChangeEvent) => this.onMappingValueChange(event), value: value, }} inputWidth={8} @@ -137,7 +137,7 @@ export default class MappingRow extends PureComponent {
{this.renderRow()}
-
diff --git a/public/app/plugins/panel/gauge/ValueMappings.test.tsx b/packages/grafana-ui/src/components/ValueMappingsEditor/ValueMappingsEditor.test.tsx similarity index 59% rename from public/app/plugins/panel/gauge/ValueMappings.test.tsx rename to packages/grafana-ui/src/components/ValueMappingsEditor/ValueMappingsEditor.test.tsx index 07db4028c68..bbad3e5a7ca 100644 --- a/public/app/plugins/panel/gauge/ValueMappings.test.tsx +++ b/packages/grafana-ui/src/components/ValueMappingsEditor/ValueMappingsEditor.test.tsx @@ -1,27 +1,23 @@ import React from 'react'; import { shallow } from 'enzyme'; -import { GaugeOptions, MappingType, PanelOptionsProps } from '@grafana/ui'; -import { defaultProps } from 'app/plugins/panel/gauge/GaugePanelOptions'; -import ValueMappings from './ValueMappings'; +import { ValueMappingsEditor, Props } from './ValueMappingsEditor'; +import { MappingType } from '../../types/panel'; const setup = (propOverrides?: object) => { - const props: PanelOptionsProps = { + const props: Props = { onChange: jest.fn(), - options: { - ...defaultProps.options, - mappings: [ - { id: 1, operator: '', type: MappingType.ValueToText, value: '20', text: 'Ok' }, - { id: 2, operator: '', type: MappingType.RangeToText, from: '21', to: '30', text: 'Meh' }, - ], - }, + valueMappings: [ + { id: 1, operator: '', type: MappingType.ValueToText, value: '20', text: 'Ok' }, + { id: 2, operator: '', type: MappingType.RangeToText, from: '21', to: '30', text: 'Meh' }, + ], }; Object.assign(props, propOverrides); - const wrapper = shallow(); + const wrapper = shallow(); - const instance = wrapper.instance() as ValueMappings; + const instance = wrapper.instance() as ValueMappingsEditor; return { instance, @@ -40,18 +36,20 @@ describe('Render', () => { describe('On remove mapping', () => { it('Should remove mapping with id 0', () => { const { instance } = setup(); + instance.onRemoveMapping(1); - expect(instance.state.mappings).toEqual([ + expect(instance.state.valueMappings).toEqual([ { id: 2, operator: '', type: MappingType.RangeToText, from: '21', to: '30', text: 'Meh' }, ]); }); it('should remove mapping with id 1', () => { const { instance } = setup(); + instance.onRemoveMapping(2); - expect(instance.state.mappings).toEqual([ + expect(instance.state.valueMappings).toEqual([ { id: 1, operator: '', type: MappingType.ValueToText, value: '20', text: 'Ok' }, ]); }); @@ -67,7 +65,7 @@ describe('Next id to add', () => { }); it('should default to 1', () => { - const { instance } = setup({ options: { ...defaultProps.options } }); + const { instance } = setup({ valueMappings: [] }); expect(instance.state.nextIdToAdd).toEqual(1); }); diff --git a/public/app/plugins/panel/gauge/ValueMappings.tsx b/packages/grafana-ui/src/components/ValueMappingsEditor/ValueMappingsEditor.tsx similarity index 50% rename from public/app/plugins/panel/gauge/ValueMappings.tsx rename to packages/grafana-ui/src/components/ValueMappingsEditor/ValueMappingsEditor.tsx index 9a3f87450f4..ca0a6e71f4a 100644 --- a/public/app/plugins/panel/gauge/ValueMappings.tsx +++ b/packages/grafana-ui/src/components/ValueMappingsEditor/ValueMappingsEditor.tsx @@ -1,33 +1,39 @@ import React, { PureComponent } from 'react'; -import { GaugeOptions, PanelOptionsProps, MappingType, RangeMap, ValueMap, PanelOptionsGroup } from '@grafana/ui'; import MappingRow from './MappingRow'; +import { MappingType, ValueMapping } from '../../types/panel'; +import { PanelOptionsGroup } from '../PanelOptionsGroup/PanelOptionsGroup'; + +export interface Props { + valueMappings: ValueMapping[]; + onChange: (valueMappings: ValueMapping[]) => void; +} interface State { - mappings: Array; + valueMappings: ValueMapping[]; nextIdToAdd: number; } -export default class ValueMappings extends PureComponent, State> { - constructor(props) { +export class ValueMappingsEditor extends PureComponent { + constructor(props: Props) { super(props); - const mappings = props.options.mappings; + const mappings = props.valueMappings; this.state = { - mappings: mappings || [], - nextIdToAdd: mappings.length > 0 ? this.getMaxIdFromMappings(mappings) : 1, + valueMappings: mappings, + nextIdToAdd: mappings.length > 0 ? this.getMaxIdFromValueMappings(mappings) : 1, }; } - getMaxIdFromMappings(mappings) { + getMaxIdFromValueMappings(mappings: ValueMapping[]) { return Math.max.apply(null, mappings.map(mapping => mapping.id).map(m => m)) + 1; } addMapping = () => this.setState(prevState => ({ - mappings: [ - ...prevState.mappings, + valueMappings: [ + ...prevState.valueMappings, { id: prevState.nextIdToAdd, operator: '', @@ -41,23 +47,23 @@ export default class ValueMappings extends PureComponent { + onRemoveMapping = (id: number) => { this.setState( prevState => ({ - mappings: prevState.mappings.filter(m => { + valueMappings: prevState.valueMappings.filter(m => { return m.id !== id; }), }), () => { - this.props.onChange({ ...this.props.options, mappings: this.state.mappings }); + this.props.onChange(this.state.valueMappings); } ); }; - updateGauge = mapping => { + updateGauge = (mapping: ValueMapping) => { this.setState( prevState => ({ - mappings: prevState.mappings.map(m => { + valueMappings: prevState.valueMappings.map(m => { if (m.id === mapping.id) { return { ...mapping }; } @@ -66,24 +72,24 @@ export default class ValueMappings extends PureComponent { - this.props.onChange({ ...this.props.options, mappings: this.state.mappings }); + this.props.onChange(this.state.valueMappings); } ); }; render() { - const { mappings } = this.state; + const { valueMappings } = this.state; return (
- {mappings.length > 0 && - mappings.map((mapping, index) => ( + {valueMappings.length > 0 && + valueMappings.map((valueMapping, index) => ( this.onRemoveMapping(mapping.id)} + key={`${valueMapping.text}-${index}`} + valueMapping={valueMapping} + updateValueMapping={this.updateGauge} + removeValueMapping={() => this.onRemoveMapping(valueMapping.id)} /> ))}
diff --git a/public/sass/components/_value-mappings.scss b/packages/grafana-ui/src/components/ValueMappingsEditor/_ValueMappingsEditor.scss similarity index 100% rename from public/sass/components/_value-mappings.scss rename to packages/grafana-ui/src/components/ValueMappingsEditor/_ValueMappingsEditor.scss diff --git a/public/app/plugins/panel/gauge/__snapshots__/ValueMappings.test.tsx.snap b/packages/grafana-ui/src/components/ValueMappingsEditor/__snapshots__/ValueMappingsEditor.test.tsx.snap similarity index 81% rename from public/app/plugins/panel/gauge/__snapshots__/ValueMappings.test.tsx.snap rename to packages/grafana-ui/src/components/ValueMappingsEditor/__snapshots__/ValueMappingsEditor.test.tsx.snap index 592b3326421..8a465ff88df 100644 --- a/public/app/plugins/panel/gauge/__snapshots__/ValueMappings.test.tsx.snap +++ b/packages/grafana-ui/src/components/ValueMappingsEditor/__snapshots__/ValueMappingsEditor.test.tsx.snap @@ -7,7 +7,9 @@ exports[`Render should render component 1`] = `
; - maxValue: number; - minValue: number; - prefix: string; - showThresholdLabels: boolean; - showThresholdMarkers: boolean; - stat: string; - suffix: string; - thresholds: Threshold[]; - unit: string; -} diff --git a/packages/grafana-ui/src/types/index.ts b/packages/grafana-ui/src/types/index.ts index 814ab0478db..f618ce6db34 100644 --- a/packages/grafana-ui/src/types/index.ts +++ b/packages/grafana-ui/src/types/index.ts @@ -1,4 +1,3 @@ export * from './series'; export * from './time'; export * from './panel'; -export * from './gauge'; diff --git a/packages/grafana-ui/src/types/panel.ts b/packages/grafana-ui/src/types/panel.ts index 0b995f932f0..7e4012ad529 100644 --- a/packages/grafana-ui/src/types/panel.ts +++ b/packages/grafana-ui/src/types/panel.ts @@ -1,6 +1,8 @@ import { TimeSeries, LoadingState } from './series'; import { TimeRange } from './time'; +export type InterpolateFunction = (value: string, format?: string | Function) => string; + export interface PanelProps { timeSeries: TimeSeries[]; timeRange: TimeRange; @@ -9,6 +11,7 @@ export interface PanelProps { renderCounter: number; width: number; height: number; + onInterpolate: InterpolateFunction; } export interface PanelOptionsProps { @@ -53,6 +56,8 @@ interface BaseMap { type: MappingType; } +export type ValueMapping = ValueMap | RangeMap; + export interface ValueMap extends BaseMap { value: string; } diff --git a/pkg/components/imguploader/imguploader.go b/pkg/components/imguploader/imguploader.go index 93f69cadd46..422a03d3501 100644 --- a/pkg/components/imguploader/imguploader.go +++ b/pkg/components/imguploader/imguploader.go @@ -6,7 +6,6 @@ import ( "regexp" "github.com/grafana/grafana/pkg/log" - "github.com/grafana/grafana/pkg/setting" ) @@ -21,6 +20,10 @@ func (NopImageUploader) Upload(ctx context.Context, path string) (string, error) return "", nil } +var ( + logger = log.New("imguploader") +) + func NewImageUploader() (ImageUploader, error) { switch setting.ImageUploadProvider { @@ -94,7 +97,7 @@ func NewImageUploader() (ImageUploader, error) { } if setting.ImageUploadProvider != "" { - log.Error2("The external image storage configuration is invalid", "unsupported provider", setting.ImageUploadProvider) + logger.Error("The external image storage configuration is invalid", "unsupported provider", setting.ImageUploadProvider) } return NopImageUploader{}, nil diff --git a/pkg/log/log.go b/pkg/log/log.go index 8f0522748ef..2e3b6303a6e 100644 --- a/pkg/log/log.go +++ b/pkg/log/log.go @@ -10,13 +10,11 @@ import ( "path/filepath" "strings" - "gopkg.in/ini.v1" - "github.com/go-stack/stack" + "github.com/grafana/grafana/pkg/util" "github.com/inconshreveable/log15" isatty "github.com/mattn/go-isatty" - - "github.com/grafana/grafana/pkg/util" + "gopkg.in/ini.v1" ) var Root log15.Logger @@ -58,10 +56,6 @@ func Debug(format string, v ...interface{}) { Root.Debug(message) } -func Debug2(message string, v ...interface{}) { - Root.Debug(message, v...) -} - func Info(format string, v ...interface{}) { var message string if len(v) > 0 { @@ -73,10 +67,6 @@ func Info(format string, v ...interface{}) { Root.Info(message) } -func Info2(message string, v ...interface{}) { - Root.Info(message, v...) -} - func Warn(format string, v ...interface{}) { var message string if len(v) > 0 { @@ -88,18 +78,10 @@ func Warn(format string, v ...interface{}) { Root.Warn(message) } -func Warn2(message string, v ...interface{}) { - Root.Warn(message, v...) -} - func Error(skip int, format string, v ...interface{}) { Root.Error(fmt.Sprintf(format, v...)) } -func Error2(message string, v ...interface{}) { - Root.Error(message, v...) -} - func Critical(skip int, format string, v ...interface{}) { Root.Crit(fmt.Sprintf(format, v...)) } diff --git a/pkg/login/ext_user.go b/pkg/login/ext_user.go index 1262c1cc44f..42fb37ff9d0 100644 --- a/pkg/login/ext_user.go +++ b/pkg/login/ext_user.go @@ -11,6 +11,10 @@ func init() { bus.AddHandler("auth", UpsertUser) } +var ( + logger = log.New("login.ext_user") +) + func UpsertUser(cmd *m.UpsertUserCommand) error { extUser := cmd.ExternalUser @@ -135,7 +139,7 @@ func updateUser(user *m.User, extUser *m.ExternalUserInfo) error { return nil } - log.Debug2("Syncing user info", "id", user.Id, "update", updateCmd) + logger.Debug("Syncing user info", "id", user.Id, "update", updateCmd) return bus.Dispatch(updateCmd) } diff --git a/pkg/services/alerting/notifiers/telegram.go b/pkg/services/alerting/notifiers/telegram.go index 4a4a989d873..ab43f3bce35 100644 --- a/pkg/services/alerting/notifiers/telegram.go +++ b/pkg/services/alerting/notifiers/telegram.go @@ -130,7 +130,7 @@ func (this *TelegramNotifier) buildMessageInlineImage(evalContext *alerting.Eval defer func() { err := imageFile.Close() if err != nil { - log.Error2("Could not close Telegram inline image.", "err", err) + this.log.Error("Could not close Telegram inline image.", "err", err) } }() diff --git a/pkg/services/alerting/test_notification.go b/pkg/services/alerting/test_notification.go index b6e59f694c8..5ffc8dc58fc 100644 --- a/pkg/services/alerting/test_notification.go +++ b/pkg/services/alerting/test_notification.go @@ -18,9 +18,12 @@ type NotificationTestCommand struct { Settings *simplejson.Json } +var ( + logger = log.New("alerting.testnotification") +) + func init() { bus.AddHandler("alerting", handleNotificationTestCommand) - } func handleNotificationTestCommand(cmd *NotificationTestCommand) error { @@ -35,7 +38,7 @@ func handleNotificationTestCommand(cmd *NotificationTestCommand) error { notifiers, err := InitNotifier(model) if err != nil { - log.Error2("Failed to create notifier", "error", err.Error()) + logger.Error("Failed to create notifier", "error", err.Error()) return err } diff --git a/public/app/core/directives/dropdown_typeahead.ts b/public/app/core/directives/dropdown_typeahead.ts index a4bed4fe2b7..dfc3eddbcbb 100644 --- a/public/app/core/directives/dropdown_typeahead.ts +++ b/public/app/core/directives/dropdown_typeahead.ts @@ -141,6 +141,9 @@ export function dropdownTypeahead2($compile) { link: ($scope, elem, attrs) => { const $input = $(inputTemplate); const $button = $(buttonTemplate); + const timeoutId = { + blur: null + }; $input.appendTo(elem); $button.appendTo(elem); @@ -177,6 +180,14 @@ export function dropdownTypeahead2($compile) { [] ); + const closeDropdownMenu = () => { + $input.hide(); + $input.val(''); + $button.show(); + $button.focus(); + elem.removeClass('open'); + }; + $scope.menuItemSelected = (index, subIndex) => { const menuItem = $scope.menuItems[index]; const payload: any = { $item: menuItem }; @@ -184,6 +195,7 @@ export function dropdownTypeahead2($compile) { payload.$subItem = menuItem.submenu[subIndex]; } $scope.dropdownTypeaheadOnSelect(payload); + closeDropdownMenu(); }; $input.attr('data-provide', 'typeahead'); @@ -223,16 +235,15 @@ export function dropdownTypeahead2($compile) { elem.toggleClass('open', $input.val() === ''); }); + elem.mousedown((evt: Event) => { + evt.preventDefault(); + timeoutId.blur = null; + }); + $input.blur(() => { - $input.hide(); - $input.val(''); - $button.show(); - $button.focus(); - // clicking the function dropdown menu won't - // work if you remove class at once - setTimeout(() => { - elem.removeClass('open'); - }, 200); + timeoutId.blur = setTimeout(() => { + closeDropdownMenu(); + }, 1); }); $compile(elem.contents())($scope); diff --git a/public/app/core/utils/explore.test.ts b/public/app/core/utils/explore.test.ts index a3b08516d16..32135eab90a 100644 --- a/public/app/core/utils/explore.test.ts +++ b/public/app/core/utils/explore.test.ts @@ -6,26 +6,13 @@ import { clearHistory, hasNonEmptyQuery, } from './explore'; -import { ExploreState } from 'app/types/explore'; +import { ExploreUrlState } from 'app/types/explore'; import store from 'app/core/store'; -const DEFAULT_EXPLORE_STATE: ExploreState = { +const DEFAULT_EXPLORE_STATE: ExploreUrlState = { datasource: null, - datasourceError: null, - datasourceLoading: null, - datasourceMissing: false, - exploreDatasources: [], - graphInterval: 1000, - history: [], - initialQueries: [], - queryTransactions: [], + queries: [], range: DEFAULT_RANGE, - showingGraph: true, - showingLogs: true, - showingTable: true, - supportsGraph: null, - supportsLogs: null, - supportsTable: null, }; describe('state functions', () => { @@ -68,21 +55,19 @@ describe('state functions', () => { it('returns url parameter value for a state object', () => { const state = { ...DEFAULT_EXPLORE_STATE, - initialDatasource: 'foo', + datasource: 'foo', + queries: [ + { + expr: 'metric{test="a/b"}', + }, + { + expr: 'super{foo="x/z"}', + }, + ], range: { from: 'now-5h', to: 'now', }, - initialQueries: [ - { - refId: '1', - expr: 'metric{test="a/b"}', - }, - { - refId: '2', - expr: 'super{foo="x/z"}', - }, - ], }; expect(serializeStateToUrlParam(state)).toBe( '{"datasource":"foo","queries":[{"expr":"metric{test=\\"a/b\\"}"},' + @@ -93,21 +78,19 @@ describe('state functions', () => { it('returns url parameter value for a state object', () => { const state = { ...DEFAULT_EXPLORE_STATE, - initialDatasource: 'foo', + datasource: 'foo', + queries: [ + { + expr: 'metric{test="a/b"}', + }, + { + expr: 'super{foo="x/z"}', + }, + ], range: { from: 'now-5h', to: 'now', }, - initialQueries: [ - { - refId: '1', - expr: 'metric{test="a/b"}', - }, - { - refId: '2', - expr: 'super{foo="x/z"}', - }, - ], }; expect(serializeStateToUrlParam(state, true)).toBe( '["now-5h","now","foo",{"expr":"metric{test=\\"a/b\\"}"},{"expr":"super{foo=\\"x/z\\"}"}]' @@ -119,35 +102,24 @@ describe('state functions', () => { it('can parse the serialized state into the original state', () => { const state = { ...DEFAULT_EXPLORE_STATE, - initialDatasource: 'foo', + datasource: 'foo', + queries: [ + { + expr: 'metric{test="a/b"}', + }, + { + expr: 'super{foo="x/z"}', + }, + ], range: { from: 'now - 5h', to: 'now', }, - initialQueries: [ - { - refId: '1', - expr: 'metric{test="a/b"}', - }, - { - refId: '2', - expr: 'super{foo="x/z"}', - }, - ], }; const serialized = serializeStateToUrlParam(state); const parsed = parseUrlState(serialized); - // Account for datasource vs datasourceName - const { datasource, queries, ...rest } = parsed; - const resultState = { - ...rest, - datasource: DEFAULT_EXPLORE_STATE.datasource, - initialDatasource: datasource, - initialQueries: queries, - }; - - expect(state).toMatchObject(resultState); + expect(state).toMatchObject(parsed); }); }); }); diff --git a/public/app/core/utils/explore.ts b/public/app/core/utils/explore.ts index f3273ffa16d..b05e38a4b33 100644 --- a/public/app/core/utils/explore.ts +++ b/public/app/core/utils/explore.ts @@ -1,6 +1,7 @@ import _ from 'lodash'; -import { colors } from '@grafana/ui'; +import { colors, RawTimeRange, IntervalValues } from '@grafana/ui'; +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'; @@ -8,9 +9,15 @@ import { parse as parseDate } from 'app/core/utils/datemath'; import TimeSeries from 'app/core/time_series2'; import TableModel, { mergeTablesIntoModel } from 'app/core/table_model'; -import { ExploreState, ExploreUrlState, HistoryItem, QueryTransaction } from 'app/types/explore'; -import { DataQuery, DataSourceApi } from 'app/types/series'; -import { RawTimeRange, IntervalValues } from '@grafana/ui'; +import { + ExploreUrlState, + HistoryItem, + QueryTransaction, + ResultType, + QueryIntervals, + QueryOptions, +} from 'app/types/explore'; +import { DataQuery } from 'app/types/series'; export const DEFAULT_RANGE = { from: 'now-6h', @@ -19,6 +26,8 @@ export const DEFAULT_RANGE = { const MAX_HISTORY_ITEMS = 100; +export const LAST_USED_DATASOURCE_KEY = 'grafana.explore.datasource'; + /** * Returns an Explore-URL that contains a panel's queries and the dashboard time range. * @@ -77,7 +86,63 @@ export async function getExploreUrl( return url; } -const clearQueryKeys: ((query: DataQuery) => object) = ({ key, refId, ...rest }) => rest; +export function buildQueryTransaction( + query: DataQuery, + rowIndex: number, + resultType: ResultType, + queryOptions: QueryOptions, + range: RawTimeRange, + queryIntervals: QueryIntervals, + scanning: boolean +): QueryTransaction { + const { interval, intervalMs } = queryIntervals; + + const configuredQueries = [ + { + ...query, + ...queryOptions, + }, + ]; + + // Clone range for query request + // const queryRange: RawTimeRange = { ...range }; + // const { from, to, raw } = this.timeSrv.timeRange(); + // Most datasource is using `panelId + query.refId` for cancellation logic. + // Using `format` here because it relates to the view panel that the request is for. + // However, some datasources don't use `panelId + query.refId`, but only `panelId`. + // Therefore panel id has to be unique. + const panelId = `${queryOptions.format}-${query.key}`; + + const options = { + interval, + 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, + scopedVars: { + __interval: { text: interval, value: interval }, + __interval_ms: { text: intervalMs, value: intervalMs }, + }, + }; + + return { + options, + query, + resultType, + rowIndex, + scanning, + id: generateKey(), // reusing for unique ID + done: false, + latency: 0, + }; +} + +export const clearQueryKeys: ((query: DataQuery) => object) = ({ key, refId, ...rest }) => rest; export function parseUrlState(initial: string | undefined): ExploreUrlState { if (initial) { @@ -103,12 +168,7 @@ export function parseUrlState(initial: string | undefined): ExploreUrlState { return { datasource: null, queries: [], range: DEFAULT_RANGE }; } -export function serializeStateToUrlParam(state: ExploreState, compact?: boolean): string { - const urlState: ExploreUrlState = { - datasource: state.initialDatasource, - queries: state.initialQueries.map(clearQueryKeys), - range: state.range, - }; +export function serializeStateToUrlParam(urlState: ExploreUrlState, compact?: boolean): string { if (compact) { return JSON.stringify([urlState.range.from, urlState.range.to, urlState.datasource, ...urlState.queries]); } @@ -123,7 +183,7 @@ export function generateRefId(index = 0): string { return `${index + 1}`; } -export function generateQueryKeys(index = 0): { refId: string; key: string } { +export function generateEmptyQuery(index = 0): { refId: string; key: string } { return { refId: generateRefId(index), key: generateKey(index) }; } @@ -132,20 +192,23 @@ export function generateQueryKeys(index = 0): { refId: string; key: string } { */ export function ensureQueries(queries?: DataQuery[]): DataQuery[] { if (queries && typeof queries === 'object' && queries.length > 0) { - return queries.map((query, i) => ({ ...query, ...generateQueryKeys(i) })); + return queries.map((query, i) => ({ ...query, ...generateEmptyQuery(i) })); } - return [{ ...generateQueryKeys() }]; + return [{ ...generateEmptyQuery() }]; } /** * A target is non-empty when it has keys (with non-empty values) other than refId and key. */ export function hasNonEmptyQuery(queries: DataQuery[]): boolean { - return queries.some( - query => - Object.keys(query) - .map(k => query[k]) - .filter(v => v).length > 2 + return ( + queries && + queries.some( + query => + Object.keys(query) + .map(k => query[k]) + .filter(v => v).length > 2 + ) ); } @@ -180,8 +243,8 @@ export function calculateResultsFromQueryTransactions( }; } -export function getIntervals(range: RawTimeRange, datasource: DataSourceApi, resolution: number): IntervalValues { - if (!datasource || !resolution) { +export function getIntervals(range: RawTimeRange, lowLimit: string, resolution: number): IntervalValues { + if (!resolution) { return { interval: '1s', intervalMs: 1000 }; } @@ -190,7 +253,7 @@ export function getIntervals(range: RawTimeRange, datasource: DataSourceApi, res to: parseDate(range.to, true), }; - return kbn.calculateInterval(absoluteRange, resolution, datasource.interval); + return kbn.calculateInterval(absoluteRange, resolution, lowLimit); } export function makeTimeSeriesList(dataList) { diff --git a/public/app/features/dashboard/dashgrid/PanelChrome.tsx b/public/app/features/dashboard/dashgrid/PanelChrome.tsx index 46534cac065..6b4ef48c32e 100644 --- a/public/app/features/dashboard/dashgrid/PanelChrome.tsx +++ b/public/app/features/dashboard/dashgrid/PanelChrome.tsx @@ -20,6 +20,7 @@ import { PanelPlugin } from 'app/types'; import { TimeRange } from '@grafana/ui'; import variables from 'sass/_variables.scss'; +import templateSrv from 'app/features/templating/template_srv'; export interface Props { panel: PanelModel; @@ -78,6 +79,10 @@ export class PanelChrome extends PureComponent { }); }; + onInterpolate = (value: string, format?: string) => { + return templateSrv.replace(value, this.props.panel.scopedVars, format); + }; + get isVisible() { return !this.props.dashboard.otherPanelInFullscreen(this.props.panel); } @@ -124,9 +129,10 @@ export class PanelChrome extends PureComponent { timeSeries={timeSeries} timeRange={timeRange} options={panel.getOptions(plugin.exports.PanelDefaults)} - width={width - 2 * variables.panelHorizontalPadding } + width={width - 2 * variables.panelHorizontalPadding} height={height - PANEL_HEADER_HEIGHT - variables.panelVerticalPadding} renderCounter={renderCounter} + onInterpolate={this.onInterpolate} />
); diff --git a/public/app/features/dashboard/dashgrid/PanelHeader/PanelHeader.tsx b/public/app/features/dashboard/dashgrid/PanelHeader/PanelHeader.tsx index 8b7afd7d09e..b5cd9258c08 100644 --- a/public/app/features/dashboard/dashgrid/PanelHeader/PanelHeader.tsx +++ b/public/app/features/dashboard/dashgrid/PanelHeader/PanelHeader.tsx @@ -3,6 +3,7 @@ import classNames from 'classnames'; import PanelHeaderCorner from './PanelHeaderCorner'; import { PanelHeaderMenu } from './PanelHeaderMenu'; +import templateSrv from 'app/features/templating/template_srv'; import { DashboardModel } from 'app/features/dashboard/dashboard_model'; import { PanelModel } from 'app/features/dashboard/panel_model'; @@ -45,7 +46,9 @@ export class PanelHeader extends Component { const isFullscreen = false; const isLoading = false; const panelHeaderClass = classNames({ 'panel-header': true, 'grid-drag-handle': !isFullscreen }); - const { panel, dashboard, timeInfo } = this.props; + const { panel, dashboard, timeInfo, scopedVars } = this.props; + const title = templateSrv.replaceWithText(panel.title, scopedVars); + return ( <> {
- {panel.title} + {title} {this.state.panelMenuOpen && ( diff --git a/public/app/features/explore/Explore.tsx b/public/app/features/explore/Explore.tsx index d4d645950c1..a8acab50137 100644 --- a/public/app/features/explore/Explore.tsx +++ b/public/app/features/explore/Explore.tsx @@ -1,55 +1,75 @@ import React from 'react'; import { hot } from 'react-hot-loader'; +import { connect } from 'react-redux'; import _ from 'lodash'; +import { AutoSizer } from 'react-virtualized'; +import { RawTimeRange, TimeRange } from '@grafana/ui'; -import { DataSource } from 'app/types/datasources'; -import { - ExploreState, - ExploreUrlState, - QueryTransaction, - ResultType, - QueryHintGetter, - QueryHint, -} from 'app/types/explore'; -import { TimeRange } from '@grafana/ui'; +import { DataSourceSelectItem } from 'app/types/datasources'; +import { ExploreItemState, ExploreUrlState, RangeScanner, ExploreId } from 'app/types/explore'; import { DataQuery } from 'app/types/series'; +import { StoreState } from 'app/types'; import store from 'app/core/store'; -import { - DEFAULT_RANGE, - calculateResultsFromQueryTransactions, - ensureQueries, - getIntervals, - generateKey, - generateQueryKeys, - hasNonEmptyQuery, - makeTimeSeriesList, - updateHistory, -} from 'app/core/utils/explore'; +import { LAST_USED_DATASOURCE_KEY, ensureQueries, DEFAULT_RANGE } from 'app/core/utils/explore'; import { DataSourcePicker } from 'app/core/components/Select/DataSourcePicker'; -import TableModel from 'app/core/table_model'; -import { DatasourceSrv } from 'app/features/plugins/datasource_srv'; import { Emitter } from 'app/core/utils/emitter'; -import * as dateMath from 'app/core/utils/datemath'; -import Panel from './Panel'; -import QueryRows from './QueryRows'; -import Graph from './Graph'; -import Logs from './Logs'; -import Table from './Table'; -import ErrorBoundary from './ErrorBoundary'; +import { + changeDatasource, + changeSize, + changeTime, + clearQueries, + initializeExplore, + modifyQueries, + runQueries, + scanStart, + scanStop, + setQueries, + splitClose, + splitOpen, +} from './state/actions'; + import { Alert } from './Error'; +import ErrorBoundary from './ErrorBoundary'; +import GraphContainer from './GraphContainer'; +import LogsContainer from './LogsContainer'; +import QueryRows from './QueryRows'; +import TableContainer from './TableContainer'; import TimePicker, { parseTime } from './TimePicker'; -const LAST_USED_DATASOURCE_KEY = 'grafana.explore.datasource'; - interface ExploreProps { - datasourceSrv: DatasourceSrv; - onChangeSplit: (split: boolean, state?: ExploreState) => void; - onSaveState: (key: string, state: ExploreState) => void; - position: string; + StartPage?: any; + changeDatasource: typeof changeDatasource; + changeSize: typeof changeSize; + changeTime: typeof changeTime; + clearQueries: typeof clearQueries; + datasourceError: string; + datasourceInstance: any; + datasourceLoading: boolean | null; + datasourceMissing: boolean; + exploreDatasources: DataSourceSelectItem[]; + exploreId: ExploreId; + initialDatasource?: string; + initialQueries: DataQuery[]; + initializeExplore: typeof initializeExplore; + initialized: boolean; + loading: boolean; + modifyQueries: typeof modifyQueries; + range: RawTimeRange; + runQueries: typeof runQueries; + scanner?: RangeScanner; + scanning?: boolean; + scanRange?: RawTimeRange; + scanStart: typeof scanStart; + scanStop: typeof scanStop; + setQueries: typeof setQueries; split: boolean; - splitState?: ExploreState; - stateKey: string; + splitClose: typeof splitClose; + splitOpen: typeof splitOpen; + showingStartPage?: boolean; + supportsGraph: boolean | null; + supportsLogs: boolean | null; + supportsTable: boolean | null; urlState: ExploreUrlState; } @@ -58,26 +78,14 @@ interface ExploreProps { * Once a datasource is selected it populates the query section at the top. * When queries are run, their results are being displayed in the main section. * The datasource determines what kind of query editor it brings, and what kind - * of results viewers it supports. + * of results viewers it supports. The state is managed entirely in Redux. * - * QUERY HANDLING + * SPLIT VIEW * - * TLDR: to not re-render Explore during edits, query editing is not "controlled" - * in a React sense: values need to be pushed down via `initialQueries`, while - * edits travel up via `this.modifiedQueries`. - * - * By default the query rows start without prior state: `initialQueries` will - * contain one empty DataQuery. While the user modifies the DataQuery, the - * modifications are being tracked in `this.modifiedQueries`, which need to be - * used whenever a query is sent to the datasource to reflect what the user sees - * on the screen. Query"react-popper": "^0.7.5", rows can be initialized or reset using `initialQueries`, - * by giving the respec"react-popper": "^0.7.5",tive row a new key. This wipes the old row and its state. - * This property is als"react-popper": "^0.7.5",o used to govern how many query rows there are (minimum 1). - * - * This flow makes sure that a query row can be arbitrarily complex without the - * fear of being wiped or re-initialized via props. The query row is free to keep - * its own state while the user edits or builds a query. Valid queries can be sent - * up to Explore via the `onChangeQuery` prop. + * Explore can have two Explore areas side-by-side. This is handled in `Wrapper.tsx`. + * Since there can be multiple Explores (e.g., left and right) each action needs + * the `exploreId` as first parameter so that the reducer knows which Explore state + * is affected. * * DATASOURCE REQUESTS * @@ -89,23 +97,9 @@ interface ExploreProps { * The result viewers determine some of the query options sent to the datasource, e.g., * `format`, to indicate eventual transformations by the datasources' result transformers. */ -export class Explore extends React.PureComponent { +export class Explore extends React.PureComponent { el: any; exploreEvents: Emitter; - /** - * Set via URL or local storage - */ - initialDatasource: string; - /** - * Current query expressions of the rows including their modifications, used for running queries. - * Not kept in component state to prevent edit-render roundtrips. - */ - modifiedQueries: DataQuery[]; - /** - * Local ID cache to compare requested vs selected datasource - */ - requestedDatasourceId: string; - scanTimer: NodeJS.Timer; /** * Timepicker to control scanning */ @@ -113,359 +107,65 @@ export class Explore extends React.PureComponent { constructor(props) { super(props); - const splitState: ExploreState = props.splitState; - let initialQueries: DataQuery[]; - if (splitState) { - // Split state overrides everything - this.state = splitState; - initialQueries = splitState.initialQueries; - } else { - const { datasource, queries, range } = props.urlState as ExploreUrlState; - const initialDatasource = datasource || store.get(LAST_USED_DATASOURCE_KEY); - initialQueries = ensureQueries(queries); - const initialRange = { from: parseTime(range.from), to: parseTime(range.to) } || { ...DEFAULT_RANGE }; - // Millies step for helper bar charts - const initialGraphInterval = 15 * 1000; - this.state = { - datasource: null, - datasourceError: null, - datasourceLoading: null, - datasourceMissing: false, - exploreDatasources: [], - graphInterval: initialGraphInterval, - graphResult: [], - initialDatasource, - initialQueries, - history: [], - logsResult: null, - queryTransactions: [], - range: initialRange, - scanning: false, - showingGraph: true, - showingLogs: true, - showingStartPage: false, - showingTable: true, - supportsGraph: null, - supportsLogs: null, - supportsTable: null, - tableResult: new TableModel(), - }; - } - this.modifiedQueries = initialQueries.slice(); this.exploreEvents = new Emitter(); this.timepickerRef = React.createRef(); } async componentDidMount() { - const { datasourceSrv } = this.props; - const { initialDatasource } = this.state; - if (!datasourceSrv) { - throw new Error('No datasource service passed as props.'); - } - - const datasources = datasourceSrv.getExternal(); - const exploreDatasources = datasources.map(ds => ({ - value: ds.name, - name: ds.name, - meta: ds.meta, - })); - - if (datasources.length > 0) { - this.setState({ datasourceLoading: true, exploreDatasources }); - // Priority for datasource preselection: URL, localstorage, default datasource - let datasource; - if (initialDatasource) { - datasource = await datasourceSrv.get(initialDatasource); - } else { - datasource = await datasourceSrv.get(); - } - await this.setDatasource(datasource); - } else { - this.setState({ datasourceMissing: true }); + 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 } = (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; + this.props.initializeExplore( + exploreId, + initialDatasource, + initialQueries, + initialRange, + width, + this.exploreEvents + ); } } componentWillUnmount() { this.exploreEvents.removeAllListeners(); - clearTimeout(this.scanTimer); - } - - async setDatasource(datasource: any, origin?: DataSource) { - const { initialQueries, range } = this.state; - - const supportsGraph = datasource.meta.metrics; - const supportsLogs = datasource.meta.logs; - const supportsTable = datasource.meta.tables; - const datasourceId = datasource.meta.id; - let datasourceError = null; - - // Keep ID to track selection - this.requestedDatasourceId = datasourceId; - - try { - const testResult = await datasource.testDatasource(); - datasourceError = testResult.status === 'success' ? null : testResult.message; - } catch (error) { - datasourceError = (error && error.statusText) || 'Network error'; - } - - if (datasourceId !== this.requestedDatasourceId) { - // User already changed datasource again, discard results - return; - } - - const historyKey = `grafana.explore.history.${datasourceId}`; - const history = store.getObject(historyKey, []); - - if (datasource.init) { - datasource.init(); - } - - // Check if queries can be imported from previously selected datasource - let modifiedQueries = this.modifiedQueries; - if (origin) { - if (origin.meta.id === datasource.meta.id) { - // Keep same queries if same type of datasource - modifiedQueries = [...this.modifiedQueries]; - } else if (datasource.importQueries) { - // Datasource-specific importers - modifiedQueries = await datasource.importQueries(this.modifiedQueries, origin.meta); - } else { - // Default is blank queries - modifiedQueries = ensureQueries(); - } - } - - // Reset edit state with new queries - const nextQueries = initialQueries.map((q, i) => ({ - ...modifiedQueries[i], - ...generateQueryKeys(i), - })); - this.modifiedQueries = modifiedQueries; - - // Custom components - const StartPage = datasource.pluginExports.ExploreStartPage; - - // Calculate graph bucketing interval - const graphInterval = getIntervals(range, datasource, this.el ? this.el.offsetWidth : 0).intervalMs; - - this.setState( - { - StartPage, - datasource, - datasourceError, - graphInterval, - history, - supportsGraph, - supportsLogs, - supportsTable, - datasourceLoading: false, - initialDatasource: datasource.name, - initialQueries: nextQueries, - logsHighlighterExpressions: undefined, - showingStartPage: Boolean(StartPage), - }, - () => { - if (datasourceError === null) { - // Save last-used datasource - store.set(LAST_USED_DATASOURCE_KEY, datasource.name); - this.onSubmit(); - } - } - ); } getRef = el => { this.el = el; }; - onAddQueryRow = index => { - // Local cache - this.modifiedQueries[index + 1] = { ...generateQueryKeys(index + 1) }; - - this.setState(state => { - const { initialQueries, queryTransactions } = state; - - const nextQueries = [ - ...initialQueries.slice(0, index + 1), - { ...this.modifiedQueries[index + 1] }, - ...initialQueries.slice(index + 1), - ]; - - // Ongoing transactions need to update their row indices - const nextQueryTransactions = queryTransactions.map(qt => { - if (qt.rowIndex > index) { - return { - ...qt, - rowIndex: qt.rowIndex + 1, - }; - } - return qt; - }); - - return { - initialQueries: nextQueries, - logsHighlighterExpressions: undefined, - queryTransactions: nextQueryTransactions, - }; - }); - }; - onChangeDatasource = async option => { - const origin = this.state.datasource; - this.setState({ - datasource: null, - datasourceError: null, - datasourceLoading: true, - queryTransactions: [], - }); - const datasourceName = option.value; - const datasource = await this.props.datasourceSrv.get(datasourceName); - this.setDatasource(datasource as any, origin); + this.props.changeDatasource(this.props.exploreId, option.value); }; - onChangeQuery = (value: DataQuery, index: number, override?: boolean) => { - // Null value means reset - if (value === null) { - value = { ...generateQueryKeys(index) }; - } - - // Keep current value in local cache - this.modifiedQueries[index] = value; - - if (override) { - this.setState(state => { - // Replace query row by injecting new key - const { initialQueries, queryTransactions } = state; - const query: DataQuery = { - ...value, - ...generateQueryKeys(index), - }; - const nextQueries = [...initialQueries]; - nextQueries[index] = query; - this.modifiedQueries = [...nextQueries]; - - // Discard ongoing transaction related to row query - const nextQueryTransactions = queryTransactions.filter(qt => qt.rowIndex !== index); - - return { - initialQueries: nextQueries, - queryTransactions: nextQueryTransactions, - }; - }, this.onSubmit); - } else if (this.state.datasource.getHighlighterExpression && this.modifiedQueries.length === 1) { - // Live preview of log search matches. Can only work on single row query for now - this.updateLogsHighlights(value); - } - }; - - onChangeTime = (nextRange: TimeRange, scanning?: boolean) => { - const range: TimeRange = { - ...nextRange, - }; - if (this.state.scanning && !scanning) { + onChangeTime = (range: TimeRange, changedByScanner?: boolean) => { + if (this.props.scanning && !changedByScanner) { this.onStopScanning(); } - this.setState({ range, scanning }, () => this.onSubmit()); + this.props.changeTime(this.props.exploreId, range); }; onClickClear = () => { - this.onStopScanning(); - this.modifiedQueries = ensureQueries(); - this.setState( - prevState => ({ - initialQueries: [...this.modifiedQueries], - queryTransactions: [], - showingStartPage: Boolean(prevState.StartPage), - }), - this.saveState - ); + this.props.clearQueries(this.props.exploreId); }; onClickCloseSplit = () => { - const { onChangeSplit } = this.props; - if (onChangeSplit) { - onChangeSplit(false); - } - }; - - onClickGraphButton = () => { - this.setState( - state => { - const showingGraph = !state.showingGraph; - let nextQueryTransactions = state.queryTransactions; - if (!showingGraph) { - // Discard transactions related to Graph query - nextQueryTransactions = state.queryTransactions.filter(qt => qt.resultType !== 'Graph'); - } - return { queryTransactions: nextQueryTransactions, showingGraph }; - }, - () => { - if (this.state.showingGraph) { - this.onSubmit(); - } - } - ); - }; - - onClickLogsButton = () => { - this.setState( - state => { - const showingLogs = !state.showingLogs; - let nextQueryTransactions = state.queryTransactions; - if (!showingLogs) { - // Discard transactions related to Logs query - nextQueryTransactions = state.queryTransactions.filter(qt => qt.resultType !== 'Logs'); - } - return { queryTransactions: nextQueryTransactions, showingLogs }; - }, - () => { - if (this.state.showingLogs) { - this.onSubmit(); - } - } - ); + this.props.splitClose(); }; // Use this in help pages to set page to a single query onClickExample = (query: DataQuery) => { - const nextQueries = [{ ...query, ...generateQueryKeys() }]; - this.modifiedQueries = [...nextQueries]; - this.setState({ initialQueries: nextQueries }, this.onSubmit); + this.props.setQueries(this.props.exploreId, [query]); }; onClickSplit = () => { - const { onChangeSplit } = this.props; - if (onChangeSplit) { - const state = this.cloneState(); - onChangeSplit(true, state); - } - }; - - onClickTableButton = () => { - this.setState( - state => { - const showingTable = !state.showingTable; - if (showingTable) { - return { showingTable, queryTransactions: state.queryTransactions }; - } - - // Toggle off needs discarding of table queries - const nextQueryTransactions = state.queryTransactions.filter(qt => qt.resultType !== 'Table'); - const results = calculateResultsFromQueryTransactions( - nextQueryTransactions, - state.datasource, - state.graphInterval - ); - - return { ...results, queryTransactions: nextQueryTransactions, showingTable }; - }, - () => { - if (this.state.showingTable) { - this.onSubmit(); - } - } - ); + this.props.splitOpen(); }; onClickLabel = (key: string, value: string) => { @@ -473,438 +173,63 @@ export class Explore extends React.PureComponent { }; onModifyQueries = (action, index?: number) => { - const { datasource } = this.state; - if (datasource && datasource.modifyQuery) { - const preventSubmit = action.preventSubmit; - this.setState( - state => { - const { initialQueries, queryTransactions } = state; - let nextQueries: DataQuery[]; - let nextQueryTransactions; - if (index === undefined) { - // Modify all queries - nextQueries = initialQueries.map((query, i) => ({ - ...datasource.modifyQuery(this.modifiedQueries[i], action), - ...generateQueryKeys(i), - })); - // Discard all ongoing transactions - nextQueryTransactions = []; - } else { - // Modify query only at index - nextQueries = initialQueries.map((query, i) => { - // Synchronize all queries with local query cache to ensure consistency - // TODO still needed? - return i === index - ? { - ...datasource.modifyQuery(this.modifiedQueries[i], action), - ...generateQueryKeys(i), - } - : query; - }); - nextQueryTransactions = queryTransactions - // Consume the hint corresponding to the action - .map(qt => { - if (qt.hints != null && qt.rowIndex === index) { - qt.hints = qt.hints.filter(hint => hint.fix.action !== action); - } - return qt; - }) - // Preserve previous row query transaction to keep results visible if next query is incomplete - .filter(qt => preventSubmit || qt.rowIndex !== index); - } - this.modifiedQueries = [...nextQueries]; - return { - initialQueries: nextQueries, - queryTransactions: nextQueryTransactions, - }; - }, - // Accepting certain fixes do not result in a well-formed query which should not be submitted - !preventSubmit ? () => this.onSubmit() : null - ); + const { datasourceInstance } = this.props; + if (datasourceInstance && datasourceInstance.modifyQuery) { + const modifier = (queries: DataQuery, modification: any) => datasourceInstance.modifyQuery(queries, modification); + this.props.modifyQueries(this.props.exploreId, action, index, modifier); } }; - onRemoveQueryRow = index => { - // Remove from local cache - this.modifiedQueries = [...this.modifiedQueries.slice(0, index), ...this.modifiedQueries.slice(index + 1)]; - - this.setState( - state => { - const { initialQueries, queryTransactions } = state; - if (initialQueries.length <= 1) { - return null; - } - // Remove row from react state - const nextQueries = [...initialQueries.slice(0, index), ...initialQueries.slice(index + 1)]; - - // Discard transactions related to row query - const nextQueryTransactions = queryTransactions.filter(qt => qt.rowIndex !== index); - const results = calculateResultsFromQueryTransactions( - nextQueryTransactions, - state.datasource, - state.graphInterval - ); - - return { - ...results, - initialQueries: nextQueries, - logsHighlighterExpressions: undefined, - queryTransactions: nextQueryTransactions, - }; - }, - () => this.onSubmit() - ); + onResize = (size: { height: number; width: number }) => { + this.props.changeSize(this.props.exploreId, size); }; onStartScanning = () => { - this.setState({ scanning: true }, this.scanPreviousRange); + // Scanner will trigger a query + const scanner = this.scanPreviousRange; + this.props.scanStart(this.props.exploreId, scanner); }; - scanPreviousRange = () => { - const scanRange = this.timepickerRef.current.move(-1, true); - this.setState({ scanRange }); + scanPreviousRange = (): RawTimeRange => { + // Calling move() on the timepicker will trigger this.onChangeTime() + return this.timepickerRef.current.move(-1, true); }; onStopScanning = () => { - clearTimeout(this.scanTimer); - this.setState(state => { - const { queryTransactions } = state; - const nextQueryTransactions = queryTransactions.filter(qt => qt.scanning && !qt.done); - return { queryTransactions: nextQueryTransactions, scanning: false, scanRange: undefined }; - }); + this.props.scanStop(this.props.exploreId); }; onSubmit = () => { - const { showingLogs, showingGraph, showingTable, supportsGraph, supportsLogs, supportsTable } = this.state; - // Keep table queries first since they need to return quickly - if (showingTable && supportsTable) { - this.runQueries( - 'Table', - { - format: 'table', - instant: true, - valueWithRefId: true, - }, - data => data[0] - ); - } - if (showingGraph && supportsGraph) { - this.runQueries( - 'Graph', - { - format: 'time_series', - instant: false, - }, - makeTimeSeriesList - ); - } - if (showingLogs && supportsLogs) { - this.runQueries('Logs', { format: 'logs' }); - } - this.saveState(); - }; - - buildQueryOptions(query: DataQuery, queryOptions: { format: string; hinting?: boolean; instant?: boolean }) { - const { datasource, range } = this.state; - const { interval, intervalMs } = getIntervals(range, datasource, this.el.offsetWidth); - - const configuredQueries = [ - { - ...query, - ...queryOptions, - }, - ]; - - // Clone range for query request - // const queryRange: RawTimeRange = { ...range }; - // const { from, to, raw } = this.timeSrv.timeRange(); - // Most datasource is using `panelId + query.refId` for cancellation logic. - // Using `format` here because it relates to the view panel that the request is for. - // However, some datasources don't use `panelId + query.refId`, but only `panelId`. - // Therefore panel id has to be unique. - const panelId = `${queryOptions.format}-${query.key}`; - - return { - interval, - 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, - scopedVars: { - __interval: { text: interval, value: interval }, - __interval_ms: { text: intervalMs, value: intervalMs }, - }, - }; - } - - startQueryTransaction(query: DataQuery, rowIndex: number, resultType: ResultType, options: any): QueryTransaction { - const queryOptions = this.buildQueryOptions(query, options); - const transaction: QueryTransaction = { - query, - resultType, - rowIndex, - id: generateKey(), // reusing for unique ID - done: false, - latency: 0, - options: queryOptions, - scanning: this.state.scanning, - }; - - // Using updater style because we might be modifying queryTransactions in quick succession - this.setState(state => { - const { queryTransactions } = state; - // Discarding existing transactions of same type - const remainingTransactions = queryTransactions.filter( - qt => !(qt.resultType === resultType && qt.rowIndex === rowIndex) - ); - - // Append new transaction - const nextQueryTransactions = [...remainingTransactions, transaction]; - - const results = calculateResultsFromQueryTransactions( - nextQueryTransactions, - state.datasource, - state.graphInterval - ); - - return { - ...results, - queryTransactions: nextQueryTransactions, - showingStartPage: false, - graphInterval: queryOptions.intervalMs, - }; - }); - - return transaction; - } - - completeQueryTransaction( - transactionId: string, - result: any, - latency: number, - queries: DataQuery[], - datasourceId: string - ) { - const { datasource } = this.state; - if (datasource.meta.id !== datasourceId) { - // Navigated away, queries did not matter - return; - } - - this.setState(state => { - const { history, queryTransactions } = state; - let { scanning } = state; - - // Transaction might have been discarded - const transaction = queryTransactions.find(qt => qt.id === transactionId); - if (!transaction) { - return null; - } - - // Get query hints - let hints: QueryHint[]; - if (datasource.getQueryHints as QueryHintGetter) { - hints = datasource.getQueryHints(transaction.query, result); - } - - // Mark transactions as complete - const nextQueryTransactions = queryTransactions.map(qt => { - if (qt.id === transactionId) { - return { - ...qt, - hints, - latency, - result, - done: true, - }; - } - return qt; - }); - - const results = calculateResultsFromQueryTransactions( - nextQueryTransactions, - state.datasource, - state.graphInterval - ); - - const nextHistory = updateHistory(history, datasourceId, queries); - - // Keep scanning for results if this was the last scanning transaction - if (scanning) { - if (_.size(result) === 0) { - const other = nextQueryTransactions.find(qt => qt.scanning && !qt.done); - if (!other) { - this.scanTimer = setTimeout(this.scanPreviousRange, 1000); - } - } else { - // We can stop scanning if we have a result - scanning = false; - } - } - - return { - ...results, - scanning, - history: nextHistory, - queryTransactions: nextQueryTransactions, - }; - }); - } - - failQueryTransaction(transactionId: string, response: any, datasourceId: string) { - const { datasource } = this.state; - if (datasource.meta.id !== datasourceId || response.cancelled) { - // Navigated away, queries did not matter - return; - } - - console.error(response); - - let error: string | JSX.Element; - if (response.data) { - if (typeof response.data === 'string') { - error = response.data; - } else if (response.data.error) { - error = response.data.error; - if (response.data.response) { - error = ( - <> - {response.data.error} -
{response.data.response}
- - ); - } - } else { - throw new Error('Could not handle error response'); - } - } else if (response.message) { - error = response.message; - } else if (typeof response === 'string') { - error = response; - } else { - error = 'Unknown error during query transaction. Please check JS console logs.'; - } - - this.setState(state => { - // Transaction might have been discarded - if (!state.queryTransactions.find(qt => qt.id === transactionId)) { - return null; - } - - // Mark transactions as complete - const nextQueryTransactions = state.queryTransactions.map(qt => { - if (qt.id === transactionId) { - return { - ...qt, - error, - done: true, - }; - } - return qt; - }); - - return { - queryTransactions: nextQueryTransactions, - }; - }); - } - - async runQueries(resultType: ResultType, queryOptions: any, resultGetter?: any) { - const queries = [...this.modifiedQueries]; - if (!hasNonEmptyQuery(queries)) { - this.setState({ - queryTransactions: [], - }); - return; - } - const { datasource } = this.state; - const datasourceId = datasource.meta.id; - // Run all queries concurrentlyso - queries.forEach(async (query, rowIndex) => { - const transaction = this.startQueryTransaction(query, rowIndex, resultType, queryOptions); - try { - const now = Date.now(); - const res = await datasource.query(transaction.options); - this.exploreEvents.emit('data-received', res.data || []); - const latency = Date.now() - now; - const results = resultGetter ? resultGetter(res.data) : res.data; - this.completeQueryTransaction(transaction.id, results, latency, queries, datasourceId); - } catch (response) { - this.exploreEvents.emit('data-error', response); - this.failQueryTransaction(transaction.id, response, datasourceId); - } - }); - } - - updateLogsHighlights = _.debounce((value: DataQuery, index: number) => { - this.setState(state => { - const { datasource } = state; - if (datasource.getHighlighterExpression) { - const logsHighlighterExpressions = [state.datasource.getHighlighterExpression(value)]; - return { logsHighlighterExpressions }; - } - return null; - }); - }, 500); - - cloneState(): ExploreState { - // Copy state, but copy queries including modifications - return { - ...this.state, - queryTransactions: [], - initialQueries: [...this.modifiedQueries], - }; - } - - saveState = () => { - const { stateKey, onSaveState } = this.props; - onSaveState(stateKey, this.cloneState()); + this.props.runQueries(this.props.exploreId); }; render() { - const { position, split } = this.props; const { StartPage, - datasource, + datasourceInstance, datasourceError, datasourceLoading, datasourceMissing, exploreDatasources, - graphResult, - history, + exploreId, + loading, initialQueries, - logsHighlighterExpressions, - logsResult, - queryTransactions, range, - scanning, - scanRange, - showingGraph, - showingLogs, showingStartPage, - showingTable, + split, supportsGraph, supportsLogs, supportsTable, - tableResult, - } = this.state; - const graphHeight = showingGraph && showingTable ? '200px' : '400px'; + } = this.props; const exploreClass = split ? 'explore explore-split' : 'explore'; - const selectedDatasource = datasource ? exploreDatasources.find(d => d.name === datasource.name) : undefined; - const graphLoading = queryTransactions.some(qt => qt.resultType === 'Graph' && !qt.done); - const tableLoading = queryTransactions.some(qt => qt.resultType === 'Table' && !qt.done); - const logsLoading = queryTransactions.some(qt => qt.resultType === 'Logs' && !qt.done); - const loading = queryTransactions.some(qt => !qt.done); + const selectedDatasource = datasourceInstance + ? exploreDatasources.find(d => d.name === datasourceInstance.name) + : undefined; return (
- {position === 'left' ? ( + {exploreId === 'left' ? ( ) : null}
- {position === 'left' && !split ? ( + {exploreId === 'left' && !split ? (
)} - {datasource && !datasourceError ? ( -
- -
- - {showingStartPage && } - {!showingStartPage && ( - <> - {supportsGraph && ( - - - - )} - {supportsTable && ( - - - - )} - {supportsLogs && ( - - - - )} - + {datasourceInstance && + !datasourceError && ( +
+ + + {({ width }) => ( +
+ + {showingStartPage && } + {!showingStartPage && ( + <> + {supportsGraph && } + {supportsTable && } + {supportsLogs && ( + + )} + + )} + +
)} - - -
- ) : null} + + + )} ); } } -export default hot(module)(Explore); +function mapStateToProps(state: StoreState, { exploreId }) { + const explore = state.explore; + const { split } = explore; + const item: ExploreItemState = explore[exploreId]; + const { + StartPage, + datasourceError, + datasourceInstance, + datasourceLoading, + datasourceMissing, + exploreDatasources, + initialDatasource, + initialQueries, + initialized, + queryTransactions, + range, + showingStartPage, + supportsGraph, + supportsLogs, + supportsTable, + } = item; + const loading = queryTransactions.some(qt => !qt.done); + return { + StartPage, + datasourceError, + datasourceInstance, + datasourceLoading, + datasourceMissing, + exploreDatasources, + initialDatasource, + initialQueries, + initialized, + loading, + queryTransactions, + range, + showingStartPage, + split, + supportsGraph, + supportsLogs, + supportsTable, + }; +} + +const mapDispatchToProps = { + changeDatasource, + changeSize, + changeTime, + clearQueries, + initializeExplore, + modifyQueries, + runQueries, + scanStart, + scanStop, + setQueries, + splitClose, + splitOpen, +}; + +export default hot(module)(connect(mapStateToProps, mapDispatchToProps)(Explore)); diff --git a/public/app/features/explore/GraphContainer.tsx b/public/app/features/explore/GraphContainer.tsx new file mode 100644 index 00000000000..e2610bcc781 --- /dev/null +++ b/public/app/features/explore/GraphContainer.tsx @@ -0,0 +1,61 @@ +import React, { PureComponent } from 'react'; +import { hot } from 'react-hot-loader'; +import { connect } from 'react-redux'; +import { RawTimeRange, TimeRange } from '@grafana/ui'; + +import { ExploreId, ExploreItemState } from 'app/types/explore'; +import { StoreState } from 'app/types'; + +import { toggleGraph } from './state/actions'; +import Graph from './Graph'; +import Panel from './Panel'; + +interface GraphContainerProps { + onChangeTime: (range: TimeRange) => void; + exploreId: ExploreId; + graphResult?: any[]; + loading: boolean; + range: RawTimeRange; + showingGraph: boolean; + showingTable: boolean; + split: boolean; + toggleGraph: typeof toggleGraph; +} + +export class GraphContainer extends PureComponent { + onClickGraphButton = () => { + this.props.toggleGraph(this.props.exploreId); + }; + + render() { + const { exploreId, graphResult, loading, onChangeTime, showingGraph, showingTable, range, split } = this.props; + const graphHeight = showingGraph && showingTable ? '200px' : '400px'; + return ( + + + + ); + } +} + +function mapStateToProps(state: StoreState, { exploreId }) { + const explore = state.explore; + const { split } = explore; + 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 }; +} + +const mapDispatchToProps = { + toggleGraph, +}; + +export default hot(module)(connect(mapStateToProps, mapDispatchToProps)(GraphContainer)); diff --git a/public/app/features/explore/Logs.tsx b/public/app/features/explore/Logs.tsx index 1a384cf011d..d07b31e2ff1 100644 --- a/public/app/features/explore/Logs.tsx +++ b/public/app/features/explore/Logs.tsx @@ -241,9 +241,9 @@ function renderMetaItem(value: any, kind: LogsMetaKind) { interface LogsProps { data: LogsModel; + exploreId: string; highlighterExpressions: string[]; loading: boolean; - position: string; range?: RawTimeRange; scanning?: boolean; scanRange?: RawTimeRange; @@ -348,10 +348,10 @@ export default class Logs extends PureComponent { render() { const { data, + exploreId, highlighterExpressions, loading = false, onClickLabel, - position, range, scanning, scanRange, @@ -400,7 +400,7 @@ export default class Logs extends PureComponent { data={data.series} height="100px" range={range} - id={`explore-logs-graph-${position}`} + id={`explore-logs-graph-${exploreId}`} onChangeTime={this.props.onChangeTime} onToggleSeries={this.onToggleLogLevel} userOptions={graphOptions} diff --git a/public/app/features/explore/LogsContainer.tsx b/public/app/features/explore/LogsContainer.tsx new file mode 100644 index 00000000000..e58cd2b5e95 --- /dev/null +++ b/public/app/features/explore/LogsContainer.tsx @@ -0,0 +1,91 @@ +import React, { PureComponent } from 'react'; +import { hot } from 'react-hot-loader'; +import { connect } from 'react-redux'; +import { RawTimeRange, TimeRange } from '@grafana/ui'; + +import { ExploreId, ExploreItemState } from 'app/types/explore'; +import { LogsModel } from 'app/core/logs_model'; +import { StoreState } from 'app/types'; + +import { toggleLogs } from './state/actions'; +import Logs from './Logs'; +import Panel from './Panel'; + +interface LogsContainerProps { + exploreId: ExploreId; + loading: boolean; + logsHighlighterExpressions?: string[]; + logsResult?: LogsModel; + onChangeTime: (range: TimeRange) => void; + onClickLabel: (key: string, value: string) => void; + onStartScanning: () => void; + onStopScanning: () => void; + range: RawTimeRange; + scanning?: boolean; + scanRange?: RawTimeRange; + showingLogs: boolean; + toggleLogs: typeof toggleLogs; +} + +export class LogsContainer extends PureComponent { + onClickLogsButton = () => { + this.props.toggleLogs(this.props.exploreId); + }; + + render() { + const { + exploreId, + loading, + logsHighlighterExpressions, + logsResult, + onChangeTime, + onClickLabel, + onStartScanning, + onStopScanning, + range, + showingLogs, + scanning, + scanRange, + } = this.props; + return ( + + + + ); + } +} + +function mapStateToProps(state: StoreState, { exploreId }) { + const explore = state.explore; + const item: ExploreItemState = explore[exploreId]; + const { logsHighlighterExpressions, logsResult, queryTransactions, scanning, scanRange, showingLogs, range } = item; + const loading = queryTransactions.some(qt => qt.resultType === 'Logs' && !qt.done); + return { + loading, + logsHighlighterExpressions, + logsResult, + scanning, + scanRange, + showingLogs, + range, + }; +} + +const mapDispatchToProps = { + toggleLogs, +}; + +export default hot(module)(connect(mapStateToProps, mapDispatchToProps)(LogsContainer)); diff --git a/public/app/features/explore/QueryEditor.tsx b/public/app/features/explore/QueryEditor.tsx index ce0a8a6e03e..dde674d3fcd 100644 --- a/public/app/features/explore/QueryEditor.tsx +++ b/public/app/features/explore/QueryEditor.tsx @@ -48,7 +48,7 @@ export default class QueryEditor extends PureComponent { getNextQueryLetter: x => '', }, hideEditorRowActions: true, - ...getIntervals(range, datasource, null), // Possible to get resolution? + ...getIntervals(range, (datasource || {}).interval, null), // Possible to get resolution? }, }; diff --git a/public/app/features/explore/QueryRow.tsx b/public/app/features/explore/QueryRow.tsx new file mode 100644 index 00000000000..b5b150b3ba8 --- /dev/null +++ b/public/app/features/explore/QueryRow.tsx @@ -0,0 +1,163 @@ +import React, { PureComponent } from 'react'; +import { hot } from 'react-hot-loader'; +import { connect } from 'react-redux'; +import { RawTimeRange } from '@grafana/ui'; +import _ from 'lodash'; + +import { QueryTransaction, HistoryItem, QueryHint, ExploreItemState, ExploreId } from 'app/types/explore'; +import { Emitter } from 'app/core/utils/emitter'; +import { DataQuery, StoreState } from 'app/types'; + +// import DefaultQueryField from './QueryField'; +import QueryEditor from './QueryEditor'; +import QueryTransactionStatus from './QueryTransactionStatus'; +import { + addQueryRow, + changeQuery, + highlightLogsExpression, + modifyQueries, + removeQueryRow, + runQueries, +} from './state/actions'; + +function getFirstHintFromTransactions(transactions: QueryTransaction[]): QueryHint { + const transaction = transactions.find(qt => qt.hints && qt.hints.length > 0); + if (transaction) { + return transaction.hints[0]; + } + return undefined; +} + +interface QueryRowProps { + addQueryRow: typeof addQueryRow; + changeQuery: typeof changeQuery; + className?: string; + exploreId: ExploreId; + datasourceInstance: any; + highlightLogsExpression: typeof highlightLogsExpression; + history: HistoryItem[]; + index: number; + initialQuery: DataQuery; + modifyQueries: typeof modifyQueries; + queryTransactions: QueryTransaction[]; + exploreEvents: Emitter; + range: RawTimeRange; + removeQueryRow: typeof removeQueryRow; + runQueries: typeof runQueries; +} + +export class QueryRow extends PureComponent { + onExecuteQuery = () => { + const { exploreId } = this.props; + this.props.runQueries(exploreId); + }; + + onChangeQuery = (query: DataQuery, override?: boolean) => { + const { datasourceInstance, exploreId, index } = this.props; + this.props.changeQuery(exploreId, query, index, override); + if (query && !override && datasourceInstance.getHighlighterExpression && index === 0) { + // Live preview of log search matches. Only use on first row for now + this.updateLogsHighlights(query); + } + }; + + onClickAddButton = () => { + const { exploreId, index } = this.props; + this.props.addQueryRow(exploreId, index); + }; + + onClickClearButton = () => { + this.onChangeQuery(null, true); + }; + + onClickHintFix = action => { + const { datasourceInstance, exploreId, index } = this.props; + if (datasourceInstance && datasourceInstance.modifyQuery) { + const modifier = (queries: DataQuery, action: any) => datasourceInstance.modifyQuery(queries, action); + this.props.modifyQueries(exploreId, action, index, modifier); + } + }; + + onClickRemoveButton = () => { + const { exploreId, index } = this.props; + this.props.removeQueryRow(exploreId, index); + }; + + updateLogsHighlights = _.debounce((value: DataQuery) => { + const { datasourceInstance } = this.props; + if (datasourceInstance.getHighlighterExpression) { + const expressions = [datasourceInstance.getHighlighterExpression(value)]; + this.props.highlightLogsExpression(this.props.exploreId, expressions); + } + }, 500); + + render() { + const { datasourceInstance, history, index, initialQuery, queryTransactions, exploreEvents, range } = this.props; + const transactions = queryTransactions.filter(t => t.rowIndex === index); + const transactionWithError = transactions.find(t => t.error !== undefined); + const hint = getFirstHintFromTransactions(transactions); + const queryError = transactionWithError ? transactionWithError.error : null; + const QueryField = datasourceInstance.pluginExports.ExploreQueryField; + return ( +
+
+ +
+
+ {QueryField ? ( + + ) : ( + + )} +
+
+ + + +
+
+ ); + } +} + +function mapStateToProps(state: StoreState, { exploreId, index }) { + const explore = state.explore; + const item: ExploreItemState = explore[exploreId]; + const { datasourceInstance, history, initialQueries, queryTransactions, range } = item; + const initialQuery = initialQueries[index]; + return { datasourceInstance, history, initialQuery, queryTransactions, range }; +} + +const mapDispatchToProps = { + addQueryRow, + changeQuery, + highlightLogsExpression, + modifyQueries, + removeQueryRow, + runQueries, +}; + +export default hot(module)(connect(mapStateToProps, mapDispatchToProps)(QueryRow)); diff --git a/public/app/features/explore/QueryRows.tsx b/public/app/features/explore/QueryRows.tsx index 4101475092b..01bd409f444 100644 --- a/public/app/features/explore/QueryRows.tsx +++ b/public/app/features/explore/QueryRows.tsx @@ -1,159 +1,25 @@ import React, { PureComponent } from 'react'; -import { QueryTransaction, HistoryItem, QueryHint } from 'app/types/explore'; import { Emitter } from 'app/core/utils/emitter'; +import { DataQuery } from 'app/types'; +import { ExploreId } from 'app/types/explore'; -// import DefaultQueryField from './QueryField'; -import QueryEditor from './QueryEditor'; -import QueryTransactionStatus from './QueryTransactionStatus'; -import { DataSource, DataQuery } from 'app/types'; -import { RawTimeRange } from '@grafana/ui'; +import QueryRow from './QueryRow'; -function getFirstHintFromTransactions(transactions: QueryTransaction[]): QueryHint { - const transaction = transactions.find(qt => qt.hints && qt.hints.length > 0); - if (transaction) { - return transaction.hints[0]; - } - return undefined; -} - -interface QueryRowEventHandlers { - onAddQueryRow: (index: number) => void; - onChangeQuery: (value: DataQuery, index: number, override?: boolean) => void; - onClickHintFix: (action: object, index?: number) => void; - onExecuteQuery: () => void; - onRemoveQueryRow: (index: number) => void; -} - -interface QueryRowCommonProps { +interface QueryRowsProps { className?: string; - datasource: DataSource; - history: HistoryItem[]; - transactions: QueryTransaction[]; exploreEvents: Emitter; - range: RawTimeRange; + exploreId: ExploreId; + initialQueries: DataQuery[]; } - -type QueryRowProps = QueryRowCommonProps & - QueryRowEventHandlers & { - index: number; - initialQuery: DataQuery; - }; - -class QueryRow extends PureComponent { - onExecuteQuery = () => { - const { onExecuteQuery } = this.props; - onExecuteQuery(); - }; - - onChangeQuery = (value: DataQuery, override?: boolean) => { - const { index, onChangeQuery } = this.props; - if (onChangeQuery) { - onChangeQuery(value, index, override); - } - }; - - onClickAddButton = () => { - const { index, onAddQueryRow } = this.props; - if (onAddQueryRow) { - onAddQueryRow(index); - } - }; - - onClickClearButton = () => { - this.onChangeQuery(null, true); - }; - - onClickHintFix = action => { - const { index, onClickHintFix } = this.props; - if (onClickHintFix) { - onClickHintFix(action, index); - } - }; - - onClickRemoveButton = () => { - const { index, onRemoveQueryRow } = this.props; - if (onRemoveQueryRow) { - onRemoveQueryRow(index); - } - }; - - onPressEnter = () => { - const { onExecuteQuery } = this.props; - if (onExecuteQuery) { - onExecuteQuery(); - } - }; - - render() { - const { datasource, history, initialQuery, transactions, exploreEvents, range } = this.props; - const transactionWithError = transactions.find(t => t.error !== undefined); - const hint = getFirstHintFromTransactions(transactions); - const queryError = transactionWithError ? transactionWithError.error : null; - const QueryField = datasource.pluginExports.ExploreQueryField; - return ( -
-
- -
-
- {QueryField ? ( - - ) : ( - - )} -
-
- - - -
-
- ); - } -} - -type QueryRowsProps = QueryRowCommonProps & - QueryRowEventHandlers & { - initialQueries: DataQuery[]; - }; - export default class QueryRows extends PureComponent { render() { - const { className = '', initialQueries, transactions, ...handlers } = this.props; + const { className = '', exploreEvents, exploreId, initialQueries } = this.props; return (
{initialQueries.map((query, index) => ( - t.rowIndex === index)} - {...handlers} - /> + // TODO instead of relying on initialQueries, move to react key list in redux + ))}
); diff --git a/public/app/features/explore/TableContainer.tsx b/public/app/features/explore/TableContainer.tsx new file mode 100644 index 00000000000..1d00a441e14 --- /dev/null +++ b/public/app/features/explore/TableContainer.tsx @@ -0,0 +1,49 @@ +import React, { PureComponent } from 'react'; +import { hot } from 'react-hot-loader'; +import { connect } from 'react-redux'; + +import { ExploreId, ExploreItemState } from 'app/types/explore'; +import { StoreState } from 'app/types'; + +import { toggleGraph } from './state/actions'; +import Table from './Table'; +import Panel from './Panel'; +import TableModel from 'app/core/table_model'; + +interface TableContainerProps { + exploreId: ExploreId; + loading: boolean; + onClickCell: (key: string, value: string) => void; + showingTable: boolean; + tableResult?: TableModel; + toggleGraph: typeof toggleGraph; +} + +export class TableContainer extends PureComponent { + onClickTableButton = () => { + this.props.toggleGraph(this.props.exploreId); + }; + + render() { + const { loading, onClickCell, showingTable, tableResult } = this.props; + return ( + +
+ + ); + } +} + +function mapStateToProps(state: StoreState, { exploreId }) { + const explore = state.explore; + const item: ExploreItemState = explore[exploreId]; + const { queryTransactions, showingTable, tableResult } = item; + const loading = queryTransactions.some(qt => qt.resultType === 'Table' && !qt.done); + return { loading, showingTable, tableResult }; +} + +const mapDispatchToProps = { + toggleGraph, +}; + +export default hot(module)(connect(mapStateToProps, mapDispatchToProps)(TableContainer)); diff --git a/public/app/features/explore/Wrapper.tsx b/public/app/features/explore/Wrapper.tsx index de1eee4c662..7ea8f228af8 100644 --- a/public/app/features/explore/Wrapper.tsx +++ b/public/app/features/explore/Wrapper.tsx @@ -3,91 +3,56 @@ import { hot } from 'react-hot-loader'; import { connect } from 'react-redux'; import { updateLocation } from 'app/core/actions'; -import { serializeStateToUrlParam, parseUrlState } from 'app/core/utils/explore'; import { StoreState } from 'app/types'; -import { ExploreState } from 'app/types/explore'; +import { ExploreId, ExploreUrlState } from 'app/types/explore'; +import { parseUrlState } from 'app/core/utils/explore'; +import { initializeExploreSplit } from './state/actions'; import ErrorBoundary from './ErrorBoundary'; import Explore from './Explore'; interface WrapperProps { - backendSrv?: any; - datasourceSrv?: any; + initializeExploreSplit: typeof initializeExploreSplit; + split: boolean; updateLocation: typeof updateLocation; urlStates: { [key: string]: string }; } -interface WrapperState { - split: boolean; - splitState: ExploreState; -} - -const STATE_KEY_LEFT = 'state'; -const STATE_KEY_RIGHT = 'stateRight'; - -export class Wrapper extends Component { - urlStates: { [key: string]: string }; +export class Wrapper extends Component { + initialSplit: boolean; + urlStates: { [key: string]: ExploreUrlState }; constructor(props: WrapperProps) { super(props); - this.urlStates = props.urlStates; - this.state = { - split: Boolean(props.urlStates[STATE_KEY_RIGHT]), - splitState: undefined, - }; + 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; + } } - onChangeSplit = (split: boolean, splitState: ExploreState) => { - this.setState({ split, splitState }); - // When closing split, remove URL state for split part - if (!split) { - delete this.urlStates[STATE_KEY_RIGHT]; - this.props.updateLocation({ - query: this.urlStates, - }); + componentDidMount() { + if (this.initialSplit) { + this.props.initializeExploreSplit(); } - }; - - onSaveState = (key: string, state: ExploreState) => { - const urlState = serializeStateToUrlParam(state, true); - this.urlStates[key] = urlState; - this.props.updateLocation({ - query: this.urlStates, - }); - }; + } render() { - const { datasourceSrv } = this.props; - // State overrides for props from first Explore - const { split, splitState } = this.state; - const urlStateLeft = parseUrlState(this.urlStates[STATE_KEY_LEFT]); - const urlStateRight = parseUrlState(this.urlStates[STATE_KEY_RIGHT]); + const { split } = this.props; + const { leftState, rightState } = this.urlStates; return (
- + {split && ( - + )}
@@ -95,11 +60,14 @@ export class Wrapper extends Component { } } -const mapStateToProps = (state: StoreState) => ({ - urlStates: state.location.query, -}); +const mapStateToProps = (state: StoreState) => { + const urlStates = state.location.query; + const { split } = state.explore; + return { split, urlStates }; +}; const mapDispatchToProps = { + initializeExploreSplit, updateLocation, }; diff --git a/public/app/features/explore/state/actionTypes.ts b/public/app/features/explore/state/actionTypes.ts new file mode 100644 index 00000000000..b267da4f2c1 --- /dev/null +++ b/public/app/features/explore/state/actionTypes.ts @@ -0,0 +1,302 @@ +import { RawTimeRange, TimeRange } from '@grafana/ui'; + +import { Emitter } from 'app/core/core'; +import { + ExploreId, + ExploreItemState, + HistoryItem, + RangeScanner, + ResultType, + QueryTransaction, +} from 'app/types/explore'; +import { DataSourceSelectItem } from 'app/types/datasources'; +import { DataQuery } from 'app/types'; + +export enum ActionTypes { + AddQueryRow = 'explore/ADD_QUERY_ROW', + ChangeDatasource = 'explore/CHANGE_DATASOURCE', + ChangeQuery = 'explore/CHANGE_QUERY', + ChangeSize = 'explore/CHANGE_SIZE', + ChangeTime = 'explore/CHANGE_TIME', + ClearQueries = 'explore/CLEAR_QUERIES', + HighlightLogsExpression = 'explore/HIGHLIGHT_LOGS_EXPRESSION', + InitializeExplore = 'explore/INITIALIZE_EXPLORE', + InitializeExploreSplit = 'explore/INITIALIZE_EXPLORE_SPLIT', + LoadDatasourceFailure = 'explore/LOAD_DATASOURCE_FAILURE', + LoadDatasourceMissing = 'explore/LOAD_DATASOURCE_MISSING', + LoadDatasourcePending = 'explore/LOAD_DATASOURCE_PENDING', + LoadDatasourceSuccess = 'explore/LOAD_DATASOURCE_SUCCESS', + ModifyQueries = 'explore/MODIFY_QUERIES', + QueryTransactionFailure = 'explore/QUERY_TRANSACTION_FAILURE', + QueryTransactionStart = 'explore/QUERY_TRANSACTION_START', + QueryTransactionSuccess = 'explore/QUERY_TRANSACTION_SUCCESS', + RemoveQueryRow = 'explore/REMOVE_QUERY_ROW', + RunQueries = 'explore/RUN_QUERIES', + RunQueriesEmpty = 'explore/RUN_QUERIES_EMPTY', + ScanRange = 'explore/SCAN_RANGE', + ScanStart = 'explore/SCAN_START', + ScanStop = 'explore/SCAN_STOP', + SetQueries = 'explore/SET_QUERIES', + SplitClose = 'explore/SPLIT_CLOSE', + SplitOpen = 'explore/SPLIT_OPEN', + StateSave = 'explore/STATE_SAVE', + ToggleGraph = 'explore/TOGGLE_GRAPH', + ToggleLogs = 'explore/TOGGLE_LOGS', + ToggleTable = 'explore/TOGGLE_TABLE', +} + +export interface AddQueryRowAction { + type: ActionTypes.AddQueryRow; + payload: { + exploreId: ExploreId; + index: number; + query: DataQuery; + }; +} + +export interface ChangeQueryAction { + type: ActionTypes.ChangeQuery; + payload: { + exploreId: ExploreId; + query: DataQuery; + index: number; + override: boolean; + }; +} + +export interface ChangeSizeAction { + type: ActionTypes.ChangeSize; + payload: { + exploreId: ExploreId; + width: number; + height: number; + }; +} + +export interface ChangeTimeAction { + type: ActionTypes.ChangeTime; + payload: { + exploreId: ExploreId; + range: TimeRange; + }; +} + +export interface ClearQueriesAction { + type: ActionTypes.ClearQueries; + payload: { + exploreId: ExploreId; + }; +} + +export interface HighlightLogsExpressionAction { + type: ActionTypes.HighlightLogsExpression; + payload: { + exploreId: ExploreId; + expressions: string[]; + }; +} + +export interface InitializeExploreAction { + type: ActionTypes.InitializeExplore; + payload: { + exploreId: ExploreId; + containerWidth: number; + datasource: string; + eventBridge: Emitter; + exploreDatasources: DataSourceSelectItem[]; + queries: DataQuery[]; + range: RawTimeRange; + }; +} + +export interface InitializeExploreSplitAction { + type: ActionTypes.InitializeExploreSplit; +} + +export interface LoadDatasourceFailureAction { + type: ActionTypes.LoadDatasourceFailure; + payload: { + exploreId: ExploreId; + error: string; + }; +} + +export interface LoadDatasourcePendingAction { + type: ActionTypes.LoadDatasourcePending; + payload: { + exploreId: ExploreId; + datasourceId: number; + }; +} + +export interface LoadDatasourceMissingAction { + type: ActionTypes.LoadDatasourceMissing; + payload: { + exploreId: ExploreId; + }; +} + +export interface LoadDatasourceSuccessAction { + type: ActionTypes.LoadDatasourceSuccess; + payload: { + exploreId: ExploreId; + StartPage?: any; + datasourceInstance: any; + history: HistoryItem[]; + initialDatasource: string; + initialQueries: DataQuery[]; + logsHighlighterExpressions?: any[]; + showingStartPage: boolean; + supportsGraph: boolean; + supportsLogs: boolean; + supportsTable: boolean; + }; +} + +export interface ModifyQueriesAction { + type: ActionTypes.ModifyQueries; + payload: { + exploreId: ExploreId; + modification: any; + index: number; + modifier: (queries: DataQuery[], modification: any) => DataQuery[]; + }; +} + +export interface QueryTransactionFailureAction { + type: ActionTypes.QueryTransactionFailure; + payload: { + exploreId: ExploreId; + queryTransactions: QueryTransaction[]; + }; +} + +export interface QueryTransactionStartAction { + type: ActionTypes.QueryTransactionStart; + payload: { + exploreId: ExploreId; + resultType: ResultType; + rowIndex: number; + transaction: QueryTransaction; + }; +} + +export interface QueryTransactionSuccessAction { + type: ActionTypes.QueryTransactionSuccess; + payload: { + exploreId: ExploreId; + history: HistoryItem[]; + queryTransactions: QueryTransaction[]; + }; +} + +export interface RemoveQueryRowAction { + type: ActionTypes.RemoveQueryRow; + payload: { + exploreId: ExploreId; + index: number; + }; +} + +export interface RunQueriesEmptyAction { + type: ActionTypes.RunQueriesEmpty; + payload: { + exploreId: ExploreId; + }; +} + +export interface ScanStartAction { + type: ActionTypes.ScanStart; + payload: { + exploreId: ExploreId; + scanner: RangeScanner; + }; +} + +export interface ScanRangeAction { + type: ActionTypes.ScanRange; + payload: { + exploreId: ExploreId; + range: RawTimeRange; + }; +} + +export interface ScanStopAction { + type: ActionTypes.ScanStop; + payload: { + exploreId: ExploreId; + }; +} + +export interface SetQueriesAction { + type: ActionTypes.SetQueries; + payload: { + exploreId: ExploreId; + queries: DataQuery[]; + }; +} + +export interface SplitCloseAction { + type: ActionTypes.SplitClose; +} + +export interface SplitOpenAction { + type: ActionTypes.SplitOpen; + payload: { + itemState: ExploreItemState; + }; +} + +export interface StateSaveAction { + type: ActionTypes.StateSave; +} + +export interface ToggleTableAction { + type: ActionTypes.ToggleTable; + payload: { + exploreId: ExploreId; + }; +} + +export interface ToggleGraphAction { + type: ActionTypes.ToggleGraph; + payload: { + exploreId: ExploreId; + }; +} + +export interface ToggleLogsAction { + type: ActionTypes.ToggleLogs; + payload: { + exploreId: ExploreId; + }; +} + +export type Action = + | AddQueryRowAction + | ChangeQueryAction + | ChangeSizeAction + | ChangeTimeAction + | ClearQueriesAction + | HighlightLogsExpressionAction + | InitializeExploreAction + | InitializeExploreSplitAction + | LoadDatasourceFailureAction + | LoadDatasourceMissingAction + | LoadDatasourcePendingAction + | LoadDatasourceSuccessAction + | ModifyQueriesAction + | QueryTransactionFailureAction + | QueryTransactionStartAction + | QueryTransactionSuccessAction + | RemoveQueryRowAction + | RunQueriesEmptyAction + | ScanRangeAction + | ScanStartAction + | ScanStopAction + | SetQueriesAction + | SplitCloseAction + | SplitOpenAction + | ToggleGraphAction + | ToggleLogsAction + | ToggleTableAction; diff --git a/public/app/features/explore/state/actions.ts b/public/app/features/explore/state/actions.ts new file mode 100644 index 00000000000..ae0bce6a019 --- /dev/null +++ b/public/app/features/explore/state/actions.ts @@ -0,0 +1,757 @@ +import _ from 'lodash'; +import { ThunkAction } from 'redux-thunk'; +import { RawTimeRange, TimeRange } from '@grafana/ui'; + +import { + LAST_USED_DATASOURCE_KEY, + clearQueryKeys, + ensureQueries, + generateEmptyQuery, + hasNonEmptyQuery, + makeTimeSeriesList, + updateHistory, + buildQueryTransaction, + serializeStateToUrlParam, +} from 'app/core/utils/explore'; + +import { updateLocation } from 'app/core/actions'; +import store from 'app/core/store'; +import { DataSourceSelectItem } from 'app/types/datasources'; +import { DataQuery, StoreState } from 'app/types'; +import { getDatasourceSrv } from 'app/features/plugins/datasource_srv'; +import { + ExploreId, + ExploreUrlState, + RangeScanner, + ResultType, + QueryOptions, + QueryTransaction, + QueryHint, + QueryHintGetter, +} from 'app/types/explore'; +import { Emitter } from 'app/core/core'; + +import { + Action as ThunkableAction, + ActionTypes, + AddQueryRowAction, + ChangeSizeAction, + HighlightLogsExpressionAction, + LoadDatasourceFailureAction, + LoadDatasourceMissingAction, + LoadDatasourcePendingAction, + LoadDatasourceSuccessAction, + QueryTransactionStartAction, + ScanStopAction, +} from './actionTypes'; + +type ThunkResult = ThunkAction; + +/** + * Adds a query row after the row with the given index. + */ +export function addQueryRow(exploreId: ExploreId, index: number): AddQueryRowAction { + const query = generateEmptyQuery(index + 1); + return { type: ActionTypes.AddQueryRow, payload: { exploreId, index, query } }; +} + +/** + * Loads a new datasource identified by the given name. + */ +export function changeDatasource(exploreId: ExploreId, datasource: string): ThunkResult { + return async dispatch => { + const instance = await getDatasourceSrv().get(datasource); + dispatch(loadDatasource(exploreId, instance)); + }; +} + +/** + * Query change handler for the query row with the given index. + * If `override` is reset the query modifications and run the queries. Use this to set queries via a link. + */ +export function changeQuery( + exploreId: ExploreId, + query: DataQuery, + index: number, + override: boolean +): ThunkResult { + return dispatch => { + // Null query means reset + if (query === null) { + query = { ...generateEmptyQuery(index) }; + } + + dispatch({ type: ActionTypes.ChangeQuery, payload: { exploreId, query, index, override } }); + if (override) { + dispatch(runQueries(exploreId)); + } + }; +} + +/** + * Keep track of the Explore container size, in particular the width. + * The width will be used to calculate graph intervals (number of datapoints). + */ +export function changeSize( + exploreId: ExploreId, + { height, width }: { height: number; width: number } +): ChangeSizeAction { + return { type: ActionTypes.ChangeSize, payload: { exploreId, height, width } }; +} + +/** + * Change the time range of Explore. Usually called from the Timepicker or a graph interaction. + */ +export function changeTime(exploreId: ExploreId, range: TimeRange): ThunkResult { + return dispatch => { + dispatch({ type: ActionTypes.ChangeTime, payload: { exploreId, range } }); + dispatch(runQueries(exploreId)); + }; +} + +/** + * Clear all queries and results. + */ +export function clearQueries(exploreId: ExploreId): ThunkResult { + return dispatch => { + dispatch(scanStop(exploreId)); + dispatch({ type: ActionTypes.ClearQueries, payload: { exploreId } }); + dispatch(stateSave()); + }; +} + +/** + * Highlight expressions in the log results + */ +export function highlightLogsExpression(exploreId: ExploreId, expressions: string[]): HighlightLogsExpressionAction { + return { type: ActionTypes.HighlightLogsExpression, payload: { exploreId, expressions } }; +} + +/** + * Initialize Explore state with state from the URL and the React component. + * Call this only on components for with the Explore state has not been initialized. + */ +export function initializeExplore( + exploreId: ExploreId, + datasource: string, + queries: DataQuery[], + range: RawTimeRange, + containerWidth: number, + eventBridge: Emitter +): ThunkResult { + return async dispatch => { + const exploreDatasources: DataSourceSelectItem[] = getDatasourceSrv() + .getExternal() + .map(ds => ({ + value: ds.name, + name: ds.name, + meta: ds.meta, + })); + + dispatch({ + type: ActionTypes.InitializeExplore, + payload: { + exploreId, + containerWidth, + datasource, + eventBridge, + exploreDatasources, + queries, + range, + }, + }); + + if (exploreDatasources.length > 1) { + let instance; + if (datasource) { + instance = await getDatasourceSrv().get(datasource); + } else { + instance = await getDatasourceSrv().get(); + } + dispatch(loadDatasource(exploreId, instance)); + } else { + dispatch(loadDatasourceMissing(exploreId)); + } + }; +} + +/** + * Initialize the wrapper split state + */ +export function initializeExploreSplit() { + return async dispatch => { + dispatch({ type: ActionTypes.InitializeExploreSplit }); + }; +} + +/** + * Display an error that happened during the selection of a datasource + */ +export const loadDatasourceFailure = (exploreId: ExploreId, error: string): LoadDatasourceFailureAction => ({ + type: ActionTypes.LoadDatasourceFailure, + payload: { + exploreId, + error, + }, +}); + +/** + * Display an error when no datasources have been configured + */ +export const loadDatasourceMissing = (exploreId: ExploreId): LoadDatasourceMissingAction => ({ + type: ActionTypes.LoadDatasourceMissing, + payload: { exploreId }, +}); + +/** + * Start the async process of loading a datasource to display a loading indicator + */ +export const loadDatasourcePending = (exploreId: ExploreId, datasourceId: number): LoadDatasourcePendingAction => ({ + type: ActionTypes.LoadDatasourcePending, + payload: { + exploreId, + datasourceId, + }, +}); + +/** + * Datasource loading was successfully completed. The instance is stored in the state as well in case we need to + * run datasource-specific code. Existing queries are imported to the new datasource if an importer exists, + * e.g., Prometheus -> Loki queries. + */ +export const loadDatasourceSuccess = ( + exploreId: ExploreId, + instance: any, + queries: DataQuery[] +): LoadDatasourceSuccessAction => { + // Capabilities + const supportsGraph = instance.meta.metrics; + const supportsLogs = instance.meta.logs; + const supportsTable = instance.meta.tables; + // Custom components + const StartPage = instance.pluginExports.ExploreStartPage; + + const historyKey = `grafana.explore.history.${instance.meta.id}`; + const history = store.getObject(historyKey, []); + // Save last-used datasource + store.set(LAST_USED_DATASOURCE_KEY, instance.name); + + return { + type: ActionTypes.LoadDatasourceSuccess, + payload: { + exploreId, + StartPage, + datasourceInstance: instance, + history, + initialDatasource: instance.name, + initialQueries: queries, + showingStartPage: Boolean(StartPage), + supportsGraph, + supportsLogs, + supportsTable, + }, + }; +}; + +/** + * Main action to asynchronously load a datasource. Dispatches lots of smaller actions for feedback. + */ +export function loadDatasource(exploreId: ExploreId, instance: any): ThunkResult { + return async (dispatch, getState) => { + const datasourceId = instance.meta.id; + + // Keep ID to track selection + dispatch(loadDatasourcePending(exploreId, datasourceId)); + + let datasourceError = null; + try { + const testResult = await instance.testDatasource(); + datasourceError = testResult.status === 'success' ? null : testResult.message; + } catch (error) { + datasourceError = (error && error.statusText) || 'Network error'; + } + if (datasourceError) { + dispatch(loadDatasourceFailure(exploreId, datasourceError)); + return; + } + + if (datasourceId !== getState().explore[exploreId].requestedDatasourceId) { + // User already changed datasource again, discard results + return; + } + + if (instance.init) { + instance.init(); + } + + // Check if queries can be imported from previously selected datasource + const queries = getState().explore[exploreId].modifiedQueries; + let importedQueries = queries; + const origin = getState().explore[exploreId].datasourceInstance; + if (origin) { + if (origin.meta.id === instance.meta.id) { + // Keep same queries if same type of datasource + importedQueries = [...queries]; + } else if (instance.importQueries) { + // Datasource-specific importers + importedQueries = await instance.importQueries(queries, origin.meta); + } else { + // Default is blank queries + importedQueries = ensureQueries(); + } + } + + if (datasourceId !== getState().explore[exploreId].requestedDatasourceId) { + // User already changed datasource again, discard results + return; + } + + // Reset edit state with new queries + const nextQueries = importedQueries.map((q, i) => ({ + ...importedQueries[i], + ...generateEmptyQuery(i), + })); + + dispatch(loadDatasourceSuccess(exploreId, instance, nextQueries)); + dispatch(runQueries(exploreId)); + }; +} + +/** + * Action to modify a query given a datasource-specific modifier action. + * @param exploreId Explore area + * @param modification Action object with a type, e.g., ADD_FILTER + * @param index Optional query row index. If omitted, the modification is applied to all query rows. + * @param modifier Function that executes the modification, typically `datasourceInstance.modifyQueries`. + */ +export function modifyQueries( + exploreId: ExploreId, + modification: any, + index: number, + modifier: any +): ThunkResult { + return dispatch => { + dispatch({ type: ActionTypes.ModifyQueries, payload: { exploreId, modification, index, modifier } }); + if (!modification.preventSubmit) { + dispatch(runQueries(exploreId)); + } + }; +} + +/** + * Mark a query transaction as failed with an error extracted from the query response. + * The transaction will be marked as `done`. + */ +export function queryTransactionFailure( + exploreId: ExploreId, + transactionId: string, + response: any, + datasourceId: string +): ThunkResult { + return (dispatch, getState) => { + const { datasourceInstance, queryTransactions } = getState().explore[exploreId]; + if (datasourceInstance.meta.id !== datasourceId || response.cancelled) { + // Navigated away, queries did not matter + return; + } + + // Transaction might have been discarded + if (!queryTransactions.find(qt => qt.id === transactionId)) { + return; + } + + console.error(response); + + let error: string; + let errorDetails: string; + if (response.data) { + if (typeof response.data === 'string') { + error = response.data; + } else if (response.data.error) { + error = response.data.error; + if (response.data.response) { + errorDetails = response.data.response; + } + } else { + throw new Error('Could not handle error response'); + } + } else if (response.message) { + error = response.message; + } else if (typeof response === 'string') { + error = response; + } else { + error = 'Unknown error during query transaction. Please check JS console logs.'; + } + + // Mark transactions as complete + const nextQueryTransactions = queryTransactions.map(qt => { + if (qt.id === transactionId) { + return { + ...qt, + error, + errorDetails, + done: true, + }; + } + return qt; + }); + + dispatch({ + type: ActionTypes.QueryTransactionFailure, + payload: { exploreId, queryTransactions: nextQueryTransactions }, + }); + }; +} + +/** + * Start a query transaction for the given result type. + * @param exploreId Explore area + * @param transaction Query options and `done` status. + * @param resultType Associate the transaction with a result viewer, e.g., Graph + * @param rowIndex Index is used to associate latency for this transaction with a query row + */ +export function queryTransactionStart( + exploreId: ExploreId, + transaction: QueryTransaction, + resultType: ResultType, + rowIndex: number +): QueryTransactionStartAction { + return { type: ActionTypes.QueryTransactionStart, payload: { exploreId, resultType, rowIndex, transaction } }; +} + +/** + * Complete a query transaction, mark the transaction as `done` and store query state in URL. + * If the transaction was started by a scanner, it keeps on scanning for more results. + * Side-effect: the query is stored in localStorage. + * @param exploreId Explore area + * @param transactionId ID + * @param result Response from `datasourceInstance.query()` + * @param latency Duration between request and response + * @param queries Queries from all query rows + * @param datasourceId Origin datasource instance, used to discard results if current datasource is different + */ +export function queryTransactionSuccess( + exploreId: ExploreId, + transactionId: string, + result: any, + latency: number, + queries: DataQuery[], + datasourceId: string +): ThunkResult { + return (dispatch, getState) => { + const { datasourceInstance, history, queryTransactions, scanner, scanning } = getState().explore[exploreId]; + + // If datasource already changed, results do not matter + if (datasourceInstance.meta.id !== datasourceId) { + return; + } + + // Transaction might have been discarded + const transaction = queryTransactions.find(qt => qt.id === transactionId); + if (!transaction) { + return; + } + + // Get query hints + let hints: QueryHint[]; + if (datasourceInstance.getQueryHints as QueryHintGetter) { + hints = datasourceInstance.getQueryHints(transaction.query, result); + } + + // Mark transactions as complete and attach result + const nextQueryTransactions = queryTransactions.map(qt => { + if (qt.id === transactionId) { + return { + ...qt, + hints, + latency, + result, + done: true, + }; + } + return qt; + }); + + // Side-effect: Saving history in localstorage + const nextHistory = updateHistory(history, datasourceId, queries); + + dispatch({ + type: ActionTypes.QueryTransactionSuccess, + payload: { + exploreId, + history: nextHistory, + queryTransactions: nextQueryTransactions, + }, + }); + + // Keep scanning for results if this was the last scanning transaction + if (scanning) { + if (_.size(result) === 0) { + const other = nextQueryTransactions.find(qt => qt.scanning && !qt.done); + if (!other) { + const range = scanner(); + dispatch({ type: ActionTypes.ScanRange, payload: { exploreId, range } }); + } + } else { + // We can stop scanning if we have a result + dispatch(scanStop(exploreId)); + } + } + }; +} + +/** + * Remove query row of the given index, as well as associated query results. + */ +export function removeQueryRow(exploreId: ExploreId, index: number): ThunkResult { + return dispatch => { + dispatch({ type: ActionTypes.RemoveQueryRow, payload: { exploreId, index } }); + dispatch(runQueries(exploreId)); + }; +} + +/** + * Main action to run queries and dispatches sub-actions based on which result viewers are active + */ +export function runQueries(exploreId: ExploreId) { + return (dispatch, getState) => { + const { + datasourceInstance, + modifiedQueries, + showingLogs, + showingGraph, + showingTable, + supportsGraph, + supportsLogs, + supportsTable, + } = getState().explore[exploreId]; + + if (!hasNonEmptyQuery(modifiedQueries)) { + dispatch({ type: ActionTypes.RunQueriesEmpty, payload: { exploreId } }); + return; + } + + // Some datasource's query builders allow per-query interval limits, + // but we're using the datasource interval limit for now + const interval = datasourceInstance.interval; + + // Keep table queries first since they need to return quickly + if (showingTable && supportsTable) { + dispatch( + runQueriesForType( + exploreId, + 'Table', + { + interval, + format: 'table', + instant: true, + valueWithRefId: true, + }, + data => data[0] + ) + ); + } + if (showingGraph && supportsGraph) { + dispatch( + runQueriesForType( + exploreId, + 'Graph', + { + interval, + format: 'time_series', + instant: false, + }, + makeTimeSeriesList + ) + ); + } + if (showingLogs && supportsLogs) { + dispatch(runQueriesForType(exploreId, 'Logs', { interval, format: 'logs' })); + } + dispatch(stateSave()); + }; +} + +/** + * Helper action to build a query transaction object and handing the query to the datasource. + * @param exploreId Explore area + * @param resultType Result viewer that will be associated with this query result + * @param queryOptions Query options as required by the datasource's `query()` function. + * @param resultGetter Optional result extractor, e.g., if the result is a list and you only need the first element. + */ +function runQueriesForType( + exploreId: ExploreId, + resultType: ResultType, + queryOptions: QueryOptions, + resultGetter?: any +) { + return async (dispatch, getState) => { + const { + datasourceInstance, + eventBridge, + modifiedQueries: queries, + queryIntervals, + range, + scanning, + } = getState().explore[exploreId]; + const datasourceId = datasourceInstance.meta.id; + + // Run all queries concurrently + queries.forEach(async (query, rowIndex) => { + const transaction = buildQueryTransaction( + query, + rowIndex, + resultType, + queryOptions, + range, + queryIntervals, + scanning + ); + dispatch(queryTransactionStart(exploreId, transaction, resultType, rowIndex)); + try { + const now = Date.now(); + const res = await datasourceInstance.query(transaction.options); + eventBridge.emit('data-received', res.data || []); + const latency = Date.now() - now; + const results = resultGetter ? resultGetter(res.data) : res.data; + dispatch(queryTransactionSuccess(exploreId, transaction.id, results, latency, queries, datasourceId)); + } catch (response) { + eventBridge.emit('data-error', response); + dispatch(queryTransactionFailure(exploreId, transaction.id, response, datasourceId)); + } + }); + }; +} + +/** + * Start a scan for more results using the given scanner. + * @param exploreId Explore area + * @param scanner Function that a) returns a new time range and b) triggers a query run for the new range + */ +export function scanStart(exploreId: ExploreId, scanner: RangeScanner): ThunkResult { + return dispatch => { + // Register the scanner + dispatch({ type: ActionTypes.ScanStart, payload: { exploreId, scanner } }); + // Scanning must trigger query run, and return the new range + const range = scanner(); + // Set the new range to be displayed + dispatch({ type: ActionTypes.ScanRange, payload: { exploreId, range } }); + }; +} + +/** + * Stop any scanning for more results. + */ +export function scanStop(exploreId: ExploreId): ScanStopAction { + return { type: ActionTypes.ScanStop, payload: { exploreId } }; +} + +/** + * Reset queries to the given queries. Any modifications will be discarded. + * Use this action for clicks on query examples. Triggers a query run. + */ +export function setQueries(exploreId: ExploreId, rawQueries: DataQuery[]): ThunkResult { + return dispatch => { + // Inject react keys into query objects + const queries = rawQueries.map(q => ({ ...q, ...generateEmptyQuery() })); + dispatch({ + type: ActionTypes.SetQueries, + payload: { + exploreId, + queries, + }, + }); + dispatch(runQueries(exploreId)); + }; +} + +/** + * Close the split view and save URL state. + */ +export function splitClose(): ThunkResult { + return dispatch => { + dispatch({ type: ActionTypes.SplitClose }); + dispatch(stateSave()); + }; +} + +/** + * Open the split view and copy the left state to be the right state. + * The right state is automatically initialized. + * The copy keeps all query modifications but wipes the query results. + */ +export function splitOpen(): ThunkResult { + return (dispatch, getState) => { + // Clone left state to become the right state + const leftState = getState().explore.left; + const itemState = { + ...leftState, + queryTransactions: [], + initialQueries: leftState.modifiedQueries.slice(), + }; + dispatch({ type: ActionTypes.SplitOpen, payload: { itemState } }); + dispatch(stateSave()); + }; +} + +/** + * Saves Explore state to URL using the `left` and `right` parameters. + * If split view is not active, `right` will not be set. + */ +export function stateSave() { + return (dispatch, getState) => { + const { left, right, split } = getState().explore; + const urlStates: { [index: string]: string } = {}; + const leftUrlState: ExploreUrlState = { + datasource: left.datasourceInstance.name, + queries: left.modifiedQueries.map(clearQueryKeys), + range: left.range, + }; + urlStates.left = serializeStateToUrlParam(leftUrlState, true); + if (split) { + const rightUrlState: ExploreUrlState = { + datasource: right.datasourceInstance.name, + queries: right.modifiedQueries.map(clearQueryKeys), + range: right.range, + }; + urlStates.right = serializeStateToUrlParam(rightUrlState, true); + } + dispatch(updateLocation({ query: urlStates })); + }; +} + +/** + * Expand/collapse the graph result viewer. When collapsed, graph queries won't be run. + */ +export function toggleGraph(exploreId: ExploreId): ThunkResult { + return (dispatch, getState) => { + dispatch({ type: ActionTypes.ToggleGraph, payload: { exploreId } }); + if (getState().explore[exploreId].showingGraph) { + dispatch(runQueries(exploreId)); + } + }; +} + +/** + * Expand/collapse the logs result viewer. When collapsed, log queries won't be run. + */ +export function toggleLogs(exploreId: ExploreId): ThunkResult { + return (dispatch, getState) => { + dispatch({ type: ActionTypes.ToggleLogs, payload: { exploreId } }); + if (getState().explore[exploreId].showingLogs) { + dispatch(runQueries(exploreId)); + } + }; +} + +/** + * Expand/collapse the table result viewer. When collapsed, table queries won't be run. + */ +export function toggleTable(exploreId: ExploreId): ThunkResult { + return (dispatch, getState) => { + dispatch({ type: ActionTypes.ToggleTable, payload: { exploreId } }); + if (getState().explore[exploreId].showingTable) { + dispatch(runQueries(exploreId)); + } + }; +} diff --git a/public/app/features/explore/state/reducers.ts b/public/app/features/explore/state/reducers.ts new file mode 100644 index 00000000000..b112a5370e3 --- /dev/null +++ b/public/app/features/explore/state/reducers.ts @@ -0,0 +1,462 @@ +import { + calculateResultsFromQueryTransactions, + generateEmptyQuery, + getIntervals, + ensureQueries, +} from 'app/core/utils/explore'; +import { ExploreItemState, ExploreState, QueryTransaction } from 'app/types/explore'; +import { DataQuery } from 'app/types/series'; + +import { Action, ActionTypes } from './actionTypes'; + +export const DEFAULT_RANGE = { + from: 'now-6h', + to: 'now', +}; + +// Millies step for helper bar charts +const DEFAULT_GRAPH_INTERVAL = 15 * 1000; + +/** + * Returns a fresh Explore area state + */ +const makeExploreItemState = (): ExploreItemState => ({ + StartPage: undefined, + containerWidth: 0, + datasourceInstance: null, + datasourceError: null, + datasourceLoading: null, + datasourceMissing: false, + exploreDatasources: [], + history: [], + initialQueries: [], + initialized: false, + modifiedQueries: [], + queryTransactions: [], + queryIntervals: { interval: '15s', intervalMs: DEFAULT_GRAPH_INTERVAL }, + range: DEFAULT_RANGE, + scanning: false, + scanRange: null, + showingGraph: true, + showingLogs: true, + showingTable: true, + supportsGraph: null, + supportsLogs: null, + supportsTable: null, +}); + +/** + * Global Explore state that handles multiple Explore areas and the split state + */ +const initialExploreState: ExploreState = { + split: null, + left: makeExploreItemState(), + right: makeExploreItemState(), +}; + +/** + * Reducer for an Explore area, to be used by the global Explore reducer. + */ +const itemReducer = (state, action: Action): ExploreItemState => { + switch (action.type) { + case ActionTypes.AddQueryRow: { + const { initialQueries, modifiedQueries, queryTransactions } = state; + const { index, query } = action.payload; + + // Add new query row after given index, keep modifications of existing rows + const nextModifiedQueries = [ + ...modifiedQueries.slice(0, index + 1), + { ...query }, + ...initialQueries.slice(index + 1), + ]; + + // Add to initialQueries, which will cause a new row to be rendered + const nextQueries = [...initialQueries.slice(0, index + 1), { ...query }, ...initialQueries.slice(index + 1)]; + + // Ongoing transactions need to update their row indices + const nextQueryTransactions = queryTransactions.map(qt => { + if (qt.rowIndex > index) { + return { + ...qt, + rowIndex: qt.rowIndex + 1, + }; + } + return qt; + }); + + return { + ...state, + initialQueries: nextQueries, + logsHighlighterExpressions: undefined, + modifiedQueries: nextModifiedQueries, + queryTransactions: nextQueryTransactions, + }; + } + + case ActionTypes.ChangeQuery: { + const { initialQueries, queryTransactions } = state; + let { modifiedQueries } = state; + const { query, index, override } = action.payload; + + // Fast path: only change modifiedQueries to not trigger an update + modifiedQueries[index] = query; + if (!override) { + return { + ...state, + modifiedQueries, + }; + } + + // Override path: queries are completely reset + const nextQuery: DataQuery = { + ...query, + ...generateEmptyQuery(index), + }; + const nextQueries = [...initialQueries]; + nextQueries[index] = nextQuery; + modifiedQueries = [...nextQueries]; + + // Discard ongoing transaction related to row query + const nextQueryTransactions = queryTransactions.filter(qt => qt.rowIndex !== index); + + return { + ...state, + initialQueries: nextQueries, + modifiedQueries: nextQueries.slice(), + queryTransactions: nextQueryTransactions, + }; + } + + case ActionTypes.ChangeSize: { + const { range, datasourceInstance } = state; + let interval = '1s'; + if (datasourceInstance && datasourceInstance.interval) { + interval = datasourceInstance.interval; + } + const containerWidth = action.payload.width; + const queryIntervals = getIntervals(range, interval, containerWidth); + return { ...state, containerWidth, queryIntervals }; + } + + case ActionTypes.ChangeTime: { + return { + ...state, + range: action.payload.range, + }; + } + + case ActionTypes.ClearQueries: { + const queries = ensureQueries(); + return { + ...state, + initialQueries: queries.slice(), + modifiedQueries: queries.slice(), + queryTransactions: [], + showingStartPage: Boolean(state.StartPage), + }; + } + + case ActionTypes.HighlightLogsExpression: { + const { expressions } = action.payload; + return { ...state, logsHighlighterExpressions: expressions }; + } + + case ActionTypes.InitializeExplore: { + const { containerWidth, datasource, eventBridge, exploreDatasources, queries, range } = action.payload; + return { + ...state, + containerWidth, + eventBridge, + exploreDatasources, + range, + initialDatasource: datasource, + initialQueries: queries, + initialized: true, + modifiedQueries: queries.slice(), + }; + } + + case ActionTypes.LoadDatasourceFailure: { + return { ...state, datasourceError: action.payload.error, datasourceLoading: false }; + } + + case ActionTypes.LoadDatasourceMissing: { + return { ...state, datasourceMissing: true, datasourceLoading: false }; + } + + case ActionTypes.LoadDatasourcePending: { + return { ...state, datasourceLoading: true, requestedDatasourceId: action.payload.datasourceId }; + } + + case ActionTypes.LoadDatasourceSuccess: { + const { containerWidth, range } = state; + const { + StartPage, + datasourceInstance, + history, + initialDatasource, + initialQueries, + showingStartPage, + supportsGraph, + supportsLogs, + supportsTable, + } = action.payload; + const queryIntervals = getIntervals(range, datasourceInstance.interval, containerWidth); + + return { + ...state, + queryIntervals, + StartPage, + datasourceInstance, + history, + initialDatasource, + initialQueries, + showingStartPage, + supportsGraph, + supportsLogs, + supportsTable, + datasourceLoading: false, + datasourceMissing: false, + logsHighlighterExpressions: undefined, + modifiedQueries: initialQueries.slice(), + queryTransactions: [], + }; + } + + case ActionTypes.ModifyQueries: { + const { initialQueries, modifiedQueries, queryTransactions } = state; + const { modification, index, modifier } = action.payload as any; + let nextQueries: DataQuery[]; + let nextQueryTransactions; + if (index === undefined) { + // Modify all queries + nextQueries = initialQueries.map((query, i) => ({ + ...modifier(modifiedQueries[i], modification), + ...generateEmptyQuery(i), + })); + // Discard all ongoing transactions + nextQueryTransactions = []; + } else { + // Modify query only at index + nextQueries = initialQueries.map((query, i) => { + // Synchronize all queries with local query cache to ensure consistency + // TODO still needed? + return i === index + ? { + ...modifier(modifiedQueries[i], modification), + ...generateEmptyQuery(i), + } + : query; + }); + nextQueryTransactions = queryTransactions + // Consume the hint corresponding to the action + .map(qt => { + if (qt.hints != null && qt.rowIndex === index) { + qt.hints = qt.hints.filter(hint => hint.fix.action !== modification); + } + return qt; + }) + // Preserve previous row query transaction to keep results visible if next query is incomplete + .filter(qt => modification.preventSubmit || qt.rowIndex !== index); + } + return { + ...state, + initialQueries: nextQueries, + modifiedQueries: nextQueries.slice(), + queryTransactions: nextQueryTransactions, + }; + } + + case ActionTypes.QueryTransactionFailure: { + const { queryTransactions } = action.payload; + return { + ...state, + queryTransactions, + showingStartPage: false, + }; + } + + case ActionTypes.QueryTransactionStart: { + const { datasourceInstance, queryIntervals, queryTransactions } = state; + const { resultType, rowIndex, transaction } = action.payload; + // Discarding existing transactions of same type + const remainingTransactions = queryTransactions.filter( + qt => !(qt.resultType === resultType && qt.rowIndex === rowIndex) + ); + + // Append new transaction + const nextQueryTransactions: QueryTransaction[] = [...remainingTransactions, transaction]; + + const results = calculateResultsFromQueryTransactions( + nextQueryTransactions, + datasourceInstance, + queryIntervals.intervalMs + ); + + return { + ...state, + ...results, + queryTransactions: nextQueryTransactions, + showingStartPage: false, + }; + } + + case ActionTypes.QueryTransactionSuccess: { + const { datasourceInstance, queryIntervals } = state; + const { history, queryTransactions } = action.payload; + const results = calculateResultsFromQueryTransactions( + queryTransactions, + datasourceInstance, + queryIntervals.intervalMs + ); + + return { + ...state, + ...results, + history, + queryTransactions, + showingStartPage: false, + }; + } + + case ActionTypes.RemoveQueryRow: { + const { datasourceInstance, initialQueries, queryIntervals, queryTransactions } = state; + let { modifiedQueries } = state; + const { index } = action.payload; + + modifiedQueries = [...modifiedQueries.slice(0, index), ...modifiedQueries.slice(index + 1)]; + + if (initialQueries.length <= 1) { + return state; + } + + const nextQueries = [...initialQueries.slice(0, index), ...initialQueries.slice(index + 1)]; + + // Discard transactions related to row query + const nextQueryTransactions = queryTransactions.filter(qt => qt.rowIndex !== index); + const results = calculateResultsFromQueryTransactions( + nextQueryTransactions, + datasourceInstance, + queryIntervals.intervalMs + ); + + return { + ...state, + ...results, + initialQueries: nextQueries, + logsHighlighterExpressions: undefined, + modifiedQueries: nextQueries.slice(), + queryTransactions: nextQueryTransactions, + }; + } + + case ActionTypes.RunQueriesEmpty: { + return { ...state, queryTransactions: [] }; + } + + case ActionTypes.ScanRange: { + return { ...state, scanRange: action.payload.range }; + } + + case ActionTypes.ScanStart: { + return { ...state, scanning: true }; + } + + case ActionTypes.ScanStop: { + const { queryTransactions } = state; + const nextQueryTransactions = queryTransactions.filter(qt => qt.scanning && !qt.done); + return { ...state, queryTransactions: nextQueryTransactions, scanning: false, scanRange: undefined }; + } + + case ActionTypes.SetQueries: { + const { queries } = action.payload; + return { ...state, initialQueries: queries.slice(), modifiedQueries: queries.slice() }; + } + + case ActionTypes.ToggleGraph: { + const showingGraph = !state.showingGraph; + let nextQueryTransactions = state.queryTransactions; + if (!showingGraph) { + // Discard transactions related to Graph query + nextQueryTransactions = state.queryTransactions.filter(qt => qt.resultType !== 'Graph'); + } + return { ...state, queryTransactions: nextQueryTransactions, showingGraph }; + } + + case ActionTypes.ToggleLogs: { + const showingLogs = !state.showingLogs; + let nextQueryTransactions = state.queryTransactions; + if (!showingLogs) { + // Discard transactions related to Logs query + nextQueryTransactions = state.queryTransactions.filter(qt => qt.resultType !== 'Logs'); + } + return { ...state, queryTransactions: nextQueryTransactions, showingLogs }; + } + + case ActionTypes.ToggleTable: { + const showingTable = !state.showingTable; + if (showingTable) { + return { ...state, showingTable, queryTransactions: state.queryTransactions }; + } + + // Toggle off needs discarding of table queries and results + const nextQueryTransactions = state.queryTransactions.filter(qt => qt.resultType !== 'Table'); + const results = calculateResultsFromQueryTransactions( + nextQueryTransactions, + state.datasourceInstance, + state.queryIntervals.intervalMs + ); + + return { ...state, ...results, queryTransactions: nextQueryTransactions, showingTable }; + } + } + + return state; +}; + +/** + * Global Explore reducer that handles multiple Explore areas (left and right). + * Actions that have an `exploreId` get routed to the ExploreItemReducer. + */ +export const exploreReducer = (state = initialExploreState, action: Action): ExploreState => { + switch (action.type) { + case ActionTypes.SplitClose: { + return { + ...state, + split: false, + }; + } + + case ActionTypes.SplitOpen: { + return { + ...state, + split: true, + right: action.payload.itemState, + }; + } + + case ActionTypes.InitializeExploreSplit: { + return { + ...state, + split: true, + }; + } + } + + if (action.payload) { + const { exploreId } = action.payload as any; + if (exploreId !== undefined) { + const exploreItemState = state[exploreId]; + return { + ...state, + [exploreId]: itemReducer(exploreItemState, action), + }; + } + } + + return state; +}; + +export default { + explore: exploreReducer, +}; diff --git a/public/app/features/teams/TeamSettings.tsx b/public/app/features/teams/TeamSettings.tsx index 22815dbb7ec..87c67b6e597 100644 --- a/public/app/features/teams/TeamSettings.tsx +++ b/public/app/features/teams/TeamSettings.tsx @@ -1,7 +1,7 @@ import React from 'react'; import { connect } from 'react-redux'; - import { Label } from '@grafana/ui'; + import { SharedPreferences } from 'app/core/components/SharedPreferences/SharedPreferences'; import { updateTeam } from './state/actions'; import { getRouteParamsId } from 'app/core/selectors/location'; diff --git a/public/app/features/templating/variable_srv.ts b/public/app/features/templating/variable_srv.ts index bc0362f0678..896987de706 100644 --- a/public/app/features/templating/variable_srv.ts +++ b/public/app/features/templating/variable_srv.ts @@ -132,7 +132,7 @@ export class VariableSrv { return this.$q.all(promises).then(() => { if (emitChangeEvents) { - this.$rootScope.$emit('template-variable-value-updated'); + this.$rootScope.appEvent('template-variable-value-updated'); this.dashboard.startRefresh(); } }); diff --git a/public/app/plugins/panel/gauge/GaugeOptionsEditor.tsx b/public/app/plugins/panel/gauge/GaugeOptionsEditor.tsx index 6a698e09565..b971e67939c 100644 --- a/public/app/plugins/panel/gauge/GaugeOptionsEditor.tsx +++ b/public/app/plugins/panel/gauge/GaugeOptionsEditor.tsx @@ -1,8 +1,8 @@ import React, { PureComponent } from 'react'; -import { GaugeOptions, PanelOptionsProps, PanelOptionsGroup } from '@grafana/ui'; +import { FormField, PanelOptionsProps, PanelOptionsGroup } from '@grafana/ui'; import { Switch } from 'app/core/components/Switch/Switch'; -import { FormField } from '@grafana/ui/src'; +import { GaugeOptions } from './types'; export default class GaugeOptionsEditor extends PureComponent> { onToggleThresholdLabels = () => diff --git a/public/app/plugins/panel/gauge/GaugePanel.tsx b/public/app/plugins/panel/gauge/GaugePanel.tsx index 38a88428be7..cfce719b5a6 100644 --- a/public/app/plugins/panel/gauge/GaugePanel.tsx +++ b/public/app/plugins/panel/gauge/GaugePanel.tsx @@ -1,14 +1,18 @@ import React, { PureComponent } from 'react'; -import { GaugeOptions, PanelProps, NullValueMode } from '@grafana/ui'; +import { PanelProps, NullValueMode } from '@grafana/ui'; import { getTimeSeriesVMs } from 'app/viz/state/timeSeries'; import Gauge from 'app/viz/Gauge'; +import { GaugeOptions } from './types'; interface Props extends PanelProps {} export class GaugePanel extends PureComponent { render() { - const { timeSeries, width, height } = this.props; + const { timeSeries, width, height, onInterpolate, options } = this.props; + + const prefix = onInterpolate(options.prefix); + const suffix = onInterpolate(options.suffix); const vmSeries = getTimeSeriesVMs({ timeSeries: timeSeries, @@ -21,6 +25,8 @@ export class GaugePanel extends PureComponent { {...this.props.options} width={width} height={height} + prefix={prefix} + suffix={suffix} /> ); } diff --git a/public/app/plugins/panel/gauge/GaugePanelOptions.tsx b/public/app/plugins/panel/gauge/GaugePanelOptions.tsx index a5334b0c6e1..9729416b7e6 100644 --- a/public/app/plugins/panel/gauge/GaugePanelOptions.tsx +++ b/public/app/plugins/panel/gauge/GaugePanelOptions.tsx @@ -1,16 +1,17 @@ import React, { PureComponent } from 'react'; import { BasicGaugeColor, - GaugeOptions, PanelOptionsProps, ThresholdsEditor, Threshold, PanelOptionsGrid, + ValueMappingsEditor, + ValueMapping, } from '@grafana/ui'; import ValueOptions from 'app/plugins/panel/gauge/ValueOptions'; -import ValueMappings from 'app/plugins/panel/gauge/ValueMappings'; import GaugeOptionsEditor from './GaugeOptionsEditor'; +import { GaugeOptions } from './types'; export const defaultProps = { options: { @@ -24,7 +25,7 @@ export const defaultProps = { decimals: 0, stat: 'avg', unit: 'none', - mappings: [], + valueMappings: [], thresholds: [], }, }; @@ -32,7 +33,17 @@ export const defaultProps = { export default class GaugePanelOptions extends PureComponent> { static defaultProps = defaultProps; - onThresholdsChanged = (thresholds: Threshold[]) => this.props.onChange({ ...this.props.options, thresholds }); + onThresholdsChanged = (thresholds: Threshold[]) => + this.props.onChange({ + ...this.props.options, + thresholds, + }); + + onValueMappingsChanged = (valueMappings: ValueMapping[]) => + this.props.onChange({ + ...this.props.options, + valueMappings, + }); render() { const { onChange, options } = this.props; @@ -44,7 +55,7 @@ export default class GaugePanelOptions extends PureComponent - + ); } diff --git a/public/app/plugins/panel/gauge/ValueOptions.tsx b/public/app/plugins/panel/gauge/ValueOptions.tsx index d68b2dcf0fd..e0dbb36bdf5 100644 --- a/public/app/plugins/panel/gauge/ValueOptions.tsx +++ b/public/app/plugins/panel/gauge/ValueOptions.tsx @@ -1,6 +1,7 @@ import React, { PureComponent } from 'react'; -import { FormField, Label, GaugeOptions, PanelOptionsProps, PanelOptionsGroup, Select } from '@grafana/ui'; +import { FormField, Label, PanelOptionsProps, PanelOptionsGroup, Select } from '@grafana/ui'; import UnitPicker from 'app/core/components/Select/UnitPicker'; +import { GaugeOptions } from './types'; const statOptions = [ { value: 'min', label: 'Min' }, diff --git a/public/app/plugins/panel/gauge/types.ts b/public/app/plugins/panel/gauge/types.ts index 139597f9cb0..b698a3389c2 100644 --- a/public/app/plugins/panel/gauge/types.ts +++ b/public/app/plugins/panel/gauge/types.ts @@ -1,2 +1,16 @@ +import { Threshold, ValueMapping } from '@grafana/ui'; - +export interface GaugeOptions { + baseColor: string; + decimals: number; + valueMappings: ValueMapping[]; + maxValue: number; + minValue: number; + prefix: string; + showThresholdLabels: boolean; + showThresholdMarkers: boolean; + stat: string; + suffix: string; + thresholds: Threshold[]; + unit: string; +} diff --git a/public/app/store/configureStore.ts b/public/app/store/configureStore.ts index 943aff80a70..570a387cd74 100644 --- a/public/app/store/configureStore.ts +++ b/public/app/store/configureStore.ts @@ -7,6 +7,7 @@ import teamsReducers from 'app/features/teams/state/reducers'; import apiKeysReducers from 'app/features/api-keys/state/reducers'; import foldersReducers from 'app/features/folders/state/reducers'; import dashboardReducers from 'app/features/dashboard/state/reducers'; +import exploreReducers from 'app/features/explore/state/reducers'; import pluginReducers from 'app/features/plugins/state/reducers'; import dataSourcesReducers from 'app/features/datasources/state/reducers'; import usersReducers from 'app/features/users/state/reducers'; @@ -20,6 +21,7 @@ const rootReducers = { ...apiKeysReducers, ...foldersReducers, ...dashboardReducers, + ...exploreReducers, ...pluginReducers, ...dataSourcesReducers, ...usersReducers, diff --git a/public/app/types/explore.ts b/public/app/types/explore.ts index c2c59d35f5b..5636bb3acdb 100644 --- a/public/app/types/explore.ts +++ b/public/app/types/explore.ts @@ -1,10 +1,12 @@ import { Value } from 'slate'; +import { RawTimeRange, TimeRange } from '@grafana/ui'; + +import { Emitter } from 'app/core/core'; +import { LogsModel } from 'app/core/logs_model'; +import TableModel from 'app/core/table_model'; +import { DataSourceSelectItem } from 'app/types/datasources'; import { DataQuery } from './series'; -import { RawTimeRange } from '@grafana/ui'; -import TableModel from 'app/core/table_model'; -import { LogsModel } from 'app/core/logs_model'; -import { DataSourceSelectItem } from 'app/types/datasources'; export interface CompletionItem { /** @@ -76,6 +78,174 @@ export interface CompletionItemGroup { skipSort?: boolean; } +export enum ExploreId { + left = 'left', + right = 'right', +} + +/** + * Global Explore state + */ +export interface ExploreState { + /** + * True if split view is active. + */ + split: boolean; + /** + * Explore state of the left split (left is default in non-split view). + */ + left: ExploreItemState; + /** + * Explore state of the right area in split view. + */ + right: ExploreItemState; +} + +export interface ExploreItemState { + /** + * React component to be shown when no queries have been run yet, e.g., for a query language cheat sheet. + */ + StartPage?: any; + /** + * Width used for calculating the graph interval (can't have more datapoints than pixels) + */ + containerWidth: number; + /** + * Datasource instance that has been selected. Datasource-specific logic can be run on this object. + */ + datasourceInstance: any; + /** + * Error to be shown when datasource loading or testing failed. + */ + datasourceError: string; + /** + * True if the datasource is loading. `null` if the loading has not started yet. + */ + datasourceLoading: boolean | null; + /** + * True if there is no datasource to be selected. + */ + datasourceMissing: boolean; + /** + * Emitter to send events to the rest of Grafana. + */ + eventBridge?: Emitter; + /** + * List of datasources to be shown in the datasource selector. + */ + exploreDatasources: DataSourceSelectItem[]; + /** + * List of timeseries to be shown in the Explore graph result viewer. + */ + graphResult?: any[]; + /** + * History of recent queries. Datasource-specific and initialized via localStorage. + */ + history: HistoryItem[]; + /** + * Initial datasource for this Explore, e.g., set via URL. + */ + initialDatasource?: string; + /** + * Initial queries for this Explore, e.g., set via URL. Each query will be + * converted to a query row. Query edits should be tracked in `modifiedQueries` though. + */ + initialQueries: DataQuery[]; + /** + * True if this Explore area has been initialized. + * Used to distinguish URL state injection versus split view state injection. + */ + initialized: boolean; + /** + * Log line substrings to be highlighted as you type in a query field. + * Currently supports only the first query row. + */ + logsHighlighterExpressions?: string[]; + /** + * Log query result to be displayed in the logs result viewer. + */ + logsResult?: LogsModel; + /** + * Copy of `initialQueries` that tracks user edits. + * Don't connect this property to a react component as it is updated on every query change. + * Used when running queries. Needs to be reset to `initialQueries` when those are reset as well. + */ + modifiedQueries: DataQuery[]; + /** + * Query intervals for graph queries to determine how many datapoints to return. + * Needs to be updated when `datasourceInstance` or `containerWidth` is changed. + */ + queryIntervals: QueryIntervals; + /** + * List of query transaction to track query duration and query result. + * Graph/Logs/Table results are calculated on the fly from the transaction, + * based on the transaction's result types. Transaction also holds the row index + * so that results can be dropped and re-computed without running queries again + * when query rows are removed. + */ + queryTransactions: QueryTransaction[]; + /** + * Tracks datasource when selected in the datasource selector. + * Allows the selection to be discarded if something went wrong during the asynchronous + * loading of the datasource. + */ + requestedDatasourceId?: number; + /** + * Time range for this Explore. Managed by the time picker and used by all query runs. + */ + range: TimeRange | RawTimeRange; + /** + * Scanner function that calculates a new range, triggers a query run, and returns the new range. + */ + scanner?: RangeScanner; + /** + * True if scanning for more results is active. + */ + scanning?: boolean; + /** + * Current scanning range to be shown to the user while scanning is active. + */ + scanRange?: RawTimeRange; + /** + * True if graph result viewer is expanded. Query runs will contain graph queries. + */ + showingGraph: boolean; + /** + * True if logs result viewer is expanded. Query runs will contain logs queries. + */ + showingLogs: boolean; + /** + * True StartPage needs to be shown. Typically set to `false` once queries have been run. + */ + showingStartPage?: boolean; + /** + * True if table result viewer is expanded. Query runs will contain table queries. + */ + showingTable: boolean; + /** + * True if `datasourceInstance` supports graph queries. + */ + supportsGraph: boolean | null; + /** + * True if `datasourceInstance` supports logs queries. + */ + supportsLogs: boolean | null; + /** + * True if `datasourceInstance` supports table queries. + */ + supportsTable: boolean | null; + /** + * Table model that combines all query table results into a single table. + */ + tableResult?: TableModel; +} + +export interface ExploreUrlState { + datasource: string; + queries: any[]; // Should be a DataQuery, but we're going to strip refIds, so typing makes less sense + range: RawTimeRange; +} + export interface HistoryItem { ts: number; query: DataQuery; @@ -128,6 +298,19 @@ export interface QueryHintGetter { (query: DataQuery, results: any[], ...rest: any): QueryHint[]; } +export interface QueryIntervals { + interval: string; + intervalMs: number; +} + +export interface QueryOptions { + interval: string; + format: string; + hinting?: boolean; + instant?: boolean; + valueWithRefId?: boolean; +} + export interface QueryTransaction { id: string; done: boolean; @@ -142,6 +325,8 @@ export interface QueryTransaction { scanning?: boolean; } +export type RangeScanner = () => RawTimeRange; + export interface TextMatch { text: string; start: number; @@ -149,38 +334,4 @@ export interface TextMatch { end: number; } -export interface ExploreState { - StartPage?: any; - datasource: any; - datasourceError: any; - datasourceLoading: boolean | null; - datasourceMissing: boolean; - exploreDatasources: DataSourceSelectItem[]; - graphInterval: number; // in ms - graphResult?: any[]; - history: HistoryItem[]; - initialDatasource?: string; - initialQueries: DataQuery[]; - logsHighlighterExpressions?: string[]; - logsResult?: LogsModel; - queryTransactions: QueryTransaction[]; - range: RawTimeRange; - scanning?: boolean; - scanRange?: RawTimeRange; - showingGraph: boolean; - showingLogs: boolean; - showingStartPage?: boolean; - showingTable: boolean; - supportsGraph: boolean | null; - supportsLogs: boolean | null; - supportsTable: boolean | null; - tableResult?: TableModel; -} - -export interface ExploreUrlState { - datasource: string; - queries: any[]; // Should be a DataQuery, but we're going to strip refIds, so typing makes less sense - range: RawTimeRange; -} - export type ResultType = 'Graph' | 'Logs' | 'Table'; diff --git a/public/app/types/index.ts b/public/app/types/index.ts index 72da1c76ea8..ad9f19e2c9f 100644 --- a/public/app/types/index.ts +++ b/public/app/types/index.ts @@ -19,6 +19,7 @@ import { } from './appNotifications'; import { DashboardSearchHit } from './search'; import { ValidationEvents, ValidationRule } from './form'; +import { ExploreState } from './explore'; export { Team, TeamsState, @@ -81,6 +82,7 @@ export interface StoreState { folder: FolderState; dashboard: DashboardState; dataSources: DataSourcesState; + explore: ExploreState; users: UsersState; organization: OrganizationState; appNotifications: AppNotificationsState; diff --git a/public/app/viz/Gauge.test.tsx b/public/app/viz/Gauge.test.tsx index f0c4a874649..69c7733f44b 100644 --- a/public/app/viz/Gauge.test.tsx +++ b/public/app/viz/Gauge.test.tsx @@ -12,7 +12,7 @@ const setup = (propOverrides?: object) => { const props: Props = { baseColor: BasicGaugeColor.Green, maxValue: 100, - mappings: [], + valueMappings: [], minValue: 0, prefix: '', showThresholdMarkers: true, diff --git a/public/app/viz/Gauge.tsx b/public/app/viz/Gauge.tsx index 5112ff9aa1b..094e630a1c0 100644 --- a/public/app/viz/Gauge.tsx +++ b/public/app/viz/Gauge.tsx @@ -1,6 +1,6 @@ import React, { PureComponent } from 'react'; import $ from 'jquery'; -import { BasicGaugeColor, Threshold, TimeSeriesVMs, RangeMap, ValueMap, MappingType } from '@grafana/ui'; +import { BasicGaugeColor, Threshold, TimeSeriesVMs, MappingType, ValueMapping } from '@grafana/ui'; import config from '../core/config'; import kbn from '../core/utils/kbn'; @@ -9,7 +9,7 @@ export interface Props { baseColor: string; decimals: number; height: number; - mappings: Array; + valueMappings: ValueMapping[]; maxValue: number; minValue: number; prefix: string; @@ -29,7 +29,7 @@ export class Gauge extends PureComponent { static defaultProps = { baseColor: BasicGaugeColor.Green, maxValue: 100, - mappings: [], + valueMappings: [], minValue: 0, prefix: '', showThresholdMarkers: true, @@ -64,25 +64,22 @@ export class Gauge extends PureComponent { } })[0]; - return { - rangeMap, - valueMap, - }; + return { rangeMap, valueMap }; } formatValue(value) { - const { decimals, mappings, prefix, suffix, unit } = this.props; + const { decimals, valueMappings, prefix, suffix, unit } = this.props; const formatFunc = kbn.valueFormats[unit]; const formattedValue = formatFunc(value, decimals); - if (mappings.length > 0) { - const { rangeMap, valueMap } = this.formatWithMappings(mappings, formattedValue); + if (valueMappings.length > 0) { + const { rangeMap, valueMap } = this.formatWithMappings(valueMappings, formattedValue); if (valueMap) { - return valueMap; + return `${prefix} ${valueMap} ${suffix}`; } else if (rangeMap) { - return rangeMap; + return `${prefix} ${rangeMap} ${suffix}`; } } @@ -148,10 +145,7 @@ export class Gauge extends PureComponent { color: index === 0 ? threshold.color : thresholds[index].color, }; }), - { - value: maxValue, - color: thresholds.length > 0 ? BasicGaugeColor.Red : baseColor, - }, + { value: maxValue, color: thresholds.length > 0 ? BasicGaugeColor.Red : baseColor }, ]; const options = { @@ -184,19 +178,14 @@ export class Gauge extends PureComponent { formatter: () => { return this.formatValue(value); }, - font: { - size: fontSize, - family: '"Helvetica Neue", Helvetica, Arial, sans-serif', - }, + font: { size: fontSize, family: '"Helvetica Neue", Helvetica, Arial, sans-serif' }, }, show: true, }, }, }; - const plotSeries = { - data: [[0, value]], - }; + const plotSeries = { data: [[0, value]] }; try { $.plot(this.canvasElement, [plotSeries], options); diff --git a/public/sass/_grafana.scss b/public/sass/_grafana.scss index 93fd26b7ca8..e8859ff8255 100644 --- a/public/sass/_grafana.scss +++ b/public/sass/_grafana.scss @@ -1,4 +1,4 @@ - // DEPENDENCIES +// DEPENDENCIES @import '../../node_modules/react-table/react-table.css'; // VENDOR @@ -97,7 +97,6 @@ @import 'components/add_data_source.scss'; @import 'components/page_loader'; @import 'components/toggle_button_group'; -@import 'components/value-mappings'; @import 'components/popover-box'; // LOAD @grafana/ui components diff --git a/public/sass/pages/_explore.scss b/public/sass/pages/_explore.scss index 098dae1a4a2..abd13a10368 100644 --- a/public/sass/pages/_explore.scss +++ b/public/sass/pages/_explore.scss @@ -1,5 +1,5 @@ .explore { - width: 100%; + flex: 1 1 auto; &-container { padding: $dashboard-padding;