Alerting: Improve UI for making more clear that evaluation interval belongs to the group (#56397)

* In GrafanaEvaluationBehaviour component : Split evaluation interval from for duration and add button to edit to allow editing it and warning

* Move folder and group fields to the evaluation section in the alert form

* Include 'Group behaviour' info in a card and fix 'Edit group behaviour' button onClick.

* Create hook for  getting groups for a particular folder

* Use dropdown in group instead of input and fill it with groups that belong to the selected folder

* Add evaluation interval for each group in dropdown , and show warning in case user wants to update it

* Avoid saving evaluation interval when some rules in the same group would have invalid For with this change

* Clear group value when reseting the drop down

* Remove evaluationEvery from form values, show this as a label and add a button to edit the group

* Open EditRuleGroupModal for editing evaluation interval form the alert rule form

* Fix aligment in group behaviour card

* compact space in evaluation behaviour card and change group drop down label

* In EditgroupModal, in case of grafana managed group, show folder instead of namespcace label and disable the folder name input

* Add edge case in rulesInSameGroupHaveInvalidFor method when For value is zero

* Vertically align annotations input to the evaluation section in alert rule form

* Fix width when editing new group

* Add placeholder for group input

* Make folder and group in modal readonly from alert form and disable edit group button when new group

* Update texts

* Don't show evaluation behaviour section until folder and group are selected

* Update texts

* Fix merge conflits

* Fix wrong margin in evaluation label

* Remove non-used isRulerGrafanaRuleDTO method

* Remove negative margin to avoid overlaping on Firefox
pull/59063/head^2
Sonia Aguilar 3 years ago committed by GitHub
parent 8f567d57fa
commit 99725bf9d4
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
  1. 12
      public/app/features/alerting/unified/RuleEditor.test.tsx
  2. 20
      public/app/features/alerting/unified/components/rule-editor/AlertRuleForm.tsx
  3. 2
      public/app/features/alerting/unified/components/rule-editor/AnnotationsField.tsx
  4. 144
      public/app/features/alerting/unified/components/rule-editor/DetailsStep.tsx
  5. 226
      public/app/features/alerting/unified/components/rule-editor/FolderAndGroup.tsx
  6. 254
      public/app/features/alerting/unified/components/rule-editor/GrafanaEvaluationBehavior.tsx
  7. 5
      public/app/features/alerting/unified/components/rule-editor/SelectWIthAdd.tsx
  8. 75
      public/app/features/alerting/unified/components/rules/EditRuleGroupModal.tsx
  9. 11
      public/app/features/alerting/unified/components/rules/RulesGroup.tsx
  10. 14
      public/app/features/alerting/unified/state/actions.ts
  11. 1
      public/app/features/alerting/unified/types/rule-form.ts
  12. 2
      public/app/features/alerting/unified/utils/rule-form.ts
  13. 19
      public/app/features/alerting/unified/utils/rulerClient.ts

@ -24,6 +24,7 @@ import { discoverFeatures } from './api/buildInfo';
import { fetchRulerRules, fetchRulerRulesGroup, fetchRulerRulesNamespace, setRulerRuleGroup } from './api/ruler';
import { ExpressionEditorProps } from './components/rule-editor/ExpressionEditor';
import { disableRBAC, mockDataSource, MockDataSourceSrv, mockFolder } from './mocks';
import { fetchRulerRulesIfNotFetchedYet } from './state/actions';
import * as config from './utils/config';
import { DataSourceType, GRAFANA_RULES_SOURCE_NAME } from './utils/datasource';
import { getDefaultQueries } from './utils/rule-form';
@ -57,6 +58,7 @@ const mocks = {
setRulerRuleGroup: jest.mocked(setRulerRuleGroup),
fetchRulerRulesNamespace: jest.mocked(fetchRulerRulesNamespace),
fetchRulerRules: jest.mocked(fetchRulerRules),
fetchRulerRulesIfNotFetchedYet: jest.mocked(fetchRulerRulesIfNotFetchedYet),
},
};
@ -226,7 +228,7 @@ describe.skip('RuleEditor', () => {
rules: [],
});
mocks.api.fetchRulerRules.mockResolvedValue({
namespace1: [
'Folder A': [
{
name: 'group1',
rules: [],
@ -270,9 +272,9 @@ describe.skip('RuleEditor', () => {
const folderInput = await ui.inputs.folder.find();
await clickSelectOption(folderInput, 'Folder A');
const groupInput = screen.getByRole('textbox', { name: /^Group/ });
await userEvent.type(groupInput, 'my group');
const groupInput = await ui.inputs.group.find();
await userEvent.click(byRole('combobox').get(groupInput));
await clickSelectOption(groupInput, 'group1 (1m)');
await userEvent.type(ui.inputs.annotationValue(0).get(), 'some summary');
await userEvent.type(ui.inputs.annotationValue(1).get(), 'some description');
@ -293,7 +295,7 @@ describe.skip('RuleEditor', () => {
'Folder A',
{
interval: '1m',
name: 'my group',
name: 'group1',
rules: [
{
annotations: { description: 'some description', summary: 'some summary' },

@ -65,6 +65,8 @@ const AlertRuleNameInput = () => {
);
};
export const MINUTE = '1m';
type Props = {
existing?: RuleWithLocation;
};
@ -75,6 +77,7 @@ export const AlertRuleForm: FC<Props> = ({ existing }) => {
const notifyApp = useAppNotification();
const [queryParams] = useQueryParams();
const [showEditYaml, setShowEditYaml] = useState(false);
const [evaluateEvery, setEvaluateEvery] = useState(existing?.group.interval ?? MINUTE);
const returnTo: string = (queryParams['returnTo'] as string | undefined) ?? '/alerting/list';
const [showDeleteModal, setShowDeleteModal] = useState<boolean>(false);
@ -89,8 +92,9 @@ export const AlertRuleForm: FC<Props> = ({ existing }) => {
condition: 'C',
...(queryParams['defaults'] ? JSON.parse(queryParams['defaults'] as string) : {}),
type: RuleFormType.grafana,
evaluateEvery: evaluateEvery,
};
}, [existing, queryParams]);
}, [existing, queryParams, evaluateEvery]);
const formAPI = useForm<RuleFormValues>({
mode: 'onSubmit',
@ -125,6 +129,8 @@ export const AlertRuleForm: FC<Props> = ({ existing }) => {
},
existing,
redirectOnSave: exitOnSave ? returnTo : undefined,
initialAlertRuleName: defaultValues.name,
evaluateEvery: evaluateEvery,
})
);
};
@ -202,8 +208,16 @@ export const AlertRuleForm: FC<Props> = ({ existing }) => {
<QueryAndExpressionsStep editingExistingRule={!!existing} />
{showStep2 && (
<>
{type === RuleFormType.grafana ? <GrafanaEvaluationBehavior /> : <CloudEvaluationBehavior />}
<DetailsStep initialFolder={defaultValues.folder} />
{type === RuleFormType.grafana ? (
<GrafanaEvaluationBehavior
initialFolder={defaultValues.folder}
evaluateEvery={evaluateEvery}
setEvaluateEvery={setEvaluateEvery}
/>
) : (
<CloudEvaluationBehavior />
)}
<DetailsStep />
<NotificationsStep />
</>
)}

@ -99,7 +99,7 @@ const AnnotationsField = () => {
const getStyles = (theme: GrafanaTheme2) => ({
annotationValueInput: css`
width: 426px;
width: 394px;
`,
textarea: css`
height: 76px;

@ -1,43 +1,19 @@
import { css } from '@emotion/css';
import classNames from 'classnames';
import React, { useCallback } from 'react';
import React from 'react';
import { useFormContext } from 'react-hook-form';
import { GrafanaTheme2 } from '@grafana/data';
import { Stack } from '@grafana/experimental';
import { useStyles2, Field, Input, InputControl, Label, Tooltip, Icon } from '@grafana/ui';
import { FolderPickerFilter } from 'app/core/components/Select/FolderPicker';
import { contextSrv } from 'app/core/services/context_srv';
import { DashboardSearchHit } from 'app/features/search/types';
import { AccessControlAction } from 'app/types';
import { RuleForm, RuleFormType, RuleFormValues } from '../../types/rule-form';
import { RuleFormType, RuleFormValues } from '../../types/rule-form';
import AnnotationsField from './AnnotationsField';
import { GroupAndNamespaceFields } from './GroupAndNamespaceFields';
import { RuleEditorSection } from './RuleEditorSection';
import { RuleFolderPicker, Folder, containsSlashes } from './RuleFolderPicker';
import { checkForPathSeparator } from './util';
interface DetailsStepProps {
initialFolder: RuleForm | null;
}
export const DetailsStep = ({ initialFolder }: DetailsStepProps) => {
const {
register,
watch,
formState: { errors },
} = useFormContext<RuleFormValues & { location?: string }>();
const styles = useStyles2(getStyles);
export function DetailsStep() {
const { watch } = useFormContext<RuleFormValues & { location?: string }>();
const ruleFormType = watch('type');
const dataSourceName = watch('dataSourceName');
const type = watch('type');
const folderFilter = useRuleFolderFilter(initialFolder);
return (
<RuleEditorSection
stepNo={type === RuleFormType.cloudRecording ? 3 : 4}
@ -53,119 +29,7 @@ export const DetailsStep = ({ initialFolder }: DetailsStepProps) => {
{(ruleFormType === RuleFormType.cloudRecording || ruleFormType === RuleFormType.cloudAlerting) &&
dataSourceName && <GroupAndNamespaceFields rulesSourceName={dataSourceName} />}
{ruleFormType === RuleFormType.grafana && (
<div className={classNames([styles.flexRow, styles.alignBaseline])}>
<Field
label={
<Label htmlFor="folder" description={'Select a folder to store your rule.'}>
<Stack gap={0.5}>
Folder
<Tooltip
placement="top"
content={
<div>
Each folder has unique folder permission. When you store multiple rules in a folder, the folder
access permissions get assigned to the rules.
</div>
}
>
<Icon name="info-circle" size="xs" />
</Tooltip>
</Stack>
</Label>
}
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}
filter={folderFilter}
dissalowSlashes={true}
/>
)}
name="folder"
rules={{
required: { value: true, message: 'Please select a folder' },
validate: {
pathSeparator: (folder: Folder) => checkForPathSeparator(folder.title),
},
}}
/>
</Field>
<Field
label="Group"
data-testid="group-picker"
description="Rules within the same group are evaluated after the same time interval."
className={styles.formInput}
error={errors.group?.message}
invalid={!!errors.group?.message}
>
<Input
id="group"
{...register('group', {
required: { value: true, message: 'Must enter a group name' },
})}
/>
</Field>
</div>
)}
{type !== RuleFormType.cloudRecording && <AnnotationsField />}
</RuleEditorSection>
);
};
const useRuleFolderFilter = (existingRuleForm: RuleForm | null) => {
const isSearchHitAvailable = useCallback(
(hit: DashboardSearchHit) => {
const rbacDisabledFallback = contextSrv.hasEditPermissionInFolders;
const canCreateRuleInFolder = contextSrv.hasAccessInMetadata(
AccessControlAction.AlertingRuleCreate,
hit,
rbacDisabledFallback
);
const canUpdateInCurrentFolder =
existingRuleForm &&
hit.folderId === existingRuleForm.id &&
contextSrv.hasAccessInMetadata(AccessControlAction.AlertingRuleUpdate, hit, rbacDisabledFallback);
return canCreateRuleInFolder || canUpdateInCurrentFolder;
},
[existingRuleForm]
);
return useCallback<FolderPickerFilter>(
(folderHits) =>
folderHits
.filter(isSearchHitAvailable)
.filter((value: DashboardSearchHit) => !containsSlashes(value.title ?? '')),
[isSearchHitAvailable]
);
};
const getStyles = (theme: GrafanaTheme2) => ({
alignBaseline: css`
align-items: baseline;
margin-bottom: ${theme.spacing(3)};
`,
formInput: css`
width: 275px;
& + & {
margin-left: ${theme.spacing(3)};
}
`,
flexRow: css`
display: flex;
flex-direction: row;
justify-content: flex-start;
align-items: end;
`,
});

@ -0,0 +1,226 @@
import { css } from '@emotion/css';
import React, { useState, useRef, useEffect, useCallback, useMemo } from 'react';
import { useFormContext } from 'react-hook-form';
import { GrafanaTheme2, SelectableValue } from '@grafana/data';
import { Stack } from '@grafana/experimental';
import { Field, InputControl, Label, LoadingPlaceholder, useStyles2 } from '@grafana/ui';
import { FolderPickerFilter } from 'app/core/components/Select/FolderPicker';
import { contextSrv } from 'app/core/core';
import { DashboardSearchHit } from 'app/features/search/types';
import { AccessControlAction, useDispatch } from 'app/types';
import { RulerRuleDTO, RulerRuleGroupDTO, RulerRulesConfigDTO } from 'app/types/unified-alerting-dto';
import { useUnifiedAlertingSelector } from '../../hooks/useUnifiedAlertingSelector';
import { fetchRulerRulesIfNotFetchedYet } from '../../state/actions';
import { RuleForm, RuleFormValues } from '../../types/rule-form';
import { GRAFANA_RULES_SOURCE_NAME } from '../../utils/datasource';
import { InfoIcon } from '../InfoIcon';
import { getIntervalForGroup } from './GrafanaEvaluationBehavior';
import { containsSlashes, Folder, RuleFolderPicker } from './RuleFolderPicker';
import { SelectWithAdd } from './SelectWIthAdd';
import { checkForPathSeparator } from './util';
const useGetGroups = (groupfoldersForGrafana: RulerRulesConfigDTO | null | undefined, folderName: string) => {
const groupOptions = useMemo(() => {
const groupsForFolderResult: Array<RulerRuleGroupDTO<RulerRuleDTO>> = groupfoldersForGrafana
? groupfoldersForGrafana[folderName] ?? []
: [];
return groupsForFolderResult.map((group) => group.name);
}, [groupfoldersForGrafana, folderName]);
return groupOptions;
};
function mapGroupsToOptions(groups: string[]): Array<SelectableValue<string>> {
return groups.map((group) => ({ label: group, value: group }));
}
interface FolderAndGroupProps {
initialFolder: RuleForm | null;
}
export const useGetGroupOptionsFromFolder = (folderTilte: string) => {
const rulerRuleRequests = useUnifiedAlertingSelector((state) => state.rulerRules);
const groupfoldersForGrafana = rulerRuleRequests[GRAFANA_RULES_SOURCE_NAME];
const groupOptions: Array<SelectableValue<string>> = mapGroupsToOptions(
useGetGroups(groupfoldersForGrafana?.result, folderTilte)
);
const groupsForFolder = groupfoldersForGrafana?.result;
return { groupOptions, groupsForFolder, loading: groupfoldersForGrafana?.loading };
};
const useRuleFolderFilter = (existingRuleForm: RuleForm | null) => {
const isSearchHitAvailable = useCallback(
(hit: DashboardSearchHit) => {
const rbacDisabledFallback = contextSrv.hasEditPermissionInFolders;
const canCreateRuleInFolder = contextSrv.hasAccessInMetadata(
AccessControlAction.AlertingRuleCreate,
hit,
rbacDisabledFallback
);
const canUpdateInCurrentFolder =
existingRuleForm &&
hit.folderId === existingRuleForm.id &&
contextSrv.hasAccessInMetadata(AccessControlAction.AlertingRuleUpdate, hit, rbacDisabledFallback);
return canCreateRuleInFolder || canUpdateInCurrentFolder;
},
[existingRuleForm]
);
return useCallback<FolderPickerFilter>(
(folderHits) =>
folderHits
.filter(isSearchHitAvailable)
.filter((value: DashboardSearchHit) => !containsSlashes(value.title ?? '')),
[isSearchHitAvailable]
);
};
export function FolderAndGroup({ initialFolder }: FolderAndGroupProps) {
const {
formState: { errors },
watch,
control,
} = useFormContext<RuleFormValues>();
const styles = useStyles2(getStyles);
const dispatch = useDispatch();
const folderFilter = useRuleFolderFilter(initialFolder);
const [isAddingGroup, setIsAddingGroup] = useState(false);
const folder = watch('folder');
const group = watch('group');
const [selectedGroup, setSelectedGroup] = useState(group);
const initialRender = useRef(true);
const { groupOptions, groupsForFolder, loading } = useGetGroupOptionsFromFolder(folder?.title ?? '');
useEffect(() => setSelectedGroup(group), [group, setSelectedGroup]);
useEffect(() => {
dispatch(fetchRulerRulesIfNotFetchedYet(GRAFANA_RULES_SOURCE_NAME));
}, [dispatch]);
const resetGroup = useCallback(() => {
if (group && !initialRender.current && folder?.title) {
setSelectedGroup('');
}
initialRender.current = false;
}, [group, folder?.title]);
const groupIsInGroupOptions = useCallback(
(group_: string) => {
return groupOptions.includes((groupInList: SelectableValue<string>) => groupInList.label === group_);
},
[groupOptions]
);
return (
<div className={styles.container}>
<Field
label={
<Label htmlFor="folder" description={'Select a folder for your rule.'}>
<Stack gap={0.5}>
Folder
<InfoIcon
text={
'Each folder has unique folder permission. When you store multiple rules in a folder, the folder access permissions are assigned to the rules.'
}
/>
</Stack>
</Label>
}
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}
filter={folderFilter}
dissalowSlashes={true}
onChange={({ title, uid }) => {
field.onChange({ title, uid });
if (!groupIsInGroupOptions(selectedGroup)) {
setIsAddingGroup(false);
resetGroup();
}
}}
/>
)}
name="folder"
rules={{
required: { value: true, message: 'Select a folder' },
validate: {
pathSeparator: (folder: Folder) => checkForPathSeparator(folder.title),
},
}}
/>
</Field>
<Field
label="Evaluation group (interval)"
data-testid="group-picker"
description="Select a group to evaluate all rules in the same group over the same time interval."
className={styles.formInput}
error={errors.group?.message}
invalid={!!errors.group?.message}
>
<InputControl
render={({ field: { ref, ...field } }) =>
loading ? (
<LoadingPlaceholder text="Loading..." />
) : (
<SelectWithAdd
key={`my_unique_select_key__${folder?.title ?? ''}`}
{...field}
options={groupOptions}
getOptionLabel={(option: SelectableValue<string>) =>
`${option.label} (${getIntervalForGroup(groupsForFolder, option.label ?? '', folder?.title ?? '')})`
}
value={selectedGroup}
custom={isAddingGroup}
onCustomChange={(custom: boolean) => setIsAddingGroup(custom)}
placeholder="Evaluation group name"
onChange={(value: string) => {
field.onChange(value);
setSelectedGroup(value);
}}
/>
)
}
name="group"
control={control}
rules={{
required: { value: true, message: 'Must enter a group name' },
}}
/>
</Field>
</div>
);
}
const getStyles = (theme: GrafanaTheme2) => ({
container: css`
display: flex;
flex-direction: row;
align-items: baseline;
max-width: ${theme.breakpoints.values.sm}px;
justify-content: space-between;
`,
formInput: css`
width: 275px;
& + & {
margin-left: ${theme.spacing(3)};
}
`,
});

@ -1,22 +1,39 @@
import { css } from '@emotion/css';
import React, { useState } from 'react';
import React, { useCallback, useEffect, useState } from 'react';
import { RegisterOptions, useFormContext } from 'react-hook-form';
import { GrafanaTheme2 } from '@grafana/data';
import { Field, InlineLabel, Input, InputControl, useStyles2 } from '@grafana/ui';
import { GrafanaTheme2, SelectableValue } from '@grafana/data';
import { Button, Card, Field, InlineLabel, Input, InputControl, useStyles2 } from '@grafana/ui';
import { RulerRuleDTO, RulerRuleGroupDTO, RulerRulesConfigDTO } from 'app/types/unified-alerting-dto';
import { RuleFormValues } from '../../types/rule-form';
import { checkEvaluationIntervalGlobalLimit } from '../../utils/config';
import { logInfo, LogMessages } from '../../Analytics';
import { useUnifiedAlertingSelector } from '../../hooks/useUnifiedAlertingSelector';
import { RuleForm, RuleFormValues } from '../../types/rule-form';
import { GRAFANA_RULES_SOURCE_NAME } from '../../utils/datasource';
import { parsePrometheusDuration } from '../../utils/time';
import { CollapseToggle } from '../CollapseToggle';
import { EvaluationIntervalLimitExceeded } from '../InvalidIntervalWarning';
import { EditCloudGroupModal } from '../rules/EditRuleGroupModal';
import { MINUTE } from './AlertRuleForm';
import { FolderAndGroup, useGetGroupOptionsFromFolder } from './FolderAndGroup';
import { GrafanaAlertStatePicker } from './GrafanaAlertStatePicker';
import { RuleEditorSection } from './RuleEditorSection';
export const MIN_TIME_RANGE_STEP_S = 10; // 10 seconds
export const forValidationOptions = (evaluateEvery: string): RegisterOptions => ({
export const getIntervalForGroup = (
rulerRules: RulerRulesConfigDTO | null | undefined,
group: string,
folder: string
) => {
const folderObj: Array<RulerRuleGroupDTO<RulerRuleDTO>> = rulerRules ? rulerRules[folder] : [];
const groupObj = folderObj?.find((rule) => rule.name === group);
const interval = groupObj?.interval ?? MINUTE;
return interval;
};
const forValidationOptions = (evaluateEvery: string): RegisterOptions => ({
required: {
value: true,
message: 'Required.',
@ -51,72 +68,124 @@ export const forValidationOptions = (evaluateEvery: string): RegisterOptions =>
},
});
export const evaluateEveryValidationOptions: RegisterOptions = {
required: {
value: true,
message: 'Required.',
},
validate: (value: string) => {
try {
const duration = parsePrometheusDuration(value);
const useIsNewGroup = (folder: string, group: string) => {
const { groupOptions } = useGetGroupOptionsFromFolder(folder);
if (duration < MIN_TIME_RANGE_STEP_S * 1000) {
return `Cannot be less than ${MIN_TIME_RANGE_STEP_S} seconds.`;
}
const groupIsInGroupOptions = useCallback(
(group_: string) => groupOptions.some((groupInList: SelectableValue<string>) => groupInList.label === group_),
[groupOptions]
);
return !groupIsInGroupOptions(group);
};
if (duration % (MIN_TIME_RANGE_STEP_S * 1000) !== 0) {
return `Must be a multiple of ${MIN_TIME_RANGE_STEP_S} seconds.`;
}
function FolderGroupAndEvaluationInterval({
initialFolder,
evaluateEvery,
setEvaluateEvery,
}: {
initialFolder: RuleForm | null;
evaluateEvery: string;
setEvaluateEvery: (value: string) => void;
}) {
const styles = useStyles2(getStyles);
const { watch } = useFormContext<RuleFormValues>();
const [isEditingGroup, setIsEditingGroup] = useState(false);
return true;
} catch (error) {
return error instanceof Error ? error.message : 'Failed to parse duration';
const group = watch('group');
const folder = watch('folder');
const rulerRuleRequests = useUnifiedAlertingSelector((state) => state.rulerRules);
const groupfoldersForGrafana = rulerRuleRequests[GRAFANA_RULES_SOURCE_NAME];
const isNewGroup = useIsNewGroup(folder?.title ?? '', group);
useEffect(() => {
group &&
folder &&
setEvaluateEvery(getIntervalForGroup(groupfoldersForGrafana?.result, group, folder?.title ?? ''));
}, [group, folder, groupfoldersForGrafana?.result, setEvaluateEvery]);
const closeEditGroupModal = (saved = false) => {
if (!saved) {
logInfo(LogMessages.leavingRuleGroupEdit);
}
},
setIsEditingGroup(false);
};
export const GrafanaEvaluationBehavior = () => {
const onOpenEditGroupModal = () => setIsEditingGroup(true);
const editGroupDisabled = groupfoldersForGrafana?.loading || isNewGroup || !folder || !group;
return (
<div>
<FolderAndGroup initialFolder={initialFolder} />
{isEditingGroup && (
<EditCloudGroupModal
groupInterval={evaluateEvery}
nameSpaceAndGroup={{ namespace: folder?.title ?? '', group: group }}
sourceName={GRAFANA_RULES_SOURCE_NAME}
onClose={() => closeEditGroupModal()}
folderAndGroupReadOnly
/>
)}
{folder && group && (
<Card className={styles.cardContainer}>
<Card.Heading>Evaluation behavior</Card.Heading>
<Card.Meta>
<div className={styles.evaluationDescription}>
<div className={styles.evaluateLabel}>
{`Alert rules in the `} <span className={styles.bold}>{group}</span> group are evaluated every{' '}
<span className={styles.bold}>{evaluateEvery}</span>.
</div>
<br />
{!isNewGroup && (
<div>
{`Evaluation group interval applies to every rule within a group. It overwrites intervals defined for existing alert rules.`}
</div>
)}
<br />
</div>
</Card.Meta>
<Card.Actions>
<div className={styles.editGroup}>
{isNewGroup && (
<div className={styles.warningMessage}>
{`To edit the evaluation group interval, save the alert rule.`}
</div>
)}
<Button
icon={'edit'}
type="button"
variant="secondary"
disabled={editGroupDisabled}
onClick={onOpenEditGroupModal}
>
<span>{'Edit evaluation group'}</span>
</Button>
</div>
</Card.Actions>
</Card>
)}
</div>
);
}
function ForInput({ evaluateEvery }: { evaluateEvery: string }) {
const styles = useStyles2(getStyles);
const [showErrorHandling, setShowErrorHandling] = useState(false);
const {
register,
formState: { errors },
watch,
} = useFormContext<RuleFormValues>();
const { exceedsLimit: exceedsGlobalEvaluationLimit } = checkEvaluationIntervalGlobalLimit(watch('evaluateEvery'));
const evaluateEveryId = 'eval-every-input';
const evaluateForId = 'eval-for-input';
return (
// TODO remove "and alert condition" for recording rules
<RuleEditorSection stepNo={3} title="Alert evaluation behavior">
<Field
label="Evaluate"
description="Evaluation interval applies to every rule within a group. It can overwrite the interval of an existing alert rule."
>
<div className={styles.flexRow}>
<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)} />
</Field>
<InlineLabel
htmlFor={evaluateForId}
width={7}
tooltip='Once condition is breached, alert will go into pending state. If it is pending for longer than the "for" value, it will become a firing alert.'
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>
@ -126,15 +195,35 @@ export const GrafanaEvaluationBehavior = () => {
invalid={!!errors.evaluateFor?.message}
validationMessageHorizontalOverflow={true}
>
<Input
id={evaluateForId}
width={8}
{...register('evaluateFor', forValidationOptions(watch('evaluateEvery')))}
/>
<Input id={evaluateForId} width={8} {...register('evaluateFor', forValidationOptions(evaluateEvery))} />
</Field>
</div>
</Field>
{exceedsGlobalEvaluationLimit && <EvaluationIntervalLimitExceeded />}
);
}
export function GrafanaEvaluationBehavior({
initialFolder,
evaluateEvery,
setEvaluateEvery,
}: {
initialFolder: RuleForm | null;
evaluateEvery: string;
setEvaluateEvery: (value: string) => void;
}) {
const styles = useStyles2(getStyles);
const [showErrorHandling, setShowErrorHandling] = useState(false);
return (
// TODO remove "and alert condition" for recording rules
<RuleEditorSection stepNo={3} title="Alert evaluation behavior">
<div className={styles.flexColumn}>
<FolderGroupAndEvaluationInterval
initialFolder={initialFolder}
setEvaluateEvery={setEvaluateEvery}
evaluateEvery={evaluateEvery}
/>
<ForInput evaluateEvery={evaluateEvery} />
</div>
<CollapseToggle
isCollapsed={!showErrorHandling}
onToggle={(collapsed) => setShowErrorHandling(!collapsed)}
@ -177,22 +266,55 @@ export const GrafanaEvaluationBehavior = () => {
)}
</RuleEditorSection>
);
};
}
const getStyles = (theme: GrafanaTheme2) => ({
flexRow: css`
display: flex;
flex-direction: row;
justify-content: flex-start;
align-items: flex-start;
`,
inlineField: css`
margin-bottom: 0;
`,
flexRow: css`
flexColumn: css`
display: flex;
flex-direction: row;
flex-direction: column;
justify-content: flex-start;
align-items: flex-start;
`,
collapseToggle: css`
margin: ${theme.spacing(2, 0, 2, -1)};
`,
globalLimitValue: css`
font-weight: ${theme.typography.fontWeightBold};
evaluateLabel: css`
align-self: left;
margin-right: ${theme.spacing(1)};
`,
cardContainer: css`
max-width: ${theme.breakpoints.values.sm}px;
`,
intervalChangedLabel: css`
margin-bottom: ${theme.spacing(1)};
`,
warningIcon: css`
justify-self: center;
margin-right: ${theme.spacing(1)};
color: ${theme.colors.warning.text};
`,
warningMessage: css`
color: ${theme.colors.warning.text};
`,
editGroup: css`
display: flex;
align-items: center;
justify-content: right;
`,
bold: css`
font-weight: bold;
`,
evaluationDescription: css`
display: flex;
flex-direction: column;
`,
});

@ -15,6 +15,7 @@ interface Props {
width?: number;
disabled?: boolean;
'aria-label'?: string;
getOptionLabel?: ((item: SelectableValue<string>) => React.ReactNode) | undefined;
}
export const SelectWithAdd: FC<Props> = ({
@ -29,13 +30,12 @@ export const SelectWithAdd: FC<Props> = ({
disabled = false,
addLabel = '+ Add new',
'aria-label': ariaLabel,
getOptionLabel,
}) => {
const [isCustom, setIsCustom] = useState(custom);
useEffect(() => {
if (custom) {
setIsCustom(custom);
}
}, [custom]);
const _options = useMemo(
@ -65,6 +65,7 @@ export const SelectWithAdd: FC<Props> = ({
value={value}
className={className}
placeholder={placeholder}
getOptionLabel={getOptionLabel}
disabled={disabled}
onChange={(val: SelectableValue) => {
const value = val?.value;

@ -14,7 +14,7 @@ import { RulerRulesConfigDTO, RulerRuleGroupDTO, RulerRuleDTO } from 'app/types/
import { useUnifiedAlertingSelector } from '../../hooks/useUnifiedAlertingSelector';
import { rulesInSameGroupHaveInvalidFor, updateLotexNamespaceAndGroupAction } from '../../state/actions';
import { checkEvaluationIntervalGlobalLimit } from '../../utils/config';
import { getRulesSourceName } from '../../utils/datasource';
import { GRAFANA_RULES_SOURCE_NAME } from '../../utils/datasource';
import { initialAsyncRequestState } from '../../utils/redux';
import { isAlertingRulerRule, isGrafanaRulerRule } from '../../utils/rules';
import { parsePrometheusDuration } from '../../utils/time';
@ -186,10 +186,20 @@ export const RulesForGroupTable = ({
);
};
interface ModalProps {
interface CombinedGroupAndNameSpace {
namespace: CombinedRuleNamespace;
group: CombinedRuleGroup;
}
interface GroupAndNameSpaceNames {
namespace: string;
group: string;
}
interface ModalProps {
nameSpaceAndGroup: CombinedGroupAndNameSpace | GroupAndNameSpaceNames;
sourceName: string;
groupInterval: string;
onClose: (saved?: boolean) => void;
folderAndGroupReadOnly?: boolean;
}
interface FormValues {
@ -199,22 +209,42 @@ interface FormValues {
}
export function EditCloudGroupModal(props: ModalProps): React.ReactElement {
const { namespace, group, onClose } = props;
const {
nameSpaceAndGroup: { namespace, group },
onClose,
groupInterval,
sourceName,
folderAndGroupReadOnly,
} = props;
const styles = useStyles2(getStyles);
const dispatch = useDispatch();
const { loading, error, dispatched } =
useUnifiedAlertingSelector((state) => state.updateLotexNamespaceAndGroup) ?? initialAsyncRequestState;
const notifyApp = useAppNotification();
const nameSpaceName = typeof namespace === 'string' ? namespace : namespace.name;
const groupName = typeof group === 'string' ? group : group.name;
const defaultValues = useMemo(
(): FormValues => ({
namespaceName: namespace.name,
groupName: group.name,
groupInterval: group.interval ?? '',
namespaceName: nameSpaceName,
groupName: groupName,
groupInterval: groupInterval ?? '',
}),
[namespace, group]
[nameSpaceName, groupName, groupInterval]
);
const isGrafanaManagedGroup = sourceName === GRAFANA_RULES_SOURCE_NAME;
const nameSpaceLabel = isGrafanaManagedGroup ? 'Folder' : 'Namespace';
const nameSpaceInfoIconLabelEditable = isGrafanaManagedGroup
? 'Folder name can be updated to a non-existing folder name'
: 'Name space can be updated to a non-existing name space';
const nameSpaceInfoIconLabelNonEditable = isGrafanaManagedGroup
? 'Folder name can be updated in folder view'
: 'Name space can be updated folder view';
const spaceNameInfoIconLabel = folderAndGroupReadOnly
? nameSpaceInfoIconLabelNonEditable
: nameSpaceInfoIconLabelEditable;
// close modal if successfully saved
useEffect(() => {
if (dispatched && !loading && !error) {
@ -223,14 +253,13 @@ export function EditCloudGroupModal(props: ModalProps): React.ReactElement {
}, [dispatched, loading, onClose, error]);
useCleanup((state) => (state.unifiedAlerting.updateLotexNamespaceAndGroup = initialAsyncRequestState));
const onSubmit = (values: FormValues) => {
dispatch(
updateLotexNamespaceAndGroupAction({
rulesSourceName: getRulesSourceName(namespace.rulesSource),
groupName: group.name,
rulesSourceName: sourceName,
groupName: groupName,
newGroupName: values.groupName,
namespaceName: namespace.name,
namespaceName: nameSpaceName,
newNamespaceName: values.namespaceName,
groupInterval: values.groupInterval || undefined,
})
@ -254,7 +283,7 @@ export function EditCloudGroupModal(props: ModalProps): React.ReactElement {
};
const rulerRuleRequests = useUnifiedAlertingSelector((state) => state.rulerRules);
const groupfoldersForSource = rulerRuleRequests[getRulesSourceName(namespace.rulesSource)];
const groupfoldersForSource = rulerRuleRequests[sourceName];
const evaluateEveryValidationOptions: RegisterOptions = {
required: {
@ -273,7 +302,7 @@ export function EditCloudGroupModal(props: ModalProps): React.ReactElement {
return `Must be a multiple of ${MIN_TIME_RANGE_STEP_S} seconds.`;
}
if (
rulesInSameGroupHaveInvalidFor(groupfoldersForSource.result, group.name, namespace.name, value).length === 0
rulesInSameGroupHaveInvalidFor(groupfoldersForSource.result, groupName, nameSpaceName, value).length === 0
) {
return true;
} else {
@ -289,7 +318,7 @@ export function EditCloudGroupModal(props: ModalProps): React.ReactElement {
<Modal
className={styles.modal}
isOpen={true}
title="Edit namespace or evaluation group"
title={folderAndGroupReadOnly ? 'Edit evaluation group' : `Edit ${nameSpaceLabel} or evaluation group`}
onDismiss={onClose}
onClickBackdrop={onClose}
>
@ -300,8 +329,8 @@ export function EditCloudGroupModal(props: ModalProps): React.ReactElement {
label={
<Label htmlFor="namespaceName">
<Stack gap={0.5}>
NameSpace
<InfoIcon text={'Name space can be updated'} />
{nameSpaceLabel}
<InfoIcon text={spaceNameInfoIconLabel} />
</Stack>
</Label>
}
@ -310,6 +339,7 @@ export function EditCloudGroupModal(props: ModalProps): React.ReactElement {
>
<Input
id="namespaceName"
readOnly={folderAndGroupReadOnly}
{...register('namespaceName', {
required: 'Namespace name is required.',
})}
@ -320,7 +350,11 @@ export function EditCloudGroupModal(props: ModalProps): React.ReactElement {
<Label htmlFor="groupName">
<Stack gap={0.5}>
Evaluation group
<InfoIcon text={'Group name can be updated'} />
{isGrafanaManagedGroup ? (
<InfoIcon text={'Group name can be updated on Group view.'} />
) : (
<InfoIcon text={'Group name can be updated to a non existing group name.'} />
)}
</Stack>
</Label>
}
@ -329,6 +363,7 @@ export function EditCloudGroupModal(props: ModalProps): React.ReactElement {
>
<Input
id="groupName"
readOnly={folderAndGroupReadOnly}
{...register('groupName', {
required: 'Evaluation group name is required.',
})}
@ -367,8 +402,8 @@ export function EditCloudGroupModal(props: ModalProps): React.ReactElement {
</div>
<RulesForGroupTable
rulerRules={groupfoldersForSource?.result}
groupName={group.name}
folderName={namespace.name}
groupName={groupName}
folderName={nameSpaceName}
/>
</>
)}

@ -13,7 +13,7 @@ import { useFolder } from '../../hooks/useFolder';
import { useHasRuler } from '../../hooks/useHasRuler';
import { deleteRulesGroupAction } from '../../state/actions';
import { useRulesAccess } from '../../utils/accessControlHooks';
import { GRAFANA_RULES_SOURCE_NAME, isCloudRulesSource } from '../../utils/datasource';
import { getRulesSourceName, GRAFANA_RULES_SOURCE_NAME, isCloudRulesSource } from '../../utils/datasource';
import { makeFolderLink } from '../../utils/misc';
import { isFederatedRuleGroup, isGrafanaRulerRule } from '../../utils/rules';
import { CollapseToggle } from '../CollapseToggle';
@ -224,7 +224,14 @@ export const RulesGroup: FC<Props> = React.memo(({ group, namespace, expandAll,
{!isCollapsed && (
<RulesTable showSummaryColumn={true} className={styles.rulesTable} showGuidelines={true} rules={group.rules} />
)}
{isEditingGroup && <EditCloudGroupModal group={group} namespace={namespace} onClose={() => closeEditModal()} />}
{isEditingGroup && (
<EditCloudGroupModal
groupInterval={group.interval ?? ''}
nameSpaceAndGroup={{ group: group, namespace: namespace }}
sourceName={getRulesSourceName(namespace.rulesSource)}
onClose={() => closeEditModal()}
/>
)}
{isReorderingGroup && (
<ReorderCloudGroupModal group={group} namespace={namespace} onClose={() => setIsReorderingGroup(false)} />
)}

@ -443,10 +443,13 @@ export const saveRuleFormAction = createAsyncThunk(
values,
existing,
redirectOnSave,
evaluateEvery,
}: {
values: RuleFormValues;
existing?: RuleWithLocation;
redirectOnSave?: string;
initialAlertRuleName?: string;
evaluateEvery: string;
},
thunkAPI
): Promise<void> =>
@ -463,15 +466,18 @@ export const saveRuleFormAction = createAsyncThunk(
if (!values.dataSourceName) {
throw new Error('The Data source has not been defined.');
}
const rulerConfig = getDataSourceRulerConfig(thunkAPI.getState, values.dataSourceName);
const rulerClient = getRulerClient(rulerConfig);
identifier = await rulerClient.saveLotexRule(values, existing);
identifier = await rulerClient.saveLotexRule(values, evaluateEvery, existing);
await thunkAPI.dispatch(fetchRulerRulesAction({ rulesSourceName: values.dataSourceName }));
// in case of grafana managed
} else if (type === RuleFormType.grafana) {
const rulerConfig = getDataSourceRulerConfig(thunkAPI.getState, GRAFANA_RULES_SOURCE_NAME);
const rulerClient = getRulerClient(rulerConfig);
identifier = await rulerClient.saveGrafanaRule(values, existing);
identifier = await rulerClient.saveGrafanaRule(values, evaluateEvery, existing);
await thunkAPI.dispatch(fetchRulerRulesAction({ rulesSourceName: GRAFANA_RULES_SOURCE_NAME }));
} else {
throw new Error('Unexpected rule form type');
}
@ -765,7 +771,9 @@ export const rulesInSameGroupHaveInvalidFor = (
return rulesSameGroup.filter((rule: RulerRuleDTO) => {
const { forDuration } = getAlertInfo(rule, everyDuration);
return safeParseDurationstr(forDuration) < safeParseDurationstr(everyDuration);
const forNumber = safeParseDurationstr(forDuration);
const everyNumber = safeParseDurationstr(everyDuration);
return forNumber !== 0 && forNumber < everyNumber;
});
};

@ -27,7 +27,6 @@ export interface RuleFormValues {
noDataState: GrafanaAlertStateDecision;
execErrState: GrafanaAlertStateDecision;
folder: RuleForm | null;
evaluateEvery: string;
evaluateFor: string;
// cortex / loki rules

@ -59,7 +59,6 @@ export const getDefaultFormValues = (): RuleFormValues => {
condition: '',
noDataState: GrafanaAlertStateDecision.NoData,
execErrState: GrafanaAlertStateDecision.Error,
evaluateEvery: '1m',
evaluateFor: '5m',
// cortex / loki
@ -126,7 +125,6 @@ export function rulerRuleToFormValues(ruleWithLocation: RuleWithLocation): RuleF
type: RuleFormType.grafana,
group: group.name,
evaluateFor: rule.for || '0',
evaluateEvery: group.interval || defaultFormValues.evaluateEvery,
noDataState: ga.no_data_state,
execErrState: ga.exec_err_state,
queries: ga.data,

@ -22,8 +22,8 @@ import {
export interface RulerClient {
findEditableRule(ruleIdentifier: RuleIdentifier): Promise<RuleWithLocation | null>;
deleteRule(ruleWithLocation: RuleWithLocation): Promise<void>;
saveLotexRule(values: RuleFormValues, existing?: RuleWithLocation): Promise<RuleIdentifier>;
saveGrafanaRule(values: RuleFormValues, existing?: RuleWithLocation): Promise<RuleIdentifier>;
saveLotexRule(values: RuleFormValues, evaluateEvery: string, existing?: RuleWithLocation): Promise<RuleIdentifier>;
saveGrafanaRule(values: RuleFormValues, evaluateEvery: string, existing?: RuleWithLocation): Promise<RuleIdentifier>;
}
export function getRulerClient(rulerConfig: RulerDataSourceConfig): RulerClient {
@ -95,7 +95,11 @@ export function getRulerClient(rulerConfig: RulerDataSourceConfig): RulerClient
});
};
const saveLotexRule = async (values: RuleFormValues, existing?: RuleWithLocation): Promise<RuleIdentifier> => {
const saveLotexRule = async (
values: RuleFormValues,
evaluateEvery: string,
existing?: RuleWithLocation
): Promise<RuleIdentifier> => {
const { dataSourceName, group, namespace } = values;
const formRule = formValuesToRulerRuleDTO(values);
if (dataSourceName && group && namespace) {
@ -116,6 +120,7 @@ export function getRulerClient(rulerConfig: RulerDataSourceConfig): RulerClient
rules: freshExisting.group.rules.map((existingRule) =>
existingRule === freshExisting.rule ? formRule : existingRule
),
evaluateEvery: evaluateEvery,
};
await setRulerRuleGroup(rulerConfig, namespace, payload);
return ruleId.fromRulerRule(dataSourceName, namespace, group, formRule);
@ -143,8 +148,12 @@ export function getRulerClient(rulerConfig: RulerDataSourceConfig): RulerClient
}
};
const saveGrafanaRule = async (values: RuleFormValues, existingRule?: RuleWithLocation): Promise<RuleIdentifier> => {
const { folder, group, evaluateEvery } = values;
const saveGrafanaRule = async (
values: RuleFormValues,
evaluateEvery: string,
existingRule?: RuleWithLocation
): Promise<RuleIdentifier> => {
const { folder, group } = values;
if (!folder) {
throw new Error('Folder must be specified');
}

Loading…
Cancel
Save