Alerting: Evaluation quick buttons (#85010)

Co-authored-by: Tom Ratcliffe <tomratcliffe@users.noreply.github.com>
This commit is contained in:
Gilles De Mey 2024-04-04 16:24:35 +02:00 committed by GitHub
parent b5c33c540c
commit d3ee3c0a24
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
23 changed files with 407 additions and 70 deletions

View File

@ -142,7 +142,7 @@ describe('RuleEditor grafana managed rules', () => {
{ {
annotations: { description: 'some description', summary: 'some summary' }, annotations: { description: 'some description', summary: 'some summary' },
labels: { severity: 'warn', team: 'the a-team' }, labels: { severity: 'warn', team: 'the a-team' },
for: '5m', for: '1m',
grafana_alert: { grafana_alert: {
uid, uid,
namespace_uid: 'abcd', namespace_uid: 'abcd',
@ -208,7 +208,7 @@ describe('RuleEditor grafana managed rules', () => {
{ {
annotations: { description: 'some description', summary: 'some summary', custom: 'value' }, annotations: { description: 'some description', summary: 'some summary', custom: 'value' },
labels: { severity: 'warn', team: 'the a-team', custom: 'value' }, labels: { severity: 'warn', team: 'the a-team', custom: 'value' },
for: '5m', for: '1m',
grafana_alert: { grafana_alert: {
uid, uid,
condition: 'B', condition: 'B',

View File

@ -111,7 +111,7 @@ describe('RuleEditor grafana managed rules', () => {
{ {
annotations: { description: 'some description', summary: 'some summary' }, annotations: { description: 'some description', summary: 'some summary' },
labels: { severity: 'warn', team: 'the a-team' }, labels: { severity: 'warn', team: 'the a-team' },
for: '5m', for: '1m',
grafana_alert: { grafana_alert: {
uid: '23', uid: '23',
namespace_uid: 'abcd', namespace_uid: 'abcd',
@ -133,7 +133,7 @@ describe('RuleEditor grafana managed rules', () => {
{ {
annotations: { description: 'some description', summary: 'some summary' }, annotations: { description: 'some description', summary: 'some summary' },
labels: { severity: 'warn', team: 'the a-team' }, labels: { severity: 'warn', team: 'the a-team' },
for: '5m', for: '1m',
grafana_alert: { grafana_alert: {
uid: '23', uid: '23',
namespace_uid: 'b', namespace_uid: 'b',
@ -209,7 +209,7 @@ describe('RuleEditor grafana managed rules', () => {
{ {
annotations: { description: 'some description' }, annotations: { description: 'some description' },
labels: { severity: 'warn' }, labels: { severity: 'warn' },
for: '5m', for: '1m',
grafana_alert: { grafana_alert: {
condition: 'B', condition: 'B',
data: getDefaultQueries(), data: getDefaultQueries(),

View File

@ -706,6 +706,7 @@ describe('RuleList', () => {
await userEvent.clear(ui.editGroupModal.ruleGroupInput.get()); await userEvent.clear(ui.editGroupModal.ruleGroupInput.get());
await userEvent.type(ui.editGroupModal.ruleGroupInput.get(), 'super group'); await userEvent.type(ui.editGroupModal.ruleGroupInput.get(), 'super group');
await userEvent.clear(ui.editGroupModal.intervalInput.get());
await userEvent.type(ui.editGroupModal.intervalInput.get(), '5m'); await userEvent.type(ui.editGroupModal.intervalInput.get(), '5m');
// submit, check that appropriate calls were made // submit, check that appropriate calls were made
@ -743,6 +744,8 @@ describe('RuleList', () => {
// make changes to form // make changes to form
await userEvent.clear(ui.editGroupModal.ruleGroupInput.get()); await userEvent.clear(ui.editGroupModal.ruleGroupInput.get());
await userEvent.type(ui.editGroupModal.ruleGroupInput.get(), 'super group'); await userEvent.type(ui.editGroupModal.ruleGroupInput.get(), 'super group');
await userEvent.clear(ui.editGroupModal.intervalInput.get());
await userEvent.type(ui.editGroupModal.intervalInput.get(), '5m'); await userEvent.type(ui.editGroupModal.intervalInput.get(), '5m');
// submit, check that appropriate calls were made // submit, check that appropriate calls were made
@ -773,6 +776,7 @@ describe('RuleList', () => {
testCase('edit lotex group eval interval, no renaming', async () => { testCase('edit lotex group eval interval, no renaming', async () => {
// make changes to form // make changes to form
await userEvent.clear(ui.editGroupModal.intervalInput.get());
await userEvent.type(ui.editGroupModal.intervalInput.get(), '5m'); await userEvent.type(ui.editGroupModal.intervalInput.get(), '5m');
// submit, check that appropriate calls were made // submit, check that appropriate calls were made

View File

@ -0,0 +1,46 @@
import { screen } from '@testing-library/dom';
import { render } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import React from 'react';
import { getEvaluationGroupOptions, EvaluationGroupQuickPick } from './EvaluationGroupQuickPick';
describe('EvaluationGroupQuickPick', () => {
it('should render the correct default preset, set active element and allow selecting another option', async () => {
const onSelect = jest.fn();
render(<EvaluationGroupQuickPick currentInterval={'10m'} onSelect={onSelect} />);
const shouldHaveButtons = ['10s', '30s', '1m', '5m', '10m', '15m', '30m', '1h'];
const shouldNotHaveButtons = ['0s', '2h'];
shouldHaveButtons.forEach((name) => {
expect(screen.getByRole('option', { name })).toBeInTheDocument();
});
shouldNotHaveButtons.forEach((name) => {
expect(screen.queryByRole('option', { name })).not.toBeInTheDocument();
});
expect(screen.getByRole('option', { selected: true })).toHaveTextContent('10m');
await userEvent.click(screen.getByRole('option', { name: '30m' }));
expect(onSelect).toHaveBeenCalledWith('30m');
});
});
describe('getEvaluationGroupOptions', () => {
it('should return the correct default options', () => {
const options = getEvaluationGroupOptions();
expect(options).toEqual(['10s', '30s', '1m', '5m', '10m', '15m', '30m', '1h']);
});
it('should return the correct options when minInterval is set within set of defaults', () => {
const options = getEvaluationGroupOptions('1m0s');
expect(options).toEqual(['1m', '5m', '10m', '15m', '30m', '1h', '2h', '4h']);
});
it('should return the correct options when minInterval is set outside set of defaults', () => {
const options = getEvaluationGroupOptions('12h');
expect(options).toEqual(['12h', '1d', '1d12h', '2d', '2d12h', '3d', '3d12h', '4d']);
});
});

View File

@ -0,0 +1,72 @@
import { last, times } from 'lodash';
import React from 'react';
import { config } from '@grafana/runtime';
import { Button, Stack } from '@grafana/ui';
import { formatPrometheusDuration, parsePrometheusDuration, safeParsePrometheusDuration } from '../../utils/time';
const MIN_INTERVAl = config.unifiedAlerting.minInterval ?? '10s';
export const getEvaluationGroupOptions = (minInterval = MIN_INTERVAl) => {
const MIN_OPTIONS_TO_SHOW = 8;
const DEFAULT_INTERVAL_OPTIONS: number[] = [
parsePrometheusDuration('10s'),
parsePrometheusDuration('30s'),
parsePrometheusDuration('1m'),
parsePrometheusDuration('5m'),
parsePrometheusDuration('10m'),
parsePrometheusDuration('15m'),
parsePrometheusDuration('30m'),
parsePrometheusDuration('1h'),
];
// 10s for OSS and 1m0s for Grafana Cloud
const minEvaluationIntervalMillis = safeParsePrometheusDuration(minInterval);
/**
* 1. make sure we always show at least 8 options to the user
* 2. find the default interval closest to the configured minInterval
* 3. if we have fewer than 8 options, we basically double the last interval until we have 8 options
*/
const head = DEFAULT_INTERVAL_OPTIONS.filter((millis) => minEvaluationIntervalMillis <= millis);
const tail = times(MIN_OPTIONS_TO_SHOW - head.length, (index: number) => {
const lastInterval = last(head) ?? minEvaluationIntervalMillis;
const multiplier = head.length === 0 ? 1 : 2; // if the head is empty we start with the min interval and multiply it only once :)
return lastInterval * multiplier * (index + 1);
});
return [...head, ...tail].map(formatPrometheusDuration);
};
export const QUICK_PICK_OPTIONS = getEvaluationGroupOptions(MIN_INTERVAl);
interface Props {
currentInterval: string;
onSelect: (interval: string) => void;
}
/**
* Allow a quick selection of group evaluation intervals, based on the configured "unifiedAlerting.minInterval" value
* ie. [1m, 2m, 5m, 10m, 15m] etc.
*/
export const EvaluationGroupQuickPick = ({ currentInterval, onSelect }: Props) => (
<Stack direction="row" gap={0.5} role="listbox">
{QUICK_PICK_OPTIONS.map((interval) => {
const isActive = currentInterval === interval;
return (
<Button
role="option"
aria-selected={isActive}
key={interval}
variant={isActive ? 'primary' : 'secondary'}
size="sm"
onClick={() => onSelect(interval)}
>
{interval}
</Button>
);
})}
</Stack>
);

View File

@ -17,11 +17,12 @@ import { useUnifiedAlertingSelector } from '../../hooks/useUnifiedAlertingSelect
import { fetchRulerRulesAction } from '../../state/actions'; import { fetchRulerRulesAction } from '../../state/actions';
import { RuleFormValues } from '../../types/rule-form'; import { RuleFormValues } from '../../types/rule-form';
import { GRAFANA_RULES_SOURCE_NAME } from '../../utils/datasource'; import { GRAFANA_RULES_SOURCE_NAME } from '../../utils/datasource';
import { MINUTE } from '../../utils/rule-form'; import { DEFAULT_GROUP_EVALUATION_INTERVAL } from '../../utils/rule-form';
import { isGrafanaRulerRule } from '../../utils/rules'; import { isGrafanaRulerRule } from '../../utils/rules';
import { ProvisioningBadge } from '../Provisioning'; import { ProvisioningBadge } from '../Provisioning';
import { evaluateEveryValidationOptions } from '../rules/EditRuleGroupModal'; import { evaluateEveryValidationOptions } from '../rules/EditRuleGroupModal';
import { EvaluationGroupQuickPick } from './EvaluationGroupQuickPick';
import { containsSlashes, Folder, RuleFolderPicker } from './RuleFolderPicker'; import { containsSlashes, Folder, RuleFolderPicker } from './RuleFolderPicker';
import { checkForPathSeparator } from './util'; import { checkForPathSeparator } from './util';
@ -48,7 +49,7 @@ export const useFolderGroupOptions = (folderUid: string, enableProvisionedGroups
return { return {
label: group.name, label: group.name,
value: group.name, value: group.name,
description: group.interval ?? MINUTE, description: group.interval ?? DEFAULT_GROUP_EVALUATION_INTERVAL,
// we include provisioned folders, but disable the option to select them // we include provisioned folders, but disable the option to select them
isDisabled: !enableProvisionedGroups ? isProvisioned : false, isDisabled: !enableProvisionedGroups ? isProvisioned : false,
isProvisioned: isProvisioned, isProvisioned: isProvisioned,
@ -357,12 +358,17 @@ function EvaluationGroupCreationModal({
}; };
const formAPI = useForm({ const formAPI = useForm({
defaultValues: { group: '', evaluateEvery: '' }, defaultValues: { group: '', evaluateEvery: DEFAULT_GROUP_EVALUATION_INTERVAL },
mode: 'onChange', mode: 'onChange',
shouldFocusError: true, shouldFocusError: true,
}); });
const { register, handleSubmit, formState, getValues } = formAPI; const { register, handleSubmit, formState, setValue, getValues, watch: watchGroupFormValues } = formAPI;
const evaluationInterval = watchGroupFormValues('evaluateEvery');
const setEvaluationInterval = (interval: string) => {
setValue('evaluateEvery', interval, { shouldValidate: true });
};
return ( return (
<Modal <Modal
@ -379,7 +385,7 @@ function EvaluationGroupCreationModal({
<Field <Field
label={<Label htmlFor={'group'}>Evaluation group name</Label>} label={<Label htmlFor={'group'}>Evaluation group name</Label>}
error={formState.errors.group?.message} error={formState.errors.group?.message}
invalid={!!formState.errors.group} invalid={Boolean(formState.errors.group)}
> >
<Input <Input
className={styles.formInput} className={styles.formInput}
@ -392,22 +398,24 @@ function EvaluationGroupCreationModal({
<Field <Field
error={formState.errors.evaluateEvery?.message} error={formState.errors.evaluateEvery?.message}
invalid={!!formState.errors.evaluateEvery} invalid={Boolean(formState.errors.evaluateEvery) ? true : undefined}
label={ label={
<Label <Label htmlFor={evaluateEveryId} description="How often all rules in the group are evaluated.">
htmlFor={evaluateEveryId}
description="How often is the rule evaluated. Applies to every rule within the group."
>
Evaluation interval Evaluation interval
</Label> </Label>
} }
> >
<Input <Stack direction="column">
className={styles.formInput} <Input
id={evaluateEveryId} className={styles.formInput}
placeholder="e.g. 5m" id={evaluateEveryId}
{...register('evaluateEvery', evaluateEveryValidationOptions(groupRules))} placeholder={DEFAULT_GROUP_EVALUATION_INTERVAL}
/> {...register('evaluateEvery', evaluateEveryValidationOptions(groupRules))}
/>
<Stack direction="row" alignItems="flex-end">
<EvaluationGroupQuickPick currentInterval={evaluationInterval} onSelect={setEvaluationInterval} />
</Stack>
</Stack>
</Field> </Field>
<Modal.ButtonRow> <Modal.ButtonRow>
<Button variant="secondary" type="button" onClick={onCancel}> <Button variant="secondary" type="button" onClick={onCancel}>

View File

@ -18,6 +18,7 @@ import { EditCloudGroupModal } from '../rules/EditRuleGroupModal';
import { FolderAndGroup, useFolderGroupOptions } from './FolderAndGroup'; import { FolderAndGroup, useFolderGroupOptions } from './FolderAndGroup';
import { GrafanaAlertStatePicker } from './GrafanaAlertStatePicker'; import { GrafanaAlertStatePicker } from './GrafanaAlertStatePicker';
import { NeedHelpInfo } from './NeedHelpInfo'; import { NeedHelpInfo } from './NeedHelpInfo';
import { PendingPeriodQuickPick } from './PendingPeriodQuickPick';
import { RuleEditorSection } from './RuleEditorSection'; import { RuleEditorSection } from './RuleEditorSection';
export const MIN_TIME_RANGE_STEP_S = 10; // 10 seconds export const MIN_TIME_RANGE_STEP_S = 10; // 10 seconds
@ -104,6 +105,11 @@ function FolderGroupAndEvaluationInterval({
setIsEditingGroup(false); setIsEditingGroup(false);
}; };
// when the group evaluation interval changes, update the pending period to match
useEffect(() => {
setValue('evaluateFor', evaluateEvery);
}, [evaluateEvery, setValue]);
const onOpenEditGroupModal = () => setIsEditingGroup(true); const onOpenEditGroupModal = () => setIsEditingGroup(true);
const editGroupDisabled = groupfoldersForGrafana?.loading || isNewGroup || !folderUid || !groupName; const editGroupDisabled = groupfoldersForGrafana?.loading || isNewGroup || !folderUid || !groupName;
@ -163,9 +169,16 @@ function ForInput({ evaluateEvery }: { evaluateEvery: string }) {
const { const {
register, register,
formState: { errors }, formState: { errors },
setValue,
watch,
} = useFormContext<RuleFormValues>(); } = useFormContext<RuleFormValues>();
const evaluateForId = 'eval-for-input'; const evaluateForId = 'eval-for-input';
const currentPendingPeriod = watch('evaluateFor');
const setPendingPeriod = (pendingPeriod: string) => {
setValue('evaluateFor', pendingPeriod);
};
return ( return (
<Stack direction="row" justify-content="flex-start" align-items="flex-start"> <Stack direction="row" justify-content="flex-start" align-items="flex-start">
@ -180,10 +193,17 @@ function ForInput({ evaluateEvery }: { evaluateEvery: string }) {
} }
className={styles.inlineField} className={styles.inlineField}
error={errors.evaluateFor?.message} error={errors.evaluateFor?.message}
invalid={!!errors.evaluateFor?.message} invalid={Boolean(errors.evaluateFor?.message) ? true : undefined}
validationMessageHorizontalOverflow={true} validationMessageHorizontalOverflow={true}
> >
<Input id={evaluateForId} width={8} {...register('evaluateFor', forValidationOptions(evaluateEvery))} /> <Stack direction="row" alignItems="center">
<Input id={evaluateForId} width={8} {...register('evaluateFor', forValidationOptions(evaluateEvery))} />
<PendingPeriodQuickPick
selectedPendingPeriod={currentPendingPeriod}
groupEvaluationInterval={evaluateEvery}
onSelect={setPendingPeriod}
/>
</Stack>
</Field> </Field>
</Stack> </Stack>
); );

View File

@ -0,0 +1,32 @@
import { screen } from '@testing-library/dom';
import { render } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import React from 'react';
import { PendingPeriodQuickPick } from './PendingPeriodQuickPick';
describe('PendingPeriodQuickPick', () => {
it('should render the correct default preset, set active element and allow selecting other options', async () => {
const onSelect = jest.fn();
render(<PendingPeriodQuickPick onSelect={onSelect} groupEvaluationInterval={'1m'} selectedPendingPeriod={'2m'} />);
const shouldHaveButtons = ['None', '1m', '2m', '3m', '4m', '5m'];
const shouldNotHaveButtons = ['0s', '10s', '6m'];
shouldHaveButtons.forEach((name) => {
expect(screen.getByRole('option', { name })).toBeInTheDocument();
});
shouldNotHaveButtons.forEach((name) => {
expect(screen.queryByRole('option', { name })).not.toBeInTheDocument();
});
expect(screen.getByRole('option', { selected: true })).toHaveTextContent('2m');
await userEvent.click(screen.getByRole('option', { name: '3m' }));
expect(onSelect).toHaveBeenCalledWith('3m');
await userEvent.click(screen.getByRole('option', { name: 'None' }));
expect(onSelect).toHaveBeenCalledWith('0s');
});
});

View File

@ -0,0 +1,52 @@
import React from 'react';
import { Button, Stack } from '@grafana/ui';
import { formatPrometheusDuration, safeParsePrometheusDuration } from '../../utils/time';
interface Props {
selectedPendingPeriod: string;
groupEvaluationInterval: string;
onSelect: (interval: string) => void;
}
export function getPendingPeriodQuickOptions(groupEvaluationInterval: string): string[] {
const groupEvaluationIntervalMillis = safeParsePrometheusDuration(groupEvaluationInterval);
// we generate the quick selection based on the group's evaluation interval
const options: number[] = [
0,
groupEvaluationIntervalMillis * 1,
groupEvaluationIntervalMillis * 2,
groupEvaluationIntervalMillis * 3,
groupEvaluationIntervalMillis * 4,
groupEvaluationIntervalMillis * 5,
];
return options.map(formatPrometheusDuration);
}
export function PendingPeriodQuickPick({ selectedPendingPeriod, groupEvaluationInterval, onSelect }: Props) {
const isQuickSelectionActive = (duration: string) => selectedPendingPeriod === duration;
const options = getPendingPeriodQuickOptions(groupEvaluationInterval);
return (
<Stack direction="row" gap={0.5} role="listbox">
{options.map((duration) => (
<Button
role="option"
aria-selected={isQuickSelectionActive(duration)}
key={duration}
variant={isQuickSelectionActive(duration) ? 'primary' : 'secondary'}
size="sm"
onClick={() => {
onSelect(duration);
}}
>
{duration === '0s' ? 'None' : duration}
</Button>
))}
</Stack>
);
}

View File

@ -29,7 +29,7 @@ import { RuleFormType, RuleFormValues } from '../../../types/rule-form';
import { initialAsyncRequestState } from '../../../utils/redux'; import { initialAsyncRequestState } from '../../../utils/redux';
import { import {
MANUAL_ROUTING_KEY, MANUAL_ROUTING_KEY,
MINUTE, DEFAULT_GROUP_EVALUATION_INTERVAL,
formValuesFromExistingRule, formValuesFromExistingRule,
getDefaultFormValues, getDefaultFormValues,
getDefaultQueries, getDefaultQueries,
@ -59,7 +59,7 @@ export const AlertRuleForm = ({ existing, prefill }: Props) => {
const notifyApp = useAppNotification(); const notifyApp = useAppNotification();
const [queryParams] = useQueryParams(); const [queryParams] = useQueryParams();
const [showEditYaml, setShowEditYaml] = useState(false); const [showEditYaml, setShowEditYaml] = useState(false);
const [evaluateEvery, setEvaluateEvery] = useState(existing?.group.interval ?? MINUTE); const [evaluateEvery, setEvaluateEvery] = useState(existing?.group.interval ?? DEFAULT_GROUP_EVALUATION_INTERVAL);
const routeParams = useParams<{ type: string; id: string }>(); const routeParams = useParams<{ type: string; id: string }>();
const ruleType = translateRouteParamToRuleType(routeParams.type); const ruleType = translateRouteParamToRuleType(routeParams.type);
@ -319,7 +319,7 @@ function formValuesFromQueryParams(ruleDefinition: string, type: RuleFormType):
annotations: normalizeDefaultAnnotations(ruleFromQueryParams.annotations ?? []), annotations: normalizeDefaultAnnotations(ruleFromQueryParams.annotations ?? []),
queries: ruleFromQueryParams.queries ?? getDefaultQueries(), queries: ruleFromQueryParams.queries ?? getDefaultQueries(),
type: type || RuleFormType.grafana, type: type || RuleFormType.grafana,
evaluateEvery: MINUTE, evaluateEvery: DEFAULT_GROUP_EVALUATION_INTERVAL,
}); });
} }

View File

@ -13,7 +13,7 @@ import { fetchRulerRulesGroup } from '../../../api/ruler';
import { useDataSourceFeatures } from '../../../hooks/useCombinedRule'; import { useDataSourceFeatures } from '../../../hooks/useCombinedRule';
import { RuleFormValues } from '../../../types/rule-form'; import { RuleFormValues } from '../../../types/rule-form';
import { GRAFANA_RULES_SOURCE_NAME } from '../../../utils/datasource'; import { GRAFANA_RULES_SOURCE_NAME } from '../../../utils/datasource';
import { formValuesToRulerGrafanaRuleDTO, MINUTE } from '../../../utils/rule-form'; import { DEFAULT_GROUP_EVALUATION_INTERVAL, formValuesToRulerGrafanaRuleDTO } from '../../../utils/rule-form';
import { isGrafanaRulerRule } from '../../../utils/rules'; import { isGrafanaRulerRule } from '../../../utils/rules';
import { FileExportPreview } from '../../export/FileExportPreview'; import { FileExportPreview } from '../../export/FileExportPreview';
import { GrafanaExportDrawer } from '../../export/GrafanaExportDrawer'; import { GrafanaExportDrawer } from '../../export/GrafanaExportDrawer';
@ -44,7 +44,7 @@ export function ModifyExportRuleForm({ ruleForm, alertUid }: ModifyExportRuleFor
const [exportData, setExportData] = useState<RuleFormValues | undefined>(undefined); const [exportData, setExportData] = useState<RuleFormValues | undefined>(undefined);
const [conditionErrorMsg, setConditionErrorMsg] = useState(''); const [conditionErrorMsg, setConditionErrorMsg] = useState('');
const [evaluateEvery, setEvaluateEvery] = useState(ruleForm?.evaluateEvery ?? MINUTE); const [evaluateEvery, setEvaluateEvery] = useState(ruleForm?.evaluateEvery ?? DEFAULT_GROUP_EVALUATION_INTERVAL);
const onInvalid = (): void => { const onInvalid = (): void => {
notifyApp.error('There are errors in the form. Please correct them and try again!'); notifyApp.error('There are errors in the form. Please correct them and try again!');

View File

@ -61,7 +61,7 @@ const expectedModifiedRule2 = (uid: string) => ({
annotations: { annotations: {
summary: 'This grafana rule2 updated', summary: 'This grafana rule2 updated',
}, },
for: '5m', for: '1m',
grafana_alert: { grafana_alert: {
condition: 'A', condition: 'A',
data: [ data: [

View File

@ -176,7 +176,7 @@ describe('Can create a new grafana managed alert unsing simplified routing', ()
{ {
annotations: { description: 'some description', summary: 'some summary' }, annotations: { description: 'some description', summary: 'some summary' },
labels: { severity: 'warn', team: 'the a-team' }, labels: { severity: 'warn', team: 'the a-team' },
for: '5m', for: '1m',
grafana_alert: { grafana_alert: {
uid: '23', uid: '23',
namespace_uid: 'abcd', namespace_uid: 'abcd',
@ -198,7 +198,7 @@ describe('Can create a new grafana managed alert unsing simplified routing', ()
{ {
annotations: { description: 'some description', summary: 'some summary' }, annotations: { description: 'some description', summary: 'some summary' },
labels: { severity: 'warn', team: 'the a-team' }, labels: { severity: 'warn', team: 'the a-team' },
for: '5m', for: '1m',
grafana_alert: { grafana_alert: {
uid: '23', uid: '23',
namespace_uid: 'b', namespace_uid: 'b',
@ -302,7 +302,7 @@ describe('Can create a new grafana managed alert unsing simplified routing', ()
{ {
annotations: { description: 'some description', summary: 'some summary' }, annotations: { description: 'some description', summary: 'some summary' },
labels: { severity: 'warn', team: 'the a-team' }, labels: { severity: 'warn', team: 'the a-team' },
for: '5m', for: '1m',
grafana_alert: { grafana_alert: {
uid: '23', uid: '23',
namespace_uid: 'abcd', namespace_uid: 'abcd',
@ -324,7 +324,7 @@ describe('Can create a new grafana managed alert unsing simplified routing', ()
{ {
annotations: { description: 'some description', summary: 'some summary' }, annotations: { description: 'some description', summary: 'some summary' },
labels: { severity: 'warn', team: 'the a-team' }, labels: { severity: 'warn', team: 'the a-team' },
for: '5m', for: '1m',
grafana_alert: { grafana_alert: {
uid: '23', uid: '23',
namespace_uid: 'b', namespace_uid: 'b',
@ -396,7 +396,7 @@ describe('Can create a new grafana managed alert unsing simplified routing', ()
{ {
annotations: {}, annotations: {},
labels: {}, labels: {},
for: '5m', for: '1m',
grafana_alert: { grafana_alert: {
condition: 'B', condition: 'B',
data: getDefaultQueries(), data: getDefaultQueries(),

View File

@ -16,11 +16,13 @@ import { rulesInSameGroupHaveInvalidFor, updateLotexNamespaceAndGroupAction } fr
import { checkEvaluationIntervalGlobalLimit } from '../../utils/config'; import { checkEvaluationIntervalGlobalLimit } from '../../utils/config';
import { getRulesSourceName, GRAFANA_RULES_SOURCE_NAME } from '../../utils/datasource'; import { getRulesSourceName, GRAFANA_RULES_SOURCE_NAME } from '../../utils/datasource';
import { initialAsyncRequestState } from '../../utils/redux'; import { initialAsyncRequestState } from '../../utils/redux';
import { DEFAULT_GROUP_EVALUATION_INTERVAL } from '../../utils/rule-form';
import { AlertInfo, getAlertInfo, isRecordingRulerRule } from '../../utils/rules'; import { AlertInfo, getAlertInfo, isRecordingRulerRule } from '../../utils/rules';
import { parsePrometheusDuration, safeParseDurationstr } from '../../utils/time'; import { formatPrometheusDuration, parsePrometheusDuration, safeParsePrometheusDuration } from '../../utils/time';
import { DynamicTable, DynamicTableColumnProps, DynamicTableItemProps } from '../DynamicTable'; import { DynamicTable, DynamicTableColumnProps, DynamicTableItemProps } from '../DynamicTable';
import { EvaluationIntervalLimitExceeded } from '../InvalidIntervalWarning'; import { EvaluationIntervalLimitExceeded } from '../InvalidIntervalWarning';
import { decodeGrafanaNamespace, encodeGrafanaNamespace } from '../expressions/util'; import { decodeGrafanaNamespace, encodeGrafanaNamespace } from '../expressions/util';
import { EvaluationGroupQuickPick } from '../rule-editor/EvaluationGroupQuickPick';
import { MIN_TIME_RANGE_STEP_S } from '../rule-editor/GrafanaEvaluationBehavior'; import { MIN_TIME_RANGE_STEP_S } from '../rule-editor/GrafanaEvaluationBehavior';
const ITEMS_PER_PAGE = 10; const ITEMS_PER_PAGE = 10;
@ -68,7 +70,8 @@ export const RulesForGroupTable = ({ rulesWithoutRecordingRules }: { rulesWithou
data: getAlertInfo(rule, currentInterval), data: getAlertInfo(rule, currentInterval),
})) }))
.sort( .sort(
(alert1, alert2) => safeParseDurationstr(alert1.data.forDuration) - safeParseDurationstr(alert2.data.forDuration) (alert1, alert2) =>
safeParsePrometheusDuration(alert1.data.forDuration) - safeParsePrometheusDuration(alert2.data.forDuration)
); );
const columns: AlertsWithForTableColumnProps[] = useMemo(() => { const columns: AlertsWithForTableColumnProps[] = useMemo(() => {
@ -145,7 +148,12 @@ export const evaluateEveryValidationOptions = (rules: RulerRuleDTO[]): RegisterO
if (rulesInSameGroupHaveInvalidFor(rules, evaluateEvery).length === 0) { if (rulesInSameGroupHaveInvalidFor(rules, evaluateEvery).length === 0) {
return true; return true;
} else { } else {
return `Invalid evaluation interval. Evaluation interval should be smaller or equal to 'For' values for existing rules in this group.`; const rulePendingPeriods = rules.map((rule) => {
const { forDuration } = getAlertInfo(rule, evaluateEvery);
return safeParsePrometheusDuration(forDuration);
});
const largestPendingPeriod = Math.min(...rulePendingPeriods);
return `Evaluation interval should be smaller or equal to "pending period" values for existing rules in this rule group. Choose a value smaller than or equal to "${formatPrometheusDuration(largestPendingPeriod)}".`;
} }
} catch (error) { } catch (error) {
return error instanceof Error ? error.message : 'Failed to parse duration'; return error instanceof Error ? error.message : 'Failed to parse duration';
@ -176,9 +184,9 @@ export function EditCloudGroupModal(props: ModalProps): React.ReactElement {
(): FormValues => ({ (): FormValues => ({
namespaceName: decodeGrafanaNamespace(namespace).name, namespaceName: decodeGrafanaNamespace(namespace).name,
groupName: group.name, groupName: group.name,
groupInterval: group.interval ?? '', groupInterval: group.interval ?? DEFAULT_GROUP_EVALUATION_INTERVAL,
}), }),
[namespace, group] [namespace, group.name, group.interval]
); );
const rulesSourceName = getRulesSourceName(namespace.rulesSource); const rulesSourceName = getRulesSourceName(namespace.rulesSource);
@ -227,6 +235,8 @@ export function EditCloudGroupModal(props: ModalProps): React.ReactElement {
register, register,
watch, watch,
formState: { isDirty, errors }, formState: { isDirty, errors },
setValue,
getValues,
} = formAPI; } = formAPI;
const onInvalid = () => { const onInvalid = () => {
@ -260,7 +270,7 @@ export function EditCloudGroupModal(props: ModalProps): React.ReactElement {
{nameSpaceLabel} {nameSpaceLabel}
</Label> </Label>
} }
invalid={!!errors.namespaceName} invalid={Boolean(errors.namespaceName) ? true : undefined}
error={errors.namespaceName?.message} error={errors.namespaceName?.message}
> >
<Input <Input
@ -305,14 +315,20 @@ export function EditCloudGroupModal(props: ModalProps): React.ReactElement {
<Stack gap={0.5}>Evaluation interval</Stack> <Stack gap={0.5}>Evaluation interval</Stack>
</Label> </Label>
} }
invalid={!!errors.groupInterval} invalid={Boolean(errors.groupInterval) ? true : undefined}
error={errors.groupInterval?.message} error={errors.groupInterval?.message}
> >
<Input <Stack direction="column">
id="groupInterval" <Input
placeholder="1m" id="groupInterval"
{...register('groupInterval', evaluateEveryValidationOptions(rulesWithoutRecordingRules))} placeholder={DEFAULT_GROUP_EVALUATION_INTERVAL}
/> {...register('groupInterval', evaluateEveryValidationOptions(rulesWithoutRecordingRules))}
/>
<EvaluationGroupQuickPick
currentInterval={getValues('groupInterval')}
onSelect={(value) => setValue('groupInterval', value, { shouldValidate: true })}
/>
</Stack>
</Field> </Field>
{checkEvaluationIntervalGlobalLimit(watch('groupInterval')).exceedsLimit && ( {checkEvaluationIntervalGlobalLimit(watch('groupInterval')).exceedsLimit && (

View File

@ -82,7 +82,7 @@ import { AsyncRequestMapSlice, withAppEvents, withSerializedError } from '../uti
import * as ruleId from '../utils/rule-id'; import * as ruleId from '../utils/rule-id';
import { getRulerClient } from '../utils/rulerClient'; import { getRulerClient } from '../utils/rulerClient';
import { getAlertInfo, isGrafanaRulerRule, isRulerNotSupportedResponse } from '../utils/rules'; import { getAlertInfo, isGrafanaRulerRule, isRulerNotSupportedResponse } from '../utils/rules';
import { safeParseDurationstr } from '../utils/time'; import { safeParsePrometheusDuration } from '../utils/time';
function getDataSourceConfig(getState: () => unknown, rulesSourceName: string) { function getDataSourceConfig(getState: () => unknown, rulesSourceName: string) {
const dataSources = (getState() as StoreState).unifiedAlerting.dataSources; const dataSources = (getState() as StoreState).unifiedAlerting.dataSources;
@ -782,8 +782,8 @@ interface UpdateNamespaceAndGroupOptions {
export const rulesInSameGroupHaveInvalidFor = (rules: RulerRuleDTO[], everyDuration: string) => { export const rulesInSameGroupHaveInvalidFor = (rules: RulerRuleDTO[], everyDuration: string) => {
return rules.filter((rule: RulerRuleDTO) => { return rules.filter((rule: RulerRuleDTO) => {
const { forDuration } = getAlertInfo(rule, everyDuration); const { forDuration } = getAlertInfo(rule, everyDuration);
const forNumber = safeParseDurationstr(forDuration); const forNumber = safeParsePrometheusDuration(forDuration);
const everyNumber = safeParseDurationstr(everyDuration); const everyNumber = safeParsePrometheusDuration(everyDuration);
return forNumber !== 0 && forNumber < everyNumber; return forNumber !== 0 && forNumber < everyNumber;
}); });

View File

@ -7,7 +7,7 @@ exports[`formValuesToRulerGrafanaRuleDTO should correctly convert rule form valu
"runbook_url": "", "runbook_url": "",
"summary": "", "summary": "",
}, },
"for": "5m", "for": "1m",
"grafana_alert": { "grafana_alert": {
"condition": "A", "condition": "A",
"data": [], "data": [],
@ -30,7 +30,7 @@ exports[`formValuesToRulerGrafanaRuleDTO should not save both instant and range
"runbook_url": "", "runbook_url": "",
"summary": "", "summary": "",
}, },
"for": "5m", "for": "1m",
"grafana_alert": { "grafana_alert": {
"condition": "A", "condition": "A",
"data": [ "data": [

View File

@ -10,7 +10,7 @@ import { matcherToMatcherField } from './alertmanager';
import { GRAFANA_RULES_SOURCE_NAME } from './datasource'; import { GRAFANA_RULES_SOURCE_NAME } from './datasource';
import { normalizeMatchers, parseMatcherToArray, quoteWithEscape, unquoteWithUnescape } from './matchers'; import { normalizeMatchers, parseMatcherToArray, quoteWithEscape, unquoteWithUnescape } from './matchers';
import { findExistingRoute } from './routeTree'; import { findExistingRoute } from './routeTree';
import { isValidPrometheusDuration, safeParseDurationstr } from './time'; import { isValidPrometheusDuration, safeParsePrometheusDuration } from './time';
const matchersToArrayFieldMatchers = ( const matchersToArrayFieldMatchers = (
matchers: Record<string, string> | undefined, matchers: Record<string, string> | undefined,
@ -268,8 +268,8 @@ export const repeatIntervalValidator = (repeatInterval: string, groupInterval =
return validGroupInterval; return validGroupInterval;
} }
const repeatDuration = safeParseDurationstr(repeatInterval); const repeatDuration = safeParsePrometheusDuration(repeatInterval);
const groupDuration = safeParseDurationstr(groupInterval); const groupDuration = safeParsePrometheusDuration(groupInterval);
const isRepeatLowerThanGroupDuration = groupDuration !== 0 && repeatDuration < groupDuration; const isRepeatLowerThanGroupDuration = groupDuration !== 0 && repeatDuration < groupDuration;

View File

@ -1,7 +1,7 @@
import { DataSourceInstanceSettings, DataSourceJsonData } from '@grafana/data'; import { DataSourceInstanceSettings, DataSourceJsonData } from '@grafana/data';
import { config } from '@grafana/runtime'; import { config } from '@grafana/runtime';
import { isValidPrometheusDuration, parsePrometheusDuration } from './time'; import { isValidPrometheusDuration, safeParsePrometheusDuration } from './time';
export function getAllDataSources(): Array<DataSourceInstanceSettings<DataSourceJsonData>> { export function getAllDataSources(): Array<DataSourceInstanceSettings<DataSourceJsonData>> {
return Object.values(config.datasources); return Object.values(config.datasources);
@ -14,13 +14,13 @@ export function checkEvaluationIntervalGlobalLimit(alertGroupEvaluateEvery?: str
return { globalLimit: 0, exceedsLimit: false }; return { globalLimit: 0, exceedsLimit: false };
} }
const evaluateEveryGlobalLimitMs = parsePrometheusDuration(config.unifiedAlerting.minInterval); const evaluateEveryGlobalLimitMs = safeParsePrometheusDuration(config.unifiedAlerting.minInterval);
if (!alertGroupEvaluateEvery || !isValidPrometheusDuration(alertGroupEvaluateEvery)) { if (!alertGroupEvaluateEvery || !isValidPrometheusDuration(alertGroupEvaluateEvery)) {
return { globalLimit: evaluateEveryGlobalLimitMs, exceedsLimit: false }; return { globalLimit: evaluateEveryGlobalLimitMs, exceedsLimit: false };
} }
const evaluateEveryMs = parsePrometheusDuration(alertGroupEvaluateEvery); const evaluateEveryMs = safeParsePrometheusDuration(alertGroupEvaluateEvery);
const exceedsLimit = evaluateEveryGlobalLimitMs > evaluateEveryMs && evaluateEveryMs > 0; const exceedsLimit = evaluateEveryGlobalLimitMs > evaluateEveryMs && evaluateEveryMs > 0;

View File

@ -9,7 +9,7 @@ import { AlertQuery } from 'app/types/unified-alerting-dto';
import { isCloudRulesSource } from './datasource'; import { isCloudRulesSource } from './datasource';
import { isGrafanaRulerRule } from './rules'; import { isGrafanaRulerRule } from './rules';
import { safeParseDurationstr } from './time'; import { safeParsePrometheusDuration } from './time';
export function alertRuleToQueries(combinedRule: CombinedRule | undefined | null): AlertQuery[] { export function alertRuleToQueries(combinedRule: CombinedRule | undefined | null): AlertQuery[] {
if (!combinedRule) { if (!combinedRule) {
@ -45,7 +45,7 @@ export function alertRuleToQueries(combinedRule: CombinedRule | undefined | null
export function widenRelativeTimeRanges(queries: AlertQuery[], pendingPeriod: string, groupInterval?: string) { export function widenRelativeTimeRanges(queries: AlertQuery[], pendingPeriod: string, groupInterval?: string) {
// if pending period is zero that means inherit from group interval, if that is empty then assume 1m // if pending period is zero that means inherit from group interval, if that is empty then assume 1m
const pendingPeriodDurationMillis = const pendingPeriodDurationMillis =
safeParseDurationstr(pendingPeriod) ?? safeParseDurationstr(groupInterval ?? '1m'); safeParsePrometheusDuration(pendingPeriod) ?? safeParsePrometheusDuration(groupInterval ?? '1m');
const pendingPeriodDuration = Math.floor(pendingPeriodDurationMillis / 1000); const pendingPeriodDuration = Math.floor(pendingPeriodDurationMillis / 1000);
return queries.map((query) => return queries.map((query) =>

View File

@ -1,4 +1,4 @@
import { omit } from 'lodash'; import { clamp, omit } from 'lodash';
import { import {
DataQuery, DataQuery,
@ -48,14 +48,21 @@ import { Annotation, defaultAnnotations } from './constants';
import { getDefaultOrFirstCompatibleDataSource, GRAFANA_RULES_SOURCE_NAME, isGrafanaRulesSource } from './datasource'; import { getDefaultOrFirstCompatibleDataSource, GRAFANA_RULES_SOURCE_NAME, isGrafanaRulesSource } from './datasource';
import { arrayToRecord, recordToArray } from './misc'; import { arrayToRecord, recordToArray } from './misc';
import { isAlertingRulerRule, isGrafanaRulerRule, isRecordingRulerRule } from './rules'; import { isAlertingRulerRule, isGrafanaRulerRule, isRecordingRulerRule } from './rules';
import { parseInterval } from './time'; import { formatPrometheusDuration, parseInterval, safeParsePrometheusDuration } from './time';
export type PromOrLokiQuery = PromQuery | LokiQuery; export type PromOrLokiQuery = PromQuery | LokiQuery;
export const MINUTE = '1m';
export const MANUAL_ROUTING_KEY = 'grafana.alerting.manualRouting'; export const MANUAL_ROUTING_KEY = 'grafana.alerting.manualRouting';
// even if the min interval is < 1m we should default to 1m, but allow arbitrary values for minInterval > 1m
const GROUP_EVALUATION_MIN_INTERVAL_MS = safeParsePrometheusDuration(config.unifiedAlerting?.minInterval ?? '10s');
const GROUP_EVALUATION_INTERVAL_LOWER_BOUND = safeParsePrometheusDuration('1m');
const GROUP_EVALUATION_INTERVAL_UPPER_BOUND = Infinity;
export const DEFAULT_GROUP_EVALUATION_INTERVAL = formatPrometheusDuration(
clamp(GROUP_EVALUATION_MIN_INTERVAL_MS, GROUP_EVALUATION_INTERVAL_LOWER_BOUND, GROUP_EVALUATION_INTERVAL_UPPER_BOUND)
);
export const getDefaultFormValues = (): RuleFormValues => { export const getDefaultFormValues = (): RuleFormValues => {
const { canCreateGrafanaRules, canCreateCloudRules } = getRulesAccess(); const { canCreateGrafanaRules, canCreateCloudRules } = getRulesAccess();
@ -75,8 +82,8 @@ export const getDefaultFormValues = (): RuleFormValues => {
condition: '', condition: '',
noDataState: GrafanaAlertStateDecision.NoData, noDataState: GrafanaAlertStateDecision.NoData,
execErrState: GrafanaAlertStateDecision.Error, execErrState: GrafanaAlertStateDecision.Error,
evaluateFor: '5m', evaluateFor: DEFAULT_GROUP_EVALUATION_INTERVAL,
evaluateEvery: MINUTE, evaluateEvery: DEFAULT_GROUP_EVALUATION_INTERVAL,
manualRouting: getDefautManualRouting(), // we default to true if the feature toggle is enabled and the user hasn't set local storage to false manualRouting: getDefautManualRouting(), // we default to true if the feature toggle is enabled and the user hasn't set local storage to false
contactPoints: {}, contactPoints: {},
overrideGrouping: false, overrideGrouping: false,

View File

@ -34,7 +34,7 @@ import { RuleHealth } from '../search/rulesSearchParser';
import { RULER_NOT_SUPPORTED_MSG } from './constants'; import { RULER_NOT_SUPPORTED_MSG } from './constants';
import { getRulesSourceName } from './datasource'; import { getRulesSourceName } from './datasource';
import { AsyncRequestState } from './redux'; import { AsyncRequestState } from './redux';
import { safeParseDurationstr } from './time'; import { safeParsePrometheusDuration } from './time';
export function isAlertingRule(rule: Rule | undefined): rule is AlertingRule { export function isAlertingRule(rule: Rule | undefined): rule is AlertingRule {
return typeof rule === 'object' && rule.type === PromRuleType.Alerting; return typeof rule === 'object' && rule.type === PromRuleType.Alerting;
@ -236,8 +236,8 @@ export const getAlertInfo = (alert: RulerRuleDTO, currentEvaluation: string): Al
}; };
export const getNumberEvaluationsToStartAlerting = (forDuration: string, currentEvaluation: string) => { export const getNumberEvaluationsToStartAlerting = (forDuration: string, currentEvaluation: string) => {
const evalNumberMs = safeParseDurationstr(currentEvaluation); const evalNumberMs = safeParsePrometheusDuration(currentEvaluation);
const forNumber = safeParseDurationstr(forDuration); const forNumber = safeParsePrometheusDuration(forDuration);
if (forNumber === 0 && evalNumberMs !== 0) { if (forNumber === 0 && evalNumberMs !== 0) {
return 1; return 1;
} }

View File

@ -1,4 +1,4 @@
import { isValidPrometheusDuration } from './time'; import { formatPrometheusDuration, isValidPrometheusDuration, parsePrometheusDuration } from './time';
describe('isValidPrometheusDuration', () => { describe('isValidPrometheusDuration', () => {
const validDurations = ['20h30m10s45ms', '1m30s', '20s4h', '90s', '10s', '20h20h', '2d4h20m']; const validDurations = ['20h30m10s45ms', '1m30s', '20s4h', '90s', '10s', '20h20h', '2d4h20m'];
@ -13,3 +13,44 @@ describe('isValidPrometheusDuration', () => {
expect(isValidPrometheusDuration(duration)).toBe(false); expect(isValidPrometheusDuration(duration)).toBe(false);
}); });
}); });
describe('parsePrometheusDuration', () => {
const tests: Array<[string, number]> = [
['1ms', 1],
['1s', 1000],
['1m', 1000 * 60],
['1h', 1000 * 60 * 60],
['1d', 1000 * 60 * 60 * 24],
['1w', 1000 * 60 * 60 * 24 * 7],
['1y', 1000 * 60 * 60 * 24 * 365],
['1d10h17m36s789ms', 123456789],
['1w4d10h20m54s321ms', 987654321],
['1y1w1d1h1m1s1ms', 32230861001],
];
test.each(tests)('.parsePrometheusDuration(%s)', (input, expected) => {
expect(parsePrometheusDuration(input)).toBe(expected);
});
});
describe('formatPrometheusDuration', () => {
it('should return "0s" for 0 milliseconds', () => {
const result = formatPrometheusDuration(0);
expect(result).toBe('0s');
});
const tests: Array<[number, string]> = [
[0, '0s'],
[1000, '1s'],
[60000, '1m'],
[3600000, '1h'],
[86400000, '1d'],
[604800000, '1w'],
[31536000000, '1y'],
[123456789, '1d10h17m36s789ms'],
[987654321, '1w4d10h20m54s321ms'],
[32230861001, '1y1w1d1h1m1s1ms'],
];
test.each(tests)('.formatPrometheusDuration(%s)', (input, expected) => {
expect(formatPrometheusDuration(input)).toBe(expected);
});
});

View File

@ -95,7 +95,46 @@ export function parsePrometheusDuration(duration: string): number {
return totalDuration; return totalDuration;
} }
export const safeParseDurationstr = (duration: string): number => { /**
* Formats the given duration in milliseconds into a human-readable string representation.
*
* @param milliseconds - The duration in milliseconds.
* @returns The formatted duration string.
*/
export function formatPrometheusDuration(milliseconds: number): string {
const seconds = Math.floor(milliseconds / 1000);
const minutes = Math.floor(seconds / 60);
const hours = Math.floor(minutes / 60);
const days = Math.floor(hours / 24);
const weeks = Math.floor(days / 7);
const years = Math.floor(days / 365);
// we'll make an exception here for 0, 0ms seems a bit weird
if (milliseconds === 0) {
return '0s';
}
const timeUnits: Array<[number, string]> = [
[years, 'y'],
[weeks % 52, 'w'],
[(days % 365) - 7 * (weeks % 52), 'd'],
[hours % 24, 'h'],
[minutes % 60, 'm'],
[seconds % 60, 's'],
[milliseconds % 1000, 'ms'],
];
return (
timeUnits
// remove all 0 values
.filter(([time]) => time > 0)
// join time and unit
.map(([time, unit]) => time + unit)
.join('')
);
}
export const safeParsePrometheusDuration = (duration: string): number => {
try { try {
return parsePrometheusDuration(duration); return parsePrometheusDuration(duration);
} catch (e) { } catch (e) {