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:
Bogdan Matei 2021-05-04 16:57:11 +03:00 committed by GitHub
parent 75b9018464
commit 9510c4f112
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
22 changed files with 1866 additions and 43 deletions

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

View File

@ -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<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) {
return <Redirect to="/alerting/routes" />;
}
@ -33,17 +99,49 @@ const AmRoutes: FC = () => {
<Field label="Choose alert manager">
<AlertManagerPicker current={alertManagerSourceName} onChange={setAlertManagerSourceName} />
</Field>
<br />
<br />
{error && !loading && (
<Alert severity="error" title="Error loading alert manager config">
{error.message || 'Unknown error.'}
{savingError && !saving && (
<Alert severity="error" title="Error saving alert manager config">
{savingError.message || 'Unknown error.'}
</Alert>
)}
{loading && <LoadingPlaceholder text="loading alert manager config..." />}
{result && !loading && !error && <pre>{JSON.stringify(result, null, 2)}</pre>}
{resultError && !resultLoading && (
<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>
);
};
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};
`,
});

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,31 +1,22 @@
import React, { FC, useState } from 'react';
import { Field, Input, Select, useStyles, InputControl, InlineLabel, Switch } from '@grafana/ui';
import { css } from '@emotion/css';
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 { 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 { GrafanaAlertStatePicker } from './GrafanaAlertStatePicker';
import { RuleEditorSection } from './RuleEditorSection';
const timeRangeValidationOptions: RegisterOptions = {
required: {
value: true,
message: 'Required.',
},
pattern: {
value: new RegExp(`^\\d+(${Object.values(TimeOptions).join('|')})$`),
message: `Must be of format "(number)(unit)", for example "1m". Available units: ${Object.values(TimeOptions).join(
', '
)}`,
},
pattern: timeValidationPattern,
};
const timeOptions = Object.entries(TimeOptions).map(([key, value]) => ({
label: key[0].toUpperCase() + key.slice(1),
value: value,
}));
export const ConditionsStep: FC = () => {
const styles = useStyles(getStyles);
const [showErrorHandling, setShowErrorHandling] = useState(false);

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

View File

@ -5,13 +5,6 @@ export enum RuleFormType {
system = 'system',
}
export enum TimeOptions {
seconds = 's',
minutes = 'm',
hours = 'h',
days = 'd',
}
export interface RuleFormValues {
// common
name: string;

View File

@ -0,0 +1,7 @@
export enum TimeOptions {
seconds = 's',
minutes = 'm',
hours = 'h',
days = 'd',
weeks = 'w',
}

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

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

View File

@ -11,6 +11,7 @@ import { RuleFormType, RuleFormValues } from '../types/rule-form';
import { isGrafanaRulesSource } from './datasource';
import { arrayToRecord, recordToArray } from './misc';
import { isAlertingRulerRule, isGrafanaRulerRule } from './rules';
import { parseInterval } from './time';
export const defaultFormValues: RuleFormValues = Object.freeze({
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 }> {
return [...recordToArray(item || {}), { key: '', value: '' }];
}

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

View File

@ -103,7 +103,7 @@ export type Route = {
match_re?: Record<string, string>;
group_wait?: string;
group_interval?: string;
repeat_itnerval?: string;
repeat_interval?: string;
routes?: Route[];
};