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