diff --git a/.betterer.results b/.betterer.results index 06a027a4207..53d62e11c3f 100644 --- a/.betterer.results +++ b/.betterer.results @@ -1637,10 +1637,6 @@ exports[`better eslint`] = { [0, 0, 0, "No untranslated strings. Wrap text with ", "4"], [0, 0, 0, "No untranslated strings. Wrap text with ", "5"] ], - "public/app/features/alerting/unified/components/InfoPausedRule.tsx:5381": [ - [0, 0, 0, "No untranslated strings in text props. Wrap text with or use t()", "0"], - [0, 0, 0, "No untranslated strings. Wrap text with ", "1"] - ], "public/app/features/alerting/unified/components/InvalidIntervalWarning.tsx:5381": [ [0, 0, 0, "No untranslated strings in text props. Wrap text with or use t()", "0"], [0, 0, 0, "No untranslated strings. Wrap text with ", "1"], @@ -2238,9 +2234,7 @@ exports[`better eslint`] = { [0, 0, 0, "No untranslated strings in text props. Wrap text with or use t()", "8"], [0, 0, 0, "No untranslated strings in text props. Wrap text with or use t()", "9"], [0, 0, 0, "No untranslated strings in text props. Wrap text with or use t()", "10"], - [0, 0, 0, "No untranslated strings in text props. Wrap text with or use t()", "11"], - [0, 0, 0, "No untranslated strings in text props. Wrap text with or use t()", "12"], - [0, 0, 0, "No untranslated strings. Wrap text with ", "13"] + [0, 0, 0, "No untranslated strings. Wrap text with ", "11"] ], "public/app/features/alerting/unified/components/rule-editor/GrafanaFolderAndLabelsStep.tsx:5381": [ [0, 0, 0, "No untranslated strings in text props. Wrap text with or use t()", "0"] @@ -2486,19 +2480,6 @@ exports[`better eslint`] = { [0, 0, 0, "No untranslated strings. Wrap text with ", "3"], [0, 0, 0, "No untranslated strings. Wrap text with ", "4"] ], - "public/app/features/alerting/unified/components/rule-viewer/tabs/Details.tsx:5381": [ - [0, 0, 0, "No untranslated strings. Wrap text with ", "0"], - [0, 0, 0, "No untranslated strings. Wrap text with ", "1"], - [0, 0, 0, "No untranslated strings. Wrap text with ", "2"], - [0, 0, 0, "No untranslated strings. Wrap text with ", "3"], - [0, 0, 0, "No untranslated strings. Wrap text with ", "4"], - [0, 0, 0, "No untranslated strings. Wrap text with ", "5"], - [0, 0, 0, "No untranslated strings. Wrap text with ", "6"], - [0, 0, 0, "No untranslated strings. Wrap text with ", "7"], - [0, 0, 0, "No untranslated strings. Wrap text with ", "8"], - [0, 0, 0, "No untranslated strings. Wrap text with ", "9"], - [0, 0, 0, "No untranslated strings. Wrap text with ", "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 or use t()", "0"], [0, 0, 0, "No untranslated strings. Wrap text with ", "1"] diff --git a/packages/grafana-ui/src/components/ClipboardButton/ClipboardButton.tsx b/packages/grafana-ui/src/components/ClipboardButton/ClipboardButton.tsx index 8f56a9a36a0..bfc7b94b793 100644 --- a/packages/grafana-ui/src/components/ClipboardButton/ClipboardButton.tsx +++ b/packages/grafana-ui/src/components/ClipboardButton/ClipboardButton.tsx @@ -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 && ( - Copied + {copiedText} )} @@ -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} diff --git a/public/app/features/alerting/unified/components/InfoPausedRule.tsx b/public/app/features/alerting/unified/components/InfoPausedRule.tsx index 52df56f1289..39f435dbccd 100644 --- a/public/app/features/alerting/unified/components/InfoPausedRule.tsx +++ b/public/app/features/alerting/unified/components/InfoPausedRule.tsx @@ -1,9 +1,12 @@ import { Alert } from '@grafana/ui'; +import { Trans, t } from 'app/core/internationalization'; const InfoPausedRule = () => { return ( - - Notifications for this rule will not fire and no alert instances will be created until the rule is un-paused. + + + Notifications for this rule will not fire and no alert instances will be created until the rule is un-paused. + ); }; diff --git a/public/app/features/alerting/unified/components/common/DetailText.tsx b/public/app/features/alerting/unified/components/common/DetailText.tsx new file mode 100644 index 00000000000..0e20671985d --- /dev/null +++ b/public/app/features/alerting/unified/components/common/DetailText.tsx @@ -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 ( + + + + {label} + + + {children}} + > + {value} + + {showCopyButton && ( + copyValue} + /> + )} + + + + ); +}; diff --git a/public/app/features/alerting/unified/components/rule-editor/GrafanaEvaluationBehavior.tsx b/public/app/features/alerting/unified/components/rule-editor/GrafanaEvaluationBehavior.tsx index 6104c3b2fa6..5178df5a685 100644 --- a/public/app/features/alerting/unified/components/rule-editor/GrafanaEvaluationBehavior.tsx +++ b/public/app/features/alerting/unified/components/rule-editor/GrafanaEvaluationBehavior.tsx @@ -363,7 +363,10 @@ export function GrafanaEvaluationBehaviorStep({ {showErrorHandling && ( <> - + ( - + ( 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); }); }); }); diff --git a/public/app/features/alerting/unified/components/rule-viewer/tabs/Details.tsx b/public/app/features/alerting/unified/components/rule-viewer/tabs/Details.tsx index 9d9397f8839..63d3933a4a8 100644 --- a/public/app/features/alerting/unified/components/rule-viewer/tabs/Details.tsx +++ b/public/app/features/alerting/unified/components/rule-viewer/tabs/Details.tsx @@ -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 ( + + {title} + + {children} + + + ); +}; + +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 ( - -
- {/* type and identifier (optional) */} - - Rule type - {ruleType} - - - {isGrafanaRulerRule(rule.rulerRule) && ( - <> - Rule Identifier - - - {rule.rulerRule.grafana_alert.uid} - - - - - )} - + const lastUpdatedBy = (() => { + if (!isGrafanaRulerRule(rule.rulerRule)) { + return null; + } - {/* evaluation duration and pending period */} - - {hasEvaluationDuration && ( - <> - Last evaluation - {evaluationTimestamp && evaluationDuration ? ( - - {formatDistanceToNowStrict(new Date(evaluationTimestamp))} ago, took{' '} - {evaluationDuration}ms - - ) : null} - - )} - - - {pendingPeriod && ( - <> - Pending period - {pendingPeriod} - - )} - + 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 && ( - <> - - Alert state if no data or all values are null - {rule.rulerRule.grafana_alert.no_data_state} - - - Alert state if execution error or timeout - {rule.rulerRule.grafana_alert.exec_err_state} - - - )} -
- - {/* annotations go here */} - {annotations && ( - <> - Annotations - {Object.keys(annotations).length === 0 ? ( - - No annotations - - ) : ( -
- {Object.entries(annotations).map(([name, value]) => ( - - {name} - - - ))} -
- )} - - )} + const updated = isGrafanaRulerRule(rule.rulerRule) ? rule.rulerRule.grafana_alert.updated : undefined; + const isPaused = isGrafanaAlertingRule(rule.rulerRule) && rule.rulerRule.grafana_alert.is_paused; + const pausedIcon = ( + + + + + + Alert evaluation currently paused + ); + return ( +
+ + + {isGrafanaRulerRule(rule.rulerRule) && ( + <> + + + {updated && ( + + )} + + )} + + + + {isPaused ? ( + pausedIcon + ) : ( + <> + {hasEvaluationDuration && evaluationTimestamp && ( + + )} + {hasEvaluationDuration && ( + + )} + + )} + + {pendingPeriod && ( + + )} + + + {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 && ( + + {hasEvaluationDuration && ( + + )} + {pendingPeriod && ( + + )} + + )} + + {annotations && ( + + {Object.keys(annotations).length === 0 ? ( +
+ + No annotations + +
+ ) : ( + Object.entries(annotations).map(([name, value]) => { + const id = `annotation-${name.replace(/\s/g, '-')}`; + return } />; + }) + )} +
+ )} +
+ ); }; interface AnnotationValueProps { @@ -152,7 +203,7 @@ export function AnnotationValue({ value }: AnnotationValueProps) { if (needsExternalLink) { return ( - + {value} ); @@ -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 }; diff --git a/public/app/types/unified-alerting-dto.ts b/public/app/types/unified-alerting-dto.ts index 1cc55fe038e..302c6a4d0ba 100644 --- a/public/app/types/unified-alerting-dto.ts +++ b/public/app/types/unified-alerting-dto.ts @@ -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 { diff --git a/public/locales/en-US/grafana.json b/public/locales/en-US/grafana.json index f0211561164..f97f3beb611 100644 --- a/public/locales/en-US/grafana.json +++ b/public/locales/en-US/grafana.json @@ -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", diff --git a/public/locales/pseudo-LOCALE/grafana.json b/public/locales/pseudo-LOCALE/grafana.json index 05e75615ed4..76b82980d04 100644 --- a/public/locales/pseudo-LOCALE/grafana.json +++ b/public/locales/pseudo-LOCALE/grafana.json @@ -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þőäřđ",