mirror of
https://github.com/grafana/grafana.git
synced 2025-02-10 23:55:47 -06:00
Alerting: Add alertmanager notifications tab (#35759)
* Add alertmanager notifications tab * Link to silences page from am alert * Include summary for alertmanager group * Fix colors for am state * Add horizontal dividing line * PR feedback * Add basic unit test for alert notificaitons * Rename Notificaitons component file * Polling interval to groups * Add alertmanager notifications tab * Link to silences page from am alert * Include summary for alertmanager group * PR feedback * Add basic unit test for alert notificaitons * Rename Notificaitons component file * Alerting: make alertmanager notifications view responsive (#36067) * refac DynamicTableWithGuidelines * more responsiveness fixes * Add more to tests * Add loading and alert state for notifications Co-authored-by: Domas <domas.lapinskas@grafana.com>
This commit is contained in:
parent
d9e500b654
commit
a0dac9c6d9
@ -210,6 +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: "Silences", Id: "silences", Url: hs.Cfg.AppSubURL + "/alerting/silences", Icon: "bell-slash"})
|
||||
}
|
||||
if c.OrgRole == models.ROLE_ADMIN || c.OrgRole == models.ROLE_EDITOR {
|
||||
|
@ -0,0 +1,84 @@
|
||||
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: 'Alert Manager',
|
||||
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();
|
||||
});
|
||||
});
|
62
public/app/features/alerting/unified/AmNotifications.tsx
Normal file
62
public/app/features/alerting/unified/AmNotifications.tsx
Normal file
@ -0,0 +1,62 @@
|
||||
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 '../../../../../packages/grafana-ui/src';
|
||||
|
||||
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;
|
@ -1,17 +1,17 @@
|
||||
import { GrafanaTheme } from '@grafana/data';
|
||||
import { useStyles } from '@grafana/ui';
|
||||
import { css } from '@emotion/css';
|
||||
import { css, cx } from '@emotion/css';
|
||||
import React from 'react';
|
||||
import { AlertLabel } from './AlertLabel';
|
||||
|
||||
type Props = { labels: Record<string, string> };
|
||||
type Props = { labels: Record<string, string>; className?: string };
|
||||
|
||||
export const AlertLabels = ({ labels }: Props) => {
|
||||
export const AlertLabels = ({ labels, className }: Props) => {
|
||||
const styles = useStyles(getStyles);
|
||||
const pairs = Object.entries(labels).filter(([key]) => !(key.startsWith('__') && key.endsWith('__')));
|
||||
|
||||
return (
|
||||
<div className={styles.wrapper}>
|
||||
<div className={cx(styles.wrapper, className)}>
|
||||
{pairs.map(([key, value], index) => (
|
||||
<AlertLabel key={`${key}-${value}-${index}`} labelKey={key} value={value} />
|
||||
))}
|
||||
@ -22,7 +22,7 @@ export const AlertLabels = ({ labels }: Props) => {
|
||||
const getStyles = (theme: GrafanaTheme) => ({
|
||||
wrapper: css`
|
||||
& > * {
|
||||
margin-top: ${theme.spacing.xs};
|
||||
margin-bottom: ${theme.spacing.xs};
|
||||
margin-right: ${theme.spacing.xs};
|
||||
}
|
||||
padding-bottom: ${theme.spacing.xs};
|
||||
|
@ -28,10 +28,18 @@ export interface DynamicTableProps<T = unknown> {
|
||||
onExpand?: (item: DynamicTableItemProps<T>) => void;
|
||||
isExpanded?: (item: DynamicTableItemProps<T>) => boolean;
|
||||
|
||||
renderExpandedContent?: (item: DynamicTableItemProps<T>, index: number) => ReactNode;
|
||||
renderExpandedContent?: (
|
||||
item: DynamicTableItemProps<T>,
|
||||
index: number,
|
||||
items: Array<DynamicTableItemProps<T>>
|
||||
) => ReactNode;
|
||||
testIdGenerator?: (item: DynamicTableItemProps<T>, index: number) => string;
|
||||
renderPrefixHeader?: () => ReactNode;
|
||||
renderPrefixCell?: (item: DynamicTableItemProps<T>, index: number) => ReactNode;
|
||||
renderPrefixCell?: (
|
||||
item: DynamicTableItemProps<T>,
|
||||
index: number,
|
||||
items: Array<DynamicTableItemProps<T>>
|
||||
) => ReactNode;
|
||||
}
|
||||
|
||||
export const DynamicTable = <T extends object>({
|
||||
@ -84,7 +92,7 @@ export const DynamicTable = <T extends object>({
|
||||
const isItemExpanded = isExpanded ? isExpanded(item) : expandedIds.includes(item.id);
|
||||
return (
|
||||
<div className={styles.row} key={item.id} data-testid={testIdGenerator?.(item, index) ?? 'row'}>
|
||||
{renderPrefixCell && renderPrefixCell(item, index)}
|
||||
{renderPrefixCell && renderPrefixCell(item, index, items)}
|
||||
{isExpandable && (
|
||||
<div className={cx(styles.cell, styles.expandCell)}>
|
||||
<IconButton
|
||||
@ -104,7 +112,7 @@ export const DynamicTable = <T extends object>({
|
||||
))}
|
||||
{isItemExpanded && renderExpandedContent && (
|
||||
<div className={styles.expandedContentRow} data-testid="expanded-content">
|
||||
{renderExpandedContent(item, index)}
|
||||
{renderExpandedContent(item, index, items)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@ -223,6 +231,7 @@ const getStyles = <T extends unknown>(
|
||||
`,
|
||||
expandButton: css`
|
||||
margin-right: 0;
|
||||
display: block;
|
||||
`,
|
||||
});
|
||||
};
|
||||
|
@ -0,0 +1,76 @@
|
||||
import { css, cx } from '@emotion/css';
|
||||
import { GrafanaTheme2 } from '@grafana/data';
|
||||
import { useStyles2 } from '@grafana/ui';
|
||||
import React from 'react';
|
||||
import { DynamicTable, DynamicTableProps } from './DynamicTable';
|
||||
|
||||
export type DynamicTableWithGuidelinesProps<T> = Omit<DynamicTableProps<T>, 'renderPrefixHeader, renderPrefixCell'>;
|
||||
|
||||
// DynamicTable, but renders visual guidelines on the left, for larger screen widths
|
||||
export const DynamicTableWithGuidelines = <T extends object>({
|
||||
renderExpandedContent,
|
||||
...props
|
||||
}: DynamicTableWithGuidelinesProps<T>) => {
|
||||
const styles = useStyles2(getStyles);
|
||||
return (
|
||||
<DynamicTable
|
||||
renderExpandedContent={
|
||||
renderExpandedContent
|
||||
? (item, index, items) => (
|
||||
<>
|
||||
{!(index === items.length - 1) && <div className={cx(styles.contentGuideline, styles.guideline)} />}
|
||||
{renderExpandedContent(item, index, items)}
|
||||
</>
|
||||
)
|
||||
: undefined
|
||||
}
|
||||
renderPrefixHeader={() => (
|
||||
<div className={styles.relative}>
|
||||
<div className={cx(styles.headerGuideline, styles.guideline)} />
|
||||
</div>
|
||||
)}
|
||||
renderPrefixCell={(_, index, items) => (
|
||||
<div className={styles.relative}>
|
||||
<div className={cx(styles.topGuideline, styles.guideline)} />
|
||||
{!(index === items.length - 1) && <div className={cx(styles.bottomGuideline, styles.guideline)} />}
|
||||
</div>
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export const getStyles = (theme: GrafanaTheme2) => ({
|
||||
relative: css`
|
||||
position: relative;
|
||||
height: 100%;
|
||||
`,
|
||||
guideline: css`
|
||||
left: -19px;
|
||||
border-left: 1px solid ${theme.colors.border.medium};
|
||||
position: absolute;
|
||||
|
||||
${theme.breakpoints.down('md')} {
|
||||
display: none;
|
||||
}
|
||||
`,
|
||||
topGuideline: css`
|
||||
width: 18px;
|
||||
border-bottom: 1px solid ${theme.colors.border.medium};
|
||||
top: 0;
|
||||
bottom: 50%;
|
||||
`,
|
||||
bottomGuideline: css`
|
||||
top: 50%;
|
||||
bottom: 0;
|
||||
`,
|
||||
contentGuideline: css`
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
left: -49px !important;
|
||||
`,
|
||||
headerGuideline: css`
|
||||
top: -25px;
|
||||
bottom: 0;
|
||||
`,
|
||||
});
|
@ -0,0 +1,92 @@
|
||||
import { css } from '@emotion/css';
|
||||
import { GrafanaTheme2 } from '@grafana/data';
|
||||
import { LinkButton, useStyles2 } from '@grafana/ui';
|
||||
import { AlertmanagerAlert, AlertState } from 'app/plugins/datasource/alertmanager/types';
|
||||
import { Labels } from 'app/types/unified-alerting-dto';
|
||||
import React, { FC } from 'react';
|
||||
import { makeAMLink } from '../../utils/misc';
|
||||
import { AnnotationDetailsField } from '../AnnotationDetailsField';
|
||||
|
||||
interface AmNotificationsAlertDetailsProps {
|
||||
alertManagerSourceName: string;
|
||||
alert: AlertmanagerAlert;
|
||||
}
|
||||
|
||||
export const AmNotificationsAlertDetails: FC<AmNotificationsAlertDetailsProps> = ({
|
||||
alert,
|
||||
alertManagerSourceName,
|
||||
}) => {
|
||||
const styles = useStyles2(getStyles);
|
||||
return (
|
||||
<>
|
||||
<div className={styles.actionsRow}>
|
||||
{alert.status.state === AlertState.Suppressed && (
|
||||
<LinkButton
|
||||
href={`${makeAMLink(
|
||||
'/alerting/silences',
|
||||
alertManagerSourceName
|
||||
)}&silenceIds=${alert.status.silencedBy.join(',')}`}
|
||||
className={styles.button}
|
||||
icon={'bell'}
|
||||
size={'sm'}
|
||||
>
|
||||
Manage silences
|
||||
</LinkButton>
|
||||
)}
|
||||
{alert.status.state === AlertState.Active && (
|
||||
<LinkButton
|
||||
href={`${makeAMLink('/alerting/silence/new', alertManagerSourceName)}&${getMatcherQueryParams(
|
||||
alert.labels
|
||||
)}`}
|
||||
className={styles.button}
|
||||
icon={'bell-slash'}
|
||||
size={'sm'}
|
||||
>
|
||||
Silence
|
||||
</LinkButton>
|
||||
)}
|
||||
{alert.generatorURL && (
|
||||
<LinkButton className={styles.button} href={alert.generatorURL} icon={'chart-line'} size={'sm'}>
|
||||
See source
|
||||
</LinkButton>
|
||||
)}
|
||||
</div>
|
||||
{Object.entries(alert.annotations).map(([annotationKey, annotationValue]) => (
|
||||
<AnnotationDetailsField key={annotationKey} annotationKey={annotationKey} value={annotationValue} />
|
||||
))}
|
||||
<div className={styles.receivers}>
|
||||
Receivers:{' '}
|
||||
{alert.receivers
|
||||
.map(({ name }) => name)
|
||||
.filter((name) => !!name)
|
||||
.join(', ')}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const getStyles = (theme: GrafanaTheme2) => ({
|
||||
button: css`
|
||||
& + & {
|
||||
margin-left: ${theme.spacing(1)};
|
||||
}
|
||||
`,
|
||||
actionsRow: css`
|
||||
padding: ${theme.spacing(2, 0)} !important;
|
||||
border-bottom: 1px solid ${theme.colors.border.medium};
|
||||
`,
|
||||
receivers: css`
|
||||
padding: ${theme.spacing(1, 0)};
|
||||
`,
|
||||
});
|
||||
|
||||
const getMatcherQueryParams = (labels: Labels) => {
|
||||
return `matchers=${encodeURIComponent(
|
||||
Object.entries(labels)
|
||||
.filter(([labelKey]) => !(labelKey.startsWith('__') && labelKey.endsWith('__')))
|
||||
.map(([labelKey, labelValue]) => {
|
||||
return `${labelKey}=${labelValue}`;
|
||||
})
|
||||
.join(',')
|
||||
)}`;
|
||||
};
|
@ -0,0 +1,91 @@
|
||||
import { AlertmanagerAlert } from 'app/plugins/datasource/alertmanager/types';
|
||||
import React, { useMemo } from 'react';
|
||||
import { useStyles2 } from '@grafana/ui';
|
||||
import { GrafanaTheme2, intervalToAbbreviatedDurationString } from '@grafana/data';
|
||||
import { css } from '@emotion/css';
|
||||
import { DynamicTableColumnProps, DynamicTableItemProps } from '../DynamicTable';
|
||||
import { AmAlertStateTag } from '../silences/AmAlertStateTag';
|
||||
import { AlertLabels } from '../AlertLabels';
|
||||
import { DynamicTableWithGuidelines } from '../DynamicTableWithGuidelines';
|
||||
import { AmNotificationsAlertDetails } from './AmNotificationsAlertDetails';
|
||||
|
||||
interface Props {
|
||||
alerts: AlertmanagerAlert[];
|
||||
alertManagerSourceName: string;
|
||||
}
|
||||
|
||||
type AmNotificationsAlertsTableColumnProps = DynamicTableColumnProps<AlertmanagerAlert>;
|
||||
type AmNotificationsAlertsTableItemProps = DynamicTableItemProps<AlertmanagerAlert>;
|
||||
|
||||
export const AmNotificationsAlertsTable = ({ alerts, alertManagerSourceName }: Props) => {
|
||||
const styles = useStyles2(getStyles);
|
||||
|
||||
const columns = useMemo(
|
||||
(): AmNotificationsAlertsTableColumnProps[] => [
|
||||
{
|
||||
id: 'state',
|
||||
label: 'State',
|
||||
// eslint-disable-next-line react/display-name
|
||||
renderCell: ({ data: alert }) => (
|
||||
<>
|
||||
<AmAlertStateTag state={alert.status.state} />
|
||||
<span className={styles.duration}>
|
||||
for{' '}
|
||||
{intervalToAbbreviatedDurationString({
|
||||
start: new Date(alert.startsAt),
|
||||
end: new Date(alert.endsAt),
|
||||
})}
|
||||
</span>
|
||||
</>
|
||||
),
|
||||
size: '190px',
|
||||
},
|
||||
{
|
||||
id: 'labels',
|
||||
label: 'Labels',
|
||||
// eslint-disable-next-line react/display-name
|
||||
renderCell: ({ data: { labels } }) => <AlertLabels className={styles.labels} labels={labels} />,
|
||||
size: 1,
|
||||
},
|
||||
],
|
||||
[styles]
|
||||
);
|
||||
|
||||
const items = useMemo(
|
||||
(): AmNotificationsAlertsTableItemProps[] =>
|
||||
alerts.map((alert) => ({
|
||||
id: alert.fingerprint,
|
||||
data: alert,
|
||||
})),
|
||||
[alerts]
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={styles.tableWrapper} data-testid="notifications-table">
|
||||
<DynamicTableWithGuidelines
|
||||
cols={columns}
|
||||
items={items}
|
||||
isExpandable={true}
|
||||
renderExpandedContent={({ data: alert }) => (
|
||||
<AmNotificationsAlertDetails alert={alert} alertManagerSourceName={alertManagerSourceName} />
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const getStyles = (theme: GrafanaTheme2) => ({
|
||||
tableWrapper: css`
|
||||
margin-top: ${theme.spacing(3)};
|
||||
${theme.breakpoints.up('md')} {
|
||||
margin-left: ${theme.spacing(4.5)};
|
||||
}
|
||||
`,
|
||||
duration: css`
|
||||
margin-left: ${theme.spacing(1)};
|
||||
font-size: ${theme.typography.bodySmall.fontSize};
|
||||
`,
|
||||
labels: css`
|
||||
padding-bottom: 0;
|
||||
`,
|
||||
});
|
@ -0,0 +1,82 @@
|
||||
import { AlertmanagerGroup, AlertState } from 'app/plugins/datasource/alertmanager/types';
|
||||
import React, { useState } from 'react';
|
||||
import { GrafanaTheme2 } from '@grafana/data';
|
||||
import { useStyles2 } from '@grafana/ui';
|
||||
import { css } from '@emotion/css';
|
||||
import { AlertLabels } from '../AlertLabels';
|
||||
import { AmNotificationsAlertsTable } from './AmNotificationsAlertsTable';
|
||||
import { CollapseToggle } from '../CollapseToggle';
|
||||
import { AmNotificationsGroupHeader } from './AmNotificationsGroupHeader';
|
||||
|
||||
interface Props {
|
||||
group: AlertmanagerGroup;
|
||||
alertManagerSourceName: string;
|
||||
}
|
||||
|
||||
export const AmNotificationsGroup = ({ 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">
|
||||
<CollapseToggle
|
||||
isCollapsed={isCollapsed}
|
||||
onToggle={() => setIsCollapsed(!isCollapsed)}
|
||||
data-testid="notifications-group-collapse-toggle"
|
||||
/>
|
||||
{Object.keys(group.labels).length ? (
|
||||
<AlertLabels className={styles.headerLabels} labels={group.labels} />
|
||||
) : (
|
||||
<span>No grouping</span>
|
||||
)}
|
||||
</div>
|
||||
<AmNotificationsGroupHeader group={group} />
|
||||
</div>
|
||||
{!isCollapsed && (
|
||||
<AmNotificationsAlertsTable alertManagerSourceName={alertManagerSourceName} alerts={group.alerts} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const getStyles = (theme: GrafanaTheme2) => ({
|
||||
wrapper: css`
|
||||
& + & {
|
||||
margin-top: ${theme.spacing(2)};
|
||||
}
|
||||
`,
|
||||
headerLabels: css`
|
||||
padding-bottom: 0 !important;
|
||||
margin-bottom: -${theme.spacing(0.5)};
|
||||
`,
|
||||
header: css`
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: ${theme.spacing(1, 1, 1, 0)};
|
||||
background-color: ${theme.colors.background.secondary};
|
||||
width: 100%;
|
||||
`,
|
||||
group: css`
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
`,
|
||||
summary: css``,
|
||||
spanElement: css`
|
||||
margin-left: ${theme.spacing(0.5)};
|
||||
`,
|
||||
[AlertState.Active]: css`
|
||||
color: ${theme.colors.error.main};
|
||||
`,
|
||||
[AlertState.Suppressed]: css`
|
||||
color: ${theme.colors.primary.main};
|
||||
`,
|
||||
[AlertState.Unprocessed]: css`
|
||||
color: ${theme.colors.secondary.main};
|
||||
`,
|
||||
});
|
@ -0,0 +1,49 @@
|
||||
import { AlertmanagerGroup, AlertState } from 'app/plugins/datasource/alertmanager/types';
|
||||
import React from 'react';
|
||||
import { GrafanaTheme2 } from '@grafana/data';
|
||||
import { useStyles2 } from '@grafana/ui';
|
||||
import { css } from '@emotion/css';
|
||||
|
||||
interface Props {
|
||||
group: AlertmanagerGroup;
|
||||
}
|
||||
|
||||
export const AmNotificationsGroupHeader = ({ group }: Props) => {
|
||||
const styles = useStyles2(getStyles);
|
||||
const total = group.alerts.length;
|
||||
const countByStatus = group.alerts.reduce((statusObj, alert) => {
|
||||
if (statusObj[alert.status.state]) {
|
||||
statusObj[alert.status.state] += 1;
|
||||
} else {
|
||||
statusObj[alert.status.state] = 1;
|
||||
}
|
||||
return statusObj;
|
||||
}, {} as Record<AlertState, number>);
|
||||
|
||||
return (
|
||||
<div className={styles.summary}>
|
||||
{`${total} alerts: `}
|
||||
{Object.entries(countByStatus).map(([state, count], index) => {
|
||||
return (
|
||||
<span key={`${JSON.stringify(group.labels)}-notifications-${index}`} className={styles[state as AlertState]}>
|
||||
{index > 0 && ', '}
|
||||
{`${count} ${state}`}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const getStyles = (theme: GrafanaTheme2) => ({
|
||||
summary: css``,
|
||||
[AlertState.Active]: css`
|
||||
color: ${theme.colors.error.main};
|
||||
`,
|
||||
[AlertState.Suppressed]: css`
|
||||
color: ${theme.colors.primary.main};
|
||||
`,
|
||||
[AlertState.Unprocessed]: css`
|
||||
color: ${theme.colors.secondary.main};
|
||||
`,
|
||||
});
|
@ -80,5 +80,6 @@ const getStyle = (theme: GrafanaTheme2) => ({
|
||||
font-size: ${theme.typography.bodySmall.fontSize};
|
||||
color: ${theme.colors.text.secondary};
|
||||
white-space: nowrap;
|
||||
padding-top: 2px;
|
||||
`,
|
||||
});
|
||||
|
@ -9,7 +9,8 @@ import { CombinedRule } from 'app/types/unified-alerting';
|
||||
import { Annotation } from '../../utils/constants';
|
||||
import { RuleState } from './RuleState';
|
||||
import { RuleHealth } from './RuleHealth';
|
||||
import { DynamicTable, DynamicTableColumnProps, DynamicTableItemProps } from '../DynamicTable';
|
||||
import { DynamicTableColumnProps, DynamicTableItemProps } from '../DynamicTable';
|
||||
import { DynamicTableWithGuidelines } from '../DynamicTableWithGuidelines';
|
||||
|
||||
type RuleTableColumnProps = DynamicTableColumnProps<CombinedRule>;
|
||||
type RuleTableItemProps = DynamicTableItemProps<CombinedRule>;
|
||||
@ -50,7 +51,7 @@ export const RulesTable: FC<Props> = ({
|
||||
});
|
||||
}, [rules]);
|
||||
|
||||
const columns = useColumns(showSummaryColumn, showGroupColumn, showGuidelines, items.length);
|
||||
const columns = useColumns(showSummaryColumn, showGroupColumn);
|
||||
|
||||
if (!rules.length) {
|
||||
return <div className={cx(wrapperClass, styles.emptyMessage)}>{emptyMessage}</div>;
|
||||
@ -58,39 +59,11 @@ export const RulesTable: FC<Props> = ({
|
||||
|
||||
return (
|
||||
<div className={wrapperClass} data-testid="rules-table">
|
||||
<DynamicTable
|
||||
<DynamicTableWithGuidelines
|
||||
cols={columns}
|
||||
isExpandable={true}
|
||||
items={items}
|
||||
renderExpandedContent={({ data: rule }, index) => (
|
||||
<>
|
||||
{!(index === rules.length - 1) && showGuidelines ? (
|
||||
<div className={cx(styles.ruleContentGuideline, styles.guideline)} />
|
||||
) : null}
|
||||
<RuleDetails rule={rule} />
|
||||
</>
|
||||
)}
|
||||
renderPrefixHeader={
|
||||
showGuidelines
|
||||
? () => (
|
||||
<div className={styles.relative}>
|
||||
<div className={cx(styles.headerGuideline, styles.guideline)} />
|
||||
</div>
|
||||
)
|
||||
: undefined
|
||||
}
|
||||
renderPrefixCell={
|
||||
showGuidelines
|
||||
? (_, index) => (
|
||||
<div className={styles.relative}>
|
||||
<div className={cx(styles.ruleTopGuideline, styles.guideline)} />
|
||||
{!(index === rules.length - 1) && (
|
||||
<div className={cx(styles.ruleBottomGuideline, styles.guideline)} />
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
: undefined
|
||||
}
|
||||
renderExpandedContent={({ data: rule }) => <RuleDetails rule={rule} />}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
@ -131,46 +104,13 @@ export const getStyles = (theme: GrafanaTheme2) => ({
|
||||
evenRow: css`
|
||||
background-color: ${theme.colors.background.primary};
|
||||
`,
|
||||
relative: css`
|
||||
position: relative;
|
||||
height: 100%;
|
||||
`,
|
||||
guideline: css`
|
||||
left: -19px;
|
||||
border-left: 1px solid ${theme.colors.border.medium};
|
||||
position: absolute;
|
||||
|
||||
${theme.breakpoints.down('md')} {
|
||||
display: none;
|
||||
}
|
||||
`,
|
||||
ruleTopGuideline: css`
|
||||
width: 18px;
|
||||
border-bottom: 1px solid ${theme.colors.border.medium};
|
||||
top: 0;
|
||||
bottom: 50%;
|
||||
`,
|
||||
ruleBottomGuideline: css`
|
||||
top: 50%;
|
||||
bottom: 0;
|
||||
`,
|
||||
ruleContentGuideline: css`
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
left: -49px !important;
|
||||
`,
|
||||
headerGuideline: css`
|
||||
top: -24px;
|
||||
bottom: 0;
|
||||
`,
|
||||
state: css`
|
||||
width: 110px;
|
||||
`,
|
||||
});
|
||||
|
||||
function useColumns(showSummaryColumn: boolean, showGroupColumn: boolean, showGuidelines: boolean, totalRules: number) {
|
||||
function useColumns(showSummaryColumn: boolean, showGroupColumn: boolean) {
|
||||
const hasRuler = useHasRuler();
|
||||
const styles = useStyles2(getStyles);
|
||||
|
||||
return useMemo((): RuleTableColumnProps[] => {
|
||||
const columns: RuleTableColumnProps[] = [
|
||||
@ -178,25 +118,13 @@ function useColumns(showSummaryColumn: boolean, showGroupColumn: boolean, showGu
|
||||
id: 'state',
|
||||
label: 'State',
|
||||
// eslint-disable-next-line react/display-name
|
||||
renderCell: ({ data: rule }, ruleIdx) => {
|
||||
renderCell: ({ data: rule }) => {
|
||||
const { namespace } = rule;
|
||||
const { rulesSource } = namespace;
|
||||
const { promRule, rulerRule } = rule;
|
||||
const isDeleting = !!(hasRuler(rulesSource) && promRule && !rulerRule);
|
||||
const isCreating = !!(hasRuler(rulesSource) && rulerRule && !promRule);
|
||||
return (
|
||||
<>
|
||||
{showGuidelines && (
|
||||
<>
|
||||
<div className={cx(styles.ruleTopGuideline, styles.guideline)} />
|
||||
{!(ruleIdx === totalRules - 1) && (
|
||||
<div className={cx(styles.ruleBottomGuideline, styles.guideline)} />
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
<RuleState rule={rule} isDeleting={isDeleting} isCreating={isCreating} />
|
||||
</>
|
||||
);
|
||||
return <RuleState rule={rule} isDeleting={isDeleting} isCreating={isCreating} />;
|
||||
},
|
||||
size: '165px',
|
||||
},
|
||||
@ -238,5 +166,5 @@ function useColumns(showSummaryColumn: boolean, showGroupColumn: boolean, showGu
|
||||
});
|
||||
}
|
||||
return columns;
|
||||
}, [hasRuler, showSummaryColumn, showGroupColumn, showGuidelines, totalRules, styles]);
|
||||
}, [hasRuler, showSummaryColumn, showGroupColumn]);
|
||||
}
|
||||
|
@ -1,4 +1,4 @@
|
||||
import React, { FC } from 'react';
|
||||
import React, { FC, useMemo } from 'react';
|
||||
import { GrafanaTheme2 } from '@grafana/data';
|
||||
import { Icon, useStyles2, Link, Button } from '@grafana/ui';
|
||||
import { css } from '@emotion/css';
|
||||
@ -8,6 +8,7 @@ import { getAlertTableStyles } from '../../styles/table';
|
||||
import { NoSilencesSplash } from './NoSilencesCTA';
|
||||
import { makeAMLink } from '../../utils/misc';
|
||||
import { contextSrv } from 'app/core/services/context_srv';
|
||||
import { useQueryParams } from 'app/core/hooks/useQueryParams';
|
||||
interface Props {
|
||||
silences: Silence[];
|
||||
alertManagerAlerts: AlertmanagerAlert[];
|
||||
@ -17,6 +18,15 @@ interface Props {
|
||||
const SilencesTable: FC<Props> = ({ silences, alertManagerAlerts, alertManagerSourceName }) => {
|
||||
const styles = useStyles2(getStyles);
|
||||
const tableStyles = useStyles2(getAlertTableStyles);
|
||||
const [queryParams] = useQueryParams();
|
||||
|
||||
const filteredSilences = useMemo(() => {
|
||||
const silenceIdsString = queryParams?.silenceIds;
|
||||
if (typeof silenceIdsString === 'string') {
|
||||
return silences.filter((silence) => silenceIdsString.split(',').includes(silence.id));
|
||||
}
|
||||
return silences;
|
||||
}, [queryParams, silences]);
|
||||
|
||||
const findSilencedAlerts = (id: string) => {
|
||||
return alertManagerAlerts.filter((alert) => alert.status.silencedBy.includes(id));
|
||||
@ -55,7 +65,7 @@ const SilencesTable: FC<Props> = ({ silences, alertManagerAlerts, alertManagerSo
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{silences.map((silence, index) => {
|
||||
{filteredSilences.map((silence, index) => {
|
||||
const silencedAlerts = findSilencedAlerts(silence.id);
|
||||
return (
|
||||
<SilenceTableRow
|
||||
|
@ -4,8 +4,11 @@ import { AlertingRule, Alert, RecordingRule, RuleGroup, RuleNamespace } from 'ap
|
||||
import DatasourceSrv from 'app/features/plugins/datasource_srv';
|
||||
import { DataSourceSrv, GetDataSourceListFilters } from '@grafana/runtime';
|
||||
import {
|
||||
AlertmanagerAlert,
|
||||
AlertManagerCortexConfig,
|
||||
AlertmanagerGroup,
|
||||
AlertmanagerStatus,
|
||||
AlertState,
|
||||
GrafanaManagedReceiverConfig,
|
||||
} from 'app/plugins/datasource/alertmanager/types';
|
||||
|
||||
@ -99,6 +102,43 @@ export const mockPromRuleNamespace = (partial: Partial<RuleNamespace> = {}): Rul
|
||||
};
|
||||
};
|
||||
|
||||
export const mockAlertmanagerAlert = (partial: Partial<AlertmanagerAlert> = {}): AlertmanagerAlert => {
|
||||
return {
|
||||
annotations: {
|
||||
summary: 'US-Central region is on fire',
|
||||
},
|
||||
endsAt: '2021-06-22T21:49:28.562Z',
|
||||
fingerprint: '88e013643c3df34ac3',
|
||||
receivers: [{ name: 'pagerduty' }],
|
||||
startsAt: '2021-06-21T17:25:28.562Z',
|
||||
status: { inhibitedBy: [], silencedBy: [], state: AlertState.Active },
|
||||
updatedAt: '2021-06-22T21:45:28.564Z',
|
||||
generatorURL: 'https://play.grafana.com/explore',
|
||||
labels: { severity: 'warning', region: 'US-Central' },
|
||||
...partial,
|
||||
};
|
||||
};
|
||||
|
||||
export const mockAlertGroup = (partial: Partial<AlertmanagerGroup> = {}): AlertmanagerGroup => {
|
||||
return {
|
||||
labels: {
|
||||
severity: 'warning',
|
||||
region: 'US-Central',
|
||||
},
|
||||
receiver: {
|
||||
name: 'pagerduty',
|
||||
},
|
||||
alerts: [
|
||||
mockAlertmanagerAlert(),
|
||||
mockAlertmanagerAlert({
|
||||
status: { state: AlertState.Suppressed, silencedBy: ['123456abcdef'], inhibitedBy: [] },
|
||||
labels: { severity: 'warning', region: 'US-Central', foo: 'bar' },
|
||||
}),
|
||||
],
|
||||
...partial,
|
||||
};
|
||||
};
|
||||
|
||||
export class MockDataSourceSrv implements DataSourceSrv {
|
||||
// @ts-ignore
|
||||
private settingsMapByName: Record<string, DataSourceInstanceSettings> = {};
|
||||
|
@ -3,6 +3,7 @@ import { createAsyncThunk } from '@reduxjs/toolkit';
|
||||
import {
|
||||
AlertmanagerAlert,
|
||||
AlertManagerCortexConfig,
|
||||
AlertmanagerGroup,
|
||||
Silence,
|
||||
SilenceCreatePayload,
|
||||
} from 'app/plugins/datasource/alertmanager/types';
|
||||
@ -19,6 +20,7 @@ import {
|
||||
expireSilence,
|
||||
fetchAlertManagerConfig,
|
||||
fetchAlerts,
|
||||
fetchAlertGroups,
|
||||
fetchSilences,
|
||||
createOrUpdateSilence,
|
||||
updateAlertManagerConfig,
|
||||
@ -534,3 +536,10 @@ export const fetchFolderIfNotFetchedAction = (uid: string): ThunkResult<void> =>
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
export const fetchAlertGroupsAction = createAsyncThunk(
|
||||
'unifiedalerting/fetchAlertGroups',
|
||||
(alertManagerSourceName: string): Promise<AlertmanagerGroup[]> => {
|
||||
return withSerializedError(fetchAlertGroups(alertManagerSourceName));
|
||||
}
|
||||
);
|
||||
|
@ -12,6 +12,7 @@ import {
|
||||
updateAlertManagerConfigAction,
|
||||
createOrUpdateSilenceAction,
|
||||
fetchFolderAction,
|
||||
fetchAlertGroupsAction,
|
||||
} from './actions';
|
||||
|
||||
export const reducer = combineReducers({
|
||||
@ -34,6 +35,11 @@ export const reducer = combineReducers({
|
||||
amAlerts: createAsyncMapSlice('amAlerts', fetchAmAlertsAction, (alertManagerSourceName) => alertManagerSourceName)
|
||||
.reducer,
|
||||
folders: createAsyncMapSlice('folders', fetchFolderAction, (uid) => uid).reducer,
|
||||
amAlertGroups: createAsyncMapSlice(
|
||||
'amAlertGroups',
|
||||
fetchAlertGroupsAction,
|
||||
(alertManagerSourceName) => alertManagerSourceName
|
||||
).reducer,
|
||||
});
|
||||
|
||||
export type UnifiedAlertingState = ReturnType<typeof reducer>;
|
||||
|
@ -5,6 +5,7 @@ export const RULE_LIST_POLL_INTERVAL_MS = 20000;
|
||||
export const ALERTMANAGER_NAME_QUERY_KEY = 'alertmanager';
|
||||
export const ALERTMANAGER_NAME_LOCAL_STORAGE_KEY = 'alerting-alertmanager';
|
||||
export const SILENCES_POLL_INTERVAL_MS = 20000;
|
||||
export const NOTIFICATIONS_POLL_INTERVAL_MS = 20000;
|
||||
|
||||
export const TIMESERIES = 'timeseries';
|
||||
export const TABLE = 'table';
|
||||
|
@ -208,7 +208,6 @@ export type AlertmanagerGroup = {
|
||||
labels: { [key: string]: string };
|
||||
receiver: { name: string };
|
||||
alerts: AlertmanagerAlert[];
|
||||
id: string;
|
||||
};
|
||||
|
||||
export interface AlertmanagerStatus {
|
||||
|
@ -439,6 +439,13 @@ export function getAppRoutes(): RouteDescriptor[] {
|
||||
import(/* webpackChunkName: "EditNotificationChannel"*/ 'app/features/alerting/EditNotificationChannelPage')
|
||||
),
|
||||
},
|
||||
{
|
||||
path: '/alerting/alertmanager/',
|
||||
component: SafeDynamicImport(
|
||||
() =>
|
||||
import(/* webpackChunkName: "AlertManagerNotifications" */ 'app/features/alerting/unified/AmNotifications')
|
||||
),
|
||||
},
|
||||
{
|
||||
path: '/alerting/new',
|
||||
pageClass: 'page-alerting',
|
||||
|
Loading…
Reference in New Issue
Block a user