Alerting: Patch missing expression model refIds (#114477)

This commit is contained in:
Gilles De Mey
2025-11-26 14:27:31 +01:00
committed by GitHub
parent 5ba3139d4a
commit c94bf34d0b
8 changed files with 115 additions and 116 deletions
@@ -48,7 +48,6 @@ interface ExpressionProps {
onSetCondition: (refId: string) => void; onSetCondition: (refId: string) => void;
onUpdateRefId: (oldRefId: string, newRefId: string) => void; onUpdateRefId: (oldRefId: string, newRefId: string) => void;
onRemoveExpression: (refId: string) => void; onRemoveExpression: (refId: string) => void;
onUpdateExpressionType: (refId: string, type: ExpressionQueryType) => void;
onChangeQuery: (query: ExpressionQuery) => void; onChangeQuery: (query: ExpressionQuery) => void;
} }
@@ -62,7 +61,6 @@ export const Expression: FC<ExpressionProps> = ({
onSetCondition, onSetCondition,
onUpdateRefId, onUpdateRefId,
onRemoveExpression, onRemoveExpression,
onUpdateExpressionType, // this method is not used? maybe we should remove it
onChangeQuery, onChangeQuery,
}) => { }) => {
const styles = useStyles2(getStyles); const styles = useStyles2(getStyles);
@@ -4,7 +4,7 @@ import { useMemo } from 'react';
import { GrafanaTheme2, PanelData } from '@grafana/data'; import { GrafanaTheme2, PanelData } from '@grafana/data';
import { useStyles2 } from '@grafana/ui'; import { useStyles2 } from '@grafana/ui';
import { isExpressionQuery } from 'app/features/expressions/guards'; import { isExpressionQuery } from 'app/features/expressions/guards';
import { ExpressionQuery, ExpressionQueryType } from 'app/features/expressions/types'; import { ExpressionQuery } from 'app/features/expressions/types';
import { AlertQuery } from 'app/types/unified-alerting-dto'; import { AlertQuery } from 'app/types/unified-alerting-dto';
import { Expression } from '../expressions/Expression'; import { Expression } from '../expressions/Expression';
@@ -18,7 +18,6 @@ interface Props {
queries: AlertQuery[]; queries: AlertQuery[];
onRemoveExpression: (refId: string) => void; onRemoveExpression: (refId: string) => void;
onUpdateRefId: (oldRefId: string, newRefId: string) => void; onUpdateRefId: (oldRefId: string, newRefId: string) => void;
onUpdateExpressionType: (refId: string, type: ExpressionQueryType) => void;
onUpdateQueryExpression: (query: ExpressionQuery) => void; onUpdateQueryExpression: (query: ExpressionQuery) => void;
} }
@@ -29,12 +28,15 @@ export const ExpressionsEditor = ({
panelData, panelData,
onUpdateRefId, onUpdateRefId,
onRemoveExpression, onRemoveExpression,
onUpdateExpressionType,
onUpdateQueryExpression, onUpdateQueryExpression,
}: Props) => { }: Props) => {
const expressionQueries = useMemo(() => { const expressionQueries = useMemo(() => {
return queries.reduce((acc: ExpressionQuery[], query) => { return queries.reduce((acc: ExpressionQuery[], query) => {
return isExpressionQuery(query.model) ? acc.concat(query.model) : acc; if (isExpressionQuery(query.model)) {
acc.push(query.model);
}
return acc;
}, []); }, []);
}, [queries]); }, [queries]);
const styles = useStyles2(getStyles); const styles = useStyles2(getStyles);
@@ -64,7 +66,6 @@ export const ExpressionsEditor = ({
onSetCondition={onSetCondition} onSetCondition={onSetCondition}
onRemoveExpression={onRemoveExpression} onRemoveExpression={onRemoveExpression}
onUpdateRefId={onUpdateRefId} onUpdateRefId={onUpdateRefId}
onUpdateExpressionType={onUpdateExpressionType}
onChangeQuery={onUpdateQueryExpression} onChangeQuery={onUpdateQueryExpression}
/> />
); );
@@ -73,7 +73,6 @@ import {
updateExpression, updateExpression,
updateExpressionRefId, updateExpressionRefId,
updateExpressionTimeRange, updateExpressionTimeRange,
updateExpressionType,
} from './reducer'; } from './reducer';
import { useAdvancedMode } from './useAdvancedMode'; import { useAdvancedMode } from './useAdvancedMode';
import { useAlertQueryRunner } from './useAlertQueryRunner'; import { useAlertQueryRunner } from './useAlertQueryRunner';
@@ -591,9 +590,6 @@ export const QueryAndExpressionsStep = ({ editingExistingRule, onDataChange, mod
dispatch(removeExpression(refId)); dispatch(removeExpression(refId));
}} }}
onUpdateRefId={onUpdateRefId} onUpdateRefId={onUpdateRefId}
onUpdateExpressionType={(refId, type) => {
dispatch(updateExpressionType({ refId, type }));
}}
onUpdateQueryExpression={(model) => { onUpdateQueryExpression={(model) => {
dispatch(updateExpression(model)); dispatch(updateExpression(model));
}} }}
@@ -442,55 +442,3 @@ exports[`Query and expressions reducer should update an expression refId and rew
], ],
} }
`; `;
exports[`Query and expressions reducer should update expression type 1`] = `
{
"queries": [
{
"datasourceUid": "abc123",
"model": {
"refId": "A",
},
"queryType": "query",
"refId": "A",
},
{
"datasourceUid": "__expr__",
"model": {
"conditions": [
{
"evaluator": {
"params": [
0,
0,
],
"type": "gt",
},
"operator": {
"type": "and",
},
"query": {
"params": [],
},
"reducer": {
"params": [],
"type": "avg",
},
"type": "query",
},
],
"datasource": {
"name": "Expression",
"type": "__expr__",
"uid": "__expr__",
},
"expression": "",
"refId": "B",
"type": "reduce",
},
"queryType": "",
"refId": "B",
},
],
}
`;
@@ -22,7 +22,6 @@ import {
updateExpression, updateExpression,
updateExpressionRefId, updateExpressionRefId,
updateExpressionTimeRange, updateExpressionTimeRange,
updateExpressionType,
} from './reducer'; } from './reducer';
const reduceExpression: AlertQuery<ExpressionQuery> = { const reduceExpression: AlertQuery<ExpressionQuery> = {
@@ -388,22 +387,6 @@ describe('Query and expressions reducer', () => {
expect(newState).toMatchSnapshot(); expect(newState).toMatchSnapshot();
}); });
it('should update expression type', () => {
const initialState: QueriesAndExpressionsState = {
queries: [alertQuery, expressionQuery],
};
const newState = queriesAndExpressionsReducer(
initialState,
updateExpressionType({
refId: 'B',
type: ExpressionQueryType.reduce,
})
);
expect(newState).toMatchSnapshot();
});
it('should remove first reducer', () => { it('should remove first reducer', () => {
const initialState: QueriesAndExpressionsState = { const initialState: QueriesAndExpressionsState = {
queries: [alertQuery, reduceExpression, thresholdExpression], queries: [alertQuery, reduceExpression, thresholdExpression],
@@ -283,23 +283,6 @@ export const queriesAndExpressionsReducer = createReducer(initialState, (builder
queryType: 'expression', queryType: 'expression',
}); });
} }
})
.addCase(updateExpressionType, (state, action) => {
state.queries = state.queries.map((query) => {
return query.refId === action.payload.refId
? {
...query,
model: {
...expressionDatasource.newQuery({
type: action.payload.type,
conditions: [{ ...defaultCondition, query: { params: [] } }],
expression: '',
}),
refId: action.payload.refId,
},
}
: query;
});
}); });
}); });
@@ -7,6 +7,7 @@ import {
GrafanaAlertStateDecision, GrafanaAlertStateDecision,
GrafanaRuleDefinition, GrafanaRuleDefinition,
RulerAlertingRuleDTO, RulerAlertingRuleDTO,
RulerGrafanaRuleDTO,
} from 'app/types/unified-alerting-dto'; } from 'app/types/unified-alerting-dto';
import { EvalFunction } from '../../state/alertDef'; import { EvalFunction } from '../../state/alertDef';
@@ -20,6 +21,7 @@ import {
alertingRulerRuleToRuleForm, alertingRulerRuleToRuleForm,
cleanAnnotations, cleanAnnotations,
cleanLabels, cleanLabels,
fixMissingRefIdsInExpressionModel,
formValuesToRulerGrafanaRuleDTO, formValuesToRulerGrafanaRuleDTO,
formValuesToRulerRuleDTO, formValuesToRulerRuleDTO,
getContactPointsFromDTO, getContactPointsFromDTO,
@@ -520,3 +522,70 @@ describe('getDefaultExpressions', () => {
expect(thresholdModel.expression).toBe('X'); expect(thresholdModel.expression).toBe('X');
}); });
}); });
describe('fixMissingRefIdsInExpressionModel', () => {
it('should return non-Grafana managed rules unchanged', () => {
const cloudAlertingRule: RulerAlertingRuleDTO = {
alert: 'CloudAlert',
expr: 'up == 0',
for: '5m',
labels: { severity: 'critical' },
annotations: { summary: 'Instance down' },
};
const result = fixMissingRefIdsInExpressionModel(cloudAlertingRule);
expect(result).toEqual(cloudAlertingRule);
expect(result).toBe(cloudAlertingRule); // should be the exact same reference
});
it('should copy refId from query to model when model.refId is missing in Grafana managed rules', () => {
const ruleWithMissingRefId: RulerGrafanaRuleDTO = {
grafana_alert: {
uid: 'test-uid',
title: 'Test Alert',
namespace_uid: 'namespace-uid',
rule_group: 'test-group',
condition: 'B',
no_data_state: GrafanaAlertStateDecision.NoData,
exec_err_state: GrafanaAlertStateDecision.Alerting,
is_paused: false,
data: [
{
refId: 'A',
datasourceUid: 'datasource-uid',
queryType: '',
relativeTimeRange: { from: 600, to: 0 },
// @ts-ignore
model: {
// refId is missing here
datasource: {
type: 'grafana-testdata-datasource',
uid: 'PD8C576611E62080A',
},
},
},
{
refId: 'B',
datasourceUid: ExpressionDatasourceUID,
queryType: '',
// @ts-ignore
model: {
// refId is missing here
type: ExpressionQueryType.reduce,
expression: 'A',
},
},
],
},
for: '5m',
labels: {},
annotations: {},
};
const result = fixMissingRefIdsInExpressionModel(ruleWithMissingRefId);
expect(result.grafana_alert.data[0].model.refId).toBe('A');
expect(result.grafana_alert.data[1].model.refId).toBe('B');
});
});
@@ -1,3 +1,5 @@
import { produce } from 'immer';
import { import {
DataSourceInstanceSettings, DataSourceInstanceSettings,
IntervalValues, IntervalValues,
@@ -278,14 +280,16 @@ function getEditorSettingsFromDTO(ga: GrafanaRuleDefinition) {
export function rulerRuleToFormValues(ruleWithLocation: RuleWithLocation): RuleFormValues { export function rulerRuleToFormValues(ruleWithLocation: RuleWithLocation): RuleFormValues {
const { ruleSourceName, namespace, group, rule } = ruleWithLocation; const { ruleSourceName, namespace, group, rule } = ruleWithLocation;
const isGrafanaRecordingRule = rulerRuleType.grafana.recordingRule(rule); const normalizedRule = fixMissingRefIdsInExpressionModel(rule);
const isGrafanaRecordingRule = rulerRuleType.grafana.recordingRule(normalizedRule);
const defaultFormValues = getDefaultFormValues(isGrafanaRecordingRule ? RuleFormType.grafanaRecording : undefined); const defaultFormValues = getDefaultFormValues(isGrafanaRecordingRule ? RuleFormType.grafanaRecording : undefined);
if (isGrafanaRulesSource(ruleSourceName)) { if (isGrafanaRulesSource(ruleSourceName)) {
// GRAFANA-MANAGED RULES // GRAFANA-MANAGED RULES
if (isGrafanaRecordingRule) { if (isGrafanaRecordingRule) {
// grafana recording rule // grafana recording rule
const ga = rule.grafana_alert; const ga = normalizedRule.grafana_alert;
return { return {
...defaultFormValues, ...defaultFormValues,
name: ga.title, name: ga.title,
@@ -294,16 +298,16 @@ export function rulerRuleToFormValues(ruleWithLocation: RuleWithLocation): RuleF
evaluateEvery: group.interval || defaultFormValues.evaluateEvery, evaluateEvery: group.interval || defaultFormValues.evaluateEvery,
queries: ga.data, queries: ga.data,
condition: ga.condition, condition: ga.condition,
annotations: normalizeDefaultAnnotations(listifyLabelsOrAnnotations(rule.annotations, false)), annotations: normalizeDefaultAnnotations(listifyLabelsOrAnnotations(normalizedRule.annotations, false)),
labels: listifyLabelsOrAnnotations(rule.labels, true), labels: listifyLabelsOrAnnotations(normalizedRule.labels, true),
folder: { title: namespace, uid: ga.namespace_uid }, folder: { title: namespace, uid: ga.namespace_uid },
isPaused: ga.is_paused, isPaused: ga.is_paused,
metric: ga.record?.metric, metric: ga.record?.metric,
targetDatasourceUid: ga.record?.target_datasource_uid || defaultFormValues.targetDatasourceUid, targetDatasourceUid: ga.record?.target_datasource_uid || defaultFormValues.targetDatasourceUid,
}; };
} else if (rulerRuleType.grafana.rule(rule)) { } else if (rulerRuleType.grafana.rule(normalizedRule)) {
// grafana alerting rule // grafana alerting rule
const ga = rule.grafana_alert; const ga = normalizedRule.grafana_alert;
const routingSettings: AlertManagerManualRouting | undefined = getContactPointsFromDTO(ga); const routingSettings: AlertManagerManualRouting | undefined = getContactPointsFromDTO(ga);
if (ga.no_data_state !== undefined && ga.exec_err_state !== undefined) { if (ga.no_data_state !== undefined && ga.exec_err_state !== undefined) {
return { return {
@@ -312,14 +316,14 @@ export function rulerRuleToFormValues(ruleWithLocation: RuleWithLocation): RuleF
type: RuleFormType.grafana, type: RuleFormType.grafana,
group: group.name, group: group.name,
evaluateEvery: group.interval || defaultFormValues.evaluateEvery, evaluateEvery: group.interval || defaultFormValues.evaluateEvery,
evaluateFor: rule.for || '0', evaluateFor: normalizedRule.for || '0',
keepFiringFor: rule.keep_firing_for || '0', keepFiringFor: normalizedRule.keep_firing_for || '0',
noDataState: ga.no_data_state, noDataState: ga.no_data_state,
execErrState: ga.exec_err_state, execErrState: ga.exec_err_state,
queries: ga.data, queries: ga.data,
condition: ga.condition, condition: ga.condition,
annotations: normalizeDefaultAnnotations(listifyLabelsOrAnnotations(rule.annotations, false)), annotations: normalizeDefaultAnnotations(listifyLabelsOrAnnotations(normalizedRule.annotations, false)),
labels: listifyLabelsOrAnnotations(rule.labels, true), labels: listifyLabelsOrAnnotations(normalizedRule.labels, true),
folder: { title: namespace, uid: ga.namespace_uid }, folder: { title: namespace, uid: ga.namespace_uid },
isPaused: ga.is_paused, isPaused: ga.is_paused,
@@ -338,7 +342,7 @@ export function rulerRuleToFormValues(ruleWithLocation: RuleWithLocation): RuleF
} }
} else { } else {
// DATASOURCE-MANAGED RULES // DATASOURCE-MANAGED RULES
if (rulerRuleType.dataSource.alertingRule(rule)) { if (rulerRuleType.dataSource.alertingRule(normalizedRule)) {
const datasourceUid = getDataSourceSrv().getInstanceSettings(ruleSourceName)?.uid ?? ''; const datasourceUid = getDataSourceSrv().getInstanceSettings(ruleSourceName)?.uid ?? '';
const defaultQuery = { const defaultQuery = {
@@ -346,27 +350,27 @@ export function rulerRuleToFormValues(ruleWithLocation: RuleWithLocation): RuleF
datasourceUid, datasourceUid,
queryType: '', queryType: '',
relativeTimeRange: getDefaultRelativeTimeRange(), relativeTimeRange: getDefaultRelativeTimeRange(),
expr: rule.expr, expr: normalizedRule.expr,
model: { model: {
refId: 'A', refId: 'A',
hide: false, hide: false,
expr: rule.expr, expr: normalizedRule.expr,
}, },
}; };
const alertingRuleValues = alertingRulerRuleToRuleForm(rule); const alertingRuleValues = alertingRulerRuleToRuleForm(normalizedRule);
return { return {
...defaultFormValues, ...defaultFormValues,
...alertingRuleValues, ...alertingRuleValues,
queries: [defaultQuery], queries: [defaultQuery],
annotations: normalizeDefaultAnnotations(listifyLabelsOrAnnotations(rule.annotations, false)), annotations: normalizeDefaultAnnotations(listifyLabelsOrAnnotations(normalizedRule.annotations, false)),
type: RuleFormType.cloudAlerting, type: RuleFormType.cloudAlerting,
dataSourceName: ruleSourceName, dataSourceName: ruleSourceName,
namespace, namespace,
group: group.name, group: group.name,
}; };
} else if (rulerRuleType.dataSource.recordingRule(rule)) { } else if (rulerRuleType.dataSource.recordingRule(normalizedRule)) {
const datasourceUid = getDataSourceSrv().getInstanceSettings(ruleSourceName)?.uid ?? ''; const datasourceUid = getDataSourceSrv().getInstanceSettings(ruleSourceName)?.uid ?? '';
const defaultQuery = { const defaultQuery = {
@@ -374,15 +378,15 @@ export function rulerRuleToFormValues(ruleWithLocation: RuleWithLocation): RuleF
datasourceUid, datasourceUid,
queryType: '', queryType: '',
relativeTimeRange: getDefaultRelativeTimeRange(), relativeTimeRange: getDefaultRelativeTimeRange(),
expr: rule.expr, expr: normalizedRule.expr,
model: { model: {
refId: 'A', refId: 'A',
hide: false, hide: false,
expr: rule.expr, expr: normalizedRule.expr,
}, },
}; };
const recordingRuleValues = recordingRulerRuleToRuleForm(rule); const recordingRuleValues = recordingRulerRuleToRuleForm(normalizedRule);
return { return {
...defaultFormValues, ...defaultFormValues,
@@ -399,6 +403,23 @@ export function rulerRuleToFormValues(ruleWithLocation: RuleWithLocation): RuleF
} }
} }
/**
* This function isn't supposed to be needed, but we've noticed some customers are creating rules via Provisioning or
* other interfaces where they aren't including the RefId in the "model" of the expression so copy the refId from the query definition.
*/
export function fixMissingRefIdsInExpressionModel<T extends RulerRuleDTO>(rule: T): T {
// non-Grafana managed rules don't use expression nodes so we return the rule as-is
if (!rulerRuleType.grafana.rule(rule)) {
return rule;
}
return produce(rule, (draft) => {
draft.grafana_alert.data.forEach((query) => {
query.model.refId = query.model.refId ?? query.refId;
});
});
}
export function grafanaRuleDtoToFormValues(rule: RulerGrafanaRuleDTO, namespace: string): RuleFormValues { export function grafanaRuleDtoToFormValues(rule: RulerGrafanaRuleDTO, namespace: string): RuleFormValues {
const isGrafanaRecordingRule = rulerRuleType.grafana.recordingRule(rule); const isGrafanaRecordingRule = rulerRuleType.grafana.recordingRule(rule);
const defaultFormValues = getDefaultFormValues(isGrafanaRecordingRule ? RuleFormType.grafanaRecording : undefined); const defaultFormValues = getDefaultFormValues(isGrafanaRecordingRule ? RuleFormType.grafanaRecording : undefined);