Alerting: Add state components to package (#110781)

This commit is contained in:
Gilles De Mey
2025-09-09 18:10:10 +02:00
committed by GitHub
parent b999807727
commit d5fca9a5fa
16 changed files with 631 additions and 2 deletions
+4 -1
View File
@@ -58,7 +58,8 @@
"typecheck": "tsc --emitDeclarationOnly false --noEmit",
"codegen": "rtk-query-codegen-openapi ./scripts/codegen.ts",
"prepack": "cp package.json package.json.bak && ALIAS_PACKAGE_NAME=testing,unstable node ../../scripts/prepare-npm-package.js",
"postpack": "mv package.json.bak package.json && rimraf ./unstable ./testing"
"postpack": "mv package.json.bak package.json && rimraf ./unstable ./testing",
"i18n-extract": "i18next --config src/locales/i18next-parser.config.cjs"
},
"devDependencies": {
"@grafana/test-utils": "workspace:*",
@@ -69,6 +70,7 @@
"@types/lodash": "^4",
"@types/react": "18.3.18",
"@types/react-dom": "18.3.5",
"i18next": "^25.5.2",
"react": "18.3.1",
"react-dom": "18.3.1",
"react-redux": "^9.2.0",
@@ -90,6 +92,7 @@
},
"dependencies": {
"@faker-js/faker": "^9.8.0",
"@grafana/i18n": "12.2.0-pre",
"fishery": "^2.3.1",
"lodash": "^4.17.21"
}
@@ -11,7 +11,7 @@ import { simpleContactPointsListScenario, withErrorScenario } from './ContactPoi
const meta: Meta<typeof ContactPointSelector> = {
component: ContactPointSelector,
title: 'ContactPointSelector',
title: 'Contact Points/ContactPointSelector',
decorators: defaultDecorators,
parameters: {
docs: {
@@ -0,0 +1,59 @@
import { css } from '@emotion/css';
import { GrafanaTheme2 } from '@grafana/data';
import { Stack, useStyles2 } from '@grafana/ui';
interface DotStylesProps {
color: 'success' | 'error' | 'warning' | 'unknown';
}
const StateDot = ({ color }: DotStylesProps) => {
const styles = useStyles2(getDotStyles, { color });
return (
<Stack direction="row" gap={0.5}>
<div className={styles.dot} />
</Stack>
);
};
const getDotStyles = (theme: GrafanaTheme2, { color }: DotStylesProps) => {
const size = theme.spacing(1.25);
const outlineSize = `calc(${size} / 2.5)`;
const errorStyle = color === 'error';
const successStyle = color === 'success';
const warningStyle = color === 'warning';
return {
dot: css(
{
width: size,
height: size,
borderRadius: theme.shape.radius.circle,
backgroundColor: theme.colors.secondary.shade,
outline: `solid ${outlineSize} ${theme.colors.secondary.transparent}`,
margin: outlineSize,
},
successStyle &&
css({
backgroundColor: theme.colors.success.main,
outlineColor: theme.colors.success.transparent,
}),
warningStyle &&
css({
backgroundColor: theme.colors.warning.main,
outlineColor: theme.colors.warning.transparent,
}),
errorStyle &&
css({
backgroundColor: theme.colors.error.main,
outlineColor: theme.colors.error.transparent,
})
),
};
};
export { StateDot };
@@ -0,0 +1,6 @@
import { ArgTypes } from '@storybook/blocks';
import { StateIcon } from './StateIcon';
# StateIcon
A component for showing the state and health of a rule. This components supports pending operations for the rule.
@@ -0,0 +1,27 @@
import type { Meta, StoryFn, StoryObj } from '@storybook/react';
import { ComponentProps } from 'react';
import { StateIcon } from './StateIcon';
import mdx from './StateIcon.mdx';
const meta: Meta<typeof StateIcon> = {
component: StateIcon,
title: 'Rules/StateIcon',
decorators: [],
parameters: {
docs: {
page: mdx,
},
},
};
const StoryRenderFn: StoryFn<ComponentProps<typeof StateIcon>> = (args) => {
return <StateIcon {...args} />;
};
export default meta;
type Story = StoryObj<typeof StateIcon>;
export const Basic: Story = {
render: StoryRenderFn,
};
@@ -0,0 +1,82 @@
import userEvent from '@testing-library/user-event';
import { render, screen } from '../../../../../tests/test-utils';
import { StateIcon } from './StateIcon';
describe('StateIcon', () => {
it('should render the icon for "normal" state', async () => {
const user = userEvent.setup();
render(<StateIcon state="normal" />);
const icon = screen.getByLabelText('Normal');
await user.hover(icon);
expect(await screen.findByRole('tooltip')).toHaveTextContent('Normal');
});
it('should render the icon for "firing" state', async () => {
const user = userEvent.setup();
render(<StateIcon state="firing" />);
const icon = screen.getByLabelText('Firing');
await user.hover(icon);
expect(await screen.findByRole('tooltip')).toHaveTextContent('Firing');
});
// Health takes precedence over state
it('should show "Failed to evaluate rule" when health is "error", ignoring state', async () => {
const user = userEvent.setup();
render(<StateIcon state="normal" health="error" />);
const icon = screen.getByLabelText('Failed to evaluate rule');
await user.hover(icon);
expect(await screen.findByRole('tooltip')).toHaveTextContent('Failed to evaluate rule');
});
it('should show "Insufficient data" when health is "nodata", ignoring state', async () => {
const user = userEvent.setup();
render(<StateIcon state="firing" health="nodata" />);
const icon = screen.getByLabelText('Insufficient data');
await user.hover(icon);
expect(await screen.findByRole('tooltip')).toHaveTextContent('Insufficient data');
});
// isPaused takes precedence over health and state
it('should show "Paused" when isPaused is true, ignoring health and state', async () => {
const user = userEvent.setup();
render(<StateIcon state="firing" health="error" isPaused />);
const icon = screen.getByLabelText('Paused');
await user.hover(icon);
expect(await screen.findByRole('tooltip')).toHaveTextContent('Paused');
});
// operation takes precedence over all
it('should show "Creating" when operation is "creating", ignoring other props', async () => {
const user = userEvent.setup();
render(<StateIcon state="firing" health="error" isPaused operation="creating" />);
const icon = screen.getByLabelText('Creating');
await user.hover(icon);
expect(await screen.findByRole('tooltip')).toHaveTextContent('Creating');
});
it('should show "Deleting" when operation is "deleting", ignoring other props', async () => {
const user = userEvent.setup();
render(<StateIcon state="normal" operation="deleting" />);
const icon = screen.getByLabelText('Deleting');
await user.hover(icon);
expect(await screen.findByRole('tooltip')).toHaveTextContent('Deleting');
});
it('should show "Recording" when recording is true', async () => {
const user = userEvent.setup();
render(<StateIcon type="recording" />);
const icon = screen.getByLabelText('Recording');
await user.hover(icon);
expect(await screen.findByRole('tooltip')).toHaveTextContent('Recording');
});
it('should show "Failed to evaluate rule" when Recording health is "error"', async () => {
const user = userEvent.setup();
render(<StateIcon type="recording" health="error" />);
const icon = screen.getByLabelText('Failed to evaluate rule');
await user.hover(icon);
expect(await screen.findByRole('tooltip')).toHaveTextContent('Failed to evaluate rule');
});
});
@@ -0,0 +1,184 @@
import { css, keyframes } from '@emotion/css';
import { upperFirst } from 'lodash';
import { ComponentProps, memo } from 'react';
import { GrafanaTheme2 } from '@grafana/data';
import { Icon, type IconName, Text, Tooltip, useStyles2, useTheme2 } from '@grafana/ui';
import type { Health, State, Type } from './types';
type TextProps = ComponentProps<typeof Text>;
interface CommonStateIconsProps {
type?: Type;
health?: Health;
isPaused?: boolean;
operation?: RuleOperation;
}
interface AlertingStateIconProps extends CommonStateIconsProps {
type?: 'alerting';
state?: State;
}
interface RecordingStateIconProps extends CommonStateIconsProps {
type: 'recording';
state?: never;
}
type StateIconProps = AlertingStateIconProps | RecordingStateIconProps;
export type RuleOperation = 'creating' | 'deleting';
const icons: Record<State, IconName> = {
normal: 'check-circle',
pending: 'circle',
recovering: 'exclamation-circle',
firing: 'exclamation-circle',
unknown: 'question-circle',
};
const color: Record<State, TextProps['color']> = {
normal: 'success',
pending: 'warning',
recovering: 'warning',
firing: 'error',
unknown: 'secondary',
};
const stateNames: Record<State, string> = {
normal: 'Normal',
pending: 'Pending',
firing: 'Firing',
recovering: 'Recovering',
unknown: 'Unknown',
};
const operationIcons: Record<RuleOperation, IconName> = {
creating: 'plus-circle',
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
*
* @TODO support translations
*/
export const StateIcon = memo(function StateIcon({
state,
health,
type = 'alerting',
isPaused = false,
operation,
}: StateIconProps) {
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 (type === 'recording') {
iconName = 'record-audio';
iconColor = 'success';
stateName = 'Recording';
}
if (health === 'nodata') {
iconName = 'exclamation-triangle';
iconColor = 'warning';
stateName = 'Insufficient data';
}
if (health === 'error') {
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 = upperFirst(operation);
}
return (
<Tooltip content={stateName} placement="right">
<div>
<Text color={iconColor}>
<div className={styles.iconsContainer}>
<Icon name={iconName} width={ICON_SIZE} height={ICON_SIZE} aria-label={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',
},
}),
});
@@ -0,0 +1,7 @@
import { ArgTypes } from '@storybook/blocks';
import { Stack } from '@grafana/ui';
import { StateText } from './StateText';
# StateText
A component for showing the state and health of a rule. The health of the rule will take precedence over its state.
@@ -0,0 +1,46 @@
import type { Meta, StoryObj } from '@storybook/react';
import { ComponentProps } from 'react';
import { Stack } from '@grafana/ui';
import { StateText } from './StateText';
import mdx from './StateText.mdx';
const meta: Meta<typeof StateText> = {
component: StateText,
title: 'Rules/StateText',
decorators: [],
parameters: {
docs: {
page: mdx,
},
},
};
export default meta;
export const AlertRule: StoryObj<typeof StateText> = {
render: (args: ComponentProps<typeof StateText>) => (
<Stack direction="column" alignItems="flex-start">
<StateText {...args} />
<hr />
<StateText type="alerting" state="normal" />
<StateText type="alerting" state="pending" />
<StateText type="alerting" state="firing" />
<StateText type="alerting" state="recovering" />
<StateText type="alerting" state="unknown" />
<hr />
<StateText type="alerting" state="firing" health="error" />
<StateText type="alerting" state="firing" health="nodata" />
</Stack>
),
};
export const RecordingRule: StoryObj<typeof StateText> = {
render: (args: ComponentProps<typeof StateText>) => (
<Stack direction="column" alignItems="flex-start">
<StateText type="recording" health="error" />
<StateText type="recording" />
</Stack>
),
};
@@ -0,0 +1,57 @@
import { render, screen } from '../../../../../tests/test-utils';
import { StateText } from './StateText';
describe('StateText', () => {
describe('alert type', () => {
it('should render the state for "normal"', () => {
render(<StateText state="normal" />);
expect(screen.getByText('Normal')).toBeInTheDocument();
});
it('should render the state for "firing"', () => {
render(<StateText state="firing" />);
expect(screen.getByText('Firing')).toBeInTheDocument();
});
it('should render the state for "pending"', () => {
render(<StateText state="pending" />);
expect(screen.getByText('Pending')).toBeInTheDocument();
});
it('should render the state for "paused"', () => {
render(<StateText isPaused />);
expect(screen.getByText('Paused')).toBeInTheDocument();
});
it('should render "Error" when health is "error", even when state is "normal"', () => {
render(<StateText state="normal" health="error" />);
expect(screen.getByText('Error')).toBeInTheDocument();
expect(screen.queryByText('Normal')).not.toBeInTheDocument();
});
it('should render "No data" when health is "nodata", even when state is "firing"', () => {
render(<StateText state="firing" health="nodata" />);
expect(screen.getByText('No data')).toBeInTheDocument();
expect(screen.queryByText('Firing')).not.toBeInTheDocument();
});
it('should render "Error" when health is "error", even when state is "pending"', () => {
render(<StateText state="pending" health="error" />);
expect(screen.getByText('Error')).toBeInTheDocument();
expect(screen.queryByText('Pending')).not.toBeInTheDocument();
});
});
describe('recording type', () => {
it('should render "Recording" for recording rule type', () => {
render(<StateText type="recording" />);
expect(screen.getByText('Recording')).toBeInTheDocument();
});
it('should render "Recording error" for recording rule type when health is "error"', () => {
render(<StateText type="recording" health="error" />);
expect(screen.getByText('Recording error')).toBeInTheDocument();
});
});
});
@@ -0,0 +1,116 @@
import { ReactNode } from 'react';
import { Trans } from '@grafana/i18n';
import { Icon, Stack, Text } from '@grafana/ui';
import { StateDot } from './StateDot';
import { Health, State } from './types';
// we're making a distinction here between the "state" of the rule and its "health".
// When the type is "recording" we only support the health property.
type CommonStateTextProps = {
health?: Health;
isPaused?: boolean;
};
interface AlertingStateTextProps extends CommonStateTextProps {
type?: 'alerting';
state?: State;
}
interface RecordingStateTextProps extends CommonStateTextProps {
type: 'recording';
state?: never;
}
type StateTextProps = AlertingStateTextProps | RecordingStateTextProps;
export const StateText = ({ state, health, type = 'alerting', isPaused = false }: StateTextProps) => {
if (isPaused) {
return <PausedText />;
}
let stateLabel: string;
let color: TextColor;
switch (state) {
case 'normal':
color = 'success';
stateLabel = 'Normal';
break;
case 'firing':
color = 'error';
stateLabel = 'Firing';
break;
case 'pending':
color = 'warning';
stateLabel = 'Pending';
break;
case 'recovering':
color = 'warning';
stateLabel = 'Recovering';
break;
case 'unknown':
default:
color = 'unknown';
stateLabel = 'Unknown';
break;
}
// if the rule is in "error" health we don't really care about the state
if (health === 'error') {
color = 'error';
stateLabel = 'Error';
}
if (health === 'nodata') {
color = 'warning';
stateLabel = 'No data';
}
// recording rule badge
// @TODO do recording rules support "nodata" state?
if (type === 'recording') {
const text = health === 'error' ? 'Recording error' : 'Recording';
const color = health === 'error' ? 'error' : 'success';
return <InnerText color={color} text={text} />;
}
return <InnerText color={color} text={stateLabel} />;
};
// the generic badge component
type TextColor = 'success' | 'error' | 'warning' | 'unknown';
interface InnerTextProps {
color: TextColor;
text: NonNullable<ReactNode>;
}
// the inner badge component doesn't care about the semantics of "state" or "health" but just renders
// a badge in a specific text color and a dot in matching color.
// We currently don't expose this component outside of this file.
function InnerText({ color, text }: InnerTextProps) {
const textColor = color === 'unknown' ? 'secondary' : color;
return (
<Stack direction="row" gap={0.5} wrap="nowrap" flex="0 0 auto" alignItems="center">
<StateDot color={color} />
<Text variant="bodySmall" color={textColor}>
{text}
</Text>
</Stack>
);
}
function PausedText() {
return (
<Text variant="bodySmall" color="warning">
<Stack direction="row" gap={0.5} wrap="nowrap" flex="0 0 auto" alignItems="center">
<Icon name="pause" size="xs" />
<Trans i18nKey="alerting.paused-badge.paused">Paused</Trans>
</Stack>
</Text>
);
}
@@ -0,0 +1,3 @@
export type Health = 'nodata' | 'error';
export type State = 'normal' | 'firing' | 'pending' | 'unknown' | 'recovering';
export type Type = 'alerting' | 'recording';
@@ -0,0 +1,7 @@
{
"alerting": {
"paused-badge": {
"paused": "Paused"
}
}
}
@@ -0,0 +1,12 @@
module.exports = {
locales: ['en-US'], // Only en-US is updated - Crowdin will PR with other languages
sort: true,
createOldCatalogs: false,
failOnWarnings: true,
verbose: false,
resetDefaultValueLocale: 'en-US', // Updates extracted values when they change in code
defaultNamespace: 'grafana-alerting',
input: ['../**/*.{tsx,ts}'],
output: './src/locales/$LOCALE/$NAMESPACE.json',
};
@@ -25,6 +25,10 @@ export {
export { USER_DEFINED_TREE_NAME } from './grafana/notificationPolicies/consts';
export * from './grafana/notificationPolicies/types';
// Rules
export { StateText } from './grafana/rules/components/state/StateText';
export { StateIcon } from './grafana/rules/components/state/StateIcon';
// Matchers
export { type LabelMatcher, type Label } from './grafana/matchers/types';
export { matchLabelsSet, matchLabels, isLabelMatch, type LabelMatchDetails } from './grafana/matchers/utils';
+16
View File
@@ -2915,6 +2915,7 @@ __metadata:
resolution: "@grafana/alerting@workspace:packages/grafana-alerting"
dependencies:
"@faker-js/faker": "npm:^9.8.0"
"@grafana/i18n": "npm:12.2.0-pre"
"@grafana/test-utils": "workspace:*"
"@rtk-query/codegen-openapi": "npm:^2.0.0"
"@testing-library/jest-dom": "npm:^6.6.3"
@@ -2924,6 +2925,7 @@ __metadata:
"@types/react": "npm:18.3.18"
"@types/react-dom": "npm:18.3.5"
fishery: "npm:^2.3.1"
i18next: "npm:^25.5.2"
lodash: "npm:^4.17.21"
react: "npm:18.3.1"
react-dom: "npm:18.3.1"
@@ -19226,6 +19228,20 @@ __metadata:
languageName: node
linkType: hard
"i18next@npm:^25.5.2":
version: 25.5.2
resolution: "i18next@npm:25.5.2"
dependencies:
"@babel/runtime": "npm:^7.27.6"
peerDependencies:
typescript: ^5
peerDependenciesMeta:
typescript:
optional: true
checksum: 10/8d52e82386722a228f4465aa5cf39d82bd9861ea9cc8b31d7cc4d22d5e70b2740ee006068b09f486d5273cabd227a2ac14f37d68fab26344524200083f679bcd
languageName: node
linkType: hard
"iconv-lite@npm:0.4.24, iconv-lite@npm:^0.4.24":
version: 0.4.24
resolution: "iconv-lite@npm:0.4.24"