Alerting: misc rule list fixes (#34400)

* misc rule table fixes

* rule stats for all rules

* inactive -> normal

* always show tooltip for rule error
This commit is contained in:
Domas 2021-05-19 18:54:03 +03:00 committed by GitHub
parent 22043d7872
commit eaf964a827
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
12 changed files with 320 additions and 90 deletions

View File

@ -12,10 +12,18 @@ const durationMap: { [key in Required<keyof Duration>]: 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]}`;
}

View File

@ -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(
<RulesFilter />
<div className={styles.break} />
<div className={styles.buttonsContainer}>
<ButtonGroup>
<a href={urlUtil.renderUrl('alerting/list', { ...queryParams, view: 'group' })}>
<ToolbarButton variant={view === 'groups' ? 'active' : 'default'} icon="folder">
Groups
</ToolbarButton>
</a>
<a href={urlUtil.renderUrl('alerting/list', { ...queryParams, view: 'state' })}>
<ToolbarButton variant={view === 'state' ? 'active' : 'default'} icon="heart-rate">
State
</ToolbarButton>
</a>
</ButtonGroup>
<RuleStats showInactive={true} showRecording={true} namespaces={filteredNamespaces} />
<div />
{(contextSrv.hasEditPermissionInFolders || contextSrv.isEditor) && (
<LinkButton

View File

@ -5,7 +5,7 @@ import { css } from '@emotion/css';
import React, { FC } from 'react';
type Props = {
status: PromAlertingRuleState;
status: PromAlertingRuleState | 'neutral';
};
export const StateColoredText: FC<Props> = ({ 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};
`,
});

View File

@ -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};

View File

@ -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<Prom> = ({ rule }) => {
const style = useStyles2(getStyle);
if (rule.health === 'err' || rule.health === 'error') {
return (
<Tooltip theme="error" content={rule.lastError || 'No error message provided.'}>
<div className={style.warn}>
<Icon name="exclamation-triangle" />
<span>error</span>
</div>
</Tooltip>
);
}
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)};
}
`,
});

View File

@ -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<Props> = ({ 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 (
<span title={String(firstActiveAt)} className={style.for}>
for{' '}
{intervalToAbbreviatedDurationString(
{
start: firstActiveAt,
end: new Date(),
},
false
)}
</span>
);
}
}
return null;
}, [promRule, style]);
if (isDeleting) {
return (
<HorizontalGroup>
<Spinner />
deleting
</HorizontalGroup>
);
} else if (isCreating) {
return (
<HorizontalGroup>
{' '}
<Spinner />
creating
</HorizontalGroup>
);
} else if (promRule && isAlertingRule(promRule)) {
return (
<HorizontalGroup>
<AlertStateTag state={promRule.state} />
{forTime}
</HorizontalGroup>
);
} 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;
`,
});

View File

@ -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<Props> = ({ 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(
<StateColoredText key="firing" status={PromAlertingRuleState.Firing}>
{calculated[PromAlertingRuleState.Firing]} firing
</StateColoredText>
);
}
if (calculated.error) {
statsComponents.push(
<StateColoredText key="errors" status={PromAlertingRuleState.Firing}>
{calculated.error} errors
</StateColoredText>
);
}
if (calculated[PromAlertingRuleState.Pending]) {
statsComponents.push(
<StateColoredText key="pending" status={PromAlertingRuleState.Pending}>
{calculated[PromAlertingRuleState.Pending]} pending
</StateColoredText>
);
}
if (showInactive && calculated[PromAlertingRuleState.Inactive]) {
statsComponents.push(
<StateColoredText key="inactive" status="neutral">
{calculated[PromAlertingRuleState.Inactive]} normal
</StateColoredText>
);
}
if (showRecording && calculated.recording) {
statsComponents.push(
<StateColoredText key="recording" status="neutral">
{calculated.recording} recording
</StateColoredText>
);
}
return (
<div>
<span>
{calculated.total} {pluralize('rule', calculated.total)}
</span>
{!!statsComponents.length && (
<>
<span>: </span>
{statsComponents.reduce<React.ReactNode[]>(
(prev, curr, idx) =>
prev.length
? [
prev,
<Fragment key={idx}>
<span>, </span>
</Fragment>,
curr,
]
: [curr],
[]
)}
</>
)}
</div>
);
};

View File

