Fix alert rule drawer condition handling and TypeScript errors
This commit is contained in:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user