diff --git a/deploy/docker-swarm/clickhouse-setup/docker-compose.yaml b/deploy/docker-swarm/clickhouse-setup/docker-compose.yaml index 7345f5b338..ba875f8c99 100644 --- a/deploy/docker-swarm/clickhouse-setup/docker-compose.yaml +++ b/deploy/docker-swarm/clickhouse-setup/docker-compose.yaml @@ -133,7 +133,7 @@ services: # - ./data/clickhouse-3/:/var/lib/clickhouse/ alertmanager: - image: signoz/alertmanager:0.23.4 + image: signoz/alertmanager:0.23.5 volumes: - ./data/alertmanager:/data command: diff --git a/deploy/docker/clickhouse-setup/docker-compose-core.yaml b/deploy/docker/clickhouse-setup/docker-compose-core.yaml index 61e03804f4..b27e23c2b4 100644 --- a/deploy/docker/clickhouse-setup/docker-compose-core.yaml +++ b/deploy/docker/clickhouse-setup/docker-compose-core.yaml @@ -54,7 +54,7 @@ services: alertmanager: container_name: signoz-alertmanager - image: signoz/alertmanager:0.23.4 + image: signoz/alertmanager:0.23.5 volumes: - ./data/alertmanager:/data depends_on: diff --git a/deploy/docker/clickhouse-setup/docker-compose.yaml b/deploy/docker/clickhouse-setup/docker-compose.yaml index 91474969e4..2b0652f1cf 100644 --- a/deploy/docker/clickhouse-setup/docker-compose.yaml +++ b/deploy/docker/clickhouse-setup/docker-compose.yaml @@ -149,7 +149,7 @@ services: # - ./user_scripts:/var/lib/clickhouse/user_scripts/ alertmanager: - image: signoz/alertmanager:${ALERTMANAGER_TAG:-0.23.4} + image: signoz/alertmanager:${ALERTMANAGER_TAG:-0.23.5} container_name: signoz-alertmanager volumes: - ./data/alertmanager:/data diff --git a/ee/query-service/model/plans.go b/ee/query-service/model/plans.go index 5b6f230550..09a88bbf9f 100644 --- a/ee/query-service/model/plans.go +++ b/ee/query-service/model/plans.go @@ -90,6 +90,13 @@ var BasicPlan = basemodel.FeatureSet{ UsageLimit: -1, Route: "", }, + basemodel.Feature{ + Name: basemodel.AlertChannelEmail, + Active: true, + Usage: 0, + UsageLimit: -1, + Route: "", + }, basemodel.Feature{ Name: basemodel.AlertChannelMsTeams, Active: false, @@ -177,6 +184,13 @@ var ProPlan = basemodel.FeatureSet{ UsageLimit: -1, Route: "", }, + basemodel.Feature{ + Name: basemodel.AlertChannelEmail, + Active: true, + Usage: 0, + UsageLimit: -1, + Route: "", + }, basemodel.Feature{ Name: basemodel.AlertChannelMsTeams, Active: true, @@ -264,6 +278,13 @@ var EnterprisePlan = basemodel.FeatureSet{ UsageLimit: -1, Route: "", }, + basemodel.Feature{ + Name: basemodel.AlertChannelEmail, + Active: true, + Usage: 0, + UsageLimit: -1, + Route: "", + }, basemodel.Feature{ Name: basemodel.AlertChannelMsTeams, Active: true, @@ -279,17 +300,17 @@ var EnterprisePlan = basemodel.FeatureSet{ Route: "", }, basemodel.Feature{ - Name: Onboarding, - Active: true, - Usage: 0, + Name: Onboarding, + Active: true, + Usage: 0, UsageLimit: -1, - Route: "", + Route: "", }, basemodel.Feature{ - Name: ChatSupport, - Active: true, - Usage: 0, + Name: ChatSupport, + Active: true, + Usage: 0, UsageLimit: -1, - Route: "", + Route: "", }, } diff --git a/frontend/public/locales/en/channels.json b/frontend/public/locales/en/channels.json index 63094aa911..9ab31d697c 100644 --- a/frontend/public/locales/en/channels.json +++ b/frontend/public/locales/en/channels.json @@ -23,6 +23,12 @@ "field_opsgenie_api_key": "API Key", "field_opsgenie_description": "Description", "placeholder_opsgenie_description": "Description", + "help_email_to": "Email address(es) to send alerts to (comma separated)", + "field_email_to": "To", + "placeholder_email_to": "To", + "help_email_html": "Send email in html format", + "field_email_html": "Email body template", + "placeholder_email_html": "Email body template", "field_webhook_username": "User Name (optional)", "field_webhook_password": "Password (optional)", "field_pager_routing_key": "Routing Key", diff --git a/frontend/src/api/channels/createEmail.ts b/frontend/src/api/channels/createEmail.ts new file mode 100644 index 0000000000..cde74b9c6d --- /dev/null +++ b/frontend/src/api/channels/createEmail.ts @@ -0,0 +1,34 @@ +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/createEmail'; + +const create = async ( + props: Props, +): Promise | ErrorResponse> => { + try { + const response = await axios.post('/channels', { + name: props.name, + email_configs: [ + { + send_resolved: true, + to: props.to, + html: props.html, + headers: props.headers, + }, + ], + }); + + 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/editEmail.ts b/frontend/src/api/channels/editEmail.ts new file mode 100644 index 0000000000..f20e5eb8f9 --- /dev/null +++ b/frontend/src/api/channels/editEmail.ts @@ -0,0 +1,34 @@ +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/editEmail'; + +const editEmail = async ( + props: Props, +): Promise | ErrorResponse> => { + try { + const response = await axios.put(`/channels/${props.id}`, { + name: props.name, + email_configs: [ + { + send_resolved: true, + to: props.to, + html: props.html, + headers: props.headers, + }, + ], + }); + + return { + statusCode: 200, + error: null, + message: 'Success', + payload: response.data.data, + }; + } catch (error) { + return ErrorResponseHandler(error as AxiosError); + } +}; + +export default editEmail; diff --git a/frontend/src/api/channels/testEmail.ts b/frontend/src/api/channels/testEmail.ts new file mode 100644 index 0000000000..825836abea --- /dev/null +++ b/frontend/src/api/channels/testEmail.ts @@ -0,0 +1,34 @@ +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/createEmail'; + +const testEmail = async ( + props: Props, +): Promise | ErrorResponse> => { + try { + const response = await axios.post('/testChannel', { + name: props.name, + email_configs: [ + { + send_resolved: true, + to: props.to, + html: props.html, + headers: props.headers, + }, + ], + }); + + return { + statusCode: 200, + error: null, + message: 'Success', + payload: response.data.data, + }; + } catch (error) { + return ErrorResponseHandler(error as AxiosError); + } +}; + +export default testEmail; diff --git a/frontend/src/container/CreateAlertChannels/config.ts b/frontend/src/container/CreateAlertChannels/config.ts index e15c1d7e08..3ee3882cc1 100644 --- a/frontend/src/container/CreateAlertChannels/config.ts +++ b/frontend/src/container/CreateAlertChannels/config.ts @@ -64,6 +64,16 @@ export interface OpsgenieChannel extends Channel { priority?: string; } +export interface EmailChannel extends Channel { + // comma separated list of email addresses to send alerts to + to: string; + // HTML body of the email notification. + html: string; + // Further headers email header key/value pairs. + // [ headers: { : , ... } ] + headers: Record; +} + export const ValidatePagerChannel = (p: PagerChannel): string => { if (!p) { return 'Received unexpected input for this channel, please contact your administrator '; diff --git a/frontend/src/container/CreateAlertChannels/defaults.ts b/frontend/src/container/CreateAlertChannels/defaults.ts index 3068d8dd0c..f687164a72 100644 --- a/frontend/src/container/CreateAlertChannels/defaults.ts +++ b/frontend/src/container/CreateAlertChannels/defaults.ts @@ -1,4 +1,4 @@ -import { OpsgenieChannel, PagerChannel } from './config'; +import { EmailChannel, OpsgenieChannel, PagerChannel } from './config'; export const PagerInitialConfig: Partial = { description: `[{{ .Status | toUpper }}{{ if eq .Status "firing" }}:{{ .Alerts.Firing | len }}{{ end }}] {{ .CommonLabels.alertname }} for {{ .CommonLabels.job }} @@ -50,3 +50,399 @@ export const OpsgenieInitialConfig: Partial = { 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 }}', }; + +export const EmailInitialConfig: Partial = { + send_resolved: true, + html: ` + + + + + + {{ template "__subject" . }} + + + + + + + + + +
+
+ + + {{ if gt (len .Alerts.Firing) 0 }} + + + + + +
+ {{ else }} + + {{ end }} + {{ .Alerts | len }} alert{{ if gt (len .Alerts) 1 }}s{{ end }} for {{ range .GroupLabels.SortedPairs }} + {{ .Name }}={{ .Value }} + {{ end }} +
+ + {{ if gt (len .Alerts.Firing) 0 }} + + + + {{ end }} + {{ range .Alerts.Firing }} + + + + {{ end }} + {{ if gt (len .Alerts.Resolved) 0 }} + {{ if gt (len .Alerts.Firing) 0 }} + + + + {{ end }} + + + + {{ end }} + {{ range .Alerts.Resolved }} + + + + {{ end }} +
+ [{{ .Alerts.Firing | len }}] Firing +
+ Labels
+ {{ range .Labels.SortedPairs }}{{ .Name }} = {{ .Value }}
{{ end }} + {{ if gt (len .Annotations) 0 }}Annotations
{{ end }} + {{ range .Annotations.SortedPairs }}{{ .Name }} = {{ .Value }}
{{ end }} + Source
+
+
+
+
+
+ [{{ .Alerts.Resolved | len }}] Resolved +
+ Labels
+ {{ range .Labels.SortedPairs }}{{ .Name }} = {{ .Value }}
{{ end }} + {{ if gt (len .Annotations) 0 }}Annotations
{{ end }} + {{ range .Annotations.SortedPairs }}{{ .Name }} = {{ .Value }}
{{ end }} + Source
+
+
+
+
+ + `, +}; diff --git a/frontend/src/container/CreateAlertChannels/index.tsx b/frontend/src/container/CreateAlertChannels/index.tsx index d8426f71b9..51a0b6214e 100644 --- a/frontend/src/container/CreateAlertChannels/index.tsx +++ b/frontend/src/container/CreateAlertChannels/index.tsx @@ -1,9 +1,11 @@ import { Form } from 'antd'; +import createEmail from 'api/channels/createEmail'; 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 testEmail from 'api/channels/testEmail'; import testMsTeamsApi from 'api/channels/testMsTeams'; import testOpsGenie from 'api/channels/testOpsgenie'; import testPagerApi from 'api/channels/testPager'; @@ -18,6 +20,7 @@ import { useTranslation } from 'react-i18next'; import { ChannelType, + EmailChannel, MsTeamsChannel, OpsgenieChannel, PagerChannel, @@ -25,7 +28,11 @@ import { ValidatePagerChannel, WebhookChannel, } from './config'; -import { OpsgenieInitialConfig, PagerInitialConfig } from './defaults'; +import { + EmailInitialConfig, + OpsgenieInitialConfig, + PagerInitialConfig, +} from './defaults'; import { isChannelType } from './utils'; function CreateAlertChannels({ @@ -42,7 +49,8 @@ function CreateAlertChannels({ WebhookChannel & PagerChannel & MsTeamsChannel & - OpsgenieChannel + OpsgenieChannel & + EmailChannel > >({ text: `{{ range .Alerts -}} @@ -94,6 +102,14 @@ function CreateAlertChannels({ ...OpsgenieInitialConfig, })); } + + // reset config to email defaults + if (value === ChannelType.Email && currentType !== value) { + setSelectedConfig((selectedConfig) => ({ + ...selectedConfig, + ...EmailInitialConfig, + })); + } }, [type, selectedConfig], ); @@ -293,6 +309,43 @@ function CreateAlertChannels({ setSavingState(false); }, [prepareOpsgenieRequest, t, notifications]); + const prepareEmailRequest = useCallback( + () => ({ + name: selectedConfig?.name || '', + send_resolved: true, + to: selectedConfig?.to || '', + html: selectedConfig?.html || '', + headers: selectedConfig?.headers || {}, + }), + [selectedConfig], + ); + + const onEmailHandler = useCallback(async () => { + setSavingState(true); + try { + const request = prepareEmailRequest(); + const response = await createEmail(request); + 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); + }, [prepareEmailRequest, t, notifications]); + const prepareMsTeamsRequest = useCallback( () => ({ webhook_url: selectedConfig?.webhook_url || '', @@ -339,6 +392,7 @@ function CreateAlertChannels({ [ChannelType.Pagerduty]: onPagerHandler, [ChannelType.Opsgenie]: onOpsgenieHandler, [ChannelType.MsTeams]: onMsTeamsHandler, + [ChannelType.Email]: onEmailHandler, }; if (isChannelType(value)) { @@ -360,6 +414,7 @@ function CreateAlertChannels({ onPagerHandler, onOpsgenieHandler, onMsTeamsHandler, + onEmailHandler, notifications, t, ], @@ -392,6 +447,10 @@ function CreateAlertChannels({ request = prepareOpsgenieRequest(); response = await testOpsGenie(request); break; + case ChannelType.Email: + request = prepareEmailRequest(); + response = await testEmail(request); + break; default: notifications.error({ message: 'Error', @@ -427,6 +486,7 @@ function CreateAlertChannels({ prepareOpsgenieRequest, prepareSlackRequest, prepareMsTeamsRequest, + prepareEmailRequest, notifications, ], ); @@ -455,6 +515,7 @@ function CreateAlertChannels({ ...selectedConfig, ...PagerInitialConfig, ...OpsgenieInitialConfig, + ...EmailInitialConfig, }, }} /> diff --git a/frontend/src/container/CreateAlertRule/defaults.ts b/frontend/src/container/CreateAlertRule/defaults.ts index fd998c1035..fee6981881 100644 --- a/frontend/src/container/CreateAlertRule/defaults.ts +++ b/frontend/src/container/CreateAlertRule/defaults.ts @@ -3,7 +3,6 @@ import { initialQueryPromQLData, PANEL_TYPES, } from 'constants/queryBuilder'; -import ROUTES from 'constants/routes'; import { AlertTypes } from 'types/api/alerts/alertTypes'; import { AlertDef, @@ -79,7 +78,6 @@ export const logAlertDefaults: AlertDef = { }, labels: { severity: 'warning', - details: `${window.location.protocol}//${window.location.host}${ROUTES.LOGS_EXPLORER}`, }, annotations: defaultAnnotations, evalWindow: defaultEvalWindow, @@ -110,7 +108,6 @@ export const traceAlertDefaults: AlertDef = { }, labels: { severity: 'warning', - details: `${window.location.protocol}//${window.location.host}/traces`, }, annotations: defaultAnnotations, evalWindow: defaultEvalWindow, @@ -141,7 +138,6 @@ export const exceptionAlertDefaults: AlertDef = { }, labels: { severity: 'warning', - details: `${window.location.protocol}//${window.location.host}/exceptions`, }, annotations: defaultAnnotations, evalWindow: defaultEvalWindow, diff --git a/frontend/src/container/EditAlertChannels/index.tsx b/frontend/src/container/EditAlertChannels/index.tsx index 58401bb48e..29d7816d90 100644 --- a/frontend/src/container/EditAlertChannels/index.tsx +++ b/frontend/src/container/EditAlertChannels/index.tsx @@ -1,9 +1,11 @@ import { Form } from 'antd'; +import editEmail from 'api/channels/editEmail'; 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 testEmail from 'api/channels/testEmail'; import testMsTeamsApi from 'api/channels/testMsTeams'; import testOpsgenie from 'api/channels/testOpsgenie'; import testPagerApi from 'api/channels/testPager'; @@ -12,6 +14,7 @@ import testWebhookApi from 'api/channels/testWebhook'; import ROUTES from 'constants/routes'; import { ChannelType, + EmailChannel, MsTeamsChannel, OpsgenieChannel, PagerChannel, @@ -39,7 +42,8 @@ function EditAlertChannels({ WebhookChannel & PagerChannel & MsTeamsChannel & - OpsgenieChannel + OpsgenieChannel & + EmailChannel > >({ ...initialValue, @@ -156,6 +160,36 @@ function EditAlertChannels({ setSavingState(false); }, [prepareWebhookRequest, t, notifications, selectedConfig]); + const prepareEmailRequest = useCallback( + () => ({ + name: selectedConfig?.name || '', + to: selectedConfig.to || '', + html: selectedConfig.html || '', + headers: selectedConfig.headers || {}, + id, + }), + [id, selectedConfig], + ); + + const onEmailEditHandler = useCallback(async () => { + setSavingState(true); + const request = prepareEmailRequest(); + const response = await editEmail(request); + 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); + }, [prepareEmailRequest, t, notifications]); + const preparePagerRequest = useCallback( () => ({ name: selectedConfig.name || '', @@ -300,6 +334,8 @@ function EditAlertChannels({ onMsTeamsEditHandler(); } else if (value === ChannelType.Opsgenie) { onOpsgenieEditHandler(); + } else if (value === ChannelType.Email) { + onEmailEditHandler(); } }, [ @@ -308,6 +344,7 @@ function EditAlertChannels({ onPagerEditHandler, onMsTeamsEditHandler, onOpsgenieEditHandler, + onEmailEditHandler, ], ); @@ -338,6 +375,10 @@ function EditAlertChannels({ request = prepareOpsgenieRequest(); if (request) response = await testOpsgenie(request); break; + case ChannelType.Email: + request = prepareEmailRequest(); + if (request) response = await testEmail(request); + break; default: notifications.error({ message: 'Error', @@ -373,6 +414,7 @@ function EditAlertChannels({ prepareSlackRequest, prepareMsTeamsRequest, prepareOpsgenieRequest, + prepareEmailRequest, notifications, ], ); diff --git a/frontend/src/container/FormAlertChannels/Settings/Email.tsx b/frontend/src/container/FormAlertChannels/Settings/Email.tsx new file mode 100644 index 0000000000..398e172a57 --- /dev/null +++ b/frontend/src/container/FormAlertChannels/Settings/Email.tsx @@ -0,0 +1,48 @@ +import { Form, Input } from 'antd'; +import { Dispatch, SetStateAction } from 'react'; +import { useTranslation } from 'react-i18next'; + +import { EmailChannel } from '../../CreateAlertChannels/config'; + +function EmailForm({ setSelectedConfig }: EmailFormProps): JSX.Element { + const { t } = useTranslation('channels'); + + const handleInputChange = (field: string) => ( + event: React.ChangeEvent, + ): void => { + setSelectedConfig((value) => ({ + ...value, + [field]: event.target.value, + })); + }; + + return ( + <> + + + + + {/* +