Alerting: Migration UI (#102010)

* add feature toggle alertingMigrationUI

* wip

* wip

* wip2

* add button to export in alert list and add target folder in the form

* update namings

* protext migration route with alertingMigrationUI feature toggle

* remove unused error style

* add namspace and group filter

* fix translations and prettier

* fix duplication

* use the new endpoint

* update right roles for the FE route

* refactor

* notify success

* address feedback

* fix translations

* Simplify migration UI for alert rules to GMA format

Extracts migration button component and reuses it in banner for better
code organization and consistency.

* Fix root folder check in import alert rules UI

Replace hardcoded empty string check with lodash isEmpty for folder UID
validation

* Add collapsible section for import rule options

* address feedback

* Split useAlertRuleSuggestions, one for labels, and other for namespaces

* refactor splited hooks

* Alerting: (PR review) Redesign slightly + use combobox (#102614)

Redesign slightly + use combobox

* udpate naming

* remove eslint-disable-next-line @grafana/no-untranslated-strings and add hasDataSourcesConfigured as a condition to render the banner

* use button instead of banner

* fix routes

* use new api

* update api

* remove query params

* update translations

* update ff files

* skip subpath when redirecting to the alert list view with filters

* prettier

* disable provenance

* prettier

* Don't send target forlder uid in the payload as undefined if it's not selected

* prettier

* add confirmation modal with a preview

* remove unnecessary export

* Add target datasource UID for recording rules

* Use skipToken

* Add recording rule datasource and update designs

* Use different hook to fetch rulerRules

---------

Co-authored-by: Gilles De Mey <gilles.de.mey@gmail.com>
Co-authored-by: Tom Ratcliffe <tom.ratcliffe@grafana.com>
This commit is contained in:
Sonia Aguilar
2025-04-03 14:04:11 +01:00
committed by GitHub
co-authored by Gilles De Mey Tom Ratcliffe
parent 9111a2839c
commit 9b91dc7855
15 changed files with 790 additions and 59 deletions
+1 -3
View File
@@ -2069,9 +2069,7 @@ exports[`better eslint`] = {
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "3"]
],
"public/app/features/alerting/unified/components/rules/CloudRules.tsx:5381": [
[0, 0, 0, "No untranslated strings in text props. Wrap text with <Trans /> or use t()", "0"],
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "1"],
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "2"]
[0, 0, 0, "No untranslated strings in text props. Wrap text with <Trans /> 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 <Trans /> or use t()", "0"],
+24
View File
@@ -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'
)
)
: () => <Navigate replace to="/alerting/list" />,
},
{
path: '/alerting/recently-deleted/',
roles: () => ['Admin'],
@@ -224,6 +236,18 @@ export function getAlertingRoutes(cfg = config): RouteDescriptor[] {
)
: () => <Navigate replace to="/alerting/list" />,
},
{
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/new/:type?',
pageClass: 'page-alerting',
@@ -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';
@@ -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 } : {}),
},
}),
}),
}),
});
@@ -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<ComponentProps<typeof ConfirmModal>, 'isOpen' | 'onDismiss'> & {
isOpen: boolean;
};
export const ConfirmConversionModal = ({ isOpen, onDismiss }: ModalProps) => {
const { watch } = useFormContext<ImportFormValues>();
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 (
<ConfirmModal
isOpen={isOpen}
title={title}
confirmText={confirmText}
confirmButtonVariant="primary"
body={
<Stack direction="column" gap={2}>
<Stack direction="row" gap={1} alignItems={'self-start'}>
<Text color="warning">
<Icon name="exclamation-triangle" />
</Text>
<Trans i18nKey="alerting.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?
</Trans>
</Stack>
<Text variant="h6">
<Trans i18nKey="alerting.to-gma.confirm-modal.summary">
These are the list of rules that will be imported:
</Trans>
</Text>
{rulerRulesToPayload && <RulesPreview rules={rulerRulesToPayload} />}
</Stack>
}
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 (
<div className={styles.content}>
<CodeEditor
width="100%"
height={500}
language={'json'}
value={JSON.stringify(rules, null, 4)}
monacoOptions={{
minimap: {
enabled: false,
},
scrollBeyondLastLine: false,
lineNumbers: 'on',
readOnly: true,
}}
/>
</div>
);
}
const getStyles = () => ({
content: css({
flex: '1 1 100%',
}),
modal: css({
width: '700px',
}),
});
@@ -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<ImportFormValues>({
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 (
<AlertingPageWrapper
navId="alert-list"
pageNav={{
text: t('alerting.import-to-gma.pageTitle', 'Import alert rules from a datasource 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);
}}
/>
)}
name="selectedDatasourceName"
rules={{
required: {
value: true,
message: t('alerting.import-to-gma.datasource.required-message', 'Please select a datasource'),
},
}}
control={control}
/>
</Field>
<Collapse
label={t('alerting.import-to-gma.optional-settings', 'Optional 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'
)}
invalid={!!errors.selectedDatasourceName}
error={errors.selectedDatasourceName?.message}
htmlFor="folder-picker"
>
<Controller
render={({ field: { onChange, ref, ...field } }) => (
<Stack width={50}>
<NestedFolderPicker
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}
/>
</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={false}
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}
/>
</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,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<ImportFormValues>();
const namespace = watch('namespace');
const { namespaceGroups, isLoading } = useGetNameSpacesByDatasourceName(rulesSourceName);
const namespaceOptions: Array<ComboboxOption<string>> = useMemo(
() =>
Array.from(namespaceGroups.keys()).map((namespace) => ({
label: namespace,
value: namespace,
})),
[namespaceGroups]
);
const groupOptions: Array<ComboboxOption<string>> = 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 (
<Stack direction="row" gap={2}>
<Field
htmlFor="namespace-picker"
data-testid="namespace-picker"
label={t('alerting.import-to-gma.namespace.label', 'Namespace')}
description={t('alerting.import-to-gma.namespace.description', 'Type to search for an existing namespace')}
error={errors.namespace?.message}
invalid={!!errors.namespace?.message}
>
<Controller
render={({ field: { onChange, ref, ...field } }) => (
<Combobox
{...field}
onChange={(value) => {
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}
/>
</Field>
<Field
htmlFor="group-picker"
data-testid="group-picker"
label={t('alerting.import-to-gma.group.label', 'Group')}
description={t('alerting.import-to-gma.group.description', 'Type to search for an existing group')}
error={errors.ruleGroup?.message}
invalid={!!errors.ruleGroup?.message}
>
<Controller
render={({ field: { ref, ...field } }) => (
<Combobox
{...field}
options={groupOptions}
width={42}
onChange={(value) => {
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}
/>
</Field>
</Stack>
);
};
@@ -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}
@@ -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;
@@ -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<RuleFormValues>();
const style = useStyles2(getStyle);
const { namespaceGroups, isLoading } = useAlertRuleSuggestions(rulesSourceName);
const { namespaceGroups, isLoading } = useGetNameSpacesByDatasourceName(rulesSourceName);
const namespace = watch('namespace');
@@ -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
@@ -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<string, Set<string>>();
}
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<string, string[]>();
@@ -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<string, Set<string>>();
}
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<string, string[]>();
Object.entries(rulerConfig).forEach(([namespace, groups]) => {
result.set(
@@ -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 (
<section className={styles.wrapper}>
<div className={styles.sectionHeader}>
<div className={styles.headerRow}>
<Text element="h2" variant="h5">
<Trans i18nKey="alerting.list-view.section.dataSourceManaged.title">Data source-managed</Trans>
</Text>
{dataSourcesLoading.length ? (
<LoadingPlaceholder
className={styles.loader}
text={`Loading rules from ${dataSourcesLoading.length} ${pluralize('source', dataSourcesLoading.length)}`}
/>
) : (
<div />
)}
<CreateRecordingRuleButton />
<Stack gap={2} direction="column">
<div className={styles.sectionHeader}>
<div className={styles.headerRow}>
<Text element="h2" variant="h5">
<Trans i18nKey="alerting.list-view.section.dataSourceManaged.title">Data source-managed</Trans>
</Text>
{dataSourcesLoading.length ? (
<LoadingPlaceholder
className={styles.loader}
text={t(
'alerting.list-view.section.loading-rules',
'Loading rules from {{numberOfSources}} {{sources}}',
{ numberOfSource: dataSourcesLoading.length, sources: pluralize('source', dataSourcesLoading.length) }
)}
/>
) : (
<div />
)}
<Stack gap={1}>
{canMigrateToGMA && <MigrateToGMAButton />}
<CreateRecordingRuleButton />
</Stack>
</div>
</div>
</div>
</Stack>
{pageItems.map(({ group, namespace }) => {
return (
<RulesGroup
group={group}
key={`${getRulesSourceUid(namespace.rulesSource)}-${namespace.name}-${group.name}`}
namespace={namespace}
expandAll={expandAll}
viewMode={'grouped'}
/>
);
})}
{pageItems.map(({ group, namespace }) => (
<RulesGroup
group={group}
key={`${getRulesSourceUid(namespace.rulesSource)}-${namespace.name}-${group.name}`}
namespace={namespace}
expandAll={expandAll}
viewMode={'grouped'}
/>
))}
{!hasDataSourcesConfigured && <p>There are no Prometheus or Loki data sources configured.</p>}
{hasDataSourcesConfigured && !hasDataSourcesLoading && !hasNamespaces && <p>No rules found.</p>}
{!hasDataSourcesConfigured && (
<p>
<Trans i18nKey="alerting.list-view.no-prom-or-loki-rules">
There are no Prometheus or Loki data sources configured
</Trans>
</p>
)}
{hasDataSourcesConfigured && !hasDataSourcesLoading && !hasNamespaces && (
<p>
<Trans i18nKey="alerting.list-view.no-rules">No rules found.</Trans>
</p>
)}
{!hasSomeResults && hasDataSourcesLoading && <Spinner size="xl" className={styles.spinner} />}
<Pagination
@@ -121,7 +147,6 @@ const getStyles = (theme: GrafanaTheme2) => ({
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 (
<LinkButton variant="secondary" href={importUrl} icon="arrow-up">
<Stack direction="row" gap={1} alignItems="center">
<Trans i18nKey="alerting.rule-list.import-to-gma.text">Import to Grafana-managed rules</Trans>
<Badge
text={t('alerting.rule-list.import-to-gma.new-badge', 'New!')}
aria-label="new"
color="blue"
icon="rocket"
/>
</Stack>
</LinkButton>
);
}
@@ -7,9 +7,9 @@ import { createRelativeUrl } from './url';
type QueryParams = ConstructorParameters<typeof URLSearchParams>[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<string, string> = {}, options?: { skipSubPath?: boolean }) =>
+57 -2
View File
@@ -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</2>"
},
"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": {