Alerting: Implement "rename / move / update" for useProduceNewRuleGroup (#89706)

This commit is contained in:
Gilles De Mey 2024-07-11 19:12:19 +02:00 committed by GitHub
parent f140594cf1
commit 6874202dfa
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
38 changed files with 1419 additions and 367 deletions

View File

@ -29,8 +29,8 @@ import {
mockRulerGrafanaRule,
mockRulerRuleGroup,
} from './mocks';
import { grafanaRulerRule } from './mocks/alertRuleApi';
import { mockAlertmanagerConfigResponse } from './mocks/alertmanagerApi';
import { grafanaRulerRule } from './mocks/grafanaRulerApi';
import { mockRulerRulesApiResponse, mockRulerRulesGroupApiResponse } from './mocks/rulerApi';
import { AlertingQueryRunner } from './state/AlertingQueryRunner';
import { setupDataSources } from './testSetup/datasources';

View File

@ -18,7 +18,7 @@ import * as ruler from './api/ruler';
import { ExpressionEditorProps } from './components/rule-editor/ExpressionEditor';
import { setupMswServer } from './mockApi';
import { grantUserPermissions, mockDataSource, mockFolder } from './mocks';
import { grafanaRulerGroup, grafanaRulerRule } from './mocks/alertRuleApi';
import { grafanaRulerGroup, grafanaRulerRule } from './mocks/grafanaRulerApi';
import { setupDataSources } from './testSetup/datasources';
import { Annotation } from './utils/constants';
import { GRAFANA_RULES_SOURCE_NAME } from './utils/datasource';

View File

@ -17,7 +17,7 @@ import { discoverFeatures } from './api/buildInfo';
import * as ruler from './api/ruler';
import { ExpressionEditorProps } from './components/rule-editor/ExpressionEditor';
import { grantUserPermissions, mockDataSource } from './mocks';
import { grafanaRulerGroup, grafanaRulerRule } from './mocks/alertRuleApi';
import { grafanaRulerGroup, grafanaRulerRule } from './mocks/grafanaRulerApi';
import { setupDataSources } from './testSetup/datasources';
import * as config from './utils/config';
import { GRAFANA_RULES_SOURCE_NAME } from './utils/datasource';

View File

@ -733,7 +733,13 @@ describe('RuleList', () => {
});
});
describe('edit lotex groups, namespaces', () => {
/**
* @TODO port these tests to MSW they rely on mocks a whole lot, and since we're looking to refactor the list view
* I imagine we'd need to rewrite these anyway.
*
* These actions are currently tested in the "useProduceNewRuleGroup" hook(s).
*/
describe.skip('edit lotex groups, namespaces', () => {
const testDatasources = {
prom: dataSources.prom,
};

View File

@ -6,11 +6,11 @@ import { selectors } from '@grafana/e2e-selectors';
import { config, locationService } from '@grafana/runtime';
import { mockAlertRuleApi, setupMswServer } from 'app/features/alerting/unified/mockApi';
import { waitForServerRequest } from 'app/features/alerting/unified/mocks/server/events';
import { MOCK_GRAFANA_ALERT_RULE_TITLE } from 'app/features/alerting/unified/mocks/server/handlers/alertRules';
import {
MOCK_DATASOURCE_NAME_BROKEN_ALERTMANAGER,
MOCK_DATASOURCE_UID_BROKEN_ALERTMANAGER,
} from 'app/features/alerting/unified/mocks/server/handlers/datasources';
import { MOCK_GRAFANA_ALERT_RULE_TITLE } from 'app/features/alerting/unified/mocks/server/handlers/grafanaRuler';
import { silenceCreateHandler } from 'app/features/alerting/unified/mocks/server/handlers/silences';
import { MatcherOperator, SilenceState } from 'app/plugins/datasource/alertmanager/types';
import { AccessControlAction } from 'app/types';
@ -24,7 +24,7 @@ import {
mockDataSource,
mockSilences,
} from './mocks';
import { grafanaRulerRule } from './mocks/alertRuleApi';
import { grafanaRulerRule } from './mocks/grafanaRulerApi';
import { setupDataSources } from './testSetup/datasources';
import { DataSourceType, GRAFANA_RULES_SOURCE_NAME } from './utils/datasource';

View File

@ -1,6 +1,7 @@
import { set } from 'lodash';
import { RelativeTimeRange } from '@grafana/data';
import { t } from 'app/core/internationalization';
import { Matcher } from 'app/plugins/datasource/alertmanager/types';
import { RuleIdentifier, RuleNamespace, RulerDataSourceConfig } from 'app/types/unified-alerting';
import {
@ -21,7 +22,7 @@ import { getDatasourceAPIUid, GRAFANA_RULES_SOURCE_NAME, isGrafanaRulesSource }
import { arrayKeyValuesToObject } from '../utils/labels';
import { isCloudRuleIdentifier, isPrometheusRuleIdentifier } from '../utils/rules';
import { alertingApi } from './alertingApi';
import { alertingApi, withRequestOptions, WithRequestOptions } from './alertingApi';
import {
FetchPromRulesFilter,
groupRulesByFileName,
@ -225,24 +226,65 @@ export const alertRuleApi = alertingApi.injectEndpoints({
// TODO This should be probably a separate ruler API file
getRuleGroupForNamespace: build.query<
RulerRuleGroupDTO,
{ rulerConfig: RulerDataSourceConfig; namespace: string; group: string }
WithRequestOptions<{ rulerConfig: RulerDataSourceConfig; namespace: string; group: string }>
>({
query: ({ rulerConfig, namespace, group }) => {
query: ({ rulerConfig, namespace, group, requestOptions }) => {
const { path, params } = rulerUrlBuilder(rulerConfig).namespaceGroup(namespace, group);
return { url: path, params };
return withRequestOptions({ url: path, params }, requestOptions);
},
providesTags: ['CombinedAlertRule'],
providesTags: (_result, _error, { namespace, group }) => [
{
type: 'RuleGroup',
id: `${namespace}/${group}`,
},
{ type: 'RuleNamespace', id: namespace },
],
}),
deleteRuleGroupFromNamespace: build.mutation<
RulerRuleGroupDTO,
{ rulerConfig: RulerDataSourceConfig; namespace: string; group: string }
WithRequestOptions<{ rulerConfig: RulerDataSourceConfig; namespace: string; group: string }>
>({
query: ({ rulerConfig, namespace, group }) => {
query: ({ rulerConfig, namespace, group, requestOptions }) => {
const successMessage = t('alerting.rule-groups.delete.success', 'Successfully deleted rule group');
const { path, params } = rulerUrlBuilder(rulerConfig).namespaceGroup(namespace, group);
return { url: path, params, method: 'DELETE' };
return withRequestOptions({ url: path, params, method: 'DELETE' }, requestOptions, { successMessage });
},
invalidatesTags: ['CombinedAlertRule'],
invalidatesTags: (_result, _error, { namespace, group }) => [
{
type: 'RuleGroup',
id: `${namespace}/${group}`,
},
{ type: 'RuleNamespace', id: namespace },
],
}),
upsertRuleGroupForNamespace: build.mutation<
AlertGroupUpdated,
WithRequestOptions<{
rulerConfig: RulerDataSourceConfig;
namespace: string;
payload: PostableRulerRuleGroupDTO;
}>
>({
query: ({ payload, namespace, rulerConfig, requestOptions }) => {
const { path, params } = rulerUrlBuilder(rulerConfig).namespace(namespace);
const successMessage = t('alerting.rule-groups.update.success', 'Successfully updated rule group');
return withRequestOptions(
{
url: path,
params,
data: payload,
method: 'POST',
},
requestOptions,
{ successMessage }
);
},
invalidatesTags: (_result, _error, { namespace }) => [{ type: 'RuleNamespace', id: namespace }],
}),
getAlertRule: build.query<RulerGrafanaRuleDTO, { uid: string }>({
@ -312,21 +354,5 @@ export const alertRuleApi = alertingApi.injectEndpoints({
}),
keepUnusedDataFor: 0,
}),
updateRuleGroupForNamespace: build.mutation<
AlertGroupUpdated,
{ rulerConfig: RulerDataSourceConfig; namespace: string; payload: PostableRulerRuleGroupDTO }
>({
query: ({ payload, namespace, rulerConfig }) => {
const { path, params } = rulerUrlBuilder(rulerConfig).namespace(namespace);
return {
url: path,
params,
data: payload,
method: 'POST',
};
},
invalidatesTags: ['CombinedAlertRule'],
}),
}),
});

View File

@ -1,34 +1,84 @@
import { BaseQueryFn, createApi } from '@reduxjs/toolkit/query/react';
import { defaultsDeep } from 'lodash';
import { lastValueFrom } from 'rxjs';
import { AppEvents } from '@grafana/data';
import { BackendSrvRequest, getBackendSrv } from '@grafana/runtime';
import appEvents from 'app/core/app_events';
import { logMeasurement } from '../Analytics';
export const backendSrvBaseQuery = (): BaseQueryFn<BackendSrvRequest> => async (requestOptions) => {
try {
const requestStartTs = performance.now();
const { data, ...meta } = await lastValueFrom(getBackendSrv().fetch(requestOptions));
logMeasurement(
'backendSrvBaseQuery',
{
loadTimeMs: performance.now() - requestStartTs,
},
{
url: requestOptions.url,
method: requestOptions.method ?? 'GET',
responseStatus: meta.statusText,
}
);
return { data, meta };
} catch (error) {
return { error };
}
export type ExtendedBackendSrvRequest = BackendSrvRequest & {
/**
* Custom success message to show after completion of the request.
*
* If a custom message is provided, any success message provided from the API response
* will not be shown
*/
successMessage?: string;
/**
* Custom error message to show if there's an error completing the request via backendSrv.
*
* If a custom message is provided, any error message from the API response
* will not be shown
*/
errorMessage?: string;
};
// utility type for passing request options to endpoints
export type WithRequestOptions<T> = T & {
requestOptions?: Partial<ExtendedBackendSrvRequest>;
};
export function withRequestOptions(
options: BackendSrvRequest,
requestOptions: Partial<ExtendedBackendSrvRequest> = {},
defaults: Partial<ExtendedBackendSrvRequest> = {}
): ExtendedBackendSrvRequest {
return {
...options,
...defaultsDeep(requestOptions, defaults),
};
}
export const backendSrvBaseQuery =
(): BaseQueryFn<ExtendedBackendSrvRequest> =>
async ({ successMessage, errorMessage, ...requestOptions }) => {
try {
const modifiedRequestOptions: BackendSrvRequest = {
...requestOptions,
...(successMessage && { showSuccessAlert: false }),
...(errorMessage && { showErrorAlert: false }),
};
const requestStartTs = performance.now();
const { data, ...meta } = await lastValueFrom(getBackendSrv().fetch(modifiedRequestOptions));
logMeasurement(
'backendSrvBaseQuery',
{
loadTimeMs: performance.now() - requestStartTs,
},
{
url: requestOptions.url,
method: requestOptions.method ?? 'GET',
responseStatus: meta.statusText,
}
);
if (successMessage && requestOptions.showSuccessAlert !== false) {
appEvents.emit(AppEvents.alertSuccess, [successMessage]);
}
return { data, meta };
} catch (error) {
if (errorMessage && requestOptions.showErrorAlert !== false) {
appEvents.emit(AppEvents.alertError, [errorMessage]);
}
return { error };
}
};
export const alertingApi = createApi({
reducerPath: 'alertingApi',
baseQuery: backendSrvBaseQuery(),
@ -45,6 +95,8 @@ export const alertingApi = createApi({
'CombinedAlertRule',
'GrafanaRulerRule',
'GrafanaSlo',
'RuleGroup',
'RuleNamespace',
],
endpoints: () => ({}),
});

View File

@ -0,0 +1,23 @@
import { notFoundToNullOrThrow } from './util';
describe('notFoundToNull', () => {
it('should convert notFound error to null', () => {
const fetchError = {
status: 404,
data: null,
};
expect(notFoundToNullOrThrow(fetchError)).toBe(null);
});
it('should not catch any non-404 error', () => {
const fetchError = {
status: 500,
data: null,
};
expect(() => {
notFoundToNullOrThrow(fetchError);
}).toThrow();
});
});

View File

@ -0,0 +1,17 @@
import { isFetchError } from '@grafana/runtime';
/**
* Catch 404 error response and return "null" instead.
*
* @example
* const ruleGroup = await fetchRuleGroup()
* .unwrap()
* .catch(notFoundToNull); // RuleGroupDTO | null
*/
export function notFoundToNullOrThrow(error: unknown): null {
if (isFetchError(error) && error.status === 404) {
return null;
}
throw error;
}

View File

@ -7,6 +7,7 @@ import {
} from 'app/features/alerting/unified/utils/rules';
import { CombinedRule } from 'app/types/unified-alerting';
import { isLoading } from '../hooks/useAsync';
import { usePauseRuleInGroup } from '../hooks/useProduceNewRuleGroup';
import { stringifyErrorLike } from '../utils/misc';
@ -24,7 +25,7 @@ interface Props {
*/
const MenuItemPauseRule = ({ rule, onPauseChange }: Props) => {
const notifyApp = useAppNotification();
const [pauseRule, updateState] = usePauseRuleInGroup();
const [updateState, pauseRule] = usePauseRuleInGroup();
const isPaused = isGrafanaRulerRule(rule.rulerRule) && isGrafanaRulerRulePaused(rule.rulerRule);
const icon = isPaused ? 'play' : 'pause';
@ -42,7 +43,7 @@ const MenuItemPauseRule = ({ rule, onPauseChange }: Props) => {
const ruleGroupId = getRuleGroupLocationFromCombinedRule(rule);
const ruleUID = rule.rulerRule.grafana_alert.uid;
await pauseRule(ruleGroupId, ruleUID, newIsPaused);
await pauseRule.execute(ruleGroupId, ruleUID, newIsPaused);
} catch (error) {
notifyApp.error(`Failed to ${newIsPaused ? 'pause' : 'resume'} the rule: ${stringifyErrorLike(error)}`);
return;
@ -55,7 +56,7 @@ const MenuItemPauseRule = ({ rule, onPauseChange }: Props) => {
<Menu.Item
label={title}
icon={icon}
disabled={updateState.isLoading}
disabled={isLoading(updateState)}
onClick={() => {
setRulePause(!isPaused);
}}

View File

@ -7,7 +7,7 @@ import { byRole, byTestId, byText } from 'testing-library-selector';
import { DashboardSearchItemType } from '../../../../search/types';
import { mockExportApi, mockSearchApi, setupMswServer } from '../../mockApi';
import { mockDashboardSearchItem, mockDataSource } from '../../mocks';
import { grafanaRulerRule } from '../../mocks/alertRuleApi';
import { grafanaRulerRule } from '../../mocks/grafanaRulerApi';
import { setupDataSources } from '../../testSetup/datasources';
import GrafanaModifyExport from './GrafanaModifyExport';

View File

@ -64,7 +64,7 @@ export const AlertRuleForm = ({ existing, prefill }: Props) => {
const [queryParams] = useQueryParams();
const [showEditYaml, setShowEditYaml] = useState(false);
const [evaluateEvery, setEvaluateEvery] = useState(existing?.group.interval ?? DEFAULT_GROUP_EVALUATION_INTERVAL);
const [deleteRuleFromGroup, _deleteRuleState] = useDeleteRuleFromGroup();
const [_deleteRuleState, deleteRuleFromGroup] = useDeleteRuleFromGroup();
const routeParams = useParams<{ type: string; id: string }>();
const ruleType = translateRouteParamToRuleType(routeParams.type);
@ -160,7 +160,7 @@ export const AlertRuleForm = ({ existing, prefill }: Props) => {
if (existing) {
const ruleGroupIdentifier = getRuleGroupLocationFromRuleWithLocation(existing);
await deleteRuleFromGroup(ruleGroupIdentifier, existing.rule);
await deleteRuleFromGroup.execute(ruleGroupIdentifier, existing.rule);
locationService.replace(returnTo);
}
};

View File

@ -28,7 +28,7 @@ import { DashboardSearchHit, DashboardSearchItemType } from 'app/features/search
import { AccessControlAction } from 'app/types';
import { GrafanaAlertStateDecision } from 'app/types/unified-alerting-dto';
import { grafanaRulerEmptyGroup, grafanaRulerNamespace2, grafanaRulerRule } from '../../../../mocks/alertRuleApi';
import { grafanaRulerEmptyGroup, grafanaRulerNamespace2, grafanaRulerRule } from '../../../../mocks/grafanaRulerApi';
import { setupDataSources } from '../../../../testSetup/datasources';
import { RECEIVER_META_KEY } from '../../../contact-points/useContactPoints';
import { ContactPointWithMetadata } from '../../../contact-points/utils';

View File

@ -8,7 +8,7 @@ import { MatcherOperator } from '../../../../../../plugins/datasource/alertmanag
import { Labels } from '../../../../../../types/unified-alerting-dto';
import { mockApi, setupMswServer } from '../../../mockApi';
import { grantUserPermissions, mockAlertQuery } from '../../../mocks';
import { mockPreviewApiResponse } from '../../../mocks/alertRuleApi';
import { mockPreviewApiResponse } from '../../../mocks/grafanaRulerApi';
import * as dataSource from '../../../utils/datasource';
import {
AlertManagerDataSource,

View File

@ -13,7 +13,7 @@ type DeleteModalHook = [JSX.Element, (rule: CombinedRule) => void, () => void];
export const useDeleteModal = (redirectToListView = false): DeleteModalHook => {
const [ruleToDelete, setRuleToDelete] = useState<CombinedRule | undefined>();
const [deleteRuleFromGroup, _deleteState] = useDeleteRuleFromGroup();
const [_deleteState, deleteRuleFromGroup] = useDeleteRuleFromGroup();
const dismissModal = useCallback(() => {
setRuleToDelete(undefined);
@ -30,7 +30,7 @@ export const useDeleteModal = (redirectToListView = false): DeleteModalHook => {
}
const location = getRuleGroupLocationFromCombinedRule(rule);
await deleteRuleFromGroup(location, rule.rulerRule);
await deleteRuleFromGroup.execute(location, rule.rulerRule);
// refetch rules for this rules source
// @TODO remove this when we moved everything to RTKQ then the endpoint will simply invalidate the tags

View File

@ -17,7 +17,7 @@ import {
mockDataSource,
mockPluginLinkExtension,
} from '../../mocks';
import { grafanaRulerRule } from '../../mocks/alertRuleApi';
import { grafanaRulerRule } from '../../mocks/grafanaRulerApi';
import { setupDataSources } from '../../testSetup/datasources';
import { Annotation } from '../../utils/constants';
import { DataSourceType } from '../../utils/datasource';

View File

@ -1,22 +1,25 @@
import { css } from '@emotion/css';
import { compact } from 'lodash';
import { useEffect, useMemo } from 'react';
import * as React from 'react';
import { useMemo } from 'react';
import { FormProvider, RegisterOptions, useForm, useFormContext } from 'react-hook-form';
import { GrafanaTheme2 } from '@grafana/data';
import { Badge, Button, Field, Input, Label, LinkButton, Modal, useStyles2, Stack } from '@grafana/ui';
import { Badge, Button, Field, Input, Label, LinkButton, Modal, useStyles2, Stack, Alert } from '@grafana/ui';
import { useAppNotification } from 'app/core/copy/appNotification';
import { useCleanup } from 'app/core/hooks/useCleanup';
import { useDispatch } from 'app/types';
import { CombinedRuleGroup, CombinedRuleNamespace } from 'app/types/unified-alerting';
import { dispatch } from 'app/store/store';
import { CombinedRuleGroup, CombinedRuleNamespace, RuleGroupIdentifier } from 'app/types/unified-alerting';
import { RulerRuleDTO } from 'app/types/unified-alerting-dto';
import { useUnifiedAlertingSelector } from '../../hooks/useUnifiedAlertingSelector';
import { rulesInSameGroupHaveInvalidFor, updateLotexNamespaceAndGroupAction } from '../../state/actions';
import { anyOfRequestState } from '../../hooks/useAsync';
import {
useMoveRuleGroup,
useRenameRuleGroup,
useUpdateRuleGroupConfiguration,
} from '../../hooks/useProduceNewRuleGroup';
import { fetchRulerRulesAction, rulesInSameGroupHaveInvalidFor } from '../../state/actions';
import { checkEvaluationIntervalGlobalLimit } from '../../utils/config';
import { getRulesSourceName, GRAFANA_RULES_SOURCE_NAME } from '../../utils/datasource';
import { initialAsyncRequestState } from '../../utils/redux';
import { stringifyErrorLike } from '../../utils/misc';
import { DEFAULT_GROUP_EVALUATION_INTERVAL } from '../../utils/rule-form';
import { AlertInfo, getAlertInfo, isRecordingRulerRule } from '../../utils/rules';
import { formatPrometheusDuration, parsePrometheusDuration, safeParsePrometheusDuration } from '../../utils/time';
@ -176,11 +179,21 @@ export function EditCloudGroupModal(props: ModalProps): React.ReactElement {
const { namespace, group, onClose, intervalEditOnly, folderUid } = props;
const styles = useStyles2(getStyles);
const dispatch = useDispatch();
const { loading, error, dispatched } =
useUnifiedAlertingSelector((state) => state.updateLotexNamespaceAndGroup) ?? initialAsyncRequestState;
const notifyApp = useAppNotification();
/**
* This modal can take 3 different actions, depending on what fields were updated.
*
* 1. update the rule group details without renaming either the namespace or group
* 2. rename the rule group, but keeping it in the same namespace
* 3. move the rule group to a new namespace, optionally with a different group name
*/
const [updateRuleGroupState, updateRuleGroup] = useUpdateRuleGroupConfiguration();
const [renameRuleGroupState, renameRuleGroup] = useRenameRuleGroup();
const [moveRuleGroupState, moveRuleGroup] = useMoveRuleGroup();
const { loading, error } = anyOfRequestState(updateRuleGroupState, moveRuleGroupState, renameRuleGroupState);
const defaultValues = useMemo(
(): FormValues => ({
namespaceName: decodeGrafanaNamespace(namespace).name,
@ -198,31 +211,35 @@ export function EditCloudGroupModal(props: ModalProps): React.ReactElement {
const nameSpaceLabel = isGrafanaManagedGroup ? 'Folder' : 'Namespace';
// close modal if successfully saved
useEffect(() => {
if (dispatched && !loading && !error) {
onClose(true);
}
}, [dispatched, loading, onClose, error]);
const onSubmit = async (values: FormValues) => {
const ruleGroupIdentifier: RuleGroupIdentifier = {
dataSourceName: rulesSourceName,
groupName: group.name,
namespaceName: isGrafanaManagedGroup ? folderUid! : namespace.name,
};
useCleanup((state) => (state.unifiedAlerting.updateLotexNamespaceAndGroup = initialAsyncRequestState));
const onSubmit = (values: FormValues) => {
// make sure that when dealing with a nested folder for Grafana managed rules we encode the folder properly
const newNamespaceName = isGrafanaManagedGroup
const updatedNamespaceName = isGrafanaManagedGroup
? encodeGrafanaNamespace(values.namespaceName, nestedFolderParents)
: values.namespaceName;
const updatedGroupName = values.groupName;
const updatedInterval = values.groupInterval;
dispatch(
updateLotexNamespaceAndGroupAction({
rulesSourceName: rulesSourceName,
groupName: group.name,
newGroupName: values.groupName,
namespaceName: namespace.name,
newNamespaceName: newNamespaceName,
groupInterval: values.groupInterval || undefined,
folderUid,
})
);
// GMA alert rules cannot be moved to another folder, we currently do not support it but it should be doable (with caveats).
const shouldMove = isGrafanaManagedGroup ? false : updatedNamespaceName !== ruleGroupIdentifier.namespaceName;
const shouldRename = updatedGroupName !== ruleGroupIdentifier.groupName;
try {
if (shouldMove) {
await moveRuleGroup.execute(ruleGroupIdentifier, updatedNamespaceName, updatedGroupName, updatedInterval);
} else if (shouldRename) {
await renameRuleGroup.execute(ruleGroupIdentifier, updatedGroupName, updatedInterval);
} else {
await updateRuleGroup.execute(ruleGroupIdentifier, updatedInterval);
}
onClose(true);
await dispatch(fetchRulerRulesAction({ rulesSourceName }));
} catch (_error) {} // React hook form will handle errors
};
const formAPI = useForm<FormValues>({
@ -254,7 +271,7 @@ export function EditCloudGroupModal(props: ModalProps): React.ReactElement {
return (
<Modal className={styles.modal} isOpen={true} title={modalTitle} onDismiss={onClose} onClickBackdrop={onClose}>
<FormProvider {...formAPI}>
<form onSubmit={(e) => e.preventDefault()} key={JSON.stringify(defaultValues)}>
<form onSubmit={handleSubmit(onSubmit, onInvalid)} key={JSON.stringify(defaultValues)}>
<>
{!props.hideFolder && (
<Stack gap={1} alignItems={'center'}>
@ -354,7 +371,7 @@ export function EditCloudGroupModal(props: ModalProps): React.ReactElement {
<RulesForGroupTable rulesWithoutRecordingRules={rulesWithoutRecordingRules} />
</>
)}
{error && <Alert title={'Failed to update rule group'}>{stringifyErrorLike(error)}</Alert>}
<div className={styles.modalButtons}>
<Modal.ButtonRow>
<Button
@ -366,11 +383,7 @@ export function EditCloudGroupModal(props: ModalProps): React.ReactElement {
>
Cancel
</Button>
<Button
type="button"
disabled={!isDirty || !isValid || loading}
onClick={handleSubmit((values) => onSubmit(values), onInvalid)}
>
<Button type="submit" disabled={!isDirty || !isValid || loading}>
{loading ? 'Saving...' : 'Save'}
</Button>
</Modal.ButtonRow>

View File

@ -5,7 +5,7 @@ import {
time_plus_15,
time_plus_30,
time_plus_5,
} from '../../../mocks/alertRuleApi';
} from '../../../mocks/grafanaRulerApi';
import { historyResultToDataFrame } from './utils';

View File

@ -241,3 +241,333 @@ exports[`pause rule should be able to pause a rule 1`] = `
},
]
`;
exports[`useUpdateRuleGroupConfiguration should be able to move a Data Source managed rule group 1`] = `
[
{
"body": "",
"headers": [
[
"accept",
"application/json, text/plain, */*",
],
],
"method": "GET",
"url": "https://mimir.local:9000/api/v1/status/buildinfo",
},
{
"body": "",
"headers": [
[
"accept",
"application/json, text/plain, */*",
],
],
"method": "GET",
"url": "http://localhost/api/ruler/mimir/api/v1/rules/namespace-1/group-1?subtype=mimir",
},
{
"body": "",
"headers": [
[
"accept",
"application/json, text/plain, */*",
],
],
"method": "GET",
"url": "http://localhost/api/ruler/mimir/api/v1/rules/namespace-2/a-new-group?subtype=mimir",
},
{
"body": {
"interval": "2m",
"name": "a-new-group",
"rules": [
{
"alert": "alert1",
"annotations": {
"summary": "test alert",
},
"expr": "up = 1",
"labels": {
"severity": "warning",
},
},
],
},
"headers": [
[
"content-type",
"application/json",
],
[
"accept",
"application/json, text/plain, */*",
],
],
"method": "POST",
"url": "http://localhost/api/ruler/mimir/api/v1/rules/namespace-2?subtype=mimir",
},
{
"body": "",
"headers": [
[
"accept",
"application/json, text/plain, */*",
],
],
"method": "GET",
"url": "http://localhost/api/ruler/mimir/api/v1/rules/namespace-2/a-new-group?subtype=mimir",
},
{
"body": "",
"headers": [
[
"accept",
"application/json, text/plain, */*",
],
],
"method": "DELETE",
"url": "http://localhost/api/ruler/mimir/api/v1/rules/namespace-1/group-1?subtype=mimir",
},
{
"body": "",
"headers": [
[
"accept",
"application/json, text/plain, */*",
],
],
"method": "GET",
"url": "http://localhost/api/ruler/mimir/api/v1/rules/namespace-1/group-1?subtype=mimir",
},
]
`;
exports[`useUpdateRuleGroupConfiguration should rename a rule group 1`] = `
[
{
"body": "",
"headers": [
[
"accept",
"application/json, text/plain, */*",
],
],
"method": "GET",
"url": "http://localhost/api/ruler/grafana/api/v1/rules/uuid020c61ef/grafana-group-1?subtype=cortex",
},
{
"body": "",
"headers": [
[
"accept",
"application/json, text/plain, */*",
],
],
"method": "GET",
"url": "http://localhost/api/ruler/grafana/api/v1/rules/uuid020c61ef/another-group-name?subtype=cortex",
},
{
"body": {
"interval": "2m",
"name": "another-group-name",
"rules": [
{
"annotations": {
"summary": "Test alert",
},
"for": "5m",
"grafana_alert": {
"condition": "A",
"data": [
{
"datasourceUid": "datasource-uid",
"model": {
"datasource": {
"type": "prometheus",
"uid": "datasource-uid",
},
"expression": "vector(1)",
"queryType": "alerting",
"refId": "A",
},
"queryType": "alerting",
"refId": "A",
"relativeTimeRange": {
"from": 1000,
"to": 2000,
},
},
],
"exec_err_state": "Error",
"is_paused": false,
"namespace_uid": "uuid020c61ef",
"no_data_state": "NoData",
"rule_group": "grafana-group-1",
"title": "Grafana-rule",
"uid": "4d7125fee983",
},
"labels": {
"region": "nasa",
"severity": "critical",
},
},
],
},
"headers": [
[
"content-type",
"application/json",
],
[
"accept",
"application/json, text/plain, */*",
],
],
"method": "POST",
"url": "http://localhost/api/ruler/grafana/api/v1/rules/uuid020c61ef?subtype=cortex",
},
{
"body": "",
"headers": [
[
"accept",
"application/json, text/plain, */*",
],
],
"method": "GET",
"url": "http://localhost/api/ruler/grafana/api/v1/rules/uuid020c61ef/grafana-group-1?subtype=cortex",
},
{
"body": "",
"headers": [
[
"accept",
"application/json, text/plain, */*",
],
],
"method": "GET",
"url": "http://localhost/api/ruler/grafana/api/v1/rules/uuid020c61ef/another-group-name?subtype=cortex",
},
{
"body": "",
"headers": [
[
"accept",
"application/json, text/plain, */*",
],
],
"method": "DELETE",
"url": "http://localhost/api/ruler/grafana/api/v1/rules/uuid020c61ef/grafana-group-1?subtype=cortex",
},
{
"body": "",
"headers": [
[
"accept",
"application/json, text/plain, */*",
],
],
"method": "GET",
"url": "http://localhost/api/ruler/grafana/api/v1/rules/uuid020c61ef/grafana-group-1?subtype=cortex",
},
{
"body": "",
"headers": [
[
"accept",
"application/json, text/plain, */*",
],
],
"method": "GET",
"url": "http://localhost/api/ruler/grafana/api/v1/rules/uuid020c61ef/another-group-name?subtype=cortex",
},
]
`;
exports[`useUpdateRuleGroupConfiguration should update a rule group interval 1`] = `
[
{
"body": "",
"headers": [
[
"accept",
"application/json, text/plain, */*",
],
],
"method": "GET",
"url": "http://localhost/api/ruler/grafana/api/v1/rules/uuid020c61ef/grafana-group-1?subtype=cortex",
},
{
"body": {
"interval": "2m",
"name": "grafana-group-1",
"rules": [
{
"annotations": {
"summary": "Test alert",
},
"for": "5m",
"grafana_alert": {
"condition": "A",
"data": [
{
"datasourceUid": "datasource-uid",
"model": {
"datasource": {
"type": "prometheus",
"uid": "datasource-uid",
},
"expression": "vector(1)",
"queryType": "alerting",
"refId": "A",
},
"queryType": "alerting",
"refId": "A",
"relativeTimeRange": {
"from": 1000,
"to": 2000,
},
},
],
"exec_err_state": "Error",
"is_paused": false,
"namespace_uid": "uuid020c61ef",
"no_data_state": "NoData",
"rule_group": "grafana-group-1",
"title": "Grafana-rule",
"uid": "4d7125fee983",
},
"labels": {
"region": "nasa",
"severity": "critical",
},
},
],
},
"headers": [
[
"content-type",
"application/json",
],
[
"accept",
"application/json, text/plain, */*",
],
],
"method": "POST",
"url": "http://localhost/api/ruler/grafana/api/v1/rules/uuid020c61ef?subtype=cortex",
},
{
"body": "",
"headers": [
[
"accept",
"application/json, text/plain, */*",
],
],
"method": "GET",
"url": "http://localhost/api/ruler/grafana/api/v1/rules/uuid020c61ef/grafana-group-1?subtype=cortex",
},
]
`;

View File

@ -0,0 +1,181 @@
/**
* Copied from https://github.com/react-hookz/web/blob/579a445fcc9f4f4bb5b9d5e670b2e57448b4ee50/src/useAsync/index.ts
*/
import { useMemo, useRef, useState } from 'react';
export type AsyncStatus = 'loading' | 'success' | 'error' | 'not-executed';
export type AsyncState<Result> =
| {
status: 'not-executed';
error: undefined;
result: Result;
}
| {
status: 'success';
error: undefined;
result: Result;
}
| {
status: 'error';
error: Error;
result: Result;
}
| {
status: AsyncStatus;
error: Error | undefined;
result: Result;
};
export type UseAsyncActions<Result, Args extends unknown[] = unknown[]> = {
/**
* Reset state to initial.
*/
reset: () => void;
/**
* Execute the async function manually.
*/
execute: (...args: Args) => Promise<Result>;
};
export type UseAsyncMeta<Result, Args extends unknown[] = unknown[]> = {
/**
* Latest promise returned from the async function.
*/
promise: Promise<Result> | undefined;
/**
* List of arguments applied to the latest async function invocation.
*/
lastArgs: Args | undefined;
};
export function useAsync<Result, Args extends unknown[] = unknown[]>(
asyncFn: (...params: Args) => Promise<Result>,
initialValue: Result
): [AsyncState<Result>, UseAsyncActions<Result, Args>, UseAsyncMeta<Result, Args>];
export function useAsync<Result, Args extends unknown[] = unknown[]>(
asyncFn: (...params: Args) => Promise<Result>,
initialValue?: Result
): [AsyncState<Result | undefined>, UseAsyncActions<Result, Args>, UseAsyncMeta<Result, Args>];
/**
* Tracks the result and errors of the provided async function and provides handles to control its execution.
*
* @param asyncFn Function that returns a promise.
* @param initialValue Value that will be set on initialisation before the async function is
* executed.
*/
export function useAsync<Result, Args extends unknown[] = unknown[]>(
asyncFn: (...params: Args) => Promise<Result>,
initialValue?: Result
): [AsyncState<Result | undefined>, UseAsyncActions<Result, Args>, UseAsyncMeta<Result, Args>] {
const [state, setState] = useState<AsyncState<Result | undefined>>({
status: 'not-executed',
error: undefined,
result: initialValue,
});
const promiseRef = useRef<Promise<Result>>();
const argsRef = useRef<Args>();
const methods = useSyncedRef({
execute(...params: Args) {
argsRef.current = params;
const promise = asyncFn(...params);
promiseRef.current = promise;
setState((s) => ({ ...s, status: 'loading' }));
promise.then(
(result) => {
// We dont want to handle result/error of non-latest function
// this approach helps to avoid race conditions
if (promise === promiseRef.current) {
setState((s) => ({ ...s, status: 'success', error: undefined, result }));
}
},
(error: Error) => {
// We dont want to handle result/error of non-latest function
// this approach helps to avoid race conditions
if (promise === promiseRef.current) {
setState((s) => ({ ...s, status: 'error', error }));
}
}
);
return promise;
},
reset() {
setState({
status: 'not-executed',
error: undefined,
result: initialValue,
});
promiseRef.current = undefined;
argsRef.current = undefined;
},
});
return [
state,
useMemo(
() => ({
reset() {
methods.current.reset();
},
execute: (...params: Args) => methods.current.execute(...params),
}),
// eslint-disable-next-line react-hooks/exhaustive-deps
[]
),
{ promise: promiseRef.current, lastArgs: argsRef.current },
];
}
/**
* Like `useRef`, but it returns immutable ref that contains actual value.
*
* @param value
*/
function useSyncedRef<T>(value: T): { readonly current: T } {
const ref = useRef(value);
ref.current = value;
return useMemo(
() =>
Object.freeze({
get current() {
return ref.current;
},
}),
[]
);
}
// --- utility functions to help with request state assertions ---
export function isError<T>(state: AsyncState<T>) {
return state.status === 'error';
}
export function isSuccess<T>(state: AsyncState<T>) {
return state.status === 'success';
}
export function isUninitialized<T>(state: AsyncState<T>) {
return state.status === 'not-executed';
}
export function isLoading<T>(state: AsyncState<T>) {
return state.status === 'loading';
}
export function anyOfRequestState(...states: Array<AsyncState<unknown>>) {
return {
uninitialized: states.every(isUninitialized),
loading: states.some(isLoading),
error: states.find(isError)?.error,
success: states.some(isSuccess),
};
}

View File

@ -4,11 +4,13 @@ import { byRole, byText } from 'testing-library-selector';
import { setBackendSrv } from '@grafana/runtime';
import { backendSrv } from 'app/core/services/backend_srv';
import { CombinedRule } from 'app/types/unified-alerting';
import { AccessControlAction } from 'app/types';
import { CombinedRule, RuleGroupIdentifier } from 'app/types/unified-alerting';
import { RulerGrafanaRuleDTO } from 'app/types/unified-alerting-dto';
import { setupMswServer } from '../mockApi';
import {
grantUserPermissions,
mockCombinedRule,
mockCombinedRuleGroup,
mockGrafanaRulerRule,
@ -16,19 +18,39 @@ import {
mockRulerRecordingRule,
mockRulerRuleGroup,
} from '../mocks';
import { grafanaRulerGroupName, grafanaRulerNamespace, grafanaRulerRule } from '../mocks/alertRuleApi';
import { setRulerRuleGroupHandler, setUpdateRulerRuleNamespaceHandler } from '../mocks/server/configure';
import {
grafanaRulerGroupName,
grafanaRulerGroupName2,
grafanaRulerNamespace,
grafanaRulerRule,
} from '../mocks/grafanaRulerApi';
import { GROUP_1, NAMESPACE_1, NAMESPACE_2, namespace2 } from '../mocks/mimirRulerApi';
import {
mimirDataSource,
setRulerRuleGroupHandler,
setUpdateRulerRuleNamespaceHandler,
} from '../mocks/server/configure';
import { MIMIR_DATASOURCE_UID } from '../mocks/server/constants';
import { captureRequests, serializeRequests } from '../mocks/server/events';
import { rulerRuleGroupHandler, updateRulerRuleNamespaceHandler } from '../mocks/server/handlers/alertRules';
import { rulerRuleGroupHandler, updateRulerRuleNamespaceHandler } from '../mocks/server/handlers/grafanaRuler';
import { GRAFANA_RULES_SOURCE_NAME } from '../utils/datasource';
import { stringifyErrorLike } from '../utils/misc';
import { getRuleGroupLocationFromCombinedRule } from '../utils/rules';
import { useDeleteRuleFromGroup, usePauseRuleInGroup } from './useProduceNewRuleGroup';
import { AsyncState, isError, isLoading, isSuccess, isUninitialized } from './useAsync';
import {
useDeleteRuleFromGroup,
useMoveRuleGroup,
usePauseRuleInGroup,
useRenameRuleGroup,
useUpdateRuleGroupConfiguration,
} from './useProduceNewRuleGroup';
const server = setupMswServer();
beforeAll(() => {
setBackendSrv(backendSrv);
grantUserPermissions([AccessControlAction.AlertingRuleExternalRead, AccessControlAction.AlertingRuleRead]);
});
describe('pause rule', () => {
@ -181,9 +203,160 @@ describe('delete rule', () => {
});
});
describe('useUpdateRuleGroupConfiguration', () => {
it('should update a rule group interval', async () => {
const capture = captureRequests();
render(<UpdateRuleGroupComponent />);
await userEvent.click(byRole('button').get());
expect(await byText(/success/i).find()).toBeInTheDocument();
const requests = await capture;
const serializedRequests = await serializeRequests(requests);
expect(serializedRequests).toMatchSnapshot();
});
it('should rename a rule group', async () => {
const capture = captureRequests();
render(<RenameRuleGroupComponent />);
await userEvent.click(byRole('button').get());
expect(await byText(/success/i).find()).toBeInTheDocument();
const requests = await capture;
const serializedRequests = await serializeRequests(requests);
expect(serializedRequests).toMatchSnapshot();
});
it('should throw if we are trying to merge rule groups', async () => {
render(<RenameRuleGroupComponent group={grafanaRulerGroupName2} />);
await userEvent.click(byRole('button').get());
expect(await byText(/error:.+not supported.+/i).find()).toBeInTheDocument();
});
it('should not be able to move a Grafana managed rule group', async () => {
render(<MoveGrafanaManagedRuleGroupComponent />);
await userEvent.click(byRole('button').get());
expect(await byText(/error:.+not supported.+/i).find()).toBeInTheDocument();
});
it('should be able to move a Data Source managed rule group', async () => {
mimirDataSource();
const capture = captureRequests();
render(<MoveDataSourceManagedRuleGroupComponent namespace={NAMESPACE_2} group={'a-new-group'} interval={'2m'} />);
await userEvent.click(byRole('button').get());
expect(await byText(/success/i).find()).toBeInTheDocument();
const requests = await capture;
const serializedRequests = await serializeRequests(requests);
expect(serializedRequests).toMatchSnapshot();
});
it('should not move a Data Source managed rule group to namespace with existing target group name', async () => {
mimirDataSource();
render(
<MoveDataSourceManagedRuleGroupComponent namespace={NAMESPACE_2} group={namespace2[0].name} interval={'2m'} />
);
await userEvent.click(byRole('button').get());
expect(await byText(/error:.+not supported.+/i).find()).toBeInTheDocument();
});
});
const UpdateRuleGroupComponent = () => {
const [requestState, updateRuleGroup] = useUpdateRuleGroupConfiguration();
const ruleGroupID: RuleGroupIdentifier = {
dataSourceName: GRAFANA_RULES_SOURCE_NAME,
groupName: grafanaRulerGroupName,
namespaceName: grafanaRulerNamespace.uid,
};
return (
<>
<button onClick={() => updateRuleGroup.execute(ruleGroupID, '2m')} />
<SerializeState state={requestState} />
</>
);
};
const RenameRuleGroupComponent = ({ group = 'another-group-name' }: { group?: string }) => {
const [requestState, renameRuleGroup] = useRenameRuleGroup();
const ruleGroupID: RuleGroupIdentifier = {
dataSourceName: GRAFANA_RULES_SOURCE_NAME,
groupName: grafanaRulerGroupName,
namespaceName: grafanaRulerNamespace.uid,
};
return (
<>
<button onClick={() => renameRuleGroup.execute(ruleGroupID, group, '2m')} />
<SerializeState state={requestState} />
</>
);
};
const MoveGrafanaManagedRuleGroupComponent = () => {
const [requestState, moveRuleGroup] = useMoveRuleGroup();
const ruleGroupID: RuleGroupIdentifier = {
dataSourceName: GRAFANA_RULES_SOURCE_NAME,
groupName: grafanaRulerGroupName,
namespaceName: grafanaRulerNamespace.uid,
};
return (
<>
<button onClick={() => moveRuleGroup.execute(ruleGroupID, 'another-namespace', 'another-group-name', '2m')} />
<SerializeState state={requestState} />
</>
);
};
function SerializeState({ state }: { state: AsyncState<unknown> }) {
return (
<>
{isUninitialized(state) && 'uninitialized'}
{isLoading(state) && 'loading'}
{isSuccess(state) && 'success'}
{isSuccess(state) && `result: ${JSON.stringify(state.result, null, 2)}`}
{isError(state) && `error: ${stringifyErrorLike(state.error)}`}
</>
);
}
type MoveDataSourceManagedRuleGroupComponentProps = {
namespace: string;
group: string;
interval: string;
};
const MoveDataSourceManagedRuleGroupComponent = ({
namespace,
group,
interval,
}: MoveDataSourceManagedRuleGroupComponentProps) => {
const [requestState, moveRuleGroup] = useMoveRuleGroup();
const ruleGroupID: RuleGroupIdentifier = {
dataSourceName: MIMIR_DATASOURCE_UID,
groupName: GROUP_1,
namespaceName: NAMESPACE_1,
};
return (
<>
<button onClick={() => moveRuleGroup.execute(ruleGroupID, namespace, group, interval)} />
<SerializeState state={requestState} />
</>
);
};
// this test component will cycle through the loading states
const PauseTestComponent = (options: { rulerRule?: RulerGrafanaRuleDTO }) => {
const [pauseRule, requestState] = usePauseRuleInGroup();
const [requestState, pauseRule] = usePauseRuleInGroup();
const rulerRule = options.rulerRule ?? grafanaRulerRule;
const rule = mockCombinedRule({
@ -194,17 +367,13 @@ const PauseTestComponent = (options: { rulerRule?: RulerGrafanaRuleDTO }) => {
const onClick = () => {
// always handle your errors!
pauseRule(ruleGroupID, rulerRule.grafana_alert.uid, true).catch(() => {});
pauseRule.execute(ruleGroupID, rulerRule.grafana_alert.uid, true).catch(() => {});
};
return (
<>
<button onClick={() => onClick()} />
{requestState.isUninitialized && 'uninitialized'}
{requestState.isLoading && 'loading'}
{requestState.isSuccess && 'success'}
{requestState.result && 'result'}
{requestState.isError && `error: ${stringifyErrorLike(requestState.error)}`}
<SerializeState state={requestState} />
</>
);
};
@ -213,22 +382,18 @@ type DeleteTestComponentProps = {
rule: CombinedRule;
};
const DeleteTestComponent = ({ rule }: DeleteTestComponentProps) => {
const [deleteRuleFromGroup, requestState] = useDeleteRuleFromGroup();
const [requestState, deleteRuleFromGroup] = useDeleteRuleFromGroup();
// always handle your errors!
const ruleGroupID = getRuleGroupLocationFromCombinedRule(rule);
const onClick = () => {
deleteRuleFromGroup(ruleGroupID, rule.rulerRule!);
deleteRuleFromGroup.execute(ruleGroupID, rule.rulerRule!);
};
return (
<>
<button onClick={() => onClick()} />
{requestState.isUninitialized && 'uninitialized'}
{requestState.isLoading && 'loading'}
{requestState.isSuccess && 'success'}
{requestState.result && `result: ${JSON.stringify(requestState.result, null, 2)}`}
{requestState.isError && `error: ${stringifyErrorLike(requestState.error)}`}
<SerializeState state={requestState} />
</>
);
};

View File

@ -1,15 +1,24 @@
import { Action } from '@reduxjs/toolkit';
import { useCallback, useState } from 'react';
import { t } from 'app/core/internationalization';
import { dispatch, getState } from 'app/store/store';
import { RuleGroupIdentifier } from 'app/types/unified-alerting';
import { RulerRuleDTO, RulerRuleGroupDTO } from 'app/types/unified-alerting-dto';
import { RulerRuleDTO } from 'app/types/unified-alerting-dto';
import { AlertGroupUpdated, alertRuleApi } from '../api/alertRuleApi';
import { deleteRuleAction, pauseRuleAction, ruleGroupReducer } from '../reducers/ruler/ruleGroups';
import { alertRuleApi } from '../api/alertRuleApi';
import { notFoundToNullOrThrow } from '../api/util';
import {
deleteRuleAction,
moveRuleGroupAction,
pauseRuleAction,
renameRuleGroupAction,
ruleGroupReducer,
updateRuleGroupAction,
} from '../reducers/ruler/ruleGroups';
import { fetchRulesSourceBuildInfoAction, getDataSourceRulerConfig } from '../state/actions';
import { isGrafanaRulesSource } from '../utils/datasource';
type ProduceResult = RulerRuleGroupDTO | AlertGroupUpdated;
import { useAsync } from './useAsync';
/**
* Hook for reuse that handles freshly fetching a rule group's definition, applying an action to it,
@ -20,39 +29,19 @@ type ProduceResult = RulerRuleGroupDTO | AlertGroupUpdated;
* and to guard against user concurrency issues.
*
* @throws
* @TODO the manual state tracking here is not great, but I don't have a better idea that works /shrug
*/
function useProduceNewRuleGroup() {
const [fetchRuleGroup] = alertRuleApi.endpoints.getRuleGroupForNamespace.useLazyQuery();
const [updateRuleGroup] = alertRuleApi.endpoints.updateRuleGroupForNamespace.useMutation();
const [deleteRuleGroup] = alertRuleApi.endpoints.deleteRuleGroupFromNamespace.useMutation();
const [isLoading, setLoading] = useState<boolean>(false);
const [isUninitialized, setUninitialized] = useState<boolean>(true);
const [result, setResult] = useState<ProduceResult | undefined>();
const [error, setError] = useState<unknown | undefined>();
const isError = Boolean(error);
const isSuccess = !isUninitialized && !isLoading && !isError;
const requestState = {
isUninitialized,
isLoading,
isSuccess,
isError,
result,
error,
};
const [fetchRuleGroup, requestState] = alertRuleApi.endpoints.getRuleGroupForNamespace.useLazyQuery();
/**
* This function will fetch the latest we have on the rule group, apply a diff to it via a reducer and sends
* the new rule group update to the correct endpoint.
* This function will fetch the latest configuration we have for the rule group, apply a diff to it via a reducer and sends
* returns the result.
*
* The API does not allow operations on a single rule and will always overwrite the existing rule group with the payload.
*
*
* fetch latest rule group apply reducer update rule group
*
*
* fetch latest rule group apply reducer new rule group
*
*/
const produceNewRuleGroup = async (ruleGroup: RuleGroupIdentifier, action: Action) => {
const { dataSourceName, groupName, namespaceName } = ruleGroup;
@ -61,47 +50,15 @@ function useProduceNewRuleGroup() {
await dispatch(fetchRulesSourceBuildInfoAction({ rulesSourceName: dataSourceName }));
const rulerConfig = getDataSourceRulerConfig(getState, dataSourceName);
setUninitialized(false);
setLoading(true);
const latestRuleGroupDefinition = await fetchRuleGroup({
rulerConfig,
namespace: namespaceName,
group: groupName,
}).unwrap();
try {
const latestRuleGroupDefinition = await fetchRuleGroup({
rulerConfig,
namespace: namespaceName,
group: groupName,
}).unwrap();
const newRuleGroupDefinition = ruleGroupReducer(latestRuleGroupDefinition, action);
// @TODO convert rule group to postable rule group TypeScript is not complaining here because
// the interfaces are compatible but it _should_ complain
const newRuleGroup = ruleGroupReducer(latestRuleGroupDefinition, action);
// if we have no more rules left after reducing, remove the entire group
const updateOrDeleteFunction = () => {
if (newRuleGroup.rules.length === 0) {
return deleteRuleGroup({
rulerConfig,
namespace: namespaceName,
group: groupName,
}).unwrap();
}
return updateRuleGroup({
rulerConfig,
namespace: namespaceName,
payload: newRuleGroup,
}).unwrap();
};
const result = await updateOrDeleteFunction();
setResult(result);
return result;
} catch (error) {
setError(error);
throw error;
} finally {
setLoading(false);
}
return { newRuleGroupDefinition, rulerConfig };
};
return [produceNewRuleGroup, requestState] as const;
@ -112,18 +69,27 @@ function useProduceNewRuleGroup() {
* use the latest definition of the ruler group identifier.
*/
export function usePauseRuleInGroup() {
const [produceNewRuleGroup, produceNewRuleGroupState] = useProduceNewRuleGroup();
const [produceNewRuleGroup] = useProduceNewRuleGroup();
const [upsertRuleGroup] = alertRuleApi.endpoints.upsertRuleGroupForNamespace.useMutation();
const pauseFn = useCallback(
async (ruleGroup: RuleGroupIdentifier, uid: string, pause: boolean) => {
const action = pauseRuleAction({ uid, pause });
return useAsync(async (ruleGroup: RuleGroupIdentifier, uid: string, pause: boolean) => {
const { namespaceName } = ruleGroup;
return produceNewRuleGroup(ruleGroup, action);
},
[produceNewRuleGroup]
);
const action = pauseRuleAction({ uid, pause });
const { newRuleGroupDefinition, rulerConfig } = await produceNewRuleGroup(ruleGroup, action);
return [pauseFn, produceNewRuleGroupState] as const;
const rulePauseMessage = t('alerting.rules.pause-rule.success', 'Rule evaluation paused');
const ruleResumeMessage = t('alerting.rules.resume-rule.success', 'Rule evaluation resumed');
return upsertRuleGroup({
rulerConfig,
namespace: namespaceName,
payload: newRuleGroupDefinition,
requestOptions: {
successMessage: pause ? rulePauseMessage : ruleResumeMessage,
},
}).unwrap();
});
}
/**
@ -133,16 +99,186 @@ export function usePauseRuleInGroup() {
* If no more rules are left in the group it will remove the entire group instead of updating.
*/
export function useDeleteRuleFromGroup() {
const [produceNewRuleGroup, produceNewRuleGroupState] = useProduceNewRuleGroup();
const [produceNewRuleGroup] = useProduceNewRuleGroup();
const [upsertRuleGroup] = alertRuleApi.endpoints.upsertRuleGroupForNamespace.useMutation();
const [deleteRuleGroup] = alertRuleApi.endpoints.deleteRuleGroupFromNamespace.useMutation();
const deleteFn = useCallback(
async (ruleGroup: RuleGroupIdentifier, rule: RulerRuleDTO) => {
const action = deleteRuleAction({ rule });
return useAsync(async (ruleGroup: RuleGroupIdentifier, rule: RulerRuleDTO) => {
const { groupName, namespaceName } = ruleGroup;
return produceNewRuleGroup(ruleGroup, action);
},
[produceNewRuleGroup]
);
const action = deleteRuleAction({ rule });
const { newRuleGroupDefinition, rulerConfig } = await produceNewRuleGroup(ruleGroup, action);
return [deleteFn, produceNewRuleGroupState] as const;
const successMessage = t('alerting.rules.delete-rule.success', 'Rule successfully deleted');
// if we have no more rules left after reducing, remove the entire group
if (newRuleGroupDefinition.rules.length === 0) {
return deleteRuleGroup({
rulerConfig,
namespace: namespaceName,
group: groupName,
requestOptions: { successMessage },
}).unwrap();
}
// otherwise just update the group
return upsertRuleGroup({
rulerConfig,
namespace: namespaceName,
payload: newRuleGroupDefinition,
requestOptions: { successMessage },
}).unwrap();
});
}
/**
* Update an existing rule group, currently only supports updating the interval.
* Use "useRenameRuleGroup" or "useMoveRuleGroup" for updating the namespace or group name.
*/
export function useUpdateRuleGroupConfiguration() {
const [produceNewRuleGroup] = useProduceNewRuleGroup();
const [upsertRuleGroup] = alertRuleApi.endpoints.upsertRuleGroupForNamespace.useMutation();
return useAsync(async (ruleGroup: RuleGroupIdentifier, interval: string) => {
const { namespaceName } = ruleGroup;
const action = updateRuleGroupAction({ interval });
const { newRuleGroupDefinition, rulerConfig } = await produceNewRuleGroup(ruleGroup, action);
const successMessage = t('alerting.rule-groups.update.success', 'Successfully updated rule group');
return upsertRuleGroup({
rulerConfig,
namespace: namespaceName,
payload: newRuleGroupDefinition,
requestOptions: { successMessage },
}).unwrap();
});
}
/**
* Move a rule group to either another namespace with (optionally) a different name, throws if the action
* targets an existing rule group.
* Optionally, update the rule group evaluation interval.
*/
export function useMoveRuleGroup() {
const [produceNewRuleGroup] = useProduceNewRuleGroup();
const [fetchRuleGroup] = alertRuleApi.endpoints.getRuleGroupForNamespace.useLazyQuery();
const [upsertRuleGroup] = alertRuleApi.endpoints.upsertRuleGroupForNamespace.useMutation();
const [deleteRuleGroup] = alertRuleApi.endpoints.deleteRuleGroupFromNamespace.useMutation();
// @TODO maybe add where we moved it from and to for additional peace of mind
const successMessage = t('alerting.rule-groups.move.success', 'Successfully moved rule group');
return useAsync(
async (ruleGroup: RuleGroupIdentifier, namespaceName: string, groupName?: string, interval?: string) => {
// we could technically support moving rule groups to another folder, though we don't have a "move" wizard yet.
if (isGrafanaRulesSource(ruleGroup.dataSourceName)) {
throw new Error('Moving a Grafana-managed rule group to another folder is currently not supported.');
}
const action = moveRuleGroupAction({ namespaceName, groupName, interval });
const { newRuleGroupDefinition, rulerConfig } = await produceNewRuleGroup(ruleGroup, action);
const oldNamespace = ruleGroup.namespaceName;
const targetNamespace = action.payload.namespaceName;
const oldGroupName = ruleGroup.groupName;
const targetGroupName = action.payload.groupName;
const isGroupRenamed = Boolean(targetGroupName) && oldGroupName !== targetGroupName;
// if we're also renaming the group, check if the target does not already exist
if (targetGroupName && isGroupRenamed) {
const targetGroup = await fetchRuleGroup({
rulerConfig,
namespace: targetNamespace,
group: targetGroupName,
// since this could throw 404
requestOptions: { showErrorAlert: false },
})
.unwrap()
.catch(notFoundToNullOrThrow);
if (targetGroup?.rules?.length) {
throw new Error('Target group already has rules, merging rule groups is currently not supported.');
}
}
// create the new group in the target namespace
// ⚠️ it's important to do this before we remove the old group better to have two groups than none if one of these requests fails
await upsertRuleGroup({
rulerConfig,
namespace: targetNamespace,
payload: newRuleGroupDefinition,
requestOptions: { successMessage },
}).unwrap();
// now remove the old one
const result = await deleteRuleGroup({
rulerConfig,
namespace: oldNamespace,
group: oldGroupName,
requestOptions: { showSuccessAlert: false },
}).unwrap();
return result;
}
);
}
/**
* Rename a rule group but keep it within the same namespace, throws if the action targets an existing rule group.
* Optionally, update the rule group evaluation interval.
*/
export function useRenameRuleGroup() {
const [produceNewRuleGroup] = useProduceNewRuleGroup();
const [fetchRuleGroup] = alertRuleApi.endpoints.getRuleGroupForNamespace.useLazyQuery();
const [upsertRuleGroup] = alertRuleApi.endpoints.upsertRuleGroupForNamespace.useMutation();
const [deleteRuleGroup] = alertRuleApi.endpoints.deleteRuleGroupFromNamespace.useMutation();
return useAsync(async (ruleGroup: RuleGroupIdentifier, groupName: string, interval?: string) => {
const action = renameRuleGroupAction({ groupName, interval });
const { newRuleGroupDefinition, rulerConfig } = await produceNewRuleGroup(ruleGroup, action);
const oldGroupName = ruleGroup.groupName;
const newGroupName = action.payload.groupName;
const namespaceName = ruleGroup.namespaceName;
const successMessage = t('alerting.rule-groups.rename.success', 'Successfully renamed rule group');
// check if the target group exists
const targetGroup = await fetchRuleGroup({
rulerConfig,
namespace: namespaceName,
group: newGroupName,
// since this could throw 404
requestOptions: { showErrorAlert: false },
})
.unwrap()
.catch(notFoundToNullOrThrow);
if (targetGroup?.rules?.length) {
throw new Error('Target group has existing rules, merging rule groups is currently not supported.');
}
// if the target group does not exist, create the new group
// ⚠️ it's important to do this before we remove the old group better to have two groups than none if one of these requests fails
const result = await upsertRuleGroup({
rulerConfig,
namespace: namespaceName,
payload: newRuleGroupDefinition,
requestOptions: { successMessage },
}).unwrap();
// now delete the group we renamed
await deleteRuleGroup({
rulerConfig,
namespace: namespaceName,
group: oldGroupName,
requestOptions: { showSuccessAlert: false },
}).unwrap();
return result;
});
}

View File

@ -20,7 +20,7 @@ import {
import { DataSourceSrv, GetDataSourceListFilters, config } from '@grafana/runtime';
import { defaultDashboard } from '@grafana/schema';
import { contextSrv } from 'app/core/services/context_srv';
import { MOCK_GRAFANA_ALERT_RULE_TITLE } from 'app/features/alerting/unified/mocks/server/handlers/alertRules';
import { MOCK_GRAFANA_ALERT_RULE_TITLE } from 'app/features/alerting/unified/mocks/server/handlers/grafanaRuler';
import { DatasourceSrv } from 'app/features/plugins/datasource_srv';
import {
AlertManagerCortexConfig,

View File

@ -21,6 +21,7 @@ export function mockPromRulesApiResponse(server: SetupServer, result: PromRulesR
}
export const grafanaRulerGroupName = 'grafana-group-1';
export const grafanaRulerGroupName2 = 'grafana-group-2';
export const grafanaRulerNamespace = { name: 'test-folder-1', uid: 'uuid020c61ef' };
export const grafanaRulerNamespace2 = { name: 'test-folder-2', uid: '6abdb25bc1eb' };
@ -66,6 +67,12 @@ export const grafanaRulerGroup: RulerRuleGroupDTO = {
rules: [grafanaRulerRule],
};
export const grafanaRulerGroup2: RulerRuleGroupDTO = {
name: grafanaRulerGroupName2,
interval: '1m',
rules: [grafanaRulerRule],
};
export const grafanaRulerEmptyGroup: RulerRuleGroupDTO = {
name: 'empty-group',
interval: '1m',
@ -78,7 +85,7 @@ export const namespaceByUid: Record<string, { name: string; uid: string }> = {
};
export const namespaces: Record<string, RulerRuleGroupDTO[]> = {
[grafanaRulerNamespace.uid]: [grafanaRulerGroup],
[grafanaRulerNamespace.uid]: [grafanaRulerGroup, grafanaRulerGroup2],
[grafanaRulerNamespace2.uid]: [grafanaRulerEmptyGroup],
};

View File

@ -0,0 +1,43 @@
import { RulerRuleGroupDTO } from 'app/types/unified-alerting-dto';
import { mockRulerAlertingRule } from '../mocks';
export const GROUP_1 = 'group-1';
export const GROUP_2 = 'group-2';
export const GROUP_3 = 'group-3';
export const GROUP_4 = 'group-4';
export const NAMESPACE_1 = 'namespace-1';
export const NAMESPACE_2 = 'namespace-2';
export const group1: RulerRuleGroupDTO = {
name: GROUP_1,
interval: '1m',
rules: [mockRulerAlertingRule()],
};
export const group2: RulerRuleGroupDTO = {
name: GROUP_2,
interval: '2m',
rules: [mockRulerAlertingRule()],
};
export const group3: RulerRuleGroupDTO = {
name: GROUP_3,
interval: '1m',
rules: [mockRulerAlertingRule()],
};
export const group4: RulerRuleGroupDTO = {
name: GROUP_4,
interval: '3m',
rules: [mockRulerAlertingRule()],
};
export const namespace1 = [group1, group2];
export const namespace2 = [group3, group4];
export const namespaces: Record<string, RulerRuleGroupDTO[]> = {
[NAMESPACE_1]: namespace1,
[NAMESPACE_2]: namespace2,
};

View File

@ -2,11 +2,12 @@
* Contains all handlers that are required for test rendering of components within Alerting
*/
import alertRuleHandlers from 'app/features/alerting/unified/mocks/server/handlers/alertRules';
import alertmanagerHandlers from 'app/features/alerting/unified/mocks/server/handlers/alertmanagers';
import datasourcesHandlers from 'app/features/alerting/unified/mocks/server/handlers/datasources';
import evalHandlers from 'app/features/alerting/unified/mocks/server/handlers/eval';
import folderHandlers from 'app/features/alerting/unified/mocks/server/handlers/folders';
import grafanaRulerHandlers from 'app/features/alerting/unified/mocks/server/handlers/grafanaRuler';
import mimirRulerHandlers from 'app/features/alerting/unified/mocks/server/handlers/mimirRuler';
import pluginsHandlers from 'app/features/alerting/unified/mocks/server/handlers/plugins';
import silenceHandlers from 'app/features/alerting/unified/mocks/server/handlers/silences';
@ -14,7 +15,8 @@ import silenceHandlers from 'app/features/alerting/unified/mocks/server/handlers
* Array of all mock handlers that are required across Alerting tests
*/
const allHandlers = [
...alertRuleHandlers,
...grafanaRulerHandlers,
...mimirRulerHandlers,
...alertmanagerHandlers,
...datasourcesHandlers,
...evalHandlers,

View File

@ -1,7 +1,7 @@
import { HttpResponse } from 'msw';
import server from 'app/features/alerting/unified/mockApi';
import { mockFolder } from 'app/features/alerting/unified/mocks';
import server, { mockFeatureDiscoveryApi } from 'app/features/alerting/unified/mockApi';
import { mockDataSource, mockFolder } from 'app/features/alerting/unified/mocks';
import {
getGrafanaAlertmanagerConfigHandler,
grafanaAlertingConfigurationStatusHandler,
@ -10,7 +10,12 @@ import { getFolderHandler } from 'app/features/alerting/unified/mocks/server/han
import { AlertManagerCortexConfig, AlertmanagerChoice } from 'app/plugins/datasource/alertmanager/types';
import { FolderDTO } from 'app/types';
import { rulerRuleGroupHandler, updateRulerRuleNamespaceHandler } from './handlers/alertRules';
import { setupDataSources } from '../../testSetup/datasources';
import { buildInfoResponse } from '../../testSetup/featureDiscovery';
import { DataSourceType } from '../../utils/datasource';
import { MIMIR_DATASOURCE_UID } from './constants';
import { rulerRuleGroupHandler, updateRulerRuleNamespaceHandler } from './handlers/grafanaRuler';
export type HandlerOptions = {
delay?: number;
@ -62,3 +67,23 @@ export const setRulerRuleGroupHandler = (options?: HandlerOptions) => {
return handler;
};
export function mimirDataSource() {
const dataSource = mockDataSource(
{
type: DataSourceType.Prometheus,
name: MIMIR_DATASOURCE_UID,
uid: MIMIR_DATASOURCE_UID,
url: 'https://mimir.local:9000',
jsonData: {
manageAlerts: true,
},
},
{ alerting: true }
);
setupDataSources(dataSource);
mockFeatureDiscoveryApi(server).discoverDsFeatures(dataSource, buildInfoResponse.mimir);
return { dataSource };
}

View File

@ -0,0 +1,3 @@
export const MIMIR_DATASOURCE_UID = 'mimir';
export const MIMIR_GROUP_NAME = 'group-1';
export const MIMIR_NAMESPACE_NAME = 'namespace-1';

View File

@ -15,7 +15,7 @@ import {
namespaces,
time_0,
time_plus_30,
} from '../../alertRuleApi';
} from '../../grafanaRulerApi';
import { HandlerOptions } from '../configure';
export const rulerRulesHandler = () => {
return http.get(`/api/ruler/grafana/api/v1/rules`, () => {

View File

@ -0,0 +1,70 @@
import { delay, http, HttpResponse } from 'msw';
import { RulerRuleGroupDTO } from '../../../../../../types/unified-alerting-dto';
import { namespaces } from '../../mimirRulerApi';
import { HandlerOptions } from '../configure';
export const updateRulerRuleNamespaceHandler = (options?: HandlerOptions) => {
return http.post<{ namespaceName: string }>(`/api/ruler/:dataSourceUID/api/v1/rules/:namespaceName`, async () => {
if (options?.delay !== undefined) {
await delay(options.delay);
}
if (options?.response) {
return options.response;
}
return HttpResponse.json({
status: 'success',
error: '',
errorType: '',
data: null,
});
});
};
export const rulerRuleGroupHandler = (options?: HandlerOptions) => {
return http.get<{ namespaceName: string; groupName: string }>(
`/api/ruler/:dataSourceUID/api/v1/rules/:namespaceName/:groupName`,
({ params: { namespaceName, groupName } }) => {
if (options?.response) {
return options.response;
}
const namespace = namespaces[namespaceName];
if (!namespace) {
return HttpResponse.json({ message: 'group does not exist\n' }, { status: 404 });
}
const matchingGroup = namespace.find((group) => group.name === groupName);
return HttpResponse.json<RulerRuleGroupDTO>({
name: groupName,
interval: matchingGroup?.interval,
rules: matchingGroup?.rules ?? [],
});
}
);
};
export const deleteRulerRuleGroupHandler = () => {
return http.delete<{ namespaceName: string; groupName: string }>(
`/api/ruler/:dataSourceUID/api/v1/rules/:namespaceName/:groupName`,
({ params: { namespaceName } }) => {
const namespace = namespaces[namespaceName];
if (!namespace) {
return HttpResponse.json({ message: 'group does not exist\n' }, { status: 404 });
}
return HttpResponse.json(
{
message: 'Rules deleted',
},
{ status: 202 }
);
}
);
};
const handlers = [updateRulerRuleNamespaceHandler(), rulerRuleGroupHandler(), deleteRulerRuleGroupHandler()];
export default handlers;

View File

@ -1,4 +1,4 @@
import { createAction, createReducer } from '@reduxjs/toolkit';
import { createAction, createReducer, isAnyOf } from '@reduxjs/toolkit';
import { remove } from 'lodash';
import { RuleIdentifier } from 'app/types/unified-alerting';
@ -16,8 +16,12 @@ export const pauseRuleAction = createAction<{ uid: string; pause: boolean }>('ru
export const deleteRuleAction = createAction<{ rule: RulerRuleDTO }>('ruleGroup/rules/delete');
// group-scoped actions
const reorderRulesActions = createAction('ruleGroup/rules/reorder');
const updateGroup = createAction('ruleGroup/update');
export const updateRuleGroupAction = createAction<{ interval?: string }>('ruleGroup/update');
export const moveRuleGroupAction = createAction<{ namespaceName: string; groupName?: string; interval?: string }>(
'ruleGroup/move'
);
export const renameRuleGroupAction = createAction<{ groupName: string; interval?: string }>('ruleGroup/rename');
export const reorderRulesInRuleGroupAction = createAction('ruleGroup/rules/reorder');
const initialState: PostableRulerRuleGroupDTO = {
name: 'initial',
@ -64,11 +68,22 @@ export const ruleGroupReducer = createReducer(initialState, (builder) => {
throw new Error(`No rule with UID ${uid} found in group ${draft.name}`);
}
})
.addCase(reorderRulesActions, () => {
.addCase(reorderRulesInRuleGroupAction, () => {
throw new Error('not yet implemented');
})
.addCase(updateGroup, () => {
throw new Error('not yet implemented');
// rename and move should allow updating the group's name
.addMatcher(isAnyOf(renameRuleGroupAction, moveRuleGroupAction), (draft, { payload }) => {
const { groupName } = payload;
if (groupName) {
draft.name = groupName;
}
})
// update, rename and move should all allow updating the interval of the group
.addMatcher(isAnyOf(updateRuleGroupAction, renameRuleGroupAction, moveRuleGroupAction), (draft, { payload }) => {
const { interval } = payload;
if (interval) {
draft.interval = interval;
}
})
.addDefaultCase((_draft, action) => {
throw new Error(`Unknown action for rule group reducer: ${action.type}`);

View File

@ -1,4 +1,4 @@
import { AsyncThunk, createAsyncThunk } from '@reduxjs/toolkit';
import { createAsyncThunk } from '@reduxjs/toolkit';
import { isEmpty } from 'lodash';
import { locationService } from '@grafana/runtime';
@ -51,14 +51,7 @@ import { fetchAnnotations } from '../api/annotations';
import { discoverFeatures } from '../api/buildInfo';
import { fetchNotifiers } from '../api/grafana';
import { FetchPromRulesFilter, fetchRules } from '../api/prometheus';
import {
FetchRulerRulesFilter,
deleteNamespace,
deleteRulerRulesGroup,
fetchRulerRules,
setRulerRuleGroup,
} from '../api/ruler';
import { encodeGrafanaNamespace } from '../components/expressions/util';
import { FetchRulerRulesFilter, deleteRulerRulesGroup, fetchRulerRules, setRulerRuleGroup } from '../api/ruler';
import { RuleFormType, RuleFormValues } from '../types/rule-form';
import { addDefaultsToAlertmanagerConfig, removeMuteTimingFromRoute } from '../utils/alertmanager';
import {
@ -77,8 +70,16 @@ import { safeParsePrometheusDuration } from '../utils/time';
function getDataSourceConfig(getState: () => unknown, rulesSourceName: string) {
const dataSources = (getState() as StoreState).unifiedAlerting.dataSources;
const dsConfig = dataSources[rulesSourceName]?.result;
const dsError = dataSources[rulesSourceName]?.error;
// @TODO use aggregateError but add support for it in "stringifyErrorLike"
if (!dsConfig) {
throw new Error(`Data source configuration is not available for "${rulesSourceName}" data source`);
const error = new Error(`Data source configuration is not available for "${rulesSourceName}" data source`);
if (dsError) {
error.cause = dsError;
}
throw error;
}
return dsConfig;
@ -652,16 +653,6 @@ export const testReceiversAction = createAsyncThunk(
}
);
interface UpdateNamespaceAndGroupOptions {
rulesSourceName: string;
namespaceName: string;
groupName: string;
newNamespaceName: string;
newGroupName: string;
groupInterval?: string;
folderUid?: string;
}
export const rulesInSameGroupHaveInvalidFor = (rules: RulerRuleDTO[], everyDuration: string) => {
return rules.filter((rule: RulerRuleDTO) => {
const { forDuration } = getAlertInfo(rule, everyDuration);
@ -672,123 +663,6 @@ export const rulesInSameGroupHaveInvalidFor = (rules: RulerRuleDTO[], everyDurat
});
};
// allows renaming namespace, renaming group and changing group interval, all in one go
export const updateLotexNamespaceAndGroupAction: AsyncThunk<
void,
UpdateNamespaceAndGroupOptions,
{ state: StoreState }
> = createAsyncThunk<void, UpdateNamespaceAndGroupOptions, { state: StoreState }>(
'unifiedalerting/updateLotexNamespaceAndGroup',
async (options: UpdateNamespaceAndGroupOptions, thunkAPI): Promise<void> => {
return withAppEvents(
withSerializedError(
(async () => {
const {
rulesSourceName,
namespaceName,
groupName,
newNamespaceName,
newGroupName,
groupInterval,
folderUid,
} = options;
const rulerConfig = getDataSourceRulerConfig(thunkAPI.getState, rulesSourceName);
// fetch rules and perform sanity checks
const rulesResult = await fetchRulerRules(rulerConfig);
const existingNamespace = Boolean(rulesResult[namespaceName]);
if (!existingNamespace) {
throw new Error(`Namespace "${namespaceName}" not found.`);
}
const existingGroup = rulesResult[namespaceName].find((group) => group.name === groupName);
if (!existingGroup) {
throw new Error(`Group "${groupName}" not found.`);
}
const newGroupAlreadyExists = Boolean(
rulesResult[namespaceName].find((group) => group.name === newGroupName)
);
if (newGroupName !== groupName && newGroupAlreadyExists) {
throw new Error(`Group "${newGroupName}" already exists in namespace "${namespaceName}".`);
}
const newNamespaceAlreadyExists = Boolean(rulesResult[newNamespaceName]);
const isGrafanaManagedGroup = rulesSourceName === GRAFANA_RULES_SOURCE_NAME;
const originalNamespace = isGrafanaManagedGroup ? encodeGrafanaNamespace(namespaceName) : namespaceName;
if (newNamespaceName !== originalNamespace && newNamespaceAlreadyExists) {
throw new Error(`Namespace "${newNamespaceName}" already exists.`);
}
if (
newNamespaceName === originalNamespace &&
groupName === newGroupName &&
groupInterval === existingGroup.interval
) {
throw new Error('Nothing changed.');
}
// validation for new groupInterval
if (groupInterval !== existingGroup.interval) {
const notValidRules = rulesInSameGroupHaveInvalidFor(existingGroup.rules, groupInterval ?? '1m');
if (notValidRules.length > 0) {
throw new Error(
`These alerts belonging to this group will have an invalid 'For' value: ${notValidRules
.map((rule) => {
const { alertName } = getAlertInfo(rule, groupInterval ?? '');
return alertName;
})
.join(',')}`
);
}
}
// if renaming namespace - make new copies of all groups, then delete old namespace
// this is only possible for cloud rules
if (newNamespaceName !== originalNamespace) {
for (const group of rulesResult[namespaceName]) {
await setRulerRuleGroup(
rulerConfig,
newNamespaceName,
group.name === groupName
? {
...group,
name: newGroupName,
interval: groupInterval,
}
: group
);
}
await deleteNamespace(rulerConfig, folderUid || namespaceName);
// if only modifying group...
} else {
// save updated group
await setRulerRuleGroup(rulerConfig, folderUid || namespaceName, {
...existingGroup,
name: newGroupName,
interval: groupInterval,
});
// if group name was changed, delete old group
if (newGroupName !== groupName) {
await deleteRulerRulesGroup(rulerConfig, folderUid || namespaceName, groupName);
}
}
// refetch all rules
await thunkAPI.dispatch(fetchRulerRulesAction({ rulesSourceName }));
})()
),
{
errorMessage: 'Failed to update namespace / group',
successMessage: 'Update successful',
}
);
}
);
interface UpdateRulesOrderOptions {
rulesSourceName: string;
namespaceName: string;

View File

@ -14,7 +14,6 @@ import {
saveRuleFormAction,
testReceiversAction,
updateAlertManagerConfigAction,
updateLotexNamespaceAndGroupAction,
} from './actions';
export const reducer = combineReducers({
@ -39,8 +38,6 @@ export const reducer = combineReducers({
(alertManagerSourceName) => alertManagerSourceName
).reducer,
testReceivers: createAsyncSlice('testReceivers', testReceiversAction).reducer,
updateLotexNamespaceAndGroup: createAsyncSlice('updateLotexNamespaceAndGroup', updateLotexNamespaceAndGroupAction)
.reducer,
managedAlertStateHistory: createAsyncSlice('managedAlertStateHistory', fetchGrafanaAnnotationsAction).reducer,
});

View File

@ -10,6 +10,7 @@ import {
makeFolderSettingsLink,
makeDashboardLink,
makePanelLink,
stringifyErrorLike,
} from 'app/features/alerting/unified/utils/misc';
import { SortOrder } from 'app/plugins/panel/alertlist/types';
import { Alert } from 'app/types/unified-alerting';
@ -138,3 +139,10 @@ describe('create links', () => {
expect(makePanelLink('dashboard uid', '1')).toBe('/d/dashboard%20uid?viewPanel=1');
});
});
describe('stringifyErrorLike', () => {
it('should stringify error with cause', () => {
const error = new Error('Something went strong', { cause: new Error('database did not respond') });
expect(stringifyErrorLike(error)).toBe('Something went strong, cause: database did not respond');
});
});

View File

@ -242,5 +242,13 @@ export function stringifyErrorLike(error: unknown): string {
return String(error.status) || 'Unknown error';
}
return isErrorLike(error) ? error.message : String(error);
if (!isErrorLike(error)) {
return String(error);
}
if (error.cause) {
return `${error.message}, cause: ${stringifyErrorLike(error.cause)}`;
}
return error.message;
}

View File

@ -6,7 +6,7 @@ import { byRole, byText } from 'testing-library-selector';
import { FieldConfigSource, getDefaultTimeRange, LoadingState, PanelProps, PluginExtensionTypes } from '@grafana/data';
import { TimeRangeUpdatedEvent, usePluginLinkExtensions } from '@grafana/runtime';
import { setupMswServer } from 'app/features/alerting/unified/mockApi';
import { mockPromRulesApiResponse } from 'app/features/alerting/unified/mocks/alertRuleApi';
import { mockPromRulesApiResponse } from 'app/features/alerting/unified/mocks/grafanaRulerApi';
import { mockRulerRulesApiResponse } from 'app/features/alerting/unified/mocks/rulerApi';
import { Annotation } from 'app/features/alerting/unified/utils/constants';
import { DashboardSrv, setDashboardSrv } from 'app/features/dashboard/services/DashboardSrv';

View File

@ -121,6 +121,31 @@
}
}
}
},
"rule-groups": {
"delete": {
"success": "Successfully deleted rule group"
},
"move": {
"success": "Successfully moved rule group"
},
"rename": {
"success": "Successfully renamed rule group"
},
"update": {
"success": "Successfully updated rule group"
}
},
"rules": {
"delete-rule": {
"success": "Rule successfully deleted"
},
"pause-rule": {
"success": "Rule evaluation paused"
},
"resume-rule": {
"success": "Rule evaluation resumed"
}
}
},
"annotations": {

View File

@ -121,6 +121,31 @@
}
}
}
},
"rule-groups": {
"delete": {
"success": "Ŝūččęşşƒūľľy đęľęŧęđ řūľę ģřőūp"
},
"move": {
"success": "Ŝūččęşşƒūľľy mővęđ řūľę ģřőūp"
},
"rename": {
"success": "Ŝūččęşşƒūľľy řęʼnämęđ řūľę ģřőūp"
},
"update": {
"success": "Ŝūččęşşƒūľľy ūpđäŧęđ řūľę ģřőūp"
}
},
"rules": {
"delete-rule": {
"success": "Ŗūľę şūččęşşƒūľľy đęľęŧęđ"
},
"pause-rule": {
"success": "Ŗūľę ęväľūäŧįőʼn päūşęđ"
},
"resume-rule": {
"success": "Ŗūľę ęväľūäŧįőʼn řęşūmęđ"
}
}
},
"annotations": {