@ -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 = () => {
/>
</div>
<div className={styles.rowChild}>
<Label>State</Label>
<RadioButtonGroup options={stateOptions} value={alertState} onChange={handleAlertStateChange} />
</div>
<div className={styles.rowChild}>
<Label>View as</Label>
<RadioButtonGroup
options={ViewOptions}
value={queryParams['view'] || 'group'}
onChange={handleViewChange}
/>
</div>
</div>
{(dataSource || alertState || queryString) && (
<div className={styles.flexRow}>

View File

@ -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<Props> = React.memo(({ group, namespace }) => {
const folderUID = (rulerRule && isGrafanaRulerRule(rulerRule) && rulerRule.grafana_alert.namespace_uid) || undefined;
const { folder } = useFolder(folderUID);
const stats = useMemo(
(): Record<PromAlertingRuleState, number> =>
group.rules.reduce<Record<PromAlertingRuleState, number>>(
(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(
<StateColoredText key="firing" status={PromAlertingRuleState.Firing}>
{stats[PromAlertingRuleState.Firing]} firing
</StateColoredText>
);
}
if (stats[PromAlertingRuleState.Pending]) {
statsComponents.push(
<StateColoredText key="pending" status={PromAlertingRuleState.Pending}>
{stats[PromAlertingRuleState.Pending]} pending
</StateColoredText>
);
}
const actionIcons: React.ReactNode[] = [];
// for grafana, link to folder views
@ -112,16 +76,7 @@ export const RulesGroup: FC<Props> = React.memo(({ group, namespace }) => {
<h6 className={styles.heading}>{groupName}</h6>
<div className={styles.spacer} />
<div className={styles.headerStats}>
{group.rules.length} {pluralize('rule', group.rules.length)}
{!!statsComponents.length && (
<>
:{' '}
{statsComponents.reduce<React.ReactNode[]>(
(prev, curr, idx) => (prev.length ? [prev, <Fragment key={idx}>, </Fragment>, curr] : [curr]),
[]
)}
</>
)}
<RuleStats showInactive={false} group={group} />
</div>
{!!actionIcons.length && (
<>
@ -130,7 +85,9 @@ export const RulesGroup: FC<Props> = React.memo(({ group, namespace }) => {
</>
)}
</div>
{!isCollapsed && <RulesTable className={styles.rulesTable} showGuidelines={true} rules={group.rules} />}
{!isCollapsed && (
<RulesTable showSummaryColumn={true} className={styles.rulesTable} showGuidelines={true} rules={group.rules} />
)}
</div>
);
});

View File

@ -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<Props> = ({
showGuidelines = false,
emptyMessage = 'No rules found.',
showGroupColumn = false,
showSummaryColumn = false,
}) => {
const hasRuler = useHasRuler();
@ -49,10 +52,10 @@ export const RulesTable: FC<Props> = ({
<table className={tableStyles.table} data-testid="rules-table">
<colgroup>
<col className={tableStyles.colExpand} />
<col className={styles.colState} />
<col />
<col className={styles.state} />
<col />
<col />
{showSummaryColumn && <col />}
{showGroupColumn && <col />}
</colgroup>
<thead>
@ -62,8 +65,9 @@ export const RulesTable: FC<Props> = ({
</th>
<th>State</th>
<th>Name</th>
<th>Health</th>
{showSummaryColumn && <th>Summary</th>}
{showGroupColumn && <th>Group</th>}
<th>Status</th>
</tr>
</thead>
<tbody>
@ -79,11 +83,16 @@ export const RulesTable: FC<Props> = ({
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 (
<Fragment key={key}>
<tr className={ruleIdx % 2 === 0 ? tableStyles.evenRow : undefined}>
@ -103,19 +112,14 @@ export const RulesTable: FC<Props> = ({
/>
</td>
<td>
{promRule && isAlertingRule(promRule) ? (
<AlertStateTag state={promRule.state} />
) : promRule && isRecordingRule(promRule) ? (
'Recording rule'
) : (
'n/a'
)}
<RuleState rule={rule} isDeleting={isDeleting} isCreating={isCreating} />
</td>
<td>{rule.name}</td>
<td>{promRule && <RuleHealth rule={promRule} />}</td>
{showSummaryColumn && <td>{rule.annotations[Annotation.summary] ?? ''}</td>}
{showGroupColumn && (
<td>{isCloudRulesSource(rulesSource) ? `${namespace.name} > ${group.name}` : namespace.name}</td>
)}
<td>{statuses.join(', ') || 'n/a'}</td>
</tr>
{isExpanded && (
<tr className={ruleIdx % 2 === 0 ? tableStyles.evenRow : undefined}>
@ -124,7 +128,7 @@ export const RulesTable: FC<Props> = ({
<div className={cx(styles.ruleContentGuideline, styles.guideline)} />
)}
</td>
<td colSpan={showGroupColumn ? 4 : 3}>
<td colSpan={detailsColspan}>
<RuleDetails rulesSource={rulesSource} rule={rule} />
</td>
</tr>
@ -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;
`,
});

View File

@ -18,7 +18,6 @@ export type Alert = {
state: PromAlertingRuleState | GrafanaAlertState;
value: string;
};
interface RuleBase {
health: string;
name: string;