mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
Alerting: Prevents creating alerts from unsupported queries (#19250)
* Refactor: Makes PanelEditor use state and shows validation message on AlerTab * Refactor: Makes validation message nicer looking * Refactor: Changes imports * Refactor: Removes conditional props * Refactor: Changes after feedback from PR review * Refactor: Removes unused action
This commit is contained in:
parent
68d6da77da
commit
9bd6ed887c
@ -1,34 +1,45 @@
|
||||
// Libraries
|
||||
import React, { PureComponent } from 'react';
|
||||
import { hot } from 'react-hot-loader';
|
||||
import { connect } from 'react-redux';
|
||||
import { css } from 'emotion';
|
||||
import { Alert, Button } from '@grafana/ui';
|
||||
|
||||
// Services & Utils
|
||||
import { AngularComponent, getAngularLoader } from '@grafana/runtime';
|
||||
import { AngularComponent, getAngularLoader, getDataSourceSrv } from '@grafana/runtime';
|
||||
import appEvents from 'app/core/app_events';
|
||||
import { getAlertingValidationMessage } from './getAlertingValidationMessage';
|
||||
|
||||
// Components
|
||||
import { EditorTabBody, EditorToolbarView } from '../dashboard/panel_editor/EditorTabBody';
|
||||
import EmptyListCTA from 'app/core/components/EmptyListCTA/EmptyListCTA';
|
||||
import StateHistory from './StateHistory';
|
||||
import 'app/features/alerting/AlertTabCtrl';
|
||||
import { Alert } from '@grafana/ui';
|
||||
|
||||
// Types
|
||||
import { DashboardModel } from '../dashboard/state/DashboardModel';
|
||||
import { PanelModel } from '../dashboard/state/PanelModel';
|
||||
import { TestRuleResult } from './TestRuleResult';
|
||||
import { AppNotificationSeverity } from 'app/types';
|
||||
import { AppNotificationSeverity, StoreState } from 'app/types';
|
||||
import { PanelEditorTabIds, getPanelEditorTab } from '../dashboard/panel_editor/state/reducers';
|
||||
import { changePanelEditorTab } from '../dashboard/panel_editor/state/actions';
|
||||
|
||||
interface Props {
|
||||
angularPanel?: AngularComponent;
|
||||
dashboard: DashboardModel;
|
||||
panel: PanelModel;
|
||||
changePanelEditorTab: typeof changePanelEditorTab;
|
||||
}
|
||||
|
||||
export class AlertTab extends PureComponent<Props> {
|
||||
interface State {
|
||||
validatonMessage: string;
|
||||
}
|
||||
|
||||
class UnConnectedAlertTab extends PureComponent<Props, State> {
|
||||
element: any;
|
||||
component: AngularComponent;
|
||||
panelCtrl: any;
|
||||
|
||||
state: State = {
|
||||
validatonMessage: '',
|
||||
};
|
||||
|
||||
componentDidMount() {
|
||||
if (this.shouldLoadAlertTab()) {
|
||||
this.loadAlertTab();
|
||||
@ -51,8 +62,8 @@ export class AlertTab extends PureComponent<Props> {
|
||||
}
|
||||
}
|
||||
|
||||
loadAlertTab() {
|
||||
const { angularPanel } = this.props;
|
||||
async loadAlertTab() {
|
||||
const { angularPanel, panel } = this.props;
|
||||
|
||||
const scope = angularPanel.getScope();
|
||||
|
||||
@ -71,6 +82,17 @@ export class AlertTab extends PureComponent<Props> {
|
||||
const scopeProps = { ctrl: this.panelCtrl };
|
||||
|
||||
this.component = loader.load(this.element, scopeProps, template);
|
||||
|
||||
const validatonMessage = await getAlertingValidationMessage(
|
||||
panel.transformations,
|
||||
panel.targets,
|
||||
getDataSourceSrv(),
|
||||
panel.datasource
|
||||
);
|
||||
|
||||
if (validatonMessage) {
|
||||
this.setState({ validatonMessage });
|
||||
}
|
||||
}
|
||||
|
||||
stateHistory = (): EditorToolbarView => {
|
||||
@ -128,19 +150,39 @@ export class AlertTab extends PureComponent<Props> {
|
||||
this.forceUpdate();
|
||||
};
|
||||
|
||||
switchToQueryTab = () => {
|
||||
const { changePanelEditorTab } = this.props;
|
||||
changePanelEditorTab(getPanelEditorTab(PanelEditorTabIds.Queries));
|
||||
};
|
||||
|
||||
renderValidationMessage = () => {
|
||||
const { validatonMessage } = this.state;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={css`
|
||||
width: 508px;
|
||||
margin: 128px auto;
|
||||
`}
|
||||
>
|
||||
<h2>{validatonMessage}</h2>
|
||||
<br />
|
||||
<div className="gf-form-group">
|
||||
<Button size={'md'} variant={'secondary'} icon="fa fa-arrow-left" onClick={this.switchToQueryTab}>
|
||||
Go back to Queries
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
render() {
|
||||
const { alert, transformations } = this.props.panel;
|
||||
const hasTransformations = transformations && transformations.length;
|
||||
const { validatonMessage } = this.state;
|
||||
const hasTransformations = transformations && transformations.length > 0;
|
||||
|
||||
if (!alert && hasTransformations) {
|
||||
return (
|
||||
<EditorTabBody heading="Alert">
|
||||
<Alert
|
||||
severity={AppNotificationSeverity.Warning}
|
||||
title="Transformations are not supported in alert queries"
|
||||
/>
|
||||
</EditorTabBody>
|
||||
);
|
||||
if (!alert && validatonMessage) {
|
||||
return this.renderValidationMessage();
|
||||
}
|
||||
|
||||
const toolbarItems = alert ? [this.stateHistory(), this.testRule(), this.deleteAlert()] : [];
|
||||
@ -163,9 +205,20 @@ export class AlertTab extends PureComponent<Props> {
|
||||
)}
|
||||
|
||||
<div ref={element => (this.element = element)} />
|
||||
{!alert && <EmptyListCTA {...model} />}
|
||||
{!alert && !validatonMessage && <EmptyListCTA {...model} />}
|
||||
</>
|
||||
</EditorTabBody>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export const mapStateToProps = (state: StoreState) => ({});
|
||||
|
||||
const mapDispatchToProps = { changePanelEditorTab };
|
||||
|
||||
export const AlertTab = hot(module)(
|
||||
connect(
|
||||
mapStateToProps,
|
||||
mapDispatchToProps
|
||||
)(UnConnectedAlertTab)
|
||||
);
|
||||
|
@ -10,6 +10,7 @@ import { DashboardSrv } from '../dashboard/services/DashboardSrv';
|
||||
import DatasourceSrv from '../plugins/datasource_srv';
|
||||
import { DataQuery } from '@grafana/ui/src/types/datasource';
|
||||
import { PanelModel } from 'app/features/dashboard/state';
|
||||
import { getDefaultCondition } from './getAlertingValidationMessage';
|
||||
|
||||
export class AlertTabCtrl {
|
||||
panel: PanelModel;
|
||||
@ -179,7 +180,7 @@ export class AlertTabCtrl {
|
||||
|
||||
alert.conditions = alert.conditions || [];
|
||||
if (alert.conditions.length === 0) {
|
||||
alert.conditions.push(this.buildDefaultCondition());
|
||||
alert.conditions.push(getDefaultCondition());
|
||||
}
|
||||
|
||||
alert.noDataState = alert.noDataState || config.alertingNoDataOrNullValues;
|
||||
@ -241,16 +242,6 @@ export class AlertTabCtrl {
|
||||
}
|
||||
}
|
||||
|
||||
buildDefaultCondition() {
|
||||
return {
|
||||
type: 'query',
|
||||
query: { params: ['A', '5m', 'now'] },
|
||||
reducer: { type: 'avg', params: [] as any[] },
|
||||
evaluator: { type: 'gt', params: [null] as any[] },
|
||||
operator: { type: 'and' },
|
||||
};
|
||||
}
|
||||
|
||||
validateModel() {
|
||||
if (!this.alert) {
|
||||
return;
|
||||
@ -348,7 +339,7 @@ export class AlertTabCtrl {
|
||||
}
|
||||
|
||||
addCondition(type: string) {
|
||||
const condition = this.buildDefaultCondition();
|
||||
const condition = getDefaultCondition();
|
||||
// add to persited model
|
||||
this.alert.conditions.push(condition);
|
||||
// add to view model
|
||||
|
@ -0,0 +1,148 @@
|
||||
import { DataSourceSrv } from '@grafana/runtime';
|
||||
import { DataSourceApi, PluginMeta } from '@grafana/ui';
|
||||
import { DataTransformerConfig } from '@grafana/data';
|
||||
|
||||
import { ElasticsearchQuery } from '../../plugins/datasource/elasticsearch/types';
|
||||
import { getAlertingValidationMessage } from './getAlertingValidationMessage';
|
||||
|
||||
describe('getAlertingValidationMessage', () => {
|
||||
describe('when called with some targets containing template variables', () => {
|
||||
it('then it should return false', async () => {
|
||||
let call = 0;
|
||||
const datasource: DataSourceApi = ({
|
||||
meta: ({ alerting: true } as any) as PluginMeta,
|
||||
targetContainsTemplate: () => {
|
||||
if (call === 0) {
|
||||
call++;
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
},
|
||||
name: 'some name',
|
||||
} as any) as DataSourceApi;
|
||||
const getMock = jest.fn().mockResolvedValue(datasource);
|
||||
const datasourceSrv: DataSourceSrv = {
|
||||
get: getMock,
|
||||
};
|
||||
const targets: ElasticsearchQuery[] = [
|
||||
{ refId: 'A', query: '@hostname:$hostname', isLogsQuery: false },
|
||||
{ refId: 'B', query: '@instance:instance', isLogsQuery: false },
|
||||
];
|
||||
const transformations: DataTransformerConfig[] = [];
|
||||
|
||||
const result = await getAlertingValidationMessage(transformations, targets, datasourceSrv, datasource.name);
|
||||
|
||||
expect(result).toBe('');
|
||||
expect(getMock).toHaveBeenCalledTimes(2);
|
||||
expect(getMock).toHaveBeenCalledWith(datasource.name);
|
||||
});
|
||||
});
|
||||
|
||||
describe('when called with some targets using a datasource that does not support alerting', () => {
|
||||
it('then it should return false', async () => {
|
||||
const alertingDatasource: DataSourceApi = ({
|
||||
meta: ({ alerting: true } as any) as PluginMeta,
|
||||
targetContainsTemplate: () => false,
|
||||
name: 'alertingDatasource',
|
||||
} as any) as DataSourceApi;
|
||||
const datasource: DataSourceApi = ({
|
||||
meta: ({ alerting: false } as any) as PluginMeta,
|
||||
targetContainsTemplate: () => false,
|
||||
name: 'datasource',
|
||||
} as any) as DataSourceApi;
|
||||
|
||||
const datasourceSrv: DataSourceSrv = {
|
||||
get: (name: string) => {
|
||||
if (name === datasource.name) {
|
||||
return Promise.resolve(datasource);
|
||||
}
|
||||
|
||||
return Promise.resolve(alertingDatasource);
|
||||
},
|
||||
};
|
||||
const targets: any[] = [
|
||||
{ refId: 'A', query: 'some query', datasource: 'alertingDatasource' },
|
||||
{ refId: 'B', query: 'some query', datasource: 'datasource' },
|
||||
];
|
||||
const transformations: DataTransformerConfig[] = [];
|
||||
|
||||
const result = await getAlertingValidationMessage(transformations, targets, datasourceSrv, datasource.name);
|
||||
|
||||
expect(result).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('when called with all targets containing template variables', () => {
|
||||
it('then it should return false', async () => {
|
||||
const datasource: DataSourceApi = ({
|
||||
meta: ({ alerting: true } as any) as PluginMeta,
|
||||
targetContainsTemplate: () => true,
|
||||
name: 'some name',
|
||||
} as any) as DataSourceApi;
|
||||
const getMock = jest.fn().mockResolvedValue(datasource);
|
||||
const datasourceSrv: DataSourceSrv = {
|
||||
get: getMock,
|
||||
};
|
||||
const targets: ElasticsearchQuery[] = [
|
||||
{ refId: 'A', query: '@hostname:$hostname', isLogsQuery: false },
|
||||
{ refId: 'B', query: '@instance:$instance', isLogsQuery: false },
|
||||
];
|
||||
const transformations: DataTransformerConfig[] = [];
|
||||
|
||||
const result = await getAlertingValidationMessage(transformations, targets, datasourceSrv, datasource.name);
|
||||
|
||||
expect(result).toBe('Template variables are not supported in alert queries');
|
||||
expect(getMock).toHaveBeenCalledTimes(2);
|
||||
expect(getMock).toHaveBeenCalledWith(datasource.name);
|
||||
});
|
||||
});
|
||||
|
||||
describe('when called with all targets using a datasource that does not support alerting', () => {
|
||||
it('then it should return false', async () => {
|
||||
const datasource: DataSourceApi = ({
|
||||
meta: ({ alerting: false } as any) as PluginMeta,
|
||||
targetContainsTemplate: () => false,
|
||||
name: 'some name',
|
||||
} as any) as DataSourceApi;
|
||||
const getMock = jest.fn().mockResolvedValue(datasource);
|
||||
const datasourceSrv: DataSourceSrv = {
|
||||
get: getMock,
|
||||
};
|
||||
const targets: ElasticsearchQuery[] = [
|
||||
{ refId: 'A', query: '@hostname:hostname', isLogsQuery: false },
|
||||
{ refId: 'B', query: '@instance:instance', isLogsQuery: false },
|
||||
];
|
||||
const transformations: DataTransformerConfig[] = [];
|
||||
|
||||
const result = await getAlertingValidationMessage(transformations, targets, datasourceSrv, datasource.name);
|
||||
|
||||
expect(result).toBe('The datasource does not support alerting queries');
|
||||
expect(getMock).toHaveBeenCalledTimes(2);
|
||||
expect(getMock).toHaveBeenCalledWith(datasource.name);
|
||||
});
|
||||
});
|
||||
|
||||
describe('when called with transformations', () => {
|
||||
it('then it should return false', async () => {
|
||||
const datasource: DataSourceApi = ({
|
||||
meta: ({ alerting: true } as any) as PluginMeta,
|
||||
targetContainsTemplate: () => false,
|
||||
name: 'some name',
|
||||
} as any) as DataSourceApi;
|
||||
const getMock = jest.fn().mockResolvedValue(datasource);
|
||||
const datasourceSrv: DataSourceSrv = {
|
||||
get: getMock,
|
||||
};
|
||||
const targets: ElasticsearchQuery[] = [
|
||||
{ refId: 'A', query: '@hostname:hostname', isLogsQuery: false },
|
||||
{ refId: 'B', query: '@instance:instance', isLogsQuery: false },
|
||||
];
|
||||
const transformations: DataTransformerConfig[] = [{ id: 'A', options: null }];
|
||||
|
||||
const result = await getAlertingValidationMessage(transformations, targets, datasourceSrv, datasource.name);
|
||||
|
||||
expect(result).toBe('Transformations are not supported in alert queries');
|
||||
expect(getMock).toHaveBeenCalledTimes(0);
|
||||
});
|
||||
});
|
||||
});
|
49
public/app/features/alerting/getAlertingValidationMessage.ts
Normal file
49
public/app/features/alerting/getAlertingValidationMessage.ts
Normal file
@ -0,0 +1,49 @@
|
||||
import { DataQuery } from '@grafana/ui';
|
||||
import { DataSourceSrv } from '@grafana/runtime';
|
||||
import { DataTransformerConfig } from '@grafana/data';
|
||||
|
||||
export const getDefaultCondition = () => ({
|
||||
type: 'query',
|
||||
query: { params: ['A', '5m', 'now'] },
|
||||
reducer: { type: 'avg', params: [] as any[] },
|
||||
evaluator: { type: 'gt', params: [null] as any[] },
|
||||
operator: { type: 'and' },
|
||||
});
|
||||
|
||||
export const getAlertingValidationMessage = async (
|
||||
transformations: DataTransformerConfig[],
|
||||
targets: DataQuery[],
|
||||
datasourceSrv: DataSourceSrv,
|
||||
datasourceName: string
|
||||
): Promise<string> => {
|
||||
if (targets.length === 0) {
|
||||
return 'Could not find any metric queries';
|
||||
}
|
||||
|
||||
if (transformations && transformations.length) {
|
||||
return 'Transformations are not supported in alert queries';
|
||||
}
|
||||
|
||||
let alertingNotSupported = 0;
|
||||
let templateVariablesNotSupported = 0;
|
||||
|
||||
for (const target of targets) {
|
||||
const dsName = target.datasource || datasourceName;
|
||||
const ds = await datasourceSrv.get(dsName);
|
||||
if (!ds.meta.alerting) {
|
||||
alertingNotSupported++;
|
||||
} else if (ds.targetContainsTemplate && ds.targetContainsTemplate(target)) {
|
||||
templateVariablesNotSupported++;
|
||||
}
|
||||
}
|
||||
|
||||
if (alertingNotSupported === targets.length) {
|
||||
return 'The datasource does not support alerting queries';
|
||||
}
|
||||
|
||||
if (templateVariablesNotSupported === targets.length) {
|
||||
return 'Template variables are not supported in alert queries';
|
||||
}
|
||||
|
||||
return '';
|
||||
};
|
@ -1,19 +1,19 @@
|
||||
import React, { PureComponent } from 'react';
|
||||
import classNames from 'classnames';
|
||||
import { hot } from 'react-hot-loader';
|
||||
import { connect } from 'react-redux';
|
||||
import { Tooltip, PanelPlugin, PanelPluginMeta } from '@grafana/ui';
|
||||
import { AngularComponent, config } from '@grafana/runtime';
|
||||
|
||||
import { QueriesTab } from './QueriesTab';
|
||||
import VisualizationTab from './VisualizationTab';
|
||||
import { GeneralTab } from './GeneralTab';
|
||||
import { AlertTab } from '../../alerting/AlertTab';
|
||||
|
||||
import config from 'app/core/config';
|
||||
import { store } from 'app/store/store';
|
||||
import { updateLocation } from 'app/core/actions';
|
||||
import { AngularComponent } from '@grafana/runtime';
|
||||
|
||||
import { PanelModel } from '../state/PanelModel';
|
||||
import { DashboardModel } from '../state/DashboardModel';
|
||||
import { Tooltip, PanelPlugin, PanelPluginMeta } from '@grafana/ui';
|
||||
import { StoreState } from '../../../types';
|
||||
import { PanelEditorTabIds, PanelEditorTab } from './state/reducers';
|
||||
import { refreshPanelEditor, changePanelEditorTab, panelEditorCleanUp } from './state/actions';
|
||||
|
||||
interface PanelEditorProps {
|
||||
panel: PanelModel;
|
||||
@ -21,56 +21,54 @@ interface PanelEditorProps {
|
||||
plugin: PanelPlugin;
|
||||
angularPanel?: AngularComponent;
|
||||
onPluginTypeChange: (newType: PanelPluginMeta) => void;
|
||||
activeTab: PanelEditorTabIds;
|
||||
tabs: PanelEditorTab[];
|
||||
refreshPanelEditor: typeof refreshPanelEditor;
|
||||
panelEditorCleanUp: typeof panelEditorCleanUp;
|
||||
changePanelEditorTab: typeof changePanelEditorTab;
|
||||
}
|
||||
|
||||
interface PanelEditorTab {
|
||||
id: string;
|
||||
text: string;
|
||||
}
|
||||
|
||||
enum PanelEditorTabIds {
|
||||
Queries = 'queries',
|
||||
Visualization = 'visualization',
|
||||
Advanced = 'advanced',
|
||||
Alert = 'alert',
|
||||
}
|
||||
|
||||
interface PanelEditorTab {
|
||||
id: string;
|
||||
text: string;
|
||||
}
|
||||
|
||||
const panelEditorTabTexts = {
|
||||
[PanelEditorTabIds.Queries]: 'Queries',
|
||||
[PanelEditorTabIds.Visualization]: 'Visualization',
|
||||
[PanelEditorTabIds.Advanced]: 'General',
|
||||
[PanelEditorTabIds.Alert]: 'Alert',
|
||||
};
|
||||
|
||||
const getPanelEditorTab = (tabId: PanelEditorTabIds): PanelEditorTab => {
|
||||
return {
|
||||
id: tabId,
|
||||
text: panelEditorTabTexts[tabId],
|
||||
};
|
||||
};
|
||||
|
||||
export class PanelEditor extends PureComponent<PanelEditorProps> {
|
||||
class UnConnectedPanelEditor extends PureComponent<PanelEditorProps> {
|
||||
constructor(props: PanelEditorProps) {
|
||||
super(props);
|
||||
}
|
||||
|
||||
componentDidMount(): void {
|
||||
this.refreshFromState();
|
||||
}
|
||||
|
||||
componentWillUnmount(): void {
|
||||
const { panelEditorCleanUp } = this.props;
|
||||
panelEditorCleanUp();
|
||||
}
|
||||
|
||||
refreshFromState = (meta?: PanelPluginMeta) => {
|
||||
const { refreshPanelEditor, plugin } = this.props;
|
||||
meta = meta || plugin.meta;
|
||||
|
||||
refreshPanelEditor({
|
||||
hasQueriesTab: !meta.skipDataQuery,
|
||||
usesGraphPlugin: meta.id === 'graph',
|
||||
alertingEnabled: config.alertingEnabled,
|
||||
});
|
||||
};
|
||||
|
||||
onChangeTab = (tab: PanelEditorTab) => {
|
||||
store.dispatch(
|
||||
updateLocation({
|
||||
query: { tab: tab.id, openVizPicker: null },
|
||||
partial: true,
|
||||
})
|
||||
);
|
||||
this.forceUpdate();
|
||||
const { changePanelEditorTab } = this.props;
|
||||
// Angular Query Components can potentially refresh the PanelModel
|
||||
// onBlur so this makes sure we change tab after that
|
||||
setTimeout(() => changePanelEditorTab(tab), 10);
|
||||
};
|
||||
|
||||
onPluginTypeChange = (newType: PanelPluginMeta) => {
|
||||
const { onPluginTypeChange } = this.props;
|
||||
onPluginTypeChange(newType);
|
||||
|
||||
this.refreshFromState(newType);
|
||||
};
|
||||
|
||||
renderCurrentTab(activeTab: string) {
|
||||
const { panel, dashboard, onPluginTypeChange, plugin, angularPanel } = this.props;
|
||||
const { panel, dashboard, plugin, angularPanel } = this.props;
|
||||
|
||||
switch (activeTab) {
|
||||
case 'advanced':
|
||||
@ -85,7 +83,7 @@ export class PanelEditor extends PureComponent<PanelEditorProps> {
|
||||
panel={panel}
|
||||
dashboard={dashboard}
|
||||
plugin={plugin}
|
||||
onPluginTypeChange={onPluginTypeChange}
|
||||
onPluginTypeChange={this.onPluginTypeChange}
|
||||
angularPanel={angularPanel}
|
||||
/>
|
||||
);
|
||||
@ -95,28 +93,7 @@ export class PanelEditor extends PureComponent<PanelEditorProps> {
|
||||
}
|
||||
|
||||
render() {
|
||||
const { plugin } = this.props;
|
||||
let activeTab: PanelEditorTabIds = store.getState().location.query.tab || PanelEditorTabIds.Queries;
|
||||
|
||||
const tabs: PanelEditorTab[] = [
|
||||
getPanelEditorTab(PanelEditorTabIds.Queries),
|
||||
getPanelEditorTab(PanelEditorTabIds.Visualization),
|
||||
getPanelEditorTab(PanelEditorTabIds.Advanced),
|
||||
];
|
||||
|
||||
// handle panels that do not have queries tab
|
||||
if (plugin.meta.skipDataQuery) {
|
||||
// remove queries tab
|
||||
tabs.shift();
|
||||
// switch tab
|
||||
if (activeTab === PanelEditorTabIds.Queries) {
|
||||
activeTab = PanelEditorTabIds.Visualization;
|
||||
}
|
||||
}
|
||||
|
||||
if (config.alertingEnabled && plugin.meta.id === 'graph') {
|
||||
tabs.push(getPanelEditorTab(PanelEditorTabIds.Alert));
|
||||
}
|
||||
const { activeTab, tabs } = this.props;
|
||||
|
||||
return (
|
||||
<div className="panel-editor-container__editor">
|
||||
@ -131,6 +108,20 @@ export class PanelEditor extends PureComponent<PanelEditorProps> {
|
||||
}
|
||||
}
|
||||
|
||||
export const mapStateToProps = (state: StoreState) => ({
|
||||
activeTab: state.location.query.tab || PanelEditorTabIds.Queries,
|
||||
tabs: state.panelEditor.tabs,
|
||||
});
|
||||
|
||||
const mapDispatchToProps = { refreshPanelEditor, panelEditorCleanUp, changePanelEditorTab };
|
||||
|
||||
export const PanelEditor = hot(module)(
|
||||
connect(
|
||||
mapStateToProps,
|
||||
mapDispatchToProps
|
||||
)(UnConnectedPanelEditor)
|
||||
);
|
||||
|
||||
interface TabItemParams {
|
||||
tab: PanelEditorTab;
|
||||
activeTab: string;
|
||||
|
127
public/app/features/dashboard/panel_editor/state/actions.test.ts
Normal file
127
public/app/features/dashboard/panel_editor/state/actions.test.ts
Normal file
@ -0,0 +1,127 @@
|
||||
import { thunkTester } from '../../../../../test/core/thunk/thunkTester';
|
||||
import { initialState, getPanelEditorTab, PanelEditorTabIds } from './reducers';
|
||||
import { refreshPanelEditor, panelEditorInitCompleted, changePanelEditorTab } from './actions';
|
||||
import { updateLocation } from '../../../../core/actions';
|
||||
|
||||
describe('refreshPanelEditor', () => {
|
||||
describe('when called and there is no activeTab in state', () => {
|
||||
it('then the dispatched action should default the activeTab to PanelEditorTabIds.Queries', async () => {
|
||||
const activeTab = PanelEditorTabIds.Queries;
|
||||
const tabs = [
|
||||
getPanelEditorTab(PanelEditorTabIds.Queries),
|
||||
getPanelEditorTab(PanelEditorTabIds.Visualization),
|
||||
getPanelEditorTab(PanelEditorTabIds.Advanced),
|
||||
getPanelEditorTab(PanelEditorTabIds.Alert),
|
||||
];
|
||||
const dispatchedActions = await thunkTester({ panelEditor: { ...initialState, activeTab: null } })
|
||||
.givenThunk(refreshPanelEditor)
|
||||
.whenThunkIsDispatched({ hasQueriesTab: true, alertingEnabled: true, usesGraphPlugin: true });
|
||||
|
||||
expect(dispatchedActions.length).toBe(1);
|
||||
expect(dispatchedActions[0]).toEqual(panelEditorInitCompleted({ activeTab, tabs }));
|
||||
});
|
||||
});
|
||||
|
||||
describe('when called and there is already an activeTab in state', () => {
|
||||
it('then the dispatched action should include activeTab from state', async () => {
|
||||
const activeTab = PanelEditorTabIds.Visualization;
|
||||
const tabs = [
|
||||
getPanelEditorTab(PanelEditorTabIds.Queries),
|
||||
getPanelEditorTab(PanelEditorTabIds.Visualization),
|
||||
getPanelEditorTab(PanelEditorTabIds.Advanced),
|
||||
getPanelEditorTab(PanelEditorTabIds.Alert),
|
||||
];
|
||||
const dispatchedActions = await thunkTester({ panelEditor: { ...initialState, activeTab } })
|
||||
.givenThunk(refreshPanelEditor)
|
||||
.whenThunkIsDispatched({ hasQueriesTab: true, alertingEnabled: true, usesGraphPlugin: true });
|
||||
|
||||
expect(dispatchedActions.length).toBe(1);
|
||||
expect(dispatchedActions[0]).toEqual(panelEditorInitCompleted({ activeTab, tabs }));
|
||||
});
|
||||
});
|
||||
|
||||
describe('when called and plugin has no queries tab', () => {
|
||||
it('then the dispatched action should not include Queries tab and default the activeTab to PanelEditorTabIds.Visualization', async () => {
|
||||
const activeTab = PanelEditorTabIds.Visualization;
|
||||
const tabs = [
|
||||
getPanelEditorTab(PanelEditorTabIds.Visualization),
|
||||
getPanelEditorTab(PanelEditorTabIds.Advanced),
|
||||
getPanelEditorTab(PanelEditorTabIds.Alert),
|
||||
];
|
||||
const dispatchedActions = await thunkTester({ panelEditor: { ...initialState } })
|
||||
.givenThunk(refreshPanelEditor)
|
||||
.whenThunkIsDispatched({ hasQueriesTab: false, alertingEnabled: true, usesGraphPlugin: true });
|
||||
|
||||
expect(dispatchedActions.length).toBe(1);
|
||||
expect(dispatchedActions[0]).toEqual(panelEditorInitCompleted({ activeTab, tabs }));
|
||||
});
|
||||
});
|
||||
|
||||
describe('when called and alerting is enabled and the visualization is the graph plugin', () => {
|
||||
it('then the dispatched action should include the alert tab', async () => {
|
||||
const activeTab = PanelEditorTabIds.Queries;
|
||||
const tabs = [
|
||||
getPanelEditorTab(PanelEditorTabIds.Queries),
|
||||
getPanelEditorTab(PanelEditorTabIds.Visualization),
|
||||
getPanelEditorTab(PanelEditorTabIds.Advanced),
|
||||
getPanelEditorTab(PanelEditorTabIds.Alert),
|
||||
];
|
||||
const dispatchedActions = await thunkTester({ panelEditor: { ...initialState } })
|
||||
.givenThunk(refreshPanelEditor)
|
||||
.whenThunkIsDispatched({ hasQueriesTab: true, alertingEnabled: true, usesGraphPlugin: true });
|
||||
|
||||
expect(dispatchedActions.length).toBe(1);
|
||||
expect(dispatchedActions[0]).toEqual(panelEditorInitCompleted({ activeTab, tabs }));
|
||||
});
|
||||
});
|
||||
|
||||
describe('when called and alerting is not enabled', () => {
|
||||
it('then the dispatched action should not include the alert tab', async () => {
|
||||
const activeTab = PanelEditorTabIds.Queries;
|
||||
const tabs = [
|
||||
getPanelEditorTab(PanelEditorTabIds.Queries),
|
||||
getPanelEditorTab(PanelEditorTabIds.Visualization),
|
||||
getPanelEditorTab(PanelEditorTabIds.Advanced),
|
||||
];
|
||||
const dispatchedActions = await thunkTester({ panelEditor: { ...initialState } })
|
||||
.givenThunk(refreshPanelEditor)
|
||||
.whenThunkIsDispatched({ hasQueriesTab: true, alertingEnabled: false, usesGraphPlugin: true });
|
||||
|
||||
expect(dispatchedActions.length).toBe(1);
|
||||
expect(dispatchedActions[0]).toEqual(panelEditorInitCompleted({ activeTab, tabs }));
|
||||
});
|
||||
});
|
||||
|
||||
describe('when called and the visualization is not the graph plugin', () => {
|
||||
it('then the dispatched action should not include the alert tab', async () => {
|
||||
const activeTab = PanelEditorTabIds.Queries;
|
||||
const tabs = [
|
||||
getPanelEditorTab(PanelEditorTabIds.Queries),
|
||||
getPanelEditorTab(PanelEditorTabIds.Visualization),
|
||||
getPanelEditorTab(PanelEditorTabIds.Advanced),
|
||||
];
|
||||
const dispatchedActions = await thunkTester({ panelEditor: { ...initialState } })
|
||||
.givenThunk(refreshPanelEditor)
|
||||
.whenThunkIsDispatched({ hasQueriesTab: true, alertingEnabled: true, usesGraphPlugin: false });
|
||||
|
||||
expect(dispatchedActions.length).toBe(1);
|
||||
expect(dispatchedActions[0]).toEqual(panelEditorInitCompleted({ activeTab, tabs }));
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('changePanelEditorTab', () => {
|
||||
describe('when called', () => {
|
||||
it('then it should dispatch correct actions', async () => {
|
||||
const activeTab = getPanelEditorTab(PanelEditorTabIds.Visualization);
|
||||
const dispatchedActions = await thunkTester({})
|
||||
.givenThunk(changePanelEditorTab)
|
||||
.whenThunkIsDispatched(activeTab);
|
||||
|
||||
expect(dispatchedActions.length).toBe(1);
|
||||
expect(dispatchedActions).toEqual([
|
||||
updateLocation({ query: { tab: activeTab.id, openVizPicker: null }, partial: true }),
|
||||
]);
|
||||
});
|
||||
});
|
||||
});
|
54
public/app/features/dashboard/panel_editor/state/actions.ts
Normal file
54
public/app/features/dashboard/panel_editor/state/actions.ts
Normal file
@ -0,0 +1,54 @@
|
||||
import { actionCreatorFactory, noPayloadActionCreatorFactory } from '../../../../core/redux';
|
||||
import { PanelEditorTabIds, PanelEditorTab, getPanelEditorTab } from './reducers';
|
||||
import { ThunkResult } from '../../../../types';
|
||||
import { updateLocation } from '../../../../core/actions';
|
||||
|
||||
export interface PanelEditorInitCompleted {
|
||||
activeTab: PanelEditorTabIds;
|
||||
tabs: PanelEditorTab[];
|
||||
}
|
||||
|
||||
export const panelEditorInitCompleted = actionCreatorFactory<PanelEditorInitCompleted>(
|
||||
'PANEL_EDITOR_INIT_COMPLETED'
|
||||
).create();
|
||||
|
||||
export const panelEditorCleanUp = noPayloadActionCreatorFactory('PANEL_EDITOR_CLEAN_UP').create();
|
||||
|
||||
export const refreshPanelEditor = (props: {
|
||||
hasQueriesTab?: boolean;
|
||||
usesGraphPlugin?: boolean;
|
||||
alertingEnabled?: boolean;
|
||||
}): ThunkResult<void> => {
|
||||
return async (dispatch, getState) => {
|
||||
let activeTab = getState().panelEditor.activeTab || PanelEditorTabIds.Queries;
|
||||
const { hasQueriesTab, usesGraphPlugin, alertingEnabled } = props;
|
||||
|
||||
const tabs: PanelEditorTab[] = [
|
||||
getPanelEditorTab(PanelEditorTabIds.Queries),
|
||||
getPanelEditorTab(PanelEditorTabIds.Visualization),
|
||||
getPanelEditorTab(PanelEditorTabIds.Advanced),
|
||||
];
|
||||
|
||||
// handle panels that do not have queries tab
|
||||
if (!hasQueriesTab) {
|
||||
// remove queries tab
|
||||
tabs.shift();
|
||||
// switch tab
|
||||
if (activeTab === PanelEditorTabIds.Queries) {
|
||||
activeTab = PanelEditorTabIds.Visualization;
|
||||
}
|
||||
}
|
||||
|
||||
if (alertingEnabled && usesGraphPlugin) {
|
||||
tabs.push(getPanelEditorTab(PanelEditorTabIds.Alert));
|
||||
}
|
||||
|
||||
dispatch(panelEditorInitCompleted({ activeTab, tabs }));
|
||||
};
|
||||
};
|
||||
|
||||
export const changePanelEditorTab = (activeTab: PanelEditorTab): ThunkResult<void> => {
|
||||
return async dispatch => {
|
||||
dispatch(updateLocation({ query: { tab: activeTab.id, openVizPicker: null }, partial: true }));
|
||||
};
|
||||
};
|
@ -0,0 +1,35 @@
|
||||
import { reducerTester } from '../../../../../test/core/redux/reducerTester';
|
||||
import { initialState, panelEditorReducer, PanelEditorTabIds, PanelEditorTab, getPanelEditorTab } from './reducers';
|
||||
import { panelEditorInitCompleted, panelEditorCleanUp } from './actions';
|
||||
|
||||
describe('panelEditorReducer', () => {
|
||||
describe('when panelEditorInitCompleted is dispatched', () => {
|
||||
it('then state should be correct', () => {
|
||||
const activeTab = PanelEditorTabIds.Alert;
|
||||
const tabs: PanelEditorTab[] = [
|
||||
getPanelEditorTab(PanelEditorTabIds.Queries),
|
||||
getPanelEditorTab(PanelEditorTabIds.Visualization),
|
||||
getPanelEditorTab(PanelEditorTabIds.Advanced),
|
||||
];
|
||||
reducerTester()
|
||||
.givenReducer(panelEditorReducer, initialState)
|
||||
.whenActionIsDispatched(panelEditorInitCompleted({ activeTab, tabs }))
|
||||
.thenStateShouldEqual({ activeTab, tabs });
|
||||
});
|
||||
});
|
||||
|
||||
describe('when panelEditorCleanUp is dispatched', () => {
|
||||
it('then state should be intialState', () => {
|
||||
const activeTab = PanelEditorTabIds.Alert;
|
||||
const tabs: PanelEditorTab[] = [
|
||||
getPanelEditorTab(PanelEditorTabIds.Queries),
|
||||
getPanelEditorTab(PanelEditorTabIds.Visualization),
|
||||
getPanelEditorTab(PanelEditorTabIds.Advanced),
|
||||
];
|
||||
reducerTester()
|
||||
.givenReducer(panelEditorReducer, { activeTab, tabs })
|
||||
.whenActionIsDispatched(panelEditorCleanUp())
|
||||
.thenStateShouldEqual(initialState);
|
||||
});
|
||||
});
|
||||
});
|
56
public/app/features/dashboard/panel_editor/state/reducers.ts
Normal file
56
public/app/features/dashboard/panel_editor/state/reducers.ts
Normal file
@ -0,0 +1,56 @@
|
||||
import { reducerFactory } from '../../../../core/redux';
|
||||
import { panelEditorCleanUp, panelEditorInitCompleted } from './actions';
|
||||
|
||||
export interface PanelEditorTab {
|
||||
id: string;
|
||||
text: string;
|
||||
}
|
||||
|
||||
export enum PanelEditorTabIds {
|
||||
Queries = 'queries',
|
||||
Visualization = 'visualization',
|
||||
Advanced = 'advanced',
|
||||
Alert = 'alert',
|
||||
}
|
||||
|
||||
export const panelEditorTabTexts = {
|
||||
[PanelEditorTabIds.Queries]: 'Queries',
|
||||
[PanelEditorTabIds.Visualization]: 'Visualization',
|
||||
[PanelEditorTabIds.Advanced]: 'General',
|
||||
[PanelEditorTabIds.Alert]: 'Alert',
|
||||
};
|
||||
|
||||
export const getPanelEditorTab = (tabId: PanelEditorTabIds): PanelEditorTab => {
|
||||
return {
|
||||
id: tabId,
|
||||
text: panelEditorTabTexts[tabId],
|
||||
};
|
||||
};
|
||||
|
||||
export interface PanelEditorState {
|
||||
activeTab: PanelEditorTabIds;
|
||||
tabs: PanelEditorTab[];
|
||||
}
|
||||
|
||||
export const initialState: PanelEditorState = {
|
||||
activeTab: null,
|
||||
tabs: [],
|
||||
};
|
||||
|
||||
export const panelEditorReducer = reducerFactory<PanelEditorState>(initialState)
|
||||
.addMapper({
|
||||
filter: panelEditorInitCompleted,
|
||||
mapper: (state, action): PanelEditorState => {
|
||||
const { activeTab, tabs } = action.payload;
|
||||
return {
|
||||
...state,
|
||||
activeTab,
|
||||
tabs,
|
||||
};
|
||||
},
|
||||
})
|
||||
.addMapper({
|
||||
filter: panelEditorCleanUp,
|
||||
mapper: (): PanelEditorState => initialState,
|
||||
})
|
||||
.create();
|
@ -1,10 +1,8 @@
|
||||
// Libraries
|
||||
import _ from 'lodash';
|
||||
|
||||
// Utils
|
||||
import { Emitter } from 'app/core/utils/emitter';
|
||||
import { getNextRefIdChar } from 'app/core/utils/query';
|
||||
|
||||
// Types
|
||||
import { DataQuery, DataQueryResponseData, PanelPlugin } from '@grafana/ui';
|
||||
import { DataLink, DataTransformerConfig, ScopedVars } from '@grafana/data';
|
||||
|
@ -2,11 +2,9 @@
|
||||
import { getBackendSrv } from '@grafana/runtime';
|
||||
import { actionCreatorFactory, noPayloadActionCreatorFactory } from 'app/core/redux';
|
||||
import { createSuccessNotification } from 'app/core/copy/appNotification';
|
||||
|
||||
// Actions
|
||||
import { loadPluginDashboards } from '../../plugins/state/actions';
|
||||
import { notifyApp } from 'app/core/actions';
|
||||
|
||||
// Types
|
||||
import {
|
||||
ThunkResult,
|
||||
|
@ -11,6 +11,7 @@ import {
|
||||
import { reducerFactory } from 'app/core/redux';
|
||||
import { processAclItems } from 'app/core/utils/acl';
|
||||
import { DashboardModel } from './DashboardModel';
|
||||
import { panelEditorReducer } from '../panel_editor/state/reducers';
|
||||
|
||||
export const initialState: DashboardState = {
|
||||
initPhase: DashboardInitPhase.NotStarted,
|
||||
@ -87,4 +88,5 @@ export const dashboardReducer = reducerFactory(initialState)
|
||||
|
||||
export default {
|
||||
dashboard: dashboardReducer,
|
||||
panelEditor: panelEditorReducer,
|
||||
};
|
||||
|
@ -15,6 +15,7 @@ import { PluginsState } from './plugins';
|
||||
import { NavIndex } from '@grafana/data';
|
||||
import { ApplicationState } from './application';
|
||||
import { LdapState, LdapUserState } from './ldap';
|
||||
import { PanelEditorState } from '../features/dashboard/panel_editor/state/reducers';
|
||||
|
||||
export interface StoreState {
|
||||
navIndex: NavIndex;
|
||||
@ -24,6 +25,7 @@ export interface StoreState {
|
||||
team: TeamState;
|
||||
folder: FolderState;
|
||||
dashboard: DashboardState;
|
||||
panelEditor: PanelEditorState;
|
||||
dataSources: DataSourcesState;
|
||||
explore: ExploreState;
|
||||
users: UsersState;
|
||||
|
@ -29,7 +29,7 @@ export const thunkTester = (initialState: any, debug?: boolean): ThunkGiven => {
|
||||
|
||||
dispatchedActions = store.getActions();
|
||||
if (debug) {
|
||||
console.log('resultingActions:', dispatchedActions);
|
||||
console.log('resultingActions:', JSON.stringify(dispatchedActions, null, 2));
|
||||
}
|
||||
|
||||
return dispatchedActions;
|
||||
|
Loading…
Reference in New Issue
Block a user