diff --git a/public/app/features/alerting/unified/AmRoutes.test.tsx b/public/app/features/alerting/unified/AmRoutes.test.tsx new file mode 100644 index 00000000000..75471ed42f7 --- /dev/null +++ b/public/app/features/alerting/unified/AmRoutes.test.tsx @@ -0,0 +1,199 @@ +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 { Route } from 'app/plugins/datasource/alertmanager/types'; +import { configureStore } from 'app/store/configureStore'; +import { typeAsJestMock } from 'test/helpers/typeAsJestMock'; +import { byTestId } from 'testing-library-selector'; +import AmRoutes from './AmRoutes'; +import { fetchAlertManagerConfig } from './api/alertmanager'; +import { mockDataSource, MockDataSourceSrv } from './mocks'; +import { getAllDataSources } from './utils/config'; +import { DataSourceType } from './utils/datasource'; + +Object.defineProperty(window, 'matchMedia', { + writable: true, + value: jest.fn().mockImplementation((query) => ({ + matches: false, + media: query, + onchange: null, + addListener: jest.fn(), // deprecated + removeListener: jest.fn(), // deprecated + addEventListener: jest.fn(), + removeEventListener: jest.fn(), + dispatchEvent: jest.fn(), + })), +}); + +jest.mock('./api/alertmanager'); +jest.mock('./utils/config'); + +const mocks = { + getAllDataSourcesMock: typeAsJestMock(getAllDataSources), + + api: { + fetchAlertManagerConfig: typeAsJestMock(fetchAlertManagerConfig), + }, +}; + +const renderAmRoutes = () => { + const store = configureStore(); + + return render( + + + + + + ); +}; + +const dataSources = { + am: mockDataSource({ + name: 'Alert Manager', + type: DataSourceType.Alertmanager, + }), +}; + +const ui = { + rootReceiver: byTestId('am-routes-root-receiver'), + rootGroupBy: byTestId('am-routes-root-group-by'), + rootTimings: byTestId('am-routes-root-timings'), + row: byTestId('am-routes-row'), +}; + +describe('AmRoutes', () => { + const subroutes: Route[] = [ + { + match: { + sub1matcher1: 'sub1value1', + sub1matcher2: 'sub1value2', + }, + match_re: { + sub1matcher3: 'sub1value3', + sub1matcher4: 'sub1value4', + }, + group_by: ['sub1group1', 'sub1group2'], + receiver: 'a-receiver', + continue: true, + group_wait: '3s', + group_interval: '2m', + repeat_interval: '1s', + routes: [ + { + match: { + sub1sub1matcher1: 'sub1sub1value1', + sub1sub1matcher2: 'sub1sub1value2', + }, + match_re: { + sub1sub1matcher3: 'sub1sub1value3', + sub1sub1matcher4: 'sub1sub1value4', + }, + group_by: ['sub1sub1group1', 'sub1sub1group2'], + receiver: 'another-receiver', + }, + { + match: { + sub1sub2matcher1: 'sub1sub2value1', + sub1sub2matcher2: 'sub1sub2value2', + }, + match_re: { + sub1sub2matcher3: 'sub1sub2value3', + sub1sub2matcher4: 'sub1sub2value4', + }, + group_by: ['sub1sub2group1', 'sub1sub2group2'], + receiver: 'another-receiver', + }, + ], + }, + { + match: { + sub2matcher1: 'sub2value1', + sub2matcher2: 'sub2value2', + }, + match_re: { + sub2matcher3: 'sub2value3', + sub2matcher4: 'sub2value4', + }, + receiver: 'another-receiver', + }, + ]; + + const rootRoute: Route = { + receiver: 'default-receiver', + group_by: ['a-group', 'another-group'], + group_wait: '1s', + group_interval: '2m', + repeat_interval: '3d', + routes: subroutes, + }; + + beforeAll(() => { + mocks.getAllDataSourcesMock.mockReturnValue(Object.values(dataSources)); + + mocks.api.fetchAlertManagerConfig.mockImplementation(() => + Promise.resolve({ + alertmanager_config: { + route: rootRoute, + receivers: [ + { + name: 'default-receiver', + }, + { + name: 'a-receiver', + }, + { + name: 'another-receiver', + }, + ], + }, + template_files: {}, + }) + ); + }); + + beforeEach(() => { + setDataSourceSrv(new MockDataSourceSrv(dataSources)); + }); + + afterEach(() => { + jest.resetAllMocks(); + + setDataSourceSrv(undefined as any); + }); + + it('loads and shows routes', async () => { + await renderAmRoutes(); + + await waitFor(() => expect(mocks.api.fetchAlertManagerConfig).toHaveBeenCalledTimes(1)); + + expect(ui.rootReceiver.get()).toHaveTextContent(rootRoute.receiver!); + expect(ui.rootGroupBy.get()).toHaveTextContent(rootRoute.group_by!.join(', ')); + const rootTimings = ui.rootTimings.get(); + expect(rootTimings).toHaveTextContent(rootRoute.group_wait!); + expect(rootTimings).toHaveTextContent(rootRoute.group_interval!); + expect(rootTimings).toHaveTextContent(rootRoute.repeat_interval!); + + const rows = await ui.row.findAll(); + expect(rows).toHaveLength(2); + + subroutes.forEach((route, index) => { + Object.entries({ + ...(route.match ?? {}), + ...(route.match_re ?? {}), + }).forEach(([label, value]) => { + expect(rows[index]).toHaveTextContent(`${label}=${value}`); + }); + + if (route.group_by) { + expect(rows[index]).toHaveTextContent(route.group_by.join(', ')); + } + + if (route.receiver) { + expect(rows[index]).toHaveTextContent(route.receiver); + } + }); + }); +}); diff --git a/public/app/features/alerting/unified/AmRoutes.tsx b/public/app/features/alerting/unified/AmRoutes.tsx index 302324ea73a..a52434cc9bb 100644 --- a/public/app/features/alerting/unified/AmRoutes.tsx +++ b/public/app/features/alerting/unified/AmRoutes.tsx @@ -1,29 +1,95 @@ -import { Alert, Field, LoadingPlaceholder } from '@grafana/ui'; -import React, { FC, useEffect } from 'react'; +import React, { FC, useCallback, useEffect, useMemo, useState } from 'react'; +import { css } from '@emotion/css'; +import { GrafanaTheme2 } from '@grafana/data'; +import { Alert, Field, LoadingPlaceholder, useStyles2 } from '@grafana/ui'; import { useDispatch } from 'react-redux'; import { Redirect } from 'react-router-dom'; +import { Receiver } from 'app/plugins/datasource/alertmanager/types'; +import { useCleanup } from '../../../core/hooks/useCleanup'; import { AlertingPageWrapper } from './components/AlertingPageWrapper'; import { AlertManagerPicker } from './components/AlertManagerPicker'; +import { AmRootRoute } from './components/amroutes/AmRootRoute'; +import { AmSpecificRouting } from './components/amroutes/AmSpecificRouting'; import { useAlertManagerSourceName } from './hooks/useAlertManagerSourceName'; import { useUnifiedAlertingSelector } from './hooks/useUnifiedAlertingSelector'; -import { fetchAlertManagerConfigAction } from './state/actions'; +import { fetchAlertManagerConfigAction, updateAlertManagerConfigAction } from './state/actions'; +import { AmRouteReceiver, FormAmRoute } from './types/amroutes'; +import { amRouteToFormAmRoute, formAmRouteToAmRoute, stringsToSelectableValues } from './utils/amroutes'; import { initialAsyncRequestState } from './utils/redux'; const AmRoutes: FC = () => { - const [alertManagerSourceName, setAlertManagerSourceName] = useAlertManagerSourceName(); const dispatch = useDispatch(); + const styles = useStyles2(getStyles); + const [isRootRouteEditMode, setIsRootRouteEditMode] = useState(false); + const [alertManagerSourceName, setAlertManagerSourceName] = useAlertManagerSourceName(); const amConfigs = useUnifiedAlertingSelector((state) => state.amConfigs); - useEffect(() => { + const fetchConfig = useCallback(() => { if (alertManagerSourceName) { dispatch(fetchAlertManagerConfigAction(alertManagerSourceName)); } }, [alertManagerSourceName, dispatch]); - const { result, loading, error } = + useEffect(() => { + fetchConfig(); + }, [fetchConfig]); + + const { result, loading: resultLoading, error: resultError } = (alertManagerSourceName && amConfigs[alertManagerSourceName]) || initialAsyncRequestState; + const config = result?.alertmanager_config; + const routes = useMemo(() => amRouteToFormAmRoute(config?.route), [config?.route]); + + const receivers = stringsToSelectableValues( + (config?.receivers ?? []).map((receiver: Receiver) => receiver.name) + ) as AmRouteReceiver[]; + + const enterRootRouteEditMode = () => { + setIsRootRouteEditMode(true); + }; + + const exitRootRouteEditMode = () => { + setIsRootRouteEditMode(false); + }; + + useCleanup((state) => state.unifiedAlerting.saveAMConfig); + const { loading: saving, error: savingError, dispatched: savingDispatched } = useUnifiedAlertingSelector( + (state) => state.saveAMConfig + ); + + const handleSave = (data: Partial) => { + const newData = formAmRouteToAmRoute({ + ...routes, + ...data, + }); + + if (isRootRouteEditMode) { + exitRootRouteEditMode(); + } + + dispatch( + updateAlertManagerConfigAction({ + newConfig: { + ...result, + alertmanager_config: { + ...result.alertmanager_config, + route: newData, + }, + }, + oldConfig: result, + alertManagerSourceName: alertManagerSourceName!, + successMessage: 'Saved', + }) + ); + }; + + useEffect(() => { + if (savingDispatched && !saving && !savingError) { + fetchConfig(); + } + }, [fetchConfig, savingDispatched, saving, savingError]); + if (!alertManagerSourceName) { return ; } @@ -33,17 +99,49 @@ const AmRoutes: FC = () => { -
-
- {error && !loading && ( - - {error.message || 'Unknown error.'} + {savingError && !saving && ( + + {savingError.message || 'Unknown error.'} )} - {loading && } - {result && !loading && !error &&
{JSON.stringify(result, null, 2)}
} + {resultError && !resultLoading && ( + + {resultError.message || 'Unknown error.'} + + )} + {resultLoading && } + {result && !resultLoading && !resultError && ( + <> +
+ +
+ + + )} ); }; export default AmRoutes; + +const getStyles = (theme: GrafanaTheme2) => ({ + break: css` + width: 100%; + height: 0; + margin-bottom: ${theme.spacing(2)}; + border-bottom: solid 1px ${theme.colors.border.medium}; + `, +}); diff --git a/public/app/features/alerting/unified/components/DynamicTable.tsx b/public/app/features/alerting/unified/components/DynamicTable.tsx new file mode 100644 index 00000000000..629be5f7abe --- /dev/null +++ b/public/app/features/alerting/unified/components/DynamicTable.tsx @@ -0,0 +1,177 @@ +import React, { FC, ReactNode } from 'react'; +import { css, cx } from '@emotion/css'; +import { GrafanaTheme2 } from '@grafana/data'; +import { IconButton, useStyles2, useTheme2 } from '@grafana/ui'; +import { useMedia } from 'react-use'; + +export interface DynamicTableColumnProps { + id: string | number; + label: string; + + renderCell?: (item: DynamicTableItemProps, index: number) => ReactNode; + size?: number | string; +} + +export interface DynamicTableItemProps { + id: string | number; + data: T; + + renderExpandedContent?: () => ReactNode; + isExpanded?: boolean; +} + +export interface DynamicTableProps { + cols: Array>; + items: Array>; + + isExpandable?: boolean; + onCollapse?: (id: DynamicTableItemProps) => void; + onExpand?: (id: DynamicTableItemProps) => void; + renderExpandedContent?: (item: DynamicTableItemProps, index: number) => ReactNode; + testIdGenerator?: (item: DynamicTableItemProps) => string; +} + +export const DynamicTable: FC = ({ + cols, + items, + isExpandable = false, + onCollapse, + onExpand, + renderExpandedContent, + testIdGenerator, +}) => { + const styles = useStyles2(getStyles(cols, isExpandable)); + const theme = useTheme2(); + const isMobile = useMedia(`(${theme.breakpoints.down('sm')})`); + + return ( +
+
+ {isExpandable &&
} + {cols.map((col) => ( +
+ {col.label} +
+ ))} +
+ + {items.map((item, index) => ( +
+ {isExpandable && ( +
+ (item.isExpanded ? onCollapse?.(item) : onExpand?.(item))} + type="button" + /> +
+ )} + {cols.map((col) => ( +
+ {col.renderCell?.(item, index)} +
+ ))} + {item.isExpanded && ( +
+ {item.renderExpandedContent ? item.renderExpandedContent() : renderExpandedContent?.(item, index)} +
+ )} +
+ ))} +
+ ); +}; + +const getStyles = (cols: DynamicTableColumnProps[], isExpandable: boolean) => { + const sizes = cols.map((col) => { + if (!col.size) { + return 'auto'; + } + + if (typeof col.size === 'number') { + return `${col.size}fr`; + } + + return col.size; + }); + + if (isExpandable) { + sizes.unshift('calc(1em + 16px)'); + } + + return (theme: GrafanaTheme2) => ({ + container: css` + border: 1px solid ${theme.colors.border.strong}; + border-radius: 2px; + color: ${theme.colors.text.secondary}; + `, + row: css` + display: grid; + grid-template-columns: ${sizes.join(' ')}; + grid-template-rows: 1fr auto; + + &:nth-child(2n + 1) { + background-color: ${theme.colors.background.secondary}; + } + + &:nth-child(2n) { + background-color: ${theme.colors.background.primary}; + } + + ${theme.breakpoints.down('sm')} { + grid-template-columns: auto 1fr; + grid-template-areas: 'left right'; + padding: 0 ${theme.spacing(0.5)}; + + &:first-child { + display: none; + } + } + `, + cell: css` + align-items: center; + display: grid; + padding: ${theme.spacing(1)}; + + ${theme.breakpoints.down('sm')} { + padding: ${theme.spacing(1)} 0; + grid-template-columns: 1fr; + } + `, + bodyCell: css` + ${theme.breakpoints.down('sm')} { + grid-column-end: right; + grid-column-start: right; + + &::before { + content: attr(data-column); + } + } + `, + expandCell: css` + justify-content: center; + + ${theme.breakpoints.down('sm')} { + align-items: start; + grid-area: left; + } + `, + expandedContentRow: css` + grid-column-end: ${sizes.length + 1}; + grid-column-start: 2; + grid-row: 2; + padding: 0 ${theme.spacing(3)} 0 ${theme.spacing(1)}; + + ${theme.breakpoints.down('sm')} { + border-top: 1px solid ${theme.colors.border.strong}; + grid-row: auto; + padding: ${theme.spacing(1)} 0 0 0; + } + `, + expandButton: css` + margin-right: 0; + `, + }); +}; diff --git a/public/app/features/alerting/unified/components/EmptyArea.tsx b/public/app/features/alerting/unified/components/EmptyArea.tsx new file mode 100644 index 00000000000..ac8bf038458 --- /dev/null +++ b/public/app/features/alerting/unified/components/EmptyArea.tsx @@ -0,0 +1,58 @@ +import React, { ButtonHTMLAttributes, FC } from 'react'; +import { css } from '@emotion/css'; +import { GrafanaTheme } from '@grafana/data'; +import { Button, ButtonVariant, IconName, useStyles } from '@grafana/ui'; + +export interface EmptyAreaProps { + buttonLabel: string; + onButtonClick: ButtonHTMLAttributes['onClick']; + text: string; + + buttonIcon?: IconName; + buttonSize?: 'xs' | 'sm' | 'md' | 'lg'; + buttonVariant?: ButtonVariant; +} + +export const EmptyArea: FC = ({ + buttonIcon, + buttonLabel, + buttonSize = 'lg', + buttonVariant = 'primary', + onButtonClick, + text, +}) => { + const styles = useStyles(getStyles); + + return ( +
+

{text}

+ +
+ ); +}; + +const getStyles = (theme: GrafanaTheme) => { + return { + container: css` + background-color: ${theme.colors.bg2}; + color: ${theme.colors.textSemiWeak}; + padding: ${theme.spacing.xl}; + text-align: center; + `, + text: css` + margin-bottom: ${theme.spacing.md}; + `, + button: css` + margin: ${theme.spacing.md} 0 ${theme.spacing.sm}; + `, + }; +}; diff --git a/public/app/features/alerting/unified/components/amroutes/AmRootRoute.tsx b/public/app/features/alerting/unified/components/amroutes/AmRootRoute.tsx new file mode 100644 index 00000000000..b282696f435 --- /dev/null +++ b/public/app/features/alerting/unified/components/amroutes/AmRootRoute.tsx @@ -0,0 +1,77 @@ +import React, { FC } from 'react'; +import { css } from '@emotion/css'; +import { GrafanaTheme2 } from '@grafana/data'; +import { Button, useStyles2 } from '@grafana/ui'; +import { AmRouteReceiver, FormAmRoute } from '../../types/amroutes'; +import { AmRootRouteForm } from './AmRootRouteForm'; +import { AmRootRouteRead } from './AmRootRouteRead'; + +export interface AmRootRouteProps { + isEditMode: boolean; + onEnterEditMode: () => void; + onExitEditMode: () => void; + onSave: (data: Partial) => void; + receivers: AmRouteReceiver[]; + routes: FormAmRoute; + alertManagerSourceName: string; +} + +export const AmRootRoute: FC = ({ + isEditMode, + onSave, + onEnterEditMode, + onExitEditMode, + receivers, + routes, + alertManagerSourceName, +}) => { + const styles = useStyles2(getStyles); + + return ( +
+
+
+ Root policy - default for all alerts +
+ {!isEditMode && ( + + )} +
+

+ All alerts will go to the default contact point, unless you set additional matchers in the specific routing + area. +

+ {isEditMode ? ( + + ) : ( + + )} +
+ ); +}; + +const getStyles = (theme: GrafanaTheme2) => { + return { + container: css` + background-color: ${theme.colors.background.secondary}; + color: ${theme.colors.text.secondary}; + padding: ${theme.spacing(2)}; + `, + titleContainer: css` + color: ${theme.colors.text.primary}; + display: flex; + flex-flow: row nowrap; + `, + title: css` + flex: 100%; + `, + }; +}; diff --git a/public/app/features/alerting/unified/components/amroutes/AmRootRouteForm.tsx b/public/app/features/alerting/unified/components/amroutes/AmRootRouteForm.tsx new file mode 100644 index 00000000000..9cdf5cbbcdf --- /dev/null +++ b/public/app/features/alerting/unified/components/amroutes/AmRootRouteForm.tsx @@ -0,0 +1,198 @@ +import React, { FC, useState } from 'react'; +import { cx } from '@emotion/css'; +import { Button, Collapse, Field, Form, Input, InputControl, Link, MultiSelect, Select, useStyles2 } from '@grafana/ui'; +import { AmRouteReceiver, FormAmRoute } from '../../types/amroutes'; +import { + mapMultiSelectValueToStrings, + mapSelectValueToString, + optionalPositiveInteger, + stringToSelectableValue, + stringsToSelectableValues, +} from '../../utils/amroutes'; +import { makeAMLink } from '../../utils/misc'; +import { timeOptions } from '../../utils/time'; +import { getFormStyles } from './formStyles'; + +export interface AmRootRouteFormProps { + alertManagerSourceName: string; + onCancel: () => void; + onSave: (data: FormAmRoute) => void; + receivers: AmRouteReceiver[]; + routes: FormAmRoute; +} + +export const AmRootRouteForm: FC = ({ + alertManagerSourceName, + onCancel, + onSave, + receivers, + routes, +}) => { + const styles = useStyles2(getFormStyles); + const [isTimingOptionsExpanded, setIsTimingOptionsExpanded] = useState(false); + const [groupByOptions, setGroupByOptions] = useState(stringsToSelectableValues(routes.groupBy)); + + return ( +
+ {({ control, errors, setValue }) => ( + <> + +
+ ( + + )} + control={control} + name="groupWaitValue" + rules={{ + validate: optionalPositiveInteger, + }} + /> + ( + + )} + control={control} + name="groupIntervalValue" + rules={{ + validate: optionalPositiveInteger, + }} + /> + ( + + )} + control={control} + name="repeatIntervalValue" + rules={{ + validate: optionalPositiveInteger, + }} + /> + ( + + + = + + + + + + + + ); + })} +
+ + + )} + + + {/* @ts-ignore-check: react-hook-form made me do this */} + ( + + )} + control={control} + name="groupWaitValue" + rules={{ + validate: optionalPositiveInteger, + }} + /> + ( + + )} + control={control} + name="groupIntervalValue" + rules={{ + validate: optionalPositiveInteger, + }} + /> + ( + + )} + control={control} + name="repeatIntervalValue" + rules={{ + validate: optionalPositiveInteger, + }} + /> + ( +