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
This commit is contained in:
Sonia Aguilar
2024-05-10 12:27:06 +02:00
committed by GitHub
parent d9d25dc649
commit 4b720206d4
15 changed files with 538 additions and 468 deletions

View File

@ -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<PromOptions>({
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<HTMLAnchorElement>('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));
});
});

View File

@ -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 ? (
<Alert title="Errors loading rules" severity="error">
{errors.map((error, index) => (
<div key={index}>Failed to load Grafana rules state: {error.message || 'Unknown error.'}</div>
<div key={index}>Failed to load Grafana rules state: {stringifyErrorLike(error)}</div>
))}
</Alert>
) : null;

View File

@ -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<string, string | undefined> = {
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 {

View File

@ -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,

View File

@ -68,7 +68,7 @@ export async function setRulerRuleGroup(
}
export interface FetchRulerRulesFilter {
dashboardUID: string;
dashboardUID?: string;
panelId?: number;
}

View File

@ -282,7 +282,7 @@ export function useCombinedRule({ ruleIdentifier }: { ruleIdentifier: RuleIdenti
};
}
const grafanaRulerConfig: RulerDataSourceConfig = {
export const grafanaRulerConfig: RulerDataSourceConfig = {
dataSourceName: GRAFANA_RULES_SOURCE_NAME,
apiVersion: 'legacy',
};

View File

@ -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<string, CombinedRuleGroup>();
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<Record<string, CacheValue>>({});
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<string, CombinedRuleNamespace> = {};
// 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,
};
}

View File

@ -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,
};
}

View File

@ -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 (
<>

View File

@ -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<typeof configureStore>) => {
@ -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<HTMLButtonElement>('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
);
});
});

View File

@ -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<PanelDataPaneTabState> implements PanelDataPaneTab {
static Component = PanelDataAlertingTabRendered;
@ -72,7 +73,7 @@ export function PanelDataAlertingTabRendered(props: SceneComponentProps<PanelDat
const alert = errors.length ? (
<Alert title="Errors loading rules" severity="error">
{errors.map((error, index) => (
<div key={index}>Failed to load Grafana rules state: {error.message || 'Unknown error.'}</div>
<div key={index}>Failed to load Grafana rules state: {stringifyErrorLike(error)}</div>
))}
</Alert>
) : null;

View File

@ -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 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<number, AlertStateInfo> = {};
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;
}
}
})
);
return Object.values(panelIdToAlertState);
}
const fetchData: () => Promise<RuleNamespace[]> = 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<PromRuleGroupDTO[]> = from(fetchData()).pipe(
map((namespaces: RuleNamespace[]) => ungroupRulesByFileName(namespaces))
);
const alerStatesExecution = res.pipe(
map((groups: PromRuleGroupDTO[]) => {
this.hasAlertRules = false;
const panelIdToAlertState: Record<number, AlertStateInfo> = {};
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);
})
);

View File

@ -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);
},
});

View File

@ -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>): 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();
});
});
});

View File

@ -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<number, AlertStateInfo> = {};
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<RuleNamespace[]> = 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<PromRuleGroupDTO[]> = from(fetchData()).pipe(
map((namespaces: RuleNamespace[]) => ungroupRulesByFileName(namespaces))
);
return res.pipe(
map((groups: PromRuleGroupDTO[]) => {
this.hasAlertRules[dashboard.uid] = false;
const panelIdToAlertState: Record<number, AlertStateInfo> = {};
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)
);