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 (
-
-
-
-
-
-
-
- );
-};
-
-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 (
+
+
+
+
+
+
+
+ );
+};
+
+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 && (