From 00ae375ecf801d26be37d7ceddb204773b40691e Mon Sep 17 00:00:00 2001 From: Michael Derynck Date: Mon, 13 Mar 2023 11:50:24 -0600 Subject: [PATCH] Add inital UI for new webhooks (#1526) --- .../OutgoingWebhook2Form.config.ts | 127 +++++++++ .../OutgoingWebhook2Form.module.css | 11 + .../OutgoingWebhook2Form.tsx | 69 +++++ .../OutgoingWebhook2Status.tsx | 118 ++++++++ .../outgoing_webhook_2/outgoing_webhook_2.ts | 84 ++++++ .../outgoing_webhook_2.types.ts | 29 ++ grafana-plugin/src/pages/index.tsx | 1 + .../OutgoingWebhooks2.module.css | 5 + .../outgoing_webhooks_2/OutgoingWebhooks2.tsx | 256 ++++++++++++++++++ grafana-plugin/src/pages/routes.tsx | 5 + grafana-plugin/src/plugin.json | 9 +- .../src/plugin/GrafanaPluginRootPage.tsx | 4 + .../src/state/rootBaseStore/index.ts | 3 + 13 files changed, 720 insertions(+), 1 deletion(-) create mode 100644 grafana-plugin/src/containers/OutgoingWebhook2Form/OutgoingWebhook2Form.config.ts create mode 100644 grafana-plugin/src/containers/OutgoingWebhook2Form/OutgoingWebhook2Form.module.css create mode 100644 grafana-plugin/src/containers/OutgoingWebhook2Form/OutgoingWebhook2Form.tsx create mode 100644 grafana-plugin/src/containers/OutgoingWebhook2Status/OutgoingWebhook2Status.tsx create mode 100644 grafana-plugin/src/models/outgoing_webhook_2/outgoing_webhook_2.ts create mode 100644 grafana-plugin/src/models/outgoing_webhook_2/outgoing_webhook_2.types.ts create mode 100644 grafana-plugin/src/pages/outgoing_webhooks_2/OutgoingWebhooks2.module.css create mode 100644 grafana-plugin/src/pages/outgoing_webhooks_2/OutgoingWebhooks2.tsx diff --git a/grafana-plugin/src/containers/OutgoingWebhook2Form/OutgoingWebhook2Form.config.ts b/grafana-plugin/src/containers/OutgoingWebhook2Form/OutgoingWebhook2Form.config.ts new file mode 100644 index 00000000..8337783f --- /dev/null +++ b/grafana-plugin/src/containers/OutgoingWebhook2Form/OutgoingWebhook2Form.config.ts @@ -0,0 +1,127 @@ +import { FormItem, FormItemType } from 'components/GForm/GForm.types'; + +export const form: { name: string; fields: FormItem[] } = { + name: 'OutgoingWebhook2', + fields: [ + { + name: 'name', + type: FormItemType.Input, + validation: { required: true }, + }, + { + name: 'trigger_type', + label: 'Trigger type', + type: FormItemType.Select, + extra: { + options: [ + { + value: '0', + label: 'Escalation step', + }, + { + value: '1', + label: 'Triggered', + }, + { + value: '2', + label: 'Acknowledged', + }, + { + value: '3', + label: 'Resolved', + }, + { + value: '4', + label: 'Silenced', + }, + { + value: '5', + label: 'Unsilenced', + }, + { + value: '6', + label: 'Unresolved', + }, + ], + }, + }, + { + name: 'http_method', + label: 'HTTP method', + type: FormItemType.Select, + extra: { + options: [ + { + value: 'GET', + label: 'GET', + }, + { + value: 'POST', + label: 'POST', + }, + { + value: 'PUT', + label: 'PUT', + }, + { + value: 'DELETE', + label: 'DELETE', + }, + { + value: 'OPTIONS', + label: 'OPTIONS', + }, + ], + }, + }, + { + name: 'url', + label: 'Webhook URL', + type: FormItemType.Input, + validation: { required: true }, + }, + { + name: 'headers', + label: 'Webhook Headers', + type: FormItemType.TextArea, + extra: { + rows: 5, + }, + }, + { + name: 'username', + type: FormItemType.Input, + }, + { + name: 'password', + type: FormItemType.Input, + }, + { + name: 'authorization_header', + type: FormItemType.Input, + }, + { + name: 'trigger_template', + type: FormItemType.TextArea, + description: 'Trigger template must be empty or evaluate to true or 1 for webhook to be sent', + extra: { + rows: 2, + }, + }, + { + name: 'data', + getDisabled: (form_data) => Boolean(form_data?.forward_whole_payload), + type: FormItemType.TextArea, + description: 'Available variables: {{ alert_payload }}, {{ alert_group_id }}', + extra: { + rows: 9, + }, + }, + { + name: 'forward_all', + normalize: (value) => Boolean(value), + type: FormItemType.Switch, + description: "Forwards whole payload of the alert to the webhook's url as POST data", + }, + ], +}; diff --git a/grafana-plugin/src/containers/OutgoingWebhook2Form/OutgoingWebhook2Form.module.css b/grafana-plugin/src/containers/OutgoingWebhook2Form/OutgoingWebhook2Form.module.css new file mode 100644 index 00000000..b0cae583 --- /dev/null +++ b/grafana-plugin/src/containers/OutgoingWebhook2Form/OutgoingWebhook2Form.module.css @@ -0,0 +1,11 @@ +.root { + display: block; +} + +.title { + margin: 16px 0 0 16px; +} + +.content { + margin: 4px; +} diff --git a/grafana-plugin/src/containers/OutgoingWebhook2Form/OutgoingWebhook2Form.tsx b/grafana-plugin/src/containers/OutgoingWebhook2Form/OutgoingWebhook2Form.tsx new file mode 100644 index 00000000..45a89c69 --- /dev/null +++ b/grafana-plugin/src/containers/OutgoingWebhook2Form/OutgoingWebhook2Form.tsx @@ -0,0 +1,69 @@ +import React, { useCallback } from 'react'; + +import { Button, Drawer } from '@grafana/ui'; +import cn from 'classnames/bind'; +import { observer } from 'mobx-react'; + +import GForm from 'components/GForm/GForm'; +import Text from 'components/Text/Text'; +import { WithPermissionControlTooltip } from 'containers/WithPermissionControl/WithPermissionControlTooltip'; +import { OutgoingWebhook2 } from 'models/outgoing_webhook_2/outgoing_webhook_2.types'; +import { useStore } from 'state/useStore'; +import { UserActions } from 'utils/authorization'; + +import { form } from './OutgoingWebhook2Form.config'; + +import styles from 'containers/OutgoingWebhook2Form/OutgoingWebhook2Form.module.css'; + +const cx = cn.bind(styles); + +interface OutgoingWebhook2FormProps { + id: OutgoingWebhook2['id'] | 'new'; + onHide: () => void; + onUpdate: () => void; +} + +const OutgoingWebhook2Form = observer((props: OutgoingWebhook2FormProps) => { + const { id, onUpdate, onHide } = props; + + const store = useStore(); + + const { outgoingWebhook2Store } = store; + + const data = id === 'new' ? {} : outgoingWebhook2Store.items[id]; + + const handleSubmit = useCallback( + (data: Partial) => { + (id === 'new' ? outgoingWebhook2Store.create(data) : outgoingWebhook2Store.update(id, data)).then(() => { + onHide(); + + onUpdate(); + }); + }, + [id] + ); + + return ( + + {id === 'new' ? 'Create' : 'Edit'} Outgoing Webhook + + } + onClose={onHide} + closeOnMaskClick + > +
+ + + + +
+
+ ); +}); + +export default OutgoingWebhook2Form; diff --git a/grafana-plugin/src/containers/OutgoingWebhook2Status/OutgoingWebhook2Status.tsx b/grafana-plugin/src/containers/OutgoingWebhook2Status/OutgoingWebhook2Status.tsx new file mode 100644 index 00000000..72fcd61c --- /dev/null +++ b/grafana-plugin/src/containers/OutgoingWebhook2Status/OutgoingWebhook2Status.tsx @@ -0,0 +1,118 @@ +import React from 'react'; + +import { Drawer, Label, VerticalGroup } from '@grafana/ui'; +import cn from 'classnames/bind'; +import { observer } from 'mobx-react'; + +import Block from 'components/GBlock/Block'; +import SourceCode from 'components/SourceCode/SourceCode'; +import Text from 'components/Text/Text'; +import { OutgoingWebhook2 } from 'models/outgoing_webhook_2/outgoing_webhook_2.types'; +import { useStore } from 'state/useStore'; + +import styles from 'containers/OutgoingWebhook2Form/OutgoingWebhook2Form.module.css'; + +const cx = cn.bind(styles); + +interface OutgoingWebhook2StatusProps { + id: OutgoingWebhook2['id']; + onHide: () => void; + onUpdate: () => void; +} + +function Debug(props) { + return ( + + + + + {props.source && {props.source}} + {props.result && props.result !== props.source && ( + + + {props.result} + + )} + + + + ); +} + +const OutgoingWebhook2Status = observer((props: OutgoingWebhook2StatusProps) => { + const { id, onHide } = props; + + const store = useStore(); + + const { outgoingWebhook2Store } = store; + + const data = outgoingWebhook2Store.items[id]; + + return ( + + Outgoing Webhook Status + + } + onClose={onHide} + closeOnMaskClick + > +
+ + + {data.name} + + {data.trigger_type_name} + + {data.last_run ? ( + + + {data.last_status_log.last_run_at} + + {JSON.stringify(data.last_status_log.input_data, null, 4)} + + {data.last_status_log.trigger && ( + + )} + {data.last_status_log.url && ( + + )} + {data.last_status_log.headers && ( + + )} + {data.last_status_log.data && ( + + )} + + {data.last_status_log.response_status && ( + + + {data.last_status_log.response_status} + + )} + + {data.last_status_log.response && ( + + + {JSON.stringify(data.last_status_log.response, null, 4)} + + )} + + ) : ( + + An event triggering this webhook has not been sent yet! + + )} + +
+
+ ); +}); + +export default OutgoingWebhook2Status; diff --git a/grafana-plugin/src/models/outgoing_webhook_2/outgoing_webhook_2.ts b/grafana-plugin/src/models/outgoing_webhook_2/outgoing_webhook_2.ts new file mode 100644 index 00000000..e1538963 --- /dev/null +++ b/grafana-plugin/src/models/outgoing_webhook_2/outgoing_webhook_2.ts @@ -0,0 +1,84 @@ +import { action, observable } from 'mobx'; + +import BaseStore from 'models/base_store'; +import { makeRequest } from 'network'; +import { RootStore } from 'state'; + +import { OutgoingWebhook2 } from './outgoing_webhook_2.types'; + +export class OutgoingWebhook2Store extends BaseStore { + @observable.shallow + items: { [id: string]: OutgoingWebhook2 } = {}; + + @observable.shallow + searchResult: { [key: string]: Array } = {}; + + constructor(rootStore: RootStore) { + super(rootStore); + + this.path = '/webhooks/'; + } + + @action + async loadItem(id: OutgoingWebhook2['id'], skipErrorHandling = false): Promise { + const outgoingWebhook2 = await this.getById(id, skipErrorHandling); + + this.items = { + ...this.items, + [id]: outgoingWebhook2, + }; + + return outgoingWebhook2; + } + + @action + async updateById(id: OutgoingWebhook2['id']) { + const response = await this.getById(id); + + this.items = { + ...this.items, + [id]: response, + }; + } + + @action + async updateItem(id: OutgoingWebhook2['id'], fromOrganization = false) { + const response = await this.getById(id, false, fromOrganization); + + this.items = { + ...this.items, + [id]: response, + }; + } + + @action + async updateItems(query = '') { + const results = await makeRequest(`${this.path}`, { + params: { search: query }, + }); + + this.items = { + ...this.items, + ...results.reduce( + (acc: { [key: number]: OutgoingWebhook2 }, item: OutgoingWebhook2) => ({ + ...acc, + [item.id]: item, + }), + {} + ), + }; + + this.searchResult = { + ...this.searchResult, + [query]: results.map((item: OutgoingWebhook2) => item.id), + }; + } + + getSearchResult(query = '') { + if (!this.searchResult[query]) { + return undefined; + } + + return this.searchResult[query].map((outgoingWebhook2Id: OutgoingWebhook2['id']) => this.items[outgoingWebhook2Id]); + } +} diff --git a/grafana-plugin/src/models/outgoing_webhook_2/outgoing_webhook_2.types.ts b/grafana-plugin/src/models/outgoing_webhook_2/outgoing_webhook_2.types.ts new file mode 100644 index 00000000..18f47314 --- /dev/null +++ b/grafana-plugin/src/models/outgoing_webhook_2/outgoing_webhook_2.types.ts @@ -0,0 +1,29 @@ +export interface OutgoingWebhook2 { + authorization_header: string; + data: string; + forward_all: boolean; + http_method: string; + id: string; + last_run: string; + name: string; + password: string; + team: null; + trigger_type: number; + trigger_type_name: string; + url: string; + username: null; + headers: string; + trigger_template: string; + last_status_log?: OutgoingWebhook2Log; +} + +export interface OutgoingWebhook2Log { + last_run_at: string; + input_data: string; + url: string; + trigger: string; + headers: string; + data: string; + response_status: string; + response: string; +} diff --git a/grafana-plugin/src/pages/index.tsx b/grafana-plugin/src/pages/index.tsx index 9e8d403c..568c13aa 100644 --- a/grafana-plugin/src/pages/index.tsx +++ b/grafana-plugin/src/pages/index.tsx @@ -187,6 +187,7 @@ export const ROUTES = { schedules: ['schedules'], schedule: ['schedules/:id'], outgoing_webhooks: ['outgoing_webhooks', 'outgoing_webhooks/:id'], + outgoing_webhooks_2: ['outgoing_webhooks_2', 'outgoing_webhooks_2/:id'], maintenance: ['maintenance'], settings: ['settings'], 'organization-logs': ['organization-logs'], diff --git a/grafana-plugin/src/pages/outgoing_webhooks_2/OutgoingWebhooks2.module.css b/grafana-plugin/src/pages/outgoing_webhooks_2/OutgoingWebhooks2.module.css new file mode 100644 index 00000000..63eb4658 --- /dev/null +++ b/grafana-plugin/src/pages/outgoing_webhooks_2/OutgoingWebhooks2.module.css @@ -0,0 +1,5 @@ +.header { + display: flex; + align-items: center; + width: 100%; +} diff --git a/grafana-plugin/src/pages/outgoing_webhooks_2/OutgoingWebhooks2.tsx b/grafana-plugin/src/pages/outgoing_webhooks_2/OutgoingWebhooks2.tsx new file mode 100644 index 00000000..9adbb3ac --- /dev/null +++ b/grafana-plugin/src/pages/outgoing_webhooks_2/OutgoingWebhooks2.tsx @@ -0,0 +1,256 @@ +import React from 'react'; + +import { Button, HorizontalGroup, VerticalGroup } from '@grafana/ui'; +import cn from 'classnames/bind'; +import { observer } from 'mobx-react'; +import LegacyNavHeading from 'navbar/LegacyNavHeading'; +import { RouteComponentProps, withRouter } from 'react-router-dom'; + +import GTable from 'components/GTable/GTable'; +import PageErrorHandlingWrapper, { PageBaseState } from 'components/PageErrorHandlingWrapper/PageErrorHandlingWrapper'; +import { + getWrongTeamResponseInfo, + initErrorDataState, +} from 'components/PageErrorHandlingWrapper/PageErrorHandlingWrapper.helpers'; +import PluginLink from 'components/PluginLink/PluginLink'; +import Text from 'components/Text/Text'; +import WithConfirm from 'components/WithConfirm/WithConfirm'; +import OutgoingWebhook2Form from 'containers/OutgoingWebhook2Form/OutgoingWebhook2Form'; +import OutgoingWebhook2Status from 'containers/OutgoingWebhook2Status/OutgoingWebhook2Status'; +import { WithPermissionControlTooltip } from 'containers/WithPermissionControl/WithPermissionControlTooltip'; +import { ActionDTO } from 'models/action'; +import { OutgoingWebhook2 } from 'models/outgoing_webhook_2/outgoing_webhook_2.types'; +import { makeRequest } from 'network'; +import { PageProps, WithStoreProps } from 'state/types'; +import { withMobXProviderContext } from 'state/withStore'; +import { isUserActionAllowed, UserActions } from 'utils/authorization'; +import { PLUGIN_ROOT } from 'utils/consts'; + +import styles from './OutgoingWebhooks2.module.css'; + +const cx = cn.bind(styles); + +interface OutgoingWebhooks2Props extends WithStoreProps, PageProps, RouteComponentProps<{ id: string }> {} + +interface OutgoingWebhooks2State extends PageBaseState { + outgoingWebhook2IdToEdit?: OutgoingWebhook2['id'] | 'new'; + outgoingWebhook2IdToShowStatus?: OutgoingWebhook2['id']; +} + +@observer +class OutgoingWebhooks2 extends React.Component { + state: OutgoingWebhooks2State = { + errorData: initErrorDataState(), + }; + + async componentDidMount() { + this.update().then(this.parseQueryParams); + } + + componentDidUpdate(prevProps: OutgoingWebhooks2Props) { + if (prevProps.match.params.id !== this.props.match.params.id) { + this.parseQueryParams(); + } + } + + parseQueryParams = async () => { + this.setState((_prevState) => ({ + errorData: initErrorDataState(), + outgoingWebhook2IdToEdit: undefined, + })); // reset state on query parse + + const { + store, + match: { + params: { id }, + }, + } = this.props; + + if (!id) { + return; + } + + let outgoingWebhook2: OutgoingWebhook2 | void = undefined; + const isNewWebhook = id === 'new'; + + if (!isNewWebhook) { + outgoingWebhook2 = await store.outgoingWebhook2Store + .loadItem(id, true) + .catch((error) => this.setState({ errorData: { ...getWrongTeamResponseInfo(error) } })); + } + + if (outgoingWebhook2 || isNewWebhook) { + this.setState({ outgoingWebhook2IdToEdit: id }); + } + }; + + update = () => { + const { store } = this.props; + + return store.outgoingWebhook2Store.updateItems(); + }; + + render() { + const { store, query } = this.props; + const { outgoingWebhook2IdToEdit, outgoingWebhook2IdToShowStatus, errorData } = this.state; + + const webhooks = store.outgoingWebhook2Store.getSearchResult(); + + const columns = [ + { + width: '25%', + title: 'Name', + dataIndex: 'name', + }, + { + width: '5%', + title: 'Trigger type', + dataIndex: 'trigger_type_name', + }, + { + width: '5%', + title: 'HTTP method', + dataIndex: 'http_method', + }, + { + width: '35%', + title: 'URL', + dataIndex: 'url', + }, + { + width: '10%', + title: 'Last run', + dataIndex: 'last_run', + }, + { + width: '20%', + key: 'action', + render: this.renderActionButtons, + }, + ]; + + return ( + + {() => ( + <> +
+ ( +
+ + + Outgoing Webhooks 2 + + ⚠️ Preview Functionality! Things will change and things will break! Do not use for critical + production processes! + + + +
+ + + + + +
+
+ )} + rowKey="id" + columns={columns} + data={webhooks} + /> +
+ {outgoingWebhook2IdToEdit && !outgoingWebhook2IdToShowStatus && ( + + )} + {outgoingWebhook2IdToShowStatus && ( + + )} + + )} +
+ ); + } + + renderActionButtons = (record: ActionDTO) => { + return ( + + + + + + + + + + + + + + ); + }; + + getDeleteClickHandler = (id: OutgoingWebhook2['id']) => { + return () => { + makeRequest(`/webhooks/${id}/`, { + method: 'DELETE', + withCredentials: true, + }); + }; + }; + + getEditClickHandler = (id: OutgoingWebhook2['id']) => { + const { history } = this.props; + + return () => { + this.setState({ outgoingWebhook2IdToEdit: id, outgoingWebhook2IdToShowStatus: undefined }); + + history.push(`${PLUGIN_ROOT}/outgoing_webhooks_2/${id}`); + }; + }; + + handleOutgoingWebhookFormHide = () => { + const { history } = this.props; + this.setState({ outgoingWebhook2IdToEdit: undefined, outgoingWebhook2IdToShowStatus: undefined }); + + history.push(`${PLUGIN_ROOT}/outgoing_webhooks_2`); + }; + + getStatusClickHandler = (id: OutgoingWebhook2['id']) => { + return () => { + const { history } = this.props; + this.setState({ outgoingWebhook2IdToEdit: undefined, outgoingWebhook2IdToShowStatus: id }); + + history.push(`${PLUGIN_ROOT}/outgoing_webhooks_2/${id}`); + }; + }; +} + +export { OutgoingWebhooks2 }; + +export default withRouter(withMobXProviderContext(OutgoingWebhooks2)); diff --git a/grafana-plugin/src/pages/routes.tsx b/grafana-plugin/src/pages/routes.tsx index ea52bfee..678b164b 100644 --- a/grafana-plugin/src/pages/routes.tsx +++ b/grafana-plugin/src/pages/routes.tsx @@ -5,6 +5,7 @@ import IntegrationsPage from 'pages/integrations/Integrations'; import MaintenancePage from 'pages/maintenance/Maintenance'; import OrganizationLogPage from 'pages/organization-logs/OrganizationLog'; import OutgoingWebhooks from 'pages/outgoing_webhooks/OutgoingWebhooks'; +import OutgoingWebhooks2 from 'pages/outgoing_webhooks_2/OutgoingWebhooks2'; import SchedulePage from 'pages/schedule/Schedule'; import SchedulesPage from 'pages/schedules/Schedules'; import SettingsPage from 'pages/settings/SettingsPage'; @@ -55,6 +56,10 @@ export const routes: { [id: string]: NavRoute } = [ component: OutgoingWebhooks, id: 'outgoing_webhooks', }, + { + component: OutgoingWebhooks2, + id: 'outgoing_webhooks_2', + }, { component: MaintenancePage, id: 'maintenance', diff --git a/grafana-plugin/src/plugin.json b/grafana-plugin/src/plugin.json index 9cd6239e..7f10dc71 100644 --- a/grafana-plugin/src/plugin.json +++ b/grafana-plugin/src/plugin.json @@ -78,7 +78,6 @@ "action": "grafana-oncall-app.schedules:read", "addToNav": true }, - { "type": "page", "name": "Outgoing Webhooks", @@ -87,6 +86,14 @@ "action": "grafana-oncall-app.outgoing-webhooks:read", "addToNav": true }, + { + "type": "page", + "name": "Outgoing Webhooks 2", + "path": "/a/grafana-oncall-app/outgoing_webhooks_2", + "role": "Viewer", + "action": "grafana-oncall-app.outgoing-webhooks:read", + "addToNav": false + }, { "type": "page", "name": "Maintenance", diff --git a/grafana-plugin/src/plugin/GrafanaPluginRootPage.tsx b/grafana-plugin/src/plugin/GrafanaPluginRootPage.tsx index fad5d207..d6be802d 100644 --- a/grafana-plugin/src/plugin/GrafanaPluginRootPage.tsx +++ b/grafana-plugin/src/plugin/GrafanaPluginRootPage.tsx @@ -28,6 +28,7 @@ import Integrations from 'pages/integrations/Integrations'; import Maintenance from 'pages/maintenance/Maintenance'; import OrganizationLogPage from 'pages/organization-logs/OrganizationLog'; import OutgoingWebhooks from 'pages/outgoing_webhooks/OutgoingWebhooks'; +import OutgoingWebhooks2 from 'pages/outgoing_webhooks_2/OutgoingWebhooks2'; import Schedule from 'pages/schedule/Schedule'; import Schedules from 'pages/schedules/Schedules'; import SettingsPage from 'pages/settings/SettingsPage'; @@ -161,6 +162,9 @@ export const Root = observer((props: AppRootProps) => { + + + diff --git a/grafana-plugin/src/state/rootBaseStore/index.ts b/grafana-plugin/src/state/rootBaseStore/index.ts index 99567a7b..99e1383a 100644 --- a/grafana-plugin/src/state/rootBaseStore/index.ts +++ b/grafana-plugin/src/state/rootBaseStore/index.ts @@ -18,6 +18,7 @@ import { HeartbeatStore } from 'models/heartbeat/heartbeat'; import { MaintenanceStore } from 'models/maintenance/maintenance'; import { OrganizationLogStore } from 'models/organization_log/organization_log'; import { OutgoingWebhookStore } from 'models/outgoing_webhook/outgoing_webhook'; +import { OutgoingWebhook2Store } from 'models/outgoing_webhook_2/outgoing_webhook_2'; import { ResolutionNotesStore } from 'models/resolution_note/resolution_note'; import { ScheduleStore } from 'models/schedule/schedule'; import { SlackStore } from 'models/slack/slack'; @@ -84,6 +85,8 @@ export class RootBaseStore { grafanaTeamStore: GrafanaTeamStore = new GrafanaTeamStore(this); alertReceiveChannelStore: AlertReceiveChannelStore = new AlertReceiveChannelStore(this); outgoingWebhookStore: OutgoingWebhookStore = new OutgoingWebhookStore(this); + + outgoingWebhook2Store: OutgoingWebhook2Store = new OutgoingWebhook2Store(this); alertReceiveChannelFiltersStore: AlertReceiveChannelFiltersStore = new AlertReceiveChannelFiltersStore(this); escalationChainStore: EscalationChainStore = new EscalationChainStore(this); escalationPolicyStore: EscalationPolicyStore = new EscalationPolicyStore(this);