Alerting: Optimize rule details page data fetching (#72977)

This commit is contained in:
Konrad Lalik 2023-08-08 20:36:38 +02:00 committed by GitHub
parent 15ac12637d
commit df3d10606d
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
20 changed files with 738 additions and 185 deletions

View File

@ -217,6 +217,7 @@ describe('CloneRuleEditor', function () {
ruleSourceName: 'my-prom-ds',
namespace: 'namespace-one',
groupName: 'group1',
ruleName: 'First Ruler Rule',
rulerRuleHash: hashRulerRule(originRule),
}}
/>,

View File

@ -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[] = [

View File

@ -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 <Redirect to="/notfound" />;
}
if (error) {
return (
<RuleViewerLayout title={pageTitle}>
<Alert title={`Failed to load rules from ${sourceName}`}>
<details className={styles.errorMessage}>
{error.message}
<br />
{!!error?.stack && error.stack}
</details>
{isFetchError(error) && (
<details className={styles.errorMessage}>
{error.message}
<br />
{/* {!!error?.stack && error.stack} */}
</details>
)}
</Alert>
</RuleViewerLayout>
);
}
if (loading || !dispatched || !Array.isArray(rules)) {
if (loading) {
return (
<RuleViewerLayout title={pageTitle}>
<LoadingPlaceholder text="Loading rule..." />
@ -60,10 +80,6 @@ export function RedirectToRuleViewer(): JSX.Element | null {
);
}
if (!name || !sourceName) {
return <Redirect to="/notfound" />;
}
const rulesSource = getRulesSourceByName(sourceName);
if (!rulesSource) {

View File

@ -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<string, string | undefined> = {};
// 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<string, { uid: string; format: 'yaml' | 'json' }>({
query: ({ uid, format }) => ({ url: getProvisioningUrl(uid, format) }),
}),

View File

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

View File

@ -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(
<TestProvider>
<RuleViewer {...mockRoute} />
</TestProvider>
);
});
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(
<TestProvider>
<RuleViewer {...mockRoute(ruleId)} />
</TestProvider>
);
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<typeof useCombinedRule>;
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<typeof useCombinedRule>;
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<typeof useCombinedRule>;
let mockCombinedRule = jest.fn();
beforeEach(() => {
mockCombinedRule = jest.mocked(useCombinedRule);
// mockCombinedRule = jest.mocked(useCombinedRule);
});
afterEach(() => {

View File

@ -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 (
<Alert title={errorTitle}>
<details className={styles.errorMessage}>
{error?.message ?? errorMessage}
{isFetchError(error) ? error.message : errorMessage}
<br />
{!!error?.stack && error.stack}
{/* TODO Fix typescript */}
{/* {error && error?.stack} */}
</details>
</Alert>
);

View File

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

View File

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

View File

@ -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<CombinedRule> {
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 };
}

View File

@ -84,7 +84,7 @@ export function useCombinedRuleNamespaces(
}
const namespaces: Record<string, CombinedRuleNamespace> = {};
// 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<string, CombinedRuleNamespace> = {};
// first get all the ruler rules from the data source
Object.entries(rulerRules || {}).forEach(([namespaceName, groups]) => {
const namespace: CombinedRuleNamespace = {
rulesSource,
name: namespaceName,
groups: [],
};
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) => {

View File

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

View File

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

View File

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

View File

@ -155,6 +155,20 @@ export const mockRulerRuleGroup = (partial: Partial<RulerRuleGroupDTO> = {}): Ru
...partial,
});
export const promRuleFromRulerRule = (
rulerRule: RulerAlertingRuleDTO,
override?: Partial<AlertingRule>
): 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> = {}): AlertingRule => {
return {
type: PromRuleType.Alerting,
@ -176,13 +190,12 @@ export const mockPromAlertingRule = (partial: Partial<AlertingRule> = {}): Alert
};
};
export const mockGrafanaRulerRule = (partial: Partial<RulerGrafanaRuleDTO> = {}): RulerGrafanaRuleDTO => {
export const mockGrafanaRulerRule = (partial: Partial<GrafanaRuleDefinition> = {}): RulerGrafanaRuleDTO => {
return {
for: '',
annotations: {},
labels: {},
grafana_alert: {
...partial,
uid: '',
title: 'my rule',
namespace_uid: '',
@ -191,6 +204,7 @@ export const mockGrafanaRulerRule = (partial: Partial<RulerGrafanaRuleDTO> = {})
no_data_state: GrafanaAlertStateDecision.NoData,
exec_err_state: GrafanaAlertStateDecision.Error,
data: [],
...partial,
},
};
};
@ -624,14 +638,14 @@ export function mockCombinedRuleNamespace(namespace: Partial<CombinedRuleNamespa
};
}
export function getGrafanaRule(override?: Partial<CombinedRule>) {
export function getGrafanaRule(override?: Partial<CombinedRule>, rulerOverride?: Partial<GrafanaRuleDefinition>) {
return mockCombinedRule({
namespace: {
groups: [],
name: 'Grafana',
rulesSource: 'grafana',
},
rulerRule: mockGrafanaRulerRule(),
rulerRule: mockGrafanaRulerRule(rulerOverride),
...override,
});
}

View File

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

View File

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

View File

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

View File

@ -113,7 +113,7 @@ interface PromRuleDTOBase {
}
export interface PromAlertingRuleDTO extends PromRuleDTOBase {
alerts: Array<{
alerts?: Array<{
labels: Labels;
annotations: Annotations;
state: Exclude<PromAlertingRuleState | GrafanaAlertStateWithReason, PromAlertingRuleState.Inactive>;

View File

@ -18,7 +18,7 @@ export type Alert = {
activeAt: string;
annotations: { [key: string]: string };
labels: { [key: string]: string };
state: PromAlertingRuleState | GrafanaAlertStateWithReason;
state: Exclude<PromAlertingRuleState | GrafanaAlertStateWithReason, PromAlertingRuleState.Inactive>;
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;
}