From df791ae2af7e1847f85f3a435083dc647cf22abd Mon Sep 17 00:00:00 2001 From: Nathan Rodman Date: Thu, 19 Aug 2021 09:22:52 -0700 Subject: [PATCH] Alerting: Add custom grouping for alert groups (#37378) * Group alertmangaer alerts by custom grouping * Filter am groups * Style filter components * Style filter bar and add clear functionality * rename components to alert group * use query params for group filters * filter style improvements * add tests for group by * Add grouping banner to better highlight groupings * clean up hook logic --- pkg/api/index.go | 2 +- .../alerting/unified/AlertGroups.test.tsx | 151 ++++++++++++++++++ .../features/alerting/unified/AlertGroups.tsx | 80 ++++++++++ .../alerting/unified/AmNotifications.test.tsx | 84 ---------- .../alerting/unified/AmNotifications.tsx | 62 ------- .../AlertDetails.tsx} | 5 +- .../AlertGroup.tsx} | 16 +- .../AlertGroupAlertsTable.tsx} | 16 +- .../alert-groups/AlertGroupFilter.tsx | 88 ++++++++++ .../AlertGroupHeader.tsx} | 2 +- .../alert-groups/AlertStateFilter.tsx | 33 ++++ .../components/alert-groups/GroupBy.tsx | 37 +++++ .../components/alert-groups/MatcherFilter.tsx | 47 ++++++ .../unified/components/rules/RulesFilter.tsx | 2 +- .../unified/hooks/useFilteredAmGroups.ts | 30 ++++ .../unified/hooks/useFilteredRules.ts | 8 +- .../unified/hooks/useGroupedAlerts.ts | 47 ++++++ public/app/features/alerting/unified/mocks.ts | 2 +- .../features/alerting/unified/utils/misc.ts | 8 +- .../plugins/panel/alertGroups/AlertGroup.tsx | 4 +- public/app/routes/routes.tsx | 5 +- public/app/types/unified-alerting.ts | 3 +- 22 files changed, 547 insertions(+), 185 deletions(-) create mode 100644 public/app/features/alerting/unified/AlertGroups.test.tsx create mode 100644 public/app/features/alerting/unified/AlertGroups.tsx delete mode 100644 public/app/features/alerting/unified/AmNotifications.test.tsx delete mode 100644 public/app/features/alerting/unified/AmNotifications.tsx rename public/app/features/alerting/unified/components/{amnotifications/AmNotificationsAlertDetails.tsx => alert-groups/AlertDetails.tsx} (94%) rename public/app/features/alerting/unified/components/{amnotifications/AmNotificationsGroup.tsx => alert-groups/AlertGroup.tsx} (77%) rename public/app/features/alerting/unified/components/{amnotifications/AmNotificationsAlertsTable.tsx => alert-groups/AlertGroupAlertsTable.tsx} (77%) create mode 100644 public/app/features/alerting/unified/components/alert-groups/AlertGroupFilter.tsx rename public/app/features/alerting/unified/components/{amnotifications/AmNotificationsGroupHeader.tsx => alert-groups/AlertGroupHeader.tsx} (94%) create mode 100644 public/app/features/alerting/unified/components/alert-groups/AlertStateFilter.tsx create mode 100644 public/app/features/alerting/unified/components/alert-groups/GroupBy.tsx create mode 100644 public/app/features/alerting/unified/components/alert-groups/MatcherFilter.tsx create mode 100644 public/app/features/alerting/unified/hooks/useFilteredAmGroups.ts create mode 100644 public/app/features/alerting/unified/hooks/useGroupedAlerts.ts diff --git a/pkg/api/index.go b/pkg/api/index.go index 51ce5a63325..282a222d886 100644 --- a/pkg/api/index.go +++ b/pkg/api/index.go @@ -210,7 +210,7 @@ func (hs *HTTPServer) getNavTree(c *models.ReqContext, hasEditPerm bool) ([]*dto {Text: "Alert rules", Id: "alert-list", Url: hs.Cfg.AppSubURL + "/alerting/list", Icon: "list-ul"}, } if hs.Cfg.IsNgAlertEnabled() { - alertChildNavs = append(alertChildNavs, &dtos.NavLink{Text: "Notifications", Id: "notifications", Url: hs.Cfg.AppSubURL + "/alerting/alertmanager", Icon: "layer-group"}) + alertChildNavs = append(alertChildNavs, &dtos.NavLink{Text: "Alert groups", Id: "groups", Url: hs.Cfg.AppSubURL + "/alerting/groups", Icon: "layer-group"}) alertChildNavs = append(alertChildNavs, &dtos.NavLink{Text: "Silences", Id: "silences", Url: hs.Cfg.AppSubURL + "/alerting/silences", Icon: "bell-slash"}) } if c.OrgRole == models.ROLE_ADMIN || c.OrgRole == models.ROLE_EDITOR { diff --git a/public/app/features/alerting/unified/AlertGroups.test.tsx b/public/app/features/alerting/unified/AlertGroups.test.tsx new file mode 100644 index 00000000000..cd307e540d6 --- /dev/null +++ b/public/app/features/alerting/unified/AlertGroups.test.tsx @@ -0,0 +1,151 @@ +import React from 'react'; +import { locationService, setDataSourceSrv } from '@grafana/runtime'; +import { render, waitFor } from '@testing-library/react'; +import { Provider } from 'react-redux'; +import { Router } from 'react-router-dom'; +import { fetchAlertGroups } from './api/alertmanager'; +import { byRole, byTestId, byText } from 'testing-library-selector'; +import { configureStore } from 'app/store/configureStore'; +import { typeAsJestMock } from 'test/helpers/typeAsJestMock'; +import AlertGroups from './AlertGroups'; +import { mockAlertGroup, mockAlertmanagerAlert, mockDataSource, MockDataSourceSrv } from './mocks'; +import { DataSourceType } from './utils/datasource'; +import userEvent from '@testing-library/user-event'; + +jest.mock('./api/alertmanager'); + +const mocks = { + api: { + fetchAlertGroups: typeAsJestMock(fetchAlertGroups), + }, +}; + +const renderAmNotifications = () => { + const store = configureStore(); + + return render( + + + + + + ); +}; + +const dataSources = { + am: mockDataSource({ + name: 'Alertmanager', + type: DataSourceType.Alertmanager, + }), +}; + +const ui = { + group: byTestId('alert-group'), + groupCollapseToggle: byTestId('alert-group-collapse-toggle'), + groupTable: byTestId('alert-group-table'), + row: byTestId('row'), + collapseToggle: byTestId('collapse-toggle'), + silenceButton: byText('Silence'), + sourceButton: byText('See source'), + matcherInput: byTestId('search-query-input'), + groupByContainer: byTestId('group-by-container'), + groupByInput: byRole('textbox', { name: /group by label keys/i }), + clearButton: byRole('button', { name: 'Clear filters' }), +}; + +describe('AlertGroups', () => { + beforeAll(() => { + mocks.api.fetchAlertGroups.mockImplementation(() => { + return Promise.resolve([ + mockAlertGroup({ labels: {}, alerts: [mockAlertmanagerAlert({ labels: { foo: 'bar' } })] }), + mockAlertGroup(), + ]); + }); + }); + + beforeEach(() => { + setDataSourceSrv(new MockDataSourceSrv(dataSources)); + }); + + it('loads and shows groups', async () => { + renderAmNotifications(); + + 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'); + + userEvent.click(ui.groupCollapseToggle.get(groups[0])); + expect(ui.groupTable.get()).toBeDefined(); + + userEvent.click(ui.collapseToggle.get(ui.groupTable.get())); + expect(ui.silenceButton.get(ui.groupTable.get())).toBeDefined(); + expect(ui.sourceButton.get(ui.groupTable.get())).toBeDefined(); + }); + + it('should group by custom grouping', async () => { + const regions = ['NASA', 'EMEA', 'APAC']; + mocks.api.fetchAlertGroups.mockImplementation(() => { + const groups = regions.map((region) => + mockAlertGroup({ + labels: { region }, + alerts: [ + mockAlertmanagerAlert({ labels: { region, appName: 'billing', env: 'production' } }), + mockAlertmanagerAlert({ labels: { region, appName: 'auth', env: 'staging', uniqueLabel: 'true' } }), + mockAlertmanagerAlert({ labels: { region, appName: 'frontend', env: 'production' } }), + ], + }) + ); + return Promise.resolve(groups); + }); + + renderAmNotifications(); + await waitFor(() => expect(mocks.api.fetchAlertGroups).toHaveBeenCalled()); + let groups = ui.group.getAll(); + const groupByInput = ui.groupByInput.get(); + const groupByWrapper = ui.groupByContainer.get(); + + expect(groups).toHaveLength(3); + expect(groups[0]).toHaveTextContent('region=NASA'); + expect(groups[1]).toHaveTextContent('region=EMEA'); + expect(groups[2]).toHaveTextContent('region=APAC'); + + userEvent.type(groupByInput, 'appName{enter}'); + + await waitFor(() => expect(groupByWrapper).toHaveTextContent('appName')); + + groups = ui.group.getAll(); + + await waitFor(() => expect(ui.clearButton.get()).toBeInTheDocument()); + expect(groups).toHaveLength(3); + expect(groups[0]).toHaveTextContent('appName=billing'); + expect(groups[1]).toHaveTextContent('appName=auth'); + expect(groups[2]).toHaveTextContent('appName=frontend'); + + userEvent.click(ui.clearButton.get()); + await waitFor(() => expect(groupByWrapper).not.toHaveTextContent('appName')); + + userEvent.type(groupByInput, 'env{enter}'); + await waitFor(() => expect(groupByWrapper).toHaveTextContent('env')); + + groups = ui.group.getAll(); + + expect(groups).toHaveLength(2); + expect(groups[0]).toHaveTextContent('env=production'); + expect(groups[1]).toHaveTextContent('env=staging'); + + userEvent.click(ui.clearButton.get()); + await waitFor(() => expect(groupByWrapper).not.toHaveTextContent('env')); + + userEvent.type(groupByInput, 'uniqueLabel{enter}'); + await waitFor(() => expect(groupByWrapper).toHaveTextContent('uniqueLabel')); + + groups = ui.group.getAll(); + expect(groups).toHaveLength(2); + expect(groups[0]).toHaveTextContent('No grouping'); + expect(groups[1]).toHaveTextContent('uniqueLabel=true'); + }); +}); diff --git a/public/app/features/alerting/unified/AlertGroups.tsx b/public/app/features/alerting/unified/AlertGroups.tsx new file mode 100644 index 00000000000..e4493281b20 --- /dev/null +++ b/public/app/features/alerting/unified/AlertGroups.tsx @@ -0,0 +1,80 @@ +import React, { useEffect } from 'react'; +import { useDispatch } from 'react-redux'; +import { Alert, LoadingPlaceholder, useStyles2 } from '@grafana/ui'; +import { GrafanaTheme2 } from '@grafana/data'; +import { useQueryParams } from 'app/core/hooks/useQueryParams'; + +import { AlertingPageWrapper } from './components/AlertingPageWrapper'; +import { AlertGroup } from './components/alert-groups/AlertGroup'; +import { AlertGroupFilter } from './components/alert-groups/AlertGroupFilter'; +import { fetchAlertGroupsAction } from './state/actions'; + +import { initialAsyncRequestState } from './utils/redux'; +import { getFiltersFromUrlParams } from './utils/misc'; +import { NOTIFICATIONS_POLL_INTERVAL_MS } from './utils/constants'; + +import { useAlertManagerSourceName } from './hooks/useAlertManagerSourceName'; +import { useUnifiedAlertingSelector } from './hooks/useUnifiedAlertingSelector'; +import { useGroupedAlerts } from './hooks/useGroupedAlerts'; +import { useFilteredAmGroups } from './hooks/useFilteredAmGroups'; +import { css } from '@emotion/css'; + +const AlertGroups = () => { + const [alertManagerSourceName] = useAlertManagerSourceName(); + const dispatch = useDispatch(); + const [queryParams] = useQueryParams(); + const { groupBy = [] } = getFiltersFromUrlParams(queryParams); + const styles = useStyles2(getStyles); + + const alertGroups = useUnifiedAlertingSelector((state) => state.amAlertGroups); + const { loading, error, result: results = [] } = + alertGroups[alertManagerSourceName || ''] ?? initialAsyncRequestState; + + const groupedAlerts = useGroupedAlerts(results, groupBy); + const filteredAlertGroups = useFilteredAmGroups(groupedAlerts); + + useEffect(() => { + function fetchNotifications() { + if (alertManagerSourceName) { + dispatch(fetchAlertGroupsAction(alertManagerSourceName)); + } + } + fetchNotifications(); + const interval = setInterval(fetchNotifications, NOTIFICATIONS_POLL_INTERVAL_MS); + return () => { + clearInterval(interval); + }; + }, [dispatch, alertManagerSourceName]); + + return ( + + + {loading && } + {error && !loading && ( + + {error.message || 'Unknown error'} + + )} + {results && + filteredAlertGroups.map((group, index) => { + return ( + + {((index === 1 && Object.keys(filteredAlertGroups[0].labels).length === 0) || + (index === 0 && Object.keys(group.labels).length > 0)) && ( +

Grouped by: {Object.keys(group.labels).join(', ')}

+ )} + +
+ ); + })} +
+ ); +}; + +const getStyles = (theme: GrafanaTheme2) => ({ + groupingBanner: css` + margin: ${theme.spacing(2, 0)}; + `, +}); + +export default AlertGroups; diff --git a/public/app/features/alerting/unified/AmNotifications.test.tsx b/public/app/features/alerting/unified/AmNotifications.test.tsx deleted file mode 100644 index 41c6ef4bb30..00000000000 --- a/public/app/features/alerting/unified/AmNotifications.test.tsx +++ /dev/null @@ -1,84 +0,0 @@ -import React from 'react'; -import { locationService, setDataSourceSrv } from '@grafana/runtime'; -import { render, waitFor } from '@testing-library/react'; -import { Provider } from 'react-redux'; -import { Router } from 'react-router-dom'; -import { fetchAlertGroups } from './api/alertmanager'; -import { byTestId, byText } from 'testing-library-selector'; -import { configureStore } from 'app/store/configureStore'; -import { typeAsJestMock } from 'test/helpers/typeAsJestMock'; -import AmNotifications from './AmNotifications'; -import { mockAlertGroup, mockAlertmanagerAlert, mockDataSource, MockDataSourceSrv } from './mocks'; -import { DataSourceType } from './utils/datasource'; -import userEvent from '@testing-library/user-event'; - -jest.mock('./api/alertmanager'); - -const mocks = { - api: { - fetchAlertGroups: typeAsJestMock(fetchAlertGroups), - }, -}; - -const renderAmNotifications = () => { - const store = configureStore(); - - return render( - - - - - - ); -}; - -const dataSources = { - am: mockDataSource({ - name: 'Alertmanager', - type: DataSourceType.Alertmanager, - }), -}; - -const ui = { - group: byTestId('notifications-group'), - groupCollapseToggle: byTestId('notifications-group-collapse-toggle'), - notificationsTable: byTestId('notifications-table'), - row: byTestId('row'), - collapseToggle: byTestId('collapse-toggle'), - silenceButton: byText('Silence'), - sourceButton: byText('See source'), -}; - -describe('AmNotifications', () => { - beforeAll(() => { - mocks.api.fetchAlertGroups.mockImplementation(() => { - return Promise.resolve([ - mockAlertGroup({ labels: {}, alerts: [mockAlertmanagerAlert({ labels: { foo: 'bar' } })] }), - mockAlertGroup(), - ]); - }); - }); - - beforeEach(() => { - setDataSourceSrv(new MockDataSourceSrv(dataSources)); - }); - - it('loads and shows groups', async () => { - await renderAmNotifications(); - - await waitFor(() => expect(mocks.api.fetchAlertGroups).toHaveBeenCalled()); - - const groups = await ui.group.getAll(); - - expect(groups).toHaveLength(2); - expect(groups[0]).toHaveTextContent('No grouping'); - expect(groups[1]).toHaveTextContent('severity=warningregion=US-Central'); - - userEvent.click(ui.groupCollapseToggle.get(groups[0])); - expect(ui.notificationsTable.get()).toBeDefined(); - - userEvent.click(ui.collapseToggle.get(ui.notificationsTable.get())); - expect(ui.silenceButton.get(ui.notificationsTable.get())).toBeDefined(); - expect(ui.sourceButton.get(ui.notificationsTable.get())).toBeDefined(); - }); -}); diff --git a/public/app/features/alerting/unified/AmNotifications.tsx b/public/app/features/alerting/unified/AmNotifications.tsx deleted file mode 100644 index 7533708c6eb..00000000000 --- a/public/app/features/alerting/unified/AmNotifications.tsx +++ /dev/null @@ -1,62 +0,0 @@ -import { AlertmanagerGroup } from 'app/plugins/datasource/alertmanager/types'; -import React, { useEffect } from 'react'; - -import { useDispatch } from 'react-redux'; - -import { AlertingPageWrapper } from './components/AlertingPageWrapper'; -import { AlertManagerPicker } from './components/AlertManagerPicker'; -import { useAlertManagerSourceName } from './hooks/useAlertManagerSourceName'; -import { useUnifiedAlertingSelector } from './hooks/useUnifiedAlertingSelector'; -import { fetchAlertGroupsAction } from './state/actions'; -import { initialAsyncRequestState } from './utils/redux'; - -import { AmNotificationsGroup } from './components/amnotifications/AmNotificationsGroup'; -import { NOTIFICATIONS_POLL_INTERVAL_MS } from './utils/constants'; -import { Alert, LoadingPlaceholder } from '@grafana/ui'; - -const AlertManagerNotifications = () => { - const [alertManagerSourceName, setAlertManagerSourceName] = useAlertManagerSourceName(); - const dispatch = useDispatch(); - - const alertGroups = useUnifiedAlertingSelector((state) => state.amAlertGroups) || initialAsyncRequestState; - const loading = alertGroups[alertManagerSourceName || '']?.loading; - const error = alertGroups[alertManagerSourceName || '']?.error; - const results: AlertmanagerGroup[] = alertGroups[alertManagerSourceName || '']?.result || []; - - useEffect(() => { - function fetchNotifications() { - if (alertManagerSourceName) { - dispatch(fetchAlertGroupsAction(alertManagerSourceName)); - } - } - fetchNotifications(); - const interval = setInterval(() => fetchNotifications, NOTIFICATIONS_POLL_INTERVAL_MS); - return () => { - clearInterval(interval); - }; - }, [dispatch, alertManagerSourceName]); - - return ( - - - {loading && } - {error && !loading && ( - - {error.message || 'Unknown error'} - - )} - {results && - results.map((group, index) => { - return ( - - ); - })} - - ); -}; - -export default AlertManagerNotifications; diff --git a/public/app/features/alerting/unified/components/amnotifications/AmNotificationsAlertDetails.tsx b/public/app/features/alerting/unified/components/alert-groups/AlertDetails.tsx similarity index 94% rename from public/app/features/alerting/unified/components/amnotifications/AmNotificationsAlertDetails.tsx rename to public/app/features/alerting/unified/components/alert-groups/AlertDetails.tsx index 7230e65a27a..8b16461f7d0 100644 --- a/public/app/features/alerting/unified/components/amnotifications/AmNotificationsAlertDetails.tsx +++ b/public/app/features/alerting/unified/components/alert-groups/AlertDetails.tsx @@ -13,10 +13,7 @@ interface AmNotificationsAlertDetailsProps { alert: AlertmanagerAlert; } -export const AmNotificationsAlertDetails: FC = ({ - alert, - alertManagerSourceName, -}) => { +export const AlertDetails: FC = ({ alert, alertManagerSourceName }) => { const styles = useStyles2(getStyles); return ( <> diff --git a/public/app/features/alerting/unified/components/amnotifications/AmNotificationsGroup.tsx b/public/app/features/alerting/unified/components/alert-groups/AlertGroup.tsx similarity index 77% rename from public/app/features/alerting/unified/components/amnotifications/AmNotificationsGroup.tsx rename to public/app/features/alerting/unified/components/alert-groups/AlertGroup.tsx index 5db45a22d91..073732227a4 100644 --- a/public/app/features/alerting/unified/components/amnotifications/AmNotificationsGroup.tsx +++ b/public/app/features/alerting/unified/components/alert-groups/AlertGroup.tsx @@ -4,27 +4,27 @@ import { GrafanaTheme2 } from '@grafana/data'; import { useStyles2 } from '@grafana/ui'; import { css } from '@emotion/css'; import { AlertLabels } from '../AlertLabels'; -import { AmNotificationsAlertsTable } from './AmNotificationsAlertsTable'; +import { AlertGroupAlertsTable } from './AlertGroupAlertsTable'; import { CollapseToggle } from '../CollapseToggle'; -import { AmNotificationsGroupHeader } from './AmNotificationsGroupHeader'; +import { AlertGroupHeader } from './AlertGroupHeader'; interface Props { group: AlertmanagerGroup; alertManagerSourceName: string; } -export const AmNotificationsGroup = ({ alertManagerSourceName, group }: Props) => { +export const AlertGroup = ({ alertManagerSourceName, group }: Props) => { const [isCollapsed, setIsCollapsed] = useState(true); const styles = useStyles2(getStyles); return (
-
+
setIsCollapsed(!isCollapsed)} - data-testid="notifications-group-collapse-toggle" + data-testid="alert-group-collapse-toggle" /> {Object.keys(group.labels).length ? ( @@ -32,11 +32,9 @@ export const AmNotificationsGroup = ({ alertManagerSourceName, group }: Props) = No grouping )}
- +
- {!isCollapsed && ( - - )} + {!isCollapsed && }
); }; diff --git a/public/app/features/alerting/unified/components/amnotifications/AmNotificationsAlertsTable.tsx b/public/app/features/alerting/unified/components/alert-groups/AlertGroupAlertsTable.tsx similarity index 77% rename from public/app/features/alerting/unified/components/amnotifications/AmNotificationsAlertsTable.tsx rename to public/app/features/alerting/unified/components/alert-groups/AlertGroupAlertsTable.tsx index ea04eb4cb01..6bd0ad274e6 100644 --- a/public/app/features/alerting/unified/components/amnotifications/AmNotificationsAlertsTable.tsx +++ b/public/app/features/alerting/unified/components/alert-groups/AlertGroupAlertsTable.tsx @@ -7,21 +7,21 @@ import { DynamicTableColumnProps, DynamicTableItemProps } from '../DynamicTable' import { AmAlertStateTag } from '../silences/AmAlertStateTag'; import { AlertLabels } from '../AlertLabels'; import { DynamicTableWithGuidelines } from '../DynamicTableWithGuidelines'; -import { AmNotificationsAlertDetails } from './AmNotificationsAlertDetails'; +import { AlertDetails } from './AlertDetails'; interface Props { alerts: AlertmanagerAlert[]; alertManagerSourceName: string; } -type AmNotificationsAlertsTableColumnProps = DynamicTableColumnProps; -type AmNotificationsAlertsTableItemProps = DynamicTableItemProps; +type AlertGroupAlertsTableColumnProps = DynamicTableColumnProps; +type AlertGroupAlertsTableItemProps = DynamicTableItemProps; -export const AmNotificationsAlertsTable = ({ alerts, alertManagerSourceName }: Props) => { +export const AlertGroupAlertsTable = ({ alerts, alertManagerSourceName }: Props) => { const styles = useStyles2(getStyles); const columns = useMemo( - (): AmNotificationsAlertsTableColumnProps[] => [ + (): AlertGroupAlertsTableColumnProps[] => [ { id: 'state', label: 'State', @@ -52,7 +52,7 @@ export const AmNotificationsAlertsTable = ({ alerts, alertManagerSourceName }: P ); const items = useMemo( - (): AmNotificationsAlertsTableItemProps[] => + (): AlertGroupAlertsTableItemProps[] => alerts.map((alert) => ({ id: alert.fingerprint, data: alert, @@ -61,13 +61,13 @@ export const AmNotificationsAlertsTable = ({ alerts, alertManagerSourceName }: P ); return ( -
+
( - + )} />
diff --git a/public/app/features/alerting/unified/components/alert-groups/AlertGroupFilter.tsx b/public/app/features/alerting/unified/components/alert-groups/AlertGroupFilter.tsx new file mode 100644 index 00000000000..7df3650276f --- /dev/null +++ b/public/app/features/alerting/unified/components/alert-groups/AlertGroupFilter.tsx @@ -0,0 +1,88 @@ +import React, { useState } from 'react'; + +import { AlertManagerPicker } from '../AlertManagerPicker'; +import { MatcherFilter } from './MatcherFilter'; +import { AlertStateFilter } from './AlertStateFilter'; +import { GroupBy } from './GroupBy'; +import { AlertmanagerGroup, AlertState } from 'app/plugins/datasource/alertmanager/types'; +import { GrafanaTheme2 } from '@grafana/data'; +import { Button, useStyles2 } from '@grafana/ui'; + +import { useAlertManagerSourceName } from '../../hooks/useAlertManagerSourceName'; +import { css } from '@emotion/css'; +import { getFiltersFromUrlParams } from '../../utils/misc'; +import { useQueryParams } from 'app/core/hooks/useQueryParams'; + +interface Props { + groups: AlertmanagerGroup[]; +} + +export const AlertGroupFilter = ({ groups }: Props) => { + const [filterKey, setFilterKey] = useState(Math.floor(Math.random() * 100)); + const [queryParams, setQueryParams] = useQueryParams(); + const { groupBy = [], queryString, alertState } = getFiltersFromUrlParams(queryParams); + const matcherFilterKey = `matcher-${filterKey}`; + + const [alertManagerSourceName, setAlertManagerSourceName] = useAlertManagerSourceName(); + const styles = useStyles2(getStyles); + + const clearFilters = () => { + setQueryParams({ + groupBy: null, + queryString: null, + alertState: null, + }); + setTimeout(() => setFilterKey(filterKey + 1), 100); + }; + + const showClearButton = !!(groupBy.length > 0 || queryString || alertState); + + return ( +
+ +
+ setQueryParams({ queryString: value ? value : null })} + /> + setQueryParams({ groupBy: keys.length ? keys.join(',') : null })} + /> + setQueryParams({ alertState: value ? value : null })} + /> + {showClearButton && ( + + )} +
+
+ ); +}; + +const getStyles = (theme: GrafanaTheme2) => ({ + wrapper: css` + border-bottom: 1px solid ${theme.colors.border.medium}; + margin-bottom: ${theme.spacing(3)}; + `, + filterSection: css` + display: flex; + flex-direction: row; + margin-bottom: ${theme.spacing(3)}; + `, + filterInput: css` + width: 340px; + margin-left: ${theme.spacing(1)}; + `, + clearButton: css` + margin-left: ${theme.spacing(1)}; + margin-top: 19px; + `, +}); diff --git a/public/app/features/alerting/unified/components/amnotifications/AmNotificationsGroupHeader.tsx b/public/app/features/alerting/unified/components/alert-groups/AlertGroupHeader.tsx similarity index 94% rename from public/app/features/alerting/unified/components/amnotifications/AmNotificationsGroupHeader.tsx rename to public/app/features/alerting/unified/components/alert-groups/AlertGroupHeader.tsx index 76464b2b9ac..9f6d9fd7cdb 100644 --- a/public/app/features/alerting/unified/components/amnotifications/AmNotificationsGroupHeader.tsx +++ b/public/app/features/alerting/unified/components/alert-groups/AlertGroupHeader.tsx @@ -8,7 +8,7 @@ interface Props { group: AlertmanagerGroup; } -export const AmNotificationsGroupHeader = ({ group }: Props) => { +export const AlertGroupHeader = ({ group }: Props) => { const textStyles = useStyles2(getNotificationsTextColors); const total = group.alerts.length; const countByStatus = group.alerts.reduce((statusObj, alert) => { diff --git a/public/app/features/alerting/unified/components/alert-groups/AlertStateFilter.tsx b/public/app/features/alerting/unified/components/alert-groups/AlertStateFilter.tsx new file mode 100644 index 00000000000..44cc945b443 --- /dev/null +++ b/public/app/features/alerting/unified/components/alert-groups/AlertStateFilter.tsx @@ -0,0 +1,33 @@ +import React from 'react'; +import { RadioButtonGroup, Label, useStyles2 } from '@grafana/ui'; +import { GrafanaTheme2, SelectableValue } from '@grafana/data'; +import { AlertState } from 'app/plugins/datasource/alertmanager/types'; +import { css } from '@emotion/css'; + +interface Props { + stateFilter?: AlertState; + onStateFilterChange: (value: AlertState) => void; +} + +export const AlertStateFilter = ({ onStateFilterChange, stateFilter }: Props) => { + const styles = useStyles2(getStyles); + const alertStateOptions: SelectableValue[] = Object.entries(AlertState) + .sort(([labelA], [labelB]) => (labelA < labelB ? -1 : 1)) + .map(([label, state]) => ({ + label, + value: state, + })); + + return ( +
+ + +
+ ); +}; + +const getStyles = (theme: GrafanaTheme2) => ({ + wrapper: css` + margin-left: ${theme.spacing(1)}; + `, +}); diff --git a/public/app/features/alerting/unified/components/alert-groups/GroupBy.tsx b/public/app/features/alerting/unified/components/alert-groups/GroupBy.tsx new file mode 100644 index 00000000000..ae595e84372 --- /dev/null +++ b/public/app/features/alerting/unified/components/alert-groups/GroupBy.tsx @@ -0,0 +1,37 @@ +import { AlertmanagerGroup } from 'app/plugins/datasource/alertmanager/types'; +import React from 'react'; +import { uniq } from 'lodash'; +import { Icon, Label, MultiSelect } from '@grafana/ui'; +import { SelectableValue } from '@grafana/data'; + +interface Props { + className?: string; + groups: AlertmanagerGroup[]; + groupBy: string[]; + onGroupingChange: (keys: string[]) => void; +} + +export const GroupBy = ({ className, groups, groupBy, onGroupingChange }: Props) => { + const labelKeyOptions = uniq(groups.flatMap((group) => group.alerts).flatMap(({ labels }) => Object.keys(labels))) + .filter((label) => !(label.startsWith('__') && label.endsWith('__'))) // Filter out private labels + .map((key) => ({ + label: key, + value: key, + })); + + return ( +
+ + } + onChange={(items) => { + onGroupingChange(items.map(({ value }) => value as string)); + }} + options={labelKeyOptions} + /> +
+ ); +}; diff --git a/public/app/features/alerting/unified/components/alert-groups/MatcherFilter.tsx b/public/app/features/alerting/unified/components/alert-groups/MatcherFilter.tsx new file mode 100644 index 00000000000..e0652394d76 --- /dev/null +++ b/public/app/features/alerting/unified/components/alert-groups/MatcherFilter.tsx @@ -0,0 +1,47 @@ +import React, { FormEvent } from 'react'; +import { Label, Tooltip, Input, Icon, useStyles2 } from '@grafana/ui'; +import { GrafanaTheme2 } from '@grafana/data'; +import { css } from '@emotion/css'; + +interface Props { + className?: string; + queryString?: string; + onFilterChange: (filterString: string) => void; +} + +export const MatcherFilter = ({ className, onFilterChange, queryString }: Props) => { + const styles = useStyles2(getStyles); + const handleSearchChange = (e: FormEvent) => { + const target = e.target as HTMLInputElement; + onFilterChange(target.value); + }; + return ( +
+
+ } + > + + + Search by label + + +
+ ); +}; + +const getStyles = (theme: GrafanaTheme2) => ({ + icon: css` + margin-right: ${theme.spacing(0.5)}; + `, +}); diff --git a/public/app/features/alerting/unified/components/rules/RulesFilter.tsx b/public/app/features/alerting/unified/components/rules/RulesFilter.tsx index ca6f4a91603..403733980c2 100644 --- a/public/app/features/alerting/unified/components/rules/RulesFilter.tsx +++ b/public/app/features/alerting/unified/components/rules/RulesFilter.tsx @@ -61,7 +61,7 @@ const RulesFilter = () => { queryString: null, dataSource: null, }); - setFilterKey(filterKey + 1); + setTimeout(() => setFilterKey(filterKey + 1), 100); }; const searchIcon = ; diff --git a/public/app/features/alerting/unified/hooks/useFilteredAmGroups.ts b/public/app/features/alerting/unified/hooks/useFilteredAmGroups.ts new file mode 100644 index 00000000000..fb5f54ae78a --- /dev/null +++ b/public/app/features/alerting/unified/hooks/useFilteredAmGroups.ts @@ -0,0 +1,30 @@ +import { useQueryParams } from 'app/core/hooks/useQueryParams'; +import { AlertmanagerGroup } from 'app/plugins/datasource/alertmanager/types'; +import { useMemo } from 'react'; +import { labelsMatchMatchers, parseMatchers } from '../utils/alertmanager'; +import { getFiltersFromUrlParams } from '../utils/misc'; + +export const useFilteredAmGroups = (groups: AlertmanagerGroup[]) => { + const [queryParams] = useQueryParams(); + const filters = getFiltersFromUrlParams(queryParams); + const matchers = parseMatchers(filters.queryString || ''); + + return useMemo(() => { + return groups.reduce((filteredGroup, group) => { + const alerts = group.alerts.filter(({ labels, status }) => { + const labelsMatch = labelsMatchMatchers(labels, matchers); + const filtersMatch = filters.alertState ? status.state === filters.alertState : true; + return labelsMatch && filtersMatch; + }); + if (alerts.length > 0) { + // The ungrouped alerts should be first in the results + if (Object.keys(group.labels).length === 0) { + filteredGroup.unshift({ ...group, alerts }); + } else { + filteredGroup.push({ ...group, alerts }); + } + } + return filteredGroup; + }, [] as AlertmanagerGroup[]); + }, [groups, filters, matchers]); +}; diff --git a/public/app/features/alerting/unified/hooks/useFilteredRules.ts b/public/app/features/alerting/unified/hooks/useFilteredRules.ts index 9588f44626f..7bc61a97fc0 100644 --- a/public/app/features/alerting/unified/hooks/useFilteredRules.ts +++ b/public/app/features/alerting/unified/hooks/useFilteredRules.ts @@ -1,6 +1,6 @@ import { useMemo } from 'react'; -import { CombinedRuleGroup, CombinedRuleNamespace, RuleFilterState } from 'app/types/unified-alerting'; +import { CombinedRuleGroup, CombinedRuleNamespace, FilterState } from 'app/types/unified-alerting'; import { isCloudRulesSource } from '../utils/datasource'; import { isAlertingRule, isGrafanaRulerRule } from '../utils/rules'; import { getFiltersFromUrlParams } from '../utils/misc'; @@ -29,7 +29,7 @@ export const useFilteredRules = (namespaces: CombinedRuleNamespace[]) => { }, [namespaces, filters]); }; -const reduceNamespaces = (filters: RuleFilterState) => { +const reduceNamespaces = (filters: FilterState) => { return (namespaceAcc: CombinedRuleNamespace[], namespace: CombinedRuleNamespace) => { const groups = namespace.groups.reduce(reduceGroups(filters), [] as CombinedRuleGroup[]); @@ -45,7 +45,7 @@ const reduceNamespaces = (filters: RuleFilterState) => { }; // Reduces groups to only groups that have rules matching the filters -const reduceGroups = (filters: RuleFilterState) => { +const reduceGroups = (filters: FilterState) => { return (groupAcc: CombinedRuleGroup[], group: CombinedRuleGroup) => { const rules = group.rules.filter((rule) => { if (filters.dataSource && isGrafanaRulerRule(rule.rulerRule) && !isQueryingDataSource(rule.rulerRule, filters)) { @@ -87,7 +87,7 @@ const reduceGroups = (filters: RuleFilterState) => { }; }; -const isQueryingDataSource = (rulerRule: RulerGrafanaRuleDTO, filter: RuleFilterState): boolean => { +const isQueryingDataSource = (rulerRule: RulerGrafanaRuleDTO, filter: FilterState): boolean => { if (!filter.dataSource) { return true; } diff --git a/public/app/features/alerting/unified/hooks/useGroupedAlerts.ts b/public/app/features/alerting/unified/hooks/useGroupedAlerts.ts new file mode 100644 index 00000000000..278851fbfc5 --- /dev/null +++ b/public/app/features/alerting/unified/hooks/useGroupedAlerts.ts @@ -0,0 +1,47 @@ +import { useMemo } from 'react'; +import { AlertmanagerGroup } from 'app/plugins/datasource/alertmanager/types'; +import { Labels } from '@grafana/data'; + +export const useGroupedAlerts = (groups: AlertmanagerGroup[], groupBy: string[]) => { + return useMemo(() => { + if (groupBy.length === 0) { + return groups; + } + const alerts = groups.flatMap(({ alerts }) => alerts); + return alerts.reduce((groupings, alert) => { + const alertContainsGroupings = groupBy.every((groupByLabel) => Object.keys(alert.labels).includes(groupByLabel)); + + if (alertContainsGroupings) { + const existingGrouping = groupings.find((group) => { + return groupBy.every((groupKey) => { + return group.labels[groupKey] === alert.labels[groupKey]; + }); + }); + if (!existingGrouping) { + const labels = groupBy.reduce((acc, key) => { + acc = { ...acc, [key]: alert.labels[key] }; + return acc; + }, {} as Labels); + groupings.push({ + alerts: [alert], + labels, + receiver: { + name: 'NONE', + }, + }); + } else { + existingGrouping.alerts.push(alert); + } + } else { + const noGroupingGroup = groupings.find((group) => Object.keys(group.labels).length === 0); + if (!noGroupingGroup) { + groupings.push({ alerts: [alert], labels: {}, receiver: { name: 'NONE' } }); + } else { + noGroupingGroup.alerts.push(alert); + } + } + + return groupings; + }, [] as AlertmanagerGroup[]); + }, [groups, groupBy]); +}; diff --git a/public/app/features/alerting/unified/mocks.ts b/public/app/features/alerting/unified/mocks.ts index bd8c0f0bc20..e1cc9a76284 100644 --- a/public/app/features/alerting/unified/mocks.ts +++ b/public/app/features/alerting/unified/mocks.ts @@ -177,7 +177,7 @@ export const mockAlertGroup = (partial: Partial = {}): Alertm mockAlertmanagerAlert(), mockAlertmanagerAlert({ status: { state: AlertState.Suppressed, silencedBy: ['123456abcdef'], inhibitedBy: [] }, - labels: { severity: 'warning', region: 'US-Central', foo: 'bar' }, + labels: { severity: 'warning', region: 'US-Central', foo: 'bar', ...partial.labels }, }), ], ...partial, diff --git a/public/app/features/alerting/unified/utils/misc.ts b/public/app/features/alerting/unified/utils/misc.ts index b8bd7fb5613..f7dfc4e310c 100644 --- a/public/app/features/alerting/unified/utils/misc.ts +++ b/public/app/features/alerting/unified/utils/misc.ts @@ -1,5 +1,5 @@ import { urlUtil, UrlQueryMap } from '@grafana/data'; -import { CombinedRule, RuleFilterState, RulesSource } from 'app/types/unified-alerting'; +import { CombinedRule, FilterState, RulesSource } from 'app/types/unified-alerting'; import { ALERTMANAGER_NAME_QUERY_KEY } from './constants'; import { getRulesSourceName } from './datasource'; import * as ruleId from './rule-id'; @@ -32,12 +32,12 @@ export function arrayToRecord(items: Array<{ key: string; value: string }>): Rec }, {}); } -export const getFiltersFromUrlParams = (queryParams: UrlQueryMap): RuleFilterState => { +export const getFiltersFromUrlParams = (queryParams: UrlQueryMap): FilterState => { const queryString = queryParams['queryString'] === undefined ? undefined : String(queryParams['queryString']); const alertState = queryParams['alertState'] === undefined ? undefined : String(queryParams['alertState']); const dataSource = queryParams['dataSource'] === undefined ? undefined : String(queryParams['dataSource']); - - return { queryString, alertState, dataSource }; + const groupBy = queryParams['groupBy'] === undefined ? undefined : String(queryParams['groupBy']).split(','); + return { queryString, alertState, dataSource, groupBy }; }; export function recordToArray(record: Record): Array<{ key: string; value: string }> { diff --git a/public/app/plugins/panel/alertGroups/AlertGroup.tsx b/public/app/plugins/panel/alertGroups/AlertGroup.tsx index 89471db4dec..caf2727f287 100644 --- a/public/app/plugins/panel/alertGroups/AlertGroup.tsx +++ b/public/app/plugins/panel/alertGroups/AlertGroup.tsx @@ -5,7 +5,7 @@ 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 { AlertGroupHeader } from 'app/features/alerting/unified/components/alert-groups/AlertGroupHeader'; 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'; @@ -33,7 +33,7 @@ export const AlertGroup = ({ alertManagerSourceName, group, expandAll }: Props) )}
setShowAlerts(!showAlerts)} />{' '} - +
{showAlerts && (
diff --git a/public/app/routes/routes.tsx b/public/app/routes/routes.tsx index 96b340529ea..18ab0165721 100644 --- a/public/app/routes/routes.tsx +++ b/public/app/routes/routes.tsx @@ -431,10 +431,9 @@ export function getAppRoutes(): RouteDescriptor[] { ), }, { - path: '/alerting/alertmanager/', + path: '/alerting/groups/', component: SafeDynamicImport( - () => - import(/* webpackChunkName: "AlertManagerNotifications" */ 'app/features/alerting/unified/AmNotifications') + () => import(/* webpackChunkName: "AlertGroups" */ 'app/features/alerting/unified/AlertGroups') ), }, { diff --git a/public/app/types/unified-alerting.ts b/public/app/types/unified-alerting.ts index 4ffcf9fe57f..38c883de66e 100644 --- a/public/app/types/unified-alerting.ts +++ b/public/app/types/unified-alerting.ts @@ -127,8 +127,9 @@ export interface PrometheusRuleIdentifier { } export type RuleIdentifier = CloudRuleIdentifier | GrafanaRuleIdentifier | PrometheusRuleIdentifier; -export interface RuleFilterState { +export interface FilterState { queryString?: string; dataSource?: string; alertState?: string; + groupBy?: string[]; }