Alerting: Use Alerting package state components (#110830)

This commit is contained in:
Gilles De Mey
2025-09-11 14:47:52 +02:00
committed by GitHub
parent c7169b3461
commit d73308690c
12 changed files with 81 additions and 308 deletions
@@ -1,12 +0,0 @@
import { Trans } from '@grafana/i18n';
import { Icon, Stack, Text } from '@grafana/ui';
export default function PausedBadge() {
return (
<Text variant="bodySmall" color="warning">
<Stack direction="row" alignItems={'center'} gap={0.25} wrap={'nowrap'} flex={'0 0 auto'}>
<Icon name="pause" size="xs" /> <Trans i18nKey="alerting.paused-badge.paused">Paused</Trans>
</Stack>
</Text>
);
}
@@ -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 (
<Stack direction="row" gap={1} minWidth={0} alignItems="center">
{returnToHref && (
@@ -321,15 +324,9 @@ export const Title = ({ name, paused = false, state, health, ruleType, ruleOrigi
<Text variant="h1" truncate>
{name}
</Text>
{paused ? (
<PausedBadge />
) : (
<>
{/* recording rules won't have a state */}
{state && <StateBadge state={state} health={health} />}
{isRecordingRule && <RecordingBadge health={health} />}
</>
)}
{/* recording rules won't have a state */}
{state && <StateText type="alerting" state={textState} health={textHealth} isPaused={paused} />}
{isRecordingRule && <StateText type="recording" health={textHealth} isPaused={paused} />}
</Stack>
);
};
@@ -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 <Badge color={color} text={text} />;
};
// 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 <Badge color={color} text={stateLabel} />;
};
// the generic badge component
type BadgeColor = 'success' | 'error' | 'warning' | 'info';
interface BadgeProps {
color: BadgeColor;
text: NonNullable<ReactNode>;
}
function Badge({ color, text }: BadgeProps) {
return (
<Stack direction="row" gap={0.5} wrap={'nowrap'} flex={'0 0 auto'}>
<AlertStateDot color={color} />
<Text variant="bodySmall" color={color}>
{text}
</Text>
</Stack>
);
}
@@ -119,14 +119,14 @@ describe('DataSourceGroupLoader', () => {
render(<DataSourceGroupLoader groupIdentifier={groupIdentifier} />);
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(<DataSourceGroupLoader groupIdentifier={groupIdentifier} />);
const promOnlyItem = await ui.ruleItem(/prom-only-rule/).find();
expect(within(promOnlyItem).getByTitle('Deleting')).toBeInTheDocument();
expect(within(promOnlyItem).getByLabelText('Deleting')).toBeInTheDocument();
});
});
});
@@ -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}
/>
))}
@@ -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' }),
@@ -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;
}
@@ -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 (
<ListItem
aria-labelledby={listItemAriaId}
@@ -159,7 +162,9 @@ export const AlertRuleListItem = (props: AlertRuleListItemProps) => {
</Stack>
}
description={<Summary content={summary} error={error} />}
icon={<RuleListIcon state={state} health={health} isPaused={isPaused} operation={operation} />}
icon={
<StateIcon type="alerting" state={ruleState} health={ruleHealth} isPaused={isPaused} operation={operation} />
}
actions={actions}
meta={metadata}
/>
@@ -207,6 +212,8 @@ export function RecordingRuleListItem({
metadata.push(<QuerySourceIcons queriedDatasourceUIDs={querySourceUIDs} />);
}
const ruleHealth = normalizeHealth(health);
return (
<ListItem
title={
@@ -222,7 +229,7 @@ export function RecordingRuleListItem({
</Stack>
}
description={<Summary error={error} />}
icon={<RuleListIcon recording={true} health={health} isPaused={isPaused} />}
icon={<StateIcon type="recording" health={ruleHealth} isPaused={isPaused} />}
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({
<Text id={listItemAriaId}>{name}</Text>
</Stack>
}
icon={<RuleListIcon operation={operation} />}
icon={<StateIcon operation={operation} />}
meta={metadata}
/>
);
@@ -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 (
<ListItem
title={<Skeleton width={64} />}
icon={<RuleListIcon isPaused={false} />}
icon={<StateIcon isPaused={false} />}
description={<Skeleton width={256} />}
actions={<RuleActionsSkeleton />}
data-testid="alert-rule-list-item-loader"
@@ -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<typeof Text>;
interface RuleListIconProps {
recording?: boolean;
state?: PromAlertingRuleState;
health?: RuleHealth;
isPaused?: boolean;
operation?: RuleOperation;
}
export enum RuleOperation {
Creating = 'Creating',
Deleting = 'Deleting',
}
const icons: Record<PromAlertingRuleState, IconName> = {
[PromAlertingRuleState.Inactive]: 'check-circle',
[PromAlertingRuleState.Pending]: 'circle',
[PromAlertingRuleState.Recovering]: 'exclamation-circle',
[PromAlertingRuleState.Firing]: 'exclamation-circle',
[PromAlertingRuleState.Unknown]: 'question-circle',
};
const color: Record<PromAlertingRuleState, 'success' | 'error' | 'warning' | 'info'> = {
[PromAlertingRuleState.Inactive]: 'success',
[PromAlertingRuleState.Pending]: 'warning',
[PromAlertingRuleState.Recovering]: 'warning',
[PromAlertingRuleState.Firing]: 'error',
[PromAlertingRuleState.Unknown]: 'info',
};
const stateNames: Record<PromAlertingRuleState, string> = {
[PromAlertingRuleState.Inactive]: 'Normal',
[PromAlertingRuleState.Pending]: 'Pending',
[PromAlertingRuleState.Firing]: 'Firing',
[PromAlertingRuleState.Recovering]: 'Recovering',
[PromAlertingRuleState.Unknown]: 'Unknown',
};
const operationIcons: Record<RuleOperation, IconName> = {
[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<RuleListIconProps>) {
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 (
<Tooltip content={stateName} placement="right">
<div>
<Text color={iconColor}>
<div className={styles.iconsContainer}>
<Icon name={iconName} width={ICON_SIZE} height={ICON_SIZE} title={stateName} />
{/* 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 && (
<svg
width={ICON_SIZE}
height={ICON_SIZE}
viewBox="0 0 20 20"
version="1.1"
xmlns="http://www.w3.org/2000/svg"
className={styles.spinning}
>
<circle
r={ICON_SIZE / 2}
cx="10"
cy="10"
// make sure to match this color to the color of the list item background where it's being used! Works for both light and dark themes.
stroke={theme.colors.background.primary}
strokeWidth="2"
strokeLinecap="round"
fill="transparent"
strokeOpacity={0.85}
strokeDasharray="20px"
/>
</svg>
)}
</div>
</Text>
</div>
</Tooltip>
);
});
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',
},
}),
});
@@ -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<typeof StateIcon>['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<NormalizedHealth> {
const valid: Array<NonNullable<NormalizedHealth>> = ['nodata', 'error'] as const;
return valid.some((v) => v === health);
}
type NormalizedState = ComponentProps<typeof StateIcon>['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<NormalizedState> {
const valid: Array<NonNullable<NormalizedState>> = ['normal', 'firing', 'pending', 'unknown', 'recovering'] as const;
return valid.some((v) => v === state);
}
-3
View File
@@ -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",