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, 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};
|
||||
`,
|
||||
});
|
||||
|
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 { 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);
|
||||
|
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',
|
||||
}
|
||||
|
||||
export enum TimeOptions {
|
||||
seconds = 's',
|
||||
minutes = 'm',
|
||||
hours = 'h',
|
||||
days = 'd',
|
||||
}
|
||||
|
||||
export interface RuleFormValues {
|
||||
// common
|
||||
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 { 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: '' }];
|
||||
}
|
||||
|
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>;
|
||||
group_wait?: string;
|
||||
group_interval?: string;
|
||||
repeat_itnerval?: string;
|
||||
repeat_interval?: string;
|
||||
routes?: Route[];
|
||||
};
|
||||
|
||||
|
Loading…
Reference in New Issue
Block a user