mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
* Implement UI for grafana-managed recording rules * use undefined for the duration instead of null , for recording rules * Fix tests * add tests * Add pause functionality for grafana recording rules * update translations * remove obsolete snapshot * use createUrl instead of renderUrl * refactor * Add validation for grafana recording rule name * create util functions for rule types and add record field in mock function * add util isDatatSourceManagedRuleByType * refactor * Add metric field in alert rule form * fix alert name component * update width for alert name and metric * fix test * add validation back to cloud recording rules name * Alerting: Recording rules PR review (#90654) Update type helper methods * add slash in createUrl * fix baseurl in the returnTo * nits * Add metric on expanded row in the alert list view * nits Co-authored-by: Tom Ratcliffe <tom.ratcliffe@grafana.com> * update snapshot --------- Co-authored-by: Tom Ratcliffe <tom.ratcliffe@grafana.com>
421 lines
15 KiB
TypeScript
421 lines
15 KiB
TypeScript
import { css } from '@emotion/css';
|
|
import { compact } from 'lodash';
|
|
import { useMemo } from 'react';
|
|
import { FormProvider, RegisterOptions, useForm, useFormContext } from 'react-hook-form';
|
|
|
|
import { GrafanaTheme2 } from '@grafana/data';
|
|
import { Alert, Badge, Button, Field, Input, Label, LinkButton, Modal, Stack, useStyles2 } from '@grafana/ui';
|
|
import { useAppNotification } from 'app/core/copy/appNotification';
|
|
import { dispatch } from 'app/store/store';
|
|
import { CombinedRuleGroup, CombinedRuleNamespace, RuleGroupIdentifier } from 'app/types/unified-alerting';
|
|
import { RulerRuleDTO } from 'app/types/unified-alerting-dto';
|
|
|
|
import {
|
|
useMoveRuleGroup,
|
|
useRenameRuleGroup,
|
|
useUpdateRuleGroupConfiguration,
|
|
} from '../../hooks/ruleGroup/useUpdateRuleGroup';
|
|
import { anyOfRequestState } from '../../hooks/useAsync';
|
|
import { fetchRulerRulesAction, rulesInSameGroupHaveInvalidFor } from '../../state/actions';
|
|
import { checkEvaluationIntervalGlobalLimit } from '../../utils/config';
|
|
import { GRAFANA_RULES_SOURCE_NAME, getRulesSourceName } from '../../utils/datasource';
|
|
import { stringifyErrorLike } from '../../utils/misc';
|
|
import { DEFAULT_GROUP_EVALUATION_INTERVAL } from '../../utils/rule-form';
|
|
import { AlertInfo, getAlertInfo, isGrafanaOrDataSourceRecordingRule } from '../../utils/rules';
|
|
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;
|
|
|
|
function ForBadge({ message, error }: { message: string; error?: boolean }) {
|
|
if (error) {
|
|
return <Badge color="red" icon="exclamation-circle" text={'Error'} tooltip={message} />;
|
|
} else {
|
|
return <Badge color="orange" icon="exclamation-triangle" text={'Unknown'} tooltip={message} />;
|
|
}
|
|
}
|
|
|
|
const isValidEvaluation = (evaluation: string) => {
|
|
try {
|
|
const duration = parsePrometheusDuration(evaluation);
|
|
|
|
if (duration < MIN_TIME_RANGE_STEP_S * 1000) {
|
|
return false;
|
|
}
|
|
|
|
if (duration % (MIN_TIME_RANGE_STEP_S * 1000) !== 0) {
|
|
return false;
|
|
}
|
|
|
|
return true;
|
|
} catch (error) {
|
|
return false;
|
|
}
|
|
};
|
|
|
|
type AlertsWithForTableColumnProps = DynamicTableColumnProps<AlertInfo>;
|
|
type AlertsWithForTableProps = DynamicTableItemProps<AlertInfo>;
|
|
|
|
export const RulesForGroupTable = ({ rulesWithoutRecordingRules }: { rulesWithoutRecordingRules: RulerRuleDTO[] }) => {
|
|
const styles = useStyles2(getStyles);
|
|
|
|
const { watch } = useFormContext<FormValues>();
|
|
const currentInterval = watch('groupInterval');
|
|
const unknownCurrentInterval = !Boolean(currentInterval);
|
|
|
|
const rows: AlertsWithForTableProps[] = rulesWithoutRecordingRules
|
|
.slice()
|
|
.map((rule: RulerRuleDTO, index) => ({
|
|
id: index,
|
|
data: getAlertInfo(rule, currentInterval),
|
|
}))
|
|
.sort(
|
|
(alert1, alert2) =>
|
|
safeParsePrometheusDuration(alert1.data.forDuration ?? '') -
|
|
safeParsePrometheusDuration(alert2.data.forDuration ?? '')
|
|
);
|
|
|
|
const columns: AlertsWithForTableColumnProps[] = useMemo(() => {
|
|
return [
|
|
{
|
|
id: 'alertName',
|
|
label: 'Alert',
|
|
renderCell: ({ data: { alertName } }) => {
|
|
return <>{alertName}</>;
|
|
},
|
|
size: '330px',
|
|
},
|
|
{
|
|
id: 'for',
|
|
label: 'Pending period',
|
|
renderCell: ({ data: { forDuration } }) => {
|
|
return <>{forDuration}</>;
|
|
},
|
|
size: 0.5,
|
|
},
|
|
{
|
|
id: 'numberEvaluations',
|
|
label: '#Eval',
|
|
renderCell: ({ data: { evaluationsToFire: numberEvaluations } }) => {
|
|
if (unknownCurrentInterval) {
|
|
return <ForBadge message="#Evaluations not available." />;
|
|
} else {
|
|
if (!isValidEvaluation(currentInterval)) {
|
|
return <ForBadge message={'Invalid evaluation interval format'} error />;
|
|
}
|
|
if (numberEvaluations === 0) {
|
|
return (
|
|
<ForBadge message="Invalid 'For' value: it should be greater or equal to evaluation interval." error />
|
|
);
|
|
} else {
|
|
return <>{numberEvaluations}</>;
|
|
}
|
|
}
|
|
},
|
|
size: 0.4,
|
|
},
|
|
];
|
|
}, [currentInterval, unknownCurrentInterval]);
|
|
|
|
return (
|
|
<div className={styles.tableWrapper}>
|
|
<DynamicTable items={rows} cols={columns} pagination={{ itemsPerPage: ITEMS_PER_PAGE }} />
|
|
</div>
|
|
);
|
|
};
|
|
|
|
interface FormValues {
|
|
namespaceName: string;
|
|
groupName: string;
|
|
groupInterval: string;
|
|
}
|
|
|
|
export const evaluateEveryValidationOptions = (rules: RulerRuleDTO[]): RegisterOptions => ({
|
|
required: {
|
|
value: true,
|
|
message: 'Required.',
|
|
},
|
|
validate: (evaluateEvery: string) => {
|
|
try {
|
|
const duration = parsePrometheusDuration(evaluateEvery);
|
|
|
|
if (duration < MIN_TIME_RANGE_STEP_S * 1000) {
|
|
return `Cannot be less than ${MIN_TIME_RANGE_STEP_S} seconds.`;
|
|
}
|
|
|
|
if (duration % (MIN_TIME_RANGE_STEP_S * 1000) !== 0) {
|
|
return `Must be a multiple of ${MIN_TIME_RANGE_STEP_S} seconds.`;
|
|
}
|
|
if (rulesInSameGroupHaveInvalidFor(rules, evaluateEvery).length === 0) {
|
|
return true;
|
|
} else {
|
|
const rulePendingPeriods = rules.map((rule) => {
|
|
const { forDuration } = getAlertInfo(rule, evaluateEvery);
|
|
return forDuration ? safeParsePrometheusDuration(forDuration) : null;
|
|
});
|
|
const largestPendingPeriod = Math.min(
|
|
...rulePendingPeriods.filter((period): period is number => period !== null)
|
|
);
|
|
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';
|
|
}
|
|
},
|
|
});
|
|
|
|
export interface ModalProps {
|
|
namespace: CombinedRuleNamespace;
|
|
group: CombinedRuleGroup;
|
|
onClose: (saved?: boolean) => void;
|
|
intervalEditOnly?: boolean;
|
|
folderUrl?: string;
|
|
folderUid?: string;
|
|
hideFolder?: boolean;
|
|
}
|
|
|
|
export function EditCloudGroupModal(props: ModalProps): React.ReactElement {
|
|
const { namespace, group, onClose, intervalEditOnly, folderUid } = props;
|
|
|
|
const styles = useStyles2(getStyles);
|
|
const notifyApp = useAppNotification();
|
|
|
|
/**
|
|
* This modal can take 3 different actions, depending on what fields were updated.
|
|
*
|
|
* 1. update the rule group details without renaming either the namespace or group
|
|
* 2. rename the rule group, but keeping it in the same namespace
|
|
* 3. move the rule group to a new namespace, optionally with a different group name
|
|
*/
|
|
const [updateRuleGroupState, updateRuleGroup] = useUpdateRuleGroupConfiguration();
|
|
const [renameRuleGroupState, renameRuleGroup] = useRenameRuleGroup();
|
|
const [moveRuleGroupState, moveRuleGroup] = useMoveRuleGroup();
|
|
|
|
const { loading, error } = anyOfRequestState(updateRuleGroupState, moveRuleGroupState, renameRuleGroupState);
|
|
|
|
const defaultValues = useMemo(
|
|
(): FormValues => ({
|
|
namespaceName: decodeGrafanaNamespace(namespace).name,
|
|
groupName: group.name,
|
|
groupInterval: group.interval ?? DEFAULT_GROUP_EVALUATION_INTERVAL,
|
|
}),
|
|
[namespace, group.name, group.interval]
|
|
);
|
|
|
|
const rulesSourceName = getRulesSourceName(namespace.rulesSource);
|
|
const isGrafanaManagedGroup = rulesSourceName === GRAFANA_RULES_SOURCE_NAME;
|
|
|
|
// parse any parent folders the alert rule might be stored in
|
|
const nestedFolderParents = decodeGrafanaNamespace(namespace).parents;
|
|
|
|
const nameSpaceLabel = isGrafanaManagedGroup ? 'Folder' : 'Namespace';
|
|
|
|
const onSubmit = async (values: FormValues) => {
|
|
const ruleGroupIdentifier: RuleGroupIdentifier = {
|
|
dataSourceName: rulesSourceName,
|
|
groupName: group.name,
|
|
namespaceName: isGrafanaManagedGroup ? folderUid! : namespace.name,
|
|
};
|
|
|
|
// make sure that when dealing with a nested folder for Grafana managed rules we encode the folder properly
|
|
const updatedNamespaceName = isGrafanaManagedGroup
|
|
? encodeGrafanaNamespace(values.namespaceName, nestedFolderParents)
|
|
: values.namespaceName;
|
|
const updatedGroupName = values.groupName;
|
|
const updatedInterval = values.groupInterval;
|
|
|
|
// GMA alert rules cannot be moved to another folder, we currently do not support it but it should be doable (with caveats).
|
|
const shouldMove = isGrafanaManagedGroup ? false : updatedNamespaceName !== ruleGroupIdentifier.namespaceName;
|
|
const shouldRename = updatedGroupName !== ruleGroupIdentifier.groupName;
|
|
|
|
try {
|
|
if (shouldMove) {
|
|
await moveRuleGroup.execute(ruleGroupIdentifier, updatedNamespaceName, updatedGroupName, updatedInterval);
|
|
} else if (shouldRename) {
|
|
await renameRuleGroup.execute(ruleGroupIdentifier, updatedGroupName, updatedInterval);
|
|
} else {
|
|
await updateRuleGroup.execute(ruleGroupIdentifier, updatedInterval);
|
|
}
|
|
onClose(true);
|
|
await dispatch(fetchRulerRulesAction({ rulesSourceName }));
|
|
} catch (_error) {} // React hook form will handle errors
|
|
};
|
|
|
|
const formAPI = useForm<FormValues>({
|
|
mode: 'onBlur',
|
|
defaultValues,
|
|
shouldFocusError: true,
|
|
});
|
|
|
|
const {
|
|
handleSubmit,
|
|
register,
|
|
watch,
|
|
formState: { isDirty, errors, isValid },
|
|
setValue,
|
|
getValues,
|
|
} = formAPI;
|
|
|
|
const onInvalid = () => {
|
|
notifyApp.error('There are errors in the form. Correct the errors and retry.');
|
|
};
|
|
|
|
const rulesWithoutRecordingRules = compact(
|
|
group.rules.map((r) => r.rulerRule).filter((rule) => !isGrafanaOrDataSourceRecordingRule(rule))
|
|
);
|
|
const hasSomeNoRecordingRules = rulesWithoutRecordingRules.length > 0;
|
|
const modalTitle =
|
|
intervalEditOnly || isGrafanaManagedGroup ? 'Edit evaluation group' : 'Edit namespace or evaluation group';
|
|
|
|
return (
|
|
<Modal className={styles.modal} isOpen={true} title={modalTitle} onDismiss={onClose} onClickBackdrop={onClose}>
|
|
<FormProvider {...formAPI}>
|
|
<form onSubmit={handleSubmit(onSubmit, onInvalid)} key={JSON.stringify(defaultValues)}>
|
|
<>
|
|
{!props.hideFolder && (
|
|
<Stack gap={1} alignItems={'center'}>
|
|
<Field
|
|
className={styles.formInput}
|
|
label={
|
|
<Label
|
|
htmlFor="namespaceName"
|
|
description={
|
|
!isGrafanaManagedGroup &&
|
|
'Change the current namespace name. Moving groups between namespaces is not supported'
|
|
}
|
|
>
|
|
{nameSpaceLabel}
|
|
</Label>
|
|
}
|
|
invalid={Boolean(errors.namespaceName) ? true : undefined}
|
|
error={errors.namespaceName?.message}
|
|
>
|
|
<Input
|
|
id="namespaceName"
|
|
readOnly={intervalEditOnly || isGrafanaManagedGroup}
|
|
{...register('namespaceName', {
|
|
required: 'Namespace name is required.',
|
|
})}
|
|
/>
|
|
</Field>
|
|
{isGrafanaManagedGroup && props.folderUrl && (
|
|
<LinkButton
|
|
href={props.folderUrl}
|
|
title="Go to folder"
|
|
variant="secondary"
|
|
icon="folder-open"
|
|
target="_blank"
|
|
/>
|
|
)}
|
|
</Stack>
|
|
)}
|
|
<Field
|
|
label={
|
|
<Label
|
|
htmlFor="groupName"
|
|
description="A group evaluates all its rules over the same evaluation interval."
|
|
>
|
|
Evaluation group
|
|
</Label>
|
|
}
|
|
invalid={!!errors.groupName}
|
|
error={errors.groupName?.message}
|
|
>
|
|
<Input
|
|
autoFocus={true}
|
|
id="groupName"
|
|
readOnly={intervalEditOnly}
|
|
{...register('groupName', {
|
|
required: 'Evaluation group name is required.',
|
|
})}
|
|
/>
|
|
</Field>
|
|
<Field
|
|
label={
|
|
<Label
|
|
htmlFor="groupInterval"
|
|
description="How often is the rule evaluated. Applies to every rule within the group."
|
|
>
|
|
<Stack gap={0.5}>Evaluation interval</Stack>
|
|
</Label>
|
|
}
|
|
invalid={Boolean(errors.groupInterval) ? true : undefined}
|
|
error={errors.groupInterval?.message}
|
|
>
|
|
<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, shouldDirty: true })}
|
|
/>
|
|
</Stack>
|
|
</Field>
|
|
|
|
{/* if we're dealing with a Grafana-managed group, check if the evaluation interval is valid / permitted */}
|
|
{isGrafanaManagedGroup && checkEvaluationIntervalGlobalLimit(watch('groupInterval')).exceedsLimit && (
|
|
<EvaluationIntervalLimitExceeded />
|
|
)}
|
|
|
|
{!hasSomeNoRecordingRules && <div>This group does not contain alert rules.</div>}
|
|
{hasSomeNoRecordingRules && (
|
|
<>
|
|
<div>List of rules that belong to this group</div>
|
|
<div className={styles.evalRequiredLabel}>
|
|
#Eval column represents the number of evaluations needed before alert starts firing.
|
|
</div>
|
|
<RulesForGroupTable rulesWithoutRecordingRules={rulesWithoutRecordingRules} />
|
|
</>
|
|
)}
|
|
{error && <Alert title={'Failed to update rule group'}>{stringifyErrorLike(error)}</Alert>}
|
|
<div className={styles.modalButtons}>
|
|
<Modal.ButtonRow>
|
|
<Button
|
|
variant="secondary"
|
|
type="button"
|
|
disabled={loading}
|
|
onClick={() => onClose(false)}
|
|
fill="outline"
|
|
>
|
|
Cancel
|
|
</Button>
|
|
<Button type="submit" disabled={!isDirty || !isValid || loading}>
|
|
{loading ? 'Saving...' : 'Save'}
|
|
</Button>
|
|
</Modal.ButtonRow>
|
|
</div>
|
|
</>
|
|
</form>
|
|
</FormProvider>
|
|
</Modal>
|
|
);
|
|
}
|
|
|
|
const getStyles = (theme: GrafanaTheme2) => ({
|
|
modal: css({
|
|
maxWidth: '560px',
|
|
}),
|
|
modalButtons: css({
|
|
top: '-24px',
|
|
position: 'relative',
|
|
}),
|
|
formInput: css({
|
|
flex: 1,
|
|
}),
|
|
tableWrapper: css({
|
|
marginTop: theme.spacing(2),
|
|
marginBottom: theme.spacing(2),
|
|
height: '100%',
|
|
}),
|
|
evalRequiredLabel: css({
|
|
fontSize: theme.typography.bodySmall.fontSize,
|
|
}),
|
|
});
|