From 99725bf9d44ae2ba6e9c30d00c511ba51a877af9 Mon Sep 17 00:00:00 2001 From: Sonia Aguilar <33540275+soniaAguilarPeiron@users.noreply.github.com> Date: Mon, 21 Nov 2022 17:59:19 +0100 Subject: [PATCH] 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 --- .../alerting/unified/RuleEditor.test.tsx | 12 +- .../components/rule-editor/AlertRuleForm.tsx | 20 +- .../rule-editor/AnnotationsField.tsx | 2 +- .../components/rule-editor/DetailsStep.tsx | 146 +-------- .../components/rule-editor/FolderAndGroup.tsx | 226 ++++++++++++++ .../rule-editor/GrafanaEvaluationBehavior.tsx | 288 +++++++++++++----- .../components/rule-editor/SelectWIthAdd.tsx | 7 +- .../components/rules/EditRuleGroupModal.tsx | 75 +++-- .../unified/components/rules/RulesGroup.tsx | 11 +- .../alerting/unified/state/actions.ts | 14 +- .../alerting/unified/types/rule-form.ts | 1 - .../alerting/unified/utils/rule-form.ts | 2 - .../alerting/unified/utils/rulerClient.ts | 19 +- 13 files changed, 554 insertions(+), 269 deletions(-) create mode 100644 public/app/features/alerting/unified/components/rule-editor/FolderAndGroup.tsx diff --git a/public/app/features/alerting/unified/RuleEditor.test.tsx b/public/app/features/alerting/unified/RuleEditor.test.tsx index 3db03b1bdcd..bad0f3cc75d 100644 --- a/public/app/features/alerting/unified/RuleEditor.test.tsx +++ b/public/app/features/alerting/unified/RuleEditor.test.tsx @@ -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' }, diff --git a/public/app/features/alerting/unified/components/rule-editor/AlertRuleForm.tsx b/public/app/features/alerting/unified/components/rule-editor/AlertRuleForm.tsx index 270fdf07d1d..f2d4df23756 100644 --- a/public/app/features/alerting/unified/components/rule-editor/AlertRuleForm.tsx +++ b/public/app/features/alerting/unified/components/rule-editor/AlertRuleForm.tsx @@ -65,6 +65,8 @@ const AlertRuleNameInput = () => { ); }; +export const MINUTE = '1m'; + type Props = { existing?: RuleWithLocation; }; @@ -75,6 +77,7 @@ export const AlertRuleForm: FC = ({ 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(false); @@ -89,8 +92,9 @@ export const AlertRuleForm: FC = ({ existing }) => { condition: 'C', ...(queryParams['defaults'] ? JSON.parse(queryParams['defaults'] as string) : {}), type: RuleFormType.grafana, + evaluateEvery: evaluateEvery, }; - }, [existing, queryParams]); + }, [existing, queryParams, evaluateEvery]); const formAPI = useForm({ mode: 'onSubmit', @@ -125,6 +129,8 @@ export const AlertRuleForm: FC = ({ existing }) => { }, existing, redirectOnSave: exitOnSave ? returnTo : undefined, + initialAlertRuleName: defaultValues.name, + evaluateEvery: evaluateEvery, }) ); }; @@ -202,8 +208,16 @@ export const AlertRuleForm: FC = ({ existing }) => { {showStep2 && ( <> - {type === RuleFormType.grafana ? : } - + {type === RuleFormType.grafana ? ( + + ) : ( + + )} + )} diff --git a/public/app/features/alerting/unified/components/rule-editor/AnnotationsField.tsx b/public/app/features/alerting/unified/components/rule-editor/AnnotationsField.tsx index 774f1bb4506..be34a7d3e4c 100644 --- a/public/app/features/alerting/unified/components/rule-editor/AnnotationsField.tsx +++ b/public/app/features/alerting/unified/components/rule-editor/AnnotationsField.tsx @@ -99,7 +99,7 @@ const AnnotationsField = () => { const getStyles = (theme: GrafanaTheme2) => ({ annotationValueInput: css` - width: 426px; + width: 394px; `, textarea: css` height: 76px; diff --git a/public/app/features/alerting/unified/components/rule-editor/DetailsStep.tsx b/public/app/features/alerting/unified/components/rule-editor/DetailsStep.tsx index 099654102a7..c40b8fa405f 100644 --- a/public/app/features/alerting/unified/components/rule-editor/DetailsStep.tsx +++ b/public/app/features/alerting/unified/components/rule-editor/DetailsStep.tsx @@ -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(); - - const styles = useStyles2(getStyles); +export function DetailsStep() { + const { watch } = useFormContext(); const ruleFormType = watch('type'); const dataSourceName = watch('dataSourceName'); const type = watch('type'); - const folderFilter = useRuleFolderFilter(initialFolder); - return ( { {(ruleFormType === RuleFormType.cloudRecording || ruleFormType === RuleFormType.cloudAlerting) && dataSourceName && } - {ruleFormType === RuleFormType.grafana && ( -
- - - Folder - - Each folder has unique folder permission. When you store multiple rules in a folder, the folder - access permissions get assigned to the rules. -
- } - > - - - - - } - className={styles.formInput} - error={errors.folder?.message} - invalid={!!errors.folder?.message} - data-testid="folder-picker" - > - ( - - )} - name="folder" - rules={{ - required: { value: true, message: 'Please select a folder' }, - validate: { - pathSeparator: (folder: Folder) => checkForPathSeparator(folder.title), - }, - }} - /> - - - - - - )} {type !== RuleFormType.cloudRecording && }
); -}; - -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( - (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; - `, -}); +} diff --git a/public/app/features/alerting/unified/components/rule-editor/FolderAndGroup.tsx b/public/app/features/alerting/unified/components/rule-editor/FolderAndGroup.tsx new file mode 100644 index 00000000000..be1e2ee99b7 --- /dev/null +++ b/public/app/features/alerting/unified/components/rule-editor/FolderAndGroup.tsx @@ -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> = groupfoldersForGrafana + ? groupfoldersForGrafana[folderName] ?? [] + : []; + return groupsForFolderResult.map((group) => group.name); + }, [groupfoldersForGrafana, folderName]); + + return groupOptions; +}; + +function mapGroupsToOptions(groups: string[]): Array> { + 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> = 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( + (folderHits) => + folderHits + .filter(isSearchHitAvailable) + .filter((value: DashboardSearchHit) => !containsSlashes(value.title ?? '')), + [isSearchHitAvailable] + ); +}; + +export function FolderAndGroup({ initialFolder }: FolderAndGroupProps) { + const { + formState: { errors }, + watch, + control, + } = useFormContext(); + + 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) => groupInList.label === group_); + }, + [groupOptions] + ); + + return ( +
+ + + Folder + + + + } + className={styles.formInput} + error={errors.folder?.message} + invalid={!!errors.folder?.message} + data-testid="folder-picker" + > + ( + { + 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), + }, + }} + /> + + + + + loading ? ( + + ) : ( + ) => + `${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' }, + }} + /> + +
+ ); +} +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)}; + } + `, +}); diff --git a/public/app/features/alerting/unified/components/rule-editor/GrafanaEvaluationBehavior.tsx b/public/app/features/alerting/unified/components/rule-editor/GrafanaEvaluationBehavior.tsx index 19453d7f81f..b52eaac7bd2 100644 --- a/public/app/features/alerting/unified/components/rule-editor/GrafanaEvaluationBehavior.tsx +++ b/public/app/features/alerting/unified/components/rule-editor/GrafanaEvaluationBehavior.tsx @@ -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> = 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,90 +68,162 @@ 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.`; - } - - if (duration % (MIN_TIME_RANGE_STEP_S * 1000) !== 0) { - return `Must be a multiple of ${MIN_TIME_RANGE_STEP_S} seconds.`; - } - - return true; - } catch (error) { - return error instanceof Error ? error.message : 'Failed to parse duration'; - } - }, + const groupIsInGroupOptions = useCallback( + (group_: string) => groupOptions.some((groupInList: SelectableValue) => groupInList.label === group_), + [groupOptions] + ); + return !groupIsInGroupOptions(group); }; -export const GrafanaEvaluationBehavior = () => { +function FolderGroupAndEvaluationInterval({ + initialFolder, + evaluateEvery, + setEvaluateEvery, +}: { + initialFolder: RuleForm | null; + evaluateEvery: string; + setEvaluateEvery: (value: string) => void; +}) { + const styles = useStyles2(getStyles); + const { watch } = useFormContext(); + const [isEditingGroup, setIsEditingGroup] = useState(false); + + 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); + }; + + const onOpenEditGroupModal = () => setIsEditingGroup(true); + + const editGroupDisabled = groupfoldersForGrafana?.loading || isNewGroup || !folder || !group; + + return ( +
+ + {isEditingGroup && ( + closeEditGroupModal()} + folderAndGroupReadOnly + /> + )} + {folder && group && ( + + Evaluation behavior + +
+
+ {`Alert rules in the `} {group} group are evaluated every{' '} + {evaluateEvery}. +
+ +
+ {!isNewGroup && ( +
+ {`Evaluation group interval applies to every rule within a group. It overwrites intervals defined for existing alert rules.`} +
+ )} +
+
+
+ +
+ {isNewGroup && ( +
+ {`To edit the evaluation group interval, save the alert rule.`} +
+ )} + +
+
+
+ )} +
+ ); +} + +function ForInput({ evaluateEvery }: { evaluateEvery: string }) { const styles = useStyles2(getStyles); - const [showErrorHandling, setShowErrorHandling] = useState(false); const { register, formState: { errors }, - watch, } = useFormContext(); - const { exceedsLimit: exceedsGlobalEvaluationLimit } = checkEvaluationIntervalGlobalLimit(watch('evaluateEvery')); - - const evaluateEveryId = 'eval-every-input'; const evaluateForId = 'eval-for-input'; + return ( +
+ + for + + + + +
+ ); +} + +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 - -
- - Evaluate every - - - - - - - for - - - - -
-
- {exceedsGlobalEvaluationLimit && } +
+ + +
setShowErrorHandling(!collapsed)} @@ -177,22 +266,55 @@ export const GrafanaEvaluationBehavior = () => { )}
); -}; +} const getStyles = (theme: GrafanaTheme2) => ({ - inlineField: css` - margin-bottom: 0; - `, flexRow: css` display: flex; flex-direction: row; justify-content: flex-start; align-items: flex-start; `, + inlineField: css` + margin-bottom: 0; + `, + flexColumn: css` + display: flex; + 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; `, }); diff --git a/public/app/features/alerting/unified/components/rule-editor/SelectWIthAdd.tsx b/public/app/features/alerting/unified/components/rule-editor/SelectWIthAdd.tsx index 351bb234a15..8a05c83cc0a 100644 --- a/public/app/features/alerting/unified/components/rule-editor/SelectWIthAdd.tsx +++ b/public/app/features/alerting/unified/components/rule-editor/SelectWIthAdd.tsx @@ -15,6 +15,7 @@ interface Props { width?: number; disabled?: boolean; 'aria-label'?: string; + getOptionLabel?: ((item: SelectableValue) => React.ReactNode) | undefined; } export const SelectWithAdd: FC = ({ @@ -29,13 +30,12 @@ export const SelectWithAdd: FC = ({ disabled = false, addLabel = '+ Add new', 'aria-label': ariaLabel, + getOptionLabel, }) => { const [isCustom, setIsCustom] = useState(custom); useEffect(() => { - if (custom) { - setIsCustom(custom); - } + setIsCustom(custom); }, [custom]); const _options = useMemo( @@ -65,6 +65,7 @@ export const SelectWithAdd: FC = ({ value={value} className={className} placeholder={placeholder} + getOptionLabel={getOptionLabel} disabled={disabled} onChange={(val: SelectableValue) => { const value = val?.value; diff --git a/public/app/features/alerting/unified/components/rules/EditRuleGroupModal.tsx b/public/app/features/alerting/unified/components/rules/EditRuleGroupModal.tsx index 2dafb3caf12..24376df0e68 100644 --- a/public/app/features/alerting/unified/components/rules/EditRuleGroupModal.tsx +++ b/public/app/features/alerting/unified/components/rules/EditRuleGroupModal.tsx @@ -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 { @@ -300,8 +329,8 @@ export function EditCloudGroupModal(props: ModalProps): React.ReactElement { label={ } @@ -310,6 +339,7 @@ export function EditCloudGroupModal(props: ModalProps): React.ReactElement { > Evaluation group - + {isGrafanaManagedGroup ? ( + + ) : ( + + )} } @@ -329,6 +363,7 @@ export function EditCloudGroupModal(props: ModalProps): React.ReactElement { > )} diff --git a/public/app/features/alerting/unified/components/rules/RulesGroup.tsx b/public/app/features/alerting/unified/components/rules/RulesGroup.tsx index c963e732964..eec77d0658b 100644 --- a/public/app/features/alerting/unified/components/rules/RulesGroup.tsx +++ b/public/app/features/alerting/unified/components/rules/RulesGroup.tsx @@ -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 = React.memo(({ group, namespace, expandAll, {!isCollapsed && ( )} - {isEditingGroup && closeEditModal()} />} + {isEditingGroup && ( + closeEditModal()} + /> + )} {isReorderingGroup && ( setIsReorderingGroup(false)} /> )} diff --git a/public/app/features/alerting/unified/state/actions.ts b/public/app/features/alerting/unified/state/actions.ts index b3c34b512c7..b2ed0b227ed 100644 --- a/public/app/features/alerting/unified/state/actions.ts +++ b/public/app/features/alerting/unified/state/actions.ts @@ -443,10 +443,13 @@ export const saveRuleFormAction = createAsyncThunk( values, existing, redirectOnSave, + evaluateEvery, }: { values: RuleFormValues; existing?: RuleWithLocation; redirectOnSave?: string; + initialAlertRuleName?: string; + evaluateEvery: string; }, thunkAPI ): Promise => @@ -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; }); }; diff --git a/public/app/features/alerting/unified/types/rule-form.ts b/public/app/features/alerting/unified/types/rule-form.ts index 3a097ab778b..b87fc0c2e5c 100644 --- a/public/app/features/alerting/unified/types/rule-form.ts +++ b/public/app/features/alerting/unified/types/rule-form.ts @@ -27,7 +27,6 @@ export interface RuleFormValues { noDataState: GrafanaAlertStateDecision; execErrState: GrafanaAlertStateDecision; folder: RuleForm | null; - evaluateEvery: string; evaluateFor: string; // cortex / loki rules diff --git a/public/app/features/alerting/unified/utils/rule-form.ts b/public/app/features/alerting/unified/utils/rule-form.ts index 8abb1698cf7..1d62bf960cf 100644 --- a/public/app/features/alerting/unified/utils/rule-form.ts +++ b/public/app/features/alerting/unified/utils/rule-form.ts @@ -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, diff --git a/public/app/features/alerting/unified/utils/rulerClient.ts b/public/app/features/alerting/unified/utils/rulerClient.ts index c7ed178ead0..ed5938020b8 100644 --- a/public/app/features/alerting/unified/utils/rulerClient.ts +++ b/public/app/features/alerting/unified/utils/rulerClient.ts @@ -22,8 +22,8 @@ import { export interface RulerClient { findEditableRule(ruleIdentifier: RuleIdentifier): Promise; deleteRule(ruleWithLocation: RuleWithLocation): Promise; - saveLotexRule(values: RuleFormValues, existing?: RuleWithLocation): Promise; - saveGrafanaRule(values: RuleFormValues, existing?: RuleWithLocation): Promise; + saveLotexRule(values: RuleFormValues, evaluateEvery: string, existing?: RuleWithLocation): Promise; + saveGrafanaRule(values: RuleFormValues, evaluateEvery: string, existing?: RuleWithLocation): Promise; } export function getRulerClient(rulerConfig: RulerDataSourceConfig): RulerClient { @@ -95,7 +95,11 @@ export function getRulerClient(rulerConfig: RulerDataSourceConfig): RulerClient }); }; - const saveLotexRule = async (values: RuleFormValues, existing?: RuleWithLocation): Promise => { + const saveLotexRule = async ( + values: RuleFormValues, + evaluateEvery: string, + existing?: RuleWithLocation + ): Promise => { 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 => { - const { folder, group, evaluateEvery } = values; + const saveGrafanaRule = async ( + values: RuleFormValues, + evaluateEvery: string, + existingRule?: RuleWithLocation + ): Promise => { + const { folder, group } = values; if (!folder) { throw new Error('Folder must be specified'); }