From 97a1614cde328c388c1163ed1b5d4b9ec4a0bb18 Mon Sep 17 00:00:00 2001 From: Gilles De Mey Date: Tue, 29 Apr 2025 15:45:45 +0200 Subject: [PATCH] Alerting: Rework rule editor layout (#103744) --- .betterer.results | 115 ++++++++++++ .../RuleEditorGrafanaRecordingRules.test.tsx | 4 +- .../rule-editor/DurationQuickPick.tsx | 6 +- .../alert-rule-form/AlertRuleForm.tsx | 177 +++++------------- .../alert-rule-form/ModifyExportRuleForm.tsx | 59 +++--- .../SimplifiedRuleEditor.test.tsx | 12 +- .../components/rule-viewer/RuleViewer.tsx | 7 +- .../unified/components/rules/RuleDetails.tsx | 8 +- .../rule-editor/CloneRuleEditor.test.tsx | 60 ++++-- .../rule-editor/ExistingRuleEditor.tsx | 115 +++++++++--- .../unified/rule-editor/RuleEditor.tsx | 161 ++++++++-------- .../rule-editor/RuleEditorCloudRules.test.tsx | 2 +- .../rule-editor/RuleEditorExisting.test.tsx | 16 +- .../RuleEditorGrafanaRules.test.tsx | 8 +- .../RuleEditorRecordingRule.test.tsx | 4 +- .../unified/rule-editor/clone.utils.ts | 39 ++++ .../features/alerting/unified/utils/misc.ts | 10 + .../features/alerting/unified/utils/rules.ts | 4 + public/locales/en-US/grafana.json | 15 +- public/test/helpers/alertingRuleEditor.tsx | 2 +- 20 files changed, 506 insertions(+), 318 deletions(-) create mode 100644 public/app/features/alerting/unified/rule-editor/clone.utils.ts diff --git a/.betterer.results b/.betterer.results index 2518e0548be..0a9111cfa72 100644 --- a/.betterer.results +++ b/.betterer.results @@ -1020,7 +1020,122 @@ exports[`better eslint`] = { [0, 0, 0, "Do not use any type assertions.", "0"] ], "public/app/features/alerting/unified/components/rule-editor/RuleInspector.tsx:5381": [ +<<<<<<< HEAD + [0, 0, 0, "Do not use any type assertions.", "0"], + [0, 0, 0, "No untranslated strings. Wrap text with ", "1"] + ], + "public/app/features/alerting/unified/components/rule-editor/alert-rule-form/simplifiedRouting/route-settings/MuteTimingFields.tsx:5381": [ + [0, 0, 0, "No untranslated strings in text props. Wrap text with or use t()", "0"] + ], + "public/app/features/alerting/unified/components/rule-editor/alert-rule-form/simplifiedRouting/route-settings/RouteSettings.tsx:5381": [ + [0, 0, 0, "No untranslated strings in text props. Wrap text with or use t()", "0"], + [0, 0, 0, "No untranslated strings. Wrap text with ", "1"], + [0, 0, 0, "No untranslated strings. Wrap text with ", "2"] + ], + "public/app/features/alerting/unified/components/rule-editor/labels/LabelsField.tsx:5381": [ + [0, 0, 0, "No untranslated strings. Wrap text with ", "0"], + [0, 0, 0, "No untranslated strings. Wrap text with ", "1"] + ], + "public/app/features/alerting/unified/components/rule-editor/notificaton-preview/NotificationRoute.tsx:5381": [ + [0, 0, 0, "No untranslated strings. Wrap text with ", "0"] + ], + "public/app/features/alerting/unified/components/rule-editor/notificaton-preview/NotificationRouteDetailsModal.tsx:5381": [ + [0, 0, 0, "No untranslated strings. Wrap text with ", "0"], + [0, 0, 0, "No untranslated strings. Wrap text with ", "1"] + ], + "public/app/features/alerting/unified/components/rule-editor/query-and-alert-condition/QueryAndExpressionsStep.tsx:5381": [ + [0, 0, 0, "No untranslated strings. Wrap text with ", "0"], + [0, 0, 0, "No untranslated strings. Wrap text with ", "1"] + ], + "public/app/features/alerting/unified/components/rule-editor/query-and-alert-condition/SmartAlertTypeDetector.tsx:5381": [ + [0, 0, 0, "No untranslated strings. Wrap text with ", "0"], + [0, 0, 0, "No untranslated strings. Wrap text with ", "1"], + [0, 0, 0, "No untranslated strings. Wrap text with ", "2"], + [0, 0, 0, "No untranslated strings. Wrap text with ", "3"] + ], + "public/app/features/alerting/unified/components/rule-editor/rule-types/GrafanaManagedAlert.tsx:5381": [ + [0, 0, 0, "No untranslated strings. Wrap text with ", "0"] + ], + "public/app/features/alerting/unified/components/rule-editor/rule-types/MimirOrLokiAlert.tsx:5381": [ + [0, 0, 0, "No untranslated strings. Wrap text with ", "0"] + ], + "public/app/features/alerting/unified/components/rule-editor/rule-types/MimirOrLokiRecordingRule.tsx:5381": [ + [0, 0, 0, "No untranslated strings. Wrap text with ", "0"] + ], + "public/app/features/alerting/unified/components/rule-editor/rule-types/RuleTypePicker.tsx:5381": [ + [0, 0, 0, "No untranslated strings. Wrap text with ", "0"] + ], + "public/app/features/alerting/unified/components/rule-viewer/FederatedRuleWarning.tsx:5381": [ + [0, 0, 0, "No untranslated strings. Wrap text with ", "0"] + ], + "public/app/features/alerting/unified/components/rule-viewer/PausedBadge.tsx:5381": [ + [0, 0, 0, "No untranslated strings. Wrap text with ", "0"] + ], + "public/app/features/alerting/unified/components/rule-viewer/RuleViewer.tsx:5381": [ + [0, 0, 0, "No untranslated strings. Wrap text with ", "0"] + ], + "public/app/features/alerting/unified/components/rules/AlertStateTag.tsx:5381": [ + [0, 0, 0, "No untranslated strings. Wrap text with ", "0"] + ], + "public/app/features/alerting/unified/components/rules/CloneRule.tsx:5381": [ + [0, 0, 0, "No untranslated strings. Wrap text with ", "0"], + [0, 0, 0, "No untranslated strings. Wrap text with ", "1"] + ], + "public/app/features/alerting/unified/components/rules/Filter/RulesFilter.v1.tsx:5381": [ + [0, 0, 0, "No untranslated strings. Wrap text with ", "0"], + [0, 0, 0, "No untranslated strings. Wrap text with ", "1"], + [0, 0, 0, "No untranslated strings. Wrap text with ", "2"] + ], + "public/app/features/alerting/unified/components/rules/RuleConfigStatus.tsx:5381": [ + [0, 0, 0, "No untranslated strings. Wrap text with ", "0"] + ], + "public/app/features/alerting/unified/components/rules/RuleDetailsMatchingInstances.tsx:5381": [ + [0, 0, 0, "No untranslated strings. Wrap text with ", "0"], + [0, 0, 0, "No untranslated strings. Wrap text with ", "1"] + ], + "public/app/features/alerting/unified/components/rules/RuleListErrors.tsx:5381": [ + [0, 0, 0, "No untranslated strings. Wrap text with ", "0"], + [0, 0, 0, "No untranslated strings. Wrap text with ", "1"], + [0, 0, 0, "No untranslated strings. Wrap text with ", "2"], + [0, 0, 0, "No untranslated strings. Wrap text with ", "3"], + [0, 0, 0, "No untranslated strings. Wrap text with ", "4"], + [0, 0, 0, "No untranslated strings. Wrap text with ", "5"] + ], + "public/app/features/alerting/unified/components/rules/RuleListStateSection.tsx:5381": [ + [0, 0, 0, "No untranslated strings. Wrap text with ", "0"] + ], + "public/app/features/alerting/unified/components/rules/RuleState.tsx:5381": [ + [0, 0, 0, "No untranslated strings. Wrap text with ", "0"] + ], + "public/app/features/alerting/unified/components/rules/RuleStats.tsx:5381": [ + [0, 0, 0, "No untranslated strings. Wrap text with ", "0"] + ], + "public/app/features/alerting/unified/components/rules/RulesGroup.tsx:5381": [ + [0, 0, 0, "No untranslated strings. Wrap text with ", "0"], + [0, 0, 0, "No untranslated strings. Wrap text with ", "1"], + [0, 0, 0, "No untranslated strings. Wrap text with ", "2"] + ], + "public/app/features/alerting/unified/components/rules/state-history/LogRecordViewer.tsx:5381": [ + [0, 0, 0, "No untranslated strings. Wrap text with ", "0"] + ], + "public/app/features/alerting/unified/components/rules/state-history/LokiStateHistory.tsx:5381": [ + [0, 0, 0, "No untranslated strings. Wrap text with ", "0"] + ], + "public/app/features/alerting/unified/components/rules/state-history/StateHistory.tsx:5381": [ + [0, 0, 0, "No untranslated strings. Wrap text with ", "0"] + ], + "public/app/features/alerting/unified/components/settings/VersionManager.tsx:5381": [ + [0, 0, 0, "No untranslated strings. Wrap text with ", "0"], + [0, 0, 0, "No untranslated strings. Wrap text with ", "1"] + ], + "public/app/features/alerting/unified/components/silences/SilencedAlertsTableRow.tsx:5381": [ + [0, 0, 0, "No untranslated strings. Wrap text with ", "0"] + ], + "public/app/features/alerting/unified/components/silences/SilencedInstancesPreview.tsx:5381": [ + [0, 0, 0, "No untranslated strings. Wrap text with ", "0"] +======= [0, 0, 0, "Do not use any type assertions.", "0"] +>>>>>>> origin/main ], "public/app/features/alerting/unified/components/silences/SilencesEditor.tsx:5381": [ [0, 0, 0, "Do not use any type assertions.", "0"] diff --git a/public/app/features/alerting/unified/RuleEditorGrafanaRecordingRules.test.tsx b/public/app/features/alerting/unified/RuleEditorGrafanaRecordingRules.test.tsx index 655b33b2e7e..faed2a0ac8b 100644 --- a/public/app/features/alerting/unified/RuleEditorGrafanaRecordingRules.test.tsx +++ b/public/app/features/alerting/unified/RuleEditorGrafanaRecordingRules.test.tsx @@ -78,7 +78,7 @@ describe('RuleEditor grafana recording rules', () => { await user.type(await ui.inputs.metric.find(), 'metricName'); await selectFolderAndGroup(user); - await user.click(ui.buttons.saveAndExit.get()); + await user.click(ui.buttons.save.get()); const requests = await capture; const serializedRequests = await serializeRequests(requests); @@ -96,7 +96,7 @@ describe('RuleEditor grafana recording rules', () => { await user.type(await ui.inputs.name.find(), 'my great new rule'); await selectFolderAndGroup(user); - await user.click(ui.buttons.saveAndExit.get()); + await user.click(ui.buttons.save.get()); const requests = await capture; expect(requests).toHaveLength(0); }); diff --git a/public/app/features/alerting/unified/components/rule-editor/DurationQuickPick.tsx b/public/app/features/alerting/unified/components/rule-editor/DurationQuickPick.tsx index 36844b9b75c..2e8bcdfc567 100644 --- a/public/app/features/alerting/unified/components/rule-editor/DurationQuickPick.tsx +++ b/public/app/features/alerting/unified/components/rule-editor/DurationQuickPick.tsx @@ -43,9 +43,13 @@ export function DurationQuickPick({ selectedDuration, groupEvaluationInterval, o onSelect(duration); }} > - {duration === '0s' ? t('alerting.duration-quick-pick.none', 'None') : duration} + {stringifyPendingPeriod(duration)} ))} ); } + +export function stringifyPendingPeriod(duration: string): string { + return duration === '0s' ? t('alerting.duration-quick-pick.none', 'None') : duration; +} diff --git a/public/app/features/alerting/unified/components/rule-editor/alert-rule-form/AlertRuleForm.tsx b/public/app/features/alerting/unified/components/rule-editor/alert-rule-form/AlertRuleForm.tsx index 88549aa16f1..cf8dfd3f589 100644 --- a/public/app/features/alerting/unified/components/rule-editor/alert-rule-form/AlertRuleForm.tsx +++ b/public/app/features/alerting/unified/components/rule-editor/alert-rule-form/AlertRuleForm.tsx @@ -5,8 +5,7 @@ import { useParams } from 'react-router-dom-v5-compat'; import { GrafanaTheme2 } from '@grafana/data'; import { config, locationService } from '@grafana/runtime'; -import { Alert, Button, ConfirmModal, Spinner, Stack, useStyles2 } from '@grafana/ui'; -import { AppChromeUpdate } from 'app/core/components/AppChrome/AppChromeUpdate'; +import { Alert, Button, Spinner, Stack, useStyles2 } from '@grafana/ui'; import { useAppNotification } from 'app/core/copy/appNotification'; import { contextSrv } from 'app/core/core'; import { Trans, t } from 'app/core/internationalization'; @@ -22,7 +21,7 @@ import { rulerRuleType, } from 'app/features/alerting/unified/utils/rules'; import { isExpressionQuery } from 'app/features/expressions/guards'; -import { RuleGroupIdentifier, RuleIdentifier, RuleWithLocation } from 'app/types/unified-alerting'; +import { RuleGroupIdentifier, RuleWithLocation } from 'app/types/unified-alerting'; import { PostableRuleGrafanaRuleDTO, RulerRuleDTO } from 'app/types/unified-alerting-dto'; import { @@ -41,10 +40,7 @@ import { RulerGroupUpdatedResponse, isGrafanaGroupUpdatedResponse, } from '../../../api/alertRuleModel'; -import { shouldUseAlertingListViewV2, shouldUsePrometheusRulesPrimary } from '../../../featureToggles'; -import { useDeleteRuleFromGroup } from '../../../hooks/ruleGroup/useDeleteRuleFromGroup'; import { useAddRuleToRuleGroup, useUpdateRuleInRuleGroup } from '../../../hooks/ruleGroup/useUpsertRuleFromRuleGroup'; -import { useReturnTo } from '../../../hooks/useReturnTo'; import { defaultFormValuesForRuleType, formValuesFromExistingRule, @@ -63,9 +59,7 @@ import { formValuesToRulerGrafanaRuleDTO, formValuesToRulerRuleDTO, } from '../../../utils/rule-form'; -import * as ruleId from '../../../utils/rule-id'; -import { fromRulerRule, fromRulerRuleAndRuleGroupIdentifier, stringifyIdentifier } from '../../../utils/rule-id'; -import { createRelativeUrl } from '../../../utils/url'; +import { fromRulerRule, fromRulerRuleAndRuleGroupIdentifier } from '../../../utils/rule-id'; import { GrafanaRuleExporter } from '../../export/GrafanaRuleExporter'; import { AlertRuleNameAndMetric } from '../AlertRuleNameInput'; import AnnotationsStep from '../AnnotationsStep'; @@ -83,27 +77,21 @@ type Props = { isManualRestore?: boolean; }; -const prometheusRulesPrimary = shouldUsePrometheusRulesPrimary(); -const alertingListViewV2 = shouldUseAlertingListViewV2(); - export const AlertRuleForm = ({ existing, prefill, isManualRestore }: Props) => { const styles = useStyles2(getStyles); const notifyApp = useAppNotification(); - const { redirectToDetailsPage } = useRedirectToDetailsPage(); + + const routeParams = useParams<{ type: string; id: string }>(); + const uidFromParams = routeParams.id; + + const { redirectToDetailsPage } = useRedirectToDetailsPage(uidFromParams); const [showEditYaml, setShowEditYaml] = useState(false); - const [deleteRuleFromGroup] = useDeleteRuleFromGroup(); const [addRuleToRuleGroup] = useAddRuleToRuleGroup(); const [updateRuleInRuleGroup] = useUpdateRuleInRuleGroup(); - const { returnTo } = useReturnTo(); - const routeParams = useParams<{ type: string; id: string }>(); const ruleType = translateRouteParamToRuleType(routeParams.type); - const uidFromParams = routeParams.id || ''; - - const [showDeleteModal, setShowDeleteModal] = useState(false); - const defaultValues: RuleFormValues = useMemo(() => { // If we have an existing AND a prefill, then we're coming from the restore dialog // and we want to merge the two @@ -157,7 +145,7 @@ export const AlertRuleForm = ({ existing, prefill, isManualRestore }: Props) => }; // @todo why is error not propagated to form? - const submit = async (values: RuleFormValues, exitOnSave: boolean) => { + const submit = async (values: RuleFormValues): Promise => { const { type, evaluateEvery } = values; if (conditionErrorMsg !== '') { @@ -208,41 +196,8 @@ export const AlertRuleForm = ({ existing, prefill, isManualRestore }: Props) => ); } - const { dataSourceName, namespaceName, groupName } = targetRuleGroupIdentifier; - - // V2 list is based on eventually consistent Prometheus API. - // When a new rule group is created it takes a while for the new rule group to be reflected in the V2 list. - // To avoid user confusion we redirect to the details page which is driven by a strongly consistent Ruler API.. - if (alertingListViewV2) { - redirectToDetailsPage(ruleDefinition, targetRuleGroupIdentifier, saveResult); - return; - } - - if (exitOnSave) { - const returnToUrl = returnTo || getReturnToUrl(targetRuleGroupIdentifier, ruleDefinition); - - locationService.push(returnToUrl); - return; - } else { - // we stay in the same page - - // Cloud Ruler rules identifier changes on update due to containing rule name and hash components - // After successful update we need to update the URL to avoid displaying 404 errors - if (rulerRuleType.dataSource.rule(ruleDefinition)) { - const updatedRuleIdentifier = fromRulerRule(dataSourceName, namespaceName, groupName, ruleDefinition); - locationService.replace(`/alerting/${encodeURIComponent(stringifyIdentifier(updatedRuleIdentifier))}/edit`); - } - } - }; - - const deleteRule = async () => { - if (existing) { - const ruleGroupIdentifier = getRuleGroupLocationFromRuleWithLocation(existing); - const ruleIdentifier = fromRulerRuleAndRuleGroupIdentifier(ruleGroupIdentifier, existing.rule); - - await deleteRuleFromGroup.execute(ruleGroupIdentifier, ruleIdentifier); - locationService.replace(returnTo ?? '/alerting/list'); - } + redirectToDetailsPage(ruleDefinition, targetRuleGroupIdentifier, saveResult); + return; }; const onInvalid: SubmitErrorHandler = (errors): void => { @@ -266,49 +221,14 @@ export const AlertRuleForm = ({ existing, prefill, isManualRestore }: Props) => locationService.getHistory().goBack(); }; - const actionButtons = ( - - - - {existing ? ( - - ) : null} - {existing && isCortexLokiOrRecordingRule(watch) && ( - - )} - - ); - - const isPaused = rulerRuleType.grafana.alertingRule(existing?.rule) && isPausedRule(existing?.rule); - if (!type) { return null; } + + const isPaused = rulerRuleType.grafana.rule(existing?.rule) && isPausedRule(existing?.rule); + return ( -
e.preventDefault()} className={styles.form}>
{isManualRestore && ( @@ -348,39 +268,55 @@ export const AlertRuleForm = ({ existing, prefill, isManualRestore }: Props) => {!isRecordingRuleByType(type) && } )} + + {/* actions */} + + + + + + {existing && isCortexLokiOrRecordingRule(watch) && ( + + )} +
- {showDeleteModal ? ( - setShowDeleteModal(false)} - /> - ) : null} + {showEditYaml && ( <> - {isGrafanaManagedRuleByType(type) && ( + {grafanaTypeRule && uidFromParams && ( setShowEditYaml(false)} /> )} - {!isGrafanaManagedRuleByType(type) && setShowEditYaml(false)} />} + {!grafanaTypeRule && setShowEditYaml(false)} />} )}
); }; -function useRedirectToDetailsPage() { +function useRedirectToDetailsPage(existingUid?: string) { const notifyApp = useAppNotification(); const redirectGrafanaRule = useCallback( (saveResult: GrafanaGroupUpdatedResponse) => { - const newOrUpdatedRuleUid = saveResult.created?.at(0) || saveResult.updated?.at(0); + // if the response contains no created or updated rules, we'll use the existing UID. + const newOrUpdatedRuleUid = (saveResult.created?.at(0) || saveResult.updated?.at(0)) ?? existingUid; if (newOrUpdatedRuleUid) { locationService.replace( rulesNav.detailsPageLink('grafana', { uid: newOrUpdatedRuleUid, ruleSourceName: 'grafana' }) @@ -393,7 +329,7 @@ function useRedirectToDetailsPage() { logWarning('Cannot navigate to the new rule details page. The rule was created but the UID is missing.'); } }, - [notifyApp] + [existingUid, notifyApp] ); const redirectCloudRulerRule = useCallback((rule: RulerRuleDTO, groupId: RuleGroupIdentifier) => { @@ -427,27 +363,6 @@ function useRedirectToDetailsPage() { return { redirectToDetailsPage }; } -function getReturnToUrl(groupId: RuleGroupIdentifier, rule: RulerRuleDTO | PostableRuleGrafanaRuleDTO) { - const { dataSourceName, namespaceName, groupName } = groupId; - - if (prometheusRulesPrimary && rulerRuleType.dataSource.rule(rule)) { - const ruleIdentifier = fromRulerRule(dataSourceName, namespaceName, groupName, rule); - return createViewLinkFromIdentifier(ruleIdentifier); - } - - // TODO We could add namespace and group filters but for GMA the namespace = uid which doesn't work with the filters - return '/alerting/list'; -} - -// The result of this function is passed to locationService.push() -// Hence it cannot contain the subpath prefix, so we cannot use createRelativeUrl for it -function createViewLinkFromIdentifier(identifier: RuleIdentifier, returnTo?: string) { - const paramId = encodeURIComponent(ruleId.stringifyIdentifier(identifier)); - const paramSource = encodeURIComponent(identifier.ruleSourceName); - - return createRelativeUrl(`/alerting/${paramSource}/${paramId}/view`, returnTo ? { returnTo } : {}); -} - const isCortexLokiOrRecordingRule = (watch: UseFormWatch) => { const [ruleType, dataSourceName] = watch(['type', 'dataSourceName']); diff --git a/public/app/features/alerting/unified/components/rule-editor/alert-rule-form/ModifyExportRuleForm.tsx b/public/app/features/alerting/unified/components/rule-editor/alert-rule-form/ModifyExportRuleForm.tsx index adc000d45eb..8e0c66438e4 100644 --- a/public/app/features/alerting/unified/components/rule-editor/alert-rule-form/ModifyExportRuleForm.tsx +++ b/public/app/features/alerting/unified/components/rule-editor/alert-rule-form/ModifyExportRuleForm.tsx @@ -6,7 +6,6 @@ import { Button, LinkButton, LoadingPlaceholder, Stack } from '@grafana/ui'; import { useAppNotification } from 'app/core/copy/appNotification'; import { Trans, t } from 'app/core/internationalization'; -import { AppChromeUpdate } from '../../../../../../core/components/AppChrome/AppChromeUpdate'; import { PostableRulerRuleGroupDTO, RulerRuleDTO, @@ -82,38 +81,38 @@ export function ModifyExportRuleForm({ ruleForm, alertUid }: ModifyExportRuleFor setExportData(undefined); }, [setExportData]); - const actionButtons = [ - submit(undefined)}> - Cancel - , - , - ]; - return ( - -
e.preventDefault()}> -
- - {/* Step 1 */} - - {/* Step 2 */} - - {/* Step 3-4-5 */} - + + e.preventDefault()}> +
+ + {/* Step 1 */} + + {/* Step 2 */} + + {/* Step 3-4-5 */} + - {/* Step 4 & 5 */} - - {/* Notifications step*/} - - {/* Annotations only for cloud and Grafana */} - - -
- - {exportData && } + {/* Step 4 & 5 */} + + {/* Notifications step*/} + + {/* Annotations only for cloud and Grafana */} + +
+
+ + {exportData && } + + + submit(undefined)}> + Cancel + + +
); } diff --git a/public/app/features/alerting/unified/components/rule-editor/alert-rule-form/simplifiedRouting/SimplifiedRuleEditor.test.tsx b/public/app/features/alerting/unified/components/rule-editor/alert-rule-form/simplifiedRouting/SimplifiedRuleEditor.test.tsx index 0a949d9bccb..381ce9cb6ca 100644 --- a/public/app/features/alerting/unified/components/rule-editor/alert-rule-form/simplifiedRouting/SimplifiedRuleEditor.test.tsx +++ b/public/app/features/alerting/unified/components/rule-editor/alert-rule-form/simplifiedRouting/SimplifiedRuleEditor.test.tsx @@ -97,7 +97,7 @@ describe('Can create a new grafana managed alert using simplified routing', () = // do not select a contact point // save and check that call to backend was not made - await user.click(ui.buttons.saveAndExit.get()); + await user.click(ui.buttons.save.get()); expect(await screen.findByText('Contact point is required.')).toBeInTheDocument(); const capturedRequests = await capture; @@ -129,7 +129,7 @@ describe('Can create a new grafana managed alert using simplified routing', () = await selectContactPoint(user, contactPointName); // save and check what was sent to backend - await user.click(ui.buttons.saveAndExit.get()); + await user.click(ui.buttons.save.get()); const requests = await capture; const serializedRequests = await serializeRequests(requests); @@ -162,7 +162,7 @@ describe('Can create a new grafana managed alert using simplified routing', () = await selectContactPoint(user, contactPointName); // save and check what was sent to backend - await user.click(ui.buttons.saveAndExit.get()); + await user.click(ui.buttons.save.get()); const requests = await capture; const serializedRequests = await serializeRequests(requests); expect(serializedRequests).toMatchSnapshot(); @@ -180,7 +180,7 @@ describe('Can create a new grafana managed alert using simplified routing', () = await selectFolderAndGroup(user); // save and check what was sent to backend - await user.click(ui.buttons.saveAndExit.get()); + await user.click(ui.buttons.save.get()); const requests = await capture; const serializedRequests = await serializeRequests(requests); expect(serializedRequests).toMatchSnapshot(); @@ -198,7 +198,7 @@ describe('Can create a new grafana managed alert using simplified routing', () = await user.click(ui.inputs.switchModeBasic(GrafanaRuleFormStep.Notification).get()); // switch notifications step to advanced mode // save and check what was sent to backend - await user.click(ui.buttons.saveAndExit.get()); + await user.click(ui.buttons.save.get()); const requests = await capture; const serializedRequests = await serializeRequests(requests); expect(serializedRequests).toMatchSnapshot(); @@ -218,7 +218,7 @@ describe('Can create a new grafana managed alert using simplified routing', () = await user.click(ui.inputs.switchModeBasic(GrafanaRuleFormStep.Query).get()); // switch query step to advanced mode // save and check what was sent to backend - await user.click(ui.buttons.saveAndExit.get()); + await user.click(ui.buttons.save.get()); const requests = await capture; const serializedRequests = await serializeRequests(requests); expect(serializedRequests).toMatchSnapshot(); diff --git a/public/app/features/alerting/unified/components/rule-viewer/RuleViewer.tsx b/public/app/features/alerting/unified/components/rule-viewer/RuleViewer.tsx index bce2689be64..90e6f9ff1da 100644 --- a/public/app/features/alerting/unified/components/rule-viewer/RuleViewer.tsx +++ b/public/app/features/alerting/unified/components/rule-viewer/RuleViewer.tsx @@ -275,16 +275,17 @@ interface TitleProps { health?: RuleHealth; ruleType?: PromRuleType; ruleOrigin?: RulePluginOrigin; + returnToHref?: string; } -export const Title = ({ name, paused = false, state, health, ruleType, ruleOrigin }: TitleProps) => { +export const Title = ({ name, paused = false, state, health, ruleType, ruleOrigin, returnToHref = '' }: TitleProps) => { const isRecordingRule = ruleType === PromRuleType.Recording; - const { returnTo } = useReturnTo('/alerting/list'); + const { returnTo } = useReturnTo(returnToHref); return ( - + {returnToHref && } {ruleOrigin && } {name} diff --git a/public/app/features/alerting/unified/components/rules/RuleDetails.tsx b/public/app/features/alerting/unified/components/rules/RuleDetails.tsx index d7e6839d810..787a0550037 100644 --- a/public/app/features/alerting/unified/components/rules/RuleDetails.tsx +++ b/public/app/features/alerting/unified/components/rules/RuleDetails.tsx @@ -70,7 +70,7 @@ interface EvaluationBehaviorSummaryProps { } const EvaluationBehaviorSummary = ({ rule }: EvaluationBehaviorSummaryProps) => { - const every = rule.group.interval; + const interval = rule.group.interval; const lastEvaluation = rule.promRule?.lastEvaluation; const lastEvaluationDuration = rule.promRule?.evaluationTime; const metric = rulerRuleType.grafana.recordingRule(rule.rulerRule) @@ -87,10 +87,10 @@ const EvaluationBehaviorSummary = ({ rule }: EvaluationBehaviorSummaryProps) => {metric} )} - {every && ( + {interval && ( - - Every {{ every }} + + Every {{ interval }} )} diff --git a/public/app/features/alerting/unified/rule-editor/CloneRuleEditor.test.tsx b/public/app/features/alerting/unified/rule-editor/CloneRuleEditor.test.tsx index 298be9b1529..c59101fca8e 100644 --- a/public/app/features/alerting/unified/rule-editor/CloneRuleEditor.test.tsx +++ b/public/app/features/alerting/unified/rule-editor/CloneRuleEditor.test.tsx @@ -1,9 +1,10 @@ import * as React from 'react'; import { FormProvider, useForm } from 'react-hook-form'; -import { getWrapper, render, waitFor, waitForElementToBeRemoved, within } from 'test/test-utils'; -import { byRole, byTestId, byText } from 'testing-library-selector'; +import { getWrapper, render, waitFor, within } from 'test/test-utils'; +import { byRole, byTestId } from 'testing-library-selector'; import { MIMIR_DATASOURCE_UID } from 'app/features/alerting/unified/mocks/server/constants'; +import { DashboardSearchItemType } from 'app/features/search/types'; import { AccessControlAction } from 'app/types'; import { RuleWithLocation } from 'app/types/unified-alerting'; import { @@ -18,19 +19,22 @@ import { setupMswServer } from '../mockApi'; import { grantUserPermissions, mockDataSource, + mockFolder, mockRulerAlertingRule, mockRulerGrafanaRule, mockRulerRuleGroup, } from '../mocks'; import { grafanaRulerRule } from '../mocks/grafanaRulerApi'; import { mockRulerRulesApiResponse, mockRulerRulesGroupApiResponse } from '../mocks/rulerApi'; +import { setFolderResponse } from '../mocks/server/configure'; import { AlertingQueryRunner } from '../state/AlertingQueryRunner'; import { setupDataSources } from '../testSetup/datasources'; import { RuleFormValues } from '../types/rule-form'; import { Annotation } from '../utils/constants'; import { hashRulerRule } from '../utils/rule-id'; -import { CloneRuleEditor, cloneRuleDefinition } from './CloneRuleEditor'; +import { ExistingRuleEditor } from './ExistingRuleEditor'; +import { cloneRuleDefinition } from './clone.utils'; import { getDefaultFormValues } from './formDefaults'; jest.mock('../components/rule-editor/ExpressionEditor', () => ({ @@ -54,7 +58,6 @@ const ui = { annotationValue: (idx: number) => byTestId(`annotation-value-${idx}`), labelValue: (idx: number) => byTestId(`label-value-${idx}`), }, - loadingIndicator: byText('Loading the rule...'), }; const Providers = getWrapper({ renderWithRouter: true }); @@ -68,18 +71,50 @@ function Wrapper({ children }: React.PropsWithChildren<{}>) { } describe('CloneRuleEditor', function () { - grantUserPermissions([AccessControlAction.AlertingRuleExternalRead]); + const folder = { + title: 'Folder A', + uid: grafanaRulerRule.grafana_alert.namespace_uid, + id: 1, + type: DashboardSearchItemType.DashDB, + accessControl: { + [AccessControlAction.AlertingRuleUpdate]: true, + }, + }; + + beforeEach(() => { + jest.clearAllMocks(); + + grantUserPermissions([ + AccessControlAction.AlertingRuleRead, + AccessControlAction.AlertingRuleCreate, + AccessControlAction.DataSourcesRead, + AccessControlAction.FoldersRead, + AccessControlAction.AlertingRuleExternalRead, + AccessControlAction.AlertingRuleExternalWrite, + ]); + + const dataSources = { + default: mockDataSource({ + uid: MIMIR_DATASOURCE_UID, + type: 'prometheus', + name: 'Mimir', + isDefault: true, + }), + }; + setupDataSources(dataSources.default); + setFolderResponse(mockFolder(folder)); + }); describe('Grafana-managed rules', function () { it('should populate form values from the existing alert rule', async function () { - setupDataSources(); - render( - , + , { wrapper: Wrapper } ); - await waitForElementToBeRemoved(ui.loadingIndicator.query()); await waitFor(() => { expect(within(ui.inputs.group.get()).queryByTestId('Spinner')).not.toBeInTheDocument(); }); @@ -130,20 +165,19 @@ describe('CloneRuleEditor', function () { }); render( - , { wrapper: Wrapper } ); - await waitForElementToBeRemoved(ui.loadingIndicator.query()); - await waitFor(() => { expect(ui.inputs.name.get()).toHaveValue('First Ruler Rule (copy)'); }); diff --git a/public/app/features/alerting/unified/rule-editor/ExistingRuleEditor.tsx b/public/app/features/alerting/unified/rule-editor/ExistingRuleEditor.tsx index 9eb61f1e13b..3626894fac1 100644 --- a/public/app/features/alerting/unified/rule-editor/ExistingRuleEditor.tsx +++ b/public/app/features/alerting/unified/rule-editor/ExistingRuleEditor.tsx @@ -1,76 +1,133 @@ -import { Alert, LoadingPlaceholder } from '@grafana/ui'; -import { useQueryParams } from 'app/core/hooks/useQueryParams'; +import { NavModelItem } from '@grafana/data'; +import { Alert, Stack } from '@grafana/ui'; import { Trans, t } from 'app/core/internationalization'; import { RuleIdentifier } from 'app/types/unified-alerting'; import { AlertWarning } from '../AlertWarning'; +import { AlertingPageWrapper } from '../components/AlertingPageWrapper'; import { AlertRuleForm } from '../components/rule-editor/alert-rule-form/AlertRuleForm'; +import { FederatedRuleWarning } from '../components/rule-viewer/FederatedRuleWarning'; import { useRuleWithLocation } from '../hooks/useCombinedRule'; import { useIsRuleEditable } from '../hooks/useIsRuleEditable'; import { RuleFormValues } from '../types/rule-form'; +import { Annotation } from '../utils/constants'; import { stringifyErrorLike } from '../utils/misc'; +import { rulerRuleToFormValues } from '../utils/rule-form'; import * as ruleId from '../utils/rule-id'; +import { isFederatedRuleGroup, rulerRuleType } from '../utils/rules'; +import { defaultPageNav } from './RuleEditor'; +import { cloneRuleDefinition } from './clone.utils'; interface ExistingRuleEditorProps { identifier: RuleIdentifier; - /** Provide prefill if we are trying to restore an old version of an alert rule but we need the user to manually tweak the values */ + // Provide prefill if we are trying to restore an old version of an alert rule but we need the user to manually tweak the values prefill?: Partial; + // indicate if this is a manual restore + isManualRestore?: boolean; + // indicate if this is a cloning operation + clone?: boolean; } -export function ExistingRuleEditor({ identifier, prefill }: ExistingRuleEditorProps) { - const [queryParams] = useQueryParams(); - const isManualRestore = Boolean(queryParams.isManualRestore); - +export function ExistingRuleEditor({ + identifier, + prefill, + isManualRestore = false, + clone = false, +}: ExistingRuleEditorProps) { + const ruleSourceName = ruleId.ruleIdentifierToRuleSourceName(identifier); const { loading: loadingAlertRule, result: ruleWithLocation, error: fetchRuleError, } = useRuleWithLocation({ ruleIdentifier: identifier }); - - const ruleSourceName = ruleId.ruleIdentifierToRuleSourceName(identifier); const { isEditable, loading: loadingEditable, error: errorEditable, } = useIsRuleEditable(ruleSourceName, ruleWithLocation?.rule); - // error handling for fetching rule and rule RBAC if (fetchRuleError || errorEditable) { return ( - - {stringifyErrorLike(errorEditable ?? fetchRuleError)} - + + + {stringifyErrorLike(errorEditable ?? fetchRuleError)} + + ); } const loading = loadingAlertRule || loadingEditable; - if (loading) { - return ; + return ( + + {null} + + ); } if (!ruleWithLocation && !loading) { return ( - - - Sorry! This rule does not exist. - - + + + + Sorry! This rule does not exist. + + + ); } if (isEditable === false) { return ( - - - Sorry! You do not have permission to edit this rule. - - + + + + Sorry! You do not have permission to edit this rule. + + + ); } - return ; + // we shouldn't get here because loading / error handling happens before this + if (!ruleWithLocation) { + return null; + } + + const rulerRule = ruleWithLocation.rule; + const summary = rulerRuleType.any.alertingRule(rulerRule) ? rulerRule.annotations?.[Annotation.summary] : null; + + const isFederatedRule = isFederatedRuleGroup(ruleWithLocation.group); + const isRecordingRule = rulerRuleType.any.recordingRule(rulerRule); + + const pageTitle = isRecordingRule + ? t('alerting.editor.edit-recording-rule', 'Edit recording rule') + : t('alerting.editor.edit-alert-rule', 'Edit alert rule'); + + return ( + + {summary} + {/* alerts and notifications and stuff */} + {isFederatedRule && } + + } + pageNav={getPageNav({ text: pageTitle })} + > + {clone ? ( + + ) : ( + + )} + + ); } + +const getPageNav = (pageNavOptions?: Partial): NavModelItem => { + return { ...defaultPageNav, id: 'alert-rule-edit', text: '', ...pageNavOptions }; +}; diff --git a/public/app/features/alerting/unified/rule-editor/RuleEditor.tsx b/public/app/features/alerting/unified/rule-editor/RuleEditor.tsx index 8a06815b6b0..eb9d5b005fa 100644 --- a/public/app/features/alerting/unified/rule-editor/RuleEditor.tsx +++ b/public/app/features/alerting/unified/rule-editor/RuleEditor.tsx @@ -1,9 +1,7 @@ -import { useCallback } from 'react'; import { useParams } from 'react-router-dom-v5-compat'; import { NavModelItem } from '@grafana/data'; import { Trans, t } from 'app/core/internationalization'; -import { RuleIdentifier } from 'app/types/unified-alerting'; import { AlertWarning } from '../AlertWarning'; import { AlertingPageWrapper } from '../components/AlertingPageWrapper'; @@ -13,91 +11,98 @@ import { useRulesAccess } from '../utils/accessControlHooks'; import * as ruleId from '../utils/rule-id'; import { withPageErrorBoundary } from '../withPageErrorBoundary'; -import { CloneRuleEditor } from './CloneRuleEditor'; import { ExistingRuleEditor } from './ExistingRuleEditor'; import { formValuesFromQueryParams, translateRouteParamToRuleType } from './formDefaults'; - -type RuleEditorPathParams = { +export type RuleEditorPathParams = { id?: string; type?: 'recording' | 'alerting' | 'grafana-recording'; }; -const defaultPageNav: Partial = { - icon: 'bell', +export const defaultPageNav: Partial = { id: 'alert-rule-view', }; -// sadly we only get the "type" when a new rule is being created, when editing an existing recording rule we can't actually know it from the URL -const getPageNav = (identifier?: RuleIdentifier, type?: RuleEditorPathParams['type']) => { - if (type === 'recording' || type === 'grafana-recording') { - if (identifier) { - // this branch should never trigger actually, the type param isn't used when editing rules - return { ...defaultPageNav, id: 'alert-rule-edit', text: 'Edit recording rule' }; - } else { - return { ...defaultPageNav, id: 'alert-rule-add', text: 'New recording rule' }; - } - } - - if (identifier) { - // keep this one ambiguous, don't mentiond a specific alert type here - return { ...defaultPageNav, id: 'alert-rule-edit', text: 'Edit rule' }; - } else { - return { ...defaultPageNav, id: 'alert-rule-add', text: 'New alert rule' }; - } -}; - const RuleEditor = () => { - const { identifier, type } = useRuleEditorPathParams(); - const { copyFromIdentifier, queryDefaults, isManualRestore } = useRuleEditorQueryParams(); + const { identifier } = useRuleEditorPathParams(); + const cloneIdentifier = useIdentifierFromCopy(); + const isManualRestore = useManualRestore(); const { canCreateGrafanaRules, canCreateCloudRules, canEditRules } = useRulesAccess(); - const getContent = useCallback(() => { - if (!identifier && !canCreateGrafanaRules && !canCreateCloudRules) { - return ( - - - Sorry! You are not allowed to create rules. - - - ); - } + if (!identifier && !canCreateGrafanaRules && !canCreateCloudRules) { + return ( + + + Sorry! You are not allowed to create rules. + + + ); + } - if (identifier && !canEditRules(identifier.ruleSourceName)) { - return ( - - - Sorry! You are not allowed to edit rules. - - - ); - } + if (identifier && !canEditRules(identifier.ruleSourceName)) { + return ( + + + Sorry! You are not allowed to edit rules. + + + ); + } - if (identifier) { - return ; - } + if (identifier) { + return ( + + ); + } - if (copyFromIdentifier) { - return ; - } - // new alert rule - return ; - }, [ - canCreateCloudRules, - canCreateGrafanaRules, - canEditRules, - copyFromIdentifier, - identifier, - queryDefaults, - isManualRestore, - ]); + if (cloneIdentifier) { + return ( + + ); + } + + // for new alerting or recording rules + return ; +}; + +export const RECORDING_TYPE = ['grafana-recording', 'recording']; + +/** + * This one is used for creating new rules (both alerting and recording rules) + */ +function NewRuleEditor() { + const prefill = useDefaultsFromQuery(); + const isManualRestore = useManualRestore(); + const { type = '', identifier = '' } = useRuleEditorPathParams(); + + const isExisting = Boolean(identifier); + const isRecordingRule = RECORDING_TYPE.includes(type); + + const newText = isRecordingRule + ? t('alerting.editor.new-recording-rule', 'New recording rule') + : t('alerting.editor.new-alert-rule', 'New alert rule'); + + const editText = isRecordingRule + ? t('alerting.editor.edit-recording-rule', 'Edit recording rule') + : t('alerting.editor.edit-alert-rule', 'Edit alert rule'); return ( - - {getContent()} + + ); -}; +} // The pageNav property makes it difficult to only rely on AlertingPageWrapper // to catch errors. @@ -112,13 +117,16 @@ function useRuleEditorPathParams() { return { identifier, type }; } -function useRuleEditorQueryParams() { - const { type } = useParams(); - +function useIdentifierFromCopy() { const [searchParams] = useURLSearchParams(); const copyFromId = searchParams.get('copyFrom') ?? undefined; - const copyFromIdentifier = ruleId.tryParse(copyFromId); - const isManualRestore = searchParams.has('isManualRestore'); + + return ruleId.tryParse(copyFromId); +} + +function useDefaultsFromQuery() { + const { type } = useRuleEditorPathParams(); + const [searchParams] = useURLSearchParams(); const ruleType = translateRouteParamToRuleType(type); @@ -126,5 +134,12 @@ function useRuleEditorQueryParams() { ? formValuesFromQueryParams(searchParams.get('defaults') ?? '', ruleType) : undefined; - return { copyFromIdentifier, queryDefaults, isManualRestore }; + return queryDefaults; +} + +function useManualRestore() { + const [searchParams] = useURLSearchParams(); + const isManualRestore = searchParams.has('isManualRestore'); + + return isManualRestore; } diff --git a/public/app/features/alerting/unified/rule-editor/RuleEditorCloudRules.test.tsx b/public/app/features/alerting/unified/rule-editor/RuleEditorCloudRules.test.tsx index 0ed8255932b..185a6b69d32 100644 --- a/public/app/features/alerting/unified/rule-editor/RuleEditorCloudRules.test.tsx +++ b/public/app/features/alerting/unified/rule-editor/RuleEditorCloudRules.test.tsx @@ -80,7 +80,7 @@ describe('RuleEditor cloud', () => { // save and check what was sent to backend const capture = captureRequests(); - await user.click(ui.buttons.saveAndExit.get()); + await user.click(ui.buttons.save.get()); const requests = await capture; const serializedRequests = await serializeRequests(requests); diff --git a/public/app/features/alerting/unified/rule-editor/RuleEditorExisting.test.tsx b/public/app/features/alerting/unified/rule-editor/RuleEditorExisting.test.tsx index ad58c4a002d..b8f3992e31e 100644 --- a/public/app/features/alerting/unified/rule-editor/RuleEditorExisting.test.tsx +++ b/public/app/features/alerting/unified/rule-editor/RuleEditorExisting.test.tsx @@ -96,27 +96,19 @@ describe('RuleEditor grafana managed rules', () => { //check that folder is in the list expect(ui.inputs.folder.get()).toHaveTextContent(new RegExp(folder.title)); expect(ui.inputs.annotationValue(0).get()).toHaveValue(grafanaRulerRule.annotations[Annotation.summary]); + expect(screen.getByText('New folder')).toBeInTheDocument(); //check that slashed folders are not in the list expect(ui.inputs.folder.get()).toHaveTextContent(new RegExp(folder.title)); expect(ui.inputs.folder.get()).not.toHaveTextContent(new RegExp(slashedFolder.title)); - //check that slashes warning is only shown once user search slashes - //todo: move this test to a unit test in FolderAndGroup unit test - // const folderInput = await ui.inputs.folderContainer.find(); - // expect(within(folderInput).queryByText("Folders with '/' character are not allowed.")).not.toBeInTheDocument(); - // await user.type(within(folderInput).getByRole('combobox'), 'new slashed //'); - // expect(within(folderInput).getByText("Folders with '/' character are not allowed.")).toBeInTheDocument(); - // await user.keyboard('{backspace} {backspace}{backspace}'); - // expect(within(folderInput).queryByText("Folders with '/' character are not allowed.")).not.toBeInTheDocument(); - // add an annotation await user.click(screen.getByText('Add custom annotation')); await user.type(screen.getByPlaceholderText('Enter custom annotation name...'), 'custom'); await user.type(screen.getByPlaceholderText('Enter custom annotation content...'), 'value'); // save and check what was sent to backend - await user.click(ui.buttons.saveAndExit.get()); + await user.click(ui.buttons.save.get()); }); it('saves evaluation interval correctly', async () => { @@ -140,7 +132,7 @@ describe('RuleEditor grafana managed rules', () => { (req) => req.method === 'POST' && req.url.includes('/api/ruler/grafana/api/v1/rules/uuid020c61ef') ); - await user.click(ui.buttons.saveAndExit.get()); + await user.click(ui.buttons.save.get()); const [request] = await capture; const postBody = await request.json(); @@ -158,6 +150,6 @@ describe('Data source managed rules', () => { it('should show an error if the data source does not exist', async () => { renderRuleEditor('cri%24grafana-cloudd%24delete me%24delete me 3%24recording_rule_delete_2%24-476183141'); - expect(await screen.findByText(/unable to find data source/i)).toBeInTheDocument(); + expect(await screen.findByText(/not found/i)).toBeInTheDocument(); }); }); diff --git a/public/app/features/alerting/unified/rule-editor/RuleEditorGrafanaRules.test.tsx b/public/app/features/alerting/unified/rule-editor/RuleEditorGrafanaRules.test.tsx index 9505c96fd23..0c9433406c7 100644 --- a/public/app/features/alerting/unified/rule-editor/RuleEditorGrafanaRules.test.tsx +++ b/public/app/features/alerting/unified/rule-editor/RuleEditorGrafanaRules.test.tsx @@ -1,7 +1,7 @@ import * as React from 'react'; import { renderRuleEditor, ui } from 'test/helpers/alertingRuleEditor'; import { clickSelectOption, selectOptionInTest } from 'test/helpers/selectOptionInTest'; -import { screen } from 'test/test-utils'; +import { screen, waitFor } from 'test/test-utils'; import { byRole } from 'testing-library-selector'; import { contextSrv } from 'app/core/services/context_srv'; @@ -73,7 +73,7 @@ describe('RuleEditor grafana managed rules', () => { await clickSelectOption(groupInput, grafanaRulerGroup.name); await user.type(ui.inputs.annotationValue(1).get(), 'some description'); - await user.click(ui.buttons.saveAndExit.get()); + await user.click(ui.buttons.save.get()); expect(await screen.findByRole('status')).toHaveTextContent('Rule added successfully'); const requests = await capture; @@ -128,12 +128,12 @@ describe('RuleEditor grafana managed rules', () => { const nameInput = await ui.inputs.name.find(); expect(nameInput).toHaveValue(grafanaRulerRule.grafana_alert.title); //check that folder is in the list - expect(ui.inputs.folder.get()).toHaveTextContent(new RegExp(folder.title)); + await waitFor(() => expect(ui.inputs.folder.get()).toHaveTextContent(new RegExp(folder.title))); expect(ui.inputs.annotationValue(0).get()).toHaveValue(grafanaRulerRule.annotations[Annotation.summary]); expect(ui.manualRestoreBanner.get()).toBeInTheDocument(); // check that manual restore banner is shown - await user.click(ui.buttons.saveAndExit.get()); + await user.click(ui.buttons.save.get()); expect(await screen.findByRole('status')).toHaveTextContent('Rule added successfully'); const requests = await capture; diff --git a/public/app/features/alerting/unified/rule-editor/RuleEditorRecordingRule.test.tsx b/public/app/features/alerting/unified/rule-editor/RuleEditorRecordingRule.test.tsx index 29c1f926dbe..19166c01ea4 100644 --- a/public/app/features/alerting/unified/rule-editor/RuleEditorRecordingRule.test.tsx +++ b/public/app/features/alerting/unified/rule-editor/RuleEditorRecordingRule.test.tsx @@ -80,7 +80,7 @@ describe('RuleEditor recording rules', () => { await userEvent.type(await ui.inputs.expr.find(), 'up == 1'); // try to save, find out that recording rule name is invalid - await userEvent.click(ui.buttons.saveAndExit.get()); + await userEvent.click(ui.buttons.save.get()); await waitFor(() => expect( byText( @@ -95,7 +95,7 @@ describe('RuleEditor recording rules', () => { // save and check what was sent to backend const capture = captureRequests(); - await userEvent.click(ui.buttons.saveAndExit.get()); + await userEvent.click(ui.buttons.save.get()); const requests = await capture; const serializedRequests = await serializeRequests(requests); diff --git a/public/app/features/alerting/unified/rule-editor/clone.utils.ts b/public/app/features/alerting/unified/rule-editor/clone.utils.ts new file mode 100644 index 00000000000..a4b38a3b7a7 --- /dev/null +++ b/public/app/features/alerting/unified/rule-editor/clone.utils.ts @@ -0,0 +1,39 @@ +import { cloneDeep } from 'lodash'; + +import { RuleWithLocation } from 'app/types/unified-alerting'; +import { RulerRuleDTO } from 'app/types/unified-alerting-dto'; + +import { generateCopiedName } from '../utils/duplicate'; +import { getRuleName, rulerRuleType } from '../utils/rules'; + +export function changeRuleName(rule: RulerRuleDTO, newName: string) { + if (rulerRuleType.grafana.rule(rule)) { + rule.grafana_alert.title = newName; + } + if (rulerRuleType.dataSource.alertingRule(rule)) { + rule.alert = newName; + } + + if (rulerRuleType.dataSource.recordingRule(rule)) { + rule.record = newName; + } +} + +export function cloneRuleDefinition(rule: RuleWithLocation) { + const ruleClone = cloneDeep(rule); + changeRuleName( + ruleClone.rule, + generateCopiedName(getRuleName(ruleClone.rule), ruleClone.group.rules.map(getRuleName)) + ); + + if (rulerRuleType.grafana.rule(ruleClone.rule)) { + ruleClone.rule.grafana_alert.uid = ''; + + // Provisioned alert rules have provisioned alert group which cannot be used in UI + if (Boolean(ruleClone.rule.grafana_alert.provenance)) { + ruleClone.group = { name: '', rules: ruleClone.group.rules }; + } + } + + return ruleClone; +} diff --git a/public/app/features/alerting/unified/utils/misc.ts b/public/app/features/alerting/unified/utils/misc.ts index 033a93c1272..daa97475a52 100644 --- a/public/app/features/alerting/unified/utils/misc.ts +++ b/public/app/features/alerting/unified/utils/misc.ts @@ -19,6 +19,7 @@ import { DataSourceRuleGroupIdentifier, FilterState, RuleIdentifier, + RuleWithLocation, RulesSource, SilenceFilterState, } from 'app/types/unified-alerting'; @@ -55,6 +56,15 @@ export function createViewLinkV2( return rulesNav.detailsPageLink(ruleSourceName, identifier, returnTo ? { returnTo } : undefined); } +export function createViewLinkFromRuleWithLocation(ruleWithLocation: RuleWithLocation) { + const ruleSourceName = ruleWithLocation.ruleSourceName; + const identifier = ruleId.fromRuleWithLocation(ruleWithLocation); + const paramId = encodeURIComponent(ruleId.stringifyIdentifier(identifier)); + const paramSource = encodeURIComponent(ruleSourceName); + + return createRelativeUrl(`/alerting/${paramSource}/${paramId}/view`); +} + export function createExploreLink(datasource: DataSourceRef, query: string) { const { uid, type } = datasource; diff --git a/public/app/features/alerting/unified/utils/rules.ts b/public/app/features/alerting/unified/utils/rules.ts index 13aa9982983..bbd7a08db95 100644 --- a/public/app/features/alerting/unified/utils/rules.ts +++ b/public/app/features/alerting/unified/utils/rules.ts @@ -208,6 +208,10 @@ export function getPendingPeriod(rule: CombinedRule): string | undefined { return undefined; } +export function getPendingPeriodFromRulerRule(rule: RulerRuleDTO) { + return rulerRuleType.any.alertingRule(rule) ? rule.for : undefined; +} + export function getKeepFiringfor(rule: CombinedRule): string | undefined { if (rulerRuleType.any.recordingRule(rule.rulerRule)) { return undefined; diff --git a/public/locales/en-US/grafana.json b/public/locales/en-US/grafana.json index fa21b59fe76..8f807d1f473 100644 --- a/public/locales/en-US/grafana.json +++ b/public/locales/en-US/grafana.json @@ -408,11 +408,9 @@ }, "alert-rule-form": { "action-buttons": { - "delete": "Delete", "edit-yaml": "Edit YAML", - "save-exit": "Save rule and exit" - }, - "title-delete-rule": "Delete rule" + "save": "Save" + } }, "alert-rule-name-and-metric": { "aria-label-name": "name", @@ -895,12 +893,18 @@ "text-loading-template": "Loading template...", "title-failed-to-fetch-notification-template": "Failed to fetch notification template" }, + "editor": { + "edit-alert-rule": "Edit alert rule", + "edit-recording-rule": "Edit recording rule", + "new-alert-rule": "New alert rule", + "new-recording-rule": "New recording rule" + }, "error-modal": { "failed-to-update-your-configuration": "Failed to update your configuration:", "title-something-went-wrong": "Something went wrong" }, "evaluation-behavior-summary": { - "evaluate": "Every {{every}}", + "evaluate": "Every {{interval}}", "label-evaluate": "Evaluate", "label-evaluation-time": "Evaluation time", "label-last-evaluation": "Last evaluation", @@ -929,7 +933,6 @@ "existing-rule-editor": { "sorry-permission": "Sorry! You do not have permission to edit this rule.", "sorry-this-rule-does-not-exist": "Sorry! This rule does not exist.", - "text-loading-rule": "Loading rule...", "title-cannot-edit-rule": "Cannot edit rule", "title-failed-to-load-rule": "Failed to load rule", "title-rule-not-found": "Rule not found" diff --git a/public/test/helpers/alertingRuleEditor.tsx b/public/test/helpers/alertingRuleEditor.tsx index 9e601269b11..0375a9123bd 100644 --- a/public/test/helpers/alertingRuleEditor.tsx +++ b/public/test/helpers/alertingRuleEditor.tsx @@ -39,7 +39,7 @@ export const ui = { byTestId(selectors.components.AlertRules.stepAdvancedModeSwitch(stepNo.toString())), }, buttons: { - saveAndExit: byRole('button', { name: 'Save rule and exit' }), + save: byTestId('save-rule'), addAnnotation: byRole('button', { name: /Add info/ }), addLabel: byRole('button', { name: /Add label/ }), preview: byRole('button', { name: /^Preview$/ }),