Alerting: Make the folder field read-only on the eval group modal (#62935)

* Make the folder field read-only on the eval group modal

* Code cleanup

* Use useCombinedRuleNamespace to simplify groups fetching

* Fix groups filtering and label

* Revert go test changes, add folder button title

* Revert go changes

* Remove folder link when no url provided, fix messages

* Fix tests

* Fix lint
This commit is contained in:
Konrad Lalik 2023-02-20 08:47:50 +01:00 committed by GitHub
parent 937b8419c3
commit 69b2aade1b
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
8 changed files with 303 additions and 324 deletions

View File

@ -1,5 +1,5 @@
import { SerializedError } from '@reduxjs/toolkit'; import { SerializedError } from '@reduxjs/toolkit';
import { render, screen, waitFor } from '@testing-library/react'; import { prettyDOM, render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event'; import userEvent from '@testing-library/user-event';
import React from 'react'; import React from 'react';
import { TestProvider } from 'test/helpers/TestProvider'; import { TestProvider } from 'test/helpers/TestProvider';
@ -119,8 +119,9 @@ const ui = {
newRuleButton: byRole('link', { name: 'Create alert rule' }), newRuleButton: byRole('link', { name: 'Create alert rule' }),
exportButton: byRole('button', { name: /export/i }), exportButton: byRole('button', { name: /export/i }),
editGroupModal: { editGroupModal: {
namespaceInput: byRole('textbox', { hidden: true, name: /namespace/i }), dialog: byRole('dialog'),
ruleGroupInput: byRole('textbox', { name: 'Evaluation group', exact: true }), namespaceInput: byRole('textbox', { name: /^Namespace/ }),
ruleGroupInput: byRole('textbox', { name: /Evaluation group/ }),
intervalInput: byRole('textbox', { intervalInput: byRole('textbox', {
name: /Rule group evaluation interval Evaluation interval should be smaller or equal to 'For' values for existing rules in this group./i, name: /Rule group evaluation interval Evaluation interval should be smaller or equal to 'For' values for existing rules in this group./i,
}), }),
@ -556,17 +557,19 @@ describe('RuleList', () => {
expect(await ui.rulesFilterInput.find()).toHaveValue(''); expect(await ui.rulesFilterInput.find()).toHaveValue('');
await waitFor(() => expect(ui.ruleGroup.queryAll()).toHaveLength(3));
const groups = await ui.ruleGroup.findAll(); const groups = await ui.ruleGroup.findAll();
expect(groups).toHaveLength(3); expect(groups).toHaveLength(3);
// open edit dialog // open edit dialog
await userEvent.click(ui.editCloudGroupIcon.get(groups[0])); await userEvent.click(ui.editCloudGroupIcon.get(groups[0]));
await expect(screen.getByRole('textbox', { hidden: true, name: /namespace/i })).toHaveDisplayValue(
'namespace1' await waitFor(() => expect(ui.editGroupModal.dialog.get()).toBeInTheDocument());
); prettyDOM(ui.editGroupModal.dialog.get());
await expect(screen.getByRole('textbox', { name: 'Evaluation group', exact: true })).toHaveDisplayValue(
'group1' expect(ui.editGroupModal.namespaceInput.get()).toHaveDisplayValue('namespace1');
); expect(ui.editGroupModal.ruleGroupInput.get()).toHaveDisplayValue('group1');
await fn(); await fn();
}); });
} }
@ -614,8 +617,8 @@ describe('RuleList', () => {
testCase('rename just the lotex group', async () => { testCase('rename just the lotex group', async () => {
// make changes to form // make changes to form
await userEvent.clear(screen.getByRole('textbox', { name: 'Evaluation group', exact: true })); await userEvent.clear(ui.editGroupModal.ruleGroupInput.get());
await userEvent.type(screen.getByRole('textbox', { name: 'Evaluation group', exact: true }), 'super group'); await userEvent.type(ui.editGroupModal.ruleGroupInput.get(), 'super group');
await userEvent.type( await userEvent.type(
screen.getByRole('textbox', { screen.getByRole('textbox', {
name: /rule group evaluation interval evaluation interval should be smaller or equal to 'for' values for existing rules in this group\./i, name: /rule group evaluation interval evaluation interval should be smaller or equal to 'for' values for existing rules in this group\./i,

View File

@ -10,8 +10,8 @@ import { FolderPickerFilter } from 'app/core/components/Select/FolderPicker';
import { contextSrv } from 'app/core/core'; import { contextSrv } from 'app/core/core';
import { DashboardSearchHit } from 'app/features/search/types'; import { DashboardSearchHit } from 'app/features/search/types';
import { AccessControlAction, useDispatch } from 'app/types'; import { AccessControlAction, useDispatch } from 'app/types';
import { RulerRuleDTO, RulerRuleGroupDTO, RulerRulesConfigDTO } from 'app/types/unified-alerting-dto';
import { useCombinedRuleNamespaces } from '../../hooks/useCombinedRuleNamespaces';
import { useUnifiedAlertingSelector } from '../../hooks/useUnifiedAlertingSelector'; import { useUnifiedAlertingSelector } from '../../hooks/useUnifiedAlertingSelector';
import { fetchRulerRulesIfNotFetchedYet } from '../../state/actions'; import { fetchRulerRulesIfNotFetchedYet } from '../../state/actions';
import { RuleForm, RuleFormValues } from '../../types/rule-form'; import { RuleForm, RuleFormValues } from '../../types/rule-form';
@ -19,56 +19,35 @@ import { GRAFANA_RULES_SOURCE_NAME } from '../../utils/datasource';
import { isGrafanaRulerRule } from '../../utils/rules'; import { isGrafanaRulerRule } from '../../utils/rules';
import { InfoIcon } from '../InfoIcon'; import { InfoIcon } from '../InfoIcon';
import { getIntervalForGroup } from './GrafanaEvaluationBehavior'; import { MINUTE } from './AlertRuleForm';
import { containsSlashes, Folder, RuleFolderPicker } from './RuleFolderPicker'; import { containsSlashes, Folder, RuleFolderPicker } from './RuleFolderPicker';
import { checkForPathSeparator } from './util'; import { checkForPathSeparator } from './util';
export const SLICE_GROUP_RESULTS_TO = 1000; export const SLICE_GROUP_RESULTS_TO = 1000;
const useGetGroups = (groupfoldersForGrafana: RulerRulesConfigDTO | null | undefined, folderName: string) => {
const groupOptions = useMemo(() => {
const groupsForFolderResult: Array<RulerRuleGroupDTO<RulerRuleDTO>> = groupfoldersForGrafana
? groupfoldersForGrafana[folderName] ?? []
: [];
const folderGroups = groupsForFolderResult.map((group) => ({
name: group.name,
provisioned: group.rules.some((rule) => isGrafanaRulerRule(rule) && Boolean(rule.grafana_alert.provenance)),
}));
return folderGroups.filter((group) => !group.provisioned).map((group) => group.name);
}, [groupfoldersForGrafana, folderName]);
return groupOptions;
};
function mapGroupsToOptions(
groupsForFolder: RulerRulesConfigDTO | null | undefined,
groups: string[],
folderTitle: string
): Array<SelectableValue<string>> {
return groups.map((group) => ({
label: group,
value: group,
description: `${getIntervalForGroup(groupsForFolder, group, folderTitle)}`,
}));
}
interface FolderAndGroupProps { interface FolderAndGroupProps {
initialFolder: RuleForm | null; initialFolder: RuleForm | null;
} }
export const useGetGroupOptionsFromFolder = (folderTitle: string) => { export const useGetGroupOptionsFromFolder = (folderTitle: string) => {
const rulerRuleRequests = useUnifiedAlertingSelector((state) => state.rulerRules); const rulerRuleRequests = useUnifiedAlertingSelector((state) => state.rulerRules);
const groupfoldersForGrafana = rulerRuleRequests[GRAFANA_RULES_SOURCE_NAME]; const groupfoldersForGrafana = rulerRuleRequests[GRAFANA_RULES_SOURCE_NAME];
const groupsForFolder = groupfoldersForGrafana?.result; const grafanaFolders = useCombinedRuleNamespaces(GRAFANA_RULES_SOURCE_NAME);
const folderGroups = grafanaFolders.find((f) => f.name === folderTitle)?.groups ?? [];
const nonProvisionedGroups = folderGroups.filter((g) => {
return g.rules.every(
(r) => isGrafanaRulerRule(r.rulerRule) && Boolean(r.rulerRule.grafana_alert.provenance) === false
);
});
const groupOptions = nonProvisionedGroups.map<SelectableValue<string>>((group) => ({
label: group.name,
value: group.name,
description: group.interval ?? MINUTE,
}));
const groupOptions: Array<SelectableValue<string>> = mapGroupsToOptions(
groupsForFolder,
useGetGroups(groupfoldersForGrafana?.result, folderTitle),
folderTitle
);
return { groupOptions, loading: groupfoldersForGrafana?.loading }; return { groupOptions, loading: groupfoldersForGrafana?.loading };
}; };

View File

@ -5,9 +5,11 @@ import { RegisterOptions, useFormContext } from 'react-hook-form';
import { GrafanaTheme2, SelectableValue } from '@grafana/data'; import { GrafanaTheme2, SelectableValue } from '@grafana/data';
import { Stack } from '@grafana/experimental'; import { Stack } from '@grafana/experimental';
import { Button, Field, InlineLabel, Input, InputControl, useStyles2, Switch, Tooltip, Icon } from '@grafana/ui'; import { Button, Field, InlineLabel, Input, InputControl, useStyles2, Switch, Tooltip, Icon } from '@grafana/ui';
import { RulerRuleDTO, RulerRuleGroupDTO, RulerRulesConfigDTO } from 'app/types/unified-alerting-dto'; import { RulerRulesConfigDTO } from 'app/types/unified-alerting-dto';
import { CombinedRuleGroup, CombinedRuleNamespace } from '../../../../../types/unified-alerting';
import { logInfo, LogMessages } from '../../Analytics'; import { logInfo, LogMessages } from '../../Analytics';
import { useCombinedRuleNamespaces } from '../../hooks/useCombinedRuleNamespaces';
import { useUnifiedAlertingSelector } from '../../hooks/useUnifiedAlertingSelector'; import { useUnifiedAlertingSelector } from '../../hooks/useUnifiedAlertingSelector';
import { RuleForm, RuleFormValues } from '../../types/rule-form'; import { RuleForm, RuleFormValues } from '../../types/rule-form';
import { GRAFANA_RULES_SOURCE_NAME } from '../../utils/datasource'; import { GRAFANA_RULES_SOURCE_NAME } from '../../utils/datasource';
@ -22,18 +24,6 @@ import { RuleEditorSection } from './RuleEditorSection';
export const MIN_TIME_RANGE_STEP_S = 10; // 10 seconds export const MIN_TIME_RANGE_STEP_S = 10; // 10 seconds
export const getIntervalForGroup = (
rulerRules: RulerRulesConfigDTO | null | undefined,
group: string,
folder: string
) => {
const folderObj: Array<RulerRuleGroupDTO<RulerRuleDTO>> = rulerRules ? rulerRules[folder] : [];
const groupObj = folderObj?.find((rule) => rule.name === group);
const interval = groupObj?.interval ?? MINUTE;
return interval;
};
const forValidationOptions = (evaluateEvery: string): RegisterOptions => ({ const forValidationOptions = (evaluateEvery: string): RegisterOptions => ({
required: { required: {
value: true, value: true,
@ -87,6 +77,10 @@ export const EvaluateEveryNewGroup = ({ rules }: { rules: RulerRulesConfigDTO |
} = useFormContext<RuleFormValues>(); } = useFormContext<RuleFormValues>();
const styles = useStyles2(getStyles); const styles = useStyles2(getStyles);
const evaluateEveryId = 'eval-every-input'; const evaluateEveryId = 'eval-every-input';
const [groupName, folderName] = watch(['group', 'folder.title']);
const groupRules = (rules && rules[folderName]?.find((g) => g.name === groupName)?.rules) ?? [];
return ( return (
<Field <Field
label="Evaluation interval" label="Evaluation interval"
@ -110,10 +104,7 @@ export const EvaluateEveryNewGroup = ({ rules }: { rules: RulerRulesConfigDTO |
<Input <Input
id={evaluateEveryId} id={evaluateEveryId}
width={8} width={8}
{...register( {...register('evaluateEvery', evaluateEveryValidationOptions(groupRules))}
'evaluateEvery',
evaluateEveryValidationOptions(rules, watch('group'), watch('folder.title'))
)}
/> />
</Field> </Field>
</Stack> </Stack>
@ -135,24 +126,25 @@ function FolderGroupAndEvaluationInterval({
const { watch, setValue } = useFormContext<RuleFormValues>(); const { watch, setValue } = useFormContext<RuleFormValues>();
const [isEditingGroup, setIsEditingGroup] = useState(false); const [isEditingGroup, setIsEditingGroup] = useState(false);
const group = watch('group'); const [groupName, folderName] = watch(['group', 'folder.title']);
const folder = watch('folder');
const rulerRuleRequests = useUnifiedAlertingSelector((state) => state.rulerRules); const rulerRuleRequests = useUnifiedAlertingSelector((state) => state.rulerRules);
const groupfoldersForGrafana = rulerRuleRequests[GRAFANA_RULES_SOURCE_NAME]; const groupfoldersForGrafana = rulerRuleRequests[GRAFANA_RULES_SOURCE_NAME];
const isNewGroup = useIsNewGroup(folder?.title ?? '', group); const grafanaNamespaces = useCombinedRuleNamespaces(GRAFANA_RULES_SOURCE_NAME);
const existingNamespace = grafanaNamespaces.find((ns) => ns.name === folderName);
const existingGroup = existingNamespace?.groups.find((g) => g.name === groupName);
const isNewGroup = useIsNewGroup(folderName ?? '', groupName);
useEffect(() => { useEffect(() => {
if (!isNewGroup) { if (!isNewGroup && existingGroup?.interval) {
group && setEvaluateEvery(existingGroup.interval);
folder &&
setEvaluateEvery(getIntervalForGroup(groupfoldersForGrafana?.result, group, folder?.title ?? ''));
} else { } else {
setEvaluateEvery(MINUTE); setEvaluateEvery(MINUTE);
setValue('evaluateEvery', MINUTE); setValue('evaluateEvery', MINUTE);
} }
}, [group, folder, groupfoldersForGrafana?.result, setEvaluateEvery, isNewGroup, setValue]); }, [setEvaluateEvery, isNewGroup, setValue, existingGroup]);
const closeEditGroupModal = (saved = false) => { const closeEditGroupModal = (saved = false) => {
if (!saved) { if (!saved) {
@ -163,30 +155,36 @@ function FolderGroupAndEvaluationInterval({
const onOpenEditGroupModal = () => setIsEditingGroup(true); const onOpenEditGroupModal = () => setIsEditingGroup(true);
const editGroupDisabled = groupfoldersForGrafana?.loading || isNewGroup || !folder || !group; const editGroupDisabled = groupfoldersForGrafana?.loading || isNewGroup || !folderName || !groupName;
const emptyNamespace: CombinedRuleNamespace = {
name: folderName,
rulesSource: GRAFANA_RULES_SOURCE_NAME,
groups: [],
};
const emptyGroup: CombinedRuleGroup = { name: groupName, interval: evaluateEvery, rules: [] };
return ( return (
<div> <div>
<FolderAndGroup initialFolder={initialFolder} /> <FolderAndGroup initialFolder={initialFolder} />
{isEditingGroup && ( {folderName && isEditingGroup && (
<EditCloudGroupModal <EditCloudGroupModal
groupInterval={evaluateEvery} namespace={existingNamespace ?? emptyNamespace}
nameSpaceAndGroup={{ namespace: folder?.title ?? '', group: group }} group={existingGroup ?? emptyGroup}
sourceName={GRAFANA_RULES_SOURCE_NAME}
onClose={() => closeEditGroupModal()} onClose={() => closeEditGroupModal()}
folderAndGroupReadOnly intervalEditOnly
/> />
)} )}
{folder && group && ( {folderName && groupName && (
<div className={styles.evaluationContainer}> <div className={styles.evaluationContainer}>
<Stack direction="column" gap={0}> <Stack direction="column" gap={0}>
<div className={styles.marginTop}> <div className={styles.marginTop}>
{isNewGroup && group ? ( {isNewGroup && groupName ? (
<EvaluateEveryNewGroup rules={groupfoldersForGrafana?.result} /> <EvaluateEveryNewGroup rules={groupfoldersForGrafana?.result} />
) : ( ) : (
<Stack direction="column" gap={1}> <Stack direction="column" gap={1}>
<div className={styles.evaluateLabel}> <div className={styles.evaluateLabel}>
{`Alert rules in the `} <span className={styles.bold}>{group}</span> group are evaluated every{' '} {`Alert rules in the `} <span className={styles.bold}>{groupName}</span> group are evaluated every{' '}
<span className={styles.bold}>{evaluateEvery}</span>. <span className={styles.bold}>{evaluateEvery}</span>.
</div> </div>
{!isNewGroup && ( {!isNewGroup && (
@ -355,7 +353,6 @@ const getStyles = (theme: GrafanaTheme2) => ({
margin: ${theme.spacing(2, 0, 2, -1)}; margin: ${theme.spacing(2, 0, 2, -1)};
`, `,
evaluateLabel: css` evaluateLabel: css`
align-self: left;
margin-right: ${theme.spacing(1)}; margin-right: ${theme.spacing(1)};
`, `,
evaluationContainer: css` evaluationContainer: css`

View File

@ -1,151 +1,192 @@
import { render, screen } from '@testing-library/react'; import { render } from '@testing-library/react';
import React from 'react'; import React from 'react';
import { Provider } from 'react-redux'; import { Provider } from 'react-redux';
import { byLabelText, byTestId, byText, byTitle } from 'testing-library-selector';
import { CombinedRuleGroup, CombinedRuleNamespace } from 'app/types/unified-alerting'; import { CombinedRuleNamespace } from 'app/types/unified-alerting';
import { RulerRulesConfigDTO } from 'app/types/unified-alerting-dto';
import { import {
mockCombinedRule, mockCombinedRule,
mockCombinedRuleNamespace,
mockDataSource, mockDataSource,
mockPromAlertingRule, mockPromAlertingRule,
mockPromRecordingRule,
mockRulerAlertingRule, mockRulerAlertingRule,
mockRulerRecordingRule, mockRulerRecordingRule,
mockRulerRuleGroup, mockRulerRuleGroup,
mockStore, mockStore,
someRulerRules,
} from '../../mocks'; } from '../../mocks';
import { GRAFANA_DATASOURCE_NAME } from '../../utils/datasource'; import { GRAFANA_RULES_SOURCE_NAME } from '../../utils/datasource';
import { CombinedGroupAndNameSpace, EditCloudGroupModal, ModalProps } from './EditRuleGroupModal'; import { EditCloudGroupModal } from './EditRuleGroupModal';
const dsSettings = mockDataSource({ const ui = {
name: 'Prometheus-1', input: {
uid: 'Prometheus-1', namespace: byLabelText(/^Folder|^Namespace/, { exact: true }),
}); group: byLabelText(/Evaluation group/),
interval: byLabelText(/Rule group evaluation interval/),
export const someCloudRulerRules: RulerRulesConfigDTO = { },
namespace1: [ folderLink: byTitle(/Go to folder/), // <a> without a href has the generic role
mockRulerRuleGroup({ table: byTestId('dynamic-table'),
name: 'group1', tableRows: byTestId('row'),
rules: [ noRulesText: byText('This group does not contain alert rules.'),
mockRulerRecordingRule({
record: 'instance:node_num_cpu:sum',
expr: 'count without (cpu) (count without (mode) (node_cpu_seconds_total{job="integrations/node_exporter"}))',
labels: { type: 'cpu' },
}),
mockRulerAlertingRule({ alert: 'nonRecordingRule' }),
],
}),
],
}; };
mockRulerRuleGroup({
export const onlyRecordingRulerRules: RulerRulesConfigDTO = {
namespace1: [
mockRulerRuleGroup({
name: 'group1',
rules: [
mockRulerRecordingRule({
record: 'instance:node_num_cpu:sum',
expr: 'count without (cpu) (count without (mode) (node_cpu_seconds_total{job="integrations/node_exporter"}))',
labels: { type: 'cpu' },
}),
],
}),
],
};
const grafanaNamespace: CombinedRuleNamespace = {
name: 'namespace1',
rulesSource: dsSettings,
groups: [
{
name: 'group1',
rules: [
mockCombinedRule({
namespace: {
groups: [],
name: 'namespace1',
rulesSource: mockDataSource(),
},
promRule: mockPromAlertingRule(),
rulerRule: mockRulerAlertingRule(),
}),
],
},
],
};
const group1: CombinedRuleGroup = {
name: 'group1', name: 'group1',
rules: [ rules: [
mockCombinedRule({ mockRulerRecordingRule({
namespace: { record: 'instance:node_num_cpu:sum',
groups: [], expr: 'count without (cpu) (count without (mode) (node_cpu_seconds_total{job="integrations/node_exporter"}))',
name: 'namespace1', labels: { type: 'cpu' },
rulesSource: mockDataSource({ name: 'Prometheus-1' }),
},
promRule: mockPromAlertingRule({ name: 'nonRecordingRule' }),
rulerRule: mockRulerAlertingRule({ alert: 'recordingRule' }),
}), }),
mockRulerAlertingRule({ alert: 'nonRecordingRule' }),
], ],
}; });
const nameSpaceAndGroup: CombinedGroupAndNameSpace = {
namespace: grafanaNamespace,
group: group1,
};
const defaultProps: ModalProps = {
nameSpaceAndGroup: nameSpaceAndGroup,
sourceName: 'Prometheus-1',
groupInterval: '1m',
onClose: jest.fn(),
};
jest.mock('app/types', () => ({ jest.mock('app/types', () => ({
...jest.requireActual('app/types'), ...jest.requireActual('app/types'),
useDispatch: () => jest.fn(), useDispatch: () => jest.fn(),
})); }));
function getProvidersWrapper(cloudRules?: RulerRulesConfigDTO) { function getProvidersWrapper() {
return function Wrapper({ children }: React.PropsWithChildren<{}>) { return function Wrapper({ children }: React.PropsWithChildren<{}>) {
const store = mockStore((store) => { const store = mockStore(() => null);
store.unifiedAlerting.rulerRules[GRAFANA_DATASOURCE_NAME] = {
loading: false,
dispatched: true,
result: someRulerRules,
};
store.unifiedAlerting.rulerRules['Prometheus-1'] = {
loading: false,
dispatched: true,
result: cloudRules ?? someCloudRulerRules,
};
});
return <Provider store={store}>{children}</Provider>; return <Provider store={store}>{children}</Provider>;
}; };
} }
describe('EditGroupModal', () => {
it('Should disable all inputs but interval when intervalEditOnly is set', () => {
const namespace = mockCombinedRuleNamespace({
name: 'my-alerts',
rulesSource: mockDataSource(),
groups: [{ name: 'default-group', interval: '90s', rules: [] }],
});
const group = namespace.groups[0];
render(<EditCloudGroupModal namespace={namespace} group={group} intervalEditOnly onClose={() => jest.fn()} />, {
wrapper: getProvidersWrapper(),
});
expect(ui.input.namespace.get()).toHaveAttribute('readonly');
expect(ui.input.group.get()).toHaveAttribute('readonly');
expect(ui.input.interval.get()).not.toHaveAttribute('readonly');
});
});
describe('EditGroupModal component on cloud alert rules', () => { describe('EditGroupModal component on cloud alert rules', () => {
const promDsSettings = mockDataSource({ name: 'Prometheus-1', uid: 'Prometheus-1' });
const alertingRule = mockCombinedRule({
namespace: undefined,
promRule: mockPromAlertingRule({ name: 'alerting-rule-cpu' }),
rulerRule: mockRulerAlertingRule({ alert: 'alerting-rule-cpu' }),
});
const recordingRule1 = mockCombinedRule({
namespace: undefined,
promRule: mockPromRecordingRule({ name: 'recording-rule-memory' }),
rulerRule: mockRulerRecordingRule({ record: 'recording-rule-memory' }),
});
const recordingRule2 = mockCombinedRule({
namespace: undefined,
promRule: mockPromRecordingRule({ name: 'recording-rule-cpu' }),
rulerRule: mockRulerRecordingRule({ record: 'recording-rule-cpu' }),
});
it('Should show alert table in case of having some non-recording rules in the group', () => { it('Should show alert table in case of having some non-recording rules in the group', () => {
render(<EditCloudGroupModal {...defaultProps} />, { const promNs = mockCombinedRuleNamespace({
name: 'prometheus-ns',
rulesSource: promDsSettings,
groups: [{ name: 'default-group', interval: '90s', rules: [alertingRule, recordingRule1, recordingRule2] }],
});
const group = promNs.groups[0];
render(<EditCloudGroupModal namespace={promNs} group={group} onClose={() => jest.fn()} />, {
wrapper: getProvidersWrapper(), wrapper: getProvidersWrapper(),
}); });
expect(screen.getByText(/nonRecordingRule/i)).toBeInTheDocument();
expect(ui.input.namespace.get()).toHaveValue('prometheus-ns');
expect(ui.input.namespace.get()).not.toHaveAttribute('readonly');
expect(ui.input.group.get()).toHaveValue('default-group');
expect(ui.tableRows.getAll()).toHaveLength(1); // Only one rule is non-recording
expect(ui.tableRows.getAll()[0]).toHaveTextContent('alerting-rule-cpu');
}); });
it('Should not show alert table in case of not having some non-recording rules in the group', () => {
render(<EditCloudGroupModal {...defaultProps} />, { it('Should not show alert table in case of having exclusively recording rules in the group', () => {
wrapper: getProvidersWrapper(onlyRecordingRulerRules), const promNs = mockCombinedRuleNamespace({
name: 'prometheus-ns',
rulesSource: promDsSettings,
groups: [{ name: 'default-group', interval: '90s', rules: [recordingRule1, recordingRule2] }],
}); });
expect(screen.queryByText(/nonRecordingRule/i)).not.toBeInTheDocument();
expect(screen.getByText(/this group does not contain alert rules\./i)); const group = promNs.groups[0];
render(<EditCloudGroupModal namespace={promNs} group={group} onClose={jest.fn()} />, {
wrapper: getProvidersWrapper(),
});
expect(ui.table.query()).not.toBeInTheDocument();
expect(ui.noRulesText.get()).toBeInTheDocument();
}); });
}); });
describe('EditGroupModal component on grafana-managed alert rules', () => { describe('EditGroupModal component on grafana-managed alert rules', () => {
const grafanaNamespace: CombinedRuleNamespace = {
name: 'namespace1',
rulesSource: GRAFANA_RULES_SOURCE_NAME,
groups: [
{
name: 'grafanaGroup1',
interval: '30s',
rules: [
mockCombinedRule({
namespace: undefined,
promRule: mockPromAlertingRule({ name: 'high-cpu-1' }),
rulerRule: mockRulerAlertingRule({ alert: 'high-cpu-1' }),
}),
mockCombinedRule({
namespace: undefined,
promRule: mockPromAlertingRule({ name: 'high-memory' }),
rulerRule: mockRulerAlertingRule({ alert: 'high-memory' }),
}),
],
},
],
};
const grafanaGroup1 = grafanaNamespace.groups[0];
it('Should show alert table', () => { it('Should show alert table', () => {
render(<EditCloudGroupModal {...defaultProps} sourceName={GRAFANA_DATASOURCE_NAME} />, { render(<EditCloudGroupModal namespace={grafanaNamespace} group={grafanaGroup1} onClose={jest.fn()} />, {
wrapper: getProvidersWrapper(), wrapper: getProvidersWrapper(),
}); });
expect(screen.getByText(/alert1/i)).toBeInTheDocument();
expect(ui.input.namespace.get()).toHaveValue('namespace1');
expect(ui.input.group.get()).toHaveValue('grafanaGroup1');
expect(ui.input.interval.get()).toHaveValue('30s');
expect(ui.tableRows.getAll()).toHaveLength(2);
expect(ui.tableRows.getAll()[0]).toHaveTextContent('high-cpu-1');
expect(ui.tableRows.getAll()[1]).toHaveTextContent('high-memory');
});
it('Should have folder input in readonly mode', () => {
render(<EditCloudGroupModal namespace={grafanaNamespace} group={grafanaGroup1} onClose={jest.fn()} />, {
wrapper: getProvidersWrapper(),
});
expect(ui.input.namespace.get()).toHaveAttribute('readonly');
});
it('Should not display folder link if no folderUrl provided', () => {
render(<EditCloudGroupModal namespace={grafanaNamespace} group={grafanaGroup1} onClose={jest.fn()} />, {
wrapper: getProvidersWrapper(),
});
expect(ui.folderLink.query()).not.toBeInTheDocument();
}); });
}); });

View File

@ -1,10 +1,11 @@
import { css } from '@emotion/css'; import { css } from '@emotion/css';
import { compact } from 'lodash';
import React, { useEffect, useMemo } from 'react'; import React, { useEffect, useMemo } from 'react';
import { FormProvider, RegisterOptions, useForm, useFormContext } from 'react-hook-form'; import { FormProvider, RegisterOptions, useForm, useFormContext } from 'react-hook-form';
import { GrafanaTheme2 } from '@grafana/data'; import { GrafanaTheme2 } from '@grafana/data';
import { Stack } from '@grafana/experimental'; import { Stack } from '@grafana/experimental';
import { Badge, Button, Field, Input, Label, Modal, useStyles2 } from '@grafana/ui'; import { Badge, Button, Field, Input, Label, LinkButton, Modal, useStyles2 } from '@grafana/ui';
import { useAppNotification } from 'app/core/copy/appNotification'; import { useAppNotification } from 'app/core/copy/appNotification';
import { useCleanup } from 'app/core/hooks/useCleanup'; import { useCleanup } from 'app/core/hooks/useCleanup';
import { useDispatch } from 'app/types'; import { useDispatch } from 'app/types';
@ -14,7 +15,7 @@ import { RulerRuleDTO, RulerRuleGroupDTO, RulerRulesConfigDTO } from 'app/types/
import { useUnifiedAlertingSelector } from '../../hooks/useUnifiedAlertingSelector'; import { useUnifiedAlertingSelector } from '../../hooks/useUnifiedAlertingSelector';
import { rulesInSameGroupHaveInvalidFor, updateLotexNamespaceAndGroupAction } from '../../state/actions'; import { rulesInSameGroupHaveInvalidFor, updateLotexNamespaceAndGroupAction } from '../../state/actions';
import { checkEvaluationIntervalGlobalLimit } from '../../utils/config'; import { checkEvaluationIntervalGlobalLimit } from '../../utils/config';
import { GRAFANA_RULES_SOURCE_NAME } from '../../utils/datasource'; import { getRulesSourceName, GRAFANA_RULES_SOURCE_NAME } from '../../utils/datasource';
import { initialAsyncRequestState } from '../../utils/redux'; import { initialAsyncRequestState } from '../../utils/redux';
import { isAlertingRulerRule, isGrafanaRulerRule, isRecordingRulerRule } from '../../utils/rules'; import { isAlertingRulerRule, isGrafanaRulerRule, isRecordingRulerRule } from '../../utils/rules';
import { parsePrometheusDuration } from '../../utils/time'; import { parsePrometheusDuration } from '../../utils/time';
@ -23,13 +24,14 @@ import { InfoIcon } from '../InfoIcon';
import { EvaluationIntervalLimitExceeded } from '../InvalidIntervalWarning'; import { EvaluationIntervalLimitExceeded } from '../InvalidIntervalWarning';
import { MIN_TIME_RANGE_STEP_S } from '../rule-editor/GrafanaEvaluationBehavior'; import { MIN_TIME_RANGE_STEP_S } from '../rule-editor/GrafanaEvaluationBehavior';
const MINUTE = '1m';
const ITEMS_PER_PAGE = 10; const ITEMS_PER_PAGE = 10;
interface AlertInfo { interface AlertInfo {
alertName: string; alertName: string;
forDuration: string; forDuration: string;
evaluationsToFire: number; evaluationsToFire: number;
} }
function ForBadge({ message, error }: { message: string; error?: boolean }) { function ForBadge({ message, error }: { message: string; error?: boolean }) {
if (error) { if (error) {
return <Badge color="red" icon="exclamation-circle" text={'Error'} tooltip={message} />; return <Badge color="red" icon="exclamation-circle" text={'Error'} tooltip={message} />;
@ -100,17 +102,6 @@ export const getGroupFromRuler = (
const folderObj: Array<RulerRuleGroupDTO<RulerRuleDTO>> = rulerRules ? rulerRules[folderName] : []; const folderObj: Array<RulerRuleGroupDTO<RulerRuleDTO>> = rulerRules ? rulerRules[folderName] : [];
return folderObj?.find((rulerRuleGroup) => rulerRuleGroup.name === groupName); return folderObj?.find((rulerRuleGroup) => rulerRuleGroup.name === groupName);
}; };
export const getIntervalForGroup = (
rulerRules: RulerRulesConfigDTO | null | undefined,
groupName: string,
folderName: string
) => {
const group = getGroupFromRuler(rulerRules, groupName, folderName);
const interval = group?.interval ?? MINUTE;
return interval;
};
export const safeParseDurationstr = (duration: string): number => { export const safeParseDurationstr = (duration: string): number => {
try { try {
return parsePrometheusDuration(duration); return parsePrometheusDuration(duration);
@ -188,40 +179,20 @@ export const RulesForGroupTable = ({ rulesWithoutRecordingRules }: { rulesWithou
); );
}; };
export interface CombinedGroupAndNameSpace {
namespace: CombinedRuleNamespace;
group: CombinedRuleGroup;
}
interface GroupAndNameSpaceNames {
namespace: string;
group: string;
}
export interface ModalProps {
nameSpaceAndGroup: CombinedGroupAndNameSpace | GroupAndNameSpaceNames;
sourceName: string;
groupInterval: string;
onClose: (saved?: boolean) => void;
folderAndGroupReadOnly?: boolean;
}
interface FormValues { interface FormValues {
namespaceName: string; namespaceName: string;
groupName: string; groupName: string;
groupInterval: string; groupInterval: string;
} }
export const evaluateEveryValidationOptions = ( export const evaluateEveryValidationOptions = (rules: RulerRuleDTO[]): RegisterOptions => ({
rules: RulerRulesConfigDTO | null | undefined,
groupName: string,
nameSpaceName: string
): RegisterOptions => ({
required: { required: {
value: true, value: true,
message: 'Required.', message: 'Required.',
}, },
validate: (value: string) => { validate: (evaluateEvery: string) => {
try { try {
const duration = parsePrometheusDuration(value); const duration = parsePrometheusDuration(evaluateEvery);
if (duration < MIN_TIME_RANGE_STEP_S * 1000) { if (duration < MIN_TIME_RANGE_STEP_S * 1000) {
return `Cannot be less than ${MIN_TIME_RANGE_STEP_S} seconds.`; return `Cannot be less than ${MIN_TIME_RANGE_STEP_S} seconds.`;
@ -230,7 +201,7 @@ export const evaluateEveryValidationOptions = (
if (duration % (MIN_TIME_RANGE_STEP_S * 1000) !== 0) { if (duration % (MIN_TIME_RANGE_STEP_S * 1000) !== 0) {
return `Must be a multiple of ${MIN_TIME_RANGE_STEP_S} seconds.`; return `Must be a multiple of ${MIN_TIME_RANGE_STEP_S} seconds.`;
} }
if (rulesInSameGroupHaveInvalidFor(rules, groupName, nameSpaceName, value).length === 0) { if (rulesInSameGroupHaveInvalidFor(rules, evaluateEvery).length === 0) {
return true; return true;
} else { } else {
return `Invalid evaluation interval. Evaluation interval should be smaller or equal to 'For' values for existing rules in this group.`; return `Invalid evaluation interval. Evaluation interval should be smaller or equal to 'For' values for existing rules in this group.`;
@ -241,43 +212,37 @@ export const evaluateEveryValidationOptions = (
}, },
}); });
export interface ModalProps {
namespace: CombinedRuleNamespace;
group: CombinedRuleGroup;
onClose: (saved?: boolean) => void;
intervalEditOnly?: boolean;
folderUrl?: string;
}
export function EditCloudGroupModal(props: ModalProps): React.ReactElement { export function EditCloudGroupModal(props: ModalProps): React.ReactElement {
const { const { namespace, group, onClose, intervalEditOnly } = props;
nameSpaceAndGroup: { namespace, group },
onClose,
groupInterval,
sourceName,
folderAndGroupReadOnly,
} = props;
const styles = useStyles2(getStyles); const styles = useStyles2(getStyles);
const dispatch = useDispatch(); const dispatch = useDispatch();
const { loading, error, dispatched } = const { loading, error, dispatched } =
useUnifiedAlertingSelector((state) => state.updateLotexNamespaceAndGroup) ?? initialAsyncRequestState; useUnifiedAlertingSelector((state) => state.updateLotexNamespaceAndGroup) ?? initialAsyncRequestState;
const notifyApp = useAppNotification(); const notifyApp = useAppNotification();
const nameSpaceName = typeof namespace === 'string' ? namespace : namespace.name;
const groupName = typeof group === 'string' ? group : group.name;
const defaultValues = useMemo( const defaultValues = useMemo(
(): FormValues => ({ (): FormValues => ({
namespaceName: nameSpaceName, namespaceName: namespace.name,
groupName: groupName, groupName: group.name,
groupInterval: groupInterval ?? '', groupInterval: group.interval ?? '',
}), }),
[nameSpaceName, groupName, groupInterval] [namespace, group]
); );
const isGrafanaManagedGroup = sourceName === GRAFANA_RULES_SOURCE_NAME; const rulesSourceName = getRulesSourceName(namespace.rulesSource);
const nameSpaceLabel = isGrafanaManagedGroup ? 'Folder' : 'Namespace'; const isGrafanaManagedGroup = rulesSourceName === GRAFANA_RULES_SOURCE_NAME;
const nameSpaceInfoIconLabelEditable = isGrafanaManagedGroup
? 'Folder name can be updated to a non-existing folder name' const nameSpaceLabel = isGrafanaManagedGroup ? 'Folder' : 'Namespace';
: 'Name space can be updated to a non-existing name space';
const nameSpaceInfoIconLabelNonEditable = isGrafanaManagedGroup
? 'Folder name can be updated in folder view'
: 'Name space can be updated folder view';
const spaceNameInfoIconLabel = folderAndGroupReadOnly
? nameSpaceInfoIconLabelNonEditable
: nameSpaceInfoIconLabelEditable;
// close modal if successfully saved // close modal if successfully saved
useEffect(() => { useEffect(() => {
if (dispatched && !loading && !error) { if (dispatched && !loading && !error) {
@ -289,10 +254,10 @@ export function EditCloudGroupModal(props: ModalProps): React.ReactElement {
const onSubmit = (values: FormValues) => { const onSubmit = (values: FormValues) => {
dispatch( dispatch(
updateLotexNamespaceAndGroupAction({ updateLotexNamespaceAndGroupAction({
rulesSourceName: sourceName, rulesSourceName: rulesSourceName,
groupName: groupName, groupName: group.name,
newGroupName: values.groupName, newGroupName: values.groupName,
namespaceName: nameSpaceName, namespaceName: namespace.name,
newNamespaceName: values.namespaceName, newNamespaceName: values.namespaceName,
groupInterval: values.groupInterval || undefined, groupInterval: values.groupInterval || undefined,
}) })
@ -315,56 +280,60 @@ export function EditCloudGroupModal(props: ModalProps): React.ReactElement {
notifyApp.error('There are errors in the form. Correct the errors and retry.'); notifyApp.error('There are errors in the form. Correct the errors and retry.');
}; };
const rulerRuleRequests = useUnifiedAlertingSelector((state) => state.rulerRules); const rulesWithoutRecordingRules = compact(
const groupfoldersForSource = rulerRuleRequests[sourceName]; group.rules.map((r) => r.rulerRule).filter((rule) => !isRecordingRulerRule(rule))
);
const groupWithRules = getGroupFromRuler(groupfoldersForSource?.result, groupName, nameSpaceName);
const rulesWithoutRecordingRules: RulerRuleDTO[] =
groupWithRules?.rules.filter((rule: RulerRuleDTO) => !isRecordingRulerRule(rule)) ?? [];
const hasSomeNoRecordingRules = rulesWithoutRecordingRules.length > 0; const hasSomeNoRecordingRules = rulesWithoutRecordingRules.length > 0;
const modalTitle =
intervalEditOnly || isGrafanaManagedGroup ? 'Edit evaluation group' : 'Edit namespace or evaluation group';
return ( return (
<Modal <Modal className={styles.modal} isOpen={true} title={modalTitle} onDismiss={onClose} onClickBackdrop={onClose}>
className={styles.modal}
isOpen={true}
title={folderAndGroupReadOnly ? 'Edit evaluation group' : `Edit ${nameSpaceLabel} or evaluation group`}
onDismiss={onClose}
onClickBackdrop={onClose}
>
<FormProvider {...formAPI}> <FormProvider {...formAPI}>
<form onSubmit={(e) => e.preventDefault()} key={JSON.stringify(defaultValues)}> <form onSubmit={(e) => e.preventDefault()} key={JSON.stringify(defaultValues)}>
<> <>
<Field <Field
label={ label={
<Label htmlFor="namespaceName"> <Label
<Stack gap={0.5}> htmlFor="namespaceName"
{nameSpaceLabel} description={
<InfoIcon text={spaceNameInfoIconLabel} /> !isGrafanaManagedGroup &&
</Stack> 'Change the current namespace name. Moving groups between namespaces is not supported'
}
>
{nameSpaceLabel}
</Label> </Label>
} }
invalid={!!errors.namespaceName} invalid={!!errors.namespaceName}
error={errors.namespaceName?.message} error={errors.namespaceName?.message}
> >
<Input <Stack gap={1} direction="row">
id="namespaceName" <Input
readOnly={folderAndGroupReadOnly} id="namespaceName"
{...register('namespaceName', { readOnly={intervalEditOnly || isGrafanaManagedGroup}
required: 'Namespace name is required.', {...register('namespaceName', {
})} required: 'Namespace name is required.',
/> })}
className={styles.formInput}
/>
{isGrafanaManagedGroup && props.folderUrl && (
<LinkButton
href={props.folderUrl}
title="Go to folder"
variant="secondary"
icon="folder-open"
target="_blank"
/>
)}
</Stack>
</Field> </Field>
<Field <Field
label={ label={
<Label htmlFor="groupName"> <Label
<Stack gap={0.5}> htmlFor="groupName"
Evaluation group description={`Evaluation group name needs to be unique within a ${nameSpaceLabel.toLocaleLowerCase()}`}
{isGrafanaManagedGroup ? ( >
<InfoIcon text={'Group name can be updated on Group view.'} /> Evaluation group name
) : (
<InfoIcon text={'Group name can be updated to a non existing group name.'} />
)}
</Stack>
</Label> </Label>
} }
invalid={!!errors.groupName} invalid={!!errors.groupName}
@ -372,7 +341,7 @@ export function EditCloudGroupModal(props: ModalProps): React.ReactElement {
> >
<Input <Input
id="groupName" id="groupName"
readOnly={folderAndGroupReadOnly} readOnly={intervalEditOnly}
{...register('groupName', { {...register('groupName', {
required: 'Evaluation group name is required.', required: 'Evaluation group name is required.',
})} })}
@ -396,10 +365,7 @@ export function EditCloudGroupModal(props: ModalProps): React.ReactElement {
<Input <Input
id="groupInterval" id="groupInterval"
placeholder="1m" placeholder="1m"
{...register( {...register('groupInterval', evaluateEveryValidationOptions(rulesWithoutRecordingRules))}
'groupInterval',
evaluateEveryValidationOptions(groupfoldersForSource?.result, groupName, nameSpaceName)
)}
/> />
</Field> </Field>
@ -426,8 +392,8 @@ export function EditCloudGroupModal(props: ModalProps): React.ReactElement {
</Button> </Button>
</Modal.ButtonRow> </Modal.ButtonRow>
</div> </div>
{rulerRuleRequests && !hasSomeNoRecordingRules && <div>This group does not contain alert rules.</div>} {!hasSomeNoRecordingRules && <div>This group does not contain alert rules.</div>}
{rulerRuleRequests && hasSomeNoRecordingRules && ( {hasSomeNoRecordingRules && (
<> <>
<div>List of rules that belong to this group</div> <div>List of rules that belong to this group</div>
<div className={styles.evalRequiredLabel}> <div className={styles.evalRequiredLabel}>
@ -452,10 +418,7 @@ const getStyles = (theme: GrafanaTheme2) => ({
position: relative; position: relative;
`, `,
formInput: css` formInput: css`
width: 275px; flex: 1;
& + & {
margin-left: ${theme.spacing(3)};
}
`, `,
tableWrapper: css` tableWrapper: css`
margin-top: ${theme.spacing(2)}; margin-top: ${theme.spacing(2)};

View File

@ -14,8 +14,8 @@ import { useFolder } from '../../hooks/useFolder';
import { useHasRuler } from '../../hooks/useHasRuler'; import { useHasRuler } from '../../hooks/useHasRuler';
import { deleteRulesGroupAction } from '../../state/actions'; import { deleteRulesGroupAction } from '../../state/actions';
import { useRulesAccess } from '../../utils/accessControlHooks'; import { useRulesAccess } from '../../utils/accessControlHooks';
import { getRulesSourceName, GRAFANA_RULES_SOURCE_NAME, isCloudRulesSource } from '../../utils/datasource'; import { GRAFANA_RULES_SOURCE_NAME, isCloudRulesSource } from '../../utils/datasource';
import { makeFolderLink } from '../../utils/misc'; import { makeFolderLink, makeFolderSettingsLink } from '../../utils/misc';
import { isFederatedRuleGroup, isGrafanaRulerRule } from '../../utils/rules'; import { isFederatedRuleGroup, isGrafanaRulerRule } from '../../utils/rules';
import { CollapseToggle } from '../CollapseToggle'; import { CollapseToggle } from '../CollapseToggle';
import { RuleLocation } from '../RuleLocation'; import { RuleLocation } from '../RuleLocation';
@ -238,10 +238,10 @@ export const RulesGroup: FC<Props> = React.memo(({ group, namespace, expandAll,
)} )}
{isEditingGroup && ( {isEditingGroup && (
<EditCloudGroupModal <EditCloudGroupModal
groupInterval={group.interval ?? ''} namespace={namespace}
nameSpaceAndGroup={{ group: group, namespace: namespace }} group={group}
sourceName={getRulesSourceName(namespace.rulesSource)}
onClose={() => closeEditModal()} onClose={() => closeEditModal()}
folderUrl={folder?.canEdit ? makeFolderSettingsLink(folder) : undefined}
/> />
)} )}
{isReorderingGroup && ( {isReorderingGroup && (

View File

@ -61,7 +61,7 @@ import {
FetchRulerRulesFilter, FetchRulerRulesFilter,
setRulerRuleGroup, setRulerRuleGroup,
} from '../api/ruler'; } from '../api/ruler';
import { getAlertInfo, safeParseDurationstr, getGroupFromRuler } from '../components/rules/EditRuleGroupModal'; import { getAlertInfo, safeParseDurationstr } from '../components/rules/EditRuleGroupModal';
import { RuleFormType, RuleFormValues } from '../types/rule-form'; import { RuleFormType, RuleFormValues } from '../types/rule-form';
import { addDefaultsToAlertmanagerConfig, removeMuteTimingFromRoute } from '../utils/alertmanager'; import { addDefaultsToAlertmanagerConfig, removeMuteTimingFromRoute } from '../utils/alertmanager';
import { import {
@ -770,20 +770,12 @@ interface UpdateNamespaceAndGroupOptions {
groupInterval?: string; groupInterval?: string;
} }
export const rulesInSameGroupHaveInvalidFor = ( export const rulesInSameGroupHaveInvalidFor = (rules: RulerRuleDTO[], everyDuration: string) => {
rulerRules: RulerRulesConfigDTO | null | undefined, return rules.filter((rule: RulerRuleDTO) => {
groupName: string,
folderName: string,
everyDuration: string
) => {
const group = getGroupFromRuler(rulerRules, groupName, folderName);
const rulesSameGroup: RulerRuleDTO[] = group?.rules ?? [];
return rulesSameGroup.filter((rule: RulerRuleDTO) => {
const { forDuration } = getAlertInfo(rule, everyDuration); const { forDuration } = getAlertInfo(rule, everyDuration);
const forNumber = safeParseDurationstr(forDuration); const forNumber = safeParseDurationstr(forDuration);
const everyNumber = safeParseDurationstr(everyDuration); const everyNumber = safeParseDurationstr(everyDuration);
return forNumber !== 0 && forNumber < everyNumber; return forNumber !== 0 && forNumber < everyNumber;
}); });
}; };
@ -809,16 +801,20 @@ export const updateLotexNamespaceAndGroupAction: AsyncThunk<
if (!existingNamespace) { if (!existingNamespace) {
throw new Error(`Namespace "${namespaceName}" not found.`); throw new Error(`Namespace "${namespaceName}" not found.`);
} }
const existingGroup = rulesResult[namespaceName].find((group) => group.name === groupName); const existingGroup = rulesResult[namespaceName].find((group) => group.name === groupName);
if (!existingGroup) { if (!existingGroup) {
throw new Error(`Group "${groupName}" not found.`); throw new Error(`Group "${groupName}" not found.`);
} }
const newGroupAlreadyExists = Boolean( const newGroupAlreadyExists = Boolean(
rulesResult[namespaceName].find((group) => group.name === newGroupName) rulesResult[namespaceName].find((group) => group.name === newGroupName)
); );
if (newGroupName !== groupName && newGroupAlreadyExists) { if (newGroupName !== groupName && newGroupAlreadyExists) {
throw new Error(`Group "${newGroupName}" already exists in namespace "${namespaceName}".`); throw new Error(`Group "${newGroupName}" already exists in namespace "${namespaceName}".`);
} }
const newNamespaceAlreadyExists = Boolean(rulesResult[newNamespaceName]); const newNamespaceAlreadyExists = Boolean(rulesResult[newNamespaceName]);
if (newNamespaceName !== namespaceName && newNamespaceAlreadyExists) { if (newNamespaceName !== namespaceName && newNamespaceAlreadyExists) {
throw new Error(`Namespace "${newNamespaceName}" already exists.`); throw new Error(`Namespace "${newNamespaceName}" already exists.`);
@ -830,16 +826,10 @@ export const updateLotexNamespaceAndGroupAction: AsyncThunk<
) { ) {
throw new Error('Nothing changed.'); throw new Error('Nothing changed.');
} }
// validation for new groupInterval // validation for new groupInterval
if (groupInterval !== existingGroup.interval) { if (groupInterval !== existingGroup.interval) {
const storeState = thunkAPI.getState(); const notValidRules = rulesInSameGroupHaveInvalidFor(existingGroup.rules, groupInterval ?? '1m');
const groupfoldersForSource = storeState?.unifiedAlerting.rulerRules[rulesSourceName];
const notValidRules = rulesInSameGroupHaveInvalidFor(
groupfoldersForSource?.result,
groupName,
namespaceName,
groupInterval ?? '1m'
);
if (notValidRules.length > 0) { if (notValidRules.length > 0) {
throw new Error( throw new Error(
`These alerts belonging to this group will have an invalid 'For' value: ${notValidRules `These alerts belonging to this group will have an invalid 'For' value: ${notValidRules

View File

@ -10,6 +10,8 @@ import {
mapStateWithReasonToBaseState, mapStateWithReasonToBaseState,
} from 'app/types/unified-alerting-dto'; } from 'app/types/unified-alerting-dto';
import { FolderDTO } from '../../../../types';
import { ALERTMANAGER_NAME_QUERY_KEY } from './constants'; import { ALERTMANAGER_NAME_QUERY_KEY } from './constants';
import { getRulesSourceName } from './datasource'; import { getRulesSourceName } from './datasource';
import { getMatcherQueryParams } from './matchers'; import { getMatcherQueryParams } from './matchers';
@ -104,6 +106,10 @@ export function makeFolderLink(folderUID: string): string {
return createUrl(`/dashboards/f/${folderUID}`); return createUrl(`/dashboards/f/${folderUID}`);
} }
export function makeFolderSettingsLink(folder: FolderDTO): string {
return createUrl(`/dashboards/f/${folder.uid}/${folder.title}/settings`);
}
// keep retrying fn if it's error passes shouldRetry(error) and timeout has not elapsed yet // keep retrying fn if it's error passes shouldRetry(error) and timeout has not elapsed yet
export function retryWhile<T, E = Error>( export function retryWhile<T, E = Error>(
fn: () => Promise<T>, fn: () => Promise<T>,