Alerting: Move AlertLabels to alerting package (#111104)

* Alerting: move AlertLabels to alerting package

* add story & test for AlertLabel

* export label component from alerting package

* rename Label to AlertLabel

* resolve PR comments

* update imports

* export types from internal

* run yarn i18n-extract

* align with WAI-ARIA standards

* export AlertLabels props

* update failing test

* move labels components to alerting unstable

* alertlabel updates

* update test props

* update types and props

* update test prop types

* update types and props

* add role list to AlertLabel parent

* update story

* ensure color contrast accessibility

* move findCommonLabels into AlertLabels component

* unset size as small for tag

* update logic to get readable colors

* make label and value fonts accessible

---------

Co-authored-by: Gilles De Mey <gilles.de.mey@gmail.com>
This commit is contained in:
Lauren
2025-09-17 23:03:00 +01:00
committed by GitHub
co-authored by Gilles De Mey
parent 4de8cfb14a
commit a9de287607
27 changed files with 609 additions and 340 deletions
@@ -0,0 +1,60 @@
import type { Meta, StoryObj } from '@storybook/react';
import { ComponentProps } from 'react';
import { Stack, Text } from '@grafana/ui';
import { AlertLabel } from './AlertLabel';
const meta: Meta<typeof AlertLabel> = {
component: AlertLabel,
title: 'Rules/AlertLabel',
};
export default meta;
export const Basic: StoryObj<typeof AlertLabel> = {
render: (args: ComponentProps<typeof AlertLabel>) => (
<Stack direction="column" alignItems="flex-start" gap={1} role="list">
<AlertLabel role="listitem" labelKey="alertname" value="HighErrorRate" />
<AlertLabel role="listitem" labelKey="instance" value="web-01" />
<AlertLabel role="listitem" labelKey="job" value="nginx" />
<AlertLabel role="listitem" value="hello, world!" />
</Stack>
),
};
export const Sizes: StoryObj<typeof AlertLabel> = {
render: (args: ComponentProps<typeof AlertLabel>) => (
<Stack direction="column" alignItems="flex-start" gap={1} role="list">
<Text>md</Text>
<AlertLabel role="listitem" labelKey="zone" value="us-east-1" size="md" />
<Text>sm</Text>
<AlertLabel role="listitem" labelKey="zone" value="us-east-1" size="sm" />
<Text>xs</Text>
<AlertLabel role="listitem" labelKey="zone" value="us-east-1" size="xs" />
</Stack>
),
};
export const Clickable: StoryObj<typeof AlertLabel> = {
render: (args: ComponentProps<typeof AlertLabel>) => (
<AlertLabel
{...args}
labelKey="region"
value="eu-central-1"
onClick={([value, key]) => console.log('clicked', key, value)}
/>
),
};
export const WithIconAndColor: StoryObj<typeof AlertLabel> = {
render: (args: ComponentProps<typeof AlertLabel>) => (
<Stack direction="column" alignItems="flex-start" gap={1} role="list">
<AlertLabel role="listitem" icon="tag-alt" labelKey="setColor" value="#268bd2" color="#268bd2" />
<AlertLabel role="listitem" icon="tag-alt" labelKey="setColor" value="#2aa198" color="#2aa198" />
<AlertLabel role="listitem" icon="tag-alt" labelKey="colorBy" value="key" colorBy="key" />
<AlertLabel role="listitem" icon="tag-alt" labelKey="colorBy" value="value" colorBy="value" />
<AlertLabel role="listitem" icon="tag-alt" labelKey="colorBy" value="both" colorBy="both" />
</Stack>
),
};
@@ -0,0 +1,33 @@
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { AlertLabel } from './AlertLabel';
describe('Label', () => {
it('renders label and value with correct aria-label', () => {
render(<AlertLabel labelKey="foo" value="bar" />);
const item = screen.getByTestId('label-value');
expect(item).toBeInTheDocument();
expect(item).toHaveAttribute('aria-label', 'foo: bar');
expect(screen.getByText('foo')).toBeInTheDocument();
expect(screen.getByText('bar')).toBeInTheDocument();
});
it('calls onClick when clicked', async () => {
const onClick = jest.fn();
render(<AlertLabel labelKey="env" value="prod" onClick={onClick} />);
await userEvent.click(screen.getByRole('button'));
expect(onClick).toHaveBeenCalledWith(['prod', 'env']);
});
it('calls onClick when Enter is pressed', async () => {
const onClick = jest.fn();
render(<AlertLabel labelKey="region" value="eu-west-1" onClick={onClick} />);
const button = screen.getByRole('button');
await userEvent.type(button, '{enter}');
expect(onClick).toHaveBeenCalledWith(['eu-west-1', 'region']);
});
});
@@ -0,0 +1,226 @@
import { css, cx } from '@emotion/css';
import { CSSProperties, HTMLAttributes, useMemo } from 'react';
import tinycolor2 from 'tinycolor2';
import { MergeExclusive } from 'type-fest';
import { GrafanaTheme2, IconName } from '@grafana/data';
import { Icon, Stack, getTagColorsFromName, useStyles2 } from '@grafana/ui';
export type LabelSize = 'md' | 'sm' | 'xs';
interface BaseProps extends Omit<HTMLAttributes<HTMLDivElement>, 'onClick' | 'className'> {
icon?: IconName;
labelKey?: string;
value?: string;
size?: LabelSize;
onClick?: ([value, key]: [string | undefined, string | undefined]) => void;
}
type Props = BaseProps & MergeExclusive<{ color?: string }, { colorBy?: 'key' | 'value' | 'both' }>;
const AlertLabel = (props: Props) => {
const { labelKey, value, icon, color, colorBy, size = 'md', onClick, ...rest } = props;
const theColor = getColorFromProps({ color, colorBy, labelKey, value });
const styles = useStyles2(getStyles, theColor, size);
const ariaLabel = `${labelKey}: ${value}`;
const keyless = !Boolean(labelKey);
const innerLabel = useMemo(
() => (
<Stack direction="row" gap={0} alignItems="stretch">
{labelKey && (
<div className={styles.label}>
<Stack direction="row" gap={0.5} alignItems="center">
{icon && <Icon name={icon} />}
{labelKey && (
<span className={styles.labelText} title={labelKey.toString()}>
{labelKey ?? ''}
</span>
)}
</Stack>
</div>
)}
<div className={cx(styles.value, keyless && styles.valueWithoutKey)} title={value?.toString()}>
{value ?? '-'}
</div>
</Stack>
),
[labelKey, styles.label, styles.labelText, styles.value, styles.valueWithoutKey, icon, keyless, value]
);
return (
<div className={styles.wrapper} aria-label={ariaLabel} data-testid="label-value" {...rest}>
{onClick && (labelKey || value) ? (
<button
type="button"
className={styles.clickable}
key={`${labelKey ?? ''}${value ?? ''}`}
onClick={() => onClick?.([value ?? '', labelKey ?? ''])}
>
{innerLabel}
</button>
) : (
innerLabel
)}
</div>
);
};
function getAccessibleTagColor(name?: string): string | undefined {
if (!name) {
return;
}
const attempts = Array.from({ length: 6 }, (_, i) => name + '-'.repeat(i));
const readableAttempt = attempts.find((attempt) => {
const candidate = getTagColorsFromName(attempt).color;
return (
tinycolor2.isReadable(candidate, '#000', { level: 'AA', size: 'small' }) ||
tinycolor2.isReadable(candidate, '#fff', { level: 'AA', size: 'small' })
);
});
const chosen = readableAttempt ?? name;
return getTagColorsFromName(chosen).color;
}
function getColorFromProps({
color,
colorBy,
labelKey,
value,
}: Pick<Props, 'color' | 'colorBy' | 'labelKey' | 'value'>) {
if (color) {
return getAccessibleTagColor(color);
}
if (colorBy === 'key') {
return getAccessibleTagColor(labelKey);
}
if (colorBy === 'value') {
return getAccessibleTagColor(value);
}
if (colorBy === 'both' && labelKey && value) {
return getAccessibleTagColor(labelKey + value);
}
return;
}
function getReadableFontColor(bg: string, fallback: string): string {
// First: explicitly check black
if (tinycolor2.isReadable(bg, '#000', { level: 'AA', size: 'small' })) {
return '#000';
}
// Then: explicitly check white
if (tinycolor2.isReadable(bg, '#fff', { level: 'AA', size: 'small' })) {
return '#fff';
}
// Then: try fallback if it’s readable
if (tinycolor2.isReadable(bg, fallback, { level: 'AA', size: 'small' })) {
return tinycolor2(fallback).toHexString();
}
// Last resort: pick the "most readable", even if not AA-compliant
return tinycolor2
.mostReadable(bg, ['#000', '#fff', fallback], {
includeFallbackColors: true,
})
.toHexString();
}
const getStyles = (theme: GrafanaTheme2, color?: string, size?: string) => {
const backgroundColor = color ?? theme.colors.secondary.main;
const borderColor = theme.isDark
? tinycolor2(backgroundColor).lighten(5).toString()
: tinycolor2(backgroundColor).darken(5).toString();
const valueBackgroundColor = theme.isDark
? tinycolor2(backgroundColor).darken(5).toString()
: tinycolor2(backgroundColor).lighten(5).toString();
const labelFontColor = color
? getReadableFontColor(backgroundColor, theme.colors.text.primary)
: theme.colors.text.primary;
const valueFontColor = color
? getReadableFontColor(valueBackgroundColor, theme.colors.text.primary)
: theme.colors.text.primary;
let padding: CSSProperties['padding'] = theme.spacing(0.33, 1);
switch (size) {
case 'sm':
padding = theme.spacing(0.2, 0.6);
break;
case 'xs':
padding = theme.spacing(0, 0.5);
break;
default:
break;
}
return {
wrapper: css({
fontSize: theme.typography.bodySmall.fontSize,
borderRadius: theme.shape.borderRadius(2),
}),
labelText: css({
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis',
maxWidth: '300px',
}),
label: css({
display: 'flex',
alignItems: 'center',
color: labelFontColor,
padding: padding,
background: backgroundColor,
border: `solid 1px ${borderColor}`,
borderTopLeftRadius: theme.shape.borderRadius(2),
borderBottomLeftRadius: theme.shape.borderRadius(2),
}),
clickable: css({
border: 'none',
background: 'none',
outline: 'none',
boxShadow: 'none',
padding: 0,
margin: 0,
'&:hover': {
opacity: 0.8,
cursor: 'pointer',
},
}),
value: css({
color: valueFontColor,
padding: padding,
background: valueBackgroundColor,
border: `solid 1px ${borderColor}`,
borderLeft: 'none',
borderTopRightRadius: theme.shape.borderRadius(2),
borderBottomRightRadius: theme.shape.borderRadius(2),
whiteSpace: 'pre',
overflow: 'hidden',
textOverflow: 'ellipsis',
maxWidth: '300px',
}),
valueWithoutKey: css({
borderTopLeftRadius: theme.shape.borderRadius(2),
borderBottomLeftRadius: theme.shape.borderRadius(2),
borderLeft: `solid 1px ${borderColor}`,
}),
};
};
export { AlertLabel };
export type AlertLabelProps = Props;
@@ -0,0 +1,48 @@
import type { Meta, StoryObj } from '@storybook/react';
import { ComponentProps } from 'react';
import { Stack } from '@grafana/ui';
import { AlertLabels } from './AlertLabels';
const meta: Meta<typeof AlertLabels> = {
component: AlertLabels,
title: 'Rules/AlertLabels',
};
export default meta;
export const Basic: StoryObj<typeof AlertLabels> = {
render: (args: ComponentProps<typeof AlertLabels>) => (
<Stack direction="column" alignItems="flex-start">
<AlertLabels
{...args}
labels={{ alertname: 'HighErrorRate', instance: 'web-01', job: 'nginx', zone: 'us-east-1' }}
/>
<AlertLabels
{...args}
size="sm"
labels={{ alertname: 'HighCPU', instance: 'db-02', job: 'postgres', zone: 'us-west-2' }}
/>
<AlertLabels
{...args}
size="xs"
labels={{ alertname: 'DiskFull', instance: 'cache-3', job: 'redis', zone: 'eu-central-1' }}
/>
</Stack>
),
};
export const WithCommonLabels: StoryObj<typeof AlertLabels> = {
render: (args: ComponentProps<typeof AlertLabels>) => (
<AlertLabels
{...args}
labels={{ foo: 'bar', bar: 'baz', baz: 'qux' }}
displayCommonLabels
labelSets={[
{ foo: 'bar', bar: 'baz', baz: 'qux' },
{ foo: 'bar', baz: 'qux', quux: 'corge' },
]}
/>
),
};
@@ -6,9 +6,9 @@ import { AlertLabels } from './AlertLabels';
describe('AlertLabels', () => {
it('should toggle show / hide common labels', async () => {
const labels = { foo: 'bar', bar: 'baz', baz: 'qux' };
const commonLabels = { foo: 'bar', baz: 'qux' };
const another = { foo: 'bar', baz: 'qux', extra: 'z' };
render(<AlertLabels labels={labels} commonLabels={commonLabels} />);
render(<AlertLabels labels={labels} displayCommonLabels labelSets={[labels, another]} />);
expect(screen.getByText('+2 common labels')).toBeInTheDocument();
await userEvent.click(screen.getByRole('button'));
@@ -0,0 +1,97 @@
import { css } from '@emotion/css';
import { chain } from 'lodash';
import { useState } from 'react';
import { GrafanaTheme2 } from '@grafana/data';
import { Trans, t } from '@grafana/i18n';
import { Button, useStyles2 } from '@grafana/ui';
import { findCommonLabels, isPrivateLabel } from '../../utils/labels';
import { AlertLabel, LabelSize } from './AlertLabel';
export interface AlertLabelsProps {
labels: Record<string, string>;
displayCommonLabels?: boolean;
labelSets?: Array<Record<string, string>>;
size?: LabelSize;
onClick?: ([value, key]: [string | undefined, string | undefined]) => void;
}
export const AlertLabels = ({ labels, displayCommonLabels, labelSets, size, onClick }: AlertLabelsProps) => {
const styles = useStyles2(getStyles, size);
const [showCommonLabels, setShowCommonLabels] = useState(false);
const computedCommonLabels =
displayCommonLabels && Array.isArray(labelSets) && labelSets.length > 1 ? findCommonLabels(labelSets) : {};
const labelsToShow = chain(labels)
.toPairs()
.reject(isPrivateLabel)
.reject(([key]) => (showCommonLabels ? false : key in computedCommonLabels))
.value();
const commonLabelsCount = Object.keys(computedCommonLabels).length;
const hasCommonLabels = commonLabelsCount > 0;
const tooltip = t('alert-labels.button.show.tooltip', 'Show common labels');
return (
<div className={styles.wrapper} role="list" aria-label={t('alerting.alert-labels.aria-label-labels', 'Labels')}>
{labelsToShow.map(([label, value]) => {
return (
<AlertLabel
key={label + value}
size={size}
labelKey={label}
value={value}
colorBy="key"
onClick={onClick}
role="listitem"
/>
);
})}
{!showCommonLabels && hasCommonLabels && (
<div role="listitem">
<Button
variant="secondary"
fill="text"
onClick={() => setShowCommonLabels(true)}
tooltip={tooltip}
tooltipPlacement="top"
size="sm"
>
<Trans i18nKey="alerting.alert-labels.common-labels-count" count={commonLabelsCount}>
+{'{{count}}'} common labels
</Trans>
</Button>
</div>
)}
{showCommonLabels && hasCommonLabels && (
<div role="listitem">
<Button
variant="secondary"
fill="text"
onClick={() => setShowCommonLabels(false)}
tooltipPlacement="top"
size="sm"
>
<Trans i18nKey="alert-labels.button.hide">Hide common labels</Trans>
</Button>
</div>
)}
</div>
);
};
const getStyles = (theme: GrafanaTheme2, size?: LabelSize) => {
return {
wrapper: css({
display: 'flex',
flexWrap: 'wrap',
alignItems: 'center',
gap: size === 'md' ? theme.spacing() : theme.spacing(0.5),
}),
};
};
@@ -0,0 +1,37 @@
export const GRAFANA_ORIGIN_LABEL = '__grafana_origin';
export function isPrivateLabelKey(labelKey: string) {
return (labelKey.startsWith('__') && labelKey.endsWith('__')) || labelKey === GRAFANA_ORIGIN_LABEL;
}
export const isPrivateLabel = ([key, _]: [string, string]) => isPrivateLabelKey(key);
/**
* Returns a map labels that are common to the given label sets.
*/
export function findCommonLabels(labelSets: Array<Record<string, string>>): Record<string, string> {
if (!Array.isArray(labelSets) || labelSets.length === 0) {
return {};
}
return labelSets.reduce(
(acc, labels) => {
if (!labels) {
throw new Error('Need parsed labels to find common labels.');
}
// Remove incoming labels that are missing or not matching in value
Object.keys(labels).forEach((key) => {
if (acc[key] === undefined || acc[key] !== labels[key]) {
delete acc[key];
}
});
// Remove common labels that are missing from incoming label set
Object.keys(acc).forEach((key) => {
if (labels[key] === undefined) {
delete acc[key];
}
});
return acc;
},
{ ...labelSets[0] }
);
}
+4
View File
@@ -7,5 +7,9 @@
* Breaking changes should be avoided to maintain backwards compatibility for consumers of this package.
*/
export { AlertLabels } from './grafana/rules/components/labels/AlertLabels';
export { AlertLabel } from './grafana/rules/components/labels/AlertLabel';
// keep label utils internal to the app for now
// This is a dummy export so typescript doesn't error importing an "empty module"
export const index = {};
@@ -1,5 +1,18 @@
{
"alert-labels": {
"button": {
"hide": "Hide common labels",
"show": {
"tooltip": "Show common labels"
}
}
},
"alerting": {
"alert-labels": {
"aria-label-labels": "Labels",
"common-labels-count_one": "+{{count}} common labels",
"common-labels-count_other": "+{{count}} common labels"
},
"paused-badge": {
"paused": "Paused"
}
@@ -28,6 +28,8 @@ export * from './grafana/notificationPolicies/types';
// Rules
export { StateText } from './grafana/rules/components/state/StateText';
export { StateIcon } from './grafana/rules/components/state/StateIcon';
export { AlertLabel, type AlertLabelProps } from './grafana/rules/components/labels/AlertLabel';
export { AlertLabels, type AlertLabelsProps } from './grafana/rules/components/labels/AlertLabels';
// Matchers
export { type LabelMatcher, type Label } from './grafana/matchers/types';
@@ -3,12 +3,12 @@ import { useMemo } from 'react';
import { Navigate } from 'react-router-dom-v5-compat';
import { useLocation } from 'react-use';
import { AlertLabels } from '@grafana/alerting/unstable';
import { GrafanaTheme2 } from '@grafana/data';
import { Trans, t } from '@grafana/i18n';
import { config, isFetchError } from '@grafana/runtime';
import { Alert, Card, Icon, LoadingPlaceholder, useStyles2 } from '@grafana/ui';
import { AlertLabels } from './components/AlertLabels';
import { RuleViewerLayout } from './components/rule-viewer/RuleViewerLayout';
import { useCloudCombinedRulesMatching } from './hooks/useCombinedRule';
import { getRulesSourceByName } from './utils/datasource';
@@ -1,92 +0,0 @@
import { css } from '@emotion/css';
import { chain } from 'lodash';
import { useState } from 'react';
import { GrafanaTheme2 } from '@grafana/data';
import { Trans, t } from '@grafana/i18n';
import { Button, getTagColorsFromName, useStyles2 } from '@grafana/ui';
import { isPrivateLabel } from '../utils/labels';
import { Label, LabelSize } from './Label';
interface Props {
labels: Record<string, string>;
commonLabels?: Record<string, string>;
size?: LabelSize;
onClick?: (label: string, value: string) => void;
}
export const AlertLabels = ({ labels, commonLabels = {}, size, onClick }: Props) => {
const styles = useStyles2(getStyles, size);
const [showCommonLabels, setShowCommonLabels] = useState(false);
const labelsToShow = chain(labels)
.toPairs()
.reject(isPrivateLabel)
.reject(([key]) => (showCommonLabels ? false : key in commonLabels))
.value();
const commonLabelsCount = Object.keys(commonLabels).length;
const hasCommonLabels = commonLabelsCount > 0;
const tooltip = t('alert-labels.button.show.tooltip', 'Show common labels');
return (
<div className={styles.wrapper} role="list" aria-label={t('alerting.alert-labels.aria-label-labels', 'Labels')}>
{labelsToShow.map(([label, value]) => {
return (
<Label
key={label + value}
size={size}
label={label}
value={value}
color={getLabelColor(label)}
onClick={onClick}
/>
);
})}
{!showCommonLabels && hasCommonLabels && (
<Button
variant="secondary"
fill="text"
onClick={() => setShowCommonLabels(true)}
tooltip={tooltip}
tooltipPlacement="top"
size="sm"
>
<Trans i18nKey="alerting.alert-labels.common-labels-count" count={commonLabelsCount}>
+{'{{count}}'} common labels
</Trans>
</Button>
)}
{showCommonLabels && hasCommonLabels && (
<Button
variant="secondary"
fill="text"
onClick={() => setShowCommonLabels(false)}
tooltipPlacement="top"
size="sm"
>
<Trans i18nKey="alert-labels.button.hide">Hide common labels</Trans>
</Button>
)}
</div>
);
};
function getLabelColor(input: string): string {
return getTagColorsFromName(input).color;
}
const getStyles = (theme: GrafanaTheme2, size?: LabelSize) => {
return {
wrapper: css({
display: 'flex',
flexWrap: 'wrap',
alignItems: 'center',
gap: size === 'md' ? theme.spacing() : theme.spacing(0.5),
}),
};
};
@@ -1,148 +0,0 @@
import { css } from '@emotion/css';
import { CSSProperties, ReactNode, useMemo } from 'react';
import tinycolor2 from 'tinycolor2';
import { GrafanaTheme2, IconName } from '@grafana/data';
import { Icon, Stack, useStyles2 } from '@grafana/ui';
export type LabelSize = 'md' | 'sm' | 'xs';
interface Props {
icon?: IconName;
label?: ReactNode;
value: ReactNode;
color?: string;
size?: LabelSize;
onClick?: (label: string, value: string) => void;
}
// TODO allow customization with color prop
const Label = ({ label, value, icon, color, size = 'md', onClick }: Props) => {
const styles = useStyles2(getStyles, color, size);
const ariaLabel = `${label}: ${value}`;
const labelStr = label?.toString() ?? '';
const valueStr = value?.toString() ?? '';
const innerLabel = useMemo(
() => (
<Stack direction="row" gap={0} alignItems="stretch">
<div className={styles.label}>
<Stack direction="row" gap={0.5} alignItems="center">
{icon && <Icon name={icon} />}
{label && (
<span className={styles.labelText} title={label.toString()}>
{label ?? ''}
</span>
)}
</Stack>
</div>
<div className={styles.value} title={value?.toString()}>
{value ?? '-'}
</div>
</Stack>
),
[icon, label, value, styles]
);
return (
<div className={styles.wrapper} role="listitem" aria-label={ariaLabel} data-testid="label-value">
{onClick ? (
<div
className={styles.clickable}
role="button" // role="button" and tabIndex={0} is needed for keyboard navigation
tabIndex={0} // Make it focusable
key={labelStr + valueStr}
onClick={() => onClick(labelStr, valueStr)}
onKeyDown={(e) => {
// needed for accessiblity: handle keyboard navigation
if (e.key === 'Enter') {
onClick(labelStr, valueStr);
e.preventDefault();
}
}}
>
{innerLabel}
</div>
) : (
innerLabel
)}
</div>
);
};
const getStyles = (theme: GrafanaTheme2, color?: string, size?: string) => {
const backgroundColor = color ?? theme.colors.secondary.main;
const borderColor = theme.isDark
? tinycolor2(backgroundColor).lighten(5).toString()
: tinycolor2(backgroundColor).darken(5).toString();
const valueBackgroundColor = theme.isDark
? tinycolor2(backgroundColor).darken(5).toString()
: tinycolor2(backgroundColor).lighten(5).toString();
const fontColor = color
? tinycolor2.mostReadable(backgroundColor, ['#000', '#fff']).toString()
: theme.colors.text.primary;
let padding: CSSProperties['padding'] = theme.spacing(0.33, 1);
switch (size) {
case 'sm':
padding = theme.spacing(0.2, 0.6);
break;
case 'xs':
padding = theme.spacing(0, 0.5);
break;
default:
break;
}
return {
wrapper: css({
color: fontColor,
fontSize: theme.typography.bodySmall.fontSize,
borderRadius: theme.shape.borderRadius(2),
}),
labelText: css({
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis',
maxWidth: '300px',
}),
label: css({
display: 'flex',
alignItems: 'center',
color: 'inherit',
padding: padding,
background: backgroundColor,
border: `solid 1px ${borderColor}`,
borderTopLeftRadius: theme.shape.borderRadius(2),
borderBottomLeftRadius: theme.shape.borderRadius(2),
}),
clickable: css({
'&:hover': {
opacity: 0.8,
cursor: 'pointer',
},
}),
value: css({
color: 'inherit',
padding: padding,
background: valueBackgroundColor,
border: `solid 1px ${borderColor}`,
borderLeft: 'none',
borderTopRightRadius: theme.shape.borderRadius(2),
borderBottomRightRadius: theme.shape.borderRadius(2),
whiteSpace: 'pre',
overflow: 'hidden',
textOverflow: 'ellipsis',
maxWidth: '300px',
}),
};
};
export { Label };
@@ -1,13 +1,13 @@
import { css } from '@emotion/css';
import { useState } from 'react';
import { AlertLabels } from '@grafana/alerting/unstable';
import { GrafanaTheme2 } from '@grafana/data';
import { Trans } from '@grafana/i18n';
import { Stack, TextLink, useStyles2 } from '@grafana/ui';
import { AlertmanagerGroup } from 'app/plugins/datasource/alertmanager/types';
import { createContactPointSearchLink } from '../../utils/misc';
import { AlertLabels } from '../AlertLabels';
import { CollapseToggle } from '../CollapseToggle';
import { MetaText } from '../MetaText';
@@ -1,12 +1,12 @@
import { css } from '@emotion/css';
import { useMemo } from 'react';
import { AlertLabels } from '@grafana/alerting/unstable';
import { GrafanaTheme2, intervalToAbbreviatedDurationString } from '@grafana/data';
import { Trans, t } from '@grafana/i18n';
import { useStyles2 } from '@grafana/ui';
import { AlertmanagerAlert } from 'app/plugins/datasource/alertmanager/types';
import { AlertLabels } from '../AlertLabels';
import { DynamicTableColumnProps, DynamicTableItemProps } from '../DynamicTable';
import { DynamicTableWithGuidelines } from '../DynamicTableWithGuidelines';
import { AmAlertStateTag } from '../silences/AmAlertStateTag';
@@ -5,6 +5,7 @@ import * as React from 'react';
import { FC, Fragment, ReactNode, useState } from 'react';
import { useToggle } from 'react-use';
import { AlertLabel } from '@grafana/alerting/unstable';
import { GrafanaTheme2 } from '@grafana/data';
import { Trans, t } from '@grafana/i18n';
import {
@@ -42,7 +43,6 @@ import { InheritableProperties, getInheritedProperties } from '../../utils/notif
import { InsertPosition } from '../../utils/routeTree';
import { Authorize } from '../Authorize';
import { PopupCard } from '../HoverCard';
import { Label } from '../Label';
import { MetaText } from '../MetaText';
import { ProvisioningBadge } from '../Provisioning';
import { Spacer } from '../Spacer';
@@ -720,7 +720,13 @@ const InheritedProperties: FC<{ properties: InheritableProperties }> = ({ proper
return null;
}
return <Label key={key} label={routePropertyToLabel(key)} value={routePropertyToValue(key, value)} />;
return (
<AlertLabel
key={key}
labelKey={routePropertyToLabel(key)}
value={String(routePropertyToValue(key, value))}
/>
);
})}
</Stack>
}
@@ -917,13 +923,13 @@ function getContactPointErrors(contactPoint: string, contactPointsState: Receive
const notifierErrors = notifierStatuses
.filter((status) => status.lastNotifyAttemptError)
.map((status) => (
<Label
<AlertLabel
icon="at"
key={uniqueId()}
label={t('alerting.contact-point-errors.label-contact-point', 'Contact Point › {{name}}', {
value={String(status.lastNotifyAttemptError)}
labelKey={t('alerting.contact-point-errors.label-contact-point', 'Contact Point › {{name}}', {
name: status.name,
})}
value={status.lastNotifyAttemptError}
/>
));
@@ -2,6 +2,7 @@ import { css, cx } from '@emotion/css';
import { FC, useCallback, useMemo, useState } from 'react';
import { Controller, FormProvider, useFieldArray, useForm, useFormContext } from 'react-hook-form';
import { AlertLabels } from '@grafana/alerting/unstable';
import { GrafanaTheme2, SelectableValue } from '@grafana/data';
import { Trans, t } from '@grafana/i18n';
import { Button, ComboboxOption, Field, InlineLabel, Input, Space, Stack, Text, useStyles2 } from '@grafana/ui';
@@ -13,7 +14,6 @@ import { KBObjectArray, RuleFormType, RuleFormValues } from '../../../types/rule
import { isPrivateLabelKey } from '../../../utils/labels';
import { isRecordingRuleByType } from '../../../utils/rules';
import AlertLabelDropdown from '../../AlertLabelDropdown';
import { AlertLabels } from '../../AlertLabels';
import { NeedHelpInfo } from '../NeedHelpInfo';
import { useGetLabelsFromDataSourceName } from '../useAlertRuleSuggestions';
@@ -3,7 +3,7 @@ import { chain, truncate } from 'lodash';
import { useEffect, useState } from 'react';
import { useMeasure } from 'react-use';
import { StateText } from '@grafana/alerting/unstable';
import { AlertLabels, StateText } from '@grafana/alerting/unstable';
import { NavModelItem, UrlQueryValue } from '@grafana/data';
import { Trans, t } from '@grafana/i18n';
import {
@@ -56,7 +56,6 @@ import {
prometheusRuleType,
rulerRuleType,
} from '../../utils/rules';
import { AlertLabels } from '../AlertLabels';
import { AlertingPageWrapper } from '../AlertingPageWrapper';
import { ProvisionedResource, ProvisioningAlert } from '../Provisioning';
import { WithReturnButton } from '../WithReturnButton';
@@ -1,11 +1,12 @@
import * as React from 'react';
import { useMemo } from 'react';
import { PluginExtensionPoints, dateTime, findCommonLabels } from '@grafana/data';
import { AlertLabels } from '@grafana/alerting/unstable';
import { PluginExtensionPoints, dateTime } from '@grafana/data';
import { t } from '@grafana/i18n';
import { Alert, CombinedRule, PaginationProps } from 'app/types/unified-alerting';
import { alertInstanceKey } from '../../utils/rules';
import { AlertLabels } from '../AlertLabels';
import { DynamicTable, DynamicTableColumnProps, DynamicTableItemProps } from '../DynamicTable';
import { AlertInstanceExtensionPoint } from '../extensions/AlertInstanceExtensionPoint';
@@ -19,34 +20,72 @@ interface Props {
footerRow?: React.ReactNode;
}
interface AlertWithCommonLabels extends Alert {
commonLabels?: Record<string, string>;
}
interface RuleAndAlert {
rule?: CombinedRule;
alert: AlertWithCommonLabels;
alert: Alert;
}
type AlertTableColumnProps = DynamicTableColumnProps<RuleAndAlert>;
type AlertTableItemProps = DynamicTableItemProps<RuleAndAlert>;
export const AlertInstancesTable = ({ rule, instances, pagination, footerRow }: Props) => {
const commonLabels = useMemo(() => {
// only compute the common labels if we have more than 1 instance, if we don't then that single instance
// will have the complete set of common labels and no unique ones
return instances.length > 1 ? findCommonLabels(instances.map((instance) => instance.labels)) : {};
}, [instances]);
const items = useMemo(
(): AlertTableItemProps[] =>
instances.map((instance) => ({
data: { rule, alert: { ...instance, commonLabels } },
data: { rule, alert: instance },
id: alertInstanceKey(instance),
})),
[commonLabels, instances, rule]
[instances, rule]
);
const columns: AlertTableColumnProps[] = [
{
id: 'state',
label: t('alerting.alert-instances-table.state', 'State'),
// eslint-disable-next-line react/display-name
renderCell: ({
data: {
alert: { state },
},
}) => <AlertStateTag state={state} />,
size: '95px',
},
{
id: 'labels',
label: t('alerting.alert-instances-table.labels', 'Labels'),
// eslint-disable-next-line react/display-name
renderCell: ({
data: {
alert: { labels },
},
}) => <AlertLabels labels={labels} labelSets={instances.map((i) => i.labels)} displayCommonLabels size="sm" />,
},
{
id: 'created',
label: t('alerting.alert-instances-table.created', 'Created'),
// eslint-disable-next-line react/display-name
renderCell: ({
data: {
alert: { activeAt },
},
}) => <>{activeAt.startsWith('0001') ? '-' : dateTime(activeAt).format('YYYY-MM-DD HH:mm:ss')}</>,
size: '150px',
},
{
id: 'actions',
label: '',
renderCell: ({ data: { alert, rule } }) => (
<AlertInstanceExtensionPoint
rule={rule}
instance={alert}
extensionPointId={PluginExtensionPoints.AlertInstanceAction}
key="alert-instance-extension-point"
/>
),
size: '40px',
},
];
return (
<DynamicTable
cols={columns}
@@ -58,51 +97,3 @@ export const AlertInstancesTable = ({ rule, instances, pagination, footerRow }:
/>
);
};
const columns: AlertTableColumnProps[] = [
{
id: 'state',
label: 'State',
// eslint-disable-next-line react/display-name
renderCell: ({
data: {
alert: { state },
},
}) => <AlertStateTag state={state} />,
size: '95px',
},
{
id: 'labels',
label: 'Labels',
// eslint-disable-next-line react/display-name
renderCell: ({
data: {
alert: { labels, commonLabels },
},
}) => <AlertLabels labels={labels} commonLabels={commonLabels} size="sm" />,
},
{
id: 'created',
label: 'Created',
// eslint-disable-next-line react/display-name
renderCell: ({
data: {
alert: { activeAt },
},
}) => <>{activeAt.startsWith('0001') ? '-' : dateTime(activeAt).format('YYYY-MM-DD HH:mm:ss')}</>,
size: '150px',
},
{
id: 'actions',
label: '',
renderCell: ({ data: { alert, rule } }) => (
<AlertInstanceExtensionPoint
rule={rule}
instance={alert}
extensionPointId={PluginExtensionPoints.AlertInstanceAction}
key="alert-instance-extension-point"
/>
),
size: '40px',
},
];
@@ -1,5 +1,6 @@
import { css } from '@emotion/css';
import { AlertLabels } from '@grafana/alerting/unstable';
import { GrafanaTheme2, dateTime, dateTimeFormat } from '@grafana/data';
import { Trans, t } from '@grafana/i18n';
import { Tooltip, useStyles2 } from '@grafana/ui';
@@ -10,7 +11,6 @@ import { usePendingPeriod } from '../../hooks/rules/usePendingPeriod';
import { useCleanAnnotations } from '../../utils/annotations';
import { prometheusRuleType, rulerRuleType } from '../../utils/rules';
import { isNullDate } from '../../utils/time';
import { AlertLabels } from '../AlertLabels';
import { DetailsField } from '../DetailsField';
import { RuleDetailsAnnotations } from './RuleDetailsAnnotations';
@@ -3,6 +3,7 @@ import { ReactElement, useState } from 'react';
import { useLocation } from 'react-router-dom-v5-compat';
import { useMeasure } from 'react-use';
import { AlertLabels } from '@grafana/alerting/unstable';
import { GrafanaTheme2, IconName, TimeRange } from '@grafana/data';
import { Trans, t } from '@grafana/i18n';
import {
@@ -31,7 +32,6 @@ import { usePagination } from '../../../hooks/usePagination';
import { combineMatcherStrings } from '../../../utils/alertmanager';
import { GRAFANA_RULES_SOURCE_NAME } from '../../../utils/datasource';
import { createRelativeUrl } from '../../../utils/url';
import { AlertLabels } from '../../AlertLabels';
import { CollapseToggle } from '../../CollapseToggle';
import { LogRecord } from '../state-history/common';
@@ -210,8 +210,10 @@ interface EventRowProps {
function EventRow({ record, addFilter, timeRange, hideAlertRuleColumn }: EventRowProps) {
const styles = useStyles2(getStyles);
const [isCollapsed, setIsCollapsed] = useState(true);
function onLabelClick(label: string, value: string) {
addFilter(label, value, 'label');
function onLabelClick([value, label]: [string | undefined, string | undefined]) {
if (label && value) {
addFilter(label, value, 'label');
}
}
return (
@@ -3,12 +3,12 @@ import { formatDistanceToNowStrict } from 'date-fns';
import { groupBy, uniqueId } from 'lodash';
import { Fragment, memo, useEffect, useRef } from 'react';
import { AlertLabel } from '@grafana/alerting/unstable';
import { GrafanaTheme2, dateTimeFormat } from '@grafana/data';
import { Trans, t } from '@grafana/i18n';
import { Icon, Stack, TagList, useStyles2 } from '@grafana/ui';
import { GrafanaAlertState, mapStateWithReasonToBaseState } from 'app/types/unified-alerting-dto';
import { Label } from '../../Label';
import { AlertStateTag } from '../AlertStateTag';
import { ErrorMessageRow } from './ErrorMessageRow';
@@ -182,7 +182,7 @@ const AlertInstanceValues = memo(({ record }: { record: Record<string, number> }
return (
<>
{values.map(([key, value]) => (
<Label key={key} label={key} value={value} />
<AlertLabel key={key} labelKey={key} value={String(value)} />
))}
</>
);
@@ -4,13 +4,13 @@ import * as React from 'react';
import { useCallback, useMemo, useRef, useState } from 'react';
import { useForm } from 'react-hook-form';
import { AlertLabels } from '@grafana/alerting/unstable';
import { DataFrame, GrafanaTheme2, TimeRange, dateTime } from '@grafana/data';
import { Trans, t } from '@grafana/i18n';
import { Alert, Button, Field, Icon, Input, Label, Stack, Text, Tooltip, useStyles2 } from '@grafana/ui';
import { stateHistoryApi } from '../../../api/stateHistoryApi';
import { combineMatcherStrings } from '../../../utils/alertmanager';
import { AlertLabels } from '../../AlertLabels';
import { PopupCard } from '../../HoverCard';
import { LogRecordViewerByTimestamp } from './LogRecordViewer';
@@ -1,10 +1,10 @@
import { useState } from 'react';
import { AlertLabels } from '@grafana/alerting/unstable';
import { intervalToAbbreviatedDurationString } from '@grafana/data';
import { Trans } from '@grafana/i18n';
import { AlertmanagerAlert } from 'app/plugins/datasource/alertmanager/types';
import { AlertLabels } from '../AlertLabels';
import { CollapseToggle } from '../CollapseToggle';
import { AmAlertStateTag } from './AmAlertStateTag';
@@ -2,6 +2,7 @@ import { css } from '@emotion/css';
import { useState } from 'react';
import { useDebounce, useDeepCompareEffect } from 'react-use';
import { AlertLabels } from '@grafana/alerting/unstable';
import { GrafanaTheme2, dateTime } from '@grafana/data';
import { Trans, t } from '@grafana/i18n';
import { Alert, Badge, Icon, LoadingPlaceholder, Tooltip, useStyles2 } from '@grafana/ui';
@@ -12,7 +13,6 @@ import { AlertmanagerAlert, Matcher, MatcherOperator } from 'app/plugins/datasou
import { alertmanagerApi } from '../../api/alertmanagerApi';
import { isNullDate } from '../../utils/time';
import { AlertLabels } from '../AlertLabels';
import { DynamicTable, DynamicTableColumnProps, DynamicTableItemProps } from '../DynamicTable';
import { AmAlertStateTag } from './AmAlertStateTag';
@@ -2,7 +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 { AlertLabels, 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';
@@ -10,7 +10,6 @@ import { Rule, RuleGroupIdentifierV2, RuleHealth, RulesSourceIdentifier } from '
import { Labels, PromAlertingRuleState, RulerRuleDTO, RulesSourceApplication } from 'app/types/unified-alerting-dto';
import { logError } from '../../Analytics';
import { AlertLabels } from '../../components/AlertLabels';
import ConditionalWrap from '../../components/ConditionalWrap';
import { MetaText } from '../../components/MetaText';
import { ProvisioningBadge } from '../../components/Provisioning';
+5 -13
View File
@@ -364,14 +364,6 @@
"tooltip-assigned-role": "A licensed role will be assigned when this user signs in"
}
},
"alert-labels": {
"button": {
"hide": "Hide common labels",
"show": {
"tooltip": "Show common labels"
}
}
},
"alerting": {
"active-timing-fields": {
"am-active-timing-select-label-active-timings": "Active timings"
@@ -480,17 +472,17 @@
"alert-instance-state-filter": {
"state": "State"
},
"alert-instances-table": {
"created": "Created",
"labels": "Labels",
"state": "State"
},
"alert-label": {
"tooltip-remove-label": "Remove label"
},
"alert-label-dropdown": {
"placeholder-select": "Choose {{type}}"
},
"alert-labels": {
"aria-label-labels": "Labels",
"common-labels-count_one": "+{{count}} common labels",
"common-labels-count_other": "+{{count}} common labels"
},
"alert-manager-manual-routing": {
"label-muting-grouping-and-timings-optional": "Muting, grouping and timings (optional)",
"title-muting-grouping-and-timings": "Muting, grouping, and timings"