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:
Konrad Lalik
2025-05-06 14:07:49 +02:00
committed by GitHub
parent 9b17cd44dc
commit f2b9830fda
24 changed files with 1149 additions and 354 deletions
+2 -5
View File
@@ -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"],
@@ -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';
@@ -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>
@@ -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}
/>
@@ -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 {
@@ -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}
@@ -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,
@@ -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}`);
}
};
@@ -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}
@@ -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;
+7
View File
@@ -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'