diff --git a/.betterer.results b/.betterer.results index 618f0476ac2..f9e1356e3e1 100644 --- a/.betterer.results +++ b/.betterer.results @@ -2069,9 +2069,7 @@ exports[`better eslint`] = { [0, 0, 0, "No untranslated strings. Wrap text with ", "3"] ], "public/app/features/alerting/unified/components/rules/CloudRules.tsx:5381": [ - [0, 0, 0, "No untranslated strings in text props. Wrap text with or use t()", "0"], - [0, 0, 0, "No untranslated strings. Wrap text with ", "1"], - [0, 0, 0, "No untranslated strings. Wrap text with ", "2"] + [0, 0, 0, "No untranslated strings in text props. Wrap text with or use t()", "0"] ], "public/app/features/alerting/unified/components/rules/Filter/RulesFilter.v1.tsx:5381": [ [0, 0, 0, "No untranslated strings in text props. Wrap text with or use t()", "0"], diff --git a/public/app/features/alerting/routes.tsx b/public/app/features/alerting/routes.tsx index 1bd1159efd9..0b66eed11f8 100644 --- a/public/app/features/alerting/routes.tsx +++ b/public/app/features/alerting/routes.tsx @@ -212,6 +212,18 @@ export function getAlertingRoutes(cfg = config): RouteDescriptor[] { ) ), }, + { + path: '/alerting/import-datasource-managed-rules', + roles: evaluateAccess([AccessControlAction.AlertingRuleCreate, AccessControlAction.AlertingRuleExternalRead]), + component: config.featureToggles.alertingMigrationUI + ? importAlertingComponent( + () => + import( + /* webpackChunkName: "AlertingImportFromDSRules"*/ 'app/features/alerting/unified/components/import-to-gma/ImportFromDSRules' + ) + ) + : () => , + }, { path: '/alerting/recently-deleted/', roles: () => ['Admin'], @@ -224,6 +236,18 @@ export function getAlertingRoutes(cfg = config): RouteDescriptor[] { ) : () => , }, + { + path: '/alerting/import-datasource-managed-rules', + roles: evaluateAccess([AccessControlAction.AlertingRuleCreate, AccessControlAction.AlertingRuleExternalRead]), + component: config.featureToggles.alertingMigrationUI + ? importAlertingComponent( + () => + import( + /* webpackChunkName: "AlertingImportFromDSRules"*/ 'app/features/alerting/unified/components/import-to-gma/ImportFromDSRules' + ) + ) + : () => , + }, { path: '/alerting/new/:type?', pageClass: 'page-alerting', diff --git a/public/app/features/alerting/unified/Analytics.ts b/public/app/features/alerting/unified/Analytics.ts index 415d0bd1408..e5f0ce7e597 100644 --- a/public/app/features/alerting/unified/Analytics.ts +++ b/public/app/features/alerting/unified/Analytics.ts @@ -225,6 +225,14 @@ export const trackDeletedRuleRestoreFail = async () => { reportInteraction('grafana_alerting_deleted_rule_restore_error'); }; +export const trackImportToGMASuccess = async () => { + reportInteraction('grafana_alerting_import_to_gma_success'); +}; + +export const trackImportToGMAError = async () => { + reportInteraction('grafana_alerting_import_to_gma_error'); +}; + interface RulesSearchInteractionPayload { filter: string; triggeredBy: 'typing' | 'component'; diff --git a/public/app/features/alerting/unified/api/convertToGMAApi.ts b/public/app/features/alerting/unified/api/convertToGMAApi.ts new file mode 100644 index 00000000000..c87a38a1df3 --- /dev/null +++ b/public/app/features/alerting/unified/api/convertToGMAApi.ts @@ -0,0 +1,34 @@ +import { RulerRulesConfigDTO } from 'app/types/unified-alerting-dto'; + +import { alertingApi } from './alertingApi'; + +export const convertToGMAApi = alertingApi.injectEndpoints({ + endpoints: (build) => ({ + convertToGMA: build.mutation< + void, + { + targetFolderUID?: string; + dataSourceUID: string; + pauseRecordingRules?: boolean; + pauseAlerts?: boolean; + payload: RulerRulesConfigDTO; + /** Target data source UID to store recording rules in */ + targetDatasourceUID?: string; + } + >({ + query: ({ payload, targetFolderUID, pauseRecordingRules, pauseAlerts, dataSourceUID, targetDatasourceUID }) => ({ + url: `/api/convert/prometheus/config/v1/rules`, + method: 'POST', + body: payload, + headers: { + 'X-Grafana-Alerting-Datasource-UID': dataSourceUID, + 'X-Grafana-Alerting-Recording-Rules-Paused': pauseRecordingRules, + 'X-Grafana-Alerting-Alert-Rules-Paused': pauseAlerts, + 'X-Disable-Provenance': true, + ...(targetFolderUID ? { 'X-Grafana-Alerting-Folder-UID': targetFolderUID } : {}), + ...(targetDatasourceUID ? { 'X-Grafana-Alerting-Target-Datasource-UID': targetDatasourceUID } : {}), + }, + }), + }), + }), +}); diff --git a/public/app/features/alerting/unified/components/import-to-gma/ConfirmConvertModal.tsx b/public/app/features/alerting/unified/components/import-to-gma/ConfirmConvertModal.tsx new file mode 100644 index 00000000000..291484cac65 --- /dev/null +++ b/public/app/features/alerting/unified/components/import-to-gma/ConfirmConvertModal.tsx @@ -0,0 +1,172 @@ +import { css } from '@emotion/css'; +import { isEmpty } from 'lodash'; +import { ComponentProps } from 'react'; +import { useFormContext } from 'react-hook-form'; + +import { locationService } from '@grafana/runtime'; +import { CodeEditor, ConfirmModal, Icon, Stack, Text, useStyles2 } from '@grafana/ui'; +import { useAppNotification } from 'app/core/copy/appNotification'; +import { Trans, t } from 'app/core/internationalization'; +import { stringifyErrorLike } from 'app/features/alerting/unified/utils/misc'; +import { RulerRulesConfigDTO } from 'app/types/unified-alerting-dto'; + +import { trackImportToGMAError, trackImportToGMASuccess } from '../../Analytics'; +import { convertToGMAApi } from '../../api/convertToGMAApi'; +import { createListFilterLink } from '../../utils/navigation'; +import { useGetRulerRules } from '../rule-editor/useAlertRuleSuggestions'; + +import { ImportFormValues } from './ImportFromDSRules'; + +type ModalProps = Pick, 'isOpen' | 'onDismiss'> & { + isOpen: boolean; +}; + +export const ConfirmConversionModal = ({ isOpen, onDismiss }: ModalProps) => { + const { watch } = useFormContext(); + + const [ + targetFolder, + selectedDatasourceName, + selectedDatasourceUID, + pauseRecordingRules, + pauseAlertingRules, + namespace, + ruleGroup, + targetDatasourceUID, + ] = watch([ + 'targetFolder', + 'selectedDatasourceName', + 'selectedDatasourceUID', + 'pauseRecordingRules', + 'pauseAlertingRules', + 'namespace', + 'ruleGroup', + 'targetDatasourceUID', + ]); + const { rulerRules } = useGetRulerRules(selectedDatasourceName || undefined); + const [convert] = convertToGMAApi.useConvertToGMAMutation(); + const notifyApp = useAppNotification(); + const rulerRulesToPayload = filterRulerRulesConfig(rulerRules, namespace, ruleGroup); + + async function onConvertConfirm() { + try { + await convert({ + dataSourceUID: selectedDatasourceUID, + targetFolderUID: targetFolder?.uid, + pauseRecordingRules: pauseRecordingRules, + pauseAlerts: pauseAlertingRules, + payload: rulerRulesToPayload, + targetDatasourceUID, + }).unwrap(); + + const isRootFolder = isEmpty(targetFolder?.uid); + + trackImportToGMASuccess(); + const ruleListUrl = createListFilterLink(isRootFolder ? [] : [['namespace', targetFolder?.title ?? '']], { + skipSubPath: true, + }); + notifyApp.success( + t('alerting.import-to-gma.success', 'Successfully imported alert rules to Grafana-managed rules.') + ); + locationService.push(ruleListUrl); + } catch (error) { + trackImportToGMAError(); + notifyApp.error( + t('alerting.import-to-gma.error', 'Failed to import alert rules: {{error}}', { + error: stringifyErrorLike(error), + }) + ); + } + } + + // translations for texts in the modal + const title = t('alerting.import-to-gma.confirm-modal.title', 'Confirm import'); + const confirmText = t('alerting.import-to-gma.confirm-modal.confirm', 'Yes, import'); + return ( + + + + + + + If the target folder is not empty, some rules may be overwritten or removed. Are you sure you want to + import these alert rules to Grafana-managed rules? + + + + + These are the list of rules that will be imported: + + + {rulerRulesToPayload && } + + } + onConfirm={onConvertConfirm} + onDismiss={onDismiss} + /> + ); +}; + +function filterRulerRulesConfig( + rulerRulesConfig: RulerRulesConfigDTO, + namespace?: string, + groupName?: string +): RulerRulesConfigDTO { + const filteredConfig: RulerRulesConfigDTO = {}; + + Object.entries(rulerRulesConfig).forEach(([ns, groups]) => { + if (namespace && ns !== namespace) { + return; + } + + const filteredGroups = groups.filter((group) => { + if (groupName && group.name !== groupName) { + return false; + } + return true; + }); + + if (filteredGroups.length > 0) { + filteredConfig[ns] = filteredGroups; + } + }); + + return filteredConfig; +} + +function RulesPreview({ rules }: { rules: RulerRulesConfigDTO }) { + const styles = useStyles2(getStyles); + return ( +
+ +
+ ); +} + +const getStyles = () => ({ + content: css({ + flex: '1 1 100%', + }), + modal: css({ + width: '700px', + }), +}); diff --git a/public/app/features/alerting/unified/components/import-to-gma/ImportFromDSRules.tsx b/public/app/features/alerting/unified/components/import-to-gma/ImportFromDSRules.tsx new file mode 100644 index 00000000000..1609f13d76b --- /dev/null +++ b/public/app/features/alerting/unified/components/import-to-gma/ImportFromDSRules.tsx @@ -0,0 +1,246 @@ +import { Controller, FormProvider, useForm } from 'react-hook-form'; +import { useToggle } from 'react-use'; + +import { DataSourceInstanceSettings } from '@grafana/data'; +import { + Box, + Button, + Collapse, + Divider, + Field, + InlineField, + InlineSwitch, + LinkButton, + Spinner, + Stack, + Text, +} from '@grafana/ui'; +import { NestedFolderPicker } from 'app/core/components/NestedFolderPicker/NestedFolderPicker'; +import { Trans, t } from 'app/core/internationalization'; +import { DataSourcePicker } from 'app/features/datasources/components/picker/DataSourcePicker'; + +import { Folder } from '../../types/rule-form'; +import { withPageErrorBoundary } from '../../withPageErrorBoundary'; +import { AlertingPageWrapper } from '../AlertingPageWrapper'; +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; +} + +const ImportFromDSRules = () => { + const formAPI = useForm({ + defaultValues: { + selectedDatasourceUID: undefined, + selectedDatasourceName: '', + pauseAlertingRules: true, + pauseRecordingRules: true, + targetFolder: undefined, + targetDatasourceUID: undefined, + }, + }); + const { + register, + handleSubmit, + watch, + control, + setValue, + formState: { errors, isSubmitting }, + } = formAPI; + + const [optionsShowing, toggleOptions] = useToggle(false); + const [targetFolder, selectedDatasourceName] = watch(['targetFolder', 'selectedDatasourceName']); + const [showConfirmModal, setShowConfirmModal] = useToggle(false); + + const onSubmit = async () => { + setShowConfirmModal(true); + }; + + return ( + + + +
+ + + ( + { + setValue('selectedDatasourceUID', ds.uid); + setValue('selectedDatasourceName', ds.name); + }} + /> + )} + name="selectedDatasourceName" + rules={{ + required: { + value: true, + message: t('alerting.import-to-gma.datasource.required-message', 'Please select a datasource'), + }, + }} + control={control} + /> + + + + + + + {t('alerting.import-to-gma.import-location-and-filters', 'Import location and filters')} + + + + + ( + + { + if (uid && title) { + setValue('targetFolder', { title, uid }); + } else { + setValue('targetFolder', undefined); + } + }} + /> + + )} + name="targetFolder" + control={control} + /> + + + + + + + + + {t('alerting.import-to-gma.alert-rules', 'Alert rules')} + + + + + + + + + + + + {t('alerting.import-to-gma.recording-rules', 'Recording rules')} + + + + + + + + + ( + ds.type === 'prometheus'} + onChange={(ds: DataSourceInstanceSettings) => { + setValue('targetDatasourceUID', ds.uid); + }} + /> + )} + name="targetDatasourceUID" + control={control} + /> + + + + + + + + + + + + Cancel + + + + setShowConfirmModal(false)} /> + +
+
+
+ ); +}; + +export default withPageErrorBoundary(ImportFromDSRules); diff --git a/public/app/features/alerting/unified/components/import-to-gma/NamespaceAndGroupFilter.tsx b/public/app/features/alerting/unified/components/import-to-gma/NamespaceAndGroupFilter.tsx new file mode 100644 index 00000000000..6cab25cb808 --- /dev/null +++ b/public/app/features/alerting/unified/components/import-to-gma/NamespaceAndGroupFilter.tsx @@ -0,0 +1,105 @@ +import { useEffect, useMemo } from 'react'; +import { Controller, useFormContext } from 'react-hook-form'; + +import { Combobox, ComboboxOption, Field, Stack } from '@grafana/ui'; +import { t } from 'app/core/internationalization'; + +import { useGetNameSpacesByDatasourceName } from '../rule-editor/useAlertRuleSuggestions'; + +import { ImportFormValues } from './ImportFromDSRules'; + +interface Props { + rulesSourceName?: string; +} + +export const NamespaceAndGroupFilter = ({ rulesSourceName }: Props) => { + const { + control, + watch, + formState: { errors }, + setValue, + } = useFormContext(); + + const namespace = watch('namespace'); + const { namespaceGroups, isLoading } = useGetNameSpacesByDatasourceName(rulesSourceName); + + const namespaceOptions: Array> = useMemo( + () => + Array.from(namespaceGroups.keys()).map((namespace) => ({ + label: namespace, + value: namespace, + })), + [namespaceGroups] + ); + + const groupOptions: Array> = useMemo( + () => (namespace && namespaceGroups.get(namespace)?.map((group) => ({ label: group, value: group }))) || [], + [namespace, namespaceGroups] + ); + + useEffect(() => { + // Reset namespace/group if datasource changes + setValue('namespace', ''); + setValue('ruleGroup', ''); + }, [rulesSourceName, setValue]); + + return ( + + + ( + { + setValue('ruleGroup', ''); //reset if namespace changes + onChange(value.value); + }} + id="namespace-picker" + placeholder={t('alerting.namespace-and-group-filter.select-namespace', 'Select namespace')} + options={namespaceOptions} + width={42} + loading={isLoading} + disabled={isLoading || !rulesSourceName} + /> + )} + name="namespace" + control={control} + /> + + + ( + { + setValue('ruleGroup', value.value ?? ''); + }} + id="group-picker" + placeholder={t('alerting.namespace-and-group-filter.select-group', 'Select group')} + loading={isLoading} + disabled={isLoading || !namespace || !rulesSourceName} + /> + )} + name="ruleGroup" + control={control} + /> + + + ); +}; diff --git a/public/app/features/alerting/unified/components/rule-editor/AlertRuleNameInput.tsx b/public/app/features/alerting/unified/components/rule-editor/AlertRuleNameInput.tsx index 87b680f616b..1fe111a35b7 100644 --- a/public/app/features/alerting/unified/components/rule-editor/AlertRuleNameInput.tsx +++ b/public/app/features/alerting/unified/components/rule-editor/AlertRuleNameInput.tsx @@ -89,7 +89,7 @@ export const AlertRuleNameAndMetric = () => { 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 the recording rule in' + 'The Prometheus data source to store recording rules in' )} error={errors.targetDatasourceUid?.message} invalid={!!errors.targetDatasourceUid?.message} diff --git a/public/app/features/alerting/unified/components/rule-editor/CloudRulesSourcePicker.tsx b/public/app/features/alerting/unified/components/rule-editor/CloudRulesSourcePicker.tsx index 8c18b146926..72ec6200ec5 100644 --- a/public/app/features/alerting/unified/components/rule-editor/CloudRulesSourcePicker.tsx +++ b/public/app/features/alerting/unified/components/rule-editor/CloudRulesSourcePicker.tsx @@ -1,11 +1,11 @@ import { useCallback } from 'react'; import { DataSourceInstanceSettings } from '@grafana/data'; -import { DataSourcePicker } from 'app/features/datasources/components/picker/DataSourcePicker'; +import { DataSourcePicker, DataSourcePickerProps } from 'app/features/datasources/components/picker/DataSourcePicker'; import { useRulesSourcesWithRuler } from '../../hooks/useRuleSourcesWithRuler'; -interface Props { +interface Props extends DataSourcePickerProps { disabled?: boolean; onChange: (ds: DataSourceInstanceSettings) => void; value: string | null; diff --git a/public/app/features/alerting/unified/components/rule-editor/GroupAndNamespaceFields.tsx b/public/app/features/alerting/unified/components/rule-editor/GroupAndNamespaceFields.tsx index 6bbac094cbb..1a19b8a5611 100644 --- a/public/app/features/alerting/unified/components/rule-editor/GroupAndNamespaceFields.tsx +++ b/public/app/features/alerting/unified/components/rule-editor/GroupAndNamespaceFields.tsx @@ -7,7 +7,7 @@ import { Field, VirtualizedSelect, useStyles2 } from '@grafana/ui'; import { RuleFormValues } from '../../types/rule-form'; -import { useAlertRuleSuggestions } from './useAlertRuleSuggestions'; +import { useGetNameSpacesByDatasourceName } from './useAlertRuleSuggestions'; interface Props { rulesSourceName: string; @@ -22,7 +22,7 @@ export const GroupAndNamespaceFields = ({ rulesSourceName }: Props) => { } = useFormContext(); const style = useStyles2(getStyle); - const { namespaceGroups, isLoading } = useAlertRuleSuggestions(rulesSourceName); + const { namespaceGroups, isLoading } = useGetNameSpacesByDatasourceName(rulesSourceName); const namespace = watch('namespace'); diff --git a/public/app/features/alerting/unified/components/rule-editor/labels/LabelsField.tsx b/public/app/features/alerting/unified/components/rule-editor/labels/LabelsField.tsx index f3919ed0b09..9010ec81620 100644 --- a/public/app/features/alerting/unified/components/rule-editor/labels/LabelsField.tsx +++ b/public/app/features/alerting/unified/components/rule-editor/labels/LabelsField.tsx @@ -15,7 +15,7 @@ import { isRecordingRuleByType } from '../../../utils/rules'; import AlertLabelDropdown from '../../AlertLabelDropdown'; import { AlertLabels } from '../../AlertLabels'; import { NeedHelpInfo } from '../NeedHelpInfo'; -import { useAlertRuleSuggestions } from '../useAlertRuleSuggestions'; +import { useGetLabelsFromDataSourceName } from '../useAlertRuleSuggestions'; import { AddButton, RemoveButton } from './LabelsButtons'; @@ -109,7 +109,7 @@ export function useCombinedLabels( selectedKey: string ) { // ------- Get labels keys and their values from existing alerts - const { isLoading, labels: labelsByKeyFromExisingAlerts } = useAlertRuleSuggestions(dataSourceName); + const { labels: labelsByKeyFromExisingAlerts, isLoading } = useGetLabelsFromDataSourceName(dataSourceName); // ------- Get only the keys from the ops labels, as we will fetch the values for the keys once the key is selected. const { loading: isLoadingLabels, labelsOpsKeys = [] } = useGetOpsLabelsKeys( !labelsPluginInstalled || loadingLabelsPlugin diff --git a/public/app/features/alerting/unified/components/rule-editor/useAlertRuleSuggestions.tsx b/public/app/features/alerting/unified/components/rule-editor/useAlertRuleSuggestions.tsx index 4da372c2747..c74b1cf2045 100644 --- a/public/app/features/alerting/unified/components/rule-editor/useAlertRuleSuggestions.tsx +++ b/public/app/features/alerting/unified/components/rule-editor/useAlertRuleSuggestions.tsx @@ -1,3 +1,4 @@ +import { skipToken } from '@reduxjs/toolkit/query'; import { useEffect, useMemo } from 'react'; import { RuleNamespace } from 'app/types/unified-alerting'; @@ -7,13 +8,13 @@ import { alertRuleApi } from '../../api/alertRuleApi'; import { featureDiscoveryApi } from '../../api/featureDiscoveryApi'; import { shouldUsePrometheusRulesPrimary } from '../../featureToggles'; -const { usePrometheusRuleNamespacesQuery, useLazyRulerRulesQuery } = alertRuleApi; +const { usePrometheusRuleNamespacesQuery, useLazyRulerRulesQuery, useRulerRulesQuery } = alertRuleApi; const { useDiscoverDsFeaturesQuery } = featureDiscoveryApi; const prometheusRulesPrimary = shouldUsePrometheusRulesPrimary(); const emptyRulerConfig: RulerRulesConfigDTO = {}; -export function useAlertRuleSuggestions(rulesSourceName: string) { +export function useGetLabelsFromDataSourceName(rulesSourceName: string) { const { data: features, isLoading: isFeaturesLoading } = useDiscoverDsFeaturesQuery({ rulesSourceName }); // emptyRulerConfig is used to prevent from triggering labels' useMemo all the time @@ -32,6 +33,42 @@ export function useAlertRuleSuggestions(rulesSourceName: string) { } }, [features?.rulerConfig, fetchRulerRules]); + const labels = useMemo(() => { + if (isPrometheusRulesLoading || isRulerRulesLoading) { + return new Map>(); + } + + if (prometheusRulesPrimary) { + return promNamespacesToLabels(promNamespaces); + } + + return rulerRulesToLabels(rulerRules); + }, [promNamespaces, rulerRules, isPrometheusRulesLoading, isRulerRulesLoading]); + + return { labels, isLoading: isPrometheusRulesLoading || isRulerRulesLoading || isFeaturesLoading }; +} + +export function useGetNameSpacesByDatasourceName(rulesSourceName?: string) { + const { data: features, isLoading: isFeaturesLoading } = useDiscoverDsFeaturesQuery( + rulesSourceName ? { rulesSourceName } : skipToken, + { skip: !rulesSourceName } + ); + + // emptyRulerConfig is used to prevent from triggering labels' useMemo all the time + // rulerRules = {} creates a new object and triggers useMemo to recalculate labels + const [fetchRulerRules, { data: rulerRules = emptyRulerConfig, isLoading: isRulerRulesLoading }] = + useLazyRulerRulesQuery(); + + const { data: promNamespaces = [], isLoading: isPrometheusRulesLoading } = usePrometheusRuleNamespacesQuery( + rulesSourceName && prometheusRulesPrimary ? { ruleSourceName: rulesSourceName } : skipToken + ); + + useEffect(() => { + if (features?.rulerConfig && !prometheusRulesPrimary) { + fetchRulerRules({ rulerConfig: features.rulerConfig }); + } + }, [features?.rulerConfig, fetchRulerRules]); + const namespaceGroups = useMemo(() => { if (isPrometheusRulesLoading || isRulerRulesLoading) { return new Map(); @@ -44,19 +81,29 @@ export function useAlertRuleSuggestions(rulesSourceName: string) { return rulerRulesToNamespaceGroups(rulerRules); }, [promNamespaces, rulerRules, isPrometheusRulesLoading, isRulerRulesLoading]); - const labels = useMemo(() => { - if (isPrometheusRulesLoading || isRulerRulesLoading) { - return new Map>(); - } + return { + namespaceGroups, + isLoading: isPrometheusRulesLoading || isRulerRulesLoading || isFeaturesLoading, + promNamespaces, + }; +} - if (prometheusRulesPrimary) { - return promNamespacesToLabels(promNamespaces); - } +export function useGetRulerRules( + /** Rules source name to fetch namespaces from */ + rulesSourceName?: string +) { + const { data: features, isLoading: isFeaturesLoading } = useDiscoverDsFeaturesQuery( + rulesSourceName ? { rulesSourceName } : skipToken + ); - return rulerRulesToLabels(rulerRules); - }, [promNamespaces, rulerRules, isPrometheusRulesLoading, isRulerRulesLoading]); + const { data: rulerRules = emptyRulerConfig, isLoading: isRulerRulesLoading } = useRulerRulesQuery( + features?.rulerConfig ? { rulerConfig: features.rulerConfig } : skipToken + ); - return { namespaceGroups, labels, isLoading: isPrometheusRulesLoading || isRulerRulesLoading || isFeaturesLoading }; + return { + isLoading: isRulerRulesLoading || isFeaturesLoading, + rulerRules, + }; } function promNamespacesToNamespaceGroups(promNamespaces: RuleNamespace[]) { @@ -70,7 +117,7 @@ function promNamespacesToNamespaceGroups(promNamespaces: RuleNamespace[]) { return groups; } -function rulerRulesToNamespaceGroups(rulerConfig: RulerRulesConfigDTO) { +export function rulerRulesToNamespaceGroups(rulerConfig: RulerRulesConfigDTO) { const result = new Map(); Object.entries(rulerConfig).forEach(([namespace, groups]) => { result.set( diff --git a/public/app/features/alerting/unified/components/rules/CloudRules.tsx b/public/app/features/alerting/unified/components/rules/CloudRules.tsx index 38717308777..e45cec6270e 100644 --- a/public/app/features/alerting/unified/components/rules/CloudRules.tsx +++ b/public/app/features/alerting/unified/components/rules/CloudRules.tsx @@ -4,8 +4,9 @@ import { useMemo } from 'react'; import { useLocation } from 'react-router-dom-v5-compat'; import { GrafanaTheme2, urlUtil } from '@grafana/data'; -import { LinkButton, LoadingPlaceholder, Pagination, Spinner, Text, useStyles2 } from '@grafana/ui'; -import { Trans } from 'app/core/internationalization'; +import { config } from '@grafana/runtime'; +import { Badge, LinkButton, LoadingPlaceholder, Pagination, Spinner, Stack, Text, useStyles2 } from '@grafana/ui'; +import { Trans, t } from 'app/core/internationalization'; import { CombinedRuleNamespace } from 'app/types/unified-alerting'; import { DEFAULT_PER_PAGE_PAGINATION } from '../../../../../core/constants'; @@ -15,6 +16,7 @@ import { useUnifiedAlertingSelector } from '../../hooks/useUnifiedAlertingSelect import { getPaginationStyles } from '../../styles/pagination'; import { getRulesDataSources, getRulesSourceUid } from '../../utils/datasource'; import { isAsyncRequestStatePending } from '../../utils/redux'; +import { createRelativeUrl } from '../../utils/url'; import { RulesGroup } from './RulesGroup'; import { useCombinedGroupNamespace } from './useCombinedGroupNamespace'; @@ -37,7 +39,6 @@ export const CloudRules = ({ namespaces, expandAll }: Props) => { ); const hasSomeResults = rulesDataSources.some((ds) => Boolean(promRules[ds.name]?.result?.length)); - const hasDataSourcesConfigured = rulesDataSources.length > 0; const hasDataSourcesLoading = dataSourcesLoading.length > 0; const hasNamespaces = namespaces.length > 0; @@ -48,39 +49,64 @@ export const CloudRules = ({ namespaces, expandAll }: Props) => { DEFAULT_PER_PAGE_PAGINATION ); + const [createRuleSupported, createRuleAllowed] = useAlertingAbility(AlertingAction.CreateAlertRule); + const [viewExternalRuleSupported, viewExternalRuleAllowed] = useAlertingAbility(AlertingAction.ViewExternalAlertRule); + + const canViewCloudRules = viewExternalRuleSupported && viewExternalRuleAllowed; + const canCreateGrafanaRules = createRuleSupported && createRuleAllowed; + const canMigrateToGMA = + hasDataSourcesConfigured && canCreateGrafanaRules && canViewCloudRules && config.featureToggles.alertingMigrationUI; + return (
-
-
- - Data source-managed - - {dataSourcesLoading.length ? ( - - ) : ( -
- )} - + +
+
+ + Data source-managed + + {dataSourcesLoading.length ? ( + + ) : ( +
+ )} + + {canMigrateToGMA && } + + +
-
+
- {pageItems.map(({ group, namespace }) => { - return ( - - ); - })} + {pageItems.map(({ group, namespace }) => ( + + ))} - {!hasDataSourcesConfigured &&

There are no Prometheus or Loki data sources configured.

} - {hasDataSourcesConfigured && !hasDataSourcesLoading && !hasNamespaces &&

No rules found.

} + {!hasDataSourcesConfigured && ( +

+ + There are no Prometheus or Loki data sources configured + +

+ )} + {hasDataSourcesConfigured && !hasDataSourcesLoading && !hasNamespaces && ( +

+ No rules found. +

+ )} {!hasSomeResults && hasDataSourcesLoading && } ({ export function CreateRecordingRuleButton() { const [createCloudRuleSupported, createCloudRuleAllowed] = useAlertingAbility(AlertingAction.CreateExternalAlertRule); - const location = useLocation(); const canCreateCloudRules = createCloudRuleSupported && createCloudRuleAllowed; @@ -144,3 +169,20 @@ export function CreateRecordingRuleButton() { } return null; } + +function MigrateToGMAButton() { + const importUrl = createRelativeUrl('/alerting/import-datasource-managed-rules'); + return ( + + + Import to Grafana-managed rules + + + + ); +} diff --git a/public/app/features/alerting/unified/utils/navigation.ts b/public/app/features/alerting/unified/utils/navigation.ts index ba103c47639..0c72f7ae8c1 100644 --- a/public/app/features/alerting/unified/utils/navigation.ts +++ b/public/app/features/alerting/unified/utils/navigation.ts @@ -7,9 +7,9 @@ import { createRelativeUrl } from './url'; type QueryParams = ConstructorParameters[0]; -export const createListFilterLink = (values: Array<[string, string]>) => { +export const createListFilterLink = (values: Array<[string, string]>, options?: { skipSubPath?: boolean }) => { const params = new URLSearchParams([['search', values.map(([key, value]) => `${key}:"${value}"`).join(' ')]]); - return createRelativeUrl(`/alerting/list`, params); + return createRelativeUrl(`/alerting/list`, params, { skipSubPath: options?.skipSubPath }); }; export const alertListPageLink = (queryParams: Record = {}, options?: { skipSubPath?: boolean }) => diff --git a/public/locales/en-US/grafana.json b/public/locales/en-US/grafana.json index 9fb06287864..beb46edc937 100644 --- a/public/locales/en-US/grafana.json +++ b/public/locales/en-US/grafana.json @@ -582,6 +582,44 @@ "group-loader": { "group-load-failed": "Failed to load rules from group {{ groupName }} in {{ namespaceName }}" }, + "import-from-dsrules": { + "description-folder-import-rules": "The folder to import the rules to" + }, + "import-to-gma": { + "action-button": "Import", + "alert-rules": "Alert rules", + "confirm-modal": { + "confirm": "Yes, import", + "title": "Confirm import" + }, + "datasource": { + "label": "Data source", + "required-message": "Please select a datasource" + }, + "error": "Failed to import alert rules: {{error}}", + "group": { + "description": "Type to search for an existing group", + "label": "Group" + }, + "import-location-and-filters": "Import location and filters", + "namespace": { + "description": "Type to search for an existing namespace", + "label": "Namespace" + }, + "optional-settings": "Optional settings", + "pageTitle": "Import alert rules from a datasource to Grafana-managed rules", + "pause": { + "label": "Pause imported alerting rules" + }, + "pause-recording": { + "label": "Pause imported recording rules" + }, + "recording-rules": "Recording rules", + "success": "Successfully imported alert rules to Grafana-managed rules.", + "target-folder": { + "label": "Target folder" + } + }, "irm-integration": { "connection-method": "How to connect to IRM", "disabled-description": "Enable Grafana IRM to use this integration", @@ -607,6 +645,8 @@ "new-recording-rule": "New recording rule", "provisioning": "You can also define rules through file provisioning or Terraform. <2>Learn more" }, + "no-prom-or-loki-rules": "There are no Prometheus or Loki data sources configured", + "no-rules": "No rules found.", "section": { "dataSourceManaged": { "title": "Data source-managed" @@ -617,7 +657,8 @@ "loading": "Loading...", "new-recording-rule": "New recording rule", "title": "Grafana-managed" - } + }, + "loading-rules": "Loading rules from {{numberOfSources}} {{sources}}" } }, "manage-permissions": { @@ -636,6 +677,10 @@ "save": "Save mute timing", "saving": "Saving mute timing" }, + "namespace-and-group-filter": { + "select-group": "Select group", + "select-namespace": "Select namespace" + }, "notification-preview": { "alertmanager": "Alertmanager:", "error": "Could not load routing preview for {{alertmanager}}", @@ -731,7 +776,7 @@ "previewCondition": "Preview alert rule condition" }, "recording-rules": { - "description-target-data-source": "The Prometheus data source to store the recording rule in", + "description-target-data-source": "The Prometheus data source to store recording rules in", "label-target-data-source": "Target data source" }, "rule-form": { @@ -846,6 +891,10 @@ "no-more-results": "No more results – showing {{numberOfRules}} rules", "no-rules-found": "No alert or recording rules matched your current set of filters." }, + "import-to-gma": { + "new-badge": "New!", + "text": "Import to Grafana-managed rules" + }, "more": "More", "new-alert-rule": "New alert rule", "new-datasource-recording-rule": "New Data source recording rule", @@ -935,6 +984,12 @@ }, "threshold": { "to": "TO" + }, + "to-gma": { + "confirm-modal": { + "body": "If the target folder is not empty, some rules may be overwritten or removed. Are you sure you want to import these alert rules to Grafana-managed rules?", + "summary": "These are the list of rules that will be imported:" + } } }, "annotations": {