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 { render, screen, waitFor } from '@testing-library/react';
import { prettyDOM, render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import React from 'react';
import { TestProvider } from 'test/helpers/TestProvider';
@ -119,8 +119,9 @@ const ui = {
newRuleButton: byRole('link', { name: 'Create alert rule' }),
exportButton: byRole('button', { name: /export/i }),
editGroupModal: {
namespaceInput: byRole('textbox', { hidden: true, name: /namespace/i }),
ruleGroupInput: byRole('textbox', { name: 'Evaluation group', exact: true }),
dialog: byRole('dialog'),
namespaceInput: byRole('textbox', { name: /^Namespace/ }),
ruleGroupInput: byRole('textbox', { name: /Evaluation group/ }),
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,
}),
@ -556,17 +557,19 @@ describe('RuleList', () => {
expect(await ui.rulesFilterInput.find()).toHaveValue('');
await waitFor(() => expect(ui.ruleGroup.queryAll()).toHaveLength(3));
const groups = await ui.ruleGroup.findAll();
expect(groups).toHaveLength(3);
// open edit dialog
await userEvent.click(ui.editCloudGroupIcon.get(groups[0]));
await expect(screen.getByRole('textbox', { hidden: true, name: /namespace/i })).toHaveDisplayValue(
'namespace1'
);
await expect(screen.getByRole('textbox', { name: 'Evaluation group', exact: true })).toHaveDisplayValue(
'group1'
);
await waitFor(() => expect(ui.editGroupModal.dialog.get()).toBeInTheDocument());
prettyDOM(ui.editGroupModal.dialog.get());
expect(ui.editGroupModal.namespaceInput.get()).toHaveDisplayValue('namespace1');
expect(ui.editGroupModal.ruleGroupInput.get()).toHaveDisplayValue('group1');
await fn();
});
}
@ -614,8 +617,8 @@ describe('RuleList', () => {
testCase('rename just the lotex group', async () => {
// make changes to form
await userEvent.clear(screen.getByRole('textbox', { name: 'Evaluation group', exact: true }));
await userEvent.type(screen.getByRole('textbox', { name: 'Evaluation group', exact: true }), 'super group');
await userEvent.clear(ui.editGroupModal.ruleGroupInput.get());
await userEvent.type(ui.editGroupModal.ruleGroupInput.get(), 'super group');
await userEvent.type(
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,

View File

@ -10,8 +10,8 @@ import { FolderPickerFilter } from 'app/core/components/Select/FolderPicker';
import { contextSrv } from 'app/core/core';
import { DashboardSearchHit } from 'app/features/search/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 { fetchRulerRulesIfNotFetchedYet } from '../../state/actions';
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 { InfoIcon } from '../InfoIcon';
import { getIntervalForGroup } from './GrafanaEvaluationBehavior';
import { MINUTE } from './AlertRuleForm';
import { containsSlashes, Folder, RuleFolderPicker } from './RuleFolderPicker';
import { checkForPathSeparator } from './util';
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 {
initialFolder: RuleForm | null;
}
export const useGetGroupOptionsFromFolder = (folderTitle: string) => {
const rulerRuleRequests = useUnifiedAlertingSelector((state) => state.rulerRules);
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 };
};

View File

@ -5,9 +5,11 @@ import { RegisterOptions, useFormContext } from 'react-hook-form';
import { GrafanaTheme2, SelectableValue } from '@grafana/data';
import { Stack } from '@grafana/experimental';
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 { useCombinedRuleNamespaces } from '../../hooks/useCombinedRuleNamespaces';
import { useUnifiedAlertingSelector } from '../../hooks/useUnifiedAlertingSelector';
import { RuleForm, RuleFormValues } from '../../types/rule-form';
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 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 => ({
required: {
value: true,
@ -87,6 +77,10 @@ export const EvaluateEveryNewGroup = ({ rules }: { rules: RulerRulesConfigDTO |
} = useFormContext<RuleFormValues>();
const styles = useStyles2(getStyles);
const evaluateEveryId = 'eval-every-input';
const [groupName, folderName] = watch(['group', 'folder.title']);
const groupRules = (rules && rules[folderName]?.find((g) => g.name === groupName)?.rules) ?? [];
return (
<Field
label="Evaluation interval"
@ -110,10 +104,7 @@ export const EvaluateEveryNewGroup = ({ rules }: { rules: RulerRulesConfigDTO |
<Input
id={evaluateEveryId}
width={8}
{...register(
'evaluateEvery',
evaluateEveryValidationOptions(rules, watch('group'), watch('folder.title'))
)}
{...register('evaluateEvery', evaluateEveryValidationOptions(groupRules))}
/>
</Field>
</Stack>
@ -135,24 +126,25 @@ function FolderGroupAndEvaluationInterval({
const { watch, setValue } = useFormContext<RuleFormValues>();
const [isEditingGroup, setIsEditingGroup] = useState(false);
const group = watch('group');
const folder = watch('folder');
const [groupName, folderName] = watch(['group', 'folder.title']);
const rulerRuleRequests = useUnifiedAlertingSelector((state) => state.rulerRules);
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(() => {
if (!isNewGroup) {
group &&
folder &&
setEvaluateEvery(getIntervalForGroup(groupfoldersForGrafana?.result, group, folder?.title ?? ''));
if (!isNewGroup && existingGroup?.interval) {
setEvaluateEvery(existingGroup.interval);
} else {
setEvaluateEvery(MINUTE);
setValue('evaluateEvery', MINUTE);
}
}, [group, folder, groupfoldersForGrafana?.result, setEvaluateEvery, isNewGroup, setValue]);
}, [setEvaluateEvery, isNewGroup, setValue, existingGroup]);
const closeEditGroupModal = (saved = false) => {
if (!saved) {
@ -163,30 +155,36 @@ function FolderGroupAndEvaluationInterval({
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 (
<div>
<FolderAndGroup initialFolder={initialFolder} />
{isEditingGroup && (
{folderName && isEditingGroup && (
<EditCloudGroupModal
groupInterval={evaluateEvery}
nameSpaceAndGroup={{ namespace: folder?.title ?? '', group: group }}
sourceName={GRAFANA_RULES_SOURCE_NAME}
namespace={existingNamespace ?? emptyNamespace}
group={existingGroup ?? emptyGroup}
onClose={() => closeEditGroupModal()}
folderAndGroupReadOnly
intervalEditOnly
/>
)}
{folder && group && (
{folderName && groupName && (
<div className={styles.evaluationContainer}>
<Stack direction="column" gap={0}>
<div className={styles.marginTop}>
{isNewGroup && group ? (
{isNewGroup && groupName ? (
<EvaluateEveryNewGroup rules={groupfoldersForGrafana?.result} />
) : (
<Stack direction="column" gap={1}>
<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>.
</div>
{!isNewGroup && (
@ -355,7 +353,6 @@ const getStyles = (theme: GrafanaTheme2) => ({
margin: ${theme.spacing(2, 0, 2, -1)};
`,
evaluateLabel: css`
align-self: left;
margin-right: ${theme.spacing(1)};
`,
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 { Provider } from 'react-redux';
import { byLabelText, byTestId, byText, byTitle } from 'testing-library-selector';
import { CombinedRuleGroup, CombinedRuleNamespace } from 'app/types/unified-alerting';
import { RulerRulesConfigDTO } from 'app/types/unified-alerting-dto';
import { CombinedRuleNamespace } from 'app/types/unified-alerting';
import {
mockCombinedRule,
mockCombinedRuleNamespace,
mockDataSource,
mockPromAlertingRule,
mockPromRecordingRule,
mockRulerAlertingRule,
mockRulerRecordingRule,
mockRulerRuleGroup,
mockStore,
someRulerRules,
} 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({
name: 'Prometheus-1',
uid: 'Prometheus-1',
});
export const someCloudRulerRules: 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' },
}),
mockRulerAlertingRule({ alert: 'nonRecordingRule' }),
],
}),
],
const ui = {
input: {
namespace: byLabelText(/^Folder|^Namespace/, { exact: true }),
group: byLabelText(/Evaluation group/),
interval: byLabelText(/Rule group evaluation interval/),
},
folderLink: byTitle(/Go to folder/), // <a> without a href has the generic role
table: byTestId('dynamic-table'),
tableRows: byTestId('row'),
noRulesText: byText('This group does not contain alert rules.'),
};
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 = {
mockRulerRuleGroup({
name: 'group1',
rules: [
mockCombinedRule({
namespace: {
groups: [],
name: 'namespace1',
rulesSource: mockDataSource({ name: 'Prometheus-1' }),
},
promRule: mockPromAlertingRule({ name: 'nonRecordingRule' }),
rulerRule: mockRulerAlertingRule({ alert: 'recordingRule' }),
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' }),
],
};
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.requireActual('app/types'),
useDispatch: () => jest.fn(),
}));
function getProvidersWrapper(cloudRules?: RulerRulesConfigDTO) {
function getProvidersWrapper() {
return function Wrapper({ children }: React.PropsWithChildren<{}>) {
const store = mockStore((store) => {
store.unifiedAlerting.rulerRules[GRAFANA_DATASOURCE_NAME] = {
loading: false,
dispatched: true,
result: someRulerRules,
};
store.unifiedAlerting.rulerRules['Prometheus-1'] = {
loading: false,
dispatched: true,
result: cloudRules ?? someCloudRulerRules,
};
});
const store = mockStore(() => null);
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', () => {
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', () => {
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(),
});
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} />, {
wrapper: getProvidersWrapper(onlyRecordingRulerRules),
it('Should not show alert table in case of having exclusively recording rules in the group', () => {
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', () => {
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', () => {
render(<EditCloudGroupModal {...defaultProps} sourceName={GRAFANA_DATASOURCE_NAME} />, {
render(<EditCloudGroupModal namespace={grafanaNamespace} group={grafanaGroup1} onClose={jest.fn()} />, {
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 { compact } from 'lodash';
import React, { useEffect, useMemo } from 'react';
import { FormProvider, RegisterOptions, useForm, useFormContext } from 'react-hook-form';
import { GrafanaTheme2 } from '@grafana/data';
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 { useCleanup } from 'app/core/hooks/useCleanup';
import { useDispatch } from 'app/types';
@ -14,7 +15,7 @@ import { RulerRuleDTO, RulerRuleGroupDTO, RulerRulesConfigDTO } from 'app/types/
import { useUnifiedAlertingSelector } from '../../hooks/useUnifiedAlertingSelector';
import { rulesInSameGroupHaveInvalidFor, updateLotexNamespaceAndGroupAction } from '../../state/actions';
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 { isAlertingRulerRule, isGrafanaRulerRule, isRecordingRulerRule } from '../../utils/rules';
import { parsePrometheusDuration } from '../../utils/time';
@ -23,13 +24,14 @@ import { InfoIcon } from '../InfoIcon';
import { EvaluationIntervalLimitExceeded } from '../InvalidIntervalWarning';
import { MIN_TIME_RANGE_STEP_S } from '../rule-editor/GrafanaEvaluationBehavior';
const MINUTE = '1m';
const ITEMS_PER_PAGE = 10;
interface AlertInfo {
alertName: string;
forDuration: string;
evaluationsToFire: number;
}
function ForBadge({ message, error }: { message: string; error?: boolean }) {
if (error) {
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] : [];
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 => {
try {
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 {
namespaceName: string;
groupName: string;
groupInterval: string;
}
export const evaluateEveryValidationOptions = (
rules: RulerRulesConfigDTO | null | undefined,
groupName: string,
nameSpaceName: string
): RegisterOptions => ({
export const evaluateEveryValidationOptions = (rules: RulerRuleDTO[]): RegisterOptions => ({
required: {
value: true,
message: 'Required.',
},
validate: (value: string) => {
validate: (evaluateEvery: string) => {
try {
const duration = parsePrometheusDuration(value);
const duration = parsePrometheusDuration(evaluateEvery);
if (duration < MIN_TIME_RANGE_STEP_S * 1000) {
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) {
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;
} else {
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 {
const {
nameSpaceAndGroup: { namespace, group },
onClose,
groupInterval,
sourceName,
folderAndGroupReadOnly,
} = props;
const { namespace, group, onClose, intervalEditOnly } = props;
const styles = useStyles2(getStyles);
const dispatch = useDispatch();
const { loading, error, dispatched } =
useUnifiedAlertingSelector((state) => state.updateLotexNamespaceAndGroup) ?? initialAsyncRequestState;
const notifyApp = useAppNotification();
const nameSpaceName = typeof namespace === 'string' ? namespace : namespace.name;
const groupName = typeof group === 'string' ? group : group.name;
const defaultValues = useMemo(
(): FormValues => ({
namespaceName: nameSpaceName,
groupName: groupName,
groupInterval: groupInterval ?? '',
namespaceName: namespace.name,
groupName: group.name,
groupInterval: group.interval ?? '',
}),
[nameSpaceName, groupName, groupInterval]
[namespace, group]
);
const isGrafanaManagedGroup = sourceName === GRAFANA_RULES_SOURCE_NAME;
const nameSpaceLabel = isGrafanaManagedGroup ? 'Folder' : 'Namespace';
const nameSpaceInfoIconLabelEditable = isGrafanaManagedGroup
? 'Folder name can be updated to a non-existing folder name'
: '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 rulesSourceName = getRulesSourceName(namespace.rulesSource);
const isGrafanaManagedGroup = rulesSourceName === GRAFANA_RULES_SOURCE_NAME;
const nameSpaceLabel = isGrafanaManagedGroup ? 'Folder' : 'Namespace';
const spaceNameInfoIconLabel = folderAndGroupReadOnly
? nameSpaceInfoIconLabelNonEditable
: nameSpaceInfoIconLabelEditable;
// close modal if successfully saved
useEffect(() => {
if (dispatched && !loading && !error) {
@ -289,10 +254,10 @@ export function EditCloudGroupModal(props: ModalProps): React.ReactElement {
const onSubmit = (values: FormValues) => {
dispatch(
updateLotexNamespaceAndGroupAction({
rulesSourceName: sourceName,
groupName: groupName,
rulesSourceName: rulesSourceName,
groupName: group.name,
newGroupName: values.groupName,
namespaceName: nameSpaceName,
namespaceName: namespace.name,
newNamespaceName: values.namespaceName,
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.');
};
const rulerRuleRequests = useUnifiedAlertingSelector((state) => state.rulerRules);
const groupfoldersForSource = rulerRuleRequests[sourceName];
const groupWithRules = getGroupFromRuler(groupfoldersForSource?.result, groupName, nameSpaceName);
const rulesWithoutRecordingRules: RulerRuleDTO[] =
groupWithRules?.rules.filter((rule: RulerRuleDTO) => !isRecordingRulerRule(rule)) ?? [];
const rulesWithoutRecordingRules = compact(
group.rules.map((r) => r.rulerRule).filter((rule) => !isRecordingRulerRule(rule))
);
const hasSomeNoRecordingRules = rulesWithoutRecordingRules.length > 0;
const modalTitle =
intervalEditOnly || isGrafanaManagedGroup ? 'Edit evaluation group' : 'Edit namespace or evaluation group';
return (
<Modal
className={styles.modal}
isOpen={true}
title={folderAndGroupReadOnly ? 'Edit evaluation group' : `Edit ${nameSpaceLabel} or evaluation group`}
onDismiss={onClose}
onClickBackdrop={onClose}
>
<Modal className={styles.modal} isOpen={true} title={modalTitle} onDismiss={onClose} onClickBackdrop={onClose}>
<FormProvider {...formAPI}>
<form onSubmit={(e) => e.preventDefault()} key={JSON.stringify(defaultValues)}>
<>
<Field
label={
<Label htmlFor="namespaceName">
<Stack gap={0.5}>
{nameSpaceLabel}
<InfoIcon text={spaceNameInfoIconLabel} />
</Stack>
<Label
htmlFor="namespaceName"
description={
!isGrafanaManagedGroup &&
'Change the current namespace name. Moving groups between namespaces is not supported'
}
>
{nameSpaceLabel}
</Label>
}
invalid={!!errors.namespaceName}
error={errors.namespaceName?.message}
>
<Input
id="namespaceName"
readOnly={folderAndGroupReadOnly}
{...register('namespaceName', {
required: 'Namespace name is required.',
})}
/>
<Stack gap={1} direction="row">
<Input
id="namespaceName"
readOnly={intervalEditOnly || isGrafanaManagedGroup}
{...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
label={
<Label htmlFor="groupName">
<Stack gap={0.5}>
Evaluation group
{isGrafanaManagedGroup ? (
<InfoIcon text={'Group name can be updated on Group view.'} />
) : (
<InfoIcon text={'Group name can be updated to a non existing group name.'} />
)}
</Stack>
<Label
htmlFor="groupName"
description={`Evaluation group name needs to be unique within a ${nameSpaceLabel.toLocaleLowerCase()}`}
>
Evaluation group name
</Label>
}
invalid={!!errors.groupName}
@ -372,7 +341,7 @@ export function EditCloudGroupModal(props: ModalProps): React.ReactElement {
>
<Input
id="groupName"
readOnly={folderAndGroupReadOnly}
readOnly={intervalEditOnly}
{...register('groupName', {
required: 'Evaluation group name is required.',
})}
@ -396,10 +365,7 @@ export function EditCloudGroupModal(props: ModalProps): React.ReactElement {
<Input
id="groupInterval"
placeholder="1m"
{...register(
'groupInterval',
evaluateEveryValidationOptions(groupfoldersForSource?.result, groupName, nameSpaceName)
)}
{...register('groupInterval', evaluateEveryValidationOptions(rulesWithoutRecordingRules))}
/>
</Field>
@ -426,8 +392,8 @@ export function EditCloudGroupModal(props: ModalProps): React.ReactElement {
</Button>
</Modal.ButtonRow>
</div>
{rulerRuleRequests && !hasSomeNoRecordingRules && <div>This group does not contain alert rules.</div>}
{rulerRuleRequests && hasSomeNoRecordingRules && (
{!hasSomeNoRecordingRules && <div>This group does not contain alert rules.</div>}
{hasSomeNoRecordingRules && (
<>
<div>List of rules that belong to this group</div>
<div className={styles.evalRequiredLabel}>
@ -452,10 +418,7 @@ const getStyles = (theme: GrafanaTheme2) => ({
position: relative;
`,
formInput: css`
width: 275px;
& + & {
margin-left: ${theme.spacing(3)};
}
flex: 1;
`,
tableWrapper: css`
margin-top: ${theme.spacing(2)};

View File

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

View File

@ -61,7 +61,7 @@ import {
FetchRulerRulesFilter,
setRulerRuleGroup,
} 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 { addDefaultsToAlertmanagerConfig, removeMuteTimingFromRoute } from '../utils/alertmanager';
import {
@ -770,20 +770,12 @@ interface UpdateNamespaceAndGroupOptions {
groupInterval?: string;
}
export const rulesInSameGroupHaveInvalidFor = (
rulerRules: RulerRulesConfigDTO | null | undefined,
groupName: string,
folderName: string,
everyDuration: string
) => {
const group = getGroupFromRuler(rulerRules, groupName, folderName);
const rulesSameGroup: RulerRuleDTO[] = group?.rules ?? [];
return rulesSameGroup.filter((rule: RulerRuleDTO) => {
export const rulesInSameGroupHaveInvalidFor = (rules: RulerRuleDTO[], everyDuration: string) => {
return rules.filter((rule: RulerRuleDTO) => {
const { forDuration } = getAlertInfo(rule, everyDuration);
const forNumber = safeParseDurationstr(forDuration);
const everyNumber = safeParseDurationstr(everyDuration);
return forNumber !== 0 && forNumber < everyNumber;
});
};
@ -809,16 +801,20 @@ export const updateLotexNamespaceAndGroupAction: AsyncThunk<
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]);
if (newNamespaceName !== namespaceName && newNamespaceAlreadyExists) {
throw new Error(`Namespace "${newNamespaceName}" already exists.`);
@ -830,16 +826,10 @@ export const updateLotexNamespaceAndGroupAction: AsyncThunk<
) {
throw new Error('Nothing changed.');
}
// validation for new groupInterval
if (groupInterval !== existingGroup.interval) {
const storeState = thunkAPI.getState();
const groupfoldersForSource = storeState?.unifiedAlerting.rulerRules[rulesSourceName];
const notValidRules = rulesInSameGroupHaveInvalidFor(
groupfoldersForSource?.result,
groupName,
namespaceName,
groupInterval ?? '1m'
);
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

View File

@ -10,6 +10,8 @@ import {
mapStateWithReasonToBaseState,
} from 'app/types/unified-alerting-dto';
import { FolderDTO } from '../../../../types';
import { ALERTMANAGER_NAME_QUERY_KEY } from './constants';
import { getRulesSourceName } from './datasource';
import { getMatcherQueryParams } from './matchers';
@ -104,6 +106,10 @@ export function makeFolderLink(folderUID: string): string {
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
export function retryWhile<T, E = Error>(
fn: () => Promise<T>,