Alerting: Add alert rule cloning action (#59200)

Co-authored-by: Gilles De Mey <gilles.de.mey@gmail.com>
This commit is contained in:
Konrad Lalik 2022-12-01 16:21:54 +01:00 committed by GitHub
parent b3284a8330
commit d2c129fbac
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
15 changed files with 566 additions and 107 deletions

View File

@ -0,0 +1,284 @@
import { render, waitFor, waitForElementToBeRemoved } from '@testing-library/react';
import { setupServer } from 'msw/node';
import React from 'react';
import { FormProvider, useForm } from 'react-hook-form';
import { Provider } from 'react-redux';
import { MemoryRouter } from 'react-router-dom';
import { byRole, byTestId, byText } from 'testing-library-selector';
import { selectors } from '@grafana/e2e-selectors/src';
import { config, setBackendSrv, setDataSourceSrv } from '@grafana/runtime';
import { backendSrv } from 'app/core/services/backend_srv';
import 'whatwg-fetch';
import { RuleWithLocation } from 'app/types/unified-alerting';
import { RulerGrafanaRuleDTO } from '../../../types/unified-alerting-dto';
import { CloneRuleEditor, generateCopiedRuleTitle } from './CloneRuleEditor';
import { ExpressionEditorProps } from './components/rule-editor/ExpressionEditor';
import { mockDataSource, MockDataSourceSrv, mockRulerAlertingRule, mockRulerGrafanaRule, mockStore } from './mocks';
import { mockSearchApiResponse } from './mocks/grafanaApi';
import { mockRulerRulesApiResponse, mockRulerRulesGroupApiResponse } from './mocks/rulerApi';
import { RuleFormValues } from './types/rule-form';
import { Annotation } from './utils/constants';
import { getDefaultFormValues } from './utils/rule-form';
import { hashRulerRule } from './utils/rule-id';
jest.mock('./components/rule-editor/ExpressionEditor', () => ({
// eslint-disable-next-line react/display-name
ExpressionEditor: ({ value, onChange }: ExpressionEditorProps) => (
<input value={value} data-testid="expr" onChange={(e) => onChange(e.target.value)} />
),
}));
const server = setupServer();
beforeAll(() => {
setBackendSrv(backendSrv);
server.listen({ onUnhandledRequest: 'error' });
});
beforeEach(() => {
server.resetHandlers();
});
afterAll(() => {
server.close();
});
const ui = {
inputs: {
name: byRole('textbox', { name: /rule name name for the alert rule\./i }),
expr: byTestId('expr'),
folderContainer: byTestId(selectors.components.FolderPicker.containerV2),
namespace: byTestId('namespace-picker'),
group: byTestId('group-picker'),
annotationValue: (idx: number) => byTestId(`annotation-value-${idx}`),
labelValue: (idx: number) => byTestId(`label-value-${idx}`),
},
loadingIndicator: byText('Loading the rule'),
loadingGroupIndicator: byText('Loading...'),
};
function getProvidersWrapper() {
return function Wrapper({ children }: React.PropsWithChildren<{}>) {
const store = mockStore((store) => {
store.unifiedAlerting.dataSources['grafana'] = {
loading: false,
dispatched: true,
result: {
id: 'grafana',
name: 'grafana',
rulerConfig: {
dataSourceName: 'grafana',
apiVersion: 'legacy',
},
},
};
store.unifiedAlerting.dataSources['my-prom-ds'] = {
loading: false,
dispatched: true,
result: {
id: 'my-prom-ds',
name: 'my-prom-ds',
rulerConfig: {
dataSourceName: 'my-prom-ds',
apiVersion: 'config',
},
},
};
});
const formApi = useForm<RuleFormValues>({ defaultValues: getDefaultFormValues() });
return (
<MemoryRouter>
<Provider store={store}>
<FormProvider {...formApi}>{children}</FormProvider>
</Provider>
</MemoryRouter>
);
};
}
describe('CloneRuleEditor', function () {
describe('Grafana-managed rules', function () {
it('should populate form values from the existing alert rule', async function () {
setDataSourceSrv(new MockDataSourceSrv({}));
const originRule: RulerGrafanaRuleDTO = mockRulerGrafanaRule(
{
for: '1m',
labels: { severity: 'critical', region: 'nasa' },
annotations: { [Annotation.summary]: 'This is a very important alert rule' },
},
{ uid: 'grafana-rule-1', title: 'First Grafana Rule', data: [] }
);
mockRulerRulesApiResponse(server, 'grafana', {
'folder-one': [{ name: 'group1', interval: '20s', rules: [originRule] }],
});
mockSearchApiResponse(server, []);
render(<CloneRuleEditor sourceRuleId={{ uid: 'grafana-rule-1', ruleSourceName: 'grafana' }} />, {
wrapper: getProvidersWrapper(),
});
await waitForElementToBeRemoved(ui.loadingIndicator.query());
await waitForElementToBeRemoved(ui.loadingGroupIndicator.query(), { container: ui.inputs.group.get() });
await waitFor(() => {
expect(ui.inputs.name.get()).toHaveValue('First Grafana Rule (copy)');
expect(ui.inputs.folderContainer.get()).toHaveTextContent('folder-one');
expect(ui.inputs.group.get()).toHaveTextContent('group1');
expect(ui.inputs.labelValue(0).get()).toHaveTextContent('critical');
expect(ui.inputs.labelValue(1).get()).toHaveTextContent('nasa');
expect(ui.inputs.annotationValue(0).get()).toHaveTextContent('This is a very important alert rule');
});
});
});
describe('Cloud rules', function () {
it('should populate form values from the existing alert rule', async function () {
const dsSettings = mockDataSource({
name: 'my-prom-ds',
uid: 'my-prom-ds',
});
config.datasources = {
'my-prom-ds': dsSettings,
};
setDataSourceSrv(new MockDataSourceSrv({ 'my-prom-ds': dsSettings }));
const originRule = mockRulerAlertingRule({
for: '1m',
alert: 'First Ruler Rule',
expr: 'vector(1) > 0',
labels: { severity: 'critical', region: 'nasa' },
annotations: { [Annotation.summary]: 'This is a very important alert rule' },
});
mockRulerRulesApiResponse(server, 'my-prom-ds', {
'namespace-one': [{ name: 'group1', interval: '20s', rules: [originRule] }],
});
mockRulerRulesGroupApiResponse(server, 'my-prom-ds', 'namespace-one', 'group1', {
name: 'group1',
interval: '20s',
rules: [originRule],
});
mockSearchApiResponse(server, []);
render(
<CloneRuleEditor
sourceRuleId={{
uid: 'prom-rule-1',
ruleSourceName: 'my-prom-ds',
namespace: 'namespace-one',
groupName: 'group1',
rulerRuleHash: hashRulerRule(originRule),
}}
/>,
{
wrapper: getProvidersWrapper(),
}
);
await waitForElementToBeRemoved(ui.loadingIndicator.query());
await waitFor(() => {
expect(ui.inputs.name.get()).toHaveValue('First Ruler Rule (copy)');
expect(ui.inputs.expr.get()).toHaveValue('vector(1) > 0');
expect(ui.inputs.namespace.get()).toHaveTextContent('namespace-one');
expect(ui.inputs.group.get()).toHaveTextContent('group1');
expect(ui.inputs.labelValue(0).get()).toHaveTextContent('critical');
expect(ui.inputs.labelValue(1).get()).toHaveTextContent('nasa');
expect(ui.inputs.annotationValue(0).get()).toHaveTextContent('This is a very important alert rule');
});
});
});
});
describe('generateCopiedRuleTitle', () => {
it('should generate copy name', () => {
const fileName = 'my file';
const expectedDuplicateName = 'my file (copy)';
const ruleWithLocation = {
rule: {
grafana_alert: {
title: fileName,
},
},
group: {
rules: [],
},
} as unknown as RuleWithLocation;
expect(generateCopiedRuleTitle(ruleWithLocation)).toEqual(expectedDuplicateName);
});
it('should generate copy name and number from original file', () => {
const fileName = 'my file';
const duplicatedName = 'my file (copy)';
const expectedDuplicateName = 'my file (copy 2)';
const ruleWithLocation = {
rule: {
grafana_alert: {
title: fileName,
},
},
group: {
rules: [{ grafana_alert: { title: fileName } }, { grafana_alert: { title: duplicatedName } }],
},
} as RuleWithLocation;
expect(generateCopiedRuleTitle(ruleWithLocation)).toEqual(expectedDuplicateName);
});
it('should generate copy name and number from duplicated file', () => {
const fileName = 'my file (copy)';
const duplicatedName = 'my file (copy 2)';
const expectedDuplicateName = 'my file (copy 3)';
const ruleWithLocation = {
rule: {
grafana_alert: {
title: fileName,
},
},
group: {
rules: [{ grafana_alert: { title: fileName } }, { grafana_alert: { title: duplicatedName } }],
},
} as RuleWithLocation;
expect(generateCopiedRuleTitle(ruleWithLocation)).toEqual(expectedDuplicateName);
});
it('should generate copy name and number from duplicated file in gap', () => {
const fileName = 'my file (copy)';
const duplicatedName = 'my file (copy 3)';
const expectedDuplicateName = 'my file (copy 2)';
const ruleWithLocation = {
rule: {
grafana_alert: {
title: fileName,
},
},
group: {
rules: [
{
grafana_alert: { title: fileName },
},
{ grafana_alert: { title: duplicatedName } },
],
},
} as RuleWithLocation;
expect(generateCopiedRuleTitle(ruleWithLocation)).toEqual(expectedDuplicateName);
});
});

View File

@ -0,0 +1,87 @@
import { cloneDeep } from 'lodash';
import React from 'react';
import { useAsync } from 'react-use';
import { locationService } from '@grafana/runtime/src';
import { Alert, LoadingPlaceholder } from '@grafana/ui/src';
import { useDispatch } from '../../../types';
import { RuleIdentifier, RuleWithLocation } from '../../../types/unified-alerting';
import { RulerRuleDTO } from '../../../types/unified-alerting-dto';
import { AlertRuleForm } from './components/rule-editor/AlertRuleForm';
import { fetchEditableRuleAction } from './state/actions';
import { rulerRuleToFormValues } from './utils/rule-form';
import { getRuleName, isAlertingRulerRule, isGrafanaRulerRule, isRecordingRulerRule } from './utils/rules';
import { createUrl } from './utils/url';
export function CloneRuleEditor({ sourceRuleId }: { sourceRuleId: RuleIdentifier }) {
const dispatch = useDispatch();
const {
loading,
value: rule,
error,
} = useAsync(() => dispatch(fetchEditableRuleAction(sourceRuleId)).unwrap(), [sourceRuleId]);
if (loading) {
return <LoadingPlaceholder text="Loading the rule" />;
}
if (rule) {
const ruleClone = cloneDeep(rule);
changeRuleName(ruleClone.rule, generateCopiedRuleTitle(ruleClone));
const formPrefill = rulerRuleToFormValues(ruleClone);
// Provisioned alert rules have provisioned alert group which cannot be used in UI
if (isGrafanaRulerRule(rule.rule) && Boolean(rule.rule.grafana_alert.provenance)) {
formPrefill.group = '';
}
return <AlertRuleForm prefill={formPrefill} />;
}
if (error) {
return (
<Alert title="Error" severity="error">
{error.message}
</Alert>
);
}
return (
<Alert
title="Cannot duplicate. The rule does not exist"
buttonContent="Go back to alert list"
onRemove={() => locationService.replace(createUrl('/alerting/list'))}
/>
);
}
export function generateCopiedRuleTitle(originRuleWithLocation: RuleWithLocation): string {
const originName = getRuleName(originRuleWithLocation.rule);
const existingRulesNames = originRuleWithLocation.group.rules.map(getRuleName);
const nonDuplicateName = originName.replace(/\(copy( [0-9]+)?\)$/, '').trim();
let newName = `${nonDuplicateName} (copy)`;
for (let i = 2; existingRulesNames.includes(newName); i++) {
newName = `${nonDuplicateName} (copy ${i})`;
}
return newName;
}
function changeRuleName(rule: RulerRuleDTO, newName: string) {
if (isGrafanaRulerRule(rule)) {
rule.grafana_alert.title = newName;
}
if (isAlertingRulerRule(rule)) {
rule.alert = newName;
}
if (isRecordingRulerRule(rule)) {
rule.record = newName;
}
}

View File

@ -7,9 +7,11 @@ import { GrafanaRouteComponentProps } from 'app/core/navigation/types';
import { useDispatch } from 'app/types'; import { useDispatch } from 'app/types';
import { AlertWarning } from './AlertWarning'; import { AlertWarning } from './AlertWarning';
import { CloneRuleEditor } from './CloneRuleEditor';
import { ExistingRuleEditor } from './ExistingRuleEditor'; import { ExistingRuleEditor } from './ExistingRuleEditor';
import { AlertingPageWrapper } from './components/AlertingPageWrapper'; import { AlertingPageWrapper } from './components/AlertingPageWrapper';
import { AlertRuleForm } from './components/rule-editor/AlertRuleForm'; import { AlertRuleForm } from './components/rule-editor/AlertRuleForm';
import { useURLSearchParams } from './hooks/useURLSearchParams';
import { fetchAllPromBuildInfoAction } from './state/actions'; import { fetchAllPromBuildInfoAction } from './state/actions';
import { useRulesAccess } from './utils/accessControlHooks'; import { useRulesAccess } from './utils/accessControlHooks';
import * as ruleId from './utils/rule-id'; import * as ruleId from './utils/rule-id';
@ -33,9 +35,14 @@ const getPageNav = (state: 'edit' | 'add') => {
const RuleEditor = ({ match }: RuleEditorProps) => { const RuleEditor = ({ match }: RuleEditorProps) => {
const dispatch = useDispatch(); const dispatch = useDispatch();
const [searchParams] = useURLSearchParams();
const { id } = match.params; const { id } = match.params;
const identifier = ruleId.tryParse(id, true); const identifier = ruleId.tryParse(id, true);
const copyFromId = searchParams.get('copyFrom') ?? undefined;
const copyFromIdentifier = ruleId.tryParse(copyFromId);
const { loading = true } = useAsync(async () => { const { loading = true } = useAsync(async () => {
await dispatch(fetchAllPromBuildInfoAction()); await dispatch(fetchAllPromBuildInfoAction());
}, [dispatch]); }, [dispatch]);
@ -59,6 +66,10 @@ const RuleEditor = ({ match }: RuleEditorProps) => {
return <ExistingRuleEditor key={id} identifier={identifier} />; return <ExistingRuleEditor key={id} identifier={identifier} />;
} }
if (copyFromIdentifier) {
return <CloneRuleEditor sourceRuleId={copyFromIdentifier} />;
}
return <AlertRuleForm />; return <AlertRuleForm />;
}; };

View File

@ -69,9 +69,10 @@ export const MINUTE = '1m';
type Props = { type Props = {
existing?: RuleWithLocation; existing?: RuleWithLocation;
prefill?: Partial<RuleFormValues>; // Existing implies we modify existing rule. Prefill only provides default form values
}; };
export const AlertRuleForm: FC<Props> = ({ existing }) => { export const AlertRuleForm: FC<Props> = ({ existing, prefill }) => {
const styles = useStyles2(getStyles); const styles = useStyles2(getStyles);
const dispatch = useDispatch(); const dispatch = useDispatch();
const notifyApp = useAppNotification(); const notifyApp = useAppNotification();
@ -86,6 +87,14 @@ export const AlertRuleForm: FC<Props> = ({ existing }) => {
if (existing) { if (existing) {
return rulerRuleToFormValues(existing); return rulerRuleToFormValues(existing);
} }
if (prefill) {
return {
...getDefaultFormValues(),
...prefill,
};
}
return { return {
...getDefaultFormValues(), ...getDefaultFormValues(),
queries: getDefaultQueries(), queries: getDefaultQueries(),
@ -94,7 +103,7 @@ export const AlertRuleForm: FC<Props> = ({ existing }) => {
type: RuleFormType.grafana, type: RuleFormType.grafana,
evaluateEvery: evaluateEvery, evaluateEvery: evaluateEvery,
}; };
}, [existing, queryParams, evaluateEvery]); }, [existing, prefill, queryParams, evaluateEvery]);
const formAPI = useForm<RuleFormValues>({ const formAPI = useForm<RuleFormValues>({
mode: 'onSubmit', mode: 'onSubmit',

View File

@ -13,6 +13,7 @@ import { backendSrv } from 'app/core/services/backend_srv';
import { DashboardDTO } from '../../../../../types'; import { DashboardDTO } from '../../../../../types';
import { DashboardSearchItem, DashboardSearchItemType } from '../../../../search/types'; import { DashboardSearchItem, DashboardSearchItemType } from '../../../../search/types';
import { mockStore } from '../../mocks'; import { mockStore } from '../../mocks';
import { mockSearchApiResponse } from '../../mocks/grafanaApi';
import { RuleFormValues } from '../../types/rule-form'; import { RuleFormValues } from '../../types/rule-form';
import { Annotation } from '../../utils/constants'; import { Annotation } from '../../utils/constants';
import { getDefaultFormValues } from '../../utils/rule-form'; import { getDefaultFormValues } from '../../utils/rule-form';
@ -83,7 +84,7 @@ describe('AnnotationsField', function () {
describe('Dashboard and panel picker', function () { describe('Dashboard and panel picker', function () {
it('should display dashboard and panel selector when select button clicked', async function () { it('should display dashboard and panel selector when select button clicked', async function () {
mockSearchResponse([]); mockSearchApiResponse(server, []);
const user = userEvent.setup(); const user = userEvent.setup();
@ -96,7 +97,7 @@ describe('AnnotationsField', function () {
}); });
it('should enable Confirm button only when dashboard and panel selected', async function () { it('should enable Confirm button only when dashboard and panel selected', async function () {
mockSearchResponse([ mockSearchApiResponse(server, [
mockDashboardSearchItem({ title: 'My dashboard', uid: 'dash-test-uid', type: DashboardSearchItemType.DashDB }), mockDashboardSearchItem({ title: 'My dashboard', uid: 'dash-test-uid', type: DashboardSearchItemType.DashDB }),
]); ]);
@ -126,7 +127,7 @@ describe('AnnotationsField', function () {
}); });
it('should add selected dashboard and panel as annotations', async function () { it('should add selected dashboard and panel as annotations', async function () {
mockSearchResponse([ mockSearchApiResponse(server, [
mockDashboardSearchItem({ title: 'My dashboard', uid: 'dash-test-uid', type: DashboardSearchItemType.DashDB }), mockDashboardSearchItem({ title: 'My dashboard', uid: 'dash-test-uid', type: DashboardSearchItemType.DashDB }),
]); ]);
@ -170,7 +171,7 @@ describe('AnnotationsField', function () {
// this test _should_ work in theory but something is stopping the 'onClick' function on the dashboard item // this test _should_ work in theory but something is stopping the 'onClick' function on the dashboard item
// to trigger "handleDashboardChange" skipping it for now but has been manually tested. // to trigger "handleDashboardChange" skipping it for now but has been manually tested.
it.skip('should update existing dashboard and panel identifies', async function () { it.skip('should update existing dashboard and panel identifies', async function () {
mockSearchResponse([ mockSearchApiResponse(server, [
mockDashboardSearchItem({ title: 'My dashboard', uid: 'dash-test-uid', type: DashboardSearchItemType.DashDB }), mockDashboardSearchItem({ title: 'My dashboard', uid: 'dash-test-uid', type: DashboardSearchItemType.DashDB }),
mockDashboardSearchItem({ mockDashboardSearchItem({
title: 'My other dashboard', title: 'My other dashboard',
@ -236,10 +237,6 @@ describe('AnnotationsField', function () {
}); });
}); });
function mockSearchResponse(searchResult: DashboardSearchItem[]) {
server.use(rest.get('/api/search', (req, res, ctx) => res(ctx.json<DashboardSearchItem[]>(searchResult))));
}
function mockGetDashboardResponse(dashboard: DashboardDTO) { function mockGetDashboardResponse(dashboard: DashboardDTO) {
server.use( server.use(
rest.get(`/api/dashboards/uid/${dashboard.dashboard.uid}`, (req, res, ctx) => rest.get(`/api/dashboards/uid/${dashboard.dashboard.uid}`, (req, res, ctx) =>

View File

@ -15,6 +15,7 @@ import { useUnifiedAlertingSelector } from '../../hooks/useUnifiedAlertingSelect
import { fetchRulerRulesIfNotFetchedYet } from '../../state/actions'; import { fetchRulerRulesIfNotFetchedYet } from '../../state/actions';
import { RuleForm, RuleFormValues } from '../../types/rule-form'; import { RuleForm, RuleFormValues } from '../../types/rule-form';
import { GRAFANA_RULES_SOURCE_NAME } from '../../utils/datasource'; import { GRAFANA_RULES_SOURCE_NAME } from '../../utils/datasource';
import { isGrafanaRulerRule } from '../../utils/rules';
import { InfoIcon } from '../InfoIcon'; import { InfoIcon } from '../InfoIcon';
import { getIntervalForGroup } from './GrafanaEvaluationBehavior'; import { getIntervalForGroup } from './GrafanaEvaluationBehavior';
@ -27,7 +28,13 @@ const useGetGroups = (groupfoldersForGrafana: RulerRulesConfigDTO | null | undef
const groupsForFolderResult: Array<RulerRuleGroupDTO<RulerRuleDTO>> = groupfoldersForGrafana const groupsForFolderResult: Array<RulerRuleGroupDTO<RulerRuleDTO>> = groupfoldersForGrafana
? groupfoldersForGrafana[folderName] ?? [] ? groupfoldersForGrafana[folderName] ?? []
: []; : [];
return groupsForFolderResult.map((group) => group.name);
const folderGroups = groupsForFolderResult.map((group) => ({
name: group.name,
provisioned: group.rules.some((rule) => isGrafanaRulerRule(rule) && Boolean(rule.grafana_alert.provenance)),
}));
return folderGroups.filter((group) => !group.provisioned).map((group) => group.name);
}, [groupfoldersForGrafana, folderName]); }, [groupfoldersForGrafana, folderName]);
return groupOptions; return groupOptions;
@ -40,13 +47,13 @@ interface FolderAndGroupProps {
initialFolder: RuleForm | null; initialFolder: RuleForm | null;
} }
export const useGetGroupOptionsFromFolder = (folderTilte: string) => { export const useGetGroupOptionsFromFolder = (folderTitle: string) => {
const rulerRuleRequests = useUnifiedAlertingSelector((state) => state.rulerRules); const rulerRuleRequests = useUnifiedAlertingSelector((state) => state.rulerRules);
const groupfoldersForGrafana = rulerRuleRequests[GRAFANA_RULES_SOURCE_NAME]; const groupfoldersForGrafana = rulerRuleRequests[GRAFANA_RULES_SOURCE_NAME];
const groupOptions: Array<SelectableValue<string>> = mapGroupsToOptions( const groupOptions: Array<SelectableValue<string>> = mapGroupsToOptions(
useGetGroups(groupfoldersForGrafana?.result, folderTilte) useGetGroups(groupfoldersForGrafana?.result, folderTitle)
); );
const groupsForFolder = groupfoldersForGrafana?.result; const groupsForFolder = groupfoldersForGrafana?.result;
return { groupOptions, groupsForFolder, loading: groupfoldersForGrafana?.loading }; return { groupOptions, groupsForFolder, loading: groupfoldersForGrafana?.loading };

View File

@ -2,20 +2,11 @@ import { css } from '@emotion/css';
import React, { FC, useState } from 'react'; import React, { FC, useState } from 'react';
import { useLocation } from 'react-router-dom'; import { useLocation } from 'react-router-dom';
import { GrafanaTheme2, urlUtil } from '@grafana/data'; import { GrafanaTheme2 } from '@grafana/data';
import { config } from '@grafana/runtime'; import { Stack } from '@grafana/experimental';
import { import { config, locationService } from '@grafana/runtime';
Button, import { Button, ClipboardButton, ConfirmModal, LinkButton, Tooltip, useStyles2 } from '@grafana/ui';
ClipboardButton,
ConfirmModal,
HorizontalGroup,
LinkButton,
Tooltip,
useStyles2,
useTheme2,
} from '@grafana/ui';
import { useAppNotification } from 'app/core/copy/appNotification'; import { useAppNotification } from 'app/core/copy/appNotification';
import { useMediaQueryChange } from 'app/core/hooks/useMediaQueryChange';
import { useDispatch } from 'app/types'; import { useDispatch } from 'app/types';
import { CombinedRule, RulesSource } from 'app/types/unified-alerting'; import { CombinedRule, RulesSource } from 'app/types/unified-alerting';
@ -25,6 +16,7 @@ import { getRulesSourceName, isCloudRulesSource } from '../../utils/datasource';
import { createViewLink } from '../../utils/misc'; import { createViewLink } from '../../utils/misc';
import * as ruleId from '../../utils/rule-id'; import * as ruleId from '../../utils/rule-id';
import { isFederatedRuleGroup, isGrafanaRulerRule } from '../../utils/rules'; import { isFederatedRuleGroup, isGrafanaRulerRule } from '../../utils/rules';
import { createUrl } from '../../utils/url';
export const matchesWidth = (width: number) => window.matchMedia(`(max-width: ${width}px)`).matches; export const matchesWidth = (width: number) => window.matchMedia(`(max-width: ${width}px)`).matches;
@ -32,25 +24,6 @@ interface Props {
rule: CombinedRule; rule: CombinedRule;
rulesSource: RulesSource; rulesSource: RulesSource;
} }
function DontShowIfSmallDevice({ children }: { children: JSX.Element | string }) {
const theme = useTheme2();
const smBreakpoint = theme.breakpoints.values.xxl;
const [isSmallScreen, setIsSmallScreen] = useState(matchesWidth(smBreakpoint));
const style = useStyles2(getStyles);
useMediaQueryChange({
breakpoint: smBreakpoint,
onChange: (e) => {
setIsSmallScreen(e.matches);
},
});
if (isSmallScreen) {
return null;
} else {
return <div className={style.buttonText}>{children}</div>;
}
}
export const RuleActionsButtons: FC<Props> = ({ rule, rulesSource }) => { export const RuleActionsButtons: FC<Props> = ({ rule, rulesSource }) => {
const dispatch = useDispatch(); const dispatch = useDispatch();
@ -59,6 +32,7 @@ export const RuleActionsButtons: FC<Props> = ({ rule, rulesSource }) => {
const style = useStyles2(getStyles); const style = useStyles2(getStyles);
const { namespace, group, rulerRule } = rule; const { namespace, group, rulerRule } = rule;
const [ruleToDelete, setRuleToDelete] = useState<CombinedRule>(); const [ruleToDelete, setRuleToDelete] = useState<CombinedRule>();
const [provRuleCloneUrl, setProvRuleCloneUrl] = useState<string | undefined>(undefined);
const rulesSourceName = getRulesSourceName(rulesSource); const rulesSourceName = getRulesSourceName(rulesSource);
@ -96,56 +70,79 @@ export const RuleActionsButtons: FC<Props> = ({ rule, rulesSource }) => {
return window.location.href.split('?')[0]; return window.location.href.split('?')[0];
}; };
const sourceName = getRulesSourceName(rulesSource);
if (!isViewMode) { if (!isViewMode) {
buttons.push( buttons.push(
<Tooltip placement="top" content={'View'}> <Tooltip placement="top" content={'View'}>
<LinkButton <LinkButton
className={style.button} className={style.button}
size="xs" title="View"
size="sm"
key="view" key="view"
variant="secondary" variant="secondary"
icon="eye" icon="eye"
href={createViewLink(rulesSource, rule, returnTo)} href={createViewLink(rulesSource, rule, returnTo)}
> ></LinkButton>
<DontShowIfSmallDevice>View</DontShowIfSmallDevice>
</LinkButton>
</Tooltip> </Tooltip>
); );
} }
if (isEditable && rulerRule && !isFederated && !isProvisioned) { if (isEditable && rulerRule && !isFederated) {
const sourceName = getRulesSourceName(rulesSource);
const identifier = ruleId.fromRulerRule(sourceName, namespace.name, group.name, rulerRule); const identifier = ruleId.fromRulerRule(sourceName, namespace.name, group.name, rulerRule);
const editURL = urlUtil.renderUrl( if (!isProvisioned) {
`${config.appSubUrl}/alerting/${encodeURIComponent(ruleId.stringifyIdentifier(identifier))}/edit`, const editURL = createUrl(`/alerting/${encodeURIComponent(ruleId.stringifyIdentifier(identifier))}/edit`, {
{
returnTo, returnTo,
} });
);
if (isViewMode) {
buttons.push(
<ClipboardButton
key="copy"
icon="copy"
onClipboardError={(copiedText) => {
notifyApp.error('Error while copying URL', copiedText);
}}
className={style.button}
size="sm"
getText={buildShareUrl}
>
Copy link to rule
</ClipboardButton>
);
}
if (isViewMode) {
buttons.push( buttons.push(
<ClipboardButton <Tooltip placement="top" content={'Edit'}>
key="copy" <LinkButton
icon="copy" title="Edit"
onClipboardError={(copiedText) => { className={style.button}
notifyApp.error('Error while copying URL', copiedText); size="sm"
}} key="edit"
className={style.button} variant="secondary"
size="sm" icon="pen"
getText={buildShareUrl} href={editURL}
> />
Copy link to rule </Tooltip>
</ClipboardButton>
); );
} }
const cloneUrl = createUrl('/alerting/new', { copyFrom: ruleId.stringifyIdentifier(identifier) });
// For provisioned rules an additional confirmation step is required
// Users have to be aware that the cloned rule will NOT be marked as provisioned
buttons.push( buttons.push(
<Tooltip placement="top" content={'Edit'}> <Tooltip placement="top" content="Duplicate">
<LinkButton className={style.button} size="xs" key="edit" variant="secondary" icon="pen" href={editURL}> <LinkButton
<DontShowIfSmallDevice>Edit</DontShowIfSmallDevice> title="Duplicate"
</LinkButton> className={style.button}
size="sm"
key="clone"
variant="secondary"
icon="copy"
href={isProvisioned ? undefined : cloneUrl}
onClick={isProvisioned ? () => setProvRuleCloneUrl(cloneUrl) : undefined}
/>
</Tooltip> </Tooltip>
); );
} }
@ -154,16 +151,15 @@ export const RuleActionsButtons: FC<Props> = ({ rule, rulesSource }) => {
buttons.push( buttons.push(
<Tooltip placement="top" content={'Delete'}> <Tooltip placement="top" content={'Delete'}>
<Button <Button
title="Delete"
className={style.button} className={style.button}
size="xs" size="sm"
type="button" type="button"
key="delete" key="delete"
variant="secondary" variant="secondary"
icon="trash-alt" icon="trash-alt"
onClick={() => setRuleToDelete(rule)} onClick={() => setRuleToDelete(rule)}
> />
<DontShowIfSmallDevice>Delete</DontShowIfSmallDevice>
</Button>
</Tooltip> </Tooltip>
); );
} }
@ -171,11 +167,11 @@ export const RuleActionsButtons: FC<Props> = ({ rule, rulesSource }) => {
if (buttons.length) { if (buttons.length) {
return ( return (
<> <>
<div className={style.wrapper}> <Stack gap={1}>
<HorizontalGroup width="auto"> {buttons.map((button, index) => (
{buttons.length ? buttons.map((button, index) => <div key={index}>{button}</div>) : <div />} <React.Fragment key={index}>{button}</React.Fragment>
</HorizontalGroup> ))}
</div> </Stack>
{!!ruleToDelete && ( {!!ruleToDelete && (
<ConfirmModal <ConfirmModal
isOpen={true} isOpen={true}
@ -187,6 +183,24 @@ export const RuleActionsButtons: FC<Props> = ({ rule, rulesSource }) => {
onDismiss={() => setRuleToDelete(undefined)} onDismiss={() => setRuleToDelete(undefined)}
/> />
)} )}
<ConfirmModal
isOpen={!!provRuleCloneUrl}
title="Clone provisioned rule"
body={
<div>
<p>
The new rule will <span className={style.bold}>NOT</span> be marked as a provisioned rule.
</p>
<p>
You will need to set a new alert group for the cloned rule because the original one has been provisioned
and cannot be used for rules created in the UI.
</p>
</div>
}
confirmText="Clone"
onConfirm={() => provRuleCloneUrl && locationService.push(provRuleCloneUrl)}
onDismiss={() => setProvRuleCloneUrl(undefined)}
/>
</> </>
); );
} }
@ -199,20 +213,10 @@ function inViewMode(pathname: string): boolean {
} }
export const getStyles = (theme: GrafanaTheme2) => ({ export const getStyles = (theme: GrafanaTheme2) => ({
wrapper: css`
display: flex;
flex-direction: row;
justify-content: space-between;
flex-wrap: wrap;
`,
button: css` button: css`
height: 24px; padding: 0 ${theme.spacing(2)};
font-size: ${theme.typography.size.sm};
svg {
margin-right: 0;
}
`, `,
buttonText: css` bold: css`
margin-left: 8px; font-weight: ${theme.typography.fontWeightBold};
`, `,
}); });

View File

@ -83,13 +83,13 @@ describe('RulesTable RBAC', () => {
it('Should render Edit button for users with the update permission', () => { it('Should render Edit button for users with the update permission', () => {
mocks.useIsRuleEditable.mockReturnValue({ loading: false, isEditable: true }); mocks.useIsRuleEditable.mockReturnValue({ loading: false, isEditable: true });
renderRulesTable(grafanaRule); renderRulesTable(grafanaRule);
expect(ui.actionButtons.edit.query()).toBeInTheDocument(); expect(ui.actionButtons.edit.get()).toBeInTheDocument();
}); });
it('Should render Delete button for users with the delete permission', () => { it('Should render Delete button for users with the delete permission', () => {
mocks.useIsRuleEditable.mockReturnValue({ loading: false, isRemovable: true }); mocks.useIsRuleEditable.mockReturnValue({ loading: false, isRemovable: true });
renderRulesTable(grafanaRule); renderRulesTable(grafanaRule);
expect(ui.actionButtons.delete.query()).toBeInTheDocument(); expect(ui.actionButtons.delete.get()).toBeInTheDocument();
}); });
}); });
@ -110,13 +110,13 @@ describe('RulesTable RBAC', () => {
it('Should render Edit button for users with the update permission', () => { it('Should render Edit button for users with the update permission', () => {
mocks.useIsRuleEditable.mockReturnValue({ loading: false, isEditable: true }); mocks.useIsRuleEditable.mockReturnValue({ loading: false, isEditable: true });
renderRulesTable(cloudRule); renderRulesTable(cloudRule);
expect(ui.actionButtons.edit.query()).toBeInTheDocument(); expect(ui.actionButtons.edit.get()).toBeInTheDocument();
}); });
it('Should render Delete button for users with the delete permission', () => { it('Should render Delete button for users with the delete permission', () => {
mocks.useIsRuleEditable.mockReturnValue({ loading: false, isRemovable: true }); mocks.useIsRuleEditable.mockReturnValue({ loading: false, isRemovable: true });
renderRulesTable(cloudRule); renderRulesTable(cloudRule);
expect(ui.actionButtons.delete.query()).toBeInTheDocument(); expect(ui.actionButtons.delete.get()).toBeInTheDocument();
}); });
}); });
}); });

View File

@ -196,7 +196,7 @@ function useColumns(showSummaryColumn: boolean, showGroupColumn: boolean) {
renderCell: ({ data: rule }) => { renderCell: ({ data: rule }) => {
return <RuleActionsButtons rule={rule} rulesSource={rule.namespace.rulesSource} />; return <RuleActionsButtons rule={rule} rulesSource={rule.namespace.rulesSource} />;
}, },
size: '290px', size: '200px',
}); });
return columns; return columns;

View File

@ -121,6 +121,7 @@ export const mockRulerAlertingRule = (partial: Partial<RulerAlertingRuleDTO> = {
annotations: { annotations: {
summary: 'test alert', summary: 'test alert',
}, },
...partial,
}); });
export const mockRulerRuleGroup = (partial: Partial<RulerRuleGroupDTO> = {}): RulerRuleGroupDTO => ({ export const mockRulerRuleGroup = (partial: Partial<RulerRuleGroupDTO> = {}): RulerRuleGroupDTO => ({

View File

@ -0,0 +1,8 @@
import { rest } from 'msw';
import { SetupServerApi } from 'msw/node';
import { DashboardSearchItem } from '../../../search/types';
export function mockSearchApiResponse(server: SetupServerApi, searchResult: DashboardSearchItem[]) {
server.use(rest.get('/api/search', (req, res, ctx) => res(ctx.json<DashboardSearchItem[]>(searchResult))));
}

View File

@ -0,0 +1,30 @@
import { rest } from 'msw';
import { SetupServerApi } from 'msw/node';
import { RulerRuleGroupDTO, RulerRulesConfigDTO } from '../../../../types/unified-alerting-dto';
export function mockRulerRulesApiResponse(
server: SetupServerApi,
rulesSourceName: string,
response: RulerRulesConfigDTO
) {
server.use(
rest.get(`/api/ruler/${rulesSourceName}/api/v1/rules`, (req, res, ctx) =>
res(ctx.json<RulerRulesConfigDTO>(response))
)
);
}
export function mockRulerRulesGroupApiResponse(
server: SetupServerApi,
rulesSourceName: string,
namespace: string,
group: string,
response: RulerRuleGroupDTO
) {
server.use(
rest.get(`/api/ruler/${rulesSourceName}/api/v1/rules/${namespace}/${group}`, (req, res, ctx) =>
res(ctx.json(response))
)
);
}

View File

@ -1,7 +1,6 @@
import { sortBy } from 'lodash'; import { sortBy } from 'lodash';
import { urlUtil, UrlQueryMap, Labels, DataSourceInstanceSettings, DataSourceJsonData } from '@grafana/data'; import { UrlQueryMap, Labels, DataSourceInstanceSettings, DataSourceJsonData } from '@grafana/data';
import { config } from '@grafana/runtime';
import { alertInstanceKey } from 'app/features/alerting/unified/utils/rules'; import { alertInstanceKey } from 'app/features/alerting/unified/utils/rules';
import { SortOrder } from 'app/plugins/panel/alertlist/types'; import { SortOrder } from 'app/plugins/panel/alertlist/types';
import { Alert, CombinedRule, FilterState, RulesSource, SilenceFilterState } from 'app/types/unified-alerting'; import { Alert, CombinedRule, FilterState, RulesSource, SilenceFilterState } from 'app/types/unified-alerting';
@ -15,6 +14,7 @@ import { ALERTMANAGER_NAME_QUERY_KEY } from './constants';
import { getRulesSourceName } from './datasource'; import { getRulesSourceName } from './datasource';
import { getMatcherQueryParams } from './matchers'; import { getMatcherQueryParams } from './matchers';
import * as ruleId from './rule-id'; import * as ruleId from './rule-id';
import { createUrl } from './url';
export function createViewLink(ruleSource: RulesSource, rule: CombinedRule, returnTo: string): string { export function createViewLink(ruleSource: RulesSource, rule: CombinedRule, returnTo: string): string {
const sourceName = getRulesSourceName(ruleSource); const sourceName = getRulesSourceName(ruleSource);
@ -22,11 +22,11 @@ export function createViewLink(ruleSource: RulesSource, rule: CombinedRule, retu
const paramId = encodeURIComponent(ruleId.stringifyIdentifier(identifier)); const paramId = encodeURIComponent(ruleId.stringifyIdentifier(identifier));
const paramSource = encodeURIComponent(sourceName); const paramSource = encodeURIComponent(sourceName);
return urlUtil.renderUrl(`${config.appSubUrl}/alerting/${paramSource}/${paramId}/view`, { returnTo }); return createUrl(`/alerting/${paramSource}/${paramId}/view`, { returnTo });
} }
export function createExploreLink(dataSourceName: string, query: string) { export function createExploreLink(dataSourceName: string, query: string) {
return urlUtil.renderUrl(`${config.appSubUrl}/explore`, { return createUrl(`/explore`, {
left: JSON.stringify([ left: JSON.stringify([
'now-1h', 'now-1h',
'now', 'now',
@ -95,15 +95,15 @@ export function makeLabelBasedSilenceLink(alertManagerSourceName: string, labels
const matcherParams = getMatcherQueryParams(labels); const matcherParams = getMatcherQueryParams(labels);
matcherParams.forEach((value, key) => silenceUrlParams.append(key, value)); matcherParams.forEach((value, key) => silenceUrlParams.append(key, value));
return `${config.appSubUrl}/alerting/silence/new?${silenceUrlParams.toString()}`; return createUrl('/alerting/silence/new', silenceUrlParams);
} }
export function makeDataSourceLink<T extends DataSourceJsonData>(dataSource: DataSourceInstanceSettings<T>) { export function makeDataSourceLink<T extends DataSourceJsonData>(dataSource: DataSourceInstanceSettings<T>) {
return `${config.appSubUrl}/datasources/edit/${dataSource.uid}`; return createUrl(`/datasources/edit/${dataSource.uid}`);
} }
export function makeFolderLink(folderUID: string): string { export function makeFolderLink(folderUID: string): string {
return `${config.appSubUrl}/dashboards/f/${folderUID}`; return createUrl(`/dashboards/f/${folderUID}`);
} }
// keep retrying fn if it's error passes shouldRetry(error) and timeout has not elapsed yet // keep retrying fn if it's error passes shouldRetry(error) and timeout has not elapsed yet

View File

@ -145,3 +145,18 @@ export function getFirstActiveAt(promRule: AlertingRule) {
export function isFederatedRuleGroup(group: CombinedRuleGroup) { export function isFederatedRuleGroup(group: CombinedRuleGroup) {
return Array.isArray(group.source_tenants); return Array.isArray(group.source_tenants);
} }
export function getRuleName(rule: RulerRuleDTO) {
if (isGrafanaRulerRule(rule)) {
return rule.grafana_alert.title;
}
if (isAlertingRulerRule(rule)) {
return rule.alert;
}
if (isRecordingRulerRule(rule)) {
return rule.record;
}
return '';
}

View File

@ -0,0 +1,6 @@
import { config } from '@grafana/runtime';
export function createUrl(path: string, queryParams?: string[][] | Record<string, string> | string | URLSearchParams) {
const searchParams = new URLSearchParams(queryParams);
return `${config.appSubUrl}${path}?${searchParams.toString()}`;
}