From d2c129fbaceb1957ccd972272dc776de02545151 Mon Sep 17 00:00:00 2001 From: Konrad Lalik Date: Thu, 1 Dec 2022 16:21:54 +0100 Subject: [PATCH] Alerting: Add alert rule cloning action (#59200) Co-authored-by: Gilles De Mey --- .../alerting/unified/CloneRuleEditor.test.tsx | 284 ++++++++++++++++++ .../alerting/unified/CloneRuleEditor.tsx | 87 ++++++ .../features/alerting/unified/RuleEditor.tsx | 11 + .../components/rule-editor/AlertRuleForm.tsx | 13 +- .../rule-editor/AnnotationsField.test.tsx | 13 +- .../components/rule-editor/FolderAndGroup.tsx | 13 +- .../components/rules/RuleActionsButtons.tsx | 168 ++++++----- .../components/rules/RulesTable.test.tsx | 8 +- .../unified/components/rules/RulesTable.tsx | 2 +- public/app/features/alerting/unified/mocks.ts | 1 + .../alerting/unified/mocks/grafanaApi.ts | 8 + .../alerting/unified/mocks/rulerApi.ts | 30 ++ .../features/alerting/unified/utils/misc.ts | 14 +- .../features/alerting/unified/utils/rules.ts | 15 + .../features/alerting/unified/utils/url.ts | 6 + 15 files changed, 566 insertions(+), 107 deletions(-) create mode 100644 public/app/features/alerting/unified/CloneRuleEditor.test.tsx create mode 100644 public/app/features/alerting/unified/CloneRuleEditor.tsx create mode 100644 public/app/features/alerting/unified/mocks/grafanaApi.ts create mode 100644 public/app/features/alerting/unified/mocks/rulerApi.ts create mode 100644 public/app/features/alerting/unified/utils/url.ts diff --git a/public/app/features/alerting/unified/CloneRuleEditor.test.tsx b/public/app/features/alerting/unified/CloneRuleEditor.test.tsx new file mode 100644 index 00000000000..1fadb5528d3 --- /dev/null +++ b/public/app/features/alerting/unified/CloneRuleEditor.test.tsx @@ -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) => ( + 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({ defaultValues: getDefaultFormValues() }); + + return ( + + + {children} + + + ); + }; +} + +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(, { + 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( + , + { + 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); + }); +}); diff --git a/public/app/features/alerting/unified/CloneRuleEditor.tsx b/public/app/features/alerting/unified/CloneRuleEditor.tsx new file mode 100644 index 00000000000..e9a75b76a03 --- /dev/null +++ b/public/app/features/alerting/unified/CloneRuleEditor.tsx @@ -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 ; + } + + 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 ; + } + + if (error) { + return ( + + {error.message} + + ); + } + + return ( + 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; + } +} diff --git a/public/app/features/alerting/unified/RuleEditor.tsx b/public/app/features/alerting/unified/RuleEditor.tsx index abc6aa8a5b8..db9788cf626 100644 --- a/public/app/features/alerting/unified/RuleEditor.tsx +++ b/public/app/features/alerting/unified/RuleEditor.tsx @@ -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 ; } + if (copyFromIdentifier) { + return ; + } + return ; }; diff --git a/public/app/features/alerting/unified/components/rule-editor/AlertRuleForm.tsx b/public/app/features/alerting/unified/components/rule-editor/AlertRuleForm.tsx index f2d4df23756..f08b9a371af 100644 --- a/public/app/features/alerting/unified/components/rule-editor/AlertRuleForm.tsx +++ b/public/app/features/alerting/unified/components/rule-editor/AlertRuleForm.tsx @@ -69,9 +69,10 @@ export const MINUTE = '1m'; type Props = { existing?: RuleWithLocation; + prefill?: Partial; // Existing implies we modify existing rule. Prefill only provides default form values }; -export const AlertRuleForm: FC = ({ existing }) => { +export const AlertRuleForm: FC = ({ existing, prefill }) => { const styles = useStyles2(getStyles); const dispatch = useDispatch(); const notifyApp = useAppNotification(); @@ -86,6 +87,14 @@ export const AlertRuleForm: FC = ({ existing }) => { if (existing) { return rulerRuleToFormValues(existing); } + + if (prefill) { + return { + ...getDefaultFormValues(), + ...prefill, + }; + } + return { ...getDefaultFormValues(), queries: getDefaultQueries(), @@ -94,7 +103,7 @@ export const AlertRuleForm: FC = ({ existing }) => { type: RuleFormType.grafana, evaluateEvery: evaluateEvery, }; - }, [existing, queryParams, evaluateEvery]); + }, [existing, prefill, queryParams, evaluateEvery]); const formAPI = useForm({ mode: 'onSubmit', diff --git a/public/app/features/alerting/unified/components/rule-editor/AnnotationsField.test.tsx b/public/app/features/alerting/unified/components/rule-editor/AnnotationsField.test.tsx index 0b40d78d138..8a47dbd9992 100644 --- a/public/app/features/alerting/unified/components/rule-editor/AnnotationsField.test.tsx +++ b/public/app/features/alerting/unified/components/rule-editor/AnnotationsField.test.tsx @@ -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(searchResult)))); -} - function mockGetDashboardResponse(dashboard: DashboardDTO) { server.use( rest.get(`/api/dashboards/uid/${dashboard.dashboard.uid}`, (req, res, ctx) => diff --git a/public/app/features/alerting/unified/components/rule-editor/FolderAndGroup.tsx b/public/app/features/alerting/unified/components/rule-editor/FolderAndGroup.tsx index 6b9aee47954..ba471fe0534 100644 --- a/public/app/features/alerting/unified/components/rule-editor/FolderAndGroup.tsx +++ b/public/app/features/alerting/unified/components/rule-editor/FolderAndGroup.tsx @@ -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> = 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> = mapGroupsToOptions( - useGetGroups(groupfoldersForGrafana?.result, folderTilte) + useGetGroups(groupfoldersForGrafana?.result, folderTitle) ); const groupsForFolder = groupfoldersForGrafana?.result; return { groupOptions, groupsForFolder, loading: groupfoldersForGrafana?.loading }; diff --git a/public/app/features/alerting/unified/components/rules/RuleActionsButtons.tsx b/public/app/features/alerting/unified/components/rules/RuleActionsButtons.tsx index 0531ac03270..71e80704617 100644 --- a/public/app/features/alerting/unified/components/rules/RuleActionsButtons.tsx +++ b/public/app/features/alerting/unified/components/rules/RuleActionsButtons.tsx @@ -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
{children}
; - } -} export const RuleActionsButtons: FC = ({ rule, rulesSource }) => { const dispatch = useDispatch(); @@ -59,6 +32,7 @@ export const RuleActionsButtons: FC = ({ rule, rulesSource }) => { const style = useStyles2(getStyles); const { namespace, group, rulerRule } = rule; const [ruleToDelete, setRuleToDelete] = useState(); + const [provRuleCloneUrl, setProvRuleCloneUrl] = useState(undefined); const rulesSourceName = getRulesSourceName(rulesSource); @@ -96,56 +70,79 @@ export const RuleActionsButtons: FC = ({ rule, rulesSource }) => { return window.location.href.split('?')[0]; }; + const sourceName = getRulesSourceName(rulesSource); + if (!isViewMode) { buttons.push( - View - + > ); } - 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( + { + notifyApp.error('Error while copying URL', copiedText); + }} + className={style.button} + size="sm" + getText={buildShareUrl} + > + Copy link to rule + + ); + } - if (isViewMode) { buttons.push( - { - notifyApp.error('Error while copying URL', copiedText); - }} - className={style.button} - size="sm" - getText={buildShareUrl} - > - Copy link to rule - + + + ); } + 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( - - - Edit - + + setProvRuleCloneUrl(cloneUrl) : undefined} + /> ); } @@ -154,16 +151,15 @@ export const RuleActionsButtons: FC = ({ rule, rulesSource }) => { buttons.push( + /> ); } @@ -171,11 +167,11 @@ export const RuleActionsButtons: FC = ({ rule, rulesSource }) => { if (buttons.length) { return ( <> -
- - {buttons.length ? buttons.map((button, index) =>
{button}
) :
} - -
+ + {buttons.map((button, index) => ( + {button} + ))} + {!!ruleToDelete && ( = ({ rule, rulesSource }) => { onDismiss={() => setRuleToDelete(undefined)} /> )} + +

+ The new rule will NOT be marked as a provisioned rule. +

+

+ 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. +

+
+ } + 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}; `, }); diff --git a/public/app/features/alerting/unified/components/rules/RulesTable.test.tsx b/public/app/features/alerting/unified/components/rules/RulesTable.test.tsx index 31b0241fb46..e53ad1f831c 100644 --- a/public/app/features/alerting/unified/components/rules/RulesTable.test.tsx +++ b/public/app/features/alerting/unified/components/rules/RulesTable.test.tsx @@ -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(); }); }); }); diff --git a/public/app/features/alerting/unified/components/rules/RulesTable.tsx b/public/app/features/alerting/unified/components/rules/RulesTable.tsx index affa11b32f2..5e1c0fddb0e 100644 --- a/public/app/features/alerting/unified/components/rules/RulesTable.tsx +++ b/public/app/features/alerting/unified/components/rules/RulesTable.tsx @@ -196,7 +196,7 @@ function useColumns(showSummaryColumn: boolean, showGroupColumn: boolean) { renderCell: ({ data: rule }) => { return ; }, - size: '290px', + size: '200px', }); return columns; diff --git a/public/app/features/alerting/unified/mocks.ts b/public/app/features/alerting/unified/mocks.ts index 492a6841313..89b8742000c 100644 --- a/public/app/features/alerting/unified/mocks.ts +++ b/public/app/features/alerting/unified/mocks.ts @@ -121,6 +121,7 @@ export const mockRulerAlertingRule = (partial: Partial = { annotations: { summary: 'test alert', }, + ...partial, }); export const mockRulerRuleGroup = (partial: Partial = {}): RulerRuleGroupDTO => ({ diff --git a/public/app/features/alerting/unified/mocks/grafanaApi.ts b/public/app/features/alerting/unified/mocks/grafanaApi.ts new file mode 100644 index 00000000000..04c45c7bb65 --- /dev/null +++ b/public/app/features/alerting/unified/mocks/grafanaApi.ts @@ -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(searchResult)))); +} diff --git a/public/app/features/alerting/unified/mocks/rulerApi.ts b/public/app/features/alerting/unified/mocks/rulerApi.ts new file mode 100644 index 00000000000..18cd13b6e4c --- /dev/null +++ b/public/app/features/alerting/unified/mocks/rulerApi.ts @@ -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(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)) + ) + ); +} diff --git a/public/app/features/alerting/unified/utils/misc.ts b/public/app/features/alerting/unified/utils/misc.ts index ef6a694a1bf..66ea5f64292 100644 --- a/public/app/features/alerting/unified/utils/misc.ts +++ b/public/app/features/alerting/unified/utils/misc.ts @@ -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(dataSource: DataSourceInstanceSettings) { - 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 diff --git a/public/app/features/alerting/unified/utils/rules.ts b/public/app/features/alerting/unified/utils/rules.ts index 3a2aa8b8884..6386541891a 100644 --- a/public/app/features/alerting/unified/utils/rules.ts +++ b/public/app/features/alerting/unified/utils/rules.ts @@ -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 ''; +} diff --git a/public/app/features/alerting/unified/utils/url.ts b/public/app/features/alerting/unified/utils/url.ts new file mode 100644 index 00000000000..8499598eaa0 --- /dev/null +++ b/public/app/features/alerting/unified/utils/url.ts @@ -0,0 +1,6 @@ +import { config } from '@grafana/runtime'; + +export function createUrl(path: string, queryParams?: string[][] | Record | string | URLSearchParams) { + const searchParams = new URLSearchParams(queryParams); + return `${config.appSubUrl}${path}?${searchParams.toString()}`; +}