Fix alert rule drawer condition handling and TypeScript errors

This commit is contained in:
laurenashleigh
2026-01-12 11:46:45 +00:00
parent 2b252b50f5
commit 2c7fdc4a93
3 changed files with 182 additions and 15 deletions
@@ -93,11 +93,18 @@ export function AlertRuleDrawerForm({
const dto = formValuesToRulerGrafanaRuleDTO(effectiveValues);
const groupIdentifier = getRuleGroupLocationFromFormValues(effectiveValues);
const result = await addRuleToRuleGroup.execute(groupIdentifier, dto, effectiveValues.evaluateEvery);
if (isGrafanaGroupUpdatedResponse(result)) {
onClose();
return;
}
notifyApp.error('Failed to create rule', 'The rule was not created. Please review the form and try again.');
// Check if result has an error message
if (result && typeof result === 'object' && 'error' in result) {
notifyApp.error('Failed to create rule', String(result.error));
} else {
notifyApp.error('Failed to create rule', 'Please review the form and try again.');
}
} catch (err) {
const errorMessage = getMessageFromError(err);
notifyApp.error('Failed to create rule', errorMessage);
@@ -117,7 +124,7 @@ export function AlertRuleDrawerForm({
<FormProvider {...methods}>
<RuleDefinitionSection type={RuleFormType.grafana} />
<div className={styles.divider} aria-hidden="true" />
<RuleConditionSection type={RuleFormType.grafana} />
<RuleConditionSection />
<div className={styles.divider} aria-hidden="true" />
<RuleNotificationSection />
<div className={styles.footer}>
@@ -1,8 +1,8 @@
import { css } from '@emotion/css';
import { useState } from 'react';
import { useEffect, useState } from 'react';
import { useFormContext } from 'react-hook-form';
import { GrafanaTheme2, ReducerID, SelectableValue } from '@grafana/data';
import { GrafanaTheme2, ReducerID, SelectableValue, getNextRefId } from '@grafana/data';
import { Trans, t } from '@grafana/i18n';
import {
Combobox,
@@ -18,26 +18,111 @@ import {
import { EvalFunction } from 'app/features/alerting/state/alertDef';
import { ThresholdSelect } from 'app/features/expressions/components/ThresholdSelect';
import { ToLabel } from 'app/features/expressions/components/ToLabel';
import { reducerTypes, thresholdFunctions } from 'app/features/expressions/types';
import { ExpressionQuery, ExpressionQueryType, reducerTypes, thresholdFunctions } from 'app/features/expressions/types';
import { isRangeEvaluator } from 'app/features/expressions/utils/expressionTypes';
import { AlertQuery } from 'app/types/unified-alerting-dto';
import { RuleFormType, RuleFormValues } from '../types/rule-form';
import { RuleFormValues } from '../types/rule-form';
// no custom pending/eval parsing here; use defaults from form
import { EvaluationGroupFieldRow } from './rule-editor/EvaluationGroupFieldRow';
export function RuleConditionSection({ type }: { type: RuleFormType }) {
const ExpressionDatasourceUID = '__expr__';
type LocalSimpleCondition = { whenField?: string; evaluator: { params: number[]; type: EvalFunction } };
// Helper function to create expression queries from simple condition
function createExpressionQueries(
simpleCondition: LocalSimpleCondition,
dataQueries: AlertQuery[]
): { reduce: AlertQuery; threshold: AlertQuery; condition: string } {
const lastDataQueryRefId = dataQueries[dataQueries.length - 1].refId;
// Always use the same refIds for expressions to keep them stable
const existingExpressions = dataQueries.filter((q) => q.datasourceUid === ExpressionDatasourceUID);
const reduceRefId = existingExpressions[0]?.refId || getNextRefId(dataQueries);
// Create a temporary query for threshold refId calculation
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
const tempQueries = [
...dataQueries,
{
refId: reduceRefId,
datasourceUid: ExpressionDatasourceUID,
queryType: 'expression',
model: { refId: reduceRefId },
} as AlertQuery,
];
const thresholdRefId = existingExpressions[1]?.refId || getNextRefId(tempQueries);
const reduceExpression: ExpressionQuery = {
refId: reduceRefId,
type: ExpressionQueryType.reduce,
datasource: { uid: ExpressionDatasourceUID, type: '__expr__' },
reducer: simpleCondition.whenField || ReducerID.last,
expression: lastDataQueryRefId,
};
const thresholdExpression: ExpressionQuery = {
refId: thresholdRefId,
type: ExpressionQueryType.threshold,
datasource: { uid: ExpressionDatasourceUID, type: '__expr__' },
conditions: [
{
type: 'query',
evaluator: {
params: simpleCondition.evaluator.params,
type: simpleCondition.evaluator.type,
},
operator: { type: 'and' },
query: { params: [thresholdRefId] },
reducer: { params: [], type: 'last' as const },
},
],
expression: reduceRefId,
};
return {
reduce: {
refId: reduceRefId,
datasourceUid: ExpressionDatasourceUID,
queryType: 'expression',
model: reduceExpression,
},
threshold: {
refId: thresholdRefId,
datasourceUid: ExpressionDatasourceUID,
queryType: 'expression',
model: thresholdExpression,
},
condition: thresholdRefId,
};
}
export function RuleConditionSection() {
const base = useStyles2(getStyles);
const { watch } = useFormContext<RuleFormValues>();
const { watch, setValue } = useFormContext<RuleFormValues>();
const evaluateFor = watch('evaluateFor') || '0s';
const queries = watch('queries');
watch('folder');
type LocalSimpleCondition = { whenField?: string; evaluator: { params: number[]; type: EvalFunction } };
const [simpleCondition, setSimpleCondition] = useState<LocalSimpleCondition>({
whenField: ReducerID.last,
evaluator: { params: [0], type: EvalFunction.IsAbove },
});
// Update expression queries whenever simpleCondition changes
useEffect(() => {
const dataQueries = queries.filter((q) => q.datasourceUid !== ExpressionDatasourceUID);
if (dataQueries.length === 0) {
return;
}
const { reduce, threshold, condition } = createExpressionQueries(simpleCondition, dataQueries);
setValue('queries', [...dataQueries, reduce, threshold], { shouldDirty: false, shouldValidate: false });
setValue('condition', condition, { shouldDirty: false, shouldValidate: false });
}, [simpleCondition, queries, setValue]);
const reducerOptions: Array<ComboboxOption<string>> = reducerTypes
.filter((o) => typeof o.value === 'string')
.map((o) => ({ value: o.value ?? '', label: o.label ?? String(o.value) }));
@@ -84,6 +169,7 @@ export function RuleConditionSection({ type }: { type: RuleFormType }) {
value={simpleCondition.whenField}
onChange={onReducerTypeChange}
width={20}
aria-label={t('alerting.simple-condition-editor.aria-label-reducer', 'Select reducer function')}
/>
</InlineField>
)}
@@ -1,5 +1,5 @@
import { css } from '@emotion/css';
import { useMemo } from 'react';
import { useCallback, useMemo } from 'react';
import { useFormContext } from 'react-hook-form';
import { notificationsAPIv0alpha1 } from '@grafana/alerting/unstable';
@@ -19,8 +19,10 @@ import {
TextLink,
useStyles2,
} from '@grafana/ui';
import { useAppNotification } from 'app/core/copy/appNotification';
import { RuleFormValues } from '../types/rule-form';
import { Annotation } from '../utils/constants';
import { NeedHelpInfoForNotificationPolicy } from './rule-editor/NotificationsStep';
@@ -29,11 +31,14 @@ const CONTACT_POINT_PATH = 'contactPoints.grafana.selectedContactPoint' as const
export function RuleNotificationSection() {
const styles = useStyles2(getStyles);
const notifyApp = useAppNotification();
const { watch, setValue } = useFormContext<RuleFormValues>();
const manualRouting = watch('manualRouting');
const useNotificationPolicy = !manualRouting;
const selectedContactPoint = watch(CONTACT_POINT_PATH);
const annotations = watch('annotations');
// Fetch contact points from Alerting API v0alpha1
const { currentData, status, refetch } = notificationsAPIv0alpha1.endpoints.listReceiver.useQuery({});
const options = useMemo<Array<ComboboxOption<string>>>(
@@ -45,6 +50,34 @@ export function RuleNotificationSection() {
[currentData]
);
// Helper functions to get and set annotation values
const getAnnotationValue = useCallback(
(key: string) => {
return annotations.find((a) => a.key === key)?.value ?? '';
},
[annotations]
);
const updateAnnotationValue = useCallback(
(key: string, value: string) => {
const updatedAnnotations = [...annotations];
const index = updatedAnnotations.findIndex((a) => a.key === key);
if (index >= 0) {
updatedAnnotations[index] = { key, value };
} else {
updatedAnnotations.push({ key, value });
}
setValue('annotations', updatedAnnotations, { shouldDirty: true, shouldValidate: true });
},
[annotations, setValue]
);
const summaryValue = getAnnotationValue(Annotation.summary);
const descriptionValue = getAnnotationValue(Annotation.description);
const runbookUrlValue = getAnnotationValue(Annotation.runbookURL);
const recipientLabelId = 'recipient-label';
return (
@@ -144,14 +177,17 @@ export function RuleNotificationSection() {
fill="text"
size="sm"
aria-label={t('alerting.common.refresh', 'Refresh')}
onClick={() => {
if (refetch) {
refetch();
onClick={async () => {
try {
await refetch();
} catch (error) {
notifyApp.error(
t('alerting.simplified.notification.refresh-error', 'Failed to refresh contact points')
);
}
}}
/>
<TextLink
external
href={'/alerting/notifications'}
aria-label={t(
'alerting.link-to-contact-points.aria-label-view-or-create-contact-points',
@@ -170,30 +206,68 @@ export function RuleNotificationSection() {
<Field label={t('alerting.simplified.notification.summary.label', 'Summary (optional)')} noMargin>
<TextArea
id="summary-text-area"
value={summaryValue}
onChange={(e) => updateAnnotationValue(Annotation.summary, e.currentTarget.value)}
placeholder={t(
'alerting.simplified.notification.summary.placeholder',
'Enter a summary of what happened and why…'
)}
aria-label={t('alerting.simplified.notification.summary.aria-label', 'Summary')}
/>
</Field>
<Field label={t('alerting.simplified.notification.description.label', 'Description (optional)')} noMargin>
<TextArea
id="description-text-area"
value={descriptionValue}
onChange={(e) => updateAnnotationValue(Annotation.description, e.currentTarget.value)}
placeholder={t(
'alerting.simplified.notification.description.placeholder',
'Enter a description of what the alert rule does…'
)}
aria-label={t('alerting.simplified.notification.description.aria-label', 'Description')}
/>
</Field>
<Field label={t('alerting.simplified.notification.runbook-url.label', 'Runbook URL (optional)')} noMargin>
<Input
id="runbook-url-input"
type="url"
value={runbookUrlValue}
onChange={(e) => {
const value = e.currentTarget.value;
updateAnnotationValue(Annotation.runbookURL, value);
}}
onBlur={(e) => {
const value = e.currentTarget.value.trim();
// Validate URL on blur
if (value && value !== '') {
try {
const url = new URL(value);
// Reject dangerous URL schemes
if (url.protocol === 'javascript:' || url.protocol === 'data:') {
notifyApp.error(
t(
'alerting.simplified.notification.runbook-url.invalid-protocol',
'Invalid URL protocol. Please use http or https.'
)
);
}
} catch {
notifyApp.warning(
t(
'alerting.simplified.notification.runbook-url.invalid-format',
'Invalid URL format. Please enter a valid URL.'
)
);
}
}
}}
placeholder={t(
'alerting.simplified.notification.runbook-url.placeholder',
'Enter the webpage where you keep your runbook for the alert…'
)}
aria-label={t('alerting.simplified.notification.runbook-url.aria-label', 'Runbook URL')}
/>
</Field>
</Stack>