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, "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.", "1"],
|
||||||
[0, 0, 0, "Unexpected any. Specify a different type.", "2"],
|
[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.", "3"]
|
||||||
[0, 0, 0, "Unexpected any. Specify a different type.", "4"],
|
|
||||||
[0, 0, 0, "Unexpected any. Specify a different type.", "5"]
|
|
||||||
],
|
],
|
||||||
"public/app/features/alerting/unified/components/receivers/form/fields/SubformArrayField.tsx:5381": [
|
"public/app/features/alerting/unified/components/receivers/form/fields/SubformArrayField.tsx:5381": [
|
||||||
[0, 0, 0, "Unexpected any. Specify a different type.", "0"],
|
[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"]
|
[0, 0, 0, "Do not use any type assertions.", "0"]
|
||||||
],
|
],
|
||||||
"public/app/features/alerting/unified/types/receiver-form.ts:5381": [
|
"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.", "0"]
|
||||||
[0, 0, 0, "Unexpected any. Specify a different type.", "1"]
|
|
||||||
],
|
],
|
||||||
"public/app/features/alerting/unified/utils/misc.test.ts:5381": [
|
"public/app/features/alerting/unified/utils/misc.test.ts:5381": [
|
||||||
[0, 0, 0, "Unexpected any. Specify a different type.", "0"],
|
[0, 0, 0, "Unexpected any. Specify a different type.", "0"],
|
||||||
|
|||||||
+1
-1
@@ -44,7 +44,7 @@ beforeEach(() => {
|
|||||||
grantUserPermissions([AccessControlAction.AlertingNotificationsRead, AccessControlAction.AlertingNotificationsWrite]);
|
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';
|
const templatesSelectorTestId = 'existing-templates-selector';
|
||||||
|
|
||||||
|
|||||||
+4
-4
@@ -106,8 +106,8 @@ exports[`useContactPoints should return contact points with status 1`] = `
|
|||||||
"type": "oncall",
|
"type": "oncall",
|
||||||
Symbol(receiver_status): undefined,
|
Symbol(receiver_status): undefined,
|
||||||
Symbol(receiver_metadata): {
|
Symbol(receiver_metadata): {
|
||||||
"description": "Sends notifications to Grafana OnCall",
|
"description": "Sends notifications to Grafana IRM",
|
||||||
"name": "Grafana OnCall",
|
"name": "Grafana IRM",
|
||||||
},
|
},
|
||||||
Symbol(receiver_plugin_metadata): {
|
Symbol(receiver_plugin_metadata): {
|
||||||
"icon": "public/img/alerting/oncall_logo.svg",
|
"icon": "public/img/alerting/oncall_logo.svg",
|
||||||
@@ -357,8 +357,8 @@ exports[`useContactPoints when having oncall plugin installed and no alert manag
|
|||||||
"type": "oncall",
|
"type": "oncall",
|
||||||
Symbol(receiver_status): undefined,
|
Symbol(receiver_status): undefined,
|
||||||
Symbol(receiver_metadata): {
|
Symbol(receiver_metadata): {
|
||||||
"description": "Sends notifications to Grafana OnCall",
|
"description": "Sends notifications to Grafana IRM",
|
||||||
"name": "Grafana OnCall",
|
"name": "Grafana IRM",
|
||||||
},
|
},
|
||||||
Symbol(receiver_plugin_metadata): {
|
Symbol(receiver_plugin_metadata): {
|
||||||
"description": "grafana-integration",
|
"description": "grafana-integration",
|
||||||
|
|||||||
@@ -3,7 +3,6 @@
|
|||||||
* and (if available) it will also fetch the status from the Grafana Managed status endpoint
|
* 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 { useMemo } from 'react';
|
||||||
|
|
||||||
import { receiversApi } from 'app/features/alerting/unified/api/receiversK8sApi';
|
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 { cloudNotifierTypes } from 'app/features/alerting/unified/utils/cloud-alertmanager-notifier-types';
|
||||||
import { GRAFANA_RULES_SOURCE_NAME } from 'app/features/alerting/unified/utils/datasource';
|
import { GRAFANA_RULES_SOURCE_NAME } from 'app/features/alerting/unified/utils/datasource';
|
||||||
import { isK8sEntityProvisioned, shouldUseK8sApi } from 'app/features/alerting/unified/utils/k8s/utils';
|
import { isK8sEntityProvisioned, shouldUseK8sApi } from 'app/features/alerting/unified/utils/k8s/utils';
|
||||||
import {
|
import { GrafanaManagedContactPoint, Receiver } from 'app/plugins/datasource/alertmanager/types';
|
||||||
GrafanaManagedContactPoint,
|
|
||||||
GrafanaManagedReceiverConfig,
|
|
||||||
Receiver,
|
|
||||||
} from 'app/plugins/datasource/alertmanager/types';
|
|
||||||
|
|
||||||
import { getAPINamespace } from '../../../../../api/utils';
|
import { getAPINamespace } from '../../../../../api/utils';
|
||||||
import { alertmanagerApi } from '../../api/alertmanagerApi';
|
import { alertmanagerApi } from '../../api/alertmanagerApi';
|
||||||
@@ -327,47 +322,6 @@ export function useDeleteContactPoint({ alertmanager }: BaseAlertmanagerArgs) {
|
|||||||
return useK8sApi ? deleteFromK8sAPI : deleteFromAlertmanagerConfiguration;
|
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 = (
|
const grafanaContactPointToK8sReceiver = (
|
||||||
contactPoint: GrafanaManagedContactPoint,
|
contactPoint: GrafanaManagedContactPoint,
|
||||||
id?: string,
|
id?: string,
|
||||||
@@ -380,7 +334,7 @@ const grafanaContactPointToK8sReceiver = (
|
|||||||
},
|
},
|
||||||
spec: {
|
spec: {
|
||||||
title: contactPoint.name,
|
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}
|
option={option}
|
||||||
error={errors[option.propertyName]}
|
error={errors[option.propertyName]}
|
||||||
pathPrefix={''}
|
pathPrefix={''}
|
||||||
|
secureFields={{}}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
+1
-1
@@ -17,7 +17,7 @@ exports[`new receiver should be able to test and save a receiver 1`] = `
|
|||||||
{
|
{
|
||||||
"disableResolveMessage": false,
|
"disableResolveMessage": false,
|
||||||
"name": "test",
|
"name": "test",
|
||||||
"secureSettings": {},
|
"secureFields": {},
|
||||||
"settings": {
|
"settings": {
|
||||||
"addresses": "tester@grafana.com",
|
"addresses": "tester@grafana.com",
|
||||||
"singleEmail": false,
|
"singleEmail": false,
|
||||||
|
|||||||
@@ -2,20 +2,29 @@ import * as React from 'react';
|
|||||||
import { DeepMap, FieldError, FieldErrors, useFormContext } from 'react-hook-form';
|
import { DeepMap, FieldError, FieldErrors, useFormContext } from 'react-hook-form';
|
||||||
|
|
||||||
import { Field, SecretInput } from '@grafana/ui';
|
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';
|
import { OptionField } from './fields/OptionField';
|
||||||
|
|
||||||
export interface Props<R extends ChannelValues> {
|
export interface Props<R extends ChannelValues> {
|
||||||
defaultValues: R;
|
defaultValues: R;
|
||||||
selectedChannelOptions: NotificationChannelOption[];
|
selectedChannelOptions: NotificationChannelOption[];
|
||||||
secureFields: NotificationChannelSecureFields;
|
|
||||||
|
|
||||||
onResetSecureField: (key: string) => void;
|
onResetSecureField: (key: string) => void;
|
||||||
|
onDeleteSubform?: (propertyName: string) => void;
|
||||||
errors?: FieldErrors<R>;
|
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;
|
readOnly?: boolean;
|
||||||
|
|
||||||
customValidators?: Record<string, React.ComponentProps<typeof OptionField>['customValidator']>;
|
customValidators?: Record<string, React.ComponentProps<typeof OptionField>['customValidator']>;
|
||||||
@@ -25,14 +34,23 @@ export function ChannelOptions<R extends ChannelValues>({
|
|||||||
defaultValues,
|
defaultValues,
|
||||||
selectedChannelOptions,
|
selectedChannelOptions,
|
||||||
onResetSecureField,
|
onResetSecureField,
|
||||||
secureFields,
|
onDeleteSubform,
|
||||||
errors,
|
errors,
|
||||||
pathPrefix = '',
|
integrationPrefix,
|
||||||
readOnly = false,
|
readOnly = false,
|
||||||
customValidators = {},
|
customValidators = {},
|
||||||
}: Props<R>): JSX.Element {
|
}: Props<R>): JSX.Element {
|
||||||
const { watch } = useFormContext<ReceiverFormValues<R>>();
|
const { watch } = useFormContext<ReceiverFormValues<CloudChannelValues | GrafanaChannelValues>>();
|
||||||
const currentFormValues = watch(); // react hook form types ARE LYING!
|
|
||||||
|
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 (
|
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
|
// Some options can be dependent on other options, this determines what is selected in the dependency options
|
||||||
// I think this needs more thought.
|
// I think this needs more thought.
|
||||||
// pathPrefix = items.index.
|
// pathPrefix = items.index.
|
||||||
const paths = pathPrefix.split('.');
|
// const paths = pathPrefix.split('.');
|
||||||
const selectedOptionValue =
|
const selectedOptionValue = settings?.[option.showWhen.field];
|
||||||
paths.length >= 2 ? currentFormValues.items?.[Number(paths[1])].settings?.[option.showWhen.field] : undefined;
|
|
||||||
|
|
||||||
if (option.showWhen.field && selectedOptionValue !== option.showWhen.is) {
|
if (option.showWhen.field && selectedOptionValue !== option.showWhen.is) {
|
||||||
return null;
|
return null;
|
||||||
@@ -51,33 +68,77 @@ export function ChannelOptions<R extends ChannelValues>({
|
|||||||
|
|
||||||
if (secureFields && secureFields[option.propertyName]) {
|
if (secureFields && secureFields[option.propertyName]) {
|
||||||
return (
|
return (
|
||||||
<Field key={key} label={option.label} description={option.description}>
|
<Field
|
||||||
<SecretInput onReset={() => onResetSecureField(option.propertyName)} isConfigured />
|
key={key}
|
||||||
|
label={option.label}
|
||||||
|
description={option.description}
|
||||||
|
htmlFor={`${settingsPath}${option.propertyName}`}
|
||||||
|
>
|
||||||
|
<SecretInput
|
||||||
|
id={`${settingsPath}${option.propertyName}`}
|
||||||
|
onReset={() => onResetSecureField(option.propertyName)}
|
||||||
|
isConfigured
|
||||||
|
/>
|
||||||
</Field>
|
</Field>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const error: FieldError | DeepMap<any, FieldError> | undefined = (
|
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];
|
)?.[option.propertyName];
|
||||||
|
|
||||||
const defaultValue = defaultValues?.settings?.[option.propertyName];
|
const defaultValue = defaultValues?.settings?.[option.propertyName];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<OptionField
|
<OptionField
|
||||||
onResetSecureField={onResetSecureField}
|
|
||||||
secureFields={secureFields}
|
secureFields={secureFields}
|
||||||
|
onResetSecureField={onResetSecureField}
|
||||||
|
onDeleteSubform={onDeleteSubform}
|
||||||
defaultValue={defaultValue}
|
defaultValue={defaultValue}
|
||||||
readOnly={readOnly}
|
readOnly={readOnly}
|
||||||
key={key}
|
key={key}
|
||||||
error={error}
|
error={error}
|
||||||
pathPrefix={pathPrefix}
|
pathPrefix={settingsPath}
|
||||||
pathSuffix={option.secure ? 'secureSettings.' : 'settings.'}
|
|
||||||
option={option}
|
option={option}
|
||||||
customValidator={customValidators[option.propertyName]}
|
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 { css } from '@emotion/css';
|
||||||
import { sortBy } from 'lodash';
|
import { sortBy } from 'lodash';
|
||||||
import * as React from 'react';
|
import * as React from 'react';
|
||||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
import { useEffect, useMemo } from 'react';
|
||||||
import { Controller, FieldErrors, FieldValues, useFormContext } from 'react-hook-form';
|
import { Controller, FieldErrors, useFormContext, useWatch } from 'react-hook-form';
|
||||||
|
|
||||||
import { GrafanaTheme2, SelectableValue } from '@grafana/data';
|
import { GrafanaTheme2, SelectableValue } from '@grafana/data';
|
||||||
import { Alert, Button, Field, Select, Stack, Text, useStyles2 } from '@grafana/ui';
|
import { Alert, Button, Field, Select, Stack, Text, useStyles2 } from '@grafana/ui';
|
||||||
import { Trans, t } from 'app/core/internationalization';
|
import { Trans, t } from 'app/core/internationalization';
|
||||||
|
|
||||||
import { useUnifiedAlertingSelector } from '../../../hooks/useUnifiedAlertingSelector';
|
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 { OnCallIntegrationType } from '../grafanaAppReceivers/onCall/useOnCallIntegration';
|
||||||
|
|
||||||
import { ChannelOptions } from './ChannelOptions';
|
import { ChannelOptions } from './ChannelOptions';
|
||||||
import { CollapsibleSection } from './CollapsibleSection';
|
import { CollapsibleSection } from './CollapsibleSection';
|
||||||
import { Notifier } from './notifiers';
|
import { Notifier } from './notifiers';
|
||||||
|
|
||||||
interface Props<R extends FieldValues> {
|
interface Props<R extends ChannelValues> {
|
||||||
defaultValues: R;
|
defaultValues: R;
|
||||||
initialValues?: R;
|
initialValues?: R;
|
||||||
pathPrefix: string;
|
pathPrefix: `items.${number}.`;
|
||||||
|
integrationIndex: number;
|
||||||
notifiers: Notifier[];
|
notifiers: Notifier[];
|
||||||
onDuplicate: () => void;
|
onDuplicate: () => void;
|
||||||
onTest?: () => void;
|
onTest?: () => void;
|
||||||
commonSettingsComponent: CommonSettingsComponentType;
|
commonSettingsComponent: CommonSettingsComponentType;
|
||||||
|
|
||||||
secureFields?: Record<string, boolean>;
|
|
||||||
errors?: FieldErrors<R>;
|
errors?: FieldErrors<R>;
|
||||||
onDelete?: () => void;
|
onDelete?: () => void;
|
||||||
isEditable?: boolean;
|
isEditable?: boolean;
|
||||||
@@ -38,74 +43,85 @@ export function ChannelSubForm<R extends ChannelValues>({
|
|||||||
defaultValues,
|
defaultValues,
|
||||||
initialValues,
|
initialValues,
|
||||||
pathPrefix,
|
pathPrefix,
|
||||||
|
integrationIndex,
|
||||||
onDuplicate,
|
onDuplicate,
|
||||||
onDelete,
|
onDelete,
|
||||||
onTest,
|
onTest,
|
||||||
notifiers,
|
notifiers,
|
||||||
errors,
|
errors,
|
||||||
secureFields,
|
|
||||||
commonSettingsComponent: CommonSettingsComponent,
|
commonSettingsComponent: CommonSettingsComponent,
|
||||||
isEditable = true,
|
isEditable = true,
|
||||||
isTestable,
|
isTestable,
|
||||||
customValidators = {},
|
customValidators = {},
|
||||||
}: Props<R>): JSX.Element {
|
}: Props<R>): JSX.Element {
|
||||||
const styles = useStyles2(getStyles);
|
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(typeFieldPath) ?? defaultValues.type;
|
||||||
const selectedType = watch(fieldName('type')) ?? defaultValues.type; // nope, setting "default" does not work at all.
|
const parse_mode = watch(`${settingsFieldPath}.parse_mode`);
|
||||||
const parse_mode = watch(fieldName('settings.parse_mode'));
|
|
||||||
const { loading: testingReceiver } = useUnifiedAlertingSelector((state) => state.testReceivers);
|
const { loading: testingReceiver } = useUnifiedAlertingSelector((state) => state.testReceivers);
|
||||||
|
|
||||||
// TODO I don't like integration specific code here but other ways require a bigger refactoring
|
// 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;
|
const isTestAvailable = onCallIntegrationType !== OnCallIntegrationType.NewIntegration;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
register(`${pathPrefix}.__id`);
|
register(`${channelFieldPath}.__id`);
|
||||||
/* Need to manually register secureFields or else they'll
|
/* Need to manually register secureFields or else they'll
|
||||||
be lost when testing a contact point */
|
be lost when testing a contact point */
|
||||||
register(`${pathPrefix}.secureFields`);
|
register(`${channelFieldPath}.secureFields`);
|
||||||
}, [register, pathPrefix]);
|
}, [register, channelFieldPath]);
|
||||||
|
|
||||||
// Prevent forgetting about initial values when switching the integration type and the oncall integration type
|
// Prevent forgetting about initial values when switching the integration type and the oncall integration type
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Restore values when switching back from a changed integration to the default one
|
// Restore values when switching back from a changed integration to the default one
|
||||||
const subscription = watch((v, { name, type }) => {
|
const subscription = watch((formValues, { name, type }) => {
|
||||||
const value = name ? v[name] : '';
|
// @ts-expect-error name is valid key for formValues
|
||||||
if (initialValues && name === fieldName('type') && value === initialValues.type && type === 'change') {
|
const value = name ? formValues[name] : '';
|
||||||
setValue(fieldName('settings'), initialValues.settings);
|
if (initialValues && name === typeFieldPath && value === initialValues.type && type === 'change') {
|
||||||
|
setValue(settingsFieldPath, initialValues.settings);
|
||||||
}
|
}
|
||||||
// Restore initial value of an existing oncall integration
|
// Restore initial value of an existing oncall integration
|
||||||
if (
|
if (
|
||||||
initialValues &&
|
initialValues &&
|
||||||
name === fieldName('settings.integration_type') &&
|
name === `${settingsFieldPath}.integration_type` &&
|
||||||
value === OnCallIntegrationType.ExistingIntegration
|
value === OnCallIntegrationType.ExistingIntegration
|
||||||
) {
|
) {
|
||||||
setValue(fieldName('settings.url'), initialValues.settings.url);
|
setValue(`${settingsFieldPath}.url`, initialValues.settings.url);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
return () => subscription.unsubscribe();
|
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) => {
|
const onResetSecureField = (key: string) => {
|
||||||
if (_secureFields[key]) {
|
// formSecureFields might not be up to date if this function is called multiple times in a row
|
||||||
const updatedSecureFields = { ..._secureFields };
|
const currentSecureFields = getValues(`${channelFieldPath}.secureFields`);
|
||||||
updatedSecureFields[key] = '';
|
if (currentSecureFields[key]) {
|
||||||
setSecureFields(updatedSecureFields);
|
setValue(`${channelFieldPath}.secureFields`, { ...currentSecureFields, [key]: '' });
|
||||||
setValue(`${pathPrefix}.secureFields`, updatedSecureFields);
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
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(
|
const typeOptions = useMemo(
|
||||||
(): SelectableValue[] =>
|
(): SelectableValue[] =>
|
||||||
sortBy(notifiers, ({ dto, meta }) => [meta?.order ?? 0, dto.name])
|
sortBy(notifiers, ({ dto, meta }) => [meta?.order ?? 0, dto.name]).map<SelectableValue>(
|
||||||
// .notifiers.sort((a, b) => a.dto.name.localeCompare(b.dto.name))
|
({ dto: { name, type }, meta }) => ({
|
||||||
.map<SelectableValue>(({ dto: { name, type }, meta }) => ({
|
|
||||||
// @ts-expect-error ReactNode is supported
|
// @ts-expect-error ReactNode is supported
|
||||||
label: (
|
label: (
|
||||||
<Stack alignItems="center" gap={1}>
|
<Stack alignItems="center" gap={1}>
|
||||||
@@ -116,7 +132,8 @@ export function ChannelSubForm<R extends ChannelValues>({
|
|||||||
value: type,
|
value: type,
|
||||||
description: meta?.description,
|
description: meta?.description,
|
||||||
isDisabled: meta ? !meta.enabled : false,
|
isDisabled: meta ? !meta.enabled : false,
|
||||||
})),
|
})
|
||||||
|
),
|
||||||
[notifiers]
|
[notifiers]
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -137,8 +154,8 @@ export function ChannelSubForm<R extends ChannelValues>({
|
|||||||
const showTelegramWarning = isTelegram && !isParseModeNone;
|
const showTelegramWarning = isTelegram && !isParseModeNone;
|
||||||
// if there are mandatory options defined, optional options will be hidden by a collapse
|
// if there are mandatory options defined, optional options will be hidden by a collapse
|
||||||
// if there aren't mandatory options, all options will be shown without collapse
|
// if there aren't mandatory options, all options will be shown without collapse
|
||||||
const mandatoryOptions = notifier?.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 optionalOptions = notifier?.dto.options.filter((o) => !o.required) ?? [];
|
||||||
|
|
||||||
const contactPointTypeInputId = `contact-point-type-${pathPrefix}`;
|
const contactPointTypeInputId = `contact-point-type-${pathPrefix}`;
|
||||||
return (
|
return (
|
||||||
@@ -151,7 +168,8 @@ export function ChannelSubForm<R extends ChannelValues>({
|
|||||||
data-testid={`${pathPrefix}type`}
|
data-testid={`${pathPrefix}type`}
|
||||||
>
|
>
|
||||||
<Controller
|
<Controller
|
||||||
name={fieldName('type')}
|
name={typeFieldPath}
|
||||||
|
control={control}
|
||||||
defaultValue={defaultValues.type}
|
defaultValue={defaultValues.type}
|
||||||
render={({ field: { ref, onChange, ...field } }) => (
|
render={({ field: { ref, onChange, ...field } }) => (
|
||||||
<Select
|
<Select
|
||||||
@@ -163,8 +181,6 @@ export function ChannelSubForm<R extends ChannelValues>({
|
|||||||
onChange={(value) => onChange(value?.value)}
|
onChange={(value) => onChange(value?.value)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
control={control}
|
|
||||||
rules={{ required: true }}
|
|
||||||
/>
|
/>
|
||||||
</Field>
|
</Field>
|
||||||
</div>
|
</div>
|
||||||
@@ -221,15 +237,15 @@ export function ChannelSubForm<R extends ChannelValues>({
|
|||||||
)}
|
)}
|
||||||
<ChannelOptions<R>
|
<ChannelOptions<R>
|
||||||
defaultValues={defaultValues}
|
defaultValues={defaultValues}
|
||||||
selectedChannelOptions={mandatoryOptions?.length ? mandatoryOptions! : optionalOptions!}
|
selectedChannelOptions={mandatoryOptions.length ? mandatoryOptions : optionalOptions}
|
||||||
secureFields={_secureFields}
|
|
||||||
errors={errors}
|
errors={errors}
|
||||||
onResetSecureField={onResetSecureField}
|
onResetSecureField={onResetSecureField}
|
||||||
pathPrefix={pathPrefix}
|
onDeleteSubform={onDeleteSubform}
|
||||||
|
integrationPrefix={channelFieldPath}
|
||||||
readOnly={!isEditable}
|
readOnly={!isEditable}
|
||||||
customValidators={customValidators}
|
customValidators={customValidators}
|
||||||
/>
|
/>
|
||||||
{!!(mandatoryOptions?.length && optionalOptions?.length) && (
|
{!!(mandatoryOptions.length && optionalOptions.length) && (
|
||||||
<CollapsibleSection
|
<CollapsibleSection
|
||||||
label={t('alerting.channel-sub-form.label-section', 'Optional {{name}} settings', {
|
label={t('alerting.channel-sub-form.label-section', 'Optional {{name}} settings', {
|
||||||
name: notifier.dto.name,
|
name: notifier.dto.name,
|
||||||
@@ -242,11 +258,11 @@ export function ChannelSubForm<R extends ChannelValues>({
|
|||||||
)}
|
)}
|
||||||
<ChannelOptions<R>
|
<ChannelOptions<R>
|
||||||
defaultValues={defaultValues}
|
defaultValues={defaultValues}
|
||||||
selectedChannelOptions={optionalOptions!}
|
selectedChannelOptions={optionalOptions}
|
||||||
secureFields={_secureFields}
|
|
||||||
onResetSecureField={onResetSecureField}
|
onResetSecureField={onResetSecureField}
|
||||||
|
onDeleteSubform={onDeleteSubform}
|
||||||
errors={errors}
|
errors={errors}
|
||||||
pathPrefix={pathPrefix}
|
integrationPrefix={channelFieldPath}
|
||||||
readOnly={!isEditable}
|
readOnly={!isEditable}
|
||||||
customValidators={customValidators}
|
customValidators={customValidators}
|
||||||
/>
|
/>
|
||||||
|
|||||||
+300
-7
@@ -17,6 +17,7 @@ import { AccessControlAction } from 'app/types';
|
|||||||
|
|
||||||
import { AlertmanagerConfigBuilder, setupMswServer } from '../../../mockApi';
|
import { AlertmanagerConfigBuilder, setupMswServer } from '../../../mockApi';
|
||||||
import { grantUserPermissions } from '../../../mocks';
|
import { grantUserPermissions } from '../../../mocks';
|
||||||
|
import { alertingFactory } from '../../../mocks/server/db';
|
||||||
import { captureRequests } from '../../../mocks/server/events';
|
import { captureRequests } from '../../../mocks/server/events';
|
||||||
|
|
||||||
import { GrafanaReceiverForm } from './GrafanaReceiverForm';
|
import { GrafanaReceiverForm } from './GrafanaReceiverForm';
|
||||||
@@ -33,9 +34,10 @@ const renderWithProvider = (
|
|||||||
{ historyOptions }
|
{ historyOptions }
|
||||||
);
|
);
|
||||||
|
|
||||||
setupMswServer();
|
const server = setupMswServer();
|
||||||
|
|
||||||
const ui = {
|
const ui = {
|
||||||
|
typeSelector: byTestId('items.0.type'),
|
||||||
loadingIndicator: byText('Loading notifiers...'),
|
loadingIndicator: byText('Loading notifiers...'),
|
||||||
integrationType: byLabelText('Integration'),
|
integrationType: byLabelText('Integration'),
|
||||||
onCallIntegrationType: byRole('radiogroup'),
|
onCallIntegrationType: byRole('radiogroup'),
|
||||||
@@ -47,9 +49,48 @@ const ui = {
|
|||||||
},
|
},
|
||||||
newOnCallIntegrationName: byRole('textbox', { name: /Integration name/ }),
|
newOnCallIntegrationName: byRole('textbox', { name: /Integration name/ }),
|
||||||
existingOnCallIntegrationSelect: (index: number) => byTestId(`items.${index}.settings.url`),
|
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', () => {
|
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(() => {
|
beforeEach(() => {
|
||||||
grantUserPermissions([
|
grantUserPermissions([
|
||||||
AccessControlAction.AlertingNotificationsRead,
|
AccessControlAction.AlertingNotificationsRead,
|
||||||
@@ -57,6 +98,10 @@ describe('GrafanaReceiverForm', () => {
|
|||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
server.events.removeAllListeners();
|
||||||
|
});
|
||||||
|
|
||||||
it('handles nested secure fields correctly', async () => {
|
it('handles nested secure fields correctly', async () => {
|
||||||
const capturedRequests = captureRequests(
|
const capturedRequests = captureRequests(
|
||||||
(req) => req.url.includes('/v0alpha1/namespaces/default/receivers') && req.method === 'POST'
|
(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());
|
await waitFor(() => expect(ui.loadingIndicator.query()).not.toBeInTheDocument());
|
||||||
|
|
||||||
// Select MQTT receiver and fill out basic required fields for contact point
|
// 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(/^name/i), 'mqtt contact point');
|
||||||
await type(screen.getByLabelText(/broker url/i), 'broker url');
|
await type(screen.getByLabelText(/broker url/i), 'broker url');
|
||||||
await type(screen.getByLabelText(/topic/i), 'topic');
|
await type(screen.getByLabelText(/topic/i), 'topic');
|
||||||
@@ -77,7 +123,7 @@ describe('GrafanaReceiverForm', () => {
|
|||||||
await click(screen.getByRole('button', { name: /^Add$/i }));
|
await click(screen.getByRole('button', { name: /^Add$/i }));
|
||||||
await type(screen.getByLabelText(/ca certificate/i), 'some cert');
|
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 [request] = await capturedRequests;
|
||||||
const postRequestbody = await request.clone().json();
|
const postRequestbody = await request.clone().json();
|
||||||
@@ -93,6 +139,197 @@ describe('GrafanaReceiverForm', () => {
|
|||||||
expect(postRequestbody).toMatchSnapshot();
|
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', () => {
|
describe('OnCall contact point', () => {
|
||||||
it('OnCall contact point should be disabled if OnCall integration is not enabled', async () => {
|
it('OnCall contact point should be disabled if OnCall integration is not enabled', async () => {
|
||||||
disablePlugin(SupportedPlugin.OnCall);
|
disablePlugin(SupportedPlugin.OnCall);
|
||||||
@@ -101,12 +338,12 @@ describe('GrafanaReceiverForm', () => {
|
|||||||
|
|
||||||
await waitFor(() => expect(ui.loadingIndicator.query()).not.toBeInTheDocument());
|
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
|
// 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
|
// eslint-disable-next-line testing-library/no-node-access
|
||||||
expect(ui.integrationType.get().closest('form')).toHaveFormValues({ 'items.0.type': 'email' });
|
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
|
// eslint-disable-next-line testing-library/no-node-access
|
||||||
expect(ui.integrationType.get().closest('form')).toHaveFormValues({ 'items.0.type': 'prometheus-alertmanager' });
|
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 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
|
// eslint-disable-next-line testing-library/no-node-access
|
||||||
expect(ui.integrationType.get().closest('form')).toHaveFormValues({ 'items.0.type': 'oncall' });
|
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());
|
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');
|
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 {
|
function getAmCortexConfig(configure: (builder: AlertmanagerConfigBuilder) => void): AlertManagerCortexConfig {
|
||||||
|
|||||||
+4
-3
@@ -84,11 +84,11 @@ export const GrafanaReceiverForm = ({ contactPoint, readOnly = false, editMode }
|
|||||||
return [undefined, {}];
|
return [undefined, {}];
|
||||||
}
|
}
|
||||||
|
|
||||||
return grafanaReceiverToFormValues(extendOnCallReceivers(contactPoint), grafanaNotifiers);
|
return grafanaReceiverToFormValues(extendOnCallReceivers(contactPoint));
|
||||||
}, [contactPoint, isLoadingNotifiers, grafanaNotifiers, extendOnCallReceivers, isLoadingOnCallIntegration]);
|
}, [contactPoint, isLoadingNotifiers, extendOnCallReceivers, isLoadingOnCallIntegration]);
|
||||||
|
|
||||||
const onSubmit = async (values: ReceiverFormValues<GrafanaChannelValues>) => {
|
const onSubmit = async (values: ReceiverFormValues<GrafanaChannelValues>) => {
|
||||||
const newReceiver = formValuesToGrafanaReceiver(values, id2original, defaultChannelValues, grafanaNotifiers);
|
const newReceiver = formValuesToGrafanaReceiver(values, id2original, defaultChannelValues);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (editMode) {
|
if (editMode) {
|
||||||
@@ -158,6 +158,7 @@ export const GrafanaReceiverForm = ({ contactPoint, readOnly = false, editMode }
|
|||||||
|
|
||||||
return { dto: n };
|
return { dto: n };
|
||||||
});
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{hasOnCallError && (
|
{hasOnCallError && (
|
||||||
|
|||||||
@@ -93,17 +93,6 @@ export function ReceiverForm<R extends ChannelValues>({
|
|||||||
const { fields, append, remove } = useControlledFieldArray<R>({ name: 'items', formAPI, softDelete: true });
|
const { fields, append, remove } = useControlledFieldArray<R>({ name: 'items', formAPI, softDelete: true });
|
||||||
|
|
||||||
const submitCallback = async (values: ReceiverFormValues<R>) => {
|
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 {
|
try {
|
||||||
await onSubmit({
|
await onSubmit({
|
||||||
...values,
|
...values,
|
||||||
@@ -175,7 +164,7 @@ export function ReceiverForm<R extends ChannelValues>({
|
|||||||
/>
|
/>
|
||||||
</Field>
|
</Field>
|
||||||
{fields.map((field, index) => {
|
{fields.map((field, index) => {
|
||||||
const pathPrefix = `items.${index}.`;
|
const pathPrefix = `items.${index}.` as const;
|
||||||
if (field.__deleted) {
|
if (field.__deleted) {
|
||||||
return <DeletedSubForm key={field.__id} pathPrefix={pathPrefix} />;
|
return <DeletedSubForm key={field.__id} pathPrefix={pathPrefix} />;
|
||||||
}
|
}
|
||||||
@@ -185,6 +174,7 @@ export function ReceiverForm<R extends ChannelValues>({
|
|||||||
defaultValues={field}
|
defaultValues={field}
|
||||||
initialValues={initialItem}
|
initialValues={initialItem}
|
||||||
key={field.__id}
|
key={field.__id}
|
||||||
|
integrationIndex={index}
|
||||||
onDuplicate={() => {
|
onDuplicate={() => {
|
||||||
const currentValues: R = getValues().items[index];
|
const currentValues: R = getValues().items[index];
|
||||||
append({ ...currentValues, __id: String(Math.random()) });
|
append({ ...currentValues, __id: String(Math.random()) });
|
||||||
@@ -200,7 +190,6 @@ export function ReceiverForm<R extends ChannelValues>({
|
|||||||
onDelete={() => remove(index)}
|
onDelete={() => remove(index)}
|
||||||
pathPrefix={pathPrefix}
|
pathPrefix={pathPrefix}
|
||||||
notifiers={notifiers}
|
notifiers={notifiers}
|
||||||
secureFields={initialItem?.secureFields}
|
|
||||||
errors={errors?.items?.[index] as FieldErrors<R>}
|
errors={errors?.items?.[index] as FieldErrors<R>}
|
||||||
commonSettingsComponent={commonSettingsComponent}
|
commonSettingsComponent={commonSettingsComponent}
|
||||||
isEditable={isEditable}
|
isEditable={isEditable}
|
||||||
|
|||||||
+54
-5
@@ -1,5 +1,58 @@
|
|||||||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
// 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`] = `
|
exports[`GrafanaReceiverForm handles nested secure fields correctly 1`] = `
|
||||||
{
|
{
|
||||||
"metadata": {},
|
"metadata": {},
|
||||||
@@ -8,11 +61,7 @@ exports[`GrafanaReceiverForm handles nested secure fields correctly 1`] = `
|
|||||||
{
|
{
|
||||||
"disableResolveMessage": false,
|
"disableResolveMessage": false,
|
||||||
"name": "mqtt contact point",
|
"name": "mqtt contact point",
|
||||||
"secureFields": {
|
"secureFields": {},
|
||||||
"password": true,
|
|
||||||
"tlsConfig.clientCertificate": true,
|
|
||||||
"tlsConfig.clientKey": true,
|
|
||||||
},
|
|
||||||
"settings": {
|
"settings": {
|
||||||
"brokerUrl": "broker url",
|
"brokerUrl": "broker url",
|
||||||
"retain": false,
|
"retain": false,
|
||||||
|
|||||||
+25
-48
@@ -1,5 +1,4 @@
|
|||||||
import { css } from '@emotion/css';
|
import { css } from '@emotion/css';
|
||||||
import { isEmpty } from 'lodash';
|
|
||||||
import { FC, useEffect } from 'react';
|
import { FC, useEffect } from 'react';
|
||||||
import { Controller, DeepMap, FieldError, useFormContext } from 'react-hook-form';
|
import { Controller, DeepMap, FieldError, useFormContext } from 'react-hook-form';
|
||||||
|
|
||||||
@@ -15,7 +14,7 @@ import {
|
|||||||
TextArea,
|
TextArea,
|
||||||
useStyles2,
|
useStyles2,
|
||||||
} from '@grafana/ui';
|
} from '@grafana/ui';
|
||||||
import { NotificationChannelOption, NotificationChannelSecureFields } from 'app/types';
|
import { NotificationChannelOption, NotificationChannelSecureFields, OptionMeta } from 'app/types';
|
||||||
|
|
||||||
import { KeyValueMapInput } from './KeyValueMapInput';
|
import { KeyValueMapInput } from './KeyValueMapInput';
|
||||||
import { StringArrayInput } from './StringArrayInput';
|
import { StringArrayInput } from './StringArrayInput';
|
||||||
@@ -26,16 +25,17 @@ import { WrapWithTemplateSelection } from './TemplateSelector';
|
|||||||
interface Props {
|
interface Props {
|
||||||
defaultValue: any;
|
defaultValue: any;
|
||||||
option: NotificationChannelOption;
|
option: NotificationChannelOption;
|
||||||
|
getOptionMeta?: (option: NotificationChannelOption) => OptionMeta;
|
||||||
// this is defined if the option is rendered inside a subform
|
// this is defined if the option is rendered inside a subform
|
||||||
parentOption?: NotificationChannelOption;
|
parentOption?: NotificationChannelOption;
|
||||||
invalid?: boolean;
|
invalid?: boolean;
|
||||||
pathPrefix: string;
|
pathPrefix: string;
|
||||||
pathSuffix?: string;
|
|
||||||
error?: FieldError | DeepMap<any, FieldError>;
|
error?: FieldError | DeepMap<any, FieldError>;
|
||||||
readOnly?: boolean;
|
readOnly?: boolean;
|
||||||
customValidator?: (value: string) => boolean | string | Promise<boolean | string>;
|
customValidator?: (value: string) => boolean | string | Promise<boolean | string>;
|
||||||
onResetSecureField?: (propertyName: string) => void;
|
onResetSecureField?: (propertyName: string) => void;
|
||||||
secureFields?: NotificationChannelSecureFields;
|
onDeleteSubform?: (propertyName: string) => void;
|
||||||
|
secureFields: NotificationChannelSecureFields;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const OptionField: FC<Props> = ({
|
export const OptionField: FC<Props> = ({
|
||||||
@@ -43,16 +43,15 @@ export const OptionField: FC<Props> = ({
|
|||||||
parentOption,
|
parentOption,
|
||||||
invalid,
|
invalid,
|
||||||
pathPrefix,
|
pathPrefix,
|
||||||
pathSuffix = '',
|
|
||||||
error,
|
error,
|
||||||
defaultValue,
|
defaultValue,
|
||||||
readOnly = false,
|
readOnly = false,
|
||||||
customValidator,
|
customValidator,
|
||||||
onResetSecureField,
|
onResetSecureField,
|
||||||
secureFields = {},
|
secureFields,
|
||||||
|
onDeleteSubform,
|
||||||
|
getOptionMeta,
|
||||||
}) => {
|
}) => {
|
||||||
const optionPath = `${pathPrefix}${pathSuffix}`;
|
|
||||||
|
|
||||||
if (option.element === 'subform') {
|
if (option.element === 'subform') {
|
||||||
return (
|
return (
|
||||||
<SubformField
|
<SubformField
|
||||||
@@ -62,60 +61,65 @@ export const OptionField: FC<Props> = ({
|
|||||||
defaultValue={defaultValue}
|
defaultValue={defaultValue}
|
||||||
option={option}
|
option={option}
|
||||||
errors={error}
|
errors={error}
|
||||||
pathPrefix={optionPath}
|
pathPrefix={pathPrefix}
|
||||||
|
onDelete={onDeleteSubform}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (option.element === 'subform_array') {
|
if (option.element === 'subform_array') {
|
||||||
return (
|
return (
|
||||||
<SubformArrayField
|
<SubformArrayField
|
||||||
|
secureFields={secureFields}
|
||||||
readOnly={readOnly}
|
readOnly={readOnly}
|
||||||
defaultValues={defaultValue}
|
defaultValues={defaultValue}
|
||||||
option={option}
|
option={option}
|
||||||
pathPrefix={optionPath}
|
pathPrefix={pathPrefix}
|
||||||
errors={error as Array<DeepMap<any, FieldError>> | undefined}
|
errors={error as Array<DeepMap<any, FieldError>> | undefined}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Field
|
<Field
|
||||||
label={option.element !== 'checkbox' && option.element !== 'radio' ? option.label : undefined}
|
label={option.element !== 'checkbox' && option.element !== 'radio' ? option.label : undefined}
|
||||||
description={option.description || undefined}
|
description={option.description || undefined}
|
||||||
invalid={!!error}
|
invalid={!!error}
|
||||||
error={error?.message}
|
error={error?.message}
|
||||||
data-testid={`${optionPath}${option.propertyName}`}
|
data-testid={`${pathPrefix}${option.propertyName}`}
|
||||||
>
|
>
|
||||||
<OptionInput
|
<OptionInput
|
||||||
id={`${optionPath}${option.propertyName}`}
|
id={`${pathPrefix}${option.propertyName}`}
|
||||||
defaultValue={defaultValue}
|
defaultValue={defaultValue}
|
||||||
option={option}
|
option={option}
|
||||||
invalid={invalid}
|
invalid={invalid}
|
||||||
pathPrefix={optionPath}
|
pathPrefix={pathPrefix}
|
||||||
readOnly={readOnly}
|
readOnly={readOnly}
|
||||||
pathIndex={pathPrefix}
|
|
||||||
parentOption={parentOption}
|
parentOption={parentOption}
|
||||||
customValidator={customValidator}
|
customValidator={customValidator}
|
||||||
onResetSecureField={onResetSecureField}
|
onResetSecureField={onResetSecureField}
|
||||||
secureFields={secureFields}
|
secureFields={secureFields}
|
||||||
|
getOptionMeta={getOptionMeta}
|
||||||
/>
|
/>
|
||||||
</Field>
|
</Field>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const OptionInput: FC<Props & { id: string; pathIndex?: string }> = ({
|
const OptionInput: FC<Props & { id: string }> = ({
|
||||||
option,
|
option,
|
||||||
invalid,
|
invalid,
|
||||||
id,
|
id,
|
||||||
pathPrefix = '',
|
pathPrefix = '',
|
||||||
pathIndex = '',
|
|
||||||
readOnly = false,
|
readOnly = false,
|
||||||
customValidator,
|
customValidator,
|
||||||
onResetSecureField,
|
onResetSecureField,
|
||||||
secureFields = {},
|
secureFields = {},
|
||||||
parentOption,
|
parentOption,
|
||||||
|
getOptionMeta,
|
||||||
}) => {
|
}) => {
|
||||||
const styles = useStyles2(getStyles);
|
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 name = `${pathPrefix}${option.propertyName}`;
|
||||||
const nestedKey = parentOption ? `${parentOption.propertyName}.${option.propertyName}` : 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}
|
onSelectTemplate={onSelectTemplate}
|
||||||
>
|
>
|
||||||
{isEncryptedInput ? (
|
{isEncryptedInput ? (
|
||||||
<SecretInput onReset={() => onResetSecureField?.(nestedKey)} isConfigured />
|
<SecretInput id={id} onReset={() => onResetSecureField?.(nestedKey)} isConfigured />
|
||||||
) : (
|
) : (
|
||||||
<Input
|
<Input
|
||||||
id={id}
|
id={id}
|
||||||
readOnly={readOnly || useTemplates || determineReadOnly(option, getValues, pathIndex)}
|
readOnly={readOnly || useTemplates || optionMeta?.readOnly}
|
||||||
invalid={invalid}
|
invalid={invalid}
|
||||||
type={option.inputType}
|
type={option.inputType}
|
||||||
{...register(name, {
|
{...register(name, {
|
||||||
required: determineRequired(option, getValues, pathIndex),
|
required: optionMeta?.required,
|
||||||
validate: {
|
validate: {
|
||||||
validationRule: (v) =>
|
validationRule: (v) =>
|
||||||
option.validationRule ? validateOption(v, option.validationRule, option.required) : true,
|
option.validationRule ? validateOption(v, option.validationRule, option.required) : true,
|
||||||
@@ -233,7 +237,7 @@ const OptionInput: FC<Props & { id: string; pathIndex?: string }> = ({
|
|||||||
onSelectTemplate={onSelectTemplate}
|
onSelectTemplate={onSelectTemplate}
|
||||||
>
|
>
|
||||||
{isEncryptedInput ? (
|
{isEncryptedInput ? (
|
||||||
<SecretTextArea onReset={() => onResetSecureField?.(nestedKey)} isConfigured />
|
<SecretTextArea id={id} onReset={() => onResetSecureField?.(nestedKey)} isConfigured />
|
||||||
) : (
|
) : (
|
||||||
<TextArea
|
<TextArea
|
||||||
id={id}
|
id={id}
|
||||||
@@ -292,30 +296,3 @@ const validateOption = (value: string, validationRule: string, required: boolean
|
|||||||
|
|
||||||
return RegExp(validationRule).test(value) ? true : 'Invalid format';
|
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 { Button, useStyles2 } from '@grafana/ui';
|
||||||
import { Trans, t } from 'app/core/internationalization';
|
import { Trans, t } from 'app/core/internationalization';
|
||||||
import { useControlledFieldArray } from 'app/features/alerting/unified/hooks/useControlledFieldArray';
|
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 { ActionIcon } from '../../../rules/ActionIcon';
|
||||||
import { CollapsibleSection } from '../CollapsibleSection';
|
import { CollapsibleSection } from '../CollapsibleSection';
|
||||||
@@ -17,9 +17,19 @@ interface Props {
|
|||||||
pathPrefix: string;
|
pathPrefix: string;
|
||||||
errors?: Array<DeepMap<any, FieldError>>;
|
errors?: Array<DeepMap<any, FieldError>>;
|
||||||
readOnly?: boolean;
|
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 styles = useStyles2(getReceiverFormFieldStyles);
|
||||||
const path = `${pathPrefix}${option.propertyName}`;
|
const path = `${pathPrefix}${option.propertyName}`;
|
||||||
const formAPI = useFormContext();
|
const formAPI = useFormContext();
|
||||||
@@ -48,6 +58,8 @@ export const SubformArrayField = ({ option, pathPrefix, errors, defaultValues, r
|
|||||||
{option.subformOptions?.map((option) => (
|
{option.subformOptions?.map((option) => (
|
||||||
<OptionField
|
<OptionField
|
||||||
readOnly={readOnly}
|
readOnly={readOnly}
|
||||||
|
getOptionMeta={getOptionMeta}
|
||||||
|
secureFields={secureFields}
|
||||||
defaultValue={field?.[option.propertyName]}
|
defaultValue={field?.[option.propertyName]}
|
||||||
key={option.propertyName}
|
key={option.propertyName}
|
||||||
option={option}
|
option={option}
|
||||||
|
|||||||
+20
-6
@@ -3,7 +3,7 @@ import { DeepMap, FieldError, useFormContext } from 'react-hook-form';
|
|||||||
|
|
||||||
import { Button, useStyles2 } from '@grafana/ui';
|
import { Button, useStyles2 } from '@grafana/ui';
|
||||||
import { Trans, t } from 'app/core/internationalization';
|
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';
|
import { ActionIcon } from '../../../rules/ActionIcon';
|
||||||
|
|
||||||
@@ -13,10 +13,16 @@ import { getReceiverFormFieldStyles } from './styles';
|
|||||||
interface Props {
|
interface Props {
|
||||||
defaultValue: any;
|
defaultValue: any;
|
||||||
option: NotificationChannelOption;
|
option: NotificationChannelOption;
|
||||||
|
getOptionMeta?: (option: NotificationChannelOption) => OptionMeta;
|
||||||
pathPrefix: string;
|
pathPrefix: string;
|
||||||
errors?: DeepMap<any, FieldError>;
|
errors?: DeepMap<any, FieldError>;
|
||||||
readOnly?: boolean;
|
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;
|
onResetSecureField?: (propertyName: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -25,8 +31,10 @@ export const SubformField = ({
|
|||||||
pathPrefix,
|
pathPrefix,
|
||||||
errors,
|
errors,
|
||||||
defaultValue,
|
defaultValue,
|
||||||
|
getOptionMeta,
|
||||||
readOnly = false,
|
readOnly = false,
|
||||||
secureFields = {},
|
secureFields,
|
||||||
|
onDelete,
|
||||||
onResetSecureField,
|
onResetSecureField,
|
||||||
}: Props) => {
|
}: Props) => {
|
||||||
const styles = useStyles2(getReceiverFormFieldStyles);
|
const styles = useStyles2(getReceiverFormFieldStyles);
|
||||||
@@ -37,18 +45,23 @@ export const SubformField = ({
|
|||||||
|
|
||||||
const [show, setShow] = useState(!!value);
|
const [show, setShow] = useState(!!value);
|
||||||
|
|
||||||
|
const onDeleteClick = () => {
|
||||||
|
onDelete?.(option.propertyName);
|
||||||
|
setShow(false);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={styles.wrapper} data-testid={`${name}.container`}>
|
<div className={styles.wrapper} data-testid={`${name}.container`}>
|
||||||
<h6>{option.label}</h6>
|
<h6>{option.label}</h6>
|
||||||
{option.description && <p className={styles.description}>{option.description}</p>}
|
{option.description && <p className={styles.description}>{option.description}</p>}
|
||||||
{show && (
|
{show && (
|
||||||
<>
|
<>
|
||||||
{!readOnly && (
|
{!readOnly && onDelete && (
|
||||||
<ActionIcon
|
<ActionIcon
|
||||||
data-testid={`${name}.delete-button`}
|
data-testid={`${name}.delete-button`}
|
||||||
icon="trash-alt"
|
icon="trash-alt"
|
||||||
tooltip={t('alerting.subform-field.tooltip-delete', 'delete')}
|
tooltip={t('alerting.subform-field.tooltip-delete', 'delete')}
|
||||||
onClick={() => setShow(false)}
|
onClick={onDeleteClick}
|
||||||
className={styles.deleteIcon}
|
className={styles.deleteIcon}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
@@ -56,8 +69,9 @@ export const SubformField = ({
|
|||||||
return (
|
return (
|
||||||
<OptionField
|
<OptionField
|
||||||
readOnly={readOnly}
|
readOnly={readOnly}
|
||||||
secureFields={secureFields}
|
getOptionMeta={getOptionMeta}
|
||||||
onResetSecureField={onResetSecureField}
|
onResetSecureField={onResetSecureField}
|
||||||
|
secureFields={secureFields}
|
||||||
defaultValue={defaultValue?.[subOption.propertyName]}
|
defaultValue={defaultValue?.[subOption.propertyName]}
|
||||||
parentOption={option}
|
parentOption={option}
|
||||||
key={subOption.propertyName}
|
key={subOption.propertyName}
|
||||||
|
|||||||
@@ -49,7 +49,6 @@ function createContactPoint(httpConfig: DeprecatedAuthHTTPConfig | HTTPAuthConfi
|
|||||||
{
|
{
|
||||||
__id: '',
|
__id: '',
|
||||||
type: '',
|
type: '',
|
||||||
secureSettings: {},
|
|
||||||
secureFields: {},
|
secureFields: {},
|
||||||
settings: {
|
settings: {
|
||||||
http_config: {
|
http_config: {
|
||||||
|
|||||||
@@ -1607,10 +1607,7 @@ export const grafanaAlertNotifiers: Record<GrafanaNotifierType, NotifierDTO> = {
|
|||||||
placeholder: '',
|
placeholder: '',
|
||||||
propertyName: 'url',
|
propertyName: 'url',
|
||||||
selectOptions: null,
|
selectOptions: null,
|
||||||
showWhen: {
|
showWhen: { field: '', is: '' },
|
||||||
field: '',
|
|
||||||
is: '',
|
|
||||||
},
|
|
||||||
required: true,
|
required: true,
|
||||||
validationRule: '',
|
validationRule: '',
|
||||||
secure: false,
|
secure: false,
|
||||||
@@ -1624,19 +1621,10 @@ export const grafanaAlertNotifiers: Record<GrafanaNotifierType, NotifierDTO> = {
|
|||||||
placeholder: '',
|
placeholder: '',
|
||||||
propertyName: 'httpMethod',
|
propertyName: 'httpMethod',
|
||||||
selectOptions: [
|
selectOptions: [
|
||||||
{
|
{ value: 'POST', label: 'POST' },
|
||||||
value: 'POST',
|
{ value: 'PUT', label: 'PUT' },
|
||||||
label: 'POST',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
value: 'PUT',
|
|
||||||
label: 'PUT',
|
|
||||||
},
|
|
||||||
],
|
],
|
||||||
showWhen: {
|
showWhen: { field: '', is: '' },
|
||||||
field: '',
|
|
||||||
is: '',
|
|
||||||
},
|
|
||||||
required: false,
|
required: false,
|
||||||
validationRule: '',
|
validationRule: '',
|
||||||
secure: false,
|
secure: false,
|
||||||
@@ -1711,6 +1699,23 @@ export const grafanaAlertNotifiers: Record<GrafanaNotifierType, NotifierDTO> = {
|
|||||||
secure: true,
|
secure: true,
|
||||||
dependsOn: '',
|
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',
|
element: 'input',
|
||||||
inputType: 'text',
|
inputType: 'text',
|
||||||
@@ -1730,7 +1735,7 @@ export const grafanaAlertNotifiers: Record<GrafanaNotifierType, NotifierDTO> = {
|
|||||||
dependsOn: '',
|
dependsOn: '',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
element: 'input',
|
element: 'textarea',
|
||||||
inputType: 'text',
|
inputType: 'text',
|
||||||
label: 'Title',
|
label: 'Title',
|
||||||
description: 'Templated title of the message.',
|
description: 'Templated title of the message.',
|
||||||
@@ -1763,13 +1768,225 @@ export const grafanaAlertNotifiers: Record<GrafanaNotifierType, NotifierDTO> = {
|
|||||||
secure: false,
|
secure: false,
|
||||||
dependsOn: '',
|
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: {
|
oncall: {
|
||||||
type: 'oncall',
|
type: 'oncall',
|
||||||
name: 'Grafana OnCall',
|
name: 'Grafana IRM',
|
||||||
heading: 'Grafana OnCall settings',
|
heading: 'Grafana IRM settings',
|
||||||
description: 'Sends notifications to Grafana OnCall',
|
description: 'Sends notifications to Grafana IRM',
|
||||||
info: '',
|
info: '',
|
||||||
options: [
|
options: [
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { uniqueId } from 'lodash';
|
|||||||
|
|
||||||
import { DataSourceInstanceSettings, PluginType } from '@grafana/data';
|
import { DataSourceInstanceSettings, PluginType } from '@grafana/data';
|
||||||
import { config } from '@grafana/runtime';
|
import { config } from '@grafana/runtime';
|
||||||
|
import { GrafanaManagedContactPoint, GrafanaManagedReceiverConfig } from 'app/plugins/datasource/alertmanager/types';
|
||||||
import { FolderDTO } from 'app/types';
|
import { FolderDTO } from 'app/types';
|
||||||
import {
|
import {
|
||||||
GrafanaRecordingRuleDefinition,
|
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
|
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 = {
|
export const alertingFactory = {
|
||||||
folder: grafanaFolderFactory,
|
folder: grafanaFolderFactory,
|
||||||
prometheus: {
|
prometheus: {
|
||||||
@@ -193,4 +286,10 @@ export const alertingFactory = {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
dataSource: dataSourceFactory,
|
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
|
__id: string; // used to correlate form values to original DTOs
|
||||||
type: string;
|
type: string;
|
||||||
settings: Record<string, any>;
|
settings: Record<string, any>;
|
||||||
secureSettings: Record<string, any>;
|
secureFields: Record<string, boolean | ''>;
|
||||||
secureFields: Record<string, boolean>;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ReceiverFormValues<R extends ChannelValues> {
|
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 { GrafanaManagedContactPoint, Receiver } from '../../../../plugins/datasource/alertmanager/types';
|
||||||
import { grafanaAlertNotifiers, grafanaAlertNotifiersMock } from '../mockGrafanaNotifiers';
|
import { grafanaAlertNotifiers } from '../mockGrafanaNotifiers';
|
||||||
import { CloudChannelValues, GrafanaChannelValues, ReceiverFormValues } from '../types/receiver-form';
|
import {
|
||||||
|
CloudChannelValues,
|
||||||
|
GrafanaChannelMap,
|
||||||
|
GrafanaChannelValues,
|
||||||
|
ReceiverFormValues,
|
||||||
|
} from '../types/receiver-form';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
convertJiraFieldToJson,
|
convertJiraFieldToJson,
|
||||||
@@ -166,45 +169,221 @@ describe('Receiver form utils', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('formValuesToGrafanaReceiver', () => {
|
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> = {
|
const formValues: ReceiverFormValues<GrafanaChannelValues> = {
|
||||||
name: 'my-receiver',
|
name: 'my-receiver',
|
||||||
items: [
|
items: [
|
||||||
{
|
{
|
||||||
__id: '1',
|
__id: '__1',
|
||||||
secureSettings: {},
|
type: 'email',
|
||||||
secureFields: {},
|
|
||||||
type: 'discord',
|
|
||||||
settings: {
|
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,
|
disableResolveMessage: false,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|
||||||
const channelMap = {
|
const channelMap: GrafanaChannelMap = {
|
||||||
'1': {
|
__1: {
|
||||||
uid: 'abc123',
|
uid: 'sns-1',
|
||||||
secureSettings: {},
|
type: 'sns',
|
||||||
secureFields: {},
|
settings: { api_url: 'https://sns.example.com' },
|
||||||
type: 'discord',
|
secureFields: {
|
||||||
settings: {
|
// All fields exist in the channel map
|
||||||
url: 'https://foo.bar/',
|
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,
|
disableResolveMessage: false,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
const notifiers = [
|
const result = formValuesToGrafanaReceiver(formValues, channelMap, defaultChannelValues);
|
||||||
{
|
|
||||||
type: 'discord',
|
|
||||||
options: [{ propertyName: 'url', secure: true }],
|
|
||||||
},
|
|
||||||
] as NotifierDTO[];
|
|
||||||
|
|
||||||
// @ts-expect-error
|
expect(result).toEqual<GrafanaManagedContactPoint>({
|
||||||
expect(formValuesToGrafanaReceiver(formValues, channelMap, {}, notifiers)).toMatchSnapshot();
|
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' }],
|
fields: [{ __id: '10', title: 'priority', value: '1' }],
|
||||||
},
|
},
|
||||||
secureFields: {},
|
secureFields: {},
|
||||||
secureSettings: {},
|
|
||||||
sendResolved: true,
|
sendResolved: true,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
@@ -235,7 +413,6 @@ describe('formValuesToCloudReceiver', () => {
|
|||||||
url: 'https://slack.example.com/',
|
url: 'https://slack.example.com/',
|
||||||
},
|
},
|
||||||
secureFields: {},
|
secureFields: {},
|
||||||
secureSettings: {},
|
|
||||||
sendResolved: true,
|
sendResolved: true,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -256,7 +433,7 @@ describe('formValuesToCloudReceiver', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('grafanaReceiverToFormValues', () => {
|
describe('grafanaReceiverToFormValues', () => {
|
||||||
const { googlechat, slack, sns } = grafanaAlertNotifiers;
|
const { slack, sns } = grafanaAlertNotifiers;
|
||||||
|
|
||||||
it('should convert fields from settings and secureFields', () => {
|
it('should convert fields from settings and secureFields', () => {
|
||||||
const slackReceiver: GrafanaManagedContactPoint = {
|
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].type).toBe(slack.type);
|
||||||
expect(formValues.items[0].settings.recipient).toBe('#alerting-ops');
|
expect(formValues.items[0].settings.recipient).toBe('#alerting-ops');
|
||||||
expect(formValues.items[0].secureFields.token).toBe(true);
|
expect(formValues.items[0].secureFields.token).toBe(true);
|
||||||
expect(formValues.items[0].secureSettings).toEqual({});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should convert nested settings and secureFields', () => {
|
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.api_url).toBe('https://sns.example.com/');
|
||||||
expect(formValues.items[0].settings.phone_number).toBe('+1234567890');
|
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.access_key']).toBe(true);
|
||||||
expect(formValues.items[0].secureFields['sigv4.secret_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', () => {
|
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 {
|
import {
|
||||||
AlertmanagerReceiver,
|
AlertmanagerReceiver,
|
||||||
GrafanaManagedContactPoint,
|
GrafanaManagedContactPoint,
|
||||||
GrafanaManagedReceiverConfig,
|
GrafanaManagedReceiverConfig,
|
||||||
|
GrafanaManagedReceiverSecureFields,
|
||||||
Receiver,
|
Receiver,
|
||||||
} from 'app/plugins/datasource/alertmanager/types';
|
} from 'app/plugins/datasource/alertmanager/types';
|
||||||
import { CloudNotifierType, NotificationChannelOption, NotifierDTO, NotifierType } from 'app/types';
|
import { CloudNotifierType, NotificationChannelOption, NotifierDTO, NotifierType } from 'app/types';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
ChannelValues,
|
||||||
CloudChannelConfig,
|
CloudChannelConfig,
|
||||||
CloudChannelMap,
|
CloudChannelMap,
|
||||||
CloudChannelValues,
|
CloudChannelValues,
|
||||||
@@ -18,8 +20,7 @@ import {
|
|||||||
} from '../types/receiver-form';
|
} from '../types/receiver-form';
|
||||||
|
|
||||||
export function grafanaReceiverToFormValues(
|
export function grafanaReceiverToFormValues(
|
||||||
receiver: GrafanaManagedContactPoint,
|
receiver: GrafanaManagedContactPoint
|
||||||
notifiers: NotifierDTO[]
|
|
||||||
): [ReceiverFormValues<GrafanaChannelValues>, GrafanaChannelMap] {
|
): [ReceiverFormValues<GrafanaChannelValues>, GrafanaChannelMap] {
|
||||||
const channelMap: GrafanaChannelMap = {};
|
const channelMap: GrafanaChannelMap = {};
|
||||||
// giving each form receiver item a unique id so we can use it to map back to "original" items
|
// 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) => {
|
receiver.grafana_managed_receiver_configs?.map((channel) => {
|
||||||
const id = String(idCounter++);
|
const id = String(idCounter++);
|
||||||
channelMap[id] = channel;
|
channelMap[id] = channel;
|
||||||
const notifier = notifiers.find(({ type }) => type === channel.type);
|
return grafanaChannelConfigToFormChannelValues(id, channel);
|
||||||
return grafanaChannelConfigToFormChannelValues(id, channel, notifier);
|
|
||||||
}) ?? [],
|
}) ?? [],
|
||||||
};
|
};
|
||||||
return [values, channelMap];
|
return [values, channelMap];
|
||||||
@@ -77,22 +77,14 @@ export function cloudReceiverToFormValues(
|
|||||||
export function formValuesToGrafanaReceiver(
|
export function formValuesToGrafanaReceiver(
|
||||||
values: ReceiverFormValues<GrafanaChannelValues>,
|
values: ReceiverFormValues<GrafanaChannelValues>,
|
||||||
channelMap: GrafanaChannelMap,
|
channelMap: GrafanaChannelMap,
|
||||||
defaultChannelValues: GrafanaChannelValues,
|
defaultChannelValues: GrafanaChannelValues
|
||||||
notifiers: NotifierDTO[]
|
): GrafanaManagedContactPoint {
|
||||||
): Receiver {
|
|
||||||
return {
|
return {
|
||||||
name: values.name,
|
name: values.name,
|
||||||
grafana_managed_receiver_configs: (values.items ?? []).map((channelValues) => {
|
grafana_managed_receiver_configs: (values.items ?? []).map((channelValues) => {
|
||||||
const existing: GrafanaManagedReceiverConfig | undefined = channelMap[channelValues.__id];
|
const existing: GrafanaManagedReceiverConfig | undefined = channelMap[channelValues.__id];
|
||||||
const notifier = notifiers.find((notifier) => notifier.type === channelValues.type);
|
|
||||||
|
|
||||||
return formChannelValuesToGrafanaChannelConfig(
|
return formChannelValuesToGrafanaChannelConfig(channelValues, defaultChannelValues, values.name, existing);
|
||||||
channelValues,
|
|
||||||
defaultChannelValues,
|
|
||||||
values.name,
|
|
||||||
existing,
|
|
||||||
notifier
|
|
||||||
);
|
|
||||||
}),
|
}),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -100,7 +92,7 @@ export function formValuesToGrafanaReceiver(
|
|||||||
export function formValuesToCloudReceiver(
|
export function formValuesToCloudReceiver(
|
||||||
values: ReceiverFormValues<CloudChannelValues>,
|
values: ReceiverFormValues<CloudChannelValues>,
|
||||||
defaults: CloudChannelValues
|
defaults: CloudChannelValues
|
||||||
): Receiver {
|
): AlertmanagerReceiver {
|
||||||
const recv: AlertmanagerReceiver = {
|
const recv: AlertmanagerReceiver = {
|
||||||
name: values.name,
|
name: values.name,
|
||||||
};
|
};
|
||||||
@@ -177,33 +169,23 @@ function cloudChannelConfigToFormChannelValues(
|
|||||||
...(type === 'jira' ? convertJsonToJiraField(channel) : channel),
|
...(type === 'jira' ? convertJsonToJiraField(channel) : channel),
|
||||||
},
|
},
|
||||||
secureFields: {},
|
secureFields: {},
|
||||||
secureSettings: {},
|
|
||||||
sendResolved: channel.send_resolved,
|
sendResolved: channel.send_resolved,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function grafanaChannelConfigToFormChannelValues(
|
function grafanaChannelConfigToFormChannelValues(
|
||||||
id: string,
|
id: string,
|
||||||
channel: GrafanaManagedReceiverConfig,
|
channel: GrafanaManagedReceiverConfig
|
||||||
notifier?: NotifierDTO
|
|
||||||
): GrafanaChannelValues {
|
): GrafanaChannelValues {
|
||||||
const values: GrafanaChannelValues = {
|
const values: GrafanaChannelValues = {
|
||||||
__id: id,
|
__id: id,
|
||||||
type: channel.type as NotifierType,
|
type: channel.type as NotifierType,
|
||||||
provenance: channel.provenance,
|
provenance: channel.provenance,
|
||||||
secureSettings: {},
|
|
||||||
settings: { ...channel.settings },
|
settings: { ...channel.settings },
|
||||||
secureFields: { ...channel.secureFields },
|
secureFields: { ...channel.secureFields },
|
||||||
disableResolveMessage: channel.disableResolveMessage,
|
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;
|
return values;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -241,43 +223,22 @@ export function formChannelValuesToGrafanaChannelConfig(
|
|||||||
values: GrafanaChannelValues,
|
values: GrafanaChannelValues,
|
||||||
defaults: GrafanaChannelValues,
|
defaults: GrafanaChannelValues,
|
||||||
name: string,
|
name: string,
|
||||||
existing?: GrafanaManagedReceiverConfig,
|
existing?: GrafanaManagedReceiverConfig
|
||||||
notifier?: NotifierDTO
|
|
||||||
): GrafanaManagedReceiverConfig {
|
): GrafanaManagedReceiverConfig {
|
||||||
|
const secureFieldsFromValues = values.secureFields ? omitFalsySecureFields(values.secureFields) : undefined;
|
||||||
|
|
||||||
const channel: GrafanaManagedReceiverConfig = {
|
const channel: GrafanaManagedReceiverConfig = {
|
||||||
settings: omitEmptyValues({
|
settings: omitEmptyValues({
|
||||||
...(existing && existing.type === values.type ? (existing.settings ?? {}) : {}),
|
...(existing && existing.type === values.type ? (existing.settings ?? {}) : {}),
|
||||||
...(values.settings ?? {}),
|
...(values.settings ?? {}),
|
||||||
}),
|
}),
|
||||||
secureSettings: omitEmptyUnlessExisting(values.secureSettings, existing?.secureFields),
|
secureFields: secureFieldsFromValues,
|
||||||
type: values.type,
|
type: values.type,
|
||||||
name,
|
name,
|
||||||
disableResolveMessage:
|
disableResolveMessage:
|
||||||
values.disableResolveMessage ?? existing?.disableResolveMessage ?? defaults.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) {
|
if (existing) {
|
||||||
channel.uid = existing.uid;
|
channel.uid = existing.uid;
|
||||||
}
|
}
|
||||||
@@ -285,6 +246,13 @@ export function formChannelValuesToGrafanaChannelConfig(
|
|||||||
return channel;
|
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
|
// null, undefined and '' are deemed unacceptable
|
||||||
const isUnacceptableValue = (value: unknown) => isNil(value) || value === '';
|
const isUnacceptableValue = (value: unknown) => isNil(value) || value === '';
|
||||||
|
|
||||||
|
|||||||
@@ -69,11 +69,16 @@ export type WebhookConfig = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
type GrafanaManagedReceiverConfigSettings<T = any> = Record<string, T>;
|
type GrafanaManagedReceiverConfigSettings<T = any> = Record<string, T>;
|
||||||
|
export type GrafanaManagedReceiverSecureFields = Record<string, boolean>;
|
||||||
|
|
||||||
export type GrafanaManagedReceiverConfig = {
|
export type GrafanaManagedReceiverConfig = {
|
||||||
uid?: string;
|
uid?: string;
|
||||||
disableResolveMessage?: boolean;
|
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 */
|
/** If retrieved from k8s API, SecureSettings property name is different */
|
||||||
// SecureSettings?: GrafanaManagedReceiverConfigSettings<boolean>;
|
// SecureSettings?: GrafanaManagedReceiverConfigSettings<boolean>;
|
||||||
settings: GrafanaManagedReceiverConfigSettings;
|
settings: GrafanaManagedReceiverConfigSettings;
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import { ValidationRule } from 'react-hook-form';
|
||||||
|
|
||||||
import { SelectableValue } from '@grafana/data';
|
import { SelectableValue } from '@grafana/data';
|
||||||
import { IconName } from '@grafana/ui';
|
import { IconName } from '@grafana/ui';
|
||||||
|
|
||||||
@@ -123,6 +125,11 @@ export interface ChannelTypeSettings {
|
|||||||
uploadImage: boolean;
|
uploadImage: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface OptionMeta {
|
||||||
|
required?: string | ValidationRule<boolean>;
|
||||||
|
readOnly?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
export interface NotificationChannelOption {
|
export interface NotificationChannelOption {
|
||||||
element:
|
element:
|
||||||
| 'input'
|
| 'input'
|
||||||
|
|||||||
Reference in New Issue
Block a user