From 4b720206d4cadc5655823808a28bcceeaed58330 Mon Sep 17 00:00:00 2001 From: Sonia Aguilar <33540275+soniaAguilarPeiron@users.noreply.github.com> Date: Fri, 10 May 2024 12:27:06 +0200 Subject: [PATCH] Alerting: Reduce number of request fetching rules in the dashboard view using rtkq (#86991) * Reduce number of request fetching rules in the dashboard view using rtkq * Fix UnifiedAlertStatesWorker work * refactor ungroupRulesByFileName * Address review comments and fix test * fix DashboardQueryRunner test * Fix tests * Update AlertStatesDataLayer to use RTKQ * Fix PanelAlertTabContent test * Fix PanelAlertTabContent test after adding RTKQ * fix test and address PR review comments * Update useCombinedRuleNamespaces to have both dashboardUID and panelId as optional params and rename the hook * Address review pr comment * remove test about template variables * Use poll interval in useCombinedRules --- .../unified/PanelAlertTabContent.test.tsx | 189 ++++++++---------- .../alerting/unified/PanelAlertTabContent.tsx | 3 +- .../alerting/unified/api/alertRuleApi.ts | 12 +- .../alerting/unified/api/prometheus.ts | 17 +- .../features/alerting/unified/api/ruler.ts | 2 +- .../alerting/unified/hooks/useCombinedRule.ts | 2 +- .../hooks/useCombinedRuleNamespaces.ts | 112 ++++++++++- .../unified/hooks/usePanelCombinedRules.ts | 71 +------ .../integration/AlertRulesDrawerContent.tsx | 22 +- .../PanelDataAlertingTab.test.tsx | 176 ++++++++-------- .../PanelDataPane/PanelDataAlertingTab.tsx | 7 +- .../scene/AlertStatesDataLayer.ts | 105 +++++----- .../DashboardQueryRunner.test.ts | 84 ++++++-- .../UnifiedAlertStatesWorker.test.ts | 101 ++++------ .../UnifiedAlertStatesWorker.ts | 101 +++++----- 15 files changed, 537 insertions(+), 467 deletions(-) diff --git a/public/app/features/alerting/unified/PanelAlertTabContent.test.tsx b/public/app/features/alerting/unified/PanelAlertTabContent.test.tsx index bf2364f7e961..0eeddc37a2a9 100644 --- a/public/app/features/alerting/unified/PanelAlertTabContent.test.tsx +++ b/public/app/features/alerting/unified/PanelAlertTabContent.test.tsx @@ -1,37 +1,35 @@ -import { render, waitFor } from '@testing-library/react'; +import { render } from '@testing-library/react'; import React from 'react'; import { TestProvider } from 'test/helpers/TestProvider'; import { byTestId } from 'testing-library-selector'; import { DataSourceApi } from '@grafana/data'; import { PromOptions, PrometheusDatasource } from '@grafana/prometheus'; -import { setDataSourceSrv } from '@grafana/runtime'; +import { setDataSourceSrv, setPluginExtensionsHook } from '@grafana/runtime'; import * as ruleActionButtons from 'app/features/alerting/unified/components/rules/RuleActionsButtons'; import { DashboardModel, PanelModel } from 'app/features/dashboard/state'; import { getDatasourceSrv } from 'app/features/plugins/datasource_srv'; -import { toggleOption } from 'app/features/variables/pickers/OptionsPicker/reducer'; -import { toKeyedAction } from 'app/features/variables/state/keyedVariablesReducer'; import { configureStore } from 'app/store/configureStore'; import { AccessControlAction } from 'app/types'; -import { AlertQuery } from 'app/types/unified-alerting-dto'; +import { AlertQuery, PromRulesResponse } from 'app/types/unified-alerting-dto'; import { PanelAlertTabContent } from './PanelAlertTabContent'; -import { fetchRules } from './api/prometheus'; -import { fetchRulerRules } from './api/ruler'; +import * as apiRuler from './api/ruler'; +import * as alertingAbilities from './hooks/useAbilities'; +import { mockAlertRuleApi, setupMswServer } from './mockApi'; import { + MockDataSourceSrv, grantUserPermissions, mockDataSource, - MockDataSourceSrv, + mockPromAlert, mockPromAlertingRule, - mockPromRuleGroup, - mockPromRuleNamespace, - mockRulerGrafanaRule, + mockRulerAlertingRule, + mockRulerRuleGroup, } from './mocks'; import { RuleFormValues } from './types/rule-form'; import * as config from './utils/config'; import { Annotation } from './utils/constants'; import { DataSourceType, GRAFANA_RULES_SOURCE_NAME } from './utils/datasource'; -import * as ruleFormUtils from './utils/rule-form'; jest.mock('./api/prometheus'); jest.mock('./api/ruler'); @@ -39,7 +37,8 @@ jest.mock('../../../core/hooks/useMediaQueryChange'); jest.spyOn(config, 'getAllDataSources'); jest.spyOn(ruleActionButtons, 'matchesWidth').mockReturnValue(false); - +jest.spyOn(apiRuler, 'rulerUrlBuilder'); +jest.spyOn(alertingAbilities, 'useAlertRuleAbility'); const dataSources = { prometheus: mockDataSource({ name: 'Prometheus', @@ -57,10 +56,8 @@ dataSources.default.meta.alerting = true; const mocks = { getAllDataSources: jest.mocked(config.getAllDataSources), - api: { - fetchRules: jest.mocked(fetchRules), - fetchRulerRules: jest.mocked(fetchRulerRules), - }, + useAlertRuleAbilityMock: jest.mocked(alertingAbilities.useAlertRuleAbility), + rulerBuilderMock: jest.mocked(apiRuler.rulerUrlBuilder), }; const renderAlertTabContent = ( @@ -75,70 +72,82 @@ const renderAlertTabContent = ( ); }; -const rules = [ - mockPromRuleNamespace({ - name: 'default', +const promResponse: PromRulesResponse = { + status: 'success', + data: { groups: [ - mockPromRuleGroup({ + { name: 'mygroup', + file: 'default', rules: [ mockPromAlertingRule({ name: 'dashboardrule1', - annotations: { - [Annotation.dashboardUID]: '12', - [Annotation.panelID]: '34', - }, + alerts: [ + mockPromAlert({ + labels: { severity: 'critical' }, + annotations: { + [Annotation.dashboardUID]: '12', + [Annotation.panelID]: '34', + }, + }), + ], + totals: { alerting: 1 }, + totalsFiltered: { alerting: 1 }, }), ], - }), - mockPromRuleGroup({ + interval: 20, + }, + { name: 'othergroup', + file: 'default', rules: [ mockPromAlertingRule({ name: 'dashboardrule2', - annotations: { - [Annotation.dashboardUID]: '121', - [Annotation.panelID]: '341', - }, + alerts: [ + mockPromAlert({ + labels: { severity: 'critical' }, + annotations: { + [Annotation.dashboardUID]: '121', + [Annotation.panelID]: '341', + }, + }), + ], + totals: { alerting: 1 }, + totalsFiltered: { alerting: 1 }, }), ], - }), + interval: 20, + }, ], - }), -]; - -const rulerRules = { + totals: { + alerting: 2, + }, + }, +}; +const rulerResponse = { default: [ - { + mockRulerRuleGroup({ name: 'mygroup', rules: [ - mockRulerGrafanaRule( - { - annotations: { - [Annotation.dashboardUID]: '12', - [Annotation.panelID]: '34', - }, + mockRulerAlertingRule({ + alert: 'dashboardrule1', + annotations: { + [Annotation.dashboardUID]: '12', + [Annotation.panelID]: '34', }, - { - title: 'dashboardrule1', - } - ), + }), ], - }, + }), { name: 'othergroup', rules: [ - mockRulerGrafanaRule( - { - annotations: { - [Annotation.dashboardUID]: '121', - [Annotation.panelID]: '341', - }, + mockRulerAlertingRule({ + alert: 'dashboardrule2', + annotations: { + [Annotation.dashboardUID]: '121', + [Annotation.panelID]: '341', }, - { - title: 'dashboardrule2', - } - ), + }), ], }, ], @@ -177,6 +186,8 @@ const ui = { createButton: byTestId('create-alert-rule-button'), }; +const server = setupMswServer(); + describe('PanelAlertTabContent', () => { beforeEach(() => { jest.resetAllMocks(); @@ -188,6 +199,12 @@ describe('PanelAlertTabContent', () => { AccessControlAction.AlertingRuleExternalRead, AccessControlAction.AlertingRuleExternalWrite, ]); + + setPluginExtensionsHook(() => ({ + extensions: [], + isLoading: false, + })); + mocks.getAllDataSources.mockReturnValue(Object.values(dataSources)); const dsService = new MockDataSourceSrv(dataSources); dsService.datasources[dataSources.prometheus.uid] = new PrometheusDatasource( @@ -195,6 +212,15 @@ describe('PanelAlertTabContent', () => { ) as DataSourceApi; dsService.datasources[dataSources.default.uid] = new PrometheusDatasource(dataSources.default) as DataSourceApi; setDataSourceSrv(dsService); + mocks.rulerBuilderMock.mockReturnValue({ + rules: () => ({ path: `api/ruler/${GRAFANA_RULES_SOURCE_NAME}/api/v1/rules` }), + namespace: () => ({ path: 'ruler' }), + namespaceGroup: () => ({ path: 'ruler' }), + }); + mocks.useAlertRuleAbilityMock.mockReturnValue([true, true]); + + mockAlertRuleApi(server).prometheusRuleNamespaces(GRAFANA_RULES_SOURCE_NAME, promResponse); + mockAlertRuleApi(server).rulerRules(GRAFANA_RULES_SOURCE_NAME, rulerResponse); }); it('Will take into account panel maxDataPoints', async () => { @@ -286,14 +312,12 @@ describe('PanelAlertTabContent', () => { }); }); - it.skip('Will render alerts belonging to panel and a button to create alert from panel queries', async () => { - mocks.api.fetchRules.mockResolvedValue(rules); - mocks.api.fetchRulerRules.mockResolvedValue(rulerRules); - + it('Will render alerts belonging to panel and a button to create alert from panel queries', async () => { renderAlertTabContent(dashboard, panel); const rows = await ui.row.findAll(); - expect(rows).toHaveLength(1); + // after updating to RTKQ, the response is already returning the alerts belonging to the panel + expect(rows).toHaveLength(2); expect(rows[0]).toHaveTextContent(/dashboardrule1/); expect(rows[0]).not.toHaveTextContent(/dashboardrule2/); @@ -315,44 +339,5 @@ describe('PanelAlertTabContent', () => { }; expect(defaultsWithDeterministicTime).toMatchSnapshot(); - - expect(mocks.api.fetchRulerRules).toHaveBeenCalledWith( - { dataSourceName: GRAFANA_RULES_SOURCE_NAME, apiVersion: 'legacy' }, - { - dashboardUID: dashboard.uid, - panelId: panel.id, - } - ); - expect(mocks.api.fetchRules).toHaveBeenCalledWith( - GRAFANA_RULES_SOURCE_NAME, - { - dashboardUID: dashboard.uid, - panelId: panel.id, - }, - undefined, - undefined, - undefined, - undefined - ); - }); - - it('Update NewRuleFromPanel button url when template changes', async () => { - const panelToRuleValuesSpy = jest.spyOn(ruleFormUtils, 'panelToRuleFormValues'); - - const store = configureStore(); - renderAlertTabContent(dashboard, panel, store); - - store.dispatch( - toKeyedAction( - 'optionKey', - toggleOption({ - option: { value: 'optionValue', selected: true, text: 'Option' }, - clearOthers: false, - forceSelect: false, - }) - ) - ); - - await waitFor(() => expect(panelToRuleValuesSpy).toHaveBeenCalledTimes(2)); }); }); diff --git a/public/app/features/alerting/unified/PanelAlertTabContent.tsx b/public/app/features/alerting/unified/PanelAlertTabContent.tsx index 79bee10201ba..3dafb5965756 100644 --- a/public/app/features/alerting/unified/PanelAlertTabContent.tsx +++ b/public/app/features/alerting/unified/PanelAlertTabContent.tsx @@ -11,6 +11,7 @@ import { NewRuleFromPanelButton } from './components/panel-alerts-tab/NewRuleFro import { RulesTable } from './components/rules/RulesTable'; import { usePanelCombinedRules } from './hooks/usePanelCombinedRules'; import { getRulesPermissions } from './utils/access-control'; +import { stringifyErrorLike } from './utils/misc'; interface Props { dashboard: DashboardModel; @@ -30,7 +31,7 @@ export const PanelAlertTabContent = ({ dashboard, panel }: Props) => { const alert = errors.length ? ( {errors.map((error, index) => ( -
Failed to load Grafana rules state: {error.message || 'Unknown error.'}
+
Failed to load Grafana rules state: {stringifyErrorLike(error)}
))}
) : null; diff --git a/public/app/features/alerting/unified/api/alertRuleApi.ts b/public/app/features/alerting/unified/api/alertRuleApi.ts index 4faddece4581..3bd3a1f91f79 100644 --- a/public/app/features/alerting/unified/api/alertRuleApi.ts +++ b/public/app/features/alerting/unified/api/alertRuleApi.ts @@ -162,14 +162,22 @@ export const alertRuleApi = alertingApi.injectEndpoints({ prometheusRuleNamespaces: build.query< RuleNamespace[], - { ruleSourceName: string; namespace?: string; groupName?: string; ruleName?: string; dashboardUid?: string } + { + ruleSourceName: string; + namespace?: string; + groupName?: string; + ruleName?: string; + dashboardUid?: string; + panelId?: number; + } >({ - query: ({ ruleSourceName, namespace, groupName, ruleName, dashboardUid }) => { + query: ({ ruleSourceName, namespace, groupName, ruleName, dashboardUid, panelId }) => { const queryParams: Record = { file: namespace, rule_group: groupName, rule_name: ruleName, dashboard_uid: dashboardUid, // Supported only by Grafana managed rules + panel_id: panelId?.toString(), // Supported only by Grafana managed rules }; return { diff --git a/public/app/features/alerting/unified/api/prometheus.ts b/public/app/features/alerting/unified/api/prometheus.ts index 75cd01f68c24..9a5c20bcd04e 100644 --- a/public/app/features/alerting/unified/api/prometheus.ts +++ b/public/app/features/alerting/unified/api/prometheus.ts @@ -2,14 +2,14 @@ import { lastValueFrom } from 'rxjs'; import { getBackendSrv } from '@grafana/runtime'; import { Matcher } from 'app/plugins/datasource/alertmanager/types'; -import { RuleIdentifier, RuleNamespace } from 'app/types/unified-alerting'; +import { RuleGroup, RuleIdentifier, RuleNamespace } from 'app/types/unified-alerting'; import { PromRuleGroupDTO, PromRulesResponse } from 'app/types/unified-alerting-dto'; import { getDatasourceAPIUid, GRAFANA_RULES_SOURCE_NAME } from '../utils/datasource'; import { isCloudRuleIdentifier, isPrometheusRuleIdentifier } from '../utils/rules'; export interface FetchPromRulesFilter { - dashboardUID: string; + dashboardUID?: string; panelId?: number; } @@ -102,7 +102,20 @@ export const groupRulesByFileName = (groups: PromRuleGroupDTO[], dataSourceName: return Object.values(nsMap); }; +export const ungroupRulesByFileName = (namespaces: RuleNamespace[] = []): PromRuleGroupDTO[] => { + return namespaces?.flatMap((namespace) => + namespace.groups.flatMap((group) => ruleGroupToPromRuleGroupDTO(group, namespace.name)) + ); +}; +function ruleGroupToPromRuleGroupDTO(group: RuleGroup, namespace: string): PromRuleGroupDTO { + return { + name: group.name, + file: namespace, + rules: group.rules, + interval: group.interval, + }; +} export async function fetchRules( dataSourceName: string, filter?: FetchPromRulesFilter, diff --git a/public/app/features/alerting/unified/api/ruler.ts b/public/app/features/alerting/unified/api/ruler.ts index 69104d16ff69..6426b828db51 100644 --- a/public/app/features/alerting/unified/api/ruler.ts +++ b/public/app/features/alerting/unified/api/ruler.ts @@ -68,7 +68,7 @@ export async function setRulerRuleGroup( } export interface FetchRulerRulesFilter { - dashboardUID: string; + dashboardUID?: string; panelId?: number; } diff --git a/public/app/features/alerting/unified/hooks/useCombinedRule.ts b/public/app/features/alerting/unified/hooks/useCombinedRule.ts index b3070d219049..62c1d5da811c 100644 --- a/public/app/features/alerting/unified/hooks/useCombinedRule.ts +++ b/public/app/features/alerting/unified/hooks/useCombinedRule.ts @@ -282,7 +282,7 @@ export function useCombinedRule({ ruleIdentifier }: { ruleIdentifier: RuleIdenti }; } -const grafanaRulerConfig: RulerDataSourceConfig = { +export const grafanaRulerConfig: RulerDataSourceConfig = { dataSourceName: GRAFANA_RULES_SOURCE_NAME, apiVersion: 'legacy', }; diff --git a/public/app/features/alerting/unified/hooks/useCombinedRuleNamespaces.ts b/public/app/features/alerting/unified/hooks/useCombinedRuleNamespaces.ts index 18fc75e3755a..737d01d532ca 100644 --- a/public/app/features/alerting/unified/hooks/useCombinedRuleNamespaces.ts +++ b/public/app/features/alerting/unified/hooks/useCombinedRuleNamespaces.ts @@ -21,6 +21,8 @@ import { RulerRulesConfigDTO, } from 'app/types/unified-alerting-dto'; +import { alertRuleApi } from '../api/alertRuleApi'; +import { RULE_LIST_POLL_INTERVAL_MS } from '../utils/constants'; import { getAllRulesSources, getRulesSourceByName, @@ -36,9 +38,10 @@ import { isRecordingRulerRule, } from '../utils/rules'; +import { grafanaRulerConfig } from './useCombinedRule'; import { useUnifiedAlertingSelector } from './useUnifiedAlertingSelector'; -interface CacheValue { +export interface CacheValue { promRules?: RuleNamespace[]; rulerRules?: RulerRulesConfigDTO | null; result: CombinedRuleNamespace[]; @@ -211,7 +214,10 @@ export function sortRulesByName(rules: CombinedRule[]) { return rules.sort((a, b) => a.name.localeCompare(b.name)); } -function addRulerGroupsToCombinedNamespace(namespace: CombinedRuleNamespace, groups: RulerRuleGroupDTO[] = []): void { +export function addRulerGroupsToCombinedNamespace( + namespace: CombinedRuleNamespace, + groups: RulerRuleGroupDTO[] = [] +): void { namespace.groups = groups.map((group) => { const numRecordingRules = group.rules.filter((rule) => isRecordingRulerRule(rule)).length; const numPaused = group.rules.filter((rule) => isGrafanaRulerRule(rule) && rule.grafana_alert.is_paused).length; @@ -231,7 +237,7 @@ function addRulerGroupsToCombinedNamespace(namespace: CombinedRuleNamespace, gro }); } -function addPromGroupsToCombinedNamespace(namespace: CombinedRuleNamespace, groups: RuleGroup[]): void { +export function addPromGroupsToCombinedNamespace(namespace: CombinedRuleNamespace, groups: RuleGroup[]): void { const existingGroupsByName = new Map(); namespace.groups.forEach((group) => existingGroupsByName.set(group.name, group)); @@ -457,3 +463,103 @@ function hashQuery(query: string) { // labels matchers can be reordered, so sort the enitre string, esentially comparing just the character counts return query.split('').sort().join(''); } + +/* + This hook returns combined Grafana rules. Optionally, it can filter rules by dashboard UID and panel ID. +*/ +export function useCombinedRules( + dashboardUID?: string, + panelId?: number, + poll?: boolean +): { + loading: boolean; + result?: CombinedRuleNamespace[]; + error?: unknown; +} { + const { + currentData: promRuleNs, + isLoading: isLoadingPromRules, + error: promRuleNsError, + } = alertRuleApi.endpoints.prometheusRuleNamespaces.useQuery( + { + ruleSourceName: GRAFANA_RULES_SOURCE_NAME, + dashboardUid: dashboardUID, + panelId, + }, + { + pollingInterval: poll ? RULE_LIST_POLL_INTERVAL_MS : undefined, + } + ); + + const { + currentData: rulerRules, + isLoading: isLoadingRulerRules, + error: rulerRulesError, + } = alertRuleApi.endpoints.rulerRules.useQuery( + { + rulerConfig: grafanaRulerConfig, + filter: { dashboardUID, panelId }, + }, + { + pollingInterval: poll ? RULE_LIST_POLL_INTERVAL_MS : undefined, + } + ); + + //--------- + // cache results per rules source, so we only recalculate those for which results have actually changed + const cache = useRef>({}); + + const rulesSource = getRulesSourceByName(GRAFANA_RULES_SOURCE_NAME); + + const rules = useMemo(() => { + if (!rulesSource) { + return []; + } + + const cached = cache.current[GRAFANA_RULES_SOURCE_NAME]; + if (cached && cached.promRules === promRuleNs && cached.rulerRules === rulerRules) { + return cached.result; + } + const namespaces: Record = {}; + + // first get all the ruler rules from the data source + Object.entries(rulerRules || {}).forEach(([namespaceName, groups]) => { + const namespace: CombinedRuleNamespace = { + rulesSource, + name: namespaceName, + groups: [], + }; + + // We need to set the namespace_uid for grafana rules as it's required to obtain the rule's groups + // All rules from all groups have the same namespace_uid so we're taking the first one. + if (isGrafanaRulerRule(groups[0].rules[0])) { + namespace.uid = groups[0].rules[0].grafana_alert.namespace_uid; + } + + namespaces[namespaceName] = namespace; + addRulerGroupsToCombinedNamespace(namespace, groups); + }); + + // then correlate with prometheus rules + promRuleNs?.forEach(({ name: namespaceName, groups }) => { + const ns = (namespaces[namespaceName] = namespaces[namespaceName] || { + rulesSource, + name: namespaceName, + groups: [], + }); + + addPromGroupsToCombinedNamespace(ns, groups); + }); + + const result = Object.values(namespaces); + + cache.current[GRAFANA_RULES_SOURCE_NAME] = { promRules: promRuleNs, rulerRules, result }; + return result; + }, [promRuleNs, rulerRules, rulesSource]); + + return { + loading: isLoadingPromRules || isLoadingRulerRules, + error: promRuleNsError ?? rulerRulesError, + result: rules, + }; +} diff --git a/public/app/features/alerting/unified/hooks/usePanelCombinedRules.ts b/public/app/features/alerting/unified/hooks/usePanelCombinedRules.ts index eb8b176b40d9..388c3ff2b1b8 100644 --- a/public/app/features/alerting/unified/hooks/usePanelCombinedRules.ts +++ b/public/app/features/alerting/unified/hooks/usePanelCombinedRules.ts @@ -1,16 +1,6 @@ -import { SerializedError } from '@reduxjs/toolkit'; -import { useEffect, useMemo } from 'react'; - -import { useDispatch } from 'app/types'; import { CombinedRule } from 'app/types/unified-alerting'; -import { fetchPromRulesAction, fetchRulerRulesAction } from '../state/actions'; -import { Annotation, RULE_LIST_POLL_INTERVAL_MS } from '../utils/constants'; -import { GRAFANA_RULES_SOURCE_NAME } from '../utils/datasource'; -import { initialAsyncRequestState } from '../utils/redux'; - -import { useCombinedRuleNamespaces } from './useCombinedRuleNamespaces'; -import { useUnifiedAlertingSelector } from './useUnifiedAlertingSelector'; +import { useCombinedRules } from './useCombinedRuleNamespaces'; interface Options { dashboardUID: string; @@ -20,70 +10,19 @@ interface Options { } interface ReturnBag { - errors: SerializedError[]; + errors: unknown[]; rules: CombinedRule[]; loading?: boolean; } export function usePanelCombinedRules({ dashboardUID, panelId, poll = false }: Options): ReturnBag { - const dispatch = useDispatch(); - - const promRuleRequest = - useUnifiedAlertingSelector((state) => state.promRules[GRAFANA_RULES_SOURCE_NAME]) ?? initialAsyncRequestState; - const rulerRuleRequest = - useUnifiedAlertingSelector((state) => state.rulerRules[GRAFANA_RULES_SOURCE_NAME]) ?? initialAsyncRequestState; - - // fetch rules, then poll every RULE_LIST_POLL_INTERVAL_MS - useEffect(() => { - const fetch = () => { - dispatch( - fetchPromRulesAction({ - rulesSourceName: GRAFANA_RULES_SOURCE_NAME, - filter: { dashboardUID, panelId }, - }) - ); - dispatch( - fetchRulerRulesAction({ - rulesSourceName: GRAFANA_RULES_SOURCE_NAME, - filter: { dashboardUID, panelId }, - }) - ); - }; - fetch(); - if (poll) { - const interval = setInterval(fetch, RULE_LIST_POLL_INTERVAL_MS); - return () => { - clearInterval(interval); - }; - } - return () => {}; - }, [dispatch, poll, panelId, dashboardUID]); - - const loading = promRuleRequest.loading || rulerRuleRequest.loading; - const errors = [promRuleRequest.error, rulerRuleRequest.error].filter( - (err: SerializedError | undefined): err is SerializedError => !!err - ); - - const combinedNamespaces = useCombinedRuleNamespaces(GRAFANA_RULES_SOURCE_NAME); - - // filter out rules that are relevant to this panel - const rules = useMemo( - (): CombinedRule[] => - combinedNamespaces - .flatMap((ns) => ns.groups) - .flatMap((group) => group.rules) - .filter( - (rule) => - rule.annotations[Annotation.dashboardUID] === dashboardUID && - rule.annotations[Annotation.panelID] === String(panelId) - ), - [combinedNamespaces, dashboardUID, panelId] - ); + const { result: combinedNamespaces, loading, error } = useCombinedRules(dashboardUID, panelId, poll); + const rules = combinedNamespaces ? combinedNamespaces.flatMap((ns) => ns.groups).flatMap((group) => group.rules) : []; return { rules, - errors, + errors: error ? [error] : [], loading, }; } diff --git a/public/app/features/alerting/unified/integration/AlertRulesDrawerContent.tsx b/public/app/features/alerting/unified/integration/AlertRulesDrawerContent.tsx index 22bd4a2a0935..fd0ff6823b7b 100644 --- a/public/app/features/alerting/unified/integration/AlertRulesDrawerContent.tsx +++ b/public/app/features/alerting/unified/integration/AlertRulesDrawerContent.tsx @@ -1,33 +1,17 @@ import React from 'react'; -import { useAsync } from 'react-use'; import { LoadingPlaceholder } from '@grafana/ui'; -import { useDispatch } from 'app/types'; import { RulesTable } from '../components/rules/RulesTable'; -import { useCombinedRuleNamespaces } from '../hooks/useCombinedRuleNamespaces'; -import { fetchPromAndRulerRulesAction } from '../state/actions'; -import { Annotation } from '../utils/constants'; -import { GRAFANA_RULES_SOURCE_NAME } from '../utils/datasource'; +import { useCombinedRules } from '../hooks/useCombinedRuleNamespaces'; interface Props { dashboardUid: string; } export default function AlertRulesDrawerContent({ dashboardUid }: Props) { - const dispatch = useDispatch(); - - const { loading: loadingAlertRules } = useAsync(async () => { - await dispatch(fetchPromAndRulerRulesAction({ rulesSourceName: GRAFANA_RULES_SOURCE_NAME })); - }, [dispatch]); - - const grafanaNamespaces = useCombinedRuleNamespaces(GRAFANA_RULES_SOURCE_NAME); - const rules = grafanaNamespaces - .flatMap((ns) => ns.groups) - .flatMap((g) => g.rules) - .filter((rule) => rule.annotations[Annotation.dashboardUID] === dashboardUid); - - const loading = loadingAlertRules; + const { loading, result: grafanaNamespaces } = useCombinedRules(dashboardUid); + const rules = grafanaNamespaces ? grafanaNamespaces.flatMap((ns) => ns.groups).flatMap((g) => g.rules) : []; return ( <> diff --git a/public/app/features/dashboard-scene/panel-edit/PanelDataPane/PanelDataAlertingTab.test.tsx b/public/app/features/dashboard-scene/panel-edit/PanelDataPane/PanelDataAlertingTab.test.tsx index ea559badb2a0..de4bd6e67791 100644 --- a/public/app/features/dashboard-scene/panel-edit/PanelDataPane/PanelDataAlertingTab.test.tsx +++ b/public/app/features/dashboard-scene/panel-edit/PanelDataPane/PanelDataAlertingTab.test.tsx @@ -8,18 +8,19 @@ import { DataSourceApi } from '@grafana/data'; import { PromOptions, PrometheusDatasource } from '@grafana/prometheus'; import { locationService, setDataSourceSrv, setPluginExtensionsHook } from '@grafana/runtime'; import { backendSrv } from 'app/core/services/backend_srv'; -import { fetchRules } from 'app/features/alerting/unified/api/prometheus'; -import { fetchRulerRules } from 'app/features/alerting/unified/api/ruler'; +import * as ruler from 'app/features/alerting/unified/api/ruler'; import * as ruleActionButtons from 'app/features/alerting/unified/components/rules/RuleActionsButtons'; +import * as alertingAbilities from 'app/features/alerting/unified/hooks/useAbilities'; +import { mockAlertRuleApi, setupMswServer } from 'app/features/alerting/unified/mockApi'; import { MockDataSourceSrv, grantUserPermissions, mockDataSource, mockFolder, + mockPromAlert, mockPromAlertingRule, - mockPromRuleGroup, - mockPromRuleNamespace, - mockRulerGrafanaRule, + mockRulerAlertingRule, + mockRulerRuleGroup, } from 'app/features/alerting/unified/mocks'; import { RuleFormValues } from 'app/features/alerting/unified/types/rule-form'; import * as config from 'app/features/alerting/unified/utils/config'; @@ -29,11 +30,11 @@ import { DashboardModel, PanelModel } from 'app/features/dashboard/state'; import { getDatasourceSrv } from 'app/features/plugins/datasource_srv'; import { configureStore } from 'app/store/configureStore'; import { AccessControlAction } from 'app/types'; -import { AlertQuery } from 'app/types/unified-alerting-dto'; +import { AlertQuery, PromRulesResponse } from 'app/types/unified-alerting-dto'; import { createDashboardSceneFromDashboardModel } from '../../serialization/transformSaveModelToScene'; -import { findVizPanelByKey, getVizPanelKeyForPanelId } from '../../utils/utils'; import * as utils from '../../utils/utils'; +import { findVizPanelByKey, getVizPanelKeyForPanelId } from '../../utils/utils'; import { VizPanelManager } from '../VizPanelManager'; import { PanelDataAlertingTab, PanelDataAlertingTabRendered } from './PanelDataAlertingTab'; @@ -47,6 +48,8 @@ jest.mock('app/features/alerting/unified/api/ruler'); jest.spyOn(config, 'getAllDataSources'); jest.spyOn(ruleActionButtons, 'matchesWidth').mockReturnValue(false); +jest.spyOn(ruler, 'rulerUrlBuilder'); +jest.spyOn(alertingAbilities, 'useAlertRuleAbility'); setPluginExtensionsHook(() => ({ extensions: [], @@ -70,10 +73,8 @@ dataSources.default.meta.alerting = true; const mocks = { getAllDataSources: jest.mocked(config.getAllDataSources), - api: { - fetchRules: jest.mocked(fetchRules), - fetchRulerRules: jest.mocked(fetchRulerRules), - }, + useAlertRuleAbilityMock: jest.mocked(alertingAbilities.useAlertRuleAbility), + rulerBuilderMock: jest.mocked(ruler.rulerUrlBuilder), }; const renderAlertTabContent = (model: PanelDataAlertingTab, initialStore?: ReturnType) => { @@ -84,73 +85,57 @@ const renderAlertTabContent = (model: PanelDataAlertingTab, initialStore?: Retur ); }; -const rules = [ - mockPromRuleNamespace({ - name: 'default', +const promResponse: PromRulesResponse = { + status: 'success', + data: { groups: [ - mockPromRuleGroup({ + { name: 'mygroup', + file: 'default', rules: [ mockPromAlertingRule({ name: 'dashboardrule1', - annotations: { - [Annotation.dashboardUID]: '12', - [Annotation.panelID]: '34', - }, + alerts: [ + mockPromAlert({ + labels: { severity: 'critical' }, + annotations: { + [Annotation.dashboardUID]: '12', + [Annotation.panelID]: '34', + }, + }), + ], + totals: { alerting: 1 }, + totalsFiltered: { alerting: 1 }, }), ], - }), - mockPromRuleGroup({ + interval: 20, + }, + { name: 'othergroup', + file: 'default', rules: [ mockPromAlertingRule({ name: 'dashboardrule2', - annotations: { - [Annotation.dashboardUID]: '121', - [Annotation.panelID]: '341', - }, + alerts: [ + mockPromAlert({ + labels: { severity: 'critical' }, + annotations: { + [Annotation.dashboardUID]: '121', + [Annotation.panelID]: '341', + }, + }), + ], + totals: { alerting: 1 }, + totalsFiltered: { alerting: 1 }, }), ], - }), + interval: 20, + }, ], - }), -]; - -const rulerRules = { - default: [ - { - name: 'mygroup', - rules: [ - mockRulerGrafanaRule( - { - annotations: { - [Annotation.dashboardUID]: '12', - [Annotation.panelID]: '34', - }, - }, - { - title: 'dashboardrule1', - } - ), - ], + totals: { + alerting: 2, }, - { - name: 'othergroup', - rules: [ - mockRulerGrafanaRule( - { - annotations: { - [Annotation.dashboardUID]: '121', - [Annotation.panelID]: '341', - }, - }, - { - title: 'dashboardrule2', - } - ), - ], - }, - ], + }, }; const dashboard = { @@ -187,8 +172,10 @@ const ui = { row: byTestId('row'), createButton: byTestId('create-alert-rule-button'), }; +const server = setupMswServer(); describe('PanelAlertTabContent', () => { + // silenceConsoleOutput(); beforeEach(() => { jest.resetAllMocks(); grantUserPermissions([ @@ -209,6 +196,42 @@ describe('PanelAlertTabContent', () => { ) as DataSourceApi; dsService.datasources[dataSources.default.uid] = new PrometheusDatasource(dataSources.default) as DataSourceApi; setDataSourceSrv(dsService); + mocks.rulerBuilderMock.mockReturnValue({ + rules: () => ({ path: `api/ruler/${GRAFANA_RULES_SOURCE_NAME}/api/v1/rules` }), + namespace: () => ({ path: 'ruler' }), + namespaceGroup: () => ({ path: 'ruler' }), + }); + mocks.useAlertRuleAbilityMock.mockReturnValue([true, true]); + + mockAlertRuleApi(server).prometheusRuleNamespaces(GRAFANA_RULES_SOURCE_NAME, promResponse); + mockAlertRuleApi(server).rulerRules(GRAFANA_RULES_SOURCE_NAME, { + default: [ + mockRulerRuleGroup({ + name: 'mygroup', + rules: [ + mockRulerAlertingRule({ + alert: 'dashboardrule1', + annotations: { + [Annotation.dashboardUID]: '12', + [Annotation.panelID]: '34', + }, + }), + ], + }), + { + name: 'othergroup', + rules: [ + mockRulerAlertingRule({ + alert: 'dashboardrule2', + annotations: { + [Annotation.dashboardUID]: '121', + [Annotation.panelID]: '341', + }, + }), + ], + }, + ], + }); }); it('Will take into account panel maxDataPoints', async () => { @@ -289,18 +312,16 @@ describe('PanelAlertTabContent', () => { }); }); + // after updating to RTKQ, the response is already returning the alerts belonging to the panel it('Will render alerts belonging to panel and a button to create alert from panel queries', async () => { - mocks.api.fetchRules.mockResolvedValue(rules); - mocks.api.fetchRulerRules.mockResolvedValue(rulerRules); - dashboard.panels = [panel]; renderAlertTab(dashboard); const rows = await ui.row.findAll(); - expect(rows).toHaveLength(1); + expect(rows).toHaveLength(2); expect(rows[0]).toHaveTextContent(/dashboardrule1/); - expect(rows[0]).not.toHaveTextContent(/dashboardrule2/); + expect(rows[1]).toHaveTextContent(/dashboardrule2/); const defaults = await clickNewButton(); @@ -316,25 +337,6 @@ describe('PanelAlertTabContent', () => { }; expect(defaultsWithDeterministicTime).toMatchSnapshot(); - - expect(mocks.api.fetchRulerRules).toHaveBeenCalledWith( - { dataSourceName: GRAFANA_RULES_SOURCE_NAME, apiVersion: 'legacy' }, - { - dashboardUID: dashboard.uid, - panelId: panel.id, - } - ); - expect(mocks.api.fetchRules).toHaveBeenCalledWith( - GRAFANA_RULES_SOURCE_NAME, - { - dashboardUID: dashboard.uid, - panelId: panel.id, - }, - undefined, - undefined, - undefined, - undefined - ); }); }); diff --git a/public/app/features/dashboard-scene/panel-edit/PanelDataPane/PanelDataAlertingTab.tsx b/public/app/features/dashboard-scene/panel-edit/PanelDataPane/PanelDataAlertingTab.tsx index 754929a73f87..5debaeb8f833 100644 --- a/public/app/features/dashboard-scene/panel-edit/PanelDataPane/PanelDataAlertingTab.tsx +++ b/public/app/features/dashboard-scene/panel-edit/PanelDataPane/PanelDataAlertingTab.tsx @@ -2,18 +2,19 @@ import { css } from '@emotion/css'; import React from 'react'; import { GrafanaTheme2 } from '@grafana/data'; -import { SceneObjectBase, SceneComponentProps } from '@grafana/scenes'; +import { SceneComponentProps, SceneObjectBase } from '@grafana/scenes'; import { Alert, LoadingPlaceholder, Tab, useStyles2 } from '@grafana/ui'; import { contextSrv } from 'app/core/core'; import { RulesTable } from 'app/features/alerting/unified/components/rules/RulesTable'; import { usePanelCombinedRules } from 'app/features/alerting/unified/hooks/usePanelCombinedRules'; import { getRulesPermissions } from 'app/features/alerting/unified/utils/access-control'; +import { stringifyErrorLike } from 'app/features/alerting/unified/utils/misc'; import { getDashboardSceneFor, getPanelIdForVizPanel } from '../../utils/utils'; import { VizPanelManager } from '../VizPanelManager'; import { ScenesNewRuleFromPanelButton } from './NewAlertRuleButton'; -import { PanelDataPaneTabState, PanelDataPaneTab, TabId, PanelDataTabHeaderProps } from './types'; +import { PanelDataPaneTab, PanelDataPaneTabState, PanelDataTabHeaderProps, TabId } from './types'; export class PanelDataAlertingTab extends SceneObjectBase implements PanelDataPaneTab { static Component = PanelDataAlertingTabRendered; @@ -72,7 +73,7 @@ export function PanelDataAlertingTabRendered(props: SceneComponentProps {errors.map((error, index) => ( -
Failed to load Grafana rules state: {error.message || 'Unknown error.'}
+
Failed to load Grafana rules state: {stringifyErrorLike(error)}
))} ) : null; diff --git a/public/app/features/dashboard-scene/scene/AlertStatesDataLayer.ts b/public/app/features/dashboard-scene/scene/AlertStatesDataLayer.ts index 5bea2d7b023f..834b95b28ef0 100644 --- a/public/app/features/dashboard-scene/scene/AlertStatesDataLayer.ts +++ b/public/app/features/dashboard-scene/scene/AlertStatesDataLayer.ts @@ -1,7 +1,7 @@ -import { from, map, Unsubscribable } from 'rxjs'; +import { from, map, Observable, Unsubscribable } from 'rxjs'; import { AlertState, AlertStateInfo, DataTopic, LoadingState, toDataFrame } from '@grafana/data'; -import { config, getBackendSrv } from '@grafana/runtime'; +import { config } from '@grafana/runtime'; import { SceneDataLayerBase, SceneDataLayerProvider, @@ -13,11 +13,15 @@ import { notifyApp } from 'app/core/actions'; import { createErrorNotification } from 'app/core/copy/appNotification'; import { contextSrv } from 'app/core/core'; import { getMessageFromError } from 'app/core/utils/errors'; +import { alertRuleApi } from 'app/features/alerting/unified/api/alertRuleApi'; +import { ungroupRulesByFileName } from 'app/features/alerting/unified/api/prometheus'; import { Annotation } from 'app/features/alerting/unified/utils/constants'; +import { GRAFANA_RULES_SOURCE_NAME } from 'app/features/alerting/unified/utils/datasource'; import { isAlertingRule } from 'app/features/alerting/unified/utils/rules'; import { dispatch } from 'app/store/store'; import { AccessControlAction } from 'app/types'; -import { PromAlertingRuleState, PromRulesResponse } from 'app/types/unified-alerting-dto'; +import { RuleNamespace } from 'app/types/unified-alerting'; +import { PromAlertingRuleState, PromRuleGroupDTO } from 'app/types/unified-alerting-dto'; import { getDashboardSceneFor } from '../utils/utils'; @@ -67,56 +71,55 @@ export class AlertStatesDataLayer if (!this.canWork(timeRange)) { return; } + const fetchData: () => Promise = async () => { + const promRules = await dispatch( + alertRuleApi.endpoints.prometheusRuleNamespaces.initiate({ + ruleSourceName: GRAFANA_RULES_SOURCE_NAME, + dashboardUid: uid, + }) + ); + if (promRules.error) { + throw new Error(`Unexpected alert rules response.`); + } + return promRules.data; + }; + const res: Observable = from(fetchData()).pipe( + map((namespaces: RuleNamespace[]) => ungroupRulesByFileName(namespaces)) + ); - const alerStatesExecution = from( - getBackendSrv().get( - '/api/prometheus/grafana/api/v1/rules', - { - dashboard_uid: uid!, - }, - `dashboard-query-runner-unified-alert-states-${id}` - ) - ).pipe( - map((result: PromRulesResponse) => { - if (result.status === 'success') { - this.hasAlertRules = false; - const panelIdToAlertState: Record = {}; - - result.data.groups.forEach((group) => - group.rules.forEach((rule) => { - if (isAlertingRule(rule) && rule.annotations && rule.annotations[Annotation.panelID]) { - this.hasAlertRules = 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: 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; - } + const alerStatesExecution = res.pipe( + map((groups: PromRuleGroupDTO[]) => { + this.hasAlertRules = false; + const panelIdToAlertState: Record = {}; + groups.forEach((group) => + group.rules.forEach((rule) => { + if (isAlertingRule(rule) && rule.annotations && rule.annotations[Annotation.panelID]) { + this.hasAlertRules = 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: 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 Object.values(panelIdToAlertState); - } - - throw new Error(`Unexpected alert rules response.`); + } + }) + ); + return Object.values(panelIdToAlertState); }) ); diff --git a/public/app/features/query/state/DashboardQueryRunner/DashboardQueryRunner.test.ts b/public/app/features/query/state/DashboardQueryRunner/DashboardQueryRunner.test.ts index e88b7a5a9b3c..cc4ae72f2cdd 100644 --- a/public/app/features/query/state/DashboardQueryRunner/DashboardQueryRunner.test.ts +++ b/public/app/features/query/state/DashboardQueryRunner/DashboardQueryRunner.test.ts @@ -3,9 +3,15 @@ import { delay, first } from 'rxjs/operators'; import { AlertState } from '@grafana/data'; import { DataSourceSrv, setDataSourceSrv } from '@grafana/runtime'; -import { grantUserPermissions } from 'app/features/alerting/unified/mocks'; +import { + grantUserPermissions, + mockPromAlertingRule, + mockPromRuleGroup, + mockPromRuleNamespace, +} from 'app/features/alerting/unified/mocks'; import { Annotation } from 'app/features/alerting/unified/utils/constants'; import { TimeSrv } from 'app/features/dashboard/services/TimeSrv'; +import * as store from 'app/store/store'; import { AccessControlAction } from 'app/types'; import { PromAlertingRuleState, PromRulesResponse, PromRuleType } from 'app/types/unified-alerting-dto'; @@ -22,6 +28,39 @@ jest.mock('@grafana/runtime', () => ({ getBackendSrv: () => backendSrv, })); +const nameSpaces = [ + mockPromRuleNamespace({ + groups: [ + mockPromRuleGroup({ + name: 'my-group', + rules: [ + mockPromAlertingRule({ + name: 'my alert', + state: PromAlertingRuleState.Firing, + annotations: { + [Annotation.dashboardUID]: '1', + [Annotation.panelID]: '1', + }, + }), + ], + }), + mockPromRuleGroup({ + name: 'another-group', + rules: [ + mockPromAlertingRule({ + name: 'another alert', + state: PromAlertingRuleState.Firing, + annotations: { + [Annotation.dashboardUID]: '1', + [Annotation.panelID]: '2', + }, + }), + ], + }), + ], + }), +]; + beforeEach(() => { grantUserPermissions([AccessControlAction.AlertingRuleRead, AccessControlAction.AlertingRuleExternalRead]); }); @@ -105,8 +144,9 @@ function getTestContext() { }, } as DataSourceSrv; setDataSourceSrv(dataSourceSrvMock); + const dispatchMock = jest.spyOn(store, 'dispatch'); - return { runner, options, annotationQueryMock, executeAnnotationQueryMock, getMock }; + return { runner, options, annotationQueryMock, executeAnnotationQueryMock, getMock, dispatchMock }; } function expectOnResults(args: { @@ -134,7 +174,8 @@ function expectOnResults(args: { describe('DashboardQueryRunnerImpl', () => { describe('when calling run and all workers succeed', () => { it('then it should return the correct results', (done) => { - const { runner, options, annotationQueryMock, executeAnnotationQueryMock, getMock } = getTestContext(); + const { dispatchMock, runner, options, annotationQueryMock, executeAnnotationQueryMock } = getTestContext(); + dispatchMock.mockResolvedValue({ data: nameSpaces }); expectOnResults({ runner, @@ -146,7 +187,7 @@ describe('DashboardQueryRunnerImpl', () => { expect(results).toEqual(getExpectedForAllResult()); expect(annotationQueryMock).toHaveBeenCalledTimes(1); expect(executeAnnotationQueryMock).toHaveBeenCalledTimes(1); - expect(getMock).toHaveBeenCalledTimes(1); + expect(dispatchMock).toHaveBeenCalledTimes(1); }, }); @@ -156,10 +197,10 @@ describe('DashboardQueryRunnerImpl', () => { describe('when calling run and all workers succeed but take longer than 200ms', () => { it('then it should return the empty results', (done) => { - const { runner, options, annotationQueryMock, executeAnnotationQueryMock, getMock } = getTestContext(); + const { runner, options, annotationQueryMock, executeAnnotationQueryMock, dispatchMock } = getTestContext(); const wait = 201; executeAnnotationQueryMock.mockReturnValue(toAsyncOfResult({ events: [{ id: 'NextGen' }] }).pipe(delay(wait))); - + dispatchMock.mockResolvedValue({ data: nameSpaces }); expectOnResults({ runner, panelId: 1, @@ -170,7 +211,7 @@ describe('DashboardQueryRunnerImpl', () => { expect(results).toEqual({ annotations: [] }); expect(annotationQueryMock).toHaveBeenCalledTimes(1); expect(executeAnnotationQueryMock).toHaveBeenCalledTimes(1); - expect(getMock).toHaveBeenCalledTimes(1); + expect(dispatchMock).toHaveBeenCalledTimes(1); }, }); @@ -180,8 +221,8 @@ describe('DashboardQueryRunnerImpl', () => { describe('when calling run and all workers succeed but the subscriber subscribes after the run', () => { it('then it should return the last results', (done) => { - const { runner, options, annotationQueryMock, executeAnnotationQueryMock, getMock } = getTestContext(); - + const { runner, options, annotationQueryMock, executeAnnotationQueryMock, dispatchMock } = getTestContext(); + dispatchMock.mockResolvedValue({ data: nameSpaces }); runner.run(options); setTimeout( @@ -196,7 +237,7 @@ describe('DashboardQueryRunnerImpl', () => { expect(results).toEqual(getExpectedForAllResult()); expect(annotationQueryMock).toHaveBeenCalledTimes(1); expect(executeAnnotationQueryMock).toHaveBeenCalledTimes(1); - expect(getMock).toHaveBeenCalledTimes(1); + expect(dispatchMock).toHaveBeenCalledTimes(1); }, }), 200 @@ -207,8 +248,8 @@ describe('DashboardQueryRunnerImpl', () => { describe('when calling run and all workers fail', () => { silenceConsoleOutput(); it('then it should return the correct results', (done) => { - const { runner, options, annotationQueryMock, executeAnnotationQueryMock, getMock } = getTestContext(); - getMock.mockRejectedValue({ message: 'Get error' }); + const { runner, options, annotationQueryMock, executeAnnotationQueryMock, dispatchMock } = getTestContext(); + dispatchMock.mockResolvedValue({ error: { message: 'Get error' } }); annotationQueryMock.mockRejectedValue({ message: 'Legacy error' }); executeAnnotationQueryMock.mockReturnValue(throwError({ message: 'NextGen error' })); @@ -223,7 +264,6 @@ describe('DashboardQueryRunnerImpl', () => { expect(results).toEqual(expected); expect(annotationQueryMock).toHaveBeenCalledTimes(1); expect(executeAnnotationQueryMock).toHaveBeenCalledTimes(1); - expect(getMock).toHaveBeenCalledTimes(1); }, }); @@ -234,8 +274,8 @@ describe('DashboardQueryRunnerImpl', () => { describe('when calling run and AlertStatesWorker fails', () => { silenceConsoleOutput(); it('then it should return the correct results', (done) => { - const { runner, options, annotationQueryMock, executeAnnotationQueryMock, getMock } = getTestContext(); - getMock.mockRejectedValue({ message: 'Get error' }); + const { runner, options, annotationQueryMock, executeAnnotationQueryMock, dispatchMock } = getTestContext(); + dispatchMock.mockResolvedValue({ message: 'Get error' }); expectOnResults({ runner, @@ -249,7 +289,6 @@ describe('DashboardQueryRunnerImpl', () => { expect(results).toEqual(expected); expect(annotationQueryMock).toHaveBeenCalledTimes(1); expect(executeAnnotationQueryMock).toHaveBeenCalledTimes(1); - expect(getMock).toHaveBeenCalledTimes(1); }, }); @@ -259,7 +298,8 @@ describe('DashboardQueryRunnerImpl', () => { describe('when calling run and AnnotationsWorker fails', () => { silenceConsoleOutput(); it('then it should return the correct results', (done) => { - const { runner, options, annotationQueryMock, executeAnnotationQueryMock, getMock } = getTestContext(); + const { runner, options, annotationQueryMock, executeAnnotationQueryMock, dispatchMock } = getTestContext(); + dispatchMock.mockResolvedValue({ data: nameSpaces }); annotationQueryMock.mockRejectedValue({ message: 'Legacy error' }); executeAnnotationQueryMock.mockReturnValue(throwError({ message: 'NextGen error' })); @@ -275,7 +315,6 @@ describe('DashboardQueryRunnerImpl', () => { expect(results).toEqual(expected); expect(annotationQueryMock).toHaveBeenCalledTimes(1); expect(executeAnnotationQueryMock).toHaveBeenCalledTimes(1); - expect(getMock).toHaveBeenCalledTimes(1); }, }); @@ -286,7 +325,8 @@ describe('DashboardQueryRunnerImpl', () => { describe('when calling run twice', () => { it('then it should cancel previous run', (done) => { - const { runner, options, annotationQueryMock, executeAnnotationQueryMock, getMock } = getTestContext(); + const { runner, options, annotationQueryMock, executeAnnotationQueryMock, dispatchMock } = getTestContext(); + dispatchMock.mockResolvedValue({ data: nameSpaces }); executeAnnotationQueryMock.mockReturnValueOnce( toAsyncOfResult({ events: [{ id: 'NextGen' }] }).pipe(delay(10000)) ); @@ -303,7 +343,7 @@ describe('DashboardQueryRunnerImpl', () => { expect(results).toEqual(expected); expect(annotationQueryMock).toHaveBeenCalledTimes(2); expect(executeAnnotationQueryMock).toHaveBeenCalledTimes(2); - expect(getMock).toHaveBeenCalledTimes(2); + expect(dispatchMock).toHaveBeenCalledTimes(2); }, }); @@ -314,7 +354,8 @@ describe('DashboardQueryRunnerImpl', () => { describe('when calling cancel', () => { it('then it should cancel matching workers', (done) => { - const { runner, options, annotationQueryMock, executeAnnotationQueryMock, getMock } = getTestContext(); + const { runner, options, annotationQueryMock, executeAnnotationQueryMock, dispatchMock } = getTestContext(); + dispatchMock.mockResolvedValue({ data: nameSpaces }); executeAnnotationQueryMock.mockReturnValueOnce( toAsyncOfResult({ events: [{ id: 'NextGen' }] }).pipe(delay(10000)) ); @@ -330,7 +371,6 @@ describe('DashboardQueryRunnerImpl', () => { expect(results).toEqual({ alertState, annotations: [annotations[0], annotations[2]] }); expect(annotationQueryMock).toHaveBeenCalledTimes(1); expect(executeAnnotationQueryMock).toHaveBeenCalledTimes(1); - expect(getMock).toHaveBeenCalledTimes(1); }, }); diff --git a/public/app/features/query/state/DashboardQueryRunner/UnifiedAlertStatesWorker.test.ts b/public/app/features/query/state/DashboardQueryRunner/UnifiedAlertStatesWorker.test.ts index 9d401a130b2c..7e782cc7a858 100644 --- a/public/app/features/query/state/DashboardQueryRunner/UnifiedAlertStatesWorker.test.ts +++ b/public/app/features/query/state/DashboardQueryRunner/UnifiedAlertStatesWorker.test.ts @@ -3,11 +3,16 @@ import { lastValueFrom } from 'rxjs'; import { AlertState, getDefaultTimeRange, TimeRange } from '@grafana/data'; import { config } from '@grafana/runtime'; import { backendSrv } from 'app/core/services/backend_srv'; -import { grantUserPermissions } from 'app/features/alerting/unified/mocks'; +import { + grantUserPermissions, + mockPromAlertingRule, + mockPromRuleGroup, + mockPromRuleNamespace, +} from 'app/features/alerting/unified/mocks'; import { Annotation } from 'app/features/alerting/unified/utils/constants'; import { createDashboardModelFixture } from 'app/features/dashboard/state/__fixtures__/dashboardFixtures'; import { AccessControlAction } from 'app/types/accessControl'; -import { PromAlertingRuleState, PromRuleDTO, PromRulesResponse, PromRuleType } from 'app/types/unified-alerting-dto'; +import { PromAlertingRuleState } from 'app/types/unified-alerting-dto'; import { silenceConsoleOutput } from '../../../../../test/core/utils/silenceConsoleOutput'; import * as store from '../../../../store/store'; @@ -37,9 +42,8 @@ function getTestContext() { jest.clearAllMocks(); const dispatchMock = jest.spyOn(store, 'dispatch'); const options = getDefaultOptions(); - const getMock = jest.spyOn(backendSrv, 'get'); - return { getMock, options, dispatchMock }; + return { options, dispatchMock }; } describe('UnifiedAlertStatesWorker', () => { @@ -88,30 +92,23 @@ describe('UnifiedAlertStatesWorker', () => { describe('when run is called with incorrect props', () => { it('then it should return the correct results', async () => { - const { getMock, options } = getTestContext(); + const { options } = getTestContext(); const dashboard = createDashboardModelFixture({}); 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', () => { + const nameSpaces = [mockPromRuleNamespace({ groups: [] })]; + const { dispatchMock, options } = getTestContext(); + dispatchMock.mockResolvedValue(nameSpaces); 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); @@ -119,45 +116,41 @@ describe('UnifiedAlertStatesWorker', () => { }); 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: { + const nameSpaces = [ + mockPromRuleNamespace({ groups: [ - { - name: 'group', - file: '', - interval: 1, + mockPromRuleGroup({ + name: 'group1', rules: [ - mockPromRuleDTO({ + mockPromAlertingRule({ + name: 'alert1', state: PromAlertingRuleState.Firing, annotations: { [Annotation.dashboardUID]: 'a uid', [Annotation.panelID]: '1', }, }), - mockPromRuleDTO({ + ], + }), + mockPromRuleGroup({ + name: 'group2', + rules: [ + mockPromAlertingRule({ + name: 'alert2', state: PromAlertingRuleState.Inactive, annotations: { [Annotation.dashboardUID]: 'a uid', [Annotation.panelID]: '2', }, }), - mockPromRuleDTO({ + ], + }), + mockPromRuleGroup({ + name: 'group3', + rules: [ + mockPromAlertingRule({ + name: 'alert3', state: PromAlertingRuleState.Pending, annotations: { [Annotation.dashboardUID]: 'a uid', @@ -165,12 +158,12 @@ describe('UnifiedAlertStatesWorker', () => { }, }), ], - }, + }), ], - }, - }; - const { getMock, options } = getTestContext(); - getMock.mockResolvedValue(getResults); + }), + ]; + const { dispatchMock, options } = getTestContext(); + dispatchMock.mockResolvedValue({ data: nameSpaces }); await expect(worker.work(options)).toEmitValuesWith((received) => { expect(received).toHaveLength(1); @@ -184,43 +177,33 @@ describe('UnifiedAlertStatesWorker', () => { }); }); - expect(getMock).toHaveBeenCalledTimes(1); - expect(getMock).toHaveBeenCalledWith( - '/api/prometheus/grafana/api/v1/rules', - { dashboard_uid: 'a uid' }, - 'dashboard-query-runner-unified-alert-states-12345' - ); + expect(dispatchMock).toHaveBeenCalledTimes(1); }); }); 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' }); + const { options, dispatchMock } = getTestContext(); + dispatchMock.mockResolvedValue({ error: '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 }); + const { options, dispatchMock } = getTestContext(); + dispatchMock.mockResolvedValue({ error: { message: 'Get 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).not.toHaveBeenCalled(); }); }); }); diff --git a/public/app/features/query/state/DashboardQueryRunner/UnifiedAlertStatesWorker.ts b/public/app/features/query/state/DashboardQueryRunner/UnifiedAlertStatesWorker.ts index d0c13024e2da..bc750ffe8d84 100644 --- a/public/app/features/query/state/DashboardQueryRunner/UnifiedAlertStatesWorker.ts +++ b/public/app/features/query/state/DashboardQueryRunner/UnifiedAlertStatesWorker.ts @@ -1,14 +1,19 @@ -import { from, Observable } from 'rxjs'; +import { Observable, from } from 'rxjs'; import { catchError, map } from 'rxjs/operators'; import { AlertState, AlertStateInfo } from '@grafana/data'; -import { config, getBackendSrv } from '@grafana/runtime'; +import { config } from '@grafana/runtime'; import { contextSrv } from 'app/core/services/context_srv'; +import { alertRuleApi } from 'app/features/alerting/unified/api/alertRuleApi'; +import { ungroupRulesByFileName } from 'app/features/alerting/unified/api/prometheus'; import { Annotation } from 'app/features/alerting/unified/utils/constants'; +import { GRAFANA_RULES_SOURCE_NAME } from 'app/features/alerting/unified/utils/datasource'; import { isAlertingRule } from 'app/features/alerting/unified/utils/rules'; import { promAlertStateToAlertState } from 'app/features/dashboard-scene/scene/AlertStatesDataLayer'; +import { dispatch } from 'app/store/store'; import { AccessControlAction } from 'app/types'; -import { PromRulesResponse } from 'app/types/unified-alerting-dto'; +import { RuleNamespace } from 'app/types/unified-alerting'; +import { PromRuleGroupDTO } from 'app/types/unified-alerting-dto'; import { DashboardQueryRunnerOptions, DashboardQueryRunnerWorker, DashboardQueryRunnerWorkerResult } from './types'; import { emptyResult, handleDashboardQueryRunnerWorkerError } from './utils'; @@ -54,53 +59,53 @@ export class UnifiedAlertStatesWorker implements DashboardQueryRunnerWorker { } 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); + const fetchData: () => Promise = async () => { + const promRules = await dispatch( + alertRuleApi.endpoints.prometheusRuleNamespaces.initiate({ + ruleSourceName: GRAFANA_RULES_SOURCE_NAME, + dashboardUid: dashboard.uid, + }) + ); + return promRules.data; + }; - // 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; - } + const res: Observable = from(fetchData()).pipe( + map((namespaces: RuleNamespace[]) => ungroupRulesByFileName(namespaces)) + ); + + return res.pipe( + map((groups: PromRuleGroupDTO[]) => { + this.hasAlertRules[dashboard.uid] = false; + const panelIdToAlertState: Record = {}; + 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.`); + } + }) + ); + return { alertStates: Object.values(panelIdToAlertState), annotations: [] }; }), catchError(handleDashboardQueryRunnerWorkerError) );