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 c8ff4d087dd0..4267828a437f 100644
--- a/public/app/features/alerting/unified/components/rule-editor/GrafanaEvaluationBehavior.tsx
+++ b/public/app/features/alerting/unified/components/rule-editor/GrafanaEvaluationBehavior.tsx
@@ -2,16 +2,12 @@ import { css } from '@emotion/css';
import React, { FC, useState } from 'react';
import { RegisterOptions, useFormContext } from 'react-hook-form';
-import { durationToMilliseconds, GrafanaTheme2, parseDuration } from '@grafana/data';
+import { GrafanaTheme2 } from '@grafana/data';
import { Field, InlineLabel, Input, InputControl, useStyles2 } from '@grafana/ui';
import { RuleFormValues } from '../../types/rule-form';
import { checkEvaluationIntervalGlobalLimit } from '../../utils/config';
-import {
- durationValidationPattern,
- parseDurationToMilliseconds,
- positiveDurationValidationPattern,
-} from '../../utils/time';
+import { parsePrometheusDuration } from '../../utils/time';
import { CollapseToggle } from '../CollapseToggle';
import { EvaluationIntervalLimitExceeded } from '../InvalidIntervalWarning';
@@ -21,43 +17,62 @@ import { RuleEditorSection } from './RuleEditorSection';
const MIN_TIME_RANGE_STEP_S = 10; // 10 seconds
-const forValidationOptions = (evaluateEvery: string): RegisterOptions => ({
+export const forValidationOptions = (evaluateEvery: string): RegisterOptions => ({
required: {
value: true,
message: 'Required.',
},
- pattern: durationValidationPattern,
- validate: (value) => {
- const millisFor = parseDurationToMilliseconds(value);
- const millisEvery = parseDurationToMilliseconds(evaluateEvery);
-
- // 0 is a special value meaning for equals evaluation interval
- if (millisFor === 0) {
+ validate: (value: string) => {
+ // parsePrometheusDuration does not allow 0 but does allow 0s
+ if (value === '0') {
return true;
}
- return millisFor >= millisEvery ? true : 'For must be greater than or equal to evaluate every.';
+ 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;
},
};
diff --git a/public/app/features/alerting/unified/components/rules/EditRuleGroupModal.tsx b/public/app/features/alerting/unified/components/rules/EditRuleGroupModal.tsx
index ef6f901cf319..deaf7b3cecf6 100644
--- a/public/app/features/alerting/unified/components/rules/EditRuleGroupModal.tsx
+++ b/public/app/features/alerting/unified/components/rules/EditRuleGroupModal.tsx
@@ -1,7 +1,6 @@
import { css } from '@emotion/css';
import React, { useEffect, useMemo } from 'react';
-import { isValidGoDuration } from '@grafana/data';
import { Modal, Button, Form, Field, Input, useStyles2 } from '@grafana/ui';
import { useCleanup } from 'app/core/hooks/useCleanup';
import { useDispatch } from 'app/types';
@@ -12,8 +11,8 @@ import { updateLotexNamespaceAndGroupAction } from '../../state/actions';
import { checkEvaluationIntervalGlobalLimit } from '../../utils/config';
import { getRulesSourceName } from '../../utils/datasource';
import { initialAsyncRequestState } from '../../utils/redux';
-import { durationValidationPattern } from '../../utils/time';
import { EvaluationIntervalLimitExceeded } from '../InvalidIntervalWarning';
+import { evaluateEveryValidationOptions } from '../rule-editor/GrafanaEvaluationBehavior';
interface ModalProps {
namespace: CombinedRuleNamespace;
@@ -100,22 +99,7 @@ export function EditCloudGroupModal(props: ModalProps): React.ReactElement {
{
- const validDuration = isValidGoDuration(input);
- if (!validDuration) {
- return 'Invalid duration. Valid example: 1m (Available units: h, m, s)';
- }
-
- const limitExceeded = !checkEvaluationIntervalGlobalLimit(input).exceedsLimit;
- if (limitExceeded) {
- return true;
- }
-
- return false;
- },
- })}
+ {...register('groupInterval', evaluateEveryValidationOptions)}
/>
{checkEvaluationIntervalGlobalLimit(watch('groupInterval')).exceedsLimit && (
diff --git a/public/app/features/alerting/unified/components/rules/RuleConfigStatus.tsx b/public/app/features/alerting/unified/components/rules/RuleConfigStatus.tsx
index 804dbca292f0..436a7525d9b1 100644
--- a/public/app/features/alerting/unified/components/rules/RuleConfigStatus.tsx
+++ b/public/app/features/alerting/unified/components/rules/RuleConfigStatus.tsx
@@ -1,5 +1,5 @@
import { css } from '@emotion/css';
-import React from 'react';
+import React, { useMemo } from 'react';
import { GrafanaTheme2 } from '@grafana/data/src';
import { config } from '@grafana/runtime/src';
@@ -15,7 +15,10 @@ interface RuleConfigStatusProps {
export function RuleConfigStatus({ rule }: RuleConfigStatusProps) {
const styles = useStyles2(getStyles);
- const { exceedsLimit } = checkEvaluationIntervalGlobalLimit(rule.group.interval);
+ const { exceedsLimit } = useMemo(
+ () => checkEvaluationIntervalGlobalLimit(rule.group.interval),
+ [rule.group.interval]
+ );
if (!exceedsLimit) {
return null;
diff --git a/public/app/features/alerting/unified/utils/config.test.ts b/public/app/features/alerting/unified/utils/config.test.ts
new file mode 100644
index 000000000000..cab4615eb7ac
--- /dev/null
+++ b/public/app/features/alerting/unified/utils/config.test.ts
@@ -0,0 +1,53 @@
+import { config } from '@grafana/runtime';
+
+import { checkEvaluationIntervalGlobalLimit } from './config';
+
+describe('checkEvaluationIntervalGlobalLimit', () => {
+ it('should NOT exceed limit if evaluate every is not valid duration', () => {
+ config.unifiedAlerting.minInterval = '2m30s';
+
+ const { globalLimit, exceedsLimit } = checkEvaluationIntervalGlobalLimit('123notvalidduration');
+
+ expect(globalLimit).toBe(150 * 1000);
+ expect(exceedsLimit).toBe(false);
+ });
+
+ it('should NOT exceed limit if config minInterval is not valid duration', () => {
+ config.unifiedAlerting.minInterval = '1A8IU3A';
+
+ const { globalLimit, exceedsLimit } = checkEvaluationIntervalGlobalLimit('1m30s');
+
+ expect(globalLimit).toBe(0);
+ expect(exceedsLimit).toBe(false);
+ });
+
+ it.each([
+ ['2m30s', '1m30s'],
+ ['30s', '10s'],
+ ['1d2h', '2h'],
+ ['1y', '90d'],
+ ])(
+ 'should exceed limit if config minInterval (%s) is greater than evaluate every (%s)',
+ (minInterval, evaluateEvery) => {
+ config.unifiedAlerting.minInterval = minInterval;
+
+ const { globalLimit, exceedsLimit } = checkEvaluationIntervalGlobalLimit(evaluateEvery);
+
+ expect(globalLimit).toBeGreaterThan(0);
+ expect(exceedsLimit).toBe(true);
+ }
+ );
+
+ it.each([
+ ['1m30s', '2m30s'],
+ ['30s', '1d'],
+ ['1m10s', '1h30m15s'],
+ ])('should NOT exceed limit if config minInterval is lesser than evaluate every', (minInterval, evaluateEvery) => {
+ config.unifiedAlerting.minInterval = minInterval;
+
+ const { globalLimit, exceedsLimit } = checkEvaluationIntervalGlobalLimit(evaluateEvery);
+
+ expect(globalLimit).toBeGreaterThan(0);
+ expect(exceedsLimit).toBe(false);
+ });
+});
diff --git a/public/app/features/alerting/unified/utils/config.ts b/public/app/features/alerting/unified/utils/config.ts
index 276efd289818..d5f7817561d1 100644
--- a/public/app/features/alerting/unified/utils/config.ts
+++ b/public/app/features/alerting/unified/utils/config.ts
@@ -1,21 +1,26 @@
-import { DataSourceInstanceSettings, DataSourceJsonData, isValidGoDuration, rangeUtil } from '@grafana/data';
+import { DataSourceInstanceSettings, DataSourceJsonData } from '@grafana/data';
import { config } from '@grafana/runtime';
+import { isValidPrometheusDuration, parsePrometheusDuration } from './time';
+
export function getAllDataSources(): Array> {
return Object.values(config.datasources);
}
export function checkEvaluationIntervalGlobalLimit(alertGroupEvaluateEvery?: string) {
- if (!alertGroupEvaluateEvery || !isValidGoDuration(alertGroupEvaluateEvery)) {
+ // config.unifiedAlerting.minInterval should be Prometheus-compatible duration
+ // However, Go's gtime library has issues with parsing y,w,d
+ if (!isValidPrometheusDuration(config.unifiedAlerting.minInterval)) {
return { globalLimit: 0, exceedsLimit: false };
}
- if (!isValidGoDuration(config.unifiedAlerting.minInterval)) {
- return { globalLimit: 0, exceedsLimit: false };
+ const evaluateEveryGlobalLimitMs = parsePrometheusDuration(config.unifiedAlerting.minInterval);
+
+ if (!alertGroupEvaluateEvery || !isValidPrometheusDuration(alertGroupEvaluateEvery)) {
+ return { globalLimit: evaluateEveryGlobalLimitMs, exceedsLimit: false };
}
- const evaluateEveryMs = rangeUtil.intervalToMs(alertGroupEvaluateEvery);
- const evaluateEveryGlobalLimitMs = rangeUtil.intervalToMs(config.unifiedAlerting.minInterval);
+ const evaluateEveryMs = parsePrometheusDuration(alertGroupEvaluateEvery);
const exceedsLimit = evaluateEveryGlobalLimitMs > evaluateEveryMs && evaluateEveryMs > 0;
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 1c2fcf3cf247..a6958b9d6ab5 100644
--- a/public/app/features/alerting/unified/utils/time.ts
+++ b/public/app/features/alerting/unified/utils/time.ts
@@ -3,6 +3,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) {
@@ -21,22 +28,74 @@ 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 parseDurationToMilliseconds(duration: string) {
+ return durationToMilliseconds(parseDuration(duration));
+}
+
+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(', ')}`
+);
-export function parseDurationToMilliseconds(duration: string) {
- return durationToMilliseconds(parseDuration(duration));
+/**
+ * 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;
}