mirror of https://github.com/grafana/grafana
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 logicpull/35142/head^2
parent
6ed60c0bec
commit
df791ae2af
@ -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( |
||||||
|
<Provider store={store}> |
||||||
|
<Router history={locationService.getHistory()}> |
||||||
|
<AlertGroups /> |
||||||
|
</Router> |
||||||
|
</Provider> |
||||||
|
); |
||||||
|
}; |
||||||
|
|
||||||
|
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'); |
||||||
|
}); |
||||||
|
}); |
||||||
@ -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 ( |
||||||
|
<AlertingPageWrapper pageId="groups"> |
||||||
|
<AlertGroupFilter groups={results} /> |
||||||
|
{loading && <LoadingPlaceholder text="Loading notifications" />} |
||||||
|
{error && !loading && ( |
||||||
|
<Alert title={'Error loading notifications'} severity={'error'}> |
||||||
|
{error.message || 'Unknown error'} |
||||||
|
</Alert> |
||||||
|
)} |
||||||
|
{results && |
||||||
|
filteredAlertGroups.map((group, index) => { |
||||||
|
return ( |
||||||
|
<React.Fragment key={`${JSON.stringify(group.labels)}-group-${index}`}> |
||||||
|
{((index === 1 && Object.keys(filteredAlertGroups[0].labels).length === 0) || |
||||||
|
(index === 0 && Object.keys(group.labels).length > 0)) && ( |
||||||
|
<p className={styles.groupingBanner}>Grouped by: {Object.keys(group.labels).join(', ')}</p> |
||||||
|
)} |
||||||
|
<AlertGroup alertManagerSourceName={alertManagerSourceName || ''} group={group} /> |
||||||
|
</React.Fragment> |
||||||
|
); |
||||||
|
})} |
||||||
|
</AlertingPageWrapper> |
||||||
|
); |
||||||
|
}; |
||||||
|
|
||||||
|
const getStyles = (theme: GrafanaTheme2) => ({ |
||||||
|
groupingBanner: css` |
||||||
|
margin: ${theme.spacing(2, 0)}; |
||||||
|
`,
|
||||||
|
}); |
||||||
|
|
||||||
|
export default AlertGroups; |
||||||
@ -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( |
|
||||||
<Provider store={store}> |
|
||||||
<Router history={locationService.getHistory()}> |
|
||||||
<AmNotifications /> |
|
||||||
</Router> |
|
||||||
</Provider> |
|
||||||
); |
|
||||||
}; |
|
||||||
|
|
||||||
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(); |
|
||||||
}); |
|
||||||
}); |
|
||||||
@ -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 ( |
|
||||||
<AlertingPageWrapper pageId="notifications"> |
|
||||||
<AlertManagerPicker current={alertManagerSourceName} onChange={setAlertManagerSourceName} /> |
|
||||||
{loading && <LoadingPlaceholder text="Loading notifications" />} |
|
||||||
{error && !loading && ( |
|
||||||
<Alert title={'Error loading notifications'} severity={'error'}> |
|
||||||
{error.message || 'Unknown error'} |
|
||||||
</Alert> |
|
||||||
)} |
|
||||||
{results && |
|
||||||
results.map((group, index) => { |
|
||||||
return ( |
|
||||||
<AmNotificationsGroup |
|
||||||
alertManagerSourceName={alertManagerSourceName || ''} |
|
||||||
key={`${JSON.stringify(group.labels)}-group-${index}`} |
|
||||||
group={group} |
|
||||||
/> |
|
||||||
); |
|
||||||
})} |
|
||||||
</AlertingPageWrapper> |
|
||||||
); |
|
||||||
}; |
|
||||||
|
|
||||||
export default AlertManagerNotifications; |
|
||||||
@ -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<number>(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 ( |
||||||
|
<div className={styles.wrapper}> |
||||||
|
<AlertManagerPicker current={alertManagerSourceName} onChange={setAlertManagerSourceName} /> |
||||||
|
<div className={styles.filterSection}> |
||||||
|
<MatcherFilter |
||||||
|
className={styles.filterInput} |
||||||
|
key={matcherFilterKey} |
||||||
|
queryString={queryString} |
||||||
|
onFilterChange={(value) => setQueryParams({ queryString: value ? value : null })} |
||||||
|
/> |
||||||
|
<GroupBy |
||||||
|
className={styles.filterInput} |
||||||
|
groups={groups} |
||||||
|
groupBy={groupBy} |
||||||
|
onGroupingChange={(keys) => setQueryParams({ groupBy: keys.length ? keys.join(',') : null })} |
||||||
|
/> |
||||||
|
<AlertStateFilter |
||||||
|
stateFilter={alertState as AlertState} |
||||||
|
onStateFilterChange={(value) => setQueryParams({ alertState: value ? value : null })} |
||||||
|
/> |
||||||
|
{showClearButton && ( |
||||||
|
<Button className={styles.clearButton} variant={'secondary'} icon="times" onClick={clearFilters}> |
||||||
|
Clear filters |
||||||
|
</Button> |
||||||
|
)} |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
); |
||||||
|
}; |
||||||
|
|
||||||
|
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; |
||||||
|
`,
|
||||||
|
}); |
||||||
@ -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 ( |
||||||
|
<div className={styles.wrapper}> |
||||||
|
<Label>State</Label> |
||||||
|
<RadioButtonGroup options={alertStateOptions} value={stateFilter} onChange={onStateFilterChange} /> |
||||||
|
</div> |
||||||
|
); |
||||||
|
}; |
||||||
|
|
||||||
|
const getStyles = (theme: GrafanaTheme2) => ({ |
||||||
|
wrapper: css` |
||||||
|
margin-left: ${theme.spacing(1)}; |
||||||
|
`,
|
||||||
|
}); |
||||||
@ -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<SelectableValue>((key) => ({ |
||||||
|
label: key, |
||||||
|
value: key, |
||||||
|
})); |
||||||
|
|
||||||
|
return ( |
||||||
|
<div data-testid={'group-by-container'} className={className}> |
||||||
|
<Label>Custom group by</Label> |
||||||
|
<MultiSelect |
||||||
|
aria-label={'group by label keys'} |
||||||
|
value={groupBy} |
||||||
|
placeholder="Group by" |
||||||
|
prefix={<Icon name={'tag-alt'} />} |
||||||
|
onChange={(items) => { |
||||||
|
onGroupingChange(items.map(({ value }) => value as string)); |
||||||
|
}} |
||||||
|
options={labelKeyOptions} |
||||||
|
/> |
||||||
|
</div> |
||||||
|
); |
||||||
|
}; |
||||||
@ -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<HTMLInputElement>) => { |
||||||
|
const target = e.target as HTMLInputElement; |
||||||
|
onFilterChange(target.value); |
||||||
|
}; |
||||||
|
return ( |
||||||
|
<div className={className}> |
||||||
|
<Label> |
||||||
|
<Tooltip |
||||||
|
content={ |
||||||
|
<div> |
||||||
|
Filter alerts using label querying, ex: |
||||||
|
<pre>{`{severity="critical", instance=~"cluster-us-.+"}`}</pre> |
||||||
|
</div> |
||||||
|
} |
||||||
|
> |
||||||
|
<Icon className={styles.icon} name="info-circle" size="xs" /> |
||||||
|
</Tooltip> |
||||||
|
Search by label |
||||||
|
</Label> |
||||||
|
<Input |
||||||
|
placeholder="Search" |
||||||
|
defaultValue={queryString} |
||||||
|
onChange={handleSearchChange} |
||||||
|
data-testid="search-query-input" |
||||||
|
/> |
||||||
|
</div> |
||||||
|
); |
||||||
|
}; |
||||||
|
|
||||||
|
const getStyles = (theme: GrafanaTheme2) => ({ |
||||||
|
icon: css` |
||||||
|
margin-right: ${theme.spacing(0.5)}; |
||||||
|
`,
|
||||||
|
}); |
||||||
@ -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]); |
||||||
|
}; |
||||||
@ -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]); |
||||||
|
}; |
||||||
Loading…
Reference in new issue