mirror of
https://github.com/grafana/grafana.git
synced 2025-07-30 20:52:34 +08:00
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:
@ -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"]
|
||||
|
@ -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" />,
|
||||
|
@ -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 {
|
||||
|
@ -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);
|
||||
});
|
||||
});
|
@ -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;
|
||||
|
@ -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);
|
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
@ -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);
|
@ -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;
|
||||
|
@ -62,6 +62,7 @@ export function useGetRulesToBeImported(skip: boolean, selectedDatasourceName: s
|
||||
|
||||
return { rulesToBeImported, isloadingCloudRules };
|
||||
}
|
||||
|
||||
function useFilterRulesThatMightBeOverwritten(
|
||||
targetNestedFolders: FolderDTO[],
|
||||
rulesToBeImported: RulerRulesConfigDTO,
|
||||
|
@ -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);
|
||||
});
|
||||
});
|
@ -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);
|
||||
}
|
@ -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}>
|
||||
|
@ -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 (
|
||||
|
@ -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')}
|
||||
|
@ -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",
|
||||
|
Reference in New Issue
Block a user