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

View File

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

View File

@ -706,6 +706,7 @@ describe('RuleList', () => {
await userEvent.clear(ui.editGroupModal.ruleGroupInput.get());
await userEvent.type(ui.editGroupModal.ruleGroupInput.get(), 'super group');
await userEvent.clear(ui.editGroupModal.intervalInput.get());
await userEvent.type(ui.editGroupModal.intervalInput.get(), '5m');
// submit, check that appropriate calls were made
@ -743,6 +744,8 @@ describe('RuleList', () => {
// make changes to form
await userEvent.clear(ui.editGroupModal.ruleGroupInput.get());
await userEvent.type(ui.editGroupModal.ruleGroupInput.get(), 'super group');
await userEvent.clear(ui.editGroupModal.intervalInput.get());
await userEvent.type(ui.editGroupModal.intervalInput.get(), '5m');
// submit, check that appropriate calls were made
@ -773,6 +776,7 @@ describe('RuleList', () => {
testCase('edit lotex group eval interval, no renaming', async () => {
// make changes to form
await userEvent.clear(ui.editGroupModal.intervalInput.get());
await userEvent.type(ui.editGroupModal.intervalInput.get(), '5m');
// 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 { RuleFormValues } from '../../types/rule-form';
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 { ProvisioningBadge } from '../Provisioning';
import { evaluateEveryValidationOptions } from '../rules/EditRuleGroupModal';
import { EvaluationGroupQuickPick } from './EvaluationGroupQuickPick';
import { containsSlashes, Folder, RuleFolderPicker } from './RuleFolderPicker';
import { checkForPathSeparator } from './util';
@ -48,7 +49,7 @@ export const useFolderGroupOptions = (folderUid: string, enableProvisionedGroups
return {
label: 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
isDisabled: !enableProvisionedGroups ? isProvisioned : false,
isProvisioned: isProvisioned,
@ -357,12 +358,17 @@ function EvaluationGroupCreationModal({
};
const formAPI = useForm({
defaultValues: { group: '', evaluateEvery: '' },
defaultValues: { group: '', evaluateEvery: DEFAULT_GROUP_EVALUATION_INTERVAL },
mode: 'onChange',
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 (
<Modal
@ -379,7 +385,7 @@ function EvaluationGroupCreationModal({
<Field
label={<Label htmlFor={'group'}>Evaluation group name</Label>}
error={formState.errors.group?.message}
invalid={!!formState.errors.group}
invalid={Boolean(formState.errors.group)}
>
<Input
className={styles.formInput}
@ -392,22 +398,24 @@ function EvaluationGroupCreationModal({
<Field
error={formState.errors.evaluateEvery?.message}
invalid={!!formState.errors.evaluateEvery}
invalid={Boolean(formState.errors.evaluateEvery) ? true : undefined}
label={
<Label
htmlFor={evaluateEveryId}
description="How often is the rule evaluated. Applies to every rule within the group."
>
<Label htmlFor={evaluateEveryId} description="How often all rules in the group are evaluated.">
Evaluation interval
</Label>
}
>
<Input
className={styles.formInput}
id={evaluateEveryId}
placeholder="e.g. 5m"
{...register('evaluateEvery', evaluateEveryValidationOptions(groupRules))}
/>
<Stack direction="column">
<Input
className={styles.formInput}
id={evaluateEveryId}
placeholder={DEFAULT_GROUP_EVALUATION_INTERVAL}
{...register('evaluateEvery', evaluateEveryValidationOptions(groupRules))}
/>
<Stack direction="row" alignItems="flex-end">
<EvaluationGroupQuickPick currentInterval={evaluationInterval} onSelect={setEvaluationInterval} />
</Stack>
</Stack>
</Field>
<Modal.ButtonRow>
<Button variant="secondary" type="button" onClick={onCancel}>

View File

@ -18,6 +18,7 @@ import { EditCloudGroupModal } from '../rules/EditRuleGroupModal';
import { FolderAndGroup, useFolderGroupOptions } from './FolderAndGroup';
import { GrafanaAlertStatePicker } from './GrafanaAlertStatePicker';
import { NeedHelpInfo } from './NeedHelpInfo';
import { PendingPeriodQuickPick } from './PendingPeriodQuickPick';
import { RuleEditorSection } from './RuleEditorSection';
export const MIN_TIME_RANGE_STEP_S = 10; // 10 seconds
@ -104,6 +105,11 @@ function FolderGroupAndEvaluationInterval({
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 editGroupDisabled = groupfoldersForGrafana?.loading || isNewGroup || !folderUid || !groupName;
@ -163,9 +169,16 @@ function ForInput({ evaluateEvery }: { evaluateEvery: string }) {
const {
register,
formState: { errors },
setValue,
watch,
} = useFormContext<RuleFormValues>();
const evaluateForId = 'eval-for-input';
const currentPendingPeriod = watch('evaluateFor');
const setPendingPeriod = (pendingPeriod: string) => {
setValue('evaluateFor', pendingPeriod);
};
return (
<Stack direction="row" justify-content="flex-start" align-items="flex-start">
@ -180,10 +193,17 @@ function ForInput({ evaluateEvery }: { evaluateEvery: string }) {
}
className={styles.inlineField}
error={errors.evaluateFor?.message}
invalid={!!errors.evaluateFor?.message}
invalid={Boolean(errors.evaluateFor?.message) ? true : undefined}
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>
</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 {
MANUAL_ROUTING_KEY,
MINUTE,
DEFAULT_GROUP_EVALUATION_INTERVAL,
formValuesFromExistingRule,
getDefaultFormValues,
getDefaultQueries,
@ -59,7 +59,7 @@ export const AlertRuleForm = ({ existing, prefill }: Props) => {
const notifyApp = useAppNotification();
const [queryParams] = useQueryParams();
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 ruleType = translateRouteParamToRuleType(routeParams.type);
@ -319,7 +319,7 @@ function formValuesFromQueryParams(ruleDefinition: string, type: RuleFormType):
annotations: normalizeDefaultAnnotations(ruleFromQueryParams.annotations ?? []),
queries: ruleFromQueryParams.queries ?? getDefaultQueries(),
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 { RuleFormValues } from '../../../types/rule-form';
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 { FileExportPreview } from '../../export/FileExportPreview';
import { GrafanaExportDrawer } from '../../export/GrafanaExportDrawer';
@ -44,7 +44,7 @@ export function ModifyExportRuleForm({ ruleForm, alertUid }: ModifyExportRuleFor
const [exportData, setExportData] = useState<RuleFormValues | undefined>(undefined);
const [conditionErrorMsg, setConditionErrorMsg] = useState('');
const [evaluateEvery, setEvaluateEvery] = useState(ruleForm?.evaluateEvery ?? MINUTE);
const [evaluateEvery, setEvaluateEvery] = useState(ruleForm?.evaluateEvery ?? DEFAULT_GROUP_EVALUATION_INTERVAL);
const onInvalid = (): void => {
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: {
summary: 'This grafana rule2 updated',
},
for: '5m',
for: '1m',
grafana_alert: {
condition: 'A',
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' },
labels: { severity: 'warn', team: 'the a-team' },
for: '5m',
for: '1m',
grafana_alert: {
uid: '23',
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' },
labels: { severity: 'warn', team: 'the a-team' },
for: '5m',
for: '1m',
grafana_alert: {
uid: '23',
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' },
labels: { severity: 'warn', team: 'the a-team' },
for: '5m',
for: '1m',
grafana_alert: {
uid: '23',
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' },
labels: { severity: 'warn', team: 'the a-team' },
for: '5m',
for: '1m',
grafana_alert: {
uid: '23',
namespace_uid: 'b',
@ -396,7 +396,7 @@ describe('Can create a new grafana managed alert unsing simplified routing', ()
{
annotations: {},
labels: {},
for: '5m',
for: '1m',
grafana_alert: {
condition: 'B',
data: getDefaultQueries(),

View File

@ -16,11 +16,13 @@ import { rulesInSameGroupHaveInvalidFor, updateLotexNamespaceAndGroupAction } fr
import { checkEvaluationIntervalGlobalLimit } from '../../utils/config';
import { getRulesSourceName, GRAFANA_RULES_SOURCE_NAME } from '../../utils/datasource';
import { initialAsyncRequestState } from '../../utils/redux';
import { DEFAULT_GROUP_EVALUATION_INTERVAL } from '../../utils/rule-form';
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 { EvaluationIntervalLimitExceeded } from '../InvalidIntervalWarning';
import { decodeGrafanaNamespace, encodeGrafanaNamespace } from '../expressions/util';
import { EvaluationGroupQuickPick } from '../rule-editor/EvaluationGroupQuickPick';
import { MIN_TIME_RANGE_STEP_S } from '../rule-editor/GrafanaEvaluationBehavior';
const ITEMS_PER_PAGE = 10;
@ -68,7 +70,8 @@ export const RulesForGroupTable = ({ rulesWithoutRecordingRules }: { rulesWithou
data: getAlertInfo(rule, currentInterval),
}))
.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(() => {
@ -145,7 +148,12 @@ export const evaluateEveryValidationOptions = (rules: RulerRuleDTO[]): RegisterO
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.`;
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) {
return error instanceof Error ? error.message : 'Failed to parse duration';
@ -176,9 +184,9 @@ export function EditCloudGroupModal(props: ModalProps): React.ReactElement {
(): FormValues => ({
namespaceName: decodeGrafanaNamespace(namespace).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);
@ -227,6 +235,8 @@ export function EditCloudGroupModal(props: ModalProps): React.ReactElement {
register,
watch,
formState: { isDirty, errors },
setValue,
getValues,
} = formAPI;
const onInvalid = () => {
@ -260,7 +270,7 @@ export function EditCloudGroupModal(props: ModalProps): React.ReactElement {
{nameSpaceLabel}
</Label>
}
invalid={!!errors.namespaceName}
invalid={Boolean(errors.namespaceName) ? true : undefined}
error={errors.namespaceName?.message}
>
<Input
@ -305,14 +315,20 @@ export function EditCloudGroupModal(props: ModalProps): React.ReactElement {
<Stack gap={0.5}>Evaluation interval</Stack>
</Label>
}
invalid={!!errors.groupInterval}
invalid={Boolean(errors.groupInterval) ? true : undefined}
error={errors.groupInterval?.message}
>
<Input
id="groupInterval"
placeholder="1m"
{...register('groupInterval', evaluateEveryValidationOptions(rulesWithoutRecordingRules))}
/>
<Stack direction="column">
<Input
id="groupInterval"
placeholder={DEFAULT_GROUP_EVALUATION_INTERVAL}
{...register('groupInterval', evaluateEveryValidationOptions(rulesWithoutRecordingRules))}
/>
<EvaluationGroupQuickPick
currentInterval={getValues('groupInterval')}
onSelect={(value) => setValue('groupInterval', value, { shouldValidate: true })}
/>
</Stack>
</Field>
{checkEvaluationIntervalGlobalLimit(watch('groupInterval')).exceedsLimit && (

View File

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

View File

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

View File

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

View File

@ -1,7 +1,7 @@
import { DataSourceInstanceSettings, DataSourceJsonData } from '@grafana/data';
import { config } from '@grafana/runtime';
import { isValidPrometheusDuration, parsePrometheusDuration } from './time';
import { isValidPrometheusDuration, safeParsePrometheusDuration } from './time';
export function getAllDataSources(): Array<DataSourceInstanceSettings<DataSourceJsonData>> {
return Object.values(config.datasources);
@ -14,13 +14,13 @@ export function checkEvaluationIntervalGlobalLimit(alertGroupEvaluateEvery?: str
return { globalLimit: 0, exceedsLimit: false };
}
const evaluateEveryGlobalLimitMs = parsePrometheusDuration(config.unifiedAlerting.minInterval);
const evaluateEveryGlobalLimitMs = safeParsePrometheusDuration(config.unifiedAlerting.minInterval);
if (!alertGroupEvaluateEvery || !isValidPrometheusDuration(alertGroupEvaluateEvery)) {
return { globalLimit: evaluateEveryGlobalLimitMs, exceedsLimit: false };
}
const evaluateEveryMs = parsePrometheusDuration(alertGroupEvaluateEvery);
const evaluateEveryMs = safeParsePrometheusDuration(alertGroupEvaluateEvery);
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 { isGrafanaRulerRule } from './rules';
import { safeParseDurationstr } from './time';
import { safeParsePrometheusDuration } from './time';
export function alertRuleToQueries(combinedRule: CombinedRule | undefined | null): AlertQuery[] {
if (!combinedRule) {
@ -45,7 +45,7 @@ export function alertRuleToQueries(combinedRule: CombinedRule | undefined | null
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
const pendingPeriodDurationMillis =
safeParseDurationstr(pendingPeriod) ?? safeParseDurationstr(groupInterval ?? '1m');
safeParsePrometheusDuration(pendingPeriod) ?? safeParsePrometheusDuration(groupInterval ?? '1m');
const pendingPeriodDuration = Math.floor(pendingPeriodDurationMillis / 1000);
return queries.map((query) =>

View File

@ -1,4 +1,4 @@
import { omit } from 'lodash';
import { clamp, omit } from 'lodash';
import {
DataQuery,
@ -48,14 +48,21 @@ import { Annotation, defaultAnnotations } from './constants';
import { getDefaultOrFirstCompatibleDataSource, GRAFANA_RULES_SOURCE_NAME, isGrafanaRulesSource } from './datasource';
import { arrayToRecord, recordToArray } from './misc';
import { isAlertingRulerRule, isGrafanaRulerRule, isRecordingRulerRule } from './rules';
import { parseInterval } from './time';
import { formatPrometheusDuration, parseInterval, safeParsePrometheusDuration } from './time';
export type PromOrLokiQuery = PromQuery | LokiQuery;
export const MINUTE = '1m';
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 => {
const { canCreateGrafanaRules, canCreateCloudRules } = getRulesAccess();
@ -75,8 +82,8 @@ export const getDefaultFormValues = (): RuleFormValues => {
condition: '',
noDataState: GrafanaAlertStateDecision.NoData,
execErrState: GrafanaAlertStateDecision.Error,
evaluateFor: '5m',
evaluateEvery: MINUTE,
evaluateFor: DEFAULT_GROUP_EVALUATION_INTERVAL,
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
contactPoints: {},
overrideGrouping: false,

View File

@ -34,7 +34,7 @@ import { RuleHealth } from '../search/rulesSearchParser';
import { RULER_NOT_SUPPORTED_MSG } from './constants';
import { getRulesSourceName } from './datasource';
import { AsyncRequestState } from './redux';
import { safeParseDurationstr } from './time';
import { safeParsePrometheusDuration } from './time';
export function isAlertingRule(rule: Rule | undefined): rule is AlertingRule {
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) => {
const evalNumberMs = safeParseDurationstr(currentEvaluation);
const forNumber = safeParseDurationstr(forDuration);
const evalNumberMs = safeParsePrometheusDuration(currentEvaluation);
const forNumber = safeParsePrometheusDuration(forDuration);
if (forNumber === 0 && evalNumberMs !== 0) {
return 1;
}

View File

@ -1,4 +1,4 @@
import { isValidPrometheusDuration } from './time';
import { formatPrometheusDuration, isValidPrometheusDuration, parsePrometheusDuration } from './time';
describe('isValidPrometheusDuration', () => {
const validDurations = ['20h30m10s45ms', '1m30s', '20s4h', '90s', '10s', '20h20h', '2d4h20m'];
@ -13,3 +13,44 @@ describe('isValidPrometheusDuration', () => {
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;
}
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 {
return parsePrometheusDuration(duration);
} catch (e) {