Alerting: Add ability to import rules to GMA from Prometheus YAML (#105807)

* re-organize folders regarding import feature

* Revert folder changes

* Add Yaml import input fields

* Set recording rules target if empty

* wip

* Convert YAML content to RulerRulesConfigDTO and use this for the import payload

* fixing some issues

* wip

* add tracking

* use yaml filename for namespace in case is not specified in the yaml content

* refactor

* add alertingImportYAMLUI ff check for yaml option

* Add test for parseYamlToRulerRulesConfigDTO

* move import feature to the More menu at the top

* add test for filterRulerRulesConfig, and fix the function

* extract parseYamlToRulerRulesConfigDTO to a separate file

* Add permission check for the import button

* Change data flow in import form, add basic tests for the import form

* remove commented code

* Add yaml import form test

* Add more tests

* Tidy up, remove type assertions in yaml converter

* Improve yaml file validation in the form

* Fix lint issues

* Fix lint

* use only Admin role for checking if the feature is available for the user

* Fix parsing recording rules

* Fix file re-selection in YAML import

* prettier

* refactor

* Remove FileReader, add more explanation to file upload reset

---------

Co-authored-by: Konrad Lalik <konradlalik@gmail.com>
This commit is contained in:
Sonia Aguilar
2025-05-29 15:12:24 +02:00
committed by GitHub
parent 3b78078ea4
commit 80c47a64b1
16 changed files with 1359 additions and 391 deletions

View File

@ -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"]

View File

@ -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'
)
)
: () => <Navigate replace to="/alerting/list" />,
},
{
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'
)
)
: () => <Navigate replace to="/alerting/list" />,

View File

@ -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 {

View File

@ -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);
});
});

View File

@ -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<ComponentProps<typeof ConfirmModal>, 'isOpen' | 'onDismiss'> & {
isOpen: boolean;
importPayload: ImportFormValues;
};
const AlertSomeRulesSkipped = () => {
@ -43,36 +44,63 @@ const AlertSomeRulesSkipped = () => {
);
};
export const ConfirmConversionModal = ({ isOpen, onDismiss }: ModalProps) => {
const { watch } = useFormContext<ImportFormValues>();
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) => {
<Text>
{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...'
)}
</Text>
</Modal>
@ -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) => {
<Stack direction="column" gap={2}>
{someRulesAreSkipped && <AlertSomeRulesSkipped />}
<Text>
{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.'
)}
</Text>
</Stack>
</Modal>
@ -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;

View File

