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

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

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

@ -1,43 +1,19 @@
import { css } from '@emotion/css'; import React from 'react';
import classNames from 'classnames';
import React, { useCallback } from 'react';
import { useFormContext } from 'react-hook-form'; import { useFormContext } from 'react-hook-form';
import { GrafanaTheme2 } from '@grafana/data'; import { RuleFormType, RuleFormValues } from '../../types/rule-form';
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 AnnotationsField from './AnnotationsField'; import AnnotationsField from './AnnotationsField';
import { GroupAndNamespaceFields } from './GroupAndNamespaceFields'; import { GroupAndNamespaceFields } from './GroupAndNamespaceFields';
import { RuleEditorSection } from './RuleEditorSection'; import { RuleEditorSection } from './RuleEditorSection';
import { RuleFolderPicker, Folder, containsSlashes } from './RuleFolderPicker';
import { checkForPathSeparator } from './util';
interface DetailsStepProps {
initialFolder: RuleForm | null;
}
export const DetailsStep = ({ initialFolder }: DetailsStepProps) => { export function DetailsStep() {
const { const { watch } = useFormContext<RuleFormValues & { location?: string }>();
register,
watch,
formState: { errors },
} = useFormContext<RuleFormValues & { location?: string }>();
const styles = useStyles2(getStyles);
const ruleFormType = watch('type'); const ruleFormType = watch('type');
const dataSourceName = watch('dataSourceName'); const dataSourceName = watch('dataSourceName');
const type = watch('type'); const type = watch('type');
const folderFilter = useRuleFolderFilter(initialFolder);
return ( return (
<RuleEditorSection <RuleEditorSection
stepNo={type === RuleFormType.cloudRecording ? 3 : 4} stepNo={type === RuleFormType.cloudRecording ? 3 : 4}
@ -53,119 +29,7 @@ export const DetailsStep = ({ initialFolder }: DetailsStepProps) => {
{(ruleFormType === RuleFormType.cloudRecording || ruleFormType === RuleFormType.cloudAlerting) && {(ruleFormType === RuleFormType.cloudRecording || ruleFormType === RuleFormType.cloudAlerting) &&
dataSourceName && <GroupAndNamespaceFields rulesSourceName={dataSourceName} />} 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 />} {type !== RuleFormType.cloudRecording && <AnnotationsField />}
</RuleEditorSection> </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 { css } from '@emotion/css';
import React, { useState } from 'react'; import React, { useCallback, useEffect, useState } from 'react';
import { RegisterOptions, useFormContext } from 'react-hook-form'; import { RegisterOptions, useFormContext } from 'react-hook-form';
import { GrafanaTheme2 } from '@grafana/data'; import { GrafanaTheme2, SelectableValue } from '@grafana/data';
import { Field, InlineLabel, Input, InputControl, useStyles2 } from '@grafana/ui'; 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 { logInfo, LogMessages } from '../../Analytics';
import { checkEvaluationIntervalGlobalLimit } from '../../utils/config'; 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 { parsePrometheusDuration } from '../../utils/time';
import { CollapseToggle } from '../CollapseToggle'; 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 { GrafanaAlertStatePicker } from './GrafanaAlertStatePicker';
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
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: { required: {
value: true, value: true,
message: 'Required.', message: 'Required.',
@ -51,72 +68,124 @@ export const forValidationOptions = (evaluateEvery: string): RegisterOptions =>
}, },
}); });
export const evaluateEveryValidationOptions: RegisterOptions = { const useIsNewGroup = (folder: string, group: string) => {
required: { const { groupOptions } = useGetGroupOptionsFromFolder(folder);
value: true,
message: 'Required.',
},
validate: (value: string) => {
try {
const duration = parsePrometheusDuration(value);
if (duration < MIN_TIME_RANGE_STEP_S * 1000) { const groupIsInGroupOptions = useCallback(
return `Cannot be less than ${MIN_TIME_RANGE_STEP_S} seconds.`; (group_: string) => groupOptions.some((groupInList: SelectableValue<string>) => groupInList.label === group_),
} [groupOptions]
);
return !groupIsInGroupOptions(group);
};
if (duration % (MIN_TIME_RANGE_STEP_S * 1000) !== 0) { function FolderGroupAndEvaluationInterval({
return `Must be a multiple of ${MIN_TIME_RANGE_STEP_S} seconds.`; 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; const group = watch('group');
} catch (error) { const folder = watch('folder');
return error instanceof Error ? error.message : 'Failed to parse duration';
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 styles = useStyles2(getStyles);
const [showErrorHandling, setShowErrorHandling] = useState(false);
const { const {
register, register,
formState: { errors }, formState: { errors },
watch,
} = useFormContext<RuleFormValues>(); } = useFormContext<RuleFormValues>();
const { exceedsLimit: exceedsGlobalEvaluationLimit } = checkEvaluationIntervalGlobalLimit(watch('evaluateEvery'));
const evaluateEveryId = 'eval-every-input';
const evaluateForId = 'eval-for-input'; const evaluateForId = 'eval-for-input';
return ( 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}> <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 <InlineLabel
htmlFor={evaluateForId} htmlFor={evaluateForId}
width={7} 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 for
</InlineLabel> </InlineLabel>
@ -126,15 +195,35 @@ export const GrafanaEvaluationBehavior = () => {
invalid={!!errors.evaluateFor?.message} invalid={!!errors.evaluateFor?.message}
validationMessageHorizontalOverflow={true} validationMessageHorizontalOverflow={true}
> >
<Input <Input id={evaluateForId} width={8} {...register('evaluateFor', forValidationOptions(evaluateEvery))} />
id={evaluateForId}
width={8}
{...register('evaluateFor', forValidationOptions(watch('evaluateEvery')))}
/>
</Field> </Field>
</div> </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 <CollapseToggle
isCollapsed={!showErrorHandling} isCollapsed={!showErrorHandling}
onToggle={(collapsed) => setShowErrorHandling(!collapsed)} onToggle={(collapsed) => setShowErrorHandling(!collapsed)}
@ -177,22 +266,55 @@ export const GrafanaEvaluationBehavior = () => {
)} )}
</RuleEditorSection> </RuleEditorSection>
); );
}; }
const getStyles = (theme: GrafanaTheme2) => ({ const getStyles = (theme: GrafanaTheme2) => ({
flexRow: css`
display: flex;
flex-direction: row;
justify-content: flex-start;
align-items: flex-start;
`,
inlineField: css` inlineField: css`
margin-bottom: 0; margin-bottom: 0;
`, `,
flexRow: css` flexColumn: css`
display: flex; display: flex;
flex-direction: row; flex-direction: column;
justify-content: flex-start; justify-content: flex-start;
align-items: flex-start; align-items: flex-start;
`, `,
collapseToggle: css` collapseToggle: css`
margin: ${theme.spacing(2, 0, 2, -1)}; margin: ${theme.spacing(2, 0, 2, -1)};
`, `,
globalLimitValue: css` evaluateLabel: css`
font-weight: ${theme.typography.fontWeightBold}; 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; width?: number;
disabled?: boolean; disabled?: boolean;
'aria-label'?: string; 'aria-label'?: string;
getOptionLabel?: ((item: SelectableValue<string>) => React.ReactNode) | undefined;
} }
export const SelectWithAdd: FC<Props> = ({ export const SelectWithAdd: FC<Props> = ({
@ -29,13 +30,12 @@ export const SelectWithAdd: FC<Props> = ({
disabled = false, disabled = false,
addLabel = '+ Add new', addLabel = '+ Add new',
'aria-label': ariaLabel, 'aria-label': ariaLabel,
getOptionLabel,
}) => { }) => {
const [isCustom, setIsCustom] = useState(custom); const [isCustom, setIsCustom] = useState(custom);
useEffect(() => { useEffect(() => {
if (custom) {
setIsCustom(custom); setIsCustom(custom);
}
}, [custom]); }, [custom]);
const _options = useMemo( const _options = useMemo(
@ -65,6 +65,7 @@ export const SelectWithAdd: FC<Props> = ({
value={value} value={value}
className={className} className={className}
placeholder={placeholder} placeholder={placeholder}
getOptionLabel={getOptionLabel}
disabled={disabled} disabled={disabled}
onChange={(val: SelectableValue) => { onChange={(val: SelectableValue) => {
const value = val?.value; const value = val?.value;

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

@ -13,7 +13,7 @@ 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 { GRAFANA_RULES_SOURCE_NAME, isCloudRulesSource } from '../../utils/datasource'; import { getRulesSourceName, GRAFANA_RULES_SOURCE_NAME, isCloudRulesSource } from '../../utils/datasource';
import { makeFolderLink } from '../../utils/misc'; import { makeFolderLink } from '../../utils/misc';
import { isFederatedRuleGroup, isGrafanaRulerRule } from '../../utils/rules'; import { isFederatedRuleGroup, isGrafanaRulerRule } from '../../utils/rules';
import { CollapseToggle } from '../CollapseToggle'; import { CollapseToggle } from '../CollapseToggle';
@ -224,7 +224,14 @@ export const RulesGroup: FC<Props> = React.memo(({ group, namespace, expandAll,
{!isCollapsed && ( {!isCollapsed && (
<RulesTable showSummaryColumn={true} className={styles.rulesTable} showGuidelines={true} rules={group.rules} /> <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 && ( {isReorderingGroup && (
<ReorderCloudGroupModal group={group} namespace={namespace} onClose={() => setIsReorderingGroup(false)} /> <ReorderCloudGroupModal group={group} namespace={namespace} onClose={() => setIsReorderingGroup(false)} />
)} )}

@ -443,10 +443,13 @@ export const saveRuleFormAction = createAsyncThunk(
values, values,
existing, existing,
redirectOnSave, redirectOnSave,
evaluateEvery,
}: { }: {
values: RuleFormValues; values: RuleFormValues;
existing?: RuleWithLocation; existing?: RuleWithLocation;
redirectOnSave?: string; redirectOnSave?: string;
initialAlertRuleName?: string;
evaluateEvery: string;
}, },
thunkAPI thunkAPI
): Promise<void> => ): Promise<void> =>
@ -463,15 +466,18 @@ export const saveRuleFormAction = createAsyncThunk(
if (!values.dataSourceName) { if (!values.dataSourceName) {
throw new Error('The Data source has not been defined.'); throw new Error('The Data source has not been defined.');
} }
const rulerConfig = getDataSourceRulerConfig(thunkAPI.getState, values.dataSourceName); const rulerConfig = getDataSourceRulerConfig(thunkAPI.getState, values.dataSourceName);
const rulerClient = getRulerClient(rulerConfig); 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 // in case of grafana managed
} else if (type === RuleFormType.grafana) { } else if (type === RuleFormType.grafana) {
const rulerConfig = getDataSourceRulerConfig(thunkAPI.getState, GRAFANA_RULES_SOURCE_NAME); const rulerConfig = getDataSourceRulerConfig(thunkAPI.getState, GRAFANA_RULES_SOURCE_NAME);
const rulerClient = getRulerClient(rulerConfig); 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 { } else {
throw new Error('Unexpected rule form type'); throw new Error('Unexpected rule form type');
} }
@ -765,7 +771,9 @@ export const rulesInSameGroupHaveInvalidFor = (
return rulesSameGroup.filter((rule: RulerRuleDTO) => { return rulesSameGroup.filter((rule: RulerRuleDTO) => {
const { forDuration } = getAlertInfo(rule, everyDuration); 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; noDataState: GrafanaAlertStateDecision;
execErrState: GrafanaAlertStateDecision; execErrState: GrafanaAlertStateDecision;
folder: RuleForm | null; folder: RuleForm | null;
evaluateEvery: string;
evaluateFor: string; evaluateFor: string;
// cortex / loki rules // cortex / loki rules

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

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

Loading…
Cancel
Save