From 2847f257815f511936ea6038c61504149262ef41 Mon Sep 17 00:00:00 2001 From: Domas Date: Thu, 26 Aug 2021 16:40:27 +0300 Subject: [PATCH] Alerting: UI to edit cortex/loki namespace & group names, group eval interval (#38543) --- .../alerting/unified/RuleList.test.tsx | 166 ++++++++++++++++-- .../features/alerting/unified/api/ruler.ts | 13 +- .../components/rules/EditCloudGroupModal.tsx | 123 +++++++++++++ .../unified/components/rules/RulesGroup.tsx | 31 +++- .../hooks/useCombinedRuleNamespaces.ts | 1 + public/app/features/alerting/unified/mocks.ts | 43 +++++ .../alerting/unified/state/actions.ts | 87 +++++++++ .../alerting/unified/state/reducers.ts | 3 + public/app/types/unified-alerting.ts | 1 + 9 files changed, 445 insertions(+), 23 deletions(-) create mode 100644 public/app/features/alerting/unified/components/rules/EditCloudGroupModal.tsx diff --git a/public/app/features/alerting/unified/RuleList.test.tsx b/public/app/features/alerting/unified/RuleList.test.tsx index 6b8e7e3bf35..1c65ae037ce 100644 --- a/public/app/features/alerting/unified/RuleList.test.tsx +++ b/public/app/features/alerting/unified/RuleList.test.tsx @@ -3,10 +3,11 @@ import { render, waitFor } from '@testing-library/react'; import { configureStore } from 'app/store/configureStore'; import { Provider } from 'react-redux'; import { RuleList } from './RuleList'; -import { byRole, byTestId, byText } from 'testing-library-selector'; +import { byLabelText, byRole, byTestId, byText } from 'testing-library-selector'; import { typeAsJestMock } from 'test/helpers/typeAsJestMock'; import { getAllDataSources } from './utils/config'; import { fetchRules } from './api/prometheus'; +import { fetchRulerRules, deleteRulerRulesGroup, deleteNamespace, setRulerRuleGroup } from './api/ruler'; import { mockDataSource, mockPromAlert, @@ -15,6 +16,8 @@ import { mockPromRuleGroup, mockPromRuleNamespace, MockDataSourceSrv, + somePromRules, + someRulerRules, } from './mocks'; import { DataSourceType, GRAFANA_RULES_SOURCE_NAME } from './utils/datasource'; import { SerializedError } from '@reduxjs/toolkit'; @@ -24,18 +27,32 @@ import { locationService, setDataSourceSrv } from '@grafana/runtime'; import { Router } from 'react-router-dom'; jest.mock('./api/prometheus'); +jest.mock('./api/ruler'); jest.mock('./utils/config'); +jest.mock('app/core/core', () => ({ + appEvents: { + subscribe: () => { + return { unsubscribe: () => {} }; + }, + emit: () => {}, + }, +})); const mocks = { getAllDataSourcesMock: typeAsJestMock(getAllDataSources), api: { fetchRules: typeAsJestMock(fetchRules), + fetchRulerRules: typeAsJestMock(fetchRulerRules), + deleteGroup: typeAsJestMock(deleteRulerRulesGroup), + deleteNamespace: typeAsJestMock(deleteNamespace), + setRulerRuleGroup: typeAsJestMock(setRulerRuleGroup), }, }; const renderRuleList = () => { const store = configureStore(); + locationService.push('/'); return render( @@ -78,6 +95,14 @@ const ui = { expandedContent: byTestId('expanded-content'), rulesFilterInput: byTestId('search-query-input'), moreErrorsButton: byRole('button', { name: /more errors/ }), + editCloudGroupIcon: byTestId('edit-group'), + + editGroupModal: { + namespaceInput: byLabelText('Namespace'), + ruleGroupInput: byLabelText('Rule group'), + intervalInput: byLabelText('Rule group evaluation interval'), + saveButton: byRole('button', { name: /Save changes/ }), + }, }; describe('RuleList', () => { @@ -309,7 +334,7 @@ describe('RuleList', () => { it('filters rules and alerts by labels', async () => { mocks.getAllDataSourcesMock.mockReturnValue([dataSources.prom]); setDataSourceSrv(new MockDataSourceSrv({ prom: dataSources.prom })); - + mocks.api.fetchRulerRules.mockResolvedValue({}); mocks.api.fetchRules.mockImplementation((dataSourceName: string) => { if (dataSourceName === GRAFANA_RULES_SOURCE_NAME) { return Promise.resolve([]); @@ -406,13 +431,12 @@ describe('RuleList', () => { expect(groups).toHaveLength(2); const filterInput = ui.rulesFilterInput.get(); - userEvent.type(filterInput, '{foo="bar"}'); + await userEvent.type(filterInput, '{foo="bar"}'); // Input is debounced so wait for it to be visible - waitFor(() => expect(filterInput).toHaveTextContent('{foo="bar"}')); + await waitFor(() => expect(filterInput).toHaveValue('{foo="bar"}')); // Group doesn't contain matching labels - waitFor(() => expect(groups[1]).not.toBeVisible()); - expect(groups[0]).toBeVisible(); + await waitFor(() => expect(ui.ruleGroup.queryAll()).toHaveLength(1)); userEvent.click(ui.groupCollapseToggle.get(groups[0])); @@ -425,20 +449,126 @@ describe('RuleList', () => { expect(ruleDetails).toHaveTextContent('Labelsseverity=warningfoo=bar'); // Check for different label matchers - userEvent.type(filterInput, '{foo!="bar"}'); - waitFor(() => expect(filterInput).toHaveTextContent('{foo!="bar"}')); + await userEvent.type(filterInput, '{selectall}{del}{foo!="bar",foo!="baz"}'); // Group doesn't contain matching labels - waitFor(() => expect(groups[0]).not.toBeVisible()); - expect(groups[1]).toBeVisible(); + await waitFor(() => expect(ui.ruleGroup.queryAll()).toHaveLength(1)); + await waitFor(() => expect(ui.ruleGroup.get()).toHaveTextContent('group-2')); - userEvent.type(filterInput, '{foo=~"b.+"}'); - waitFor(() => expect(filterInput).toHaveTextContent('{foo=~"b.+"}')); - expect(groups[0]).toBeVisible(); - expect(groups[1]).toBeVisible(); + await userEvent.type(filterInput, '{selectall}{del}{foo=~"b.+"}'); + await waitFor(() => expect(ui.ruleGroup.queryAll()).toHaveLength(2)); - userEvent.type(filterInput, '{region="US"}'); - waitFor(() => expect(filterInput).toHaveTextContent('{region="US"}')); - waitFor(() => expect(groups[0]).not.toBeVisible()); - expect(groups[1]).toBeVisible(); + await userEvent.type(filterInput, '{selectall}{del}{region="US"}'); + await waitFor(() => expect(ui.ruleGroup.queryAll()).toHaveLength(1)); + await waitFor(() => expect(ui.ruleGroup.get()).toHaveTextContent('group-2')); + }); + + describe('edit lotex groups, namespaces', () => { + const testDatasources = { + prom: dataSources.prom, + }; + + function testCase(name: string, fn: () => Promise) { + it(name, async () => { + mocks.getAllDataSourcesMock.mockReturnValue(Object.values(testDatasources)); + setDataSourceSrv(new MockDataSourceSrv(testDatasources)); + mocks.api.fetchRules.mockImplementation((sourceName) => + Promise.resolve(sourceName === testDatasources.prom.name ? somePromRules() : []) + ); + mocks.api.fetchRulerRules.mockImplementation((sourceName) => + Promise.resolve(sourceName === testDatasources.prom.name ? someRulerRules : {}) + ); + mocks.api.setRulerRuleGroup.mockResolvedValue(); + mocks.api.deleteNamespace.mockResolvedValue(); + + await renderRuleList(); + + expect(await ui.rulesFilterInput.find()).toHaveValue(''); + + const groups = await ui.ruleGroup.findAll(); + expect(groups).toHaveLength(3); + + // open edit dialog + userEvent.click(ui.editCloudGroupIcon.get(groups[0])); + + expect(ui.editGroupModal.namespaceInput.get()).toHaveValue('namespace1'); + expect(ui.editGroupModal.ruleGroupInput.get()).toHaveValue('group1'); + await fn(); + }); + } + + testCase('rename both lotex namespace and group', async () => { + // make changes to form + userEvent.clear(ui.editGroupModal.namespaceInput.get()); + await userEvent.type(ui.editGroupModal.namespaceInput.get(), 'super namespace'); + + userEvent.clear(ui.editGroupModal.ruleGroupInput.get()); + await userEvent.type(ui.editGroupModal.ruleGroupInput.get(), 'super group'); + + await userEvent.type(ui.editGroupModal.intervalInput.get(), '5m'); + + // submit, check that appropriate calls were made + userEvent.click(ui.editGroupModal.saveButton.get()); + + await waitFor(() => expect(ui.editGroupModal.namespaceInput.query()).not.toBeInTheDocument()); + + expect(mocks.api.setRulerRuleGroup).toHaveBeenCalledTimes(2); + expect(mocks.api.deleteNamespace).toHaveBeenCalledTimes(1); + expect(mocks.api.deleteGroup).not.toHaveBeenCalled(); + expect(mocks.api.fetchRulerRules).toHaveBeenCalledTimes(4); + expect(mocks.api.setRulerRuleGroup).toHaveBeenNthCalledWith(1, testDatasources.prom.name, 'super namespace', { + ...someRulerRules['namespace1'][0], + name: 'super group', + interval: '5m', + }); + expect(mocks.api.setRulerRuleGroup).toHaveBeenNthCalledWith( + 2, + testDatasources.prom.name, + 'super namespace', + someRulerRules['namespace1'][1] + ); + expect(mocks.api.deleteNamespace).toHaveBeenLastCalledWith('Prometheus', 'namespace1'); + }); + + testCase('rename just the lotex group', async () => { + // make changes to form + userEvent.clear(ui.editGroupModal.ruleGroupInput.get()); + await userEvent.type(ui.editGroupModal.ruleGroupInput.get(), 'super group'); + await userEvent.type(ui.editGroupModal.intervalInput.get(), '5m'); + + // submit, check that appropriate calls were made + userEvent.click(ui.editGroupModal.saveButton.get()); + + await waitFor(() => expect(ui.editGroupModal.namespaceInput.query()).not.toBeInTheDocument()); + + expect(mocks.api.setRulerRuleGroup).toHaveBeenCalledTimes(1); + expect(mocks.api.deleteGroup).toHaveBeenCalledTimes(1); + expect(mocks.api.deleteNamespace).not.toHaveBeenCalled(); + expect(mocks.api.fetchRulerRules).toHaveBeenCalledTimes(4); + expect(mocks.api.setRulerRuleGroup).toHaveBeenNthCalledWith(1, testDatasources.prom.name, 'namespace1', { + ...someRulerRules['namespace1'][0], + name: 'super group', + interval: '5m', + }); + expect(mocks.api.deleteGroup).toHaveBeenLastCalledWith('Prometheus', 'namespace1', 'group1'); + }); + + testCase('edit lotex group eval interval, no renaming', async () => { + // make changes to form + await userEvent.type(ui.editGroupModal.intervalInput.get(), '5m'); + + // submit, check that appropriate calls were made + userEvent.click(ui.editGroupModal.saveButton.get()); + + await waitFor(() => expect(ui.editGroupModal.namespaceInput.query()).not.toBeInTheDocument()); + + expect(mocks.api.setRulerRuleGroup).toHaveBeenCalledTimes(1); + expect(mocks.api.deleteGroup).not.toHaveBeenCalled(); + expect(mocks.api.deleteNamespace).not.toHaveBeenCalled(); + expect(mocks.api.fetchRulerRules).toHaveBeenCalledTimes(4); + expect(mocks.api.setRulerRuleGroup).toHaveBeenNthCalledWith(1, testDatasources.prom.name, 'namespace1', { + ...someRulerRules['namespace1'][0], + interval: '5m', + }); + }); }); }); diff --git a/public/app/features/alerting/unified/api/ruler.ts b/public/app/features/alerting/unified/api/ruler.ts index 2b73aac6562..b6d528764ad 100644 --- a/public/app/features/alerting/unified/api/ruler.ts +++ b/public/app/features/alerting/unified/api/ruler.ts @@ -53,7 +53,7 @@ export async function fetchRulerRulesGroup( } export async function deleteRulerRulesGroup(dataSourceName: string, namespace: string, groupName: string) { - return await lastValueFrom( + await lastValueFrom( getBackendSrv().fetch({ url: `/api/ruler/${getDatasourceAPIId(dataSourceName)}/api/v1/rules/${encodeURIComponent( namespace @@ -97,3 +97,14 @@ async function rulerGetRequest(url: string, empty: T): Promise { throw e; } } + +export async function deleteNamespace(dataSourceName: string, namespace: string): Promise { + await lastValueFrom( + getBackendSrv().fetch({ + method: 'DELETE', + url: `/api/ruler/${getDatasourceAPIId(dataSourceName)}/api/v1/rules/${encodeURIComponent(namespace)}`, + showErrorAlert: false, + showSuccessAlert: false, + }) + ); +} diff --git a/public/app/features/alerting/unified/components/rules/EditCloudGroupModal.tsx b/public/app/features/alerting/unified/components/rules/EditCloudGroupModal.tsx new file mode 100644 index 00000000000..52c86b0397f --- /dev/null +++ b/public/app/features/alerting/unified/components/rules/EditCloudGroupModal.tsx @@ -0,0 +1,123 @@ +import React, { useEffect, useMemo } from 'react'; +import { CombinedRuleGroup, CombinedRuleNamespace } from 'app/types/unified-alerting'; +import { Modal, Button, Form, Field, Input, useStyles2 } from '@grafana/ui'; +import { durationValidationPattern } from '../../utils/time'; +import { css } from '@emotion/css'; +import { useDispatch } from 'react-redux'; +import { updateLotexNamespaceAndGroupAction } from '../../state/actions'; +import { getRulesSourceName } from '../../utils/datasource'; +import { useUnifiedAlertingSelector } from '../../hooks/useUnifiedAlertingSelector'; +import { initialAsyncRequestState } from '../../utils/redux'; +import { useCleanup } from 'app/core/hooks/useCleanup'; + +interface Props { + namespace: CombinedRuleNamespace; + group: CombinedRuleGroup; + onClose: () => void; +} + +interface FormValues { + namespaceName: string; + groupName: string; + groupInterval: string; +} + +export function EditCloudGroupModal(props: Props): React.ReactElement { + const { namespace, group, onClose } = props; + const styles = useStyles2(getStyles); + const dispatch = useDispatch(); + const { loading, error, dispatched } = + useUnifiedAlertingSelector((state) => state.updateLotexNamespaceAndGroup) ?? initialAsyncRequestState; + + const defaultValues = useMemo( + (): FormValues => ({ + namespaceName: namespace.name, + groupName: group.name, + groupInterval: group.interval ?? '', + }), + [namespace, group] + ); + + // close modal if successfully saved + useEffect(() => { + if (dispatched && !loading && !error) { + onClose(); + } + }, [dispatched, loading, onClose, error]); + + useCleanup((state) => state.unifiedAlerting.updateLotexNamespaceAndGroup); + + const onSubmit = (values: FormValues) => { + dispatch( + updateLotexNamespaceAndGroupAction({ + rulesSourceName: getRulesSourceName(namespace.rulesSource), + groupName: group.name, + newGroupName: values.groupName, + namespaceName: namespace.name, + newNamespaceName: values.namespaceName, + groupInterval: values.groupInterval || undefined, + }) + ); + }; + + return ( + +
+ {({ register, errors, formState: { isDirty } }) => ( + <> + + + + + + + + + + + + + + + + )} +
+
+ ); +} + +const getStyles = () => ({ + modal: css` + max-width: 560px; + `, +}); diff --git a/public/app/features/alerting/unified/components/rules/RulesGroup.tsx b/public/app/features/alerting/unified/components/rules/RulesGroup.tsx index d77eb2fb7dd..f5b3a752219 100644 --- a/public/app/features/alerting/unified/components/rules/RulesGroup.tsx +++ b/public/app/features/alerting/unified/components/rules/RulesGroup.tsx @@ -1,6 +1,6 @@ import { CombinedRuleGroup, CombinedRuleNamespace } from 'app/types/unified-alerting'; import React, { FC, useState } from 'react'; -import { Icon, Tooltip, useStyles2 } from '@grafana/ui'; +import { HorizontalGroup, Icon, Spinner, Tooltip, useStyles2 } from '@grafana/ui'; import { GrafanaTheme2 } from '@grafana/data'; import { css } from '@emotion/css'; import { isGrafanaRulerRule } from '../../utils/rules'; @@ -12,6 +12,7 @@ import { useHasRuler } from '../../hooks/useHasRuler'; import kbn from 'app/core/utils/kbn'; import { useFolder } from '../../hooks/useFolder'; import { RuleStats } from './RuleStats'; +import { EditCloudGroupModal } from './EditCloudGroupModal'; interface Props { namespace: CombinedRuleNamespace; @@ -23,16 +24,27 @@ export const RulesGroup: FC = React.memo(({ group, namespace }) => { const styles = useStyles2(getStyles); const [isCollapsed, setIsCollapsed] = useState(true); + const [isEditingGroup, setIsEditingGroup] = useState(false); const hasRuler = useHasRuler(); const rulerRule = group.rules[0]?.rulerRule; const folderUID = (rulerRule && isGrafanaRulerRule(rulerRule) && rulerRule.grafana_alert.namespace_uid) || undefined; const { folder } = useFolder(folderUID); + // group "is deleting" if rules source has ruler, but this group has no rules that are in ruler + const isDeleting = hasRuler(rulesSource) && !group.rules.find((rule) => !!rule.rulerRule); + const actionIcons: React.ReactNode[] = []; // for grafana, link to folder views - if (rulesSource === GRAFANA_RULES_SOURCE_NAME) { + if (isDeleting) { + actionIcons.push( + + + deleting + + ); + } else if (rulesSource === GRAFANA_RULES_SOURCE_NAME) { if (folderUID) { const baseUrl = `/dashboards/f/${folderUID}/${kbn.slugifyForUrl(namespace.name)}`; if (folder?.canSave) { @@ -51,9 +63,17 @@ export const RulesGroup: FC = React.memo(({ group, namespace }) => { /> ); } - } else if (hasRuler(rulesSource)) { - actionIcons.push(); // @TODO } + } else if (hasRuler(rulesSource)) { + actionIcons.push( + setIsEditingGroup(true)} + /> + ); } const groupName = isCloudRulesSource(rulesSource) ? `${namespace.name} > ${group.name}` : namespace.name; @@ -88,6 +108,9 @@ export const RulesGroup: FC = React.memo(({ group, namespace }) => { {!isCollapsed && ( )} + {isEditingGroup && ( + setIsEditingGroup(false)} /> + )} ); }); diff --git a/public/app/features/alerting/unified/hooks/useCombinedRuleNamespaces.ts b/public/app/features/alerting/unified/hooks/useCombinedRuleNamespaces.ts index b1403265874..13abd147ef9 100644 --- a/public/app/features/alerting/unified/hooks/useCombinedRuleNamespaces.ts +++ b/public/app/features/alerting/unified/hooks/useCombinedRuleNamespaces.ts @@ -104,6 +104,7 @@ function addRulerGroupsToCombinedNamespace(namespace: CombinedRuleNamespace, gro namespace.groups = groups.map((group) => { const combinedGroup: CombinedRuleGroup = { name: group.name, + interval: group.interval, rules: [], }; combinedGroup.rules = group.rules.map((rule) => rulerRuleToCombinedRule(rule, namespace, combinedGroup)); diff --git a/public/app/features/alerting/unified/mocks.ts b/public/app/features/alerting/unified/mocks.ts index e1cc9a76284..98bb65c6d93 100644 --- a/public/app/features/alerting/unified/mocks.ts +++ b/public/app/features/alerting/unified/mocks.ts @@ -4,7 +4,10 @@ import { GrafanaRuleDefinition, PromAlertingRuleState, PromRuleType, + RulerAlertingRuleDTO, RulerGrafanaRuleDTO, + RulerRuleGroupDTO, + RulerRulesConfigDTO, } from 'app/types/unified-alerting-dto'; import { AlertingRule, Alert, RecordingRule, RuleGroup, RuleNamespace } from 'app/types/unified-alerting'; import DatasourceSrv from 'app/features/plugins/datasource_srv'; @@ -94,6 +97,23 @@ export const mockRulerGrafanaRule = ( }; }; +export const mockRulerAlertingRule = (partial: Partial = {}): RulerAlertingRuleDTO => ({ + alert: 'alert1', + expr: 'up = 1', + labels: { + severity: 'warning', + }, + annotations: { + summary: 'test alert', + }, +}); + +export const mockRulerRuleGroup = (partial: Partial = {}): RulerRuleGroupDTO => ({ + name: 'group1', + rules: [mockRulerAlertingRule()], + ...partial, +}); + export const mockPromAlertingRule = (partial: Partial = {}): AlertingRule => { return { type: PromRuleType.Alerting, @@ -350,3 +370,26 @@ export const someCloudAlertManagerConfig: AlertManagerCortexConfig = { ], }, }; + +export const somePromRules = (dataSourceName = 'Prometheus'): RuleNamespace[] => [ + { + dataSourceName, + name: 'namespace1', + groups: [ + mockPromRuleGroup({ name: 'group1', rules: [mockPromAlertingRule({ name: 'alert1' })] }), + mockPromRuleGroup({ name: 'group2', rules: [mockPromAlertingRule({ name: 'alert2' })] }), + ], + }, + { + dataSourceName, + name: 'namespace2', + groups: [mockPromRuleGroup({ name: 'group3', rules: [mockPromAlertingRule({ name: 'alert3' })] })], + }, +]; +export const someRulerRules: RulerRulesConfigDTO = { + namespace1: [ + mockRulerRuleGroup({ name: 'group1', rules: [mockRulerAlertingRule({ alert: 'alert1' })] }), + mockRulerRuleGroup({ name: 'group2', rules: [mockRulerAlertingRule({ alert: 'alert2' })] }), + ], + namespace2: [mockRulerRuleGroup({ name: 'group3', rules: [mockRulerAlertingRule({ alert: 'alert3' })] })], +}; diff --git a/public/app/features/alerting/unified/state/actions.ts b/public/app/features/alerting/unified/state/actions.ts index 19b0cfc9162..f13f0598658 100644 --- a/public/app/features/alerting/unified/state/actions.ts +++ b/public/app/features/alerting/unified/state/actions.ts @@ -31,6 +31,7 @@ import { } from '../api/alertmanager'; import { fetchRules } from '../api/prometheus'; import { + deleteNamespace, deleteRulerRulesGroup, fetchRulerRules, fetchRulerRulesGroup, @@ -620,3 +621,89 @@ export const testReceiversAction = createAsyncThunk( }); } ); + +interface UpdateNamespaceAndGroupOptions { + rulesSourceName: string; + namespaceName: string; + groupName: string; + newNamespaceName: string; + newGroupName: string; + groupInterval?: string; +} + +// allows renaming namespace, renaming group and changing group interval, all in one go +export const updateLotexNamespaceAndGroupAction = createAsyncThunk( + 'unifiedalerting/updateLotexNamespaceAndGroup', + async (options: UpdateNamespaceAndGroupOptions, thunkAPI): Promise => { + return withAppEvents( + withSerializedError( + (async () => { + const { rulesSourceName, namespaceName, groupName, newNamespaceName, newGroupName, groupInterval } = options; + if (options.rulesSourceName === GRAFANA_RULES_SOURCE_NAME) { + throw new Error(`this action does not support Grafana rules`); + } + // fetch rules and perform sanity checks + const rulesResult = await fetchRulerRules(rulesSourceName); + if (!rulesResult[namespaceName]) { + 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.`); + } + if (newGroupName !== groupName && !!rulesResult[namespaceName].find((group) => group.name === newGroupName)) { + throw new Error(`Group "${newGroupName}" already exists.`); + } + if (newNamespaceName !== namespaceName && !!rulesResult[newNamespaceName]) { + throw new Error(`Namespace "${newNamespaceName}" already exists.`); + } + if ( + newNamespaceName === namespaceName && + groupName === newGroupName && + groupInterval === existingGroup.interval + ) { + throw new Error('Nothing changed.'); + } + + // if renaming namespace - make new copies of all groups, then delete old namespace + if (newNamespaceName !== namespaceName) { + for (const group of rulesResult[namespaceName]) { + await setRulerRuleGroup( + rulesSourceName, + newNamespaceName, + group.name === groupName + ? { + ...group, + name: newGroupName, + interval: groupInterval, + } + : group + ); + } + await deleteNamespace(rulesSourceName, namespaceName); + + // if only modifying group... + } else { + // save updated group + await setRulerRuleGroup(rulesSourceName, namespaceName, { + ...existingGroup, + name: newGroupName, + interval: groupInterval, + }); + // if group name was changed, delete old group + if (newGroupName !== groupName) { + await deleteRulerRulesGroup(rulesSourceName, namespaceName, groupName); + } + } + + // refetch all rules + await thunkAPI.dispatch(fetchRulerRulesAction(rulesSourceName)); + })() + ), + { + errorMessage: 'Failed to update namespace / group', + successMessage: 'Update successful', + } + ); + } +); diff --git a/public/app/features/alerting/unified/state/reducers.ts b/public/app/features/alerting/unified/state/reducers.ts index 896860a217d..b8de37879c0 100644 --- a/public/app/features/alerting/unified/state/reducers.ts +++ b/public/app/features/alerting/unified/state/reducers.ts @@ -16,6 +16,7 @@ import { checkIfLotexSupportsEditingRulesAction, deleteAlertManagerConfigAction, testReceiversAction, + updateLotexNamespaceAndGroupAction, } from './actions'; export const reducer = combineReducers({ @@ -50,6 +51,8 @@ export const reducer = combineReducers({ (source) => source ).reducer, testReceivers: createAsyncSlice('testReceivers', testReceiversAction).reducer, + updateLotexNamespaceAndGroup: createAsyncSlice('updateLotexNamespaceAndGroup', updateLotexNamespaceAndGroupAction) + .reducer, }); export type UnifiedAlertingState = ReturnType; diff --git a/public/app/types/unified-alerting.ts b/public/app/types/unified-alerting.ts index 38c883de66e..b5d98cf7ac2 100644 --- a/public/app/types/unified-alerting.ts +++ b/public/app/types/unified-alerting.ts @@ -85,6 +85,7 @@ export interface CombinedRule { export interface CombinedRuleGroup { name: string; + interval?: string; rules: CombinedRule[]; }