From 0efb901863304970b8fc5b9518cb10d439e73b65 Mon Sep 17 00:00:00 2001 From: Amol Umbark Date: Mon, 28 Mar 2022 21:01:57 +0530 Subject: [PATCH] feat: Amol/webhook (#868) webhook receiver enabled for alerts Co-authored-by: Palash gupta --- .../clickhouse-setup/docker-compose.yaml | 11 +- frontend/src/api/channels/createWebhook.ts | 51 +++++++ frontend/src/api/channels/editWebhook.ts | 50 +++++++ frontend/src/components/Graph/index.tsx | 1 + .../src/container/AllAlertChannels/Delete.tsx | 3 +- .../container/CreateAlertChannels/config.ts | 26 +++- .../container/CreateAlertChannels/index.tsx | 101 ++++++++++++-- .../src/container/EditAlertChannels/index.tsx | 65 ++++++++- .../FormAlertChannels/Settings/Webhook.tsx | 59 ++++++++ .../src/container/FormAlertChannels/index.tsx | 22 ++- frontend/src/container/Timeline/index.tsx | 5 +- .../src/pages/AlertChannelCreate/index.tsx | 2 +- frontend/src/pages/ChannelsEdit/index.tsx | 32 ++++- .../src/types/api/channels/createWebhook.ts | 8 ++ .../src/types/api/channels/editWebhook.ts | 10 ++ .../app/clickhouseReader/reader.go | 86 +++--------- pkg/query-service/app/druidReader/reader.go | 5 +- pkg/query-service/app/http_handler.go | 5 +- pkg/query-service/app/interface.go | 5 +- pkg/query-service/constants/constants.go | 14 +- .../integrations/alertManager/manager.go | 129 ++++++++++++++++++ .../integrations/alertManager/model.go | 22 +++ pkg/query-service/model/response.go | 21 --- 23 files changed, 604 insertions(+), 129 deletions(-) create mode 100644 frontend/src/api/channels/createWebhook.ts create mode 100644 frontend/src/api/channels/editWebhook.ts create mode 100644 frontend/src/container/FormAlertChannels/Settings/Webhook.tsx create mode 100644 frontend/src/types/api/channels/createWebhook.ts create mode 100644 frontend/src/types/api/channels/editWebhook.ts create mode 100644 pkg/query-service/integrations/alertManager/manager.go create mode 100644 pkg/query-service/integrations/alertManager/model.go diff --git a/deploy/docker/clickhouse-setup/docker-compose.yaml b/deploy/docker/clickhouse-setup/docker-compose.yaml index ac69884ff6..c161d1c4d4 100644 --- a/deploy/docker/clickhouse-setup/docker-compose.yaml +++ b/deploy/docker/clickhouse-setup/docker-compose.yaml @@ -15,17 +15,20 @@ services: retries: 3 alertmanager: - image: signoz/alertmanager:0.5.0 + image: signoz/alertmanager:0.6.0 volumes: - - ./alertmanager.yml:/prometheus/alertmanager.yml + # we no longer need the config file as query services delivers + # the required config now + # - ./alertmanager.yml:/prometheus/alertmanager.yml - ./data/alertmanager:/data + depends_on: + - query-service command: - - '--config.file=/prometheus/alertmanager.yml' + - '--queryService.url=http://query-service:8080' - '--storage.path=/data' # Notes for Maintainers/Contributors who will change Line Numbers of Frontend & Query-Section. Please Update Line Numbers in `./scripts/commentLinesForSetup.sh` & `./CONTRIBUTING.md` - query-service: image: signoz/query-service:0.7.3 container_name: query-service diff --git a/frontend/src/api/channels/createWebhook.ts b/frontend/src/api/channels/createWebhook.ts new file mode 100644 index 0000000000..9c3c52c943 --- /dev/null +++ b/frontend/src/api/channels/createWebhook.ts @@ -0,0 +1,51 @@ +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/createWebhook'; + +const create = async ( + props: Props, +): Promise | ErrorResponse> => { + try { + let httpConfig = {}; + + if (props.username !== '' && props.password !== '') { + httpConfig = { + basic_auth: { + username: props.username, + password: props.password, + }, + }; + } else if (props.username === '' && props.password !== '') { + httpConfig = { + authorization: { + type: 'bearer', + credentials: props.password, + }, + }; + } + + const response = await axios.post('/channels', { + name: props.name, + webhook_configs: [ + { + send_resolved: true, + url: props.api_url, + http_config: httpConfig, + }, + ], + }); + + 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/editWebhook.ts b/frontend/src/api/channels/editWebhook.ts new file mode 100644 index 0000000000..a574633e4e --- /dev/null +++ b/frontend/src/api/channels/editWebhook.ts @@ -0,0 +1,50 @@ +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/editWebhook'; + +const editWebhook = async ( + props: Props, +): Promise | ErrorResponse> => { + try { + let httpConfig = {}; + if (props.username !== '' && props.password !== '') { + httpConfig = { + basic_auth: { + username: props.username, + password: props.password, + }, + }; + } else if (props.username === '' && props.password !== '') { + httpConfig = { + authorization: { + type: 'bearer', + credentials: props.password, + }, + }; + } + + const response = await axios.put(`/channels/${props.id}`, { + name: props.name, + webhook_configs: [ + { + send_resolved: true, + url: props.api_url, + http_config: httpConfig, + }, + ], + }); + + return { + statusCode: 200, + error: null, + message: 'Success', + payload: response.data.data, + }; + } catch (error) { + return ErrorResponseHandler(error as AxiosError); + } +}; + +export default editWebhook; diff --git a/frontend/src/components/Graph/index.tsx b/frontend/src/components/Graph/index.tsx index bdf23e6468..a8f668235d 100644 --- a/frontend/src/components/Graph/index.tsx +++ b/frontend/src/components/Graph/index.tsx @@ -79,6 +79,7 @@ function Graph({ return 'rgba(231,233,237,0.8)'; }, [currentTheme]); + // eslint-disable-next-line sonarjs/cognitive-complexity const buildChart = useCallback(() => { if (lineChartRef.current !== undefined) { lineChartRef.current.destroy(); diff --git a/frontend/src/container/AllAlertChannels/Delete.tsx b/frontend/src/container/AllAlertChannels/Delete.tsx index 4501c916c8..85116fd922 100644 --- a/frontend/src/container/AllAlertChannels/Delete.tsx +++ b/frontend/src/container/AllAlertChannels/Delete.tsx @@ -30,7 +30,8 @@ function Delete({ notifications, setChannels, id }: DeleteProps): JSX.Element { } catch (error) { notifications.error({ message: 'Error', - description: error instanceof Error ? error.toString() : 'Something went wrong', + description: + error instanceof Error ? error.toString() : 'Something went wrong', }); setLoading(false); } diff --git a/frontend/src/container/CreateAlertChannels/config.ts b/frontend/src/container/CreateAlertChannels/config.ts index 364d367806..f104a84076 100644 --- a/frontend/src/container/CreateAlertChannels/config.ts +++ b/frontend/src/container/CreateAlertChannels/config.ts @@ -1,10 +1,22 @@ -export interface SlackChannel { - send_resolved: boolean; - api_url: string; - channel: string; - title: string; - text: string; +export interface Channel { + send_resolved?: boolean; name: string; } -export type ChannelType = 'slack' | 'email'; +export interface SlackChannel extends Channel { + api_url?: string; + channel?: string; + title?: string; + text?: string; +} + +export interface WebhookChannel extends Channel { + api_url?: string; + // basic auth + username?: string; + password?: string; +} + +export type ChannelType = 'slack' | 'email' | 'webhook'; +export const SlackType: ChannelType = 'slack'; +export const WebhookType: ChannelType = 'webhook'; diff --git a/frontend/src/container/CreateAlertChannels/index.tsx b/frontend/src/container/CreateAlertChannels/index.tsx index 8a3b3fe606..f999627154 100644 --- a/frontend/src/container/CreateAlertChannels/index.tsx +++ b/frontend/src/container/CreateAlertChannels/index.tsx @@ -1,17 +1,26 @@ import { Form, notification } from 'antd'; import createSlackApi from 'api/channels/createSlack'; +import createWebhookApi from 'api/channels/createWebhook'; import ROUTES from 'constants/routes'; import FormAlertChannels from 'container/FormAlertChannels'; import history from 'lib/history'; import React, { useCallback, useState } from 'react'; -import { ChannelType, SlackChannel } from './config'; +import { + ChannelType, + SlackChannel, + SlackType, + WebhookChannel, + WebhookType, +} from './config'; function CreateAlertChannels({ preType = 'slack', }: CreateAlertChannelsProps): JSX.Element { const [formInstance] = Form.useForm(); - const [selectedConfig, setSelectedConfig] = useState>({ + const [selectedConfig, setSelectedConfig] = useState< + Partial + >({ text: ` {{ range .Alerts -}} *Alert:* {{ .Annotations.title }}{{ if .Labels.severity }} - {{ .Labels.severity }}{{ end }} @@ -73,17 +82,93 @@ function CreateAlertChannels({ } setSavingState(false); } catch (error) { + notifications.error({ + message: 'Error', + description: + 'An unexpected error occurred while creating this channel, please try again', + }); setSavingState(false); } }, [notifications, selectedConfig]); + const onWebhookHandler = useCallback(async () => { + // initial api request without auth params + let request: WebhookChannel = { + api_url: selectedConfig?.api_url || '', + name: selectedConfig?.name || '', + send_resolved: true, + }; + + setSavingState(true); + + try { + if (selectedConfig?.username !== '' || selectedConfig?.password !== '') { + if (selectedConfig?.username !== '') { + // if username is not null then password must be passed + if (selectedConfig?.password !== '') { + request = { + ...request, + username: selectedConfig.username, + password: selectedConfig.password, + }; + } else { + notifications.error({ + message: 'Error', + description: 'A Password must be provided with user name', + }); + } + } else if (selectedConfig?.password !== '') { + // only password entered, set bearer token + request = { + ...request, + username: '', + password: selectedConfig.password, + }; + } + } + + const response = await createWebhookApi(request); + if (response.statusCode === 200) { + notifications.success({ + message: 'Success', + description: 'Successfully created the channel', + }); + setTimeout(() => { + history.replace(ROUTES.SETTINGS); + }, 2000); + } else { + notifications.error({ + message: 'Error', + description: response.error || 'Error while creating the channel', + }); + } + } catch (error) { + notifications.error({ + message: 'Error', + description: + 'An unexpected error occurred while creating this channel, please try again', + }); + } + setSavingState(false); + }, [notifications, selectedConfig]); + const onSaveHandler = useCallback( async (value: ChannelType) => { - if (value === 'slack') { - onSlackHandler(); + switch (value) { + case SlackType: + onSlackHandler(); + break; + case WebhookType: + onWebhookHandler(); + break; + default: + notifications.error({ + message: 'Error', + description: 'channel type selected is invalid', + }); } }, - [onSlackHandler], + [onSlackHandler, onWebhookHandler, notifications], ); return ( @@ -108,11 +193,7 @@ function CreateAlertChannels({ } interface CreateAlertChannelsProps { - preType?: ChannelType; + preType: ChannelType; } -CreateAlertChannels.defaultProps = { - preType: undefined, -}; - export default CreateAlertChannels; diff --git a/frontend/src/container/EditAlertChannels/index.tsx b/frontend/src/container/EditAlertChannels/index.tsx index 2d8ac3576d..e4aab19d31 100644 --- a/frontend/src/container/EditAlertChannels/index.tsx +++ b/frontend/src/container/EditAlertChannels/index.tsx @@ -1,9 +1,13 @@ import { Form, notification } from 'antd'; import editSlackApi from 'api/channels/editSlack'; +import editWebhookApi from 'api/channels/editWebhook'; import ROUTES from 'constants/routes'; import { ChannelType, SlackChannel, + SlackType, + WebhookChannel, + WebhookType, } from 'container/CreateAlertChannels/config'; import FormAlertChannels from 'container/FormAlertChannels'; import history from 'lib/history'; @@ -14,14 +18,18 @@ function EditAlertChannels({ initialValue, }: EditAlertChannelsProps): JSX.Element { const [formInstance] = Form.useForm(); - const [selectedConfig, setSelectedConfig] = useState>({ + const [selectedConfig, setSelectedConfig] = useState< + Partial + >({ ...initialValue, }); const [savingState, setSavingState] = useState(false); const [notifications, NotificationElement] = notification.useNotification(); const { id } = useParams<{ id: string }>(); - const [type, setType] = useState('slack'); + const [type, setType] = useState( + initialValue?.type ? (initialValue.type as ChannelType) : SlackType, + ); const onTypeChangeHandler = useCallback((value: string) => { setType(value as ChannelType); @@ -57,13 +65,62 @@ function EditAlertChannels({ setSavingState(false); }, [selectedConfig, notifications, id]); + const onWebhookEditHandler = useCallback(async () => { + setSavingState(true); + const { name, username, password } = selectedConfig; + + const showError = (msg: string): void => { + notifications.error({ + message: 'Error', + description: msg, + }); + }; + + if (selectedConfig?.api_url === '') { + showError('Webhook URL is mandatory'); + setSavingState(false); + return; + } + + if (username && (!password || password === '')) { + showError('Please enter a password'); + setSavingState(false); + return; + } + + const response = await editWebhookApi({ + api_url: selectedConfig?.api_url || '', + name: name || '', + send_resolved: true, + username, + password, + id, + }); + + if (response.statusCode === 200) { + notifications.success({ + message: 'Success', + description: 'Channels Edited Successfully', + }); + + setTimeout(() => { + history.replace(ROUTES.SETTINGS); + }, 2000); + } else { + showError(response.error || 'error while updating the Channels'); + } + setSavingState(false); + }, [selectedConfig, notifications, id]); + const onSaveHandler = useCallback( (value: ChannelType) => { - if (value === 'slack') { + if (value === SlackType) { onSlackEditHandler(); + } else if (value === WebhookType) { + onWebhookEditHandler(); } }, - [onSlackEditHandler], + [onSlackEditHandler, onWebhookEditHandler], ); const onTestHandler = useCallback(() => { diff --git a/frontend/src/container/FormAlertChannels/Settings/Webhook.tsx b/frontend/src/container/FormAlertChannels/Settings/Webhook.tsx new file mode 100644 index 0000000000..1c7748f795 --- /dev/null +++ b/frontend/src/container/FormAlertChannels/Settings/Webhook.tsx @@ -0,0 +1,59 @@ +import { Input } from 'antd'; +import FormItem from 'antd/lib/form/FormItem'; +import React from 'react'; + +import { WebhookChannel } from '../../CreateAlertChannels/config'; + +function WebhookSettings({ setSelectedConfig }: WebhookProps): JSX.Element { + return ( + <> + + { + setSelectedConfig((value) => ({ + ...value, + api_url: event.target.value, + })); + }} + /> + + + { + setSelectedConfig((value) => ({ + ...value, + username: event.target.value, + })); + }} + /> + + + { + setSelectedConfig((value) => ({ + ...value, + password: event.target.value, + })); + }} + /> + + + ); +} + +interface WebhookProps { + setSelectedConfig: React.Dispatch< + React.SetStateAction> + >; +} + +export default WebhookSettings; diff --git a/frontend/src/container/FormAlertChannels/index.tsx b/frontend/src/container/FormAlertChannels/index.tsx index 573be68d00..55aa6e238f 100644 --- a/frontend/src/container/FormAlertChannels/index.tsx +++ b/frontend/src/container/FormAlertChannels/index.tsx @@ -5,11 +5,14 @@ import ROUTES from 'constants/routes'; import { ChannelType, SlackChannel, + SlackType, + WebhookType, } from 'container/CreateAlertChannels/config'; import history from 'lib/history'; import React from 'react'; import SlackSettings from './Settings/Slack'; +import WebhookSettings from './Settings/Webhook'; import { Button } from './styles'; const { Option } = Select; @@ -28,6 +31,16 @@ function FormAlertChannels({ initialValue, nameDisable = false, }: FormAlertChannelsProps): JSX.Element { + const renderSettings = (): React.ReactElement | null => { + switch (type) { + case SlackType: + return ; + case WebhookType: + return ; + default: + return null; + } + }; return ( <> {NotificationElement} @@ -52,14 +65,13 @@ function FormAlertChannels({ + - - {type === 'slack' && ( - - )} - + {renderSettings()}