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