From 571257226e22169a001f06439fd366fe6ef71761 Mon Sep 17 00:00:00 2001 From: Domas Date: Mon, 11 Oct 2021 16:55:45 +0300 Subject: [PATCH] Alerting: make alert state indicator in panel header work with Grafana 8 alerts (#38713) --- packages/grafana-data/src/types/alerts.ts | 5 +- packages/grafana-data/src/types/panel.ts | 1 - .../unified/PanelAlertTabContent.test.tsx | 11 +- .../alerting/unified/api/prometheus.ts | 22 +- .../features/alerting/unified/api/ruler.ts | 29 ++- .../components/rule-editor/AlertTypeStep.tsx | 2 +- .../rule-editor/GroupAndNamespaceFields.tsx | 10 +- .../alerting/unified/hooks/useCombinedRule.ts | 14 +- .../unified/hooks/usePanelCombinedRules.ts | 16 +- .../alerting/unified/state/actions.ts | 44 ++-- .../alerting/unified/state/reducers.ts | 5 +- .../PanelEditor/PanelEditorTabs.tsx | 2 + .../components/PanelEditor/state/selectors.ts | 5 +- .../dashboard/dashgrid/PanelChrome.tsx | 2 +- .../dashboard/dashgrid/PanelChromeAngular.tsx | 2 +- .../AlertStatesWorker.test.ts | 14 +- .../DashboardQueryRunner/AlertStatesWorker.ts | 5 + .../DashboardQueryRunner.test.ts | 5 +- .../DashboardQueryRunner.ts | 4 +- .../UnifiedAlertStatesWorker.test.ts | 200 ++++++++++++++++++ .../UnifiedAlertStatesWorker.ts | 99 +++++++++ .../state/DashboardQueryRunner/testHelpers.ts | 1 + public/app/types/unified-alerting-dto.ts | 2 +- 23 files changed, 442 insertions(+), 58 deletions(-) create mode 100644 public/app/features/query/state/DashboardQueryRunner/UnifiedAlertStatesWorker.test.ts create mode 100644 public/app/features/query/state/DashboardQueryRunner/UnifiedAlertStatesWorker.ts diff --git a/packages/grafana-data/src/types/alerts.ts b/packages/grafana-data/src/types/alerts.ts index 895ff324a7d..497562b47e1 100644 --- a/packages/grafana-data/src/types/alerts.ts +++ b/packages/grafana-data/src/types/alerts.ts @@ -1,5 +1,5 @@ /** - * @internal -- might be replaced by next generation Alerting + * @internal */ export enum AlertState { NoData = 'no_data', @@ -11,12 +11,11 @@ export enum AlertState { } /** - * @internal -- might be replaced by next generation Alerting + * @internal */ export interface AlertStateInfo { id: number; dashboardId: number; panelId: number; state: AlertState; - newStateDate: string; } diff --git a/packages/grafana-data/src/types/panel.ts b/packages/grafana-data/src/types/panel.ts index 82f824fc245..6b9f6406091 100644 --- a/packages/grafana-data/src/types/panel.ts +++ b/packages/grafana-data/src/types/panel.ts @@ -42,7 +42,6 @@ export interface PanelData { /** * @internal - * @deprecated alertState is deprecated and will be removed when the next generation Alerting is in place */ alertState?: AlertStateInfo; diff --git a/public/app/features/alerting/unified/PanelAlertTabContent.test.tsx b/public/app/features/alerting/unified/PanelAlertTabContent.test.tsx index d434643355a..70f4fdd3492 100644 --- a/public/app/features/alerting/unified/PanelAlertTabContent.test.tsx +++ b/public/app/features/alerting/unified/PanelAlertTabContent.test.tsx @@ -14,7 +14,7 @@ import { mockPromRuleNamespace, mockRulerGrafanaRule, } from './mocks'; -import { DataSourceType } from './utils/datasource'; +import { DataSourceType, GRAFANA_RULES_SOURCE_NAME } from './utils/datasource'; import { typeAsJestMock } from 'test/helpers/typeAsJestMock'; import { getAllDataSources } from './utils/config'; import { fetchRules } from './api/prometheus'; @@ -270,5 +270,14 @@ describe('PanelAlertTabContent', () => { { key: '__panelId__', value: '34' }, ], }); + + expect(mocks.api.fetchRulerRules).toHaveBeenCalledWith(GRAFANA_RULES_SOURCE_NAME, { + dashboardUID: dashboard.uid, + panelId: panel.editSourceId, + }); + expect(mocks.api.fetchRules).toHaveBeenCalledWith(GRAFANA_RULES_SOURCE_NAME, { + dashboardUID: dashboard.uid, + panelId: panel.editSourceId, + }); }); }); diff --git a/public/app/features/alerting/unified/api/prometheus.ts b/public/app/features/alerting/unified/api/prometheus.ts index 900435a568f..5d830406bd6 100644 --- a/public/app/features/alerting/unified/api/prometheus.ts +++ b/public/app/features/alerting/unified/api/prometheus.ts @@ -3,14 +3,32 @@ import { getBackendSrv } from '@grafana/runtime'; import { RuleNamespace } from 'app/types/unified-alerting'; import { PromRulesResponse } from 'app/types/unified-alerting-dto'; -import { getDatasourceAPIId } from '../utils/datasource'; +import { getDatasourceAPIId, GRAFANA_RULES_SOURCE_NAME } from '../utils/datasource'; + +export interface FetchPromRulesFilter { + dashboardUID: string; + panelId?: number; +} + +export async function fetchRules(dataSourceName: string, filter?: FetchPromRulesFilter): Promise { + if (filter?.dashboardUID && dataSourceName !== GRAFANA_RULES_SOURCE_NAME) { + throw new Error('Filtering by dashboard UID is not supported for cloud rules sources.'); + } + + const params: Record = {}; + if (filter?.dashboardUID) { + params['dashboard_uid'] = filter.dashboardUID; + if (filter.panelId) { + params['panel_id'] = String(filter.panelId); + } + } -export async function fetchRules(dataSourceName: string): Promise { const response = await lastValueFrom( getBackendSrv().fetch({ url: `/api/prometheus/${getDatasourceAPIId(dataSourceName)}/api/v1/rules`, showErrorAlert: false, showSuccessAlert: false, + params, }) ).catch((e) => { if ('status' in e && e.status === 404) { diff --git a/public/app/features/alerting/unified/api/ruler.ts b/public/app/features/alerting/unified/api/ruler.ts index b6d528764ad..f215ffe4952 100644 --- a/public/app/features/alerting/unified/api/ruler.ts +++ b/public/app/features/alerting/unified/api/ruler.ts @@ -2,7 +2,7 @@ import { lastValueFrom } from 'rxjs'; import { getBackendSrv } from '@grafana/runtime'; import { PostableRulerRuleGroupDTO, RulerRuleGroupDTO, RulerRulesConfigDTO } from 'app/types/unified-alerting-dto'; -import { getDatasourceAPIId } from '../utils/datasource'; +import { getDatasourceAPIId, GRAFANA_RULES_SOURCE_NAME } from '../utils/datasource'; import { RULER_NOT_SUPPORTED_MSG } from '../utils/constants'; // upsert a rule group. use this to update rules @@ -22,9 +22,29 @@ export async function setRulerRuleGroup( ); } +export interface FetchRulerRulesFilter { + dashboardUID: string; + panelId?: number; +} + // fetch all ruler rule namespaces and included groups -export async function fetchRulerRules(dataSourceName: string) { - return rulerGetRequest(`/api/ruler/${getDatasourceAPIId(dataSourceName)}/api/v1/rules`, {}); +export async function fetchRulerRules(dataSourceName: string, filter?: FetchRulerRulesFilter) { + if (filter?.dashboardUID && dataSourceName !== GRAFANA_RULES_SOURCE_NAME) { + throw new Error('Filtering by dashboard UID is not supported for cloud rules sources.'); + } + + const params: Record = {}; + if (filter?.dashboardUID) { + params['dashboard_uid'] = filter.dashboardUID; + if (filter.panelId) { + params['panel_id'] = String(filter.panelId); + } + } + return rulerGetRequest( + `/api/ruler/${getDatasourceAPIId(dataSourceName)}/api/v1/rules`, + {}, + params + ); } // fetch rule groups for a particular namespace @@ -66,13 +86,14 @@ export async function deleteRulerRulesGroup(dataSourceName: string, namespace: s } // false in case ruler is not supported. this is weird, but we'll work on it -async function rulerGetRequest(url: string, empty: T): Promise { +async function rulerGetRequest(url: string, empty: T, params?: Record): Promise { try { const response = await lastValueFrom( getBackendSrv().fetch({ url, showErrorAlert: false, showSuccessAlert: false, + params, }) ); return response.data; diff --git a/public/app/features/alerting/unified/components/rule-editor/AlertTypeStep.tsx b/public/app/features/alerting/unified/components/rule-editor/AlertTypeStep.tsx index e158b714bd5..4fe55e5385c 100644 --- a/public/app/features/alerting/unified/components/rule-editor/AlertTypeStep.tsx +++ b/public/app/features/alerting/unified/components/rule-editor/AlertTypeStep.tsx @@ -130,7 +130,7 @@ export const AlertTypeStep: FC = ({ editingExistingRule }) => { )} {(ruleFormType === RuleFormType.cloudRecording || ruleFormType === RuleFormType.cloudAlerting) && - dataSourceName && } + dataSourceName && } {ruleFormType === RuleFormType.grafana && ( = ({ dataSourceName }) => { +export const GroupAndNamespaceFields: FC = ({ rulesSourceName }) => { const { control, watch, @@ -28,10 +28,10 @@ export const GroupAndNamespaceFields: FC = ({ dataSourceName }) => { const rulerRequests = useUnifiedAlertingSelector((state) => state.rulerRules); const dispatch = useDispatch(); useEffect(() => { - dispatch(fetchRulerRulesAction(dataSourceName)); - }, [dataSourceName, dispatch]); + dispatch(fetchRulerRulesAction({ rulesSourceName })); + }, [rulesSourceName, dispatch]); - const rulesConfig = rulerRequests[dataSourceName]?.result; + const rulesConfig = rulerRequests[rulesSourceName]?.result; const namespace = watch('namespace'); diff --git a/public/app/features/alerting/unified/hooks/useCombinedRule.ts b/public/app/features/alerting/unified/hooks/useCombinedRule.ts index 81e60aacb4e..7809c75f1bf 100644 --- a/public/app/features/alerting/unified/hooks/useCombinedRule.ts +++ b/public/app/features/alerting/unified/hooks/useCombinedRule.ts @@ -75,21 +75,21 @@ export function useCombinedRulesMatching( }; } -function useCombinedRulesLoader(ruleSourceName: string | undefined): AsyncRequestState { +function useCombinedRulesLoader(rulesSourceName: string | undefined): AsyncRequestState { const dispatch = useDispatch(); const promRuleRequests = useUnifiedAlertingSelector((state) => state.promRules); - const promRuleRequest = getRequestState(ruleSourceName, promRuleRequests); + const promRuleRequest = getRequestState(rulesSourceName, promRuleRequests); const rulerRuleRequests = useUnifiedAlertingSelector((state) => state.rulerRules); - const rulerRuleRequest = getRequestState(ruleSourceName, rulerRuleRequests); + const rulerRuleRequest = getRequestState(rulesSourceName, rulerRuleRequests); useEffect(() => { - if (!ruleSourceName) { + if (!rulesSourceName) { return; } - dispatch(fetchPromRulesAction(ruleSourceName)); - dispatch(fetchRulerRulesAction(ruleSourceName)); - }, [dispatch, ruleSourceName]); + dispatch(fetchPromRulesAction({ rulesSourceName })); + dispatch(fetchRulerRulesAction({ rulesSourceName })); + }, [dispatch, rulesSourceName]); return { loading: promRuleRequest.loading || rulerRuleRequest.loading, diff --git a/public/app/features/alerting/unified/hooks/usePanelCombinedRules.ts b/public/app/features/alerting/unified/hooks/usePanelCombinedRules.ts index 85bd7666eb3..0fdd3db62bc 100644 --- a/public/app/features/alerting/unified/hooks/usePanelCombinedRules.ts +++ b/public/app/features/alerting/unified/hooks/usePanelCombinedRules.ts @@ -34,8 +34,18 @@ export function usePanelCombinedRules({ dashboard, panel, poll = false }: Option // fetch rules, then poll every RULE_LIST_POLL_INTERVAL_MS useEffect(() => { const fetch = () => { - dispatch(fetchPromRulesAction(GRAFANA_RULES_SOURCE_NAME)); - dispatch(fetchRulerRulesAction(GRAFANA_RULES_SOURCE_NAME)); + dispatch( + fetchPromRulesAction({ + rulesSourceName: GRAFANA_RULES_SOURCE_NAME, + filter: { dashboardUID: dashboard.uid, panelId: panel.editSourceId }, + }) + ); + dispatch( + fetchRulerRulesAction({ + rulesSourceName: GRAFANA_RULES_SOURCE_NAME, + filter: { dashboardUID: dashboard.uid, panelId: panel.editSourceId }, + }) + ); }; fetch(); if (poll) { @@ -45,7 +55,7 @@ export function usePanelCombinedRules({ dashboard, panel, poll = false }: Option }; } return () => {}; - }, [dispatch, poll]); + }, [dispatch, poll, panel.editSourceId, dashboard.uid]); const loading = promRuleRequest.loading || rulerRuleRequest.loading; const errors = [promRuleRequest.error, rulerRuleRequest.error].filter( diff --git a/public/app/features/alerting/unified/state/actions.ts b/public/app/features/alerting/unified/state/actions.ts index 0828a46b1ea..24ea7b2a443 100644 --- a/public/app/features/alerting/unified/state/actions.ts +++ b/public/app/features/alerting/unified/state/actions.ts @@ -29,13 +29,14 @@ import { deleteAlertManagerConfig, testReceivers, } from '../api/alertmanager'; -import { fetchRules } from '../api/prometheus'; +import { FetchPromRulesFilter, fetchRules } from '../api/prometheus'; import { deleteNamespace, deleteRulerRulesGroup, fetchRulerRules, fetchRulerRulesGroup, fetchRulerRulesNamespace, + FetchRulerRulesFilter, setRulerRuleGroup, } from '../api/ruler'; import { RuleFormType, RuleFormValues } from '../types/rule-form'; @@ -64,7 +65,8 @@ const FETCH_CONFIG_RETRY_TIMEOUT = 30 * 1000; export const fetchPromRulesAction = createAsyncThunk( 'unifiedalerting/fetchPromRules', - (rulesSourceName: string): Promise => withSerializedError(fetchRules(rulesSourceName)) + ({ rulesSourceName, filter }: { rulesSourceName: string; filter?: FetchPromRulesFilter }): Promise => + withSerializedError(fetchRules(rulesSourceName, filter)) ); export const fetchAlertManagerConfigAction = createAsyncThunk( @@ -106,8 +108,14 @@ export const fetchAlertManagerConfigAction = createAsyncThunk( export const fetchRulerRulesAction = createAsyncThunk( 'unifiedalerting/fetchRulerRules', - (rulesSourceName: string): Promise => { - return withSerializedError(fetchRulerRules(rulesSourceName)); + ({ + rulesSourceName, + filter, + }: { + rulesSourceName: string; + filter?: FetchRulerRulesFilter; + }): Promise => { + return withSerializedError(fetchRulerRules(rulesSourceName, filter)); } ); @@ -119,12 +127,12 @@ export const fetchSilencesAction = createAsyncThunk( ); // this will only trigger ruler rules fetch if rules are not loaded yet and request is not in flight -export function fetchRulerRulesIfNotFetchedYet(dataSourceName: string): ThunkResult { +export function fetchRulerRulesIfNotFetchedYet(rulesSourceName: string): ThunkResult { return (dispatch, getStore) => { const { rulerRules } = getStore().unifiedAlerting; - const resp = rulerRules[dataSourceName]; + const resp = rulerRules[rulesSourceName]; if (!resp?.result && !(resp && isRulerNotSupportedResponse(resp)) && !resp?.loading) { - dispatch(fetchRulerRulesAction(dataSourceName)); + dispatch(fetchRulerRulesAction({ rulesSourceName })); } }; } @@ -132,12 +140,12 @@ export function fetchRulerRulesIfNotFetchedYet(dataSourceName: string): ThunkRes export function fetchAllPromAndRulerRulesAction(force = false): ThunkResult { return (dispatch, getStore) => { const { promRules, rulerRules } = getStore().unifiedAlerting; - getAllRulesSourceNames().map((name) => { - if (force || !promRules[name]?.loading) { - dispatch(fetchPromRulesAction(name)); + getAllRulesSourceNames().map((rulesSourceName) => { + if (force || !promRules[rulesSourceName]?.loading) { + dispatch(fetchPromRulesAction({ rulesSourceName })); } - if (force || !rulerRules[name]?.loading) { - dispatch(fetchRulerRulesAction(name)); + if (force || !rulerRules[rulesSourceName]?.loading) { + dispatch(fetchRulerRulesAction({ rulesSourceName })); } }); }; @@ -146,9 +154,9 @@ export function fetchAllPromAndRulerRulesAction(force = false): ThunkResult { return (dispatch, getStore) => { const { promRules } = getStore().unifiedAlerting; - getAllRulesSourceNames().map((name) => { - if (force || !promRules[name]?.loading) { - dispatch(fetchPromRulesAction(name)); + getAllRulesSourceNames().map((rulesSourceName) => { + if (force || !promRules[rulesSourceName]?.loading) { + dispatch(fetchPromRulesAction({ rulesSourceName })); } }); }; @@ -250,8 +258,8 @@ export function deleteRuleAction( } await deleteRule(ruleWithLocation); // refetch rules for this rules source - dispatch(fetchRulerRulesAction(ruleWithLocation.ruleSourceName)); - dispatch(fetchPromRulesAction(ruleWithLocation.ruleSourceName)); + dispatch(fetchRulerRulesAction({ rulesSourceName: ruleWithLocation.ruleSourceName })); + dispatch(fetchPromRulesAction({ rulesSourceName: ruleWithLocation.ruleSourceName })); if (options.navigateTo) { locationService.replace(options.navigateTo); @@ -712,7 +720,7 @@ export const updateLotexNamespaceAndGroupAction = createAsyncThunk( } // refetch all rules - await thunkAPI.dispatch(fetchRulerRulesAction(rulesSourceName)); + await thunkAPI.dispatch(fetchRulerRulesAction({ rulesSourceName })); })() ), { diff --git a/public/app/features/alerting/unified/state/reducers.ts b/public/app/features/alerting/unified/state/reducers.ts index b8de37879c0..25706cac90b 100644 --- a/public/app/features/alerting/unified/state/reducers.ts +++ b/public/app/features/alerting/unified/state/reducers.ts @@ -20,8 +20,9 @@ import { } from './actions'; export const reducer = combineReducers({ - promRules: createAsyncMapSlice('promRules', fetchPromRulesAction, (dataSourceName) => dataSourceName).reducer, - rulerRules: createAsyncMapSlice('rulerRules', fetchRulerRulesAction, (dataSourceName) => dataSourceName).reducer, + promRules: createAsyncMapSlice('promRules', fetchPromRulesAction, ({ rulesSourceName }) => rulesSourceName).reducer, + rulerRules: createAsyncMapSlice('rulerRules', fetchRulerRulesAction, ({ rulesSourceName }) => rulesSourceName) + .reducer, amConfigs: createAsyncMapSlice( 'amConfigs', fetchAlertManagerConfigAction, diff --git a/public/app/features/dashboard/components/PanelEditor/PanelEditorTabs.tsx b/public/app/features/dashboard/components/PanelEditor/PanelEditorTabs.tsx index bc6a30ac454..b39b7dbd139 100644 --- a/public/app/features/dashboard/components/PanelEditor/PanelEditorTabs.tsx +++ b/public/app/features/dashboard/components/PanelEditor/PanelEditorTabs.tsx @@ -36,6 +36,8 @@ export const PanelEditorTabs: FC = React.memo(({ panel, da return null; } + console.log(config.unifiedAlertingEnabled, tabs); + return (
diff --git a/public/app/features/dashboard/components/PanelEditor/state/selectors.ts b/public/app/features/dashboard/components/PanelEditor/state/selectors.ts index 8c9f002694c..64ce72c531f 100644 --- a/public/app/features/dashboard/components/PanelEditor/state/selectors.ts +++ b/public/app/features/dashboard/components/PanelEditor/state/selectors.ts @@ -34,7 +34,10 @@ export const getPanelEditorTabs = memoizeOne((tab?: string, plugin?: PanelPlugin }); } - if ((getConfig().alertingEnabled && plugin.meta.id === 'graph') || plugin.meta.id === 'timeseries') { + if ( + ((getConfig().alertingEnabled || getConfig().unifiedAlertingEnabled) && plugin.meta.id === 'graph') || + plugin.meta.id === 'timeseries' + ) { tabs.push({ id: PanelEditorTabId.Alert, text: 'Alert', diff --git a/public/app/features/dashboard/dashgrid/PanelChrome.tsx b/public/app/features/dashboard/dashgrid/PanelChrome.tsx index 456f607d783..75f8e09428e 100644 --- a/public/app/features/dashboard/dashgrid/PanelChrome.tsx +++ b/public/app/features/dashboard/dashgrid/PanelChrome.tsx @@ -475,7 +475,7 @@ export class PanelChrome extends Component { const { errorMessage, data } = this.state; const { transparent } = panel; - let alertState = config.unifiedAlertingEnabled ? undefined : data.alertState?.state; + const alertState = data.alertState?.state; const containerClassNames = classNames({ 'panel-container': true, diff --git a/public/app/features/dashboard/dashgrid/PanelChromeAngular.tsx b/public/app/features/dashboard/dashgrid/PanelChromeAngular.tsx index 3fa4bd20113..6d07465e37b 100644 --- a/public/app/features/dashboard/dashgrid/PanelChromeAngular.tsx +++ b/public/app/features/dashboard/dashgrid/PanelChromeAngular.tsx @@ -186,7 +186,7 @@ export class PanelChromeAngularUnconnected extends PureComponent { const { errorMessage, data } = this.state; const { transparent } = panel; - let alertState = config.unifiedAlertingEnabled ? undefined : data.alertState?.state; + const alertState = data.alertState?.state; const containerClassNames = classNames({ 'panel-container': true, diff --git a/public/app/features/query/state/DashboardQueryRunner/AlertStatesWorker.test.ts b/public/app/features/query/state/DashboardQueryRunner/AlertStatesWorker.test.ts index 1e4f2593631..35841e07cc8 100644 --- a/public/app/features/query/state/DashboardQueryRunner/AlertStatesWorker.test.ts +++ b/public/app/features/query/state/DashboardQueryRunner/AlertStatesWorker.test.ts @@ -12,7 +12,7 @@ jest.mock('@grafana/runtime', () => ({ })); function getDefaultOptions(): DashboardQueryRunnerOptions { - const dashboard: any = { id: 'an id' }; + const dashboard: any = { id: 'an id', panels: [{ alert: {} }] }; const range = getDefaultTimeRange(); return { dashboard, range }; @@ -57,6 +57,14 @@ describe('AlertStatesWorker', () => { }); }); + describe('when canWork is called for dashboard with no alert panels', () => { + it('then it should return false', () => { + const options = getDefaultOptions(); + options.dashboard.panels.forEach((panel) => delete panel.alert); + expect(worker.canWork(options)).toBe(false); + }); + }); + describe('when run is called with incorrect props', () => { it('then it should return the correct results', async () => { const { getMock, options } = getTestContext(); @@ -74,8 +82,8 @@ describe('AlertStatesWorker', () => { describe('when run is called with correct props and request is successful', () => { it('then it should return the correct results', async () => { const getResults: AlertStateInfo[] = [ - { id: 1, state: AlertState.Alerting, newStateDate: '2021-01-01', dashboardId: 1, panelId: 1 }, - { id: 2, state: AlertState.Alerting, newStateDate: '2021-02-01', dashboardId: 1, panelId: 2 }, + { id: 1, state: AlertState.Alerting, dashboardId: 1, panelId: 1 }, + { id: 2, state: AlertState.Alerting, dashboardId: 1, panelId: 2 }, ]; const { getMock, options } = getTestContext(); getMock.mockResolvedValue(getResults); diff --git a/public/app/features/query/state/DashboardQueryRunner/AlertStatesWorker.ts b/public/app/features/query/state/DashboardQueryRunner/AlertStatesWorker.ts index cc7c269644b..698f05d7165 100644 --- a/public/app/features/query/state/DashboardQueryRunner/AlertStatesWorker.ts +++ b/public/app/features/query/state/DashboardQueryRunner/AlertStatesWorker.ts @@ -14,6 +14,11 @@ export class AlertStatesWorker implements DashboardQueryRunnerWorker { return false; } + // if dashboard has no alerts, no point to query alert states + if (!dashboard.panels.find((panel) => !!panel.alert)) { + return false; + } + return true; } diff --git a/public/app/features/query/state/DashboardQueryRunner/DashboardQueryRunner.test.ts b/public/app/features/query/state/DashboardQueryRunner/DashboardQueryRunner.test.ts index dd2504fec57..9f8b3a1ae7c 100644 --- a/public/app/features/query/state/DashboardQueryRunner/DashboardQueryRunner.test.ts +++ b/public/app/features/query/state/DashboardQueryRunner/DashboardQueryRunner.test.ts @@ -23,8 +23,8 @@ function getTestContext() { const runner = createDashboardQueryRunner({ dashboard: options.dashboard, timeSrv: timeSrvMock }); const getResults: AlertStateInfo[] = [ - { id: 1, state: AlertState.Alerting, newStateDate: '2021-01-01', dashboardId: 1, panelId: 1 }, - { id: 2, state: AlertState.Alerting, newStateDate: '2021-02-01', dashboardId: 1, panelId: 2 }, + { id: 1, state: AlertState.Alerting, dashboardId: 1, panelId: 1 }, + { id: 2, state: AlertState.Alerting, dashboardId: 1, panelId: 2 }, ]; const getMock = jest.spyOn(backendSrv, 'get').mockResolvedValue(getResults); const executeAnnotationQueryMock = jest @@ -291,7 +291,6 @@ function getExpectedForAllResult(): DashboardQueryRunnerResult { alertState: { dashboardId: 1, id: 1, - newStateDate: '2021-01-01', panelId: 1, state: AlertState.Alerting, }, diff --git a/public/app/features/query/state/DashboardQueryRunner/DashboardQueryRunner.ts b/public/app/features/query/state/DashboardQueryRunner/DashboardQueryRunner.ts index 0ac27c6c4f3..8061be2dfee 100644 --- a/public/app/features/query/state/DashboardQueryRunner/DashboardQueryRunner.ts +++ b/public/app/features/query/state/DashboardQueryRunner/DashboardQueryRunner.ts @@ -17,6 +17,8 @@ import { getAnnotationsByPanelId } from './utils'; import { DashboardModel } from '../../../dashboard/state'; import { getTimeSrv, TimeSrv } from '../../../dashboard/services/TimeSrv'; import { RefreshEvent } from '../../../../types/events'; +import { config } from 'app/core/config'; +import { UnifiedAlertStatesWorker } from './UnifiedAlertStatesWorker'; class DashboardQueryRunnerImpl implements DashboardQueryRunner { private readonly results: ReplaySubject; @@ -29,7 +31,7 @@ class DashboardQueryRunnerImpl implements DashboardQueryRunner { private readonly dashboard: DashboardModel, private readonly timeSrv: TimeSrv = getTimeSrv(), private readonly workers: DashboardQueryRunnerWorker[] = [ - new AlertStatesWorker(), + config.featureToggles.ngalert ? new UnifiedAlertStatesWorker() : new AlertStatesWorker(), new SnapshotWorker(), new AnnotationsWorker(), ] diff --git a/public/app/features/query/state/DashboardQueryRunner/UnifiedAlertStatesWorker.test.ts b/public/app/features/query/state/DashboardQueryRunner/UnifiedAlertStatesWorker.test.ts new file mode 100644 index 00000000000..a45d333682b --- /dev/null +++ b/public/app/features/query/state/DashboardQueryRunner/UnifiedAlertStatesWorker.test.ts @@ -0,0 +1,200 @@ +import { AlertState, getDefaultTimeRange, TimeRange } from '@grafana/data'; +import { backendSrv } from 'app/core/services/backend_srv'; + +import { DashboardQueryRunnerOptions } from './types'; +import { UnifiedAlertStatesWorker } from './UnifiedAlertStatesWorker'; +import { silenceConsoleOutput } from '../../../../../test/core/utils/silenceConsoleOutput'; +import * as store from '../../../../store/store'; +import { PromAlertingRuleState, PromRuleDTO, PromRulesResponse, PromRuleType } from 'app/types/unified-alerting-dto'; +import { Annotation } from 'app/features/alerting/unified/utils/constants'; +import { lastValueFrom } from 'rxjs'; + +jest.mock('@grafana/runtime', () => ({ + ...((jest.requireActual('@grafana/runtime') as unknown) as object), + getBackendSrv: () => backendSrv, +})); + +function getDefaultOptions(): DashboardQueryRunnerOptions { + const dashboard: any = { id: 'an id', uid: 'a uid' }; + const range = getDefaultTimeRange(); + + return { dashboard, range }; +} + +function getTestContext() { + jest.clearAllMocks(); + const dispatchMock = jest.spyOn(store, 'dispatch'); + const options = getDefaultOptions(); + const getMock = jest.spyOn(backendSrv, 'get'); + + return { getMock, options, dispatchMock }; +} + +describe('UnifiedAlertStatesWorker', () => { + const worker = new UnifiedAlertStatesWorker(); + + describe('when canWork is called with correct props', () => { + it('then it should return true', () => { + const options = getDefaultOptions(); + + expect(worker.canWork(options)).toBe(true); + }); + }); + + describe('when canWork is called with no dashboard id', () => { + it('then it should return false', () => { + const dashboard: any = {}; + const options = { ...getDefaultOptions(), dashboard }; + + expect(worker.canWork(options)).toBe(false); + }); + }); + + describe('when canWork is called with wrong range', () => { + it('then it should return false', () => { + const defaultRange = getDefaultTimeRange(); + const range: TimeRange = { ...defaultRange, raw: { ...defaultRange.raw, to: 'now-6h' } }; + const options = { ...getDefaultOptions(), range }; + + expect(worker.canWork(options)).toBe(false); + }); + }); + + describe('when run is called with incorrect props', () => { + it('then it should return the correct results', async () => { + const { getMock, options } = getTestContext(); + const dashboard: any = {}; + + await expect(worker.work({ ...options, dashboard })).toEmitValuesWith((received) => { + expect(received).toHaveLength(1); + const results = received[0]; + expect(results).toEqual({ alertStates: [], annotations: [] }); + expect(getMock).not.toHaveBeenCalled(); + }); + }); + }); + + describe('when run repeatedly for the same dashboard and no alert rules are found', () => { + it('then canWork should start returning false', async () => { + const worker = new UnifiedAlertStatesWorker(); + + const getResults: PromRulesResponse = { + status: 'success', + data: { + groups: [], + }, + }; + const { getMock, options } = getTestContext(); + getMock.mockResolvedValue(getResults); + expect(worker.canWork(options)).toBe(true); + await lastValueFrom(worker.work(options)); + expect(worker.canWork(options)).toBe(false); + }); + }); + + describe('when run is called with correct props and request is successful', () => { + function mockPromRuleDTO(overrides: Partial): PromRuleDTO { + return { + alerts: [], + health: 'ok', + name: 'foo', + query: 'foo', + type: PromRuleType.Alerting, + state: PromAlertingRuleState.Firing, + labels: {}, + annotations: {}, + ...overrides, + }; + } + it('then it should return the correct results', async () => { + const getResults: PromRulesResponse = { + status: 'success', + data: { + groups: [ + { + name: 'group', + file: '', + interval: 1, + rules: [ + mockPromRuleDTO({ + state: PromAlertingRuleState.Firing, + annotations: { + [Annotation.dashboardUID]: 'a uid', + [Annotation.panelID]: '1', + }, + }), + mockPromRuleDTO({ + state: PromAlertingRuleState.Inactive, + annotations: { + [Annotation.dashboardUID]: 'a uid', + [Annotation.panelID]: '2', + }, + }), + mockPromRuleDTO({ + state: PromAlertingRuleState.Pending, + annotations: { + [Annotation.dashboardUID]: 'a uid', + [Annotation.panelID]: '2', + }, + }), + ], + }, + ], + }, + }; + const { getMock, options } = getTestContext(); + getMock.mockResolvedValue(getResults); + + await expect(worker.work(options)).toEmitValuesWith((received) => { + expect(received).toHaveLength(1); + const results = received[0]; + expect(results).toEqual({ + alertStates: [ + { id: 0, state: AlertState.Alerting, dashboardId: 'an id', panelId: 1 }, + { id: 1, state: AlertState.Pending, dashboardId: 'an id', panelId: 2 }, + ], + annotations: [], + }); + }); + + expect(getMock).toHaveBeenCalledTimes(1); + expect(getMock).toHaveBeenCalledWith( + '/api/prometheus/grafana/api/v1/rules', + { dashboard_uid: 'a uid' }, + 'dashboard-query-runner-unified-alert-states-an id' + ); + }); + }); + + describe('when run is called with correct props and request fails', () => { + silenceConsoleOutput(); + it('then it should return the correct results', async () => { + const { getMock, options, dispatchMock } = getTestContext(); + getMock.mockRejectedValue({ message: 'An error' }); + + await expect(worker.work(options)).toEmitValuesWith((received) => { + expect(received).toHaveLength(1); + const results = received[0]; + expect(results).toEqual({ alertStates: [], annotations: [] }); + expect(getMock).toHaveBeenCalledTimes(1); + expect(dispatchMock).toHaveBeenCalledTimes(1); + }); + }); + }); + + describe('when run is called with correct props and request is cancelled', () => { + silenceConsoleOutput(); + it('then it should return the correct results', async () => { + const { getMock, options, dispatchMock } = getTestContext(); + getMock.mockRejectedValue({ cancelled: true }); + + await expect(worker.work(options)).toEmitValuesWith((received) => { + expect(received).toHaveLength(1); + const results = received[0]; + expect(results).toEqual({ alertStates: [], annotations: [] }); + expect(getMock).toHaveBeenCalledTimes(1); + expect(dispatchMock).not.toHaveBeenCalled(); + }); + }); + }); +}); diff --git a/public/app/features/query/state/DashboardQueryRunner/UnifiedAlertStatesWorker.ts b/public/app/features/query/state/DashboardQueryRunner/UnifiedAlertStatesWorker.ts new file mode 100644 index 00000000000..fc43b6b93a7 --- /dev/null +++ b/public/app/features/query/state/DashboardQueryRunner/UnifiedAlertStatesWorker.ts @@ -0,0 +1,99 @@ +import { DashboardQueryRunnerOptions, DashboardQueryRunnerWorker, DashboardQueryRunnerWorkerResult } from './types'; +import { from, Observable } from 'rxjs'; +import { getBackendSrv } from '@grafana/runtime'; +import { catchError, map } from 'rxjs/operators'; +import { emptyResult, handleDashboardQueryRunnerWorkerError } from './utils'; +import { PromAlertingRuleState, PromRulesResponse } from 'app/types/unified-alerting-dto'; +import { AlertState, AlertStateInfo } from '@grafana/data'; +import { isAlertingRule } from 'app/features/alerting/unified/utils/rules'; +import { Annotation } from 'app/features/alerting/unified/utils/constants'; + +export class UnifiedAlertStatesWorker implements DashboardQueryRunnerWorker { + // maps dashboard uid to wether it has alert rules. + // if it is determined that a dashboard does not have alert rules, + // further attempts to get alert states for it will not be made + private hasAlertRules: Record = {}; + + canWork({ dashboard, range }: DashboardQueryRunnerOptions): boolean { + if (!dashboard.uid) { + return false; + } + + if (range.raw.to !== 'now') { + return false; + } + + if (this.hasAlertRules[dashboard.uid] === false) { + return false; + } + + return true; + } + + work(options: DashboardQueryRunnerOptions): Observable { + if (!this.canWork(options)) { + return emptyResult(); + } + + const { dashboard } = options; + return from( + getBackendSrv().get( + '/api/prometheus/grafana/api/v1/rules', + { + dashboard_uid: dashboard.uid, + }, + `dashboard-query-runner-unified-alert-states-${dashboard.id}` + ) + ).pipe( + map((result: PromRulesResponse) => { + if (result.status === 'success') { + this.hasAlertRules[dashboard.uid] = false; + const panelIdToAlertState: Record = {}; + result.data.groups.forEach((group) => + group.rules.forEach((rule) => { + if (isAlertingRule(rule) && rule.annotations && rule.annotations[Annotation.panelID]) { + this.hasAlertRules[dashboard.uid] = true; + const panelId = Number(rule.annotations[Annotation.panelID]); + const state = promAlertStateToAlertState(rule.state); + + // there can be multiple alerts per panel, so we make sure we get the most severe state: + // alerting > pending > ok + if (!panelIdToAlertState[panelId]) { + panelIdToAlertState[panelId] = { + state, + id: Object.keys(panelIdToAlertState).length, + panelId, + dashboardId: dashboard.id, + }; + } else if ( + state === AlertState.Alerting && + panelIdToAlertState[panelId].state !== AlertState.Alerting + ) { + panelIdToAlertState[panelId].state = AlertState.Alerting; + } else if ( + state === AlertState.Pending && + panelIdToAlertState[panelId].state !== AlertState.Alerting && + panelIdToAlertState[panelId].state !== AlertState.Pending + ) { + panelIdToAlertState[panelId].state = AlertState.Pending; + } + } + }) + ); + return { alertStates: Object.values(panelIdToAlertState), annotations: [] }; + } + throw new Error(`Unexpected alert rules response.`); + }), + catchError(handleDashboardQueryRunnerWorkerError) + ); + } +} + +function promAlertStateToAlertState(state: PromAlertingRuleState): AlertState { + if (state === PromAlertingRuleState.Firing) { + return AlertState.Alerting; + } else if (state === PromAlertingRuleState.Pending) { + return AlertState.Pending; + } + return AlertState.OK; +} diff --git a/public/app/features/query/state/DashboardQueryRunner/testHelpers.ts b/public/app/features/query/state/DashboardQueryRunner/testHelpers.ts index ab7bc4b92e7..7891c23e604 100644 --- a/public/app/features/query/state/DashboardQueryRunner/testHelpers.ts +++ b/public/app/features/query/state/DashboardQueryRunner/testHelpers.ts @@ -52,6 +52,7 @@ export function getDefaultOptions(): DashboardQueryRunnerOptions { subscribe: jest.fn().mockReturnValue({ unsubscribe: jest.fn() }), publish: jest.fn(), }, + panels: [{ alert: {} } as any], }; const range = getDefaultTimeRange(); diff --git a/public/app/types/unified-alerting-dto.ts b/public/app/types/unified-alerting-dto.ts index 578fa392ff2..cd04d4050ab 100644 --- a/public/app/types/unified-alerting-dto.ts +++ b/public/app/types/unified-alerting-dto.ts @@ -42,7 +42,7 @@ export interface PromAlertingRuleDTO extends PromRuleDTOBase { value: string; }>; labels: Labels; - annotations: Annotations; + annotations?: Annotations; duration?: number; // for state: PromAlertingRuleState; type: PromRuleType.Alerting;