mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
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:
parent
6ed60c0bec
commit
df791ae2af
@ -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 {
|
||||
|
151
public/app/features/alerting/unified/AlertGroups.test.tsx
Normal file
151
public/app/features/alerting/unified/AlertGroups.test.tsx
Normal 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');
|
||||
});
|
||||
});
|
80
public/app/features/alerting/unified/AlertGroups.tsx
Normal file
80
public/app/features/alerting/unified/AlertGroups.tsx
Normal 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;
|
@ -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;
|
@ -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 (
|
||||
<>
|
@ -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>
|
||||
);
|
||||
};
|
@ -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>
|
@ -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;
|
||||
`,
|
||||
});
|
@ -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) => {
|
@ -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)};
|
||||
`,
|
||||
});
|
@ -61,7 +61,7 @@ const RulesFilter = () => {
|
||||
queryString: null,
|
||||
dataSource: null,
|
||||
});
|
||||
setFilterKey(filterKey + 1);
|
||||
setTimeout(() => setFilterKey(filterKey + 1), 100);
|
||||
};
|
||||
|
||||
const searchIcon = <Icon name={'search'} />;
|
||||
|
@ -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]);
|
||||
};
|
@ -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;
|
||||
}
|
||||
|
@ -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]);
|
||||
};
|
@ -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,
|
||||
|
@ -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 }> {
|
||||
|
@ -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}>
|
||||
|
@ -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')
|
||||
),
|
||||
},
|
||||
{
|
||||
|
@ -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[];
|
||||
}
|
||||
|
Loading…
Reference in New Issue
Block a user