mirror of https://github.com/grafana/grafana
Alerting: Add alert rule cloning action (#59200)
Co-authored-by: Gilles De Mey <gilles.de.mey@gmail.com>pull/59319/head^2
parent
b3284a8330
commit
d2c129fbac
@ -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); |
||||
}); |
||||
}); |
||||
@ -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; |
||||
} |
||||
} |
||||
@ -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)))); |
||||
} |
||||
@ -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)) |
||||
) |
||||
); |
||||
} |
||||
@ -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