Skip to content

Commit

Permalink
[v9.0.x] Alerting: Fix evaluation interval validation (#56115) (#56607)
Browse files Browse the repository at this point in the history
Co-authored-by: Gilles De Mey <gilles.de.mey@gmail.com>
  • Loading branch information
konrad147 and gillesdemey committed Oct 10, 2022
1 parent 50d81e4 commit 287e129
Show file tree
Hide file tree
Showing 5 changed files with 145 additions and 31 deletions.
Expand Up @@ -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';
Expand All @@ -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;
},
};

Expand All @@ -51,6 +81,7 @@ export const GrafanaEvaluationBehavior: FC = () => {
const {
register,
formState: { errors },
watch,
} = useFormContext<RuleFormValues>();

const evaluateEveryId = 'eval-every-input';
Expand All @@ -71,7 +102,14 @@ export const GrafanaEvaluationBehavior: FC = () => {
>
Evaluate every
</InlineLabel>
<Input id={evaluateEveryId} width={8} {...register('evaluateEvery', evaluateEveryValidationOptions)} />
<Field
className={styles.inlineField}
error={errors.evaluateEvery?.message}
invalid={!!errors.evaluateEvery?.message}
validationMessageHorizontalOverflow={true}
>
<Input id={evaluateEveryId} width={8} {...register('evaluateEvery', evaluateEveryValidationOptions)} />
</Field>
<InlineLabel
htmlFor={evaluateForId}
width={7}
Expand All @@ -85,7 +123,11 @@ export const GrafanaEvaluationBehavior: FC = () => {
invalid={!!errors.evaluateFor?.message}
validationMessageHorizontalOverflow={true}
>
<Input id={evaluateForId} width={8} {...register('evaluateFor', forValidationOptions)} />
<Input
id={evaluateForId}
width={8}
{...register('evaluateFor', forValidationOptions(watch('evaluateEvery')))}
/>
</Field>
</div>
</Field>
Expand Down
Expand Up @@ -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;
Expand Down Expand Up @@ -97,9 +97,7 @@ export function EditCloudGroupModal(props: Props): React.ReactElement {
<Input
id="groupInterval"
placeholder="1m"
{...register('groupInterval', {
pattern: durationValidationPattern,
})}
{...register('groupInterval', evaluateEveryValidationOptions)}
/>
</Field>

Expand Down
Empty file.
15 changes: 15 additions & 0 deletions 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);
});
});
83 changes: 71 additions & 12 deletions public/app/features/alerting/unified/utils/time.ts
Expand Up @@ -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) {
Expand All @@ -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<string, number> = {
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(/^(?:(?<value>\d+)(?<type>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 <duration>
*
* @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;
}

0 comments on commit 287e129

Please sign in to comment.