diff --git a/public/app/features/alerting/unified/rule-list/DataSourceGroupLoader.tsx b/public/app/features/alerting/unified/rule-list/DataSourceGroupLoader.tsx
index bd221cd72ed..ad1e4e8d87e 100644
--- a/public/app/features/alerting/unified/rule-list/DataSourceGroupLoader.tsx
+++ b/public/app/features/alerting/unified/rule-list/DataSourceGroupLoader.tsx
@@ -191,24 +191,29 @@ export function RulerBasedGroupRules({
return (
<>
- {pageItems.map((rulerRule) => {
+ {pageItems.map((rulerRule, index) => {
+ // If rules are indistinguishable by name, labels, annotations, and query, we need to use the index to disambiguate
const promRule = matches.get(rulerRule);
- return promRule ? (
-
- }
- showLocation={false}
- />
- ) : (
+ if (promRule) {
+ return (
+
+ }
+ showLocation={false}
+ />
+ );
+ }
+
+ return (
);
})}
- {promOnlyRules.map((rule) => (
-
- ))}
+ {promOnlyRules.map((rule, index) => {
+ return (
+
+ );
+ })}
{hasMore && (
diff --git a/public/app/features/alerting/unified/rule-list/DataSourceRuleLoader.tsx b/public/app/features/alerting/unified/rule-list/DataSourceRuleLoader.tsx
index ac54bc375b3..5e0cf04d982 100644
--- a/public/app/features/alerting/unified/rule-list/DataSourceRuleLoader.tsx
+++ b/public/app/features/alerting/unified/rule-list/DataSourceRuleLoader.tsx
@@ -1,8 +1,6 @@
import { skipToken } from '@reduxjs/toolkit/query';
import { memo, useMemo } from 'react';
-import { DataSourceRuleGroupIdentifier, Rule } from 'app/types/unified-alerting';
-
import { alertRuleApi } from '../api/alertRuleApi';
import { featureDiscoveryApi } from '../api/featureDiscoveryApi';
import { isCloudRulerGroup } from '../utils/rules';
@@ -10,20 +8,18 @@ import { isCloudRulerGroup } from '../utils/rules';
import { DataSourceRuleListItem } from './DataSourceRuleListItem';
import { RuleActionsButtons } from './components/RuleActionsButtons.V2';
import { RuleActionsSkeleton } from './components/RuleActionsSkeleton';
+import { PromRuleWithOrigin } from './hooks/useFilteredRulesIterator';
import { getMatchingRulerRule } from './ruleMatching';
const { useDiscoverDsFeaturesQuery } = featureDiscoveryApi;
const { useGetRuleGroupForNamespaceQuery } = alertRuleApi;
interface DataSourceRuleLoaderProps {
- rule: Rule;
- groupIdentifier: DataSourceRuleGroupIdentifier;
+ ruleWithOrigin: PromRuleWithOrigin;
}
-export const DataSourceRuleLoader = memo(function DataSourceRuleLoader({
- rule,
- groupIdentifier,
-}: DataSourceRuleLoaderProps) {
+export const DataSourceRuleLoader = memo(function DataSourceRuleLoader({ ruleWithOrigin }: DataSourceRuleLoaderProps) {
+ const { rule, groupIdentifier } = ruleWithOrigin;
const { rulesSource, namespace, groupName } = groupIdentifier;
const { data: dsFeatures } = useDiscoverDsFeaturesQuery({ uid: rulesSource.uid });
@@ -43,8 +39,8 @@ export const DataSourceRuleLoader = memo(function DataSourceRuleLoader({
return;
}
- return getMatchingRulerRule(rulerRuleGroup, rule);
- }, [rulerRuleGroup, rule]);
+ return getMatchingRulerRule(rulerRuleGroup, ruleWithOrigin);
+ }, [rulerRuleGroup, ruleWithOrigin]);
// 1. get the rule from the ruler API with "ruleWithLocation"
// 1.1 skip this if this datasource does not have a ruler
diff --git a/public/app/features/alerting/unified/rule-list/FilterView.tsx b/public/app/features/alerting/unified/rule-list/FilterView.tsx
index 08a8ecbce43..5e1a651cdaf 100644
--- a/public/app/features/alerting/unified/rule-list/FilterView.tsx
+++ b/public/app/features/alerting/unified/rule-list/FilterView.tsx
@@ -162,7 +162,7 @@ function FilterViewResults({ filterState }: FilterViewProps) {
/>
);
case 'datasource':
- return ;
+ return ;
default:
return (
:" (e.g., "0:3", "1:5")
+ *
+ * This is used as a tiebreaker when multiple identical rules exist in a group and need to be
+ * matched against their counterparts in another rule source (e.g., matching Prometheus rules
+ * to Ruler API rules). The hash ensures that identical rules are matched by their position
+ * only when both groups have the same structure (same number of rules).
+ *
+ * @example
+ * // Two identical alerts in different positions
+ * Rule at position 0 in a 3-rule group: rulePositionHash = "0:3"
+ * Rule at position 1 in a 3-rule group: rulePositionHash = "1:3"
+ * // These won't match rules in a 2-rule group (e.g., "0:2") even if identical
+ */
+ rulePositionHash: RulePositionHash;
}
interface GetIteratorResult {
@@ -99,9 +116,9 @@ export function useFilteredRulesIteratorProvider() {
concatMap((groups) =>
groups
.filter((group) => groupFilter(group, normalizedFilterState))
- .flatMap((group) => group.rules.map((rule) => ({ group, rule })))
+ .flatMap((group) => group.rules.map((rule, index) => ({ group, rule, index })))
.filter(({ rule }) => ruleFilter(rule, normalizedFilterState))
- .map(({ group, rule }) => mapRuleToRuleWithOrigin(dataSourceIdentifier, group, rule))
+ .map(({ group, rule, index }) => mapRuleToRuleWithOrigin(dataSourceIdentifier, group, rule, index))
),
catchError(() => empty())
);
@@ -166,7 +183,8 @@ function getRulesSourcesFromFilter(filter: RulesFilter): DataSourceRulesSourceId
function mapRuleToRuleWithOrigin(
rulesSource: DataSourceRulesSourceIdentifier,
group: PromRuleGroupDTO,
- rule: PromRuleDTO
+ rule: PromRuleDTO,
+ ruleIndex: number
): PromRuleWithOrigin {
return {
rule,
@@ -177,6 +195,7 @@ function mapRuleToRuleWithOrigin(
groupOrigin: 'datasource',
},
origin: 'datasource',
+ rulePositionHash: createRulePositionHash(ruleIndex, group.rules.length),
};
}
diff --git a/public/app/features/alerting/unified/rule-list/ruleMatching.test.ts b/public/app/features/alerting/unified/rule-list/ruleMatching.test.ts
index ee542bac1bf..87ee3e35f95 100644
--- a/public/app/features/alerting/unified/rule-list/ruleMatching.test.ts
+++ b/public/app/features/alerting/unified/rule-list/ruleMatching.test.ts
@@ -1,7 +1,38 @@
+import { PromRuleDTO, RulerCloudRuleDTO } from 'app/types/unified-alerting-dto';
+
import { mockPromRecordingRule } from '../mocks';
import { alertingFactory } from '../mocks/server/db';
+import { PromRuleWithOrigin } from './hooks/useFilteredRulesIterator';
import { getMatchingPromRule, getMatchingRulerRule, matchRulesGroup } from './ruleMatching';
+import { RulePositionHash, createRulePositionHash } from './rulePositionHash';
+
+// Helper to create PromRuleWithOrigin mock
+function createPromRuleWithOrigin(rule: PromRuleDTO, ruleIndex: number, totalRules: number): PromRuleWithOrigin {
+ return {
+ rule,
+ groupIdentifier: {
+ rulesSource: { uid: 'test-ds', name: 'test-datasource', ruleSourceType: 'datasource' },
+ namespace: { name: 'test-namespace' },
+ groupName: 'test-group',
+ groupOrigin: 'datasource',
+ },
+ origin: 'datasource',
+ rulePositionHash: createRulePositionHash(ruleIndex, totalRules),
+ };
+}
+
+// Helper to create RulerRuleWithRulePosition mock
+function createRulerRuleWithPosition(
+ rule: RulerCloudRuleDTO,
+ ruleIndex: number,
+ totalRules: number
+): RulerCloudRuleDTO & { rulePositionHash: RulePositionHash } {
+ return {
+ ...rule,
+ rulePositionHash: createRulePositionHash(ruleIndex, totalRules),
+ };
+}
describe('getMatchingRulerRule', () => {
it('should match rule by unique name', () => {
@@ -11,8 +42,9 @@ describe('getMatchingRulerRule', () => {
// Create a matching prom rule with same name
const promRule = alertingFactory.prometheus.rule.build({ name: 'test-rule' });
+ const promRuleWithOrigin = createPromRuleWithOrigin(promRule, 0, 1);
- const match = getMatchingRulerRule(rulerGroup, promRule);
+ const match = getMatchingRulerRule(rulerGroup, promRuleWithOrigin);
expect(match).toBe(rulerRule);
});
@@ -30,8 +62,9 @@ describe('getMatchingRulerRule', () => {
labels: { severity: 'warning' },
annotations: { summary: 'test' },
});
+ const promRuleWithOrigin = createPromRuleWithOrigin(promRule, 0, 1);
- const match = getMatchingRulerRule(rulerGroup, promRule);
+ const match = getMatchingRulerRule(rulerGroup, promRuleWithOrigin);
expect(match).toBeUndefined();
});
@@ -55,8 +88,9 @@ describe('getMatchingRulerRule', () => {
labels: { severity: 'warning' },
annotations: { summary: 'test' },
});
+ const promRuleWithOrigin = createPromRuleWithOrigin(promRule, 0, 2);
- const match = getMatchingRulerRule(rulerGroup, promRule);
+ const match = getMatchingRulerRule(rulerGroup, promRuleWithOrigin);
expect(match).toBe(rulerRule1);
});
@@ -83,8 +117,9 @@ describe('getMatchingRulerRule', () => {
annotations: { summary: 'test' },
query: 'up == 1',
});
+ const promRuleWithOrigin = createPromRuleWithOrigin(promRule, 0, 2);
- const match = getMatchingRulerRule(rulerGroup, promRule);
+ const match = getMatchingRulerRule(rulerGroup, promRuleWithOrigin);
expect(match).toBe(rulerRule1);
});
@@ -111,8 +146,9 @@ describe('getMatchingRulerRule', () => {
annotations: { summary: 'other' },
query: 'up == 2',
});
+ const promRuleWithOrigin = createPromRuleWithOrigin(promRule, 0, 2);
- const match = getMatchingRulerRule(rulerGroup, promRule);
+ const match = getMatchingRulerRule(rulerGroup, promRuleWithOrigin);
expect(match).toBeUndefined();
});
@@ -141,10 +177,120 @@ max by (service) (up{env="staging"}) * 0.8`,
labels: {},
query: `max by (service) (up{env="production"}) or max by (service) (up{env="staging"}) * 0.8`,
});
+ const promRuleWithOrigin = createPromRuleWithOrigin(promRule, 0, 2);
- const match = getMatchingRulerRule(rulerGroup, promRule);
+ const match = getMatchingRulerRule(rulerGroup, promRuleWithOrigin);
expect(match).toBe(rulerRuleWithComments);
});
+
+ it('should match prometheus rule against one of two identical ruler rules', () => {
+ // Create two identical ruler alerting rules
+ const rulerRule1 = alertingFactory.ruler.alertingRule.build({
+ alert: 'KubeJobFailed',
+ expr: `kube_job_failed{job!=\"\"} > 0\n`,
+ labels: { severity: 'warning' },
+ annotations: { summary: 'Job failed to complete.' },
+ });
+ const rulerRule2 = alertingFactory.ruler.alertingRule.build({
+ alert: 'KubeJobFailed',
+ expr: `kube_job_failed{job!=\"\"} > 0\n`,
+ labels: { severity: 'warning' },
+ annotations: { summary: 'Job failed to complete.' },
+ });
+ const rulerGroup = alertingFactory.ruler.group.build({ rules: [rulerRule1, rulerRule2] });
+
+ // Create corresponding prometheus rule at position 0 (should match rulerRule1)
+ const promRule = alertingFactory.prometheus.rule.build({
+ name: 'KubeJobFailed',
+ query: `kube_job_failed{job!=\"\"} > 0`,
+ labels: { severity: 'warning' },
+ annotations: { summary: 'Job failed to complete.' },
+ });
+ const promRuleWithOrigin = createPromRuleWithOrigin(promRule, 0, 2);
+
+ const match = getMatchingRulerRule(rulerGroup, promRuleWithOrigin);
+ // Should match the first ruler rule because position hash matches (0:2)
+ expect(match).toBeDefined();
+ expect(match).toEqual(rulerRule1);
+ });
+
+ it('should match second identical rule when position hash indicates index 1', () => {
+ // Create three identical ruler alerting rules to test matching at different positions
+ const rulerRule1 = alertingFactory.ruler.alertingRule.build({
+ alert: 'HighMemoryUsage',
+ expr: `memory_usage > 90`,
+ labels: { severity: 'critical' },
+ annotations: { summary: 'High memory' },
+ });
+ const rulerRule2 = alertingFactory.ruler.alertingRule.build({
+ alert: 'HighMemoryUsage',
+ expr: `memory_usage > 90`,
+ labels: { severity: 'critical' },
+ annotations: { summary: 'High memory' },
+ });
+ const rulerRule3 = alertingFactory.ruler.alertingRule.build({
+ alert: 'HighMemoryUsage',
+ expr: `memory_usage > 90`,
+ labels: { severity: 'critical' },
+ annotations: { summary: 'High memory' },
+ });
+ const rulerGroup = alertingFactory.ruler.group.build({ rules: [rulerRule1, rulerRule2, rulerRule3] });
+
+ // Create prometheus rule at position 1 (should match rulerRule2, the middle one)
+ const promRule = alertingFactory.prometheus.rule.build({
+ name: 'HighMemoryUsage',
+ query: `memory_usage > 90`,
+ labels: { severity: 'critical' },
+ annotations: { summary: 'High memory' },
+ });
+ const promRuleWithOrigin = createPromRuleWithOrigin(promRule, 1, 3);
+
+ const match = getMatchingRulerRule(rulerGroup, promRuleWithOrigin);
+ // Should match the second ruler rule because position hash is "1:3"
+ expect(match).toBeDefined();
+ expect(match).toBe(rulerRule2);
+ // Verify it's NOT matching the first or third rule
+ expect(match).not.toBe(rulerRule1);
+ expect(match).not.toBe(rulerRule3);
+ });
+
+ it('should NOT match when group sizes differ even with identical rules', () => {
+ // Create a ruler group with 3 identical rules
+ const rulerRule1 = alertingFactory.ruler.alertingRule.build({
+ alert: 'DiskSpaceLow',
+ expr: `disk_free < 10`,
+ labels: { severity: 'warning' },
+ annotations: { summary: 'Low disk space' },
+ });
+ const rulerRule2 = alertingFactory.ruler.alertingRule.build({
+ alert: 'DiskSpaceLow',
+ expr: `disk_free < 10`,
+ labels: { severity: 'warning' },
+ annotations: { summary: 'Low disk space' },
+ });
+ const rulerRule3 = alertingFactory.ruler.alertingRule.build({
+ alert: 'DiskSpaceLow',
+ expr: `disk_free < 10`,
+ labels: { severity: 'warning' },
+ annotations: { summary: 'Low disk space' },
+ });
+ const rulerGroup = alertingFactory.ruler.group.build({ rules: [rulerRule1, rulerRule2, rulerRule3] });
+
+ // Create prometheus rule with position hash indicating 2 rules total (but ruler has 3)
+ const promRule = alertingFactory.prometheus.rule.build({
+ name: 'DiskSpaceLow',
+ query: `disk_free < 10`,
+ labels: { severity: 'warning' },
+ annotations: { summary: 'Low disk space' },
+ });
+ // Position hash "0:2" indicates prom group has 2 rules, but ruler group has 3
+ const promRuleWithOrigin = createPromRuleWithOrigin(promRule, 0, 2);
+
+ const match = getMatchingRulerRule(rulerGroup, promRuleWithOrigin);
+ // Should NOT match because position hash "0:2" doesn't match any ruler rule
+ // (ruler rules would have hashes "0:3", "1:3", "2:3")
+ expect(match).toBeUndefined();
+ });
});
describe('getMatchingPromRule', () => {
@@ -155,8 +301,9 @@ describe('getMatchingPromRule', () => {
// Create a matching ruler rule with same name
const rulerRule = alertingFactory.ruler.alertingRule.build({ alert: 'test-rule' });
+ const rulerRuleWithPosition = createRulerRuleWithPosition(rulerRule, 0, 1);
- const match = getMatchingPromRule(promGroup, rulerRule);
+ const match = getMatchingPromRule(promGroup, rulerRuleWithPosition);
expect(match).toBe(promRule);
});
@@ -174,8 +321,9 @@ describe('getMatchingPromRule', () => {
labels: { severity: 'warning' },
annotations: { summary: 'test' },
});
+ const rulerRuleWithPosition = createRulerRuleWithPosition(rulerRule, 0, 1);
- const match = getMatchingPromRule(promGroup, rulerRule);
+ const match = getMatchingPromRule(promGroup, rulerRuleWithPosition);
expect(match).toBeUndefined();
});
@@ -199,8 +347,9 @@ describe('getMatchingPromRule', () => {
labels: { severity: 'warning' },
annotations: { summary: 'test' },
});
+ const rulerRuleWithPosition = createRulerRuleWithPosition(rulerRule, 0, 2);
- const match = getMatchingPromRule(promGroup, rulerRule);
+ const match = getMatchingPromRule(promGroup, rulerRuleWithPosition);
expect(match).toBe(promRule1);
});
@@ -227,8 +376,9 @@ describe('getMatchingPromRule', () => {
annotations: { summary: 'test' },
expr: 'up == 1',
});
+ const rulerRuleWithPosition = createRulerRuleWithPosition(rulerRule, 0, 2);
- const match = getMatchingPromRule(promGroup, rulerRule);
+ const match = getMatchingPromRule(promGroup, rulerRuleWithPosition);
expect(match).toBe(promRule1);
});
@@ -255,8 +405,9 @@ describe('getMatchingPromRule', () => {
annotations: { summary: 'other' },
expr: 'up == 2',
});
+ const rulerRuleWithPosition = createRulerRuleWithPosition(rulerRule, 0, 2);
- const match = getMatchingPromRule(promGroup, rulerRule);
+ const match = getMatchingPromRule(promGroup, rulerRuleWithPosition);
expect(match).toBeUndefined();
});
@@ -285,10 +436,42 @@ or
# Fall back to staging for missing production metrics
max by (service) (up{env="staging"}) * 0.8`,
});
+ const rulerRuleWithPosition = createRulerRuleWithPosition(rulerRule, 0, 2);
- const match = getMatchingPromRule(promGroup, rulerRule);
+ const match = getMatchingPromRule(promGroup, rulerRuleWithPosition);
expect(match).toBe(promRuleWithoutComments);
});
+
+ it('should match ruler rule against one of two identical prometheus rules', () => {
+ // Create two identical prometheus alerting rules
+ const promRule1 = alertingFactory.prometheus.rule.build({
+ name: 'KubeJobFailed',
+ query: `kube_job_failed{job!=\"\"} > 0`,
+ labels: { severity: 'warning' },
+ annotations: { summary: 'Job failed to complete.' },
+ });
+ const promRule2 = alertingFactory.prometheus.rule.build({
+ name: 'KubeJobFailed',
+ query: `kube_job_failed{job!=\"\"} > 0`,
+ labels: { severity: 'warning' },
+ annotations: { summary: 'Job failed to complete.' },
+ });
+ const promGroup = alertingFactory.prometheus.group.build({ rules: [promRule1, promRule2] });
+
+ // Create corresponding ruler rule at position 0 (should match promRule1)
+ const rulerRule = alertingFactory.ruler.alertingRule.build({
+ alert: 'KubeJobFailed',
+ expr: `kube_job_failed{job!=\"\"} > 0\n`,
+ labels: { severity: 'warning' },
+ annotations: { summary: 'Job failed to complete.' },
+ });
+ const rulerRuleWithPosition = createRulerRuleWithPosition(rulerRule, 0, 2);
+
+ const match = getMatchingPromRule(promGroup, rulerRuleWithPosition);
+ // Should match the first prometheus rule because position hash matches (0:2)
+ expect(match).toBeDefined();
+ expect(match).toEqual(promRule1);
+ });
});
describe('matchRulesGroup', () => {
diff --git a/public/app/features/alerting/unified/rule-list/ruleMatching.ts b/public/app/features/alerting/unified/rule-list/ruleMatching.ts
index 513a43be6f8..ad188ac2526 100644
--- a/public/app/features/alerting/unified/rule-list/ruleMatching.ts
+++ b/public/app/features/alerting/unified/rule-list/ruleMatching.ts
@@ -1,4 +1,3 @@
-import { Rule } from 'app/types/unified-alerting';
import {
PromRuleDTO,
PromRuleGroupDTO,
@@ -10,7 +9,15 @@ import {
import { getPromRuleFingerprint, getRulerRuleFingerprint } from '../utils/rule-id';
import { getRuleName } from '../utils/rules';
-export function getMatchingRulerRule(rulerRuleGroup: RulerRuleGroupDTO, rule: Rule) {
+import { PromRuleWithOrigin } from './hooks/useFilteredRulesIterator';
+import { RulePositionHash, createRulePositionHash } from './rulePositionHash';
+
+export function getMatchingRulerRule(
+ rulerRuleGroup: RulerRuleGroupDTO,
+ promRuleWithOrigin: PromRuleWithOrigin
+) {
+ const { rule, rulePositionHash } = promRuleWithOrigin;
+
// If all rule names are unique, we can use the rule name to find the rule. We don't need to hash the rule
const rulesByName = rulerRuleGroup.rules.filter((r) => getRuleName(r) === rule.name);
if (rulesByName.length === 1) {
@@ -35,10 +42,47 @@ export function getMatchingRulerRule(rulerRuleGroup: RulerRuleGroupDTO 1 && rulePositionHash) {
+ for (const candidateRule of rulesByLabelsAndAnnotationsAndQuery) {
+ const rulerRuleIndex = rulerRuleGroup.rules.indexOf(candidateRule);
+ const rulerPositionHash = createRulePositionHash(rulerRuleIndex, rulerRuleGroup.rules.length);
+
+ // Match if position hashes are identical
+ if (rulerPositionHash === rulePositionHash) {
+ return candidateRule;
+ }
+ }
+ }
+
return undefined;
}
-export function getMatchingPromRule(promRuleGroup: PromRuleGroupDTO, rule: RulerCloudRuleDTO) {
+type RulerRuleWithRulePosition = RulerCloudRuleDTO & {
+ /**
+ * Position hash encoding both the rule's index and the total number of rules in the group.
+ * Format: ":" (e.g., "0:3", "1:5")
+ *
+ * This is used to disambiguate between identical Ruler rules when matching them against
+ * Prometheus rules. The hash ensures rules are matched by position only when both groups
+ * have the same structure (same number of rules).
+ *
+ * @example
+ * // Matching identical rules by position
+ * Ruler rule at position 0 in a 2-rule group: rulePositionHash = "0:2"
+ * Prometheus rule at position 0 in a 2-rule group: rulePositionHash = "0:2" → Match!
+ * Prometheus rule at position 0 in a 3-rule group: rulePositionHash = "0:3" → No match
+ */
+ rulePositionHash: RulePositionHash;
+};
+
+export function getMatchingPromRule(
+ promRuleGroup: PromRuleGroupDTO,
+ rulerRuleWithPosition: RulerRuleWithRulePosition
+) {
+ const { rulePositionHash, ...rule } = rulerRuleWithPosition;
+
// If all rule names are unique, we can use the rule name to find the rule. We don't need to hash the rule
const rulesByName = promRuleGroup.rules.filter((r) => r.name === getRuleName(rule));
if (rulesByName.length === 1) {
@@ -63,6 +107,20 @@ export function getMatchingPromRule(promRuleGroup: PromRuleGroupDTO
return rulesByLabelsAndAnnotationsAndQuery[0];
}
+ // If there are still multiple identical matches, use position hash to disambiguate
+ // This only works if both groups have the same structure (same number of rules in the same order)
+ if (rulesByLabelsAndAnnotationsAndQuery.length > 1 && rulePositionHash) {
+ for (const candidateRule of rulesByLabelsAndAnnotationsAndQuery) {
+ const promRuleIndex = promRuleGroup.rules.indexOf(candidateRule);
+ const promPositionHash = createRulePositionHash(promRuleIndex, promRuleGroup.rules.length);
+
+ // Match if position hashes are identical
+ if (promPositionHash === rulePositionHash) {
+ return candidateRule;
+ }
+ }
+ }
+
return undefined;
}
@@ -76,10 +134,16 @@ export function matchRulesGroup(
promGroup: PromRuleGroupDTO
): GroupMatchingResult {
const matchingResult = rulerGroup.rules.reduce(
- (acc, rulerRule) => {
+ (acc, rulerRule, index) => {
const { matches, unmatchedPromRules } = acc;
- const promRule = getMatchingPromRule(promGroup, rulerRule);
+ // Create ruler rule with position hash
+ const rulerRuleWithPosition: RulerRuleWithRulePosition = {
+ ...rulerRule,
+ rulePositionHash: createRulePositionHash(index, rulerGroup.rules.length),
+ };
+
+ const promRule = getMatchingPromRule(promGroup, rulerRuleWithPosition);
if (promRule) {
matches.set(rulerRule, promRule);
unmatchedPromRules.delete(promRule);
diff --git a/public/app/features/alerting/unified/rule-list/rulePositionHash.ts b/public/app/features/alerting/unified/rule-list/rulePositionHash.ts
new file mode 100644
index 00000000000..462cc415f31
--- /dev/null
+++ b/public/app/features/alerting/unified/rule-list/rulePositionHash.ts
@@ -0,0 +1,20 @@
+/**
+ * A branded type representing a rule's position within a group.
+ * Format: ":" (e.g., "0:3", "1:5")
+ *
+ * This is used to disambiguate between identical rules when matching them across
+ * different rule sources (e.g., matching Prometheus rules to Ruler API rules).
+ * The hash ensures rules are matched by position only when both groups have the
+ * same structure (same number of rules).
+ *
+ * @example
+ * // Matching identical rules by position
+ * Rule at position 0 in a 2-rule group: "0:2"
+ * Rule at position 1 in a 3-rule group: "1:3"
+ * // These won't match rules in differently-sized groups even if identical
+ */
+export type RulePositionHash = `${number}:${number}`;
+
+export function createRulePositionHash(ruleIndex: number, totalRules: number): RulePositionHash {
+ return `${ruleIndex}:${totalRules}`;
+}