From d73308690c4ac4247178316502d79822ebd925bd Mon Sep 17 00:00:00 2001 From: Gilles De Mey Date: Thu, 11 Sep 2025 14:47:52 +0200 Subject: [PATCH] Alerting: Use Alerting package state components (#110830) --- .../components/rule-viewer/PausedBadge.tsx | 12 -- .../components/rule-viewer/RuleViewer.tsx | 19 +- .../components/rule-viewer/StateBadges.tsx | 88 --------- .../rule-list/DataSourceGroupLoader.test.tsx | 4 +- .../rule-list/DataSourceGroupLoader.tsx | 5 +- .../rule-list/GrafanaGroupLoader.test.tsx | 4 +- .../unified/rule-list/GrafanaRuleListItem.tsx | 3 +- .../components/AlertRuleListItem.tsx | 21 ++- .../components/AlertRuleListItemLoader.tsx | 4 +- .../rule-list/components/RuleListIcon.tsx | 176 ------------------ .../unified/rule-list/components/util.ts | 50 +++++ public/locales/en-US/grafana.json | 3 - 12 files changed, 81 insertions(+), 308 deletions(-) delete mode 100644 public/app/features/alerting/unified/components/rule-viewer/PausedBadge.tsx delete mode 100644 public/app/features/alerting/unified/components/rule-viewer/StateBadges.tsx delete mode 100644 public/app/features/alerting/unified/rule-list/components/RuleListIcon.tsx diff --git a/public/app/features/alerting/unified/components/rule-viewer/PausedBadge.tsx b/public/app/features/alerting/unified/components/rule-viewer/PausedBadge.tsx deleted file mode 100644 index a1764f4ed38..00000000000 --- a/public/app/features/alerting/unified/components/rule-viewer/PausedBadge.tsx +++ /dev/null @@ -1,12 +0,0 @@ -import { Trans } from '@grafana/i18n'; -import { Icon, Stack, Text } from '@grafana/ui'; - -export default function PausedBadge() { - return ( - - - Paused - - - ); -} diff --git a/public/app/features/alerting/unified/components/rule-viewer/RuleViewer.tsx b/public/app/features/alerting/unified/components/rule-viewer/RuleViewer.tsx index 5224756b624..de406b59761 100644 --- a/public/app/features/alerting/unified/components/rule-viewer/RuleViewer.tsx +++ b/public/app/features/alerting/unified/components/rule-viewer/RuleViewer.tsx @@ -3,6 +3,7 @@ import { chain, truncate } from 'lodash'; import { useEffect, useState } from 'react'; import { useMeasure } from 'react-use'; +import { StateText } from '@grafana/alerting/unstable'; import { NavModelItem, UrlQueryValue } from '@grafana/data'; import { Trans, t } from '@grafana/i18n'; import { @@ -40,6 +41,7 @@ import { useHasRulerV2 } from '../../hooks/useHasRuler'; import { useRuleGroupConsistencyCheck } from '../../hooks/usePrometheusConsistencyCheck'; import { useReturnTo } from '../../hooks/useReturnTo'; import { PluginOriginBadge } from '../../plugins/PluginOriginBadge'; +import { normalizeHealth, normalizeState } from '../../rule-list/components/util'; import { Annotation } from '../../utils/constants'; import { getRulesSourceUid, ruleIdentifierToRuleSourceIdentifier } from '../../utils/datasource'; import { labelsSize } from '../../utils/labels'; @@ -63,9 +65,7 @@ import { RedirectToCloneRule } from '../rules/CloneRule'; import { ContactPointLink } from './ContactPointLink'; import { FederatedRuleWarning } from './FederatedRuleWarning'; -import PausedBadge from './PausedBadge'; import { useAlertRule } from './RuleContext'; -import { RecordingBadge, StateBadge } from './StateBadges'; import { AlertVersionHistory } from './tabs/AlertVersionHistory'; import { Details } from './tabs/Details'; import { History } from './tabs/History'; @@ -307,6 +307,9 @@ export const Title = ({ name, paused = false, state, health, ruleType, ruleOrigi const { returnTo } = useReturnTo(returnToHref); + const textHealth = normalizeHealth(health); + const textState = normalizeState(state); + return ( {returnToHref && ( @@ -321,15 +324,9 @@ export const Title = ({ name, paused = false, state, health, ruleType, ruleOrigi {name} - {paused ? ( - - ) : ( - <> - {/* recording rules won't have a state */} - {state && } - {isRecordingRule && } - - )} + {/* recording rules won't have a state */} + {state && } + {isRecordingRule && } ); }; diff --git a/public/app/features/alerting/unified/components/rule-viewer/StateBadges.tsx b/public/app/features/alerting/unified/components/rule-viewer/StateBadges.tsx deleted file mode 100644 index d213f07103f..00000000000 --- a/public/app/features/alerting/unified/components/rule-viewer/StateBadges.tsx +++ /dev/null @@ -1,88 +0,0 @@ -import { ReactNode } from 'react'; - -import { Stack, Text } from '@grafana/ui'; -import { RuleHealth } from 'app/types/unified-alerting'; -import { PromAlertingRuleState } from 'app/types/unified-alerting-dto'; - -import { AlertStateDot } from '../AlertStateDot'; - -import { isErrorHealth } from './RuleViewer'; - -interface RecordingBadgeProps { - health?: RuleHealth; -} - -export const RecordingBadge = ({ health }: RecordingBadgeProps) => { - const hasError = isErrorHealth(health); - - const color = hasError ? 'error' : 'success'; - const text = hasError ? 'Recording error' : 'Recording'; - - return ; -}; - -// we're making a distinction here between the "state" of the rule and its "health". -interface StateBadgeProps { - state: PromAlertingRuleState; - health?: RuleHealth; -} - -export const StateBadge = ({ state, health }: StateBadgeProps) => { - let stateLabel: string; - let color: BadgeColor; - - switch (state) { - case PromAlertingRuleState.Inactive: - color = 'success'; - stateLabel = 'Normal'; - break; - case PromAlertingRuleState.Firing: - color = 'error'; - stateLabel = 'Firing'; - break; - case PromAlertingRuleState.Pending: - color = 'warning'; - stateLabel = 'Pending'; - break; - case PromAlertingRuleState.Recovering: - color = 'warning'; - stateLabel = 'Recovering'; - break; - case PromAlertingRuleState.Unknown: - color = 'info'; - stateLabel = 'Unknown'; - break; - } - - // if the rule is in "error" health we don't really care about the state - if (isErrorHealth(health)) { - color = 'error'; - stateLabel = 'Error'; - } - - if (health === 'nodata') { - color = 'warning'; - stateLabel = 'No data'; - } - - return ; -}; - -// the generic badge component -type BadgeColor = 'success' | 'error' | 'warning' | 'info'; - -interface BadgeProps { - color: BadgeColor; - text: NonNullable; -} - -function Badge({ color, text }: BadgeProps) { - return ( - - - - {text} - - - ); -} diff --git a/public/app/features/alerting/unified/rule-list/DataSourceGroupLoader.test.tsx b/public/app/features/alerting/unified/rule-list/DataSourceGroupLoader.test.tsx index 2c8411e09a9..53b80f1ebaf 100644 --- a/public/app/features/alerting/unified/rule-list/DataSourceGroupLoader.test.tsx +++ b/public/app/features/alerting/unified/rule-list/DataSourceGroupLoader.test.tsx @@ -119,14 +119,14 @@ describe('DataSourceGroupLoader', () => { render(); const mimirOnlyItem = await ui.ruleItem(/mimir-only-rule/).find(); - expect(within(mimirOnlyItem).getByTitle('Creating')).toBeInTheDocument(); + expect(within(mimirOnlyItem).getByLabelText('Creating')).toBeInTheDocument(); }); it('should render deleting state if a rule is only present in prometheus', async () => { render(); const promOnlyItem = await ui.ruleItem(/prom-only-rule/).find(); - expect(within(promOnlyItem).getByTitle('Deleting')).toBeInTheDocument(); + expect(within(promOnlyItem).getByLabelText('Deleting')).toBeInTheDocument(); }); }); }); diff --git a/public/app/features/alerting/unified/rule-list/DataSourceGroupLoader.tsx b/public/app/features/alerting/unified/rule-list/DataSourceGroupLoader.tsx index 9842f651ec3..bd221cd72ed 100644 --- a/public/app/features/alerting/unified/rule-list/DataSourceGroupLoader.tsx +++ b/public/app/features/alerting/unified/rule-list/DataSourceGroupLoader.tsx @@ -28,7 +28,6 @@ import { RuleOperationListItem } from './components/AlertRuleListItem'; import { AlertRuleListItemSkeleton } from './components/AlertRuleListItemLoader'; import { LoadMoreButton } from './components/LoadMoreButton'; import { RuleActionsButtons } from './components/RuleActionsButtons.V2'; -import { RuleOperation } from './components/RuleListIcon'; import { matchRulesGroup } from './ruleMatching'; const { useDiscoverDsFeaturesQuery } = featureDiscoveryApi; @@ -215,7 +214,7 @@ export function RulerBasedGroupRules({ group={groupName} rulesSource={groupIdentifier.rulesSource} application={application} - operation={RuleOperation.Creating} + operation="creating" showLocation={false} /> ); @@ -228,7 +227,7 @@ export function RulerBasedGroupRules({ group={groupName} rulesSource={groupIdentifier.rulesSource} application={application} - operation={RuleOperation.Deleting} + operation="deleting" showLocation={false} /> ))} diff --git a/public/app/features/alerting/unified/rule-list/GrafanaGroupLoader.test.tsx b/public/app/features/alerting/unified/rule-list/GrafanaGroupLoader.test.tsx index 3169996b88c..3105c3efd6b 100644 --- a/public/app/features/alerting/unified/rule-list/GrafanaGroupLoader.test.tsx +++ b/public/app/features/alerting/unified/rule-list/GrafanaGroupLoader.test.tsx @@ -1,5 +1,5 @@ import { render } from 'test/test-utils'; -import { byRole, byTitle } from 'testing-library-selector'; +import { byLabelText, byRole } from 'testing-library-selector'; import { setPluginComponentsHook, setPluginLinksHook } from '@grafana/runtime'; import { AccessControlAction } from 'app/types/accessControl'; @@ -29,7 +29,7 @@ setupMswServer(); const ui = { ruleItem: (ruleName: string) => byRole('treeitem', { name: ruleName }), - ruleStatus: (status: string) => byTitle(status), + ruleStatus: (status: string) => byLabelText(status), ruleLink: (ruleName: string) => byRole('link', { name: ruleName }), editButton: () => byRole('link', { name: 'Edit' }), moreButton: () => byRole('button', { name: 'More' }), diff --git a/public/app/features/alerting/unified/rule-list/GrafanaRuleListItem.tsx b/public/app/features/alerting/unified/rule-list/GrafanaRuleListItem.tsx index ed90116bcaa..2e708627128 100644 --- a/public/app/features/alerting/unified/rule-list/GrafanaRuleListItem.tsx +++ b/public/app/features/alerting/unified/rule-list/GrafanaRuleListItem.tsx @@ -14,13 +14,12 @@ import { UnknownRuleListItem, } from './components/AlertRuleListItem'; import { RuleActionsButtons } from './components/RuleActionsButtons.V2'; -import { RuleOperation } from './components/RuleListIcon'; interface GrafanaRuleListItemProps { rule: GrafanaPromRuleDTO; groupIdentifier: GrafanaRuleGroupIdentifier; namespaceName: string; - operation?: RuleOperation; + operation?: 'creating' | 'deleting'; showLocation?: boolean; } diff --git a/public/app/features/alerting/unified/rule-list/components/AlertRuleListItem.tsx b/public/app/features/alerting/unified/rule-list/components/AlertRuleListItem.tsx index 38c5a01e7a0..637344db3e1 100644 --- a/public/app/features/alerting/unified/rule-list/components/AlertRuleListItem.tsx +++ b/public/app/features/alerting/unified/rule-list/components/AlertRuleListItem.tsx @@ -2,6 +2,7 @@ import { css, cx } from '@emotion/css'; import pluralize from 'pluralize'; import { ReactNode, forwardRef, memo, useEffect, useId } from 'react'; +import { StateIcon } from '@grafana/alerting/unstable'; import { DataSourceInstanceSettings, GrafanaTheme2 } from '@grafana/data'; import { Trans, t } from '@grafana/i18n'; import { Alert, Stack, Text, TextLink, Tooltip, useStyles2 } from '@grafana/ui'; @@ -21,9 +22,8 @@ import { createContactPointSearchLink, makeDataSourceLink } from '../../utils/mi import { RulePluginOrigin } from '../../utils/rules'; import { ListItem } from './ListItem'; -import { RuleListIcon, RuleOperation } from './RuleListIcon'; import { RuleLocation } from './RuleLocation'; -import { calculateNextEvaluationEstimate } from './util'; +import { calculateNextEvaluationEstimate, normalizeHealth, normalizeState } from './util'; export interface AlertRuleListItemProps { name: string; @@ -47,7 +47,7 @@ export interface AlertRuleListItemProps { contactPoint?: string; actions?: ReactNode; origin?: RulePluginOrigin; - operation?: RuleOperation; + operation?: 'creating' | 'deleting'; // the grouped view doesn't need to show the location again – it's redundant showLocation?: boolean; querySourceUIDs?: string[]; @@ -143,6 +143,9 @@ export const AlertRuleListItem = (props: AlertRuleListItemProps) => { ); } + const ruleHealth = normalizeHealth(health); + const ruleState = normalizeState(state); + return ( { } description={} - icon={} + icon={ + + } actions={actions} meta={metadata} /> @@ -207,6 +212,8 @@ export function RecordingRuleListItem({ metadata.push(); } + const ruleHealth = normalizeHealth(health); + return ( } description={} - icon={} + icon={} actions={actions} meta={metadata} /> @@ -236,7 +243,7 @@ interface RuleOperationListItemProps { groupUrl?: string; rulesSource?: RulesSourceIdentifier; application?: RulesSourceApplication; - operation: RuleOperation; + operation: 'creating' | 'deleting'; showLocation?: boolean; } @@ -275,7 +282,7 @@ export function RuleOperationListItem({ {name} } - icon={} + icon={} meta={metadata} /> ); diff --git a/public/app/features/alerting/unified/rule-list/components/AlertRuleListItemLoader.tsx b/public/app/features/alerting/unified/rule-list/components/AlertRuleListItemLoader.tsx index e8abce41ed8..600d9ae9620 100644 --- a/public/app/features/alerting/unified/rule-list/components/AlertRuleListItemLoader.tsx +++ b/public/app/features/alerting/unified/rule-list/components/AlertRuleListItemLoader.tsx @@ -1,5 +1,6 @@ import Skeleton from 'react-loading-skeleton'; +import { StateIcon } from '@grafana/alerting/unstable'; import { t } from '@grafana/i18n'; import { GrafanaRuleIdentifier } from 'app/types/unified-alerting'; @@ -7,13 +8,12 @@ import { stringifyErrorLike } from '../../utils/misc'; import { ListItem } from './ListItem'; import { RuleActionsSkeleton } from './RuleActionsSkeleton'; -import { RuleListIcon } from './RuleListIcon'; export function AlertRuleListItemSkeleton() { return ( } - icon={} + icon={} description={} actions={} data-testid="alert-rule-list-item-loader" diff --git a/public/app/features/alerting/unified/rule-list/components/RuleListIcon.tsx b/public/app/features/alerting/unified/rule-list/components/RuleListIcon.tsx deleted file mode 100644 index 96025385826..00000000000 --- a/public/app/features/alerting/unified/rule-list/components/RuleListIcon.tsx +++ /dev/null @@ -1,176 +0,0 @@ -import { css, keyframes } from '@emotion/css'; -import { ComponentProps, memo } from 'react'; -import type { RequireAtLeastOne } from 'type-fest'; - -import { GrafanaTheme2 } from '@grafana/data'; -import { Icon, type IconName, Text, Tooltip, useStyles2, useTheme2 } from '@grafana/ui'; -import type { RuleHealth } from 'app/types/unified-alerting'; -import { PromAlertingRuleState } from 'app/types/unified-alerting-dto'; - -import { isErrorHealth } from '../../components/rule-viewer/RuleViewer'; - -type TextProps = ComponentProps; - -interface RuleListIconProps { - recording?: boolean; - state?: PromAlertingRuleState; - health?: RuleHealth; - isPaused?: boolean; - operation?: RuleOperation; -} - -export enum RuleOperation { - Creating = 'Creating', - Deleting = 'Deleting', -} - -const icons: Record = { - [PromAlertingRuleState.Inactive]: 'check-circle', - [PromAlertingRuleState.Pending]: 'circle', - [PromAlertingRuleState.Recovering]: 'exclamation-circle', - [PromAlertingRuleState.Firing]: 'exclamation-circle', - [PromAlertingRuleState.Unknown]: 'question-circle', -}; - -const color: Record = { - [PromAlertingRuleState.Inactive]: 'success', - [PromAlertingRuleState.Pending]: 'warning', - [PromAlertingRuleState.Recovering]: 'warning', - [PromAlertingRuleState.Firing]: 'error', - [PromAlertingRuleState.Unknown]: 'info', -}; - -const stateNames: Record = { - [PromAlertingRuleState.Inactive]: 'Normal', - [PromAlertingRuleState.Pending]: 'Pending', - [PromAlertingRuleState.Firing]: 'Firing', - [PromAlertingRuleState.Recovering]: 'Recovering', - [PromAlertingRuleState.Unknown]: 'Unknown', -}; - -const operationIcons: Record = { - [RuleOperation.Creating]: 'plus-circle', - [RuleOperation.Deleting]: 'minus-circle', -}; - -// ⚠️ not trivial to update this, you have to re-do the math for the loading spinner -const ICON_SIZE = 15; - -/** - * Make sure that the order of importance here matches the one we use in the StateBadge component for the detail view - * This component is often rendered tens or hundreds of times in a single page, so it's performance is important - */ -export const RuleListIcon = memo(function RuleListIcon({ - state, - health, - recording = false, - isPaused = false, - operation, -}: RequireAtLeastOne) { - const styles = useStyles2(getStyles); - const theme = useTheme2(); - - let iconName: IconName = state ? icons[state] : 'circle'; - let iconColor: TextProps['color'] = state ? color[state] : 'secondary'; - let stateName: string = state ? stateNames[state] : 'unknown'; - - if (recording) { - iconName = 'record-audio'; - iconColor = 'success'; - stateName = 'Recording'; - } - - if (health === 'nodata') { - iconName = 'exclamation-triangle'; - iconColor = 'warning'; - stateName = 'Insufficient data'; - } - - if (isErrorHealth(health)) { - iconName = 'times-circle'; - iconColor = 'error'; - stateName = 'Failed to evaluate rule'; - } - - if (isPaused) { - iconName = 'pause-circle'; - iconColor = 'warning'; - stateName = 'Paused'; - } - - if (operation) { - iconName = operationIcons[operation]; - iconColor = 'secondary'; - stateName = operation; - } - - return ( - -
- -
- - {/* this loading spinner works by using an optical illusion; - the actual icon is static and the "spinning" part is just a semi-transparent darker circle overlayed on top. - This makes it look like there is a small bright colored spinner rotating. - */} - {operation && ( - - - - )} -
-
-
-
- ); -}); - -const spin = keyframes({ - '0%': { - transform: 'rotate(0deg)', - }, - '50%': { - transform: 'rotate(180deg)', - }, - '100%': { - transform: 'rotate(360deg)', - }, -}); - -const getStyles = (theme: GrafanaTheme2) => ({ - iconsContainer: css({ - position: 'relative', - width: ICON_SIZE, - height: ICON_SIZE, - '> *': { - position: 'absolute', - }, - }), - spinning: css({ - [theme.transitions.handleMotion('no-preference')]: { - animationName: spin, - animationIterationCount: 'infinite', - animationDuration: '1s', - animationTimingFunction: 'linear', - }, - }), -}); diff --git a/public/app/features/alerting/unified/rule-list/components/util.ts b/public/app/features/alerting/unified/rule-list/components/util.ts index f7dfcbfa8ab..c04ad5b3f0b 100644 --- a/public/app/features/alerting/unified/rule-list/components/util.ts +++ b/public/app/features/alerting/unified/rule-list/components/util.ts @@ -1,6 +1,10 @@ import { addMilliseconds, formatDistanceToNowStrict, isBefore } from 'date-fns'; +import { ComponentProps } from 'react'; +import { StateIcon } from '@grafana/alerting/unstable'; import { dateTime, dateTimeFormat, isValidDate } from '@grafana/data'; +import { RuleHealth } from 'app/types/unified-alerting'; +import { PromAlertingRuleState } from 'app/types/unified-alerting-dto'; import { isNullDate, parsePrometheusDuration } from '../../utils/time'; @@ -69,3 +73,49 @@ export function getRelativeEvaluationInterval(lastEvaluation?: string) { return formatDistanceToNowStrict(new Date(lastEvaluation)); } + +type NormalizedHealth = ComponentProps['health']; +export function normalizeHealth(health?: RuleHealth): NormalizedHealth { + if (!health) { + return; + } + + // backwards compatibility with Prometheus rule state + if (health === 'err') { + return 'error'; + } + + if (isValidHealth(health)) { + return health; + } + + return; +} + +function isValidHealth(health: string): health is NonNullable { + const valid: Array> = ['nodata', 'error'] as const; + return valid.some((v) => v === health); +} + +type NormalizedState = ComponentProps['state']; +export function normalizeState(state?: PromAlertingRuleState): NormalizedState { + if (!state) { + return 'unknown'; + } + + // backwards compatibility with Prometheus rule state + if (state === 'inactive') { + return 'normal'; + } + + if (isValidState(state)) { + return state; + } + + return; +} + +function isValidState(state: string): state is NonNullable { + const valid: Array> = ['normal', 'firing', 'pending', 'unknown', 'recovering'] as const; + return valid.some((v) => v === state); +} diff --git a/public/locales/en-US/grafana.json b/public/locales/en-US/grafana.json index e974a23708a..dbd9423b48d 100644 --- a/public/locales/en-US/grafana.json +++ b/public/locales/en-US/grafana.json @@ -2058,9 +2058,6 @@ "text-loading-rules": "Loading rules...", "title-dashboard-not-saved": "Dashboard not saved" }, - "paused-badge": { - "paused": "Paused" - }, "payload-editor": { "edit-payload": "Edit payload", "label-add-custom-alert-instance": "Add custom alert instance",