Alerting: Custom contact point for OnCall in Grafana AM (#72021)

This commit is contained in:
Konrad Lalik 2023-09-06 12:33:35 +02:00 committed by GitHub
parent 13f4382214
commit 7baf9cc033
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
32 changed files with 4033 additions and 225 deletions

View File

@ -5,17 +5,27 @@ import { GrafanaTheme2 } from '@grafana/data';
import { useStyles2 } from '../../../themes';
export interface RadioButtonDotProps {
export interface RadioButtonDotProps<T> {
id: string;
name: string;
checked?: boolean;
value?: T;
disabled?: boolean;
label: React.ReactNode;
description?: string;
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);
return (
@ -25,11 +35,15 @@ export const RadioButtonDot = ({ id, name, label, checked, disabled, description
name={name}
type="radio"
checked={checked}
value={value}
disabled={disabled}
className={styles.input}
onChange={() => onChange && onChange(id)}
/>
{label}
<div>
{label}
{description && <div className={styles.description}>{description}</div>}
</div>
</label>
);
};
@ -84,4 +98,8 @@ const getStyles = (theme: GrafanaTheme2) => ({
gridTemplateColumns: `${theme.spacing(2)} auto`,
gap: theme.spacing(1),
}),
description: css({
fontSize: theme.typography.size.sm,
color: theme.colors.text.secondary,
}),
});

View File

@ -43,6 +43,30 @@ const options = [
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
options={options}
disabledOptions={disabledOptions}

View File

@ -8,11 +8,11 @@ import { RadioButtonList, RadioButtonListProps } from './RadioButtonList';
import mdx from './RadioButtonList.mdx';
const defaultOptions: Array<SelectableValue<string>> = [
{ label: 'Option 1', value: 'opt-1', description: 'A description of Option 1' },
{ label: 'Option 2', value: 'opt-2', description: 'A description of Option 2' },
{ label: 'Option 3', value: 'opt-3', description: 'A description of Option 3' },
{ label: 'Option 4', value: 'opt-4', description: 'A description of Option 4' },
{ label: 'Option 5', value: 'opt-5', description: 'A description of Option 5' },
{ label: 'Option 1', value: 'opt-1' },
{ label: 'Option 2', value: 'opt-2' },
{ label: 'Option 3', value: 'opt-3' },
{ label: 'Option 4', value: 'opt-4' },
{ label: 'Option 5', value: 'opt-5' },
];
const meta: Meta<typeof RadioButtonList> = {
@ -81,6 +81,18 @@ export const LongLabels: StoryFn<typeof RadioButtonList> = ({ disabled, disabled
</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 }) => {
const [selected, setSelected] = useState<string>(defaultOptions[0].value!);

View File

@ -23,7 +23,7 @@ export interface RadioButtonListProps<T> {
className?: string;
}
export function RadioButtonList<T>({
export function RadioButtonList<T extends string | number | readonly string[]>({
name,
id,
options,
@ -47,13 +47,14 @@ export function RadioButtonList<T>({
const handleChange = () => onChange && option.value && onChange(option.value);
return (
<RadioButtonDot
<RadioButtonDot<T>
key={index}
id={itemId}
name={name}
label={option.label}
description={option.description}
checked={isChecked}
value={option.value}
disabled={isDisabled}
onChange={handleChange}
/>

View File

@ -1,6 +1,6 @@
import { dateTime } from '@grafana/data';
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 { 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
export function withPerformanceLogging<TFunc extends (...args: any[]) => Promise<any>>(
func: TFunc,

View File

@ -27,6 +27,6 @@ export const backendSrvBaseQuery = (): BaseQueryFn<BackendSrvRequest> => async (
export const alertingApi = createApi({
reducerPath: 'alertingApi',
baseQuery: backendSrvBaseQuery(),
tagTypes: ['AlertmanagerChoice', 'AlertmanagerConfiguration'],
tagTypes: ['AlertmanagerChoice', 'AlertmanagerConfiguration', 'OnCallIntegrations'],
endpoints: () => ({}),
});

View File

@ -13,6 +13,7 @@ import {
ExternalAlertmanagersResponse,
Matcher,
} from '../../../../plugins/datasource/alertmanager/types';
import { NotifierDTO } from '../../../../types';
import { withPerformanceLogging } from '../Analytics';
import { matcherToOperator } from '../utils/alertmanager';
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>({
query: () => ({ url: '/api/v1/ngalert' }),
providesTags: ['AlertmanagerChoice'],

View File

@ -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';
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;
}
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({
endpoints: (build) => ({
getOnCallIntegrations: build.query<OnCallIntegrationsUrls, void>({
queryFn: async () => {
const integrations = await fetchOnCallIntegrations();
return { data: integrations };
grafanaOnCallIntegrations: build.query<OnCallIntegrationDTO[], void>({
query: () => ({
url: getProxyApiUrl('/api/internal/v1/alert_receive_channels/'),
// 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 {
const response = await lastValueFrom(
getBackendSrv().fetch<OnCallIntegrationsResponse>({
url: '/api/plugin-proxy/grafana-oncall-app/api/internal/v1/alert_receive_channels/',
showErrorAlert: false,
showSuccessAlert: false,
})
);
return response.data.map((result) => result.integration_url);
} catch (error) {
return [];
}
function isPaginatedResponse(
response: AlertReceiveChannelsResult
): response is OnCallPaginatedResult<OnCallIntegrationDTO> {
return 'results' in response && Array.isArray(response.results);
}
export const { useGrafanaOnCallIntegrationsQuery } = onCallApi;
export function isOnCallFetchError(error: unknown): error is FetchError<{ detail: string }> {
return isFetchError(error) && 'detail' in error.data;
}
export const { useGetOnCallIntegrationsQuery } = onCallApi;

View File

@ -1,14 +1,12 @@
import { render, waitFor, within, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { setupServer } from 'msw/node';
import React from 'react';
import { TestProvider } from 'test/helpers/TestProvider';
import { selectOptionInTest } from 'test/helpers/selectOptionInTest';
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 { backendSrv } from 'app/core/services/backend_srv';
import { contextSrv } from 'app/core/services/context_srv';
import store from 'app/core/store';
import {
@ -19,6 +17,7 @@ import {
import { AccessControlAction, ContactPointsState } from 'app/types';
import 'whatwg-fetch';
import 'core-js/stable/structured-clone';
import { fetchAlertManagerConfig, fetchStatus, testReceivers, updateAlertManagerConfig } from '../../api/alertmanager';
import { AlertmanagersChoiceResponse } from '../../api/alertmanagerApi';
@ -26,9 +25,11 @@ import { discoverAlertmanagerFeatures } from '../../api/buildInfo';
import { fetchNotifiers } from '../../api/grafana';
import * as receiversApi from '../../api/receiversApi';
import * as grafanaApp from '../../components/receivers/grafanaAppReceivers/grafanaApp';
import { mockApi, setupMswServer } from '../../mockApi';
import {
mockDataSource,
MockDataSourceSrv,
onCallPluginMetaMock,
someCloudAlertManagerConfig,
someCloudAlertManagerStatus,
someGrafanaAlertManagerConfig,
@ -150,21 +151,16 @@ const emptyContactPointsState: ContactPointsState = { receivers: {}, errorCount:
const useGetGrafanaReceiverTypeCheckerMock = jest.spyOn(grafanaApp, 'useGetGrafanaReceiverTypeChecker');
const server = setupMswServer();
describe('Receivers', () => {
const server = setupServer();
beforeAll(() => {
setBackendSrv(backendSrv);
server.listen({ onUnhandledRequest: 'error' });
});
afterAll(() => {
server.close();
});
beforeEach(() => {
server.resetHandlers();
jest.resetAllMocks();
mockApi(server).grafanaNotifiers(grafanaNotifiersMock);
mockApi(server).plugins.getPluginSettings(onCallPluginMetaMock);
useGetGrafanaReceiverTypeCheckerMock.mockReturnValue(() => undefined);
mocks.getAllDataSources.mockReturnValue(Object.values(dataSources));
mocks.api.fetchNotifiers.mockResolvedValue(grafanaNotifiersMock);

View File

@ -2,6 +2,7 @@ import { screen, render, within } from '@testing-library/react';
import React from 'react';
import { TestProvider } from 'test/helpers/TestProvider';
import { setBackendSrv } from '@grafana/runtime';
import {
AlertManagerCortexConfig,
GrafanaManagedReceiverConfig,
@ -10,7 +11,7 @@ import {
import { configureStore } from 'app/store/configureStore';
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 { enableRBAC, grantUserPermissions } from '../../mocks';
import { fetchGrafanaNotifiersAction } from '../../state/actions';
@ -18,7 +19,8 @@ import { GRAFANA_RULES_SOURCE_NAME } from '../../utils/datasource';
import { createUrl } from '../../utils/url';
import { ReceiversTable } from './ReceiversTable';
import * as grafanaApp from './grafanaAppReceivers/grafanaApp';
import * as receiversMeta from './grafanaAppReceivers/useReceiversMetadata';
import { ReceiverMetadata } from './grafanaAppReceivers/useReceiversMetadata';
const renderReceieversTable = async (
receivers: Receiver[],
@ -58,16 +60,17 @@ const mockNotifier = (type: NotifierType, name: string): NotifierDTO => ({
options: [],
});
jest.spyOn(onCallApi, 'useGetOnCallIntegrationsQuery');
const useGetGrafanaReceiverTypeCheckerMock = jest.spyOn(grafanaApp, 'useGetGrafanaReceiverTypeChecker');
const useReceiversMetadata = jest.spyOn(receiversMeta, 'useReceiversMetadata');
const useGetContactPointsStateMock = jest.spyOn(receiversApi, 'useGetContactPointsState');
setBackendSrv(backendSrv);
describe('ReceiversTable', () => {
beforeEach(() => {
jest.resetAllMocks();
const emptyContactPointsState: ContactPointsState = { receivers: {}, errorCount: 0 };
useGetContactPointsStateMock.mockReturnValue(emptyContactPointsState);
useGetGrafanaReceiverTypeCheckerMock.mockReturnValue(() => undefined);
useReceiversMetadata.mockReturnValue(new Map<Receiver, ReceiverMetadata>());
});
it('render receivers with grafana notifiers', async () => {

View File

@ -26,9 +26,8 @@ import { ProvisioningBadge } from '../Provisioning';
import { ActionIcon } from '../rules/ActionIcon';
import { ReceiversSection } from './ReceiversSection';
import { GrafanaAppBadge } from './grafanaAppReceivers/GrafanaAppBadge';
import { useGetReceiversWithGrafanaAppTypes } from './grafanaAppReceivers/grafanaApp';
import { ReceiverWithTypes } from './grafanaAppReceivers/types';
import { ReceiverMetadataBadge } from './grafanaAppReceivers/ReceiverMetadataBadge';
import { ReceiverMetadata, useReceiversMetadata } from './grafanaAppReceivers/useReceiversMetadata';
import { AlertmanagerConfigHealth, useAlertmanagerConfigHealth } from './useAlertmanagerConfigHealth';
interface UpdateActionProps extends ActionProps {
@ -183,6 +182,7 @@ interface ReceiverItem {
types: string[];
provisioned?: boolean;
grafanaAppReceiverType?: SupportedPlugin;
metadata?: ReceiverMetadata;
}
interface NotifierStatus {
@ -299,6 +299,7 @@ export const ReceiversTable = ({ config, alertManagerName }: Props) => {
const configHealth = useAlertmanagerConfigHealth(config.alertmanager_config);
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
const [receiverToDelete, setReceiverToDelete] = useState<string>();
@ -325,10 +326,11 @@ export const ReceiversTable = ({ config, alertManagerName }: Props) => {
setReceiverToDelete(undefined);
};
const receivers = useGetReceiversWithGrafanaAppTypes(config.alertmanager_config.receivers ?? []);
const rows: RowItemTableProps[] = useMemo(() => {
const receivers = config.alertmanager_config.receivers ?? [];
return (
receivers?.map((receiver: ReceiverWithTypes) => ({
receivers.map((receiver) => ({
id: receiver.name,
data: {
name: receiver.name,
@ -340,12 +342,12 @@ export const ReceiversTable = ({ config, alertManagerName }: Props) => {
return type;
}
),
grafanaAppReceiverType: receiver.grafanaAppReceiverType,
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(
alertManagerName,
@ -472,8 +474,8 @@ function useGetColumns(
{
id: 'type',
label: 'Type',
renderCell: ({ data: { types, grafanaAppReceiverType } }) => (
<>{grafanaAppReceiverType ? <GrafanaAppBadge grafanaAppType={grafanaAppReceiverType} /> : types.join(', ')}</>
renderCell: ({ data: { types, metadata } }) => (
<>{metadata ? <ReceiverMetadataBadge metadata={metadata} /> : types.join(', ')}</>
),
size: 2,
},

View File

@ -17,6 +17,8 @@ export interface Props<R extends ChannelValues> {
errors?: FieldErrors<R>;
pathPrefix?: string;
readOnly?: boolean;
customValidators?: Record<string, React.ComponentProps<typeof OptionField>['customValidator']>;
}
export function ChannelOptions<R extends ChannelValues>({
@ -27,6 +29,7 @@ export function ChannelOptions<R extends ChannelValues>({
errors,
pathPrefix = '',
readOnly = false,
customValidators = {},
}: Props<R>): JSX.Element {
const { watch } = useFormContext<ReceiverFormValues<R>>();
const currentFormValues = watch(); // react hook form types ARE LYING!
@ -78,6 +81,7 @@ export function ChannelOptions<R extends ChannelValues>({
pathPrefix={pathPrefix}
pathSuffix={option.secure ? 'secureSettings.' : 'settings.'}
option={option}
customValidator={customValidators[option.propertyName]}
/>
);
})}

View File

@ -1,21 +1,24 @@
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 { GrafanaTheme2, SelectableValue } from '@grafana/data';
import { Alert, Button, Field, InputControl, Select, useStyles2 } from '@grafana/ui';
import { NotifierDTO } from 'app/types';
import { useUnifiedAlertingSelector } from '../../../hooks/useUnifiedAlertingSelector';
import { ChannelValues, CommonSettingsComponentType } from '../../../types/receiver-form';
import { OnCallIntegrationType } from '../grafanaAppReceivers/onCall/useOnCallIntegration';
import { ChannelOptions } from './ChannelOptions';
import { CollapsibleSection } from './CollapsibleSection';
import { Notifier } from './notifiers';
interface Props<R extends FieldValues> {
defaultValues: R;
initialValues?: R;
pathPrefix: string;
notifiers: NotifierDTO[];
notifiers: Notifier[];
onDuplicate: () => void;
onTest?: () => void;
commonSettingsComponent: CommonSettingsComponentType;
@ -25,10 +28,13 @@ interface Props<R extends FieldValues> {
onDelete?: () => void;
isEditable?: boolean;
isTestable?: boolean;
customValidators?: React.ComponentProps<typeof ChannelOptions>['customValidators'];
}
export function ChannelSubForm<R extends ChannelValues>({
defaultValues,
initialValues,
pathPrefix,
onDuplicate,
onDelete,
@ -39,13 +45,20 @@ export function ChannelSubForm<R extends ChannelValues>({
commonSettingsComponent: CommonSettingsComponent,
isEditable = true,
isTestable,
customValidators = {},
}: Props<R>): JSX.Element {
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 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);
// 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(() => {
register(`${pathPrefix}.__id`);
/* Need to manually register secureFields or else they'll
@ -53,6 +66,26 @@ export function ChannelSubForm<R extends ChannelValues>({
register(`${pathPrefix}.secureFields`);
}, [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 onResetSecureField = (key: string) => {
@ -66,12 +99,15 @@ export function ChannelSubForm<R extends ChannelValues>({
const typeOptions = useMemo(
(): SelectableValue[] =>
notifiers
.map(({ name, type }) => ({
sortBy(notifiers, ({ dto, meta }) => [meta?.order ?? 0, dto.name])
// .notifiers.sort((a, b) => a.dto.name.localeCompare(b.dto.name))
.map<SelectableValue>(({ dto: { name, type }, meta }) => ({
label: name,
value: type,
}))
.sort((a, b) => a.label.localeCompare(b.label)),
description: meta?.description,
isDisabled: meta ? !meta.enabled : false,
imgUrl: meta?.iconUrl,
})),
[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 aren't mandatory options, all options will be shown without collapse
const mandatoryOptions = notifier?.options.filter((o) => o.required);
const optionalOptions = notifier?.options.filter((o) => !o.required);
const mandatoryOptions = notifier?.dto.options.filter((o) => o.required);
const optionalOptions = notifier?.dto.options.filter((o) => !o.required);
const contactPointTypeInputId = `contact-point-type-${pathPrefix}`;
@ -98,7 +134,7 @@ export function ChannelSubForm<R extends ChannelValues>({
<div>
<Field label="Integration" htmlFor={contactPointTypeInputId} data-testid={`${pathPrefix}type`}>
<InputControl
name={name('type')}
name={fieldName('type')}
defaultValue={defaultValues.type}
render={({ field: { ref, onChange, ...field } }) => (
<Select
@ -116,7 +152,7 @@ export function ChannelSubForm<R extends ChannelValues>({
</Field>
</div>
<div className={styles.buttons}>
{isTestable && onTest && (
{isTestable && onTest && isTestAvailable && (
<Button
disabled={testingReceiver}
size="xs"
@ -159,12 +195,13 @@ export function ChannelSubForm<R extends ChannelValues>({
onResetSecureField={onResetSecureField}
pathPrefix={pathPrefix}
readOnly={!isEditable}
customValidators={customValidators}
/>
{!!(mandatoryOptions?.length && optionalOptions?.length) && (
<CollapsibleSection label={`Optional ${notifier.name} settings`}>
{notifier.info !== '' && (
<CollapsibleSection label={`Optional ${notifier.dto.name} settings`}>
{notifier.dto.info !== '' && (
<Alert title="" severity="info">
{notifier.info}
{notifier.dto.info}
</Alert>
)}
<ChannelOptions<R>
@ -175,6 +212,7 @@ export function ChannelSubForm<R extends ChannelValues>({
errors={errors}
pathPrefix={pathPrefix}
readOnly={!isEditable}
customValidators={customValidators}
/>
</CollapsibleSection>
)}

View File

@ -16,6 +16,7 @@ import {
import { CloudCommonChannelSettings } from './CloudCommonChannelSettings';
import { ReceiverForm } from './ReceiverForm';
import { Notifier } from './notifiers';
interface Props {
alertManagerSourceName: string;
@ -32,6 +33,8 @@ const defaultChannelValues: CloudChannelValues = Object.freeze({
type: 'email',
});
const cloudNotifiers = cloudNotifierTypes.map<Notifier>((n) => ({ dto: n }));
export const CloudReceiverForm = ({ existing, alertManagerSourceName, config }: Props) => {
const dispatch = useDispatch();
const isVanillaAM = isVanillaPrometheusAlertManagerDataSource(alertManagerSourceName);
@ -44,9 +47,9 @@ export const CloudReceiverForm = ({ existing, alertManagerSourceName, config }:
return cloudReceiverToFormValues(existing, cloudNotifierTypes);
}, [existing]);
const onSubmit = (values: ReceiverFormValues<CloudChannelValues>) => {
const onSubmit = async (values: ReceiverFormValues<CloudChannelValues>) => {
const newReceiver = formValuesToCloudReceiver(values, defaultChannelValues);
dispatch(
await dispatch(
updateAlertManagerConfigAction({
newConfig: updateConfigWithReceiver(config, newReceiver, existing?.name),
oldConfig: config,
@ -79,7 +82,7 @@ export const CloudReceiverForm = ({ existing, alertManagerSourceName, config }:
config={config}
onSubmit={onSubmit}
initialValues={existingValue}
notifiers={cloudNotifierTypes}
notifiers={cloudNotifiers}
alertManagerSourceName={alertManagerSourceName}
defaultItem={defaultChannelValues}
takenReceiverNames={takenReceiverNames}

View File

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

View File

@ -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 {
AlertManagerCortexConfig,
GrafanaManagedContactPoint,
@ -9,12 +9,8 @@ import {
} from 'app/plugins/datasource/alertmanager/types';
import { useDispatch } from 'app/types';
import { useUnifiedAlertingSelector } from '../../../hooks/useUnifiedAlertingSelector';
import {
fetchGrafanaNotifiersAction,
testReceiversAction,
updateAlertManagerConfigAction,
} from '../../../state/actions';
import { alertmanagerApi } from '../../../api/alertmanagerApi';
import { testReceiversAction, updateAlertManagerConfigAction } from '../../../state/actions';
import { GrafanaChannelValues, ReceiverFormValues } from '../../../types/receiver-form';
import { GRAFANA_RULES_SOURCE_NAME, isVanillaPrometheusAlertManagerDataSource } from '../../../utils/datasource';
import {
@ -24,10 +20,12 @@ import {
updateConfigWithReceiver,
} from '../../../utils/receiver-form';
import { ProvisionedResource, ProvisioningAlert } from '../../Provisioning';
import { useOnCallIntegration } from '../grafanaAppReceivers/onCall/useOnCallIntegration';
import { GrafanaCommonChannelSettings } from './GrafanaCommonChannelSettings';
import { ReceiverForm } from './ReceiverForm';
import { TestContactPointModal } from './TestContactPointModal';
import { Notifier } from './notifiers';
interface Props {
alertManagerSourceName: string;
@ -45,35 +43,43 @@ const defaultChannelValues: GrafanaChannelValues = Object.freeze({
});
export const GrafanaReceiverForm = ({ existing, alertManagerSourceName, config }: Props) => {
const grafanaNotifiers = useUnifiedAlertingSelector((state) => state.grafanaNotifiers);
const [testChannelValues, setTestChannelValues] = useState<GrafanaChannelValues>();
const dispatch = useDispatch();
useEffect(() => {
if (!(grafanaNotifiers.result || grafanaNotifiers.loading)) {
dispatch(fetchGrafanaNotifiersAction());
}
}, [grafanaNotifiers, dispatch]);
const {
onCallNotifierMeta,
extendOnCallNotifierFeatures,
extendOnCallReceivers,
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
const [existingValue, id2original] = useMemo((): [
ReceiverFormValues<GrafanaChannelValues> | undefined,
Record<string, GrafanaManagedReceiverConfig>,
] => {
if (!existing || !grafanaNotifiers.result) {
if (!existing || isLoadingNotifiers || isLoadingOnCallIntegration) {
return [undefined, {}];
}
return grafanaReceiverToFormValues(existing, grafanaNotifiers.result!);
}, [existing, grafanaNotifiers.result]);
const onSubmit = (values: ReceiverFormValues<GrafanaChannelValues>) => {
const notifiers = grafanaNotifiers.result;
return grafanaReceiverToFormValues(extendOnCallReceivers(existing), grafanaNotifiers);
}, [existing, isLoadingNotifiers, grafanaNotifiers, extendOnCallReceivers, isLoadingOnCallIntegration]);
const newReceiver = formValuesToGrafanaReceiver(values, id2original, defaultChannelValues, notifiers ?? []);
dispatch(
const onSubmit = async (values: ReceiverFormValues<GrafanaChannelValues>) => {
const newReceiver = formValuesToGrafanaReceiver(values, id2original, defaultChannelValues, grafanaNotifiers);
const receiverWithOnCall = await createOnCallIntegrations(newReceiver);
const newConfig = updateConfigWithReceiver(config, receiverWithOnCall, existing?.name);
await dispatch(
updateAlertManagerConfigAction({
newConfig: updateConfigWithReceiver(config, newReceiver, existing?.name),
newConfig: newConfig,
oldConfig: config,
alertManagerSourceName: GRAFANA_RULES_SOURCE_NAME,
successMessage: existing ? 'Contact point updated.' : 'Contact point created',
@ -123,32 +129,51 @@ export const GrafanaReceiverForm = ({ existing, alertManagerSourceName, config }
const isEditable = isManageableAlertManagerDataSource && !hasProvisionedItems;
const isTestable = isManageableAlertManagerDataSource || hasProvisionedItems;
if (grafanaNotifiers.result) {
return (
<>
{hasProvisionedItems && <ProvisioningAlert resource={ProvisionedResource.ContactPoint} />}
<ReceiverForm<GrafanaChannelValues>
isEditable={isEditable}
isTestable={isTestable}
config={config}
onSubmit={onSubmit}
initialValues={existingValue}
onTestChannel={onTestChannel}
notifiers={grafanaNotifiers.result}
alertManagerSourceName={alertManagerSourceName}
defaultItem={defaultChannelValues}
takenReceiverNames={takenReceiverNames}
commonSettingsComponent={GrafanaCommonChannelSettings}
/>
<TestContactPointModal
onDismiss={() => setTestChannelValues(undefined)}
isOpen={!!testChannelValues}
onTest={(alert) => testNotification(alert)}
/>
</>
);
} else {
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 (
<>
{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} />}
<ReceiverForm<GrafanaChannelValues>
isEditable={isEditable}
isTestable={isTestable}
config={config}
onSubmit={onSubmit}
initialValues={existingValue}
onTestChannel={onTestChannel}
notifiers={notifiers}
alertManagerSourceName={alertManagerSourceName}
defaultItem={{ ...defaultChannelValues }}
takenReceiverNames={takenReceiverNames}
commonSettingsComponent={GrafanaCommonChannelSettings}
customValidators={onCallFormValidators}
/>
<TestContactPointModal
onDismiss={() => setTestChannelValues(undefined)}
isOpen={!!testChannelValues}
onTest={(alert) => testNotification(alert)}
/>
</>
);
};

View File

@ -1,36 +1,40 @@
import { css } from '@emotion/css';
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 { isFetchError } from '@grafana/runtime';
import { Alert, Button, Field, Input, LinkButton, useStyles2 } from '@grafana/ui';
import { useAppNotification } from 'app/core/copy/appNotification';
import { useCleanup } from 'app/core/hooks/useCleanup';
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 { useUnifiedAlertingSelector } from '../../../hooks/useUnifiedAlertingSelector';
import { ChannelValues, CommonSettingsComponentType, ReceiverFormValues } from '../../../types/receiver-form';
import { makeAMLink } from '../../../utils/misc';
import { initialAsyncRequestState } from '../../../utils/redux';
import { ChannelSubForm } from './ChannelSubForm';
import { DeletedSubForm } from './fields/DeletedSubform';
import { Notifier } from './notifiers';
import { normalizeFormValues } from './util';
interface Props<R extends ChannelValues> {
config: AlertManagerCortexConfig;
notifiers: NotifierDTO[];
notifiers: Notifier[];
defaultItem: R;
alertManagerSourceName: string;
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
commonSettingsComponent: CommonSettingsComponentType;
initialValues?: ReceiverFormValues<R>;
isEditable: boolean;
isTestable?: boolean;
customValidators?: React.ComponentProps<typeof ChannelSubForm>['customValidators'];
}
export function ReceiverForm<R extends ChannelValues>({
@ -45,6 +49,7 @@ export function ReceiverForm<R extends ChannelValues>({
commonSettingsComponent,
isEditable,
isTestable,
customValidators,
}: Props<R>): JSX.Element {
const notifyApp = useAppNotification();
const styles = useStyles2(getStyles);
@ -64,17 +69,15 @@ export function ReceiverForm<R extends ChannelValues>({
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.
defaultValues: JSON.parse(JSON.stringify(defaultValues)),
defaultValues: structuredClone(defaultValues),
});
useCleanup((state) => (state.unifiedAlerting.saveAMConfig = initialAsyncRequestState));
const { loading } = useUnifiedAlertingSelector((state) => state.saveAMConfig);
const {
handleSubmit,
register,
formState: { errors },
formState: { errors, isSubmitting },
getValues,
} = formAPI;
@ -88,14 +91,25 @@ export function ReceiverForm<R extends ChannelValues>({
[takenReceiverNames]
);
const submitCallback = (values: ReceiverFormValues<R>) => {
onSubmit({
...values,
items: values.items.filter((item) => !item.__deleted),
});
const submitCallback = async (values: ReceiverFormValues<R>) => {
try {
await onSubmit({
...values,
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!');
};
@ -131,6 +145,7 @@ export function ReceiverForm<R extends ChannelValues>({
return (
<ChannelSubForm<R>
defaultValues={field}
initialValues={initialItem}
key={field.__id}
onDuplicate={() => {
const currentValues: R = getValues().items[index];
@ -152,6 +167,7 @@ export function ReceiverForm<R extends ChannelValues>({
commonSettingsComponent={commonSettingsComponent}
isEditable={isEditable}
isTestable={isTestable}
customValidators={customValidators}
/>
);
})}
@ -169,16 +185,16 @@ export function ReceiverForm<R extends ChannelValues>({
<div className={styles.buttons}>
{isEditable && (
<>
{loading && (
{isSubmitting && (
<Button disabled={true} icon="fa fa-spinner" variant="primary">
Saving...
</Button>
)}
{!loading && <Button type="submit">Save contact point</Button>}
{!isSubmitting && <Button type="submit">Save contact point</Button>}
</>
)}
<LinkButton
disabled={loading}
disabled={isSubmitting}
variant="secondary"
data-testid="cancel-button"
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);
}

View File

@ -3,7 +3,8 @@ import { isEmpty } from 'lodash';
import React, { FC, useEffect } from 'react';
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 { KeyValueMapInput } from './KeyValueMapInput';
@ -19,6 +20,7 @@ interface Props {
pathSuffix?: string;
error?: FieldError | DeepMap<any, FieldError>;
readOnly?: boolean;
customValidator?: (value: string) => boolean | string | Promise<boolean | string>;
}
export const OptionField: FC<Props> = ({
@ -29,6 +31,7 @@ export const OptionField: FC<Props> = ({
error,
defaultValue,
readOnly = false,
customValidator,
}) => {
const optionPath = `${pathPrefix}${pathSuffix}`;
@ -56,10 +59,11 @@ export const OptionField: FC<Props> = ({
}
return (
<Field
label={option.element !== 'checkbox' ? option.label : undefined}
label={option.element !== 'checkbox' && option.element !== 'radio' ? option.label : undefined}
description={option.description || undefined}
invalid={!!error}
error={error?.message}
data-testid={`${optionPath}${option.propertyName}`}
>
<OptionInput
id={`${optionPath}${option.propertyName}`}
@ -69,6 +73,7 @@ export const OptionField: FC<Props> = ({
pathPrefix={optionPath}
readOnly={readOnly}
pathIndex={pathPrefix}
customValidator={customValidator}
/>
</Field>
);
@ -81,7 +86,9 @@ const OptionInput: FC<Props & { id: string; pathIndex?: string }> = ({
pathPrefix = '',
pathIndex = '',
readOnly = false,
customValidator,
}) => {
const styles = useStyles2(getStyles);
const { control, register, unregister, getValues } = useFormContext();
const name = `${pathPrefix}${option.propertyName}`;
@ -114,7 +121,10 @@ const OptionInput: FC<Props & { id: string; pathIndex?: string }> = ({
type={option.inputType}
{...register(name, {
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,
})}
placeholder={option.placeholder}
@ -127,18 +137,43 @@ const OptionInput: FC<Props & { id: string; pathIndex?: string }> = ({
render={({ field: { onChange, ref, ...field } }) => (
<Select
disabled={readOnly}
{...field}
defaultValue={option.defaultValue}
options={option.selectOptions ?? undefined}
invalid={invalid}
onChange={(value) => onChange(value.value)}
{...field}
/>
)}
control={control}
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':
return (
<TextArea
@ -179,11 +214,14 @@ const OptionInput: FC<Props & { id: string; pathIndex?: string }> = ({
}
};
const styles = {
const getStyles = (theme: GrafanaTheme2) => ({
checkbox: css`
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) => {
return RegExp(validationRule).test(value) ? true : 'Invalid format';

View File

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

View File

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

View File

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

View File

@ -1,6 +1,6 @@
import { Receiver } from 'app/plugins/datasource/alertmanager/types';
import { useGetOnCallIntegrationsQuery } from '../../../api/onCallApi';
import { onCallApi } from '../../../api/onCallApi';
import { usePluginBridge } from '../../../hooks/usePluginBridge';
import { SupportedPlugin } from '../../../types/pluginBridges';
@ -9,7 +9,7 @@ import { AmRouteReceiver, ReceiverWithTypes } from './types';
export const useGetGrafanaReceiverTypeChecker = () => {
const { installed: isOnCallEnabled } = usePluginBridge(SupportedPlugin.OnCall);
const { data } = useGetOnCallIntegrationsQuery(undefined, {
const { data } = onCallApi.useGrafanaOnCallIntegrationsQuery(undefined, {
skip: !isOnCallEnabled,
});
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
return undefined;
};
return getGrafanaReceiverType;
};

View File

@ -1,19 +1,28 @@
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[]) => {
return integrationsUrls.includes(url);
};
export const isOnCallReceiver = (receiver: Receiver, integrationsUrls: string[]) => {
export const isOnCallReceiver = (receiver: Receiver, integrations: OnCallIntegrationDTO[]) => {
if (!receiver.grafana_managed_receiver_configs) {
return false;
}
// 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
const onlyOneIntegration = receiver.grafana_managed_receiver_configs.length === 1;
const isOncall = isInOnCallIntegrations(
const isOnCall = isInOnCallIntegrations(
receiver.grafana_managed_receiver_configs[0]?.settings?.url ?? '',
integrationsUrls
integrations.map((i) => i.integration_url)
);
return onlyOneIntegration && isOncall;
return onlyOneIntegration && isOnCall;
};

View File

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

View File

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

View File

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

View File

@ -1,8 +1,9 @@
import { uniqueId } from 'lodash';
import { rest } from 'msw';
import { setupServer, SetupServer } from 'msw/node';
import 'whatwg-fetch';
import { DataSourceInstanceSettings } from '@grafana/data';
import { DataSourceInstanceSettings, PluginMeta } from '@grafana/data';
import { setBackendSrv } from '@grafana/runtime';
import {
PromBuildInfoResponse,
@ -17,13 +18,18 @@ import {
AlertManagerCortexConfig,
AlertmanagerReceiver,
EmailConfig,
GrafanaManagedReceiverConfig,
MatcherOperator,
Route,
} from '../../../plugins/datasource/alertmanager/types';
import { NotifierDTO } from '../../../types';
import { CreateIntegrationDTO, NewOnCallIntegrationDTO, OnCallIntegrationDTO } from './api/onCallApi';
import { AlertingQueryResponse } from './state/AlertingQueryRunner';
class AlertmanagerConfigBuilder {
type Configurator<T> = (builder: T) => T;
export class AlertmanagerConfigBuilder {
private alertmanagerConfig: AlertmanagerConfig = { receivers: [] };
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 {
private receiver: AlertmanagerReceiver = { name: '', email_configs: [] };
private receiver: AlertmanagerReceiver = { name: '', email_configs: [], grafana_managed_receiver_configs: [] };
withName(name: string): AlertmanagerReceiverBuilder {
this.receiver.name = name;
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 {
const builder = new EmailConfigBuilder();
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) {
return {
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,
})
);
}
)
);
},
},
};
}

File diff suppressed because it is too large Load Diff

View File

@ -10,6 +10,8 @@ import {
DataSourceJsonData,
DataSourcePluginMeta,
DataSourceRef,
PluginMeta,
PluginType,
ScopedVars,
TestDataSourceResponse,
} from '@grafana/data';
@ -689,3 +691,23 @@ export function getCloudRule(override?: Partial<CombinedRule>) {
export function mockAlertWithState(state: GrafanaAlertState, labels?: {}): Alert {
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: [],
},
};

View File

@ -1,26 +1,6 @@
import { CloudNotifierType, NotificationChannelOption, NotifierDTO } from 'app/types';
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,
};
}
import { option } from './notifier-types';
const basicAuthOption: NotificationChannelOption = option(
'basic_auth',

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

View File

@ -55,9 +55,11 @@ export type GrafanaNotifierType =
| 'victorops'
| 'pushover'
| 'LINE'
| 'kafka';
| 'kafka'
| 'wecom';
export type CloudNotifierType =
| 'oncall' // Only FE implementation for now
| 'email'
| 'pagerduty'
| 'pushover'
@ -122,6 +124,7 @@ export interface NotificationChannelOption {
| 'input'
| 'select'
| 'checkbox'
| 'radio'
| 'textarea'
| 'subform'
| 'subform_array'
@ -136,7 +139,7 @@ export interface NotificationChannelOption {
secure: boolean;
selectOptions?: Array<SelectableValue<string>> | null;
defaultValue?: SelectableValue<string>;
showWhen: { field: string; is: string };
showWhen: { field: string; is: string | boolean };
validationRule: string;
subformOptions?: NotificationChannelOption[];
dependsOn: string;