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;
onUpdateRefId: (oldRefId: string, newRefId: string) => void;
onRemoveExpression: (refId: string) => void;
onUpdateExpressionType: (refId: string, type: ExpressionQueryType) => void;
onChangeQuery: (query: ExpressionQuery) => void;
}
@@ -62,7 +61,6 @@ export const Expression: FC<ExpressionProps> = ({
onSetCondition,
onUpdateRefId,
onRemoveExpression,
onUpdateExpressionType, // this method is not used? maybe we should remove it
onChangeQuery,
}) => {
const styles = useStyles2(getStyles);
@@ -4,7 +4,7 @@ import { useMemo } from 'react';
import { GrafanaTheme2, PanelData } from '@grafana/data';
import { useStyles2 } from '@grafana/ui';
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 { Expression } from '../expressions/Expression';
@@ -18,7 +18,6 @@ interface Props {
queries: AlertQuery[];
onRemoveExpression: (refId: string) => void;
onUpdateRefId: (oldRefId: string, newRefId: string) => void;
onUpdateExpressionType: (refId: string, type: ExpressionQueryType) => void;
onUpdateQueryExpression: (query: ExpressionQuery) => void;
}
@@ -29,12 +28,15 @@ export const ExpressionsEditor = ({
panelData,
onUpdateRefId,
onRemoveExpression,
onUpdateExpressionType,
onUpdateQueryExpression,
}: Props) => {
const expressionQueries = useMemo(() => {
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]);
const styles = useStyles2(getStyles);
@@ -64,7 +66,6 @@ export const ExpressionsEditor = ({
onSetCondition={onSetCondition}
onRemoveExpression={onRemoveExpression}
onUpdateRefId={onUpdateRefId}
onUpdateExpressionType={onUpdateExpressionType}
onChangeQuery={onUpdateQueryExpression}
/>
);
@@ -73,7 +73,6 @@ import {
updateExpression,
updateExpressionRefId,
updateExpressionTimeRange,
updateExpressionType,
} from './reducer';
import { useAdvancedMode } from './useAdvancedMode';
import { useAlertQueryRunner } from './useAlertQueryRunner';
@@ -591,9 +590,6 @@ export const QueryAndExpressionsStep = ({ editingExistingRule, onDataChange, mod
dispatch(removeExpression(refId));
}}
onUpdateRefId={onUpdateRefId}
onUpdateExpressionType={(refId, type) => {
dispatch(updateExpressionType({ refId, type }));
}}
onUpdateQueryExpression={(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,
updateExpressionRefId,
updateExpressionTimeRange,
updateExpressionType,
} from './reducer';
const reduceExpression: AlertQuery<ExpressionQuery> = {
@@ -388,22 +387,6 @@ describe('Query and expressions reducer', () => {
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', () => {
const initialState: QueriesAndExpressionsState = {
queries: [alertQuery, reduceExpression, thresholdExpression],
@@ -283,23 +283,6 @@ export const queriesAndExpressionsReducer = createReducer(initialState, (builder
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,
GrafanaRuleDefinition,
RulerAlertingRuleDTO,
RulerGrafanaRuleDTO,
} from 'app/types/unified-alerting-dto';
import { EvalFunction } from '../../state/alertDef';
@@ -20,6 +21,7 @@ import {
alertingRulerRuleToRuleForm,
cleanAnnotations,
cleanLabels,
fixMissingRefIdsInExpressionModel,
formValuesToRulerGrafanaRuleDTO,
formValuesToRulerRuleDTO,
getContactPointsFromDTO,
@@ -520,3 +522,70 @@ describe('getDefaultExpressions', () => {
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 {
DataSourceInstanceSettings,
IntervalValues,
@@ -278,14 +280,16 @@ function getEditorSettingsFromDTO(ga: GrafanaRuleDefinition) {
export function rulerRuleToFormValues(ruleWithLocation: RuleWithLocation): RuleFormValues {
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);
if (isGrafanaRulesSource(ruleSourceName)) {
// GRAFANA-MANAGED RULES
if (isGrafanaRecordingRule) {
// grafana recording rule
const ga = rule.grafana_alert;
const ga = normalizedRule.grafana_alert;
return {
...defaultFormValues,
name: ga.title,
@@ -294,16 +298,16 @@ export function rulerRuleToFormValues(ruleWithLocation: RuleWithLocation): RuleF
evaluateEvery: group.interval || defaultFormValues.evaluateEvery,
queries: ga.data,
condition: ga.condition,
annotations: normalizeDefaultAnnotations(listifyLabelsOrAnnotations(rule.annotations, false)),
labels: listifyLabelsOrAnnotations(rule.labels, true),
annotations: normalizeDefaultAnnotations(listifyLabelsOrAnnotations(normalizedRule.annotations, false)),
labels: listifyLabelsOrAnnotations(normalizedRule.labels, true),
folder: { title: namespace, uid: ga.namespace_uid },
isPaused: ga.is_paused,
metric: ga.record?.metric,
targetDatasourceUid: ga.record?.target_datasource_uid || defaultFormValues.targetDatasourceUid,
};
} else if (rulerRuleType.grafana.rule(rule)) {
} else if (rulerRuleType.grafana.rule(normalizedRule)) {
// grafana alerting rule
const ga = rule.grafana_alert;
const ga = normalizedRule.grafana_alert;
const routingSettings: AlertManagerManualRouting | undefined = getContactPointsFromDTO(ga);
if (ga.no_data_state !== undefined && ga.exec_err_state !== undefined) {
return {
@@ -312,14 +316,14 @@ export function rulerRuleToFormValues(ruleWithLocation: RuleWithLocation): RuleF
type: RuleFormType.grafana,
group: group.name,
evaluateEvery: group.interval || defaultFormValues.evaluateEvery,
evaluateFor: rule.for || '0',
keepFiringFor: rule.keep_firing_for || '0',
evaluateFor: normalizedRule.for || '0',
keepFiringFor: normalizedRule.keep_firing_for || '0',
noDataState: ga.no_data_state,
execErrState: ga.exec_err_state,
queries: ga.data,
condition: ga.condition,
annotations: normalizeDefaultAnnotations(listifyLabelsOrAnnotations(rule.annotations, false)),
labels: listifyLabelsOrAnnotations(rule.labels, true),
annotations: normalizeDefaultAnnotations(listifyLabelsOrAnnotations(normalizedRule.annotations, false)),
labels: listifyLabelsOrAnnotations(normalizedRule.labels, true),
folder: { title: namespace, uid: ga.namespace_uid },
isPaused: ga.is_paused,
@@ -338,7 +342,7 @@ export function rulerRuleToFormValues(ruleWithLocation: RuleWithLocation): RuleF
}
} else {
// DATASOURCE-MANAGED RULES
if (rulerRuleType.dataSource.alertingRule(rule)) {
if (rulerRuleType.dataSource.alertingRule(normalizedRule)) {
const datasourceUid = getDataSourceSrv().getInstanceSettings(ruleSourceName)?.uid ?? '';
const defaultQuery = {
@@ -346,27 +350,27 @@ export function rulerRuleToFormValues(ruleWithLocation: RuleWithLocation): RuleF
datasourceUid,
queryType: '',
relativeTimeRange: getDefaultRelativeTimeRange(),
expr: rule.expr,
expr: normalizedRule.expr,
model: {
refId: 'A',
hide: false,
expr: rule.expr,
expr: normalizedRule.expr,
},
};
const alertingRuleValues = alertingRulerRuleToRuleForm(rule);
const alertingRuleValues = alertingRulerRuleToRuleForm(normalizedRule);
return {
...defaultFormValues,
...alertingRuleValues,
queries: [defaultQuery],
annotations: normalizeDefaultAnnotations(listifyLabelsOrAnnotations(rule.annotations, false)),
annotations: normalizeDefaultAnnotations(listifyLabelsOrAnnotations(normalizedRule.annotations, false)),
type: RuleFormType.cloudAlerting,
dataSourceName: ruleSourceName,
namespace,
group: group.name,
};
} else if (rulerRuleType.dataSource.recordingRule(rule)) {
} else if (rulerRuleType.dataSource.recordingRule(normalizedRule)) {
const datasourceUid = getDataSourceSrv().getInstanceSettings(ruleSourceName)?.uid ?? '';
const defaultQuery = {
@@ -374,15 +378,15 @@ export function rulerRuleToFormValues(ruleWithLocation: RuleWithLocation): RuleF
datasourceUid,
queryType: '',
relativeTimeRange: getDefaultRelativeTimeRange(),
expr: rule.expr,
expr: normalizedRule.expr,
model: {
refId: 'A',
hide: false,
expr: rule.expr,
expr: normalizedRule.expr,
},
};
const recordingRuleValues = recordingRulerRuleToRuleForm(rule);
const recordingRuleValues = recordingRulerRuleToRuleForm(normalizedRule);
return {
...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 {
const isGrafanaRecordingRule = rulerRuleType.grafana.recordingRule(rule);
const defaultFormValues = getDefaultFormValues(isGrafanaRecordingRule ? RuleFormType.grafanaRecording : undefined);