@ -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<ImportFormValues>({
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 (
<AlertingPageWrapper
navId="alert-list"
pageNav={{
text: t('alerting.import-to-gma.pageTitle', 'Import alert rules from a data source to Grafana-managed rules'),
}}
>
<Stack gap={2} direction={'column'}>
<FormProvider {...formAPI}>
<form onSubmit={handleSubmit(onSubmit)}>
<Stack direction="column" gap={1}>
<Field
label={t('alerting.import-to-gma.datasource.label', 'Data source')}
invalid={!!errors.selectedDatasourceName}
error={errors.selectedDatasourceName?.message}
htmlFor="datasource-picker"
>
<Controller
render={({ field: { onChange, ref, ...field } }) => (
<CloudRulesSourcePicker
{...field}
width={50}
inputId="datasource-picker"
onChange={(ds: DataSourceInstanceSettings) => {
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}
/>
</Field>
<Collapse
label={t('alerting.import-to-gma.additional-settings', 'Additional settings')}
isOpen={optionsShowing}
onToggle={toggleOptions}
collapsible={true}
>
<Box marginLeft={1}>
<Box marginBottom={2}>
<Text variant="h5">
{t('alerting.import-to-gma.import-location-and-filters', 'Import location and filters')}
</Text>
</Box>
<Field
label={t('alerting.import-to-gma.target-folder.label', 'Target folder')}
description={t(
'alerting.import-from-dsrules.description-folder-import-rules',
'The folder to import the rules to'
)}
error={errors.selectedDatasourceName?.message}
htmlFor="folder-picker"
>
<Stack gap={2}>
<Controller
render={({ field: { onChange, ref, ...field } }) => (
<Stack width={42}>
<NestedFolderPicker
permission="view"
showRootFolder={false}
invalid={!!errors.targetFolder?.message}
{...field}
value={targetFolder?.uid}
onChange={(uid, title) => {
if (uid && title) {
setValue('targetFolder', { title, uid });
} else {
setValue('targetFolder', undefined);
}
}}
/>
</Stack>
)}
name="targetFolder"
control={control}
/>
<CreateNewFolder
onCreate={(folder) => {
setValue('targetFolder', folder);
}}
/>
</Stack>
</Field>
<NamespaceAndGroupFilter rulesSourceName={selectedDatasourceName || undefined} />
</Box>
<Divider />
<Box>
<Box marginLeft={1} marginBottom={1}>
<Text variant="h5">{t('alerting.import-to-gma.alert-rules', 'Alert rules')}</Text>
</Box>
<InlineField
transparent={true}
label={t('alerting.import-to-gma.pause.label', 'Pause imported alerting rules')}
labelWidth={30}
htmlFor="pause-alerting-rules"
>
<InlineSwitch transparent id="pause-alerting-rules" {...register('pauseAlertingRules')} />
</InlineField>
</Box>
<Divider />
<Box>
<Box marginBottom={1} marginLeft={1}>
<Text variant="h5">{t('alerting.import-to-gma.recording-rules', 'Recording rules')}</Text>
</Box>
<InlineField
transparent={true}
label={t('alerting.import-to-gma.pause-recording.label', 'Pause imported recording rules')}
labelWidth={30}
htmlFor="pause-recording-rules"
>
<InlineSwitch transparent id="pause-recording-rules" {...register('pauseRecordingRules')} />
</InlineField>
<Box marginLeft={1} width={50}>
<Field
required
id="target-data-source"
label={t('alerting.recording-rules.label-target-data-source', 'Target data source')}
description={t(
'alerting.recording-rules.description-target-data-source',
'The Prometheus data source to store recording rules in'
)}
error={errors.targetDatasourceUID?.message}
invalid={!!errors.targetDatasourceUID?.message}
>
<Controller
render={({ field: { onChange, ref, ...field } }) => (
<DataSourcePicker
{...field}
current={field.value}
noDefault
// Filter with `filter` prop instead of `type` prop to avoid showing the `-- Grafana --` data source
filter={(ds: DataSourceInstanceSettings) => 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'
),
},
}}
/>
</Field>
</Box>
</Box>
</Collapse>
</Stack>
<Box marginTop={2}>
<Stack gap={1}>
<Button type="submit" variant="primary" disabled={isSubmitting || !selectedDatasourceName}>
<Stack direction="row" gap={2} alignItems="center">
{isSubmitting && <Spinner inline={true} />}
<Trans i18nKey="alerting.import-to-gma.action-button">Import</Trans>
</Stack>
</Button>
<LinkButton variant="secondary" href="/alerting/list">
<Trans i18nKey="common.cancel">Cancel</Trans>
</LinkButton>
</Stack>
</Box>
<ConfirmConversionModal isOpen={showConfirmModal} onDismiss={() => setShowConfirmModal(false)} />
</form>
</FormProvider>
</Stack>
</AlertingPageWrapper>
);
};
export default withPageErrorBoundary(ImportFromDSRules);

View File

@ -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(<ImportToGMARules />);
expect(ui.importSource.existingDatasource.get()).toBeInTheDocument();
expect(ui.importSource.yaml.get()).toBeInTheDocument();
});
describe('existing datasource', () => {
it('should render datasource options', async () => {
const { user } = render(<ImportToGMARules />);
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(<ImportToGMARules />);
// 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(<ImportToGMARules />);
// 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(<ImportToGMARules />);
// 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(<ImportToGMARules />);
// 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();
});
});
});

View File

