mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
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:
parent
937b8419c3
commit
69b2aade1b
@ -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,
|
||||||
|
@ -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 };
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -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`
|
||||||
|
@ -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();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
@ -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)};
|
||||||
|
@ -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 && (
|
||||||
|
@ -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
|
||||||
|
@ -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>,
|
||||||
|
Loading…
Reference in New Issue
Block a user