diff --git a/public/app/features/alerting/unified/AlertsFolderView.test.tsx b/public/app/features/alerting/unified/AlertsFolderView.test.tsx index 7c09245f377..f7de4aedb8d 100644 --- a/public/app/features/alerting/unified/AlertsFolderView.test.tsx +++ b/public/app/features/alerting/unified/AlertsFolderView.test.tsx @@ -58,6 +58,7 @@ describe('AlertsFolderView tests', () => { mockCombinedRule({ name: 'Test Alert 2' }), mockCombinedRule({ name: 'Test Alert 3' }), ], + totals: {}, }, { name: 'group2', @@ -66,6 +67,7 @@ describe('AlertsFolderView tests', () => { mockCombinedRule({ name: 'Test Alert 5' }), mockCombinedRule({ name: 'Test Alert 6' }), ], + totals: {}, }, ], }; @@ -104,6 +106,7 @@ describe('AlertsFolderView tests', () => { mockCombinedRule({ name: 'Test Alert from other folder 1' }), mockCombinedRule({ name: 'Test Alert from other folder 2' }), ], + totals: {}, }, ], }; @@ -132,6 +135,7 @@ describe('AlertsFolderView tests', () => { { name: 'default', rules: [mockCombinedRule({ name: 'CPU Alert' }), mockCombinedRule({ name: 'RAM usage alert' })], + totals: {}, }, ], }; @@ -166,6 +170,7 @@ describe('AlertsFolderView tests', () => { mockCombinedRule({ name: 'CPU Alert', labels: {} }), mockCombinedRule({ name: 'RAM usage alert', labels: { severity: 'critical' } }), ], + totals: {}, }, ], }; diff --git a/public/app/features/alerting/unified/PanelAlertTabContent.test.tsx b/public/app/features/alerting/unified/PanelAlertTabContent.test.tsx index 7745083f61a..c350e122493 100644 --- a/public/app/features/alerting/unified/PanelAlertTabContent.test.tsx +++ b/public/app/features/alerting/unified/PanelAlertTabContent.test.tsx @@ -319,10 +319,16 @@ describe('PanelAlertTabContent', () => { panelId: panel.id, } ); - expect(mocks.api.fetchRules).toHaveBeenCalledWith(GRAFANA_RULES_SOURCE_NAME, { - dashboardUID: dashboard.uid, - panelId: panel.id, - }); + expect(mocks.api.fetchRules).toHaveBeenCalledWith( + GRAFANA_RULES_SOURCE_NAME, + { + dashboardUID: dashboard.uid, + panelId: panel.id, + }, + undefined, + undefined, + undefined + ); }); it('Update NewRuleFromPanel button url when template changes', async () => { diff --git a/public/app/features/alerting/unified/RedirectToRuleViewer.test.tsx b/public/app/features/alerting/unified/RedirectToRuleViewer.test.tsx index 2dcbcf1a381..36d917e6dca 100644 --- a/public/app/features/alerting/unified/RedirectToRuleViewer.test.tsx +++ b/public/app/features/alerting/unified/RedirectToRuleViewer.test.tsx @@ -10,8 +10,8 @@ import { CombinedRule, Rule } from '../../../types/unified-alerting'; import { PromRuleType } from '../../../types/unified-alerting-dto'; import { RedirectToRuleViewer } from './RedirectToRuleViewer'; -import { useCombinedRulesMatching } from './hooks/useCombinedRule'; import * as combinedRuleHooks from './hooks/useCombinedRule'; +import { useCombinedRulesMatching } from './hooks/useCombinedRule'; import { getRulesSourceByName } from './utils/datasource'; jest.mock('./hooks/useCombinedRule'); @@ -119,6 +119,7 @@ const mockedRules: CombinedRule[] = [ group: { name: 'test', rules: [], + totals: {}, }, promRule: { health: 'ok', @@ -140,6 +141,8 @@ const mockedRules: CombinedRule[] = [ readOnly: false, }, }, + instanceTotals: {}, + filteredInstanceTotals: {}, }, { name: 'Cloud test alert', @@ -149,6 +152,7 @@ const mockedRules: CombinedRule[] = [ group: { name: 'test', rules: [], + totals: {}, }, promRule: { health: 'ok', @@ -170,5 +174,7 @@ const mockedRules: CombinedRule[] = [ readOnly: false, }, }, + instanceTotals: {}, + filteredInstanceTotals: {}, }, ]; diff --git a/public/app/features/alerting/unified/RuleList.tsx b/public/app/features/alerting/unified/RuleList.tsx index ca3236c7be4..1a566e19dc6 100644 --- a/public/app/features/alerting/unified/RuleList.tsx +++ b/public/app/features/alerting/unified/RuleList.tsx @@ -15,6 +15,7 @@ import { CombinedRuleNamespace } from '../../../types/unified-alerting'; import { LogMessages } from './Analytics'; import { AlertingPageWrapper } from './components/AlertingPageWrapper'; import { NoRulesSplash } from './components/rules/NoRulesCTA'; +import { INSTANCES_DISPLAY_LIMIT } from './components/rules/RuleDetails'; import { RuleListErrors } from './components/rules/RuleListErrors'; import { RuleListGroupView } from './components/rules/RuleListGroupView'; import { RuleListStateView } from './components/rules/RuleListStateView'; @@ -34,6 +35,9 @@ const VIEWS = { state: RuleListStateView, }; +// make sure we ask for 1 more so we show the "show x more" button +const LIMIT_ALERTS = INSTANCES_DISPLAY_LIMIT + 1; + const RuleList = withErrorBoundary( () => { const dispatch = useDispatch(); @@ -68,17 +72,18 @@ const RuleList = withErrorBoundary( ); const allPromEmpty = promRequests.every(([_, state]) => state.dispatched && state?.result?.length === 0); + const limitAlerts = hasActiveFilters ? undefined : LIMIT_ALERTS; // Trigger data refresh only when the RULE_LIST_POLL_INTERVAL_MS elapsed since the previous load FINISHED const [_, fetchRules] = useAsyncFn(async () => { if (!loading) { - await dispatch(fetchAllPromAndRulerRulesAction()); + await dispatch(fetchAllPromAndRulerRulesAction(false, { limitAlerts })); } - }, [loading]); + }, [loading, limitAlerts, dispatch]); // fetch rules, then poll every RULE_LIST_POLL_INTERVAL_MS useEffect(() => { - dispatch(fetchAllPromAndRulerRulesAction()); - }, [dispatch]); + dispatch(fetchAllPromAndRulerRulesAction(false, { limitAlerts })); + }, [dispatch, limitAlerts]); useInterval(fetchRules, RULE_LIST_POLL_INTERVAL_MS); // Show splash only when we loaded all of the data sources and none of them has alerts @@ -108,7 +113,7 @@ const RuleList = withErrorBoundary( {expandAll ? 'Collapse all' : 'Expand all'} )} - + {canReadProvisioning && ( diff --git a/public/app/features/alerting/unified/RuleViewer.tsx b/public/app/features/alerting/unified/RuleViewer.tsx index 1737b144d92..5878dad64bf 100644 --- a/public/app/features/alerting/unified/RuleViewer.tsx +++ b/public/app/features/alerting/unified/RuleViewer.tsx @@ -199,7 +199,11 @@ export function RuleViewer({ match }: RuleViewerProps) {
- +
JSON.stringify(m)); +const matchers = [...matcher, { name: 'label1', isRegex: false, isEqual: true, value: 'hello there' }]; +const matchersToJson = matchers.map((m) => JSON.stringify(m)); + +describe('paramsWithMatcherAndState method', () => { + it('Should return same params object with no changes if there are no states nor matchers', () => { + const params: Record = { hello: 'there', bye: 'bye' }; + expect(paramsWithMatcherAndState(params)).toStrictEqual(params); + }); + it('Should return params object with state if there are states and no matchers', () => { + const params: Record = { hello: 'there', bye: 'bye' }; + const state: string[] = ['firing', 'pending']; + expect(paramsWithMatcherAndState(params, state)).toStrictEqual({ ...params, state: state }); + }); + it('Should return params object with state if there are matchers and no states', () => { + const params: Record = { hello: 'there', bye: 'bye' }; + expect(paramsWithMatcherAndState(params, undefined, matcher)).toStrictEqual({ + ...params, + matcher: matcherToJson, + }); + expect(paramsWithMatcherAndState(params, undefined, matchers)).toStrictEqual({ + ...params, + matcher: matchersToJson, + }); + }); + it('Should return params object with stateand matchers if there are states and matchers', () => { + const params: Record = { hello: 'there', bye: 'bye' }; + const state: string[] = ['firing', 'pending']; + expect(paramsWithMatcherAndState(params, state, matchers)).toStrictEqual({ + ...params, + state: state, + matcher: matchersToJson, + }); + }); +}); diff --git a/public/app/features/alerting/unified/api/prometheus.ts b/public/app/features/alerting/unified/api/prometheus.ts index b14373b5fc7..1e7a88c7d0f 100644 --- a/public/app/features/alerting/unified/api/prometheus.ts +++ b/public/app/features/alerting/unified/api/prometheus.ts @@ -1,6 +1,7 @@ import { lastValueFrom } from 'rxjs'; import { getBackendSrv } from '@grafana/runtime'; +import { Matcher } from 'app/plugins/datasource/alertmanager/types'; import { RuleNamespace } from 'app/types/unified-alerting'; import { PromRulesResponse } from 'app/types/unified-alerting-dto'; @@ -13,19 +14,27 @@ export interface FetchPromRulesFilter { export interface PrometheusDataSourceConfig { dataSourceName: string; + limitAlerts?: number; } export function prometheusUrlBuilder(dataSourceConfig: PrometheusDataSourceConfig) { - const { dataSourceName } = dataSourceConfig; + const { dataSourceName, limitAlerts } = dataSourceConfig; return { - rules: (filter?: FetchPromRulesFilter) => { + rules: (filter?: FetchPromRulesFilter, state?: string[], matcher?: Matcher[]) => { const searchParams = new URLSearchParams(); + + // if we're fetching for Grafana managed rules, we should add a limit to the number of alert instances + // we do this because the response is large otherwise and we don't show all of them in the UI anyway. + if (dataSourceName === GRAFANA_RULES_SOURCE_NAME && limitAlerts) { + searchParams.set('limit_alerts', String(limitAlerts)); + } + const params = prepareRulesFilterQueryParams(searchParams, filter); return { url: `/api/prometheus/${getDatasourceAPIUid(dataSourceName)}/api/v1/rules`, - params: params, + params: paramsWithMatcherAndState(params, state, matcher), }; }, }; @@ -45,17 +54,46 @@ export function prepareRulesFilterQueryParams( return Object.fromEntries(params); } -export async function fetchRules(dataSourceName: string, filter?: FetchPromRulesFilter): Promise { +export function paramsWithMatcherAndState( + params: Record, + state?: string[], + matchers?: Matcher[] +) { + let paramsResult = { ...params }; + + if (state?.length) { + paramsResult = { ...paramsResult, state }; + } + + if (matchers?.length) { + const matcherToJsonString: string[] = matchers.map((m) => JSON.stringify(m)); + paramsResult = { + ...paramsResult, + matcher: matcherToJsonString, + }; + } + + return paramsResult; +} + +export async function fetchRules( + dataSourceName: string, + filter?: FetchPromRulesFilter, + limitAlerts?: number, + matcher?: Matcher[], + state?: string[] +): Promise { if (filter?.dashboardUID && dataSourceName !== GRAFANA_RULES_SOURCE_NAME) { throw new Error('Filtering by dashboard UID is only supported for Grafana Managed rules.'); } - const { url, params } = prometheusUrlBuilder({ dataSourceName }).rules(filter); + const { url, params } = prometheusUrlBuilder({ dataSourceName, limitAlerts }).rules(filter, state, matcher); + // adding state param here instead of adding it in prometheusUrlBuilder, for being a possible multiple query param const response = await lastValueFrom( getBackendSrv().fetch({ url, - params, + params: params, showErrorAlert: false, showSuccessAlert: false, }) diff --git a/public/app/features/alerting/unified/components/rule-editor/GrafanaEvaluationBehavior.tsx b/public/app/features/alerting/unified/components/rule-editor/GrafanaEvaluationBehavior.tsx index a1d5feab2b2..94bb9d33f1a 100644 --- a/public/app/features/alerting/unified/components/rule-editor/GrafanaEvaluationBehavior.tsx +++ b/public/app/features/alerting/unified/components/rule-editor/GrafanaEvaluationBehavior.tsx @@ -162,7 +162,7 @@ function FolderGroupAndEvaluationInterval({ rulesSource: GRAFANA_RULES_SOURCE_NAME, groups: [], }; - const emptyGroup: CombinedRuleGroup = { name: groupName, interval: evaluateEvery, rules: [] }; + const emptyGroup: CombinedRuleGroup = { name: groupName, interval: evaluateEvery, rules: [], totals: {} }; return (
diff --git a/public/app/features/alerting/unified/components/rules/EditRuleGroupModal.test.tsx b/public/app/features/alerting/unified/components/rules/EditRuleGroupModal.test.tsx index 79c54e4023f..77631b55177 100644 --- a/public/app/features/alerting/unified/components/rules/EditRuleGroupModal.test.tsx +++ b/public/app/features/alerting/unified/components/rules/EditRuleGroupModal.test.tsx @@ -60,7 +60,7 @@ describe('EditGroupModal', () => { const namespace = mockCombinedRuleNamespace({ name: 'my-alerts', rulesSource: mockDataSource(), - groups: [{ name: 'default-group', interval: '90s', rules: [] }], + groups: [{ name: 'default-group', interval: '90s', rules: [], totals: {} }], }); const group = namespace.groups[0]; @@ -100,7 +100,9 @@ describe('EditGroupModal component on cloud alert rules', () => { const promNs = mockCombinedRuleNamespace({ name: 'prometheus-ns', rulesSource: promDsSettings, - groups: [{ name: 'default-group', interval: '90s', rules: [alertingRule, recordingRule1, recordingRule2] }], + groups: [ + { name: 'default-group', interval: '90s', rules: [alertingRule, recordingRule1, recordingRule2], totals: {} }, + ], }); const group = promNs.groups[0]; @@ -121,7 +123,7 @@ describe('EditGroupModal component on cloud alert rules', () => { const promNs = mockCombinedRuleNamespace({ name: 'prometheus-ns', rulesSource: promDsSettings, - groups: [{ name: 'default-group', interval: '90s', rules: [recordingRule1, recordingRule2] }], + groups: [{ name: 'default-group', interval: '90s', rules: [recordingRule1, recordingRule2], totals: {} }], }); const group = promNs.groups[0]; @@ -154,6 +156,7 @@ describe('EditGroupModal component on grafana-managed alert rules', () => { rulerRule: mockRulerAlertingRule({ alert: 'high-memory' }), }), ], + totals: {}, }, ], }; diff --git a/public/app/features/alerting/unified/components/rules/RuleDetails.tsx b/public/app/features/alerting/unified/components/rules/RuleDetails.tsx index 7899ac4ac30..c38efc5bd45 100644 --- a/public/app/features/alerting/unified/components/rules/RuleDetails.tsx +++ b/public/app/features/alerting/unified/components/rules/RuleDetails.tsx @@ -25,7 +25,7 @@ interface Props { // The limit is set to 15 in order to upkeep the good performance // and to encourage users to go to the rule details page to see the rest of the instances // We don't want to paginate the instances list on the alert list page -const INSTANCES_DISPLAY_LIMIT = 15; +export const INSTANCES_DISPLAY_LIMIT = 15; export const RuleDetails = ({ rule }: Props) => { const styles = useStyles2(getStyles); diff --git a/public/app/features/alerting/unified/components/rules/RuleDetailsMatchingInstances.test.tsx b/public/app/features/alerting/unified/components/rules/RuleDetailsMatchingInstances.test.tsx index 4c7cfd599b7..d3d5b197067 100644 --- a/public/app/features/alerting/unified/components/rules/RuleDetailsMatchingInstances.test.tsx +++ b/public/app/features/alerting/unified/components/rules/RuleDetailsMatchingInstances.test.tsx @@ -32,7 +32,7 @@ describe('RuleDetailsMatchingInstances', () => { it('For Grafana Managed rules instances filter should contain five states', () => { const rule = mockCombinedRule(); - render(); + render(); const stateFilter = ui.stateFilter.get(); expect(stateFilter).toBeInTheDocument(); @@ -69,7 +69,7 @@ describe('RuleDetailsMatchingInstances', () => { [GrafanaAlertState.Error]: ui.grafanaStateButton.error, }; - render(); + render(); await userEvent.click(buttons[state].get()); @@ -82,7 +82,7 @@ describe('RuleDetailsMatchingInstances', () => { namespace: mockPromNamespace(), }); - render(); + render(); const stateFilter = ui.stateFilter.get(); expect(stateFilter).toBeInTheDocument(); @@ -108,7 +108,7 @@ describe('RuleDetailsMatchingInstances', () => { }), }); - render(); + render(); await userEvent.click(ui.cloudStateButton[state].get()); @@ -122,7 +122,7 @@ describe('RuleDetailsMatchingInstances', () => { function mockPromNamespace(): CombinedRuleNamespace { return { rulesSource: mockDataSource(), - groups: [{ name: 'Prom rules group', rules: [] }], + groups: [{ name: 'Prom rules group', rules: [], totals: {} }], name: 'Prometheus-test', }; } diff --git a/public/app/features/alerting/unified/components/rules/RuleDetailsMatchingInstances.tsx b/public/app/features/alerting/unified/components/rules/RuleDetailsMatchingInstances.tsx index f9bbf24de4b..c52170a4d6c 100644 --- a/public/app/features/alerting/unified/components/rules/RuleDetailsMatchingInstances.tsx +++ b/public/app/features/alerting/unified/components/rules/RuleDetailsMatchingInstances.tsx @@ -1,5 +1,5 @@ import { css, cx } from '@emotion/css'; -import { countBy } from 'lodash'; +import { countBy, sum } from 'lodash'; import React, { useMemo, useState } from 'react'; import { GrafanaTheme2 } from '@grafana/data'; @@ -20,11 +20,13 @@ import { isAlertingRule } from '../../utils/rules'; import { DetailsField } from '../DetailsField'; import { AlertInstancesTable } from './AlertInstancesTable'; +import { getComponentsFromStats } from './RuleStats'; interface Props { rule: CombinedRule; pagination?: PaginationProps; itemsDisplayLimit?: number; + enableFiltering?: boolean; } interface ShowMoreStats { @@ -52,9 +54,10 @@ function ShowMoreInstances(props: { ruleViewPageLink: string; stats: ShowMoreSta export function RuleDetailsMatchingInstances(props: Props): JSX.Element | null { const { - rule: { promRule, namespace }, + rule: { promRule, namespace, instanceTotals }, itemsDisplayLimit = Number.POSITIVE_INFINITY, pagination, + enableFiltering = false, } = props; const [queryString, setQueryString] = useState(); @@ -82,40 +85,45 @@ export function RuleDetailsMatchingInstances(props: Props): JSX.Element | null { const visibleInstances = alerts.slice(0, itemsDisplayLimit); + // Count All By State is used only when filtering is enabled and we have access to all instances const countAllByState = countBy(promRule.alerts, (alert) => mapStateWithReasonToBaseState(alert.state)); - const hiddenItemsCount = alerts.length - visibleInstances.length; + const totalInstancesCount = sum(Object.values(instanceTotals)); + const hiddenInstancesCount = totalInstancesCount - visibleInstances.length; const stats: ShowMoreStats = { - totalItemsCount: alerts.length, + totalItemsCount: totalInstancesCount, visibleItemsCount: visibleInstances.length, }; const ruleViewPageLink = createViewLink(namespace.rulesSource, props.rule, location.pathname + location.search); + const statsComponents = getComponentsFromStats(instanceTotals); - const footerRow = hiddenItemsCount ? ( + const footerRow = hiddenInstancesCount ? ( ) : undefined; return ( -
-
- setQueryString(value)} - /> - + {enableFiltering && ( +
+
+ setQueryString(value)} + /> + +
-
- + )} + {!enableFiltering &&
{statsComponents}
} ); @@ -164,5 +172,13 @@ const getStyles = (theme: GrafanaTheme2) => { align-items: center; width: 100%; `, + instancesContainer: css` + margin-bottom: ${theme.spacing(2)}; + `, + stats: css` + display: flex; + gap: ${theme.spacing(1)}; + padding: ${theme.spacing(1, 0)}; + `, }; }; diff --git a/public/app/features/alerting/unified/components/rules/RuleListGroupView.test.tsx b/public/app/features/alerting/unified/components/rules/RuleListGroupView.test.tsx index 82ffb120c84..9d0c05fac86 100644 --- a/public/app/features/alerting/unified/components/rules/RuleListGroupView.test.tsx +++ b/public/app/features/alerting/unified/components/rules/RuleListGroupView.test.tsx @@ -116,6 +116,7 @@ function getGrafanaNamespace(): CombinedRuleNamespace { { name: 'default', rules: [mockCombinedRule()], + totals: {}, }, ], }; @@ -129,6 +130,7 @@ function getCloudNamespace(): CombinedRuleNamespace { { name: 'Prom group', rules: [mockCombinedRule()], + totals: {}, }, ], }; diff --git a/public/app/features/alerting/unified/components/rules/RuleState.tsx b/public/app/features/alerting/unified/components/rules/RuleState.tsx index a16c8324ead..11d95a99982 100644 --- a/public/app/features/alerting/unified/components/rules/RuleState.tsx +++ b/public/app/features/alerting/unified/components/rules/RuleState.tsx @@ -31,7 +31,7 @@ export const RuleState = ({ rule, isDeleting, isCreating, isPaused }: Props) => promRule.state !== PromAlertingRuleState.Inactive ) { // find earliest alert - const firstActiveAt = getFirstActiveAt(promRule); + const firstActiveAt = promRule.activeAt ? new Date(promRule.activeAt) : getFirstActiveAt(promRule); // calculate time elapsed from earliest alert if (firstActiveAt) { diff --git a/public/app/features/alerting/unified/components/rules/RuleStats.tsx b/public/app/features/alerting/unified/components/rules/RuleStats.tsx index 11fb219cef5..46148dee699 100644 --- a/public/app/features/alerting/unified/components/rules/RuleStats.tsx +++ b/public/app/features/alerting/unified/components/rules/RuleStats.tsx @@ -1,121 +1,77 @@ +import { isUndefined, omitBy, sum } from 'lodash'; import pluralize from 'pluralize'; -import React, { Fragment, useState } from 'react'; -import { useDebounce } from 'react-use'; +import React, { Fragment } from 'react'; import { Stack } from '@grafana/experimental'; import { Badge } from '@grafana/ui'; -import { CombinedRule, CombinedRuleGroup, CombinedRuleNamespace } from 'app/types/unified-alerting'; +import { + AlertGroupTotals, + AlertInstanceTotalState, + CombinedRuleGroup, + CombinedRuleNamespace, +} from 'app/types/unified-alerting'; import { PromAlertingRuleState } from 'app/types/unified-alerting-dto'; -import { isAlertingRule, isRecordingRule, isRecordingRulerRule, isGrafanaRulerRulePaused } from '../../utils/rules'; - interface Props { - includeTotal?: boolean; - group?: CombinedRuleGroup; - namespaces?: CombinedRuleNamespace[]; + namespaces: CombinedRuleNamespace[]; } -const emptyStats = { - total: 0, +// All available states for a rule need to be initialized to prevent NaN values when adding a number and undefined +const emptyStats: Required = { recording: 0, - [PromAlertingRuleState.Firing]: 0, + alerting: 0, [PromAlertingRuleState.Pending]: 0, [PromAlertingRuleState.Inactive]: 0, paused: 0, error: 0, -} as const; + nodata: 0, +}; -export const RuleStats = ({ group, namespaces, includeTotal }: Props) => { - const evaluationInterval = group?.interval; - const [calculated, setCalculated] = useState(emptyStats); +export const RuleStats = ({ namespaces }: Props) => { + const stats = { ...emptyStats }; - // Performance optimization allowing reducing number of stats calculation - // The problem occurs when we load many data sources. - // Then redux store gets updated multiple times in a pretty short period, triggering calculating stats many times. - // debounce allows to skip calculations which results would be abandoned in milliseconds - useDebounce( - () => { - const stats = { ...emptyStats }; - - const calcRule = (rule: CombinedRule) => { - if (rule.promRule && isAlertingRule(rule.promRule)) { - if (isGrafanaRulerRulePaused(rule)) { - stats.paused += 1; - } - stats[rule.promRule.state] += 1; - } - if (ruleHasError(rule)) { - 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); + // sum all totals for all namespaces + namespaces.forEach(({ groups }) => { + groups.forEach((group) => { + const groupTotals = omitBy(group.totals, isUndefined); + for (let key in groupTotals) { + // @ts-ignore + stats[key] += groupTotals[key]; } + }); + }); - if (namespaces) { - namespaces.forEach((namespace) => namespace.groups.forEach((group) => group.rules.forEach(calcRule))); - } + const statsComponents = getComponentsFromStats(stats); + const hasStats = Boolean(statsComponents.length); - setCalculated(stats); - }, - 400, - [group, namespaces] + const total = sum(Object.values(stats)); + + statsComponents.unshift( + + {total} {pluralize('rule', total)} + ); - const statsComponents: React.ReactNode[] = []; + return ( + + {hasStats && ( +
+ {statsComponents} +
+ )} +
+ ); +}; - if (includeTotal) { - statsComponents.push( - - {calculated.total} {pluralize('rule', calculated.total)} - - ); - } +interface RuleGroupStatsProps { + group: CombinedRuleGroup; +} - if (calculated[PromAlertingRuleState.Firing]) { - statsComponents.push( - - ); - } - - if (calculated.error) { - statsComponents.push(); - } - - if (calculated[PromAlertingRuleState.Pending]) { - statsComponents.push( - - ); - } - - if (calculated[PromAlertingRuleState.Inactive] && calculated.paused) { - statsComponents.push( - - ); - } - - if (calculated[PromAlertingRuleState.Inactive] && !calculated.paused) { - statsComponents.push( - - ); - } - - if (calculated.recording) { - statsComponents.push(); - } +export const RuleGroupStats = ({ group }: RuleGroupStatsProps) => { + const stats = group.totals; + const evaluationInterval = group?.interval; + const statsComponents = getComponentsFromStats(stats); const hasStats = Boolean(statsComponents.length); return ( @@ -135,6 +91,48 @@ export const RuleStats = ({ group, namespaces, includeTotal }: Props) => { ); }; -function ruleHasError(rule: CombinedRule) { - return rule.promRule?.health === 'err' || rule.promRule?.health === 'error'; +export function getComponentsFromStats( + stats: Partial> +) { + const statsComponents: React.ReactNode[] = []; + + if (stats[AlertInstanceTotalState.Alerting]) { + statsComponents.push(); + } + + if (stats.error) { + statsComponents.push(); + } + + if (stats.nodata) { + statsComponents.push(); + } + + if (stats[AlertInstanceTotalState.Pending]) { + statsComponents.push( + + ); + } + + if (stats[AlertInstanceTotalState.Normal] && stats.paused) { + statsComponents.push( + + ); + } + + if (stats[AlertInstanceTotalState.Normal] && !stats.paused) { + statsComponents.push( + + ); + } + + if (stats.recording) { + statsComponents.push(); + } + + return statsComponents; } diff --git a/public/app/features/alerting/unified/components/rules/RulesGroup.test.tsx b/public/app/features/alerting/unified/components/rules/RulesGroup.test.tsx index 0d4f7277d13..fd6fcdb9112 100644 --- a/public/app/features/alerting/unified/components/rules/RulesGroup.test.tsx +++ b/public/app/features/alerting/unified/components/rules/RulesGroup.test.tsx @@ -62,6 +62,7 @@ describe('Rules group tests', () => { const group: CombinedRuleGroup = { name: 'TestGroup', rules: [mockCombinedRule()], + totals: {}, }; const namespace: CombinedRuleNamespace = { @@ -89,6 +90,7 @@ describe('Rules group tests', () => { const group: CombinedRuleGroup = { name: 'TestGroup', rules: [mockCombinedRule()], + totals: {}, }; const namespace: CombinedRuleNamespace = { @@ -147,6 +149,7 @@ describe('Rules group tests', () => { const group: CombinedRuleGroup = { name: 'TestGroup', rules: [mockCombinedRule()], + totals: {}, }; const namespace: CombinedRuleNamespace = { diff --git a/public/app/features/alerting/unified/components/rules/RulesGroup.tsx b/public/app/features/alerting/unified/components/rules/RulesGroup.tsx index 3e6a5b51493..b0cd0856f07 100644 --- a/public/app/features/alerting/unified/components/rules/RulesGroup.tsx +++ b/public/app/features/alerting/unified/components/rules/RulesGroup.tsx @@ -23,7 +23,7 @@ import { RuleLocation } from '../RuleLocation'; import { ActionIcon } from './ActionIcon'; import { EditCloudGroupModal } from './EditRuleGroupModal'; import { ReorderCloudGroupModal } from './ReorderRuleGroupModal'; -import { RuleStats } from './RuleStats'; +import { RuleGroupStats } from './RuleStats'; import { RulesTable } from './RulesTable'; type ViewMode = 'grouped' | 'list'; @@ -217,7 +217,7 @@ export const RulesGroup = React.memo(({ group, namespace, expandAll, viewMode }: }
- +
{isProvisioned && ( <> diff --git a/public/app/features/alerting/unified/hooks/useCombinedRuleNamespaces.test.ts b/public/app/features/alerting/unified/hooks/useCombinedRuleNamespaces.test.ts index 544b2837855..bbffb07382b 100644 --- a/public/app/features/alerting/unified/hooks/useCombinedRuleNamespaces.test.ts +++ b/public/app/features/alerting/unified/hooks/useCombinedRuleNamespaces.test.ts @@ -8,22 +8,26 @@ describe('flattenGrafanaManagedRules', () => { const ungroupedGroup1 = { name: 'my-rule', rules: [{ name: 'my-rule' }], + totals: {}, } as CombinedRuleGroup; const ungroupedGroup2 = { name: 'another-rule', rules: [{ name: 'another-rule' }], + totals: {}, } as CombinedRuleGroup; // the rules from both these groups should go in their own group name const group1 = { name: 'group1', rules: [{ name: 'rule-1' }, { name: 'rule-2' }], + totals: {}, } as CombinedRuleGroup; const group2 = { name: 'group2', rules: [{ name: 'rule-1' }, { name: 'rule-2' }], + totals: {}, } as CombinedRuleGroup; const namespace1 = { @@ -45,6 +49,7 @@ describe('flattenGrafanaManagedRules', () => { { name: 'default', rules: sortRulesByName([...ungroupedGroup1.rules, ...ungroupedGroup2.rules, ...group1.rules, ...group2.rules]), + totals: {}, }, ]); @@ -52,6 +57,7 @@ describe('flattenGrafanaManagedRules', () => { { name: 'default', rules: ungroupedGroup1.rules, + totals: {}, }, ]); }); diff --git a/public/app/features/alerting/unified/hooks/useCombinedRuleNamespaces.ts b/public/app/features/alerting/unified/hooks/useCombinedRuleNamespaces.ts index 6a463c7a451..9a903261b26 100644 --- a/public/app/features/alerting/unified/hooks/useCombinedRuleNamespaces.ts +++ b/public/app/features/alerting/unified/hooks/useCombinedRuleNamespaces.ts @@ -1,7 +1,11 @@ -import { isEqual } from 'lodash'; +import { countBy, isEqual } from 'lodash'; import { useMemo, useRef } from 'react'; import { + AlertGroupTotals, + AlertingRule, + AlertInstanceTotals, + AlertInstanceTotalState, CombinedRule, CombinedRuleGroup, CombinedRuleNamespace, @@ -10,7 +14,12 @@ import { RuleNamespace, RulesSource, } from 'app/types/unified-alerting'; -import { RulerRuleDTO, RulerRuleGroupDTO, RulerRulesConfigDTO } from 'app/types/unified-alerting-dto'; +import { + PromAlertingRuleState, + RulerRuleDTO, + RulerRuleGroupDTO, + RulerRulesConfigDTO, +} from 'app/types/unified-alerting-dto'; import { getAllRulesSources, @@ -18,7 +27,13 @@ import { isCloudRulesSource, isGrafanaRulesSource, } from '../utils/datasource'; -import { isAlertingRule, isAlertingRulerRule, isRecordingRulerRule } from '../utils/rules'; +import { + isAlertingRule, + isAlertingRulerRule, + isGrafanaRulerRule, + isRecordingRule, + isRecordingRulerRule, +} from '../utils/rules'; import { useUnifiedAlertingSelector } from './useUnifiedAlertingSelector'; @@ -104,6 +119,7 @@ export function flattenGrafanaManagedRules(namespaces: CombinedRuleNamespace[]) newNamespace.groups.push({ name: 'default', rules: sortRulesByName(namespace.groups.flatMap((group) => group.rules)), + totals: calculateAllGroupsTotals(namespace.groups), }); return newNamespace; @@ -116,11 +132,18 @@ export function sortRulesByName(rules: CombinedRule[]) { function addRulerGroupsToCombinedNamespace(namespace: CombinedRuleNamespace, groups: RulerRuleGroupDTO[] = []): void { namespace.groups = groups.map((group) => { + const numRecordingRules = group.rules.filter((rule) => isRecordingRulerRule(rule)).length; + const numPaused = group.rules.filter((rule) => isGrafanaRulerRule(rule) && rule.grafana_alert.is_paused).length; + const combinedGroup: CombinedRuleGroup = { name: group.name, interval: group.interval, source_tenants: group.source_tenants, rules: [], + totals: { + paused: numPaused, + recording: numRecordingRules, + }, }; combinedGroup.rules = group.rules.map((rule) => rulerRuleToCombinedRule(rule, namespace, combinedGroup)); return combinedGroup; @@ -137,11 +160,18 @@ function addPromGroupsToCombinedNamespace(namespace: CombinedRuleNamespace, grou combinedGroup = { name: group.name, rules: [], + totals: calculateGroupTotals(group), }; namespace.groups.push(combinedGroup); existingGroupsByName.set(group.name, combinedGroup); } + // combine totals from ruler with totals from prometheus state API + combinedGroup.totals = { + ...combinedGroup.totals, + ...calculateGroupTotals(group), + }; + const combinedRulesByName = new Map(); combinedGroup!.rules.forEach((r) => { // Prometheus rules do not have to be unique by name @@ -153,6 +183,8 @@ function addPromGroupsToCombinedNamespace(namespace: CombinedRuleNamespace, grou const existingRule = getExistingRuleInGroup(rule, combinedRulesByName, namespace.rulesSource); if (existingRule) { existingRule.promRule = rule; + existingRule.instanceTotals = isAlertingRule(rule) ? calculateRuleTotals(rule) : {}; + existingRule.filteredInstanceTotals = isAlertingRule(rule) ? calculateRuleFilteredTotals(rule) : {}; } else { combinedGroup!.rules.push(promRuleToCombinedRule(rule, namespace, combinedGroup!)); } @@ -160,6 +192,74 @@ function addPromGroupsToCombinedNamespace(namespace: CombinedRuleNamespace, grou }); } +export function calculateRuleTotals(rule: Pick): AlertInstanceTotals { + const result = countBy(rule.alerts, 'state'); + + if (rule.totals) { + const { normal, ...totals } = rule.totals; + return { ...totals, inactive: normal }; + } + + return { + alerting: result[AlertInstanceTotalState.Alerting], + pending: result[AlertInstanceTotalState.Pending], + inactive: result[AlertInstanceTotalState.Normal], + nodata: result[AlertInstanceTotalState.NoData], + error: result[AlertInstanceTotalState.Error] + result['err'], // Prometheus uses "err" instead of "error" + }; +} + +export function calculateRuleFilteredTotals( + rule: Pick +): AlertInstanceTotals { + if (rule.totalsFiltered) { + const { normal, ...totals } = rule.totalsFiltered; + return { ...totals, inactive: normal }; + } + return {}; +} + +export function calculateGroupTotals(group: Pick): AlertGroupTotals { + if (group.totals) { + const { firing, ...totals } = group.totals; + + return { + ...totals, + alerting: firing, + }; + } + + const countsByState = countBy(group.rules, (rule) => isAlertingRule(rule) && rule.state); + const countsByHealth = countBy(group.rules, (rule) => rule.health); + const recordingCount = group.rules.filter((rule) => isRecordingRule(rule)).length; + + return { + alerting: countsByState[PromAlertingRuleState.Firing], + error: countsByHealth.error, + nodata: countsByHealth.nodata, + inactive: countsByState[PromAlertingRuleState.Inactive], + pending: countsByState[PromAlertingRuleState.Pending], + recording: recordingCount, + }; +} + +function calculateAllGroupsTotals(groups: CombinedRuleGroup[]): AlertGroupTotals { + const totals: Record = {}; + + groups.forEach((group) => { + const groupTotals = group.totals; + Object.entries(groupTotals).forEach(([key, value]) => { + if (!totals[key]) { + totals[key] = 0; + } + + totals[key] += value; + }); + }); + + return totals; +} + function promRuleToCombinedRule(rule: Rule, namespace: CombinedRuleNamespace, group: CombinedRuleGroup): CombinedRule { return { name: rule.name, @@ -169,6 +269,8 @@ function promRuleToCombinedRule(rule: Rule, namespace: CombinedRuleNamespace, gr promRule: rule, namespace: namespace, group, + instanceTotals: isAlertingRule(rule) ? calculateRuleTotals(rule) : {}, + filteredInstanceTotals: isAlertingRule(rule) ? calculateRuleFilteredTotals(rule) : {}, }; } @@ -186,6 +288,8 @@ function rulerRuleToCombinedRule( rulerRule: rule, namespace, group, + instanceTotals: {}, + filteredInstanceTotals: {}, } : isRecordingRulerRule(rule) ? { @@ -196,6 +300,8 @@ function rulerRuleToCombinedRule( rulerRule: rule, namespace, group, + instanceTotals: {}, + filteredInstanceTotals: {}, } : { name: rule.grafana_alert.title, @@ -205,6 +311,8 @@ function rulerRuleToCombinedRule( rulerRule: rule, namespace, group, + instanceTotals: {}, + filteredInstanceTotals: {}, }; } diff --git a/public/app/features/alerting/unified/hooks/useFilteredRules.ts b/public/app/features/alerting/unified/hooks/useFilteredRules.ts index 2979c6a78cc..aea4ba65c3e 100644 --- a/public/app/features/alerting/unified/hooks/useFilteredRules.ts +++ b/public/app/features/alerting/unified/hooks/useFilteredRules.ts @@ -5,7 +5,7 @@ import { useCallback, useEffect, useMemo } from 'react'; import { getDataSourceSrv } from '@grafana/runtime'; import { Matcher } from 'app/plugins/datasource/alertmanager/types'; -import { CombinedRuleGroup, CombinedRuleNamespace } from 'app/types/unified-alerting'; +import { CombinedRuleGroup, CombinedRuleNamespace, Rule } from 'app/types/unified-alerting'; import { isPromAlertingRuleState, PromRuleType, RulerGrafanaRuleDTO } from 'app/types/unified-alerting-dto'; import { applySearchFilterToQuery, getSearchFilterFromQuery, RulesFilter } from '../search/rulesSearchParser'; @@ -13,6 +13,7 @@ import { labelsMatchMatchers, matcherToMatcherField, parseMatcher, parseMatchers import { isCloudRulesSource } from '../utils/datasource'; import { getRuleHealth, isAlertingRule, isGrafanaRulerRule, isPromRuleType } from '../utils/rules'; +import { calculateGroupTotals, calculateRuleFilteredTotals, calculateRuleTotals } from './useCombinedRuleNamespaces'; import { useURLSearchParams } from './useURLSearchParams'; export function useRulesFilter() { @@ -74,7 +75,27 @@ export function useRulesFilter() { } export const useFilteredRules = (namespaces: CombinedRuleNamespace[], filterState: RulesFilter) => { - return useMemo(() => filterRules(namespaces, filterState), [namespaces, filterState]); + return useMemo(() => { + const filteredRules = filterRules(namespaces, filterState); + + // Totals recalculation is a workaround for the lack of server-side filtering + filteredRules.forEach((namespace) => { + namespace.groups.forEach((group) => { + group.rules.forEach((rule) => { + if (isAlertingRule(rule.promRule)) { + rule.instanceTotals = calculateRuleTotals(rule.promRule); + rule.filteredInstanceTotals = calculateRuleFilteredTotals(rule.promRule); + } + }); + + group.totals = calculateGroupTotals({ + rules: group.rules.map((r) => r.promRule).filter((r): r is Rule => !!r), + }); + }); + }); + + return filteredRules; + }, [namespaces, filterState]); }; // Options details can be found here https://github.com/leeoniya/uFuzzy#options diff --git a/public/app/features/alerting/unified/mocks.ts b/public/app/features/alerting/unified/mocks.ts index 9a8bc0a82bd..34007712684 100644 --- a/public/app/features/alerting/unified/mocks.ts +++ b/public/app/features/alerting/unified/mocks.ts @@ -170,6 +170,7 @@ export const mockPromAlertingRule = (partial: Partial = {}): Alert }, state: PromAlertingRuleState.Firing, health: 'OK', + totalsFiltered: { alerting: 1 }, ...partial, }; }; @@ -512,16 +513,19 @@ export const mockCombinedRule = (partial?: Partial): CombinedRule group: { name: 'mockCombinedRuleGroup', rules: [], + totals: {}, }, namespace: { name: 'mockCombinedNamespace', - groups: [{ name: 'mockCombinedRuleGroup', rules: [] }], + groups: [{ name: 'mockCombinedRuleGroup', rules: [], totals: {} }], rulesSource: 'grafana', }, labels: {}, annotations: {}, promRule: mockPromAlertingRule(), rulerRule: mockRulerAlertingRule(), + instanceTotals: {}, + filteredInstanceTotals: {}, ...partial, }); @@ -596,7 +600,7 @@ export function mockAlertQuery(query: Partial): AlertQuery { } export function mockCombinedRuleGroup(name: string, rules: CombinedRule[]): CombinedRuleGroup { - return { name, rules }; + return { name, rules, totals: {} }; } export function mockCombinedRuleNamespace(namespace: Partial): CombinedRuleNamespace { diff --git a/public/app/features/alerting/unified/state/actions.ts b/public/app/features/alerting/unified/state/actions.ts index be215b06619..e2835e7aff6 100644 --- a/public/app/features/alerting/unified/state/actions.ts +++ b/public/app/features/alerting/unified/state/actions.ts @@ -8,6 +8,7 @@ import { AlertmanagerGroup, ExternalAlertmanagerConfig, ExternalAlertmanagersResponse, + Matcher, Receiver, Silence, SilenceCreatePayload, @@ -101,7 +102,19 @@ function getDataSourceRulerConfig(getState: () => unknown, rulesSourceName: stri export const fetchPromRulesAction = createAsyncThunk( 'unifiedalerting/fetchPromRules', async ( - { rulesSourceName, filter }: { rulesSourceName: string; filter?: FetchPromRulesFilter }, + { + rulesSourceName, + filter, + limitAlerts, + matcher, + state, + }: { + rulesSourceName: string; + filter?: FetchPromRulesFilter; + limitAlerts?: number; + matcher?: Matcher[]; + state?: string[]; + }, thunkAPI ): Promise => { await thunkAPI.dispatch(fetchRulesSourceBuildInfoAction({ rulesSourceName })); @@ -111,7 +124,7 @@ export const fetchPromRulesAction = createAsyncThunk( thunk: 'unifiedalerting/fetchPromRules', }); - return await withSerializedError(fetchRulesWithLogging(rulesSourceName, filter)); + return await withSerializedError(fetchRulesWithLogging(rulesSourceName, filter, limitAlerts, matcher, state)); } ); @@ -339,7 +352,17 @@ export const fetchRulesSourceBuildInfoAction = createAsyncThunk( } ); -export function fetchAllPromAndRulerRulesAction(force = false): ThunkResult> { +interface FetchPromRulesRulesActionProps { + filter?: FetchPromRulesFilter; + limitAlerts?: number; + matcher?: Matcher[]; + state?: string[]; +} + +export function fetchAllPromAndRulerRulesAction( + force = false, + options: FetchPromRulesRulesActionProps = {} +): ThunkResult> { return async (dispatch, getStore) => { const allStartLoadingTs = performance.now(); @@ -359,7 +382,7 @@ export function fetchAllPromAndRulerRulesAction(force = false): ThunkResult { group: { name: 'Prom up alert', rules: [], + totals: {}, }, namespace: { rulesSource: GRAFANA_RULES_SOURCE_NAME, @@ -28,6 +29,8 @@ describe('alertRuleToQueries', () => { labels: {}, grafana_alert: grafanaAlert, }, + instanceTotals: {}, + filteredInstanceTotals: {}, }; const result = alertRuleToQueries(combinedRule); @@ -43,6 +46,7 @@ describe('alertRuleToQueries', () => { group: { name: 'test', rules: [], + totals: {}, }, namespace: { name: 'prom test alerts', @@ -58,6 +62,8 @@ describe('alertRuleToQueries', () => { readOnly: false, }, }, + instanceTotals: {}, + filteredInstanceTotals: {}, }; const result = alertRuleToQueries(combinedRule); diff --git a/public/app/plugins/panel/alertlist/AlertInstances.tsx b/public/app/plugins/panel/alertlist/AlertInstances.tsx index 6c1edec5eb3..7437268bcad 100644 --- a/public/app/plugins/panel/alertlist/AlertInstances.tsx +++ b/public/app/plugins/panel/alertlist/AlertInstances.tsx @@ -4,8 +4,9 @@ import pluralize from 'pluralize'; import React, { useCallback, useEffect, useMemo, useState } from 'react'; import { GrafanaTheme2, PanelProps } from '@grafana/data'; -import { clearButtonStyles, Icon, useStyles2 } from '@grafana/ui'; +import { Button, clearButtonStyles, Icon, useStyles2 } from '@grafana/ui'; import { AlertInstancesTable } from 'app/features/alerting/unified/components/rules/AlertInstancesTable'; +import { INSTANCES_DISPLAY_LIMIT } from 'app/features/alerting/unified/components/rules/RuleDetails'; import { sortAlerts } from 'app/features/alerting/unified/utils/misc'; import { Alert } from 'app/types/unified-alerting'; @@ -17,9 +18,20 @@ import { filterAlerts } from './util'; interface Props { alerts: Alert[]; options: PanelProps['options']; + grafanaTotalInstances?: number; + grafanaFilteredInstancesTotal?: number; + handleInstancesLimit?: (limit: boolean) => void; + limitInstances?: boolean; } -export const AlertInstances = ({ alerts, options }: Props) => { +export const AlertInstances = ({ + alerts, + options, + grafanaTotalInstances, + handleInstancesLimit, + limitInstances, + grafanaFilteredInstancesTotal, +}: Props) => { // when custom grouping is enabled, we will always uncollapse the list of alert instances const defaultShowInstances = options.groupMode === GroupMode.Custom ? true : options.showInstances; const [displayInstances, setDisplayInstances] = useState(defaultShowInstances); @@ -36,8 +48,13 @@ export const AlertInstances = ({ alerts, options }: Props) => { (): Alert[] => filterAlerts(options, sortAlerts(options.sortOrder, alerts)) ?? [], [alerts, options] ); + const isGrafanaAlert = grafanaTotalInstances !== undefined; - const hiddenInstances = alerts.length - filteredAlerts.length; + const hiddenInstancesForGrafanaAlerts = + grafanaTotalInstances && grafanaFilteredInstancesTotal ? grafanaTotalInstances - grafanaFilteredInstancesTotal : 0; + const hiddenInstancesForNonGrafanaAlerts = alerts.length - filteredAlerts.length; + + const hiddenInstances = isGrafanaAlert ? hiddenInstancesForGrafanaAlerts : hiddenInstancesForNonGrafanaAlerts; const uncollapsible = filteredAlerts.length > 0; const toggleShowInstances = uncollapsible ? toggleDisplayInstances : noop; @@ -48,6 +65,49 @@ export const AlertInstances = ({ alerts, options }: Props) => { } }, [filteredAlerts]); + const onShowAllClick = async () => { + if (!handleInstancesLimit) { + return; + } + handleInstancesLimit(false); + setDisplayInstances(true); + }; + + const onShowLimitedClick = async () => { + if (!handleInstancesLimit) { + return; + } + handleInstancesLimit(true); + setDisplayInstances(true); + }; + const totalInstancesNumber = limitInstances ? grafanaFilteredInstancesTotal : filteredAlerts.length; + const limitStatus = limitInstances + ? `Showing ${INSTANCES_DISPLAY_LIMIT} of ${grafanaTotalInstances} instances` + : `Showing all ${grafanaTotalInstances} instances`; + + const limitButtonLabel = limitInstances + ? 'View all instances' + : `Limit the result to ${INSTANCES_DISPLAY_LIMIT} instances`; + + const instancesLimitedAndOverflowed = + grafanaTotalInstances && + INSTANCES_DISPLAY_LIMIT === filteredAlerts.length && + grafanaTotalInstances > filteredAlerts.length; + const instancesNotLimitedAndoverflowed = + grafanaTotalInstances && INSTANCES_DISPLAY_LIMIT < filteredAlerts.length && !limitInstances; + + const footerRow = + instancesLimitedAndOverflowed || instancesNotLimitedAndoverflowed ? ( +
+
{limitStatus}
+ { + + } +
+ ) : undefined; + return (
{options.groupMode === GroupMode.Default && ( @@ -56,7 +116,7 @@ export const AlertInstances = ({ alerts, options }: Props) => { onClick={() => toggleShowInstances()} > {uncollapsible && } - {`${filteredAlerts.length} ${pluralize('instance', filteredAlerts.length)}`} + {`${totalInstancesNumber} ${pluralize('instance', totalInstancesNumber)}`} {hiddenInstances > 0 && , {`${hiddenInstances} hidden by filters`}} )} @@ -64,14 +124,23 @@ export const AlertInstances = ({ alerts, options }: Props) => { )}
); }; -const getStyles = (_: GrafanaTheme2) => ({ +const getStyles = (theme: GrafanaTheme2) => ({ clickable: css` cursor: pointer; `, + footerRow: css` + display: flex; + flex-direction: column; + gap: ${theme.spacing(1)}; + justify-content: space-between; + align-items: center; + width: 100%; + `, }); diff --git a/public/app/plugins/panel/alertlist/UnifiedAlertList.tsx b/public/app/plugins/panel/alertlist/UnifiedAlertList.tsx index dbfed7f94a9..da19f2c8352 100644 --- a/public/app/plugins/panel/alertlist/UnifiedAlertList.tsx +++ b/public/app/plugins/panel/alertlist/UnifiedAlertList.tsx @@ -1,7 +1,7 @@ import { css } from '@emotion/css'; import { sortBy } from 'lodash'; import React, { useEffect, useMemo } from 'react'; -import { useEffectOnce } from 'react-use'; +import { useEffectOnce, useToggle } from 'react-use'; import { GrafanaTheme2, PanelProps } from '@grafana/data'; import { TimeRangeUpdatedEvent } from '@grafana/runtime'; @@ -18,9 +18,11 @@ import { import { config } from 'app/core/config'; import { contextSrv } from 'app/core/services/context_srv'; import alertDef from 'app/features/alerting/state/alertDef'; +import { INSTANCES_DISPLAY_LIMIT } from 'app/features/alerting/unified/components/rules/RuleDetails'; import { useCombinedRuleNamespaces } from 'app/features/alerting/unified/hooks/useCombinedRuleNamespaces'; import { useUnifiedAlertingSelector } from 'app/features/alerting/unified/hooks/useUnifiedAlertingSelector'; import { fetchAllPromAndRulerRulesAction } from 'app/features/alerting/unified/state/actions'; +import { parseMatchers } from 'app/features/alerting/unified/utils/alertmanager'; import { Annotation } from 'app/features/alerting/unified/utils/constants'; import { getAllRulesSourceNames, @@ -37,14 +39,26 @@ 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 { GroupMode, SortOrder, StateFilter, UnifiedAlertListOptions, ViewMode } from './types'; import GroupedModeView from './unified-alerting/GroupedView'; import UngroupedModeView from './unified-alerting/UngroupedView'; import { filterAlerts } from './util'; +function getStateList(state: StateFilter) { + const reducer = (list: string[], [stateKey, value]: [string, boolean]) => { + if (Boolean(value)) { + return [...list, stateKey]; + } else { + return list; + } + }; + return Object.entries(state).reduce(reducer, []); +} + export function UnifiedAlertList(props: PanelProps) { const dispatch = useDispatch(); const rulesDataSourceNames = useMemo(getAllRulesSourceNames, []); + const [limitInstances, toggleLimit] = useToggle(true); // backwards compat for "Inactive" state filter useEffect(() => { @@ -60,14 +74,74 @@ export function UnifiedAlertList(props: PanelProps) { dashboard = getDashboardSrv().getCurrent(); }); + const stateList = useMemo(() => getStateList(props.options.stateFilter), [props.options.stateFilter]); + const { options, replaceVariables } = props; + const parsedOptions: UnifiedAlertListOptions = { + ...props.options, + alertName: replaceVariables(options.alertName), + alertInstanceLabelFilter: replaceVariables(options.alertInstanceLabelFilter), + }; + + const matcherList = useMemo( + () => parseMatchers(parsedOptions.alertInstanceLabelFilter), + [parsedOptions.alertInstanceLabelFilter] + ); + + useEffect(() => { + if (props.options.groupMode === GroupMode.Default) { + dispatch( + fetchAllPromAndRulerRulesAction(false, { + limitAlerts: limitInstances ? INSTANCES_DISPLAY_LIMIT : undefined, + matcher: matcherList, + state: stateList, + }) + ); + } + }, [props.options.groupMode, limitInstances, dispatch, matcherList, stateList]); + useEffect(() => { //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())); + dispatch( + fetchAllPromAndRulerRulesAction(false, { + limitAlerts: limitInstances ? INSTANCES_DISPLAY_LIMIT : undefined, + matcher: matcherList, + state: stateList, + }) + ); + const sub = dashboard?.events.subscribe(TimeRangeUpdatedEvent, () => + dispatch( + fetchAllPromAndRulerRulesAction(false, { + limitAlerts: limitInstances ? INSTANCES_DISPLAY_LIMIT : undefined, + matcher: matcherList, + state: stateList, + }) + ) + ); return () => { sub?.unsubscribe(); }; - }, [dispatch, dashboard]); + }, [dispatch, dashboard, matcherList, stateList, toggleLimit, limitInstances]); + + const handleInstancesLimit = (limit: boolean) => { + if (limit) { + dispatch( + fetchAllPromAndRulerRulesAction(false, { + limitAlerts: INSTANCES_DISPLAY_LIMIT, + matcher: matcherList, + state: stateList, + }) + ); + toggleLimit(true); + } else { + dispatch( + fetchAllPromAndRulerRulesAction(false, { + matcher: matcherList, + state: stateList, + }) + ); + toggleLimit(false); + } + }; const { prom, ruler } = useUnifiedAlertingSelector((state) => ({ prom: state.promRules[GRAFANA_RULES_SOURCE_NAME] || initialAsyncRequestState, @@ -106,13 +180,6 @@ export function UnifiedAlertList(props: PanelProps) { ); } - const { options, replaceVariables } = props; - const parsedOptions: UnifiedAlertListOptions = { - ...props.options, - alertName: replaceVariables(options.alertName), - alertInstanceLabelFilter: replaceVariables(options.alertInstanceLabelFilter), - }; - return (
@@ -134,7 +201,12 @@ export function UnifiedAlertList(props: PanelProps) { )} {props.options.viewMode === ViewMode.List && props.options.groupMode === GroupMode.Default && haveResults && ( - + )}
diff --git a/public/app/plugins/panel/alertlist/UnifiedalertList.test.tsx b/public/app/plugins/panel/alertlist/UnifiedalertList.test.tsx index 2ae4648c602..1ff3972f11c 100644 --- a/public/app/plugins/panel/alertlist/UnifiedalertList.test.tsx +++ b/public/app/plugins/panel/alertlist/UnifiedalertList.test.tsx @@ -1,10 +1,10 @@ -import { render } from '@testing-library/react'; +import { render, screen, waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import React from 'react'; import { Provider } from 'react-redux'; import { byRole, byText } from 'testing-library-selector'; -import { getDefaultTimeRange, LoadingState, PanelProps, FieldConfigSource } from '@grafana/data'; +import { FieldConfigSource, getDefaultTimeRange, LoadingState, PanelProps } from '@grafana/data'; import { TimeRangeUpdatedEvent } from '@grafana/runtime'; import { DashboardSrv, setDashboardSrv } from 'app/features/dashboard/services/DashboardSrv'; @@ -19,7 +19,7 @@ import { import { GRAFANA_RULES_SOURCE_NAME } from '../../../features/alerting/unified/utils/datasource'; import { UnifiedAlertList } from './UnifiedAlertList'; -import { UnifiedAlertListOptions, SortOrder, GroupMode, ViewMode } from './types'; +import { GroupMode, SortOrder, UnifiedAlertListOptions, ViewMode } from './types'; import * as utils from './util'; jest.mock('app/features/alerting/unified/api/alertmanager'); @@ -88,6 +88,8 @@ const renderPanel = (options: Partial = defaultOptions) mockPromAlertingRule({ name: 'rule1', alerts: [mockPromAlert({ labels: { severity: 'critical' } })], + totals: { alerting: 1 }, + totalsFiltered: { alerting: 1 }, }), ], }), @@ -112,6 +114,7 @@ const renderPanel = (options: Partial = defaultOptions) describe('UnifiedAlertList', () => { it('subscribes to the dashboard refresh interval', async () => { + jest.spyOn(defaultProps, 'replaceVariables').mockReturnValue('severity=critical'); await renderPanel(); expect(dashboard.events.subscribe).toHaveBeenCalledTimes(1); expect(dashboard.events.subscribe.mock.calls[0][0]).toEqual(TimeRangeUpdatedEvent); @@ -125,7 +128,7 @@ describe('UnifiedAlertList', () => { const user = userEvent.setup(); - renderPanel({ + await renderPanel({ alertInstanceLabelFilter: '$label', dashboardAlerts: false, alertName: '', @@ -135,6 +138,10 @@ describe('UnifiedAlertList', () => { expect(byText('rule1').get()).toBeInTheDocument(); + await waitFor(() => { + expect(screen.getByText('1 instance')).toBeInTheDocument(); + }); + const expandElement = byText('1 instance').get(); await user.click(expandElement); diff --git a/public/app/plugins/panel/alertlist/types.ts b/public/app/plugins/panel/alertlist/types.ts index c94f5de97e1..a0e07fd4516 100644 --- a/public/app/plugins/panel/alertlist/types.ts +++ b/public/app/plugins/panel/alertlist/types.ts @@ -42,7 +42,7 @@ export interface AlertListOptions { folderId: number; } -interface StateFilter { +export interface StateFilter { firing: boolean; pending: boolean; inactive?: boolean; // backwards compat diff --git a/public/app/plugins/panel/alertlist/unified-alerting/UngroupedView.tsx b/public/app/plugins/panel/alertlist/unified-alerting/UngroupedView.tsx index 7a0c0ecffad..882b8c810e6 100644 --- a/public/app/plugins/panel/alertlist/unified-alerting/UngroupedView.tsx +++ b/public/app/plugins/panel/alertlist/unified-alerting/UngroupedView.tsx @@ -17,7 +17,8 @@ import { 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 { GRAFANA_RULES_SOURCE_NAME } from '../../../../features/alerting/unified/utils/datasource'; +import { AlertingRule, AlertInstanceTotalState, CombinedRuleWithLocation } from '../../../../types/unified-alerting'; import { AlertInstances } from '../AlertInstances'; import { getStyles } from '../UnifiedAlertList'; import { UnifiedAlertListOptions } from '../types'; @@ -25,9 +26,17 @@ import { UnifiedAlertListOptions } from '../types'; type Props = { rules: CombinedRuleWithLocation[]; options: UnifiedAlertListOptions; + handleInstancesLimit?: (limit: boolean) => void; + limitInstances: boolean; }; -const UngroupedModeView = ({ rules, options }: Props) => { +function getGrafanaInstancesTotal(totals: Partial>) { + return Object.values(totals) + .filter((total) => total !== undefined) + .reduce((total, currentTotal) => total + currentTotal, 0); +} + +const UngroupedModeView = ({ rules, options, handleInstancesLimit, limitInstances }: Props) => { const styles = useStyles2(getStyles); const stateStyle = useStyles2(getStateTagStyles); const { href: returnTo } = useLocation(); @@ -46,6 +55,15 @@ const UngroupedModeView = ({ rules, options }: Props) => { const indentifier = fromCombinedRule(ruleWithLocation.dataSourceName, ruleWithLocation); const strIndentifier = stringifyIdentifier(indentifier); + const grafanaInstancesTotal = + ruleWithLocation.dataSourceName === GRAFANA_RULES_SOURCE_NAME + ? getGrafanaInstancesTotal(ruleWithLocation.instanceTotals) + : undefined; + const grafanaFilteredInstancesTotal = + ruleWithLocation.dataSourceName === GRAFANA_RULES_SOURCE_NAME + ? getGrafanaInstancesTotal(ruleWithLocation.filteredInstanceTotals) + : undefined; + const href = createUrl( `/alerting/${encodeURIComponent(dataSourceName)}/${encodeURIComponent(strIndentifier)}/view`, { returnTo: returnTo ?? '' } @@ -96,7 +114,14 @@ const UngroupedModeView = ({ rules, options }: Props) => { )}
- +
); diff --git a/public/app/types/unified-alerting-dto.ts b/public/app/types/unified-alerting-dto.ts index 487d8cda0a3..cdd0de5efd7 100644 --- a/public/app/types/unified-alerting-dto.ts +++ b/public/app/types/unified-alerting-dto.ts @@ -2,6 +2,8 @@ import { DataQuery, RelativeTimeRange } from '@grafana/data'; +import { AlertGroupTotals } from './unified-alerting'; + export type Labels = Record; export type Annotations = Record; @@ -153,7 +155,10 @@ export interface PromResponse { warnings?: string[]; } -export type PromRulesResponse = PromResponse<{ groups: PromRuleGroupDTO[] }>; +export type PromRulesResponse = PromResponse<{ + groups: PromRuleGroupDTO[]; + totals?: AlertGroupTotals; +}>; // Ruler rule DTOs interface RulerRuleBaseDTO { diff --git a/public/app/types/unified-alerting.ts b/public/app/types/unified-alerting.ts index 927e5c5226c..cc160c87336 100644 --- a/public/app/types/unified-alerting.ts +++ b/public/app/types/unified-alerting.ts @@ -45,6 +45,9 @@ export interface AlertingRule extends RuleBase { }; state: PromAlertingRuleState; type: PromRuleType.Alerting; + totals?: Partial, number>>; + totalsFiltered?: Partial, number>>; + activeAt?: string; // ISO timestamp } export interface RecordingRule extends RuleBase { @@ -59,10 +62,16 @@ export type Rule = AlertingRule | RecordingRule; export type BaseRuleGroup = { name: string }; +type TotalsWithoutAlerting = Exclude; +enum FiringTotal { + Firing = 'firing', +} export interface RuleGroup { name: string; interval: number; rules: Rule[]; + // totals only exist for Grafana Managed rules + totals?: Partial>; } export interface RuleNamespace { @@ -89,13 +98,30 @@ export interface CombinedRule { rulerRule?: RulerRuleDTO; group: CombinedRuleGroup; namespace: CombinedRuleNamespace; + instanceTotals: AlertInstanceTotals; + filteredInstanceTotals: AlertInstanceTotals; } +// export type AlertInstanceState = PromAlertingRuleState | 'nodata' | 'error'; +export enum AlertInstanceTotalState { + Alerting = 'alerting', + Pending = 'pending', + Normal = 'inactive', + NoData = 'nodata', + Error = 'error', +} + +export type AlertInstanceTotals = Partial>; + +// AlertGroupTotals also contain the amount of recording and paused rules +export type AlertGroupTotals = Partial>; + export interface CombinedRuleGroup { name: string; interval?: string; source_tenants?: string[]; rules: CombinedRule[]; + totals: AlertGroupTotals; } export interface CombinedRuleNamespace {