diff --git a/public/app/features/alerting/unified/state/actions.ts b/public/app/features/alerting/unified/state/actions.ts index 771cc97d83e..ace635a434d 100644 --- a/public/app/features/alerting/unified/state/actions.ts +++ b/public/app/features/alerting/unified/state/actions.ts @@ -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, diff --git a/public/app/features/alerting/unified/utils/rules.ts b/public/app/features/alerting/unified/utils/rules.ts index c44ba0869d4..af1ad2c6456 100644 --- a/public/app/features/alerting/unified/utils/rules.ts +++ b/public/app/features/alerting/unified/utils/rules.ts @@ -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((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 { diff --git a/public/app/plugins/panel/alertlist/UnifiedAlertList.tsx b/public/app/plugins/panel/alertlist/UnifiedAlertList.tsx index 4e09124d752..a3e034cee50 100644 --- a/public/app/plugins/panel/alertlist/UnifiedAlertList.tsx +++ b/public/app/plugins/panel/alertlist/UnifiedAlertList.tsx @@ -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) { }); 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) { ); } -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, rules: PromRuleWithLocation[]) { +function filterRules(props: PanelProps, 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((rules, rule) => { - const filteredAlerts = (rule.rule.alerts ?? []).filter(({ labels }) => labelsMatchMatchers(labels, matchers)); + filteredRules = filteredRules.reduce((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, 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((rules, rule) => { - const filteredAlerts = filterAlerts(options, rule.rule.alerts ?? []); + filteredRules = filteredRules.reduce((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}; + `, }); diff --git a/public/app/plugins/panel/alertlist/unified-alerting/GroupedView.tsx b/public/app/plugins/panel/alertlist/unified-alerting/GroupedView.tsx index ab96cb715b0..add529b9632 100644 --- a/public/app/plugins/panel/alertlist/unified-alerting/GroupedView.tsx +++ b/public/app/plugins/panel/alertlist/unified-alerting/GroupedView.tsx @@ -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 = ({ rules, options }) => { const groupedRules = useMemo(() => { const groupedRules = new Map(); - 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]); }); } diff --git a/public/app/plugins/panel/alertlist/unified-alerting/UngroupedView.tsx b/public/app/plugins/panel/alertlist/unified-alerting/UngroupedView.tsx index aa18073d09e..d319afa7dc8 100644 --- a/public/app/plugins/panel/alertlist/unified-alerting/UngroupedView.tsx +++ b/public/app/plugins/panel/alertlist/unified-alerting/UngroupedView.tsx @@ -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 = ({ 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,43 +38,71 @@ const UngroupedModeView: FC = ({ rules, options }) => { <>
    {rulesToDisplay.map((ruleWithLocation, index) => { - const { rule, namespaceName, groupName } = ruleWithLocation; - const firstActiveAt = getFirstActiveAt(rule); - return ( -
  1. -
    - -
    -
    -
    -
    - {rule.name} -
    -
    - - {alertStateToReadable(rule.state)} - {' '} - {firstActiveAt && rule.state !== PromAlertingRuleState.Inactive && ( - <> - for{' '} - - {intervalToAbbreviatedDurationString({ - start: firstActiveAt, - end: Date.now(), - })} - - - )} -
    -
    - -
    -
  2. + 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 ( +
  3. +
    + +
    +
    +
    + +
    + {ruleWithLocation.name} +
    + + {href && ( + + + View alert rule + + + + )} +
    +
    + + {alertStateToReadable(alertingRule.state)} + {' '} + {firstActiveAt && alertingRule.state !== PromAlertingRuleState.Inactive && ( + <> + for{' '} + + {intervalToAbbreviatedDurationString({ + start: firstActiveAt, + end: Date.now(), + })} + + + )} +
    +
    + +
    +
  4. + ); + } else { + return null; + } })}
diff --git a/public/app/types/unified-alerting.ts b/public/app/types/unified-alerting.ts index 13037fd637b..927e5c5226c 100644 --- a/public/app/types/unified-alerting.ts +++ b/public/app/types/unified-alerting.ts @@ -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 { rule: T; } +export interface CombinedRuleWithLocation extends CombinedRule { + dataSourceName: string; + namespaceName: string; + groupName: string; +} + export interface PromRuleWithLocation { rule: AlertingRule; dataSourceName: string;