From 287e129a1d476bf591c706ae99b98a41369dddf8 Mon Sep 17 00:00:00 2001 From: Konrad Lalik Date: Mon, 10 Oct 2022 10:47:07 +0200 Subject: [PATCH] [v9.0.x] Alerting: Fix evaluation interval validation (#56115) (#56607) Co-authored-by: Gilles De Mey --- .../rule-editor/GrafanaEvaluationBehavior.tsx | 72 ++++++++++++---- .../components/rules/EditCloudGroupModal.tsx | 6 +- .../components/rules/RuleConfigStatus.tsx | 0 .../alerting/unified/utils/time.test.ts | 15 ++++ .../features/alerting/unified/utils/time.ts | 83 ++++++++++++++++--- 5 files changed, 145 insertions(+), 31 deletions(-) create mode 100644 public/app/features/alerting/unified/components/rules/RuleConfigStatus.tsx create mode 100644 public/app/features/alerting/unified/utils/time.test.ts 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 056c6f7fcb6b..ccefbb607803 100644 --- a/public/app/features/alerting/unified/components/rule-editor/GrafanaEvaluationBehavior.tsx +++ b/public/app/features/alerting/unified/components/rule-editor/GrafanaEvaluationBehavior.tsx @@ -2,11 +2,11 @@ import { css } from '@emotion/css'; import React, { FC, useState } from 'react'; import { useFormContext, RegisterOptions } from 'react-hook-form'; -import { parseDuration, durationToMilliseconds, GrafanaTheme2 } from '@grafana/data'; +import { GrafanaTheme2 } from '@grafana/data'; import { Field, InlineLabel, Input, InputControl, useStyles2 } from '@grafana/ui'; import { RuleFormValues } from '../../types/rule-form'; -import { positiveDurationValidationPattern, durationValidationPattern } from '../../utils/time'; +import { parsePrometheusDuration } from '../../utils/time'; import { CollapseToggle } from '../CollapseToggle'; import { GrafanaAlertStatePicker } from './GrafanaAlertStatePicker'; @@ -16,32 +16,62 @@ import { RuleEditorSection } from './RuleEditorSection'; const MIN_TIME_RANGE_STEP_S = 10; // 10 seconds -const forValidationOptions: RegisterOptions = { +export const forValidationOptions = (evaluateEvery: string): RegisterOptions => ({ required: { value: true, message: 'Required.', }, - pattern: durationValidationPattern, -}; + validate: (value: string) => { + // parsePrometheusDuration does not allow 0 but does allow 0s + if (value === '0') { + return true; + } + + try { + const millisFor = parsePrometheusDuration(value); + + // 0 is a special value meaning for equals evaluation interval + if (millisFor === 0) { + return true; + } + + try { + const millisEvery = parsePrometheusDuration(evaluateEvery); + return millisFor >= millisEvery + ? true + : 'For duration must be greater than or equal to the evaluation interval.'; + } catch (err) { + // if we fail to parse "every", assume validation is successful, or the error messages + // will overlap in the UI + return true; + } + } catch (error) { + return error instanceof Error ? error.message : 'Failed to parse duration'; + } + }, +}); -const evaluateEveryValidationOptions: RegisterOptions = { +export const evaluateEveryValidationOptions: RegisterOptions = { required: { value: true, message: 'Required.', }, - pattern: positiveDurationValidationPattern, validate: (value: string) => { - const duration = parseDuration(value); - if (Object.keys(duration).length) { - const diff = durationToMilliseconds(duration); - if (diff < MIN_TIME_RANGE_STEP_S * 1000) { + try { + const duration = parsePrometheusDuration(value); + + if (duration < MIN_TIME_RANGE_STEP_S * 1000) { return `Cannot be less than ${MIN_TIME_RANGE_STEP_S} seconds.`; } - if (diff % (MIN_TIME_RANGE_STEP_S * 1000) !== 0) { + + 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'; } - return true; }, }; @@ -51,6 +81,7 @@ export const GrafanaEvaluationBehavior: FC = () => { const { register, formState: { errors }, + watch, } = useFormContext(); const evaluateEveryId = 'eval-every-input'; @@ -71,7 +102,14 @@ export const GrafanaEvaluationBehavior: FC = () => { > Evaluate every - + + + { invalid={!!errors.evaluateFor?.message} validationMessageHorizontalOverflow={true} > - + diff --git a/public/app/features/alerting/unified/components/rules/EditCloudGroupModal.tsx b/public/app/features/alerting/unified/components/rules/EditCloudGroupModal.tsx index 8018fef98bbb..c8ee8210b5fc 100644 --- a/public/app/features/alerting/unified/components/rules/EditCloudGroupModal.tsx +++ b/public/app/features/alerting/unified/components/rules/EditCloudGroupModal.tsx @@ -10,7 +10,7 @@ import { useUnifiedAlertingSelector } from '../../hooks/useUnifiedAlertingSelect import { updateLotexNamespaceAndGroupAction } from '../../state/actions'; import { getRulesSourceName } from '../../utils/datasource'; import { initialAsyncRequestState } from '../../utils/redux'; -import { durationValidationPattern } from '../../utils/time'; +import { evaluateEveryValidationOptions } from '../rule-editor/GrafanaEvaluationBehavior'; interface Props { namespace: CombinedRuleNamespace; @@ -97,9 +97,7 @@ export function EditCloudGroupModal(props: Props): React.ReactElement { diff --git a/public/app/features/alerting/unified/components/rules/RuleConfigStatus.tsx b/public/app/features/alerting/unified/components/rules/RuleConfigStatus.tsx new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/public/app/features/alerting/unified/utils/time.test.ts b/public/app/features/alerting/unified/utils/time.test.ts new file mode 100644 index 000000000000..6237b023e6df --- /dev/null +++ b/public/app/features/alerting/unified/utils/time.test.ts @@ -0,0 +1,15 @@ +import { isValidPrometheusDuration } from './time'; + +describe('isValidPrometheusDuration', () => { + const validDurations = ['20h30m10s45ms', '1m30s', '20s4h', '90s', '10s', '20h20h', '2d4h20m']; + + it.each(validDurations)('%s should be valid', (duration) => { + expect(isValidPrometheusDuration(duration)).toBe(true); + }); + + const invalidDurations = ['20h 30m 10s 45ms', '10Y', 'sample text', 'm']; + + it.each(invalidDurations)('%s should NOT be valid', (duration) => { + expect(isValidPrometheusDuration(duration)).toBe(false); + }); +}); diff --git a/public/app/features/alerting/unified/utils/time.ts b/public/app/features/alerting/unified/utils/time.ts index d6a18f9c6340..a13f238fe5a3 100644 --- a/public/app/features/alerting/unified/utils/time.ts +++ b/public/app/features/alerting/unified/utils/time.ts @@ -2,6 +2,13 @@ import { describeInterval } from '@grafana/data/src/datetime/rangeutil'; import { TimeOptions } from '../types/time'; +/** + * ⚠️ + * Some of these functions might be confusing, but there is a significant difference between "Golang duration", + * supported by the time.ParseDuration() function and "prometheus duration" which is similar but does not support anything + * smaller than seconds and adds the following supported units: "d, w, y" + */ + export function parseInterval(value: string): [number, string] { const match = value.match(/(\d+)(\w+)/); if (match) { @@ -20,18 +27,70 @@ export const timeOptions = Object.entries(TimeOptions).map(([key, value]) => ({ value: value, })); -// 1h, 10m and such -export const positiveDurationValidationPattern = { - value: new RegExp(`^\\d+(${Object.values(TimeOptions).join('|')})$`), - message: `Must be of format "(number)(unit)" , for example "1m". Available units: ${Object.values(TimeOptions).join( - ', ' - )}`, +export function isValidPrometheusDuration(duration: string): boolean { + try { + parsePrometheusDuration(duration); + return true; + } catch (err) { + return false; + } +} + +const PROMETHEUS_SUFFIX_MULTIPLIER: Record = { + ms: 1, + s: 1000, + m: 60 * 1000, + h: 60 * 60 * 1000, + d: 24 * 60 * 60 * 1000, + w: 7 * 24 * 60 * 60 * 1000, + y: 365 * 24 * 60 * 60 * 1000, }; -// 1h, 10m or 0 (without units) -export const durationValidationPattern = { - value: new RegExp(`^\\d+(${Object.values(TimeOptions).join('|')})|0$`), - message: `Must be of format "(number)(unit)", for example "1m", or just "0". Available units: ${Object.values( +const DURATION_REGEXP = new RegExp(/^(?:(?\d+)(?ms|s|m|h|d|w|y))|0$/); +const INVALID_FORMAT = new Error( + `Must be of format "(number)(unit)", for example "1m", or just "0". Available units: ${Object.values( TimeOptions - ).join(', ')}`, -}; + ).join(', ')}` +); + +/** + * According to https://prometheus.io/docs/alerting/latest/configuration/#configuration-file + * see + * + * @returns Duration in milliseconds + */ +export function parsePrometheusDuration(duration: string): number { + let input = duration; + let parts: Array<[number, string]> = []; + + function matchDuration(part: string) { + const match = DURATION_REGEXP.exec(part); + const hasValueAndType = match?.groups?.value && match?.groups?.type; + + if (!match || !hasValueAndType) { + throw INVALID_FORMAT; + } + + if (match && match.groups?.value && match.groups?.type) { + input = input.replace(match[0], ''); + parts.push([Number(match.groups.value), match.groups.type]); + } + + if (input) { + matchDuration(input); + } + } + + matchDuration(duration); + + if (!parts.length) { + throw INVALID_FORMAT; + } + + const totalDuration = parts.reduce((acc, [value, type]) => { + const duration = value * PROMETHEUS_SUFFIX_MULTIPLIER[type]; + return acc + duration; + }, 0); + + return totalDuration; +}