Alerting: Update UI of instance counts on triage page (#113660)

* Alerting: Update UI of instance counts on triage page

* make boxes full height

* resolve PR comments- use grafana-ui components

* refactoring
This commit is contained in:
Lauren
2025-11-14 09:45:57 +00:00
committed by GitHub
parent 3fffb2d998
commit d8a5d8d882
4 changed files with 138 additions and 150 deletions
@@ -247,7 +247,7 @@ export const getStyles = (theme: GrafanaTheme2) => {
overflow: 'hidden', // Let AutoSizer handle the overflow
}),
summaryContainer: css({
gridTemplateRows: summaryHeight,
minHeight: summaryHeight,
marginBottom: theme.spacing(2),
}),
headerContainer: css({
@@ -4,31 +4,37 @@ 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 array with "firing" when filter contains alertstate="firing"', () => {
expect(parseAlertstateFilter('alertstate="firing"')).toEqual([PromAlertingRuleState.Firing]);
});
it('should return "pending" when filter contains alertstate="pending"', () => {
expect(parseAlertstateFilter('alertstate="pending"')).toBe(PromAlertingRuleState.Pending);
it('should return array with "pending" when filter contains alertstate="pending"', () => {
expect(parseAlertstateFilter('alertstate="pending"')).toEqual([PromAlertingRuleState.Pending]);
});
it('should return null when filter contains both firing and pending', () => {
expect(parseAlertstateFilter('alertstate=~"firing|pending"')).toBe(null);
it('should return both states when filter contains both firing and pending', () => {
expect(parseAlertstateFilter('alertstate=~"firing|pending"')).toEqual([
PromAlertingRuleState.Firing,
PromAlertingRuleState.Pending,
]);
});
it('should return null when no alertstate filter', () => {
expect(parseAlertstateFilter('')).toBe(null);
expect(parseAlertstateFilter('namespace="default"')).toBe(null);
it('should return both states when no alertstate filter', () => {
expect(parseAlertstateFilter('')).toEqual([PromAlertingRuleState.Firing, PromAlertingRuleState.Pending]);
expect(parseAlertstateFilter('namespace="default"')).toEqual([
PromAlertingRuleState.Firing,
PromAlertingRuleState.Pending,
]);
});
it('should handle regex operator =~', () => {
expect(parseAlertstateFilter('alertstate=~"firing"')).toBe(PromAlertingRuleState.Firing);
expect(parseAlertstateFilter('alertstate=~"pending"')).toBe(PromAlertingRuleState.Pending);
expect(parseAlertstateFilter('alertstate=~"firing"')).toEqual([PromAlertingRuleState.Firing]);
expect(parseAlertstateFilter('alertstate=~"pending"')).toEqual([PromAlertingRuleState.Pending]);
});
it('should handle whitespace', () => {
expect(parseAlertstateFilter('alertstate = "firing"')).toBe(PromAlertingRuleState.Firing);
expect(parseAlertstateFilter('alertstate =~ "pending"')).toBe(PromAlertingRuleState.Pending);
expect(parseAlertstateFilter('alertstate = "firing"')).toEqual([PromAlertingRuleState.Firing]);
expect(parseAlertstateFilter('alertstate =~ "pending"')).toEqual([PromAlertingRuleState.Pending]);
});
});
@@ -58,7 +64,7 @@ describe('countRules', () => {
{ ruleUID: 'rule3', alertstate: PromAlertingRuleState.Pending },
]);
const result = countRules(ruleDfv, null);
const result = countRules(ruleDfv, [PromAlertingRuleState.Firing, PromAlertingRuleState.Pending]);
expect(result.firing).toBe(2);
expect(result.pending).toBe(1);
@@ -71,13 +77,13 @@ describe('countRules', () => {
{ ruleUID: 'rule3', alertstate: PromAlertingRuleState.Firing },
]);
const result = countRules(ruleDfv, null);
const result = countRules(ruleDfv, [PromAlertingRuleState.Firing, PromAlertingRuleState.Pending]);
expect(result.firing).toBe(1);
expect(result.pending).toBe(2);
});
it('should count rules with BOTH firing and pending as firing (firing takes precedence)', () => {
it('should count rules with BOTH firing and pending in both counts', () => {
const ruleDfv = createMockRuleDfv([
{ ruleUID: 'rule1', alertstate: PromAlertingRuleState.Firing },
{ ruleUID: 'rule1', alertstate: PromAlertingRuleState.Pending }, // Same rule, both states
@@ -85,13 +91,13 @@ describe('countRules', () => {
{ ruleUID: 'rule3', alertstate: PromAlertingRuleState.Pending }, // Only pending
]);
const result = countRules(ruleDfv, null);
const result = countRules(ruleDfv, [PromAlertingRuleState.Firing, PromAlertingRuleState.Pending]);
// rule1 should be counted as firing (firing takes precedence)
// rule2 should be counted as firing
// rule3 should be counted as pending
// rule1 has both states, so counted in both
// rule2 counted only in firing
// rule3 counted only in pending
expect(result.firing).toBe(2); // rule1 and rule2
expect(result.pending).toBe(1); // only rule3
expect(result.pending).toBe(2); // rule1 and rule3
});
it('should handle multiple instances of the same rule with same state', () => {
@@ -102,7 +108,7 @@ describe('countRules', () => {
{ ruleUID: 'rule2', alertstate: PromAlertingRuleState.Pending }, // Same rule, duplicate entry
]);
const result = countRules(ruleDfv, null);
const result = countRules(ruleDfv, [PromAlertingRuleState.Firing, PromAlertingRuleState.Pending]);
// Each rule should only be counted once despite multiple entries
expect(result.firing).toBe(1);
@@ -112,7 +118,7 @@ describe('countRules', () => {
it('should return 0 for both counts when no rules', () => {
const ruleDfv = createMockRuleDfv([]);
const result = countRules(ruleDfv, null);
const result = countRules(ruleDfv, [PromAlertingRuleState.Firing, PromAlertingRuleState.Pending]);
expect(result.firing).toBe(0);
expect(result.pending).toBe(0);
@@ -128,7 +134,7 @@ describe('countRules', () => {
{ ruleUID: 'rule3', alertstate: PromAlertingRuleState.Firing }, // Only firing
]);
const result = countRules(ruleDfv, PromAlertingRuleState.Pending);
const result = countRules(ruleDfv, [PromAlertingRuleState.Pending]);
// Should count rule1 and rule2 (both have pending instances)
expect(result.pending).toBe(2);
@@ -144,7 +150,7 @@ describe('countRules', () => {
{ ruleUID: 'rule3', alertstate: PromAlertingRuleState.Pending },
]);
const result = countRules(ruleDfv, PromAlertingRuleState.Pending);
const result = countRules(ruleDfv, [PromAlertingRuleState.Pending]);
// Should count all three rules (all have pending instances)
expect(result.pending).toBe(3);
@@ -158,14 +164,13 @@ describe('countRules', () => {
{ ruleUID: 'rule2', alertstate: PromAlertingRuleState.Pending },
]);
const noFilterResult = countRules(ruleDfv, null);
const pendingFilterResult = countRules(ruleDfv, PromAlertingRuleState.Pending);
const noFilterResult = countRules(ruleDfv, [PromAlertingRuleState.Firing, PromAlertingRuleState.Pending]);
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);
// Without filter: should also count both rules = 2 (both have pending instances)
expect(pendingFilterResult.pending).toBe(2);
expect(noFilterResult.pending).toBe(1); // Only rule2 (rule1 is firing)
expect(noFilterResult.pending).toBe(2); // Both rule1 and rule2 have pending instances
expect(noFilterResult.firing).toBe(1); // rule1
});
});
@@ -179,7 +184,7 @@ describe('countRules', () => {
{ ruleUID: 'rule3', alertstate: PromAlertingRuleState.Pending }, // Only pending
]);
const result = countRules(ruleDfv, PromAlertingRuleState.Firing);
const result = countRules(ruleDfv, [PromAlertingRuleState.Firing]);
// Should count rule1 and rule2 (both have firing instances)
expect(result.firing).toBe(2);
@@ -208,18 +213,18 @@ describe('countRules', () => {
{ 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)
// No filter: Count all rules with at least one instance in each state
const noFilter = countRules(ruleDfv, [PromAlertingRuleState.Firing, PromAlertingRuleState.Pending]);
expect(noFilter.firing).toBe(3); // Rule A, C, and D (all have at least one firing instance)
expect(noFilter.pending).toBe(3); // Rule B, C, and E (all have at least one pending instance)
// With pending filter: Count all rules with ANY pending instances
const pendingFilter = countRules(ruleDfv, PromAlertingRuleState.Pending);
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);
const firingFilter = countRules(ruleDfv, [PromAlertingRuleState.Firing]);
expect(firingFilter.firing).toBe(3); // Rule A, C, and D
expect(firingFilter.pending).toBe(0);
});
@@ -1,11 +1,12 @@
import { DataFrameView } from '@grafana/data';
import { css } from '@emotion/css';
import { DataFrameView, GrafanaTheme2 } from '@grafana/data';
import { Trans } from '@grafana/i18n';
import { SceneObjectBase, SceneObjectState } from '@grafana/scenes';
import { useQueryRunner } from '@grafana/scenes-react';
import { ErrorBoundaryAlert, Stack, Text } from '@grafana/ui';
import { Box, ErrorBoundaryAlert, Grid, useStyles2 } 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';
@@ -25,63 +26,44 @@ export interface RuleFrame {
Value: number;
}
export function parseAlertstateFilter(filter: string): AlertState | null {
const firingMatch = filter.match(/alertstate\s*=~?\s*"firing"/);
const pendingMatch = filter.match(/alertstate\s*=~?\s*"pending"/);
export function parseAlertstateFilter(filter: string): AlertState[] {
const hasFiring = filter.match(/alertstate\s*=~?\s*"firing"/);
const hasPending = filter.match(/alertstate\s*=~?\s*"pending"/);
if (firingMatch && pendingMatch) {
return null;
const states: AlertState[] = [];
// If both or neither match, include both states
if ((hasFiring && hasPending) || (!hasFiring && !hasPending)) {
return [PromAlertingRuleState.Firing, PromAlertingRuleState.Pending];
}
if (firingMatch) {
return PromAlertingRuleState.Firing;
if (hasFiring) {
states.push(PromAlertingRuleState.Firing);
}
if (hasPending) {
states.push(PromAlertingRuleState.Pending);
}
if (pendingMatch) {
return PromAlertingRuleState.Pending;
}
return null;
return states;
}
export function countRules(ruleDfv: DataFrameView<RuleFrame>, alertstateFilter: AlertState | null) {
const rulesWithFiring = new Set<string>();
const rulesWithPending = new Set<string>();
export function countRules(ruleDfv: DataFrameView<RuleFrame>, alertstateFilter: AlertState[]) {
const counts = {
[PromAlertingRuleState.Firing]: new Set<string>(),
[PromAlertingRuleState.Pending]: new Set<string>(),
};
// Only count rules for states we're interested in
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);
if (alertstateFilter.includes(alertstate)) {
counts[alertstate].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
firing: counts[PromAlertingRuleState.Firing].size,
pending: counts[PromAlertingRuleState.Pending].size,
};
}
@@ -93,23 +75,57 @@ function countInstances(instanceDfv: DataFrameView<Frame>) {
return { firing: getValue(PromAlertingRuleState.Firing), pending: getValue(PromAlertingRuleState.Pending) };
}
interface StatRowProps {
interface StatBoxProps {
i18nKey: string;
value: number;
color: 'error' | 'warning';
values: Record<string, number>;
children: React.ReactNode;
}
function StatRow({ i18nKey, color, values, children }: StatRowProps) {
function StatBox({ i18nKey, value, color, children }: StatBoxProps) {
const styles = useStyles2(getStatBoxStyles);
const colorClass = color === 'error' ? styles.errorColor : styles.warningColor;
return (
<Text color={color}>
<Trans i18nKey={i18nKey} values={values}>
{children}
</Trans>
</Text>
<Box
display="flex"
direction="column"
justifyContent="center"
alignItems="center"
padding={2}
backgroundColor="secondary"
borderRadius="default"
gap={1}
height="100%"
>
<div className={styles.label}>{children}</div>
<div className={`${styles.value} ${colorClass}`}>{value}</div>
</Box>
);
}
const getStatBoxStyles = (theme: GrafanaTheme2) => ({
label: css({
fontSize: theme.typography.bodySmall.fontSize,
color: theme.colors.text.primary,
wordWrap: 'break-word',
whiteSpace: 'normal',
textAlign: 'center',
}),
value: css({
fontSize: theme.typography.h1.fontSize,
fontWeight: theme.typography.fontWeightMedium,
lineHeight: 1.2,
textAlign: 'center',
}),
errorColor: css({
color: theme.colors.error.text,
}),
warningColor: css({
color: theme.colors.warning.text,
}),
});
function SummaryStatsContent() {
const filter = useQueryFilter();
const alertstateFilter = parseAlertstateFilter(filter);
@@ -161,65 +177,28 @@ function SummaryStatsContent() {
const rules = countRules(ruleDfv, alertstateFilter);
return (
<Stack direction="column" alignItems="flex-end" gap={0}>
<Spacer />
{alertstateFilter === PromAlertingRuleState.Firing && (
<>
<StatRow i18nKey="alerting.triage.firing-rules-count" color="error" values={{ count: rules.firing }}>
{'{{count}} firing alert rules'}
</StatRow>
<StatRow
i18nKey="alerting.triage.firing-instances-count"
color="error"
values={{ firingCount: instances.firing }}
>
{'{{firingCount}} firing instances'}
</StatRow>
</>
<Grid gap={2}>
{alertstateFilter.includes(PromAlertingRuleState.Firing) && (
<Grid columns={2} gap={2}>
<StatBox i18nKey="alerting.triage.firing-instances-count" value={instances.firing} color="error">
<Trans i18nKey="alerting.triage.firing-instances-count">Firing alert instances</Trans>
</StatBox>
<StatBox i18nKey="alerting.triage.firing-rules-count" value={rules.firing} color="error">
<Trans i18nKey="alerting.triage.rules-with-firing-instances">Alert rules with firing instances</Trans>
</StatBox>
</Grid>
)}
{alertstateFilter === PromAlertingRuleState.Pending && (
<>
<StatRow
i18nKey="alerting.triage.rules-with-pending-instances"
color="warning"
values={{ count: rules.pending }}
>
{'{{count}} rules with pending instances'}
</StatRow>
<StatRow
i18nKey="alerting.triage.pending-instances-count"
color="warning"
values={{ pendingCount: instances.pending }}
>
{'{{pendingCount}} pending instances'}
</StatRow>
</>
{alertstateFilter.includes(PromAlertingRuleState.Pending) && (
<Grid columns={2} gap={2}>
<StatBox i18nKey="alerting.triage.pending-instances-count" value={instances.pending} color="warning">
<Trans i18nKey="alerting.triage.pending-instances-count">Pending alert instances</Trans>
</StatBox>
<StatBox i18nKey="alerting.triage.rules-with-pending-instances" value={rules.pending} color="warning">
<Trans i18nKey="alerting.triage.rules-with-pending-instances">Alert rules with pending instances</Trans>
</StatBox>
</Grid>
)}
{!alertstateFilter && (
<>
<StatRow i18nKey="alerting.triage.firing-rules-count" color="error" values={{ count: rules.firing }}>
{'{{count}} firing alert rules'}
</StatRow>
<StatRow
i18nKey="alerting.triage.firing-instances-count"
color="error"
values={{ firingCount: instances.firing }}
>
{'{{firingCount}} firing instances'}
</StatRow>
<StatRow i18nKey="alerting.triage.pending-rules-count" color="warning" values={{ count: rules.pending }}>
{'{{count}} pending alert rules'}
</StatRow>
<StatRow
i18nKey="alerting.triage.pending-instances-count"
color="warning"
values={{ pendingCount: instances.pending }}
>
{'{{pendingCount}} pending instances'}
</StatRow>
</>
)}
</Stack>
</Grid>
);
}
+5 -1
View File
@@ -2945,6 +2945,7 @@
"triage": {
"alert-instances": "Alert instances",
"error-loading-rule": "Error loading rule",
"firing-instances-count": "Firing alert instances",
"instance-details-drawer": {
"instance-details": "Instance details"
},
@@ -2954,6 +2955,7 @@
"no-matching-instances-with-filters": "No alert instances match your current set of filters for the selected time range.",
"open-in-sidebar": "Open in sidebar",
"open-rule-details": "Open rule details",
"pending-instances-count": "Pending alert instances",
"rule-details": {
"subtitle": "Rule details and conditions",
"title": "Rule Details"
@@ -2961,7 +2963,9 @@
"rule-not-found": {
"description": "The requested rule could not be found.",
"title": "Rule not found"
}
},
"rules-with-firing-instances": "Alert rules with firing instances",
"rules-with-pending-instances": "Alert rules with pending instances"
},
"type-selector-button": {
"add-expression": "Add expression"