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

View File

@ -69,9 +69,10 @@ export const MINUTE = '1m';
type Props = {
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 dispatch = useDispatch();
const notifyApp = useAppNotification();
@ -86,6 +87,14 @@ export const AlertRuleForm: FC<Props> = ({ existing }) => {
if (existing) {
return rulerRuleToFormValues(existing);
}
if (prefill) {
return {
...getDefaultFormValues(),
...prefill,
};
}
return {
...getDefaultFormValues(),
queries: getDefaultQueries(),
@ -94,7 +103,7 @@ export const AlertRuleForm: FC<Props> = ({ existing }) => {
type: RuleFormType.grafana,
evaluateEvery: evaluateEvery,
};
}, [existing, queryParams, evaluateEvery]);
}, [existing, prefill, queryParams, evaluateEvery]);
const formAPI = useForm<RuleFormValues>({
mode: 'onSubmit',

View File

@ -13,6 +13,7 @@ import { backendSrv } from 'app/core/services/backend_srv';
import { DashboardDTO } from '../../../../../types';
import { DashboardSearchItem, DashboardSearchItemType } from '../../../../search/types';
import { mockStore } from '../../mocks';
import { mockSearchApiResponse } from '../../mocks/grafanaApi';
import { RuleFormValues } from '../../types/rule-form';
import { Annotation } from '../../utils/constants';
import { getDefaultFormValues } from '../../utils/rule-form';
@ -83,7 +84,7 @@ describe('AnnotationsField', function () {
describe('Dashboard and panel picker', function () {
it('should display dashboard and panel selector when select button clicked', async function () {
mockSearchResponse([]);
mockSearchApiResponse(server, []);
const user = userEvent.setup();
@ -96,7 +97,7 @@ describe('AnnotationsField', 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 }),
]);
@ -126,7 +127,7 @@ describe('AnnotationsField', 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 }),
]);
@ -170,7 +171,7 @@ describe('AnnotationsField', function () {
// 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.
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 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) {
server.use(
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 { RuleForm, RuleFormValues } from '../../types/rule-form';
import { GRAFANA_RULES_SOURCE_NAME } from '../../utils/datasource';
import { isGrafanaRulerRule } from '../../utils/rules';
import { InfoIcon } from '../InfoIcon';
import { getIntervalForGroup } from './GrafanaEvaluationBehavior';
@ -27,7 +28,13 @@ const useGetGroups = (groupfoldersForGrafana: RulerRulesConfigDTO | null | undef
const groupsForFolderResult: Array<RulerRuleGroupDTO<RulerRuleDTO>> = groupfoldersForGrafana
? 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]);
return groupOptions;
@ -40,13 +47,13 @@ interface FolderAndGroupProps {
initialFolder: RuleForm | null;
}
export const useGetGroupOptionsFromFolder = (folderTilte: string) => {
export const useGetGroupOptionsFromFolder = (folderTitle: string) => {
const rulerRuleRequests = useUnifiedAlertingSelector((state) => state.rulerRules);
const groupfoldersForGrafana = rulerRuleRequests[GRAFANA_RULES_SOURCE_NAME];
const groupOptions: Array<SelectableValue<string>> = mapGroupsToOptions(
useGetGroups(groupfoldersForGrafana?.result, folderTilte)
useGetGroups(groupfoldersForGrafana?.result, folderTitle)
);
const groupsForFolder = groupfoldersForGrafana?.result;
return { groupOptions, groupsForFolder, loading: groupfoldersForGrafana?.loading };

View File

@ -2,20 +2,11 @@ import { css } from '@emotion/css';
import React, { FC, useState } from 'react';
import { useLocation } from 'react-router-dom';
import { GrafanaTheme2, urlUtil } from '@grafana/data';
import { config } from '@grafana/runtime';
import {
Button,
ClipboardButton,
ConfirmModal,
HorizontalGroup,
LinkButton,
Tooltip,
useStyles2,
useTheme2,
} from '@grafana/ui';
import { GrafanaTheme2 } from '@grafana/data';
import { Stack } from '@grafana/experimental';
import { config, locationService } from '@grafana/runtime';
import { Button, ClipboardButton, ConfirmModal, LinkButton, Tooltip, useStyles2 } from '@grafana/ui';
import { useAppNotification } from 'app/core/copy/appNotification';
import { useMediaQueryChange } from 'app/core/hooks/useMediaQueryChange';
import { useDispatch } from 'app/types';
import { CombinedRule, RulesSource } from 'app/types/unified-alerting';
@ -25,6 +16,7 @@ import { getRulesSourceName, isCloudRulesSource } from '../../utils/datasource';
import { createViewLink } from '../../utils/misc';
import * as ruleId from '../../utils/rule-id';
import { isFederatedRuleGroup, isGrafanaRulerRule } from '../../utils/rules';
import { createUrl } from '../../utils/url';
export const matchesWidth = (width: number) => window.matchMedia(`(max-width: ${width}px)`).matches;
@ -32,25 +24,6 @@ interface Props {
rule: CombinedRule;
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 }) => {
const dispatch = useDispatch();
@ -59,6 +32,7 @@ export const RuleActionsButtons: FC<Props> = ({ rule, rulesSource }) => {
const style = useStyles2(getStyles);
const { namespace, group, rulerRule } = rule;
const [ruleToDelete, setRuleToDelete] = useState<CombinedRule>();
const [provRuleCloneUrl, setProvRuleCloneUrl] = useState<string | undefined>(undefined);
const rulesSourceName = getRulesSourceName(rulesSource);
@ -96,33 +70,31 @@ export const RuleActionsButtons: FC<Props> = ({ rule, rulesSource }) => {
return window.location.href.split('?')[0];
};
const sourceName = getRulesSourceName(rulesSource);
if (!isViewMode) {
buttons.push(
<Tooltip placement="top" content={'View'}>
<LinkButton
className={style.button}
size="xs"
title="View"
size="sm"
key="view"
variant="secondary"
icon="eye"
href={createViewLink(rulesSource, rule, returnTo)}
>
<DontShowIfSmallDevice>View</DontShowIfSmallDevice>
</LinkButton>
></LinkButton>
</Tooltip>
);
}
if (isEditable && rulerRule && !isFederated && !isProvisioned) {
const sourceName = getRulesSourceName(rulesSource);
if (isEditable && rulerRule && !isFederated) {
const identifier = ruleId.fromRulerRule(sourceName, namespace.name, group.name, rulerRule);
const editURL = urlUtil.renderUrl(
`${config.appSubUrl}/alerting/${encodeURIComponent(ruleId.stringifyIdentifier(identifier))}/edit`,
{
if (!isProvisioned) {
const editURL = createUrl(`/alerting/${encodeURIComponent(ruleId.stringifyIdentifier(identifier))}/edit`, {
returnTo,
}
);
});
if (isViewMode) {
buttons.push(
@ -143,9 +115,34 @@ export const RuleActionsButtons: FC<Props> = ({ rule, rulesSource }) => {
buttons.push(
<Tooltip placement="top" content={'Edit'}>
<LinkButton className={style.button} size="xs" key="edit" variant="secondary" icon="pen" href={editURL}>
<DontShowIfSmallDevice>Edit</DontShowIfSmallDevice>
</LinkButton>
<LinkButton
title="Edit"
className={style.button}
size="sm"
key="edit"
variant="secondary"
icon="pen"
href={editURL}
/>
</Tooltip>
);
}
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(
<Tooltip placement="top" content="Duplicate">
<LinkButton
title="Duplicate"
className={style.button}
size="sm"
key="clone"
variant="secondary"
icon="copy"
href={isProvisioned ? undefined : cloneUrl}
onClick={isProvisioned ? () => setProvRuleCloneUrl(cloneUrl) : undefined}
/>
</Tooltip>
);
}
@ -154,16 +151,15 @@ export const RuleActionsButtons: FC<Props> = ({ rule, rulesSource }) => {
buttons.push(
<Tooltip placement="top" content={'Delete'}>
<Button
title="Delete"
className={style.button}
size="xs"
size="sm"
type="button"
key="delete"
variant="secondary"
icon="trash-alt"
onClick={() => setRuleToDelete(rule)}
>
<DontShowIfSmallDevice>Delete</DontShowIfSmallDevice>
</Button>
/>
</Tooltip>
);
}
@ -171,11 +167,11 @@ export const RuleActionsButtons: FC<Props> = ({ rule, rulesSource }) => {
if (buttons.length) {
return (
<>
<div className={style.wrapper}>
<HorizontalGroup width="auto">
{buttons.length ? buttons.map((button, index) => <div key={index}>{button}</div>) : <div />}
</HorizontalGroup>
</div>
<Stack gap={1}>
{buttons.map((button, index) => (
<React.Fragment key={index}>{button}</React.Fragment>
))}
</Stack>
{!!ruleToDelete && (
<ConfirmModal
isOpen={true}
@ -187,6 +183,24 @@ export const RuleActionsButtons: FC<Props> = ({ rule, rulesSource }) => {
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) => ({
wrapper: css`
display: flex;
flex-direction: row;
justify-content: space-between;
flex-wrap: wrap;
`,
button: css`
height: 24px;
font-size: ${theme.typography.size.sm};
svg {
margin-right: 0;
}
padding: 0 ${theme.spacing(2)};
`,
buttonText: css`
margin-left: 8px;
bold: css`
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', () => {
mocks.useIsRuleEditable.mockReturnValue({ loading: false, isEditable: true });
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', () => {
mocks.useIsRuleEditable.mockReturnValue({ loading: false, isRemovable: true });
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', () => {
mocks.useIsRuleEditable.mockReturnValue({ loading: false, isEditable: true });
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', () => {
mocks.useIsRuleEditable.mockReturnValue({ loading: false, isRemovable: true });
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 }) => {
return <RuleActionsButtons rule={rule} rulesSource={rule.namespace.rulesSource} />;
},
size: '290px',
size: '200px',
});
return columns;

View File

@ -121,6 +121,7 @@ export const mockRulerAlertingRule = (partial: Partial<RulerAlertingRuleDTO> = {
annotations: {
summary: 'test alert',
},
...partial,
});
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 { urlUtil, UrlQueryMap, Labels, DataSourceInstanceSettings, DataSourceJsonData } from '@grafana/data';
import { config } from '@grafana/runtime';
import { UrlQueryMap, Labels, DataSourceInstanceSettings, DataSourceJsonData } from '@grafana/data';
import { alertInstanceKey } from 'app/features/alerting/unified/utils/rules';
import { SortOrder } from 'app/plugins/panel/alertlist/types';
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 { getMatcherQueryParams } from './matchers';
import * as ruleId from './rule-id';
import { createUrl } from './url';
export function createViewLink(ruleSource: RulesSource, rule: CombinedRule, returnTo: string): string {
const sourceName = getRulesSourceName(ruleSource);
@ -22,11 +22,11 @@ export function createViewLink(ruleSource: RulesSource, rule: CombinedRule, retu
const paramId = encodeURIComponent(ruleId.stringifyIdentifier(identifier));
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) {
return urlUtil.renderUrl(`${config.appSubUrl}/explore`, {
return createUrl(`/explore`, {
left: JSON.stringify([
'now-1h',
'now',
@ -95,15 +95,15 @@ export function makeLabelBasedSilenceLink(alertManagerSourceName: string, labels
const matcherParams = getMatcherQueryParams(labels);
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>) {
return `${config.appSubUrl}/datasources/edit/${dataSource.uid}`;
return createUrl(`/datasources/edit/${dataSource.uid}`);
}
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

View File

@ -145,3 +145,18 @@ export function getFirstActiveAt(promRule: AlertingRule) {
export function isFederatedRuleGroup(group: CombinedRuleGroup) {
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()}`;
}