import { css } from '@emotion/css'; import { compact } from 'lodash'; import React, { useEffect, useMemo } from 'react'; import { FormProvider, RegisterOptions, useForm, useFormContext } from 'react-hook-form'; import { GrafanaTheme2 } from '@grafana/data'; import { Stack } from '@grafana/experimental'; import { Badge, Button, Field, Input, Label, LinkButton, Modal, useStyles2 } from '@grafana/ui'; import { useAppNotification } from 'app/core/copy/appNotification'; import { useCleanup } from 'app/core/hooks/useCleanup'; import { useDispatch } from 'app/types'; import { CombinedRuleGroup, CombinedRuleNamespace } from 'app/types/unified-alerting'; import { RulerRuleDTO, RulerRuleGroupDTO, RulerRulesConfigDTO } from 'app/types/unified-alerting-dto'; import { useUnifiedAlertingSelector } from '../../hooks/useUnifiedAlertingSelector'; import { rulesInSameGroupHaveInvalidFor, updateLotexNamespaceAndGroupAction } from '../../state/actions'; import { checkEvaluationIntervalGlobalLimit } from '../../utils/config'; import { getRulesSourceName, GRAFANA_RULES_SOURCE_NAME } from '../../utils/datasource'; import { initialAsyncRequestState } from '../../utils/redux'; import { isAlertingRulerRule, isGrafanaRulerRule, isRecordingRulerRule } from '../../utils/rules'; import { parsePrometheusDuration } from '../../utils/time'; import { DynamicTable, DynamicTableColumnProps, DynamicTableItemProps } from '../DynamicTable'; import { InfoIcon } from '../InfoIcon'; import { EvaluationIntervalLimitExceeded } from '../InvalidIntervalWarning'; import { MIN_TIME_RANGE_STEP_S } from '../rule-editor/GrafanaEvaluationBehavior'; const ITEMS_PER_PAGE = 10; interface AlertInfo { alertName: string; forDuration: string; evaluationsToFire: number; } function ForBadge({ message, error }: { message: string; error?: boolean }) { if (error) { return ; } else { return ; } } export const getNumberEvaluationsToStartAlerting = (forDuration: string, currentEvaluation: string) => { const evalNumberMs = safeParseDurationstr(currentEvaluation); const forNumber = safeParseDurationstr(forDuration); if (forNumber === 0 && evalNumberMs !== 0) { return 1; } if (evalNumberMs === 0) { return 0; } else { const evaluationsBeforeCeil = forNumber / evalNumberMs; return evaluationsBeforeCeil < 1 ? 0 : Math.ceil(forNumber / evalNumberMs) + 1; } }; export const getAlertInfo = (alert: RulerRuleDTO, currentEvaluation: string): AlertInfo => { const emptyAlert: AlertInfo = { alertName: '', forDuration: '0s', evaluationsToFire: 0, }; if (isGrafanaRulerRule(alert)) { return { alertName: alert.grafana_alert.title, forDuration: alert.for, evaluationsToFire: getNumberEvaluationsToStartAlerting(alert.for, currentEvaluation), }; } if (isAlertingRulerRule(alert)) { return { alertName: alert.alert, forDuration: alert.for ?? '1m', evaluationsToFire: getNumberEvaluationsToStartAlerting(alert.for ?? '1m', currentEvaluation), }; } return emptyAlert; }; export const isValidEvaluation = (evaluation: string) => { try { const duration = parsePrometheusDuration(evaluation); if (duration < MIN_TIME_RANGE_STEP_S * 1000) { return false; } if (duration % (MIN_TIME_RANGE_STEP_S * 1000) !== 0) { return false; } return true; } catch (error) { return false; } }; export const getGroupFromRuler = ( rulerRules: RulerRulesConfigDTO | null | undefined, groupName: string, folderName: string ) => { const folderObj: Array> = rulerRules ? rulerRules[folderName] : []; return folderObj?.find((rulerRuleGroup) => rulerRuleGroup.name === groupName); }; export const safeParseDurationstr = (duration: string): number => { try { return parsePrometheusDuration(duration); } catch (e) { return 0; } }; type AlertsWithForTableColumnProps = DynamicTableColumnProps; type AlertsWithForTableProps = DynamicTableItemProps; export const RulesForGroupTable = ({ rulesWithoutRecordingRules }: { rulesWithoutRecordingRules: RulerRuleDTO[] }) => { const styles = useStyles2(getStyles); const { watch } = useFormContext(); const currentInterval = watch('groupInterval'); const unknownCurrentInterval = !Boolean(currentInterval); const rows: AlertsWithForTableProps[] = rulesWithoutRecordingRules .slice() .map((rule: RulerRuleDTO, index) => ({ id: index, data: getAlertInfo(rule, currentInterval), })) .sort( (alert1, alert2) => safeParseDurationstr(alert1.data.forDuration) - safeParseDurationstr(alert2.data.forDuration) ); const columns: AlertsWithForTableColumnProps[] = useMemo(() => { return [ { id: 'alertName', label: 'Alert', renderCell: ({ data: { alertName } }) => { return <>{alertName}>; }, size: '330px', }, { id: 'for', label: 'For', renderCell: ({ data: { forDuration } }) => { return <>{forDuration}>; }, size: 0.5, }, { id: 'numberEvaluations', label: '#Eval', renderCell: ({ data: { evaluationsToFire: numberEvaluations } }) => { if (unknownCurrentInterval) { return ; } else { if (!isValidEvaluation(currentInterval)) { return ; } if (numberEvaluations === 0) { return ( ); } else { return <>{numberEvaluations}>; } } }, size: 0.4, }, ]; }, [currentInterval, unknownCurrentInterval]); return ( ); }; interface FormValues { namespaceName: string; groupName: string; groupInterval: string; } export const evaluateEveryValidationOptions = (rules: RulerRuleDTO[]): RegisterOptions => ({ required: { value: true, message: 'Required.', }, validate: (evaluateEvery: string) => { try { const duration = parsePrometheusDuration(evaluateEvery); 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.`; } if (rulesInSameGroupHaveInvalidFor(rules, evaluateEvery).length === 0) { return true; } else { return `Invalid evaluation interval. Evaluation interval should be smaller or equal to 'For' values for existing rules in this group.`; } } catch (error) { return error instanceof Error ? error.message : 'Failed to parse duration'; } }, }); export interface ModalProps { namespace: CombinedRuleNamespace; group: CombinedRuleGroup; onClose: (saved?: boolean) => void; intervalEditOnly?: boolean; folderUrl?: string; } export function EditCloudGroupModal(props: ModalProps): React.ReactElement { const { namespace, group, onClose, intervalEditOnly } = props; const styles = useStyles2(getStyles); const dispatch = useDispatch(); const { loading, error, dispatched } = useUnifiedAlertingSelector((state) => state.updateLotexNamespaceAndGroup) ?? initialAsyncRequestState; const notifyApp = useAppNotification(); const defaultValues = useMemo( (): FormValues => ({ namespaceName: namespace.name, groupName: group.name, groupInterval: group.interval ?? '', }), [namespace, group] ); const rulesSourceName = getRulesSourceName(namespace.rulesSource); const isGrafanaManagedGroup = rulesSourceName === GRAFANA_RULES_SOURCE_NAME; const nameSpaceLabel = isGrafanaManagedGroup ? 'Folder' : 'Namespace'; // close modal if successfully saved useEffect(() => { if (dispatched && !loading && !error) { onClose(true); } }, [dispatched, loading, onClose, error]); useCleanup((state) => (state.unifiedAlerting.updateLotexNamespaceAndGroup = initialAsyncRequestState)); const onSubmit = (values: FormValues) => { dispatch( updateLotexNamespaceAndGroupAction({ rulesSourceName: rulesSourceName, groupName: group.name, newGroupName: values.groupName, namespaceName: namespace.name, newNamespaceName: values.namespaceName, groupInterval: values.groupInterval || undefined, }) ); }; const formAPI = useForm({ mode: 'onBlur', defaultValues, shouldFocusError: true, }); const { handleSubmit, register, watch, formState: { isDirty, errors }, } = formAPI; const onInvalid = () => { notifyApp.error('There are errors in the form. Correct the errors and retry.'); }; const rulesWithoutRecordingRules = compact( group.rules.map((r) => r.rulerRule).filter((rule) => !isRecordingRulerRule(rule)) ); const hasSomeNoRecordingRules = rulesWithoutRecordingRules.length > 0; const modalTitle = intervalEditOnly || isGrafanaManagedGroup ? 'Edit evaluation group' : 'Edit namespace or evaluation group'; return ( e.preventDefault()} key={JSON.stringify(defaultValues)}> <> {nameSpaceLabel} } invalid={!!errors.namespaceName} error={errors.namespaceName?.message} > {isGrafanaManagedGroup && props.folderUrl && ( )} Evaluation group name } invalid={!!errors.groupName} error={errors.groupName?.message} > Rule group evaluation interval } invalid={!!errors.groupInterval} error={errors.groupInterval?.message} > {checkEvaluationIntervalGlobalLimit(watch('groupInterval')).exceedsLimit && ( )} onClose(false)} fill="outline" > Cancel onSubmit(values), onInvalid)} > {loading ? 'Saving...' : 'Save evaluation interval'} {!hasSomeNoRecordingRules && This group does not contain alert rules.} {hasSomeNoRecordingRules && ( <> List of rules that belong to this group #Eval column represents the number of evaluations needed before alert starts firing. > )} > ); } const getStyles = (theme: GrafanaTheme2) => ({ modal: css` max-width: 560px; `, modalButtons: css` top: -24px; position: relative; `, formInput: css` flex: 1; `, tableWrapper: css` margin-top: ${theme.spacing(2)}; margin-bottom: ${theme.spacing(2)}; height: 100%; `, evalRequiredLabel: css` font-size: ${theme.typography.bodySmall.fontSize}; `, });