diff --git a/public/app/features/alerting/unified/RuleEditorExisting.test.tsx b/public/app/features/alerting/unified/RuleEditorExisting.test.tsx index 87143007b66..ee8a14a749f 100644 --- a/public/app/features/alerting/unified/RuleEditorExisting.test.tsx +++ b/public/app/features/alerting/unified/RuleEditorExisting.test.tsx @@ -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', diff --git a/public/app/features/alerting/unified/RuleEditorGrafanaRules.test.tsx b/public/app/features/alerting/unified/RuleEditorGrafanaRules.test.tsx index d4f6f031e6f..096c594ae83 100644 --- a/public/app/features/alerting/unified/RuleEditorGrafanaRules.test.tsx +++ b/public/app/features/alerting/unified/RuleEditorGrafanaRules.test.tsx @@ -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(), diff --git a/public/app/features/alerting/unified/RuleList.test.tsx b/public/app/features/alerting/unified/RuleList.test.tsx index a02a5b6a67a..06d1e9c8256 100644 --- a/public/app/features/alerting/unified/RuleList.test.tsx +++ b/public/app/features/alerting/unified/RuleList.test.tsx @@ -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 diff --git a/public/app/features/alerting/unified/components/rule-editor/EvaluationGroupQuickPick.test.tsx b/public/app/features/alerting/unified/components/rule-editor/EvaluationGroupQuickPick.test.tsx new file mode 100644 index 00000000000..ec9b1ceb0ef --- /dev/null +++ b/public/app/features/alerting/unified/components/rule-editor/EvaluationGroupQuickPick.test.tsx @@ -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(); + + 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']); + }); +}); diff --git a/public/app/features/alerting/unified/components/rule-editor/EvaluationGroupQuickPick.tsx b/public/app/features/alerting/unified/components/rule-editor/EvaluationGroupQuickPick.tsx new file mode 100644 index 00000000000..a92c2826676 --- /dev/null +++ b/public/app/features/alerting/unified/components/rule-editor/EvaluationGroupQuickPick.tsx @@ -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) => ( + + {QUICK_PICK_OPTIONS.map((interval) => { + const isActive = currentInterval === interval; + + return ( + + ); + })} + +); diff --git a/public/app/features/alerting/unified/components/rule-editor/FolderAndGroup.tsx b/public/app/features/alerting/unified/components/rule-editor/FolderAndGroup.tsx index f36ed62992a..e2e59d7c7b7 100644 --- a/public/app/features/alerting/unified/components/rule-editor/FolderAndGroup.tsx +++ b/public/app/features/alerting/unified/components/rule-editor/FolderAndGroup.tsx @@ -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 ( Evaluation group name} error={formState.errors.group?.message} - invalid={!!formState.errors.group} + invalid={Boolean(formState.errors.group)} > + } > - + + + + + + + ))} + + ); +} diff --git a/public/app/features/alerting/unified/components/rule-editor/alert-rule-form/AlertRuleForm.tsx b/public/app/features/alerting/unified/components/rule-editor/alert-rule-form/AlertRuleForm.tsx index 1e0bdd3d0aa..6c1a1434f02 100644 --- a/public/app/features/alerting/unified/components/rule-editor/alert-rule-form/AlertRuleForm.tsx +++ b/public/app/features/alerting/unified/components/rule-editor/alert-rule-form/AlertRuleForm.tsx @@ -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, }); } diff --git a/public/app/features/alerting/unified/components/rule-editor/alert-rule-form/ModifyExportRuleForm.tsx b/public/app/features/alerting/unified/components/rule-editor/alert-rule-form/ModifyExportRuleForm.tsx index 87f44a6c2b9..e8490cd7e7f 100644 --- a/public/app/features/alerting/unified/components/rule-editor/alert-rule-form/ModifyExportRuleForm.tsx +++ b/public/app/features/alerting/unified/components/rule-editor/alert-rule-form/ModifyExportRuleForm.tsx @@ -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(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!'); diff --git a/public/app/features/alerting/unified/components/rule-editor/alert-rule-form/getPayloadToExport.test.ts b/public/app/features/alerting/unified/components/rule-editor/alert-rule-form/getPayloadToExport.test.ts index 1dd5e991d94..cedaff19f6d 100644 --- a/public/app/features/alerting/unified/components/rule-editor/alert-rule-form/getPayloadToExport.test.ts +++ b/public/app/features/alerting/unified/components/rule-editor/alert-rule-form/getPayloadToExport.test.ts @@ -61,7 +61,7 @@ const expectedModifiedRule2 = (uid: string) => ({ annotations: { summary: 'This grafana rule2 updated', }, - for: '5m', + for: '1m', grafana_alert: { condition: 'A', data: [ diff --git a/public/app/features/alerting/unified/components/rule-editor/alert-rule-form/simplifiedRouting/SimplifiedRuleEditor.test.tsx b/public/app/features/alerting/unified/components/rule-editor/alert-rule-form/simplifiedRouting/SimplifiedRuleEditor.test.tsx index fc6db8b26bd..901671761a1 100644 --- a/public/app/features/alerting/unified/components/rule-editor/alert-rule-form/simplifiedRouting/SimplifiedRuleEditor.test.tsx +++ b/public/app/features/alerting/unified/components/rule-editor/alert-rule-form/simplifiedRouting/SimplifiedRuleEditor.test.tsx @@ -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(), diff --git a/public/app/features/alerting/unified/components/rules/EditRuleGroupModal.tsx b/public/app/features/alerting/unified/components/rules/EditRuleGroupModal.tsx index 4864237c17c..2e56dfb04e8 100644 --- a/public/app/features/alerting/unified/components/rules/EditRuleGroupModal.tsx +++ b/public/app/features/alerting/unified/components/rules/EditRuleGroupModal.tsx @@ -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} } - invalid={!!errors.namespaceName} + invalid={Boolean(errors.namespaceName) ? true : undefined} error={errors.namespaceName?.message} > Evaluation interval } - invalid={!!errors.groupInterval} + invalid={Boolean(errors.groupInterval) ? true : undefined} error={errors.groupInterval?.message} > - + + + setValue('groupInterval', value, { shouldValidate: true })} + /> + {checkEvaluationIntervalGlobalLimit(watch('groupInterval')).exceedsLimit && ( diff --git a/public/app/features/alerting/unified/state/actions.ts b/public/app/features/alerting/unified/state/actions.ts index 377877f84a4..caf8d9db0a0 100644 --- a/public/app/features/alerting/unified/state/actions.ts +++ b/public/app/features/alerting/unified/state/actions.ts @@ -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; }); diff --git a/public/app/features/alerting/unified/utils/__snapshots__/rule-form.test.ts.snap b/public/app/features/alerting/unified/utils/__snapshots__/rule-form.test.ts.snap index feca637e835..972dd0ff608 100644 --- a/public/app/features/alerting/unified/utils/__snapshots__/rule-form.test.ts.snap +++ b/public/app/features/alerting/unified/utils/__snapshots__/rule-form.test.ts.snap @@ -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": [ diff --git a/public/app/features/alerting/unified/utils/amroutes.ts b/public/app/features/alerting/unified/utils/amroutes.ts index e8f875d14b3..fd59aac2a84 100644 --- a/public/app/features/alerting/unified/utils/amroutes.ts +++ b/public/app/features/alerting/unified/utils/amroutes.ts @@ -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 | 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; diff --git a/public/app/features/alerting/unified/utils/config.ts b/public/app/features/alerting/unified/utils/config.ts index d5f7817561d..2183ebd5ce1 100644 --- a/public/app/features/alerting/unified/utils/config.ts +++ b/public/app/features/alerting/unified/utils/config.ts @@ -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> { 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; diff --git a/public/app/features/alerting/unified/utils/query.ts b/public/app/features/alerting/unified/utils/query.ts index bde53876d92..950c7340956 100644 --- a/public/app/features/alerting/unified/utils/query.ts +++ b/public/app/features/alerting/unified/utils/query.ts @@ -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) => diff --git a/public/app/features/alerting/unified/utils/rule-form.ts b/public/app/features/alerting/unified/utils/rule-form.ts index f7dd7d6c437..1b664dfbfb4 100644 --- a/public/app/features/alerting/unified/utils/rule-form.ts +++ b/public/app/features/alerting/unified/utils/rule-form.ts @@ -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, diff --git a/public/app/features/alerting/unified/utils/rules.ts b/public/app/features/alerting/unified/utils/rules.ts index ca45ba028d4..49481acb971 100644 --- a/public/app/features/alerting/unified/utils/rules.ts +++ b/public/app/features/alerting/unified/utils/rules.ts @@ -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; } diff --git a/public/app/features/alerting/unified/utils/time.test.ts b/public/app/features/alerting/unified/utils/time.test.ts index 6237b023e6d..2e92645e4ed 100644 --- a/public/app/features/alerting/unified/utils/time.test.ts +++ b/public/app/features/alerting/unified/utils/time.test.ts @@ -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); + }); +}); diff --git a/public/app/features/alerting/unified/utils/time.ts b/public/app/features/alerting/unified/utils/time.ts index d0bc6840377..9a836c3a21d 100644 --- a/public/app/features/alerting/unified/utils/time.ts +++ b/public/app/features/alerting/unified/utils/time.ts @@ -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) {