@ -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<ImportFormValues>({
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<ImportFormValues | null>(null);
const isImportYamlEnabled = config.featureToggles.alertingImportYAMLUI;
const { t } = useTranslate();
const onSubmit: SubmitHandler<ImportFormValues> = 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 (
<AlertingPageWrapper
navId="alert-list"
pageNav={{
text: t('alerting.import-to-gma.pageTitle', 'Import alert rules'),
}}
>
<Stack gap={2} direction={'column'}>
<FormProvider {...formAPI}>
<form onSubmit={handleSubmit(onSubmit)}>
<Stack direction="column" gap={1}>
<Field
label={t('alerting.import-to-gma.import-source', 'Import source')}
invalid={!!errors.importSource}
error={errors.importSource?.message}
htmlFor="import-source"
noMargin
>
<Controller
render={({ field: { onChange, ref, ...field } }) => (
<RadioButtonList
{...field}
onChange={(value) => setValue('importSource', value)}
options={importSourceOptions}
/>
)}
control={control}
name="importSource"
/>
</Field>
{importSource === 'datasource' && <DataSourceField />}
{isImportYamlEnabled && importSource === 'yaml' && (
<>
<YamlFileUpload />
<YamlTargetDataSourceField />
</>
)}
{/* Optional settings */}
<Collapse
label={t('alerting.import-to-gma.additional-settings', 'Additional settings')}
isOpen={optionsShowing}
onToggle={toggleOptions}
collapsible={true}
>
<Box marginLeft={1}>
<Box marginBottom={2}>
<Text variant="h5">
{t('alerting.import-to-gma.import-location-and-filters', 'Import location and filters')}
</Text>
</Box>
<Stack direction="column" gap={2}>
<TargetFolderField />
{importSource === 'datasource' && (
<NamespaceAndGroupFilter rulesSourceName={selectedDatasourceName || undefined} />
)}
</Stack>
</Box>
<Divider />
<Box>
<Box marginLeft={1} marginBottom={1}>
<Text variant="h5">{t('alerting.import-to-gma.alert-rules', 'Alert rules')}</Text>
</Box>
<InlineField
transparent={true}
label={t('alerting.import-to-gma.pause.label', 'Pause imported alerting rules')}
labelWidth={30}
htmlFor="pause-alerting-rules"
>
<InlineSwitch
transparent
id="pause-alerting-rules"
{...register('pauseAlertingRules')}
showLabel={false}
/>
</InlineField>
</Box>
<Divider />
<Box>
<Box marginBottom={1} marginLeft={1}>
<Text variant="h5">{t('alerting.import-to-gma.recording-rules', 'Recording rules')}</Text>
</Box>
<InlineFieldRow>
<InlineField
transparent={true}
label={t('alerting.import-to-gma.pause-recording.label', 'Pause imported recording rules')}
labelWidth={30}
htmlFor="pause-recording-rules"
>
<InlineSwitch transparent id="pause-recording-rules" {...register('pauseRecordingRules')} />
</InlineField>
</InlineFieldRow>
<Box marginLeft={1} width={50}>
<TargetDataSourceForRecordingRulesField />
</Box>
</Box>
</Collapse>
</Stack>
<Box marginTop={2}>
<Stack gap={1}>
<Button type="submit" variant="primary" disabled={isSubmitting}>
<Stack direction="row" gap={2} alignItems="center">
{isSubmitting && <Spinner inline={true} />}
<Trans i18nKey="alerting.import-to-gma.action-button">Import</Trans>
</Stack>
</Button>
<LinkButton variant="secondary" href="/alerting/list">
<Trans i18nKey="common.cancel">Cancel</Trans>
</LinkButton>
</Stack>
</Box>
{formImportPayload && (
<ConfirmConversionModal
isOpen={!!formImportPayload}
onDismiss={() => setFormImportPayload(null)}
importPayload={formImportPayload}
/>
)}
</form>
</FormProvider>
</Stack>
</AlertingPageWrapper>
);
};
function YamlFileUpload() {
const { t } = useTranslate();
const {
formState: { errors },
} = useFormContext<ImportFormValues>();
return (
<Field
label={t('alerting.import-to-gma.yaml.label', 'Prometheus YAML file')}
invalid={!!errors.yamlFile}
error={errors.yamlFile?.message}
description={t('alerting.import-to-gma.yaml.description', 'Select a Prometheus-compatible YAML file to import')}
noMargin
>
<Controller<ImportFormValues, 'yamlFile'>
name="yamlFile"
render={({ field: { onChange, ref, ...field } }) => (
<FileUpload
{...field}
onFileUpload={(event) => {
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),
});
}
},
}}
/>
</Field>
);
}
function YamlTargetDataSourceField() {
const { t } = useTranslate();
const {
formState: { errors },
setValue,
getValues,
} = useFormContext<ImportFormValues>();
return (
<Field
label={t('alerting.import-to-gma.yaml.target-datasource', 'Target data source')}
description={t(
'alerting.import-to-gma.yaml.target-datasource-description',
'Select the data source that will be queried by the imported rules. Make sure metrics used in the imported rules are available in this data source.'
)}
invalid={!!errors.yamlImportTargetDatasourceUID}
error={errors.yamlImportTargetDatasourceUID?.message}
htmlFor="yaml-target-data-source"
noMargin
>
<Controller<ImportFormValues, 'yamlImportTargetDatasourceUID'>
name="yamlImportTargetDatasourceUID"
render={({ field: { onChange, ref, value, ...field } }) => (
<DataSourcePicker
{...field}
current={value}
noDefault
inputId="yaml-target-data-source"
alerting
filter={(ds: DataSourceInstanceSettings) => 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'),
},
}}
/>
</Field>
);
}
function TargetDataSourceForRecordingRulesField() {
const { t } = useTranslate();
const {
control,
formState: { errors },
setValue,
} = useFormContext<ImportFormValues>();
return (
<Field
required
label={t('alerting.recording-rules.label-target-data-source', 'Target data source')}
description={t(
'alerting.recording-rules.description-target-data-source',
'The Prometheus data source to store recording rules in'
)}
htmlFor="recording-rules-target-data-source"
error={errors.targetDatasourceUID?.message}
invalid={!!errors.targetDatasourceUID?.message}
noMargin
>
<Controller<ImportFormValues, 'targetDatasourceUID'>
render={({ field: { onChange, ref, ...field } }) => (
<DataSourcePicker
{...field}
current={field.value}
inputId="recording-rules-target-data-source"
noDefault
filter={(ds: DataSourceInstanceSettings) => 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'),
},
}}
/>
</Field>
);
}
function TargetFolderField() {
const { t } = useTranslate();
const {
control,
formState: { errors },
setValue,
} = useFormContext<ImportFormValues>();
return (
<Field
label={t('alerting.import-to-gma.target-folder.label', 'Target folder')}
description={t('alerting.import-to-gma.target-folder.description', 'The folder to import the rules to')}
error={errors.targetFolder?.message}
htmlFor="folder-picker"
noMargin
>
<Stack gap={2}>
<Controller<ImportFormValues, 'targetFolder'>
name="targetFolder"
render={({ field: { onChange, ref, ...field } }) => (
<Stack width={42}>
<NestedFolderPicker
permission="view"
showRootFolder={false}
invalid={!!errors.targetFolder?.message}
{...field}
value={field.value?.uid}
onChange={(uid, title) => {
if (uid && title) {
setValue('targetFolder', { title, uid });
} else {
setValue('targetFolder', undefined);
}
}}
/>
</Stack>
)}
control={control}
/>
<CreateNewFolder
onCreate={(folder) => {
setValue('targetFolder', folder);
}}
/>
</Stack>
</Field>
);
}
function DataSourceField() {
const { t } = useTranslate();
const {
control,
formState: { errors },
setValue,
getValues,
} = useFormContext<ImportFormValues>();
return (
<Field
label={t('alerting.import-to-gma.datasource.label', 'Data source')}
invalid={!!errors.selectedDatasourceName}
error={errors.selectedDatasourceName?.message}
htmlFor="datasource-picker"
noMargin
>
<Controller<ImportFormValues, 'selectedDatasourceName'>
name="selectedDatasourceName"
render={({ field: { onChange, ref, ...field } }) => (
<CloudRulesSourcePicker
{...field}
width={50}
inputId="datasource-picker"
onChange={(ds: DataSourceInstanceSettings) => {
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'),
},
}}
/>
</Field>
);
}
export default withPageErrorBoundary(ImportToGMARules);

View File

@ -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;

View File

@ -62,6 +62,7 @@ export function useGetRulesToBeImported(skip: boolean, selectedDatasourceName: s
return { rulesToBeImported, isloadingCloudRules };
}
function useFilterRulesThatMightBeOverwritten(
targetNestedFolders: FolderDTO[],
rulesToBeImported: RulerRulesConfigDTO,

View File

@ -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);
});
});

