diff --git a/pkg/api/index.go b/pkg/api/index.go index 3201310639e..1889aa61c04 100644 --- a/pkg/api/index.go +++ b/pkg/api/index.go @@ -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 { diff --git a/public/app/features/alerting/unified/AmNotifications.test.tsx b/public/app/features/alerting/unified/AmNotifications.test.tsx new file mode 100644 index 00000000000..63396b86639 --- /dev/null +++ b/public/app/features/alerting/unified/AmNotifications.test.tsx @@ -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( + + + + + + ); +}; + +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(); + }); +}); diff --git a/public/app/features/alerting/unified/AmNotifications.tsx b/public/app/features/alerting/unified/AmNotifications.tsx new file mode 100644 index 00000000000..c7a3e56a0d6 --- /dev/null +++ b/public/app/features/alerting/unified/AmNotifications.tsx @@ -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 ( + + + {loading && } + {error && !loading && ( + + {error.message || 'Unknown error'} + + )} + {results && + results.map((group, index) => { + return ( + + ); + })} + + ); +}; + +export default AlertManagerNotifications; diff --git a/public/app/features/alerting/unified/components/AlertLabels.tsx b/public/app/features/alerting/unified/components/AlertLabels.tsx index 9c886ec8eb0..d4bbf0f5bbb 100644 --- a/public/app/features/alerting/unified/components/AlertLabels.tsx +++ b/public/app/features/alerting/unified/components/AlertLabels.tsx @@ -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 }; +type Props = { labels: Record; 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 ( -
+
{pairs.map(([key, value], index) => ( ))} @@ -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}; diff --git a/public/app/features/alerting/unified/components/DynamicTable.tsx b/public/app/features/alerting/unified/components/DynamicTable.tsx index fb36cb3523d..e26bf183ca2 100644 --- a/public/app/features/alerting/unified/components/DynamicTable.tsx +++ b/public/app/features/alerting/unified/components/DynamicTable.tsx @@ -28,10 +28,18 @@ export interface DynamicTableProps { onExpand?: (item: DynamicTableItemProps) => void; isExpanded?: (item: DynamicTableItemProps) => boolean; - renderExpandedContent?: (item: DynamicTableItemProps, index: number) => ReactNode; + renderExpandedContent?: ( + item: DynamicTableItemProps, + index: number, + items: Array> + ) => ReactNode; testIdGenerator?: (item: DynamicTableItemProps, index: number) => string; renderPrefixHeader?: () => ReactNode; - renderPrefixCell?: (item: DynamicTableItemProps, index: number) => ReactNode; + renderPrefixCell?: ( + item: DynamicTableItemProps, + index: number, + items: Array> + ) => ReactNode; } export const DynamicTable = ({ @@ -84,7 +92,7 @@ export const DynamicTable = ({ const isItemExpanded = isExpanded ? isExpanded(item) : expandedIds.includes(item.id); return (
- {renderPrefixCell && renderPrefixCell(item, index)} + {renderPrefixCell && renderPrefixCell(item, index, items)} {isExpandable && (
({ ))} {isItemExpanded && renderExpandedContent && (
- {renderExpandedContent(item, index)} + {renderExpandedContent(item, index, items)}
)}
@@ -223,6 +231,7 @@ const getStyles = ( `, expandButton: css` margin-right: 0; + display: block; `, }); }; diff --git a/public/app/features/alerting/unified/components/DynamicTableWithGuidelines.tsx b/public/app/features/alerting/unified/components/DynamicTableWithGuidelines.tsx new file mode 100644 index 00000000000..65f98b1524b --- /dev/null +++ b/public/app/features/alerting/unified/components/DynamicTableWithGuidelines.tsx @@ -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 = Omit, 'renderPrefixHeader, renderPrefixCell'>; + +// DynamicTable, but renders visual guidelines on the left, for larger screen widths +export const DynamicTableWithGuidelines = ({ + renderExpandedContent, + ...props +}: DynamicTableWithGuidelinesProps) => { + const styles = useStyles2(getStyles); + return ( + ( + <> + {!(index === items.length - 1) &&
} + {renderExpandedContent(item, index, items)} + + ) + : undefined + } + renderPrefixHeader={() => ( +
+
+
+ )} + renderPrefixCell={(_, index, items) => ( +
+
+ {!(index === items.length - 1) &&
} +
+ )} + {...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; + `, +}); diff --git a/public/app/features/alerting/unified/components/amnotifications/AmNotificationsAlertDetails.tsx b/public/app/features/alerting/unified/components/amnotifications/AmNotificationsAlertDetails.tsx new file mode 100644 index 00000000000..e70d37426a1 --- /dev/null +++ b/public/app/features/alerting/unified/components/amnotifications/AmNotificationsAlertDetails.tsx @@ -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 = ({ + alert, + alertManagerSourceName, +}) => { + const styles = useStyles2(getStyles); + return ( + <> +
+ {alert.status.state === AlertState.Suppressed && ( + + Manage silences + + )} + {alert.status.state === AlertState.Active && ( + + Silence + + )} + {alert.generatorURL && ( + + See source + + )} +
+ {Object.entries(alert.annotations).map(([annotationKey, annotationValue]) => ( + + ))} +
+ Receivers:{' '} + {alert.receivers + .map(({ name }) => name) + .filter((name) => !!name) + .join(', ')} +
+ + ); +}; + +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(',') + )}`; +}; diff --git a/public/app/features/alerting/unified/components/amnotifications/AmNotificationsAlertsTable.tsx b/public/app/features/alerting/unified/components/amnotifications/AmNotificationsAlertsTable.tsx new file mode 100644 index 00000000000..ea04eb4cb01 --- /dev/null +++ b/public/app/features/alerting/unified/components/amnotifications/AmNotificationsAlertsTable.tsx @@ -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; +type AmNotificationsAlertsTableItemProps = DynamicTableItemProps; + +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 }) => ( + <> + + + for{' '} + {intervalToAbbreviatedDurationString({ + start: new Date(alert.startsAt), + end: new Date(alert.endsAt), + })} + + + ), + size: '190px', + }, + { + id: 'labels', + label: 'Labels', + // eslint-disable-next-line react/display-name + renderCell: ({ data: { labels } }) => , + size: 1, + }, + ], + [styles] + ); + + const items = useMemo( + (): AmNotificationsAlertsTableItemProps[] => + alerts.map((alert) => ({ + id: alert.fingerprint, + data: alert, + })), + [alerts] + ); + + return ( +
+ ( + + )} + /> +
+ ); +}; + +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; + `, +}); diff --git a/public/app/features/alerting/unified/components/amnotifications/AmNotificationsGroup.tsx b/public/app/features/alerting/unified/components/amnotifications/AmNotificationsGroup.tsx new file mode 100644 index 00000000000..5db45a22d91 --- /dev/null +++ b/public/app/features/alerting/unified/components/amnotifications/AmNotificationsGroup.tsx @@ -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(true); + const styles = useStyles2(getStyles); + + return ( +
+
+
+ setIsCollapsed(!isCollapsed)} + data-testid="notifications-group-collapse-toggle" + /> + {Object.keys(group.labels).length ? ( + + ) : ( + No grouping + )} +
+ +
+ {!isCollapsed && ( + + )} +
+ ); +}; + +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}; + `, +}); diff --git a/public/app/features/alerting/unified/components/amnotifications/AmNotificationsGroupHeader.tsx b/public/app/features/alerting/unified/components/amnotifications/AmNotificationsGroupHeader.tsx new file mode 100644 index 00000000000..6fb481b9a59 --- /dev/null +++ b/public/app/features/alerting/unified/components/amnotifications/AmNotificationsGroupHeader.tsx @@ -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); + + return ( +
+ {`${total} alerts: `} + {Object.entries(countByStatus).map(([state, count], index) => { + return ( + + {index > 0 && ', '} + {`${count} ${state}`} + + ); + })} +
+ ); +}; + +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}; + `, +}); diff --git a/public/app/features/alerting/unified/components/rules/RuleState.tsx b/public/app/features/alerting/unified/components/rules/RuleState.tsx index 04b8da8c167..85e85a2d448 100644 --- a/public/app/features/alerting/unified/components/rules/RuleState.tsx +++ b/public/app/features/alerting/unified/components/rules/RuleState.tsx @@ -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; `, }); diff --git a/public/app/features/alerting/unified/components/rules/RulesTable.tsx b/public/app/features/alerting/unified/components/rules/RulesTable.tsx index aa8df099d37..ce59280e7d2 100644 --- a/public/app/features/alerting/unified/components/rules/RulesTable.tsx +++ b/public/app/features/alerting/unified/components/rules/RulesTable.tsx @@ -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; type RuleTableItemProps = DynamicTableItemProps; @@ -50,7 +51,7 @@ export const RulesTable: FC = ({ }); }, [rules]); - const columns = useColumns(showSummaryColumn, showGroupColumn, showGuidelines, items.length); + const columns = useColumns(showSummaryColumn, showGroupColumn); if (!rules.length) { return
{emptyMessage}
; @@ -58,39 +59,11 @@ export const RulesTable: FC = ({ return (
- ( - <> - {!(index === rules.length - 1) && showGuidelines ? ( -
- ) : null} - - - )} - renderPrefixHeader={ - showGuidelines - ? () => ( -
-
-
- ) - : undefined - } - renderPrefixCell={ - showGuidelines - ? (_, index) => ( -
-
- {!(index === rules.length - 1) && ( -
- )} -
- ) - : undefined - } + renderExpandedContent={({ data: rule }) => } />
); @@ -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 && ( - <> -
- {!(ruleIdx === totalRules - 1) && ( -
- )} - - )} - - - ); + return ; }, size: '165px', }, @@ -238,5 +166,5 @@ function useColumns(showSummaryColumn: boolean, showGroupColumn: boolean, showGu }); } return columns; - }, [hasRuler, showSummaryColumn, showGroupColumn, showGuidelines, totalRules, styles]); + }, [hasRuler, showSummaryColumn, showGroupColumn]); } diff --git a/public/app/features/alerting/unified/components/silences/SilencesTable.tsx b/public/app/features/alerting/unified/components/silences/SilencesTable.tsx index 5a3ccd90e84..4832d5ffa89 100644 --- a/public/app/features/alerting/unified/components/silences/SilencesTable.tsx +++ b/public/app/features/alerting/unified/components/silences/SilencesTable.tsx @@ -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 = ({ 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 = ({ silences, alertManagerAlerts, alertManagerSo - {silences.map((silence, index) => { + {filteredSilences.map((silence, index) => { const silencedAlerts = findSilencedAlerts(silence.id); return ( = {}): Rul }; }; +export const mockAlertmanagerAlert = (partial: Partial = {}): 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 => { + 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 = {}; diff --git a/public/app/features/alerting/unified/state/actions.ts b/public/app/features/alerting/unified/state/actions.ts index 4fbd81eeca6..b2b3f6a1aa5 100644 --- a/public/app/features/alerting/unified/state/actions.ts +++ b/public/app/features/alerting/unified/state/actions.ts @@ -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 => } }; }; + +export const fetchAlertGroupsAction = createAsyncThunk( + 'unifiedalerting/fetchAlertGroups', + (alertManagerSourceName: string): Promise => { + return withSerializedError(fetchAlertGroups(alertManagerSourceName)); + } +); diff --git a/public/app/features/alerting/unified/state/reducers.ts b/public/app/features/alerting/unified/state/reducers.ts index 6e2bcf45114..26e0a51537a 100644 --- a/public/app/features/alerting/unified/state/reducers.ts +++ b/public/app/features/alerting/unified/state/reducers.ts @@ -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; diff --git a/public/app/features/alerting/unified/utils/constants.ts b/public/app/features/alerting/unified/utils/constants.ts index efeea1c9341..6f4332cb281 100644 --- a/public/app/features/alerting/unified/utils/constants.ts +++ b/public/app/features/alerting/unified/utils/constants.ts @@ -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'; diff --git a/public/app/plugins/datasource/alertmanager/types.ts b/public/app/plugins/datasource/alertmanager/types.ts index ca953310c5d..cac8e670e25 100644 --- a/public/app/plugins/datasource/alertmanager/types.ts +++ b/public/app/plugins/datasource/alertmanager/types.ts @@ -208,7 +208,6 @@ export type AlertmanagerGroup = { labels: { [key: string]: string }; receiver: { name: string }; alerts: AlertmanagerAlert[]; - id: string; }; export interface AlertmanagerStatus { diff --git a/public/app/routes/routes.tsx b/public/app/routes/routes.tsx index aa2cd750594..3db699be012 100644 --- a/public/app/routes/routes.tsx +++ b/public/app/routes/routes.tsx @@ -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',