mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
Alerting: Implement "rename / move / update" for useProduceNewRuleGroup
(#89706)
This commit is contained in:
parent
f140594cf1
commit
6874202dfa
@ -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';
|
||||
|
@ -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';
|
||||
|
@ -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';
|
||||
|
@ -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,
|
||||
};
|
||||
|
@ -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';
|
||||
|
||||
|
@ -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'],
|
||||
}),
|
||||
}),
|
||||
});
|
||||
|
@ -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: () => ({}),
|
||||
});
|
||||
|
23
public/app/features/alerting/unified/api/util.test.ts
Normal file
23
public/app/features/alerting/unified/api/util.test.ts
Normal 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();
|
||||
});
|
||||
});
|
17
public/app/features/alerting/unified/api/util.ts
Normal file
17
public/app/features/alerting/unified/api/util.ts
Normal 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;
|
||||
}
|
@ -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);
|
||||
}}
|
||||
|
@ -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';
|
||||
|
@ -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);
|
||||
}
|
||||
};
|
||||
|
@ -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';
|
||||
|
@ -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,
|
||||
|
@ -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
|
||||
|
@ -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';
|
||||
|
@ -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>
|
||||
|
@ -5,7 +5,7 @@ import {
|
||||
time_plus_15,
|
||||
time_plus_30,
|
||||
time_plus_5,
|
||||
} from '../../../mocks/alertRuleApi';
|
||||
} from '../../../mocks/grafanaRulerApi';
|
||||
|
||||
import { historyResultToDataFrame } from './utils';
|
||||
|
||||
|
@ -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",
|
||||
},
|
||||
]
|
||||
`;
|
||||
|
181
public/app/features/alerting/unified/hooks/useAsync.ts
Normal file
181
public/app/features/alerting/unified/hooks/useAsync.ts
Normal 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),
|
||||
};
|
||||
}
|
@ -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} />
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
@ -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;
|
||||
});
|
||||
}
|
||||
|
@ -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,
|
||||
|
@ -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],
|
||||
};
|
||||
|
43
public/app/features/alerting/unified/mocks/mimirRulerApi.ts
Normal file
43
public/app/features/alerting/unified/mocks/mimirRulerApi.ts
Normal 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,
|
||||
};
|
@ -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,
|
||||
|
@ -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 };
|
||||
}
|
||||
|
@ -0,0 +1,3 @@
|
||||
export const MIMIR_DATASOURCE_UID = 'mimir';
|
||||
export const MIMIR_GROUP_NAME = 'group-1';
|
||||
export const MIMIR_NAMESPACE_NAME = 'namespace-1';
|
@ -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`, () => {
|
@ -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;
|
@ -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}`);
|
||||
|
@ -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;
|
||||
|
@ -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,
|
||||
});
|
||||
|
||||
|
@ -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');
|
||||
});
|
||||
});
|
||||
|
@ -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;
|
||||
}
|
||||
|
@ -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';
|
||||
|
@ -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": {
|
||||
|
@ -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": {
|
||||
|
Loading…
Reference in New Issue
Block a user