diff --git a/public/app/features/alerting/unified/components/amnotifications/AmNotificationsAlertDetails.tsx b/public/app/features/alerting/unified/components/amnotifications/AmNotificationsAlertDetails.tsx index e70d37426a1..7230e65a27a 100644 --- a/public/app/features/alerting/unified/components/amnotifications/AmNotificationsAlertDetails.tsx +++ b/public/app/features/alerting/unified/components/amnotifications/AmNotificationsAlertDetails.tsx @@ -2,10 +2,11 @@ import { css } from '@emotion/css'; import { GrafanaTheme2 } from '@grafana/data'; import { LinkButton, useStyles2 } from '@grafana/ui'; import { AlertmanagerAlert, AlertState } from 'app/plugins/datasource/alertmanager/types'; -import { Labels } from 'app/types/unified-alerting-dto'; + import React, { FC } from 'react'; import { makeAMLink } from '../../utils/misc'; import { AnnotationDetailsField } from '../AnnotationDetailsField'; +import { getMatcherQueryParams } from '../../utils/matchers'; interface AmNotificationsAlertDetailsProps { alertManagerSourceName: string; @@ -79,14 +80,3 @@ const getStyles = (theme: GrafanaTheme2) => ({ padding: ${theme.spacing(1, 0)}; `, }); - -const getMatcherQueryParams = (labels: Labels) => { - return `matchers=${encodeURIComponent( - Object.entries(labels) - .filter(([labelKey]) => !(labelKey.startsWith('__') && labelKey.endsWith('__'))) - .map(([labelKey, labelValue]) => { - return `${labelKey}=${labelValue}`; - }) - .join(',') - )}`; -}; diff --git a/public/app/features/alerting/unified/components/amnotifications/AmNotificationsGroupHeader.tsx b/public/app/features/alerting/unified/components/amnotifications/AmNotificationsGroupHeader.tsx index 6fb481b9a59..76464b2b9ac 100644 --- a/public/app/features/alerting/unified/components/amnotifications/AmNotificationsGroupHeader.tsx +++ b/public/app/features/alerting/unified/components/amnotifications/AmNotificationsGroupHeader.tsx @@ -1,15 +1,15 @@ import { AlertmanagerGroup, AlertState } from 'app/plugins/datasource/alertmanager/types'; import React from 'react'; -import { GrafanaTheme2 } from '@grafana/data'; import { useStyles2 } from '@grafana/ui'; -import { css } from '@emotion/css'; +import { getNotificationsTextColors } from '../../styles/notifications'; +import pluralize from 'pluralize'; interface Props { group: AlertmanagerGroup; } export const AmNotificationsGroupHeader = ({ group }: Props) => { - const styles = useStyles2(getStyles); + const textStyles = useStyles2(getNotificationsTextColors); const total = group.alerts.length; const countByStatus = group.alerts.reduce((statusObj, alert) => { if (statusObj[alert.status.state]) { @@ -21,11 +21,14 @@ export const AmNotificationsGroupHeader = ({ group }: Props) => { }, {} as Record); return ( -
- {`${total} alerts: `} +
+ {`${total} ${pluralize('alert', total)}: `} {Object.entries(countByStatus).map(([state, count], index) => { return ( - + {index > 0 && ', '} {`${count} ${state}`} @@ -34,16 +37,3 @@ export const AmNotificationsGroupHeader = ({ group }: Props) => {
); }; - -const getStyles = (theme: GrafanaTheme2) => ({ - summary: css``, - [AlertState.Active]: css` - color: ${theme.colors.error.main}; - `, - [AlertState.Suppressed]: css` - color: ${theme.colors.primary.main}; - `, - [AlertState.Unprocessed]: css` - color: ${theme.colors.secondary.main}; - `, -}); diff --git a/public/app/features/alerting/unified/styles/notifications.ts b/public/app/features/alerting/unified/styles/notifications.ts new file mode 100644 index 00000000000..2d5966eb4fc --- /dev/null +++ b/public/app/features/alerting/unified/styles/notifications.ts @@ -0,0 +1,15 @@ +import { GrafanaTheme2 } from '@grafana/data'; +import { css } from '@emotion/css'; +import { AlertState } from 'app/plugins/datasource/alertmanager/types'; + +export const getNotificationsTextColors = (theme: GrafanaTheme2) => ({ + [AlertState.Active]: css` + color: ${theme.colors.error.text}; + `, + [AlertState.Suppressed]: css` + color: ${theme.colors.primary.text}; + `, + [AlertState.Unprocessed]: css` + color: ${theme.colors.secondary.text}; + `, +}); diff --git a/public/app/features/alerting/unified/utils/matchers.ts b/public/app/features/alerting/unified/utils/matchers.ts index d740569620e..fee8c2d53ef 100644 --- a/public/app/features/alerting/unified/utils/matchers.ts +++ b/public/app/features/alerting/unified/utils/matchers.ts @@ -1,4 +1,5 @@ import { Matcher } from 'app/plugins/datasource/alertmanager/types'; +import { Labels } from '@grafana/data'; import { parseMatcher } from './alertmanager'; // parses comma separated matchers like "foo=bar,baz=~bad*" into SilenceMatcher[] @@ -8,3 +9,14 @@ export function parseQueryParamMatchers(paramValue: string): Matcher[] { .filter((x) => !!x.trim()) .map((x) => parseMatcher(x.trim())); } + +export const getMatcherQueryParams = (labels: Labels) => { + return `matchers=${encodeURIComponent( + Object.entries(labels) + .filter(([labelKey]) => !(labelKey.startsWith('__') && labelKey.endsWith('__'))) + .map(([labelKey, labelValue]) => { + return `${labelKey}=${labelValue}`; + }) + .join(',') + )}`; +}; diff --git a/public/app/features/plugins/built_in_plugins.ts b/public/app/features/plugins/built_in_plugins.ts index 4769cc29e49..235311897fc 100644 --- a/public/app/features/plugins/built_in_plugins.ts +++ b/public/app/features/plugins/built_in_plugins.ts @@ -66,6 +66,7 @@ import * as debugPanel from 'app/plugins/panel/debug/module'; import * as welcomeBanner from 'app/plugins/panel/welcome/module'; import * as nodeGraph from 'app/plugins/panel/nodeGraph/module'; import * as histogramPanel from 'app/plugins/panel/histogram/module'; +import * as alertGroupsPanel from 'app/plugins/panel/alertGroups/module'; // Async loaded panels const geomapPanel = async () => await import(/* webpackChunkName: "geomapPanel" */ 'app/plugins/panel/geomap/module'); @@ -119,6 +120,7 @@ const builtInPlugins: any = { 'app/plugins/panel/welcome/module': welcomeBanner, 'app/plugins/panel/nodeGraph/module': nodeGraph, 'app/plugins/panel/histogram/module': histogramPanel, + 'app/plugins/panel/alertGroups/module': alertGroupsPanel, }; export default builtInPlugins; diff --git a/public/app/plugins/panel/alertGroups/AlertGroup.tsx b/public/app/plugins/panel/alertGroups/AlertGroup.tsx new file mode 100644 index 00000000000..89471db4dec --- /dev/null +++ b/public/app/plugins/panel/alertGroups/AlertGroup.tsx @@ -0,0 +1,127 @@ +import { AlertmanagerGroup, AlertState } from 'app/plugins/datasource/alertmanager/types'; +import React, { useState, useEffect } from 'react'; +import { GrafanaTheme2, intervalToAbbreviatedDurationString } from '@grafana/data'; +import { useStyles2, LinkButton } from '@grafana/ui'; +import { css } from '@emotion/css'; + +import { AlertLabels } from 'app/features/alerting/unified/components/AlertLabels'; +import { AmNotificationsGroupHeader } from 'app/features/alerting/unified/components/amnotifications/AmNotificationsGroupHeader'; +import { CollapseToggle } from 'app/features/alerting/unified/components/CollapseToggle'; +import { getNotificationsTextColors } from 'app/features/alerting/unified/styles/notifications'; +import { makeAMLink } from 'app/features/alerting/unified/utils/misc'; +import { getMatcherQueryParams } from 'app/features/alerting/unified/utils/matchers'; + +type Props = { + alertManagerSourceName: string; + group: AlertmanagerGroup; + expandAll: boolean; +}; + +export const AlertGroup = ({ alertManagerSourceName, group, expandAll }: Props) => { + const [showAlerts, setShowAlerts] = useState(expandAll); + const styles = useStyles2(getStyles); + const textStyles = useStyles2(getNotificationsTextColors); + + useEffect(() => setShowAlerts(expandAll), [expandAll]); + + return ( +
+ {Object.keys(group.labels).length > 0 ? ( + + ) : ( +
No grouping
+ )} +
+ setShowAlerts(!showAlerts)} />{' '} + +
+ {showAlerts && ( +
+ {group.alerts.map((alert, index) => { + const state = alert.status.state.toUpperCase(); + const interval = intervalToAbbreviatedDurationString({ + start: new Date(alert.startsAt), + end: Date.now(), + }); + + return ( +
+
+ {state} for {interval} +
+
+ +
+
+ {alert.status.state === AlertState.Suppressed && ( + + Manage silences + + )} + {alert.status.state === AlertState.Active && ( + + Silence + + )} + {alert.generatorURL && ( + + See source + + )} +
+
+ ); + })} +
+ )} +
+ ); +}; + +const getStyles = (theme: GrafanaTheme2) => ({ + noGroupingText: css` + height: ${theme.spacing(4)}; + `, + group: css` + background-color: ${theme.colors.background.secondary}; + margin: ${theme.spacing(0.5, 1, 0.5, 1)}; + padding: ${theme.spacing(1)}; + `, + row: css` + display: flex; + flex-direction: row; + align-items: center; + `, + alerts: css` + margin: ${theme.spacing(0, 2, 0, 4)}; + `, + alert: css` + padding: ${theme.spacing(1, 0)}; + & + & { + border-top: 1px solid ${theme.colors.border.medium}; + } + `, + button: css` + & + & { + margin-left: ${theme.spacing(1)}; + } + `, + actionsRow: css` + padding: ${theme.spacing(1, 0)}; + `, +}); diff --git a/public/app/plugins/panel/alertGroups/AlertGroupsPanel.test.tsx b/public/app/plugins/panel/alertGroups/AlertGroupsPanel.test.tsx new file mode 100644 index 00000000000..14eec25d34c --- /dev/null +++ b/public/app/plugins/panel/alertGroups/AlertGroupsPanel.test.tsx @@ -0,0 +1,148 @@ +import React from 'react'; +import { Provider } from 'react-redux'; +import { AlertGroupsPanel } from './AlertGroupsPanel'; +import { setDataSourceSrv } from '@grafana/runtime'; +import { byTestId } from 'testing-library-selector'; +import { configureStore } from 'app/store/configureStore'; +import { AlertGroupPanelOptions } from './types'; +import { getDefaultTimeRange, LoadingState, PanelProps, FieldConfigSource } from '@grafana/data'; +import { typeAsJestMock } from 'test/helpers/typeAsJestMock'; +import { fetchAlertGroups } from 'app/features/alerting/unified/api/alertmanager'; +import { + mockAlertGroup, + mockAlertmanagerAlert, + mockDataSource, + MockDataSourceSrv, +} from 'app/features/alerting/unified/mocks'; +import { DataSourceType } from 'app/features/alerting/unified/utils/datasource'; +import { setDashboardSrv } from 'app/features/dashboard/services/DashboardSrv'; +import { render, waitFor } from '@testing-library/react'; + +jest.mock('app/features/alerting/unified/api/alertmanager'); + +jest.mock('@grafana/runtime', () => ({ + ...((jest.requireActual('@grafana/runtime') as unknown) as object), + config: { + ...jest.requireActual('@grafana/runtime').config, + buildInfo: {}, + panels: {}, + featureToggles: { + ngalert: true, + }, + }, +})); + +const mocks = { + api: { + fetchAlertGroups: typeAsJestMock(fetchAlertGroups), + }, +}; + +const dataSources = { + am: mockDataSource({ + name: 'Alertmanager', + type: DataSourceType.Alertmanager, + }), +}; + +const defaultOptions: AlertGroupPanelOptions = { + labels: '', + alertmanager: 'Alertmanager', + expandAll: false, +}; + +const defaultProps: PanelProps = { + data: { state: LoadingState.Done, series: [], timeRange: getDefaultTimeRange() }, + id: 1, + timeRange: getDefaultTimeRange(), + timeZone: 'utc', + options: defaultOptions, + eventBus: { + subscribe: jest.fn(), + getStream: () => + ({ + subscribe: jest.fn(), + } as any), + publish: jest.fn(), + removeAllListeners: jest.fn(), + newScopedBus: jest.fn(), + }, + fieldConfig: ({} as unknown) as FieldConfigSource, + height: 400, + onChangeTimeRange: jest.fn(), + onFieldConfigChange: jest.fn(), + onOptionsChange: jest.fn(), + renderCounter: 1, + replaceVariables: jest.fn(), + title: 'Alert groups test', + transparent: false, + width: 320, +}; + +const renderPanel = (options: AlertGroupPanelOptions = defaultOptions) => { + const store = configureStore(); + const dash: any = { id: 1, formatDate: (time: number) => new Date(time).toISOString() }; + const dashSrv: any = { getCurrent: () => dash }; + setDashboardSrv(dashSrv); + + defaultProps.options = options; + const props = { ...defaultProps }; + + return render( + + + + ); +}; + +const ui = { + group: byTestId('alert-group'), + alert: byTestId('alert-group-alert'), +}; + +describe('AlertGroupsPanel', () => { + beforeAll(() => { + mocks.api.fetchAlertGroups.mockImplementation(() => { + return Promise.resolve([ + mockAlertGroup({ labels: {}, alerts: [mockAlertmanagerAlert({ labels: { foo: 'bar' } })] }), + mockAlertGroup(), + ]); + }); + }); + + beforeEach(() => { + setDataSourceSrv(new MockDataSourceSrv(dataSources)); + }); + + it('renders the panel with the groups', async () => { + await renderPanel(); + + await waitFor(() => expect(mocks.api.fetchAlertGroups).toHaveBeenCalled()); + const groups = ui.group.getAll(); + + expect(groups).toHaveLength(2); + + expect(groups[0]).toHaveTextContent('No grouping'); + expect(groups[1]).toHaveTextContent('severity=warningregion=US-Central'); + + const alerts = ui.alert.queryAll(); + expect(alerts).toHaveLength(0); + }); + + it('renders panel with groups expanded', async () => { + await renderPanel({ labels: '', alertmanager: 'Alertmanager', expandAll: true }); + + await waitFor(() => expect(mocks.api.fetchAlertGroups).toHaveBeenCalled()); + const alerts = ui.alert.queryAll(); + expect(alerts).toHaveLength(3); + }); + + it('filters alerts by label filter', async () => { + await renderPanel({ labels: 'region=US-Central', alertmanager: 'Alertmanager', expandAll: true }); + + await waitFor(() => expect(mocks.api.fetchAlertGroups).toHaveBeenCalled()); + const alerts = ui.alert.queryAll(); + + expect(alerts).toHaveLength(2); + }); +}); diff --git a/public/app/plugins/panel/alertGroups/AlertGroupsPanel.tsx b/public/app/plugins/panel/alertGroups/AlertGroupsPanel.tsx new file mode 100644 index 00000000000..47adc9cddfe --- /dev/null +++ b/public/app/plugins/panel/alertGroups/AlertGroupsPanel.tsx @@ -0,0 +1,66 @@ +import React, { useEffect } from 'react'; +import { useDispatch } from 'react-redux'; +import { PanelProps } from '@grafana/data'; +import { CustomScrollbar } from '@grafana/ui'; +import { config } from '@grafana/runtime'; + +import { AlertmanagerGroup, Matcher } from 'app/plugins/datasource/alertmanager/types'; +import { fetchAlertGroupsAction } from 'app/features/alerting/unified/state/actions'; +import { initialAsyncRequestState } from 'app/features/alerting/unified/utils/redux'; +import { NOTIFICATIONS_POLL_INTERVAL_MS } from 'app/features/alerting/unified/utils/constants'; +import { useUnifiedAlertingSelector } from 'app/features/alerting/unified/hooks/useUnifiedAlertingSelector'; + +import { AlertGroup } from './AlertGroup'; +import { AlertGroupPanelOptions } from './types'; +import { parseMatchers } from 'app/features/alerting/unified/utils/alertmanager'; +import { useFilteredGroups } from './useFilteredGroups'; + +export const AlertGroupsPanel = (props: PanelProps) => { + const dispatch = useDispatch(); + const isAlertingEnabled = config.featureToggles.ngalert; + + const expandAll = props.options.expandAll; + const alertManagerSourceName = props.options.alertmanager; + + const alertGroups = useUnifiedAlertingSelector((state) => state.amAlertGroups) || initialAsyncRequestState; + const results: AlertmanagerGroup[] = alertGroups[alertManagerSourceName || '']?.result || []; + const matchers: Matcher[] = props.options.labels ? parseMatchers(props.options.labels) : []; + + const filteredResults = useFilteredGroups(results, matchers); + + useEffect(() => { + function fetchNotifications() { + if (alertManagerSourceName) { + dispatch(fetchAlertGroupsAction(alertManagerSourceName)); + } + } + fetchNotifications(); + const interval = setInterval(fetchNotifications, NOTIFICATIONS_POLL_INTERVAL_MS); + return () => { + clearInterval(interval); + }; + }, [dispatch, alertManagerSourceName]); + + const hasResults = filteredResults.length > 0; + + return ( + + {isAlertingEnabled && ( +
+ {hasResults && + filteredResults.map((group) => { + return ( + + ); + })} + {!hasResults && 'No alerts'} +
+ )} +
+ ); +}; diff --git a/public/app/plugins/panel/alertGroups/module.tsx b/public/app/plugins/panel/alertGroups/module.tsx new file mode 100644 index 00000000000..d1a8ef6b88f --- /dev/null +++ b/public/app/plugins/panel/alertGroups/module.tsx @@ -0,0 +1,39 @@ +import React from 'react'; +import { PanelPlugin } from '@grafana/data'; +import { AlertGroupPanelOptions } from './types'; +import { AlertGroupsPanel } from './AlertGroupsPanel'; +import { AlertManagerPicker } from 'app/features/alerting/unified/components/AlertManagerPicker'; +import { GRAFANA_RULES_SOURCE_NAME } from 'app/features/alerting/unified/utils/datasource'; + +export const plugin = new PanelPlugin(AlertGroupsPanel).setPanelOptions((builder) => { + return builder + .addCustomEditor({ + name: 'Alertmanager', + path: 'alertmanager', + id: 'alertmanager', + defaultValue: GRAFANA_RULES_SOURCE_NAME, + category: ['Options'], + editor: function RenderAlertmanagerPicker(props) { + return ( + { + return props.onChange(alertManagerSourceName); + }} + /> + ); + }, + }) + .addBooleanSwitch({ + name: 'Expand all by default', + path: 'expandAll', + defaultValue: false, + category: ['Options'], + }) + .addTextInput({ + description: 'Filter results by matching labels, ex: env=production,severity=~critical|warning', + name: 'Labels', + path: 'labels', + category: ['Filter'], + }); +}); diff --git a/public/app/plugins/panel/alertGroups/plugin.json b/public/app/plugins/panel/alertGroups/plugin.json new file mode 100644 index 00000000000..9e566d71d1c --- /dev/null +++ b/public/app/plugins/panel/alertGroups/plugin.json @@ -0,0 +1,15 @@ +{ + "type": "panel", + "name": "Alert groups", + "id": "alertGroups", + "state": "alpha", + + "skipDataQuery": true, + "info": { + "description": "Shows alertmanager alerts grouped by labels", + "author": { + "name": "Grafana Labs", + "url": "https://grafana.com" + } + } +} diff --git a/public/app/plugins/panel/alertGroups/types.ts b/public/app/plugins/panel/alertGroups/types.ts new file mode 100644 index 00000000000..eec4309e8ab --- /dev/null +++ b/public/app/plugins/panel/alertGroups/types.ts @@ -0,0 +1,5 @@ +export interface AlertGroupPanelOptions { + labels: string; + alertmanager: string; + expandAll: boolean; +} diff --git a/public/app/plugins/panel/alertGroups/useFilteredGroups.ts b/public/app/plugins/panel/alertGroups/useFilteredGroups.ts new file mode 100644 index 00000000000..ac3c111d63b --- /dev/null +++ b/public/app/plugins/panel/alertGroups/useFilteredGroups.ts @@ -0,0 +1,14 @@ +import { labelsMatchMatchers } from 'app/features/alerting/unified/utils/alertmanager'; +import { AlertmanagerGroup, Matcher } from 'app/plugins/datasource/alertmanager/types'; +import { useMemo } from 'react'; + +export const useFilteredGroups = (groups: AlertmanagerGroup[], matchers: Matcher[]): AlertmanagerGroup[] => { + return useMemo(() => { + return groups.filter((group) => { + return ( + labelsMatchMatchers(group.labels, matchers) || + group.alerts.some((alert) => labelsMatchMatchers(alert.labels, matchers)) + ); + }); + }, [groups, matchers]); +};