mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
Alerting: Add alerting routes (#33179)
* Add amroutes * Update table * Recompute items when prop changes * Update styling * Updates * Improvements * Remove unnecessary line * Updates * Updates * Improve code * Add empty area component * Move panel from root route to specific routing * Update from master * Update theme * Implement save * Fixes for PR review * receiver -> contact point * Fixes for PR review * Fixes * Add basic test Co-authored-by: Domas <domasx2@gmail.com>
This commit is contained in:
parent
75b9018464
commit
9510c4f112
199
public/app/features/alerting/unified/AmRoutes.test.tsx
Normal file
199
public/app/features/alerting/unified/AmRoutes.test.tsx
Normal file
@ -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(
|
||||||
|
<Provider store={store}>
|
||||||
|
<Router history={locationService.getHistory()}>
|
||||||
|
<AmRoutes />
|
||||||
|
</Router>
|
||||||
|
</Provider>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
@ -1,29 +1,95 @@
|
|||||||
import { Alert, Field, LoadingPlaceholder } from '@grafana/ui';
|
import React, { FC, useCallback, useEffect, useMemo, useState } from 'react';
|
||||||
import React, { FC, useEffect } 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 { useDispatch } from 'react-redux';
|
||||||
import { Redirect } from 'react-router-dom';
|
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 { AlertingPageWrapper } from './components/AlertingPageWrapper';
|
||||||
import { AlertManagerPicker } from './components/AlertManagerPicker';
|
import { AlertManagerPicker } from './components/AlertManagerPicker';
|
||||||
|
import { AmRootRoute } from './components/amroutes/AmRootRoute';
|
||||||
|
import { AmSpecificRouting } from './components/amroutes/AmSpecificRouting';
|
||||||
import { useAlertManagerSourceName } from './hooks/useAlertManagerSourceName';
|
import { useAlertManagerSourceName } from './hooks/useAlertManagerSourceName';
|
||||||
import { useUnifiedAlertingSelector } from './hooks/useUnifiedAlertingSelector';
|
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';
|
import { initialAsyncRequestState } from './utils/redux';
|
||||||
|
|
||||||
const AmRoutes: FC = () => {
|
const AmRoutes: FC = () => {
|
||||||
const [alertManagerSourceName, setAlertManagerSourceName] = useAlertManagerSourceName();
|
|
||||||
const dispatch = useDispatch();
|
const dispatch = useDispatch();
|
||||||
|
const styles = useStyles2(getStyles);
|
||||||
|
const [isRootRouteEditMode, setIsRootRouteEditMode] = useState(false);
|
||||||
|
|
||||||
|
const [alertManagerSourceName, setAlertManagerSourceName] = useAlertManagerSourceName();
|
||||||
const amConfigs = useUnifiedAlertingSelector((state) => state.amConfigs);
|
const amConfigs = useUnifiedAlertingSelector((state) => state.amConfigs);
|
||||||
|
|
||||||
useEffect(() => {
|
const fetchConfig = useCallback(() => {
|
||||||
if (alertManagerSourceName) {
|
if (alertManagerSourceName) {
|
||||||
dispatch(fetchAlertManagerConfigAction(alertManagerSourceName));
|
dispatch(fetchAlertManagerConfigAction(alertManagerSourceName));
|
||||||
}
|
}
|
||||||
}, [alertManagerSourceName, dispatch]);
|
}, [alertManagerSourceName, dispatch]);
|
||||||
|
|
||||||
const { result, loading, error } =
|
useEffect(() => {
|
||||||
|
fetchConfig();
|
||||||
|
}, [fetchConfig]);
|
||||||
|
|
||||||
|
const { result, loading: resultLoading, error: resultError } =
|
||||||
(alertManagerSourceName && amConfigs[alertManagerSourceName]) || initialAsyncRequestState;
|
(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<FormAmRoute>) => {
|
||||||
|
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) {
|
if (!alertManagerSourceName) {
|
||||||
return <Redirect to="/alerting/routes" />;
|
return <Redirect to="/alerting/routes" />;
|
||||||
}
|
}
|
||||||
@ -33,17 +99,49 @@ const AmRoutes: FC = () => {
|
|||||||
<Field label="Choose alert manager">
|
<Field label="Choose alert manager">
|
||||||
<AlertManagerPicker current={alertManagerSourceName} onChange={setAlertManagerSourceName} />
|
<AlertManagerPicker current={alertManagerSourceName} onChange={setAlertManagerSourceName} />
|
||||||
</Field>
|
</Field>
|
||||||
<br />
|
{savingError && !saving && (
|
||||||
<br />
|
<Alert severity="error" title="Error saving alert manager config">
|
||||||
{error && !loading && (
|
{savingError.message || 'Unknown error.'}
|
||||||
<Alert severity="error" title="Error loading alert manager config">
|
|
||||||
{error.message || 'Unknown error.'}
|
|
||||||
</Alert>
|
</Alert>
|
||||||
)}
|
)}
|
||||||
{loading && <LoadingPlaceholder text="loading alert manager config..." />}
|
{resultError && !resultLoading && (
|
||||||
{result && !loading && !error && <pre>{JSON.stringify(result, null, 2)}</pre>}
|
<Alert severity="error" title="Error loading alert manager config">
|
||||||
|
{resultError.message || 'Unknown error.'}
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
{resultLoading && <LoadingPlaceholder text="Loading alert manager config..." />}
|
||||||
|
{result && !resultLoading && !resultError && (
|
||||||
|
<>
|
||||||
|
<div className={styles.break} />
|
||||||
|
<AmRootRoute
|
||||||
|
alertManagerSourceName={alertManagerSourceName}
|
||||||
|
isEditMode={isRootRouteEditMode}
|
||||||
|
onSave={handleSave}
|
||||||
|
onEnterEditMode={enterRootRouteEditMode}
|
||||||
|
onExitEditMode={exitRootRouteEditMode}
|
||||||
|
receivers={receivers}
|
||||||
|
routes={routes}
|
||||||
|
/>
|
||||||
|
<div className={styles.break} />
|
||||||
|
<AmSpecificRouting
|
||||||
|
onChange={handleSave}
|
||||||
|
onRootRouteEdit={enterRootRouteEditMode}
|
||||||
|
receivers={receivers}
|
||||||
|
routes={routes}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</AlertingPageWrapper>
|
</AlertingPageWrapper>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default AmRoutes;
|
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};
|
||||||
|
`,
|
||||||
|
});
|
||||||
|
177
public/app/features/alerting/unified/components/DynamicTable.tsx
Normal file
177
public/app/features/alerting/unified/components/DynamicTable.tsx
Normal file
@ -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<T = unknown> {
|
||||||
|
id: string | number;
|
||||||
|
label: string;
|
||||||
|
|
||||||
|
renderCell?: (item: DynamicTableItemProps<T>, index: number) => ReactNode;
|
||||||
|
size?: number | string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DynamicTableItemProps<T = unknown> {
|
||||||
|
id: string | number;
|
||||||
|
data: T;
|
||||||
|
|
||||||
|
renderExpandedContent?: () => ReactNode;
|
||||||
|
isExpanded?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DynamicTableProps<T = unknown> {
|
||||||
|
cols: Array<DynamicTableColumnProps<T>>;
|
||||||
|
items: Array<DynamicTableItemProps<T>>;
|
||||||
|
|
||||||
|
isExpandable?: boolean;
|
||||||
|
onCollapse?: (id: DynamicTableItemProps<T>) => void;
|
||||||
|
onExpand?: (id: DynamicTableItemProps<T>) => void;
|
||||||
|
renderExpandedContent?: (item: DynamicTableItemProps, index: number) => ReactNode;
|
||||||
|
testIdGenerator?: (item: DynamicTableItemProps<T>) => string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const DynamicTable: FC<DynamicTableProps> = ({
|
||||||
|
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 (
|
||||||
|
<div className={styles.container}>
|
||||||
|
<div className={styles.row}>
|
||||||
|
{isExpandable && <div className={styles.cell} />}
|
||||||
|
{cols.map((col) => (
|
||||||
|
<div className={styles.cell} key={col.id}>
|
||||||
|
{col.label}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{items.map((item, index) => (
|
||||||
|
<div className={styles.row} key={item.id} data-testid={testIdGenerator?.(item)}>
|
||||||
|
{isExpandable && (
|
||||||
|
<div className={cx(styles.cell, styles.expandCell)}>
|
||||||
|
<IconButton
|
||||||
|
size={isMobile ? 'xl' : 'md'}
|
||||||
|
className={styles.expandButton}
|
||||||
|
name={item.isExpanded ? 'angle-down' : 'angle-right'}
|
||||||
|
onClick={() => (item.isExpanded ? onCollapse?.(item) : onExpand?.(item))}
|
||||||
|
type="button"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{cols.map((col) => (
|
||||||
|
<div className={cx(styles.cell, styles.bodyCell)} data-column={col.label} key={`${item.id}-${col.id}`}>
|
||||||
|
{col.renderCell?.(item, index)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{item.isExpanded && (
|
||||||
|
<div className={styles.expandedContentRow}>
|
||||||
|
{item.renderExpandedContent ? item.renderExpandedContent() : renderExpandedContent?.(item, index)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
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;
|
||||||
|
`,
|
||||||
|
});
|
||||||
|
};
|
@ -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<HTMLButtonElement>['onClick'];
|
||||||
|
text: string;
|
||||||
|
|
||||||
|
buttonIcon?: IconName;
|
||||||
|
buttonSize?: 'xs' | 'sm' | 'md' | 'lg';
|
||||||
|
buttonVariant?: ButtonVariant;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const EmptyArea: FC<EmptyAreaProps> = ({
|
||||||
|
buttonIcon,
|
||||||
|
buttonLabel,
|
||||||
|
buttonSize = 'lg',
|
||||||
|
buttonVariant = 'primary',
|
||||||
|
onButtonClick,
|
||||||
|
text,
|
||||||
|
}) => {
|
||||||
|
const styles = useStyles(getStyles);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.container}>
|
||||||
|
<p className={styles.text}>{text}</p>
|
||||||
|
<Button
|
||||||
|
className={styles.button}
|
||||||
|
icon={buttonIcon}
|
||||||
|
onClick={onButtonClick}
|
||||||
|
size={buttonSize}
|
||||||
|
type="button"
|
||||||
|
variant={buttonVariant}
|
||||||
|
>
|
||||||
|
{buttonLabel}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
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};
|
||||||
|
`,
|
||||||
|
};
|
||||||
|
};
|
@ -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<FormAmRoute>) => void;
|
||||||
|
receivers: AmRouteReceiver[];
|
||||||
|
routes: FormAmRoute;
|
||||||
|
alertManagerSourceName: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const AmRootRoute: FC<AmRootRouteProps> = ({
|
||||||
|
isEditMode,
|
||||||
|
onSave,
|
||||||
|
onEnterEditMode,
|
||||||
|
onExitEditMode,
|
||||||
|
receivers,
|
||||||
|
routes,
|
||||||
|
alertManagerSourceName,
|
||||||
|
}) => {
|
||||||
|
const styles = useStyles2(getStyles);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.container}>
|
||||||
|
<div className={styles.titleContainer}>
|
||||||
|
<h5 className={styles.title}>
|
||||||
|
Root policy - <i>default for all alerts</i>
|
||||||
|
</h5>
|
||||||
|
{!isEditMode && (
|
||||||
|
<Button icon="pen" onClick={onEnterEditMode} size="sm" type="button" variant="secondary">
|
||||||
|
Edit
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<p>
|
||||||
|
All alerts will go to the default contact point, unless you set additional matchers in the specific routing
|
||||||
|
area.
|
||||||
|
</p>
|
||||||
|
{isEditMode ? (
|
||||||
|
<AmRootRouteForm
|
||||||
|
alertManagerSourceName={alertManagerSourceName}
|
||||||
|
onCancel={onExitEditMode}
|
||||||
|
onSave={onSave}
|
||||||
|
receivers={receivers}
|
||||||
|
routes={routes}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<AmRootRouteRead routes={routes} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
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%;
|
||||||
|
`,
|
||||||
|
};
|
||||||
|
};
|
@ -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<AmRootRouteFormProps> = ({
|
||||||
|
alertManagerSourceName,
|
||||||
|
onCancel,
|
||||||
|
onSave,
|
||||||
|
receivers,
|
||||||
|
routes,
|
||||||
|
}) => {
|
||||||
|
const styles = useStyles2(getFormStyles);
|
||||||
|
const [isTimingOptionsExpanded, setIsTimingOptionsExpanded] = useState(false);
|
||||||
|
const [groupByOptions, setGroupByOptions] = useState(stringsToSelectableValues(routes.groupBy));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Form defaultValues={routes} onSubmit={onSave}>
|
||||||
|
{({ control, errors, setValue }) => (
|
||||||
|
<>
|
||||||
|
<Field label="Default contact point">
|
||||||
|
<div className={styles.container}>
|
||||||
|
<InputControl
|
||||||
|
render={({ field: { onChange, ref, ...field } }) => (
|
||||||
|
<Select
|
||||||
|
{...field}
|
||||||
|
className={styles.input}
|
||||||
|
onChange={(value) => onChange(mapSelectValueToString(value))}
|
||||||
|
options={receivers}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
control={control}
|
||||||
|
name="receiver"
|
||||||
|
/>
|
||||||
|
<span>or</span>
|
||||||
|
<Link href={makeAMLink('/alerting/notifications/receivers/new', alertManagerSourceName)}>
|
||||||
|
Create a contact point
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</Field>
|
||||||
|
<Field label="Group by" description="Group alerts when you receive a notification based on labels.">
|
||||||
|
{/* @ts-ignore-check: react-hook-form made me do this */}
|
||||||
|
<InputControl
|
||||||
|
render={({ field: { onChange, ref, ...field } }) => (
|
||||||
|
<MultiSelect
|
||||||
|
{...field}
|
||||||
|
allowCustomValue
|
||||||
|
className={styles.input}
|
||||||
|
onCreateOption={(opt: string) => {
|
||||||
|
setGroupByOptions((opts) => [...opts, stringToSelectableValue(opt)]);
|
||||||
|
|
||||||
|
// @ts-ignore-check: react-hook-form made me do this
|
||||||
|
setValue('groupBy', [...field.value, opt]);
|
||||||
|
}}
|
||||||
|
onChange={(value) => onChange(mapMultiSelectValueToStrings(value))}
|
||||||
|
options={groupByOptions}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
control={control}
|
||||||
|
name="groupBy"
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<Collapse
|
||||||
|
collapsible
|
||||||
|
isOpen={isTimingOptionsExpanded}
|
||||||
|
label="Timing options"
|
||||||
|
onToggle={setIsTimingOptionsExpanded}
|
||||||
|
>
|
||||||
|
<Field
|
||||||
|
label="Group wait"
|
||||||
|
description="The waiting time until the initial notification is sent for a new group created by an incoming alert."
|
||||||
|
invalid={!!errors.groupWaitValue}
|
||||||
|
error={errors.groupWaitValue?.message}
|
||||||
|
>
|
||||||
|
<>
|
||||||
|
<div className={cx(styles.container, styles.timingContainer)}>
|
||||||
|
<InputControl
|
||||||
|
render={({ field, fieldState: { invalid } }) => (
|
||||||
|
<Input {...field} className={styles.smallInput} invalid={invalid} />
|
||||||
|
)}
|
||||||
|
control={control}
|
||||||
|
name="groupWaitValue"
|
||||||
|
rules={{
|
||||||
|
validate: optionalPositiveInteger,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<InputControl
|
||||||
|
render={({ field: { onChange, ref, ...field } }) => (
|
||||||
|
<Select
|
||||||
|
{...field}
|
||||||
|
className={styles.input}
|
||||||
|
onChange={(value) => onChange(mapSelectValueToString(value))}
|
||||||
|
options={timeOptions}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
control={control}
|
||||||
|
name="groupWaitValueType"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
</Field>
|
||||||
|
<Field
|
||||||
|
label="Group interval"
|
||||||
|
description="The waiting time to send a batch of new alerts for that group after the first notification was sent."
|
||||||
|
invalid={!!errors.groupIntervalValue}
|
||||||
|
error={errors.groupIntervalValue?.message}
|
||||||
|
>
|
||||||
|
<>
|
||||||
|
<div className={cx(styles.container, styles.timingContainer)}>
|
||||||
|
<InputControl
|
||||||
|
render={({ field, fieldState: { invalid } }) => (
|
||||||
|
<Input {...field} className={styles.smallInput} invalid={invalid} />
|
||||||
|
)}
|
||||||
|
control={control}
|
||||||
|
name="groupIntervalValue"
|
||||||
|
rules={{
|
||||||
|
validate: optionalPositiveInteger,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<InputControl
|
||||||
|
render={({ field: { onChange, ref, ...field } }) => (
|
||||||
|
<Select
|
||||||
|
{...field}
|
||||||
|
className={styles.input}
|
||||||
|
onChange={(value) => onChange(mapSelectValueToString(value))}
|
||||||
|
options={timeOptions}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
control={control}
|
||||||
|
name="groupIntervalValueType"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
</Field>
|
||||||
|
<Field
|
||||||
|
label="Repeat interval"
|
||||||
|
description="The waiting time to resend an alert after they have successfully been sent."
|
||||||
|
invalid={!!errors.repeatIntervalValue}
|
||||||
|
error={errors.repeatIntervalValue?.message}
|
||||||
|
>
|
||||||
|
<>
|
||||||
|
<div className={cx(styles.container, styles.timingContainer)}>
|
||||||
|
<InputControl
|
||||||
|
render={({ field, fieldState: { invalid } }) => (
|
||||||
|
<Input {...field} className={styles.smallInput} invalid={invalid} />
|
||||||
|
)}
|
||||||
|
control={control}
|
||||||
|
name="repeatIntervalValue"
|
||||||
|
rules={{
|
||||||
|
validate: optionalPositiveInteger,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<InputControl
|
||||||
|
render={({ field: { onChange, ref, ...field } }) => (
|
||||||
|
<Select
|
||||||
|
{...field}
|
||||||
|
className={styles.input}
|
||||||
|
menuPlacement="top"
|
||||||
|
onChange={(value) => onChange(mapSelectValueToString(value))}
|
||||||
|
options={timeOptions}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
control={control}
|
||||||
|
name="repeatIntervalValueType"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
</Field>
|
||||||
|
</Collapse>
|
||||||
|
<div className={styles.container}>
|
||||||
|
<Button type="submit">Save</Button>
|
||||||
|
<Button onClick={onCancel} type="reset" variant="secondary">
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Form>
|
||||||
|
);
|
||||||
|
};
|
@ -0,0 +1,39 @@
|
|||||||
|
import React, { FC } from 'react';
|
||||||
|
import { useStyles2 } from '@grafana/ui';
|
||||||
|
import { FormAmRoute } from '../../types/amroutes';
|
||||||
|
import { getGridStyles } from './gridStyles';
|
||||||
|
|
||||||
|
export interface AmRootRouteReadProps {
|
||||||
|
routes: FormAmRoute;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const AmRootRouteRead: FC<AmRootRouteReadProps> = ({ routes }) => {
|
||||||
|
const styles = useStyles2(getGridStyles);
|
||||||
|
|
||||||
|
const receiver = routes.receiver || '-';
|
||||||
|
const groupBy = routes.groupBy.join(', ') || '-';
|
||||||
|
const groupWait = routes.groupWaitValue ? `${routes.groupWaitValue}${routes.groupWaitValueType}` : '-';
|
||||||
|
const groupInterval = routes.groupIntervalValue
|
||||||
|
? `${routes.groupIntervalValue}${routes.groupIntervalValueType}`
|
||||||
|
: '-';
|
||||||
|
const repeatInterval = routes.repeatIntervalValue
|
||||||
|
? `${routes.repeatIntervalValue}${routes.repeatIntervalValueType}`
|
||||||
|
: '-';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.container}>
|
||||||
|
<div className={styles.titleCell}>Contact point</div>
|
||||||
|
<div className={styles.valueCell} data-testid="am-routes-root-receiver">
|
||||||
|
{receiver}
|
||||||
|
</div>
|
||||||
|
<div className={styles.titleCell}>Group by</div>
|
||||||
|
<div className={styles.valueCell} data-testid="am-routes-root-group-by">
|
||||||
|
{groupBy}
|
||||||
|
</div>
|
||||||
|
<div className={styles.titleCell}>Timings</div>
|
||||||
|
<div className={styles.valueCell} data-testid="am-routes-root-timings">
|
||||||
|
Group wait: {groupWait} | Group interval: {groupInterval} | Repeat interval: {repeatInterval}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
@ -0,0 +1,297 @@
|
|||||||
|
import React, { FC, useState } from 'react';
|
||||||
|
import { css, cx } from '@emotion/css';
|
||||||
|
import { GrafanaTheme2 } from '@grafana/data';
|
||||||
|
import {
|
||||||
|
Button,
|
||||||
|
Checkbox,
|
||||||
|
Field,
|
||||||
|
FieldArray,
|
||||||
|
Form,
|
||||||
|
HorizontalGroup,
|
||||||
|
Input,
|
||||||
|
InputControl,
|
||||||
|
MultiSelect,
|
||||||
|
Select,
|
||||||
|
Switch,
|
||||||
|
useStyles2,
|
||||||
|
} from '@grafana/ui';
|
||||||
|
import { AmRouteReceiver, FormAmRoute } from '../../types/amroutes';
|
||||||
|
import {
|
||||||
|
emptyArrayFieldMatcher,
|
||||||
|
mapMultiSelectValueToStrings,
|
||||||
|
mapSelectValueToString,
|
||||||
|
optionalPositiveInteger,
|
||||||
|
stringToSelectableValue,
|
||||||
|
stringsToSelectableValues,
|
||||||
|
} from '../../utils/amroutes';
|
||||||
|
import { timeOptions } from '../../utils/time';
|
||||||
|
import { getFormStyles } from './formStyles';
|
||||||
|
|
||||||
|
export interface AmRoutesExpandedFormProps {
|
||||||
|
onCancel: () => void;
|
||||||
|
onSave: (data: FormAmRoute) => void;
|
||||||
|
receivers: AmRouteReceiver[];
|
||||||
|
routes: FormAmRoute;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const AmRoutesExpandedForm: FC<AmRoutesExpandedFormProps> = ({ onCancel, onSave, receivers, routes }) => {
|
||||||
|
const styles = useStyles2(getStyles);
|
||||||
|
const formStyles = useStyles2(getFormStyles);
|
||||||
|
const [overrideGrouping, setOverrideGrouping] = useState(routes.groupBy.length > 0);
|
||||||
|
const [overrideTimings, setOverrideTimings] = useState(
|
||||||
|
!!routes.groupWaitValue || !!routes.groupIntervalValue || !!routes.repeatIntervalValue
|
||||||
|
);
|
||||||
|
const [groupByOptions, setGroupByOptions] = useState(stringsToSelectableValues(routes.groupBy));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Form defaultValues={routes} onSubmit={onSave}>
|
||||||
|
{({ control, register, errors, setValue }) => (
|
||||||
|
<>
|
||||||
|
{/* @ts-ignore-check: react-hook-form made me do this */}
|
||||||
|
<FieldArray name="matchers" control={control}>
|
||||||
|
{({ fields, append }) => (
|
||||||
|
<>
|
||||||
|
<div>Matchers</div>
|
||||||
|
<div className={styles.matchersContainer}>
|
||||||
|
{fields.map((field, index) => {
|
||||||
|
const localPath = `matchers[${index}]`;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<HorizontalGroup key={field.id}>
|
||||||
|
<Field
|
||||||
|
label="Label"
|
||||||
|
invalid={!!errors.matchers?.[index]?.label}
|
||||||
|
error={errors.matchers?.[index]?.label?.message}
|
||||||
|
>
|
||||||
|
<Input
|
||||||
|
{...register(`${localPath}.label`, { required: 'Field is required' })}
|
||||||
|
defaultValue={field.label}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<span>=</span>
|
||||||
|
<Field
|
||||||
|
label="Value"
|
||||||
|
invalid={!!errors.matchers?.[index]?.value}
|
||||||
|
error={errors.matchers?.[index]?.value?.message}
|
||||||
|
>
|
||||||
|
<Input
|
||||||
|
{...register(`${localPath}.value`, { required: 'Field is required' })}
|
||||||
|
defaultValue={field.value}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<Field className={styles.matcherRegexField} label="Regex">
|
||||||
|
<Checkbox {...register(`${localPath}.isRegex`)} defaultChecked={field.isRegex} />
|
||||||
|
</Field>
|
||||||
|
</HorizontalGroup>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
className={styles.addMatcherBtn}
|
||||||
|
icon="plus"
|
||||||
|
onClick={() => append(emptyArrayFieldMatcher)}
|
||||||
|
variant="secondary"
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
Add matcher
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</FieldArray>
|
||||||
|
<Field label="Contact point">
|
||||||
|
{/* @ts-ignore-check: react-hook-form made me do this */}
|
||||||
|
<InputControl
|
||||||
|
render={({ field: { onChange, ref, ...field } }) => (
|
||||||
|
<Select
|
||||||
|
{...field}
|
||||||
|
className={formStyles.input}
|
||||||
|
onChange={(value) => onChange(mapSelectValueToString(value))}
|
||||||
|
options={receivers}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
control={control}
|
||||||
|
name="receiver"
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<Field label="Continue matching subsequent sibling nodes">
|
||||||
|
<Switch {...register('continue')} />
|
||||||
|
</Field>
|
||||||
|
<Field label="Override grouping">
|
||||||
|
<Switch
|
||||||
|
value={overrideGrouping}
|
||||||
|
onChange={() => setOverrideGrouping((overrideGrouping) => !overrideGrouping)}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
{overrideGrouping && (
|
||||||
|
<Field label="Group by" description="Group alerts when you receive a notification based on labels.">
|
||||||
|
<InputControl
|
||||||
|
render={({ field: { onChange, ref, ...field } }) => (
|
||||||
|
<MultiSelect
|
||||||
|
{...field}
|
||||||
|
allowCustomValue
|
||||||
|
className={formStyles.input}
|
||||||
|
onCreateOption={(opt: string) => {
|
||||||
|
setGroupByOptions((opts) => [...opts, stringToSelectableValue(opt)]);
|
||||||
|
|
||||||
|
// @ts-ignore-check: react-hook-form made me do this
|
||||||
|
setValue('groupBy', [...field.value, opt]);
|
||||||
|
}}
|
||||||
|
onChange={(value) => onChange(mapMultiSelectValueToStrings(value))}
|
||||||
|
options={groupByOptions}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
control={control}
|
||||||
|
name="groupBy"
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
)}
|
||||||
|
<Field label="Override general timings">
|
||||||
|
<Switch
|
||||||
|
value={overrideTimings}
|
||||||
|
onChange={() => setOverrideTimings((overrideTimings) => !overrideTimings)}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
{overrideTimings && (
|
||||||
|
<>
|
||||||
|
<Field
|
||||||
|
label="Group wait"
|
||||||
|
description="The waiting time until the initial notification is sent for a new group created by an incoming alert."
|
||||||
|
invalid={!!errors.groupWaitValue}
|
||||||
|
error={errors.groupWaitValue?.message}
|
||||||
|
>
|
||||||
|
<>
|
||||||
|
<div className={cx(formStyles.container, formStyles.timingContainer)}>
|
||||||
|
<InputControl
|
||||||
|
render={({ field, fieldState: { invalid } }) => (
|
||||||
|
<Input {...field} className={formStyles.smallInput} invalid={invalid} />
|
||||||
|
)}
|
||||||
|
control={control}
|
||||||
|
name="groupWaitValue"
|
||||||
|
rules={{
|
||||||
|
validate: optionalPositiveInteger,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<InputControl
|
||||||
|
render={({ field: { onChange, ref, ...field } }) => (
|
||||||
|
<Select
|
||||||
|
{...field}
|
||||||
|
className={formStyles.input}
|
||||||
|
onChange={(value) => onChange(mapSelectValueToString(value))}
|
||||||
|
options={timeOptions}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
control={control}
|
||||||
|
name="groupWaitValueType"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
</Field>
|
||||||
|
<Field
|
||||||
|
label="Group interval"
|
||||||
|
description="The waiting time to send a batch of new alerts for that group after the first notification was sent."
|
||||||
|
invalid={!!errors.groupIntervalValue}
|
||||||
|
error={errors.groupIntervalValue?.message}
|
||||||
|
>
|
||||||
|
<>
|
||||||
|
<div className={cx(formStyles.container, formStyles.timingContainer)}>
|
||||||
|
<InputControl
|
||||||
|
render={({ field, fieldState: { invalid } }) => (
|
||||||
|
<Input {...field} className={formStyles.smallInput} invalid={invalid} />
|
||||||
|
)}
|
||||||
|
control={control}
|
||||||
|
name="groupIntervalValue"
|
||||||
|
rules={{
|
||||||
|
validate: optionalPositiveInteger,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<InputControl
|
||||||
|
render={({ field: { onChange, ref, ...field } }) => (
|
||||||
|
<Select
|
||||||
|
{...field}
|
||||||
|
className={formStyles.input}
|
||||||
|
onChange={(value) => onChange(mapSelectValueToString(value))}
|
||||||
|
options={timeOptions}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
control={control}
|
||||||
|
name="groupIntervalValueType"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
</Field>
|
||||||
|
<Field
|
||||||
|
label="Repeat interval"
|
||||||
|
description="The waiting time to resend an alert after they have successfully been sent."
|
||||||
|
invalid={!!errors.repeatIntervalValue}
|
||||||
|
error={errors.repeatIntervalValue?.message}
|
||||||
|
>
|
||||||
|
<>
|
||||||
|
<div className={cx(formStyles.container, formStyles.timingContainer)}>
|
||||||
|
<InputControl
|
||||||
|
render={({ field, fieldState: { invalid } }) => (
|
||||||
|
<Input {...field} className={formStyles.smallInput} invalid={invalid} />
|
||||||
|
)}
|
||||||
|
control={control}
|
||||||
|
name="repeatIntervalValue"
|
||||||
|
rules={{
|
||||||
|
validate: optionalPositiveInteger,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<InputControl
|
||||||
|
render={({ field: { onChange, ref, ...field } }) => (
|
||||||
|
<Select
|
||||||
|
{...field}
|
||||||
|
className={formStyles.input}
|
||||||
|
menuPlacement="top"
|
||||||
|
onChange={(value) => onChange(mapSelectValueToString(value))}
|
||||||
|
options={timeOptions}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
control={control}
|
||||||
|
name="repeatIntervalValueType"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
</Field>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<div className={styles.buttonGroup}>
|
||||||
|
<Button type="submit">Save policy</Button>
|
||||||
|
<Button onClick={onCancel} type="button" variant="secondary">
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Form>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const getStyles = (theme: GrafanaTheme2) => {
|
||||||
|
const commonSpacing = theme.spacing(3.5);
|
||||||
|
|
||||||
|
return {
|
||||||
|
addMatcherBtn: css`
|
||||||
|
margin-bottom: ${commonSpacing};
|
||||||
|
`,
|
||||||
|
matchersContainer: css`
|
||||||
|
background-color: ${theme.colors.background.secondary};
|
||||||
|
margin: ${theme.spacing(1, 0)};
|
||||||
|
padding: ${theme.spacing(1, 4.6, 1, 1.5)};
|
||||||
|
width: fit-content;
|
||||||
|
`,
|
||||||
|
matcherRegexField: css`
|
||||||
|
margin-left: ${theme.spacing(6)};
|
||||||
|
`,
|
||||||
|
nestedPolicies: css`
|
||||||
|
margin-top: ${commonSpacing};
|
||||||
|
`,
|
||||||
|
buttonGroup: css`
|
||||||
|
margin: ${theme.spacing(6)} 0 ${commonSpacing};
|
||||||
|
|
||||||
|
& > * + * {
|
||||||
|
margin-left: ${theme.spacing(1.5)};
|
||||||
|
}
|
||||||
|
`,
|
||||||
|
};
|
||||||
|
};
|
@ -0,0 +1,90 @@
|
|||||||
|
import { css } from '@emotion/css';
|
||||||
|
import { GrafanaTheme2 } from '@grafana/data';
|
||||||
|
import React, { FC, useState } from 'react';
|
||||||
|
import { Button, useStyles2 } from '@grafana/ui';
|
||||||
|
import { AmRouteReceiver, FormAmRoute } from '../../types/amroutes';
|
||||||
|
import { emptyRoute } from '../../utils/amroutes';
|
||||||
|
import { AmRoutesTable } from './AmRoutesTable';
|
||||||
|
import { getGridStyles } from './gridStyles';
|
||||||
|
|
||||||
|
export interface AmRoutesExpandedReadProps {
|
||||||
|
onChange: (routes: FormAmRoute) => void;
|
||||||
|
receivers: AmRouteReceiver[];
|
||||||
|
routes: FormAmRoute;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const AmRoutesExpandedRead: FC<AmRoutesExpandedReadProps> = ({ onChange, receivers, routes }) => {
|
||||||
|
const styles = useStyles2(getStyles);
|
||||||
|
const gridStyles = useStyles2(getGridStyles);
|
||||||
|
|
||||||
|
const groupWait = routes.groupWaitValue ? `${routes.groupWaitValue}${routes.groupWaitValueType}` : '-';
|
||||||
|
const groupInterval = routes.groupIntervalValue
|
||||||
|
? `${routes.groupIntervalValue}${routes.groupIntervalValueType}`
|
||||||
|
: '-';
|
||||||
|
const repeatInterval = routes.repeatIntervalValue
|
||||||
|
? `${routes.repeatIntervalValue}${routes.repeatIntervalValueType}`
|
||||||
|
: '-';
|
||||||
|
|
||||||
|
const [subroutes, setSubroutes] = useState(routes.routes);
|
||||||
|
const [isAddMode, setIsAddMode] = useState(false);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={gridStyles.container}>
|
||||||
|
<div className={gridStyles.titleCell}>Group wait</div>
|
||||||
|
<div className={gridStyles.valueCell}>{groupWait}</div>
|
||||||
|
<div className={gridStyles.titleCell}>Group interval</div>
|
||||||
|
<div className={gridStyles.valueCell}>{groupInterval}</div>
|
||||||
|
<div className={gridStyles.titleCell}>Repeat interval</div>
|
||||||
|
<div className={gridStyles.valueCell}>{repeatInterval}</div>
|
||||||
|
<div className={gridStyles.titleCell}>Nested policies</div>
|
||||||
|
<div className={gridStyles.valueCell}>
|
||||||
|
<AmRoutesTable
|
||||||
|
isAddMode={isAddMode}
|
||||||
|
onCancelAdd={() => {
|
||||||
|
setIsAddMode(false);
|
||||||
|
setSubroutes((subroutes) => {
|
||||||
|
const newSubroutes = [...subroutes];
|
||||||
|
newSubroutes.pop();
|
||||||
|
|
||||||
|
return newSubroutes;
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
onChange={(newRoutes) => {
|
||||||
|
onChange({
|
||||||
|
...routes,
|
||||||
|
routes: newRoutes,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (isAddMode) {
|
||||||
|
setIsAddMode(false);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
receivers={receivers}
|
||||||
|
routes={subroutes}
|
||||||
|
/>
|
||||||
|
{!isAddMode && (
|
||||||
|
<Button
|
||||||
|
className={styles.addNestedRoutingBtn}
|
||||||
|
icon="plus"
|
||||||
|
onClick={() => {
|
||||||
|
setSubroutes((subroutes) => [...subroutes, emptyRoute]);
|
||||||
|
setIsAddMode(true);
|
||||||
|
}}
|
||||||
|
variant="secondary"
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
Add nested policy
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const getStyles = (theme: GrafanaTheme2) => {
|
||||||
|
return {
|
||||||
|
addNestedRoutingBtn: css`
|
||||||
|
margin-top: ${theme.spacing(2)};
|
||||||
|
`,
|
||||||
|
};
|
||||||
|
};
|
@ -0,0 +1,181 @@
|
|||||||
|
import React, { FC, useCallback, useEffect, useState } from 'react';
|
||||||
|
import { Button, HorizontalGroup, IconButton } from '@grafana/ui';
|
||||||
|
import { AmRouteReceiver, FormAmRoute } from '../../types/amroutes';
|
||||||
|
import {
|
||||||
|
addCustomExpandedContent,
|
||||||
|
collapseItem,
|
||||||
|
expandItem,
|
||||||
|
prepareItems,
|
||||||
|
removeCustomExpandedContent,
|
||||||
|
} from '../../utils/dynamicTable';
|
||||||
|
import { AlertLabels } from '../AlertLabels';
|
||||||
|
import { DynamicTable, DynamicTableColumnProps, DynamicTableItemProps } from '../DynamicTable';
|
||||||
|
import { AmRoutesExpandedForm } from './AmRoutesExpandedForm';
|
||||||
|
import { AmRoutesExpandedRead } from './AmRoutesExpandedRead';
|
||||||
|
|
||||||
|
export interface AmRoutesTableProps {
|
||||||
|
isAddMode: boolean;
|
||||||
|
onChange: (routes: FormAmRoute[]) => void;
|
||||||
|
onCancelAdd: () => void;
|
||||||
|
receivers: AmRouteReceiver[];
|
||||||
|
routes: FormAmRoute[];
|
||||||
|
}
|
||||||
|
|
||||||
|
type RouteTableColumnProps = DynamicTableColumnProps<FormAmRoute>;
|
||||||
|
type RouteTableItemProps = DynamicTableItemProps<FormAmRoute>;
|
||||||
|
|
||||||
|
export const AmRoutesTable: FC<AmRoutesTableProps> = ({ isAddMode, onCancelAdd, onChange, receivers, routes }) => {
|
||||||
|
const [items, setItems] = useState<RouteTableItemProps[]>([]);
|
||||||
|
|
||||||
|
const getRenderEditExpandedContent = useCallback(
|
||||||
|
// eslint-disable-next-line react/display-name
|
||||||
|
(item: RouteTableItemProps, index: number) => () => (
|
||||||
|
<AmRoutesExpandedForm
|
||||||
|
onCancel={() => {
|
||||||
|
setItems((items) => {
|
||||||
|
let newItems = collapseItem(items, item.id);
|
||||||
|
newItems = removeCustomExpandedContent(newItems, item.id);
|
||||||
|
|
||||||
|
return newItems;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (isAddMode) {
|
||||||
|
onCancelAdd();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onSave={(data) => {
|
||||||
|
const newRoutes = [...routes];
|
||||||
|
|
||||||
|
newRoutes[index] = {
|
||||||
|
...newRoutes[index],
|
||||||
|
...data,
|
||||||
|
};
|
||||||
|
|
||||||
|
setItems((items) => collapseItem(items, item.id));
|
||||||
|
|
||||||
|
onChange(newRoutes);
|
||||||
|
}}
|
||||||
|
receivers={receivers}
|
||||||
|
routes={item.data}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
[isAddMode, onCancelAdd, onChange, receivers, routes]
|
||||||
|
);
|
||||||
|
|
||||||
|
const cols: RouteTableColumnProps[] = [
|
||||||
|
{
|
||||||
|
id: 'matchingCriteria',
|
||||||
|
label: 'Matching criteria',
|
||||||
|
// eslint-disable-next-line react/display-name
|
||||||
|
renderCell: (item) => (
|
||||||
|
<AlertLabels
|
||||||
|
labels={item.data.matchers.reduce(
|
||||||
|
(acc, matcher) => ({
|
||||||
|
...acc,
|
||||||
|
[matcher.label]: matcher.value,
|
||||||
|
}),
|
||||||
|
{}
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
size: 10,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'groupBy',
|
||||||
|
label: 'Group by',
|
||||||
|
renderCell: (item) => item.data.groupBy.join(', ') || '-',
|
||||||
|
size: 5,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'receiverChannel',
|
||||||
|
label: 'Contact point',
|
||||||
|
renderCell: (item) => item.data.receiver || '-',
|
||||||
|
size: 5,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'actions',
|
||||||
|
label: 'Actions',
|
||||||
|
// eslint-disable-next-line react/display-name
|
||||||
|
renderCell: (item, index) => {
|
||||||
|
if (item.renderExpandedContent) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const expandWithCustomContent = () =>
|
||||||
|
setItems((items) => {
|
||||||
|
let newItems = expandItem(items, item.id);
|
||||||
|
newItems = addCustomExpandedContent(newItems, item.id, getRenderEditExpandedContent(item, index));
|
||||||
|
|
||||||
|
return newItems;
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<HorizontalGroup>
|
||||||
|
<Button icon="pen" onClick={expandWithCustomContent} size="sm" type="button" variant="secondary">
|
||||||
|
Edit
|
||||||
|
</Button>
|
||||||
|
<IconButton
|
||||||
|
name="trash-alt"
|
||||||
|
onClick={() => {
|
||||||
|
const newRoutes = [...routes];
|
||||||
|
|
||||||
|
newRoutes.splice(index, 1);
|
||||||
|
|
||||||
|
onChange(newRoutes);
|
||||||
|
}}
|
||||||
|
type="button"
|
||||||
|
/>
|
||||||
|
</HorizontalGroup>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
size: '100px',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const items = prepareItems(routes).map((item, index, arr) => {
|
||||||
|
if (isAddMode && index === arr.length - 1) {
|
||||||
|
return {
|
||||||
|
...item,
|
||||||
|
isExpanded: true,
|
||||||
|
renderExpandedContent: getRenderEditExpandedContent(item, index),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...item,
|
||||||
|
isExpanded: false,
|
||||||
|
renderExpandedContent: undefined,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
setItems(items);
|
||||||
|
}, [routes, getRenderEditExpandedContent, isAddMode]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DynamicTable
|
||||||
|
cols={cols}
|
||||||
|
isExpandable={true}
|
||||||
|
items={items}
|
||||||
|
onCollapse={(item: RouteTableItemProps) => setItems((items) => collapseItem(items, item.id))}
|
||||||
|
onExpand={(item: RouteTableItemProps) => setItems((items) => expandItem(items, item.id))}
|
||||||
|
testIdGenerator={() => 'am-routes-row'}
|
||||||
|
renderExpandedContent={(item: RouteTableItemProps, index) => (
|
||||||
|
<AmRoutesExpandedRead
|
||||||
|
onChange={(data) => {
|
||||||
|
const newRoutes = [...routes];
|
||||||
|
|
||||||
|
newRoutes[index] = {
|
||||||
|
...item.data,
|
||||||
|
...data,
|
||||||
|
};
|
||||||
|
|
||||||
|
onChange(newRoutes);
|
||||||
|
}}
|
||||||
|
receivers={receivers}
|
||||||
|
routes={item.data}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
@ -0,0 +1,94 @@
|
|||||||
|
import React, { FC, useState } 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 { emptyRoute } from '../../utils/amroutes';
|
||||||
|
import { EmptyArea } from '../EmptyArea';
|
||||||
|
import { AmRoutesTable } from './AmRoutesTable';
|
||||||
|
|
||||||
|
export interface AmSpecificRoutingProps {
|
||||||
|
onChange: (routes: FormAmRoute) => void;
|
||||||
|
onRootRouteEdit: () => void;
|
||||||
|
receivers: AmRouteReceiver[];
|
||||||
|
routes: FormAmRoute;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const AmSpecificRouting: FC<AmSpecificRoutingProps> = ({ onChange, onRootRouteEdit, receivers, routes }) => {
|
||||||
|
const [actualRoutes, setActualRoutes] = useState(routes.routes);
|
||||||
|
const [isAddMode, setIsAddMode] = useState(false);
|
||||||
|
|
||||||
|
const styles = useStyles2(getStyles);
|
||||||
|
|
||||||
|
const addNewRoute = () => {
|
||||||
|
setIsAddMode(true);
|
||||||
|
setActualRoutes((actualRoutes) => [...actualRoutes, emptyRoute]);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.container}>
|
||||||
|
<h5>Specific routing</h5>
|
||||||
|
<p>Send specific alerts to chosen contact points, based on matching criteria</p>
|
||||||
|
{!routes.receiver ? (
|
||||||
|
<EmptyArea
|
||||||
|
buttonIcon="rocket"
|
||||||
|
buttonLabel="Set a default contact point"
|
||||||
|
onButtonClick={onRootRouteEdit}
|
||||||
|
text="You haven't set a default contact point for the root route yet."
|
||||||
|
/>
|
||||||
|
) : actualRoutes.length > 0 ? (
|
||||||
|
<>
|
||||||
|
{!isAddMode && (
|
||||||
|
<Button className={styles.addMatcherBtn} icon="plus" onClick={addNewRoute} type="button">
|
||||||
|
New policy
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
<AmRoutesTable
|
||||||
|
isAddMode={isAddMode}
|
||||||
|
onCancelAdd={() => {
|
||||||
|
setIsAddMode(false);
|
||||||
|
setActualRoutes((actualRoutes) => {
|
||||||
|
const newRoutes = [...actualRoutes];
|
||||||
|
newRoutes.pop();
|
||||||
|
|
||||||
|
return newRoutes;
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
onChange={(newRoutes) => {
|
||||||
|
onChange({
|
||||||
|
...routes,
|
||||||
|
routes: newRoutes,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (isAddMode) {
|
||||||
|
setIsAddMode(false);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
receivers={receivers}
|
||||||
|
routes={actualRoutes}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<EmptyArea
|
||||||
|
buttonIcon="plus"
|
||||||
|
buttonLabel="New specific policy"
|
||||||
|
onButtonClick={addNewRoute}
|
||||||
|
text="You haven't created any specific policies yet."
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const getStyles = (theme: GrafanaTheme2) => {
|
||||||
|
return {
|
||||||
|
container: css`
|
||||||
|
display: flex;
|
||||||
|
flex-flow: column nowrap;
|
||||||
|
`,
|
||||||
|
addMatcherBtn: css`
|
||||||
|
align-self: flex-end;
|
||||||
|
margin-bottom: ${theme.spacing(3.5)};
|
||||||
|
`,
|
||||||
|
};
|
||||||
|
};
|
@ -0,0 +1,25 @@
|
|||||||
|
import { css } from '@emotion/css';
|
||||||
|
import { GrafanaTheme2 } from '@grafana/data';
|
||||||
|
|
||||||
|
export const getFormStyles = (theme: GrafanaTheme2) => {
|
||||||
|
return {
|
||||||
|
container: css`
|
||||||
|
align-items: center;
|
||||||
|
display: flex;
|
||||||
|
flex-flow: row nowrap;
|
||||||
|
|
||||||
|
& > * + * {
|
||||||
|
margin-left: ${theme.spacing(1)};
|
||||||
|
}
|
||||||
|
`,
|
||||||
|
input: css`
|
||||||
|
flex: 1;
|
||||||
|
`,
|
||||||
|
timingContainer: css`
|
||||||
|
max-width: ${theme.spacing(33)};
|
||||||
|
`,
|
||||||
|
smallInput: css`
|
||||||
|
width: ${theme.spacing(6.5)};
|
||||||
|
`,
|
||||||
|
};
|
||||||
|
};
|
@ -0,0 +1,23 @@
|
|||||||
|
import { css } from '@emotion/css';
|
||||||
|
import { GrafanaTheme2 } from '@grafana/data';
|
||||||
|
|
||||||
|
export const getGridStyles = (theme: GrafanaTheme2) => {
|
||||||
|
return {
|
||||||
|
container: css`
|
||||||
|
display: grid;
|
||||||
|
font-style: ${theme.typography.fontSize};
|
||||||
|
grid-template-columns: ${theme.spacing(15.5)} auto;
|
||||||
|
|
||||||
|
${theme.breakpoints.down('md')} {
|
||||||
|
grid-template-columns: 100%;
|
||||||
|
}
|
||||||
|
`,
|
||||||
|
titleCell: css`
|
||||||
|
color: ${theme.colors.text.primary};
|
||||||
|
`,
|
||||||
|
valueCell: css`
|
||||||
|
color: ${theme.colors.text.secondary};
|
||||||
|
margin-bottom: ${theme.spacing(1)};
|
||||||
|
`,
|
||||||
|
};
|
||||||
|
};
|
@ -1,31 +1,22 @@
|
|||||||
import React, { FC, useState } from 'react';
|
import React, { FC, useState } from 'react';
|
||||||
import { Field, Input, Select, useStyles, InputControl, InlineLabel, Switch } from '@grafana/ui';
|
|
||||||
import { css } from '@emotion/css';
|
import { css } from '@emotion/css';
|
||||||
import { GrafanaTheme } from '@grafana/data';
|
import { GrafanaTheme } from '@grafana/data';
|
||||||
import { RuleEditorSection } from './RuleEditorSection';
|
import { Field, InlineLabel, Input, InputControl, Select, Switch, useStyles } from '@grafana/ui';
|
||||||
import { useFormContext, RegisterOptions } from 'react-hook-form';
|
import { useFormContext, RegisterOptions } from 'react-hook-form';
|
||||||
import { RuleFormType, RuleFormValues, TimeOptions } from '../../types/rule-form';
|
import { RuleFormType, RuleFormValues } from '../../types/rule-form';
|
||||||
|
import { timeOptions, timeValidationPattern } from '../../utils/time';
|
||||||
import { ConditionField } from './ConditionField';
|
import { ConditionField } from './ConditionField';
|
||||||
import { GrafanaAlertStatePicker } from './GrafanaAlertStatePicker';
|
import { GrafanaAlertStatePicker } from './GrafanaAlertStatePicker';
|
||||||
|
import { RuleEditorSection } from './RuleEditorSection';
|
||||||
|
|
||||||
const timeRangeValidationOptions: RegisterOptions = {
|
const timeRangeValidationOptions: RegisterOptions = {
|
||||||
required: {
|
required: {
|
||||||
value: true,
|
value: true,
|
||||||
message: 'Required.',
|
message: 'Required.',
|
||||||
},
|
},
|
||||||
pattern: {
|
pattern: timeValidationPattern,
|
||||||
value: new RegExp(`^\\d+(${Object.values(TimeOptions).join('|')})$`),
|
|
||||||
message: `Must be of format "(number)(unit)", for example "1m". Available units: ${Object.values(TimeOptions).join(
|
|
||||||
', '
|
|
||||||
)}`,
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const timeOptions = Object.entries(TimeOptions).map(([key, value]) => ({
|
|
||||||
label: key[0].toUpperCase() + key.slice(1),
|
|
||||||
value: value,
|
|
||||||
}));
|
|
||||||
|
|
||||||
export const ConditionsStep: FC = () => {
|
export const ConditionsStep: FC = () => {
|
||||||
const styles = useStyles(getStyles);
|
const styles = useStyles(getStyles);
|
||||||
const [showErrorHandling, setShowErrorHandling] = useState(false);
|
const [showErrorHandling, setShowErrorHandling] = useState(false);
|
||||||
|
24
public/app/features/alerting/unified/types/amroutes.ts
Normal file
24
public/app/features/alerting/unified/types/amroutes.ts
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
export interface ArrayFieldMatcher {
|
||||||
|
label: string;
|
||||||
|
value: string;
|
||||||
|
isRegex: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FormAmRoute {
|
||||||
|
matchers: ArrayFieldMatcher[];
|
||||||
|
continue: boolean;
|
||||||
|
receiver: string;
|
||||||
|
groupBy: string[];
|
||||||
|
groupWaitValue: string;
|
||||||
|
groupWaitValueType: string;
|
||||||
|
groupIntervalValue: string;
|
||||||
|
groupIntervalValueType: string;
|
||||||
|
repeatIntervalValue: string;
|
||||||
|
repeatIntervalValueType: string;
|
||||||
|
routes: FormAmRoute[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AmRouteReceiver {
|
||||||
|
label: string;
|
||||||
|
value: string;
|
||||||
|
}
|
@ -5,13 +5,6 @@ export enum RuleFormType {
|
|||||||
system = 'system',
|
system = 'system',
|
||||||
}
|
}
|
||||||
|
|
||||||
export enum TimeOptions {
|
|
||||||
seconds = 's',
|
|
||||||
minutes = 'm',
|
|
||||||
hours = 'h',
|
|
||||||
days = 'd',
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface RuleFormValues {
|
export interface RuleFormValues {
|
||||||
// common
|
// common
|
||||||
name: string;
|
name: string;
|
||||||
|
7
public/app/features/alerting/unified/types/time.ts
Normal file
7
public/app/features/alerting/unified/types/time.ts
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
export enum TimeOptions {
|
||||||
|
seconds = 's',
|
||||||
|
minutes = 'm',
|
||||||
|
hours = 'h',
|
||||||
|
days = 'd',
|
||||||
|
weeks = 'w',
|
||||||
|
}
|
161
public/app/features/alerting/unified/utils/amroutes.ts
Normal file
161
public/app/features/alerting/unified/utils/amroutes.ts
Normal file
@ -0,0 +1,161 @@
|
|||||||
|
import { SelectableValue } from '@grafana/data';
|
||||||
|
import { Validate } from 'react-hook-form';
|
||||||
|
import { Route } from 'app/plugins/datasource/alertmanager/types';
|
||||||
|
import { FormAmRoute, ArrayFieldMatcher } from '../types/amroutes';
|
||||||
|
import { parseInterval, timeOptions } from './time';
|
||||||
|
|
||||||
|
const defaultValueAndType: [string, string] = ['', timeOptions[0].value];
|
||||||
|
|
||||||
|
const matchersToArrayFieldMatchers = (
|
||||||
|
matchers: Record<string, string> | undefined,
|
||||||
|
isRegex: boolean
|
||||||
|
): ArrayFieldMatcher[] =>
|
||||||
|
Object.entries(matchers ?? {}).reduce(
|
||||||
|
(acc, [label, value]) => [
|
||||||
|
...acc,
|
||||||
|
{
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
isRegex: isRegex,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
|
const intervalToValueAndType = (strValue: string | undefined): [string, string] => {
|
||||||
|
if (!strValue) {
|
||||||
|
return defaultValueAndType;
|
||||||
|
}
|
||||||
|
|
||||||
|
const [value, valueType] = strValue ? parseInterval(strValue) : [undefined, undefined];
|
||||||
|
|
||||||
|
const timeOption = timeOptions.find((opt) => opt.value === valueType);
|
||||||
|
|
||||||
|
if (!value || !timeOption) {
|
||||||
|
return defaultValueAndType;
|
||||||
|
}
|
||||||
|
|
||||||
|
return [String(value), timeOption.value];
|
||||||
|
};
|
||||||
|
|
||||||
|
const selectableValueToString = (selectableValue: SelectableValue<string>): string => selectableValue.value!;
|
||||||
|
|
||||||
|
const selectableValuesToStrings = (arr: Array<SelectableValue<string>> | undefined): string[] =>
|
||||||
|
(arr ?? []).map(selectableValueToString);
|
||||||
|
|
||||||
|
export const emptyArrayFieldMatcher: ArrayFieldMatcher = {
|
||||||
|
label: '',
|
||||||
|
value: '',
|
||||||
|
isRegex: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
export const emptyRoute: FormAmRoute = {
|
||||||
|
matchers: [emptyArrayFieldMatcher],
|
||||||
|
groupBy: [],
|
||||||
|
routes: [],
|
||||||
|
continue: false,
|
||||||
|
receiver: '',
|
||||||
|
groupWaitValue: '',
|
||||||
|
groupWaitValueType: timeOptions[0].value,
|
||||||
|
groupIntervalValue: '',
|
||||||
|
groupIntervalValueType: timeOptions[0].value,
|
||||||
|
repeatIntervalValue: '',
|
||||||
|
repeatIntervalValueType: timeOptions[0].value,
|
||||||
|
};
|
||||||
|
|
||||||
|
export const amRouteToFormAmRoute = (route: Route | undefined): FormAmRoute => {
|
||||||
|
if (!route || Object.keys(route).length === 0) {
|
||||||
|
return emptyRoute;
|
||||||
|
}
|
||||||
|
|
||||||
|
const [groupWaitValue, groupWaitValueType] = intervalToValueAndType(route.group_wait);
|
||||||
|
const [groupIntervalValue, groupIntervalValueType] = intervalToValueAndType(route.group_interval);
|
||||||
|
const [repeatIntervalValue, repeatIntervalValueType] = intervalToValueAndType(route.repeat_interval);
|
||||||
|
|
||||||
|
return {
|
||||||
|
matchers: [
|
||||||
|
...matchersToArrayFieldMatchers(route.match, false),
|
||||||
|
...matchersToArrayFieldMatchers(route.match_re, true),
|
||||||
|
],
|
||||||
|
continue: route.continue ?? false,
|
||||||
|
receiver: route.receiver ?? '',
|
||||||
|
groupBy: route.group_by ?? [],
|
||||||
|
groupWaitValue,
|
||||||
|
groupWaitValueType,
|
||||||
|
groupIntervalValue,
|
||||||
|
groupIntervalValueType,
|
||||||
|
repeatIntervalValue,
|
||||||
|
repeatIntervalValueType,
|
||||||
|
routes: (route.routes ?? []).map(amRouteToFormAmRoute),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export const formAmRouteToAmRoute = (formAmRoute: FormAmRoute): Route => {
|
||||||
|
const amRoute: Route = {
|
||||||
|
continue: formAmRoute.continue,
|
||||||
|
group_by: formAmRoute.groupBy,
|
||||||
|
...Object.values(formAmRoute.matchers).reduce(
|
||||||
|
(acc, { label, value, isRegex }) => {
|
||||||
|
const target = acc[isRegex ? 'match_re' : 'match'];
|
||||||
|
|
||||||
|
target![label] = value;
|
||||||
|
|
||||||
|
return acc;
|
||||||
|
},
|
||||||
|
{
|
||||||
|
match: {},
|
||||||
|
match_re: {},
|
||||||
|
} as Pick<Route, 'match' | 'match_re'>
|
||||||
|
),
|
||||||
|
group_wait: formAmRoute.groupWaitValue
|
||||||
|
? `${formAmRoute.groupWaitValue}${formAmRoute.groupWaitValueType}`
|
||||||
|
: undefined,
|
||||||
|
group_interval: formAmRoute.groupIntervalValue
|
||||||
|
? `${formAmRoute.groupIntervalValue}${formAmRoute.groupIntervalValueType}`
|
||||||
|
: undefined,
|
||||||
|
repeat_interval: formAmRoute.repeatIntervalValue
|
||||||
|
? `${formAmRoute.repeatIntervalValue}${formAmRoute.repeatIntervalValueType}`
|
||||||
|
: undefined,
|
||||||
|
routes: formAmRoute.routes.map(formAmRouteToAmRoute),
|
||||||
|
};
|
||||||
|
|
||||||
|
if (formAmRoute.receiver) {
|
||||||
|
amRoute.receiver = formAmRoute.receiver;
|
||||||
|
}
|
||||||
|
|
||||||
|
return amRoute;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const stringToSelectableValue = (str: string): SelectableValue<string> => ({
|
||||||
|
label: str,
|
||||||
|
value: str,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const stringsToSelectableValues = (arr: string[] | undefined): Array<SelectableValue<string>> =>
|
||||||
|
(arr ?? []).map(stringToSelectableValue);
|
||||||
|
|
||||||
|
export const mapSelectValueToString = (selectableValue: SelectableValue<string>): string => {
|
||||||
|
if (!selectableValue) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
return selectableValueToString(selectableValue) ?? '';
|
||||||
|
};
|
||||||
|
|
||||||
|
export const mapMultiSelectValueToStrings = (
|
||||||
|
selectableValues: Array<SelectableValue<string>> | undefined
|
||||||
|
): string[] => {
|
||||||
|
if (!selectableValues) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return selectableValuesToStrings(selectableValues);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const optionalPositiveInteger: Validate<string> = (value) => {
|
||||||
|
if (!value) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
return !/^\d+$/.test(value) ? 'Must be a positive integer.' : undefined;
|
||||||
|
};
|
71
public/app/features/alerting/unified/utils/dynamicTable.ts
Normal file
71
public/app/features/alerting/unified/utils/dynamicTable.ts
Normal file
@ -0,0 +1,71 @@
|
|||||||
|
import { DynamicTableItemProps } from '../components/DynamicTable';
|
||||||
|
|
||||||
|
export const prepareItems = <T = unknown>(
|
||||||
|
items: T[],
|
||||||
|
idCreator?: (item: T) => number | string
|
||||||
|
): Array<DynamicTableItemProps<T>> =>
|
||||||
|
items.map((item, index) => ({
|
||||||
|
id: idCreator?.(item) ?? index,
|
||||||
|
data: item,
|
||||||
|
}));
|
||||||
|
|
||||||
|
export const collapseItem = <T = unknown>(
|
||||||
|
items: Array<DynamicTableItemProps<T>>,
|
||||||
|
itemId: DynamicTableItemProps<T>['id']
|
||||||
|
): Array<DynamicTableItemProps<T>> =>
|
||||||
|
items.map((currentItem) => {
|
||||||
|
if (currentItem.id !== itemId) {
|
||||||
|
return currentItem;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...currentItem,
|
||||||
|
isExpanded: false,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
export const expandItem = <T = unknown>(
|
||||||
|
items: Array<DynamicTableItemProps<T>>,
|
||||||
|
itemId: DynamicTableItemProps<T>['id']
|
||||||
|
): Array<DynamicTableItemProps<T>> =>
|
||||||
|
items.map((currentItem) => {
|
||||||
|
if (currentItem.id !== itemId) {
|
||||||
|
return currentItem;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...currentItem,
|
||||||
|
isExpanded: true,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
export const addCustomExpandedContent = <T = unknown>(
|
||||||
|
items: Array<DynamicTableItemProps<T>>,
|
||||||
|
itemId: DynamicTableItemProps<T>['id'],
|
||||||
|
renderExpandedContent: DynamicTableItemProps['renderExpandedContent']
|
||||||
|
): Array<DynamicTableItemProps<T>> =>
|
||||||
|
items.map((currentItem) => {
|
||||||
|
if (currentItem.id !== itemId) {
|
||||||
|
return currentItem;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...currentItem,
|
||||||
|
renderExpandedContent,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
export const removeCustomExpandedContent = <T = unknown>(
|
||||||
|
items: Array<DynamicTableItemProps<T>>,
|
||||||
|
itemId: DynamicTableItemProps<T>['id']
|
||||||
|
): Array<DynamicTableItemProps<T>> =>
|
||||||
|
items.map((currentItem) => {
|
||||||
|
if (currentItem.id !== itemId) {
|
||||||
|
return currentItem;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...currentItem,
|
||||||
|
renderExpandedContent: undefined,
|
||||||
|
};
|
||||||
|
});
|
@ -11,6 +11,7 @@ import { RuleFormType, RuleFormValues } from '../types/rule-form';
|
|||||||
import { isGrafanaRulesSource } from './datasource';
|
import { isGrafanaRulesSource } from './datasource';
|
||||||
import { arrayToRecord, recordToArray } from './misc';
|
import { arrayToRecord, recordToArray } from './misc';
|
||||||
import { isAlertingRulerRule, isGrafanaRulerRule } from './rules';
|
import { isAlertingRulerRule, isGrafanaRulerRule } from './rules';
|
||||||
|
import { parseInterval } from './time';
|
||||||
|
|
||||||
export const defaultFormValues: RuleFormValues = Object.freeze({
|
export const defaultFormValues: RuleFormValues = Object.freeze({
|
||||||
name: '',
|
name: '',
|
||||||
@ -46,14 +47,6 @@ export function formValuesToRulerAlertingRuleDTO(values: RuleFormValues): RulerA
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function parseInterval(value: string): [number, string] {
|
|
||||||
const match = value.match(/(\d+)(\w+)/);
|
|
||||||
if (match) {
|
|
||||||
return [Number(match[1]), match[2]];
|
|
||||||
}
|
|
||||||
throw new Error(`Invalid interval description: ${value}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
function listifyLabelsOrAnnotations(item: Labels | Annotations | undefined): Array<{ key: string; value: string }> {
|
function listifyLabelsOrAnnotations(item: Labels | Annotations | undefined): Array<{ key: string; value: string }> {
|
||||||
return [...recordToArray(item || {}), { key: '', value: '' }];
|
return [...recordToArray(item || {}), { key: '', value: '' }];
|
||||||
}
|
}
|
||||||
|
27
public/app/features/alerting/unified/utils/time.ts
Normal file
27
public/app/features/alerting/unified/utils/time.ts
Normal file
@ -0,0 +1,27 @@
|
|||||||
|
import { describeInterval } from '@grafana/data/src/datetime/rangeutil';
|
||||||
|
import { TimeOptions } from '../types/time';
|
||||||
|
|
||||||
|
export function parseInterval(value: string): [number, string] {
|
||||||
|
const match = value.match(/(\d+)(\w+)/);
|
||||||
|
if (match) {
|
||||||
|
return [Number(match[1]), match[2]];
|
||||||
|
}
|
||||||
|
throw new Error(`Invalid interval description: ${value}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function intervalToSeconds(interval: string): number {
|
||||||
|
const { sec, count } = describeInterval(interval);
|
||||||
|
return sec * count;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const timeOptions = Object.entries(TimeOptions).map(([key, value]) => ({
|
||||||
|
label: key[0].toUpperCase() + key.slice(1),
|
||||||
|
value: value,
|
||||||
|
}));
|
||||||
|
|
||||||
|
export const timeValidationPattern = {
|
||||||
|
value: new RegExp(`^\\d+(${Object.values(TimeOptions).join('|')})$`),
|
||||||
|
message: `Must be of format "(number)(unit)", for example "1m". Available units: ${Object.values(TimeOptions).join(
|
||||||
|
', '
|
||||||
|
)}`,
|
||||||
|
};
|
@ -103,7 +103,7 @@ export type Route = {
|
|||||||
match_re?: Record<string, string>;
|
match_re?: Record<string, string>;
|
||||||
group_wait?: string;
|
group_wait?: string;
|
||||||
group_interval?: string;
|
group_interval?: string;
|
||||||
repeat_itnerval?: string;
|
repeat_interval?: string;
|
||||||
routes?: Route[];
|
routes?: Route[];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
Loading…
Reference in New Issue
Block a user