View File

@ -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<RulerRuleGroupDTO<RulerCloudRuleDTO>>;
}
function isValidObject(value: unknown): value is Record<string, unknown> {
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<RulerCloudRuleDTO> {
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<RulerRulesConfigDTO> {
const yamlContent = await file.text();
return parseYamlToRulerRulesConfigDTO(yamlContent, defaultNamespace);
}

View File

@ -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 (
<section className={styles.wrapper}>

View File

@ -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 && (
<Menu.Item
label={t('alerting.rule-list-v2.import-to-gma', 'Import alert rules')}
icon="import"
url="/alerting/import-datasource-managed-rules"
/>
)}
</Menu.Group>
<Menu.Group label={t('alerting.rule-list.recording-rules', 'Recording rules')}>
{canCreateGrafanaRules && (
@ -66,7 +78,7 @@ export function RuleListActions() {
</Menu.Group>
</Menu>
),
[canCreateGrafanaRules, canCreateCloudRules, t]
[t, canCreateGrafanaRules, canCreateCloudRules, canImportRulesToGMA]
);
return (

View File

@ -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 = ({
</Text>
)}
<Spacer />
{showImportLink && (
<LinkButton
variant="secondary"
fill="text"
size="sm"
href={`/alerting/import-datasource-managed-rules?datasourceUid=${String(uid)}`}
>
<Trans i18nKey="alerting.data-source-section.import-to-grafana">Import to Grafana rules</Trans>
</LinkButton>
)}
{configureLink && (
<WithReturnButton
title={t('alerting.rule-list.return-button.title', 'Alert rules')}

View File

@ -948,9 +948,6 @@
"title-search-panel": "Search panel",
"title-select-dashboard-and-panel": "Select dashboard and panel"
},
"data-source-section": {
"import-to-grafana": "Import to Grafana rules"
},
"datasource-not-found": {
"card-description": "The datasource for this query was not found, it was either removed or is not installed correctly.",
"remove-query": "Remove query",
@ -1509,12 +1506,6 @@
"label-insights": "Insights",
"title-alerting": "Alerting"
},
"import-from-dsrules": {
"description-folder-import-rules": "The folder to import the rules to",
"message": {
"please-select-a-target-data-source": "Please select a target data source"
}
},
"import-to-gma": {
"action-button": "Import",
"additional-settings": "Additional settings",
@ -1522,8 +1513,9 @@
"confirm-modal": {
"confirm": "Import",
"loading": "Loading...",
"loading-body": "Preparing data to be imported.This can take a while...",
"loading-body": "Preparing data to be imported. This can take a while...",
"no-rules-body": "There are no rules to import. Please select a different namespace or rule group.",
"no-rules-body-yaml": "There are no rules to import. Please select a different yaml file.",
"no-rules-title": "No rules to import",
"plugin-rules-warning": {
"text": "We have detected that some rules are managed by plugins. These rules will not be imported.",
@ -1542,11 +1534,12 @@
"label": "Group"
},
"import-location-and-filters": "Import location and filters",
"import-source": "Import source",
"namespace": {
"description": "Type to search for an existing namespace",
"label": "Namespace"
},
"pageTitle": "Import alert rules from a data source to Grafana-managed rules",
"pageTitle": "Import alert rules",
"pause": {
"label": "Pause imported alerting rules"
},
@ -1554,10 +1547,26 @@
"label": "Pause imported recording rules"
},
"recording-rules": "Recording rules",
"source": {
"datasource": "Existing data source-managed rules",
"datasource-description": "Import rules from existing data sources",
"yaml": "Prometheus YAML file",
"yaml-description": "Import rules from a Prometheus YAML file."
},
"success": "Successfully imported alert rules to Grafana-managed rules.",
"target-folder": {
"description": "The folder to import the rules to",
"label": "Target folder"
}
},
"yaml": {
"description": "Select a Prometheus-compatible YAML file to import",
"label": "Prometheus YAML file",
"required-message": "Please select a file",
"target-datasource": "Target data source",
"target-datasource-description": "Select the data source that will be queried by the imported rules. Make sure metrics used in the imported rules are available in this data source.",
"target-datasource-required": "Please select a target data source"
},
"yaml-error": "Failed to parse YAML file: {{error}}"
},
"insights": {
"monitor-status-of-system": "Monitor the status of your system",
@ -2137,7 +2146,8 @@
},
"recording-rules": {
"description-target-data-source": "The Prometheus data source to store recording rules in",
"label-target-data-source": "Target data source"
"label-target-data-source": "Target data source",
"target-data-source-required": "Please select a target data source"
},
"recording-rules-name-space-and-group-step": {
"description-select-namespace-group-recording": "Select the Namespace and Group for your recording rule.",
@ -2398,6 +2408,9 @@
"collapse-all": "Collapse all",
"expand-all": "Expand all"
},
"rule-list-v2": {
"import-to-gma": "Import alert rules"
},
"rule-modify-export": {
"text-loading-the-rule": "Loading the rule...",
"title-cannot-exist": "Cannot load the rule. The rule does not exist",