diff --git a/.betterer.results b/.betterer.results index 96b03ea2ab5..6311022a3ec 100644 --- a/.betterer.results +++ b/.betterer.results @@ -1189,11 +1189,6 @@ exports[`better eslint`] = { "public/app/features/alerting/unified/components/create-folder/CreateNewFolder.tsx:5381": [ [0, 0, 0, "Add noMargin prop to Field components to remove built-in margins. Use layout components like Stack or Grid with the gap prop instead for consistent spacing.", "0"] ], - "public/app/features/alerting/unified/components/import-to-gma/ImportFromDSRules.tsx:5381": [ - [0, 0, 0, "Add noMargin prop to Field components to remove built-in margins. Use layout components like Stack or Grid with the gap prop instead for consistent spacing.", "0"], - [0, 0, 0, "Add noMargin prop to Field components to remove built-in margins. Use layout components like Stack or Grid with the gap prop instead for consistent spacing.", "1"], - [0, 0, 0, "Add noMargin prop to Field components to remove built-in margins. Use layout components like Stack or Grid with the gap prop instead for consistent spacing.", "2"] - ], "public/app/features/alerting/unified/components/import-to-gma/NamespaceAndGroupFilter.tsx:5381": [ [0, 0, 0, "Add noMargin prop to Field components to remove built-in margins. Use layout components like Stack or Grid with the gap prop instead for consistent spacing.", "0"], [0, 0, 0, "Add noMargin prop to Field components to remove built-in margins. Use layout components like Stack or Grid with the gap prop instead for consistent spacing.", "1"] diff --git a/public/app/features/alerting/routes.tsx b/public/app/features/alerting/routes.tsx index 0b66eed11f8..12a8dbfbadf 100644 --- a/public/app/features/alerting/routes.tsx +++ b/public/app/features/alerting/routes.tsx @@ -212,18 +212,6 @@ export function getAlertingRoutes(cfg = config): RouteDescriptor[] { ) ), }, - { - path: '/alerting/import-datasource-managed-rules', - roles: evaluateAccess([AccessControlAction.AlertingRuleCreate, AccessControlAction.AlertingRuleExternalRead]), - component: config.featureToggles.alertingMigrationUI - ? importAlertingComponent( - () => - import( - /* webpackChunkName: "AlertingImportFromDSRules"*/ 'app/features/alerting/unified/components/import-to-gma/ImportFromDSRules' - ) - ) - : () => , - }, { path: '/alerting/recently-deleted/', roles: () => ['Admin'], @@ -238,12 +226,12 @@ export function getAlertingRoutes(cfg = config): RouteDescriptor[] { }, { path: '/alerting/import-datasource-managed-rules', - roles: evaluateAccess([AccessControlAction.AlertingRuleCreate, AccessControlAction.AlertingRuleExternalRead]), + roles: () => ['Admin'], component: config.featureToggles.alertingMigrationUI ? importAlertingComponent( () => import( - /* webpackChunkName: "AlertingImportFromDSRules"*/ 'app/features/alerting/unified/components/import-to-gma/ImportFromDSRules' + /* webpackChunkName: "AlertingImportFromDSRules"*/ 'app/features/alerting/unified/components/import-to-gma/ImportToGMARules' ) ) : () => , diff --git a/public/app/features/alerting/unified/Analytics.ts b/public/app/features/alerting/unified/Analytics.ts index c531794482a..efaae6b6fc1 100644 --- a/public/app/features/alerting/unified/Analytics.ts +++ b/public/app/features/alerting/unified/Analytics.ts @@ -229,12 +229,12 @@ export const trackDeletedRuleRestoreFail = async () => { reportInteraction('grafana_alerting_deleted_rule_restore_error'); }; -export const trackImportToGMASuccess = async () => { - reportInteraction('grafana_alerting_import_to_gma_success'); +export const trackImportToGMASuccess = async (payload: { importSource: 'yaml' | 'datasource' }) => { + reportInteraction('grafana_alerting_import_to_gma_success', { ...payload }); }; -export const trackImportToGMAError = async () => { - reportInteraction('grafana_alerting_import_to_gma_error'); +export const trackImportToGMAError = async (payload: { importSource: 'yaml' | 'datasource' }) => { + reportInteraction('grafana_alerting_import_to_gma_error', { ...payload }); }; interface RulesSearchInteractionPayload { diff --git a/public/app/features/alerting/unified/components/import-to-gma/ConfirmConvertModal.test.tsx b/public/app/features/alerting/unified/components/import-to-gma/ConfirmConvertModal.test.tsx new file mode 100644 index 00000000000..cbd9f55516e --- /dev/null +++ b/public/app/features/alerting/unified/components/import-to-gma/ConfirmConvertModal.test.tsx @@ -0,0 +1,187 @@ +import { RulerRulesConfigDTO } from 'app/types/unified-alerting-dto'; + +import { GRAFANA_ORIGIN_LABEL } from '../../utils/labels'; + +import { filterRulerRulesConfig } from './ConfirmConvertModal'; + +describe('filterRulerRulesConfig', () => { + const mockRulesConfig: RulerRulesConfigDTO = { + namespace1: [ + { + name: 'group1', + rules: [ + { + alert: 'Alert1', + expr: 'up == 0', + labels: { + severity: 'warning', + }, + }, + { + alert: 'Alert2', + expr: 'down == 1', + labels: { + [GRAFANA_ORIGIN_LABEL]: 'true', + }, + }, + ], + }, + { + name: 'group2', + rules: [ + { + alert: 'Alert3', + expr: 'error == 1', + }, + ], + }, + ], + namespace2: [ + { + name: 'group3', + rules: [ + { + alert: 'Alert4', + expr: 'test == 0', + }, + ], + }, + ], + }; + + it('should filter by namespace', () => { + const { filteredConfig, someRulesAreSkipped } = filterRulerRulesConfig(mockRulesConfig, 'namespace1'); + + expect(filteredConfig).toEqual({ + namespace1: [ + { + name: 'group1', + rules: [ + { + alert: 'Alert1', + expr: 'up == 0', + labels: { + severity: 'warning', + }, + }, + ], + }, + { + name: 'group2', + rules: [ + { + alert: 'Alert3', + expr: 'error == 1', + }, + ], + }, + ], + }); + expect(someRulesAreSkipped).toBe(true); + }); + + it('should filter by group name', () => { + const { filteredConfig, someRulesAreSkipped } = filterRulerRulesConfig(mockRulesConfig, 'namespace1', 'group1'); + + expect(filteredConfig).toEqual({ + namespace1: [ + { + name: 'group1', + rules: [ + { + alert: 'Alert1', + expr: 'up == 0', + labels: { + severity: 'warning', + }, + }, + ], + }, + ], + }); + expect(someRulesAreSkipped).toBe(true); + }); + + it('should filter out rules with grafana origin label', () => { + const { filteredConfig, someRulesAreSkipped } = filterRulerRulesConfig(mockRulesConfig); + + expect(filteredConfig).toEqual({ + namespace1: [ + { + name: 'group1', + rules: [ + { + alert: 'Alert1', + expr: 'up == 0', + labels: { + severity: 'warning', + }, + }, + ], + }, + { + name: 'group2', + rules: [ + { + alert: 'Alert3', + expr: 'error == 1', + }, + ], + }, + ], + namespace2: [ + { + name: 'group3', + rules: [ + { + alert: 'Alert4', + expr: 'test == 0', + }, + ], + }, + ], + }); + expect(someRulesAreSkipped).toBe(true); + }); + + it('should return empty config when no rules match filters', () => { + const { filteredConfig, someRulesAreSkipped } = filterRulerRulesConfig(mockRulesConfig, 'nonexistent-namespace'); + + expect(filteredConfig).toEqual({}); + expect(someRulesAreSkipped).toBe(false); + }); + + it('should handle empty rules config', () => { + const { filteredConfig, someRulesAreSkipped } = filterRulerRulesConfig({}); + + expect(filteredConfig).toEqual({}); + expect(someRulesAreSkipped).toBe(false); + }); + + it('should handle empty groups', () => { + const emptyGroupsConfig: RulerRulesConfigDTO = { + namespace1: [], + }; + + const { filteredConfig, someRulesAreSkipped } = filterRulerRulesConfig(emptyGroupsConfig); + + expect(filteredConfig).toEqual({}); + expect(someRulesAreSkipped).toBe(false); + }); + + it('should handle empty rules array', () => { + const emptyRulesConfig: RulerRulesConfigDTO = { + namespace1: [ + { + name: 'group1', + rules: [], + }, + ], + }; + + const { filteredConfig, someRulesAreSkipped } = filterRulerRulesConfig(emptyRulesConfig); + + expect(filteredConfig).toEqual({}); + expect(someRulesAreSkipped).toBe(false); + }); +}); 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 f04454b5694..c83dbdf5054 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,8 +1,7 @@ import { css } from '@emotion/css'; import { isEmpty } from 'lodash'; import { ComponentProps, useMemo } from 'react'; -import { useFormContext } from 'react-hook-form'; -import { useToggle } from 'react-use'; +import { useAsync, useToggle } from 'react-use'; import { Trans, useTranslate } from '@grafana/i18n'; import { locationService } from '@grafana/runtime'; @@ -16,11 +15,13 @@ import { convertToGMAApi } from '../../api/convertToGMAApi'; import { GRAFANA_ORIGIN_LABEL } from '../../utils/labels'; import { createListFilterLink } from '../../utils/navigation'; -import { ImportFormValues } from './ImportFromDSRules'; +import { ImportFormValues } from './ImportToGMARules'; import { useGetRulesThatMightBeOverwritten, useGetRulesToBeImported } from './hooks'; +import { parseYamlFileToRulerRulesConfigDTO } from './yamlToRulerConverter'; type ModalProps = Pick, 'isOpen' | 'onDismiss'> & { isOpen: boolean; + importPayload: ImportFormValues; }; const AlertSomeRulesSkipped = () => { @@ -43,36 +44,63 @@ const AlertSomeRulesSkipped = () => { ); }; -export const ConfirmConversionModal = ({ isOpen, onDismiss }: ModalProps) => { - const { watch } = useFormContext(); +const emptyObject = {}; + +export const ConfirmConversionModal = ({ importPayload, isOpen, onDismiss }: ModalProps) => { + const appNotification = useAppNotification(); const styles = useStyles2(getStyles); - const [ - targetFolder, + const { + importSource, selectedDatasourceName, selectedDatasourceUID, - pauseRecordingRules, - pauseAlertingRules, + yamlFile, + targetFolder, namespace, ruleGroup, targetDatasourceUID, - ] = watch([ - 'targetFolder', - 'selectedDatasourceName', - 'selectedDatasourceUID', - 'pauseRecordingRules', - 'pauseAlertingRules', - 'namespace', - 'ruleGroup', - 'targetDatasourceUID', - ]); + yamlImportTargetDatasourceUID, + pauseRecordingRules, + pauseAlertingRules, + } = importPayload; - const dataSourceToFetch = isOpen ? (selectedDatasourceName ?? '') : undefined; - const { rulesToBeImported, isloadingCloudRules } = useGetRulesToBeImported(!isOpen, dataSourceToFetch); - const { filteredConfig: rulerRulesToPayload, someRulesAreSkipped } = useMemo( - () => filterRulerRulesConfig(rulesToBeImported, namespace, ruleGroup), - [rulesToBeImported, namespace, ruleGroup] + // for datasource import, we need to fetch the rules from the datasource + const dataSourceToFetch = isOpen && importSource === 'datasource' ? (selectedDatasourceName ?? '') : undefined; + const { rulesToBeImported: rulesToBeImportedFromDatasource, isloadingCloudRules } = useGetRulesToBeImported( + !isOpen || importSource === 'yaml', + dataSourceToFetch ); + + // for yaml import, we need to fetch the rules from the yaml file + const { value: rulesToBeImportedFromYaml = emptyObject } = useAsync(async () => { + if (!yamlFile || importSource !== 'yaml') { + return emptyObject; + } + try { + const rulerConfigFromYAML = await parseYamlFileToRulerRulesConfigDTO(yamlFile, yamlFile.name); + return rulerConfigFromYAML; + } catch (error) { + appNotification.error( + t('alerting.import-to-gma.yaml-error', 'Failed to parse YAML file: {{error}}', { + error: stringifyErrorLike(error), + }) + ); + return emptyObject; + } + }, [importSource, yamlFile]); + + // filter the rules to be imported from the datasource + const { filteredConfig: rulerRulesToPayload, someRulesAreSkipped } = useMemo(() => { + if (importSource === 'datasource') { + return filterRulerRulesConfig(rulesToBeImportedFromDatasource, namespace, ruleGroup); + } + // for yaml, we don't filter the rules + return { + filteredConfig: rulesToBeImportedFromYaml, + someRulesAreSkipped: false, + }; + }, [namespace, ruleGroup, importSource, rulesToBeImportedFromYaml, rulesToBeImportedFromDatasource]); + const { rulesThatMightBeOverwritten } = useGetRulesThatMightBeOverwritten(!isOpen, targetFolder, rulerRulesToPayload); const [convert] = convertToGMAApi.useConvertToGMAMutation(); @@ -89,7 +117,7 @@ export const ConfirmConversionModal = ({ isOpen, onDismiss }: ModalProps) => { {t( 'alerting.import-to-gma.confirm-modal.loading-body', - 'Preparing data to be imported.This can take a while...' + 'Preparing data to be imported. This can take a while...' )} @@ -97,9 +125,17 @@ export const ConfirmConversionModal = ({ isOpen, onDismiss }: ModalProps) => { } async function onConvertConfirm() { + if (!yamlImportTargetDatasourceUID && !selectedDatasourceUID) { + notifyApp.error( + t('alerting.import-to-gma.error', 'Failed to import alert rules: {{error}}', { + error: 'No data source selected', + }) + ); + return; + } try { await convert({ - dataSourceUID: selectedDatasourceUID, + dataSourceUID: importSource === 'yaml' ? (yamlImportTargetDatasourceUID ?? '') : (selectedDatasourceUID ?? ''), targetFolderUID: targetFolder?.uid, pauseRecordingRules: pauseRecordingRules, pauseAlerts: pauseAlertingRules, @@ -109,7 +145,7 @@ export const ConfirmConversionModal = ({ isOpen, onDismiss }: ModalProps) => { const isRootFolder = isEmpty(targetFolder?.uid); - trackImportToGMASuccess(); + trackImportToGMASuccess({ importSource }); const ruleListUrl = createListFilterLink(isRootFolder ? [] : [['namespace', targetFolder?.title ?? '']], { skipSubPath: true, }); @@ -118,7 +154,7 @@ export const ConfirmConversionModal = ({ isOpen, onDismiss }: ModalProps) => { ); locationService.push(ruleListUrl); } catch (error) { - trackImportToGMAError(); + trackImportToGMAError({ importSource }); notifyApp.error( t('alerting.import-to-gma.error', 'Failed to import alert rules: {{error}}', { error: stringifyErrorLike(error), @@ -139,10 +175,15 @@ export const ConfirmConversionModal = ({ isOpen, onDismiss }: ModalProps) => { {someRulesAreSkipped && } - {t( - 'alerting.import-to-gma.confirm-modal.no-rules-body', - 'There are no rules to import. Please select a different namespace or rule group.' - )} + {importSource === 'yaml' + ? t( + 'alerting.import-to-gma.confirm-modal.no-rules-body-yaml', + 'There are no rules to import. Please select a different yaml file.' + ) + : t( + 'alerting.import-to-gma.confirm-modal.no-rules-body', + 'There are no rules to import. Please select a different namespace or rule group.' + )} @@ -177,7 +218,15 @@ export const ConfirmConversionModal = ({ isOpen, onDismiss }: ModalProps) => { ); }; -function filterRulerRulesConfig( +/** + * Filter the ruler rules config to be imported. It filters the rules by namespace and group name. + * It also filters out the rules that have the '__grafana_origin' label. + * @param rulerRulesConfig - The ruler rules config to be imported + * @param namespace - The namespace to filter the rules by + * @param groupName - The group name to filter the rules by + * @returns The filtered ruler rules config and if some rules are skipped + */ +export function filterRulerRulesConfig( rulerRulesConfig: RulerRulesConfigDTO, namespace?: string, groupName?: string @@ -190,26 +239,29 @@ function filterRulerRulesConfig( return; } - const filteredGroups = groups.filter((group) => { - if (groupName && group.name !== groupName) { - return false; - } - - // Filter out rules that have the GRAFANA_ORIGIN_LABEL - const filteredRules = group.rules.filter((rule) => { - const hasGrafanaOriginLabel = rule.labels?.[GRAFANA_ORIGIN_LABEL]; - if (hasGrafanaOriginLabel) { - someRulesAreSkipped = true; + const filteredGroups = groups + .filter((group) => { + if (groupName && group.name !== groupName) { return false; } return true; - }); + }) + .map((group) => { + const filteredRules = group.rules.filter((rule) => { + const hasGrafanaOriginLabel = rule.labels?.[GRAFANA_ORIGIN_LABEL]; + if (hasGrafanaOriginLabel) { + someRulesAreSkipped = true; + return false; + } + return true; + }); - return { - ...group, - rules: filteredRules, - }; - }); + return { + ...group, + rules: filteredRules, + }; + }) + .filter((group) => group.rules.length > 0); if (filteredGroups.length > 0) { filteredConfig[ns] = filteredGroups; 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 deleted file mode 100644 index e8aeae238ac..00000000000 --- a/public/app/features/alerting/unified/components/import-to-gma/ImportFromDSRules.tsx +++ /dev/null @@ -1,280 +0,0 @@ -import { Controller, FormProvider, useForm } from 'react-hook-form'; -import { useToggle } from 'react-use'; - -import { DataSourceInstanceSettings } from '@grafana/data'; -import { Trans, useTranslate } from '@grafana/i18n'; -import { - Box, - Button, - Collapse, - Divider, - Field, - InlineField, - InlineSwitch, - LinkButton, - Spinner, - Stack, - Text, -} from '@grafana/ui'; -import { NestedFolderPicker } from 'app/core/components/NestedFolderPicker/NestedFolderPicker'; -import { useQueryParams } from 'app/core/hooks/useQueryParams'; -import { DataSourcePicker } from 'app/features/datasources/components/picker/DataSourcePicker'; -import { useDatasource } from 'app/features/datasources/hooks'; - -import { Folder } from '../../types/rule-form'; -import { DataSourceType } from '../../utils/datasource'; -import { withPageErrorBoundary } from '../../withPageErrorBoundary'; -import { AlertingPageWrapper } from '../AlertingPageWrapper'; -import { CreateNewFolder } from '../create-folder/CreateNewFolder'; -import { CloudRulesSourcePicker } from '../rule-editor/CloudRulesSourcePicker'; - -import { ConfirmConversionModal } from './ConfirmConvertModal'; -import { NamespaceAndGroupFilter } from './NamespaceAndGroupFilter'; - -export interface ImportFormValues { - selectedDatasourceUID: string; - selectedDatasourceName: string | null; - pauseAlertingRules: boolean; - pauseRecordingRules: boolean; - targetFolder?: Folder; - namespace?: string; - ruleGroup?: string; - targetDatasourceUID?: string; -} - -export const supportedImportTypes: string[] = [DataSourceType.Prometheus, DataSourceType.Loki]; - -const ImportFromDSRules = () => { - const [queryParams] = useQueryParams(); - const queryParamSelectedDatasourceUID: string = String(queryParams.datasourceUid) || ''; - const defaultDataSourceSettings = useDatasource(queryParamSelectedDatasourceUID); - // useDatasource gets the default data source as a fallback, so we need to check if it's the right type - // before trying to use it - const defaultDataSource = supportedImportTypes.includes(defaultDataSourceSettings?.type || '') - ? defaultDataSourceSettings - : undefined; - - const formAPI = useForm({ - defaultValues: { - selectedDatasourceUID: defaultDataSource?.uid, - selectedDatasourceName: defaultDataSource?.name, - pauseAlertingRules: true, - pauseRecordingRules: true, - targetFolder: undefined, - targetDatasourceUID: undefined, - }, - }); - const { - register, - handleSubmit, - watch, - control, - setValue, - formState: { errors, isSubmitting }, - } = formAPI; - - const [optionsShowing, toggleOptions] = useToggle(true); - const [targetFolder, selectedDatasourceName] = watch(['targetFolder', 'selectedDatasourceName']); - const [showConfirmModal, setShowConfirmModal] = useToggle(false); - const { t } = useTranslate(); - const onSubmit = async () => { - setShowConfirmModal(true); - }; - - return ( - - - -
- - - ( - { - setValue('selectedDatasourceUID', ds.uid); - setValue('selectedDatasourceName', ds.name); - // If we've chosen a Prometheus data source, we can set the recording rules target data source to the same as the source - const targetDataSourceUID = ds.type === DataSourceType.Prometheus ? ds.uid : undefined; - setValue('targetDatasourceUID', targetDataSourceUID); - }} - /> - )} - name="selectedDatasourceName" - rules={{ - required: { - value: true, - message: t('alerting.import-to-gma.datasource.required-message', 'Please select a data source'), - }, - }} - control={control} - /> - - - - - - - {t('alerting.import-to-gma.import-location-and-filters', 'Import location and filters')} - - - - - - ( - - { - if (uid && title) { - setValue('targetFolder', { title, uid }); - } else { - setValue('targetFolder', undefined); - } - }} - /> - - )} - name="targetFolder" - control={control} - /> - { - setValue('targetFolder', folder); - }} - /> - - - - - - - - - - {t('alerting.import-to-gma.alert-rules', 'Alert rules')} - - - - - - - - - - - - {t('alerting.import-to-gma.recording-rules', 'Recording rules')} - - - - - - - - - ( - ds.type === 'prometheus'} - onChange={(ds: DataSourceInstanceSettings) => { - setValue('targetDatasourceUID', ds.uid); - }} - /> - )} - name="targetDatasourceUID" - control={control} - rules={{ - required: { - value: true, - message: t( - 'alerting.import-from-dsrules.message.please-select-a-target-data-source', - 'Please select a target data source' - ), - }, - }} - /> - - - - - - - - - - - - Cancel - - - - setShowConfirmModal(false)} /> - -
-
-
- ); -}; - -export default withPageErrorBoundary(ImportFromDSRules); diff --git a/public/app/features/alerting/unified/components/import-to-gma/ImportToGMARules.test.tsx b/public/app/features/alerting/unified/components/import-to-gma/ImportToGMARules.test.tsx new file mode 100644 index 00000000000..40a66fd51aa --- /dev/null +++ b/public/app/features/alerting/unified/components/import-to-gma/ImportToGMARules.test.tsx @@ -0,0 +1,158 @@ +import { render } from 'test/test-utils'; +import { byLabelText, byRole } from 'testing-library-selector'; + +import { setPluginComponentsHook, setPluginLinksHook } from '@grafana/runtime'; +import { AccessControlAction } from 'app/types'; + +import { setupMswServer } from '../../mockApi'; +import { grantUserPermissions } from '../../mocks'; +import { alertingFactory } from '../../mocks/server/db'; +import { testWithFeatureToggles } from '../../test/test-utils'; + +import ImportToGMARules from './ImportToGMARules'; + +setPluginLinksHook(() => ({ links: [], isLoading: false })); +setPluginComponentsHook(() => ({ components: [], isLoading: false })); + +setupMswServer(); + +const ui = { + importSource: { + existingDatasource: byRole('radio', { name: /Import rules from existing data sources/ }), + yaml: byRole('radio', { name: /Import rules from a Prometheus YAML file./ }), + }, + dsImport: { + dsPicker: byLabelText('Data source'), + mimirDsOption: byRole('button', { name: /Mimir Prometheus$/ }), + }, + yamlImport: { + fileUpload: byLabelText('Upload file'), + targetDataSource: byLabelText(/Target data source/, { selector: '#yaml-target-data-source' }), + }, + additionalSettings: { + targetFolder: byRole('button', { name: /Select folder/ }), + namespaceFilter: byRole('combobox', { name: /^Namespace/ }), + ruleGroupFilter: byRole('combobox', { name: /^Group/ }), + // There is a bug affecting using byRole selector. The bug has been fixed but we use older version of the library. + // https://github.com/testing-library/dom-testing-library/issues/1101#issuecomment-2001928377 + pauseAlertingRules: byLabelText('Pause imported alerting rules', { selector: '#pause-alerting-rules' }), + pauseRecordingRules: byLabelText('Pause imported recording rules', { selector: '#pause-recording-rules' }), + targetDataSourceForRecording: byLabelText(/Target data source/, { + selector: '#recording-rules-target-data-source', + }), + }, + importButton: byRole('button', { name: /Import/ }), + confirmationModal: byRole('dialog', { name: /Confirm import/ }), +}; + +alertingFactory.dataSource.mimir().build({ meta: { alerting: true } }); + +describe('ImportToGMARules', () => { + grantUserPermissions([AccessControlAction.AlertingRuleExternalRead, AccessControlAction.AlertingRuleCreate]); + testWithFeatureToggles(['alertingImportYAMLUI', 'alertingMigrationUI']); + + it('should render the import source options', () => { + render(); + + expect(ui.importSource.existingDatasource.get()).toBeInTheDocument(); + expect(ui.importSource.yaml.get()).toBeInTheDocument(); + }); + + describe('existing datasource', () => { + it('should render datasource options', async () => { + const { user } = render(); + + await user.click(ui.dsImport.dsPicker.get()); + await user.click(await ui.dsImport.mimirDsOption.find()); + + expect(ui.dsImport.dsPicker.get()).toHaveProperty('placeholder', 'Mimir'); + }); + + it('should render additional options', async () => { + render(); + + // Test that all additional option fields are visible + expect(await ui.additionalSettings.targetFolder.find()).toBeInTheDocument(); + expect(ui.additionalSettings.namespaceFilter.get()).toBeDisabled(); + expect(ui.additionalSettings.ruleGroupFilter.get()).toBeDisabled(); + expect(ui.additionalSettings.targetDataSourceForRecording.get()).toBeInTheDocument(); + + // // Test default values for pause switches (both should be checked by default) + expect(ui.additionalSettings.pauseAlertingRules.get()).toBeChecked(); + expect(ui.additionalSettings.pauseRecordingRules.get()).toBeChecked(); + + expect(ui.yamlImport.fileUpload.query()).not.toBeInTheDocument(); + expect(ui.yamlImport.targetDataSource.query()).not.toBeInTheDocument(); + }); + + it('should show confirmation dialog when importing from data source', async () => { + const { user } = render(); + + // Select a data source + await user.click(ui.dsImport.dsPicker.get()); + await user.click(await ui.dsImport.mimirDsOption.find()); + + // Click the import button + await user.click(ui.importButton.get()); + + // Verify confirmation dialog appears + expect(await ui.confirmationModal.find()).toBeInTheDocument(); + }); + }); + + describe('yaml import', () => { + it('should render the yaml import options', async () => { + const { user } = render(); + + // Select YAML import option + await user.click(ui.importSource.yaml.get()); + + // Test that YAML-specific fields are visible + expect(await ui.yamlImport.fileUpload.find()).toBeInTheDocument(); + expect(ui.yamlImport.targetDataSource.get()).toBeInTheDocument(); + + // Test that pause switches are checked by default + expect(ui.additionalSettings.pauseAlertingRules.get()).toBeChecked(); + expect(ui.additionalSettings.pauseRecordingRules.get()).toBeChecked(); + + // Test that datasource-specific field is not visible + expect(ui.dsImport.dsPicker.query()).not.toBeInTheDocument(); + }); + + it('should show confirmation dialog when importing YAML file', async () => { + const { user } = render(); + + // Select YAML import option + await user.click(ui.importSource.yaml.get()); + + // Create a simple YAML file + const yamlContent = ` +groups: + - name: example + rules: + - alert: HighRequestLatency + expr: http_request_duration_seconds > 0.5 + for: 10m + labels: + severity: page + annotations: + summary: High request latency +`; + const file = new File([yamlContent], 'test-rules.yaml', { type: 'application/x-yaml' }); + + // Upload the file + const fileInput = ui.yamlImport.fileUpload.get(); + await user.upload(fileInput, file); + + // Select target data source + await user.click(ui.yamlImport.targetDataSource.get()); + await user.click(await ui.dsImport.mimirDsOption.find()); + + // Click the import button + await user.click(ui.importButton.get()); + + // Verify confirmation dialog appears + expect(await ui.confirmationModal.find()).toBeInTheDocument(); + }); + }); +}); diff --git a/public/app/features/alerting/unified/components/import-to-gma/ImportToGMARules.tsx b/public/app/features/alerting/unified/components/import-to-gma/ImportToGMARules.tsx new file mode 100644 index 00000000000..d1518a379a2 --- /dev/null +++ b/public/app/features/alerting/unified/components/import-to-gma/ImportToGMARules.tsx @@ -0,0 +1,490 @@ +import { useState } from 'react'; +import { Controller, FormProvider, SubmitHandler, useForm, useFormContext } from 'react-hook-form'; +import { useToggle } from 'react-use'; + +import { DataSourceInstanceSettings } from '@grafana/data'; +import { Trans, useTranslate } from '@grafana/i18n'; +import { config } from '@grafana/runtime'; +import { + Box, + Button, + Collapse, + Divider, + Field, + FileUpload, + InlineField, + InlineFieldRow, + InlineSwitch, + LinkButton, + RadioButtonList, + Spinner, + Stack, + Text, +} from '@grafana/ui'; +import { NestedFolderPicker } from 'app/core/components/NestedFolderPicker/NestedFolderPicker'; +import { DataSourcePicker } from 'app/features/datasources/components/picker/DataSourcePicker'; + +import { Folder } from '../../types/rule-form'; +import { DataSourceType } from '../../utils/datasource'; +import { stringifyErrorLike } from '../../utils/misc'; +import { withPageErrorBoundary } from '../../withPageErrorBoundary'; +import { AlertingPageWrapper } from '../AlertingPageWrapper'; +import { CreateNewFolder } from '../create-folder/CreateNewFolder'; +import { CloudRulesSourcePicker } from '../rule-editor/CloudRulesSourcePicker'; + +import { ConfirmConversionModal } from './ConfirmConvertModal'; +import { NamespaceAndGroupFilter } from './NamespaceAndGroupFilter'; +import { parseYamlFileToRulerRulesConfigDTO } from './yamlToRulerConverter'; + +export interface ImportFormValues { + importSource: 'datasource' | 'yaml'; + yamlFile: File | null; + yamlImportTargetDatasourceUID?: string; + selectedDatasourceUID?: string; + selectedDatasourceName: string | null; + pauseAlertingRules: boolean; + pauseRecordingRules: boolean; + targetFolder?: Folder; + namespace?: string; + ruleGroup?: string; + targetDatasourceUID?: string; +} + +export const supportedImportTypes: string[] = [DataSourceType.Prometheus, DataSourceType.Loki]; + +const ImportToGMARules = () => { + const formAPI = useForm({ + defaultValues: { + importSource: 'datasource', + yamlFile: null, + yamlImportTargetDatasourceUID: undefined, + selectedDatasourceUID: undefined, + selectedDatasourceName: undefined, + pauseAlertingRules: true, + pauseRecordingRules: true, + targetFolder: undefined, + targetDatasourceUID: undefined, + }, + }); + const { + register, + handleSubmit, + watch, + control, + setValue, + formState: { errors, isSubmitting }, + } = formAPI; + + const [optionsShowing, toggleOptions] = useToggle(true); + const [selectedDatasourceName, importSource] = watch(['selectedDatasourceName', 'importSource']); + + const [formImportPayload, setFormImportPayload] = useState(null); + const isImportYamlEnabled = config.featureToggles.alertingImportYAMLUI; + + const { t } = useTranslate(); + const onSubmit: SubmitHandler = async (formData) => { + setFormImportPayload(formData); + }; + + const importSourceOptions: Array<{ label: string; description: string; value: 'datasource' | 'yaml' }> = [ + { + label: t('alerting.import-to-gma.source.datasource', 'Existing data source-managed rules'), + description: t('alerting.import-to-gma.source.datasource-description', 'Import rules from existing data sources'), + value: 'datasource', + }, + ]; + + if (isImportYamlEnabled) { + importSourceOptions.push({ + label: t('alerting.import-to-gma.source.yaml', 'Prometheus YAML file'), + description: t('alerting.import-to-gma.source.yaml-description', 'Import rules from a Prometheus YAML file.'), + value: 'yaml', + }); + } + + return ( + + + +
+ + + ( + setValue('importSource', value)} + options={importSourceOptions} + /> + )} + control={control} + name="importSource" + /> + + + {importSource === 'datasource' && } + + {isImportYamlEnabled && importSource === 'yaml' && ( + <> + + + + )} + {/* Optional settings */} + + + + + {t('alerting.import-to-gma.import-location-and-filters', 'Import location and filters')} + + + + + + {importSource === 'datasource' && ( + + )} + + + + + + + + {t('alerting.import-to-gma.alert-rules', 'Alert rules')} + + + + + + + + + + + + {t('alerting.import-to-gma.recording-rules', 'Recording rules')} + + + + + + + + + + + + + + + + + + + + + Cancel + + + + {formImportPayload && ( + setFormImportPayload(null)} + importPayload={formImportPayload} + /> + )} + +
+
+
+ ); +}; + +function YamlFileUpload() { + const { t } = useTranslate(); + const { + formState: { errors }, + } = useFormContext(); + + return ( + + + name="yamlFile" + render={({ field: { onChange, ref, ...field } }) => ( + { + const yamlFile = event.currentTarget.files?.item(0); + onChange(yamlFile); + // Crucial for allowing re-selection of the same file after external edits + event.currentTarget.value = ''; + }} + size="sm" + showFileName + accept=".yaml,.yml,.json" + /> + )} + rules={{ + required: { + value: true, + message: t('alerting.import-to-gma.yaml.required-message', 'Please select a file'), + }, + validate: async (value) => { + if (!value) { + return t('alerting.import-to-gma.yaml.required-message', 'Please select a file'); + } + try { + await parseYamlFileToRulerRulesConfigDTO(value, value.name); + return true; + } catch (error) { + return t('alerting.import-to-gma.yaml-error', 'Failed to parse YAML file: {{error}}', { + error: stringifyErrorLike(error), + }); + } + }, + }} + /> + + ); +} + +function YamlTargetDataSourceField() { + const { t } = useTranslate(); + const { + formState: { errors }, + setValue, + getValues, + } = useFormContext(); + + return ( + + + name="yamlImportTargetDatasourceUID" + render={({ field: { onChange, ref, value, ...field } }) => ( + ds.type === 'prometheus'} + onChange={(ds: DataSourceInstanceSettings) => { + setValue('yamlImportTargetDatasourceUID', ds.uid); + const recordingRulesTargetDs = getValues('targetDatasourceUID'); + if (!recordingRulesTargetDs) { + setValue('targetDatasourceUID', ds.uid); + } + }} + /> + )} + rules={{ + required: { + value: true, + message: t('alerting.import-to-gma.yaml.target-datasource-required', 'Please select a target data source'), + }, + }} + /> + + ); +} + +function TargetDataSourceForRecordingRulesField() { + const { t } = useTranslate(); + const { + control, + formState: { errors }, + setValue, + } = useFormContext(); + + return ( + + + render={({ field: { onChange, ref, ...field } }) => ( + ds.type === 'prometheus'} + onChange={(ds: DataSourceInstanceSettings) => { + setValue('targetDatasourceUID', ds.uid); + }} + /> + )} + name="targetDatasourceUID" + control={control} + rules={{ + required: { + value: true, + message: t('alerting.recording-rules.target-data-source-required', 'Please select a target data source'), + }, + }} + /> + + ); +} + +function TargetFolderField() { + const { t } = useTranslate(); + const { + control, + formState: { errors }, + setValue, + } = useFormContext(); + + return ( + + + + name="targetFolder" + render={({ field: { onChange, ref, ...field } }) => ( + + { + if (uid && title) { + setValue('targetFolder', { title, uid }); + } else { + setValue('targetFolder', undefined); + } + }} + /> + + )} + control={control} + /> + { + setValue('targetFolder', folder); + }} + /> + + + ); +} + +function DataSourceField() { + const { t } = useTranslate(); + const { + control, + formState: { errors }, + setValue, + getValues, + } = useFormContext(); + + return ( + + + name="selectedDatasourceName" + render={({ field: { onChange, ref, ...field } }) => ( + { + setValue('selectedDatasourceUID', ds.uid); + setValue('selectedDatasourceName', ds.name); + + // If we've chosen a Prometheus data source, we can set the recording rules target data source to the same as the source + const recordingRulesTargetDs = getValues('targetDatasourceUID'); + if (!recordingRulesTargetDs) { + const targetDataSourceUID = ds.type === DataSourceType.Prometheus ? ds.uid : undefined; + setValue('targetDatasourceUID', targetDataSourceUID); + } + }} + /> + )} + control={control} + rules={{ + required: { + value: true, + message: t('alerting.import-to-gma.datasource.required-message', 'Please select a data source'), + }, + }} + /> + + ); +} + +export default withPageErrorBoundary(ImportToGMARules); diff --git a/public/app/features/alerting/unified/components/import-to-gma/NamespaceAndGroupFilter.tsx b/public/app/features/alerting/unified/components/import-to-gma/NamespaceAndGroupFilter.tsx index 3ef22754f1f..2f79a03d7a7 100644 --- a/public/app/features/alerting/unified/components/import-to-gma/NamespaceAndGroupFilter.tsx +++ b/public/app/features/alerting/unified/components/import-to-gma/NamespaceAndGroupFilter.tsx @@ -6,7 +6,7 @@ import { Combobox, ComboboxOption, Field, Stack } from '@grafana/ui'; import { useGetNameSpacesByDatasourceName } from '../rule-editor/useAlertRuleSuggestions'; -import { ImportFormValues } from './ImportFromDSRules'; +import { ImportFormValues } from './ImportToGMARules'; interface Props { rulesSourceName?: string; 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 index c7c9e233181..0d41f413a2b 100644 --- a/public/app/features/alerting/unified/components/import-to-gma/hooks.ts +++ b/public/app/features/alerting/unified/components/import-to-gma/hooks.ts @@ -62,6 +62,7 @@ export function useGetRulesToBeImported(skip: boolean, selectedDatasourceName: s return { rulesToBeImported, isloadingCloudRules }; } + function useFilterRulesThatMightBeOverwritten( targetNestedFolders: FolderDTO[], rulesToBeImported: RulerRulesConfigDTO, diff --git a/public/app/features/alerting/unified/components/import-to-gma/yamlToRulerConverter.test.ts b/public/app/features/alerting/unified/components/import-to-gma/yamlToRulerConverter.test.ts new file mode 100644 index 00000000000..ff19f18b7b1 --- /dev/null +++ b/public/app/features/alerting/unified/components/import-to-gma/yamlToRulerConverter.test.ts @@ -0,0 +1,238 @@ +import { RulerRulesConfigDTO } from 'app/types/unified-alerting-dto'; + +import { parseYamlToRulerRulesConfigDTO } from './yamlToRulerConverter'; + +describe('parseYamlToRulerRulesConfigDTO', () => { + it('should parse valid YAML with namespace', () => { + const yaml = ` + namespace: test-namespace + groups: + - name: test-group + rules: + - alert: TestAlert + expr: up == 0 + for: 5m + labels: + severity: warning + annotations: + description: "Test alert description" + `; + + const expected: RulerRulesConfigDTO = { + 'test-namespace': [ + { + name: 'test-group', + rules: [ + { + alert: 'TestAlert', + expr: 'up == 0', + for: '5m', + labels: { + severity: 'warning', + }, + annotations: { + description: 'Test alert description', + }, + }, + ], + }, + ], + }; + + const result = parseYamlToRulerRulesConfigDTO(yaml, 'default-namespace'); + expect(result).toEqual(expected); + }); + + it('should use default namespace when not specified in YAML', () => { + const yaml = ` + groups: + - name: test-group + rules: + - alert: TestAlert + expr: up == 0 + `; + + const expected: RulerRulesConfigDTO = { + 'default-namespace': [ + { + name: 'test-group', + rules: [ + { + alert: 'TestAlert', + expr: 'up == 0', + }, + ], + }, + ], + }; + + const result = parseYamlToRulerRulesConfigDTO(yaml, 'default-namespace'); + expect(result).toEqual(expected); + }); + + it('should parse recording rules', () => { + const yaml = ` + groups: + - name: test-group + rules: + - record: test:rate5m + expr: rate(prometheus_tsdb_reloads_total{job="prometheus"}[5m]) + `; + const expected: RulerRulesConfigDTO = { + 'default-namespace': [ + { + name: 'test-group', + rules: [ + { + record: 'test:rate5m', + expr: 'rate(prometheus_tsdb_reloads_total{job="prometheus"}[5m])', + }, + ], + }, + ], + }; + const result = parseYamlToRulerRulesConfigDTO(yaml, 'default-namespace'); + expect(result).toEqual(expected); + }); + + it('should throw error for invalid YAML format', () => { + const invalidYaml = 'invalid: yaml: content'; + expect(() => parseYamlToRulerRulesConfigDTO(invalidYaml, 'default-namespace')).toThrow(); + }); + + it('should throw error for missing groups array', () => { + const yaml = ` + namespace: test-namespace + invalid: content + `; + expect(() => parseYamlToRulerRulesConfigDTO(yaml, 'default-namespace')).toThrow( + 'Invalid YAML format: missing or invalid groups array' + ); + }); + + it('should throw error for invalid group format', () => { + const yaml = ` + namespace: test-namespace + groups: + - invalid: group + `; + expect(() => parseYamlToRulerRulesConfigDTO(yaml, 'default-namespace')).toThrow( + 'Invalid YAML format: missing or invalid groups array at index 0' + ); + }); + + it('should throw error for invalid rule format', () => { + const yaml = ` + namespace: test-namespace + groups: + - name: test-group + rules: + - invalid: rule + `; + expect(() => parseYamlToRulerRulesConfigDTO(yaml, 'default-namespace')).toThrow( + 'Invalid YAML format: missing or invalid groups array at index 0' + ); + }); + + it('should throw error for empty YAML string', () => { + const emptyYaml = ''; + expect(() => parseYamlToRulerRulesConfigDTO(emptyYaml, 'default-namespace')).toThrow( + 'Invalid YAML format: missing or invalid groups array' + ); + }); + + it('should throw error for rule missing expr field', () => { + const yaml = ` + namespace: test-namespace + groups: + - name: test-group + rules: + - alert: TestAlert + for: 5m + labels: + severity: warning + `; + expect(() => parseYamlToRulerRulesConfigDTO(yaml, 'default-namespace')).toThrow( + 'Invalid YAML format: missing or invalid groups array at index 0' + ); + }); + + it('should throw error for rule with expr but missing both alert and record fields', () => { + const yaml = ` + namespace: test-namespace + groups: + - name: test-group + rules: + - expr: up == 0 + for: 5m + labels: + severity: warning + `; + expect(() => parseYamlToRulerRulesConfigDTO(yaml, 'default-namespace')).toThrow( + 'Invalid YAML format: missing or invalid groups array at index 0' + ); + }); + + it('should throw error for rule with both alert and record fields', () => { + const yaml = ` + namespace: test-namespace + groups: + - name: test-group + rules: + - alert: TestAlert + record: test:rate5m + expr: up == 0 + for: 5m + `; + expect(() => parseYamlToRulerRulesConfigDTO(yaml, 'default-namespace')).toThrow( + 'Invalid YAML format: missing or invalid groups array at index 0' + ); + }); + + it('should handle multiple groups and rules', () => { + const yaml = ` + namespace: test-namespace + groups: + - name: group1 + rules: + - alert: Alert1 + expr: up == 0 + - alert: Alert2 + expr: down == 1 + - name: group2 + rules: + - alert: Alert3 + expr: error == 1 + `; + + const expected: RulerRulesConfigDTO = { + 'test-namespace': [ + { + name: 'group1', + rules: [ + { + alert: 'Alert1', + expr: 'up == 0', + }, + { + alert: 'Alert2', + expr: 'down == 1', + }, + ], + }, + { + name: 'group2', + rules: [ + { + alert: 'Alert3', + expr: 'error == 1', + }, + ], + }, + ], + }; + + const result = parseYamlToRulerRulesConfigDTO(yaml, 'default-namespace'); + expect(result).toEqual(expected); + }); +}); diff --git a/public/app/features/alerting/unified/components/import-to-gma/yamlToRulerConverter.ts b/public/app/features/alerting/unified/components/import-to-gma/yamlToRulerConverter.ts new file mode 100644 index 00000000000..85aa67922b2 --- /dev/null +++ b/public/app/features/alerting/unified/components/import-to-gma/yamlToRulerConverter.ts @@ -0,0 +1,135 @@ +import { load } from 'js-yaml'; + +import { RulerCloudRuleDTO, RulerRuleGroupDTO, RulerRulesConfigDTO } from 'app/types/unified-alerting-dto'; + +interface PrometheusYamlFile { + namespace?: string; + groups: Array>; +} + +function isValidObject(value: unknown): value is Record { + return typeof value === 'object' && Boolean(value); +} + +function isRule(yamlRule: unknown): yamlRule is RulerCloudRuleDTO { + if (!isValidObject(yamlRule)) { + return false; + } + + const alert = 'alert' in yamlRule && typeof yamlRule.alert === 'string' ? yamlRule.alert : undefined; + const record = 'record' in yamlRule && typeof yamlRule.record === 'string' ? yamlRule.record : undefined; + const expr = 'expr' in yamlRule && typeof yamlRule.expr === 'string' ? yamlRule.expr : undefined; + + if (!expr) { + return false; + } + + if (!alert && !record) { + return false; + } + + // If both are specified we don't know which one to use + if (alert && record) { + return false; + } + + // Check optional properties if they exist + if ('for' in yamlRule && typeof yamlRule.for !== 'string') { + return false; + } + + if ('labels' in yamlRule && !isValidObject(yamlRule.labels)) { + return false; + } + + if ('annotations' in yamlRule && !isValidObject(yamlRule.annotations)) { + return false; + } + + return true; +} + +function isGroup(obj: unknown): obj is RulerRuleGroupDTO { + if (!isValidObject(obj)) { + return false; + } + + const name = 'name' in obj && typeof obj.name === 'string' ? obj.name : undefined; + const rules = 'rules' in obj && Array.isArray(obj.rules) ? obj.rules : undefined; + + if (!name || !rules) { + return false; + } + + return rules.every(isRule); +} + +type ValidationResult = { isValid: true; data: PrometheusYamlFile } | { isValid: false; error: string }; + +function validatePrometheusYamlFile(obj: unknown): ValidationResult { + if (!isValidObject(obj)) { + return { isValid: false, error: 'Invalid YAML format: missing or invalid groups array' }; + } + + if (!('groups' in obj) || ('groups' in obj && !Array.isArray(obj.groups))) { + return { isValid: false, error: 'Invalid YAML format: missing or invalid groups array' }; + } + + // Check if groups is an array + if (!Array.isArray(obj.groups)) { + return { isValid: false, error: 'Invalid YAML format: missing or invalid groups array' }; + } + + // Check optional namespace property if it exists + if ('namespace' in obj && typeof obj.namespace !== 'string') { + return { isValid: false, error: 'Invalid YAML format: namespace must be a string' }; + } + + // If we get here, the object is valid - we can safely use it + // Since we validated the entire structure above, we know obj conforms to PrometheusYamlFile + const validatedGroups = obj.groups.map((group, index) => { + if (isGroup(group)) { + return group; + } + throw new Error(`Invalid YAML format: missing or invalid groups array at index ${index}`); + }); + + const prometheusFile: PrometheusYamlFile = { + groups: validatedGroups, + }; + + if ('namespace' in obj && typeof obj.namespace === 'string') { + prometheusFile.namespace = obj.namespace; + } + + return { + isValid: true, + data: prometheusFile, + }; +} + +// only use this function directly for testing purposes, use parseYamlFileToRulerRulesConfigDTO instead +export function parseYamlToRulerRulesConfigDTO(yamlAsString: string, defaultNamespace: string): RulerRulesConfigDTO { + const obj = load(yamlAsString); + const validation = validatePrometheusYamlFile(obj); + + if (!validation.isValid) { + throw new Error(validation.error); + } + + // TypeScript now knows validation.data exists and is PrometheusYamlFile + const prometheusFile = validation.data; + const namespace = prometheusFile.namespace ?? defaultNamespace; + + return { + [namespace]: prometheusFile.groups, + }; +} + +export async function parseYamlFileToRulerRulesConfigDTO( + file: File, + defaultNamespace: string +): Promise { + const yamlContent = await file.text(); + return parseYamlToRulerRulesConfigDTO(yamlContent, defaultNamespace); +} diff --git a/public/app/features/alerting/unified/components/rules/CloudRules.tsx b/public/app/features/alerting/unified/components/rules/CloudRules.tsx index 740eeef7ee5..2d1142064a7 100644 --- a/public/app/features/alerting/unified/components/rules/CloudRules.tsx +++ b/public/app/features/alerting/unified/components/rules/CloudRules.tsx @@ -14,6 +14,7 @@ import { usePagination } from '../../hooks/usePagination'; import { useUnifiedAlertingSelector } from '../../hooks/useUnifiedAlertingSelector'; import { getPaginationStyles } from '../../styles/pagination'; import { getRulesDataSources, getRulesSourceUid } from '../../utils/datasource'; +import { isAdmin } from '../../utils/misc'; import { isAsyncRequestStatePending } from '../../utils/redux'; import { createRelativeUrl } from '../../utils/url'; @@ -48,13 +49,8 @@ export const CloudRules = ({ namespaces, expandAll }: Props) => { DEFAULT_PER_PAGE_PAGINATION ); - const [createRuleSupported, createRuleAllowed] = useAlertingAbility(AlertingAction.CreateAlertRule); - const [viewExternalRuleSupported, viewExternalRuleAllowed] = useAlertingAbility(AlertingAction.ViewExternalAlertRule); const { t } = useTranslate(); - const canViewCloudRules = viewExternalRuleSupported && viewExternalRuleAllowed; - const canCreateGrafanaRules = createRuleSupported && createRuleAllowed; - const canMigrateToGMA = - hasDataSourcesConfigured && canCreateGrafanaRules && canViewCloudRules && config.featureToggles.alertingMigrationUI; + const canMigrateToGMA = hasDataSourcesConfigured && isAdmin() && config.featureToggles.alertingMigrationUI; return (
diff --git a/public/app/features/alerting/unified/rule-list/RuleList.v2.tsx b/public/app/features/alerting/unified/rule-list/RuleList.v2.tsx index a2beaf08927..124e230aeef 100644 --- a/public/app/features/alerting/unified/rule-list/RuleList.v2.tsx +++ b/public/app/features/alerting/unified/rule-list/RuleList.v2.tsx @@ -1,6 +1,7 @@ import { useMemo } from 'react'; import { Trans, useTranslate } from '@grafana/i18n'; +import { config } from '@grafana/runtime'; import { Button, Dropdown, Icon, LinkButton, Menu, Stack } from '@grafana/ui'; import { AlertingPageWrapper } from '../components/AlertingPageWrapper'; @@ -9,6 +10,7 @@ import { SupportedView } from '../components/rules/Filter/RulesViewModeSelector' import { AlertingAction, useAlertingAbility } from '../hooks/useAbilities'; import { useRulesFilter } from '../hooks/useFilteredRules'; import { useURLSearchParams } from '../hooks/useURLSearchParams'; +import { isAdmin } from '../utils/misc'; import { FilterView } from './FilterView'; import { GroupedView } from './GroupedView'; @@ -30,13 +32,16 @@ function RuleList() { } export function RuleListActions() { + const { t } = useTranslate(); + const [createGrafanaRuleSupported, createGrafanaRuleAllowed] = useAlertingAbility(AlertingAction.CreateAlertRule); const [createCloudRuleSupported, createCloudRuleAllowed] = useAlertingAbility(AlertingAction.CreateExternalAlertRule); - const { t } = useTranslate(); + const canCreateGrafanaRules = createGrafanaRuleSupported && createGrafanaRuleAllowed; const canCreateCloudRules = createCloudRuleSupported && createCloudRuleAllowed; const canCreateRules = canCreateGrafanaRules || canCreateCloudRules; + const canImportRulesToGMA = isAdmin() && config.featureToggles.alertingMigrationUI; const moreActionsMenu = useMemo( () => ( @@ -47,6 +52,13 @@ export function RuleListActions() { icon="file-export" url="/alerting/export-new-rule" /> + {canImportRulesToGMA && ( + + )} {canCreateGrafanaRules && ( @@ -66,7 +78,7 @@ export function RuleListActions() { ), - [canCreateGrafanaRules, canCreateCloudRules, t] + [t, canCreateGrafanaRules, canCreateCloudRules, canImportRulesToGMA] ); return ( diff --git a/public/app/features/alerting/unified/rule-list/components/DataSourceSection.tsx b/public/app/features/alerting/unified/rule-list/components/DataSourceSection.tsx index 6fad0fa1d7f..c75a9ff9efb 100644 --- a/public/app/features/alerting/unified/rule-list/components/DataSourceSection.tsx +++ b/public/app/features/alerting/unified/rule-list/components/DataSourceSection.tsx @@ -10,8 +10,6 @@ import { RulesSourceApplication } from 'app/types/unified-alerting-dto'; import { Spacer } from '../../components/Spacer'; import { WithReturnButton } from '../../components/WithReturnButton'; -import { supportedImportTypes } from '../../components/import-to-gma/ImportFromDSRules'; -import { useRulesSourcesWithRuler } from '../../hooks/useRuleSourcesWithRuler'; import { isAdmin } from '../../utils/misc'; import { DataSourceIcon } from './Namespace'; @@ -37,11 +35,6 @@ export const DataSourceSection = ({ }: DataSourceSectionProps) => { const [isCollapsed, toggleCollapsed] = useToggle(false); const styles = useStyles2((theme) => getStyles(theme, isCollapsed)); - const { rulesSourcesWithRuler } = useRulesSourcesWithRuler(); - - const showImportLink = - uid !== GrafanaRulesSourceSymbol && - rulesSourcesWithRuler.some(({ uid: dsUid, type }) => dsUid === uid && supportedImportTypes.includes(type)); const { t } = useTranslate(); const configureLink = (() => { @@ -79,16 +72,6 @@ export const DataSourceSection = ({ )} - {showImportLink && ( - - Import to Grafana rules - - )} {configureLink && (