2025-04-01 10:38:02 +09:00

334 lines
11 KiB
TypeScript

import { get, has, isArray, isNil, omit, omitBy, reduce } from 'lodash';
import {
AlertmanagerReceiver,
GrafanaManagedContactPoint,
GrafanaManagedReceiverConfig,
Receiver,
} from 'app/plugins/datasource/alertmanager/types';
import { CloudNotifierType, NotificationChannelOption, NotifierDTO, NotifierType } from 'app/types';
import {
CloudChannelConfig,
CloudChannelMap,
CloudChannelValues,
GrafanaChannelMap,
GrafanaChannelValues,
ReceiverFormValues,
} from '../types/receiver-form';
export function grafanaReceiverToFormValues(
receiver: GrafanaManagedContactPoint,
notifiers: NotifierDTO[]
): [ReceiverFormValues<GrafanaChannelValues>, GrafanaChannelMap] {
const channelMap: GrafanaChannelMap = {};
// giving each form receiver item a unique id so we can use it to map back to "original" items
// as well as to use as `key` prop.
// @TODO use uid once backend is fixed to provide it. then we can get rid of the GrafanaChannelMap
let idCounter = 1;
const values = {
name: receiver.name,
items:
receiver.grafana_managed_receiver_configs?.map((channel) => {
const id = String(idCounter++);
channelMap[id] = channel;
const notifier = notifiers.find(({ type }) => type === channel.type);
return grafanaChannelConfigToFormChannelValues(id, channel, notifier);
}) ?? [],
};
return [values, channelMap];
}
export function cloudReceiverToFormValues(
receiver: Receiver,
notifiers: NotifierDTO[]
): [ReceiverFormValues<CloudChannelValues>, CloudChannelMap] {
const channelMap: CloudChannelMap = {};
// giving each form receiver item a unique id so we can use it to map back to "original" items
let idCounter = 1;
const items: CloudChannelValues[] = Object.entries(receiver)
// filter out only config items that are relevant to cloud
.filter(([type]) => type.endsWith('_configs') && type !== 'grafana_managed_receiver_configs')
// map property names to cloud notifier types by removing the `_config` suffix
.map(([type, configs]): [CloudNotifierType, CloudChannelConfig[]] => [
type.replace('_configs', '') as CloudNotifierType,
configs,
])
// convert channel configs to form values
.map(([type, configs]) =>
configs.map((config) => {
const id = String(idCounter++);
channelMap[id] = { type, config };
const notifier = notifiers.find((notifier) => notifier.type === type);
if (!notifier) {
throw new Error(`unknown cloud notifier: ${type}`);
}
return cloudChannelConfigToFormChannelValues(id, type, config);
})
)
.flat();
const values = {
name: receiver.name,
items,
};
return [values, channelMap];
}
export function formValuesToGrafanaReceiver(
values: ReceiverFormValues<GrafanaChannelValues>,
channelMap: GrafanaChannelMap,
defaultChannelValues: GrafanaChannelValues,
notifiers: NotifierDTO[]
): Receiver {
return {
name: values.name,
grafana_managed_receiver_configs: (values.items ?? []).map((channelValues) => {
const existing: GrafanaManagedReceiverConfig | undefined = channelMap[channelValues.__id];
const notifier = notifiers.find((notifier) => notifier.type === channelValues.type);
return formChannelValuesToGrafanaChannelConfig(
channelValues,
defaultChannelValues,
values.name,
existing,
notifier
);
}),
};
}
export function formValuesToCloudReceiver(
values: ReceiverFormValues<CloudChannelValues>,
defaults: CloudChannelValues
): Receiver {
const recv: AlertmanagerReceiver = {
name: values.name,
};
values.items.forEach(({ __id, type, settings, sendResolved }) => {
const channelWithOmmitedIdentifiers = omitEmptyValues({
...omitTemporaryIdentifiers(settings),
send_resolved: sendResolved ?? defaults.sendResolved,
});
const channel =
type === 'jira' ? convertJiraFieldToJson(channelWithOmmitedIdentifiers) : channelWithOmmitedIdentifiers;
if (!(`${type}_configs` in recv)) {
recv[`${type}_configs`] = [channel];
} else {
recv[`${type}_configs`]?.push(channel);
}
});
return recv;
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export function convertJiraFieldToJson(object: Record<string, any>) {
// Only for cloud alert manager. Jira fields option can be a nested object. We need to convert it to JSON.
const objectCopy = structuredClone(object);
if (typeof objectCopy.fields === 'object') {
for (const [optionName, optionValue] of Object.entries(objectCopy.fields)) {
let valueForField;
try {
// eslint-disable-next-line
valueForField = JSON.parse(optionValue as string); // is a stringified object
} catch {
valueForField = optionValue; // is not a stringified object
}
objectCopy.fields[optionName] = valueForField;
}
}
return objectCopy;
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export function convertJsonToJiraField(object: Record<string, any>) {
// Only for cloud alert manager. Convert JSON back to nested Jira fields option.
const objectCopy = structuredClone(object);
if (typeof objectCopy.fields === 'object') {
for (const [optionName, optionValue] of Object.entries(objectCopy.fields)) {
let valueForField;
if (typeof optionValue === 'object') {
valueForField = JSON.stringify(optionValue);
} else {
valueForField = optionValue;
}
objectCopy.fields[optionName] = valueForField;
}
}
return objectCopy;
}
function cloudChannelConfigToFormChannelValues(
id: string,
type: CloudNotifierType,
channel: CloudChannelConfig
): CloudChannelValues {
return {
__id: id,
type,
settings: {
...(type === 'jira' ? convertJsonToJiraField(channel) : channel),
},
secureFields: {},
secureSettings: {},
sendResolved: channel.send_resolved,
};
}
function grafanaChannelConfigToFormChannelValues(
id: string,
channel: GrafanaManagedReceiverConfig,
notifier?: NotifierDTO
): GrafanaChannelValues {
const values: GrafanaChannelValues = {
__id: id,
type: channel.type as NotifierType,
provenance: channel.provenance,
secureSettings: {},
settings: { ...channel.settings },
secureFields: { ...channel.secureFields },
disableResolveMessage: channel.disableResolveMessage,
};
notifier?.options.forEach((option) => {
if (option.secure && values.settings[option.propertyName]) {
values.secureSettings[option.propertyName] = values.settings[option.propertyName];
delete values.settings[option.propertyName];
}
});
return values;
}
/**
* Recursively find all keys that should be marked a secure fields, using JSONpath for nested fields.
*/
export function getSecureFieldNames(notifier: NotifierDTO): string[] {
// eg. ['foo', 'bar.baz']
const secureFieldPaths: string[] = [];
// we'll pass in the prefix for each iteration so we can track the JSON path
function findSecureOptions(options: NotificationChannelOption[], prefix?: string) {
for (const option of options) {
const key = prefix ? `${prefix}.${option.propertyName}` : option.propertyName;
// if the field is a subform, recurse
if (option.subformOptions) {
findSecureOptions(option.subformOptions, key);
continue;
}
if (option.secure) {
secureFieldPaths.push(key);
continue;
}
}
}
findSecureOptions(notifier.options);
return secureFieldPaths;
}
export function formChannelValuesToGrafanaChannelConfig(
values: GrafanaChannelValues,
defaults: GrafanaChannelValues,
name: string,
existing?: GrafanaManagedReceiverConfig,
notifier?: NotifierDTO
): GrafanaManagedReceiverConfig {
const channel: GrafanaManagedReceiverConfig = {
settings: omitEmptyValues({
...(existing && existing.type === values.type ? (existing.settings ?? {}) : {}),
...(values.settings ?? {}),
}),
secureSettings: omitEmptyUnlessExisting(values.secureSettings, existing?.secureFields),
type: values.type,
name,
disableResolveMessage:
values.disableResolveMessage ?? existing?.disableResolveMessage ?? defaults.disableResolveMessage,
};
// find all secure field definitions
const secureFieldNames = notifier ? getSecureFieldNames(notifier) : [];
// we make sure all fields that are marked as "secure" will be moved to "SecureSettings" instead of "settings"
const secureSettings = reduce(
secureFieldNames,
(acc: Record<string, unknown> = {}, key) => {
// the value for secure settings can come from either the "settings" (accidental) or "secureFields" if editing an existing receiver
acc[key] = get(channel.settings, key) ?? get(values.secureFields, key);
return acc;
},
{}
);
channel.secureSettings = {
...secureSettings,
...channel.secureSettings,
};
// remove the secure ones from the regular settings
channel.settings = omit(channel.settings, secureFieldNames);
if (existing) {
channel.uid = existing.uid;
}
return channel;
}
// null, undefined and '' are deemed unacceptable
const isUnacceptableValue = (value: unknown) => isNil(value) || value === '';
// will remove properties that have empty ('', null, undefined) object properties.
// traverses nested objects and arrays as well. in place, mutates the object.
// this is needed because form will submit empty string for not filled in fields,
// but for cloud alertmanager receiver config to use global default value the property must be omitted entirely
// this isn't a perfect solution though. No way for user to intentionally provide an empty string. Will need rethinking later
export function omitEmptyValues<T>(obj: T): T {
if (isArray(obj)) {
obj.forEach(omitEmptyValues);
} else if (typeof obj === 'object' && obj !== null) {
Object.entries(obj).forEach(([key, value]) => {
if (isUnacceptableValue(value)) {
delete (obj as any)[key];
} else {
omitEmptyValues(value);
}
});
}
return obj;
}
// Will remove empty ('', null, undefined) object properties unless they were previously defined.
// existing is a map of property names that were previously defined.
export function omitEmptyUnlessExisting(settings = {}, existing = {}): Record<string, unknown> {
return omitBy(settings, (value, key) => isUnacceptableValue(value) && !has(existing, key));
}
export function omitTemporaryIdentifiers<T>(object: Readonly<T>): T {
function omitIdentifiers<T>(obj: T) {
if (isArray(obj)) {
obj.forEach(omitIdentifiers);
} else if (typeof obj === 'object' && obj !== null) {
if ('__id' in obj) {
delete obj.__id;
}
Object.values(obj).forEach(omitIdentifiers);
}
}
const objectCopy = structuredClone(object);
omitIdentifiers(objectCopy);
return objectCopy;
}