Alerting: Improve secret fields handling in contact points (#104386)
* Refactor ChannelOptions and related components for improved secure field handling - Updated ChannelOptions to utilize integrationPrefix for path management. - Introduced getOptionMeta for dynamic option metadata handling. - Enhanced ChannelSubForm to manage secure fields and subform deletions. - Refactored OptionField and SubformField to support new secure field logic. - Adjusted types for better clarity and integration with the form context. * Fix Combobox sizing * Refactor ChannelSubForm and ChannelOptions for improved field handling - Updated ChannelOptions to enhance secure field management. - Refactored ChannelSubForm to replace Select with Combobox for better user experience. - Adjusted type options handling in ChannelSubForm to align with new Combobox implementation. - Cleaned up unused code and improved overall readability. * Refactor contact point components for improved secure field handling and remove obsolete secure settings field - Removed secureSettings from various components and tests to streamline the receiver configuration. - Updated GrafanaReceiverForm to manage secure fields more effectively. - Enhanced test cases for Slack contact points to ensure proper field behavior based on user input. - Introduced a factory for creating mock Grafana contact points and receiver configurations for better test coverage. * Improve conversion from form values to grafana receivers * Revert Combobox migration and bring back Select for contact point type selector * Update Grafana OnCall to Grafana IRM in notifier settings and enhance test coverage for SNS contact points - Renamed notifier settings from 'Grafana OnCall' to 'Grafana IRM' in mockGrafanaNotifiers. - Updated test cases in GrafanaReceiverForm to handle SNS contact points, including secure field management. - Improved test assertions and added new tests for SNS integration to ensure correct behavior and state management. - Refactored related components for better clarity and maintainability. * Add secret fiels removal tests * Fix mocks, remove Combobox changes * Update snapshots * Remove obsolete snapshot
This commit is contained in:
+2
-5
@@ -990,9 +990,7 @@ exports[`better eslint`] = {
|
||||
[0, 0, 0, "Do not use any type assertions.", "0"],
|
||||
[0, 0, 0, "Unexpected any. Specify a different type.", "1"],
|
||||
[0, 0, 0, "Unexpected any. Specify a different type.", "2"],
|
||||
[0, 0, 0, "Unexpected any. Specify a different type.", "3"],
|
||||
[0, 0, 0, "Unexpected any. Specify a different type.", "4"],
|
||||
[0, 0, 0, "Unexpected any. Specify a different type.", "5"]
|
||||
[0, 0, 0, "Unexpected any. Specify a different type.", "3"]
|
||||
],
|
||||
"public/app/features/alerting/unified/components/receivers/form/fields/SubformArrayField.tsx:5381": [
|
||||
[0, 0, 0, "Unexpected any. Specify a different type.", "0"],
|
||||
@@ -1033,8 +1031,7 @@ exports[`better eslint`] = {
|
||||
[0, 0, 0, "Do not use any type assertions.", "0"]
|
||||
],
|
||||
"public/app/features/alerting/unified/types/receiver-form.ts:5381": [
|
||||
[0, 0, 0, "Unexpected any. Specify a different type.", "0"],
|
||||
[0, 0, 0, "Unexpected any. Specify a different type.", "1"]
|
||||
[0, 0, 0, "Unexpected any. Specify a different type.", "0"]
|
||||
],
|
||||
"public/app/features/alerting/unified/utils/misc.test.ts:5381": [
|
||||
[0, 0, 0, "Unexpected any. Specify a different type.", "0"],
|
||||
|
||||
+1
-1
@@ -44,7 +44,7 @@ beforeEach(() => {
|
||||
grantUserPermissions([AccessControlAction.AlertingNotificationsRead, AccessControlAction.AlertingNotificationsWrite]);
|
||||
});
|
||||
|
||||
const getTemplatePreviewContent = async () => within(screen.getByTestId('template-preview')).getByTestId('mockeditor');
|
||||
const getTemplatePreviewContent = async () => within(screen.getByTestId('template-preview')).findByTestId('mockeditor');
|
||||
|
||||
const templatesSelectorTestId = 'existing-templates-selector';
|
||||
|
||||
|
||||
+4
-4
@@ -106,8 +106,8 @@ exports[`useContactPoints should return contact points with status 1`] = `
|
||||
"type": "oncall",
|
||||
Symbol(receiver_status): undefined,
|
||||
Symbol(receiver_metadata): {
|
||||
"description": "Sends notifications to Grafana OnCall",
|
||||
"name": "Grafana OnCall",
|
||||
"description": "Sends notifications to Grafana IRM",
|
||||
"name": "Grafana IRM",
|
||||
},
|
||||
Symbol(receiver_plugin_metadata): {
|
||||
"icon": "public/img/alerting/oncall_logo.svg",
|
||||
@@ -357,8 +357,8 @@ exports[`useContactPoints when having oncall plugin installed and no alert manag
|
||||
"type": "oncall",
|
||||
Symbol(receiver_status): undefined,
|
||||
Symbol(receiver_metadata): {
|
||||
"description": "Sends notifications to Grafana OnCall",
|
||||
"name": "Grafana OnCall",
|
||||
"description": "Sends notifications to Grafana IRM",
|
||||
"name": "Grafana IRM",
|
||||
},
|
||||
Symbol(receiver_plugin_metadata): {
|
||||
"description": "grafana-integration",
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
* and (if available) it will also fetch the status from the Grafana Managed status endpoint
|
||||
*/
|
||||
|
||||
import { merge, set } from 'lodash';
|
||||
import { useMemo } from 'react';
|
||||
|
||||
import { receiversApi } from 'app/features/alerting/unified/api/receiversK8sApi';
|
||||
@@ -13,11 +12,7 @@ import { BaseAlertmanagerArgs, Skippable } from 'app/features/alerting/unified/t
|
||||
import { cloudNotifierTypes } from 'app/features/alerting/unified/utils/cloud-alertmanager-notifier-types';
|
||||
import { GRAFANA_RULES_SOURCE_NAME } from 'app/features/alerting/unified/utils/datasource';
|
||||
import { isK8sEntityProvisioned, shouldUseK8sApi } from 'app/features/alerting/unified/utils/k8s/utils';
|
||||
import {
|
||||
GrafanaManagedContactPoint,
|
||||
GrafanaManagedReceiverConfig,
|
||||
Receiver,
|
||||
} from 'app/plugins/datasource/alertmanager/types';
|
||||
import { GrafanaManagedContactPoint, Receiver } from 'app/plugins/datasource/alertmanager/types';
|
||||
|
||||
import { getAPINamespace } from '../../../../../api/utils';
|
||||
import { alertmanagerApi } from '../../api/alertmanagerApi';
|
||||
@@ -327,47 +322,6 @@ export function useDeleteContactPoint({ alertmanager }: BaseAlertmanagerArgs) {
|
||||
return useK8sApi ? deleteFromK8sAPI : deleteFromAlertmanagerConfiguration;
|
||||
}
|
||||
|
||||
/**
|
||||
* Turns a Grafana Managed receiver config into a format that can be sent to the k8s API
|
||||
*
|
||||
* When updating secure settings, we need to send a value of `true` for any secure setting that we want to keep the same.
|
||||
*
|
||||
* Any other setting that has a value in `secureSettings` will correspond to a new value for that setting -
|
||||
* so we should not tell the API that we want to preserve it. Those values will instead be sent within `settings`
|
||||
*/
|
||||
const mapIntegrationSettingsForK8s = (integration: GrafanaManagedReceiverConfig): GrafanaManagedReceiverConfig => {
|
||||
const { secureSettings, settings, ...restOfIntegration } = integration;
|
||||
const secureFields = Object.entries(secureSettings || {}).reduce((acc, [key, value]) => {
|
||||
// If a secure field has no (changed) value, then we tell the backend to persist it
|
||||
if (value === undefined) {
|
||||
return {
|
||||
...acc,
|
||||
[key]: true,
|
||||
};
|
||||
}
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
const mappedSecureSettings = Object.entries(secureSettings || {}).reduce((acc, [key, value]) => {
|
||||
// If the value is an empty string/falsy value, then we need to omit it from the payload
|
||||
// so the backend knows to remove it
|
||||
if (!value) {
|
||||
return acc;
|
||||
}
|
||||
|
||||
// Otherwise, we send the value of the secure field
|
||||
return set(acc, key, value);
|
||||
}, {});
|
||||
|
||||
// Merge settings properly with lodash so we don't lose any information from nested keys/secure settings
|
||||
const mergedSettings = merge({}, settings, mappedSecureSettings);
|
||||
|
||||
return {
|
||||
...restOfIntegration,
|
||||
secureFields,
|
||||
settings: mergedSettings,
|
||||
};
|
||||
};
|
||||
const grafanaContactPointToK8sReceiver = (
|
||||
contactPoint: GrafanaManagedContactPoint,
|
||||
id?: string,
|
||||
@@ -380,7 +334,7 @@ const grafanaContactPointToK8sReceiver = (
|
||||
},
|
||||
spec: {
|
||||
title: contactPoint.name,
|
||||
integrations: (contactPoint.grafana_managed_receiver_configs || []).map(mapIntegrationSettingsForK8s),
|
||||
integrations: contactPoint.grafana_managed_receiver_configs || [],
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
@@ -87,6 +87,7 @@ export const GlobalConfigForm = ({ config, alertManagerSourceName }: Props) => {
|
||||
option={option}
|
||||
error={errors[option.propertyName]}
|
||||
pathPrefix={''}
|
||||
secureFields={{}}
|
||||
/>
|
||||
))}
|
||||
<div>
|
||||
|
||||
+1
-1
@@ -17,7 +17,7 @@ exports[`new receiver should be able to test and save a receiver 1`] = `
|
||||
{
|
||||
"disableResolveMessage": false,
|
||||
"name": "test",
|
||||
"secureSettings": {},
|
||||
"secureFields": {},
|
||||
"settings": {
|
||||
"addresses": "tester@grafana.com",
|
||||
"singleEmail": false,
|
||||
|
||||
@@ -2,20 +2,29 @@ import * as React from 'react';
|
||||
import { DeepMap, FieldError, FieldErrors, useFormContext } from 'react-hook-form';
|
||||
|
||||
import { Field, SecretInput } from '@grafana/ui';
|
||||
import { NotificationChannelOption, NotificationChannelSecureFields } from 'app/types';
|
||||
import { NotificationChannelOption, NotificationChannelSecureFields, OptionMeta } from 'app/types';
|
||||
|
||||
import { ChannelValues, ReceiverFormValues } from '../../../types/receiver-form';
|
||||
import {
|
||||
ChannelValues,
|
||||
CloudChannelValues,
|
||||
GrafanaChannelValues,
|
||||
ReceiverFormValues,
|
||||
} from '../../../types/receiver-form';
|
||||
|
||||
import { OptionField } from './fields/OptionField';
|
||||
|
||||
export interface Props<R extends ChannelValues> {
|
||||
defaultValues: R;
|
||||
selectedChannelOptions: NotificationChannelOption[];
|
||||
secureFields: NotificationChannelSecureFields;
|
||||
|
||||
onResetSecureField: (key: string) => void;
|
||||
onDeleteSubform?: (propertyName: string) => void;
|
||||
errors?: FieldErrors<R>;
|
||||
pathPrefix?: string;
|
||||
/**
|
||||
* The path for the integration in the array of integrations.
|
||||
* This is used to access the settings and secure fields for the integration in a type-safe way.
|
||||
*/
|
||||
integrationPrefix: `items.${number}`;
|
||||
readOnly?: boolean;
|
||||
|
||||
customValidators?: Record<string, React.ComponentProps<typeof OptionField>['customValidator']>;
|
||||
@@ -25,14 +34,23 @@ export function ChannelOptions<R extends ChannelValues>({
|
||||
defaultValues,
|
||||
selectedChannelOptions,
|
||||
onResetSecureField,
|
||||
secureFields,
|
||||
onDeleteSubform,
|
||||
errors,
|
||||
pathPrefix = '',
|
||||
integrationPrefix,
|
||||
readOnly = false,
|
||||
customValidators = {},
|
||||
}: Props<R>): JSX.Element {
|
||||
const { watch } = useFormContext<ReceiverFormValues<R>>();
|
||||
const currentFormValues = watch(); // react hook form types ARE LYING!
|
||||
const { watch } = useFormContext<ReceiverFormValues<CloudChannelValues | GrafanaChannelValues>>();
|
||||
|
||||
const [settings, secureFields] = watch([`${integrationPrefix}.settings`, `${integrationPrefix}.secureFields`]);
|
||||
|
||||
// Note: settingsPath includes a trailing dot for OptionField, unlike the path used in watch()
|
||||
const settingsPath = `${integrationPrefix}.settings.` as const;
|
||||
|
||||
const getOptionMeta = (option: NotificationChannelOption): OptionMeta => ({
|
||||
required: determineRequired(option, settings, secureFields),
|
||||
readOnly: determineReadOnly(option, settings, secureFields),
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -41,9 +59,8 @@ export function ChannelOptions<R extends ChannelValues>({
|
||||
// Some options can be dependent on other options, this determines what is selected in the dependency options
|
||||
// I think this needs more thought.
|
||||
// pathPrefix = items.index.
|
||||
const paths = pathPrefix.split('.');
|
||||
const selectedOptionValue =
|
||||
paths.length >= 2 ? currentFormValues.items?.[Number(paths[1])].settings?.[option.showWhen.field] : undefined;
|
||||
// const paths = pathPrefix.split('.');
|
||||
const selectedOptionValue = settings?.[option.showWhen.field];
|
||||
|
||||
if (option.showWhen.field && selectedOptionValue !== option.showWhen.is) {
|
||||
return null;
|
||||
@@ -51,33 +68,77 @@ export function ChannelOptions<R extends ChannelValues>({
|
||||
|
||||
if (secureFields && secureFields[option.propertyName]) {
|
||||
return (
|
||||
<Field key={key} label={option.label} description={option.description}>
|
||||
<SecretInput onReset={() => onResetSecureField(option.propertyName)} isConfigured />
|
||||
<Field
|
||||
key={key}
|
||||
label={option.label}
|
||||
description={option.description}
|
||||
htmlFor={`${settingsPath}${option.propertyName}`}
|
||||
>
|
||||
<SecretInput
|
||||
id={`${settingsPath}${option.propertyName}`}
|
||||
onReset={() => onResetSecureField(option.propertyName)}
|
||||
isConfigured
|
||||
/>
|
||||
</Field>
|
||||
);
|
||||
}
|
||||
|
||||
const error: FieldError | DeepMap<any, FieldError> | undefined = (
|
||||
(option.secure ? errors?.secureSettings : errors?.settings) as DeepMap<any, FieldError> | undefined
|
||||
(option.secure ? errors?.secureFields : errors?.settings) as DeepMap<any, FieldError> | undefined
|
||||
)?.[option.propertyName];
|
||||
|
||||
const defaultValue = defaultValues?.settings?.[option.propertyName];
|
||||
|
||||
return (
|
||||
<OptionField
|
||||
onResetSecureField={onResetSecureField}
|
||||
secureFields={secureFields}
|
||||
onResetSecureField={onResetSecureField}
|
||||
onDeleteSubform={onDeleteSubform}
|
||||
defaultValue={defaultValue}
|
||||
readOnly={readOnly}
|
||||
key={key}
|
||||
error={error}
|
||||
pathPrefix={pathPrefix}
|
||||
pathSuffix={option.secure ? 'secureSettings.' : 'settings.'}
|
||||
pathPrefix={settingsPath}
|
||||
option={option}
|
||||
customValidator={customValidators[option.propertyName]}
|
||||
getOptionMeta={getOptionMeta}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const determineRequired = (
|
||||
option: NotificationChannelOption,
|
||||
settings: Record<string, unknown>,
|
||||
secureFields: NotificationChannelSecureFields
|
||||
) => {
|
||||
if (!option.required) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!option.dependsOn) {
|
||||
return option.required ? 'Required' : false;
|
||||
}
|
||||
|
||||
const dependentOn = Boolean(settings[option.dependsOn]) || Boolean(secureFields[option.dependsOn]);
|
||||
|
||||
if (dependentOn) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return 'Required';
|
||||
};
|
||||
|
||||
const determineReadOnly = (
|
||||
option: NotificationChannelOption,
|
||||
settings: Record<string, unknown>,
|
||||
secureFields: NotificationChannelSecureFields
|
||||
) => {
|
||||
if (!option.dependsOn) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return Boolean(settings[option.dependsOn]) || Boolean(secureFields[option.dependsOn]);
|
||||
};
|
||||
|
||||
@@ -1,31 +1,36 @@
|
||||
import { css } from '@emotion/css';
|
||||
import { sortBy } from 'lodash';
|
||||
import * as React from 'react';
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { Controller, FieldErrors, FieldValues, useFormContext } from 'react-hook-form';
|
||||
import { useEffect, useMemo } from 'react';
|
||||
import { Controller, FieldErrors, useFormContext, useWatch } from 'react-hook-form';
|
||||
|
||||
import { GrafanaTheme2, SelectableValue } from '@grafana/data';
|
||||
import { Alert, Button, Field, Select, Stack, Text, useStyles2 } from '@grafana/ui';
|
||||
import { Trans, t } from 'app/core/internationalization';
|
||||
|
||||
import { useUnifiedAlertingSelector } from '../../../hooks/useUnifiedAlertingSelector';
|
||||
import { ChannelValues, CommonSettingsComponentType } from '../../../types/receiver-form';
|
||||
import {
|
||||
ChannelValues,
|
||||
CloudChannelValues,
|
||||
CommonSettingsComponentType,
|
||||
GrafanaChannelValues,
|
||||
ReceiverFormValues,
|
||||
} 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> {
|
||||
interface Props<R extends ChannelValues> {
|
||||
defaultValues: R;
|
||||
initialValues?: R;
|
||||
pathPrefix: string;
|
||||
pathPrefix: `items.${number}.`;
|
||||
integrationIndex: number;
|
||||
notifiers: Notifier[];
|
||||
onDuplicate: () => void;
|
||||
onTest?: () => void;
|
||||
commonSettingsComponent: CommonSettingsComponentType;
|
||||
|
||||
secureFields?: Record<string, boolean>;
|
||||
errors?: FieldErrors<R>;
|
||||
onDelete?: () => void;
|
||||
isEditable?: boolean;
|
||||
@@ -38,74 +43,85 @@ export function ChannelSubForm<R extends ChannelValues>({
|
||||
defaultValues,
|
||||
initialValues,
|
||||
pathPrefix,
|
||||
integrationIndex,
|
||||
onDuplicate,
|
||||
onDelete,
|
||||
onTest,
|
||||
notifiers,
|
||||
errors,
|
||||
secureFields,
|
||||
commonSettingsComponent: CommonSettingsComponent,
|
||||
isEditable = true,
|
||||
isTestable,
|
||||
customValidators = {},
|
||||
}: Props<R>): JSX.Element {
|
||||
const styles = useStyles2(getStyles);
|
||||
const { control, watch, register, trigger, formState, setValue, getValues } =
|
||||
useFormContext<ReceiverFormValues<CloudChannelValues | GrafanaChannelValues>>();
|
||||
|
||||
const fieldName = useCallback((fieldName: string) => `${pathPrefix}${fieldName}`, [pathPrefix]);
|
||||
const channelFieldPath = `items.${integrationIndex}` as const;
|
||||
const typeFieldPath = `${channelFieldPath}.type` as const;
|
||||
const settingsFieldPath = `${channelFieldPath}.settings` as const;
|
||||
|
||||
const { control, watch, register, trigger, formState, setValue } = useFormContext();
|
||||
const selectedType = watch(fieldName('type')) ?? defaultValues.type; // nope, setting "default" does not work at all.
|
||||
const parse_mode = watch(fieldName('settings.parse_mode'));
|
||||
const selectedType = watch(typeFieldPath) ?? defaultValues.type;
|
||||
const parse_mode = watch(`${settingsFieldPath}.parse_mode`);
|
||||
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 onCallIntegrationType = watch(`${settingsFieldPath}.integration_type`);
|
||||
const isTestAvailable = onCallIntegrationType !== OnCallIntegrationType.NewIntegration;
|
||||
|
||||
useEffect(() => {
|
||||
register(`${pathPrefix}.__id`);
|
||||
register(`${channelFieldPath}.__id`);
|
||||
/* Need to manually register secureFields or else they'll
|
||||
be lost when testing a contact point */
|
||||
register(`${pathPrefix}.secureFields`);
|
||||
}, [register, pathPrefix]);
|
||||
register(`${channelFieldPath}.secureFields`);
|
||||
}, [register, channelFieldPath]);
|
||||
|
||||
// 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((v, { name, type }) => {
|
||||
const value = name ? v[name] : '';
|
||||
if (initialValues && name === fieldName('type') && value === initialValues.type && type === 'change') {
|
||||
setValue(fieldName('settings'), initialValues.settings);
|
||||
const subscription = watch((formValues, { name, type }) => {
|
||||
// @ts-expect-error name is valid key for formValues
|
||||
const value = name ? formValues[name] : '';
|
||||
if (initialValues && name === typeFieldPath && value === initialValues.type && type === 'change') {
|
||||
setValue(settingsFieldPath, initialValues.settings);
|
||||
}
|
||||
// Restore initial value of an existing oncall integration
|
||||
if (
|
||||
initialValues &&
|
||||
name === fieldName('settings.integration_type') &&
|
||||
name === `${settingsFieldPath}.integration_type` &&
|
||||
value === OnCallIntegrationType.ExistingIntegration
|
||||
) {
|
||||
setValue(fieldName('settings.url'), initialValues.settings.url);
|
||||
setValue(`${settingsFieldPath}.url`, initialValues.settings.url);
|
||||
}
|
||||
});
|
||||
|
||||
return () => subscription.unsubscribe();
|
||||
}, [selectedType, initialValues, setValue, fieldName, watch]);
|
||||
}, [selectedType, initialValues, setValue, settingsFieldPath, typeFieldPath, watch]);
|
||||
|
||||
const [_secureFields, setSecureFields] = useState<Record<string, boolean | ''>>(secureFields ?? {});
|
||||
// const [_secureFields, setSecureFields] = useState<Record<string, boolean | ''>>(secureFields ?? {});
|
||||
const formSecureFields = useWatch({ control, name: `${channelFieldPath}.secureFields` });
|
||||
|
||||
const onResetSecureField = (key: string) => {
|
||||
if (_secureFields[key]) {
|
||||
const updatedSecureFields = { ..._secureFields };
|
||||
updatedSecureFields[key] = '';
|
||||
setSecureFields(updatedSecureFields);
|
||||
setValue(`${pathPrefix}.secureFields`, updatedSecureFields);
|
||||
// formSecureFields might not be up to date if this function is called multiple times in a row
|
||||
const currentSecureFields = getValues(`${channelFieldPath}.secureFields`);
|
||||
if (currentSecureFields[key]) {
|
||||
setValue(`${channelFieldPath}.secureFields`, { ...currentSecureFields, [key]: '' });
|
||||
}
|
||||
};
|
||||
|
||||
const onDeleteSubform = (propertyName: string) => {
|
||||
const relatedSecureFields = Object.keys(formSecureFields).filter((key) => key.startsWith(propertyName));
|
||||
relatedSecureFields.forEach((key) => {
|
||||
onResetSecureField(key);
|
||||
});
|
||||
setValue(`${channelFieldPath}.settings.${propertyName}`, undefined);
|
||||
};
|
||||
|
||||
const typeOptions = useMemo(
|
||||
(): SelectableValue[] =>
|
||||
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 }) => ({
|
||||
sortBy(notifiers, ({ dto, meta }) => [meta?.order ?? 0, dto.name]).map<SelectableValue>(
|
||||
({ dto: { name, type }, meta }) => ({
|
||||
// @ts-expect-error ReactNode is supported
|
||||
label: (
|
||||
<Stack alignItems="center" gap={1}>
|
||||
@@ -116,7 +132,8 @@ export function ChannelSubForm<R extends ChannelValues>({
|
||||
value: type,
|
||||
description: meta?.description,
|
||||
isDisabled: meta ? !meta.enabled : false,
|
||||
})),
|
||||
})
|
||||
),
|
||||
[notifiers]
|
||||
);
|
||||
|
||||
@@ -137,8 +154,8 @@ export function ChannelSubForm<R extends ChannelValues>({
|
||||
const showTelegramWarning = isTelegram && !isParseModeNone;
|
||||
// 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?.dto.options.filter((o) => o.required);
|
||||
const optionalOptions = notifier?.dto.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}`;
|
||||
return (
|
||||
@@ -151,7 +168,8 @@ export function ChannelSubForm<R extends ChannelValues>({
|
||||
data-testid={`${pathPrefix}type`}
|
||||
>
|
||||
<Controller
|
||||
name={fieldName('type')}
|
||||
name={typeFieldPath}
|
||||
control={control}
|
||||
defaultValue={defaultValues.type}
|
||||
render={({ field: { ref, onChange, ...field } }) => (
|
||||
<Select
|
||||
@@ -163,8 +181,6 @@ export function ChannelSubForm<R extends ChannelValues>({
|
||||
onChange={(value) => onChange(value?.value)}
|
||||
/>
|
||||
)}
|
||||
control={control}
|
||||
rules={{ required: true }}
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
@@ -221,15 +237,15 @@ export function ChannelSubForm<R extends ChannelValues>({
|
||||
)}
|
||||
<ChannelOptions<R>
|
||||
defaultValues={defaultValues}
|
||||
selectedChannelOptions={mandatoryOptions?.length ? mandatoryOptions! : optionalOptions!}
|
||||
secureFields={_secureFields}
|
||||
selectedChannelOptions={mandatoryOptions.length ? mandatoryOptions : optionalOptions}
|
||||
errors={errors}
|
||||
onResetSecureField={onResetSecureField}
|
||||
pathPrefix={pathPrefix}
|
||||
onDeleteSubform={onDeleteSubform}
|
||||
integrationPrefix={channelFieldPath}
|
||||
readOnly={!isEditable}
|
||||
customValidators={customValidators}
|
||||
/>
|
||||
{!!(mandatoryOptions?.length && optionalOptions?.length) && (
|
||||
{!!(mandatoryOptions.length && optionalOptions.length) && (
|
||||
<CollapsibleSection
|
||||
label={t('alerting.channel-sub-form.label-section', 'Optional {{name}} settings', {
|
||||
name: notifier.dto.name,
|
||||
@@ -242,11 +258,11 @@ export function ChannelSubForm<R extends ChannelValues>({
|
||||
)}
|
||||
<ChannelOptions<R>
|
||||
defaultValues={defaultValues}
|
||||
selectedChannelOptions={optionalOptions!}
|
||||
secureFields={_secureFields}
|
||||
selectedChannelOptions={optionalOptions}
|
||||
onResetSecureField={onResetSecureField}
|
||||
onDeleteSubform={onDeleteSubform}
|
||||
errors={errors}
|
||||
pathPrefix={pathPrefix}
|
||||
integrationPrefix={channelFieldPath}
|
||||
readOnly={!isEditable}
|
||||
customValidators={customValidators}
|
||||
/>
|
||||
|
||||
+300
-7
@@ -17,6 +17,7 @@ import { AccessControlAction } from 'app/types';
|
||||
|
||||
import { AlertmanagerConfigBuilder, setupMswServer } from '../../../mockApi';
|
||||
import { grantUserPermissions } from '../../../mocks';
|
||||
import { alertingFactory } from '../../../mocks/server/db';
|
||||
import { captureRequests } from '../../../mocks/server/events';
|
||||
|
||||
import { GrafanaReceiverForm } from './GrafanaReceiverForm';
|
||||
@@ -33,9 +34,10 @@ const renderWithProvider = (
|
||||
{ historyOptions }
|
||||
);
|
||||
|
||||
setupMswServer();
|
||||
const server = setupMswServer();
|
||||
|
||||
const ui = {
|
||||
typeSelector: byTestId('items.0.type'),
|
||||
loadingIndicator: byText('Loading notifiers...'),
|
||||
integrationType: byLabelText('Integration'),
|
||||
onCallIntegrationType: byRole('radiogroup'),
|
||||
@@ -47,9 +49,48 @@ const ui = {
|
||||
},
|
||||
newOnCallIntegrationName: byRole('textbox', { name: /Integration name/ }),
|
||||
existingOnCallIntegrationSelect: (index: number) => byTestId(`items.${index}.settings.url`),
|
||||
saveButton: byRole('button', { name: /save contact point/i }),
|
||||
slack: {
|
||||
recipient: byRole('textbox', { name: /^Recipient/ }),
|
||||
token: byRole('textbox', { name: /^Token/ }),
|
||||
webhookUrl: byRole('textbox', { name: /^Webhook URL/ }),
|
||||
},
|
||||
sns: {
|
||||
apiUrl: byRole('textbox', { name: /The Amazon SNS API URL/ }),
|
||||
region: byRole('textbox', { name: /^Region/ }),
|
||||
accessKey: byRole('textbox', { name: /^Access Key/ }),
|
||||
secretKey: byRole('textbox', { name: /^Secret Key/ }),
|
||||
topicArn: byRole('textbox', { name: /^SNS topic ARN/ }),
|
||||
},
|
||||
webhook: {
|
||||
url: byRole('textbox', { name: /^URL/ }),
|
||||
tlsConfig: {
|
||||
header: byRole('heading', { name: /TLS/ }),
|
||||
caCertificate: byRole('textbox', { name: /^CA certificate/ }),
|
||||
clientCert: byRole('textbox', { name: /^Client certificate/ }),
|
||||
clientKey: byRole('textbox', { name: /^Client key/ }),
|
||||
deleteButton: byTestId('items.0.settings.tlsConfig.delete-button'),
|
||||
},
|
||||
optionalSettings: byRole('button', { name: /optional webhook settings/i }),
|
||||
},
|
||||
};
|
||||
|
||||
describe('GrafanaReceiverForm', () => {
|
||||
beforeAll(() => {
|
||||
const mockGetBoundingClientRect = jest.fn(() => ({
|
||||
width: 120,
|
||||
height: 120,
|
||||
top: 0,
|
||||
left: 0,
|
||||
bottom: 0,
|
||||
right: 0,
|
||||
}));
|
||||
|
||||
Object.defineProperty(Element.prototype, 'getBoundingClientRect', {
|
||||
value: mockGetBoundingClientRect,
|
||||
});
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
grantUserPermissions([
|
||||
AccessControlAction.AlertingNotificationsRead,
|
||||
@@ -57,6 +98,10 @@ describe('GrafanaReceiverForm', () => {
|
||||
]);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
server.events.removeAllListeners();
|
||||
});
|
||||
|
||||
it('handles nested secure fields correctly', async () => {
|
||||
const capturedRequests = captureRequests(
|
||||
(req) => req.url.includes('/v0alpha1/namespaces/default/receivers') && req.method === 'POST'
|
||||
@@ -67,7 +112,8 @@ describe('GrafanaReceiverForm', () => {
|
||||
await waitFor(() => expect(ui.loadingIndicator.query()).not.toBeInTheDocument());
|
||||
|
||||
// Select MQTT receiver and fill out basic required fields for contact point
|
||||
await clickSelectOption(await byTestId('items.0.type').find(), 'MQTT');
|
||||
await clickSelectOption(ui.typeSelector.get(), 'MQTT');
|
||||
|
||||
await type(screen.getByLabelText(/^name/i), 'mqtt contact point');
|
||||
await type(screen.getByLabelText(/broker url/i), 'broker url');
|
||||
await type(screen.getByLabelText(/topic/i), 'topic');
|
||||
@@ -77,7 +123,7 @@ describe('GrafanaReceiverForm', () => {
|
||||
await click(screen.getByRole('button', { name: /^Add$/i }));
|
||||
await type(screen.getByLabelText(/ca certificate/i), 'some cert');
|
||||
|
||||
await click(screen.getByRole('button', { name: /save contact point/i }));
|
||||
await click(ui.saveButton.get());
|
||||
|
||||
const [request] = await capturedRequests;
|
||||
const postRequestbody = await request.clone().json();
|
||||
@@ -93,6 +139,197 @@ describe('GrafanaReceiverForm', () => {
|
||||
expect(postRequestbody).toMatchSnapshot();
|
||||
});
|
||||
|
||||
describe('Slack contact point', () => {
|
||||
it('should disable webhook url field if the user typed the token', async () => {
|
||||
const { user } = renderWithProvider(<GrafanaReceiverForm />);
|
||||
|
||||
await waitFor(() => expect(ui.loadingIndicator.query()).not.toBeInTheDocument());
|
||||
|
||||
// Select Slack receiver
|
||||
await clickSelectOption(byTestId('items.0.type').get(), 'Slack');
|
||||
|
||||
// Enter a value in the recipient field (required)
|
||||
await user.type(ui.slack.recipient.get(), 'my-channel');
|
||||
|
||||
// Webhook URL field should be initially enabled
|
||||
const webhookUrlField = ui.slack.webhookUrl.get();
|
||||
expect(webhookUrlField).toBeEnabled();
|
||||
|
||||
// Enter a token value
|
||||
const tokenField = ui.slack.token.get();
|
||||
await user.type(tokenField, 'xoxb-my-token');
|
||||
|
||||
// Now the webhook URL field should be readonly
|
||||
expect(webhookUrlField).toHaveAttribute('readonly');
|
||||
});
|
||||
|
||||
it('should disable token field if the user typed the webhook URL', async () => {
|
||||
const { user } = renderWithProvider(<GrafanaReceiverForm />);
|
||||
|
||||
await waitFor(() => expect(ui.loadingIndicator.query()).not.toBeInTheDocument());
|
||||
|
||||
// Select Slack receiver
|
||||
await clickSelectOption(byTestId('items.0.type').get(), 'Slack');
|
||||
|
||||
// Token field should be initially enabled
|
||||
const tokenField = ui.slack.token.get();
|
||||
expect(tokenField).toBeEnabled();
|
||||
|
||||
// Enter a webhook URL value
|
||||
const webhookUrlField = ui.slack.webhookUrl.get();
|
||||
await user.type(webhookUrlField, 'https://hooks.slack.com/services/T123456/B123456/abcdef123456');
|
||||
|
||||
// Now the token field should be readonly
|
||||
expect(tokenField).toHaveAttribute('readonly');
|
||||
});
|
||||
|
||||
it('should display token field as readonly with a Reset button when editing contact point with configured token', async () => {
|
||||
// Create mock config for a Slack contact point using token
|
||||
const contactPoint = alertingFactory.alertmanager.grafana.contactPoint
|
||||
.withIntegrations((integrationFactory) => [integrationFactory.slack({ token: 'xoxb-my-token' }).build()])
|
||||
.build();
|
||||
|
||||
renderWithProvider(<GrafanaReceiverForm contactPoint={contactPoint} editMode={true} />);
|
||||
|
||||
await waitFor(() => expect(ui.loadingIndicator.query()).not.toBeInTheDocument());
|
||||
|
||||
const tokenField = ui.slack.token.get();
|
||||
const webhookUrlField = ui.slack.webhookUrl.get();
|
||||
|
||||
expect(tokenField).toHaveValue('configured');
|
||||
expect(tokenField).toBeDisabled();
|
||||
expect(screen.getByRole('button', { name: 'Reset' })).toBeInTheDocument();
|
||||
|
||||
expect(webhookUrlField).toHaveValue('');
|
||||
expect(webhookUrlField).toHaveAttribute('readonly');
|
||||
});
|
||||
it('should display webhook URL field as readonly with a Reset button when editing existing contact point with configured webhook URL', async () => {
|
||||
// Create mock config for a Slack contact point using webhook
|
||||
const contactPoint = alertingFactory.alertmanager.grafana.contactPoint
|
||||
.withIntegrations((integrationFactory) => [
|
||||
integrationFactory.slack({ url: 'https://slack.example.com' }).build(),
|
||||
])
|
||||
.build();
|
||||
|
||||
renderWithProvider(<GrafanaReceiverForm contactPoint={contactPoint} editMode={true} />);
|
||||
|
||||
await waitFor(() => expect(ui.loadingIndicator.query()).not.toBeInTheDocument());
|
||||
|
||||
const webhookField = ui.slack.webhookUrl.get();
|
||||
const tokenField = ui.slack.token.get();
|
||||
|
||||
expect(webhookField).toHaveValue('configured');
|
||||
expect(webhookField).toBeDisabled();
|
||||
expect(screen.getByRole('button', { name: 'Reset' })).toBeInTheDocument();
|
||||
|
||||
expect(tokenField).toHaveValue('');
|
||||
expect(tokenField).toHaveAttribute('readonly');
|
||||
});
|
||||
|
||||
it('clicking the Reset button when editing a Slack contact point with webhook should make token field editable again', async () => {
|
||||
// Create mock config for a Slack contact point using webhook URL
|
||||
const contactPoint = alertingFactory.alertmanager.grafana.contactPoint
|
||||
.withIntegrations((integrationFactory) => [
|
||||
integrationFactory.slack({ url: 'https://slack.example.com' }).build(),
|
||||
])
|
||||
.build();
|
||||
|
||||
const { user } = renderWithProvider(<GrafanaReceiverForm contactPoint={contactPoint} editMode={true} />);
|
||||
|
||||
await waitFor(() => expect(ui.loadingIndicator.query()).not.toBeInTheDocument());
|
||||
|
||||
// Initially, the token field should be readonly
|
||||
expect(ui.slack.token.get()).toHaveAttribute('readonly');
|
||||
|
||||
// Find and click the Reset button
|
||||
const resetButton = screen.getByRole('button', { name: 'Reset' });
|
||||
await user.click(resetButton);
|
||||
|
||||
// After resetting the webhook URL, the token field should be editable
|
||||
expect(ui.slack.token.get()).not.toHaveAttribute('readonly');
|
||||
|
||||
// And we should be able to enter a token value
|
||||
await user.type(ui.slack.token.get(), 'xoxb-new-token');
|
||||
});
|
||||
});
|
||||
|
||||
describe('SNS contact point', () => {
|
||||
it('should handle secure fields correctly when editing contact point', async () => {
|
||||
// Create mock config for an SNS contact point with secure fields configured
|
||||
const contactPointName = 'amazon-sns';
|
||||
const contactPoint = alertingFactory.alertmanager.grafana.contactPoint
|
||||
.withIntegrations((integrationFactory) => [
|
||||
integrationFactory
|
||||
.sns({
|
||||
api_url: 'https://amazon.example.com:1234',
|
||||
sigv4: { region: 'us-east-1', access_key: 'access-key', secret_key: 'secret-key' },
|
||||
})
|
||||
.build(),
|
||||
])
|
||||
.build({ id: 'amazon-sns-id', name: contactPointName, metadata: { name: contactPointName } });
|
||||
|
||||
const capture = captureRequests(
|
||||
(req) => req.url.includes(`/v0alpha1/namespaces/default/receivers/${contactPoint.id}`) && req.method === 'PUT'
|
||||
);
|
||||
|
||||
const { user } = renderWithProvider(<GrafanaReceiverForm contactPoint={contactPoint} editMode={true} />);
|
||||
|
||||
await waitFor(() => expect(ui.loadingIndicator.query()).not.toBeInTheDocument());
|
||||
|
||||
const apiUrlField = await ui.sns.apiUrl.find();
|
||||
const regionField = await ui.sns.region.find();
|
||||
const accessKeyField = await ui.sns.accessKey.find();
|
||||
const secretKeyField = await ui.sns.secretKey.find();
|
||||
|
||||
expect(apiUrlField).toHaveValue('https://amazon.example.com:1234');
|
||||
expect(regionField).toHaveValue('us-east-1');
|
||||
expect(accessKeyField).toHaveValue('configured');
|
||||
expect(accessKeyField).toBeDisabled();
|
||||
expect(secretKeyField).toHaveValue('configured');
|
||||
expect(secretKeyField).toBeDisabled();
|
||||
|
||||
// There should be a Reset button for secure fields
|
||||
const resetButtons = screen.getAllByRole('button', { name: 'Reset' });
|
||||
expect(resetButtons).toHaveLength(2);
|
||||
|
||||
// Reset and update access key
|
||||
await user.click(resetButtons[0]); // Reset access key
|
||||
expect(ui.sns.accessKey.get()).toBeEnabled();
|
||||
expect(ui.sns.accessKey.get()).toHaveValue('');
|
||||
await user.type(ui.sns.accessKey.get(), 'new-access-key');
|
||||
await user.type(ui.sns.topicArn.get(), 'arn:aws:sns:us-east-1:123456789012:MyTopic');
|
||||
|
||||
await user.click(ui.saveButton.get());
|
||||
|
||||
const requests = await capture;
|
||||
expect(requests).toHaveLength(1);
|
||||
|
||||
const [request] = requests;
|
||||
const postRequestBody = await request.clone().json();
|
||||
|
||||
const integrationPayload = postRequestBody.spec.integrations[0];
|
||||
|
||||
// Verify that secureFields object correctly reflects which fields were reset
|
||||
expect(integrationPayload.secureFields).toEqual({
|
||||
'sigv4.secret_key': true, // Should remain true as we didn't reset it
|
||||
});
|
||||
// The access key should not be in the secureFields object as it was reset
|
||||
expect(integrationPayload.secureFields).not.toHaveProperty('sigv4.access_key');
|
||||
|
||||
// Verify that the new access key value is included in the settings
|
||||
expect(integrationPayload.settings).toEqual({
|
||||
api_url: 'https://amazon.example.com:1234',
|
||||
sigv4: {
|
||||
access_key: 'new-access-key',
|
||||
region: 'us-east-1',
|
||||
},
|
||||
topic_arn: 'arn:aws:sns:us-east-1:123456789012:MyTopic',
|
||||
});
|
||||
|
||||
expect(postRequestBody).toMatchSnapshot();
|
||||
});
|
||||
});
|
||||
|
||||
describe('OnCall contact point', () => {
|
||||
it('OnCall contact point should be disabled if OnCall integration is not enabled', async () => {
|
||||
disablePlugin(SupportedPlugin.OnCall);
|
||||
@@ -101,12 +338,12 @@ describe('GrafanaReceiverForm', () => {
|
||||
|
||||
await waitFor(() => expect(ui.loadingIndicator.query()).not.toBeInTheDocument());
|
||||
|
||||
await clickSelectOption(byTestId('items.0.type').get(), 'Grafana OnCall');
|
||||
await clickSelectOption(ui.typeSelector.get(), 'Grafana IRM');
|
||||
// Clicking on a disable element shouldn't change the form value. email is the default value
|
||||
// eslint-disable-next-line testing-library/no-node-access
|
||||
expect(ui.integrationType.get().closest('form')).toHaveFormValues({ 'items.0.type': 'email' });
|
||||
|
||||
await clickSelectOption(byTestId('items.0.type').get(), 'Alertmanager');
|
||||
await clickSelectOption(ui.typeSelector.get(), 'Alertmanager');
|
||||
// eslint-disable-next-line testing-library/no-node-access
|
||||
expect(ui.integrationType.get().closest('form')).toHaveFormValues({ 'items.0.type': 'prometheus-alertmanager' });
|
||||
});
|
||||
@@ -121,7 +358,7 @@ describe('GrafanaReceiverForm', () => {
|
||||
|
||||
await waitFor(() => expect(ui.loadingIndicator.query()).not.toBeInTheDocument());
|
||||
|
||||
await clickSelectOption(byTestId('items.0.type').get(), 'Grafana OnCall');
|
||||
await clickSelectOption(ui.typeSelector.get(), 'Grafana IRM');
|
||||
|
||||
// eslint-disable-next-line testing-library/no-node-access
|
||||
expect(ui.integrationType.get().closest('form')).toHaveFormValues({ 'items.0.type': 'oncall' });
|
||||
@@ -173,10 +410,66 @@ describe('GrafanaReceiverForm', () => {
|
||||
|
||||
await waitFor(() => expect(ui.loadingIndicator.query()).not.toBeInTheDocument());
|
||||
|
||||
expect(byTestId('items.0.type').get()).toHaveTextContent('Grafana OnCall');
|
||||
expect(byTestId('items.0.type').get()).toHaveTextContent('Grafana IRM');
|
||||
expect(byLabelText('URL').get()).toHaveValue('https://oncall.example.com');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Webhook contact point', () => {
|
||||
it('should properly remove TLS config when deleted', async () => {
|
||||
const contactPointName = 'webhook-test';
|
||||
const contactPoint = alertingFactory.alertmanager.grafana.contactPoint
|
||||
.withIntegrations((integrationFactory) => [
|
||||
integrationFactory
|
||||
.webhook()
|
||||
.params({
|
||||
settings: {
|
||||
url: 'http://example.com',
|
||||
tlsConfig: {
|
||||
caCertificate: 'ca-cert',
|
||||
clientCertificate: 'client-cert',
|
||||
clientKey: 'client-key',
|
||||
insecureSkipVerify: false,
|
||||
},
|
||||
},
|
||||
})
|
||||
.build(),
|
||||
])
|
||||
.build({ id: 'webhook-id', name: contactPointName, metadata: { name: contactPointName } });
|
||||
|
||||
const capture = captureRequests(
|
||||
(req) => req.url.includes(`/v0alpha1/namespaces/default/receivers/${contactPoint.id}`) && req.method === 'PUT'
|
||||
);
|
||||
|
||||
const { user } = renderWithProvider(<GrafanaReceiverForm contactPoint={contactPoint} editMode={true} />);
|
||||
|
||||
await waitFor(() => expect(ui.loadingIndicator.query()).not.toBeInTheDocument());
|
||||
|
||||
// Find and click the delete button next to TLS config
|
||||
await user.click(ui.webhook.optionalSettings.get());
|
||||
|
||||
expect(await ui.webhook.tlsConfig.header.find(undefined)).toBeInTheDocument();
|
||||
await user.click(await ui.webhook.tlsConfig.deleteButton.find());
|
||||
|
||||
await user.click(ui.saveButton.get());
|
||||
|
||||
const requests = await capture;
|
||||
expect(requests).toHaveLength(1);
|
||||
|
||||
const [request] = requests;
|
||||
const postRequestBody = await request.clone().json();
|
||||
|
||||
const integrationPayload = postRequestBody.spec.integrations[0];
|
||||
|
||||
// Verify that TLS config is not present in the settings
|
||||
expect(integrationPayload.settings).not.toHaveProperty('tlsConfig');
|
||||
expect(integrationPayload.secureFields).not.toHaveProperty('tlsConfig.caCertificate');
|
||||
expect(integrationPayload.secureFields).not.toHaveProperty('tlsConfig.clientCert');
|
||||
expect(integrationPayload.secureFields).not.toHaveProperty('tlsConfig.clientKey');
|
||||
|
||||
expect(postRequestBody).toMatchSnapshot();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
function getAmCortexConfig(configure: (builder: AlertmanagerConfigBuilder) => void): AlertManagerCortexConfig {
|
||||
|
||||
+4
-3
@@ -84,11 +84,11 @@ export const GrafanaReceiverForm = ({ contactPoint, readOnly = false, editMode }
|
||||
return [undefined, {}];
|
||||
}
|
||||
|
||||
return grafanaReceiverToFormValues(extendOnCallReceivers(contactPoint), grafanaNotifiers);
|
||||
}, [contactPoint, isLoadingNotifiers, grafanaNotifiers, extendOnCallReceivers, isLoadingOnCallIntegration]);
|
||||
return grafanaReceiverToFormValues(extendOnCallReceivers(contactPoint));
|
||||
}, [contactPoint, isLoadingNotifiers, extendOnCallReceivers, isLoadingOnCallIntegration]);
|
||||
|
||||
const onSubmit = async (values: ReceiverFormValues<GrafanaChannelValues>) => {
|
||||
const newReceiver = formValuesToGrafanaReceiver(values, id2original, defaultChannelValues, grafanaNotifiers);
|
||||
const newReceiver = formValuesToGrafanaReceiver(values, id2original, defaultChannelValues);
|
||||
|
||||
try {
|
||||
if (editMode) {
|
||||
@@ -158,6 +158,7 @@ export const GrafanaReceiverForm = ({ contactPoint, readOnly = false, editMode }
|
||||
|
||||
return { dto: n };
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
{hasOnCallError && (
|
||||
|
||||
@@ -93,17 +93,6 @@ export function ReceiverForm<R extends ChannelValues>({
|
||||
const { fields, append, remove } = useControlledFieldArray<R>({ name: 'items', formAPI, softDelete: true });
|
||||
|
||||
const submitCallback = async (values: ReceiverFormValues<R>) => {
|
||||
values.items.forEach((item) => {
|
||||
if (item.secureFields) {
|
||||
// omit secure fields with boolean value as BE expects not touched fields to be omitted: https://github.com/grafana/grafana/pull/71307
|
||||
Object.keys(item.secureFields).forEach((key) => {
|
||||
if (item.secureFields[key] === true || item.secureFields[key] === false) {
|
||||
delete item.secureFields[key];
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
try {
|
||||
await onSubmit({
|
||||
...values,
|
||||
@@ -175,7 +164,7 @@ export function ReceiverForm<R extends ChannelValues>({
|
||||
/>
|
||||
</Field>
|
||||
{fields.map((field, index) => {
|
||||
const pathPrefix = `items.${index}.`;
|
||||
const pathPrefix = `items.${index}.` as const;
|
||||
if (field.__deleted) {
|
||||
return <DeletedSubForm key={field.__id} pathPrefix={pathPrefix} />;
|
||||
}
|
||||
@@ -185,6 +174,7 @@ export function ReceiverForm<R extends ChannelValues>({
|
||||
defaultValues={field}
|
||||
initialValues={initialItem}
|
||||
key={field.__id}
|
||||
integrationIndex={index}
|
||||
onDuplicate={() => {
|
||||
const currentValues: R = getValues().items[index];
|
||||
append({ ...currentValues, __id: String(Math.random()) });
|
||||
@@ -200,7 +190,6 @@ export function ReceiverForm<R extends ChannelValues>({
|
||||
onDelete={() => remove(index)}
|
||||
pathPrefix={pathPrefix}
|
||||
notifiers={notifiers}
|
||||
secureFields={initialItem?.secureFields}
|
||||
errors={errors?.items?.[index] as FieldErrors<R>}
|
||||
commonSettingsComponent={commonSettingsComponent}
|
||||
isEditable={isEditable}
|
||||
|
||||
+54
-5
@@ -1,5 +1,58 @@
|
||||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`GrafanaReceiverForm SNS contact point should handle secure fields correctly when editing contact point 1`] = `
|
||||
{
|
||||
"metadata": {
|
||||
"name": "amazon-sns-id",
|
||||
"resourceVersion": "a5e9fd75262d5488",
|
||||
},
|
||||
"spec": {
|
||||
"integrations": [
|
||||
{
|
||||
"disableResolveMessage": false,
|
||||
"name": "amazon-sns",
|
||||
"secureFields": {
|
||||
"sigv4.secret_key": true,
|
||||
},
|
||||
"settings": {
|
||||
"api_url": "https://amazon.example.com:1234",
|
||||
"sigv4": {
|
||||
"access_key": "new-access-key",
|
||||
"region": "us-east-1",
|
||||
},
|
||||
"topic_arn": "arn:aws:sns:us-east-1:123456789012:MyTopic",
|
||||
},
|
||||
"type": "sns",
|
||||
},
|
||||
],
|
||||
"title": "amazon-sns",
|
||||
},
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`GrafanaReceiverForm Webhook contact point should properly remove TLS config when deleted 1`] = `
|
||||
{
|
||||
"metadata": {
|
||||
"name": "webhook-id",
|
||||
"resourceVersion": "a5e9fd75262d5488",
|
||||
},
|
||||
"spec": {
|
||||
"integrations": [
|
||||
{
|
||||
"disableResolveMessage": false,
|
||||
"name": "webhook-test",
|
||||
"secureFields": {},
|
||||
"settings": {
|
||||
"url": "http://example.com",
|
||||
},
|
||||
"type": "webhook",
|
||||
},
|
||||
],
|
||||
"title": "webhook-test",
|
||||
},
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`GrafanaReceiverForm handles nested secure fields correctly 1`] = `
|
||||
{
|
||||
"metadata": {},
|
||||
@@ -8,11 +61,7 @@ exports[`GrafanaReceiverForm handles nested secure fields correctly 1`] = `
|
||||
{
|
||||
"disableResolveMessage": false,
|
||||
"name": "mqtt contact point",
|
||||
"secureFields": {
|
||||
"password": true,
|
||||
"tlsConfig.clientCertificate": true,
|
||||
"tlsConfig.clientKey": true,
|
||||
},
|
||||
"secureFields": {},
|
||||
"settings": {
|
||||
"brokerUrl": "broker url",
|
||||
"retain": false,
|
||||
|
||||
+25
-48
@@ -1,5 +1,4 @@
|
||||
import { css } from '@emotion/css';
|
||||
import { isEmpty } from 'lodash';
|
||||
import { FC, useEffect } from 'react';
|
||||
import { Controller, DeepMap, FieldError, useFormContext } from 'react-hook-form';
|
||||
|
||||
@@ -15,7 +14,7 @@ import {
|
||||
TextArea,
|
||||
useStyles2,
|
||||
} from '@grafana/ui';
|
||||
import { NotificationChannelOption, NotificationChannelSecureFields } from 'app/types';
|
||||
import { NotificationChannelOption, NotificationChannelSecureFields, OptionMeta } from 'app/types';
|
||||
|
||||
import { KeyValueMapInput } from './KeyValueMapInput';
|
||||
import { StringArrayInput } from './StringArrayInput';
|
||||
@@ -26,16 +25,17 @@ import { WrapWithTemplateSelection } from './TemplateSelector';
|
||||
interface Props {
|
||||
defaultValue: any;
|
||||
option: NotificationChannelOption;
|
||||
getOptionMeta?: (option: NotificationChannelOption) => OptionMeta;
|
||||
// this is defined if the option is rendered inside a subform
|
||||
parentOption?: NotificationChannelOption;
|
||||
invalid?: boolean;
|
||||
pathPrefix: string;
|
||||
pathSuffix?: string;
|
||||
error?: FieldError | DeepMap<any, FieldError>;
|
||||
readOnly?: boolean;
|
||||
customValidator?: (value: string) => boolean | string | Promise<boolean | string>;
|
||||
onResetSecureField?: (propertyName: string) => void;
|
||||
secureFields?: NotificationChannelSecureFields;
|
||||
onDeleteSubform?: (propertyName: string) => void;
|
||||
secureFields: NotificationChannelSecureFields;
|
||||
}
|
||||
|
||||
export const OptionField: FC<Props> = ({
|
||||
@@ -43,16 +43,15 @@ export const OptionField: FC<Props> = ({
|
||||
parentOption,
|
||||
invalid,
|
||||
pathPrefix,
|
||||
pathSuffix = '',
|
||||
error,
|
||||
defaultValue,
|
||||
readOnly = false,
|
||||
customValidator,
|
||||
onResetSecureField,
|
||||
secureFields = {},
|
||||
secureFields,
|
||||
onDeleteSubform,
|
||||
getOptionMeta,
|
||||
}) => {
|
||||
const optionPath = `${pathPrefix}${pathSuffix}`;
|
||||
|
||||
if (option.element === 'subform') {
|
||||
return (
|
||||
<SubformField
|
||||
@@ -62,60 +61,65 @@ export const OptionField: FC<Props> = ({
|
||||
defaultValue={defaultValue}
|
||||
option={option}
|
||||
errors={error}
|
||||
pathPrefix={optionPath}
|
||||
pathPrefix={pathPrefix}
|
||||
onDelete={onDeleteSubform}
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (option.element === 'subform_array') {
|
||||
return (
|
||||
<SubformArrayField
|
||||
secureFields={secureFields}
|
||||
readOnly={readOnly}
|
||||
defaultValues={defaultValue}
|
||||
option={option}
|
||||
pathPrefix={optionPath}
|
||||
pathPrefix={pathPrefix}
|
||||
errors={error as Array<DeepMap<any, FieldError>> | undefined}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Field
|
||||
label={option.element !== 'checkbox' && option.element !== 'radio' ? option.label : undefined}
|
||||
description={option.description || undefined}
|
||||
invalid={!!error}
|
||||
error={error?.message}
|
||||
data-testid={`${optionPath}${option.propertyName}`}
|
||||
data-testid={`${pathPrefix}${option.propertyName}`}
|
||||
>
|
||||
<OptionInput
|
||||
id={`${optionPath}${option.propertyName}`}
|
||||
id={`${pathPrefix}${option.propertyName}`}
|
||||
defaultValue={defaultValue}
|
||||
option={option}
|
||||
invalid={invalid}
|
||||
pathPrefix={optionPath}
|
||||
pathPrefix={pathPrefix}
|
||||
readOnly={readOnly}
|
||||
pathIndex={pathPrefix}
|
||||
parentOption={parentOption}
|
||||
customValidator={customValidator}
|
||||
onResetSecureField={onResetSecureField}
|
||||
secureFields={secureFields}
|
||||
getOptionMeta={getOptionMeta}
|
||||
/>
|
||||
</Field>
|
||||
);
|
||||
};
|
||||
|
||||
const OptionInput: FC<Props & { id: string; pathIndex?: string }> = ({
|
||||
const OptionInput: FC<Props & { id: string }> = ({
|
||||
option,
|
||||
invalid,
|
||||
id,
|
||||
pathPrefix = '',
|
||||
pathIndex = '',
|
||||
readOnly = false,
|
||||
customValidator,
|
||||
onResetSecureField,
|
||||
secureFields = {},
|
||||
parentOption,
|
||||
getOptionMeta,
|
||||
}) => {
|
||||
const styles = useStyles2(getStyles);
|
||||
const { control, register, unregister, getValues, setValue } = useFormContext();
|
||||
const { control, register, unregister, setValue } = useFormContext();
|
||||
|
||||
const optionMeta = getOptionMeta?.(option);
|
||||
|
||||
const name = `${pathPrefix}${option.propertyName}`;
|
||||
const nestedKey = parentOption ? `${parentOption.propertyName}.${option.propertyName}` : option.propertyName;
|
||||
@@ -158,15 +162,15 @@ const OptionInput: FC<Props & { id: string; pathIndex?: string }> = ({
|
||||
onSelectTemplate={onSelectTemplate}
|
||||
>
|
||||
{isEncryptedInput ? (
|
||||
<SecretInput onReset={() => onResetSecureField?.(nestedKey)} isConfigured />
|
||||
<SecretInput id={id} onReset={() => onResetSecureField?.(nestedKey)} isConfigured />
|
||||
) : (
|
||||
<Input
|
||||
id={id}
|
||||
readOnly={readOnly || useTemplates || determineReadOnly(option, getValues, pathIndex)}
|
||||
readOnly={readOnly || useTemplates || optionMeta?.readOnly}
|
||||
invalid={invalid}
|
||||
type={option.inputType}
|
||||
{...register(name, {
|
||||
required: determineRequired(option, getValues, pathIndex),
|
||||
required: optionMeta?.required,
|
||||
validate: {
|
||||
validationRule: (v) =>
|
||||
option.validationRule ? validateOption(v, option.validationRule, option.required) : true,
|
||||
@@ -233,7 +237,7 @@ const OptionInput: FC<Props & { id: string; pathIndex?: string }> = ({
|
||||
onSelectTemplate={onSelectTemplate}
|
||||
>
|
||||
{isEncryptedInput ? (
|
||||
<SecretTextArea onReset={() => onResetSecureField?.(nestedKey)} isConfigured />
|
||||
<SecretTextArea id={id} onReset={() => onResetSecureField?.(nestedKey)} isConfigured />
|
||||
) : (
|
||||
<TextArea
|
||||
id={id}
|
||||
@@ -292,30 +296,3 @@ const validateOption = (value: string, validationRule: string, required: boolean
|
||||
|
||||
return RegExp(validationRule).test(value) ? true : 'Invalid format';
|
||||
};
|
||||
|
||||
const determineRequired = (option: NotificationChannelOption, getValues: any, pathIndex: string) => {
|
||||
const secureFields = getValues(`${pathIndex}secureFields`);
|
||||
const secureSettings = getValues(`${pathIndex}secureSettings`);
|
||||
|
||||
if (!option.dependsOn) {
|
||||
return option.required ? 'Required' : false;
|
||||
}
|
||||
if (isEmpty(secureFields) || !secureFields[option.dependsOn]) {
|
||||
const dependentOn = Boolean(secureSettings[option.dependsOn]);
|
||||
return !dependentOn && option.required ? 'Required' : false;
|
||||
} else {
|
||||
const dependentOn = Boolean(secureFields[option.dependsOn]);
|
||||
return !dependentOn && option.required ? 'Required' : false;
|
||||
}
|
||||
};
|
||||
|
||||
const determineReadOnly = (option: NotificationChannelOption, getValues: any, pathIndex: string) => {
|
||||
if (!option.dependsOn) {
|
||||
return false;
|
||||
}
|
||||
if (isEmpty(getValues(`${pathIndex}secureFields`))) {
|
||||
return getValues(`${pathIndex}secureSettings.${option.dependsOn}`);
|
||||
} else {
|
||||
return getValues(`${pathIndex}secureFields.${option.dependsOn}`);
|
||||
}
|
||||
};
|
||||
|
||||
+14
-2
@@ -3,7 +3,7 @@ import { DeepMap, FieldError, useFormContext } from 'react-hook-form';
|
||||
import { Button, useStyles2 } from '@grafana/ui';
|
||||
import { Trans, t } from 'app/core/internationalization';
|
||||
import { useControlledFieldArray } from 'app/features/alerting/unified/hooks/useControlledFieldArray';
|
||||
import { NotificationChannelOption } from 'app/types';
|
||||
import { NotificationChannelOption, NotificationChannelSecureFields, OptionMeta } from 'app/types';
|
||||
|
||||
import { ActionIcon } from '../../../rules/ActionIcon';
|
||||
import { CollapsibleSection } from '../CollapsibleSection';
|
||||
@@ -17,9 +17,19 @@ interface Props {
|
||||
pathPrefix: string;
|
||||
errors?: Array<DeepMap<any, FieldError>>;
|
||||
readOnly?: boolean;
|
||||
secureFields: NotificationChannelSecureFields;
|
||||
getOptionMeta?: (option: NotificationChannelOption) => OptionMeta;
|
||||
}
|
||||
|
||||
export const SubformArrayField = ({ option, pathPrefix, errors, defaultValues, readOnly = false }: Props) => {
|
||||
export const SubformArrayField = ({
|
||||
option,
|
||||
pathPrefix,
|
||||
errors,
|
||||
defaultValues,
|
||||
readOnly = false,
|
||||
secureFields,
|
||||
getOptionMeta,
|
||||
}: Props) => {
|
||||
const styles = useStyles2(getReceiverFormFieldStyles);
|
||||
const path = `${pathPrefix}${option.propertyName}`;
|
||||
const formAPI = useFormContext();
|
||||
@@ -48,6 +58,8 @@ export const SubformArrayField = ({ option, pathPrefix, errors, defaultValues, r
|
||||
{option.subformOptions?.map((option) => (
|
||||
<OptionField
|
||||
readOnly={readOnly}
|
||||
getOptionMeta={getOptionMeta}
|
||||
secureFields={secureFields}
|
||||
defaultValue={field?.[option.propertyName]}
|
||||
key={option.propertyName}
|
||||
option={option}
|
||||
|
||||
+20
-6
@@ -3,7 +3,7 @@ import { DeepMap, FieldError, useFormContext } from 'react-hook-form';
|
||||
|
||||
import { Button, useStyles2 } from '@grafana/ui';
|
||||
import { Trans, t } from 'app/core/internationalization';
|
||||
import { NotificationChannelOption, NotificationChannelSecureFields } from 'app/types';
|
||||
import { NotificationChannelOption, NotificationChannelSecureFields, OptionMeta } from 'app/types';
|
||||
|
||||
import { ActionIcon } from '../../../rules/ActionIcon';
|
||||
|
||||
@@ -13,10 +13,16 @@ import { getReceiverFormFieldStyles } from './styles';
|
||||
interface Props {
|
||||
defaultValue: any;
|
||||
option: NotificationChannelOption;
|
||||
getOptionMeta?: (option: NotificationChannelOption) => OptionMeta;
|
||||
pathPrefix: string;
|
||||
errors?: DeepMap<any, FieldError>;
|
||||
readOnly?: boolean;
|
||||
secureFields?: NotificationChannelSecureFields;
|
||||
secureFields: NotificationChannelSecureFields;
|
||||
/**
|
||||
* Callback function to delete a subform field. Removal requires side effects
|
||||
* like settings and secure fields cleanup.
|
||||
*/
|
||||
onDelete?: (propertyName: string) => void;
|
||||
onResetSecureField?: (propertyName: string) => void;
|
||||
}
|
||||
|
||||
@@ -25,8 +31,10 @@ export const SubformField = ({
|
||||
pathPrefix,
|
||||
errors,
|
||||
defaultValue,
|
||||
getOptionMeta,
|
||||
readOnly = false,
|
||||
secureFields = {},
|
||||
secureFields,
|
||||
onDelete,
|
||||
onResetSecureField,
|
||||
}: Props) => {
|
||||
const styles = useStyles2(getReceiverFormFieldStyles);
|
||||
@@ -37,18 +45,23 @@ export const SubformField = ({
|
||||
|
||||
const [show, setShow] = useState(!!value);
|
||||
|
||||
const onDeleteClick = () => {
|
||||
onDelete?.(option.propertyName);
|
||||
setShow(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={styles.wrapper} data-testid={`${name}.container`}>
|
||||
<h6>{option.label}</h6>
|
||||
{option.description && <p className={styles.description}>{option.description}</p>}
|
||||
{show && (
|
||||
<>
|
||||
{!readOnly && (
|
||||
{!readOnly && onDelete && (
|
||||
<ActionIcon
|
||||
data-testid={`${name}.delete-button`}
|
||||
icon="trash-alt"
|
||||
tooltip={t('alerting.subform-field.tooltip-delete', 'delete')}
|
||||
onClick={() => setShow(false)}
|
||||
onClick={onDeleteClick}
|
||||
className={styles.deleteIcon}
|
||||
/>
|
||||
)}
|
||||
@@ -56,8 +69,9 @@ export const SubformField = ({
|
||||
return (
|
||||
<OptionField
|
||||
readOnly={readOnly}
|
||||
secureFields={secureFields}
|
||||
getOptionMeta={getOptionMeta}
|
||||
onResetSecureField={onResetSecureField}
|
||||
secureFields={secureFields}
|
||||
defaultValue={defaultValue?.[subOption.propertyName]}
|
||||
parentOption={option}
|
||||
key={subOption.propertyName}
|
||||
|
||||
@@ -49,7 +49,6 @@ function createContactPoint(httpConfig: DeprecatedAuthHTTPConfig | HTTPAuthConfi
|
||||
{
|
||||
__id: '',
|
||||
type: '',
|
||||
secureSettings: {},
|
||||
secureFields: {},
|
||||
settings: {
|
||||
http_config: {
|
||||
|
||||
@@ -1607,10 +1607,7 @@ export const grafanaAlertNotifiers: Record<GrafanaNotifierType, NotifierDTO> = {
|
||||
placeholder: '',
|
||||
propertyName: 'url',
|
||||
selectOptions: null,
|
||||
showWhen: {
|
||||
field: '',
|
||||
is: '',
|
||||
},
|
||||
showWhen: { field: '', is: '' },
|
||||
required: true,
|
||||
validationRule: '',
|
||||
secure: false,
|
||||
@@ -1624,19 +1621,10 @@ export const grafanaAlertNotifiers: Record<GrafanaNotifierType, NotifierDTO> = {
|
||||
placeholder: '',
|
||||
propertyName: 'httpMethod',
|
||||
selectOptions: [
|
||||
{
|
||||
value: 'POST',
|
||||
label: 'POST',
|
||||
},
|
||||
{
|
||||
value: 'PUT',
|
||||
label: 'PUT',
|
||||
},
|
||||
{ value: 'POST', label: 'POST' },
|
||||
{ value: 'PUT', label: 'PUT' },
|
||||
],
|
||||
showWhen: {
|
||||
field: '',
|
||||
is: '',
|
||||
},
|
||||
showWhen: { field: '', is: '' },
|
||||
required: false,
|
||||
validationRule: '',
|
||||
secure: false,
|
||||
@@ -1711,6 +1699,23 @@ export const grafanaAlertNotifiers: Record<GrafanaNotifierType, NotifierDTO> = {
|
||||
secure: true,
|
||||
dependsOn: '',
|
||||
},
|
||||
{
|
||||
element: 'key_value_map',
|
||||
inputType: 'text',
|
||||
label: 'Extra Headers',
|
||||
description: 'Optionally provide extra headers to be used in the request.',
|
||||
placeholder: '',
|
||||
propertyName: 'headers',
|
||||
selectOptions: null,
|
||||
showWhen: {
|
||||
field: '',
|
||||
is: '',
|
||||
},
|
||||
required: false,
|
||||
validationRule: '',
|
||||
secure: false,
|
||||
dependsOn: '',
|
||||
},
|
||||
{
|
||||
element: 'input',
|
||||
inputType: 'text',
|
||||
@@ -1730,7 +1735,7 @@ export const grafanaAlertNotifiers: Record<GrafanaNotifierType, NotifierDTO> = {
|
||||
dependsOn: '',
|
||||
},
|
||||
{
|
||||
element: 'input',
|
||||
element: 'textarea',
|
||||
inputType: 'text',
|
||||
label: 'Title',
|
||||
description: 'Templated title of the message.',
|
||||
@@ -1763,13 +1768,225 @@ export const grafanaAlertNotifiers: Record<GrafanaNotifierType, NotifierDTO> = {
|
||||
secure: false,
|
||||
dependsOn: '',
|
||||
},
|
||||
{
|
||||
element: 'subform',
|
||||
inputType: '',
|
||||
label: 'Custom Payload',
|
||||
description: "Optionally provide a templated payload. Overrides 'Message' and 'Title' field.",
|
||||
placeholder: '',
|
||||
propertyName: 'payload',
|
||||
selectOptions: null,
|
||||
showWhen: {
|
||||
field: '',
|
||||
is: '',
|
||||
},
|
||||
required: false,
|
||||
validationRule: '',
|
||||
secure: false,
|
||||
dependsOn: '',
|
||||
subformOptions: [
|
||||
{
|
||||
element: 'textarea',
|
||||
inputType: '',
|
||||
label: 'Payload Template',
|
||||
description: 'Custom payload template.',
|
||||
placeholder: '{{ template "webhook.default.payload" . }}',
|
||||
propertyName: 'template',
|
||||
selectOptions: null,
|
||||
showWhen: {
|
||||
field: '',
|
||||
is: '',
|
||||
},
|
||||
required: true,
|
||||
validationRule: '',
|
||||
secure: false,
|
||||
dependsOn: '',
|
||||
},
|
||||
{
|
||||
element: 'key_value_map',
|
||||
inputType: 'text',
|
||||
label: 'Payload Variables',
|
||||
description:
|
||||
'Optionally provide a variables to be used in the payload template. They will be available in the template as `.Vars.<variable_name>`.',
|
||||
placeholder: '',
|
||||
propertyName: 'vars',
|
||||
selectOptions: null,
|
||||
showWhen: {
|
||||
field: '',
|
||||
is: '',
|
||||
},
|
||||
required: false,
|
||||
validationRule: '',
|
||||
secure: false,
|
||||
dependsOn: '',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
element: 'subform',
|
||||
inputType: '',
|
||||
label: 'TLS',
|
||||
description: 'TLS configuration options',
|
||||
placeholder: '',
|
||||
propertyName: 'tlsConfig',
|
||||
selectOptions: null,
|
||||
showWhen: {
|
||||
field: '',
|
||||
is: '',
|
||||
},
|
||||
required: false,
|
||||
validationRule: '',
|
||||
secure: false,
|
||||
dependsOn: '',
|
||||
subformOptions: [
|
||||
{
|
||||
element: 'checkbox',
|
||||
inputType: '',
|
||||
label: 'Disable certificate verification',
|
||||
description: "Do not verify the server's certificate chain and host name.",
|
||||
placeholder: '',
|
||||
propertyName: 'insecureSkipVerify',
|
||||
selectOptions: null,
|
||||
showWhen: {
|
||||
field: '',
|
||||
is: '',
|
||||
},
|
||||
required: false,
|
||||
validationRule: '',
|
||||
secure: false,
|
||||
dependsOn: '',
|
||||
},
|
||||
{
|
||||
element: 'textarea',
|
||||
inputType: 'text',
|
||||
label: 'CA Certificate',
|
||||
description: "Certificate in PEM format to use when verifying the server's certificate chain.",
|
||||
placeholder: '',
|
||||
propertyName: 'caCertificate',
|
||||
selectOptions: null,
|
||||
showWhen: {
|
||||
field: '',
|
||||
is: '',
|
||||
},
|
||||
required: false,
|
||||
validationRule: '',
|
||||
secure: true,
|
||||
dependsOn: '',
|
||||
},
|
||||
{
|
||||
element: 'textarea',
|
||||
inputType: 'text',
|
||||
label: 'Client Certificate',
|
||||
description: 'Client certificate in PEM format to use when connecting to the server.',
|
||||
placeholder: '',
|
||||
propertyName: 'clientCertificate',
|
||||
selectOptions: null,
|
||||
showWhen: {
|
||||
field: '',
|
||||
is: '',
|
||||
},
|
||||
required: false,
|
||||
validationRule: '',
|
||||
secure: true,
|
||||
dependsOn: '',
|
||||
},
|
||||
{
|
||||
element: 'textarea',
|
||||
inputType: 'text',
|
||||
label: 'Client Key',
|
||||
description: 'Client key in PEM format to use when connecting to the server.',
|
||||
placeholder: '',
|
||||
propertyName: 'clientKey',
|
||||
selectOptions: null,
|
||||
showWhen: {
|
||||
field: '',
|
||||
is: '',
|
||||
},
|
||||
required: false,
|
||||
validationRule: '',
|
||||
secure: true,
|
||||
dependsOn: '',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
element: 'subform',
|
||||
inputType: '',
|
||||
label: 'HMAC Signature',
|
||||
description: 'HMAC signature configuration options',
|
||||
placeholder: '',
|
||||
propertyName: 'hmacConfig',
|
||||
selectOptions: null,
|
||||
showWhen: {
|
||||
field: '',
|
||||
is: '',
|
||||
},
|
||||
required: false,
|
||||
validationRule: '',
|
||||
secure: false,
|
||||
dependsOn: '',
|
||||
subformOptions: [
|
||||
{
|
||||
element: 'input',
|
||||
inputType: 'text',
|
||||
label: 'Secret',
|
||||
description: '',
|
||||
placeholder: '',
|
||||
propertyName: 'secret',
|
||||
selectOptions: null,
|
||||
showWhen: {
|
||||
field: '',
|
||||
is: '',
|
||||
},
|
||||
required: true,
|
||||
validationRule: '',
|
||||
secure: true,
|
||||
dependsOn: '',
|
||||
},
|
||||
{
|
||||
element: 'input',
|
||||
inputType: 'text',
|
||||
label: 'Header',
|
||||
description: 'The header in which the HMAC signature will be included.',
|
||||
placeholder: 'X-Grafana-Alerting-Signature',
|
||||
propertyName: 'header',
|
||||
selectOptions: null,
|
||||
showWhen: {
|
||||
field: '',
|
||||
is: '',
|
||||
},
|
||||
required: false,
|
||||
validationRule: '',
|
||||
secure: false,
|
||||
dependsOn: '',
|
||||
},
|
||||
{
|
||||
element: 'input',
|
||||
inputType: 'text',
|
||||
label: 'Timestamp header',
|
||||
description:
|
||||
'If set, the timestamp will be included in the HMAC signature. The value should be the name of the header to use.',
|
||||
placeholder: '',
|
||||
propertyName: 'timestampHeader',
|
||||
selectOptions: null,
|
||||
showWhen: {
|
||||
field: '',
|
||||
is: '',
|
||||
},
|
||||
required: false,
|
||||
validationRule: '',
|
||||
secure: false,
|
||||
dependsOn: '',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
oncall: {
|
||||
type: 'oncall',
|
||||
name: 'Grafana OnCall',
|
||||
heading: 'Grafana OnCall settings',
|
||||
description: 'Sends notifications to Grafana OnCall',
|
||||
name: 'Grafana IRM',
|
||||
heading: 'Grafana IRM settings',
|
||||
description: 'Sends notifications to Grafana IRM',
|
||||
info: '',
|
||||
options: [
|
||||
{
|
||||
|
||||
@@ -3,6 +3,7 @@ import { uniqueId } from 'lodash';
|
||||
|
||||
import { DataSourceInstanceSettings, PluginType } from '@grafana/data';
|
||||
import { config } from '@grafana/runtime';
|
||||
import { GrafanaManagedContactPoint, GrafanaManagedReceiverConfig } from 'app/plugins/datasource/alertmanager/types';
|
||||
import { FolderDTO } from 'app/types';
|
||||
import {
|
||||
GrafanaRecordingRuleDefinition,
|
||||
@@ -178,6 +179,98 @@ const grafanaRecordingRule = Factory.define<RulerGrafanaRuleDTO<GrafanaRecording
|
||||
annotations: {}, // @TODO recording rules don't have annotations, we need to fix this type definition
|
||||
}));
|
||||
|
||||
class GrafanaContactPointFactory extends Factory<GrafanaManagedContactPoint> {
|
||||
withIntegrations(builder: (factory: GrafanaReceiverConfigFactory) => GrafanaManagedReceiverConfig[]) {
|
||||
return this.params({
|
||||
grafana_managed_receiver_configs: builder(grafanaReceiverConfigFactory),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const grafanaContactPointFactory = GrafanaContactPointFactory.define(({ sequence }) => ({
|
||||
id: `contact-point-${sequence}`,
|
||||
name: `contact-point-${sequence}`,
|
||||
metadata: {
|
||||
name: `contact-point-${sequence}`,
|
||||
namespace: 'default',
|
||||
uid: `uid-${sequence}`,
|
||||
resourceVersion: 'a5e9fd75262d5488',
|
||||
creationTimestamp: undefined,
|
||||
annotations: {
|
||||
'grafana.com/access/canAdmin': 'true',
|
||||
'grafana.com/access/canDelete': 'true',
|
||||
'grafana.com/access/canReadSecrets': 'true',
|
||||
'grafana.com/access/canWrite': 'true',
|
||||
'grafana.com/inUse/routes': '0',
|
||||
'grafana.com/inUse/rules': '1',
|
||||
'grafana.com/provenance': 'none',
|
||||
},
|
||||
},
|
||||
provisioned: false,
|
||||
grafana_managed_receiver_configs: [],
|
||||
}));
|
||||
|
||||
interface SlackReceiverOptions {
|
||||
recipient?: string;
|
||||
token?: string;
|
||||
url?: string;
|
||||
}
|
||||
|
||||
interface SNSReceiverOptions {
|
||||
api_url: string;
|
||||
sigv4?: {
|
||||
region: string;
|
||||
access_key?: string;
|
||||
secret_key?: string;
|
||||
};
|
||||
topic_arn?: string;
|
||||
}
|
||||
|
||||
class GrafanaReceiverConfigFactory extends Factory<GrafanaManagedReceiverConfig> {
|
||||
email() {
|
||||
return this.params({ type: 'email', name: `email-${this.sequence()}` });
|
||||
}
|
||||
|
||||
oncall() {
|
||||
return this.params({ type: 'oncall', name: `oncall-${this.sequence()}` });
|
||||
}
|
||||
|
||||
slack({ recipient, token, url }: SlackReceiverOptions = {}) {
|
||||
return this.params({
|
||||
type: 'slack',
|
||||
name: `slack-${this.sequence()}`,
|
||||
settings: { recipient },
|
||||
secureFields: { token: Boolean(token), url: Boolean(url) },
|
||||
});
|
||||
}
|
||||
|
||||
webhook() {
|
||||
return this.params({ type: 'webhook', name: `webhook-${this.sequence()}` });
|
||||
}
|
||||
|
||||
sns({ api_url, sigv4, topic_arn }: SNSReceiverOptions) {
|
||||
const { access_key, secret_key, ...sigv4WithoutSecrets } = sigv4 ?? {};
|
||||
return this.params({
|
||||
type: 'sns',
|
||||
name: `sns-${this.sequence()}`,
|
||||
settings: { api_url, sigv4: sigv4WithoutSecrets, topic_arn },
|
||||
secureFields: { 'sigv4.access_key': Boolean(access_key), 'sigv4.secret_key': Boolean(secret_key) },
|
||||
});
|
||||
}
|
||||
|
||||
mqtt() {
|
||||
return this.params({ type: 'mqtt', name: `mqtt-${this.sequence()}` });
|
||||
}
|
||||
}
|
||||
|
||||
const grafanaReceiverConfigFactory = GrafanaReceiverConfigFactory.define(({ sequence }) => ({
|
||||
name: `receiver-config-${sequence}`,
|
||||
type: 'email',
|
||||
settings: {},
|
||||
secureFields: {},
|
||||
disableResolveMessage: false,
|
||||
}));
|
||||
|
||||
export const alertingFactory = {
|
||||
folder: grafanaFolderFactory,
|
||||
prometheus: {
|
||||
@@ -193,4 +286,10 @@ export const alertingFactory = {
|
||||
},
|
||||
},
|
||||
dataSource: dataSourceFactory,
|
||||
alertmanager: {
|
||||
grafana: {
|
||||
contactPoint: grafanaContactPointFactory,
|
||||
receiver: grafanaReceiverConfigFactory,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
@@ -9,8 +9,7 @@ export interface ChannelValues {
|
||||
__id: string; // used to correlate form values to original DTOs
|
||||
type: string;
|
||||
settings: Record<string, any>;
|
||||
secureSettings: Record<string, any>;
|
||||
secureFields: Record<string, boolean>;
|
||||
secureFields: Record<string, boolean | ''>;
|
||||
}
|
||||
|
||||
export interface ReceiverFormValues<R extends ChannelValues> {
|
||||
|
||||
@@ -1,19 +0,0 @@
|
||||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`formValuesToGrafanaReceiver should migrate regular settings to secure settings if the field is defined as secure 1`] = `
|
||||
{
|
||||
"grafana_managed_receiver_configs": [
|
||||
{
|
||||
"disableResolveMessage": false,
|
||||
"name": "my-receiver",
|
||||
"secureSettings": {
|
||||
"url": "https://foo.bar/",
|
||||
},
|
||||
"settings": {},
|
||||
"type": "discord",
|
||||
"uid": "abc123",
|
||||
},
|
||||
],
|
||||
"name": "my-receiver",
|
||||
}
|
||||
`;
|
||||
@@ -1,8 +1,11 @@
|
||||
import { NotifierDTO } from 'app/types';
|
||||
|
||||
import { GrafanaManagedContactPoint, Receiver } from '../../../../plugins/datasource/alertmanager/types';
|
||||
import { grafanaAlertNotifiers, grafanaAlertNotifiersMock } from '../mockGrafanaNotifiers';
|
||||
import { CloudChannelValues, GrafanaChannelValues, ReceiverFormValues } from '../types/receiver-form';
|
||||
import { grafanaAlertNotifiers } from '../mockGrafanaNotifiers';
|
||||
import {
|
||||
CloudChannelValues,
|
||||
GrafanaChannelMap,
|
||||
GrafanaChannelValues,
|
||||
ReceiverFormValues,
|
||||
} from '../types/receiver-form';
|
||||
|
||||
import {
|
||||
convertJiraFieldToJson,
|
||||
@@ -166,45 +169,221 @@ describe('Receiver form utils', () => {
|
||||
});
|
||||
|
||||
describe('formValuesToGrafanaReceiver', () => {
|
||||
it('should migrate regular settings to secure settings if the field is defined as secure', () => {
|
||||
const emailChannelValues: GrafanaChannelValues = {
|
||||
__id: '__1',
|
||||
type: 'email',
|
||||
settings: {
|
||||
to: 'test@example.com',
|
||||
},
|
||||
secureFields: {},
|
||||
disableResolveMessage: false,
|
||||
};
|
||||
|
||||
const slackChannelValues: GrafanaChannelValues = {
|
||||
__id: '__2',
|
||||
type: 'slack',
|
||||
settings: {
|
||||
url: 'https://slack.example.com',
|
||||
channel: '#alerts',
|
||||
},
|
||||
secureFields: {},
|
||||
disableResolveMessage: false,
|
||||
};
|
||||
|
||||
const defaultChannelValues = { ...emailChannelValues };
|
||||
|
||||
it('should convert form values to Grafana receiver with basic settings for a new receiver', () => {
|
||||
const formValues: ReceiverFormValues<GrafanaChannelValues> = {
|
||||
name: 'my-receiver',
|
||||
items: [slackChannelValues],
|
||||
};
|
||||
|
||||
const channelMap: GrafanaChannelMap = {};
|
||||
|
||||
const result = formValuesToGrafanaReceiver(formValues, channelMap, defaultChannelValues);
|
||||
|
||||
expect(result).toEqual<GrafanaManagedContactPoint>({
|
||||
name: 'my-receiver',
|
||||
grafana_managed_receiver_configs: [
|
||||
{
|
||||
name: 'my-receiver',
|
||||
type: 'slack',
|
||||
settings: {
|
||||
url: 'https://slack.example.com',
|
||||
channel: '#alerts',
|
||||
},
|
||||
secureFields: {},
|
||||
disableResolveMessage: false,
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it('should convert form values to Grafana receiver with uid mapping for existing receiver', () => {
|
||||
const formValues: ReceiverFormValues<GrafanaChannelValues> = {
|
||||
name: 'my-receiver',
|
||||
items: [emailChannelValues, slackChannelValues],
|
||||
};
|
||||
|
||||
const channelMap: GrafanaChannelMap = {
|
||||
__1: { ...emailChannelValues, secureFields: {}, uid: 'email-1' },
|
||||
__2: { ...slackChannelValues, secureFields: {}, uid: 'slack-1' },
|
||||
};
|
||||
|
||||
const result = formValuesToGrafanaReceiver(formValues, channelMap, defaultChannelValues);
|
||||
|
||||
expect(result).toEqual<GrafanaManagedContactPoint>({
|
||||
name: 'my-receiver',
|
||||
grafana_managed_receiver_configs: [
|
||||
{
|
||||
uid: 'email-1',
|
||||
name: 'my-receiver',
|
||||
type: 'email',
|
||||
settings: { to: 'test@example.com' },
|
||||
secureFields: {},
|
||||
disableResolveMessage: false,
|
||||
},
|
||||
{
|
||||
uid: 'slack-1',
|
||||
name: 'my-receiver',
|
||||
type: 'slack',
|
||||
settings: { url: 'https://slack.example.com', channel: '#alerts' },
|
||||
secureFields: {},
|
||||
disableResolveMessage: false,
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it('should omit empty values from settings', () => {
|
||||
const formValues: ReceiverFormValues<GrafanaChannelValues> = {
|
||||
name: 'my-receiver',
|
||||
items: [
|
||||
{
|
||||
__id: '1',
|
||||
secureSettings: {},
|
||||
secureFields: {},
|
||||
type: 'discord',
|
||||
__id: '__1',
|
||||
type: 'email',
|
||||
settings: {
|
||||
url: 'https://foo.bar/',
|
||||
to: 'test@example.com',
|
||||
from: '', // empty string
|
||||
subject: undefined, // undefined
|
||||
body: null, // null
|
||||
cc: 'cc@example.com',
|
||||
},
|
||||
secureFields: {},
|
||||
disableResolveMessage: false,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const channelMap: GrafanaChannelMap = {
|
||||
__1: {
|
||||
uid: 'email-1',
|
||||
type: 'email',
|
||||
settings: {
|
||||
to: 'test@example.com',
|
||||
from: 'old@example.com', // existing value that should be removed
|
||||
},
|
||||
secureFields: {},
|
||||
disableResolveMessage: false,
|
||||
},
|
||||
};
|
||||
|
||||
const result = formValuesToGrafanaReceiver(formValues, channelMap, defaultChannelValues);
|
||||
|
||||
expect(result).toEqual<GrafanaManagedContactPoint>({
|
||||
name: 'my-receiver',
|
||||
grafana_managed_receiver_configs: [
|
||||
{
|
||||
uid: 'email-1',
|
||||
name: 'my-receiver',
|
||||
type: 'email',
|
||||
settings: {
|
||||
to: 'test@example.com',
|
||||
cc: 'cc@example.com',
|
||||
},
|
||||
secureFields: {},
|
||||
disableResolveMessage: false,
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it('should remove falsy secure fields and preserve truthy ones', () => {
|
||||
const formValues: ReceiverFormValues<GrafanaChannelValues> = {
|
||||
name: 'my-receiver',
|
||||
items: [
|
||||
{
|
||||
__id: '__1',
|
||||
type: 'sns',
|
||||
settings: { api_url: 'https://sns.example.com' },
|
||||
secureFields: {
|
||||
// Basic secure fields
|
||||
password: true, // should be preserved
|
||||
token: false, // should be omitted
|
||||
apiKey: '', // should be omitted
|
||||
|
||||
// Nested secure fields with dot notation
|
||||
'sigv4.access_key': true, // should be preserved
|
||||
'sigv4.secret_key': false, // should be omitted
|
||||
'sigv4.session_token': '', // should be omitted
|
||||
'other.nested.key': true, // should be preserved
|
||||
'other.nested.empty': false, // should be omitted
|
||||
|
||||
// Various falsy values
|
||||
secret: false, // should be omitted
|
||||
key: '', // should be omitted
|
||||
empty: false, // should be omitted
|
||||
},
|
||||
disableResolveMessage: false,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const channelMap = {
|
||||
'1': {
|
||||
uid: 'abc123',
|
||||
secureSettings: {},
|
||||
secureFields: {},
|
||||
type: 'discord',
|
||||
settings: {
|
||||
url: 'https://foo.bar/',
|
||||
const channelMap: GrafanaChannelMap = {
|
||||
__1: {
|
||||
uid: 'sns-1',
|
||||
type: 'sns',
|
||||
settings: { api_url: 'https://sns.example.com' },
|
||||
secureFields: {
|
||||
// All fields exist in the channel map
|
||||
password: true,
|
||||
token: true,
|
||||
apiKey: true,
|
||||
'sigv4.access_key': true,
|
||||
'sigv4.secret_key': true,
|
||||
'sigv4.session_token': true,
|
||||
'other.nested.key': true,
|
||||
'other.nested.empty': true,
|
||||
secret: true,
|
||||
key: true,
|
||||
empty: true,
|
||||
},
|
||||
disableResolveMessage: false,
|
||||
},
|
||||
};
|
||||
|
||||
const notifiers = [
|
||||
{
|
||||
type: 'discord',
|
||||
options: [{ propertyName: 'url', secure: true }],
|
||||
},
|
||||
] as NotifierDTO[];
|
||||
const result = formValuesToGrafanaReceiver(formValues, channelMap, defaultChannelValues);
|
||||
|
||||
// @ts-expect-error
|
||||
expect(formValuesToGrafanaReceiver(formValues, channelMap, {}, notifiers)).toMatchSnapshot();
|
||||
expect(result).toEqual<GrafanaManagedContactPoint>({
|
||||
name: 'my-receiver',
|
||||
grafana_managed_receiver_configs: [
|
||||
{
|
||||
uid: 'sns-1',
|
||||
name: 'my-receiver',
|
||||
type: 'sns',
|
||||
settings: {
|
||||
api_url: 'https://sns.example.com',
|
||||
},
|
||||
secureFields: {
|
||||
// Only truthy values should be preserved
|
||||
password: true,
|
||||
'sigv4.access_key': true,
|
||||
'other.nested.key': true,
|
||||
},
|
||||
disableResolveMessage: false,
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -222,7 +401,6 @@ describe('formValuesToCloudReceiver', () => {
|
||||
fields: [{ __id: '10', title: 'priority', value: '1' }],
|
||||
},
|
||||
secureFields: {},
|
||||
secureSettings: {},
|
||||
sendResolved: true,
|
||||
},
|
||||
],
|
||||
@@ -235,7 +413,6 @@ describe('formValuesToCloudReceiver', () => {
|
||||
url: 'https://slack.example.com/',
|
||||
},
|
||||
secureFields: {},
|
||||
secureSettings: {},
|
||||
sendResolved: true,
|
||||
};
|
||||
|
||||
@@ -256,7 +433,7 @@ describe('formValuesToCloudReceiver', () => {
|
||||
});
|
||||
|
||||
describe('grafanaReceiverToFormValues', () => {
|
||||
const { googlechat, slack, sns } = grafanaAlertNotifiers;
|
||||
const { slack, sns } = grafanaAlertNotifiers;
|
||||
|
||||
it('should convert fields from settings and secureFields', () => {
|
||||
const slackReceiver: GrafanaManagedContactPoint = {
|
||||
@@ -274,11 +451,10 @@ describe('grafanaReceiverToFormValues', () => {
|
||||
],
|
||||
};
|
||||
|
||||
const [formValues, _] = grafanaReceiverToFormValues(slackReceiver, grafanaAlertNotifiersMock);
|
||||
const [formValues, _] = grafanaReceiverToFormValues(slackReceiver);
|
||||
expect(formValues.items[0].type).toBe(slack.type);
|
||||
expect(formValues.items[0].settings.recipient).toBe('#alerting-ops');
|
||||
expect(formValues.items[0].secureFields.token).toBe(true);
|
||||
expect(formValues.items[0].secureSettings).toEqual({});
|
||||
});
|
||||
|
||||
it('should convert nested settings and secureFields', () => {
|
||||
@@ -300,7 +476,7 @@ describe('grafanaReceiverToFormValues', () => {
|
||||
],
|
||||
};
|
||||
|
||||
const [formValues, _] = grafanaReceiverToFormValues(snsReceiver, grafanaAlertNotifiersMock);
|
||||
const [formValues, _] = grafanaReceiverToFormValues(snsReceiver);
|
||||
|
||||
expect(formValues.items[0].settings.api_url).toBe('https://sns.example.com/');
|
||||
expect(formValues.items[0].settings.phone_number).toBe('+1234567890');
|
||||
@@ -308,26 +484,6 @@ describe('grafanaReceiverToFormValues', () => {
|
||||
expect(formValues.items[0].secureFields['sigv4.access_key']).toBe(true);
|
||||
expect(formValues.items[0].secureFields['sigv4.secret_key']).toBe(true);
|
||||
});
|
||||
|
||||
// Some receivers have migrated options that are now marked as secure but were standard fields in the past
|
||||
// We need to handle the case where the field is still present in settings but marked as secure
|
||||
it('should convert fields from settings to secureSettings for migrated options', () => {
|
||||
const googleChatReceiver: GrafanaManagedContactPoint = {
|
||||
name: 'googlechat-receiver',
|
||||
grafana_managed_receiver_configs: [
|
||||
{
|
||||
type: googlechat.type,
|
||||
settings: {
|
||||
url: 'https://googlechat.example.com/',
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const [formValues, _] = grafanaReceiverToFormValues(googleChatReceiver, grafanaAlertNotifiersMock);
|
||||
expect(formValues.items[0].secureSettings.url).toBe('https://googlechat.example.com/');
|
||||
expect(formValues.items[0].settings.url).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('convertJsonToJiraField', () => {
|
||||
|
||||
@@ -1,14 +1,16 @@
|
||||
import { get, has, isArray, isNil, omit, omitBy, reduce } from 'lodash';
|
||||
import { has, isArray, isNil, omitBy, pickBy } from 'lodash';
|
||||
|
||||
import {
|
||||
AlertmanagerReceiver,
|
||||
GrafanaManagedContactPoint,
|
||||
GrafanaManagedReceiverConfig,
|
||||
GrafanaManagedReceiverSecureFields,
|
||||
Receiver,
|
||||
} from 'app/plugins/datasource/alertmanager/types';
|
||||
import { CloudNotifierType, NotificationChannelOption, NotifierDTO, NotifierType } from 'app/types';
|
||||
|
||||
import {
|
||||
ChannelValues,
|
||||
CloudChannelConfig,
|
||||
CloudChannelMap,
|
||||
CloudChannelValues,
|
||||
@@ -18,8 +20,7 @@ import {
|
||||
} from '../types/receiver-form';
|
||||
|
||||
export function grafanaReceiverToFormValues(
|
||||
receiver: GrafanaManagedContactPoint,
|
||||
notifiers: NotifierDTO[]
|
||||
receiver: GrafanaManagedContactPoint
|
||||
): [ReceiverFormValues<GrafanaChannelValues>, GrafanaChannelMap] {
|
||||
const channelMap: GrafanaChannelMap = {};
|
||||
// giving each form receiver item a unique id so we can use it to map back to "original" items
|
||||
@@ -32,8 +33,7 @@ export function grafanaReceiverToFormValues(
|
||||
receiver.grafana_managed_receiver_configs?.map((channel) => {
|
||||
const id = String(idCounter++);
|
||||
channelMap[id] = channel;
|
||||
const notifier = notifiers.find(({ type }) => type === channel.type);
|
||||
return grafanaChannelConfigToFormChannelValues(id, channel, notifier);
|
||||
return grafanaChannelConfigToFormChannelValues(id, channel);
|
||||
}) ?? [],
|
||||
};
|
||||
return [values, channelMap];
|
||||
@@ -77,22 +77,14 @@ export function cloudReceiverToFormValues(
|
||||
export function formValuesToGrafanaReceiver(
|
||||
values: ReceiverFormValues<GrafanaChannelValues>,
|
||||
channelMap: GrafanaChannelMap,
|
||||
defaultChannelValues: GrafanaChannelValues,
|
||||
notifiers: NotifierDTO[]
|
||||
): Receiver {
|
||||
defaultChannelValues: GrafanaChannelValues
|
||||
): GrafanaManagedContactPoint {
|
||||
return {
|
||||
name: values.name,
|
||||
grafana_managed_receiver_configs: (values.items ?? []).map((channelValues) => {
|
||||
const existing: GrafanaManagedReceiverConfig | undefined = channelMap[channelValues.__id];
|
||||
const notifier = notifiers.find((notifier) => notifier.type === channelValues.type);
|
||||
|
||||
return formChannelValuesToGrafanaChannelConfig(
|
||||
channelValues,
|
||||
defaultChannelValues,
|
||||
values.name,
|
||||
existing,
|
||||
notifier
|
||||
);
|
||||
return formChannelValuesToGrafanaChannelConfig(channelValues, defaultChannelValues, values.name, existing);
|
||||
}),
|
||||
};
|
||||
}
|
||||
@@ -100,7 +92,7 @@ export function formValuesToGrafanaReceiver(
|
||||
export function formValuesToCloudReceiver(
|
||||
values: ReceiverFormValues<CloudChannelValues>,
|
||||
defaults: CloudChannelValues
|
||||
): Receiver {
|
||||
): AlertmanagerReceiver {
|
||||
const recv: AlertmanagerReceiver = {
|
||||
name: values.name,
|
||||
};
|
||||
@@ -177,33 +169,23 @@ function cloudChannelConfigToFormChannelValues(
|
||||
...(type === 'jira' ? convertJsonToJiraField(channel) : channel),
|
||||
},
|
||||
secureFields: {},
|
||||
secureSettings: {},
|
||||
sendResolved: channel.send_resolved,
|
||||
};
|
||||
}
|
||||
|
||||
function grafanaChannelConfigToFormChannelValues(
|
||||
id: string,
|
||||
channel: GrafanaManagedReceiverConfig,
|
||||
notifier?: NotifierDTO
|
||||
channel: GrafanaManagedReceiverConfig
|
||||
): GrafanaChannelValues {
|
||||
const values: GrafanaChannelValues = {
|
||||
__id: id,
|
||||
type: channel.type as NotifierType,
|
||||
provenance: channel.provenance,
|
||||
secureSettings: {},
|
||||
settings: { ...channel.settings },
|
||||
secureFields: { ...channel.secureFields },
|
||||
disableResolveMessage: channel.disableResolveMessage,
|
||||
};
|
||||
|
||||
notifier?.options.forEach((option) => {
|
||||
if (option.secure && values.settings[option.propertyName]) {
|
||||
values.secureSettings[option.propertyName] = values.settings[option.propertyName];
|
||||
delete values.settings[option.propertyName];
|
||||
}
|
||||
});
|
||||
|
||||
return values;
|
||||
}
|
||||
|
||||
@@ -241,43 +223,22 @@ export function formChannelValuesToGrafanaChannelConfig(
|
||||
values: GrafanaChannelValues,
|
||||
defaults: GrafanaChannelValues,
|
||||
name: string,
|
||||
existing?: GrafanaManagedReceiverConfig,
|
||||
notifier?: NotifierDTO
|
||||
existing?: GrafanaManagedReceiverConfig
|
||||
): GrafanaManagedReceiverConfig {
|
||||
const secureFieldsFromValues = values.secureFields ? omitFalsySecureFields(values.secureFields) : undefined;
|
||||
|
||||
const channel: GrafanaManagedReceiverConfig = {
|
||||
settings: omitEmptyValues({
|
||||
...(existing && existing.type === values.type ? (existing.settings ?? {}) : {}),
|
||||
...(values.settings ?? {}),
|
||||
}),
|
||||
secureSettings: omitEmptyUnlessExisting(values.secureSettings, existing?.secureFields),
|
||||
secureFields: secureFieldsFromValues,
|
||||
type: values.type,
|
||||
name,
|
||||
disableResolveMessage:
|
||||
values.disableResolveMessage ?? existing?.disableResolveMessage ?? defaults.disableResolveMessage,
|
||||
};
|
||||
|
||||
// find all secure field definitions
|
||||
const secureFieldNames = notifier ? getSecureFieldNames(notifier) : [];
|
||||
|
||||
// we make sure all fields that are marked as "secure" will be moved to "SecureSettings" instead of "settings"
|
||||
const secureSettings = reduce(
|
||||
secureFieldNames,
|
||||
(acc: Record<string, unknown> = {}, key) => {
|
||||
// the value for secure settings can come from either the "settings" (accidental) or "secureFields" if editing an existing receiver
|
||||
acc[key] = get(channel.settings, key) ?? get(values.secureFields, key);
|
||||
return acc;
|
||||
},
|
||||
{}
|
||||
);
|
||||
|
||||
channel.secureSettings = {
|
||||
...secureSettings,
|
||||
...channel.secureSettings,
|
||||
};
|
||||
|
||||
// remove the secure ones from the regular settings
|
||||
channel.settings = omit(channel.settings, secureFieldNames);
|
||||
|
||||
if (existing) {
|
||||
channel.uid = existing.uid;
|
||||
}
|
||||
@@ -285,6 +246,13 @@ export function formChannelValuesToGrafanaChannelConfig(
|
||||
return channel;
|
||||
}
|
||||
|
||||
/**
|
||||
* Omit falsy values from secure fields object so the backend knows to reset them
|
||||
*/
|
||||
function omitFalsySecureFields(secureFields: ChannelValues['secureFields']): GrafanaManagedReceiverSecureFields {
|
||||
return pickBy(secureFields, (value) => value === true);
|
||||
}
|
||||
|
||||
// null, undefined and '' are deemed unacceptable
|
||||
const isUnacceptableValue = (value: unknown) => isNil(value) || value === '';
|
||||
|
||||
|
||||
@@ -69,11 +69,16 @@ export type WebhookConfig = {
|
||||
};
|
||||
|
||||
type GrafanaManagedReceiverConfigSettings<T = any> = Record<string, T>;
|
||||
export type GrafanaManagedReceiverSecureFields = Record<string, boolean>;
|
||||
|
||||
export type GrafanaManagedReceiverConfig = {
|
||||
uid?: string;
|
||||
disableResolveMessage?: boolean;
|
||||
secureFields?: Record<string, boolean>;
|
||||
secureSettings?: GrafanaManagedReceiverConfigSettings;
|
||||
/**
|
||||
* Secure fields keys values should be true if they are already configured in the database
|
||||
* To reset the secure field, omit the key from the object when updating the receiver
|
||||
*/
|
||||
secureFields?: GrafanaManagedReceiverSecureFields;
|
||||
/** If retrieved from k8s API, SecureSettings property name is different */
|
||||
// SecureSettings?: GrafanaManagedReceiverConfigSettings<boolean>;
|
||||
settings: GrafanaManagedReceiverConfigSettings;
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { ValidationRule } from 'react-hook-form';
|
||||
|
||||
import { SelectableValue } from '@grafana/data';
|
||||
import { IconName } from '@grafana/ui';
|
||||
|
||||
@@ -123,6 +125,11 @@ export interface ChannelTypeSettings {
|
||||
uploadImage: boolean;
|
||||
}
|
||||
|
||||
export interface OptionMeta {
|
||||
required?: string | ValidationRule<boolean>;
|
||||
readOnly?: boolean;
|
||||
}
|
||||
|
||||
export interface NotificationChannelOption {
|
||||
element:
|
||||
| 'input'
|
||||
|
||||
Reference in New Issue
Block a user