mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
Alerting: Add alert rule cloning action (#59200)
Co-authored-by: Gilles De Mey <gilles.de.mey@gmail.com>
This commit is contained in:
parent
b3284a8330
commit
d2c129fbac
284
public/app/features/alerting/unified/CloneRuleEditor.test.tsx
Normal file
284
public/app/features/alerting/unified/CloneRuleEditor.test.tsx
Normal 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);
|
||||
});
|
||||
});
|
87
public/app/features/alerting/unified/CloneRuleEditor.tsx
Normal file
87
public/app/features/alerting/unified/CloneRuleEditor.tsx
Normal 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;
|
||||
}
|
||||
}
|
@ -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 />;
|
||||
};
|
||||
|
||||
|
@ -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',
|
||||
|
@ -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) =>
|
||||
|
@ -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 };
|
||||
|
@ -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};
|
||||
`,
|
||||
});
|
||||
|
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
@ -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;
|
||||
|
@ -121,6 +121,7 @@ export const mockRulerAlertingRule = (partial: Partial<RulerAlertingRuleDTO> = {
|
||||
annotations: {
|
||||
summary: 'test alert',
|
||||
},
|
||||
...partial,
|
||||
});
|
||||
|
||||
export const mockRulerRuleGroup = (partial: Partial<RulerRuleGroupDTO> = {}): RulerRuleGroupDTO => ({
|
||||
|
8
public/app/features/alerting/unified/mocks/grafanaApi.ts
Normal file
8
public/app/features/alerting/unified/mocks/grafanaApi.ts
Normal 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))));
|
||||
}
|
30
public/app/features/alerting/unified/mocks/rulerApi.ts
Normal file
30
public/app/features/alerting/unified/mocks/rulerApi.ts
Normal 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))
|
||||
)
|
||||
);
|
||||
}
|
@ -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
|
||||
|
@ -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 '';
|
||||
}
|
||||
|
6
public/app/features/alerting/unified/utils/url.ts
Normal file
6
public/app/features/alerting/unified/utils/url.ts
Normal 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()}`;
|
||||
}
|
Loading…
Reference in New Issue
Block a user