mirror of
https://github.com/grafana/grafana.git
synced 2024-12-01 21:19:28 -06:00
Alerting: Evaluation quick buttons (#85010)
Co-authored-by: Tom Ratcliffe <tomratcliffe@users.noreply.github.com>
This commit is contained in:
parent
b5c33c540c
commit
d3ee3c0a24
@ -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',
|
||||
|
@ -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(),
|
||||
|
@ -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
|
||||
|
@ -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']);
|
||||
});
|
||||
});
|
@ -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>
|
||||
);
|
@ -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}>
|
||||
|
@ -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>
|
||||
);
|
||||
|
@ -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');
|
||||
});
|
||||
});
|
@ -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>
|
||||
);
|
||||
}
|
@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
|
@ -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!');
|
||||
|
@ -61,7 +61,7 @@ const expectedModifiedRule2 = (uid: string) => ({
|
||||
annotations: {
|
||||
summary: 'This grafana rule2 updated',
|
||||
},
|
||||
for: '5m',
|
||||
for: '1m',
|
||||
grafana_alert: {
|
||||
condition: 'A',
|
||||
data: [
|
||||
|
@ -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(),
|
||||
|
@ -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 && (
|
||||
|
@ -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;
|
||||
});
|
||||
|
@ -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": [
|
||||
|
@ -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;
|
||||
|
||||
|
@ -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;
|
||||
|
||||
|
@ -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) =>
|
||||
|
@ -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,
|
||||
|
@ -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;
|
||||
}
|
||||
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
@ -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) {
|
||||
|
Loading…
Reference in New Issue
Block a user