diff --git a/public/app/features/alerting/unified/components/rule-viewer/RuleViewer.test.tsx b/public/app/features/alerting/unified/components/rule-viewer/RuleViewer.test.tsx index ed59a8e9791..992eba0a1a2 100644 --- a/public/app/features/alerting/unified/components/rule-viewer/RuleViewer.test.tsx +++ b/public/app/features/alerting/unified/components/rule-viewer/RuleViewer.test.tsx @@ -12,18 +12,21 @@ import { CombinedRule, RuleIdentifier } from 'app/types/unified-alerting'; import { getCloudRule, getGrafanaRule, + getVanillaPromRule, grantUserPermissions, mockDataSource, mockPluginLinkExtension, + mockPromAlertingRule, } from '../../mocks'; import { grafanaRulerRule } from '../../mocks/grafanaRulerApi'; import { setupDataSources } from '../../testSetup/datasources'; import { Annotation } from '../../utils/constants'; import { DataSourceType } from '../../utils/datasource'; import * as ruleId from '../../utils/rule-id'; +import { stringifyIdentifier } from '../../utils/rule-id'; import { AlertRuleProvider } from './RuleContext'; -import RuleViewer from './RuleViewer'; +import RuleViewer, { ActiveTab } from './RuleViewer'; // metadata and interactive elements const ELEMENTS = { @@ -35,6 +38,12 @@ const ELEMENTS = { evaluationInterval: (interval: string) => byText(`Every ${interval}`), label: ([key, value]: [string, string]) => byRole('listitem', { name: `${key}: ${value}` }), }, + details: { + pendingPeriod: byText(/Pending period/i), + }, + tabs: { + details: byRole('tab', { name: /Details/i }), + }, actions: { edit: byRole('link', { name: 'Edit' }), more: { @@ -259,13 +268,39 @@ describe('RuleViewer', () => { await waitFor(() => expect(ELEMENTS.actions.more.pluginActions.declareIncident.get()).toBeEnabled()); }); }); + + describe('Vanilla Prometheus rule', () => { + const mockRule = getVanillaPromRule({ + name: 'prom test alert', + annotations: { [Annotation.summary]: 'prom summary', [Annotation.runbookURL]: 'https://runbook.example.com' }, + promRule: { + ...mockPromAlertingRule(), + duration: 900, // 15 minutes + }, + }); + + const mockRuleIdentifier = ruleId.fromCombinedRule('prometheus', mockRule); + + it('should render pending period for vanilla Prometheus alert rule', async () => { + renderRuleViewer(mockRule, mockRuleIdentifier, ActiveTab.Details); + + expect(screen.getByText('prom test alert')).toBeInTheDocument(); + + // 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(); + }); + }); }); -const renderRuleViewer = async (rule: CombinedRule, identifier: RuleIdentifier) => { +const renderRuleViewer = async (rule: CombinedRule, identifier: RuleIdentifier, tab: ActiveTab = ActiveTab.Query) => { + const path = `/alerting/${identifier.ruleSourceName}/${stringifyIdentifier(identifier)}/view?tab=${tab}`; render( - + , + { historyOptions: { initialEntries: [path] } } ); await waitFor(() => expect(ELEMENTS.loading.query()).not.toBeInTheDocument()); 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 5c723326abb..53c74196586 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 @@ -7,6 +7,7 @@ import { ClipboardButton, Stack, Text, TextLink, useStyles2 } from '@grafana/ui' import { CombinedRule } from 'app/types/unified-alerting'; import { Annotations } from 'app/types/unified-alerting-dto'; +import { usePendingPeriod } from '../../../hooks/rules/usePendingPeriod'; import { isGrafanaRulerRule, isRecordingRulerRule } from '../../../utils/rules'; import { MetaText } from '../../MetaText'; import { Tokenize } from '../../Tokenize'; @@ -26,6 +27,8 @@ const Details = ({ rule }: DetailsProps) => { let ruleType: RuleType; + const pendingPeriod = usePendingPeriod(rule); + if (isGrafanaRulerRule(rule.rulerRule)) { ruleType = RuleType.GrafanaManagedAlertRule; } else if (isRecordingRulerRule(rule.rulerRule)) { @@ -89,10 +92,10 @@ const Details = ({ rule }: DetailsProps) => { )} - {!isRecordingRulerRule(rule.rulerRule) && ( + {pendingPeriod && ( <> Pending period - {rule.rulerRule?.for ?? '0s'} + {pendingPeriod} )} diff --git a/public/app/features/alerting/unified/components/rules/RuleDetails.tsx b/public/app/features/alerting/unified/components/rules/RuleDetails.tsx index 6c8ddb552e1..7c7659a5e32 100644 --- a/public/app/features/alerting/unified/components/rules/RuleDetails.tsx +++ b/public/app/features/alerting/unified/components/rules/RuleDetails.tsx @@ -5,8 +5,9 @@ import { Tooltip, useStyles2 } from '@grafana/ui'; import { Time } from 'app/features/explore/Time'; import { CombinedRule } from 'app/types/unified-alerting'; +import { usePendingPeriod } from '../../hooks/rules/usePendingPeriod'; import { useCleanAnnotations } from '../../utils/annotations'; -import { isGrafanaRecordingRule, isRecordingRulerRule } from '../../utils/rules'; +import { isGrafanaRecordingRule } from '../../utils/rules'; import { isNullDate } from '../../utils/time'; import { AlertLabels } from '../AlertLabels'; import { DetailsField } from '../DetailsField'; @@ -64,16 +65,12 @@ interface EvaluationBehaviorSummaryProps { } const EvaluationBehaviorSummary = ({ rule }: EvaluationBehaviorSummaryProps) => { - let forDuration: string | undefined; const every = rule.group.interval; const lastEvaluation = rule.promRule?.lastEvaluation; const lastEvaluationDuration = rule.promRule?.evaluationTime; const metric = isGrafanaRecordingRule(rule.rulerRule) ? rule.rulerRule?.grafana_alert.record?.metric : undefined; - // recording rules don't have a for duration - if (!isRecordingRulerRule(rule.rulerRule)) { - forDuration = rule.rulerRule?.for ?? '0s'; - } + const pendingPeriod = usePendingPeriod(rule); return ( <> @@ -88,9 +85,11 @@ const EvaluationBehaviorSummary = ({ rule }: EvaluationBehaviorSummaryProps) => )} - - {forDuration} - + {pendingPeriod && ( + + {pendingPeriod} + + )} {lastEvaluation && !isNullDate(lastEvaluation) && ( diff --git a/public/app/features/alerting/unified/hooks/rules/usePendingPeriod.tsx b/public/app/features/alerting/unified/hooks/rules/usePendingPeriod.tsx new file mode 100644 index 00000000000..d7ebd337f7c --- /dev/null +++ b/public/app/features/alerting/unified/hooks/rules/usePendingPeriod.tsx @@ -0,0 +1,9 @@ +import { useMemo } from 'react'; + +import { CombinedRule } from 'app/types/unified-alerting'; + +import { getPendingPeriod } from '../../utils/rules'; + +export function usePendingPeriod(rule: CombinedRule): string | undefined { + return useMemo(() => getPendingPeriod(rule), [rule]); +} diff --git a/public/app/features/alerting/unified/mocks.ts b/public/app/features/alerting/unified/mocks.ts index 699252137db..632868a90e0 100644 --- a/public/app/features/alerting/unified/mocks.ts +++ b/public/app/features/alerting/unified/mocks.ts @@ -785,6 +785,19 @@ export function getCloudRule(override?: Partial) { }); } +export function getVanillaPromRule(override?: Partial>) { + return mockCombinedRule({ + namespace: { + groups: [], + name: 'Prometheus', + rulesSource: mockDataSource(), + }, + promRule: mockPromAlertingRule(), + rulerRule: undefined, + ...override, + }); +} + export function mockPluginLinkExtension(extension: Partial): PluginExtensionLink { return { type: PluginExtensionTypes.link, diff --git a/public/app/features/alerting/unified/utils/rules.ts b/public/app/features/alerting/unified/utils/rules.ts index fe4aa16b4b4..cc4a7af1af9 100644 --- a/public/app/features/alerting/unified/utils/rules.ts +++ b/public/app/features/alerting/unified/utils/rules.ts @@ -44,7 +44,7 @@ import { RULER_NOT_SUPPORTED_MSG } from './constants'; import { getRulesSourceName, isGrafanaRulesSource } from './datasource'; import { GRAFANA_ORIGIN_LABEL } from './labels'; import { AsyncRequestState } from './redux'; -import { safeParsePrometheusDuration } from './time'; +import { formatPrometheusDuration, safeParsePrometheusDuration } from './time'; export function isAlertingRule(rule: Rule | undefined): rule is AlertingRule { return typeof rule === 'object' && rule.type === PromRuleType.Alerting; @@ -137,6 +137,26 @@ export function getRuleHealth(health: string): RuleHealth | undefined { } } +export function getPendingPeriod(rule: CombinedRule): string | undefined { + if (isRecordingRulerRule(rule.rulerRule) || isRecordingRule(rule.promRule)) { + return undefined; + } + + // We prefer the for duration from the ruler rule because it is formatted as a duration string + // Prometheus duration is in seconds and we need to format it as a duration string + // Additionally, due to eventual consistency of the Prometheus endpoint the ruler data might be newer + if (isAlertingRulerRule(rule.rulerRule)) { + return rule.rulerRule.for; + } + + if (isAlertingRule(rule.promRule)) { + const durationInMilliseconds = (rule.promRule.duration ?? 0) * 1000; + return formatPrometheusDuration(durationInMilliseconds); + } + + return undefined; +} + export interface RulePluginOrigin { pluginId: string; } diff --git a/public/app/types/unified-alerting.ts b/public/app/types/unified-alerting.ts index 338fa189fc9..734e6c650ad 100644 --- a/public/app/types/unified-alerting.ts +++ b/public/app/types/unified-alerting.ts @@ -50,6 +50,11 @@ export interface AlertingRule extends RuleBase { }; state: PromAlertingRuleState; type: PromRuleType.Alerting; + + /** + * Pending period in seconds, aka for. 0 or undefined means no pending period + */ + duration?: number; totals?: Partial, number>>; totalsFiltered?: Partial, number>>; activeAt?: string; // ISO timestamp