mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
Alerting: Custom contact point for OnCall in Grafana AM (#72021)
This commit is contained in:
parent
13f4382214
commit
7baf9cc033
@ -5,17 +5,27 @@ import { GrafanaTheme2 } from '@grafana/data';
|
|||||||
|
|
||||||
import { useStyles2 } from '../../../themes';
|
import { useStyles2 } from '../../../themes';
|
||||||
|
|
||||||
export interface RadioButtonDotProps {
|
export interface RadioButtonDotProps<T> {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
checked?: boolean;
|
checked?: boolean;
|
||||||
|
value?: T;
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
label: React.ReactNode;
|
label: React.ReactNode;
|
||||||
description?: string;
|
description?: string;
|
||||||
onChange?: (id: string) => void;
|
onChange?: (id: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const RadioButtonDot = ({ id, name, label, checked, disabled, description, onChange }: RadioButtonDotProps) => {
|
export const RadioButtonDot = <T extends string | number | readonly string[]>({
|
||||||
|
id,
|
||||||
|
name,
|
||||||
|
label,
|
||||||
|
checked,
|
||||||
|
value,
|
||||||
|
disabled,
|
||||||
|
description,
|
||||||
|
onChange,
|
||||||
|
}: RadioButtonDotProps<T>) => {
|
||||||
const styles = useStyles2(getStyles);
|
const styles = useStyles2(getStyles);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@ -25,11 +35,15 @@ export const RadioButtonDot = ({ id, name, label, checked, disabled, description
|
|||||||
name={name}
|
name={name}
|
||||||
type="radio"
|
type="radio"
|
||||||
checked={checked}
|
checked={checked}
|
||||||
|
value={value}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
className={styles.input}
|
className={styles.input}
|
||||||
onChange={() => onChange && onChange(id)}
|
onChange={() => onChange && onChange(id)}
|
||||||
/>
|
/>
|
||||||
|
<div>
|
||||||
{label}
|
{label}
|
||||||
|
{description && <div className={styles.description}>{description}</div>}
|
||||||
|
</div>
|
||||||
</label>
|
</label>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@ -84,4 +98,8 @@ const getStyles = (theme: GrafanaTheme2) => ({
|
|||||||
gridTemplateColumns: `${theme.spacing(2)} auto`,
|
gridTemplateColumns: `${theme.spacing(2)} auto`,
|
||||||
gap: theme.spacing(1),
|
gap: theme.spacing(1),
|
||||||
}),
|
}),
|
||||||
|
description: css({
|
||||||
|
fontSize: theme.typography.size.sm,
|
||||||
|
color: theme.colors.text.secondary,
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
|
@ -43,6 +43,30 @@ const options = [
|
|||||||
const disabledOptions = ['prometheus', 'elastic'];
|
const disabledOptions = ['prometheus', 'elastic'];
|
||||||
|
|
||||||
|
|
||||||
|
<RadioButtonGroup
|
||||||
|
options={options}
|
||||||
|
disabledOptions={disabledOptions}
|
||||||
|
value={...}
|
||||||
|
onChange={...}
|
||||||
|
/>
|
||||||
|
```
|
||||||
|
|
||||||
|
### Options with descriptions
|
||||||
|
|
||||||
|
You can add descriptions to the options by passing them to the option's description property.
|
||||||
|
Descriptions should be short and concise. Try to avoid multiline text.
|
||||||
|
|
||||||
|
```jsx
|
||||||
|
import { RadioButtonList } from '@grafana/ui';
|
||||||
|
|
||||||
|
const options = [
|
||||||
|
{ label: 'Prometheus', value: 'prometheus', description: 'Monitoring system & TSDB' },
|
||||||
|
{ label: 'Loki', value: 'loki', description: 'Log aggregation system' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const disabledOptions = ['prometheus', 'elastic'];
|
||||||
|
|
||||||
|
|
||||||
<RadioButtonGroup
|
<RadioButtonGroup
|
||||||
options={options}
|
options={options}
|
||||||
disabledOptions={disabledOptions}
|
disabledOptions={disabledOptions}
|
||||||
|
@ -8,11 +8,11 @@ import { RadioButtonList, RadioButtonListProps } from './RadioButtonList';
|
|||||||
import mdx from './RadioButtonList.mdx';
|
import mdx from './RadioButtonList.mdx';
|
||||||
|
|
||||||
const defaultOptions: Array<SelectableValue<string>> = [
|
const defaultOptions: Array<SelectableValue<string>> = [
|
||||||
{ label: 'Option 1', value: 'opt-1', description: 'A description of Option 1' },
|
{ label: 'Option 1', value: 'opt-1' },
|
||||||
{ label: 'Option 2', value: 'opt-2', description: 'A description of Option 2' },
|
{ label: 'Option 2', value: 'opt-2' },
|
||||||
{ label: 'Option 3', value: 'opt-3', description: 'A description of Option 3' },
|
{ label: 'Option 3', value: 'opt-3' },
|
||||||
{ label: 'Option 4', value: 'opt-4', description: 'A description of Option 4' },
|
{ label: 'Option 4', value: 'opt-4' },
|
||||||
{ label: 'Option 5', value: 'opt-5', description: 'A description of Option 5' },
|
{ label: 'Option 5', value: 'opt-5' },
|
||||||
];
|
];
|
||||||
|
|
||||||
const meta: Meta<typeof RadioButtonList> = {
|
const meta: Meta<typeof RadioButtonList> = {
|
||||||
@ -81,6 +81,18 @@ export const LongLabels: StoryFn<typeof RadioButtonList> = ({ disabled, disabled
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
export const WithDescriptions: StoryFn<typeof RadioButtonList> = ({ disabled, disabledOptions }) => (
|
||||||
|
<div>
|
||||||
|
<RadioButtonList
|
||||||
|
name="withDescriptions"
|
||||||
|
options={[
|
||||||
|
{ label: 'Prometheus', value: 'prometheus', description: 'Monitoring system & TSDB' },
|
||||||
|
{ label: 'Loki', value: 'loki', description: 'Log aggregation system' },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
export const ControlledComponent: Story<RadioButtonListProps<string>> = ({ disabled, disabledOptions }) => {
|
export const ControlledComponent: Story<RadioButtonListProps<string>> = ({ disabled, disabledOptions }) => {
|
||||||
const [selected, setSelected] = useState<string>(defaultOptions[0].value!);
|
const [selected, setSelected] = useState<string>(defaultOptions[0].value!);
|
||||||
|
|
||||||
|
@ -23,7 +23,7 @@ export interface RadioButtonListProps<T> {
|
|||||||
className?: string;
|
className?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function RadioButtonList<T>({
|
export function RadioButtonList<T extends string | number | readonly string[]>({
|
||||||
name,
|
name,
|
||||||
id,
|
id,
|
||||||
options,
|
options,
|
||||||
@ -47,13 +47,14 @@ export function RadioButtonList<T>({
|
|||||||
const handleChange = () => onChange && option.value && onChange(option.value);
|
const handleChange = () => onChange && option.value && onChange(option.value);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<RadioButtonDot
|
<RadioButtonDot<T>
|
||||||
key={index}
|
key={index}
|
||||||
id={itemId}
|
id={itemId}
|
||||||
name={name}
|
name={name}
|
||||||
label={option.label}
|
label={option.label}
|
||||||
description={option.description}
|
description={option.description}
|
||||||
checked={isChecked}
|
checked={isChecked}
|
||||||
|
value={option.value}
|
||||||
disabled={isDisabled}
|
disabled={isDisabled}
|
||||||
onChange={handleChange}
|
onChange={handleChange}
|
||||||
/>
|
/>
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
import { dateTime } from '@grafana/data';
|
import { dateTime } from '@grafana/data';
|
||||||
import { faro, LogLevel as GrafanaLogLevel } from '@grafana/faro-web-sdk';
|
import { faro, LogLevel as GrafanaLogLevel } from '@grafana/faro-web-sdk';
|
||||||
import { getBackendSrv } from '@grafana/runtime';
|
import { getBackendSrv, logError } from '@grafana/runtime';
|
||||||
import { config, reportInteraction } from '@grafana/runtime/src';
|
import { config, reportInteraction } from '@grafana/runtime/src';
|
||||||
import { contextSrv } from 'app/core/core';
|
import { contextSrv } from 'app/core/core';
|
||||||
|
|
||||||
@ -30,6 +30,10 @@ export function logInfo(message: string, context: Record<string, string | number
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function logAlertingError(error: Error, context: Record<string, string | number> = {}) {
|
||||||
|
logError(error, { ...context, module: 'Alerting' });
|
||||||
|
}
|
||||||
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
export function withPerformanceLogging<TFunc extends (...args: any[]) => Promise<any>>(
|
export function withPerformanceLogging<TFunc extends (...args: any[]) => Promise<any>>(
|
||||||
func: TFunc,
|
func: TFunc,
|
||||||
|
@ -27,6 +27,6 @@ export const backendSrvBaseQuery = (): BaseQueryFn<BackendSrvRequest> => async (
|
|||||||
export const alertingApi = createApi({
|
export const alertingApi = createApi({
|
||||||
reducerPath: 'alertingApi',
|
reducerPath: 'alertingApi',
|
||||||
baseQuery: backendSrvBaseQuery(),
|
baseQuery: backendSrvBaseQuery(),
|
||||||
tagTypes: ['AlertmanagerChoice', 'AlertmanagerConfiguration'],
|
tagTypes: ['AlertmanagerChoice', 'AlertmanagerConfiguration', 'OnCallIntegrations'],
|
||||||
endpoints: () => ({}),
|
endpoints: () => ({}),
|
||||||
});
|
});
|
||||||
|
@ -13,6 +13,7 @@ import {
|
|||||||
ExternalAlertmanagersResponse,
|
ExternalAlertmanagersResponse,
|
||||||
Matcher,
|
Matcher,
|
||||||
} from '../../../../plugins/datasource/alertmanager/types';
|
} from '../../../../plugins/datasource/alertmanager/types';
|
||||||
|
import { NotifierDTO } from '../../../../types';
|
||||||
import { withPerformanceLogging } from '../Analytics';
|
import { withPerformanceLogging } from '../Analytics';
|
||||||
import { matcherToOperator } from '../utils/alertmanager';
|
import { matcherToOperator } from '../utils/alertmanager';
|
||||||
import {
|
import {
|
||||||
@ -83,6 +84,10 @@ export const alertmanagerApi = alertingApi.injectEndpoints({
|
|||||||
}),
|
}),
|
||||||
}),
|
}),
|
||||||
|
|
||||||
|
grafanaNotifiers: build.query<NotifierDTO[], void>({
|
||||||
|
query: () => ({ url: '/api/alert-notifiers' }),
|
||||||
|
}),
|
||||||
|
|
||||||
getAlertmanagerChoiceStatus: build.query<AlertmanagersChoiceResponse, void>({
|
getAlertmanagerChoiceStatus: build.query<AlertmanagersChoiceResponse, void>({
|
||||||
query: () => ({ url: '/api/v1/ngalert' }),
|
query: () => ({ url: '/api/v1/ngalert' }),
|
||||||
providesTags: ['AlertmanagerChoice'],
|
providesTags: ['AlertmanagerChoice'],
|
||||||
|
@ -1,36 +1,89 @@
|
|||||||
import { lastValueFrom } from 'rxjs';
|
import { FetchError, isFetchError } from '@grafana/runtime';
|
||||||
|
|
||||||
import { getBackendSrv } from '@grafana/runtime';
|
import { GRAFANA_ONCALL_INTEGRATION_TYPE } from '../components/receivers/grafanaAppReceivers/onCall/onCall';
|
||||||
|
import { SupportedPlugin } from '../types/pluginBridges';
|
||||||
|
|
||||||
import { alertingApi } from './alertingApi';
|
import { alertingApi } from './alertingApi';
|
||||||
export interface OnCallIntegration {
|
|
||||||
|
export interface NewOnCallIntegrationDTO {
|
||||||
|
id: string;
|
||||||
|
connected_escalations_chains_count: number;
|
||||||
|
integration: string;
|
||||||
|
integration_url: string;
|
||||||
|
verbal_name: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface OnCallPaginatedResult<T> {
|
||||||
|
results: T[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ONCALL_INTEGRATION_V2_FEATURE = 'grafana_alerting_v2';
|
||||||
|
type OnCallFeature = typeof ONCALL_INTEGRATION_V2_FEATURE | string;
|
||||||
|
|
||||||
|
type AlertReceiveChannelsResult = OnCallPaginatedResult<OnCallIntegrationDTO> | OnCallIntegrationDTO[];
|
||||||
|
|
||||||
|
export interface OnCallIntegrationDTO {
|
||||||
|
value: string;
|
||||||
|
display_name: string;
|
||||||
integration_url: string;
|
integration_url: string;
|
||||||
}
|
}
|
||||||
export type OnCallIntegrationsResponse = OnCallIntegration[];
|
|
||||||
export type OnCallIntegrationsUrls = string[];
|
export interface CreateIntegrationDTO {
|
||||||
|
integration: typeof GRAFANA_ONCALL_INTEGRATION_TYPE; // The only one supported right now
|
||||||
|
verbal_name: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const getProxyApiUrl = (path: string) => `/api/plugin-proxy/${SupportedPlugin.OnCall}${path}`;
|
||||||
|
|
||||||
export const onCallApi = alertingApi.injectEndpoints({
|
export const onCallApi = alertingApi.injectEndpoints({
|
||||||
endpoints: (build) => ({
|
endpoints: (build) => ({
|
||||||
getOnCallIntegrations: build.query<OnCallIntegrationsUrls, void>({
|
grafanaOnCallIntegrations: build.query<OnCallIntegrationDTO[], void>({
|
||||||
queryFn: async () => {
|
query: () => ({
|
||||||
const integrations = await fetchOnCallIntegrations();
|
url: getProxyApiUrl('/api/internal/v1/alert_receive_channels/'),
|
||||||
return { data: integrations };
|
// legacy_grafana_alerting is necessary for OnCall.
|
||||||
|
// We do NOT need to differentiate between these two on our side
|
||||||
|
params: { filters: true, integration: [GRAFANA_ONCALL_INTEGRATION_TYPE, 'legacy_grafana_alerting'] },
|
||||||
|
}),
|
||||||
|
transformResponse: (response: AlertReceiveChannelsResult) => {
|
||||||
|
if (isPaginatedResponse(response)) {
|
||||||
|
return response.results;
|
||||||
|
}
|
||||||
|
return response;
|
||||||
},
|
},
|
||||||
|
providesTags: ['OnCallIntegrations'],
|
||||||
|
}),
|
||||||
|
validateIntegrationName: build.query<boolean, string>({
|
||||||
|
query: (name) => ({
|
||||||
|
url: getProxyApiUrl('/api/internal/v1/alert_receive_channels/validate_name/'),
|
||||||
|
params: { verbal_name: name },
|
||||||
|
showErrorAlert: false,
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
createIntegration: build.mutation<NewOnCallIntegrationDTO, CreateIntegrationDTO>({
|
||||||
|
query: (integration) => ({
|
||||||
|
url: getProxyApiUrl('/api/internal/v1/alert_receive_channels/'),
|
||||||
|
data: integration,
|
||||||
|
method: 'POST',
|
||||||
|
showErrorAlert: true,
|
||||||
|
}),
|
||||||
|
invalidatesTags: ['OnCallIntegrations'],
|
||||||
|
}),
|
||||||
|
features: build.query<OnCallFeature[], void>({
|
||||||
|
query: () => ({
|
||||||
|
url: getProxyApiUrl('/api/internal/v1/features/'),
|
||||||
|
}),
|
||||||
}),
|
}),
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
export async function fetchOnCallIntegrations(): Promise<OnCallIntegrationsUrls> {
|
|
||||||
try {
|
function isPaginatedResponse(
|
||||||
const response = await lastValueFrom(
|
response: AlertReceiveChannelsResult
|
||||||
getBackendSrv().fetch<OnCallIntegrationsResponse>({
|
): response is OnCallPaginatedResult<OnCallIntegrationDTO> {
|
||||||
url: '/api/plugin-proxy/grafana-oncall-app/api/internal/v1/alert_receive_channels/',
|
return 'results' in response && Array.isArray(response.results);
|
||||||
showErrorAlert: false,
|
}
|
||||||
showSuccessAlert: false,
|
|
||||||
})
|
export const { useGrafanaOnCallIntegrationsQuery } = onCallApi;
|
||||||
);
|
|
||||||
return response.data.map((result) => result.integration_url);
|
export function isOnCallFetchError(error: unknown): error is FetchError<{ detail: string }> {
|
||||||
} catch (error) {
|
return isFetchError(error) && 'detail' in error.data;
|
||||||
return [];
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
export const { useGetOnCallIntegrationsQuery } = onCallApi;
|
|
||||||
|
@ -1,14 +1,12 @@
|
|||||||
import { render, waitFor, within, screen } from '@testing-library/react';
|
import { render, waitFor, within, screen } from '@testing-library/react';
|
||||||
import userEvent from '@testing-library/user-event';
|
import userEvent from '@testing-library/user-event';
|
||||||
import { setupServer } from 'msw/node';
|
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { TestProvider } from 'test/helpers/TestProvider';
|
import { TestProvider } from 'test/helpers/TestProvider';
|
||||||
import { selectOptionInTest } from 'test/helpers/selectOptionInTest';
|
import { selectOptionInTest } from 'test/helpers/selectOptionInTest';
|
||||||
import { byLabelText, byPlaceholderText, byRole, byTestId, byText } from 'testing-library-selector';
|
import { byLabelText, byPlaceholderText, byRole, byTestId, byText } from 'testing-library-selector';
|
||||||
|
|
||||||
import { locationService, setBackendSrv, setDataSourceSrv } from '@grafana/runtime';
|
import { locationService, setDataSourceSrv } from '@grafana/runtime';
|
||||||
import { interceptLinkClicks } from 'app/core/navigation/patch/interceptLinkClicks';
|
import { interceptLinkClicks } from 'app/core/navigation/patch/interceptLinkClicks';
|
||||||
import { backendSrv } from 'app/core/services/backend_srv';
|
|
||||||
import { contextSrv } from 'app/core/services/context_srv';
|
import { contextSrv } from 'app/core/services/context_srv';
|
||||||
import store from 'app/core/store';
|
import store from 'app/core/store';
|
||||||
import {
|
import {
|
||||||
@ -19,6 +17,7 @@ import {
|
|||||||
import { AccessControlAction, ContactPointsState } from 'app/types';
|
import { AccessControlAction, ContactPointsState } from 'app/types';
|
||||||
|
|
||||||
import 'whatwg-fetch';
|
import 'whatwg-fetch';
|
||||||
|
import 'core-js/stable/structured-clone';
|
||||||
|
|
||||||
import { fetchAlertManagerConfig, fetchStatus, testReceivers, updateAlertManagerConfig } from '../../api/alertmanager';
|
import { fetchAlertManagerConfig, fetchStatus, testReceivers, updateAlertManagerConfig } from '../../api/alertmanager';
|
||||||
import { AlertmanagersChoiceResponse } from '../../api/alertmanagerApi';
|
import { AlertmanagersChoiceResponse } from '../../api/alertmanagerApi';
|
||||||
@ -26,9 +25,11 @@ import { discoverAlertmanagerFeatures } from '../../api/buildInfo';
|
|||||||
import { fetchNotifiers } from '../../api/grafana';
|
import { fetchNotifiers } from '../../api/grafana';
|
||||||
import * as receiversApi from '../../api/receiversApi';
|
import * as receiversApi from '../../api/receiversApi';
|
||||||
import * as grafanaApp from '../../components/receivers/grafanaAppReceivers/grafanaApp';
|
import * as grafanaApp from '../../components/receivers/grafanaAppReceivers/grafanaApp';
|
||||||
|
import { mockApi, setupMswServer } from '../../mockApi';
|
||||||
import {
|
import {
|
||||||
mockDataSource,
|
mockDataSource,
|
||||||
MockDataSourceSrv,
|
MockDataSourceSrv,
|
||||||
|
onCallPluginMetaMock,
|
||||||
someCloudAlertManagerConfig,
|
someCloudAlertManagerConfig,
|
||||||
someCloudAlertManagerStatus,
|
someCloudAlertManagerStatus,
|
||||||
someGrafanaAlertManagerConfig,
|
someGrafanaAlertManagerConfig,
|
||||||
@ -150,21 +151,16 @@ const emptyContactPointsState: ContactPointsState = { receivers: {}, errorCount:
|
|||||||
|
|
||||||
const useGetGrafanaReceiverTypeCheckerMock = jest.spyOn(grafanaApp, 'useGetGrafanaReceiverTypeChecker');
|
const useGetGrafanaReceiverTypeCheckerMock = jest.spyOn(grafanaApp, 'useGetGrafanaReceiverTypeChecker');
|
||||||
|
|
||||||
|
const server = setupMswServer();
|
||||||
|
|
||||||
describe('Receivers', () => {
|
describe('Receivers', () => {
|
||||||
const server = setupServer();
|
|
||||||
|
|
||||||
beforeAll(() => {
|
|
||||||
setBackendSrv(backendSrv);
|
|
||||||
server.listen({ onUnhandledRequest: 'error' });
|
|
||||||
});
|
|
||||||
|
|
||||||
afterAll(() => {
|
|
||||||
server.close();
|
|
||||||
});
|
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
server.resetHandlers();
|
server.resetHandlers();
|
||||||
jest.resetAllMocks();
|
jest.resetAllMocks();
|
||||||
|
|
||||||
|
mockApi(server).grafanaNotifiers(grafanaNotifiersMock);
|
||||||
|
mockApi(server).plugins.getPluginSettings(onCallPluginMetaMock);
|
||||||
|
|
||||||
useGetGrafanaReceiverTypeCheckerMock.mockReturnValue(() => undefined);
|
useGetGrafanaReceiverTypeCheckerMock.mockReturnValue(() => undefined);
|
||||||
mocks.getAllDataSources.mockReturnValue(Object.values(dataSources));
|
mocks.getAllDataSources.mockReturnValue(Object.values(dataSources));
|
||||||
mocks.api.fetchNotifiers.mockResolvedValue(grafanaNotifiersMock);
|
mocks.api.fetchNotifiers.mockResolvedValue(grafanaNotifiersMock);
|
||||||
|
@ -2,6 +2,7 @@ import { screen, render, within } from '@testing-library/react';
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { TestProvider } from 'test/helpers/TestProvider';
|
import { TestProvider } from 'test/helpers/TestProvider';
|
||||||
|
|
||||||
|
import { setBackendSrv } from '@grafana/runtime';
|
||||||
import {
|
import {
|
||||||
AlertManagerCortexConfig,
|
AlertManagerCortexConfig,
|
||||||
GrafanaManagedReceiverConfig,
|
GrafanaManagedReceiverConfig,
|
||||||
@ -10,7 +11,7 @@ import {
|
|||||||
import { configureStore } from 'app/store/configureStore';
|
import { configureStore } from 'app/store/configureStore';
|
||||||
import { AccessControlAction, ContactPointsState, NotifierDTO, NotifierType } from 'app/types';
|
import { AccessControlAction, ContactPointsState, NotifierDTO, NotifierType } from 'app/types';
|
||||||
|
|
||||||
import * as onCallApi from '../../api/onCallApi';
|
import { backendSrv } from '../../../../../core/services/backend_srv';
|
||||||
import * as receiversApi from '../../api/receiversApi';
|
import * as receiversApi from '../../api/receiversApi';
|
||||||
import { enableRBAC, grantUserPermissions } from '../../mocks';
|
import { enableRBAC, grantUserPermissions } from '../../mocks';
|
||||||
import { fetchGrafanaNotifiersAction } from '../../state/actions';
|
import { fetchGrafanaNotifiersAction } from '../../state/actions';
|
||||||
@ -18,7 +19,8 @@ import { GRAFANA_RULES_SOURCE_NAME } from '../../utils/datasource';
|
|||||||
import { createUrl } from '../../utils/url';
|
import { createUrl } from '../../utils/url';
|
||||||
|
|
||||||
import { ReceiversTable } from './ReceiversTable';
|
import { ReceiversTable } from './ReceiversTable';
|
||||||
import * as grafanaApp from './grafanaAppReceivers/grafanaApp';
|
import * as receiversMeta from './grafanaAppReceivers/useReceiversMetadata';
|
||||||
|
import { ReceiverMetadata } from './grafanaAppReceivers/useReceiversMetadata';
|
||||||
|
|
||||||
const renderReceieversTable = async (
|
const renderReceieversTable = async (
|
||||||
receivers: Receiver[],
|
receivers: Receiver[],
|
||||||
@ -58,16 +60,17 @@ const mockNotifier = (type: NotifierType, name: string): NotifierDTO => ({
|
|||||||
options: [],
|
options: [],
|
||||||
});
|
});
|
||||||
|
|
||||||
jest.spyOn(onCallApi, 'useGetOnCallIntegrationsQuery');
|
const useReceiversMetadata = jest.spyOn(receiversMeta, 'useReceiversMetadata');
|
||||||
const useGetGrafanaReceiverTypeCheckerMock = jest.spyOn(grafanaApp, 'useGetGrafanaReceiverTypeChecker');
|
|
||||||
const useGetContactPointsStateMock = jest.spyOn(receiversApi, 'useGetContactPointsState');
|
const useGetContactPointsStateMock = jest.spyOn(receiversApi, 'useGetContactPointsState');
|
||||||
|
|
||||||
|
setBackendSrv(backendSrv);
|
||||||
|
|
||||||
describe('ReceiversTable', () => {
|
describe('ReceiversTable', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
jest.resetAllMocks();
|
jest.resetAllMocks();
|
||||||
const emptyContactPointsState: ContactPointsState = { receivers: {}, errorCount: 0 };
|
const emptyContactPointsState: ContactPointsState = { receivers: {}, errorCount: 0 };
|
||||||
useGetContactPointsStateMock.mockReturnValue(emptyContactPointsState);
|
useGetContactPointsStateMock.mockReturnValue(emptyContactPointsState);
|
||||||
useGetGrafanaReceiverTypeCheckerMock.mockReturnValue(() => undefined);
|
useReceiversMetadata.mockReturnValue(new Map<Receiver, ReceiverMetadata>());
|
||||||
});
|
});
|
||||||
|
|
||||||
it('render receivers with grafana notifiers', async () => {
|
it('render receivers with grafana notifiers', async () => {
|
||||||
|
@ -26,9 +26,8 @@ import { ProvisioningBadge } from '../Provisioning';
|
|||||||
import { ActionIcon } from '../rules/ActionIcon';
|
import { ActionIcon } from '../rules/ActionIcon';
|
||||||
|
|
||||||
import { ReceiversSection } from './ReceiversSection';
|
import { ReceiversSection } from './ReceiversSection';
|
||||||
import { GrafanaAppBadge } from './grafanaAppReceivers/GrafanaAppBadge';
|
import { ReceiverMetadataBadge } from './grafanaAppReceivers/ReceiverMetadataBadge';
|
||||||
import { useGetReceiversWithGrafanaAppTypes } from './grafanaAppReceivers/grafanaApp';
|
import { ReceiverMetadata, useReceiversMetadata } from './grafanaAppReceivers/useReceiversMetadata';
|
||||||
import { ReceiverWithTypes } from './grafanaAppReceivers/types';
|
|
||||||
import { AlertmanagerConfigHealth, useAlertmanagerConfigHealth } from './useAlertmanagerConfigHealth';
|
import { AlertmanagerConfigHealth, useAlertmanagerConfigHealth } from './useAlertmanagerConfigHealth';
|
||||||
|
|
||||||
interface UpdateActionProps extends ActionProps {
|
interface UpdateActionProps extends ActionProps {
|
||||||
@ -183,6 +182,7 @@ interface ReceiverItem {
|
|||||||
types: string[];
|
types: string[];
|
||||||
provisioned?: boolean;
|
provisioned?: boolean;
|
||||||
grafanaAppReceiverType?: SupportedPlugin;
|
grafanaAppReceiverType?: SupportedPlugin;
|
||||||
|
metadata?: ReceiverMetadata;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface NotifierStatus {
|
interface NotifierStatus {
|
||||||
@ -299,6 +299,7 @@ export const ReceiversTable = ({ config, alertManagerName }: Props) => {
|
|||||||
|
|
||||||
const configHealth = useAlertmanagerConfigHealth(config.alertmanager_config);
|
const configHealth = useAlertmanagerConfigHealth(config.alertmanager_config);
|
||||||
const { contactPointsState, errorStateAvailable } = useContactPointsState(alertManagerName);
|
const { contactPointsState, errorStateAvailable } = useContactPointsState(alertManagerName);
|
||||||
|
const receiversMetadata = useReceiversMetadata(config.alertmanager_config.receivers ?? []);
|
||||||
|
|
||||||
// receiver name slated for deletion. If this is set, a confirmation modal is shown. If user approves, this receiver is deleted
|
// receiver name slated for deletion. If this is set, a confirmation modal is shown. If user approves, this receiver is deleted
|
||||||
const [receiverToDelete, setReceiverToDelete] = useState<string>();
|
const [receiverToDelete, setReceiverToDelete] = useState<string>();
|
||||||
@ -325,10 +326,11 @@ export const ReceiversTable = ({ config, alertManagerName }: Props) => {
|
|||||||
setReceiverToDelete(undefined);
|
setReceiverToDelete(undefined);
|
||||||
};
|
};
|
||||||
|
|
||||||
const receivers = useGetReceiversWithGrafanaAppTypes(config.alertmanager_config.receivers ?? []);
|
|
||||||
const rows: RowItemTableProps[] = useMemo(() => {
|
const rows: RowItemTableProps[] = useMemo(() => {
|
||||||
|
const receivers = config.alertmanager_config.receivers ?? [];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
receivers?.map((receiver: ReceiverWithTypes) => ({
|
receivers.map((receiver) => ({
|
||||||
id: receiver.name,
|
id: receiver.name,
|
||||||
data: {
|
data: {
|
||||||
name: receiver.name,
|
name: receiver.name,
|
||||||
@ -340,12 +342,12 @@ export const ReceiversTable = ({ config, alertManagerName }: Props) => {
|
|||||||
return type;
|
return type;
|
||||||
}
|
}
|
||||||
),
|
),
|
||||||
grafanaAppReceiverType: receiver.grafanaAppReceiverType,
|
|
||||||
provisioned: receiver.grafana_managed_receiver_configs?.some((receiver) => receiver.provenance),
|
provisioned: receiver.grafana_managed_receiver_configs?.some((receiver) => receiver.provenance),
|
||||||
|
metadata: receiversMetadata.get(receiver),
|
||||||
},
|
},
|
||||||
})) ?? []
|
})) ?? []
|
||||||
);
|
);
|
||||||
}, [grafanaNotifiers.result, receivers]);
|
}, [grafanaNotifiers.result, config.alertmanager_config, receiversMetadata]);
|
||||||
|
|
||||||
const columns = useGetColumns(
|
const columns = useGetColumns(
|
||||||
alertManagerName,
|
alertManagerName,
|
||||||
@ -472,8 +474,8 @@ function useGetColumns(
|
|||||||
{
|
{
|
||||||
id: 'type',
|
id: 'type',
|
||||||
label: 'Type',
|
label: 'Type',
|
||||||
renderCell: ({ data: { types, grafanaAppReceiverType } }) => (
|
renderCell: ({ data: { types, metadata } }) => (
|
||||||
<>{grafanaAppReceiverType ? <GrafanaAppBadge grafanaAppType={grafanaAppReceiverType} /> : types.join(', ')}</>
|
<>{metadata ? <ReceiverMetadataBadge metadata={metadata} /> : types.join(', ')}</>
|
||||||
),
|
),
|
||||||
size: 2,
|
size: 2,
|
||||||
},
|
},
|
||||||
|
@ -17,6 +17,8 @@ export interface Props<R extends ChannelValues> {
|
|||||||
errors?: FieldErrors<R>;
|
errors?: FieldErrors<R>;
|
||||||
pathPrefix?: string;
|
pathPrefix?: string;
|
||||||
readOnly?: boolean;
|
readOnly?: boolean;
|
||||||
|
|
||||||
|
customValidators?: Record<string, React.ComponentProps<typeof OptionField>['customValidator']>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ChannelOptions<R extends ChannelValues>({
|
export function ChannelOptions<R extends ChannelValues>({
|
||||||
@ -27,6 +29,7 @@ export function ChannelOptions<R extends ChannelValues>({
|
|||||||
errors,
|
errors,
|
||||||
pathPrefix = '',
|
pathPrefix = '',
|
||||||
readOnly = false,
|
readOnly = false,
|
||||||
|
customValidators = {},
|
||||||
}: Props<R>): JSX.Element {
|
}: Props<R>): JSX.Element {
|
||||||
const { watch } = useFormContext<ReceiverFormValues<R>>();
|
const { watch } = useFormContext<ReceiverFormValues<R>>();
|
||||||
const currentFormValues = watch(); // react hook form types ARE LYING!
|
const currentFormValues = watch(); // react hook form types ARE LYING!
|
||||||
@ -78,6 +81,7 @@ export function ChannelOptions<R extends ChannelValues>({
|
|||||||
pathPrefix={pathPrefix}
|
pathPrefix={pathPrefix}
|
||||||
pathSuffix={option.secure ? 'secureSettings.' : 'settings.'}
|
pathSuffix={option.secure ? 'secureSettings.' : 'settings.'}
|
||||||
option={option}
|
option={option}
|
||||||
|
customValidator={customValidators[option.propertyName]}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
@ -1,21 +1,24 @@
|
|||||||
import { css } from '@emotion/css';
|
import { css } from '@emotion/css';
|
||||||
import React, { useEffect, useMemo, useState } from 'react';
|
import { sortBy } from 'lodash';
|
||||||
|
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
||||||
import { useFormContext, FieldErrors, FieldValues } from 'react-hook-form';
|
import { useFormContext, FieldErrors, FieldValues } from 'react-hook-form';
|
||||||
|
|
||||||
import { GrafanaTheme2, SelectableValue } from '@grafana/data';
|
import { GrafanaTheme2, SelectableValue } from '@grafana/data';
|
||||||
import { Alert, Button, Field, InputControl, Select, useStyles2 } from '@grafana/ui';
|
import { Alert, Button, Field, InputControl, Select, useStyles2 } from '@grafana/ui';
|
||||||
import { NotifierDTO } from 'app/types';
|
|
||||||
|
|
||||||
import { useUnifiedAlertingSelector } from '../../../hooks/useUnifiedAlertingSelector';
|
import { useUnifiedAlertingSelector } from '../../../hooks/useUnifiedAlertingSelector';
|
||||||
import { ChannelValues, CommonSettingsComponentType } from '../../../types/receiver-form';
|
import { ChannelValues, CommonSettingsComponentType } from '../../../types/receiver-form';
|
||||||
|
import { OnCallIntegrationType } from '../grafanaAppReceivers/onCall/useOnCallIntegration';
|
||||||
|
|
||||||
import { ChannelOptions } from './ChannelOptions';
|
import { ChannelOptions } from './ChannelOptions';
|
||||||
import { CollapsibleSection } from './CollapsibleSection';
|
import { CollapsibleSection } from './CollapsibleSection';
|
||||||
|
import { Notifier } from './notifiers';
|
||||||
|
|
||||||
interface Props<R extends FieldValues> {
|
interface Props<R extends FieldValues> {
|
||||||
defaultValues: R;
|
defaultValues: R;
|
||||||
|
initialValues?: R;
|
||||||
pathPrefix: string;
|
pathPrefix: string;
|
||||||
notifiers: NotifierDTO[];
|
notifiers: Notifier[];
|
||||||
onDuplicate: () => void;
|
onDuplicate: () => void;
|
||||||
onTest?: () => void;
|
onTest?: () => void;
|
||||||
commonSettingsComponent: CommonSettingsComponentType;
|
commonSettingsComponent: CommonSettingsComponentType;
|
||||||
@ -25,10 +28,13 @@ interface Props<R extends FieldValues> {
|
|||||||
onDelete?: () => void;
|
onDelete?: () => void;
|
||||||
isEditable?: boolean;
|
isEditable?: boolean;
|
||||||
isTestable?: boolean;
|
isTestable?: boolean;
|
||||||
|
|
||||||
|
customValidators?: React.ComponentProps<typeof ChannelOptions>['customValidators'];
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ChannelSubForm<R extends ChannelValues>({
|
export function ChannelSubForm<R extends ChannelValues>({
|
||||||
defaultValues,
|
defaultValues,
|
||||||
|
initialValues,
|
||||||
pathPrefix,
|
pathPrefix,
|
||||||
onDuplicate,
|
onDuplicate,
|
||||||
onDelete,
|
onDelete,
|
||||||
@ -39,13 +45,20 @@ export function ChannelSubForm<R extends ChannelValues>({
|
|||||||
commonSettingsComponent: CommonSettingsComponent,
|
commonSettingsComponent: CommonSettingsComponent,
|
||||||
isEditable = true,
|
isEditable = true,
|
||||||
isTestable,
|
isTestable,
|
||||||
|
customValidators = {},
|
||||||
}: Props<R>): JSX.Element {
|
}: Props<R>): JSX.Element {
|
||||||
const styles = useStyles2(getStyles);
|
const styles = useStyles2(getStyles);
|
||||||
const name = (fieldName: string) => `${pathPrefix}${fieldName}`;
|
|
||||||
|
const fieldName = useCallback((fieldName: string) => `${pathPrefix}${fieldName}`, [pathPrefix]);
|
||||||
|
|
||||||
const { control, watch, register, trigger, formState, setValue } = useFormContext();
|
const { control, watch, register, trigger, formState, setValue } = useFormContext();
|
||||||
const selectedType = watch(name('type')) ?? defaultValues.type; // nope, setting "default" does not work at all.
|
const selectedType = watch(fieldName('type')) ?? defaultValues.type; // nope, setting "default" does not work at all.
|
||||||
const { loading: testingReceiver } = useUnifiedAlertingSelector((state) => state.testReceivers);
|
const { loading: testingReceiver } = useUnifiedAlertingSelector((state) => state.testReceivers);
|
||||||
|
|
||||||
|
// TODO I don't like integration specific code here but other ways require a bigger refactoring
|
||||||
|
const onCallIntegrationType = watch(fieldName('settings.integration_type'));
|
||||||
|
const isTestAvailable = onCallIntegrationType !== OnCallIntegrationType.NewIntegration;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
register(`${pathPrefix}.__id`);
|
register(`${pathPrefix}.__id`);
|
||||||
/* Need to manually register secureFields or else they'll
|
/* Need to manually register secureFields or else they'll
|
||||||
@ -53,6 +66,26 @@ export function ChannelSubForm<R extends ChannelValues>({
|
|||||||
register(`${pathPrefix}.secureFields`);
|
register(`${pathPrefix}.secureFields`);
|
||||||
}, [register, pathPrefix]);
|
}, [register, pathPrefix]);
|
||||||
|
|
||||||
|
// Prevent forgetting about initial values when switching the integration type and the oncall integration type
|
||||||
|
useEffect(() => {
|
||||||
|
// Restore values when switching back from a changed integration to the default one
|
||||||
|
const subscription = watch((_, { name, type, value }) => {
|
||||||
|
if (initialValues && name === fieldName('type') && value === initialValues.type && type === 'change') {
|
||||||
|
setValue(fieldName('settings'), initialValues.settings);
|
||||||
|
}
|
||||||
|
// Restore initial value of an existing oncall integration
|
||||||
|
if (
|
||||||
|
initialValues &&
|
||||||
|
name === fieldName('settings.integration_type') &&
|
||||||
|
value === OnCallIntegrationType.ExistingIntegration
|
||||||
|
) {
|
||||||
|
setValue(fieldName('settings.url'), initialValues.settings['url']);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => subscription.unsubscribe();
|
||||||
|
}, [selectedType, initialValues, setValue, fieldName, watch]);
|
||||||
|
|
||||||
const [_secureFields, setSecureFields] = useState(secureFields ?? {});
|
const [_secureFields, setSecureFields] = useState(secureFields ?? {});
|
||||||
|
|
||||||
const onResetSecureField = (key: string) => {
|
const onResetSecureField = (key: string) => {
|
||||||
@ -66,12 +99,15 @@ export function ChannelSubForm<R extends ChannelValues>({
|
|||||||
|
|
||||||
const typeOptions = useMemo(
|
const typeOptions = useMemo(
|
||||||
(): SelectableValue[] =>
|
(): SelectableValue[] =>
|
||||||
notifiers
|
sortBy(notifiers, ({ dto, meta }) => [meta?.order ?? 0, dto.name])
|
||||||
.map(({ name, type }) => ({
|
// .notifiers.sort((a, b) => a.dto.name.localeCompare(b.dto.name))
|
||||||
|
.map<SelectableValue>(({ dto: { name, type }, meta }) => ({
|
||||||
label: name,
|
label: name,
|
||||||
value: type,
|
value: type,
|
||||||
}))
|
description: meta?.description,
|
||||||
.sort((a, b) => a.label.localeCompare(b.label)),
|
isDisabled: meta ? !meta.enabled : false,
|
||||||
|
imgUrl: meta?.iconUrl,
|
||||||
|
})),
|
||||||
[notifiers]
|
[notifiers]
|
||||||
);
|
);
|
||||||
|
|
||||||
@ -84,11 +120,11 @@ export function ChannelSubForm<R extends ChannelValues>({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const notifier = notifiers.find(({ type }) => type === selectedType);
|
const notifier = notifiers.find(({ dto: { type } }) => type === selectedType);
|
||||||
// if there are mandatory options defined, optional options will be hidden by a collapse
|
// if there are mandatory options defined, optional options will be hidden by a collapse
|
||||||
// if there aren't mandatory options, all options will be shown without collapse
|
// if there aren't mandatory options, all options will be shown without collapse
|
||||||
const mandatoryOptions = notifier?.options.filter((o) => o.required);
|
const mandatoryOptions = notifier?.dto.options.filter((o) => o.required);
|
||||||
const optionalOptions = notifier?.options.filter((o) => !o.required);
|
const optionalOptions = notifier?.dto.options.filter((o) => !o.required);
|
||||||
|
|
||||||
const contactPointTypeInputId = `contact-point-type-${pathPrefix}`;
|
const contactPointTypeInputId = `contact-point-type-${pathPrefix}`;
|
||||||
|
|
||||||
@ -98,7 +134,7 @@ export function ChannelSubForm<R extends ChannelValues>({
|
|||||||
<div>
|
<div>
|
||||||
<Field label="Integration" htmlFor={contactPointTypeInputId} data-testid={`${pathPrefix}type`}>
|
<Field label="Integration" htmlFor={contactPointTypeInputId} data-testid={`${pathPrefix}type`}>
|
||||||
<InputControl
|
<InputControl
|
||||||
name={name('type')}
|
name={fieldName('type')}
|
||||||
defaultValue={defaultValues.type}
|
defaultValue={defaultValues.type}
|
||||||
render={({ field: { ref, onChange, ...field } }) => (
|
render={({ field: { ref, onChange, ...field } }) => (
|
||||||
<Select
|
<Select
|
||||||
@ -116,7 +152,7 @@ export function ChannelSubForm<R extends ChannelValues>({
|
|||||||
</Field>
|
</Field>
|
||||||
</div>
|
</div>
|
||||||
<div className={styles.buttons}>
|
<div className={styles.buttons}>
|
||||||
{isTestable && onTest && (
|
{isTestable && onTest && isTestAvailable && (
|
||||||
<Button
|
<Button
|
||||||
disabled={testingReceiver}
|
disabled={testingReceiver}
|
||||||
size="xs"
|
size="xs"
|
||||||
@ -159,12 +195,13 @@ export function ChannelSubForm<R extends ChannelValues>({
|
|||||||
onResetSecureField={onResetSecureField}
|
onResetSecureField={onResetSecureField}
|
||||||
pathPrefix={pathPrefix}
|
pathPrefix={pathPrefix}
|
||||||
readOnly={!isEditable}
|
readOnly={!isEditable}
|
||||||
|
customValidators={customValidators}
|
||||||
/>
|
/>
|
||||||
{!!(mandatoryOptions?.length && optionalOptions?.length) && (
|
{!!(mandatoryOptions?.length && optionalOptions?.length) && (
|
||||||
<CollapsibleSection label={`Optional ${notifier.name} settings`}>
|
<CollapsibleSection label={`Optional ${notifier.dto.name} settings`}>
|
||||||
{notifier.info !== '' && (
|
{notifier.dto.info !== '' && (
|
||||||
<Alert title="" severity="info">
|
<Alert title="" severity="info">
|
||||||
{notifier.info}
|
{notifier.dto.info}
|
||||||
</Alert>
|
</Alert>
|
||||||
)}
|
)}
|
||||||
<ChannelOptions<R>
|
<ChannelOptions<R>
|
||||||
@ -175,6 +212,7 @@ export function ChannelSubForm<R extends ChannelValues>({
|
|||||||
errors={errors}
|
errors={errors}
|
||||||
pathPrefix={pathPrefix}
|
pathPrefix={pathPrefix}
|
||||||
readOnly={!isEditable}
|
readOnly={!isEditable}
|
||||||
|
customValidators={customValidators}
|
||||||
/>
|
/>
|
||||||
</CollapsibleSection>
|
</CollapsibleSection>
|
||||||
)}
|
)}
|
||||||
|
@ -16,6 +16,7 @@ import {
|
|||||||
|
|
||||||
import { CloudCommonChannelSettings } from './CloudCommonChannelSettings';
|
import { CloudCommonChannelSettings } from './CloudCommonChannelSettings';
|
||||||
import { ReceiverForm } from './ReceiverForm';
|
import { ReceiverForm } from './ReceiverForm';
|
||||||
|
import { Notifier } from './notifiers';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
alertManagerSourceName: string;
|
alertManagerSourceName: string;
|
||||||
@ -32,6 +33,8 @@ const defaultChannelValues: CloudChannelValues = Object.freeze({
|
|||||||
type: 'email',
|
type: 'email',
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const cloudNotifiers = cloudNotifierTypes.map<Notifier>((n) => ({ dto: n }));
|
||||||
|
|
||||||
export const CloudReceiverForm = ({ existing, alertManagerSourceName, config }: Props) => {
|
export const CloudReceiverForm = ({ existing, alertManagerSourceName, config }: Props) => {
|
||||||
const dispatch = useDispatch();
|
const dispatch = useDispatch();
|
||||||
const isVanillaAM = isVanillaPrometheusAlertManagerDataSource(alertManagerSourceName);
|
const isVanillaAM = isVanillaPrometheusAlertManagerDataSource(alertManagerSourceName);
|
||||||
@ -44,9 +47,9 @@ export const CloudReceiverForm = ({ existing, alertManagerSourceName, config }:
|
|||||||
return cloudReceiverToFormValues(existing, cloudNotifierTypes);
|
return cloudReceiverToFormValues(existing, cloudNotifierTypes);
|
||||||
}, [existing]);
|
}, [existing]);
|
||||||
|
|
||||||
const onSubmit = (values: ReceiverFormValues<CloudChannelValues>) => {
|
const onSubmit = async (values: ReceiverFormValues<CloudChannelValues>) => {
|
||||||
const newReceiver = formValuesToCloudReceiver(values, defaultChannelValues);
|
const newReceiver = formValuesToCloudReceiver(values, defaultChannelValues);
|
||||||
dispatch(
|
await dispatch(
|
||||||
updateAlertManagerConfigAction({
|
updateAlertManagerConfigAction({
|
||||||
newConfig: updateConfigWithReceiver(config, newReceiver, existing?.name),
|
newConfig: updateConfigWithReceiver(config, newReceiver, existing?.name),
|
||||||
oldConfig: config,
|
oldConfig: config,
|
||||||
@ -79,7 +82,7 @@ export const CloudReceiverForm = ({ existing, alertManagerSourceName, config }:
|
|||||||
config={config}
|
config={config}
|
||||||
onSubmit={onSubmit}
|
onSubmit={onSubmit}
|
||||||
initialValues={existingValue}
|
initialValues={existingValue}
|
||||||
notifiers={cloudNotifierTypes}
|
notifiers={cloudNotifiers}
|
||||||
alertManagerSourceName={alertManagerSourceName}
|
alertManagerSourceName={alertManagerSourceName}
|
||||||
defaultItem={defaultChannelValues}
|
defaultItem={defaultChannelValues}
|
||||||
takenReceiverNames={takenReceiverNames}
|
takenReceiverNames={takenReceiverNames}
|
||||||
|
@ -0,0 +1,157 @@
|
|||||||
|
import { render, waitFor } from '@testing-library/react';
|
||||||
|
import userEvent from '@testing-library/user-event';
|
||||||
|
import React from 'react';
|
||||||
|
import { TestProvider } from 'test/helpers/TestProvider';
|
||||||
|
import { clickSelectOption } from 'test/helpers/selectOptionInTest';
|
||||||
|
import { byLabelText, byRole, byTestId, byText } from 'testing-library-selector';
|
||||||
|
|
||||||
|
import { clearPluginSettingsCache } from 'app/features/plugins/pluginSettings';
|
||||||
|
import { AlertManagerCortexConfig } from 'app/plugins/datasource/alertmanager/types';
|
||||||
|
|
||||||
|
import { ONCALL_INTEGRATION_V2_FEATURE } from '../../../api/onCallApi';
|
||||||
|
import { AlertmanagerConfigBuilder, mockApi, setupMswServer } from '../../../mockApi';
|
||||||
|
import { grafanaAlertNotifiersMock } from '../../../mockGrafanaNotifiers';
|
||||||
|
import { onCallPluginMetaMock } from '../../../mocks';
|
||||||
|
import { GRAFANA_RULES_SOURCE_NAME } from '../../../utils/datasource';
|
||||||
|
|
||||||
|
import { GrafanaReceiverForm } from './GrafanaReceiverForm';
|
||||||
|
|
||||||
|
import 'core-js/stable/structured-clone';
|
||||||
|
|
||||||
|
const server = setupMswServer();
|
||||||
|
|
||||||
|
const ui = {
|
||||||
|
loadingIndicator: byText('Loading notifiers...'),
|
||||||
|
integrationType: byLabelText('Integration'),
|
||||||
|
onCallIntegrationType: byRole('radiogroup'),
|
||||||
|
integrationOption: {
|
||||||
|
new: byRole('radio', {
|
||||||
|
name: 'A new OnCall integration without escalation chains will be automatically created',
|
||||||
|
}),
|
||||||
|
existing: byRole('radio', { name: 'Use an existing OnCall integration' }),
|
||||||
|
},
|
||||||
|
newOnCallIntegrationName: byRole('textbox', { name: /Integration name/ }),
|
||||||
|
existingOnCallIntegrationSelect: (index: number) => byTestId(`items.${index}.settings.url`),
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('GrafanaReceiverForm', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
server.resetHandlers();
|
||||||
|
clearPluginSettingsCache();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('OnCall contact point', () => {
|
||||||
|
it('OnCall contact point should be disabled if OnCall integration is not enabled', async () => {
|
||||||
|
mockApi(server).grafanaNotifiers(grafanaAlertNotifiersMock);
|
||||||
|
mockApi(server).plugins.getPluginSettings({ ...onCallPluginMetaMock, enabled: false });
|
||||||
|
|
||||||
|
const amConfig = getAmCortexConfig((_) => {});
|
||||||
|
|
||||||
|
render(<GrafanaReceiverForm alertManagerSourceName={GRAFANA_RULES_SOURCE_NAME} config={amConfig} />, {
|
||||||
|
wrapper: TestProvider,
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitFor(() => expect(ui.loadingIndicator.query()).not.toBeInTheDocument());
|
||||||
|
|
||||||
|
await clickSelectOption(byTestId('items.0.type').get(), 'Grafana OnCall');
|
||||||
|
// Clicking on a disable element shouldn't change the form value. email is the default value
|
||||||
|
expect(ui.integrationType.get().closest('form')).toHaveFormValues({ 'items.0.type': 'email' });
|
||||||
|
|
||||||
|
await clickSelectOption(byTestId('items.0.type').get(), 'Alertmanager');
|
||||||
|
expect(ui.integrationType.get().closest('form')).toHaveFormValues({ 'items.0.type': 'prometheus-alertmanager' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('OnCall contact point should support new and existing integration options if OnCall integration V2 is enabled', async () => {
|
||||||
|
mockApi(server).grafanaNotifiers(grafanaAlertNotifiersMock);
|
||||||
|
mockApi(server).plugins.getPluginSettings({ ...onCallPluginMetaMock, enabled: true });
|
||||||
|
mockApi(server).oncall.features([ONCALL_INTEGRATION_V2_FEATURE]);
|
||||||
|
mockApi(server).oncall.getOnCallIntegrations([
|
||||||
|
{ display_name: 'nasa-oncall', value: 'nasa-oncall', integration_url: 'https://nasa.oncall.example.com' },
|
||||||
|
{ display_name: 'apac-oncall', value: 'apac-oncall', integration_url: 'https://apac.oncall.example.com' },
|
||||||
|
]);
|
||||||
|
|
||||||
|
const amConfig = getAmCortexConfig((_) => {});
|
||||||
|
|
||||||
|
const user = userEvent.setup();
|
||||||
|
|
||||||
|
render(<GrafanaReceiverForm alertManagerSourceName={GRAFANA_RULES_SOURCE_NAME} config={amConfig} />, {
|
||||||
|
wrapper: TestProvider,
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitFor(() => expect(ui.loadingIndicator.query()).not.toBeInTheDocument());
|
||||||
|
|
||||||
|
await clickSelectOption(byTestId('items.0.type').get(), 'Grafana OnCall');
|
||||||
|
|
||||||
|
expect(ui.integrationType.get().closest('form')).toHaveFormValues({ 'items.0.type': 'oncall' });
|
||||||
|
expect(ui.onCallIntegrationType.get()).toBeInTheDocument();
|
||||||
|
|
||||||
|
const newIntegrationRadio = ui.integrationOption.new;
|
||||||
|
const existingIntegrationRadio = ui.integrationOption.existing;
|
||||||
|
|
||||||
|
expect(newIntegrationRadio.get()).toBeInTheDocument();
|
||||||
|
expect(existingIntegrationRadio.get()).toBeInTheDocument();
|
||||||
|
|
||||||
|
await user.click(newIntegrationRadio.get());
|
||||||
|
expect(newIntegrationRadio.get()).toBeChecked();
|
||||||
|
|
||||||
|
await user.type(ui.newOnCallIntegrationName.get(), 'emea-oncall');
|
||||||
|
|
||||||
|
expect(ui.integrationType.get().closest('form')).toHaveFormValues({
|
||||||
|
'items.0.settings.integration_type': 'new_oncall_integration',
|
||||||
|
'items.0.settings.integration_name': 'emea-oncall',
|
||||||
|
'items.0.settings.url_name': undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
await user.click(existingIntegrationRadio.get());
|
||||||
|
expect(existingIntegrationRadio.get()).toBeChecked();
|
||||||
|
|
||||||
|
await clickSelectOption(ui.existingOnCallIntegrationSelect(0).get(), 'apac-oncall');
|
||||||
|
|
||||||
|
expect(ui.integrationType.get().closest('form')).toHaveFormValues({
|
||||||
|
'items.0.settings.url': 'https://apac.oncall.example.com',
|
||||||
|
'items.0.settings.integration_name': undefined,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Should render URL text input field for OnCall concact point if OnCall plugin uses legacy integration', async () => {
|
||||||
|
mockApi(server).grafanaNotifiers(grafanaAlertNotifiersMock);
|
||||||
|
mockApi(server).plugins.getPluginSettings({ ...onCallPluginMetaMock, enabled: true });
|
||||||
|
mockApi(server).oncall.features([]);
|
||||||
|
mockApi(server).oncall.getOnCallIntegrations([]);
|
||||||
|
|
||||||
|
const amConfig = getAmCortexConfig((config) =>
|
||||||
|
config.addReceivers((receiver) =>
|
||||||
|
receiver.addGrafanaReceiverConfig((receiverConfig) =>
|
||||||
|
receiverConfig.withType('oncall').withName('emea-oncall').addSetting('url', 'https://oncall.example.com')
|
||||||
|
)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
render(
|
||||||
|
<GrafanaReceiverForm
|
||||||
|
alertManagerSourceName={GRAFANA_RULES_SOURCE_NAME}
|
||||||
|
config={amConfig}
|
||||||
|
existing={amConfig.alertmanager_config.receivers![0]}
|
||||||
|
/>,
|
||||||
|
{
|
||||||
|
wrapper: TestProvider,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
await waitFor(() => expect(ui.loadingIndicator.query()).not.toBeInTheDocument());
|
||||||
|
|
||||||
|
expect(byTestId('items.0.type').get()).toHaveTextContent('Grafana OnCall');
|
||||||
|
expect(byLabelText('URL').get()).toHaveValue('https://oncall.example.com');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
function getAmCortexConfig(configure: (builder: AlertmanagerConfigBuilder) => void): AlertManagerCortexConfig {
|
||||||
|
const configBuilder = new AlertmanagerConfigBuilder();
|
||||||
|
configure(configBuilder);
|
||||||
|
|
||||||
|
return {
|
||||||
|
alertmanager_config: configBuilder.build(),
|
||||||
|
template_files: {},
|
||||||
|
};
|
||||||
|
}
|
@ -1,6 +1,6 @@
|
|||||||
import React, { useEffect, useMemo, useState } from 'react';
|
import React, { useMemo, useState } from 'react';
|
||||||
|
|
||||||
import { LoadingPlaceholder } from '@grafana/ui';
|
import { Alert, LoadingPlaceholder } from '@grafana/ui';
|
||||||
import {
|
import {
|
||||||
AlertManagerCortexConfig,
|
AlertManagerCortexConfig,
|
||||||
GrafanaManagedContactPoint,
|
GrafanaManagedContactPoint,
|
||||||
@ -9,12 +9,8 @@ import {
|
|||||||
} from 'app/plugins/datasource/alertmanager/types';
|
} from 'app/plugins/datasource/alertmanager/types';
|
||||||
import { useDispatch } from 'app/types';
|
import { useDispatch } from 'app/types';
|
||||||
|
|
||||||
import { useUnifiedAlertingSelector } from '../../../hooks/useUnifiedAlertingSelector';
|
import { alertmanagerApi } from '../../../api/alertmanagerApi';
|
||||||
import {
|
import { testReceiversAction, updateAlertManagerConfigAction } from '../../../state/actions';
|
||||||
fetchGrafanaNotifiersAction,
|
|
||||||
testReceiversAction,
|
|
||||||
updateAlertManagerConfigAction,
|
|
||||||
} from '../../../state/actions';
|
|
||||||
import { GrafanaChannelValues, ReceiverFormValues } from '../../../types/receiver-form';
|
import { GrafanaChannelValues, ReceiverFormValues } from '../../../types/receiver-form';
|
||||||
import { GRAFANA_RULES_SOURCE_NAME, isVanillaPrometheusAlertManagerDataSource } from '../../../utils/datasource';
|
import { GRAFANA_RULES_SOURCE_NAME, isVanillaPrometheusAlertManagerDataSource } from '../../../utils/datasource';
|
||||||
import {
|
import {
|
||||||
@ -24,10 +20,12 @@ import {
|
|||||||
updateConfigWithReceiver,
|
updateConfigWithReceiver,
|
||||||
} from '../../../utils/receiver-form';
|
} from '../../../utils/receiver-form';
|
||||||
import { ProvisionedResource, ProvisioningAlert } from '../../Provisioning';
|
import { ProvisionedResource, ProvisioningAlert } from '../../Provisioning';
|
||||||
|
import { useOnCallIntegration } from '../grafanaAppReceivers/onCall/useOnCallIntegration';
|
||||||
|
|
||||||
import { GrafanaCommonChannelSettings } from './GrafanaCommonChannelSettings';
|
import { GrafanaCommonChannelSettings } from './GrafanaCommonChannelSettings';
|
||||||
import { ReceiverForm } from './ReceiverForm';
|
import { ReceiverForm } from './ReceiverForm';
|
||||||
import { TestContactPointModal } from './TestContactPointModal';
|
import { TestContactPointModal } from './TestContactPointModal';
|
||||||
|
import { Notifier } from './notifiers';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
alertManagerSourceName: string;
|
alertManagerSourceName: string;
|
||||||
@ -45,35 +43,43 @@ const defaultChannelValues: GrafanaChannelValues = Object.freeze({
|
|||||||
});
|
});
|
||||||
|
|
||||||
export const GrafanaReceiverForm = ({ existing, alertManagerSourceName, config }: Props) => {
|
export const GrafanaReceiverForm = ({ existing, alertManagerSourceName, config }: Props) => {
|
||||||
const grafanaNotifiers = useUnifiedAlertingSelector((state) => state.grafanaNotifiers);
|
|
||||||
const [testChannelValues, setTestChannelValues] = useState<GrafanaChannelValues>();
|
|
||||||
|
|
||||||
const dispatch = useDispatch();
|
const dispatch = useDispatch();
|
||||||
|
|
||||||
useEffect(() => {
|
const {
|
||||||
if (!(grafanaNotifiers.result || grafanaNotifiers.loading)) {
|
onCallNotifierMeta,
|
||||||
dispatch(fetchGrafanaNotifiersAction());
|
extendOnCallNotifierFeatures,
|
||||||
}
|
extendOnCallReceivers,
|
||||||
}, [grafanaNotifiers, dispatch]);
|
onCallFormValidators,
|
||||||
|
createOnCallIntegrations,
|
||||||
|
isLoadingOnCallIntegration,
|
||||||
|
hasOnCallError,
|
||||||
|
} = useOnCallIntegration();
|
||||||
|
|
||||||
|
const { useGrafanaNotifiersQuery } = alertmanagerApi;
|
||||||
|
const { data: grafanaNotifiers = [], isLoading: isLoadingNotifiers } = useGrafanaNotifiersQuery();
|
||||||
|
|
||||||
|
const [testChannelValues, setTestChannelValues] = useState<GrafanaChannelValues>();
|
||||||
|
|
||||||
// transform receiver DTO to form values
|
// transform receiver DTO to form values
|
||||||
const [existingValue, id2original] = useMemo((): [
|
const [existingValue, id2original] = useMemo((): [
|
||||||
ReceiverFormValues<GrafanaChannelValues> | undefined,
|
ReceiverFormValues<GrafanaChannelValues> | undefined,
|
||||||
Record<string, GrafanaManagedReceiverConfig>,
|
Record<string, GrafanaManagedReceiverConfig>,
|
||||||
] => {
|
] => {
|
||||||
if (!existing || !grafanaNotifiers.result) {
|
if (!existing || isLoadingNotifiers || isLoadingOnCallIntegration) {
|
||||||
return [undefined, {}];
|
return [undefined, {}];
|
||||||
}
|
}
|
||||||
return grafanaReceiverToFormValues(existing, grafanaNotifiers.result!);
|
|
||||||
}, [existing, grafanaNotifiers.result]);
|
|
||||||
|
|
||||||
const onSubmit = (values: ReceiverFormValues<GrafanaChannelValues>) => {
|
return grafanaReceiverToFormValues(extendOnCallReceivers(existing), grafanaNotifiers);
|
||||||
const notifiers = grafanaNotifiers.result;
|
}, [existing, isLoadingNotifiers, grafanaNotifiers, extendOnCallReceivers, isLoadingOnCallIntegration]);
|
||||||
|
|
||||||
const newReceiver = formValuesToGrafanaReceiver(values, id2original, defaultChannelValues, notifiers ?? []);
|
const onSubmit = async (values: ReceiverFormValues<GrafanaChannelValues>) => {
|
||||||
dispatch(
|
const newReceiver = formValuesToGrafanaReceiver(values, id2original, defaultChannelValues, grafanaNotifiers);
|
||||||
|
const receiverWithOnCall = await createOnCallIntegrations(newReceiver);
|
||||||
|
|
||||||
|
const newConfig = updateConfigWithReceiver(config, receiverWithOnCall, existing?.name);
|
||||||
|
await dispatch(
|
||||||
updateAlertManagerConfigAction({
|
updateAlertManagerConfigAction({
|
||||||
newConfig: updateConfigWithReceiver(config, newReceiver, existing?.name),
|
newConfig: newConfig,
|
||||||
oldConfig: config,
|
oldConfig: config,
|
||||||
alertManagerSourceName: GRAFANA_RULES_SOURCE_NAME,
|
alertManagerSourceName: GRAFANA_RULES_SOURCE_NAME,
|
||||||
successMessage: existing ? 'Contact point updated.' : 'Contact point created',
|
successMessage: existing ? 'Contact point updated.' : 'Contact point created',
|
||||||
@ -123,9 +129,30 @@ export const GrafanaReceiverForm = ({ existing, alertManagerSourceName, config }
|
|||||||
const isEditable = isManageableAlertManagerDataSource && !hasProvisionedItems;
|
const isEditable = isManageableAlertManagerDataSource && !hasProvisionedItems;
|
||||||
const isTestable = isManageableAlertManagerDataSource || hasProvisionedItems;
|
const isTestable = isManageableAlertManagerDataSource || hasProvisionedItems;
|
||||||
|
|
||||||
if (grafanaNotifiers.result) {
|
if (isLoadingNotifiers || isLoadingOnCallIntegration) {
|
||||||
|
return <LoadingPlaceholder text="Loading notifiers..." />;
|
||||||
|
}
|
||||||
|
|
||||||
|
const notifiers: Notifier[] = grafanaNotifiers.map((n) => {
|
||||||
|
if (n.type === 'oncall') {
|
||||||
|
return {
|
||||||
|
dto: extendOnCallNotifierFeatures(n),
|
||||||
|
meta: onCallNotifierMeta,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return { dto: n };
|
||||||
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
{hasOnCallError && (
|
||||||
|
<Alert severity="error" title="Loading OnCall integration failed">
|
||||||
|
Grafana OnCall plugin has been enabled in your Grafana instances but it is not reachable. Please check the
|
||||||
|
plugin configuration
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
{hasProvisionedItems && <ProvisioningAlert resource={ProvisionedResource.ContactPoint} />}
|
{hasProvisionedItems && <ProvisioningAlert resource={ProvisionedResource.ContactPoint} />}
|
||||||
|
|
||||||
<ReceiverForm<GrafanaChannelValues>
|
<ReceiverForm<GrafanaChannelValues>
|
||||||
@ -135,11 +162,12 @@ export const GrafanaReceiverForm = ({ existing, alertManagerSourceName, config }
|
|||||||
onSubmit={onSubmit}
|
onSubmit={onSubmit}
|
||||||
initialValues={existingValue}
|
initialValues={existingValue}
|
||||||
onTestChannel={onTestChannel}
|
onTestChannel={onTestChannel}
|
||||||
notifiers={grafanaNotifiers.result}
|
notifiers={notifiers}
|
||||||
alertManagerSourceName={alertManagerSourceName}
|
alertManagerSourceName={alertManagerSourceName}
|
||||||
defaultItem={defaultChannelValues}
|
defaultItem={{ ...defaultChannelValues }}
|
||||||
takenReceiverNames={takenReceiverNames}
|
takenReceiverNames={takenReceiverNames}
|
||||||
commonSettingsComponent={GrafanaCommonChannelSettings}
|
commonSettingsComponent={GrafanaCommonChannelSettings}
|
||||||
|
customValidators={onCallFormValidators}
|
||||||
/>
|
/>
|
||||||
<TestContactPointModal
|
<TestContactPointModal
|
||||||
onDismiss={() => setTestChannelValues(undefined)}
|
onDismiss={() => setTestChannelValues(undefined)}
|
||||||
@ -148,7 +176,4 @@ export const GrafanaReceiverForm = ({ existing, alertManagerSourceName, config }
|
|||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
} else {
|
|
||||||
return <LoadingPlaceholder text="Loading notifiers..." />;
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
@ -1,36 +1,40 @@
|
|||||||
import { css } from '@emotion/css';
|
import { css } from '@emotion/css';
|
||||||
import React, { useCallback } from 'react';
|
import React, { useCallback } from 'react';
|
||||||
import { FieldErrors, FormProvider, useForm, Validate } from 'react-hook-form';
|
import { FieldErrors, FormProvider, SubmitErrorHandler, useForm, Validate } from 'react-hook-form';
|
||||||
|
|
||||||
import { GrafanaTheme2 } from '@grafana/data';
|
import { GrafanaTheme2 } from '@grafana/data';
|
||||||
|
import { isFetchError } from '@grafana/runtime';
|
||||||
import { Alert, Button, Field, Input, LinkButton, useStyles2 } from '@grafana/ui';
|
import { Alert, Button, Field, Input, LinkButton, useStyles2 } from '@grafana/ui';
|
||||||
import { useAppNotification } from 'app/core/copy/appNotification';
|
import { useAppNotification } from 'app/core/copy/appNotification';
|
||||||
import { useCleanup } from 'app/core/hooks/useCleanup';
|
import { useCleanup } from 'app/core/hooks/useCleanup';
|
||||||
import { AlertManagerCortexConfig } from 'app/plugins/datasource/alertmanager/types';
|
import { AlertManagerCortexConfig } from 'app/plugins/datasource/alertmanager/types';
|
||||||
import { NotifierDTO } from 'app/types';
|
|
||||||
|
|
||||||
|
import { getMessageFromError } from '../../../../../../core/utils/errors';
|
||||||
|
import { logAlertingError } from '../../../Analytics';
|
||||||
|
import { isOnCallFetchError } from '../../../api/onCallApi';
|
||||||
import { useControlledFieldArray } from '../../../hooks/useControlledFieldArray';
|
import { useControlledFieldArray } from '../../../hooks/useControlledFieldArray';
|
||||||
import { useUnifiedAlertingSelector } from '../../../hooks/useUnifiedAlertingSelector';
|
|
||||||
import { ChannelValues, CommonSettingsComponentType, ReceiverFormValues } from '../../../types/receiver-form';
|
import { ChannelValues, CommonSettingsComponentType, ReceiverFormValues } from '../../../types/receiver-form';
|
||||||
import { makeAMLink } from '../../../utils/misc';
|
import { makeAMLink } from '../../../utils/misc';
|
||||||
import { initialAsyncRequestState } from '../../../utils/redux';
|
import { initialAsyncRequestState } from '../../../utils/redux';
|
||||||
|
|
||||||
import { ChannelSubForm } from './ChannelSubForm';
|
import { ChannelSubForm } from './ChannelSubForm';
|
||||||
import { DeletedSubForm } from './fields/DeletedSubform';
|
import { DeletedSubForm } from './fields/DeletedSubform';
|
||||||
|
import { Notifier } from './notifiers';
|
||||||
import { normalizeFormValues } from './util';
|
import { normalizeFormValues } from './util';
|
||||||
|
|
||||||
interface Props<R extends ChannelValues> {
|
interface Props<R extends ChannelValues> {
|
||||||
config: AlertManagerCortexConfig;
|
config: AlertManagerCortexConfig;
|
||||||
notifiers: NotifierDTO[];
|
notifiers: Notifier[];
|
||||||
defaultItem: R;
|
defaultItem: R;
|
||||||
alertManagerSourceName: string;
|
alertManagerSourceName: string;
|
||||||
onTestChannel?: (channel: R) => void;
|
onTestChannel?: (channel: R) => void;
|
||||||
onSubmit: (values: ReceiverFormValues<R>) => void;
|
onSubmit: (values: ReceiverFormValues<R>) => Promise<void>;
|
||||||
takenReceiverNames: string[]; // will validate that user entered receiver name is not one of these
|
takenReceiverNames: string[]; // will validate that user entered receiver name is not one of these
|
||||||
commonSettingsComponent: CommonSettingsComponentType;
|
commonSettingsComponent: CommonSettingsComponentType;
|
||||||
initialValues?: ReceiverFormValues<R>;
|
initialValues?: ReceiverFormValues<R>;
|
||||||
isEditable: boolean;
|
isEditable: boolean;
|
||||||
isTestable?: boolean;
|
isTestable?: boolean;
|
||||||
|
customValidators?: React.ComponentProps<typeof ChannelSubForm>['customValidators'];
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ReceiverForm<R extends ChannelValues>({
|
export function ReceiverForm<R extends ChannelValues>({
|
||||||
@ -45,6 +49,7 @@ export function ReceiverForm<R extends ChannelValues>({
|
|||||||
commonSettingsComponent,
|
commonSettingsComponent,
|
||||||
isEditable,
|
isEditable,
|
||||||
isTestable,
|
isTestable,
|
||||||
|
customValidators,
|
||||||
}: Props<R>): JSX.Element {
|
}: Props<R>): JSX.Element {
|
||||||
const notifyApp = useAppNotification();
|
const notifyApp = useAppNotification();
|
||||||
const styles = useStyles2(getStyles);
|
const styles = useStyles2(getStyles);
|
||||||
@ -64,17 +69,15 @@ export function ReceiverForm<R extends ChannelValues>({
|
|||||||
|
|
||||||
const formAPI = useForm<ReceiverFormValues<R>>({
|
const formAPI = useForm<ReceiverFormValues<R>>({
|
||||||
// making a copy here beacuse react-hook-form will mutate these, and break if the object is frozen. for real.
|
// making a copy here beacuse react-hook-form will mutate these, and break if the object is frozen. for real.
|
||||||
defaultValues: JSON.parse(JSON.stringify(defaultValues)),
|
defaultValues: structuredClone(defaultValues),
|
||||||
});
|
});
|
||||||
|
|
||||||
useCleanup((state) => (state.unifiedAlerting.saveAMConfig = initialAsyncRequestState));
|
useCleanup((state) => (state.unifiedAlerting.saveAMConfig = initialAsyncRequestState));
|
||||||
|
|
||||||
const { loading } = useUnifiedAlertingSelector((state) => state.saveAMConfig);
|
|
||||||
|
|
||||||
const {
|
const {
|
||||||
handleSubmit,
|
handleSubmit,
|
||||||
register,
|
register,
|
||||||
formState: { errors },
|
formState: { errors, isSubmitting },
|
||||||
getValues,
|
getValues,
|
||||||
} = formAPI;
|
} = formAPI;
|
||||||
|
|
||||||
@ -88,14 +91,25 @@ export function ReceiverForm<R extends ChannelValues>({
|
|||||||
[takenReceiverNames]
|
[takenReceiverNames]
|
||||||
);
|
);
|
||||||
|
|
||||||
const submitCallback = (values: ReceiverFormValues<R>) => {
|
const submitCallback = async (values: ReceiverFormValues<R>) => {
|
||||||
onSubmit({
|
try {
|
||||||
|
await onSubmit({
|
||||||
...values,
|
...values,
|
||||||
items: values.items.filter((item) => !item.__deleted),
|
items: values.items.filter((item) => !item.__deleted),
|
||||||
});
|
});
|
||||||
|
} catch (e) {
|
||||||
|
if (e instanceof Error || isFetchError(e)) {
|
||||||
|
notifyApp.error('Failed to save the contact point', getErrorMessage(e));
|
||||||
|
|
||||||
|
const error = new Error('Failed to save the contact point');
|
||||||
|
error.cause = e;
|
||||||
|
logAlertingError(error);
|
||||||
|
}
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const onInvalid = () => {
|
const onInvalid: SubmitErrorHandler<ReceiverFormValues<R>> = () => {
|
||||||
notifyApp.error('There are errors in the form. Please correct them and try again!');
|
notifyApp.error('There are errors in the form. Please correct them and try again!');
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -131,6 +145,7 @@ export function ReceiverForm<R extends ChannelValues>({
|
|||||||
return (
|
return (
|
||||||
<ChannelSubForm<R>
|
<ChannelSubForm<R>
|
||||||
defaultValues={field}
|
defaultValues={field}
|
||||||
|
initialValues={initialItem}
|
||||||
key={field.__id}
|
key={field.__id}
|
||||||
onDuplicate={() => {
|
onDuplicate={() => {
|
||||||
const currentValues: R = getValues().items[index];
|
const currentValues: R = getValues().items[index];
|
||||||
@ -152,6 +167,7 @@ export function ReceiverForm<R extends ChannelValues>({
|
|||||||
commonSettingsComponent={commonSettingsComponent}
|
commonSettingsComponent={commonSettingsComponent}
|
||||||
isEditable={isEditable}
|
isEditable={isEditable}
|
||||||
isTestable={isTestable}
|
isTestable={isTestable}
|
||||||
|
customValidators={customValidators}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
@ -169,16 +185,16 @@ export function ReceiverForm<R extends ChannelValues>({
|
|||||||
<div className={styles.buttons}>
|
<div className={styles.buttons}>
|
||||||
{isEditable && (
|
{isEditable && (
|
||||||
<>
|
<>
|
||||||
{loading && (
|
{isSubmitting && (
|
||||||
<Button disabled={true} icon="fa fa-spinner" variant="primary">
|
<Button disabled={true} icon="fa fa-spinner" variant="primary">
|
||||||
Saving...
|
Saving...
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
{!loading && <Button type="submit">Save contact point</Button>}
|
{!isSubmitting && <Button type="submit">Save contact point</Button>}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
<LinkButton
|
<LinkButton
|
||||||
disabled={loading}
|
disabled={isSubmitting}
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
data-testid="cancel-button"
|
data-testid="cancel-button"
|
||||||
href={makeAMLink('alerting/notifications', alertManagerSourceName)}
|
href={makeAMLink('alerting/notifications', alertManagerSourceName)}
|
||||||
@ -204,3 +220,11 @@ const getStyles = (theme: GrafanaTheme2) => ({
|
|||||||
}
|
}
|
||||||
`,
|
`,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
function getErrorMessage(error: unknown) {
|
||||||
|
if (isOnCallFetchError(error)) {
|
||||||
|
return error.data.detail;
|
||||||
|
}
|
||||||
|
|
||||||
|
return getMessageFromError(error);
|
||||||
|
}
|
||||||
|
@ -3,7 +3,8 @@ import { isEmpty } from 'lodash';
|
|||||||
import React, { FC, useEffect } from 'react';
|
import React, { FC, useEffect } from 'react';
|
||||||
import { useFormContext, FieldError, DeepMap } from 'react-hook-form';
|
import { useFormContext, FieldError, DeepMap } from 'react-hook-form';
|
||||||
|
|
||||||
import { Checkbox, Field, Input, InputControl, Select, TextArea } from '@grafana/ui';
|
import { GrafanaTheme2 } from '@grafana/data';
|
||||||
|
import { Checkbox, Field, Input, InputControl, RadioButtonList, Select, TextArea, useStyles2 } from '@grafana/ui';
|
||||||
import { NotificationChannelOption } from 'app/types';
|
import { NotificationChannelOption } from 'app/types';
|
||||||
|
|
||||||
import { KeyValueMapInput } from './KeyValueMapInput';
|
import { KeyValueMapInput } from './KeyValueMapInput';
|
||||||
@ -19,6 +20,7 @@ interface Props {
|
|||||||
pathSuffix?: string;
|
pathSuffix?: string;
|
||||||
error?: FieldError | DeepMap<any, FieldError>;
|
error?: FieldError | DeepMap<any, FieldError>;
|
||||||
readOnly?: boolean;
|
readOnly?: boolean;
|
||||||
|
customValidator?: (value: string) => boolean | string | Promise<boolean | string>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const OptionField: FC<Props> = ({
|
export const OptionField: FC<Props> = ({
|
||||||
@ -29,6 +31,7 @@ export const OptionField: FC<Props> = ({
|
|||||||
error,
|
error,
|
||||||
defaultValue,
|
defaultValue,
|
||||||
readOnly = false,
|
readOnly = false,
|
||||||
|
customValidator,
|
||||||
}) => {
|
}) => {
|
||||||
const optionPath = `${pathPrefix}${pathSuffix}`;
|
const optionPath = `${pathPrefix}${pathSuffix}`;
|
||||||
|
|
||||||
@ -56,10 +59,11 @@ export const OptionField: FC<Props> = ({
|
|||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
<Field
|
<Field
|
||||||
label={option.element !== 'checkbox' ? option.label : undefined}
|
label={option.element !== 'checkbox' && option.element !== 'radio' ? option.label : undefined}
|
||||||
description={option.description || undefined}
|
description={option.description || undefined}
|
||||||
invalid={!!error}
|
invalid={!!error}
|
||||||
error={error?.message}
|
error={error?.message}
|
||||||
|
data-testid={`${optionPath}${option.propertyName}`}
|
||||||
>
|
>
|
||||||
<OptionInput
|
<OptionInput
|
||||||
id={`${optionPath}${option.propertyName}`}
|
id={`${optionPath}${option.propertyName}`}
|
||||||
@ -69,6 +73,7 @@ export const OptionField: FC<Props> = ({
|
|||||||
pathPrefix={optionPath}
|
pathPrefix={optionPath}
|
||||||
readOnly={readOnly}
|
readOnly={readOnly}
|
||||||
pathIndex={pathPrefix}
|
pathIndex={pathPrefix}
|
||||||
|
customValidator={customValidator}
|
||||||
/>
|
/>
|
||||||
</Field>
|
</Field>
|
||||||
);
|
);
|
||||||
@ -81,7 +86,9 @@ const OptionInput: FC<Props & { id: string; pathIndex?: string }> = ({
|
|||||||
pathPrefix = '',
|
pathPrefix = '',
|
||||||
pathIndex = '',
|
pathIndex = '',
|
||||||
readOnly = false,
|
readOnly = false,
|
||||||
|
customValidator,
|
||||||
}) => {
|
}) => {
|
||||||
|
const styles = useStyles2(getStyles);
|
||||||
const { control, register, unregister, getValues } = useFormContext();
|
const { control, register, unregister, getValues } = useFormContext();
|
||||||
const name = `${pathPrefix}${option.propertyName}`;
|
const name = `${pathPrefix}${option.propertyName}`;
|
||||||
|
|
||||||
@ -114,7 +121,10 @@ const OptionInput: FC<Props & { id: string; pathIndex?: string }> = ({
|
|||||||
type={option.inputType}
|
type={option.inputType}
|
||||||
{...register(name, {
|
{...register(name, {
|
||||||
required: determineRequired(option, getValues, pathIndex),
|
required: determineRequired(option, getValues, pathIndex),
|
||||||
validate: (v) => (option.validationRule !== '' ? validateOption(v, option.validationRule) : true),
|
validate: {
|
||||||
|
validationRule: (v) => (option.validationRule ? validateOption(v, option.validationRule) : true),
|
||||||
|
customValidator: (v) => (customValidator ? customValidator(v) : true),
|
||||||
|
},
|
||||||
setValueAs: option.setValueAs,
|
setValueAs: option.setValueAs,
|
||||||
})}
|
})}
|
||||||
placeholder={option.placeholder}
|
placeholder={option.placeholder}
|
||||||
@ -127,18 +137,43 @@ const OptionInput: FC<Props & { id: string; pathIndex?: string }> = ({
|
|||||||
render={({ field: { onChange, ref, ...field } }) => (
|
render={({ field: { onChange, ref, ...field } }) => (
|
||||||
<Select
|
<Select
|
||||||
disabled={readOnly}
|
disabled={readOnly}
|
||||||
{...field}
|
|
||||||
defaultValue={option.defaultValue}
|
|
||||||
options={option.selectOptions ?? undefined}
|
options={option.selectOptions ?? undefined}
|
||||||
invalid={invalid}
|
invalid={invalid}
|
||||||
onChange={(value) => onChange(value.value)}
|
onChange={(value) => onChange(value.value)}
|
||||||
|
{...field}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
control={control}
|
control={control}
|
||||||
name={name}
|
name={name}
|
||||||
|
defaultValue={option.defaultValue}
|
||||||
|
rules={{
|
||||||
|
validate: {
|
||||||
|
customValidator: (v) => (customValidator ? customValidator(v) : true),
|
||||||
|
},
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
case 'radio':
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<legend className={styles.legend}>{option.label}</legend>
|
||||||
|
<InputControl
|
||||||
|
render={({ field: { ref, ...field } }) => (
|
||||||
|
<RadioButtonList disabled={readOnly} options={option.selectOptions ?? []} {...field} />
|
||||||
|
)}
|
||||||
|
control={control}
|
||||||
|
defaultValue={option.defaultValue?.value}
|
||||||
|
name={name}
|
||||||
|
rules={{
|
||||||
|
required: option.required ? 'Option is required' : false,
|
||||||
|
validate: {
|
||||||
|
validationRule: (v) => (option.validationRule ? validateOption(v, option.validationRule) : true),
|
||||||
|
customValidator: (v) => (customValidator ? customValidator(v) : true),
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
case 'textarea':
|
case 'textarea':
|
||||||
return (
|
return (
|
||||||
<TextArea
|
<TextArea
|
||||||
@ -179,11 +214,14 @@ const OptionInput: FC<Props & { id: string; pathIndex?: string }> = ({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const styles = {
|
const getStyles = (theme: GrafanaTheme2) => ({
|
||||||
checkbox: css`
|
checkbox: css`
|
||||||
height: auto; // native checkbox has fixed height which does not take into account description
|
height: auto; // native checkbox has fixed height which does not take into account description
|
||||||
`,
|
`,
|
||||||
};
|
legend: css`
|
||||||
|
font-size: ${theme.typography.h6.fontSize};
|
||||||
|
`,
|
||||||
|
});
|
||||||
|
|
||||||
const validateOption = (value: string, validationRule: string) => {
|
const validateOption = (value: string, validationRule: string) => {
|
||||||
return RegExp(validationRule).test(value) ? true : 'Invalid format';
|
return RegExp(validationRule).test(value) ? true : 'Invalid format';
|
||||||
|
@ -0,0 +1,13 @@
|
|||||||
|
import { NotifierDTO } from '../../../../../../types';
|
||||||
|
|
||||||
|
export interface NotifierMetadata {
|
||||||
|
enabled: boolean;
|
||||||
|
order: number;
|
||||||
|
description?: string;
|
||||||
|
iconUrl?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Notifier {
|
||||||
|
dto: NotifierDTO;
|
||||||
|
meta?: NotifierMetadata;
|
||||||
|
}
|
@ -1,34 +0,0 @@
|
|||||||
import { css } from '@emotion/css';
|
|
||||||
import React from 'react';
|
|
||||||
|
|
||||||
import { GrafanaTheme2 } from '@grafana/data';
|
|
||||||
import { HorizontalGroup, useStyles2 } from '@grafana/ui';
|
|
||||||
|
|
||||||
import { SupportedPlugin } from '../../../types/pluginBridges';
|
|
||||||
|
|
||||||
import { GRAFANA_APP_RECEIVERS_SOURCE_IMAGE } from './types';
|
|
||||||
|
|
||||||
export const GrafanaAppBadge = ({ grafanaAppType }: { grafanaAppType: SupportedPlugin }) => {
|
|
||||||
const styles = useStyles2(getStyles);
|
|
||||||
return (
|
|
||||||
<div className={styles.wrapper}>
|
|
||||||
<HorizontalGroup align="center" spacing="xs">
|
|
||||||
<img src={GRAFANA_APP_RECEIVERS_SOURCE_IMAGE[grafanaAppType]} alt="" height="12px" />
|
|
||||||
<span>{grafanaAppType}</span>
|
|
||||||
</HorizontalGroup>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const getStyles = (theme: GrafanaTheme2) => ({
|
|
||||||
wrapper: css`
|
|
||||||
text-align: left;
|
|
||||||
height: 22px;
|
|
||||||
display: inline-flex;
|
|
||||||
padding: 1px 4px;
|
|
||||||
border-radius: ${theme.shape.radius.default};
|
|
||||||
border: 1px solid rgba(245, 95, 62, 1);
|
|
||||||
color: rgba(245, 95, 62, 1);
|
|
||||||
font-weight: ${theme.typography.fontWeightRegular};
|
|
||||||
`,
|
|
||||||
});
|
|
@ -0,0 +1,49 @@
|
|||||||
|
import { css } from '@emotion/css';
|
||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
import { GrafanaTheme2 } from '@grafana/data';
|
||||||
|
import { Stack } from '@grafana/experimental';
|
||||||
|
import { HorizontalGroup, Icon, LinkButton, Tooltip, useStyles2 } from '@grafana/ui';
|
||||||
|
|
||||||
|
import { ReceiverMetadata } from './useReceiversMetadata';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
metadata: ReceiverMetadata;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ReceiverMetadataBadge = ({ metadata: { icon, title, externalUrl, warning } }: Props) => {
|
||||||
|
const styles = useStyles2(getStyles);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Stack alignItems="center" gap={1}>
|
||||||
|
<div className={styles.wrapper}>
|
||||||
|
<HorizontalGroup align="center" spacing="xs">
|
||||||
|
<img src={icon} alt="" height="12px" />
|
||||||
|
<span>{title}</span>
|
||||||
|
</HorizontalGroup>
|
||||||
|
</div>
|
||||||
|
{externalUrl && <LinkButton icon="external-link-alt" href={externalUrl} variant="secondary" size="sm" />}
|
||||||
|
{warning && (
|
||||||
|
<Tooltip content={warning} theme="error">
|
||||||
|
<Icon name="exclamation-triangle" size="lg" className={styles.warnIcon} />
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const getStyles = (theme: GrafanaTheme2) => ({
|
||||||
|
wrapper: css`
|
||||||
|
text-align: left;
|
||||||
|
height: 22px;
|
||||||
|
display: inline-flex;
|
||||||
|
padding: 1px 4px;
|
||||||
|
border-radius: ${theme.shape.borderRadius()};
|
||||||
|
border: 1px solid rgba(245, 95, 62, 1);
|
||||||
|
color: rgba(245, 95, 62, 1);
|
||||||
|
font-weight: ${theme.typography.fontWeightRegular};
|
||||||
|
`,
|
||||||
|
warnIcon: css`
|
||||||
|
fill: ${theme.colors.warning.main};
|
||||||
|
`,
|
||||||
|
});
|
@ -1,6 +1,6 @@
|
|||||||
import { Receiver } from 'app/plugins/datasource/alertmanager/types';
|
import { Receiver } from 'app/plugins/datasource/alertmanager/types';
|
||||||
|
|
||||||
import { useGetOnCallIntegrationsQuery } from '../../../api/onCallApi';
|
import { onCallApi } from '../../../api/onCallApi';
|
||||||
import { usePluginBridge } from '../../../hooks/usePluginBridge';
|
import { usePluginBridge } from '../../../hooks/usePluginBridge';
|
||||||
import { SupportedPlugin } from '../../../types/pluginBridges';
|
import { SupportedPlugin } from '../../../types/pluginBridges';
|
||||||
|
|
||||||
@ -9,7 +9,7 @@ import { AmRouteReceiver, ReceiverWithTypes } from './types';
|
|||||||
|
|
||||||
export const useGetGrafanaReceiverTypeChecker = () => {
|
export const useGetGrafanaReceiverTypeChecker = () => {
|
||||||
const { installed: isOnCallEnabled } = usePluginBridge(SupportedPlugin.OnCall);
|
const { installed: isOnCallEnabled } = usePluginBridge(SupportedPlugin.OnCall);
|
||||||
const { data } = useGetOnCallIntegrationsQuery(undefined, {
|
const { data } = onCallApi.useGrafanaOnCallIntegrationsQuery(undefined, {
|
||||||
skip: !isOnCallEnabled,
|
skip: !isOnCallEnabled,
|
||||||
});
|
});
|
||||||
const getGrafanaReceiverType = (receiver: Receiver): SupportedPlugin | undefined => {
|
const getGrafanaReceiverType = (receiver: Receiver): SupportedPlugin | undefined => {
|
||||||
@ -21,6 +21,7 @@ export const useGetGrafanaReceiverTypeChecker = () => {
|
|||||||
//WE WILL ADD IN HERE IF THERE ARE MORE TYPES TO CHECK
|
//WE WILL ADD IN HERE IF THERE ARE MORE TYPES TO CHECK
|
||||||
return undefined;
|
return undefined;
|
||||||
};
|
};
|
||||||
|
|
||||||
return getGrafanaReceiverType;
|
return getGrafanaReceiverType;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -1,19 +1,28 @@
|
|||||||
import { Receiver } from 'app/plugins/datasource/alertmanager/types';
|
import { Receiver } from 'app/plugins/datasource/alertmanager/types';
|
||||||
|
|
||||||
|
import { OnCallIntegrationDTO } from '../../../../api/onCallApi';
|
||||||
|
|
||||||
|
// TODO This value needs to be changed to grafana_alerting when the OnCall team introduces the necessary changes
|
||||||
|
export const GRAFANA_ONCALL_INTEGRATION_TYPE = 'grafana_alerting';
|
||||||
|
|
||||||
|
export enum ReceiverTypes {
|
||||||
|
OnCall = 'oncall',
|
||||||
|
}
|
||||||
|
|
||||||
export const isInOnCallIntegrations = (url: string, integrationsUrls: string[]) => {
|
export const isInOnCallIntegrations = (url: string, integrationsUrls: string[]) => {
|
||||||
return integrationsUrls.includes(url);
|
return integrationsUrls.includes(url);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const isOnCallReceiver = (receiver: Receiver, integrationsUrls: string[]) => {
|
export const isOnCallReceiver = (receiver: Receiver, integrations: OnCallIntegrationDTO[]) => {
|
||||||
if (!receiver.grafana_managed_receiver_configs) {
|
if (!receiver.grafana_managed_receiver_configs) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
// A receiver it's an onCall contact point if it includes only one integration, and this integration it's an onCall
|
// A receiver it's an onCall contact point if it includes only one integration, and this integration it's an onCall
|
||||||
// An integration it's an onCall type if it's included in the list of integrations returned by the onCall api endpoint
|
// An integration it's an onCall type if it's included in the list of integrations returned by the onCall api endpoint
|
||||||
const onlyOneIntegration = receiver.grafana_managed_receiver_configs.length === 1;
|
const onlyOneIntegration = receiver.grafana_managed_receiver_configs.length === 1;
|
||||||
const isOncall = isInOnCallIntegrations(
|
const isOnCall = isInOnCallIntegrations(
|
||||||
receiver.grafana_managed_receiver_configs[0]?.settings?.url ?? '',
|
receiver.grafana_managed_receiver_configs[0]?.settings?.url ?? '',
|
||||||
integrationsUrls
|
integrations.map((i) => i.integration_url)
|
||||||
);
|
);
|
||||||
return onlyOneIntegration && isOncall;
|
return onlyOneIntegration && isOnCall;
|
||||||
};
|
};
|
||||||
|
@ -0,0 +1,238 @@
|
|||||||
|
import { renderHook, waitFor } from '@testing-library/react';
|
||||||
|
import { TestProvider } from 'test/helpers/TestProvider';
|
||||||
|
|
||||||
|
import { mockApi, setupMswServer } from 'app/features/alerting/unified/mockApi';
|
||||||
|
import { onCallPluginMetaMock } from 'app/features/alerting/unified/mocks';
|
||||||
|
import { option } from 'app/features/alerting/unified/utils/notifier-types';
|
||||||
|
import { clearPluginSettingsCache } from 'app/features/plugins/pluginSettings';
|
||||||
|
|
||||||
|
import { ONCALL_INTEGRATION_V2_FEATURE } from '../../../../api/onCallApi';
|
||||||
|
|
||||||
|
import { ReceiverTypes } from './onCall';
|
||||||
|
import { OnCallIntegrationSetting, OnCallIntegrationType, useOnCallIntegration } from './useOnCallIntegration';
|
||||||
|
|
||||||
|
const server = setupMswServer();
|
||||||
|
|
||||||
|
describe('useOnCallIntegration', () => {
|
||||||
|
afterEach(() => {
|
||||||
|
server.resetHandlers();
|
||||||
|
clearPluginSettingsCache();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('When OnCall Alerting V2 integration enabled', () => {
|
||||||
|
it('extendOnCalReceivers should add new settings to the oncall receiver', async () => {
|
||||||
|
mockApi(server).plugins.getPluginSettings({ ...onCallPluginMetaMock, enabled: true });
|
||||||
|
mockApi(server).oncall.features([ONCALL_INTEGRATION_V2_FEATURE]);
|
||||||
|
mockApi(server).oncall.getOnCallIntegrations([]);
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useOnCallIntegration(), { wrapper: TestProvider });
|
||||||
|
|
||||||
|
await waitFor(() => expect(result.current.isLoadingOnCallIntegration).toBe(false));
|
||||||
|
|
||||||
|
const { extendOnCallReceivers } = result.current;
|
||||||
|
|
||||||
|
const receiver = extendOnCallReceivers({
|
||||||
|
name: 'OnCall Conctact point',
|
||||||
|
grafana_managed_receiver_configs: [
|
||||||
|
{
|
||||||
|
name: 'Oncall-integration',
|
||||||
|
type: ReceiverTypes.OnCall,
|
||||||
|
settings: {
|
||||||
|
url: 'https://oncall-endpoint.example.com',
|
||||||
|
},
|
||||||
|
disableResolveMessage: false,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const receiverConfig = receiver.grafana_managed_receiver_configs![0];
|
||||||
|
|
||||||
|
expect(receiverConfig.settings[OnCallIntegrationSetting.IntegrationType]).toBe(
|
||||||
|
OnCallIntegrationType.ExistingIntegration
|
||||||
|
);
|
||||||
|
expect(receiverConfig.settings[OnCallIntegrationSetting.IntegrationName]).toBeUndefined();
|
||||||
|
expect(receiverConfig.settings['url']).toBe('https://oncall-endpoint.example.com');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('createOnCallIntegrations should provide integration name and url validators', async () => {
|
||||||
|
mockApi(server).plugins.getPluginSettings({ ...onCallPluginMetaMock, enabled: true });
|
||||||
|
mockApi(server).oncall.features([ONCALL_INTEGRATION_V2_FEATURE]);
|
||||||
|
mockApi(server).oncall.getOnCallIntegrations([
|
||||||
|
{
|
||||||
|
display_name: 'grafana-integration',
|
||||||
|
value: 'ABC123',
|
||||||
|
integration_url: 'https://oncall.com/grafana-integration',
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
mockApi(server).oncall.validateIntegrationName(['grafana-integration', 'alertmanager-integration']);
|
||||||
|
mockApi(server).oncall.createIntegraion();
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useOnCallIntegration(), { wrapper: TestProvider });
|
||||||
|
|
||||||
|
await waitFor(() => expect(result.current.isLoadingOnCallIntegration).toBe(false));
|
||||||
|
|
||||||
|
const { onCallFormValidators } = result.current;
|
||||||
|
|
||||||
|
const gfValidationResult = await waitFor(() => onCallFormValidators.integration_name('grafana-integration'));
|
||||||
|
expect(gfValidationResult).toBe('Integration of this name already exists in OnCall');
|
||||||
|
|
||||||
|
const amValidationResult = await waitFor(() => onCallFormValidators.integration_name('alertmanager-integration'));
|
||||||
|
expect(amValidationResult).toBe('Integration of this name already exists in OnCall');
|
||||||
|
|
||||||
|
// ULR validator should check if the provided URL already exists
|
||||||
|
expect(onCallFormValidators.url('https://oncall.com/grafana-integration')).toBe(true);
|
||||||
|
|
||||||
|
expect(onCallFormValidators.url('https://oncall.com/alertmanager-integration')).toBe(
|
||||||
|
'Selection of existing OnCall integration is required'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('extendOnCallNotifierFeatures should add integration type and name options and swap url to a select option', async () => {
|
||||||
|
mockApi(server).plugins.getPluginSettings({ ...onCallPluginMetaMock, enabled: true });
|
||||||
|
mockApi(server).oncall.features([ONCALL_INTEGRATION_V2_FEATURE]);
|
||||||
|
mockApi(server).oncall.getOnCallIntegrations([
|
||||||
|
{
|
||||||
|
display_name: 'grafana-integration',
|
||||||
|
value: 'ABC123',
|
||||||
|
integration_url: 'https://oncall.com/grafana-integration',
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useOnCallIntegration(), { wrapper: TestProvider });
|
||||||
|
|
||||||
|
await waitFor(() => expect(result.current.isLoadingOnCallIntegration).toBe(false));
|
||||||
|
|
||||||
|
const { extendOnCallNotifierFeatures } = result.current;
|
||||||
|
|
||||||
|
const notifier = extendOnCallNotifierFeatures({
|
||||||
|
name: 'Grafana OnCall',
|
||||||
|
type: 'oncall',
|
||||||
|
options: [option('url', 'Grafana OnCall', 'Grafana OnCall', { element: 'input' })],
|
||||||
|
description: '',
|
||||||
|
heading: '',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(notifier.options).toHaveLength(3);
|
||||||
|
expect(notifier.options[0].propertyName).toBe(OnCallIntegrationSetting.IntegrationType);
|
||||||
|
expect(notifier.options[1].propertyName).toBe(OnCallIntegrationSetting.IntegrationName);
|
||||||
|
expect(notifier.options[2].propertyName).toBe('url');
|
||||||
|
|
||||||
|
expect(notifier.options[0].element).toBe('radio');
|
||||||
|
expect(notifier.options[2].element).toBe('select');
|
||||||
|
|
||||||
|
expect(notifier.options[2].selectOptions).toHaveLength(1);
|
||||||
|
expect(notifier.options[2].selectOptions![0]).toMatchObject({
|
||||||
|
label: 'grafana-integration',
|
||||||
|
value: 'https://oncall.com/grafana-integration',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('When OnCall Alerting V2 integration disabled', () => {
|
||||||
|
it('extendOnCalReceivers should not add new settings to the oncall receiver', async () => {
|
||||||
|
mockApi(server).plugins.getPluginSettings({ ...onCallPluginMetaMock, enabled: true });
|
||||||
|
mockApi(server).oncall.features([]);
|
||||||
|
mockApi(server).oncall.getOnCallIntegrations([]);
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useOnCallIntegration(), { wrapper: TestProvider });
|
||||||
|
|
||||||
|
await waitFor(() => expect(result.current.isLoadingOnCallIntegration).toBe(false));
|
||||||
|
|
||||||
|
const { extendOnCallReceivers } = result.current;
|
||||||
|
|
||||||
|
const receiver = extendOnCallReceivers({
|
||||||
|
name: 'OnCall Conctact point',
|
||||||
|
grafana_managed_receiver_configs: [
|
||||||
|
{
|
||||||
|
name: 'Oncall-integration',
|
||||||
|
type: ReceiverTypes.OnCall,
|
||||||
|
settings: {
|
||||||
|
url: 'https://oncall-endpoint.example.com',
|
||||||
|
},
|
||||||
|
disableResolveMessage: false,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const receiverConfig = receiver.grafana_managed_receiver_configs![0];
|
||||||
|
|
||||||
|
expect(receiverConfig.settings[OnCallIntegrationSetting.IntegrationType]).toBeUndefined();
|
||||||
|
expect(receiverConfig.settings[OnCallIntegrationSetting.IntegrationName]).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('extendConCallNotifierFeatures should not extend notifier', async () => {
|
||||||
|
mockApi(server).plugins.getPluginSettings({ ...onCallPluginMetaMock, enabled: true });
|
||||||
|
mockApi(server).oncall.features([]);
|
||||||
|
mockApi(server).oncall.getOnCallIntegrations([]);
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useOnCallIntegration(), { wrapper: TestProvider });
|
||||||
|
|
||||||
|
await waitFor(() => expect(result.current.isLoadingOnCallIntegration).toBe(false));
|
||||||
|
|
||||||
|
const { extendOnCallNotifierFeatures } = result.current;
|
||||||
|
|
||||||
|
const notifier = extendOnCallNotifierFeatures({
|
||||||
|
name: 'Grafana OnCall',
|
||||||
|
type: 'oncall',
|
||||||
|
options: [option('url', 'Grafana OnCall', 'Grafana OnCall', { element: 'input' })],
|
||||||
|
description: '',
|
||||||
|
heading: '',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(notifier.options).toHaveLength(1);
|
||||||
|
expect(notifier.options[0].propertyName).toBe('url');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('When OnCall plugin disabled', () => {
|
||||||
|
it('extendOnCalReceivers should not add new settings to the oncall receiver', async () => {
|
||||||
|
mockApi(server).plugins.getPluginSettings({ ...onCallPluginMetaMock, enabled: false });
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useOnCallIntegration(), { wrapper: TestProvider });
|
||||||
|
|
||||||
|
await waitFor(() => expect(result.current.isLoadingOnCallIntegration).toBe(false));
|
||||||
|
|
||||||
|
const { extendOnCallReceivers } = result.current;
|
||||||
|
|
||||||
|
const receiver = extendOnCallReceivers({
|
||||||
|
name: 'OnCall Conctact point',
|
||||||
|
grafana_managed_receiver_configs: [
|
||||||
|
{
|
||||||
|
name: 'Oncall-integration',
|
||||||
|
type: ReceiverTypes.OnCall,
|
||||||
|
settings: {
|
||||||
|
url: 'https://oncall-endpoint.example.com',
|
||||||
|
},
|
||||||
|
disableResolveMessage: false,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const receiverConfig = receiver.grafana_managed_receiver_configs![0];
|
||||||
|
|
||||||
|
expect(receiverConfig.settings[OnCallIntegrationSetting.IntegrationType]).toBeUndefined();
|
||||||
|
expect(receiverConfig.settings[OnCallIntegrationSetting.IntegrationName]).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('extendConCallNotifierFeatures should not extend notifier', async () => {
|
||||||
|
mockApi(server).plugins.getPluginSettings({ ...onCallPluginMetaMock, enabled: false });
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useOnCallIntegration(), { wrapper: TestProvider });
|
||||||
|
|
||||||
|
await waitFor(() => expect(result.current.isLoadingOnCallIntegration).toBe(false));
|
||||||
|
|
||||||
|
const { extendOnCallNotifierFeatures } = result.current;
|
||||||
|
|
||||||
|
const notifier = extendOnCallNotifierFeatures({
|
||||||
|
name: 'Grafana OnCall',
|
||||||
|
type: 'oncall',
|
||||||
|
options: [option('url', 'Grafana OnCall', 'Grafana OnCall', { element: 'input' })],
|
||||||
|
description: '',
|
||||||
|
heading: '',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(notifier.options).toHaveLength(1);
|
||||||
|
expect(notifier.options[0].propertyName).toBe('url');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
@ -0,0 +1,252 @@
|
|||||||
|
import { produce } from 'immer';
|
||||||
|
import { useCallback, useMemo } from 'react';
|
||||||
|
|
||||||
|
import { SelectableValue } from '@grafana/data';
|
||||||
|
import { isFetchError } from '@grafana/runtime';
|
||||||
|
|
||||||
|
import { useAppNotification } from '../../../../../../../core/copy/appNotification';
|
||||||
|
import { Receiver } from '../../../../../../../plugins/datasource/alertmanager/types';
|
||||||
|
import { NotifierDTO } from '../../../../../../../types';
|
||||||
|
import { ONCALL_INTEGRATION_V2_FEATURE, onCallApi } from '../../../../api/onCallApi';
|
||||||
|
import { usePluginBridge } from '../../../../hooks/usePluginBridge';
|
||||||
|
import { SupportedPlugin } from '../../../../types/pluginBridges';
|
||||||
|
import { option } from '../../../../utils/notifier-types';
|
||||||
|
import { GRAFANA_APP_RECEIVERS_SOURCE_IMAGE } from '../types';
|
||||||
|
|
||||||
|
import { GRAFANA_ONCALL_INTEGRATION_TYPE, ReceiverTypes } from './onCall';
|
||||||
|
|
||||||
|
export enum OnCallIntegrationType {
|
||||||
|
NewIntegration = 'new_oncall_integration',
|
||||||
|
ExistingIntegration = 'existing_oncall_integration',
|
||||||
|
}
|
||||||
|
|
||||||
|
export enum OnCallIntegrationSetting {
|
||||||
|
IntegrationType = 'integration_type',
|
||||||
|
IntegrationName = 'integration_name',
|
||||||
|
}
|
||||||
|
|
||||||
|
enum OnCallIntegrationStatus {
|
||||||
|
Disabled = 'disabled',
|
||||||
|
// The old integration done exclusively on the OnCall side
|
||||||
|
// Relies on automatic creation of contact points and altering notification policies
|
||||||
|
// If enabled Alerting UI should not enable any OnCall integration features
|
||||||
|
V1 = 'v1',
|
||||||
|
// The new integration - On Alerting side we create OnCall integrations and use theirs URLs
|
||||||
|
// as parameters for oncall contact points
|
||||||
|
V2 = 'v2',
|
||||||
|
}
|
||||||
|
|
||||||
|
function useOnCallPluginStatus() {
|
||||||
|
const {
|
||||||
|
installed: isOnCallEnabled,
|
||||||
|
loading: isPluginBridgeLoading,
|
||||||
|
error: pluginError,
|
||||||
|
} = usePluginBridge(SupportedPlugin.OnCall);
|
||||||
|
|
||||||
|
const {
|
||||||
|
data: onCallFeatures = [],
|
||||||
|
error: onCallFeaturesError,
|
||||||
|
isLoading: isOnCallFeaturesLoading,
|
||||||
|
} = onCallApi.endpoints.features.useQuery(undefined, { skip: !isOnCallEnabled });
|
||||||
|
|
||||||
|
const integrationStatus = useMemo((): OnCallIntegrationStatus => {
|
||||||
|
if (!isOnCallEnabled) {
|
||||||
|
return OnCallIntegrationStatus.Disabled;
|
||||||
|
}
|
||||||
|
// TODO Support for V2 integration should be added when the OnCall team introduces the necessary changes
|
||||||
|
|
||||||
|
return onCallFeatures.includes(ONCALL_INTEGRATION_V2_FEATURE)
|
||||||
|
? OnCallIntegrationStatus.V2
|
||||||
|
: OnCallIntegrationStatus.V1;
|
||||||
|
}, [isOnCallEnabled, onCallFeatures]);
|
||||||
|
|
||||||
|
const isAlertingV2IntegrationEnabled = useMemo(
|
||||||
|
() => integrationStatus === OnCallIntegrationStatus.V2,
|
||||||
|
[integrationStatus]
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
isOnCallEnabled,
|
||||||
|
integrationStatus,
|
||||||
|
isAlertingV2IntegrationEnabled,
|
||||||
|
isOnCallStatusLoading: isPluginBridgeLoading || isOnCallFeaturesLoading,
|
||||||
|
onCallError: pluginError ?? onCallFeaturesError,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useOnCallIntegration() {
|
||||||
|
const notifyApp = useAppNotification();
|
||||||
|
|
||||||
|
const { isOnCallEnabled, integrationStatus, isAlertingV2IntegrationEnabled, isOnCallStatusLoading, onCallError } =
|
||||||
|
useOnCallPluginStatus();
|
||||||
|
|
||||||
|
const { useCreateIntegrationMutation, useGrafanaOnCallIntegrationsQuery, useLazyValidateIntegrationNameQuery } =
|
||||||
|
onCallApi;
|
||||||
|
|
||||||
|
const [validateIntegrationNameQuery, { isFetching: isValidating }] = useLazyValidateIntegrationNameQuery();
|
||||||
|
const [createIntegrationMutation] = useCreateIntegrationMutation();
|
||||||
|
|
||||||
|
const {
|
||||||
|
data: grafanaOnCallIntegrations = [],
|
||||||
|
isLoading: isLoadingOnCallIntegrations,
|
||||||
|
isError: isIntegrationsQueryError,
|
||||||
|
} = useGrafanaOnCallIntegrationsQuery(undefined, { skip: !isAlertingV2IntegrationEnabled });
|
||||||
|
|
||||||
|
const onCallFormValidators = useMemo(() => {
|
||||||
|
return {
|
||||||
|
integration_name: async (value: string) => {
|
||||||
|
try {
|
||||||
|
await validateIntegrationNameQuery(value).unwrap();
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
if (isFetchError(error) && error.status === 409) {
|
||||||
|
return 'Integration of this name already exists in OnCall';
|
||||||
|
}
|
||||||
|
|
||||||
|
notifyApp.error('Failed to validate OnCall integration name. Is the OnCall API available?');
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
url: (value: string) => {
|
||||||
|
if (!isAlertingV2IntegrationEnabled) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return grafanaOnCallIntegrations.map((i) => i.integration_url).includes(value)
|
||||||
|
? true
|
||||||
|
: 'Selection of existing OnCall integration is required';
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}, [grafanaOnCallIntegrations, validateIntegrationNameQuery, isAlertingV2IntegrationEnabled, notifyApp]);
|
||||||
|
|
||||||
|
const extendOnCallReceivers = useCallback(
|
||||||
|
(receiver: Receiver): Receiver => {
|
||||||
|
if (!isAlertingV2IntegrationEnabled) {
|
||||||
|
return receiver;
|
||||||
|
}
|
||||||
|
|
||||||
|
return produce(receiver, (draft) => {
|
||||||
|
draft.grafana_managed_receiver_configs?.forEach((config) => {
|
||||||
|
if (config.type === ReceiverTypes.OnCall) {
|
||||||
|
config.settings[OnCallIntegrationSetting.IntegrationType] = OnCallIntegrationType.ExistingIntegration;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[isAlertingV2IntegrationEnabled]
|
||||||
|
);
|
||||||
|
|
||||||
|
const createOnCallIntegrations = useCallback(
|
||||||
|
async (receiver: Receiver): Promise<Receiver> => {
|
||||||
|
if (!isAlertingV2IntegrationEnabled) {
|
||||||
|
return receiver;
|
||||||
|
}
|
||||||
|
|
||||||
|
const onCallIntegrations = receiver.grafana_managed_receiver_configs?.filter((c) => c.type === 'oncall') ?? [];
|
||||||
|
const newOnCallIntegrations = onCallIntegrations.filter(
|
||||||
|
(c) => c.settings[OnCallIntegrationSetting.IntegrationType] === OnCallIntegrationType.NewIntegration
|
||||||
|
);
|
||||||
|
|
||||||
|
const createNewOnCallIntegrationJobs = newOnCallIntegrations.map(async (c) => {
|
||||||
|
const newIntegration = await createIntegrationMutation({
|
||||||
|
integration: GRAFANA_ONCALL_INTEGRATION_TYPE,
|
||||||
|
verbal_name: c.settings[OnCallIntegrationSetting.IntegrationName],
|
||||||
|
}).unwrap();
|
||||||
|
|
||||||
|
c.settings['url'] = newIntegration.integration_url;
|
||||||
|
});
|
||||||
|
|
||||||
|
await Promise.all(createNewOnCallIntegrationJobs);
|
||||||
|
|
||||||
|
return produce(receiver, (draft) => {
|
||||||
|
draft.grafana_managed_receiver_configs?.forEach((c) => {
|
||||||
|
// Clean up the settings before sending the receiver to the backend
|
||||||
|
// The settings object can contain any additional data but integration type and name are purely frontend thing
|
||||||
|
// and should not be sent and kept in the backend
|
||||||
|
if (c.type === ReceiverTypes.OnCall) {
|
||||||
|
delete c.settings[OnCallIntegrationSetting.IntegrationType];
|
||||||
|
delete c.settings[OnCallIntegrationSetting.IntegrationName];
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[isAlertingV2IntegrationEnabled, createIntegrationMutation]
|
||||||
|
);
|
||||||
|
|
||||||
|
const extendOnCallNotifierFeatures = useCallback(
|
||||||
|
(notifier: NotifierDTO): NotifierDTO => {
|
||||||
|
// If V2 integration is not enabled the receiver will not be extended
|
||||||
|
// We still allow users to use this contact point but they need to provide URL manually
|
||||||
|
// As they do for webhook integration
|
||||||
|
// Removing the oncall notifier from the list of available notifiers has drawbacks - it's tricky to define what should happen
|
||||||
|
// if someone turned off or downgraded the OnCall plugin but had some receivers configured with OnCall notifier
|
||||||
|
// By falling back to plain URL input we allow users to change the config with OnCall disabled/not supporting V2 integration
|
||||||
|
if (notifier.type === ReceiverTypes.OnCall && isAlertingV2IntegrationEnabled) {
|
||||||
|
const options = notifier.options.filter((o) => o.propertyName !== 'url');
|
||||||
|
|
||||||
|
const newIntegrationOption: SelectableValue<string> = {
|
||||||
|
value: OnCallIntegrationType.NewIntegration,
|
||||||
|
label: 'New OnCall integration',
|
||||||
|
description: 'A new OnCall integration without escalation chains will be automatically created',
|
||||||
|
};
|
||||||
|
const existingIntegrationOption: SelectableValue<string> = {
|
||||||
|
value: OnCallIntegrationType.ExistingIntegration,
|
||||||
|
label: 'Existing OnCall integration',
|
||||||
|
description: 'Use an existing OnCall integration',
|
||||||
|
};
|
||||||
|
|
||||||
|
options.unshift(
|
||||||
|
option(OnCallIntegrationSetting.IntegrationType, 'How to connect to OnCall', '', {
|
||||||
|
required: true,
|
||||||
|
element: 'radio',
|
||||||
|
defaultValue: newIntegrationOption,
|
||||||
|
selectOptions: [newIntegrationOption, existingIntegrationOption],
|
||||||
|
}),
|
||||||
|
option(
|
||||||
|
OnCallIntegrationSetting.IntegrationName,
|
||||||
|
'Integration name',
|
||||||
|
'The name of the new OnCall integration',
|
||||||
|
{
|
||||||
|
required: true,
|
||||||
|
showWhen: { field: 'integration_type', is: OnCallIntegrationType.NewIntegration },
|
||||||
|
}
|
||||||
|
),
|
||||||
|
option('url', 'OnCall Integration', 'The OnCall integration to send alerts to', {
|
||||||
|
element: 'select',
|
||||||
|
required: true,
|
||||||
|
showWhen: { field: 'integration_type', is: OnCallIntegrationType.ExistingIntegration },
|
||||||
|
selectOptions: grafanaOnCallIntegrations.map((i) => ({
|
||||||
|
label: i.display_name,
|
||||||
|
description: i.integration_url,
|
||||||
|
value: i.integration_url,
|
||||||
|
})),
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
return { ...notifier, options };
|
||||||
|
}
|
||||||
|
|
||||||
|
return notifier;
|
||||||
|
},
|
||||||
|
[grafanaOnCallIntegrations, isAlertingV2IntegrationEnabled]
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
integrationStatus,
|
||||||
|
onCallNotifierMeta: {
|
||||||
|
enabled: !!isOnCallEnabled,
|
||||||
|
order: -1, // The default is 0. We want OnCall to be the first on the list
|
||||||
|
description: isOnCallEnabled
|
||||||
|
? 'Connect effortlessly to Grafana OnCall'
|
||||||
|
: 'Enable Grafana OnCall plugin to use this integration',
|
||||||
|
iconUrl: GRAFANA_APP_RECEIVERS_SOURCE_IMAGE[SupportedPlugin.OnCall],
|
||||||
|
},
|
||||||
|
extendOnCallNotifierFeatures,
|
||||||
|
extendOnCallReceivers,
|
||||||
|
createOnCallIntegrations,
|
||||||
|
onCallFormValidators,
|
||||||
|
isLoadingOnCallIntegration: isLoadingOnCallIntegrations || isOnCallStatusLoading,
|
||||||
|
isValidating,
|
||||||
|
hasOnCallError: Boolean(onCallError) || isIntegrationsQueryError,
|
||||||
|
};
|
||||||
|
}
|
@ -0,0 +1,64 @@
|
|||||||
|
import { useMemo } from 'react';
|
||||||
|
|
||||||
|
import { Receiver } from '../../../../../../plugins/datasource/alertmanager/types';
|
||||||
|
import { onCallApi } from '../../../api/onCallApi';
|
||||||
|
import { usePluginBridge } from '../../../hooks/usePluginBridge';
|
||||||
|
import { SupportedPlugin } from '../../../types/pluginBridges';
|
||||||
|
import { createBridgeURL } from '../../PluginBridge';
|
||||||
|
|
||||||
|
import { ReceiverTypes } from './onCall/onCall';
|
||||||
|
import { GRAFANA_APP_RECEIVERS_SOURCE_IMAGE } from './types';
|
||||||
|
|
||||||
|
export interface ReceiverMetadata {
|
||||||
|
icon: string;
|
||||||
|
title: string;
|
||||||
|
externalUrl?: string;
|
||||||
|
warning?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const onCallReceiverICon = GRAFANA_APP_RECEIVERS_SOURCE_IMAGE[SupportedPlugin.OnCall];
|
||||||
|
const onCallReceiverTitle = 'Grafana OnCall';
|
||||||
|
|
||||||
|
const onCallReceiverMeta: ReceiverMetadata = {
|
||||||
|
title: onCallReceiverTitle,
|
||||||
|
icon: onCallReceiverICon,
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useReceiversMetadata = (receivers: Receiver[]): Map<Receiver, ReceiverMetadata> => {
|
||||||
|
const { installed: isOnCallEnabled } = usePluginBridge(SupportedPlugin.OnCall);
|
||||||
|
const { data: onCallIntegrations = [] } = onCallApi.useGrafanaOnCallIntegrationsQuery(undefined, {
|
||||||
|
skip: !isOnCallEnabled,
|
||||||
|
});
|
||||||
|
|
||||||
|
return useMemo(() => {
|
||||||
|
const result = new Map<Receiver, ReceiverMetadata>();
|
||||||
|
|
||||||
|
receivers.forEach((receiver) => {
|
||||||
|
const onCallReceiver = receiver.grafana_managed_receiver_configs?.find((c) => c.type === ReceiverTypes.OnCall);
|
||||||
|
|
||||||
|
if (onCallReceiver) {
|
||||||
|
if (!isOnCallEnabled) {
|
||||||
|
result.set(receiver, {
|
||||||
|
...onCallReceiverMeta,
|
||||||
|
warning: 'Grafana OnCall is not enabled',
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const matchingOnCallIntegration = onCallIntegrations.find(
|
||||||
|
(i) => i.integration_url === onCallReceiver.settings.url
|
||||||
|
);
|
||||||
|
|
||||||
|
result.set(receiver, {
|
||||||
|
...onCallReceiverMeta,
|
||||||
|
externalUrl: matchingOnCallIntegration
|
||||||
|
? createBridgeURL(SupportedPlugin.OnCall, `/integrations/${matchingOnCallIntegration.value}`)
|
||||||
|
: undefined,
|
||||||
|
warning: matchingOnCallIntegration ? undefined : 'OnCall Integration no longer exists',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}, [isOnCallEnabled, receivers, onCallIntegrations]);
|
||||||
|
};
|
@ -1,8 +1,9 @@
|
|||||||
|
import { uniqueId } from 'lodash';
|
||||||
import { rest } from 'msw';
|
import { rest } from 'msw';
|
||||||
import { setupServer, SetupServer } from 'msw/node';
|
import { setupServer, SetupServer } from 'msw/node';
|
||||||
import 'whatwg-fetch';
|
import 'whatwg-fetch';
|
||||||
|
|
||||||
import { DataSourceInstanceSettings } from '@grafana/data';
|
import { DataSourceInstanceSettings, PluginMeta } from '@grafana/data';
|
||||||
import { setBackendSrv } from '@grafana/runtime';
|
import { setBackendSrv } from '@grafana/runtime';
|
||||||
import {
|
import {
|
||||||
PromBuildInfoResponse,
|
PromBuildInfoResponse,
|
||||||
@ -17,13 +18,18 @@ import {
|
|||||||
AlertManagerCortexConfig,
|
AlertManagerCortexConfig,
|
||||||
AlertmanagerReceiver,
|
AlertmanagerReceiver,
|
||||||
EmailConfig,
|
EmailConfig,
|
||||||
|
GrafanaManagedReceiverConfig,
|
||||||
MatcherOperator,
|
MatcherOperator,
|
||||||
Route,
|
Route,
|
||||||
} from '../../../plugins/datasource/alertmanager/types';
|
} from '../../../plugins/datasource/alertmanager/types';
|
||||||
|
import { NotifierDTO } from '../../../types';
|
||||||
|
|
||||||
|
import { CreateIntegrationDTO, NewOnCallIntegrationDTO, OnCallIntegrationDTO } from './api/onCallApi';
|
||||||
import { AlertingQueryResponse } from './state/AlertingQueryRunner';
|
import { AlertingQueryResponse } from './state/AlertingQueryRunner';
|
||||||
|
|
||||||
class AlertmanagerConfigBuilder {
|
type Configurator<T> = (builder: T) => T;
|
||||||
|
|
||||||
|
export class AlertmanagerConfigBuilder {
|
||||||
private alertmanagerConfig: AlertmanagerConfig = { receivers: [] };
|
private alertmanagerConfig: AlertmanagerConfig = { receivers: [] };
|
||||||
|
|
||||||
addReceivers(configure: (builder: AlertmanagerReceiverBuilder) => void): AlertmanagerConfigBuilder {
|
addReceivers(configure: (builder: AlertmanagerReceiverBuilder) => void): AlertmanagerConfigBuilder {
|
||||||
@ -92,14 +98,47 @@ class EmailConfigBuilder {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class GrafanaReceiverConfigBuilder {
|
||||||
|
private grafanaReceiverConfig: GrafanaManagedReceiverConfig = {
|
||||||
|
name: '',
|
||||||
|
type: '',
|
||||||
|
settings: {},
|
||||||
|
disableResolveMessage: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
withType(type: string): GrafanaReceiverConfigBuilder {
|
||||||
|
this.grafanaReceiverConfig.type = type;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
withName(name: string): GrafanaReceiverConfigBuilder {
|
||||||
|
this.grafanaReceiverConfig.name = name;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
addSetting(key: string, value: string): GrafanaReceiverConfigBuilder {
|
||||||
|
this.grafanaReceiverConfig.settings[key] = value;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
build() {
|
||||||
|
return this.grafanaReceiverConfig;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
class AlertmanagerReceiverBuilder {
|
class AlertmanagerReceiverBuilder {
|
||||||
private receiver: AlertmanagerReceiver = { name: '', email_configs: [] };
|
private receiver: AlertmanagerReceiver = { name: '', email_configs: [], grafana_managed_receiver_configs: [] };
|
||||||
|
|
||||||
withName(name: string): AlertmanagerReceiverBuilder {
|
withName(name: string): AlertmanagerReceiverBuilder {
|
||||||
this.receiver.name = name;
|
this.receiver.name = name;
|
||||||
return this;
|
return this;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
addGrafanaReceiverConfig(configure: Configurator<GrafanaReceiverConfigBuilder>): AlertmanagerReceiverBuilder {
|
||||||
|
this.receiver.grafana_managed_receiver_configs?.push(configure(new GrafanaReceiverConfigBuilder()).build());
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
addEmailConfig(configure: (builder: EmailConfigBuilder) => void): AlertmanagerReceiverBuilder {
|
addEmailConfig(configure: (builder: EmailConfigBuilder) => void): AlertmanagerReceiverBuilder {
|
||||||
const builder = new EmailConfigBuilder();
|
const builder = new EmailConfigBuilder();
|
||||||
configure(builder);
|
configure(builder);
|
||||||
@ -112,6 +151,35 @@ class AlertmanagerReceiverBuilder {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export class OnCallIntegrationBuilder {
|
||||||
|
private onCallIntegration: NewOnCallIntegrationDTO = {
|
||||||
|
id: uniqueId('oncall-integration-mock-'),
|
||||||
|
integration: '',
|
||||||
|
integration_url: '',
|
||||||
|
verbal_name: '',
|
||||||
|
connected_escalations_chains_count: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
withIntegration(integration: string): OnCallIntegrationBuilder {
|
||||||
|
this.onCallIntegration.integration = integration;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
withIntegrationUrl(integrationUrl: string): OnCallIntegrationBuilder {
|
||||||
|
this.onCallIntegration.integration_url = integrationUrl;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
withVerbalName(verbalName: string): OnCallIntegrationBuilder {
|
||||||
|
this.onCallIntegration.verbal_name = verbalName;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
build() {
|
||||||
|
return this.onCallIntegration;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export function mockApi(server: SetupServer) {
|
export function mockApi(server: SetupServer) {
|
||||||
return {
|
return {
|
||||||
getAlertmanagerConfig: (amName: string, configure: (builder: AlertmanagerConfigBuilder) => void) => {
|
getAlertmanagerConfig: (amName: string, configure: (builder: AlertmanagerConfigBuilder) => void) => {
|
||||||
@ -138,6 +206,71 @@ export function mockApi(server: SetupServer) {
|
|||||||
})
|
})
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
grafanaNotifiers: (response: NotifierDTO[]) => {
|
||||||
|
server.use(
|
||||||
|
rest.get(`api/alert-notifiers`, (req, res, ctx) => res(ctx.status(200), ctx.json<NotifierDTO[]>(response)))
|
||||||
|
);
|
||||||
|
},
|
||||||
|
|
||||||
|
plugins: {
|
||||||
|
getPluginSettings: (response: PluginMeta) => {
|
||||||
|
server.use(
|
||||||
|
rest.get(`api/plugins/${response.id}/settings`, (req, res, ctx) =>
|
||||||
|
res(ctx.status(200), ctx.json<PluginMeta>(response))
|
||||||
|
)
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
oncall: {
|
||||||
|
getOnCallIntegrations: (response: OnCallIntegrationDTO[]) => {
|
||||||
|
server.use(
|
||||||
|
rest.get(`api/plugin-proxy/grafana-oncall-app/api/internal/v1/alert_receive_channels`, (_, res, ctx) =>
|
||||||
|
res(ctx.status(200), ctx.json<OnCallIntegrationDTO[]>(response))
|
||||||
|
)
|
||||||
|
);
|
||||||
|
},
|
||||||
|
features: (response: string[]) => {
|
||||||
|
server.use(
|
||||||
|
rest.get(`api/plugin-proxy/grafana-oncall-app/api/internal/v1/features`, (_, res, ctx) =>
|
||||||
|
res(ctx.status(200), ctx.json<string[]>(response))
|
||||||
|
)
|
||||||
|
);
|
||||||
|
},
|
||||||
|
validateIntegrationName: (invalidNames: string[]) => {
|
||||||
|
server.use(
|
||||||
|
rest.get(
|
||||||
|
`api/plugin-proxy/grafana-oncall-app/api/internal/v1/alert_receive_channels/validate_name`,
|
||||||
|
(req, res, ctx) => {
|
||||||
|
const isValid = !invalidNames.includes(req.url.searchParams.get('verbal_name') ?? '');
|
||||||
|
return res(ctx.status(isValid ? 200 : 409), ctx.json<boolean>(isValid));
|
||||||
|
}
|
||||||
|
)
|
||||||
|
);
|
||||||
|
},
|
||||||
|
createIntegraion: () => {
|
||||||
|
server.use(
|
||||||
|
rest.post<CreateIntegrationDTO>(
|
||||||
|
`api/plugin-proxy/grafana-oncall-app/api/internal/v1/alert_receive_channels`,
|
||||||
|
async (req, res, ctx) => {
|
||||||
|
const body = await req.json<CreateIntegrationDTO>();
|
||||||
|
const integrationId = uniqueId('oncall-integration-');
|
||||||
|
|
||||||
|
return res(
|
||||||
|
ctx.status(200),
|
||||||
|
ctx.json<NewOnCallIntegrationDTO>({
|
||||||
|
id: integrationId,
|
||||||
|
integration: body.integration,
|
||||||
|
integration_url: `https://oncall-endpoint.example.com/${integrationId}`,
|
||||||
|
verbal_name: body.verbal_name,
|
||||||
|
connected_escalations_chains_count: 0,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
)
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
2648
public/app/features/alerting/unified/mockGrafanaNotifiers.ts
Normal file
2648
public/app/features/alerting/unified/mockGrafanaNotifiers.ts
Normal file
File diff suppressed because it is too large
Load Diff
@ -10,6 +10,8 @@ import {
|
|||||||
DataSourceJsonData,
|
DataSourceJsonData,
|
||||||
DataSourcePluginMeta,
|
DataSourcePluginMeta,
|
||||||
DataSourceRef,
|
DataSourceRef,
|
||||||
|
PluginMeta,
|
||||||
|
PluginType,
|
||||||
ScopedVars,
|
ScopedVars,
|
||||||
TestDataSourceResponse,
|
TestDataSourceResponse,
|
||||||
} from '@grafana/data';
|
} from '@grafana/data';
|
||||||
@ -689,3 +691,23 @@ export function getCloudRule(override?: Partial<CombinedRule>) {
|
|||||||
export function mockAlertWithState(state: GrafanaAlertState, labels?: {}): Alert {
|
export function mockAlertWithState(state: GrafanaAlertState, labels?: {}): Alert {
|
||||||
return { activeAt: '', annotations: {}, labels: labels || {}, state: state, value: '' };
|
return { activeAt: '', annotations: {}, labels: labels || {}, state: state, value: '' };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const onCallPluginMetaMock: PluginMeta = {
|
||||||
|
name: 'Grafana OnCall',
|
||||||
|
id: 'grafana-oncall-app',
|
||||||
|
type: PluginType.app,
|
||||||
|
module: 'plugins/grafana-oncall-app/module',
|
||||||
|
baseUrl: 'public/plugins/grafana-oncall-app',
|
||||||
|
info: {
|
||||||
|
author: { name: 'Grafana Labs' },
|
||||||
|
description: 'Grafana OnCall',
|
||||||
|
updated: '',
|
||||||
|
version: '',
|
||||||
|
links: [],
|
||||||
|
logos: {
|
||||||
|
small: '',
|
||||||
|
large: '',
|
||||||
|
},
|
||||||
|
screenshots: [],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
@ -1,26 +1,6 @@
|
|||||||
import { CloudNotifierType, NotificationChannelOption, NotifierDTO } from 'app/types';
|
import { CloudNotifierType, NotificationChannelOption, NotifierDTO } from 'app/types';
|
||||||
|
|
||||||
function option(
|
import { option } from './notifier-types';
|
||||||
propertyName: string,
|
|
||||||
label: string,
|
|
||||||
description: string,
|
|
||||||
rest: Partial<NotificationChannelOption> = {}
|
|
||||||
): NotificationChannelOption {
|
|
||||||
return {
|
|
||||||
propertyName,
|
|
||||||
label,
|
|
||||||
description,
|
|
||||||
element: 'input',
|
|
||||||
inputType: '',
|
|
||||||
required: false,
|
|
||||||
secure: false,
|
|
||||||
placeholder: '',
|
|
||||||
validationRule: '',
|
|
||||||
showWhen: { field: '', is: '' },
|
|
||||||
dependsOn: '',
|
|
||||||
...rest,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const basicAuthOption: NotificationChannelOption = option(
|
const basicAuthOption: NotificationChannelOption = option(
|
||||||
'basic_auth',
|
'basic_auth',
|
||||||
|
23
public/app/features/alerting/unified/utils/notifier-types.ts
Normal file
23
public/app/features/alerting/unified/utils/notifier-types.ts
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
import { NotificationChannelOption } from '../../../../types';
|
||||||
|
|
||||||
|
export function option(
|
||||||
|
propertyName: string,
|
||||||
|
label: string,
|
||||||
|
description: string,
|
||||||
|
rest: Partial<NotificationChannelOption> = {}
|
||||||
|
): NotificationChannelOption {
|
||||||
|
return {
|
||||||
|
propertyName,
|
||||||
|
label,
|
||||||
|
description,
|
||||||
|
element: 'input',
|
||||||
|
inputType: '',
|
||||||
|
required: false,
|
||||||
|
secure: false,
|
||||||
|
placeholder: '',
|
||||||
|
validationRule: '',
|
||||||
|
showWhen: { field: '', is: '' },
|
||||||
|
dependsOn: '',
|
||||||
|
...rest,
|
||||||
|
};
|
||||||
|
}
|
@ -55,9 +55,11 @@ export type GrafanaNotifierType =
|
|||||||
| 'victorops'
|
| 'victorops'
|
||||||
| 'pushover'
|
| 'pushover'
|
||||||
| 'LINE'
|
| 'LINE'
|
||||||
| 'kafka';
|
| 'kafka'
|
||||||
|
| 'wecom';
|
||||||
|
|
||||||
export type CloudNotifierType =
|
export type CloudNotifierType =
|
||||||
|
| 'oncall' // Only FE implementation for now
|
||||||
| 'email'
|
| 'email'
|
||||||
| 'pagerduty'
|
| 'pagerduty'
|
||||||
| 'pushover'
|
| 'pushover'
|
||||||
@ -122,6 +124,7 @@ export interface NotificationChannelOption {
|
|||||||
| 'input'
|
| 'input'
|
||||||
| 'select'
|
| 'select'
|
||||||
| 'checkbox'
|
| 'checkbox'
|
||||||
|
| 'radio'
|
||||||
| 'textarea'
|
| 'textarea'
|
||||||
| 'subform'
|
| 'subform'
|
||||||
| 'subform_array'
|
| 'subform_array'
|
||||||
@ -136,7 +139,7 @@ export interface NotificationChannelOption {
|
|||||||
secure: boolean;
|
secure: boolean;
|
||||||
selectOptions?: Array<SelectableValue<string>> | null;
|
selectOptions?: Array<SelectableValue<string>> | null;
|
||||||
defaultValue?: SelectableValue<string>;
|
defaultValue?: SelectableValue<string>;
|
||||||
showWhen: { field: string; is: string };
|
showWhen: { field: string; is: string | boolean };
|
||||||
validationRule: string;
|
validationRule: string;
|
||||||
subformOptions?: NotificationChannelOption[];
|
subformOptions?: NotificationChannelOption[];
|
||||||
dependsOn: string;
|
dependsOn: string;
|
||||||
|
Loading…
Reference in New Issue
Block a user