diff --git a/packages/grafana-alerting/package.json b/packages/grafana-alerting/package.json index 2c0f4f6f4c1..a4475f819d9 100644 --- a/packages/grafana-alerting/package.json +++ b/packages/grafana-alerting/package.json @@ -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" } diff --git a/packages/grafana-alerting/src/grafana/contactPoints/components/ContactPointSelector/ContactPointSelector.story.tsx b/packages/grafana-alerting/src/grafana/contactPoints/components/ContactPointSelector/ContactPointSelector.story.tsx index 9c0d2bcfa8b..e0aed86d30f 100644 --- a/packages/grafana-alerting/src/grafana/contactPoints/components/ContactPointSelector/ContactPointSelector.story.tsx +++ b/packages/grafana-alerting/src/grafana/contactPoints/components/ContactPointSelector/ContactPointSelector.story.tsx @@ -11,7 +11,7 @@ import { simpleContactPointsListScenario, withErrorScenario } from './ContactPoi const meta: Meta = { component: ContactPointSelector, - title: 'ContactPointSelector', + title: 'Contact Points/ContactPointSelector', decorators: defaultDecorators, parameters: { docs: { diff --git a/packages/grafana-alerting/src/grafana/rules/components/state/StateDot.tsx b/packages/grafana-alerting/src/grafana/rules/components/state/StateDot.tsx new file mode 100644 index 00000000000..85001d21e7a --- /dev/null +++ b/packages/grafana-alerting/src/grafana/rules/components/state/StateDot.tsx @@ -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 ( + +
+ + ); +}; + +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 }; diff --git a/packages/grafana-alerting/src/grafana/rules/components/state/StateIcon.mdx b/packages/grafana-alerting/src/grafana/rules/components/state/StateIcon.mdx new file mode 100644 index 00000000000..cde0f8fb70e --- /dev/null +++ b/packages/grafana-alerting/src/grafana/rules/components/state/StateIcon.mdx @@ -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. diff --git a/packages/grafana-alerting/src/grafana/rules/components/state/StateIcon.story.tsx b/packages/grafana-alerting/src/grafana/rules/components/state/StateIcon.story.tsx new file mode 100644 index 00000000000..9e21723ec49 --- /dev/null +++ b/packages/grafana-alerting/src/grafana/rules/components/state/StateIcon.story.tsx @@ -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 = { + component: StateIcon, + title: 'Rules/StateIcon', + decorators: [], + parameters: { + docs: { + page: mdx, + }, + }, +}; + +const StoryRenderFn: StoryFn> = (args) => { + return ; +}; + +export default meta; +type Story = StoryObj; + +export const Basic: Story = { + render: StoryRenderFn, +}; diff --git a/packages/grafana-alerting/src/grafana/rules/components/state/StateIcon.test.tsx b/packages/grafana-alerting/src/grafana/rules/components/state/StateIcon.test.tsx new file mode 100644 index 00000000000..7ccaa6bfc09 --- /dev/null +++ b/packages/grafana-alerting/src/grafana/rules/components/state/StateIcon.test.tsx @@ -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(); + 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(); + 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(); + 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(); + 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(); + 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(); + 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(); + 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(); + 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(); + const icon = screen.getByLabelText('Failed to evaluate rule'); + await user.hover(icon); + expect(await screen.findByRole('tooltip')).toHaveTextContent('Failed to evaluate rule'); + }); +}); diff --git a/packages/grafana-alerting/src/grafana/rules/components/state/StateIcon.tsx b/packages/grafana-alerting/src/grafana/rules/components/state/StateIcon.tsx new file mode 100644 index 00000000000..4d11e266905 --- /dev/null +++ b/packages/grafana-alerting/src/grafana/rules/components/state/StateIcon.tsx @@ -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; + +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 = { + normal: 'check-circle', + pending: 'circle', + recovering: 'exclamation-circle', + firing: 'exclamation-circle', + unknown: 'question-circle', +}; + +const color: Record = { + normal: 'success', + pending: 'warning', + recovering: 'warning', + firing: 'error', + unknown: 'secondary', +}; + +const stateNames: Record = { + normal: 'Normal', + pending: 'Pending', + firing: 'Firing', + recovering: 'Recovering', + unknown: 'Unknown', +}; + +const operationIcons: Record = { + 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 ( + +
+ +
+ + {/* 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/packages/grafana-alerting/src/grafana/rules/components/state/StateText.mdx b/packages/grafana-alerting/src/grafana/rules/components/state/StateText.mdx new file mode 100644 index 00000000000..36ca81aec35 --- /dev/null +++ b/packages/grafana-alerting/src/grafana/rules/components/state/StateText.mdx @@ -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. diff --git a/packages/grafana-alerting/src/grafana/rules/components/state/StateText.story.tsx b/packages/grafana-alerting/src/grafana/rules/components/state/StateText.story.tsx new file mode 100644 index 00000000000..73b70967fb8 --- /dev/null +++ b/packages/grafana-alerting/src/grafana/rules/components/state/StateText.story.tsx @@ -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 = { + component: StateText, + title: 'Rules/StateText', + decorators: [], + parameters: { + docs: { + page: mdx, + }, + }, +}; + +export default meta; + +export const AlertRule: StoryObj = { + render: (args: ComponentProps) => ( + + +
+ + + + + +
+ + +
+ ), +}; + +export const RecordingRule: StoryObj = { + render: (args: ComponentProps) => ( + + + + + ), +}; diff --git a/packages/grafana-alerting/src/grafana/rules/components/state/StateText.test.tsx b/packages/grafana-alerting/src/grafana/rules/components/state/StateText.test.tsx new file mode 100644 index 00000000000..c028ac7d30a --- /dev/null +++ b/packages/grafana-alerting/src/grafana/rules/components/state/StateText.test.tsx @@ -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(); + expect(screen.getByText('Normal')).toBeInTheDocument(); + }); + + it('should render the state for "firing"', () => { + render(); + expect(screen.getByText('Firing')).toBeInTheDocument(); + }); + + it('should render the state for "pending"', () => { + render(); + expect(screen.getByText('Pending')).toBeInTheDocument(); + }); + + it('should render the state for "paused"', () => { + render(); + expect(screen.getByText('Paused')).toBeInTheDocument(); + }); + + it('should render "Error" when health is "error", even when state is "normal"', () => { + render(); + 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(); + 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(); + expect(screen.getByText('Error')).toBeInTheDocument(); + expect(screen.queryByText('Pending')).not.toBeInTheDocument(); + }); + }); + + describe('recording type', () => { + it('should render "Recording" for recording rule type', () => { + render(); + expect(screen.getByText('Recording')).toBeInTheDocument(); + }); + + it('should render "Recording error" for recording rule type when health is "error"', () => { + render(); + expect(screen.getByText('Recording error')).toBeInTheDocument(); + }); + }); +}); diff --git a/packages/grafana-alerting/src/grafana/rules/components/state/StateText.tsx b/packages/grafana-alerting/src/grafana/rules/components/state/StateText.tsx new file mode 100644 index 00000000000..d9ccbdb1797 --- /dev/null +++ b/packages/grafana-alerting/src/grafana/rules/components/state/StateText.tsx @@ -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 ; + } + + 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 ; + } + + return ; +}; + +// the generic badge component +type TextColor = 'success' | 'error' | 'warning' | 'unknown'; + +interface InnerTextProps { + color: TextColor; + text: NonNullable; +} + +// 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 ( + + + + {text} + + + ); +} + +function PausedText() { + return ( + + + + Paused + + + ); +} diff --git a/packages/grafana-alerting/src/grafana/rules/components/state/types.ts b/packages/grafana-alerting/src/grafana/rules/components/state/types.ts new file mode 100644 index 00000000000..c37ff0851f4 --- /dev/null +++ b/packages/grafana-alerting/src/grafana/rules/components/state/types.ts @@ -0,0 +1,3 @@ +export type Health = 'nodata' | 'error'; +export type State = 'normal' | 'firing' | 'pending' | 'unknown' | 'recovering'; +export type Type = 'alerting' | 'recording'; diff --git a/packages/grafana-alerting/src/locales/en-US/grafana-alerting.json b/packages/grafana-alerting/src/locales/en-US/grafana-alerting.json new file mode 100644 index 00000000000..e15baa2cce7 --- /dev/null +++ b/packages/grafana-alerting/src/locales/en-US/grafana-alerting.json @@ -0,0 +1,7 @@ +{ + "alerting": { + "paused-badge": { + "paused": "Paused" + } + } +} diff --git a/packages/grafana-alerting/src/locales/i18next-parser.config.cjs b/packages/grafana-alerting/src/locales/i18next-parser.config.cjs new file mode 100644 index 00000000000..47939b46cc9 --- /dev/null +++ b/packages/grafana-alerting/src/locales/i18next-parser.config.cjs @@ -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', +}; diff --git a/packages/grafana-alerting/src/unstable.ts b/packages/grafana-alerting/src/unstable.ts index 2433b19ade9..7fadd98eb45 100644 --- a/packages/grafana-alerting/src/unstable.ts +++ b/packages/grafana-alerting/src/unstable.ts @@ -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'; diff --git a/yarn.lock b/yarn.lock index 6910c46af06..8ee11d02f3a 100644 --- a/yarn.lock +++ b/yarn.lock @@ -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"