Alerting: Update design of rule details tab and add updated by (#99895)

This commit is contained in:
Tom Ratcliffe
2025-02-04 16:56:17 +02:00
committed by GitHub
parent 00bcb61382
commit bb15f24dcd
10 changed files with 295 additions and 140 deletions
+1 -20
View File
@@ -1637,10 +1637,6 @@ exports[`better eslint`] = {
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "4"],
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "5"]
],
"public/app/features/alerting/unified/components/InfoPausedRule.tsx:5381": [
[0, 0, 0, "No untranslated strings in text props. Wrap text with <Trans /> or use t()", "0"],
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "1"]
],
"public/app/features/alerting/unified/components/InvalidIntervalWarning.tsx:5381": [
[0, 0, 0, "No untranslated strings in text props. Wrap text with <Trans /> or use t()", "0"],
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "1"],
@@ -2238,9 +2234,7 @@ exports[`better eslint`] = {
[0, 0, 0, "No untranslated strings in text props. Wrap text with <Trans /> or use t()", "8"],
[0, 0, 0, "No untranslated strings in text props. Wrap text with <Trans /> or use t()", "9"],
[0, 0, 0, "No untranslated strings in text props. Wrap text with <Trans /> or use t()", "10"],
[0, 0, 0, "No untranslated strings in text props. Wrap text with <Trans /> or use t()", "11"],
[0, 0, 0, "No untranslated strings in text props. Wrap text with <Trans /> or use t()", "12"],
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "13"]
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "11"]
],
"public/app/features/alerting/unified/components/rule-editor/GrafanaFolderAndLabelsStep.tsx:5381": [
[0, 0, 0, "No untranslated strings in text props. Wrap text with <Trans /> or use t()", "0"]
@@ -2486,19 +2480,6 @@ exports[`better eslint`] = {
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "3"],
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "4"]
],
"public/app/features/alerting/unified/components/rule-viewer/tabs/Details.tsx:5381": [
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "0"],
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "1"],
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "2"],
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "3"],
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "4"],
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "5"],
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "6"],
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "7"],
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "8"],
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "9"],
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "10"]
],
"public/app/features/alerting/unified/components/rule-viewer/tabs/Query.tsx:5381": [
[0, 0, 0, "No untranslated strings in text props. Wrap text with <Trans /> or use t()", "0"],
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "1"]
@@ -4,7 +4,7 @@ import * as React from 'react';
import { GrafanaTheme2 } from '@grafana/data';
import { Trans } from '../../../src/utils/i18n';
import { t } from '../../../src/utils/i18n';
import { useStyles2 } from '../../themes';
import { Button, ButtonProps } from '../Button';
import { Icon } from '../Icon/Icon';
@@ -60,11 +60,12 @@ export function ClipboardButton({
}
}, [getText, onClipboardCopy, onClipboardError]);
const copiedText = t('clipboard-button.inline-toast.success', 'Copied');
return (
<>
{showCopySuccess && (
<InlineToast placement="top" referenceElement={buttonRef.current}>
<Trans i18nKey="clipboard-button.inline-toast.success">Copied</Trans>
{copiedText}
</InlineToast>
)}
@@ -72,7 +73,7 @@ export function ClipboardButton({
onClick={copyTextCallback}
icon={icon}
variant={showCopySuccess ? 'success' : variant}
aria-label={showCopySuccess ? 'Copied' : undefined}
aria-label={showCopySuccess ? copiedText : undefined}
{...buttonProps}
className={cx(styles.button, showCopySuccess && styles.successButton, buttonProps.className)}
ref={buttonRef}
@@ -1,9 +1,12 @@
import { Alert } from '@grafana/ui';
import { Trans, t } from 'app/core/internationalization';
const InfoPausedRule = () => {
return (
<Alert severity="info" title="Alert evaluation currently paused">
Notifications for this rule will not fire and no alert instances will be created until the rule is un-paused.
<Alert severity="info" title={t('alerting.alert.evaluation-paused', 'Alert evaluation currently paused')}>
<Trans i18nKey="alerting.alert.evaluation-paused-description">
Notifications for this rule will not fire and no alert instances will be created until the rule is un-paused.
</Trans>
</Alert>
);
};
@@ -0,0 +1,66 @@
import { Box, ClipboardButton, Stack, Text, Tooltip } from '@grafana/ui';
import { t } from 'app/core/internationalization';
import ConditionalWrap from '../ConditionalWrap';
type DetailTextProps = {
id: string;
label: string;
value: string | JSX.Element | null;
/** Should the value be displayed using monospace font family? */
monospace?: boolean;
/** Optional string to display in a tooltip on hover of the value */
tooltipValue?: string;
} & ConditionalProps;
type ConditionalProps =
// Require either both copy props or neither
| {
/** Should we show a button for copying the value to clipboard? */
showCopyButton: boolean;
/**
* Value to use for copying to clipboard, when enabled.
* Needed as the value could be an element
*/
copyValue: string;
}
| { showCopyButton?: never; copyValue?: never };
export const DetailText = ({
id,
label,
value,
monospace,
showCopyButton,
copyValue,
tooltipValue,
}: DetailTextProps) => {
const copyToClipboardLabel = t('alerting.copy-to-clipboard', 'Copy "{{label}}" to clipboard', { label });
return (
<Box>
<Stack direction="column" gap={0}>
<Text color="secondary" id={id}>
{label}
</Text>
<Text aria-labelledby={id} color="primary" variant={monospace ? 'code' : 'body'}>
<ConditionalWrap
shouldWrap={Boolean(tooltipValue)}
wrap={(children) => <Tooltip content={tooltipValue!}>{children}</Tooltip>}
>
<span>{value}</span>
</ConditionalWrap>
{showCopyButton && (
<ClipboardButton
aria-label={copyToClipboardLabel}
fill="text"
variant="secondary"
icon="copy"
size="sm"
getText={() => copyValue}
/>
)}
</Text>
</Stack>
</Box>
);
};
@@ -363,7 +363,10 @@ export function GrafanaEvaluationBehaviorStep({
{showErrorHandling && (
<>
<NeedHelpInfoForConfigureNoDataError />
<Field htmlFor="no-data-state-input" label="Alert state if no data or all values are null">
<Field
htmlFor="no-data-state-input"
label={t('alerting.alert.state-no-data', 'Alert state if no data or all values are null')}
>
<Controller
render={({ field: { onChange, ref, ...field } }) => (
<GrafanaAlertStatePicker
@@ -378,7 +381,10 @@ export function GrafanaEvaluationBehaviorStep({
name="noDataState"
/>
</Field>
<Field htmlFor="exec-err-state-input" label="Alert state if execution error or timeout">
<Field
htmlFor="exec-err-state-input"
label={t('alerting.alert.state-error-timeout', 'Alert state if execution error or timeout')}
>
<Controller
render={({ field: { onChange, ref, ...field } }) => (
<GrafanaAlertStatePicker
@@ -1,6 +1,6 @@
import { within } from '@testing-library/react';
import { render, screen, userEvent, waitFor } from 'test/test-utils';
import { byRole, byText } from 'testing-library-selector';
import { byLabelText, byRole, byText } from 'testing-library-selector';
import { setPluginLinksHook } from '@grafana/runtime';
import { setupMswServer } from 'app/features/alerting/unified/mockApi';
@@ -40,7 +40,7 @@ const ELEMENTS = {
label: ([key, value]: [string, string]) => byRole('listitem', { name: `${key}: ${value}` }),
},
details: {
pendingPeriod: byText(/Pending period/i),
pendingPeriod: byLabelText(/Pending period/i),
},
actions: {
edit: byRole('link', { name: 'Edit' }),
@@ -107,6 +107,10 @@ const dataSources = {
};
describe('RuleViewer', () => {
beforeEach(() => {
setupDataSources(...Object.values(dataSources));
});
describe('Grafana managed alert rule', () => {
const mockRule = getGrafanaRule(
{
@@ -211,10 +215,6 @@ describe('RuleViewer', () => {
]);
});
beforeEach(() => {
setupDataSources(...Object.values(dataSources));
});
it('should render a data source managed alert rule', () => {
renderRuleViewer(mockRule, mockRuleIdentifier);
@@ -291,7 +291,7 @@ describe('RuleViewer', () => {
// One summary is rendered by the Title component, and the other by the DetailsTab component
expect(ELEMENTS.metadata.summary(mockRule.annotations[Annotation.summary]).getAll()).toHaveLength(2);
expect(within(ELEMENTS.details.pendingPeriod.get()).getByText(/15m/i)).toBeInTheDocument();
expect(ELEMENTS.details.pendingPeriod.get()).toHaveTextContent(/15m/i);
});
});
});
@@ -1,19 +1,22 @@
import { css } from '@emotion/css';
import { formatDistanceToNowStrict } from 'date-fns';
import { useCallback } from 'react';
import { GrafanaTheme2 } from '@grafana/data';
import { ClipboardButton, Stack, Text, TextLink, useStyles2 } from '@grafana/ui';
import { GrafanaTheme2, dateTimeFormat, dateTimeFormatTimeAgo } from '@grafana/data';
import { Icon, Stack, Text, TextLink, useStyles2 } from '@grafana/ui';
import { Trans, t } from 'app/core/internationalization';
import { CombinedRule } from 'app/types/unified-alerting';
import { usePendingPeriod } from '../../../hooks/rules/usePendingPeriod';
import { getAnnotations, isGrafanaRecordingRule, isGrafanaRulerRule, isRecordingRulerRule } from '../../../utils/rules';
import { MetaText } from '../../MetaText';
import {
getAnnotations,
isGrafanaAlertingRule,
isGrafanaRecordingRule,
isGrafanaRulerRule,
isRecordingRulerRule,
} from '../../../utils/rules';
import { isNullDate } from '../../../utils/time';
import { Tokenize } from '../../Tokenize';
interface DetailsProps {
rule: CombinedRule;
}
import { DetailText } from '../../common/DetailText';
enum RuleType {
GrafanaManagedAlertRule = 'Grafana-managed alert rule',
@@ -22,7 +25,22 @@ enum RuleType {
CloudRecordingRule = 'Cloud recording rule',
}
const Details = ({ rule }: DetailsProps) => {
const DetailGroup = ({ title, children }: { title: string; children: React.ReactNode }) => {
return (
<Stack direction="column" gap={1}>
<Text variant="h4">{title}</Text>
<Stack direction="column" gap={2}>
{children}
</Stack>
</Stack>
);
};
interface DetailsProps {
rule: CombinedRule;
}
export const Details = ({ rule }: DetailsProps) => {
const styles = useStyles2(getStyles);
let ruleType: RuleType;
@@ -43,103 +61,136 @@ const Details = ({ rule }: DetailsProps) => {
const evaluationDuration = rule.promRule?.evaluationTime;
const evaluationTimestamp = rule.promRule?.lastEvaluation;
const copyRuleUID = useCallback(() => {
if (isGrafanaRulerRule(rule.rulerRule)) {
return rule.rulerRule.grafana_alert.uid;
} else {
return '';
}
}, [rule.rulerRule]);
const annotations = getAnnotations(rule);
const hasEvaluationDuration = Number.isFinite(evaluationDuration);
return (
<Stack direction="column" gap={3}>
<div className={styles.metadataWrapper}>
{/* type and identifier (optional) */}
<MetaText direction="column">
Rule type
<Text color="primary">{ruleType}</Text>
</MetaText>
<MetaText direction="column">
{isGrafanaRulerRule(rule.rulerRule) && (
<>
Rule Identifier
<Stack direction="row" alignItems="center" gap={0.5}>
<Text color="primary">
{rule.rulerRule.grafana_alert.uid}
<ClipboardButton fill="text" variant="secondary" icon="copy" size="sm" getText={copyRuleUID} />
</Text>
</Stack>
</>
)}
</MetaText>
const lastUpdatedBy = (() => {
if (!isGrafanaRulerRule(rule.rulerRule)) {
return null;
}
{/* evaluation duration and pending period */}
<MetaText direction="column">
{hasEvaluationDuration && (
<>
Last evaluation
{evaluationTimestamp && evaluationDuration ? (
<span>
<Text color="primary">{formatDistanceToNowStrict(new Date(evaluationTimestamp))} ago</Text>, took{' '}
<Text color="primary">{evaluationDuration}ms</Text>
</span>
) : null}
</>
)}
</MetaText>
<MetaText direction="column">
{pendingPeriod && (
<>
Pending period
<Text color="primary">{pendingPeriod}</Text>
</>
)}
</MetaText>
return rule.rulerRule.grafana_alert.updated_by?.name || `User ID: ${rule.rulerRule.grafana_alert.updated_by?.uid}`;
})();
{/* nodata and execution error state mapping */}
{isGrafanaRulerRule(rule.rulerRule) &&
// grafana recording rules don't have these fields
rule.rulerRule.grafana_alert.no_data_state &&
rule.rulerRule.grafana_alert.exec_err_state && (
<>
<MetaText direction="column">
Alert state if no data or all values are null
<Text color="primary">{rule.rulerRule.grafana_alert.no_data_state}</Text>
</MetaText>
<MetaText direction="column">
Alert state if execution error or timeout
<Text color="primary">{rule.rulerRule.grafana_alert.exec_err_state}</Text>
</MetaText>
</>
)}
</div>
{/* annotations go here */}
{annotations && (
<>
<Text variant="h4">Annotations</Text>
{Object.keys(annotations).length === 0 ? (
<Text variant="bodySmall" color="secondary" italic>
No annotations
</Text>
) : (
<div className={styles.metadataWrapper}>
{Object.entries(annotations).map(([name, value]) => (
<MetaText direction="column" key={name}>
{name}
<AnnotationValue value={value} />
</MetaText>
))}
</div>
)}
</>
)}
const updated = isGrafanaRulerRule(rule.rulerRule) ? rule.rulerRule.grafana_alert.updated : undefined;
const isPaused = isGrafanaAlertingRule(rule.rulerRule) && rule.rulerRule.grafana_alert.is_paused;
const pausedIcon = (
<Stack>
<Text color="warning">
<Icon name="pause-circle" />
</Text>
<Text>
<Trans i18nKey="alerting.alert.evaluation-paused">Alert evaluation currently paused</Trans>
</Text>
</Stack>
);
return (
<div className={styles.metadata}>
<DetailGroup title={t('alerting.alert.rule', 'Rule')}>
<DetailText id="rule-type" label={t('alerting.alert.rule-type', 'Rule type')} value={ruleType} />
{isGrafanaRulerRule(rule.rulerRule) && (
<>
<DetailText
id="rule-type"
label={t('alerting.alert.rule-identifier', 'Rule identifier')}
value={rule.rulerRule.grafana_alert.uid}
monospace
showCopyButton
copyValue={rule.rulerRule.grafana_alert.uid}
/>
<DetailText
id="last-updated-by"
label={t('alerting.alert.last-updated-by', 'Last updated by')}
value={lastUpdatedBy}
/>
{updated && (
<DetailText
id="date-of-last-update"
label={t('alerting.alert.last-updated-at', 'Last updated at')}
value={dateTimeFormat(updated) + ` (${dateTimeFormatTimeAgo(updated)})`}
/>
)}
</>
)}
</DetailGroup>
<DetailGroup title={t('alerting.alert.evaluation', 'Evaluation')}>
{isPaused ? (
pausedIcon
) : (
<>
{hasEvaluationDuration && evaluationTimestamp && (
<DetailText
id="last-evaluated"
label={t('alerting.alert.last-evaluated', 'Last evaluated')}
value={
!isNullDate(evaluationTimestamp)
? formatDistanceToNowStrict(new Date(evaluationTimestamp), { addSuffix: true })
: '-'
}
tooltipValue={!isNullDate(evaluationTimestamp) ? dateTimeFormat(evaluationTimestamp) : undefined}
/>
)}
{hasEvaluationDuration && (
<DetailText
id="last-evaluation-duration"
label={t('alerting.alert.last-evaluation-duration', 'Last evaluation duration')}
value={isPaused ? pausedIcon : `${evaluationDuration} ms`}
/>
)}
</>
)}
{pendingPeriod && (
<DetailText
id="pending-period"
label={t('alerting.alert.pending-period', 'Pending period')}
value={pendingPeriod}
/>
)}
</DetailGroup>
{isGrafanaRulerRule(rule.rulerRule) &&
// grafana recording rules don't have these fields
rule.rulerRule.grafana_alert.no_data_state &&
rule.rulerRule.grafana_alert.exec_err_state && (
<DetailGroup title={t('alerting.alert.alert-state', 'Alert state')}>
{hasEvaluationDuration && (
<DetailText
id="alert-state-no-data"
label={t('alerting.alert.state-no-data', 'Alert state if no data or all values are null')}
value={rule.rulerRule.grafana_alert.no_data_state}
/>
)}
{pendingPeriod && (
<DetailText
id="alert-state-exec-err"
label={t('alerting.alert.state-error-timeout', 'Alert state if execution error or timeout')}
value={rule.rulerRule.grafana_alert.exec_err_state}
/>
)}
</DetailGroup>
)}
{annotations && (
<DetailGroup title={t('alerting.alert.annotations', 'Annotations')}>
{Object.keys(annotations).length === 0 ? (
<div>
<Text color="secondary" italic>
<Trans i18nKey="alerting.alert.no-annotations">No annotations</Trans>
</Text>
</div>
) : (
Object.entries(annotations).map(([name, value]) => {
const id = `annotation-${name.replace(/\s/g, '-')}`;
return <DetailText id={id} key={name} label={name} value={<AnnotationValue value={value} />} />;
})
)}
</DetailGroup>
)}
</div>
);
};
interface AnnotationValueProps {
@@ -152,7 +203,7 @@ export function AnnotationValue({ value }: AnnotationValueProps) {
if (needsExternalLink) {
return (
<TextLink variant="bodySmall" href={value} external>
<TextLink href={value} external>
{value}
</TextLink>
);
@@ -162,12 +213,16 @@ export function AnnotationValue({ value }: AnnotationValueProps) {
}
const getStyles = (theme: GrafanaTheme2) => ({
metadataWrapper: css({
metadata: css({
display: 'grid',
gridTemplateColumns: 'auto auto',
rowGap: theme.spacing(3),
columnGap: theme.spacing(12),
gap: theme.spacing(4),
gridTemplateColumns: '1fr 1fr 1fr',
[theme.breakpoints.down('lg')]: {
gridTemplateColumns: '1fr 1fr',
},
[theme.breakpoints.down('sm')]: {
gridTemplateColumns: '1fr',
},
}),
});
export { Details };
+5
View File
@@ -265,6 +265,11 @@ export interface GrafanaRuleDefinition extends PostableGrafanaRuleDefinition {
namespace_uid: string;
rule_group: string;
provenance?: string;
updated_by?: {
uid: string;
name?: string;
};
updated?: string;
}
export interface RulerGrafanaRuleDTO<T = GrafanaRuleDefinition> {
+19
View File
@@ -174,6 +174,24 @@
}
},
"alerting": {
"alert": {
"alert-state": "Alert state",
"annotations": "Annotations",
"evaluation": "Evaluation",
"evaluation-paused": "Alert evaluation currently paused",
"evaluation-paused-description": "Notifications for this rule will not fire and no alert instances will be created until the rule is un-paused.",
"last-evaluated": "Last evaluated",
"last-evaluation-duration": "Last evaluation duration",
"last-updated-at": "Last updated at",
"last-updated-by": "Last updated by",
"no-annotations": "No annotations",
"pending-period": "Pending period",
"rule": "Rule",
"rule-identifier": "Rule identifier",
"rule-type": "Rule type",
"state-error-timeout": "Alert state if execution error or timeout",
"state-no-data": "Alert state if no data or all values are null"
},
"alert-recording-rule-form": {
"evaluation-behaviour": {
"description": {
@@ -283,6 +301,7 @@
"contactPointFilter": {
"label": "Contact point"
},
"copy-to-clipboard": "Copy \"{{label}}\" to clipboard",
"export": {
"subtitle": {
"formats": "Select the format and download the file or copy the contents to clipboard",
+19
View File
@@ -174,6 +174,24 @@
}
},
"alerting": {
"alert": {
"alert-state": "Åľęřŧ şŧäŧę",
"annotations": "Åʼnʼnőŧäŧįőʼnş",
"evaluation": "Ēväľūäŧįőʼn",
"evaluation-paused": "Åľęřŧ ęväľūäŧįőʼn čūřřęʼnŧľy päūşęđ",
"evaluation-paused-description": "Ńőŧįƒįčäŧįőʼnş ƒőř ŧĥįş řūľę ŵįľľ ʼnőŧ ƒįřę äʼnđ ʼnő äľęřŧ įʼnşŧäʼnčęş ŵįľľ þę čřęäŧęđ ūʼnŧįľ ŧĥę řūľę įş ūʼn-päūşęđ.",
"last-evaluated": "Ŀäşŧ ęväľūäŧęđ",
"last-evaluation-duration": "Ŀäşŧ ęväľūäŧįőʼn đūřäŧįőʼn",
"last-updated-at": "Ŀäşŧ ūpđäŧęđ äŧ",
"last-updated-by": "Ŀäşŧ ūpđäŧęđ þy",
"no-annotations": "Ńő äʼnʼnőŧäŧįőʼnş",
"pending-period": "Pęʼnđįʼnģ pęřįőđ",
"rule": "Ŗūľę",
"rule-identifier": "Ŗūľę įđęʼnŧįƒįęř",
"rule-type": "Ŗūľę ŧypę",
"state-error-timeout": "Åľęřŧ şŧäŧę įƒ ęχęčūŧįőʼn ęřřőř őř ŧįmęőūŧ",
"state-no-data": "Åľęřŧ şŧäŧę įƒ ʼnő đäŧä őř äľľ väľūęş äřę ʼnūľľ"
},
"alert-recording-rule-form": {
"evaluation-behaviour": {
"description": {
@@ -283,6 +301,7 @@
"contactPointFilter": {
"label": "Cőʼnŧäčŧ pőįʼnŧ"
},
"copy-to-clipboard": "Cőpy \"{{label}}\" ŧő čľįpþőäřđ",
"export": {
"subtitle": {
"formats": "Ŝęľęčŧ ŧĥę ƒőřmäŧ äʼnđ đőŵʼnľőäđ ŧĥę ƒįľę őř čőpy ŧĥę čőʼnŧęʼnŧş ŧő čľįpþőäřđ",