mirror of https://github.com/grafana/grafana
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 actionpull/19317/head
parent
68d6da77da
commit
9bd6ed887c
@ -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); |
||||
}); |
||||
}); |
||||
}); |
@ -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 ''; |
||||
}; |
@ -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 }), |
||||
]); |
||||
}); |
||||
}); |
||||
}); |
@ -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); |
||||
}); |
||||
}); |
||||
}); |
@ -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(); |
Loading…
Reference in new issue