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
This commit is contained in:
Nathan Rodman 2021-08-19 09:22:52 -07:00 committed by GitHub
parent 6ed60c0bec
commit df791ae2af
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
22 changed files with 547 additions and 185 deletions

View File

@ -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 {

View File

@ -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');
});
});

View File

@ -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;

View File

@ -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();
});
});

View File

@ -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;

View File

@ -13,10 +13,7 @@ interface AmNotificationsAlertDetailsProps {
alert: AlertmanagerAlert;
}
export const AmNotificationsAlertDetails: FC<AmNotificationsAlertDetailsProps> = ({
alert,
alertManagerSourceName,
}) => {
export const AlertDetails: FC<AmNotificationsAlertDetailsProps> = ({ alert, alertManagerSourceName }) => {
const styles = useStyles2(getStyles);
return (
<>

View File

@ -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<boolean>(true);
const styles = useStyles2(getStyles);
return (
<div className={styles.wrapper}>
<div className={styles.header}>
<div className={styles.group} data-testid="notifications-group">
<div className={styles.group} data-testid="alert-group">
<CollapseToggle
isCollapsed={isCollapsed}
onToggle={() => setIsCollapsed(!isCollapsed)}
data-testid="notifications-group-collapse-toggle"
data-testid="alert-group-collapse-toggle"
/>
{Object.keys(group.labels).length ? (
<AlertLabels className={styles.headerLabels} labels={group.labels} />
@ -32,11 +32,9 @@ export const AmNotificationsGroup = ({ alertManagerSourceName, group }: Props) =
<span>No grouping</span>
)}
</div>
<AmNotificationsGroupHeader group={group} />
<AlertGroupHeader group={group} />
</div>
{!isCollapsed && (
<AmNotificationsAlertsTable alertManagerSourceName={alertManagerSourceName} alerts={group.alerts} />
)}
{!isCollapsed && <AlertGroupAlertsTable alertManagerSourceName={alertManagerSourceName} alerts={group.alerts} />}
</div>
);
};

View File

@ -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<AlertmanagerAlert>;
type AmNotificationsAlertsTableItemProps = DynamicTableItemProps<AlertmanagerAlert>;
type AlertGroupAlertsTableColumnProps = DynamicTableColumnProps<AlertmanagerAlert>;
type AlertGroupAlertsTableItemProps = DynamicTableItemProps<AlertmanagerAlert>;
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 (
<div className={styles.tableWrapper} data-testid="notifications-table">
<div className={styles.tableWrapper} data-testid="alert-group-table">
<DynamicTableWithGuidelines
cols={columns}
items={items}
isExpandable={true}
renderExpandedContent={({ data: alert }) => (
<AmNotificationsAlertDetails alert={alert} alertManagerSourceName={alertManagerSourceName} />
<AlertDetails alert={alert} alertManagerSourceName={alertManagerSourceName} />
)}
/>
</div>

View File

@ -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;
`,
});

View File

@ -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) => {

View File

@ -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)};
`,
});

View File

@ -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>
);
};

View File

@ -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)};
`,
});

View File

@ -61,7 +61,7 @@ const RulesFilter = () => {
queryString: null,
dataSource: null,
});
setFilterKey(filterKey + 1);
setTimeout(() => setFilterKey(filterKey + 1), 100);
};
const searchIcon = <Icon name={'search'} />;

View File

@ -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]);
};

View File

@ -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;
}

View File

@ -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]);
};

View File

@ -177,7 +177,7 @@ export const mockAlertGroup = (partial: Partial<AlertmanagerGroup> = {}): 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,

View File

@ -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<string, string>): Array<{ key: string; value: string }> {

View File

@ -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)
)}
<div className={styles.row}>
<CollapseToggle isCollapsed={!showAlerts} onToggle={() => setShowAlerts(!showAlerts)} />{' '}
<AmNotificationsGroupHeader group={group} />
<AlertGroupHeader group={group} />
</div>
{showAlerts && (
<div className={styles.alerts}>

View File

@ -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')
),
},
{

View File

@ -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[];
}