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:
Nathan Rodman 2021-07-07 16:17:26 -07:00 committed by GitHub
parent d9e500b654
commit a0dac9c6d9
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
19 changed files with 640 additions and 93 deletions

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -208,7 +208,6 @@ export type AlertmanagerGroup = {
labels: { [key: string]: string };
receiver: { name: string };
alerts: AlertmanagerAlert[];
id: string;
};
export interface AlertmanagerStatus {

View File

@ -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',