Alerting: Changes to evaluation group step (#71251)

* Initial changes to evaluation group step

* Add separate buttons for folder and eval group creation

* Implement folder creation from a modal

* Reset group upon folder creation

* Implement creation of evaluation group in modal

* Changes to evaluation group edit modal

* Fix tests

* Address review comments

* Fix tests

* Refactor to avoid passing AsyncRequestState as prop

* Refactor to avoid passing AsyncRequestState as prop
This commit is contained in:
Virginia Cepeda
2023-07-18 10:56:02 -03:00
committed by GitHub
parent e586c4549b
commit 5faf5e48ea
6 changed files with 444 additions and 276 deletions
@@ -1,4 +1,4 @@
import { render, waitFor, screen, within, act } from '@testing-library/react'; import { render, waitFor, screen, within } 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 { Route } from 'react-router-dom'; import { Route } from 'react-router-dom';
@@ -6,7 +6,6 @@ import { TestProvider } from 'test/helpers/TestProvider';
import { ui } from 'test/helpers/alertingRuleEditor'; import { ui } from 'test/helpers/alertingRuleEditor';
import { locationService, setDataSourceSrv } from '@grafana/runtime'; import { locationService, setDataSourceSrv } from '@grafana/runtime';
import { ADD_NEW_FOLER_OPTION } from 'app/core/components/Select/FolderPicker';
import { contextSrv } from 'app/core/services/context_srv'; import { contextSrv } from 'app/core/services/context_srv';
import { DashboardSearchHit } from 'app/features/search/types'; import { DashboardSearchHit } from 'app/features/search/types';
import { GrafanaAlertStateDecision } from 'app/types/unified-alerting-dto'; import { GrafanaAlertStateDecision } from 'app/types/unified-alerting-dto';
@@ -184,14 +183,8 @@ describe('RuleEditor grafana managed rules', () => {
await userEvent.click(ui.buttons.save.get()); await userEvent.click(ui.buttons.save.get());
await waitFor(() => expect(mocks.api.setRulerRuleGroup).toHaveBeenCalled()); await waitFor(() => expect(mocks.api.setRulerRuleGroup).toHaveBeenCalled());
//check that '+ Add new' option is in folders drop down even if we don't have values
const emptyFolderInput = await ui.inputs.folderContainer.find();
mocks.searchFolders.mockResolvedValue([] as DashboardSearchHit[]); mocks.searchFolders.mockResolvedValue([] as DashboardSearchHit[]);
await act(async () => { expect(screen.getByText('New folder')).toBeInTheDocument();
renderRuleEditor(uid);
});
await userEvent.click(within(emptyFolderInput).getByRole('combobox'));
expect(screen.getByText(ADD_NEW_FOLER_OPTION)).toBeInTheDocument();
expect(mocks.api.setRulerRuleGroup).toHaveBeenCalledWith( expect(mocks.api.setRulerRuleGroup).toHaveBeenCalledWith(
{ dataSourceName: GRAFANA_RULES_SOURCE_NAME, apiVersion: 'legacy' }, { dataSourceName: GRAFANA_RULES_SOURCE_NAME, apiVersion: 'legacy' },
@@ -125,9 +125,9 @@ const ui = {
namespaceInput: byRole('textbox', { name: /^Namespace/ }), namespaceInput: byRole('textbox', { name: /^Namespace/ }),
ruleGroupInput: byRole('textbox', { name: /Evaluation group/ }), 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: /Evaluation interval How often is the rule evaluated. Applies to every rule within the group./i,
}), }),
saveButton: byRole('button', { name: /Save evaluation interval/ }), saveButton: byRole('button', { name: /Save/ }),
}, },
}; };
@@ -626,12 +626,7 @@ describe('RuleList', () => {
// make changes to form // make changes to form
await userEvent.clear(ui.editGroupModal.ruleGroupInput.get()); await userEvent.clear(ui.editGroupModal.ruleGroupInput.get());
await userEvent.type(ui.editGroupModal.ruleGroupInput.get(), 'super group'); await userEvent.type(ui.editGroupModal.ruleGroupInput.get(), 'super group');
await userEvent.type( await userEvent.type(ui.editGroupModal.intervalInput.get(), '5m');
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,
}),
'5m'
);
// submit, check that appropriate calls were made // submit, check that appropriate calls were made
await userEvent.click(ui.editGroupModal.saveButton.get()); await userEvent.click(ui.editGroupModal.saveButton.get());
@@ -1,14 +1,16 @@
import { css } from '@emotion/css'; import { css } from '@emotion/css';
import { debounce, take, uniqueId } from 'lodash'; import { debounce, take, uniqueId } from 'lodash';
import React, { useCallback, useEffect, useMemo } from 'react'; import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { useFormContext } from 'react-hook-form'; import { FormProvider, useForm, useFormContext } from 'react-hook-form';
import { GrafanaTheme2, SelectableValue } from '@grafana/data'; import { AppEvents, GrafanaTheme2, SelectableValue } from '@grafana/data';
import { Stack } from '@grafana/experimental'; import { AsyncSelect, Button, Field, Input, InputControl, Label, Modal, useStyles2 } from '@grafana/ui';
import { AsyncSelect, Field, InputControl, Label, useStyles2 } from '@grafana/ui'; import appEvents from 'app/core/app_events';
import { contextSrv } from 'app/core/core'; import { contextSrv } from 'app/core/services/context_srv';
import { createFolder } from 'app/features/manage-dashboards/state/actions';
import { AccessControlAction, useDispatch } from 'app/types'; import { AccessControlAction, useDispatch } from 'app/types';
import { CombinedRuleGroup } from 'app/types/unified-alerting'; import { CombinedRuleGroup } from 'app/types/unified-alerting';
import { RulerRulesConfigDTO } from 'app/types/unified-alerting-dto';
import { useCombinedRuleNamespaces } from '../../hooks/useCombinedRuleNamespaces'; import { useCombinedRuleNamespaces } from '../../hooks/useCombinedRuleNamespaces';
import { useUnifiedAlertingSelector } from '../../hooks/useUnifiedAlertingSelector'; import { useUnifiedAlertingSelector } from '../../hooks/useUnifiedAlertingSelector';
@@ -17,10 +19,10 @@ import { RuleFormValues } from '../../types/rule-form';
import { GRAFANA_RULES_SOURCE_NAME } from '../../utils/datasource'; import { GRAFANA_RULES_SOURCE_NAME } from '../../utils/datasource';
import { MINUTE } from '../../utils/rule-form'; import { MINUTE } from '../../utils/rule-form';
import { isGrafanaRulerRule } from '../../utils/rules'; import { isGrafanaRulerRule } from '../../utils/rules';
import { InfoIcon } from '../InfoIcon';
import { ProvisioningBadge } from '../Provisioning'; import { ProvisioningBadge } from '../Provisioning';
import { evaluateEveryValidationOptions } from '../rules/EditRuleGroupModal';
import { Folder, RuleFolderPicker } from './RuleFolderPicker'; import { containsSlashes, Folder, RuleFolderPicker } from './RuleFolderPicker';
import { checkForPathSeparator } from './util'; import { checkForPathSeparator } from './util';
export const MAX_GROUP_RESULTS = 1000; export const MAX_GROUP_RESULTS = 1000;
@@ -67,7 +69,7 @@ const findGroupMatchingLabel = (group: SelectableValue<string>, query: string) =
return group.label?.toLowerCase().includes(query.toLowerCase()); return group.label?.toLowerCase().includes(query.toLowerCase());
}; };
export function FolderAndGroup() { export function FolderAndGroup({ groupfoldersForGrafana }: { groupfoldersForGrafana?: RulerRulesConfigDTO | null }) {
const { const {
formState: { errors }, formState: { errors },
watch, watch,
@@ -82,6 +84,24 @@ export function FolderAndGroup() {
const { groupOptions, loading } = useGetGroupOptionsFromFolder(folder?.title ?? ''); const { groupOptions, loading } = useGetGroupOptionsFromFolder(folder?.title ?? '');
const [isCreatingFolder, setIsCreatingFolder] = useState(false);
const [isCreatingEvaluationGroup, setIsCreatingEvaluationGroup] = useState(false);
const onOpenFolderCreationModal = () => setIsCreatingFolder(true);
const onOpenEvaluationGroupCreationModal = () => setIsCreatingEvaluationGroup(true);
const handleFolderCreation = (folder: Folder) => {
resetGroup();
setValue('folder', folder);
setIsCreatingFolder(false);
};
const handleEvalGroupCreation = (groupName: string, evaluationInterval: string) => {
setValue('group', groupName);
setValue('evaluateEvery', evaluationInterval);
setIsCreatingEvaluationGroup(false);
};
const resetGroup = useCallback(() => { const resetGroup = useCallback(() => {
setValue('group', ''); setValue('group', '');
}, [setValue]); }, [setValue]);
@@ -102,116 +122,327 @@ export function FolderAndGroup() {
return ( return (
<div className={styles.container}> <div className={styles.container}>
<Field <div className={styles.evaluationGroupsContainer}>
label={ {
<Label htmlFor="folder" description={'Select a folder for your rule.'}> <Field
<Stack gap={0.5}> label={
Folder <Label htmlFor="folder" description={'Select a folder to store your rule.'}>
<InfoIcon Folder
text={ </Label>
'Each folder has unique folder permission. When you store multiple rules in a folder, the folder access permissions are assigned to the rules.' }
} className={styles.formInput}
error={errors.folder?.message}
invalid={!!errors.folder?.message}
data-testid="folder-picker"
>
{(!isCreatingFolder && (
<InputControl
render={({ field: { ref, ...field } }) => (
<RuleFolderPicker
inputId="folder"
{...field}
enableReset={true}
onChange={({ title, uid }) => {
field.onChange({ title, uid });
resetGroup();
}}
/>
)}
name="folder"
rules={{
required: { value: true, message: 'Select a folder' },
validate: {
pathSeparator: (folder: Folder) => checkForPathSeparator(folder.title),
},
}}
/> />
</Stack> )) || <div>Creating new folder...</div>}
</Label> </Field>
} }
className={styles.formInput}
error={errors.folder?.message}
invalid={!!errors.folder?.message}
data-testid="folder-picker"
>
<InputControl
render={({ field: { ref, ...field } }) => (
<RuleFolderPicker
inputId="folder"
{...field}
enableCreateNew={contextSrv.hasPermission(AccessControlAction.FoldersCreate)}
enableReset={true}
onChange={({ title, uid }) => {
field.onChange({ title, uid });
resetGroup();
}}
/>
)}
name="folder"
rules={{
required: { value: true, message: 'Select a folder' },
validate: {
pathSeparator: (folder: Folder) => checkForPathSeparator(folder.title),
},
}}
/>
</Field>
<Field <div className={styles.addButton}>
label="Evaluation group (interval)" <span>or</span>
data-testid="group-picker" <Button
description="Select a group to evaluate all rules in the same group over the same time interval." onClick={onOpenFolderCreationModal}
className={styles.formInput} type="button"
error={errors.group?.message} icon="plus"
invalid={!!errors.group?.message} fill="outline"
> variant="secondary"
<InputControl disabled={!contextSrv.hasPermission(AccessControlAction.FoldersCreate)}
render={({ field: { ref, ...field }, fieldState }) => ( >
<AsyncSelect New folder
disabled={!folder || loading} </Button>
inputId="group" </div>
key={uniqueId()} {isCreatingFolder && (
{...field} <FolderCreationModal onCreate={handleFolderCreation} onClose={() => setIsCreatingFolder(false)} />
onChange={(group) => { )}
field.onChange(group.label ?? ''); </div>
}}
isLoading={loading} <div className={styles.evaluationGroupsContainer}>
invalid={Boolean(folder) && !group && Boolean(fieldState.error)} <Field
loadOptions={debouncedSearch} label="Evaluation group"
cacheOptions data-testid="group-picker"
loadingMessage={'Loading groups...'} description="Rules within the same group are evaluated sequentially over the same time interval"
defaultValue={defaultGroupValue} className={styles.formInput}
defaultOptions={groupOptions} error={errors.group?.message}
getOptionLabel={(option: SelectableValue<string>) => ( invalid={!!errors.group?.message}
<div> >
<span>{option.label}</span> <InputControl
{/* making the assumption here that it's provisioned when it's disabled, should probably change this */} render={({ field: { ref, ...field }, fieldState }) => (
{option.isDisabled && ( <AsyncSelect
<> disabled={!folder || loading}
{' '} inputId="group"
<ProvisioningBadge /> key={uniqueId()}
</> {...field}
)} onChange={(group) => {
</div> field.onChange(group.label ?? '');
)} }}
placeholder={'Evaluation group name'} isLoading={loading}
allowCustomValue invalid={Boolean(folder) && !group && Boolean(fieldState.error)}
formatCreateLabel={(_) => '+ Add new '} loadOptions={debouncedSearch}
noOptionsMessage="Start typing to create evaluation group" cacheOptions
/> loadingMessage={'Loading groups...'}
)} defaultValue={defaultGroupValue}
name="group" defaultOptions={groupOptions}
control={control} getOptionLabel={(option: SelectableValue<string>) => (
rules={{ <div>
required: { value: true, message: 'Must enter a group name' }, <span>{option.label}</span>
validate: { {/* making the assumption here that it's provisioned when it's disabled, should probably change this */}
pathSeparator: (group_: string) => checkForPathSeparator(group_), {option.isDisabled && (
}, <>
}} {' '}
/> <ProvisioningBadge />
</Field> </>
)}
</div>
)}
placeholder={'Select an evaluation group...'}
/>
)}
name="group"
control={control}
rules={{
required: { value: true, message: 'Must enter a group name' },
validate: {
pathSeparator: (group_: string) => checkForPathSeparator(group_),
},
}}
/>
</Field>
<div className={styles.addButton}>
<span>or</span>
<Button
onClick={onOpenEvaluationGroupCreationModal}
type="button"
icon="plus"
fill="outline"
variant="secondary"
disabled={!folder}
>
New evaluation group
</Button>
</div>
{isCreatingEvaluationGroup && (
<EvaluationGroupCreationModal
onCreate={handleEvalGroupCreation}
onClose={() => setIsCreatingEvaluationGroup(false)}
groupfoldersForGrafana={groupfoldersForGrafana}
/>
)}
</div>
</div> </div>
); );
} }
function FolderCreationModal({
onClose,
onCreate,
}: {
onClose: () => void;
onCreate: (folder: Folder) => void;
}): React.ReactElement {
const styles = useStyles2(getStyles);
const [title, setTitle] = useState('');
const onSubmit = async () => {
const newFolder = await createFolder({ title: title });
if (!newFolder.uid) {
appEvents.emit(AppEvents.alertError, ['Folder could not be created']);
return;
}
const folder: Folder = { title: newFolder.title, uid: newFolder.uid };
onCreate(folder);
appEvents.emit(AppEvents.alertSuccess, ['Folder Created', 'OK']);
};
const error = containsSlashes(title);
return (
<Modal className={styles.modal} isOpen={true} title={'New folder'} onDismiss={onClose} onClickBackdrop={onClose}>
<div className={styles.modalTitle}>Create a new folder to store your rule</div>
<form onSubmit={onSubmit}>
<Field
label={<Label htmlFor="folder">Folder name</Label>}
error={"The folder name can't contain slashes"}
invalid={error}
>
<Input
autoFocus={true}
id="folderName"
placeholder="Enter a name"
value={title}
onChange={(e) => setTitle(e.currentTarget.value)}
className={styles.formInput}
/>
</Field>
<Modal.ButtonRow>
<Button variant="secondary" type="button" onClick={onClose}>
Cancel
</Button>
<Button type="submit" disabled={!title || error}>
Create
</Button>
</Modal.ButtonRow>
</form>
</Modal>
);
}
function EvaluationGroupCreationModal({
onClose,
onCreate,
groupfoldersForGrafana,
}: {
onClose: () => void;
onCreate: (group: string, evaluationInterval: string) => void;
groupfoldersForGrafana?: RulerRulesConfigDTO | null;
}): React.ReactElement {
const styles = useStyles2(getStyles);
const onSubmit = () => {
onCreate(getValues('group'), getValues('evaluateEvery'));
};
const { watch } = useFormContext<RuleFormValues>();
const evaluateEveryId = 'eval-every-input';
const [groupName, folderName] = watch(['group', 'folder.title']);
const groupRules =
(groupfoldersForGrafana && groupfoldersForGrafana[folderName]?.find((g) => g.name === groupName)?.rules) ?? [];
const onCancel = () => {
onClose();
};
const formAPI = useForm({
defaultValues: { group: '', evaluateEvery: '' },
mode: 'onChange',
shouldFocusError: true,
});
const { register, handleSubmit, formState, getValues } = formAPI;
return (
<Modal
className={styles.modal}
isOpen={true}
title={'New evaluation group'}
onDismiss={onCancel}
onClickBackdrop={onCancel}
>
<div className={styles.modalTitle}>Create a new evaluation group to use for this alert rule.</div>
<FormProvider {...formAPI}>
<form onSubmit={handleSubmit(() => onSubmit())}>
<Field
label={<Label htmlFor={'group'}>Evaluation group name</Label>}
error={formState.errors.group?.message}
invalid={!!formState.errors.group}
>
<Input
className={styles.formInput}
autoFocus={true}
id={'group'}
placeholder="Enter a name"
{...register('group', { required: { value: true, message: 'Required.' } })}
/>
</Field>
<Field
error={formState.errors.evaluateEvery?.message}
invalid={!!formState.errors.evaluateEvery}
label={
<Label
htmlFor={evaluateEveryId}
description="How often is the rule evaluated. Applies to every rule within the group."
>
Evaluation interval
</Label>
}
>
<Input
className={styles.formInput}
id={evaluateEveryId}
placeholder="e.g. 5m"
{...register('evaluateEvery', evaluateEveryValidationOptions(groupRules))}
/>
</Field>
<Modal.ButtonRow>
<Button variant="secondary" type="button" onClick={onCancel}>
Cancel
</Button>
<Button type="submit" disabled={!formState.isValid}>
Create
</Button>
</Modal.ButtonRow>
</form>
</FormProvider>
</Modal>
);
}
const getStyles = (theme: GrafanaTheme2) => ({ const getStyles = (theme: GrafanaTheme2) => ({
container: css` container: css`
margin-top: ${theme.spacing(1)};
display: flex; display: flex;
flex-direction: row; flex-direction: column;
align-items: baseline; align-items: baseline;
max-width: ${theme.breakpoints.values.sm}px; max-width: ${theme.breakpoints.values.lg}px;
justify-content: space-between; justify-content: space-between;
`, `,
formInput: css` evaluationGroupsContainer: css`
width: 275px; width: 100%;
display: flex;
flex-direction: row;
gap: ${theme.spacing(2)};
`,
& + & { addButton: css`
margin-left: ${theme.spacing(3)}; display: flex;
direction: row;
gap: ${theme.spacing(2)};
line-height: 2;
margin-top: 35px;
`,
formInput: css`
max-width: ${theme.breakpoints.values.sm}px;
flex-grow: 1;
label {
width: ${theme.breakpoints.values.sm}px;
} }
`, `,
modal: css`
width: ${theme.breakpoints.values.sm}px;
`,
modalTitle: css`
color: ${theme.colors.text.secondary};
margin-bottom: ${theme.spacing(2)};
`,
}); });
@@ -4,8 +4,7 @@ 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, Icon, InlineLabel, Input, InputControl, Switch, Tooltip, useStyles2 } from '@grafana/ui'; import { Field, Icon, IconButton, Input, InputControl, Label, Switch, Tooltip, useStyles2 } from '@grafana/ui';
import { RulerRulesConfigDTO } from 'app/types/unified-alerting-dto';
import { CombinedRuleGroup, CombinedRuleNamespace } from '../../../../../types/unified-alerting'; import { CombinedRuleGroup, CombinedRuleNamespace } from '../../../../../types/unified-alerting';
import { logInfo, LogMessages } from '../../Analytics'; import { logInfo, LogMessages } from '../../Analytics';
@@ -13,13 +12,13 @@ import { useCombinedRuleNamespaces } from '../../hooks/useCombinedRuleNamespaces
import { useUnifiedAlertingSelector } from '../../hooks/useUnifiedAlertingSelector'; import { useUnifiedAlertingSelector } from '../../hooks/useUnifiedAlertingSelector';
import { RuleFormValues } from '../../types/rule-form'; import { RuleFormValues } from '../../types/rule-form';
import { GRAFANA_RULES_SOURCE_NAME } from '../../utils/datasource'; import { GRAFANA_RULES_SOURCE_NAME } from '../../utils/datasource';
import { MINUTE } from '../../utils/rule-form';
import { parsePrometheusDuration } from '../../utils/time'; import { parsePrometheusDuration } from '../../utils/time';
import { CollapseToggle } from '../CollapseToggle'; import { CollapseToggle } from '../CollapseToggle';
import { EditCloudGroupModal, evaluateEveryValidationOptions } from '../rules/EditRuleGroupModal'; import { EditCloudGroupModal } from '../rules/EditRuleGroupModal';
import { FolderAndGroup, useGetGroupOptionsFromFolder } from './FolderAndGroup'; import { FolderAndGroup, useGetGroupOptionsFromFolder } from './FolderAndGroup';
import { GrafanaAlertStatePicker } from './GrafanaAlertStatePicker'; import { GrafanaAlertStatePicker } from './GrafanaAlertStatePicker';
import { NeedHelpInfo } from './NeedHelpInfo';
import { RuleEditorSection } from './RuleEditorSection'; import { RuleEditorSection } from './RuleEditorSection';
export const MIN_TIME_RANGE_STEP_S = 10; // 10 seconds export const MIN_TIME_RANGE_STEP_S = 10; // 10 seconds
@@ -69,50 +68,6 @@ const useIsNewGroup = (folder: string, group: string) => {
return !groupIsInGroupOptions(group); return !groupIsInGroupOptions(group);
}; };
export const EvaluateEveryNewGroup = ({ rules }: { rules: RulerRulesConfigDTO | null | undefined }) => {
const {
watch,
register,
formState: { errors },
} = 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"
description="Applies to every rule within a group. It can overwrite the interval of an existing alert rule."
>
<div className={styles.alignInterval}>
<Stack direction="row" justify-content="left" align-items="baseline" gap={0}>
<InlineLabel
htmlFor={evaluateEveryId}
width={16}
tooltip="How often the alert will be evaluated to see if it fires"
>
Evaluate every
</InlineLabel>
<Field
className={styles.inlineField}
error={errors.evaluateEvery?.message}
invalid={!!errors.evaluateEvery}
validationMessageHorizontalOverflow={true}
>
<Input
id={evaluateEveryId}
width={8}
{...register('evaluateEvery', evaluateEveryValidationOptions(groupRules))}
/>
</Field>
</Stack>
</div>
</Field>
);
};
function FolderGroupAndEvaluationInterval({ function FolderGroupAndEvaluationInterval({
evaluateEvery, evaluateEvery,
setEvaluateEvery, setEvaluateEvery,
@@ -121,7 +76,7 @@ function FolderGroupAndEvaluationInterval({
setEvaluateEvery: (value: string) => void; setEvaluateEvery: (value: string) => void;
}) { }) {
const styles = useStyles2(getStyles); const styles = useStyles2(getStyles);
const { watch, setValue } = useFormContext<RuleFormValues>(); const { watch, setValue, getValues } = useFormContext<RuleFormValues>();
const [isEditingGroup, setIsEditingGroup] = useState(false); const [isEditingGroup, setIsEditingGroup] = useState(false);
const [groupName, folderName] = watch(['group', 'folder.title']); const [groupName, folderName] = watch(['group', 'folder.title']);
@@ -138,9 +93,6 @@ function FolderGroupAndEvaluationInterval({
useEffect(() => { useEffect(() => {
if (!isNewGroup && existingGroup?.interval) { if (!isNewGroup && existingGroup?.interval) {
setEvaluateEvery(existingGroup.interval); setEvaluateEvery(existingGroup.interval);
} else {
setEvaluateEvery(MINUTE);
setValue('evaluateEvery', MINUTE);
} }
}, [setEvaluateEvery, isNewGroup, setValue, existingGroup]); }, [setEvaluateEvery, isNewGroup, setValue, existingGroup]);
@@ -164,50 +116,36 @@ function FolderGroupAndEvaluationInterval({
return ( return (
<div> <div>
<FolderAndGroup /> <FolderAndGroup groupfoldersForGrafana={groupfoldersForGrafana?.result} />
{folderName && isEditingGroup && ( {folderName && isEditingGroup && (
<EditCloudGroupModal <EditCloudGroupModal
namespace={existingNamespace ?? emptyNamespace} namespace={existingNamespace ?? emptyNamespace}
group={existingGroup ?? emptyGroup} group={existingGroup ?? emptyGroup}
onClose={() => closeEditGroupModal()} onClose={() => closeEditGroupModal()}
intervalEditOnly intervalEditOnly
hideFolder={true}
/> />
)} )}
{folderName && groupName && ( {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 && groupName ? ( <Stack direction="column" gap={1}>
<EvaluateEveryNewGroup rules={groupfoldersForGrafana?.result} /> {getValues('group') && getValues('evaluateEvery') && (
) : ( <span>
<Stack direction="column" gap={1}> All rules in the selected group are evaluated every {evaluateEvery}.{' '}
<div className={styles.evaluateLabel}> {!isNewGroup && (
{`Alert rules in the `} <span className={styles.bold}>{groupName}</span> group are evaluated every{' '} <IconButton
<span className={styles.bold}>{evaluateEvery}</span>. name="pen"
</div> aria-label="Edit"
{!isNewGroup && ( disabled={editGroupDisabled}
<div> onClick={onOpenEditGroupModal}
{`Evaluation group interval applies to every rule within a group. It overwrites intervals defined for existing alert rules.`} />
</div> )}
)} </span>
</Stack> )}
)} </Stack>
</div> </div>
<Stack direction="row" justify-content="right" align-items="center">
{!isNewGroup && (
<div className={styles.marginTop}>
<Button
icon={'edit'}
type="button"
variant="secondary"
disabled={editGroupDisabled}
onClick={onOpenEditGroupModal}
>
<span>{'Edit evaluation group'}</span>
</Button>
</div>
)}
</Stack>
</Stack> </Stack>
</div> </div>
)} )}
@@ -226,14 +164,15 @@ function ForInput({ evaluateEvery }: { evaluateEvery: string }) {
return ( return (
<Stack direction="row" justify-content="flex-start" align-items="flex-start"> <Stack direction="row" justify-content="flex-start" align-items="flex-start">
<InlineLabel
htmlFor={evaluateForId}
width={7}
tooltip='Once the condition is breached, the alert goes into pending state. If the alert is pending longer than the "for" value, it becomes a firing alert.'
>
for
</InlineLabel>
<Field <Field
label={
<Label
htmlFor="evaluateFor"
description="Period in which an alert rule can be in breach of the condition until the alert rule fires"
>
Pending period
</Label>
}
className={styles.inlineField} className={styles.inlineField}
error={errors.evaluateFor?.message} error={errors.evaluateFor?.message}
invalid={!!errors.evaluateFor?.message} invalid={!!errors.evaluateFor?.message}
@@ -245,6 +184,22 @@ function ForInput({ evaluateEvery }: { evaluateEvery: string }) {
); );
} }
function getDescription() {
const textToRender = 'Define how the alert rule is evaluated.';
const docsLink = 'https://grafana.com/docs/grafana/latest/alerting/fundamentals/alert-rules/rule-evaluation/';
return (
<Stack gap={0.5}>
{`${textToRender}`}
<NeedHelpInfo
contentText="Evaluation groups are containers for evaluating alert and recording rules. An evaluation group defines an evaluation interval - how often a rule is checked. Alert rules within the same evaluation group are evaluated sequentially"
externalLink={docsLink}
linkText={`Read about evaluation`}
title="Evaluation"
/>
</Stack>
);
}
export function GrafanaEvaluationBehavior({ export function GrafanaEvaluationBehavior({
evaluateEvery, evaluateEvery,
setEvaluateEvery, setEvaluateEvery,
@@ -263,7 +218,7 @@ export function GrafanaEvaluationBehavior({
return ( return (
// TODO remove "and alert condition" for recording rules // TODO remove "and alert condition" for recording rules
<RuleEditorSection stepNo={3} title="Alert evaluation behavior"> <RuleEditorSection stepNo={3} title="Set evaluation behavior" description={getDescription()}>
<Stack direction="column" justify-content="flex-start" align-items="flex-start"> <Stack direction="column" justify-content="flex-start" align-items="flex-start">
<FolderGroupAndEvaluationInterval setEvaluateEvery={setEvaluateEvery} evaluateEvery={evaluateEvery} /> <FolderGroupAndEvaluationInterval setEvaluateEvery={setEvaluateEvery} evaluateEvery={evaluateEvery} />
<ForInput evaluateEvery={evaluateEvery} /> <ForInput evaluateEvery={evaluateEvery} />
@@ -348,8 +303,7 @@ const getStyles = (theme: GrafanaTheme2) => ({
margin-right: ${theme.spacing(1)}; margin-right: ${theme.spacing(1)};
`, `,
evaluationContainer: css` evaluationContainer: css`
background-color: ${theme.colors.background.secondary}; color: ${theme.colors.text.secondary};
padding: ${theme.spacing(2)};
max-width: ${theme.breakpoints.values.sm}px; max-width: ${theme.breakpoints.values.sm}px;
font-size: ${theme.typography.size.sm}; font-size: ${theme.typography.size.sm};
`, `,
@@ -24,7 +24,7 @@ const ui = {
input: { input: {
namespace: byLabelText(/^Folder|^Namespace/, { exact: true }), namespace: byLabelText(/^Folder|^Namespace/, { exact: true }),
group: byLabelText(/Evaluation group/), group: byLabelText(/Evaluation group/),
interval: byLabelText(/Rule group evaluation interval/), interval: byLabelText(/Evaluation interval/),
}, },
folderLink: byTitle(/Go to folder/), // <a> without a href has the generic role folderLink: byTitle(/Go to folder/), // <a> without a href has the generic role
table: byTestId('dynamic-table'), table: byTestId('dynamic-table'),
@@ -20,7 +20,6 @@ import { initialAsyncRequestState } from '../../utils/redux';
import { AlertInfo, getAlertInfo, isRecordingRulerRule } from '../../utils/rules'; import { AlertInfo, getAlertInfo, isRecordingRulerRule } from '../../utils/rules';
import { parsePrometheusDuration, safeParseDurationstr } from '../../utils/time'; import { parsePrometheusDuration, safeParseDurationstr } from '../../utils/time';
import { DynamicTable, DynamicTableColumnProps, DynamicTableItemProps } from '../DynamicTable'; import { DynamicTable, DynamicTableColumnProps, DynamicTableItemProps } from '../DynamicTable';
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';
@@ -160,6 +159,7 @@ export interface ModalProps {
onClose: (saved?: boolean) => void; onClose: (saved?: boolean) => void;
intervalEditOnly?: boolean; intervalEditOnly?: boolean;
folderUrl?: string; folderUrl?: string;
hideFolder?: boolean;
} }
export function EditCloudGroupModal(props: ModalProps): React.ReactElement { export function EditCloudGroupModal(props: ModalProps): React.ReactElement {
@@ -234,54 +234,50 @@ export function EditCloudGroupModal(props: ModalProps): React.ReactElement {
<FormProvider {...formAPI}> <FormProvider {...formAPI}>
<form onSubmit={(e) => e.preventDefault()} key={JSON.stringify(defaultValues)}> <form onSubmit={(e) => e.preventDefault()} key={JSON.stringify(defaultValues)}>
<> <>
<Field {!props.hideFolder && (
label={ <Field
<Label label={
htmlFor="namespaceName" <Label
description={ htmlFor="namespaceName"
!isGrafanaManagedGroup && description={
'Change the current namespace name. Moving groups between namespaces is not supported' !isGrafanaManagedGroup &&
} 'Change the current namespace name. Moving groups between namespaces is not supported'
> }
{nameSpaceLabel} >
</Label> {nameSpaceLabel}
} </Label>
invalid={!!errors.namespaceName} }
error={errors.namespaceName?.message} invalid={!!errors.namespaceName}
> error={errors.namespaceName?.message}
<Stack gap={1} direction="row"> >
<Input <Stack gap={1} direction="row">
id="namespaceName" <Input
readOnly={intervalEditOnly || isGrafanaManagedGroup} id="namespaceName"
{...register('namespaceName', { readOnly={intervalEditOnly || isGrafanaManagedGroup}
required: 'Namespace name is required.', {...register('namespaceName', {
})} required: 'Namespace name is required.',
className={styles.formInput} })}
/> className={styles.formInput}
{isGrafanaManagedGroup && props.folderUrl && (
<LinkButton
href={props.folderUrl}
title="Go to folder"
variant="secondary"
icon="folder-open"
target="_blank"
/> />
)} {isGrafanaManagedGroup && props.folderUrl && (
</Stack> <LinkButton
</Field> href={props.folderUrl}
title="Go to folder"
variant="secondary"
icon="folder-open"
target="_blank"
/>
)}
</Stack>
</Field>
)}
<Field <Field
label={ label={<Label htmlFor="groupName">Evaluation group name</Label>}
<Label
htmlFor="groupName"
description={`Evaluation group name needs to be unique within a ${nameSpaceLabel.toLocaleLowerCase()}`}
>
Evaluation group name
</Label>
}
invalid={!!errors.groupName} invalid={!!errors.groupName}
error={errors.groupName?.message} error={errors.groupName?.message}
> >
<Input <Input
autoFocus={true}
id="groupName" id="groupName"
readOnly={intervalEditOnly} readOnly={intervalEditOnly}
{...register('groupName', { {...register('groupName', {
@@ -293,12 +289,9 @@ export function EditCloudGroupModal(props: ModalProps): React.ReactElement {
label={ label={
<Label <Label
htmlFor="groupInterval" htmlFor="groupInterval"
description="Evaluation interval should be smaller or equal to 'For' values for existing rules in this group." description="How often is the rule evaluated. Applies to every rule within the group."
> >
<Stack gap={0.5}> <Stack gap={0.5}>Evaluation interval</Stack>
Rule group evaluation interval
<InfoIcon text={'How frequently to evaluate rules.'} />
</Stack>
</Label> </Label>
} }
invalid={!!errors.groupInterval} invalid={!!errors.groupInterval}
@@ -314,6 +307,18 @@ export function EditCloudGroupModal(props: ModalProps): React.ReactElement {
{checkEvaluationIntervalGlobalLimit(watch('groupInterval')).exceedsLimit && ( {checkEvaluationIntervalGlobalLimit(watch('groupInterval')).exceedsLimit && (
<EvaluationIntervalLimitExceeded /> <EvaluationIntervalLimitExceeded />
)} )}
{!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}>
#Eval column represents the number of evaluations needed before alert starts firing.
</div>
<RulesForGroupTable rulesWithoutRecordingRules={rulesWithoutRecordingRules} />
</>
)}
<div className={styles.modalButtons}> <div className={styles.modalButtons}>
<Modal.ButtonRow> <Modal.ButtonRow>
<Button <Button
@@ -330,20 +335,10 @@ export function EditCloudGroupModal(props: ModalProps): React.ReactElement {
disabled={!isDirty || loading} disabled={!isDirty || loading}
onClick={handleSubmit((values) => onSubmit(values), onInvalid)} onClick={handleSubmit((values) => onSubmit(values), onInvalid)}
> >
{loading ? 'Saving...' : 'Save evaluation interval'} {loading ? 'Saving...' : 'Save'}
</Button> </Button>
</Modal.ButtonRow> </Modal.ButtonRow>
</div> </div>
{!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}>
#Eval column represents the number of evaluations needed before alert starts firing.
</div>
<RulesForGroupTable rulesWithoutRecordingRules={rulesWithoutRecordingRules} />
</>
)}
</> </>
</form> </form>
</FormProvider> </FormProvider>