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';
|
||||
|
||||
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,
|
||||
}),
|
||||
});
|
||||
|
@ -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}
|
||||
|
@ -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!);
|
||||
|
||||
|
@ -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}
|
||||
/>
|
||||
|
@ -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,
|
||||
|
@ -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: () => ({}),
|
||||
});
|
||||
|
@ -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'],
|
||||
|
@ -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;
|
||||
|
@ -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);
|
||||
|
@ -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 () => {
|
||||
|
@ -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,
|
||||
},
|
||||
|
@ -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]}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
@ -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>
|
||||
)}
|
||||
|
@ -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}
|
||||
|
@ -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 {
|
||||
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)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
@ -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);
|
||||
}
|
||||
|
@ -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';
|
||||
|
@ -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 { 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;
|
||||
};
|
||||
|
||||
|
@ -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;
|
||||
};
|
||||
|
@ -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 { 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,
|
||||
})
|
||||
);
|
||||
}
|
||||
)
|
||||
);
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
|
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,
|
||||
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: [],
|
||||
},
|
||||
};
|
||||
|
@ -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',
|
||||
|
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'
|
||||
| '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;
|
||||
|
Loading…
Reference in New Issue
Block a user