diff --git a/public/app/features/alerting/unified/CloneRuleEditor.test.tsx b/public/app/features/alerting/unified/CloneRuleEditor.test.tsx
index c3c0cd1a7a7..a2fa421db81 100644
--- a/public/app/features/alerting/unified/CloneRuleEditor.test.tsx
+++ b/public/app/features/alerting/unified/CloneRuleEditor.test.tsx
@@ -217,6 +217,7 @@ describe('CloneRuleEditor', function () {
ruleSourceName: 'my-prom-ds',
namespace: 'namespace-one',
groupName: 'group1',
+ ruleName: 'First Ruler Rule',
rulerRuleHash: hashRulerRule(originRule),
}}
/>,
diff --git a/public/app/features/alerting/unified/RedirectToRuleViewer.test.tsx b/public/app/features/alerting/unified/RedirectToRuleViewer.test.tsx
index 36d917e6dca..5b78e5f5ac1 100644
--- a/public/app/features/alerting/unified/RedirectToRuleViewer.test.tsx
+++ b/public/app/features/alerting/unified/RedirectToRuleViewer.test.tsx
@@ -11,7 +11,6 @@ import { PromRuleType } from '../../../types/unified-alerting-dto';
import { RedirectToRuleViewer } from './RedirectToRuleViewer';
import * as combinedRuleHooks from './hooks/useCombinedRule';
-import { useCombinedRulesMatching } from './hooks/useCombinedRule';
import { getRulesSourceByName } from './utils/datasource';
jest.mock('./hooks/useCombinedRule');
@@ -23,8 +22,8 @@ jest.mock('react-router-dom', () => ({
jest.mock('react-use');
-const renderRedirectToRuleViewer = (pathname: string) => {
- jest.mocked(useLocation).mockReturnValue({ pathname, trigger: '' });
+const renderRedirectToRuleViewer = (pathname: string, search?: string) => {
+ jest.mocked(useLocation).mockReturnValue({ pathname, trigger: '', search });
locationService.push(pathname);
@@ -50,11 +49,9 @@ const mockRuleSourceByName = () => {
describe('Redirect to Rule viewer', () => {
it('should list rules that match the same name', () => {
- jest.mocked(useCombinedRulesMatching).mockReturnValue({
- result: mockedRules,
+ jest.mocked(combinedRuleHooks.useCloudCombinedRulesMatching).mockReturnValue({
+ rules: mockedRules,
loading: false,
- dispatched: true,
- requestId: 'A',
error: undefined,
});
mockRuleSourceByName();
@@ -63,11 +60,9 @@ describe('Redirect to Rule viewer', () => {
});
it('should redirect to view rule page if only one match', () => {
- jest.mocked(useCombinedRulesMatching).mockReturnValue({
- result: [mockedRules[0]],
+ jest.mocked(combinedRuleHooks.useCloudCombinedRulesMatching).mockReturnValue({
+ rules: [mockedRules[0]],
loading: false,
- dispatched: true,
- requestId: 'A',
error: undefined,
});
mockRuleSourceByName();
@@ -76,38 +71,71 @@ describe('Redirect to Rule viewer', () => {
});
it('should properly decode rule name', () => {
- const rulesMatchingSpy = jest.spyOn(combinedRuleHooks, 'useCombinedRulesMatching').mockReturnValue({
- result: [mockedRules[0]],
+ const rulesMatchingSpy = jest.spyOn(combinedRuleHooks, 'useCloudCombinedRulesMatching').mockReturnValue({
+ rules: [mockedRules[0]],
loading: false,
- dispatched: true,
- requestId: 'A',
error: undefined,
});
+ mockRuleSourceByName();
const ruleName = 'cloud rule++ !@#$%^&*()-/?';
renderRedirectToRuleViewer(`/alerting/prom-db/${encodeURIComponent(ruleName)}/find`);
- expect(rulesMatchingSpy).toHaveBeenCalledWith(ruleName, 'prom-db');
+ expect(rulesMatchingSpy).toHaveBeenCalledWith(ruleName, 'prom-db', { groupName: undefined, namespace: undefined });
expect(screen.getByText('Redirected')).toBeInTheDocument();
});
- it('should properly decode source name', () => {
- const rulesMatchingSpy = jest.spyOn(combinedRuleHooks, 'useCombinedRulesMatching').mockReturnValue({
- result: [mockedRules[0]],
+ it('should apply additional group name and namespace filters', () => {
+ const rulesMatchingSpy = jest.spyOn(combinedRuleHooks, 'useCloudCombinedRulesMatching').mockReturnValue({
+ rules: [mockedRules[0]],
loading: false,
- dispatched: true,
- requestId: 'A',
error: undefined,
});
+ mockRuleSourceByName();
+
+ const ruleName = 'prom alert';
+ const dsName = 'test prom';
+ const group = 'foo';
+ const namespace = 'bar';
+
+ renderRedirectToRuleViewer(`/alerting/${dsName}/${ruleName}/find`, `?group=${group}&namespace=${namespace}`);
+ expect(rulesMatchingSpy).toHaveBeenCalledWith(ruleName, dsName, {
+ groupName: group,
+ namespace: namespace,
+ });
+ });
+
+ it('should properly decode source name', () => {
+ const rulesMatchingSpy = jest.spyOn(combinedRuleHooks, 'useCloudCombinedRulesMatching').mockReturnValue({
+ rules: [mockedRules[0]],
+ loading: false,
+ error: undefined,
+ });
+ mockRuleSourceByName();
const sourceName = 'prom<|>++ !@#$%^&*()-/?';
renderRedirectToRuleViewer(`/alerting/${encodeURIComponent(sourceName)}/prom alert/find`);
- expect(rulesMatchingSpy).toHaveBeenCalledWith('prom alert', sourceName);
+ expect(rulesMatchingSpy).toHaveBeenCalledWith('prom alert', sourceName, {
+ groupName: undefined,
+ namespace: undefined,
+ });
expect(screen.getByText('Redirected')).toBeInTheDocument();
});
+
+ it('should show error when datasource does not exist', () => {
+ jest.mocked(getRulesSourceByName).mockReturnValueOnce(undefined);
+ jest.mocked(combinedRuleHooks.useCloudCombinedRulesMatching).mockReturnValue({
+ rules: [],
+ loading: false,
+ error: undefined,
+ });
+
+ renderRedirectToRuleViewer(`/alerting/does-not-exist/prom alert/find`);
+ expect(screen.getByText('Could not find data source with name: does-not-exist.')).toBeInTheDocument();
+ });
});
const mockedRules: CombinedRule[] = [
diff --git a/public/app/features/alerting/unified/RedirectToRuleViewer.tsx b/public/app/features/alerting/unified/RedirectToRuleViewer.tsx
index faecfd358ec..3805cbbbcd0 100644
--- a/public/app/features/alerting/unified/RedirectToRuleViewer.tsx
+++ b/public/app/features/alerting/unified/RedirectToRuleViewer.tsx
@@ -1,15 +1,15 @@
import { css } from '@emotion/css';
-import React from 'react';
+import React, { useMemo } from 'react';
import { Redirect } from 'react-router-dom';
import { useLocation } from 'react-use';
import { GrafanaTheme2 } from '@grafana/data';
-import { config } from '@grafana/runtime';
+import { config, isFetchError } from '@grafana/runtime';
import { Alert, Card, Icon, LoadingPlaceholder, useStyles2, withErrorBoundary } from '@grafana/ui';
import { AlertLabels } from './components/AlertLabels';
import { RuleViewerLayout } from './components/rule-viewer/RuleViewerLayout';
-import { useCombinedRulesMatching } from './hooks/useCombinedRule';
+import { useCloudCombinedRulesMatching } from './hooks/useCombinedRule';
import { getRulesSourceByName } from './utils/datasource';
import { createViewLink } from './utils/misc';
@@ -24,35 +24,55 @@ function useRuleFindParams() {
// Relevant issue: https://github.com/remix-run/history/issues/505#issuecomment-453175833
// It was probably fixed in React-Router v6
const location = useLocation();
- const segments = location.pathname?.replace(subUrl, '').split('/') ?? []; // ["", "alerting", "{sourceName}", "{name}]
- const name = decodeURIComponent(segments[3]);
- const sourceName = decodeURIComponent(segments[2]);
+ return useMemo(() => {
+ const segments = location.pathname?.replace(subUrl, '').split('/') ?? []; // ["", "alerting", "{sourceName}", "{name}]
- return { name, sourceName };
+ const name = decodeURIComponent(segments[3]);
+ const sourceName = decodeURIComponent(segments[2]);
+
+ const searchParams = new URLSearchParams(location.search);
+
+ return {
+ name,
+ sourceName,
+ namespace: searchParams.get('namespace') ?? undefined,
+ group: searchParams.get('group') ?? undefined,
+ };
+ }, [location]);
}
export function RedirectToRuleViewer(): JSX.Element | null {
const styles = useStyles2(getStyles);
- const { name, sourceName } = useRuleFindParams();
- const { error, loading, result: rules, dispatched } = useCombinedRulesMatching(name, sourceName);
+ const { name, sourceName, namespace, group } = useRuleFindParams();
+ const {
+ error,
+ loading,
+ rules = [],
+ } = useCloudCombinedRulesMatching(name, sourceName, { namespace, groupName: group });
+
+ if (!name || !sourceName) {
+ return ;
+ }
if (error) {
return (
-
- {error.message}
-
- {!!error?.stack && error.stack}
-
+ {isFetchError(error) && (
+
+ {error.message}
+
+ {/* {!!error?.stack && error.stack} */}
+
+ )}
);
}
- if (loading || !dispatched || !Array.isArray(rules)) {
+ if (loading) {
return (
@@ -60,10 +80,6 @@ export function RedirectToRuleViewer(): JSX.Element | null {
);
}
- if (!name || !sourceName) {
- return ;
- }
-
const rulesSource = getRulesSourceByName(sourceName);
if (!rulesSource) {
diff --git a/public/app/features/alerting/unified/api/alertRuleApi.ts b/public/app/features/alerting/unified/api/alertRuleApi.ts
index ed5dc6eb376..e0bc634dbb1 100644
--- a/public/app/features/alerting/unified/api/alertRuleApi.ts
+++ b/public/app/features/alerting/unified/api/alertRuleApi.ts
@@ -1,16 +1,18 @@
import { RelativeTimeRange } from '@grafana/data';
import { Matcher } from 'app/plugins/datasource/alertmanager/types';
-import { RuleIdentifier, RuleNamespace } from 'app/types/unified-alerting';
+import { RuleIdentifier, RuleNamespace, RulerDataSourceConfig } from 'app/types/unified-alerting';
import {
AlertQuery,
Annotations,
GrafanaAlertStateDecision,
Labels,
PromRulesResponse,
+ RulerRuleGroupDTO,
+ RulerRulesConfigDTO,
} from 'app/types/unified-alerting-dto';
import { Folder } from '../components/rule-editor/RuleFolderPicker';
-import { GRAFANA_RULES_SOURCE_NAME } from '../utils/datasource';
+import { getDatasourceAPIUid, GRAFANA_RULES_SOURCE_NAME } from '../utils/datasource';
import { arrayKeyValuesToObject } from '../utils/labels';
import { isCloudRuleIdentifier, isPrometheusRuleIdentifier } from '../utils/rules';
@@ -21,6 +23,7 @@ import {
paramsWithMatcherAndState,
prepareRulesFilterQueryParams,
} from './prometheus';
+import { FetchRulerRulesFilter, rulerUrlBuilder } from './ruler';
export type ResponseLabels = {
labels: AlertInstances[];
@@ -132,6 +135,49 @@ export const alertRuleApi = alertingApi.injectEndpoints({
},
}),
+ prometheusRuleNamespaces: build.query<
+ RuleNamespace[],
+ { ruleSourceName: string; namespace?: string; groupName?: string; ruleName?: string }
+ >({
+ query: ({ ruleSourceName, namespace, groupName, ruleName }) => {
+ const queryParams: Record = {};
+ // if (isPrometheusRuleIdentifier(ruleIdentifier) || isCloudRuleIdentifier(ruleIdentifier)) {
+ queryParams['file'] = namespace;
+ queryParams['rule_group'] = groupName;
+ queryParams['rule_name'] = ruleName;
+ // }
+
+ return {
+ url: `api/prometheus/${getDatasourceAPIUid(ruleSourceName)}/api/v1/rules`,
+ params: queryParams,
+ };
+ },
+ transformResponse: (response: PromRulesResponse, _, args): RuleNamespace[] => {
+ return groupRulesByFileName(response.data.groups, args.ruleSourceName);
+ },
+ }),
+
+ rulerRules: build.query<
+ RulerRulesConfigDTO,
+ { rulerConfig: RulerDataSourceConfig; filter?: FetchRulerRulesFilter }
+ >({
+ query: ({ rulerConfig, filter }) => {
+ const { path, params } = rulerUrlBuilder(rulerConfig).rules(filter);
+ return { url: path, params };
+ },
+ }),
+
+ // TODO This should be probably a separate ruler API file
+ rulerRuleGroup: build.query<
+ RulerRuleGroupDTO,
+ { rulerConfig: RulerDataSourceConfig; namespace: string; group: string }
+ >({
+ query: ({ rulerConfig, namespace, group }) => {
+ const { path, params } = rulerUrlBuilder(rulerConfig).namespaceGroup(namespace, group);
+ return { url: path, params };
+ },
+ }),
+
exportRule: build.query({
query: ({ uid, format }) => ({ url: getProvisioningUrl(uid, format) }),
}),
diff --git a/public/app/features/alerting/unified/api/featureDiscoveryApi.ts b/public/app/features/alerting/unified/api/featureDiscoveryApi.ts
index 2f7f00bd31f..659cc191f76 100644
--- a/public/app/features/alerting/unified/api/featureDiscoveryApi.ts
+++ b/public/app/features/alerting/unified/api/featureDiscoveryApi.ts
@@ -1,7 +1,11 @@
-import { AlertmanagerApiFeatures } from '../../../../types/unified-alerting-dto';
+import { RulerDataSourceConfig } from 'app/types/unified-alerting';
+
+import { AlertmanagerApiFeatures, PromApplication } from '../../../../types/unified-alerting-dto';
+import { withPerformanceLogging } from '../Analytics';
+import { getRulesDataSource } from '../utils/datasource';
import { alertingApi } from './alertingApi';
-import { discoverAlertmanagerFeatures } from './buildInfo';
+import { discoverAlertmanagerFeatures, discoverFeatures } from './buildInfo';
export const featureDiscoveryApi = alertingApi.injectEndpoints({
endpoints: (build) => ({
@@ -15,5 +19,34 @@ export const featureDiscoveryApi = alertingApi.injectEndpoints({
}
},
}),
+
+ discoverDsFeatures: build.query<{ rulerConfig?: RulerDataSourceConfig }, { rulesSourceName: string }>({
+ queryFn: async ({ rulesSourceName }) => {
+ const dsSettings = getRulesDataSource(rulesSourceName);
+ if (!dsSettings) {
+ return { error: new Error(`Missing data source configuration for ${rulesSourceName}`) };
+ }
+
+ const discoverFeaturesWithLogging = withPerformanceLogging(
+ discoverFeatures,
+ `[${rulesSourceName}] Rules source features discovered`,
+ {
+ dataSourceName: rulesSourceName,
+ endpoint: 'unifiedalerting/featureDiscoveryApi/discoverDsFeatures',
+ }
+ );
+
+ const dsFeatures = await discoverFeaturesWithLogging(dsSettings.name);
+
+ const rulerConfig: RulerDataSourceConfig | undefined = dsFeatures.features.rulerApiEnabled
+ ? {
+ dataSourceName: dsSettings.name,
+ apiVersion: dsFeatures.application === PromApplication.Cortex ? 'legacy' : 'config',
+ }
+ : undefined;
+
+ return { data: { rulerConfig } };
+ },
+ }),
}),
});
diff --git a/public/app/features/alerting/unified/components/rule-viewer/RuleViewer.v1.test.tsx b/public/app/features/alerting/unified/components/rule-viewer/RuleViewer.v1.test.tsx
index 64e2c978259..f37feab4b4c 100644
--- a/public/app/features/alerting/unified/components/rule-viewer/RuleViewer.v1.test.tsx
+++ b/public/app/features/alerting/unified/components/rule-viewer/RuleViewer.v1.test.tsx
@@ -1,36 +1,53 @@
-import { act, render, screen } from '@testing-library/react';
+import { render, screen, waitFor } from '@testing-library/react';
import React from 'react';
import { TestProvider } from 'test/helpers/TestProvider';
-import { byRole } from 'testing-library-selector';
+import { byRole, byText } from 'testing-library-selector';
-import { locationService, setBackendSrv } from '@grafana/runtime';
+import { config, locationService, setBackendSrv, setDataSourceSrv } from '@grafana/runtime';
import { GrafanaRouteComponentProps } from 'app/core/navigation/types';
import { backendSrv } from 'app/core/services/backend_srv';
import { contextSrv } from 'app/core/services/context_srv';
+import { AlertmanagerChoice } from 'app/plugins/datasource/alertmanager/types';
import { AccessControlAction } from 'app/types';
import { CombinedRule } from 'app/types/unified-alerting';
+import { PromAlertingRuleState, PromApplication } from 'app/types/unified-alerting-dto';
-import { useCombinedRule } from '../../hooks/useCombinedRule';
+import { discoverFeatures } from '../../api/buildInfo';
import { useIsRuleEditable } from '../../hooks/useIsRuleEditable';
-import { getCloudRule, getGrafanaRule, grantUserPermissions } from '../../mocks';
+import { mockAlertRuleApi, setupMswServer } from '../../mockApi';
+import {
+ getCloudRule,
+ getGrafanaRule,
+ grantUserPermissions,
+ mockDataSource,
+ MockDataSourceSrv,
+ mockPromAlertingRule,
+ mockRulerAlertingRule,
+ promRuleFromRulerRule,
+} from '../../mocks';
+import { mockAlertmanagerChoiceResponse } from '../../mocks/alertmanagerApi';
+import { mockPluginSettings } from '../../mocks/plugins';
+import { SupportedPlugin } from '../../types/pluginBridges';
+import * as ruleId from '../../utils/rule-id';
import { RuleViewer } from './RuleViewer.v1';
-const mockGrafanaRule = getGrafanaRule({ name: 'Test alert' });
+const mockGrafanaRule = getGrafanaRule({ name: 'Test alert' }, { uid: 'test1', title: 'Test alert' });
const mockCloudRule = getCloudRule({ name: 'cloud test alert' });
-const mockRoute: GrafanaRouteComponentProps<{ id?: string; sourceName?: string }> = {
+
+const mockRoute = (id?: string): GrafanaRouteComponentProps<{ id?: string; sourceName?: string }> => ({
route: {
path: '/',
component: RuleViewer,
},
queryParams: { returnTo: '/alerting/list' },
- match: { params: { id: 'test1', sourceName: 'grafana' }, isExact: false, url: 'asdf', path: '' },
+ match: { params: { id: id ?? 'test1', sourceName: 'grafana' }, isExact: false, url: 'asdf', path: '' },
history: locationService.getHistory(),
location: { pathname: '', hash: '', search: '', state: '' },
staticContext: {},
-};
+});
-jest.mock('../../hooks/useCombinedRule');
+// jest.mock('../../hooks/useCombinedRule');
jest.mock('@grafana/runtime', () => ({
...jest.requireActual('@grafana/runtime'),
getDataSourceSrv: () => {
@@ -44,14 +61,11 @@ jest.mock('@grafana/runtime', () => ({
},
}));
-const renderRuleViewer = () => {
- return act(async () => {
- render(
-
-
-
- );
- });
+jest.mock('../../hooks/useIsRuleEditable');
+jest.mock('../../api/buildInfo');
+
+const mocks = {
+ useIsRuleEditable: jest.mocked(useIsRuleEditable),
};
const ui = {
@@ -61,36 +75,96 @@ const ui = {
delete: byRole('button', { name: /delete/i }),
silence: byRole('link', { name: 'Silence' }),
},
+ loadingIndicator: byText(/Loading rule/i),
};
-jest.mock('../../hooks/useIsRuleEditable');
-const mocks = {
- useIsRuleEditable: jest.mocked(useIsRuleEditable),
+const renderRuleViewer = async (ruleId?: string) => {
+ render(
+
+
+
+ );
+
+ await waitFor(() => expect(ui.loadingIndicator.query()).not.toBeInTheDocument());
};
+const server = setupMswServer();
+
+const dsName = 'prometheus';
+const rulerRule = mockRulerAlertingRule({ alert: 'cloud test alert' });
+const rulerRuleIdentifier = ruleId.fromRulerRule('prometheus', 'ns-default', 'group-default', rulerRule);
+
beforeAll(() => {
setBackendSrv(backendSrv);
+
+ // some action buttons need to check what Alertmanager setup we have for Grafana managed rules
+ mockAlertmanagerChoiceResponse(server, {
+ alertmanagersChoice: AlertmanagerChoice.Internal,
+ numExternalAlertmanagers: 1,
+ });
+ // we need to mock this one for the "declare incident" button
+ mockPluginSettings(server, SupportedPlugin.Incident);
+
+ const dsSettings = mockDataSource({
+ name: dsName,
+ uid: dsName,
+ });
+ config.datasources = {
+ [dsName]: dsSettings,
+ };
+
+ setDataSourceSrv(new MockDataSourceSrv({ [dsName]: dsSettings }));
+
+ mockAlertRuleApi(server).rulerRules('grafana', {
+ [mockGrafanaRule.namespace.name]: [
+ { name: mockGrafanaRule.group.name, interval: '1m', rules: [mockGrafanaRule.rulerRule!] },
+ ],
+ });
+
+ const { name, query, labels, annotations } = mockGrafanaRule;
+ mockAlertRuleApi(server).prometheusRuleNamespaces('grafana', {
+ data: {
+ groups: [
+ {
+ file: mockGrafanaRule.namespace.name,
+ interval: 60,
+ name: mockGrafanaRule.group.name,
+ rules: [mockPromAlertingRule({ name, query, labels, annotations })],
+ },
+ ],
+ },
+ status: 'success',
+ });
+
+ mockAlertRuleApi(server).rulerRuleGroup(dsName, 'ns-default', 'group-default', {
+ name: 'group-default',
+ interval: '1m',
+ rules: [rulerRule],
+ });
+
+ mockAlertRuleApi(server).prometheusRuleNamespaces(dsName, {
+ data: {
+ groups: [
+ {
+ file: 'ns-default',
+ interval: 60,
+ name: 'group-default',
+ rules: [promRuleFromRulerRule(rulerRule, { state: PromAlertingRuleState.Inactive })],
+ },
+ ],
+ },
+ status: 'success',
+ });
});
describe('RuleViewer', () => {
- let mockCombinedRule: jest.MockedFn;
-
- beforeEach(() => {
- mockCombinedRule = jest.mocked(useCombinedRule);
- });
+ let mockCombinedRule = jest.fn();
afterEach(() => {
mockCombinedRule.mockReset();
});
it('should render page with grafana alert', async () => {
- mockCombinedRule.mockReturnValue({
- result: mockGrafanaRule as CombinedRule,
- loading: false,
- dispatched: true,
- requestId: 'A',
- error: undefined,
- });
mocks.useIsRuleEditable.mockReturnValue({ loading: false, isEditable: false });
await renderRuleViewer();
@@ -98,15 +172,14 @@ describe('RuleViewer', () => {
});
it('should render page with cloud alert', async () => {
- mockCombinedRule.mockReturnValue({
- result: mockCloudRule as CombinedRule,
- loading: false,
- dispatched: true,
- requestId: 'A',
- error: undefined,
- });
+ jest.spyOn(contextSrv, 'hasPermission').mockReturnValue(true);
+
+ jest
+ .mocked(discoverFeatures)
+ .mockResolvedValue({ application: PromApplication.Mimir, features: { rulerApiEnabled: true } });
+
mocks.useIsRuleEditable.mockReturnValue({ loading: false, isEditable: false });
- await renderRuleViewer();
+ await renderRuleViewer(ruleId.stringifyIdentifier(rulerRuleIdentifier));
expect(screen.getByText(/cloud test alert/i)).toBeInTheDocument();
});
@@ -114,10 +187,10 @@ describe('RuleViewer', () => {
describe('RuleDetails RBAC', () => {
describe('Grafana rules action buttons in details', () => {
- let mockCombinedRule: jest.MockedFn;
+ let mockCombinedRule = jest.fn();
beforeEach(() => {
- mockCombinedRule = jest.mocked(useCombinedRule);
+ // mockCombinedRule = jest.mocked(useCombinedRule);
});
afterEach(() => {
@@ -141,7 +214,7 @@ describe('RuleDetails RBAC', () => {
expect(ui.actionButtons.edit.get()).toBeInTheDocument();
});
- it('Should render Delete button for users with the delete permission', async () => {
+ it('Should render Delete button for users with the delete permission', async () => {
// Arrange
mockCombinedRule.mockReturnValue({
result: mockGrafanaRule as CombinedRule,
@@ -174,7 +247,9 @@ describe('RuleDetails RBAC', () => {
await renderRuleViewer();
// Assert
- expect(ui.actionButtons.silence.query()).not.toBeInTheDocument();
+ await waitFor(() => {
+ expect(ui.actionButtons.silence.query()).not.toBeInTheDocument();
+ });
});
it('Should render Silence button for users with the instance create permissions', async () => {
@@ -194,7 +269,9 @@ describe('RuleDetails RBAC', () => {
await renderRuleViewer();
// Assert
- expect(ui.actionButtons.silence.query()).toBeInTheDocument();
+ await waitFor(() => {
+ expect(ui.actionButtons.silence.query()).toBeInTheDocument();
+ });
});
it('Should render clone button for users having create rule permission', async () => {
@@ -228,10 +305,10 @@ describe('RuleDetails RBAC', () => {
});
});
describe('Cloud rules action buttons', () => {
- let mockCombinedRule: jest.MockedFn;
+ let mockCombinedRule = jest.fn();
beforeEach(() => {
- mockCombinedRule = jest.mocked(useCombinedRule);
+ // mockCombinedRule = jest.mocked(useCombinedRule);
});
afterEach(() => {
diff --git a/public/app/features/alerting/unified/components/rule-viewer/RuleViewer.v1.tsx b/public/app/features/alerting/unified/components/rule-viewer/RuleViewer.v1.tsx
index 5d0db496ca4..ffd7cf71183 100644
--- a/public/app/features/alerting/unified/components/rule-viewer/RuleViewer.v1.tsx
+++ b/public/app/features/alerting/unified/components/rule-viewer/RuleViewer.v1.tsx
@@ -5,7 +5,7 @@ import { useObservable, useToggle } from 'react-use';
import { GrafanaTheme2, LoadingState, PanelData, RelativeTimeRange } from '@grafana/data';
import { Stack } from '@grafana/experimental';
-import { config } from '@grafana/runtime';
+import { config, isFetchError } from '@grafana/runtime';
import { Alert, Button, Collapse, Icon, IconButton, LoadingPlaceholder, useStyles2, VerticalGroup } from '@grafana/ui';
import { GrafanaRouteComponentProps } from 'app/core/navigation/types';
@@ -44,9 +44,16 @@ export function RuleViewer({ match }: RuleViewerProps) {
const [expandQuery, setExpandQuery] = useToggle(false);
const { id } = match.params;
- const identifier = ruleId.tryParse(id, true);
+ const identifier = useMemo(() => {
+ if (!id) {
+ throw new Error('Rule ID is required');
+ }
+
+ return ruleId.parse(id, true);
+ }, [id]);
+
+ const { loading, error, result: rule } = useCombinedRule({ ruleIdentifier: identifier });
- const { loading, error, result: rule } = useCombinedRule(identifier, identifier?.ruleSourceName);
const runner = useMemo(() => new AlertingQueryRunner(), []);
const data = useObservable(runner.get());
const queries = useMemo(() => alertRuleToQueries(rule), [rule]);
@@ -120,9 +127,10 @@ export function RuleViewer({ match }: RuleViewerProps) {
return (
- {error?.message ?? errorMessage}
+ {isFetchError(error) ? error.message : errorMessage}
- {!!error?.stack && error.stack}
+ {/* TODO Fix typescript */}
+ {/* {error && error?.stack} */}
);
diff --git a/public/app/features/alerting/unified/components/rule-viewer/v2/RuleViewer.v2.tsx b/public/app/features/alerting/unified/components/rule-viewer/v2/RuleViewer.v2.tsx
index ee40d8ff13a..b7fcd608bb5 100644
--- a/public/app/features/alerting/unified/components/rule-viewer/v2/RuleViewer.v2.tsx
+++ b/public/app/features/alerting/unified/components/rule-viewer/v2/RuleViewer.v2.tsx
@@ -1,4 +1,4 @@
-import React, { useState } from 'react';
+import React, { useMemo, useState } from 'react';
import { Stack } from '@grafana/experimental';
import { Alert, Button, Icon, LoadingPlaceholder, Tab, TabContent, TabsBar, Text } from '@grafana/ui';
@@ -35,10 +35,17 @@ enum Tabs {
// add provisioning and federation stuff back in
const RuleViewer = ({ match }: RuleViewerProps) => {
const { id } = match.params;
- const identifier = ruleId.tryParse(id, true);
const [activeTab, setActiveTab] = useState(Tabs.Instances);
- const { loading, error, result: rule } = useCombinedRule(identifier, identifier?.ruleSourceName);
+ const identifier = useMemo(() => {
+ if (!id) {
+ throw new Error('Rule ID is required');
+ }
+
+ return ruleId.parse(id, true);
+ }, [id]);
+
+ const { loading, error, result: rule } = useCombinedRule({ ruleIdentifier: identifier });
// we're setting the document title and the breadcrumb manually
useRuleViewerPageTitle(rule);
diff --git a/public/app/features/alerting/unified/components/rules/RuleListGroupView.test.tsx b/public/app/features/alerting/unified/components/rules/RuleListGroupView.test.tsx
index 9d0c05fac86..38c0a829e3f 100644
--- a/public/app/features/alerting/unified/components/rules/RuleListGroupView.test.tsx
+++ b/public/app/features/alerting/unified/components/rules/RuleListGroupView.test.tsx
@@ -1,4 +1,4 @@
-import { render } from '@testing-library/react';
+import { render, waitFor } from '@testing-library/react';
import React from 'react';
import { Provider } from 'react-redux';
import { Router } from 'react-router-dom';
@@ -33,7 +33,7 @@ describe('RuleListGroupView', () => {
describe('RBAC', () => {
jest.spyOn(contextSrv, 'accessControlEnabled').mockReturnValue(true);
- it('Should display Grafana rules when the user has the alert rule read permission', () => {
+ it('Should display Grafana rules when the user has the alert rule read permission', async () => {
const grafanaNamespace = getGrafanaNamespace();
const namespaces: CombinedRuleNamespace[] = [grafanaNamespace];
@@ -43,10 +43,12 @@ describe('RuleListGroupView', () => {
renderRuleList(namespaces);
- expect(ui.grafanaRulesHeading.get()).toBeInTheDocument();
+ await waitFor(() => {
+ expect(ui.grafanaRulesHeading.get()).toBeInTheDocument();
+ });
});
- it('Should display Cloud rules when the user has the external rules read permission', () => {
+ it('Should display Cloud rules when the user has the external rules read permission', async () => {
const cloudNamespace = getCloudNamespace();
const namespaces: CombinedRuleNamespace[] = [cloudNamespace];
@@ -56,10 +58,12 @@ describe('RuleListGroupView', () => {
renderRuleList(namespaces);
- expect(ui.cloudRulesHeading.get()).toBeInTheDocument();
+ await waitFor(() => {
+ expect(ui.cloudRulesHeading.get()).toBeInTheDocument();
+ });
});
- it('Should not display Grafana rules when the user does not have alert rule read permission', () => {
+ it('Should not display Grafana rules when the user does not have alert rule read permission', async () => {
const grafanaNamespace = getGrafanaNamespace();
const namespaces: CombinedRuleNamespace[] = [grafanaNamespace];
@@ -67,10 +71,12 @@ describe('RuleListGroupView', () => {
renderRuleList(namespaces);
- expect(ui.grafanaRulesHeading.query()).not.toBeInTheDocument();
+ await waitFor(() => {
+ expect(ui.grafanaRulesHeading.query()).not.toBeInTheDocument();
+ });
});
- it('Should not display Cloud rules when the user does not have the external rules read permission', () => {
+ it('Should not display Cloud rules when the user does not have the external rules read permission', async () => {
const cloudNamespace = getCloudNamespace();
const namespaces: CombinedRuleNamespace[] = [cloudNamespace];
@@ -80,7 +86,9 @@ describe('RuleListGroupView', () => {
renderRuleList(namespaces);
- expect(ui.cloudRulesHeading.query()).not.toBeInTheDocument();
+ await waitFor(() => {
+ expect(ui.cloudRulesHeading.query()).not.toBeInTheDocument();
+ });
});
});
diff --git a/public/app/features/alerting/unified/hooks/useCombinedRule.ts b/public/app/features/alerting/unified/hooks/useCombinedRule.ts
index 9b1640373f4..21c64a2e4b5 100644
--- a/public/app/features/alerting/unified/hooks/useCombinedRule.ts
+++ b/public/app/features/alerting/unified/hooks/useCombinedRule.ts
@@ -1,51 +1,30 @@
-import { useMemo } from 'react';
+import { useEffect, useMemo } from 'react';
import { useAsync } from 'react-use';
import { useDispatch } from 'app/types';
-import { CombinedRule, RuleIdentifier, RuleNamespace } from 'app/types/unified-alerting';
-import { RulerRulesConfigDTO } from 'app/types/unified-alerting-dto';
+import { CombinedRule, RuleIdentifier, RuleNamespace, RulerDataSourceConfig } from 'app/types/unified-alerting';
+import { RulerRuleGroupDTO, RulerRulesConfigDTO } from 'app/types/unified-alerting-dto';
+import { alertRuleApi } from '../api/alertRuleApi';
+import { featureDiscoveryApi } from '../api/featureDiscoveryApi';
import { fetchPromAndRulerRulesAction } from '../state/actions';
+import { getDataSourceByName, GRAFANA_RULES_SOURCE_NAME, isGrafanaRulesSource } from '../utils/datasource';
import { AsyncRequestMapSlice, AsyncRequestState, initialAsyncRequestState } from '../utils/redux';
import * as ruleId from '../utils/rule-id';
-import { isRulerNotSupportedResponse } from '../utils/rules';
+import {
+ isCloudRuleIdentifier,
+ isGrafanaRuleIdentifier,
+ isPrometheusRuleIdentifier,
+ isRulerNotSupportedResponse,
+} from '../utils/rules';
-import { useCombinedRuleNamespaces } from './useCombinedRuleNamespaces';
+import {
+ attachRulerRulesToCombinedRules,
+ combineRulesNamespaces,
+ useCombinedRuleNamespaces,
+} from './useCombinedRuleNamespaces';
import { useUnifiedAlertingSelector } from './useUnifiedAlertingSelector';
-export function useCombinedRule(
- identifier: RuleIdentifier | undefined,
- ruleSourceName: string | undefined
-): AsyncRequestState {
- const requestState = useCombinedRulesLoader(ruleSourceName, identifier);
- const combinedRules = useCombinedRuleNamespaces(ruleSourceName);
-
- const rule = useMemo(() => {
- if (!identifier || !ruleSourceName || combinedRules.length === 0) {
- return;
- }
-
- for (const namespace of combinedRules) {
- for (const group of namespace.groups) {
- for (const rule of group.rules) {
- const id = ruleId.fromCombinedRule(ruleSourceName, rule);
-
- if (ruleId.equal(id, identifier)) {
- return rule;
- }
- }
- }
- }
-
- return;
- }, [identifier, ruleSourceName, combinedRules]);
-
- return {
- ...requestState,
- result: rule,
- };
-}
-
export function useCombinedRulesMatching(
ruleName: string | undefined,
ruleSourceName: string | undefined
@@ -79,6 +58,67 @@ export function useCombinedRulesMatching(
};
}
+export function useCloudCombinedRulesMatching(
+ ruleName: string,
+ ruleSourceName: string,
+ filter?: { namespace?: string; groupName?: string }
+): { loading: boolean; error?: unknown; rules?: CombinedRule[] } {
+ const dsSettings = getDataSourceByName(ruleSourceName);
+ const { dsFeatures, isLoadingDsFeatures } = useDataSourceFeatures(ruleSourceName);
+
+ const {
+ currentData: promRuleNs = [],
+ isLoading: isLoadingPromRules,
+ error: promRuleNsError,
+ } = alertRuleApi.endpoints.prometheusRuleNamespaces.useQuery({
+ ruleSourceName: ruleSourceName,
+ ruleName: ruleName,
+ namespace: filter?.namespace,
+ groupName: filter?.groupName,
+ });
+
+ const [fetchRulerRuleGroup] = alertRuleApi.endpoints.rulerRuleGroup.useLazyQuery();
+
+ const { loading, error, value } = useAsync(async () => {
+ if (!dsSettings) {
+ throw new Error('Unable to obtain data source settings');
+ }
+
+ if (promRuleNsError) {
+ throw new Error('Unable to obtain Prometheus rules');
+ }
+
+ const rulerGroups: RulerRuleGroupDTO[] = [];
+ if (dsFeatures?.rulerConfig) {
+ const rulerConfig = dsFeatures.rulerConfig;
+
+ const nsGroups = promRuleNs
+ .map((namespace) => namespace.groups.map((group) => ({ namespace: namespace, group: group })))
+ .flat();
+
+ // RTK query takes care of deduplication
+ await Promise.allSettled(
+ nsGroups.map(async (nsGroup) => {
+ const rulerGroup = await fetchRulerRuleGroup({
+ rulerConfig: rulerConfig,
+ namespace: nsGroup.namespace.name,
+ group: nsGroup.group.name,
+ }).unwrap();
+ rulerGroups.push(rulerGroup);
+ })
+ );
+ }
+
+ // TODO Join with ruler rules
+ const namespaces = promRuleNs.map((ns) => attachRulerRulesToCombinedRules(dsSettings, ns, rulerGroups));
+ const rules = namespaces.flatMap((ns) => ns.groups.flatMap((group) => group.rules));
+
+ return rules;
+ }, [dsSettings, dsFeatures, isLoadingPromRules, promRuleNsError, promRuleNs, fetchRulerRuleGroup]);
+
+ return { loading: isLoadingDsFeatures || loading, error: error, rules: value };
+}
+
function useCombinedRulesLoader(
rulesSourceName: string | undefined,
identifier?: RuleIdentifier
@@ -120,3 +160,150 @@ function getRequestState(
return state;
}
+
+export function useCombinedRule({ ruleIdentifier }: { ruleIdentifier: RuleIdentifier }): {
+ loading: boolean;
+ result?: CombinedRule;
+ error?: unknown;
+} {
+ const { ruleSourceName } = ruleIdentifier;
+ const dsSettings = getDataSourceByName(ruleSourceName);
+
+ const { dsFeatures, isLoadingDsFeatures } = useDataSourceFeatures(ruleSourceName);
+
+ const {
+ currentData: promRuleNs,
+ isLoading: isLoadingPromRules,
+ error: promRuleNsError,
+ } = alertRuleApi.endpoints.prometheusRuleNamespaces.useQuery(
+ {
+ // TODO Refactor parameters
+ ruleSourceName: ruleIdentifier.ruleSourceName,
+ namespace:
+ isPrometheusRuleIdentifier(ruleIdentifier) || isCloudRuleIdentifier(ruleIdentifier)
+ ? ruleIdentifier.namespace
+ : undefined,
+ groupName:
+ isPrometheusRuleIdentifier(ruleIdentifier) || isCloudRuleIdentifier(ruleIdentifier)
+ ? ruleIdentifier.groupName
+ : undefined,
+ ruleName:
+ isPrometheusRuleIdentifier(ruleIdentifier) || isCloudRuleIdentifier(ruleIdentifier)
+ ? ruleIdentifier.ruleName
+ : undefined,
+ }
+ // TODO – experiment with enabling these now that we request a single alert rule more efficiently.
+ // Requires a recent version of Prometheus with support for query params on /api/v1/rules
+ // {
+ // refetchOnFocus: true,
+ // refetchOnReconnect: true,
+ // }
+ );
+
+ const [
+ fetchRulerRuleGroup,
+ { currentData: rulerRuleGroup, isLoading: isLoadingRulerGroup, error: rulerRuleGroupError },
+ ] = alertRuleApi.endpoints.rulerRuleGroup.useLazyQuery();
+
+ const [fetchRulerRules, { currentData: rulerRules, isLoading: isLoadingRulerRules, error: rulerRulesError }] =
+ alertRuleApi.endpoints.rulerRules.useLazyQuery();
+
+ useEffect(() => {
+ if (!dsFeatures?.rulerConfig) {
+ return;
+ }
+
+ if (dsFeatures.rulerConfig && isCloudRuleIdentifier(ruleIdentifier)) {
+ fetchRulerRuleGroup({
+ rulerConfig: dsFeatures.rulerConfig,
+ namespace: ruleIdentifier.namespace,
+ group: ruleIdentifier.groupName,
+ });
+ } else if (isGrafanaRuleIdentifier(ruleIdentifier)) {
+ // TODO Fetch a single group for Grafana managed rules, we're currently still fetching all rules for Grafana managed
+ fetchRulerRules({ rulerConfig: dsFeatures.rulerConfig });
+ }
+ }, [dsFeatures, fetchRulerRuleGroup, fetchRulerRules, ruleIdentifier]);
+
+ const rule = useMemo(() => {
+ if (!promRuleNs) {
+ return;
+ }
+
+ if (isGrafanaRuleIdentifier(ruleIdentifier)) {
+ const combinedNamespaces = combineRulesNamespaces('grafana', promRuleNs, rulerRules);
+
+ for (const namespace of combinedNamespaces) {
+ for (const group of namespace.groups) {
+ for (const rule of group.rules) {
+ const id = ruleId.fromCombinedRule(ruleSourceName, rule);
+
+ if (ruleId.equal(id, ruleIdentifier)) {
+ return rule;
+ }
+ }
+ }
+ }
+ }
+
+ if (!dsSettings) {
+ return;
+ }
+
+ if (
+ promRuleNs.length > 0 &&
+ (isCloudRuleIdentifier(ruleIdentifier) || isPrometheusRuleIdentifier(ruleIdentifier))
+ ) {
+ const namespaces = promRuleNs.map((ns) =>
+ attachRulerRulesToCombinedRules(dsSettings, ns, rulerRuleGroup ? [rulerRuleGroup] : [])
+ );
+
+ for (const namespace of namespaces) {
+ for (const group of namespace.groups) {
+ for (const rule of group.rules) {
+ const id = ruleId.fromCombinedRule(ruleSourceName, rule);
+
+ if (ruleId.equal(id, ruleIdentifier)) {
+ return rule;
+ }
+ }
+ }
+ }
+ }
+
+ return;
+ }, [ruleIdentifier, ruleSourceName, promRuleNs, rulerRuleGroup, rulerRules, dsSettings]);
+
+ return {
+ loading: isLoadingDsFeatures || isLoadingPromRules || isLoadingRulerGroup || isLoadingRulerRules,
+ error: promRuleNsError ?? rulerRuleGroupError ?? rulerRulesError,
+ result: rule,
+ };
+}
+
+const grafanaRulerConfig: RulerDataSourceConfig = {
+ dataSourceName: GRAFANA_RULES_SOURCE_NAME,
+ apiVersion: 'legacy',
+};
+
+const grafanaDsFeatures = {
+ rulerConfig: grafanaRulerConfig,
+};
+
+export function useDataSourceFeatures(dataSourceName: string) {
+ const isGrafanaDs = isGrafanaRulesSource(dataSourceName);
+
+ const { currentData: dsFeatures, isLoading: isLoadingDsFeatures } =
+ featureDiscoveryApi.endpoints.discoverDsFeatures.useQuery(
+ {
+ rulesSourceName: dataSourceName,
+ },
+ { skip: isGrafanaDs }
+ );
+
+ if (isGrafanaDs) {
+ return { isLoadingDsFeatures: false, dsFeatures: grafanaDsFeatures };
+ }
+
+ return { isLoadingDsFeatures, dsFeatures };
+}
diff --git a/public/app/features/alerting/unified/hooks/useCombinedRuleNamespaces.ts b/public/app/features/alerting/unified/hooks/useCombinedRuleNamespaces.ts
index f0663ffdf14..eb9f5f73801 100644
--- a/public/app/features/alerting/unified/hooks/useCombinedRuleNamespaces.ts
+++ b/public/app/features/alerting/unified/hooks/useCombinedRuleNamespaces.ts
@@ -84,7 +84,7 @@ export function useCombinedRuleNamespaces(
}
const namespaces: Record = {};
- // first get all the ruler rules in
+ // first get all the ruler rules from the data source
Object.entries(rulerRules || {}).forEach(([namespaceName, groups]) => {
const namespace: CombinedRuleNamespace = {
rulesSource,
@@ -115,6 +115,72 @@ export function useCombinedRuleNamespaces(
}, [promRulesResponses, rulerRulesResponses, rulesSources, grafanaPromRuleNamespaces]);
}
+export function combineRulesNamespaces(
+ rulesSource: RulesSource,
+ promNamespaces: RuleNamespace[],
+ rulerRules?: RulerRulesConfigDTO
+): CombinedRuleNamespace[] {
+ 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: [],
+ };
+ namespaces[namespaceName] = namespace;
+ addRulerGroupsToCombinedNamespace(namespace, groups);
+ });
+
+ // then correlate with prometheus rules
+ promNamespaces?.forEach(({ name: namespaceName, groups }) => {
+ const ns = (namespaces[namespaceName] = namespaces[namespaceName] || {
+ rulesSource,
+ name: namespaceName,
+ groups: [],
+ });
+
+ addPromGroupsToCombinedNamespace(ns, groups);
+ });
+
+ return Object.values(namespaces);
+}
+
+export function attachRulerRulesToCombinedRules(
+ rulesSource: RulesSource,
+ promNamespace: RuleNamespace,
+ rulerGroups: RulerRuleGroupDTO[]
+): CombinedRuleNamespace {
+ const ns: CombinedRuleNamespace = {
+ rulesSource: rulesSource,
+ name: promNamespace.name,
+ groups: [],
+ };
+
+ // The order is important. Adding Ruler rules overrides Prometheus rules.
+ addRulerGroupsToCombinedNamespace(ns, rulerGroups);
+ addPromGroupsToCombinedNamespace(ns, promNamespace.groups);
+
+ // Remove ruler rules which does not have Prom rule counterpart
+ // This function should only attach Ruler rules to existing Prom rules
+ ns.groups.forEach((group) => {
+ group.rules = group.rules.filter((rule) => rule.promRule);
+ });
+
+ return ns;
+}
+
+export function addCombinedPromAndRulerGroups(
+ ns: CombinedRuleNamespace,
+ promGroups: RuleGroup[],
+ rulerGroups: RulerRuleGroupDTO[]
+): CombinedRuleNamespace {
+ addRulerGroupsToCombinedNamespace(ns, rulerGroups);
+ addPromGroupsToCombinedNamespace(ns, promGroups);
+ return ns;
+}
+
// merge all groups in case of grafana managed, essentially treating namespaces (folders) as groups
export function flattenGrafanaManagedRules(namespaces: CombinedRuleNamespace[]) {
return namespaces.map((namespace) => {
diff --git a/public/app/features/alerting/unified/hooks/useIsRuleEditable.test.tsx b/public/app/features/alerting/unified/hooks/useIsRuleEditable.test.tsx
index 8b1705aa9d0..c94d79a21f5 100644
--- a/public/app/features/alerting/unified/hooks/useIsRuleEditable.test.tsx
+++ b/public/app/features/alerting/unified/hooks/useIsRuleEditable.test.tsx
@@ -1,4 +1,4 @@
-import { renderHook } from '@testing-library/react';
+import { renderHook, waitFor } from '@testing-library/react';
import React from 'react';
import { Provider } from 'react-redux';
@@ -30,7 +30,7 @@ describe('useIsRuleEditable', () => {
beforeEach(enableRBAC);
describe('Grafana rules', () => {
// When RBAC is enabled we require appropriate alerting permissions in the folder scope
- it('Should allow editing when the user has the alert rule update permission in the folder', () => {
+ it('Should allow editing when the user has the alert rule update permission in the folder', async () => {
mockUseFolder({
accessControl: {
[AccessControlAction.AlertingRuleUpdate]: true,
@@ -41,11 +41,11 @@ describe('useIsRuleEditable', () => {
const { result } = renderHook(() => useIsRuleEditable('grafana', mockRulerGrafanaRule()), { wrapper });
- expect(result.current.loading).toBe(false);
+ await waitFor(() => expect(result.current.loading).toBe(false));
expect(result.current.isEditable).toBe(true);
});
- it('Should allow deleting when the user has the alert rule delete permission', () => {
+ it('Should allow deleting when the user has the alert rule delete permission', async () => {
mockUseFolder({
accessControl: {
[AccessControlAction.AlertingRuleDelete]: true,
@@ -56,33 +56,33 @@ describe('useIsRuleEditable', () => {
const { result } = renderHook(() => useIsRuleEditable('grafana', mockRulerGrafanaRule()), { wrapper });
- expect(result.current.loading).toBe(false);
+ await waitFor(() => expect(result.current.loading).toBe(false));
expect(result.current.isRemovable).toBe(true);
});
- it('Should forbid editing when the user has no alert rule update permission', () => {
+ it('Should forbid editing when the user has no alert rule update permission', async () => {
mockUseFolder({ accessControl: {} });
const wrapper = getProviderWrapper();
const { result } = renderHook(() => useIsRuleEditable('grafana', mockRulerGrafanaRule()), { wrapper });
- expect(result.current.loading).toBe(false);
+ await waitFor(() => expect(result.current.loading).toBe(false));
expect(result.current.isEditable).toBe(false);
});
- it('Should forbid deleting when the user has no alert rule delete permission', () => {
+ it('Should forbid deleting when the user has no alert rule delete permission', async () => {
mockUseFolder({ accessControl: {} });
const wrapper = getProviderWrapper();
const { result } = renderHook(() => useIsRuleEditable('grafana', mockRulerGrafanaRule()), { wrapper });
- expect(result.current.loading).toBe(false);
+ await waitFor(() => expect(result.current.loading).toBe(false));
expect(result.current.isRemovable).toBe(false);
});
- it('Should allow editing and deleting when the user has alert rule permissions but does not have folder canSave permission', () => {
+ it('Should allow editing and deleting when the user has alert rule permissions but does not have folder canSave permission', async () => {
mockUseFolder({
canSave: false,
accessControl: {
@@ -95,7 +95,7 @@ describe('useIsRuleEditable', () => {
const { result } = renderHook(() => useIsRuleEditable('grafana', mockRulerGrafanaRule()), { wrapper });
- expect(result.current.loading).toBe(false);
+ await waitFor(() => expect(result.current.loading).toBe(false));
expect(result.current.isEditable).toBe(true);
expect(result.current.isRemovable).toBe(true);
});
@@ -103,27 +103,28 @@ describe('useIsRuleEditable', () => {
describe('Cloud rules', () => {
beforeEach(() => {
+ mocks.useFolder.mockReturnValue({ loading: false });
contextSrv.isEditor = true;
});
- it('Should allow editing and deleting when the user has alert rule external write permission', () => {
+ it('Should allow editing and deleting when the user has alert rule external write permission', async () => {
mockPermissions([AccessControlAction.AlertingRuleExternalWrite]);
const wrapper = getProviderWrapper();
const { result } = renderHook(() => useIsRuleEditable('cortex', mockRulerAlertingRule()), { wrapper });
- expect(result.current.loading).toBe(false);
+ await waitFor(() => expect(result.current.loading).toBe(false));
expect(result.current.isEditable).toBe(true);
expect(result.current.isRemovable).toBe(true);
});
- it('Should forbid editing and deleting when the user has no alert rule external write permission', () => {
+ it('Should forbid editing and deleting when the user has no alert rule external write permission', async () => {
mockPermissions([]);
const wrapper = getProviderWrapper();
const { result } = renderHook(() => useIsRuleEditable('cortex', mockRulerAlertingRule()), { wrapper });
- expect(result.current.loading).toBe(false);
+ await waitFor(() => expect(result.current.loading).toBe(false));
expect(result.current.isEditable).toBe(false);
expect(result.current.isRemovable).toBe(false);
});
@@ -133,26 +134,26 @@ describe('useIsRuleEditable', () => {
describe('RBAC disabled', () => {
beforeEach(disableRBAC);
describe('Grafana rules', () => {
- it('Should allow editing and deleting when the user has folder canSave permission', () => {
+ it('Should allow editing and deleting when the user has folder canSave permission', async () => {
mockUseFolder({ canSave: true });
const wrapper = getProviderWrapper();
const { result } = renderHook(() => useIsRuleEditable('grafana', mockRulerGrafanaRule()), { wrapper });
- expect(result.current.loading).toBe(false);
+ await waitFor(() => expect(result.current.loading).toBe(false));
expect(result.current.isEditable).toBe(true);
expect(result.current.isRemovable).toBe(true);
});
- it('Should forbid editing and deleting when the user has no folder canSave permission', () => {
+ it('Should forbid editing and deleting when the user has no folder canSave permission', async () => {
mockUseFolder({ canSave: false });
const wrapper = getProviderWrapper();
const { result } = renderHook(() => useIsRuleEditable('grafana', mockRulerGrafanaRule()), { wrapper });
- expect(result.current.loading).toBe(false);
+ await waitFor(() => expect(result.current.loading).toBe(false));
expect(result.current.isEditable).toBe(false);
expect(result.current.isRemovable).toBe(false);
});
diff --git a/public/app/features/alerting/unified/hooks/useIsRuleEditable.ts b/public/app/features/alerting/unified/hooks/useIsRuleEditable.ts
index 150f4ee00ef..86fa29ce685 100644
--- a/public/app/features/alerting/unified/hooks/useIsRuleEditable.ts
+++ b/public/app/features/alerting/unified/hooks/useIsRuleEditable.ts
@@ -1,6 +1,7 @@
import { contextSrv } from 'app/core/services/context_srv';
import { RulerRuleDTO } from 'app/types/unified-alerting-dto';
+import { featureDiscoveryApi } from '../api/featureDiscoveryApi';
import { getRulesPermissions } from '../utils/access-control';
import { isGrafanaRulerRule } from '../utils/rules';
@@ -15,6 +16,10 @@ interface ResultBag {
export function useIsRuleEditable(rulesSourceName: string, rule?: RulerRuleDTO): ResultBag {
const dataSources = useUnifiedAlertingSelector((state) => state.dataSources);
+ const { currentData: dsFeatures, isLoading } = featureDiscoveryApi.endpoints.discoverDsFeatures.useQuery({
+ rulesSourceName,
+ });
+
const folderUID = rule && isGrafanaRulerRule(rule) ? rule.grafana_alert.namespace_uid : undefined;
const rulePermission = getRulesPermissions(rulesSourceName);
@@ -50,18 +55,19 @@ export function useIsRuleEditable(rulesSourceName: string, rule?: RulerRuleDTO):
return {
isEditable: canEditGrafanaRules,
isRemovable: canRemoveGrafanaRules,
- loading,
+ loading: loading || isLoading,
};
}
// prom rules are only editable by users with Editor role and only if rules source supports editing
- const isRulerAvailable = Boolean(dataSources[rulesSourceName]?.result?.rulerConfig);
+ const isRulerAvailable =
+ Boolean(dataSources[rulesSourceName]?.result?.rulerConfig) || Boolean(dsFeatures?.rulerConfig);
const canEditCloudRules = contextSrv.hasAccess(rulePermission.update, contextSrv.isEditor);
const canRemoveCloudRules = contextSrv.hasAccess(rulePermission.delete, contextSrv.isEditor);
return {
isEditable: canEditCloudRules && isRulerAvailable,
isRemovable: canRemoveCloudRules && isRulerAvailable,
- loading: dataSources[rulesSourceName]?.loading,
+ loading: isLoading || dataSources[rulesSourceName]?.loading,
};
}
diff --git a/public/app/features/alerting/unified/mockApi.ts b/public/app/features/alerting/unified/mockApi.ts
index 99e840a893d..cf20e20c109 100644
--- a/public/app/features/alerting/unified/mockApi.ts
+++ b/public/app/features/alerting/unified/mockApi.ts
@@ -3,6 +3,7 @@ import { setupServer, SetupServer } from 'msw/node';
import 'whatwg-fetch';
import { setBackendSrv } from '@grafana/runtime';
+import { PromRulesResponse, RulerRuleGroupDTO, RulerRulesConfigDTO } from 'app/types/unified-alerting-dto';
import { backendSrv } from '../../../core/services/backend_srv';
import {
@@ -124,6 +125,30 @@ export function mockApi(server: SetupServer) {
};
}
+export function mockAlertRuleApi(server: SetupServer) {
+ return {
+ prometheusRuleNamespaces: (dsName: string, response: PromRulesResponse) => {
+ server.use(
+ rest.get(`api/prometheus/${dsName}/api/v1/rules`, (req, res, ctx) =>
+ res(ctx.status(200), ctx.json(response))
+ )
+ );
+ },
+ rulerRules: (dsName: string, response: RulerRulesConfigDTO) => {
+ server.use(
+ rest.get(`/api/ruler/${dsName}/api/v1/rules`, (req, res, ctx) => res(ctx.status(200), ctx.json(response)))
+ );
+ },
+ rulerRuleGroup: (dsName: string, namespace: string, group: string, response: RulerRuleGroupDTO) => {
+ server.use(
+ rest.get(`/api/ruler/${dsName}/api/v1/rules/${namespace}/${group}`, (req, res, ctx) =>
+ res(ctx.status(200), ctx.json(response))
+ )
+ );
+ },
+ };
+}
+
// Creates a MSW server and sets up beforeAll, afterAll and beforeEach handlers for it
export function setupMswServer() {
const server = setupServer();
diff --git a/public/app/features/alerting/unified/mocks.ts b/public/app/features/alerting/unified/mocks.ts
index e3eac047bb1..fbd1222a80e 100644
--- a/public/app/features/alerting/unified/mocks.ts
+++ b/public/app/features/alerting/unified/mocks.ts
@@ -155,6 +155,20 @@ export const mockRulerRuleGroup = (partial: Partial = {}): Ru
...partial,
});
+export const promRuleFromRulerRule = (
+ rulerRule: RulerAlertingRuleDTO,
+ override?: Partial
+): AlertingRule => {
+ return mockPromAlertingRule({
+ name: rulerRule.alert,
+ query: rulerRule.expr,
+ labels: rulerRule.labels,
+ annotations: rulerRule.annotations,
+ type: PromRuleType.Alerting,
+ ...override,
+ });
+};
+
export const mockPromAlertingRule = (partial: Partial = {}): AlertingRule => {
return {
type: PromRuleType.Alerting,
@@ -176,13 +190,12 @@ export const mockPromAlertingRule = (partial: Partial = {}): Alert
};
};
-export const mockGrafanaRulerRule = (partial: Partial = {}): RulerGrafanaRuleDTO => {
+export const mockGrafanaRulerRule = (partial: Partial = {}): RulerGrafanaRuleDTO => {
return {
for: '',
annotations: {},
labels: {},
grafana_alert: {
- ...partial,
uid: '',
title: 'my rule',
namespace_uid: '',
@@ -191,6 +204,7 @@ export const mockGrafanaRulerRule = (partial: Partial = {})
no_data_state: GrafanaAlertStateDecision.NoData,
exec_err_state: GrafanaAlertStateDecision.Error,
data: [],
+ ...partial,
},
};
};
@@ -624,14 +638,14 @@ export function mockCombinedRuleNamespace(namespace: Partial) {
+export function getGrafanaRule(override?: Partial, rulerOverride?: Partial) {
return mockCombinedRule({
namespace: {
groups: [],
name: 'Grafana',
rulesSource: 'grafana',
},
- rulerRule: mockGrafanaRulerRule(),
+ rulerRule: mockGrafanaRulerRule(rulerOverride),
...override,
});
}
diff --git a/public/app/features/alerting/unified/mocks/plugins.ts b/public/app/features/alerting/unified/mocks/plugins.ts
new file mode 100644
index 00000000000..eb8a2b5eab7
--- /dev/null
+++ b/public/app/features/alerting/unified/mocks/plugins.ts
@@ -0,0 +1,14 @@
+import { rest } from 'msw';
+import { SetupServer } from 'msw/lib/node';
+
+import { PluginMeta } from '@grafana/data';
+
+import { SupportedPlugin } from '../types/pluginBridges';
+
+export function mockPluginSettings(server: SetupServer, plugin: SupportedPlugin, response?: PluginMeta) {
+ server.use(
+ rest.get(`/api/plugins/${plugin}/settings`, (_req, res, ctx) => {
+ return response ? res(ctx.status(200), ctx.json(response)) : res(ctx.status(404));
+ })
+ );
+}
diff --git a/public/app/features/alerting/unified/utils/rule-id.test.ts b/public/app/features/alerting/unified/utils/rule-id.test.ts
index dc439fff81b..cd75a0ee8a6 100644
--- a/public/app/features/alerting/unified/utils/rule-id.test.ts
+++ b/public/app/features/alerting/unified/utils/rule-id.test.ts
@@ -1,3 +1,4 @@
+import { RuleIdentifier } from 'app/types/unified-alerting';
import {
GrafanaAlertStateDecision,
GrafanaRuleDefinition,
@@ -61,16 +62,17 @@ describe('hashRulerRule', () => {
});
it('should correctly encode and decode unix-style path separators', () => {
- const identifier = {
+ const identifier: RuleIdentifier = {
ruleSourceName: 'my-datasource',
namespace: 'folder1/folder2',
groupName: 'group1/group2',
+ ruleName: 'CPU-firing',
ruleHash: 'abc123',
};
const encodedIdentifier = encodeURIComponent(stringifyIdentifier(identifier));
- expect(encodedIdentifier).toBe('pri%24my-datasource%24folder1%1Ffolder2%24group1%1Fgroup2%24abc123');
+ expect(encodedIdentifier).toBe('pri%24my-datasource%24folder1%1Ffolder2%24group1%1Fgroup2%24CPU-firing%24abc123');
expect(encodedIdentifier).not.toContain('%2F');
expect(parse(encodedIdentifier, true)).toStrictEqual(identifier);
});
@@ -80,10 +82,13 @@ describe('hashRulerRule', () => {
ruleSourceName: 'my-datasource',
namespace: 'folder1/folder2',
groupName: 'group1/group2',
+ ruleName: 'CPU-firing/burning',
ruleHash: 'abc123',
};
- expect(parse('pri%24my-datasource%24folder1%2Ffolder2%24group1%2Fgroup2%24abc123', true)).toStrictEqual(identifier);
+ expect(
+ parse('pri%24my-datasource%24folder1%2Ffolder2%24group1%2Fgroup2%24CPU-firing%2Fburning%24abc123', true)
+ ).toStrictEqual(identifier);
});
it('should correctly encode and decode windows-style path separators', () => {
@@ -91,12 +96,13 @@ describe('hashRulerRule', () => {
ruleSourceName: 'my-datasource',
namespace: 'folder1\\folder2',
groupName: 'group1\\group2',
+ ruleName: 'CPU-firing',
ruleHash: 'abc123',
};
const encodedIdentifier = encodeURIComponent(stringifyIdentifier(identifier));
- expect(encodedIdentifier).toBe('pri%24my-datasource%24folder1%1Efolder2%24group1%1Egroup2%24abc123');
+ expect(encodedIdentifier).toBe('pri%24my-datasource%24folder1%1Efolder2%24group1%1Egroup2%24CPU-firing%24abc123');
expect(parse(encodedIdentifier, true)).toStrictEqual(identifier);
});
diff --git a/public/app/features/alerting/unified/utils/rule-id.ts b/public/app/features/alerting/unified/utils/rule-id.ts
index f7292ff59b6..e209612c4e4 100644
--- a/public/app/features/alerting/unified/utils/rule-id.ts
+++ b/public/app/features/alerting/unified/utils/rule-id.ts
@@ -26,6 +26,7 @@ export function fromRulerRule(
ruleSourceName,
namespace,
groupName,
+ ruleName: isAlertingRulerRule(rule) ? rule.alert : rule.record,
rulerRuleHash: hashRulerRule(rule),
};
}
@@ -35,6 +36,7 @@ export function fromRule(ruleSourceName: string, namespace: string, groupName: s
ruleSourceName,
namespace,
groupName,
+ ruleName: rule.name,
ruleHash: hashRule(rule),
};
}
@@ -67,6 +69,7 @@ export function equal(a: RuleIdentifier, b: RuleIdentifier) {
return (
a.groupName === b.groupName &&
a.namespace === b.namespace &&
+ a.ruleName === b.ruleName &&
a.rulerRuleHash === b.rulerRuleHash &&
a.ruleSourceName === b.ruleSourceName
);
@@ -76,6 +79,7 @@ export function equal(a: RuleIdentifier, b: RuleIdentifier) {
return (
a.groupName === b.groupName &&
a.namespace === b.namespace &&
+ a.ruleName === b.ruleName &&
a.ruleHash === b.ruleHash &&
a.ruleSourceName === b.ruleSourceName
);
@@ -118,15 +122,17 @@ export function parse(value: string, decodeFromUri = false): RuleIdentifier {
return { uid: value, ruleSourceName: 'grafana' };
}
- if (parts.length === 5) {
- const [prefix, ruleSourceName, namespace, groupName, hash] = parts.map(unescapeDollars).map(unescapePathSeparators);
+ if (parts.length === 6) {
+ const [prefix, ruleSourceName, namespace, groupName, ruleName, hash] = parts
+ .map(unescapeDollars)
+ .map(unescapePathSeparators);
if (prefix === cloudRuleIdentifierPrefix) {
- return { ruleSourceName, namespace, groupName, rulerRuleHash: hash };
+ return { ruleSourceName, namespace, groupName, ruleName, rulerRuleHash: hash };
}
if (prefix === prometheusRuleIdentifierPrefix) {
- return { ruleSourceName, namespace, groupName, ruleHash: hash };
+ return { ruleSourceName, namespace, groupName, ruleName, ruleHash: hash };
}
}
@@ -156,6 +162,7 @@ export function stringifyIdentifier(identifier: RuleIdentifier): string {
identifier.ruleSourceName,
identifier.namespace,
identifier.groupName,
+ identifier.ruleName,
identifier.rulerRuleHash,
]
.map(String)
@@ -169,6 +176,7 @@ export function stringifyIdentifier(identifier: RuleIdentifier): string {
identifier.ruleSourceName,
identifier.namespace,
identifier.groupName,
+ identifier.ruleName,
identifier.ruleHash,
]
.map(String)
diff --git a/public/app/types/unified-alerting-dto.ts b/public/app/types/unified-alerting-dto.ts
index cdd0de5efd7..b3af0806f23 100644
--- a/public/app/types/unified-alerting-dto.ts
+++ b/public/app/types/unified-alerting-dto.ts
@@ -113,7 +113,7 @@ interface PromRuleDTOBase {
}
export interface PromAlertingRuleDTO extends PromRuleDTOBase {
- alerts: Array<{
+ alerts?: Array<{
labels: Labels;
annotations: Annotations;
state: Exclude;
diff --git a/public/app/types/unified-alerting.ts b/public/app/types/unified-alerting.ts
index 7a9525f3a11..a553b6dff92 100644
--- a/public/app/types/unified-alerting.ts
+++ b/public/app/types/unified-alerting.ts
@@ -18,7 +18,7 @@ export type Alert = {
activeAt: string;
annotations: { [key: string]: string };
labels: { [key: string]: string };
- state: PromAlertingRuleState | GrafanaAlertStateWithReason;
+ state: Exclude;
value: string;
};
@@ -154,6 +154,7 @@ export interface CloudRuleIdentifier {
ruleSourceName: string;
namespace: string;
groupName: string;
+ ruleName: string;
rulerRuleHash: string;
}
export interface GrafanaRuleIdentifier {
@@ -166,6 +167,7 @@ export interface PrometheusRuleIdentifier {
ruleSourceName: string;
namespace: string;
groupName: string;
+ ruleName: string;
ruleHash: string;
}