diff --git a/ee/query-service/model/plans.go b/ee/query-service/model/plans.go index 9f56f50655..e3a8b44cc6 100644 --- a/ee/query-service/model/plans.go +++ b/ee/query-service/model/plans.go @@ -81,6 +81,13 @@ var BasicPlan = basemodel.FeatureSet{ UsageLimit: -1, Route: "", }, + basemodel.Feature{ + Name: basemodel.AlertChannelOpsgenie, + Active: true, + Usage: 0, + UsageLimit: -1, + Route: "", + }, basemodel.Feature{ Name: basemodel.AlertChannelMsTeams, Active: false, @@ -161,6 +168,13 @@ var ProPlan = basemodel.FeatureSet{ UsageLimit: -1, Route: "", }, + basemodel.Feature{ + Name: basemodel.AlertChannelOpsgenie, + Active: true, + Usage: 0, + UsageLimit: -1, + Route: "", + }, basemodel.Feature{ Name: basemodel.AlertChannelMsTeams, Active: true, @@ -241,6 +255,13 @@ var EnterprisePlan = basemodel.FeatureSet{ UsageLimit: -1, Route: "", }, + basemodel.Feature{ + Name: basemodel.AlertChannelOpsgenie, + Active: true, + Usage: 0, + UsageLimit: -1, + Route: "", + }, basemodel.Feature{ Name: basemodel.AlertChannelMsTeams, Active: true, diff --git a/frontend/public/locales/en/channels.json b/frontend/public/locales/en/channels.json index 027501f69d..63094aa911 100644 --- a/frontend/public/locales/en/channels.json +++ b/frontend/public/locales/en/channels.json @@ -20,6 +20,9 @@ "field_slack_recipient": "Recipient", "field_slack_title": "Title", "field_slack_description": "Description", + "field_opsgenie_api_key": "API Key", + "field_opsgenie_description": "Description", + "placeholder_opsgenie_description": "Description", "field_webhook_username": "User Name (optional)", "field_webhook_password": "Password (optional)", "field_pager_routing_key": "Routing Key", @@ -31,8 +34,12 @@ "field_pager_class": "Class", "field_pager_client": "Client", "field_pager_client_url": "Client URL", + "field_opsgenie_message": "Message", + "field_opsgenie_priority": "Priority", "placeholder_slack_description": "Description", "placeholder_pager_description": "Description", + "placeholder_opsgenie_message": "Message", + "placeholder_opsgenie_priority": "Priority", "help_pager_client": "Shows up as event source in Pagerduty", "help_pager_client_url": "Shows up as event source link in Pagerduty", "help_pager_class": "The class/type of the event", @@ -43,6 +50,9 @@ "help_webhook_username": "Leave empty for bearer auth or when authentication is not necessary.", "help_webhook_password": "Specify a password or bearer token", "help_pager_description": "Shows up as description in pagerduty", + "help_opsgenie_message": "Shows up as message in opsgenie", + "help_opsgenie_priority": "Priority of the incident", + "help_opsgenie_description": "Shows up as description in opsgenie", "channel_creation_done": "Successfully created the channel", "channel_creation_failed": "An unexpected error occurred while creating this channel", "channel_edit_done": "Channels Edited Successfully", diff --git a/frontend/src/api/channels/createOpsgenie.ts b/frontend/src/api/channels/createOpsgenie.ts new file mode 100644 index 0000000000..4cf60f9e94 --- /dev/null +++ b/frontend/src/api/channels/createOpsgenie.ts @@ -0,0 +1,37 @@ +import axios from 'api'; +import { ErrorResponseHandler } from 'api/ErrorResponseHandler'; +import { AxiosError } from 'axios'; +import { ErrorResponse, SuccessResponse } from 'types/api'; +import { PayloadProps, Props } from 'types/api/channels/createOpsgenie'; + +const create = async ( + props: Props, +): Promise | ErrorResponse> => { + try { + const response = await axios.post('/channels', { + name: props.name, + opsgenie_configs: [ + { + api_key: props.api_key, + description: props.description, + priority: props.priority, + message: props.message, + details: { + ...props.detailsArray, + }, + }, + ], + }); + + return { + statusCode: 200, + error: null, + message: 'Success', + payload: response.data.data, + }; + } catch (error) { + return ErrorResponseHandler(error as AxiosError); + } +}; + +export default create; diff --git a/frontend/src/api/channels/editOpsgenie.ts b/frontend/src/api/channels/editOpsgenie.ts new file mode 100644 index 0000000000..71f830f9f8 --- /dev/null +++ b/frontend/src/api/channels/editOpsgenie.ts @@ -0,0 +1,38 @@ +import axios from 'api'; +import { ErrorResponseHandler } from 'api/ErrorResponseHandler'; +import { AxiosError } from 'axios'; +import { ErrorResponse, SuccessResponse } from 'types/api'; +import { PayloadProps, Props } from 'types/api/channels/editOpsgenie'; + +const editOpsgenie = async ( + props: Props, +): Promise | ErrorResponse> => { + try { + const response = await axios.put(`/channels/${props.id}`, { + name: props.name, + opsgenie_configs: [ + { + send_resolved: true, + api_key: props.api_key, + description: props.description, + priority: props.priority, + message: props.message, + details: { + ...props.detailsArray, + }, + }, + ], + }); + + return { + statusCode: 200, + error: null, + message: 'Success', + payload: response.data.data, + }; + } catch (error) { + return ErrorResponseHandler(error as AxiosError); + } +}; + +export default editOpsgenie; diff --git a/frontend/src/api/channels/testOpsgenie.ts b/frontend/src/api/channels/testOpsgenie.ts new file mode 100644 index 0000000000..780a4432ae --- /dev/null +++ b/frontend/src/api/channels/testOpsgenie.ts @@ -0,0 +1,37 @@ +import axios from 'api'; +import { ErrorResponseHandler } from 'api/ErrorResponseHandler'; +import { AxiosError } from 'axios'; +import { ErrorResponse, SuccessResponse } from 'types/api'; +import { PayloadProps, Props } from 'types/api/channels/createOpsgenie'; + +const testOpsgenie = async ( + props: Props, +): Promise | ErrorResponse> => { + try { + const response = await axios.post('/testChannel', { + name: props.name, + opsgenie_configs: [ + { + api_key: props.api_key, + description: props.description, + priority: props.priority, + message: props.message, + details: { + ...props.detailsArray, + }, + }, + ], + }); + + return { + statusCode: 200, + error: null, + message: 'Success', + payload: response.data.data, + }; + } catch (error) { + return ErrorResponseHandler(error as AxiosError); + } +}; + +export default testOpsgenie; diff --git a/frontend/src/constants/features.ts b/frontend/src/constants/features.ts index 0d444ff3f0..fc267c0e7c 100644 --- a/frontend/src/constants/features.ts +++ b/frontend/src/constants/features.ts @@ -6,6 +6,7 @@ export enum FeatureKeys { ALERT_CHANNEL_SLACK = 'ALERT_CHANNEL_SLACK', ALERT_CHANNEL_WEBHOOK = 'ALERT_CHANNEL_WEBHOOK', ALERT_CHANNEL_PAGERDUTY = 'ALERT_CHANNEL_PAGERDUTY', + ALERT_CHANNEL_OPSGENIE = 'ALERT_CHANNEL_OPSGENIE', ALERT_CHANNEL_MSTEAMS = 'ALERT_CHANNEL_MSTEAMS', DurationSort = 'DurationSort', TimestampSort = 'TimestampSort', diff --git a/frontend/src/container/CreateAlertChannels/config.ts b/frontend/src/container/CreateAlertChannels/config.ts index 634dc95c41..e15c1d7e08 100644 --- a/frontend/src/container/CreateAlertChannels/config.ts +++ b/frontend/src/container/CreateAlertChannels/config.ts @@ -40,6 +40,30 @@ export interface PagerChannel extends Channel { details?: string; detailsArray?: Record; } + +// OpsgenieChannel configures alert manager to send +// events to opsgenie +export interface OpsgenieChannel extends Channel { + // ref: https://prometheus.io/docs/alerting/latest/configuration/#opsgenie_config + api_key: string; + + message?: string; + + // A description of the incident + description?: string; + + // A backlink to the sender of the notification. + source?: string; + + // A set of arbitrary key/value pairs that provide further detail + // about the alert. + details?: string; + detailsArray?: Record; + + // Priority level of alert. Possible values are P1, P2, P3, P4, and P5. + priority?: string; +} + export const ValidatePagerChannel = (p: PagerChannel): string => { if (!p) { return 'Received unexpected input for this channel, please contact your administrator '; @@ -63,16 +87,14 @@ export const ValidatePagerChannel = (p: PagerChannel): string => { return ''; }; -export type ChannelType = - | 'slack' - | 'email' - | 'webhook' - | 'pagerduty' - | 'msteams'; -export const SlackType: ChannelType = 'slack'; -export const WebhookType: ChannelType = 'webhook'; -export const PagerType: ChannelType = 'pagerduty'; -export const MsTeamsType: ChannelType = 'msteams'; +export enum ChannelType { + Slack = 'slack', + Email = 'email', + Webhook = 'webhook', + Pagerduty = 'pagerduty', + Opsgenie = 'opsgenie', + MsTeams = 'msteams', +} // LabelFilterStatement will be used for preparing filter conditions / matchers export interface LabelFilterStatement { diff --git a/frontend/src/container/CreateAlertChannels/defaults.ts b/frontend/src/container/CreateAlertChannels/defaults.ts index e37ad6be03..3068d8dd0c 100644 --- a/frontend/src/container/CreateAlertChannels/defaults.ts +++ b/frontend/src/container/CreateAlertChannels/defaults.ts @@ -1,4 +1,4 @@ -import { PagerChannel } from './config'; +import { OpsgenieChannel, PagerChannel } from './config'; export const PagerInitialConfig: Partial = { description: `[{{ .Status | toUpper }}{{ if eq .Status "firing" }}:{{ .Alerts.Firing | len }}{{ end }}] {{ .CommonLabels.alertname }} for {{ .CommonLabels.job }} @@ -22,3 +22,31 @@ export const PagerInitialConfig: Partial = { num_resolved: '{{ .Alerts.Resolved | len }}', }), }; + +export const OpsgenieInitialConfig: Partial = { + message: '{{ .CommonLabels.alertname }}', + description: `{{ if gt (len .Alerts.Firing) 0 -}} + Alerts Firing: + {{ range .Alerts.Firing }} + - Message: {{ .Annotations.description }} + Labels: + {{ range .Labels.SortedPairs }} - {{ .Name }} = {{ .Value }} + {{ end }} Annotations: + {{ range .Annotations.SortedPairs }} - {{ .Name }} = {{ .Value }} + {{ end }} Source: {{ .GeneratorURL }} + {{ end }} + {{- end }} + {{ if gt (len .Alerts.Resolved) 0 -}} + Alerts Resolved: + {{ range .Alerts.Resolved }} + - Message: {{ .Annotations.description }} + Labels: + {{ range .Labels.SortedPairs }} - {{ .Name }} = {{ .Value }} + {{ end }} Annotations: + {{ range .Annotations.SortedPairs }} - {{ .Name }} = {{ .Value }} + {{ end }} Source: {{ .GeneratorURL }} + {{ end }} + {{- end }}`, + priority: + '{{ if eq (index .Alerts 0).Labels.severity "critical" }}P1{{ else if eq (index .Alerts 0).Labels.severity "warning" }}P2{{ else if eq (index .Alerts 0).Labels.severity "info" }}P3{{ else }}P4{{ end }}', +}; diff --git a/frontend/src/container/CreateAlertChannels/index.tsx b/frontend/src/container/CreateAlertChannels/index.tsx index 70fe3fb7c6..cbe0d39e55 100644 --- a/frontend/src/container/CreateAlertChannels/index.tsx +++ b/frontend/src/container/CreateAlertChannels/index.tsx @@ -1,9 +1,11 @@ import { Form } from 'antd'; import createMsTeamsApi from 'api/channels/createMsTeams'; +import createOpsgenie from 'api/channels/createOpsgenie'; import createPagerApi from 'api/channels/createPager'; import createSlackApi from 'api/channels/createSlack'; import createWebhookApi from 'api/channels/createWebhook'; import testMsTeamsApi from 'api/channels/testMsTeams'; +import testOpsGenie from 'api/channels/testOpsgenie'; import testPagerApi from 'api/channels/testPager'; import testSlackApi from 'api/channels/testSlack'; import testWebhookApi from 'api/channels/testWebhook'; @@ -17,19 +19,17 @@ import { useTranslation } from 'react-i18next'; import { ChannelType, MsTeamsChannel, - MsTeamsType, + OpsgenieChannel, PagerChannel, - PagerType, SlackChannel, - SlackType, ValidatePagerChannel, WebhookChannel, - WebhookType, } from './config'; -import { PagerInitialConfig } from './defaults'; +import { OpsgenieInitialConfig, PagerInitialConfig } from './defaults'; +import { isChannelType } from './utils'; function CreateAlertChannels({ - preType = 'slack', + preType = ChannelType.Slack, }: CreateAlertChannelsProps): JSX.Element { // init namespace for translations const { t } = useTranslation('channels'); @@ -37,7 +37,13 @@ function CreateAlertChannels({ const [formInstance] = Form.useForm(); const [selectedConfig, setSelectedConfig] = useState< - Partial + Partial< + SlackChannel & + WebhookChannel & + PagerChannel & + MsTeamsChannel & + OpsgenieChannel + > >({ text: `{{ range .Alerts -}} *Alert:* {{ .Labels.alertname }}{{ if .Labels.severity }} - {{ .Labels.severity }}{{ end }} @@ -71,7 +77,7 @@ function CreateAlertChannels({ const currentType = type; setType(value as ChannelType); - if (value === PagerType && currentType !== value) { + if (value === ChannelType.Pagerduty && currentType !== value) { // reset config to pager defaults setSelectedConfig({ name: selectedConfig?.name, @@ -79,6 +85,13 @@ function CreateAlertChannels({ ...PagerInitialConfig, }); } + + if (value === ChannelType.Opsgenie && currentType !== value) { + setSelectedConfig((selectedConfig) => ({ + ...selectedConfig, + ...OpsgenieInitialConfig, + })); + } }, [type, selectedConfig], ); @@ -239,6 +252,45 @@ function CreateAlertChannels({ setSavingState(false); }, [t, notifications, preparePagerRequest]); + const prepareOpsgenieRequest = useCallback( + () => ({ + api_key: selectedConfig?.api_key || '', + name: selectedConfig?.name || '', + send_resolved: true, + description: selectedConfig?.description || '', + message: selectedConfig?.message || '', + priority: selectedConfig?.priority || '', + }), + [selectedConfig], + ); + + const onOpsgenieHandler = useCallback(async () => { + setSavingState(true); + + try { + const response = await createOpsgenie(prepareOpsgenieRequest()); + + if (response.statusCode === 200) { + notifications.success({ + message: 'Success', + description: t('channel_creation_done'), + }); + history.replace(ROUTES.ALL_CHANNELS); + } else { + notifications.error({ + message: 'Error', + description: response.error || t('channel_creation_failed'), + }); + } + } catch (error) { + notifications.error({ + message: 'Error', + description: t('channel_creation_failed'), + }); + } + setSavingState(false); + }, [prepareOpsgenieRequest, t, notifications]); + const prepareMsTeamsRequest = useCallback( () => ({ webhook_url: selectedConfig?.webhook_url || '', @@ -280,26 +332,31 @@ function CreateAlertChannels({ const onSaveHandler = useCallback( async (value: ChannelType) => { const functionMapper = { - [SlackType]: onSlackHandler, - [WebhookType]: onWebhookHandler, - [PagerType]: onPagerHandler, - [MsTeamsType]: onMsTeamsHandler, + [ChannelType.Slack]: onSlackHandler, + [ChannelType.Webhook]: onWebhookHandler, + [ChannelType.Pagerduty]: onPagerHandler, + [ChannelType.Opsgenie]: onOpsgenieHandler, + [ChannelType.MsTeams]: onMsTeamsHandler, }; - const functionToCall = functionMapper[value]; - if (functionToCall) { - functionToCall(); - } else { - notifications.error({ - message: 'Error', - description: t('selected_channel_invalid'), - }); + if (isChannelType(value)) { + const functionToCall = functionMapper[value as keyof typeof functionMapper]; + + if (functionToCall) { + functionToCall(); + } else { + notifications.error({ + message: 'Error', + description: t('selected_channel_invalid'), + }); + } } }, [ onSlackHandler, onWebhookHandler, onPagerHandler, + onOpsgenieHandler, onMsTeamsHandler, notifications, t, @@ -313,22 +370,26 @@ function CreateAlertChannels({ let request; let response; switch (channelType) { - case WebhookType: + case ChannelType.Webhook: request = prepareWebhookRequest(); response = await testWebhookApi(request); break; - case SlackType: + case ChannelType.Slack: request = prepareSlackRequest(); response = await testSlackApi(request); break; - case PagerType: + case ChannelType.Pagerduty: request = preparePagerRequest(); if (request) response = await testPagerApi(request); break; - case MsTeamsType: + case ChannelType.MsTeams: request = prepareMsTeamsRequest(); response = await testMsTeamsApi(request); break; + case ChannelType.Opsgenie: + request = prepareOpsgenieRequest(); + response = await testOpsGenie(request); + break; default: notifications.error({ message: 'Error', @@ -361,6 +422,7 @@ function CreateAlertChannels({ prepareWebhookRequest, t, preparePagerRequest, + prepareOpsgenieRequest, prepareSlackRequest, prepareMsTeamsRequest, notifications, @@ -390,6 +452,7 @@ function CreateAlertChannels({ type, ...selectedConfig, ...PagerInitialConfig, + ...OpsgenieInitialConfig, }, }} /> diff --git a/frontend/src/container/CreateAlertChannels/utils.ts b/frontend/src/container/CreateAlertChannels/utils.ts new file mode 100644 index 0000000000..ce7520a9f1 --- /dev/null +++ b/frontend/src/container/CreateAlertChannels/utils.ts @@ -0,0 +1,4 @@ +import { ChannelType } from './config'; + +export const isChannelType = (type: string): type is ChannelType => + Object.values(ChannelType).includes(type as ChannelType); diff --git a/frontend/src/container/EditAlertChannels/index.tsx b/frontend/src/container/EditAlertChannels/index.tsx index d6cba08381..ca8bc96f7f 100644 --- a/frontend/src/container/EditAlertChannels/index.tsx +++ b/frontend/src/container/EditAlertChannels/index.tsx @@ -1,9 +1,11 @@ import { Form } from 'antd'; import editMsTeamsApi from 'api/channels/editMsTeams'; +import editOpsgenie from 'api/channels/editOpsgenie'; import editPagerApi from 'api/channels/editPager'; import editSlackApi from 'api/channels/editSlack'; import editWebhookApi from 'api/channels/editWebhook'; import testMsTeamsApi from 'api/channels/testMsTeams'; +import testOpsgenie from 'api/channels/testOpsgenie'; import testPagerApi from 'api/channels/testPager'; import testSlackApi from 'api/channels/testSlack'; import testWebhookApi from 'api/channels/testWebhook'; @@ -11,14 +13,11 @@ import ROUTES from 'constants/routes'; import { ChannelType, MsTeamsChannel, - MsTeamsType, + OpsgenieChannel, PagerChannel, - PagerType, SlackChannel, - SlackType, ValidatePagerChannel, WebhookChannel, - WebhookType, } from 'container/CreateAlertChannels/config'; import FormAlertChannels from 'container/FormAlertChannels'; import { useNotifications } from 'hooks/useNotifications'; @@ -35,7 +34,13 @@ function EditAlertChannels({ const [formInstance] = Form.useForm(); const [selectedConfig, setSelectedConfig] = useState< - Partial + Partial< + SlackChannel & + WebhookChannel & + PagerChannel & + MsTeamsChannel & + OpsgenieChannel + > >({ ...initialValue, }); @@ -45,7 +50,7 @@ function EditAlertChannels({ const { id } = useParams<{ id: string }>(); const [type, setType] = useState( - initialValue?.type ? (initialValue.type as ChannelType) : SlackType, + initialValue?.type ? (initialValue.type as ChannelType) : ChannelType.Slack, ); const onTypeChangeHandler = useCallback((value: string) => { @@ -193,6 +198,48 @@ function EditAlertChannels({ setSavingState(false); }, [preparePagerRequest, notifications, selectedConfig, t]); + const prepareOpsgenieRequest = useCallback( + () => ({ + name: selectedConfig.name || '', + api_key: selectedConfig.api_key || '', + message: selectedConfig.message || '', + description: selectedConfig.description || '', + priority: selectedConfig.priority || '', + id, + }), + [id, selectedConfig], + ); + + const onOpsgenieEditHandler = useCallback(async () => { + setSavingState(true); + + if (selectedConfig?.api_key === '') { + notifications.error({ + message: 'Error', + description: t('api_key_required'), + }); + setSavingState(false); + return; + } + + const response = await editOpsgenie(prepareOpsgenieRequest()); + + if (response.statusCode === 200) { + notifications.success({ + message: 'Success', + description: t('channel_edit_done'), + }); + + history.replace(ROUTES.ALL_CHANNELS); + } else { + notifications.error({ + message: 'Error', + description: response.error || t('channel_edit_failed'), + }); + } + setSavingState(false); + }, [prepareOpsgenieRequest, t, notifications, selectedConfig]); + const prepareMsTeamsRequest = useCallback( () => ({ webhook_url: selectedConfig?.webhook_url || '', @@ -237,14 +284,16 @@ function EditAlertChannels({ const onSaveHandler = useCallback( (value: ChannelType) => { - if (value === SlackType) { + if (value === ChannelType.Slack) { onSlackEditHandler(); - } else if (value === WebhookType) { + } else if (value === ChannelType.Webhook) { onWebhookEditHandler(); - } else if (value === PagerType) { + } else if (value === ChannelType.Pagerduty) { onPagerEditHandler(); - } else if (value === MsTeamsType) { + } else if (value === ChannelType.MsTeams) { onMsTeamsEditHandler(); + } else if (value === ChannelType.Opsgenie) { + onOpsgenieEditHandler(); } }, [ @@ -252,6 +301,7 @@ function EditAlertChannels({ onWebhookEditHandler, onPagerEditHandler, onMsTeamsEditHandler, + onOpsgenieEditHandler, ], ); @@ -262,22 +312,26 @@ function EditAlertChannels({ let request; let response; switch (channelType) { - case WebhookType: + case ChannelType.Webhook: request = prepareWebhookRequest(); response = await testWebhookApi(request); break; - case SlackType: + case ChannelType.Slack: request = prepareSlackRequest(); response = await testSlackApi(request); break; - case PagerType: + case ChannelType.Pagerduty: request = preparePagerRequest(); if (request) response = await testPagerApi(request); break; - case MsTeamsType: + case ChannelType.MsTeams: request = prepareMsTeamsRequest(); if (request) response = await testMsTeamsApi(request); break; + case ChannelType.Opsgenie: + request = prepareOpsgenieRequest(); + if (request) response = await testOpsgenie(request); + break; default: notifications.error({ message: 'Error', @@ -312,6 +366,7 @@ function EditAlertChannels({ preparePagerRequest, prepareSlackRequest, prepareMsTeamsRequest, + prepareOpsgenieRequest, notifications, ], ); diff --git a/frontend/src/container/FormAlertChannels/Settings/Opsgenie.tsx b/frontend/src/container/FormAlertChannels/Settings/Opsgenie.tsx new file mode 100644 index 0000000000..009dd01882 --- /dev/null +++ b/frontend/src/container/FormAlertChannels/Settings/Opsgenie.tsx @@ -0,0 +1,74 @@ +import { Form, Input } from 'antd'; +import { useTranslation } from 'react-i18next'; + +import { OpsgenieChannel } from '../../CreateAlertChannels/config'; + +const { TextArea } = Input; + +function OpsgenieForm({ setSelectedConfig }: OpsgenieFormProps): JSX.Element { + const { t } = useTranslation('channels'); + + const handleInputChange = (field: string) => ( + event: React.ChangeEvent, + ): void => { + setSelectedConfig((value) => ({ + ...value, + [field]: event.target.value, + })); + }; + + return ( + <> + + + + + +