From c75a451b13327f36aafe4b81e649f619518be5d1 Mon Sep 17 00:00:00 2001 From: Lauren <61048546+laurenashleigh@users.noreply.github.com> Date: Tue, 4 Nov 2025 10:17:07 +0000 Subject: [PATCH] Alerting: Add counts for firing and pending alert rules (#113309) * add counts for firing and pending alert rules * resolve Pr comments part 1 * resolve PR comments part 2 * resolve PR comments part 3 --- .../unified/triage/scene/SummaryStats.test.ts | 227 ++++++++++++++++++ .../unified/triage/scene/SummaryStats.tsx | 226 ++++++++++++++--- public/locales/en-US/grafana.json | 2 - 3 files changed, 426 insertions(+), 29 deletions(-) create mode 100644 public/app/features/alerting/unified/triage/scene/SummaryStats.test.ts diff --git a/public/app/features/alerting/unified/triage/scene/SummaryStats.test.ts b/public/app/features/alerting/unified/triage/scene/SummaryStats.test.ts new file mode 100644 index 00000000000..b232733d430 --- /dev/null +++ b/public/app/features/alerting/unified/triage/scene/SummaryStats.test.ts @@ -0,0 +1,227 @@ +import { DataFrameView } from '@grafana/data'; +import { PromAlertingRuleState } from 'app/types/unified-alerting-dto'; + +import { RuleFrame, countRules, parseAlertstateFilter } from './SummaryStats'; + +describe('parseAlertstateFilter', () => { + it('should return "firing" when filter contains alertstate="firing"', () => { + expect(parseAlertstateFilter('alertstate="firing"')).toBe(PromAlertingRuleState.Firing); + }); + + it('should return "pending" when filter contains alertstate="pending"', () => { + expect(parseAlertstateFilter('alertstate="pending"')).toBe(PromAlertingRuleState.Pending); + }); + + it('should return null when filter contains both firing and pending', () => { + expect(parseAlertstateFilter('alertstate=~"firing|pending"')).toBe(null); + }); + + it('should return null when no alertstate filter', () => { + expect(parseAlertstateFilter('')).toBe(null); + expect(parseAlertstateFilter('namespace="default"')).toBe(null); + }); + + it('should handle regex operator =~', () => { + expect(parseAlertstateFilter('alertstate=~"firing"')).toBe(PromAlertingRuleState.Firing); + expect(parseAlertstateFilter('alertstate=~"pending"')).toBe(PromAlertingRuleState.Pending); + }); + + it('should handle whitespace', () => { + expect(parseAlertstateFilter('alertstate = "firing"')).toBe(PromAlertingRuleState.Firing); + expect(parseAlertstateFilter('alertstate =~ "pending"')).toBe(PromAlertingRuleState.Pending); + }); +}); + +describe('countRules', () => { + // Helper to create mock DataFrameView + function createMockRuleDfv( + data: Array<{ ruleUID: string; alertstate: PromAlertingRuleState.Firing | PromAlertingRuleState.Pending }> + ): DataFrameView { + return { + length: data.length, + fields: { + grafana_rule_uid: { + values: data.map((d) => d.ruleUID), + }, + alertstate: { + values: data.map((d) => d.alertstate), + }, + }, + } as unknown as DataFrameView; + } + + describe('when no alertstate filter applied', () => { + it('should count rules with ANY firing instances', () => { + const ruleDfv = createMockRuleDfv([ + { ruleUID: 'rule1', alertstate: PromAlertingRuleState.Firing }, + { ruleUID: 'rule2', alertstate: PromAlertingRuleState.Firing }, + { ruleUID: 'rule3', alertstate: PromAlertingRuleState.Pending }, + ]); + + const result = countRules(ruleDfv, null); + + expect(result.firing).toBe(2); + expect(result.pending).toBe(1); + }); + + it('should count rules with ONLY pending instances (no firing)', () => { + const ruleDfv = createMockRuleDfv([ + { ruleUID: 'rule1', alertstate: PromAlertingRuleState.Pending }, + { ruleUID: 'rule2', alertstate: PromAlertingRuleState.Pending }, + { ruleUID: 'rule3', alertstate: PromAlertingRuleState.Firing }, + ]); + + const result = countRules(ruleDfv, null); + + expect(result.firing).toBe(1); + expect(result.pending).toBe(2); + }); + + it('should count rules with BOTH firing and pending as firing (firing takes precedence)', () => { + const ruleDfv = createMockRuleDfv([ + { ruleUID: 'rule1', alertstate: PromAlertingRuleState.Firing }, + { ruleUID: 'rule1', alertstate: PromAlertingRuleState.Pending }, // Same rule, both states + { ruleUID: 'rule2', alertstate: PromAlertingRuleState.Firing }, // Only firing + { ruleUID: 'rule3', alertstate: PromAlertingRuleState.Pending }, // Only pending + ]); + + const result = countRules(ruleDfv, null); + + // rule1 should be counted as firing (firing takes precedence) + // rule2 should be counted as firing + // rule3 should be counted as pending + expect(result.firing).toBe(2); // rule1 and rule2 + expect(result.pending).toBe(1); // only rule3 + }); + + it('should handle multiple instances of the same rule with same state', () => { + const ruleDfv = createMockRuleDfv([ + { ruleUID: 'rule1', alertstate: PromAlertingRuleState.Firing }, + { ruleUID: 'rule1', alertstate: PromAlertingRuleState.Firing }, // Same rule, duplicate entry + { ruleUID: 'rule2', alertstate: PromAlertingRuleState.Pending }, + { ruleUID: 'rule2', alertstate: PromAlertingRuleState.Pending }, // Same rule, duplicate entry + ]); + + const result = countRules(ruleDfv, null); + + // Each rule should only be counted once despite multiple entries + expect(result.firing).toBe(1); + expect(result.pending).toBe(1); + }); + + it('should return 0 for both counts when no rules', () => { + const ruleDfv = createMockRuleDfv([]); + + const result = countRules(ruleDfv, null); + + expect(result.firing).toBe(0); + expect(result.pending).toBe(0); + }); + }); + + describe('when filtering by alertstate=pending', () => { + it('should count ALL rules with any pending instances', () => { + const ruleDfv = createMockRuleDfv([ + { ruleUID: 'rule1', alertstate: PromAlertingRuleState.Firing }, + { ruleUID: 'rule1', alertstate: PromAlertingRuleState.Pending }, // Has both + { ruleUID: 'rule2', alertstate: PromAlertingRuleState.Pending }, // Only pending + { ruleUID: 'rule3', alertstate: PromAlertingRuleState.Firing }, // Only firing + ]); + + const result = countRules(ruleDfv, PromAlertingRuleState.Pending); + + // Should count rule1 and rule2 (both have pending instances) + expect(result.pending).toBe(2); + expect(result.firing).toBe(0); + }); + + it('should count rules with BOTH states as pending', () => { + const ruleDfv = createMockRuleDfv([ + { ruleUID: 'rule1', alertstate: PromAlertingRuleState.Firing }, + { ruleUID: 'rule1', alertstate: PromAlertingRuleState.Pending }, + { ruleUID: 'rule2', alertstate: PromAlertingRuleState.Firing }, + { ruleUID: 'rule2', alertstate: PromAlertingRuleState.Pending }, + { ruleUID: 'rule3', alertstate: PromAlertingRuleState.Pending }, + ]); + + const result = countRules(ruleDfv, PromAlertingRuleState.Pending); + + // Should count all three rules (all have pending instances) + expect(result.pending).toBe(3); + expect(result.firing).toBe(0); + }); + + it('should be >= pending count from no filter scenario', () => { + const ruleDfv = createMockRuleDfv([ + { ruleUID: 'rule1', alertstate: PromAlertingRuleState.Firing }, + { ruleUID: 'rule1', alertstate: PromAlertingRuleState.Pending }, + { ruleUID: 'rule2', alertstate: PromAlertingRuleState.Pending }, + ]); + + const noFilterResult = countRules(ruleDfv, null); + const pendingFilterResult = countRules(ruleDfv, PromAlertingRuleState.Pending); + + // With pending filter: should count rule1 and rule2 = 2 + // Without filter: should count only rule2 = 1 (rule1 is counted as firing, not pending) + expect(pendingFilterResult.pending).toBeGreaterThanOrEqual(noFilterResult.pending); + expect(pendingFilterResult.pending).toBe(2); + expect(noFilterResult.pending).toBe(1); // Only rule2 (rule1 is firing) + expect(noFilterResult.firing).toBe(1); // rule1 + }); + }); + + describe('when filtering by alertstate=firing', () => { + it('should count ALL rules with any firing instances', () => { + const ruleDfv = createMockRuleDfv([ + { ruleUID: 'rule1', alertstate: PromAlertingRuleState.Firing }, + { ruleUID: 'rule1', alertstate: PromAlertingRuleState.Pending }, // Has both + { ruleUID: 'rule2', alertstate: PromAlertingRuleState.Firing }, // Only firing + { ruleUID: 'rule3', alertstate: PromAlertingRuleState.Pending }, // Only pending + ]); + + const result = countRules(ruleDfv, PromAlertingRuleState.Firing); + + // Should count rule1 and rule2 (both have firing instances) + expect(result.firing).toBe(2); + expect(result.pending).toBe(0); + }); + }); + + describe('real-world scenarios', () => { + it('should handle complex scenario with many rules', () => { + const ruleDfv = createMockRuleDfv([ + // Rule A: Only firing (3 instances) + { ruleUID: 'ruleA', alertstate: PromAlertingRuleState.Firing }, + { ruleUID: 'ruleA', alertstate: PromAlertingRuleState.Firing }, + { ruleUID: 'ruleA', alertstate: PromAlertingRuleState.Firing }, + // Rule B: Only pending (2 instances) + { ruleUID: 'ruleB', alertstate: PromAlertingRuleState.Pending }, + { ruleUID: 'ruleB', alertstate: PromAlertingRuleState.Pending }, + // Rule C: Both firing and pending (4 instances) + { ruleUID: 'ruleC', alertstate: PromAlertingRuleState.Firing }, + { ruleUID: 'ruleC', alertstate: PromAlertingRuleState.Firing }, + { ruleUID: 'ruleC', alertstate: PromAlertingRuleState.Pending }, + { ruleUID: 'ruleC', alertstate: PromAlertingRuleState.Pending }, + // Rule D: Only firing (1 instance) + { ruleUID: 'ruleD', alertstate: PromAlertingRuleState.Firing }, + // Rule E: Only pending (1 instance) + { ruleUID: 'ruleE', alertstate: PromAlertingRuleState.Pending }, + ]); + + // No filter: Firing takes precedence + const noFilter = countRules(ruleDfv, null); + expect(noFilter.firing).toBe(3); // Rule A, C, and D (C has firing so it's counted as firing) + expect(noFilter.pending).toBe(2); // Rule B and E (only those with NO firing) + + // With pending filter: Count all rules with ANY pending instances + const pendingFilter = countRules(ruleDfv, PromAlertingRuleState.Pending); + expect(pendingFilter.pending).toBe(3); // Rule B, C, and E + expect(pendingFilter.firing).toBe(0); + + // With firing filter: Count all rules with ANY firing instances + const firingFilter = countRules(ruleDfv, PromAlertingRuleState.Firing); + expect(firingFilter.firing).toBe(3); // Rule A, C, and D + expect(firingFilter.pending).toBe(0); + }); + }); +}); diff --git a/public/app/features/alerting/unified/triage/scene/SummaryStats.tsx b/public/app/features/alerting/unified/triage/scene/SummaryStats.tsx index 73957f85789..b26e9b9aa8f 100644 --- a/public/app/features/alerting/unified/triage/scene/SummaryStats.tsx +++ b/public/app/features/alerting/unified/triage/scene/SummaryStats.tsx @@ -2,63 +2,235 @@ import { DataFrameView } from '@grafana/data'; import { Trans } from '@grafana/i18n'; import { SceneObjectBase, SceneObjectState } from '@grafana/scenes'; import { useQueryRunner } from '@grafana/scenes-react'; -import { Stack, Text } from '@grafana/ui'; +import { ErrorBoundaryAlert, Stack, Text } from '@grafana/ui'; +import { PromAlertingRuleState } from 'app/types/unified-alerting-dto'; import { Spacer } from '../../components/Spacer'; import { METRIC_NAME } from '../constants'; import { getDataQuery, useQueryFilter } from './utils'; +type AlertState = PromAlertingRuleState.Firing | PromAlertingRuleState.Pending; + interface Frame { - alertstate: 'firing' | 'pending'; + alertstate: AlertState; Value: number; } -export function SummaryStatsReact() { - const filter = useQueryFilter(); +export interface RuleFrame { + alertstate: AlertState; + alertname: string; + grafana_folder: string; + grafana_rule_uid: string; + Value: number; +} - const dataProvider = useQueryRunner({ +export function parseAlertstateFilter(filter: string): AlertState | null { + const firingMatch = filter.match(/alertstate\s*=~?\s*"firing"/); + const pendingMatch = filter.match(/alertstate\s*=~?\s*"pending"/); + + if (firingMatch && pendingMatch) { + return null; + } + + if (firingMatch) { + return PromAlertingRuleState.Firing; + } + + if (pendingMatch) { + return PromAlertingRuleState.Pending; + } + + return null; +} + +export function countRules(ruleDfv: DataFrameView, alertstateFilter: AlertState | null) { + const rulesWithFiring = new Set(); + const rulesWithPending = new Set(); + + ruleDfv.fields.grafana_rule_uid.values.forEach((ruleUID, i) => { + const alertstate = ruleDfv.fields.alertstate.values[i]; + if (alertstate === PromAlertingRuleState.Firing) { + rulesWithFiring.add(ruleUID); + } + if (alertstate === PromAlertingRuleState.Pending) { + rulesWithPending.add(ruleUID); + } + }); + + // When filtering by pending, count all rules with pending instances (may also have firing) + if (alertstateFilter === PromAlertingRuleState.Pending) { + return { + firing: 0, + pending: rulesWithPending.size, + }; + } + + // When filtering by firing, count all rules with firing instances (may also have pending) + if (alertstateFilter === PromAlertingRuleState.Firing) { + return { + firing: rulesWithFiring.size, + pending: 0, + }; + } + + // When no filter: firing takes precedence + // A rule is "firing" if it has ANY firing instances (even if it also has pending) + // A rule is "pending" ONLY if it has pending instances but NO firing instances + const onlyPending = new Set([...rulesWithPending].filter((uid) => !rulesWithFiring.has(uid))); + + return { + firing: rulesWithFiring.size, // ALL rules with firing instances + pending: onlyPending.size, // ONLY rules with no firing instances + }; +} + +function countInstances(instanceDfv: DataFrameView) { + const getValue = (state: AlertState) => { + const index = instanceDfv.fields.alertstate.values.findIndex((s) => s === state); + return instanceDfv.fields.Value.values[index] ?? 0; + }; + return { firing: getValue(PromAlertingRuleState.Firing), pending: getValue(PromAlertingRuleState.Pending) }; +} + +interface StatRowProps { + i18nKey: string; + color: 'error' | 'warning'; + values: Record; + children: React.ReactNode; +} + +function StatRow({ i18nKey, color, values, children }: StatRowProps) { + return ( + + + {children} + + + ); +} + +function SummaryStatsContent() { + const filter = useQueryFilter(); + const alertstateFilter = parseAlertstateFilter(filter); + + const instanceDataProvider = useQueryRunner({ + queries: [getDataQuery(`count by (alertstate) (${METRIC_NAME}{${filter}})`, { instant: true, format: 'table' })], + }); + + // Always remove alertstate filter from rule query to get accurate counts across both states + // This ensures we can count rules that have instances in either state + const ruleFilter = filter + .replace(/alertstate\s*=~?\s*"(firing|pending)"[,\s]*/, '') + .replace(/,\s*$/, '') + .replace(/^\s*,/, ''); + const ruleDataProvider = useQueryRunner({ queries: [ - getDataQuery(`count by (alertstate) (${METRIC_NAME}{${filter}})`, { - instant: true, - exemplar: false, - format: 'table', - }), + getDataQuery( + `count by (alertname, grafana_folder, grafana_rule_uid, alertstate) (${METRIC_NAME}{${ruleFilter}})`, + { + instant: true, + format: 'table', + } + ), ], }); - const isLoading = !dataProvider.isDataReadyToDisplay; - const data = dataProvider.useState().data; - const firstFrame = data?.series?.at(0); + const { data: instanceData } = instanceDataProvider.useState(); + const { data: ruleData } = ruleDataProvider.useState(); + const instanceFrame = instanceData?.series?.at(0); + const ruleFrame = ruleData?.series?.at(0); - if (isLoading || !firstFrame) { + if ( + !instanceDataProvider.isDataReadyToDisplay() || + !ruleDataProvider.isDataReadyToDisplay() || + !instanceFrame || + !ruleFrame + ) { return
; } - const dfv = new DataFrameView(firstFrame); - if (dfv.length === 0) { + const instanceDfv = new DataFrameView(instanceFrame); + const ruleDfv = new DataFrameView(ruleFrame); + + if (instanceDfv.length === 0 && ruleDfv.length === 0) { return
; } - const firingIndex = dfv.fields.alertstate.values.findIndex((state) => state === 'firing'); - const firingCount = dfv.fields.Value.values[firingIndex] ?? 0; - - const pendingIndex = dfv.fields.alertstate.values.findIndex((state) => state === 'pending'); - const pendingCount = dfv.fields.Value.values[pendingIndex] ?? 0; + const instances = countInstances(instanceDfv); + const rules = countRules(ruleDfv, alertstateFilter); return ( - - {{ firingCount }} firing instances - - - {{ pendingCount }} pending instances - + {alertstateFilter === PromAlertingRuleState.Firing && ( + <> + + {'{{count}} firing alert rules'} + + + {'{{firingCount}} firing instances'} + + + )} + {alertstateFilter === PromAlertingRuleState.Pending && ( + <> + + {'{{count}} rules with pending instances'} + + + {'{{pendingCount}} pending instances'} + + + )} + {!alertstateFilter && ( + <> + + {'{{count}} firing alert rules'} + + + {'{{firingCount}} firing instances'} + + + {'{{count}} pending alert rules'} + + + {'{{pendingCount}} pending instances'} + + + )} ); } +export function SummaryStatsReact() { + return ( + + + + ); +} + // simple wrapper so we can render the Chart using a Scene parent export class SummaryStatsScene extends SceneObjectBase { static Component = SummaryStatsReact; diff --git a/public/locales/en-US/grafana.json b/public/locales/en-US/grafana.json index dc7eeab17c4..d95bf14de31 100644 --- a/public/locales/en-US/grafana.json +++ b/public/locales/en-US/grafana.json @@ -2945,7 +2945,6 @@ "triage": { "alert-instances": "Alert instances", "error-loading-rule": "Error loading rule", - "firing-instances-count": "{{firingCount}} firing instances", "instance-details-drawer": { "instance-details": "Instance details" }, @@ -2953,7 +2952,6 @@ "no-labels": "No labels", "open-in-sidebar": "Open in sidebar", "open-rule-details": "Open rule details", - "pending-instances-count": "{{pendingCount}} pending instances", "rule-details": { "subtitle": "Rule details and conditions", "title": "Rule Details"