Alerting: Create alert link from dashboard alerting panel (#63648)

* WIP

* feat: update CSS for long names

also adds broken href, to fix later

* Create correct link using CombinedRules

* Use link instead of button for alert link

* Updates from PR review

* Handle loading,haveResults and dispatched state for both promRules and rulerRules

---------

Co-authored-by: Gilles De Mey <gilles.de.mey@gmail.com>
This commit is contained in:
Sonia Aguilar 2023-02-27 15:24:58 +01:00 committed by GitHub
parent eca0a9f487
commit a41e9b2dc7
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
6 changed files with 203 additions and 96 deletions

View File

@ -1,7 +1,7 @@
import { createAsyncThunk, AsyncThunk } from '@reduxjs/toolkit';
import { AsyncThunk, createAsyncThunk } from '@reduxjs/toolkit';
import { isEmpty } from 'lodash';
import { locationService, config } from '@grafana/runtime';
import { config, locationService } from '@grafana/runtime';
import {
AlertmanagerAlert,
AlertManagerCortexConfig,
@ -33,7 +33,7 @@ import {
import { contextSrv } from '../../../../core/core';
import { backendSrv } from '../../../../core/services/backend_srv';
import { logInfo, LogMessages, withPerformanceLogging, trackNewAlerRuleFormSaved } from '../Analytics';
import { logInfo, LogMessages, trackNewAlerRuleFormSaved, withPerformanceLogging } from '../Analytics';
import {
addAlertManagers,
createOrUpdateSilence,

View File

@ -6,6 +6,7 @@ import {
AlertingRule,
CloudRuleIdentifier,
CombinedRuleGroup,
CombinedRuleWithLocation,
GrafanaRuleIdentifier,
PrometheusRuleIdentifier,
PromRuleWithLocation,
@ -26,10 +27,12 @@ import {
RulerRuleDTO,
} from 'app/types/unified-alerting-dto';
import { CombinedRuleNamespace } from '../../../../types/unified-alerting';
import { State } from '../components/StateTag';
import { RuleHealth } from '../search/rulesSearchParser';
import { RULER_NOT_SUPPORTED_MSG } from './constants';
import { getRulesSourceName } from './datasource';
import { AsyncRequestState } from './redux';
export function isAlertingRule(rule: Rule | undefined): rule is AlertingRule {
@ -112,6 +115,22 @@ export const flattenRules = (rules: RuleNamespace[]) => {
}, []);
};
export const getAlertingRule = (rule: CombinedRuleWithLocation) =>
isAlertingRule(rule.promRule) ? rule.promRule : null;
export const flattenCombinedRules = (rules: CombinedRuleNamespace[]) => {
return rules.reduce<CombinedRuleWithLocation[]>((acc, { rulesSource, name: namespaceName, groups }) => {
groups.forEach(({ name: groupName, rules }) => {
rules.forEach((rule) => {
if (rule.promRule && isAlertingRule(rule.promRule)) {
acc.push({ dataSourceName: getRulesSourceName(rulesSource), namespaceName, groupName, ...rule });
}
});
});
return acc;
}, []);
};
export function alertStateToState(state: PromAlertingRuleState | GrafanaAlertStateWithReason | AlertState): State {
let key: PromAlertingRuleState | GrafanaAlertState | AlertState;
if (Object.values(AlertState).includes(state as AlertState)) {
@ -140,8 +159,8 @@ const alertStateToStateMap: Record<PromAlertingRuleState | GrafanaAlertState | A
[AlertState.Unknown]: 'info',
};
export function getFirstActiveAt(promRule: AlertingRule) {
if (!promRule.alerts) {
export function getFirstActiveAt(promRule?: AlertingRule) {
if (!promRule?.alerts) {
return null;
}
return promRule.alerts.reduce((prev, alert) => {

View File

@ -18,8 +18,9 @@ import {
import { config } from 'app/core/config';
import { contextSrv } from 'app/core/services/context_srv';
import alertDef from 'app/features/alerting/state/alertDef';
import { useCombinedRuleNamespaces } from 'app/features/alerting/unified/hooks/useCombinedRuleNamespaces';
import { useUnifiedAlertingSelector } from 'app/features/alerting/unified/hooks/useUnifiedAlertingSelector';
import { fetchAllPromRulesAction } from 'app/features/alerting/unified/state/actions';
import { fetchAllPromAndRulerRulesAction } from 'app/features/alerting/unified/state/actions';
import { labelsMatchMatchers, parseMatchers } from 'app/features/alerting/unified/utils/alertmanager';
import { Annotation } from 'app/features/alerting/unified/utils/constants';
import {
@ -27,13 +28,16 @@ import {
GRAFANA_DATASOURCE_NAME,
GRAFANA_RULES_SOURCE_NAME,
} from 'app/features/alerting/unified/utils/datasource';
import { flattenRules, getFirstActiveAt } from 'app/features/alerting/unified/utils/rules';
import { initialAsyncRequestState } from 'app/features/alerting/unified/utils/redux';
import { flattenCombinedRules, getFirstActiveAt } from 'app/features/alerting/unified/utils/rules';
import { getDashboardSrv } from 'app/features/dashboard/services/DashboardSrv';
import { DashboardModel } from 'app/features/dashboard/state';
import { useDispatch, AccessControlAction } from 'app/types';
import { PromRuleWithLocation } from 'app/types/unified-alerting';
import { AccessControlAction, useDispatch } from 'app/types';
import { PromAlertingRuleState } from 'app/types/unified-alerting-dto';
import { getAlertingRule } from '../../../features/alerting/unified/utils/rules';
import { AlertingRule, CombinedRuleWithLocation } from '../../../types/unified-alerting';
import { GroupMode, SortOrder, UnifiedAlertListOptions, ViewMode } from './types';
import GroupedModeView from './unified-alerting/GroupedView';
import UngroupedModeView from './unified-alerting/UngroupedView';
@ -58,33 +62,38 @@ export function UnifiedAlertList(props: PanelProps<UnifiedAlertListOptions>) {
});
useEffect(() => {
dispatch(fetchAllPromRulesAction());
const sub = dashboard?.events.subscribe(TimeRangeUpdatedEvent, () => dispatch(fetchAllPromRulesAction()));
//we need promRules and rulerRules for getting the uid when creating the alert link in panel in case of being a rulerRule.
dispatch(fetchAllPromAndRulerRulesAction());
const sub = dashboard?.events.subscribe(TimeRangeUpdatedEvent, () => dispatch(fetchAllPromAndRulerRulesAction()));
return () => {
sub?.unsubscribe();
};
}, [dispatch, dashboard]);
const promRulesRequests = useUnifiedAlertingSelector((state) => state.promRules);
const { prom, ruler } = useUnifiedAlertingSelector((state) => ({
prom: state.promRules[GRAFANA_RULES_SOURCE_NAME] || initialAsyncRequestState,
ruler: state.rulerRules[GRAFANA_RULES_SOURCE_NAME] || initialAsyncRequestState,
}));
const dispatched = rulesDataSourceNames.some((name) => promRulesRequests[name]?.dispatched);
const loading = rulesDataSourceNames.some((name) => promRulesRequests[name]?.loading);
const haveResults = rulesDataSourceNames.some(
(name) => promRulesRequests[name]?.result?.length && !promRulesRequests[name]?.error
);
const loading = prom.loading || ruler.loading;
const haveResults = !!prom.result || !!ruler.result;
const promRulesRequests = useUnifiedAlertingSelector((state) => state.promRules);
const rulerRulesRequests = useUnifiedAlertingSelector((state) => state.rulerRules);
const combinedRules = useCombinedRuleNamespaces();
const somePromRulesDispatched = rulesDataSourceNames.some((name) => promRulesRequests[name]?.dispatched);
const someRulerRulesDispatched = rulesDataSourceNames.some((name) => rulerRulesRequests[name]?.dispatched);
const dispatched = somePromRulesDispatched || someRulerRulesDispatched;
const styles = useStyles2(getStyles);
const flattenedCombinedRules = flattenCombinedRules(combinedRules);
const order = props.options.sortOrder;
const rules = useMemo(
() =>
filterRules(
props,
sortRules(
props.options.sortOrder,
Object.values(promRulesRequests).flatMap(({ result = [] }) => flattenRules(result))
)
),
[props, promRulesRequests]
() => filterRules(props, sortRules(order, flattenedCombinedRules)),
[flattenedCombinedRules, order, props]
);
const noAlertsMessage = rules.length === 0 ? 'No alerts matching filters' : undefined;
@ -127,16 +136,24 @@ export function UnifiedAlertList(props: PanelProps<UnifiedAlertListOptions>) {
);
}
function sortRules(sortOrder: SortOrder, rules: PromRuleWithLocation[]) {
function sortRules(sortOrder: SortOrder, rules: CombinedRuleWithLocation[]) {
if (sortOrder === SortOrder.Importance) {
// @ts-ignore
return sortBy(rules, (rule) => alertDef.alertStateSortScore[rule.state]);
} else if (sortOrder === SortOrder.TimeAsc) {
return sortBy(rules, (rule) => getFirstActiveAt(rule.rule) || new Date());
return sortBy(rules, (rule) => {
//at this point rules are all AlertingRule, this check is only needed for Typescript checks
const alertingRule: AlertingRule | undefined = getAlertingRule(rule) ?? undefined;
return getFirstActiveAt(alertingRule) || new Date();
});
} else if (sortOrder === SortOrder.TimeDesc) {
return sortBy(rules, (rule) => getFirstActiveAt(rule.rule) || new Date()).reverse();
return sortBy(rules, (rule) => {
//at this point rules are all AlertingRule, this check is only needed for Typescript checks
const alertingRule: AlertingRule | undefined = getAlertingRule(rule) ?? undefined;
return getFirstActiveAt(alertingRule) || new Date();
}).reverse();
}
const result = sortBy(rules, (rule) => rule.rule.name.toLowerCase());
const result = sortBy(rules, (rule) => rule.name.toLowerCase());
if (sortOrder === SortOrder.AlphaDesc) {
result.reverse();
}
@ -144,39 +161,46 @@ function sortRules(sortOrder: SortOrder, rules: PromRuleWithLocation[]) {
return result;
}
function filterRules(props: PanelProps<UnifiedAlertListOptions>, rules: PromRuleWithLocation[]) {
function filterRules(props: PanelProps<UnifiedAlertListOptions>, rules: CombinedRuleWithLocation[]) {
const { options, replaceVariables } = props;
let filteredRules = [...rules];
if (options.dashboardAlerts) {
const dashboardUid = getDashboardSrv().getCurrent()?.uid;
filteredRules = filteredRules.filter(({ rule: { annotations = {} } }) =>
filteredRules = filteredRules.filter(({ annotations = {} }) =>
Object.entries(annotations).some(([key, value]) => key === Annotation.dashboardUID && value === dashboardUid)
);
}
if (options.alertName) {
const replacedName = replaceVariables(options.alertName);
filteredRules = filteredRules.filter(({ rule: { name } }) =>
filteredRules = filteredRules.filter(({ name }) =>
name.toLocaleLowerCase().includes(replacedName.toLocaleLowerCase())
);
}
filteredRules = filteredRules.filter((rule) => {
const alertingRule = getAlertingRule(rule);
if (!alertingRule) {
return false;
}
return (
(options.stateFilter.firing && rule.rule.state === PromAlertingRuleState.Firing) ||
(options.stateFilter.pending && rule.rule.state === PromAlertingRuleState.Pending) ||
(options.stateFilter.normal && rule.rule.state === PromAlertingRuleState.Inactive)
(options.stateFilter.firing && alertingRule.state === PromAlertingRuleState.Firing) ||
(options.stateFilter.pending && alertingRule.state === PromAlertingRuleState.Pending) ||
(options.stateFilter.normal && alertingRule.state === PromAlertingRuleState.Inactive)
);
});
if (options.alertInstanceLabelFilter) {
const replacedLabelFilter = replaceVariables(options.alertInstanceLabelFilter);
const matchers = parseMatchers(replacedLabelFilter);
// Reduce rules and instances to only those that match
filteredRules = filteredRules.reduce<PromRuleWithLocation[]>((rules, rule) => {
const filteredAlerts = (rule.rule.alerts ?? []).filter(({ labels }) => labelsMatchMatchers(labels, matchers));
filteredRules = filteredRules.reduce<CombinedRuleWithLocation[]>((rules, rule) => {
const alertingRule = getAlertingRule(rule);
const filteredAlerts = (alertingRule?.alerts ?? []).filter(({ labels }) => labelsMatchMatchers(labels, matchers));
if (filteredAlerts.length) {
rules.push({ ...rule, rule: { ...rule.rule, alerts: filteredAlerts } });
const alertRule: AlertingRule | null = getAlertingRule(rule);
alertRule && rules.push({ ...rule, promRule: { ...alertRule, alerts: filteredAlerts } });
}
return rules;
}, []);
@ -200,8 +224,9 @@ function filterRules(props: PanelProps<UnifiedAlertListOptions>, rules: PromRule
// Remove rules having 0 instances
// AlertInstances filters instances and we need to prevent situation
// when we display a rule with 0 instances
filteredRules = filteredRules.reduce<PromRuleWithLocation[]>((rules, rule) => {
const filteredAlerts = filterAlerts(options, rule.rule.alerts ?? []);
filteredRules = filteredRules.reduce<CombinedRuleWithLocation[]>((rules, rule) => {
const alertingRule = getAlertingRule(rule);
const filteredAlerts = alertingRule ? filterAlerts(options, alertingRule.alerts ?? []) : [];
if (filteredAlerts.length) {
// We intentionally don't set alerts to filteredAlerts
// because later we couldn't display that some alerts are hidden (ref AlertInstances filtering)
@ -239,13 +264,23 @@ export const getStyles = (theme: GrafanaTheme2) => ({
border-radius: ${theme.shape.borderRadius(2)};
margin-bottom: ${theme.spacing(0.5)};
& > * {
margin-right: ${theme.spacing(1)};
}
gap: ${theme.spacing(2)};
`,
alertName: css`
font-size: ${theme.typography.h6.fontSize};
font-weight: ${theme.typography.fontWeightBold};
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
`,
alertNameWrapper: css`
display: flex;
flex: 1;
flex-wrap: nowrap;
flex-direction: column;
min-width: 100px;
`,
alertLabels: css`
> * {
@ -290,4 +325,8 @@ export const getStyles = (theme: GrafanaTheme2) => ({
customGroupDetails: css`
margin-bottom: ${theme.spacing(0.5)};
`,
link: css`
word-break: break-all;
color: ${theme.colors.primary.text};
`,
});

View File

@ -2,15 +2,17 @@ import React, { FC, useMemo } from 'react';
import { useStyles2 } from '@grafana/ui';
import { AlertLabel } from 'app/features/alerting/unified/components/AlertLabel';
import { Alert, PromRuleWithLocation } from 'app/types/unified-alerting';
import { getAlertingRule } from 'app/features/alerting/unified/utils/rules';
import { Alert } from 'app/types/unified-alerting';
import { AlertingRule, CombinedRuleWithLocation } from '../../../../types/unified-alerting';
import { AlertInstances } from '../AlertInstances';
import { getStyles } from '../UnifiedAlertList';
import { GroupedRules, UnifiedAlertListOptions } from '../types';
import { filterAlerts } from '../util';
type GroupedModeProps = {
rules: PromRuleWithLocation[];
rules: CombinedRuleWithLocation[];
options: UnifiedAlertListOptions;
};
@ -22,12 +24,13 @@ const GroupedModeView: FC<GroupedModeProps> = ({ rules, options }) => {
const groupedRules = useMemo<GroupedRules>(() => {
const groupedRules = new Map<string, Alert[]>();
const hasInstancesWithMatchingLabels = (rule: PromRuleWithLocation) =>
groupBy ? alertHasEveryLabel(rule, groupBy) : true;
const hasInstancesWithMatchingLabels = (rule: CombinedRuleWithLocation) =>
groupBy ? alertHasEveryLabelForCombinedRules(rule, groupBy) : true;
const matchingRules = rules.filter(hasInstancesWithMatchingLabels);
matchingRules.forEach((rule: PromRuleWithLocation) => {
(rule.rule.alerts ?? []).forEach((alert) => {
matchingRules.forEach((rule: CombinedRuleWithLocation) => {
const alertingRule: AlertingRule | null = getAlertingRule(rule);
(alertingRule?.alerts ?? []).forEach((alert) => {
const mapKey = createMapKey(groupBy, alert.labels);
const existingAlerts = groupedRules.get(mapKey) ?? [];
groupedRules.set(mapKey, [...existingAlerts, alert]);
@ -75,9 +78,10 @@ function parseMapKey(key: string): Array<[string, string]> {
return [...new URLSearchParams(key)];
}
function alertHasEveryLabel(rule: PromRuleWithLocation, groupByKeys: string[]) {
function alertHasEveryLabelForCombinedRules(rule: CombinedRuleWithLocation, groupByKeys: string[]) {
const alertingRule: AlertingRule | null = getAlertingRule(rule);
return groupByKeys.every((key) => {
return (rule.rule.alerts ?? []).some((alert) => alert.labels[key]);
return (alertingRule?.alerts ?? []).some((alert) => alert.labels[key]);
});
}

View File

@ -1,25 +1,36 @@
import { css } from '@emotion/css';
import React, { FC } from 'react';
import { useLocation } from 'react-use';
import { GrafanaTheme2, intervalToAbbreviatedDurationString } from '@grafana/data';
import { Stack } from '@grafana/experimental';
import { Icon, useStyles2 } from '@grafana/ui';
import alertDef from 'app/features/alerting/state/alertDef';
import { alertStateToReadable, alertStateToState, getFirstActiveAt } from 'app/features/alerting/unified/utils/rules';
import { PromRuleWithLocation } from 'app/types/unified-alerting';
import { Spacer } from 'app/features/alerting/unified/components/Spacer';
import { fromCombinedRule, stringifyIdentifier } from 'app/features/alerting/unified/utils/rule-id';
import {
alertStateToReadable,
alertStateToState,
getFirstActiveAt,
isAlertingRule,
} from 'app/features/alerting/unified/utils/rules';
import { createUrl } from 'app/features/alerting/unified/utils/url';
import { PromAlertingRuleState } from 'app/types/unified-alerting-dto';
import { AlertingRule, CombinedRuleWithLocation } from '../../../../types/unified-alerting';
import { AlertInstances } from '../AlertInstances';
import { getStyles } from '../UnifiedAlertList';
import { UnifiedAlertListOptions } from '../types';
type UngroupedModeProps = {
rules: PromRuleWithLocation[];
rules: CombinedRuleWithLocation[];
options: UnifiedAlertListOptions;
};
const UngroupedModeView: FC<UngroupedModeProps> = ({ rules, options }) => {
const styles = useStyles2(getStyles);
const stateStyle = useStyles2(getStateTagStyles);
const { href: returnTo } = useLocation();
const rulesToDisplay = rules.length <= options.maxItems ? rules : rules.slice(0, options.maxItems);
@ -27,27 +38,52 @@ const UngroupedModeView: FC<UngroupedModeProps> = ({ rules, options }) => {
<>
<ol className={styles.alertRuleList}>
{rulesToDisplay.map((ruleWithLocation, index) => {
const { rule, namespaceName, groupName } = ruleWithLocation;
const firstActiveAt = getFirstActiveAt(rule);
const { namespaceName, groupName, dataSourceName } = ruleWithLocation;
const alertingRule: AlertingRule | undefined = isAlertingRule(ruleWithLocation.promRule)
? ruleWithLocation.promRule
: undefined;
const firstActiveAt = getFirstActiveAt(alertingRule);
const indentifier = fromCombinedRule(ruleWithLocation.dataSourceName, ruleWithLocation);
const strIndentifier = stringifyIdentifier(indentifier);
const href = createUrl(
`/alerting/${encodeURIComponent(dataSourceName)}/${encodeURIComponent(strIndentifier)}/view`,
{ returnTo: returnTo ?? '' }
);
if (alertingRule) {
return (
<li className={styles.alertRuleItem} key={`alert-${namespaceName}-${groupName}-${rule.name}-${index}`}>
<li
className={styles.alertRuleItem}
key={`alert-${namespaceName}-${groupName}-${ruleWithLocation.name}-${index}`}
>
<div className={stateStyle.icon}>
<Icon
name={alertDef.getStateDisplayModel(rule.state).iconClass}
className={stateStyle[alertStateToState(rule.state)]}
name={alertDef.getStateDisplayModel(alertingRule.state).iconClass}
className={stateStyle[alertStateToState(alertingRule.state)]}
size={'lg'}
/>
</div>
<div>
<div className={styles.alertNameWrapper}>
<div className={styles.instanceDetails}>
<div className={styles.alertName} title={rule.name}>
{rule.name}
<Stack direction="row" gap={1} wrap={false}>
<div className={styles.alertName} title={ruleWithLocation.name}>
{ruleWithLocation.name}
</div>
<Spacer />
{href && (
<a href={href} target="__blank" className={styles.link} rel="noopener">
<Stack alignItems="center" gap={1}>
View alert rule
<Icon name={'external-link-alt'} size="sm" />
</Stack>
</a>
)}
</Stack>
<div className={styles.alertDuration}>
<span className={stateStyle[alertStateToState(rule.state)]}>
{alertStateToReadable(rule.state)}
<span className={stateStyle[alertStateToState(alertingRule.state)]}>
{alertStateToReadable(alertingRule.state)}
</span>{' '}
{firstActiveAt && rule.state !== PromAlertingRuleState.Inactive && (
{firstActiveAt && alertingRule.state !== PromAlertingRuleState.Inactive && (
<>
for{' '}
<span>
@ -60,10 +96,13 @@ const UngroupedModeView: FC<UngroupedModeProps> = ({ rules, options }) => {
)}
</div>
</div>
<AlertInstances alerts={ruleWithLocation.rule.alerts ?? []} options={options} />
<AlertInstances alerts={alertingRule.alerts ?? []} options={options} />
</div>
</li>
);
} else {
return null;
}
})}
</ol>
</>

View File

@ -3,15 +3,15 @@
import { AlertState, DataSourceInstanceSettings } from '@grafana/data';
import {
Annotations,
GrafanaAlertState,
GrafanaAlertStateWithReason,
Labels,
mapStateWithReasonToBaseState,
PromAlertingRuleState,
PromRuleType,
RulerRuleDTO,
Labels,
Annotations,
RulerRuleGroupDTO,
GrafanaAlertState,
GrafanaAlertStateWithReason,
mapStateWithReasonToBaseState,
} from './unified-alerting-dto';
export type Alert = {
@ -111,6 +111,12 @@ export interface RuleWithLocation<T = RulerRuleDTO> {
rule: T;
}
export interface CombinedRuleWithLocation extends CombinedRule {
dataSourceName: string;
namespaceName: string;
groupName: string;
}
export interface PromRuleWithLocation {
rule: AlertingRule;
dataSourceName: string;