From b4442b4f22139ecca436db0ef8c4230b52e4ac52 Mon Sep 17 00:00:00 2001 From: Sonia Aguilar <33540275+soniaAguilarPeiron@users.noreply.github.com> Date: Thu, 10 Apr 2025 16:18:20 +0200 Subject: [PATCH] Alerting: Detect target folder rules and show warning (#103673) * detect target folder rules and show warning * show detected rules in target folder in a collapsed section * fix detecting rules that might be overwritten * refactor * remove undefined * fix text --- .../import-to-gma/ConfirmConvertModal.tsx | 86 ++++++++++---- .../import-to-gma/ImportFromDSRules.tsx | 1 - .../unified/components/import-to-gma/hooks.ts | 108 ++++++++++++++++++ .../rule-editor/GrafanaEvaluationBehavior.tsx | 18 +-- .../unified/hooks/useFetchGroupsForFolder.ts | 20 ++++ public/locales/en-US/grafana.json | 9 +- 6 files changed, 200 insertions(+), 42 deletions(-) create mode 100644 public/app/features/alerting/unified/components/import-to-gma/hooks.ts create mode 100644 public/app/features/alerting/unified/hooks/useFetchGroupsForFolder.ts diff --git a/public/app/features/alerting/unified/components/import-to-gma/ConfirmConvertModal.tsx b/public/app/features/alerting/unified/components/import-to-gma/ConfirmConvertModal.tsx index ea6fea591c4..ffa8981ee37 100644 --- a/public/app/features/alerting/unified/components/import-to-gma/ConfirmConvertModal.tsx +++ b/public/app/features/alerting/unified/components/import-to-gma/ConfirmConvertModal.tsx @@ -1,10 +1,11 @@ import { css } from '@emotion/css'; import { isEmpty } from 'lodash'; -import { ComponentProps } from 'react'; +import { ComponentProps, useMemo } from 'react'; import { useFormContext } from 'react-hook-form'; +import { useToggle } from 'react-use'; import { locationService } from '@grafana/runtime'; -import { Alert, CodeEditor, ConfirmModal, Modal, Stack, Text, useStyles2 } from '@grafana/ui'; +import { Alert, CodeEditor, Collapse, ConfirmModal, Modal, Stack, Text, useStyles2 } from '@grafana/ui'; import { useAppNotification } from 'app/core/copy/appNotification'; import { Trans, t } from 'app/core/internationalization'; import { stringifyErrorLike } from 'app/features/alerting/unified/utils/misc'; @@ -14,9 +15,9 @@ import { trackImportToGMAError, trackImportToGMASuccess } from '../../Analytics' import { convertToGMAApi } from '../../api/convertToGMAApi'; import { GRAFANA_ORIGIN_LABEL } from '../../utils/labels'; import { createListFilterLink } from '../../utils/navigation'; -import { useGetRulerRules } from '../rule-editor/useAlertRuleSuggestions'; import { ImportFormValues } from './ImportFromDSRules'; +import { useGetRulesThatMightBeOverwritten, useGetRulesToBeImported } from './hooks'; type ModalProps = Pick, 'isOpen' | 'onDismiss'> & { isOpen: boolean; @@ -58,14 +59,35 @@ export const ConfirmConversionModal = ({ isOpen, onDismiss }: ModalProps) => { 'ruleGroup', 'targetDatasourceUID', ]); - const { rulerRules } = useGetRulerRules(selectedDatasourceName || undefined); + + const dataSourceToFetch = isOpen ? (selectedDatasourceName ?? '') : undefined; + const { rulesToBeImported, isloadingCloudRules } = useGetRulesToBeImported(!isOpen, dataSourceToFetch); + const { filteredConfig: rulerRulesToPayload, someRulesAreSkipped } = useMemo( + () => filterRulerRulesConfig(rulesToBeImported, namespace, ruleGroup), + [rulesToBeImported, namespace, ruleGroup] + ); + const { rulesThatMightBeOverwritten } = useGetRulesThatMightBeOverwritten(!isOpen, targetFolder, rulerRulesToPayload); + const [convert] = convertToGMAApi.useConvertToGMAMutation(); const notifyApp = useAppNotification(); - const { filteredConfig: rulerRulesToPayload, someRulesAreSkipped } = filterRulerRulesConfig( - rulerRules, - namespace, - ruleGroup - ); + + if (isloadingCloudRules) { + return ( + + + {t( + 'alerting.import-to-gma.confirm-modal.loading-body', + 'Preparing data to be imported.This can take a while...' + )} + + + ); + } async function onConvertConfirm() { try { @@ -122,7 +144,7 @@ export const ConfirmConversionModal = ({ isOpen, onDismiss }: ModalProps) => { // translations for texts in the modal const title = t('alerting.import-to-gma.confirm-modal.title', 'Confirm import'); - const confirmText = t('alerting.import-to-gma.confirm-modal.confirm', 'Yes, import'); + const confirmText = t('alerting.import-to-gma.confirm-modal.confirm', 'Import'); return ( { modalClass={styles.modal} body={ - - - - If the target folder is not empty, some rules may be overwritten or removed. Are you sure you want to - import these alert rules to Grafana-managed rules? - - - + {!isEmpty(rulesThatMightBeOverwritten) && ( + + )} {someRulesAreSkipped && } - - These are the list of rules that will be imported: - + The following alert rules will be imported: {rulerRulesToPayload && } @@ -227,3 +242,32 @@ const getStyles = () => ({ width: '800px', }), }); + +function TargetFolderNotEmptyWarning({ targetFolderRules }: { targetFolderRules: RulerRulesConfigDTO }) { + const [showTargetRules, toggleShowTargetRules] = useToggle(false); + return ( + + + + + The target folder is not empty, some rules may be overwritten or removed. Are you sure you want to import + these alert rules to Grafana-managed rules? + + + + {targetFolderRules && ( + + + + )} + + ); +} diff --git a/public/app/features/alerting/unified/components/import-to-gma/ImportFromDSRules.tsx b/public/app/features/alerting/unified/components/import-to-gma/ImportFromDSRules.tsx index 938aeff00df..e795327e6cb 100644 --- a/public/app/features/alerting/unified/components/import-to-gma/ImportFromDSRules.tsx +++ b/public/app/features/alerting/unified/components/import-to-gma/ImportFromDSRules.tsx @@ -143,7 +143,6 @@ const ImportFromDSRules = () => { 'alerting.import-from-dsrules.description-folder-import-rules', 'The folder to import the rules to' )} - invalid={!!errors.selectedDatasourceName} error={errors.selectedDatasourceName?.message} htmlFor="folder-picker" > diff --git a/public/app/features/alerting/unified/components/import-to-gma/hooks.ts b/public/app/features/alerting/unified/components/import-to-gma/hooks.ts new file mode 100644 index 00000000000..c7c9e233181 --- /dev/null +++ b/public/app/features/alerting/unified/components/import-to-gma/hooks.ts @@ -0,0 +1,108 @@ +import { isEmpty } from 'lodash'; +import { useEffect, useState } from 'react'; +import { lastValueFrom } from 'rxjs'; + +import { getBackendSrv } from '@grafana/runtime'; +import { FolderDTO } from 'app/types'; +import { RulerRulesConfigDTO } from 'app/types/unified-alerting-dto'; + +import { alertRuleApi } from '../../api/alertRuleApi'; +import { GRAFANA_RULER_CONFIG } from '../../api/featureDiscoveryApi'; +import { Folder } from '../../types/rule-form'; +import { useGetRulerRules } from '../rule-editor/useAlertRuleSuggestions'; + +async function getNestedFoldersIn(uid: string) { + const response = await lastValueFrom( + getBackendSrv().fetch({ + url: `/api/folders`, + params: { parentUid: uid }, + method: 'GET', + showErrorAlert: false, + showSuccessAlert: false, + }) + ); + + return response?.data; +} + +export function useGetNestedFolders(folderUID: string, skip = false) { + const [nestedFolders, setNestedFolders] = useState([]); + + useEffect(() => { + (async () => { + const nestedFoldersIn = skip ? [] : await getNestedFoldersIn(folderUID); + setNestedFolders(nestedFoldersIn); + })(); + }, [folderUID, skip]); + + return nestedFolders; +} + +export function useGetRulesThatMightBeOverwritten( + skip: boolean, + targetFolder: Folder | undefined, + rulesToBeImported: RulerRulesConfigDTO +) { + // get nested folders in the target folder + const nestedFoldersInTargetFolder = useGetNestedFolders(targetFolder?.uid || '', skip); + const skipFiltering = skip || nestedFoldersInTargetFolder.length === 0; + const rulesThatMightBeOverwritten = useFilterRulesThatMightBeOverwritten( + nestedFoldersInTargetFolder, + rulesToBeImported, + skipFiltering + ); + + return { rulesThatMightBeOverwritten }; +} + +export function useGetRulesToBeImported(skip: boolean, selectedDatasourceName: string | undefined) { + // we need to skip fetching and filtering if the modal is not open + const dataSourceToFetch = !skip ? selectedDatasourceName : undefined; + const { rulerRules: rulesToBeImported, isLoading: isloadingCloudRules } = useGetRulerRules(dataSourceToFetch); + + return { rulesToBeImported, isloadingCloudRules }; +} +function useFilterRulesThatMightBeOverwritten( + targetNestedFolders: FolderDTO[], + rulesToBeImported: RulerRulesConfigDTO, + skip = true +): RulerRulesConfigDTO { + const [fetchRulesByFolderUID] = alertRuleApi.endpoints.rulerNamespace.useLazyQuery(); + const [rulesThatMightBeOverwritten, setRulesThatMightBeOverwritten] = useState({}); + + useEffect(() => { + if (skip || isEmpty(targetNestedFolders) || isEmpty(rulesToBeImported)) { + setRulesThatMightBeOverwritten({}); + return; + } + // filter targetNestedFolders to only include folders that are in the rulesToBeImported + const targetNestedFoldersFiltered = targetNestedFolders.filter((folder) => { + return Object.keys(rulesToBeImported).includes(folder.title); + }); + const fetchRules = async () => { + const results: RulerRulesConfigDTO = {}; + + await Promise.all( + targetNestedFoldersFiltered.map(async (folder) => { + const { data: rules } = await fetchRulesByFolderUID({ + namespace: folder.uid, + rulerConfig: GRAFANA_RULER_CONFIG, + }); + + if (rules) { + const folderWithParentTitle = Object.keys(rules)[0]; + if (folderWithParentTitle) { + results[folderWithParentTitle] = rules[folderWithParentTitle] || []; + } + } + }) + ); + + setRulesThatMightBeOverwritten(results); + }; + + fetchRules(); + }, [targetNestedFolders, rulesToBeImported, skip, fetchRulesByFolderUID]); + + return rulesThatMightBeOverwritten; +} diff --git a/public/app/features/alerting/unified/components/rule-editor/GrafanaEvaluationBehavior.tsx b/public/app/features/alerting/unified/components/rule-editor/GrafanaEvaluationBehavior.tsx index af7a49e0d37..917664a5a08 100644 --- a/public/app/features/alerting/unified/components/rule-editor/GrafanaEvaluationBehavior.tsx +++ b/public/app/features/alerting/unified/components/rule-editor/GrafanaEvaluationBehavior.tsx @@ -24,9 +24,8 @@ import { import { Trans, t } from 'app/core/internationalization'; import { RulerRulesConfigDTO } from 'app/types/unified-alerting-dto'; -import { alertRuleApi } from '../../api/alertRuleApi'; -import { GRAFANA_RULER_CONFIG } from '../../api/featureDiscoveryApi'; import { evaluateEveryValidationOptions } from '../../group-details/validation'; +import { useFetchGroupsForFolder } from '../../hooks/useFetchGroupsForFolder'; import { DEFAULT_GROUP_EVALUATION_INTERVAL } from '../../rule-editor/formDefaults'; import { RuleFormValues } from '../../types/rule-form'; import { @@ -48,21 +47,6 @@ import { RuleEditorSection } from './RuleEditorSection'; export const MIN_TIME_RANGE_STEP_S = 10; // 10 seconds export const MAX_GROUP_RESULTS = 1000; -const useFetchGroupsForFolder = (folderUid: string) => { - // fetch the ruler rules from the database so we can figure out what other "groups" are already defined - // for our folders - return alertRuleApi.endpoints.rulerNamespace.useQuery( - { - namespace: folderUid, - rulerConfig: GRAFANA_RULER_CONFIG, - }, - { - refetchOnMountOrArgChange: true, - skip: !folderUid, - } - ); -}; - const namespaceToGroupOptions = (rulerNamespace: RulerRulesConfigDTO, enableProvisionedGroups: boolean) => { const folderGroups = Object.values(rulerNamespace).flat(); diff --git a/public/app/features/alerting/unified/hooks/useFetchGroupsForFolder.ts b/public/app/features/alerting/unified/hooks/useFetchGroupsForFolder.ts new file mode 100644 index 00000000000..896051258b7 --- /dev/null +++ b/public/app/features/alerting/unified/hooks/useFetchGroupsForFolder.ts @@ -0,0 +1,20 @@ +import { alertRuleApi } from '../api/alertRuleApi'; +import { GRAFANA_RULER_CONFIG } from '../api/featureDiscoveryApi'; + +/** + * Fetch groups for a given folder UID. + * This hook only returns the rules that are directly in the folder. Rules in subfolders are not included. + * @param folderUid - The UID of the folder to fetch groups for. + */ +export const useFetchGroupsForFolder = (folderUid: string) => { + return alertRuleApi.endpoints.rulerNamespace.useQuery( + { + namespace: folderUid, + rulerConfig: GRAFANA_RULER_CONFIG, + }, + { + refetchOnMountOrArgChange: true, + skip: !folderUid, + } + ); +}; diff --git a/public/locales/en-US/grafana.json b/public/locales/en-US/grafana.json index 51a56abfae4..042d9fff4b2 100644 --- a/public/locales/en-US/grafana.json +++ b/public/locales/en-US/grafana.json @@ -1047,13 +1047,16 @@ "additional-settings": "Additional settings", "alert-rules": "Alert rules", "confirm-modal": { - "confirm": "Yes, import", + "confirm": "Import", + "loading": "Loading...", + "loading-body": "Preparing data to be imported.This can take a while...", "no-rules-body": "There are no rules to import. Please select a different namespace or rule group.", "no-rules-title": "No rules to import", "plugin-rules-warning": { "text": "We have detected that some rules are managed by plugins. These rules will not be imported.", "title": "Some rules are excluded from import" }, + "target-folder-rules": "Target folder rules that might be overwritten", "title": "Confirm import" }, "datasource": { @@ -2004,8 +2007,8 @@ }, "to-gma": { "confirm-modal": { - "body": "If the target folder is not empty, some rules may be overwritten or removed. Are you sure you want to import these alert rules to Grafana-managed rules?", - "summary": "These are the list of rules that will be imported:", + "body": "The target folder is not empty, some rules may be overwritten or removed. Are you sure you want to import these alert rules to Grafana-managed rules?", + "summary": "The following alert rules will be imported:", "title-warning": "Warning" } },