From eaf964a8275fca7051a019d31ed5d1c5ae1a0cf1 Mon Sep 17 00:00:00 2001 From: Domas Date: Wed, 19 May 2021 18:54:03 +0300 Subject: [PATCH] Alerting: misc rule list fixes (#34400) * misc rule table fixes * rule stats for all rules * inactive -> normal * always show tooltip for rule error --- .../grafana-data/src/datetime/durationutil.ts | 12 +- .../features/alerting/unified/RuleList.tsx | 16 +-- .../unified/components/StateColoredText.tsx | 5 +- .../alerting/unified/components/StateTag.tsx | 3 + .../unified/components/rules/RuleHealth.tsx | 35 ++++++ .../unified/components/rules/RuleState.tsx | 92 ++++++++++++++ .../unified/components/rules/RuleStats.tsx | 113 ++++++++++++++++++ .../unified/components/rules/RuleTableRow.tsx | 0 .../unified/components/rules/RulesFilter.tsx | 28 ++++- .../unified/components/rules/RulesGroup.tsx | 57 ++------- .../unified/components/rules/RulesTable.tsx | 48 ++++---- public/app/types/unified-alerting.ts | 1 - 12 files changed, 320 insertions(+), 90 deletions(-) create mode 100644 public/app/features/alerting/unified/components/rules/RuleHealth.tsx create mode 100644 public/app/features/alerting/unified/components/rules/RuleState.tsx create mode 100644 public/app/features/alerting/unified/components/rules/RuleStats.tsx delete mode 100644 public/app/features/alerting/unified/components/rules/RuleTableRow.tsx diff --git a/packages/grafana-data/src/datetime/durationutil.ts b/packages/grafana-data/src/datetime/durationutil.ts index 80a1d666a03..ee1efa2c31d 100644 --- a/packages/grafana-data/src/datetime/durationutil.ts +++ b/packages/grafana-data/src/datetime/durationutil.ts @@ -12,10 +12,18 @@ const durationMap: { [key in Required]: string[] } = { seconds: ['s', 'S', 'seconds'], }; -export function intervalToAbbreviatedDurationString(interval: Interval): string { +/** + * intervalToAbbreviatedDurationString convers interval to readable duration string + * + * @param interval - interval to convert + * @param includeSeconds - optional, default true. If false, will not include seconds unless interval is less than 1 minute + * + * @public + */ +export function intervalToAbbreviatedDurationString(interval: Interval, includeSeconds = true): string { const duration = intervalToDuration(interval); return (Object.entries(duration) as Array<[keyof Duration, number | undefined]>).reduce((str, [unit, value]) => { - if (value && value !== 0) { + if (value && value !== 0 && !(unit === 'seconds' && !includeSeconds && str)) { const padding = str !== '' ? ' ' : ''; return str + `${padding}${value}${durationMap[unit][0]}`; } diff --git a/public/app/features/alerting/unified/RuleList.tsx b/public/app/features/alerting/unified/RuleList.tsx index 01afe507a1e..14d5f535605 100644 --- a/public/app/features/alerting/unified/RuleList.tsx +++ b/public/app/features/alerting/unified/RuleList.tsx @@ -1,5 +1,5 @@ import { DataSourceInstanceSettings, GrafanaTheme, urlUtil } from '@grafana/data'; -import { useStyles, ButtonGroup, ToolbarButton, Alert, LinkButton, withErrorBoundary } from '@grafana/ui'; +import { useStyles, Alert, LinkButton, withErrorBoundary } from '@grafana/ui'; import { SerializedError } from '@reduxjs/toolkit'; import React, { useEffect, useMemo } from 'react'; import { useDispatch } from 'react-redux'; @@ -19,6 +19,7 @@ import { RuleListStateView } from './components/rules/RuleListStateView'; import { useQueryParams } from 'app/core/hooks/useQueryParams'; import { useLocation } from 'react-router-dom'; import { contextSrv } from 'app/core/services/context_srv'; +import { RuleStats } from './components/rules/RuleStats'; const VIEWS = { groups: RuleListGroupView, @@ -117,18 +118,7 @@ export const RuleList = withErrorBoundary(
- - - - Groups - - - - - State - - - +
{(contextSrv.hasEditPermissionInFolders || contextSrv.isEditor) && ( = ({ children, status }) => { @@ -24,4 +24,7 @@ const getStyles = (theme: GrafanaTheme2) => ({ [PromAlertingRuleState.Firing]: css` color: ${theme.colors.error.text}; `, + neutral: css` + color: ${theme.colors.text.secondary}; + `, }); diff --git a/public/app/features/alerting/unified/components/StateTag.tsx b/public/app/features/alerting/unified/components/StateTag.tsx index 6a7bbcaa02f..5ca76eb73ea 100644 --- a/public/app/features/alerting/unified/components/StateTag.tsx +++ b/public/app/features/alerting/unified/components/StateTag.tsx @@ -24,6 +24,9 @@ const getStyles = (theme: GrafanaTheme2) => ({ padding: ${theme.spacing(0.5, 1)}; text-transform: capitalize; line-height: 1.2; + min-width: ${theme.spacing(8)}; + text-align: center; + font-weight: ${theme.typography.fontWeightBold}; `, good: css` background-color: ${theme.colors.success.main}; diff --git a/public/app/features/alerting/unified/components/rules/RuleHealth.tsx b/public/app/features/alerting/unified/components/rules/RuleHealth.tsx new file mode 100644 index 00000000000..3cb4c3d9b15 --- /dev/null +++ b/public/app/features/alerting/unified/components/rules/RuleHealth.tsx @@ -0,0 +1,35 @@ +import { css } from '@emotion/css'; +import { GrafanaTheme2 } from '@grafana/data'; +import { Icon, Tooltip, useStyles2 } from '@grafana/ui'; +import { Rule } from 'app/types/unified-alerting'; +import React, { FC } from 'react'; + +interface Prom { + rule: Rule; +} + +export const RuleHealth: FC = ({ rule }) => { + const style = useStyles2(getStyle); + if (rule.health === 'err' || rule.health === 'error') { + return ( + +
+ + error +
+
+ ); + } + return <>{rule.health}; +}; + +const getStyle = (theme: GrafanaTheme2) => ({ + warn: css` + display: inline-flex; + flex-direction: row; + color: ${theme.colors.warning.text}; + & > * + * { + margin-left: ${theme.spacing(1)}; + } + `, +}); diff --git a/public/app/features/alerting/unified/components/rules/RuleState.tsx b/public/app/features/alerting/unified/components/rules/RuleState.tsx new file mode 100644 index 00000000000..b00046ff2b3 --- /dev/null +++ b/public/app/features/alerting/unified/components/rules/RuleState.tsx @@ -0,0 +1,92 @@ +import { css } from '@emotion/css'; +import { GrafanaTheme2, intervalToAbbreviatedDurationString } from '@grafana/data'; +import { HorizontalGroup, Spinner, useStyles2 } from '@grafana/ui'; +import { CombinedRule } from 'app/types/unified-alerting'; +import { PromAlertingRuleState } from 'app/types/unified-alerting-dto'; +import React, { FC, useMemo } from 'react'; +import { isAlertingRule, isRecordingRule } from '../../utils/rules'; +import { AlertStateTag } from './AlertStateTag'; + +interface Props { + rule: CombinedRule; + isDeleting: boolean; + isCreating: boolean; +} + +export const RuleState: FC = ({ rule, isDeleting, isCreating }) => { + const style = useStyles2(getStyle); + const { promRule } = rule; + + // return how long the rule has been in it's firing state, if any + const forTime = useMemo(() => { + if ( + promRule && + isAlertingRule(promRule) && + promRule.alerts?.length && + promRule.state !== PromAlertingRuleState.Inactive + ) { + // find earliest alert + const firstActiveAt = promRule.alerts.reduce((prev, alert) => { + if (alert.activeAt) { + const activeAt = new Date(alert.activeAt); + if (prev === null || prev.getTime() > activeAt.getTime()) { + return activeAt; + } + } + return prev; + }, null as Date | null); + + // caclulate time elapsed from earliest alert + if (firstActiveAt) { + return ( + + for{' '} + {intervalToAbbreviatedDurationString( + { + start: firstActiveAt, + end: new Date(), + }, + false + )} + + ); + } + } + return null; + }, [promRule, style]); + + if (isDeleting) { + return ( + + + deleting + + ); + } else if (isCreating) { + return ( + + {' '} + + creating + + ); + } else if (promRule && isAlertingRule(promRule)) { + return ( + + + {forTime} + + ); + } else if (promRule && isRecordingRule(promRule)) { + return <>Recording rule; + } + return <>n/a; +}; + +const getStyle = (theme: GrafanaTheme2) => ({ + for: css` + font-size: ${theme.typography.bodySmall.fontSize}; + color: ${theme.colors.text.secondary}; + white-space: nowrap; + `, +}); diff --git a/public/app/features/alerting/unified/components/rules/RuleStats.tsx b/public/app/features/alerting/unified/components/rules/RuleStats.tsx new file mode 100644 index 00000000000..58415d364f2 --- /dev/null +++ b/public/app/features/alerting/unified/components/rules/RuleStats.tsx @@ -0,0 +1,113 @@ +import { CombinedRule, CombinedRuleGroup, CombinedRuleNamespace } from 'app/types/unified-alerting'; +import { PromAlertingRuleState } from 'app/types/unified-alerting-dto'; +import pluralize from 'pluralize'; +import React, { FC, Fragment, useMemo } from 'react'; +import { isAlertingRule, isRecordingRule, isRecordingRulerRule } from '../../utils/rules'; +import { StateColoredText } from '../StateColoredText'; + +interface Props { + showInactive?: boolean; + showRecording?: boolean; + group?: CombinedRuleGroup; + namespaces?: CombinedRuleNamespace[]; +} + +const emptyStats = { + total: 0, + recording: 0, + [PromAlertingRuleState.Firing]: 0, + [PromAlertingRuleState.Pending]: 0, + [PromAlertingRuleState.Inactive]: 0, + error: 0, +} as const; + +export const RuleStats: FC = ({ showInactive, showRecording, group, namespaces }) => { + const calculated = useMemo(() => { + const stats = { ...emptyStats }; + const calcRule = (rule: CombinedRule) => { + if (rule.promRule && isAlertingRule(rule.promRule)) { + stats[rule.promRule.state] += 1; + } + if (rule.promRule?.health === 'err' || rule.promRule?.health === 'error') { + stats.error += 1; + } + if ( + (rule.promRule && isRecordingRule(rule.promRule)) || + (rule.rulerRule && isRecordingRulerRule(rule.rulerRule)) + ) { + stats.recording += 1; + } + stats.total += 1; + }; + if (group) { + group.rules.forEach(calcRule); + } + if (namespaces) { + namespaces.forEach((namespace) => namespace.groups.forEach((group) => group.rules.forEach(calcRule))); + } + return stats; + }, [group, namespaces]); + + const statsComponents: React.ReactNode[] = []; + if (calculated[PromAlertingRuleState.Firing]) { + statsComponents.push( + + {calculated[PromAlertingRuleState.Firing]} firing + + ); + } + if (calculated.error) { + statsComponents.push( + + {calculated.error} errors + + ); + } + if (calculated[PromAlertingRuleState.Pending]) { + statsComponents.push( + + {calculated[PromAlertingRuleState.Pending]} pending + + ); + } + if (showInactive && calculated[PromAlertingRuleState.Inactive]) { + statsComponents.push( + + {calculated[PromAlertingRuleState.Inactive]} normal + + ); + } + if (showRecording && calculated.recording) { + statsComponents.push( + + {calculated.recording} recording + + ); + } + + return ( +
+ + {calculated.total} {pluralize('rule', calculated.total)} + + {!!statsComponents.length && ( + <> + : + {statsComponents.reduce( + (prev, curr, idx) => + prev.length + ? [ + prev, + + , + , + curr, + ] + : [curr], + [] + )} + + )} +
+ ); +}; diff --git a/public/app/features/alerting/unified/components/rules/RuleTableRow.tsx b/public/app/features/alerting/unified/components/rules/RuleTableRow.tsx deleted file mode 100644 index e69de29bb2d..00000000000 diff --git a/public/app/features/alerting/unified/components/rules/RulesFilter.tsx b/public/app/features/alerting/unified/components/rules/RulesFilter.tsx index 450b955a977..a6f42777df2 100644 --- a/public/app/features/alerting/unified/components/rules/RulesFilter.tsx +++ b/public/app/features/alerting/unified/components/rules/RulesFilter.tsx @@ -1,6 +1,6 @@ import React, { FormEvent, useState } from 'react'; import { Button, Icon, Input, Label, RadioButtonGroup, useStyles } from '@grafana/ui'; -import { DataSourceInstanceSettings, GrafanaTheme } from '@grafana/data'; +import { DataSourceInstanceSettings, GrafanaTheme, SelectableValue } from '@grafana/data'; import { css, cx } from '@emotion/css'; import { debounce } from 'lodash'; @@ -10,6 +10,19 @@ import { getFiltersFromUrlParams } from '../../utils/misc'; import { DataSourcePicker } from '@grafana/runtime'; import { alertStateToReadable } from '../../utils/rules'; +const ViewOptions: SelectableValue[] = [ + { + icon: 'folder', + label: 'Groups', + value: 'group', + }, + { + icon: 'heart-rate', + label: 'State', + value: 'state', + }, +]; + const RulesFilter = () => { const [queryParams, setQueryParams] = useQueryParams(); // This key is used to force a rerender on the inputs when the filters are cleared @@ -38,6 +51,10 @@ const RulesFilter = () => { setQueryParams({ alertState: value }); }; + const handleViewChange = (view: string) => { + setQueryParams({ view }); + }; + const handleClearFiltersClick = () => { setQueryParams({ alertState: null, @@ -74,8 +91,17 @@ const RulesFilter = () => { />
+
+
+ + +
{(dataSource || alertState || queryString) && (
diff --git a/public/app/features/alerting/unified/components/rules/RulesGroup.tsx b/public/app/features/alerting/unified/components/rules/RulesGroup.tsx index 09d4746b7df..321f80b9180 100644 --- a/public/app/features/alerting/unified/components/rules/RulesGroup.tsx +++ b/public/app/features/alerting/unified/components/rules/RulesGroup.tsx @@ -1,19 +1,17 @@ import { CombinedRuleGroup, CombinedRuleNamespace } from 'app/types/unified-alerting'; -import React, { FC, useMemo, useState, Fragment } from 'react'; +import React, { FC, useState } from 'react'; import { Icon, Tooltip, useStyles2 } from '@grafana/ui'; import { GrafanaTheme2 } from '@grafana/data'; import { css } from '@emotion/css'; -import { isAlertingRule, isGrafanaRulerRule } from '../../utils/rules'; -import { PromAlertingRuleState } from 'app/types/unified-alerting-dto'; -import { StateColoredText } from '../StateColoredText'; +import { isGrafanaRulerRule } from '../../utils/rules'; import { CollapseToggle } from '../CollapseToggle'; import { RulesTable } from './RulesTable'; import { GRAFANA_RULES_SOURCE_NAME, isCloudRulesSource } from '../../utils/datasource'; import { ActionIcon } from './ActionIcon'; -import pluralize from 'pluralize'; import { useHasRuler } from '../../hooks/useHasRuler'; import kbn from 'app/core/utils/kbn'; import { useFolder } from '../../hooks/useFolder'; +import { RuleStats } from './RuleStats'; interface Props { namespace: CombinedRuleNamespace; @@ -31,40 +29,6 @@ export const RulesGroup: FC = React.memo(({ group, namespace }) => { const folderUID = (rulerRule && isGrafanaRulerRule(rulerRule) && rulerRule.grafana_alert.namespace_uid) || undefined; const { folder } = useFolder(folderUID); - const stats = useMemo( - (): Record => - group.rules.reduce>( - (stats, rule) => { - if (rule.promRule && isAlertingRule(rule.promRule)) { - stats[rule.promRule.state] += 1; - } - return stats; - }, - { - [PromAlertingRuleState.Firing]: 0, - [PromAlertingRuleState.Pending]: 0, - [PromAlertingRuleState.Inactive]: 0, - } - ), - [group] - ); - - const statsComponents: React.ReactNode[] = []; - if (stats[PromAlertingRuleState.Firing]) { - statsComponents.push( - - {stats[PromAlertingRuleState.Firing]} firing - - ); - } - if (stats[PromAlertingRuleState.Pending]) { - statsComponents.push( - - {stats[PromAlertingRuleState.Pending]} pending - - ); - } - const actionIcons: React.ReactNode[] = []; // for grafana, link to folder views @@ -112,16 +76,7 @@ export const RulesGroup: FC = React.memo(({ group, namespace }) => {
{groupName}
- {group.rules.length} {pluralize('rule', group.rules.length)} - {!!statsComponents.length && ( - <> - :{' '} - {statsComponents.reduce( - (prev, curr, idx) => (prev.length ? [prev, , , curr] : [curr]), - [] - )} - - )} +
{!!actionIcons.length && ( <> @@ -130,7 +85,9 @@ export const RulesGroup: FC = React.memo(({ group, namespace }) => { )}
- {!isCollapsed && } + {!isCollapsed && ( + + )}
); }); diff --git a/public/app/features/alerting/unified/components/rules/RulesTable.tsx b/public/app/features/alerting/unified/components/rules/RulesTable.tsx index 39084d46222..3b3edfe3bc4 100644 --- a/public/app/features/alerting/unified/components/rules/RulesTable.tsx +++ b/public/app/features/alerting/unified/components/rules/RulesTable.tsx @@ -1,7 +1,6 @@ import { GrafanaTheme2 } from '@grafana/data'; import { useStyles2 } from '@grafana/ui'; import React, { FC, Fragment, useState } from 'react'; -import { isAlertingRule, isRecordingRule } from '../../utils/rules'; import { CollapseToggle } from '../CollapseToggle'; import { css, cx } from '@emotion/css'; import { RuleDetails } from './RuleDetails'; @@ -9,12 +8,15 @@ import { getAlertTableStyles } from '../../styles/table'; import { isCloudRulesSource } from '../../utils/datasource'; import { useHasRuler } from '../../hooks/useHasRuler'; import { CombinedRule } from 'app/types/unified-alerting'; -import { AlertStateTag } from './AlertStateTag'; +import { Annotation } from '../../utils/constants'; +import { RuleState } from './RuleState'; +import { RuleHealth } from './RuleHealth'; interface Props { rules: CombinedRule[]; showGuidelines?: boolean; showGroupColumn?: boolean; + showSummaryColumn?: boolean; emptyMessage?: string; className?: string; } @@ -25,6 +27,7 @@ export const RulesTable: FC = ({ showGuidelines = false, emptyMessage = 'No rules found.', showGroupColumn = false, + showSummaryColumn = false, }) => { const hasRuler = useHasRuler(); @@ -49,10 +52,10 @@ export const RulesTable: FC = ({ - - + + {showSummaryColumn && } {showGroupColumn && } @@ -62,8 +65,9 @@ export const RulesTable: FC = ({ + + {showSummaryColumn && } {showGroupColumn && } - @@ -79,11 +83,16 @@ export const RulesTable: FC = ({ seenKeys.push(key); const isExpanded = expandedKeys.includes(key); const { promRule, rulerRule } = rule; - const statuses = [ - promRule?.health, - hasRuler(rulesSource) && promRule && !rulerRule ? 'deleting' : '', - hasRuler(rulesSource) && rulerRule && !promRule ? 'creating' : '', - ].filter((x) => !!x); + const isDeleting = !!(hasRuler(rulesSource) && promRule && !rulerRule); + const isCreating = !!(hasRuler(rulesSource) && rulerRule && !promRule); + + let detailsColspan = 3; + if (showGroupColumn) { + detailsColspan += 1; + } + if (showSummaryColumn) { + detailsColspan += 1; + } return ( @@ -103,19 +112,14 @@ export const RulesTable: FC = ({ /> + + {showSummaryColumn && } {showGroupColumn && ( )} - {isExpanded && ( @@ -124,7 +128,7 @@ export const RulesTable: FC = ({
)} -
@@ -172,9 +176,6 @@ export const getStyles = (theme: GrafanaTheme2) => ({ evenRow: css` background-color: ${theme.colors.background.primary}; `, - colState: css` - width: 110px; - `, relative: css` position: relative; `, @@ -201,4 +202,7 @@ export const getStyles = (theme: GrafanaTheme2) => ({ top: -24px; bottom: 0; `, + state: css` + width: 110px; + `, }); diff --git a/public/app/types/unified-alerting.ts b/public/app/types/unified-alerting.ts index 3357e8e8c15..d86ad06e33c 100644 --- a/public/app/types/unified-alerting.ts +++ b/public/app/types/unified-alerting.ts @@ -18,7 +18,6 @@ export type Alert = { state: PromAlertingRuleState | GrafanaAlertState; value: string; }; - interface RuleBase { health: string; name: string;
State NameHealthSummaryGroupStatus
- {promRule && isAlertingRule(promRule) ? ( - - ) : promRule && isRecordingRule(promRule) ? ( - 'Recording rule' - ) : ( - 'n/a' - )} + {rule.name}{promRule && }{rule.annotations[Annotation.summary] ?? ''}{isCloudRulesSource(rulesSource) ? `${namespace.name} > ${group.name}` : namespace.name}{statuses.join(', ') || 'n/a'}
+