From b5a8b8b1687df75eabc51e2952f9d6d517ae7d99 Mon Sep 17 00:00:00 2001 From: Michael Derynck Date: Wed, 27 Sep 2023 07:22:52 -0600 Subject: [PATCH] Add webhook presets (#2996) # What this PR does Add a system similar to how we select integrations when creating webhooks so that the user has a description of what webhookds do and does not have to write complex templates for common webhook use cases. Presets allow us to create the contents of the webhooks in code and define which fields are controlled by the preset. Some specifics: - Newly created webhooks must choose between Simple, Advanced or another predefined system - Simple is always an escalation step and will post the entire payload to the given URL - Advanced is the same as no preset which is our current view where all fields are available - There are no changes for all existing webhooks with empty preset fields - Once a webhook is created with a preset the preset cannot be changed - Fields in the webhook that are populated by code will give a validation error if they are modified - In the public API webhooks with presets are returned for viewing but cannot be created or modified. This restriction is in place because the Web UI provides the context for which fields to use with a preset. The public API is for interacting with webhooks where all fields are defined. To define a preset create a file with metadata and an override function. The metadata drives validation and what to display in the UI. There are two functions one is connected to the pre_save hook of the Webhook model for persistent changes, the other replaces parameters at execution time for ephemeral changes. See the simple and advanced presets as an example. The file must be listed in settings in `INSTALLED_WEBHOOK_PRESETS` to be enabled at runtime.. ## Which issue(s) this PR fixes ## Checklist - [x] Unit, integration, and e2e (if applicable) tests updated - [x] Documentation added (or `pr:no public docs` PR label added if not required) - [x] `CHANGELOG.md` updated (or `pr:no changelog` PR label added if not required) --------- Co-authored-by: Joey Orlando --- CHANGELOG.md | 1 + docs/sources/outgoing-webhooks/_index.md | 6 +- engine/apps/api/serializers/webhook.py | 81 +++- engine/apps/api/tests/test_webhook_presets.py | 161 ++++++++ engine/apps/api/tests/test_webhooks.py | 74 +++- engine/apps/api/views/webhooks.py | 8 + .../apps/public_api/serializers/webhooks.py | 11 + engine/apps/public_api/tests/test_webhooks.py | 70 ++++ .../migrations/0011_auto_20230920_1813.py | 23 ++ engine/apps/webhooks/models/webhook.py | 5 +- engine/apps/webhooks/presets/__init__.py | 0 engine/apps/webhooks/presets/advanced.py | 19 + engine/apps/webhooks/presets/preset.py | 36 ++ .../apps/webhooks/presets/preset_options.py | 30 ++ engine/apps/webhooks/presets/simple.py | 32 ++ engine/apps/webhooks/tasks/trigger_webhook.py | 9 +- .../webhooks/tests/test_webhook_presets.py | 160 ++++++++ engine/conftest.py | 10 + engine/settings/base.py | 5 + .../CommonWebhookPresetIcons.config.tsx | 3 + .../OutgoingWebhookForm.config.tsx | 387 ++++++++++-------- .../OutgoingWebhookForm.module.css | 30 +- .../OutgoingWebhookForm.tsx | 176 ++++++-- .../WebhookPresetIcons.config.tsx | 5 + .../outgoing_webhook/outgoing_webhook.ts | 11 +- .../outgoing_webhook.types.ts | 9 + .../outgoing_webhooks/OutgoingWebhooks.tsx | 2 +- .../src/state/rootBaseStore/index.ts | 1 + 28 files changed, 1150 insertions(+), 215 deletions(-) create mode 100644 engine/apps/api/tests/test_webhook_presets.py create mode 100644 engine/apps/webhooks/migrations/0011_auto_20230920_1813.py create mode 100644 engine/apps/webhooks/presets/__init__.py create mode 100644 engine/apps/webhooks/presets/advanced.py create mode 100644 engine/apps/webhooks/presets/preset.py create mode 100644 engine/apps/webhooks/presets/preset_options.py create mode 100644 engine/apps/webhooks/presets/simple.py create mode 100644 engine/apps/webhooks/tests/test_webhook_presets.py create mode 100644 grafana-plugin/src/containers/OutgoingWebhookForm/CommonWebhookPresetIcons.config.tsx create mode 100644 grafana-plugin/src/containers/OutgoingWebhookForm/WebhookPresetIcons.config.tsx diff --git a/CHANGELOG.md b/CHANGELOG.md index c59e1908..71eef3ff 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- Presets for webhooks @mderynck ([#2996](https://github.com/grafana/oncall/pull/2996)) - Unify breadcrumbs behaviour with other Grafana Apps and main core ([#1906](https://github.com/grafana/oncall/issues/1906)) - Add `enable_web_overrides` option to schedules public API ([#3062](https://github.com/grafana/oncall/pull/3062)) diff --git a/docs/sources/outgoing-webhooks/_index.md b/docs/sources/outgoing-webhooks/_index.md index 19cbf10c..8469466e 100644 --- a/docs/sources/outgoing-webhooks/_index.md +++ b/docs/sources/outgoing-webhooks/_index.md @@ -30,8 +30,10 @@ Jinja2 templates to customize the request being sent. ## Creating an outgoing webhook To create an outgoing webhook navigate to **Outgoing Webhooks** and click **+ Create**. On this screen outgoing -webhooks can be viewed, edited and deleted. To create the outgoing webhook populate the required fields and -click **Create Webhook** +webhooks can be viewed, edited and deleted. To create the outgoing webhook click **New Outgoing Webhook** and then +select a preset based on what you want to do. A simple webhook will POST alert group data as a selectable escalation +step to the specified url. If you require more customization use the advanced webhook which provides all of the +fields described below. ### Outgoing webhook fields diff --git a/engine/apps/api/serializers/webhook.py b/engine/apps/api/serializers/webhook.py index 2a38200c..832292ce 100644 --- a/engine/apps/api/serializers/webhook.py +++ b/engine/apps/api/serializers/webhook.py @@ -4,7 +4,8 @@ from rest_framework import serializers from rest_framework.validators import UniqueTogetherValidator from apps.webhooks.models import Webhook, WebhookResponse -from apps.webhooks.models.webhook import WEBHOOK_FIELD_PLACEHOLDER +from apps.webhooks.models.webhook import PUBLIC_WEBHOOK_HTTP_METHODS, WEBHOOK_FIELD_PLACEHOLDER +from apps.webhooks.presets.preset_options import WebhookPresetOptions from common.api_helpers.custom_fields import TeamPrimaryKeyRelatedField from common.api_helpers.utils import CurrentOrganizationDefault, CurrentTeamDefault, CurrentUserDefault from common.jinja_templater import apply_jinja_template @@ -31,9 +32,9 @@ class WebhookSerializer(serializers.ModelSerializer): organization = serializers.HiddenField(default=CurrentOrganizationDefault()) team = TeamPrimaryKeyRelatedField(allow_null=True, default=CurrentTeamDefault()) user = serializers.HiddenField(default=CurrentUserDefault()) - trigger_type = serializers.CharField(required=True) forward_all = serializers.BooleanField(allow_null=True, required=False) last_response_log = serializers.SerializerMethodField() + trigger_type = serializers.CharField(allow_null=True) trigger_type_name = serializers.SerializerMethodField() class Meta: @@ -59,11 +60,8 @@ class WebhookSerializer(serializers.ModelSerializer): "trigger_type_name", "last_response_log", "integration_filter", + "preset", ] - extra_kwargs = { - "name": {"required": True, "allow_null": False, "allow_blank": False}, - "url": {"required": True, "allow_null": False, "allow_blank": False}, - } validators = [UniqueTogetherValidator(queryset=Webhook.objects.all(), fields=["name", "organization"])] @@ -78,6 +76,16 @@ class WebhookSerializer(serializers.ModelSerializer): def to_internal_value(self, data): webhook = self.instance + # Some fields are conditionally required, add none values for missing required fields + if webhook and webhook.preset and "preset" not in data: + data["preset"] = webhook.preset + for key in ["url", "http_method", "trigger_type"]: + if key not in data: + if self.instance: + data[key] = getattr(self.instance, key) + else: + data[key] = None + # If webhook is being copied instance won't exist to copy values from if not webhook and "id" in data: webhook = Webhook.objects.get( @@ -111,10 +119,29 @@ class WebhookSerializer(serializers.ModelSerializer): return self._validate_template_field(headers) def validate_url(self, url): + if self.is_field_controlled("url"): + return url + if not url: - return None + raise serializers.ValidationError(detail="This field is required.") return self._validate_template_field(url) + def validate_http_method(self, http_method): + if self.is_field_controlled("http_method"): + return http_method + + if http_method not in PUBLIC_WEBHOOK_HTTP_METHODS: + raise serializers.ValidationError(detail=f"This field must be one of {PUBLIC_WEBHOOK_HTTP_METHODS}.") + return http_method + + def validate_trigger_type(self, trigger_type): + if self.is_field_controlled("trigger_type"): + return trigger_type + + if not trigger_type or int(trigger_type) not in Webhook.ALL_TRIGGER_TYPES: + raise serializers.ValidationError(detail="This field is required.") + return trigger_type + def validate_data(self, data): if not data: return None @@ -125,6 +152,29 @@ class WebhookSerializer(serializers.ModelSerializer): return False return data + def validate_preset(self, preset): + if self.instance and self.instance.preset != preset: + raise serializers.ValidationError(detail="This field once set cannot be modified.") + + if preset: + if preset not in WebhookPresetOptions.WEBHOOK_PRESETS: + raise serializers.ValidationError(detail=f"{preset} is not a valid preset id.") + + preset_metadata = WebhookPresetOptions.WEBHOOK_PRESETS[preset].metadata + for controlled_field in preset_metadata.controlled_fields: + if controlled_field in self.initial_data: + if self.instance: + if self.initial_data[controlled_field] != getattr(self.instance, controlled_field): + raise serializers.ValidationError( + detail=f"{controlled_field} is controlled by preset, cannot update" + ) + elif self.initial_data[controlled_field] is not None: + raise serializers.ValidationError( + detail=f"{controlled_field} is controlled by preset, cannot create" + ) + + return preset + def get_last_response_log(self, obj): return WebhookResponseSerializer(obj.responses.all().last()).data @@ -133,3 +183,20 @@ class WebhookSerializer(serializers.ModelSerializer): if obj.trigger_type is not None: trigger_type_name = Webhook.TRIGGER_TYPES[int(obj.trigger_type)][1] return trigger_type_name + + def is_field_controlled(self, field_name): + if self.instance: + if not self.instance.preset: + return False + elif "preset" not in self.initial_data: + return False + + preset_id = self.instance.preset if self.instance else self.initial_data["preset"] + if preset_id: + if preset_id not in WebhookPresetOptions.WEBHOOK_PRESETS: + raise serializers.ValidationError(detail=f"unknown preset {preset_id} referenced") + + preset = WebhookPresetOptions.WEBHOOK_PRESETS[preset_id] + if field_name not in preset.metadata.controlled_fields: + return False + return True diff --git a/engine/apps/api/tests/test_webhook_presets.py b/engine/apps/api/tests/test_webhook_presets.py new file mode 100644 index 00000000..e87f7587 --- /dev/null +++ b/engine/apps/api/tests/test_webhook_presets.py @@ -0,0 +1,161 @@ +import pytest +from django.urls import reverse +from rest_framework import status +from rest_framework.test import APIClient + +from apps.webhooks.models import Webhook +from apps.webhooks.models.webhook import WEBHOOK_FIELD_PLACEHOLDER +from apps.webhooks.tests.test_webhook_presets import ( + TEST_WEBHOOK_LOGO, + TEST_WEBHOOK_PRESET_CONTROLLED_FIELDS, + TEST_WEBHOOK_PRESET_DESCRIPTION, + TEST_WEBHOOK_PRESET_ID, + TEST_WEBHOOK_PRESET_NAME, + TEST_WEBHOOK_PRESET_URL, +) + + +@pytest.mark.django_db +def test_get_webhook_preset_options( + make_organization_and_user_with_plugin_token, webhook_preset_api_setup, make_user_auth_headers +): + organization, user, token = make_organization_and_user_with_plugin_token() + client = APIClient() + url = reverse("api-internal:webhooks-preset-options") + + response = client.get(url, format="json", **make_user_auth_headers(user, token)) + + assert response.status_code == status.HTTP_200_OK + assert response.data[0]["id"] == TEST_WEBHOOK_PRESET_ID + assert response.data[0]["name"] == TEST_WEBHOOK_PRESET_NAME + assert response.data[0]["logo"] == TEST_WEBHOOK_LOGO + assert response.data[0]["description"] == TEST_WEBHOOK_PRESET_DESCRIPTION + assert response.data[0]["controlled_fields"] == TEST_WEBHOOK_PRESET_CONTROLLED_FIELDS + + +@pytest.mark.django_db +def test_create_webhook_from_preset( + make_organization_and_user_with_plugin_token, webhook_preset_api_setup, make_user_auth_headers +): + organization, user, token = make_organization_and_user_with_plugin_token() + client = APIClient() + url = reverse("api-internal:webhooks-list") + + data = { + "name": "the_webhook", + "trigger_type": Webhook.TRIGGER_ALERT_GROUP_CREATED, + "team": None, + "password": "secret_password", + "preset": TEST_WEBHOOK_PRESET_ID, + } + response = client.post(url, data, format="json", **make_user_auth_headers(user, token)) + webhook = Webhook.objects.get(public_primary_key=response.data["id"]) + + expected_response = data | { + "id": webhook.public_primary_key, + "url": TEST_WEBHOOK_PRESET_URL, + "data": organization.org_title, + "username": None, + "password": WEBHOOK_FIELD_PLACEHOLDER, + "authorization_header": None, + "forward_all": True, + "headers": None, + "http_method": "GET", + "integration_filter": None, + "is_webhook_enabled": True, + "is_legacy": False, + "last_response_log": { + "request_data": "", + "request_headers": "", + "timestamp": None, + "content": "", + "status_code": None, + "request_trigger": "", + "url": "", + "event_data": "", + }, + "trigger_template": None, + "trigger_type": str(data["trigger_type"]), + "trigger_type_name": "Alert Group Created", + } + + assert response.status_code == status.HTTP_201_CREATED + assert response.json() == expected_response + assert webhook.password == data["password"] + + +@pytest.mark.django_db +def test_invalid_create_webhook_with_preset( + make_organization_and_user_with_plugin_token, webhook_preset_api_setup, make_user_auth_headers +): + organization, user, token = make_organization_and_user_with_plugin_token() + client = APIClient() + url = reverse("api-internal:webhooks-list") + + data = { + "name": "the_webhook", + "trigger_type": Webhook.TRIGGER_ALERT_GROUP_CREATED, + "url": "https://test12345.com", + "preset": TEST_WEBHOOK_PRESET_ID, + } + response = client.post(url, data, format="json", **make_user_auth_headers(user, token)) + assert response.status_code == status.HTTP_400_BAD_REQUEST + assert response.json()["preset"][0] == "url is controlled by preset, cannot create" + + +@pytest.mark.django_db +def test_update_webhook_from_preset( + make_organization_and_user_with_plugin_token, webhook_preset_api_setup, make_user_auth_headers, make_custom_webhook +): + organization, user, token = make_organization_and_user_with_plugin_token() + webhook = make_custom_webhook( + name="the_webhook", + organization=organization, + trigger_type=Webhook.TRIGGER_ALERT_GROUP_CREATED, + preset=TEST_WEBHOOK_PRESET_ID, + ) + + client = APIClient() + url = reverse("api-internal:webhooks-detail", kwargs={"pk": webhook.public_primary_key}) + + data = { + "name": "the_webhook 2", + } + response = client.put(url, data, format="json", **make_user_auth_headers(user, token)) + assert response.status_code == status.HTTP_200_OK + assert response.json()["name"] == data["name"] + + webhook.refresh_from_db() + assert webhook.name == data["name"] + assert webhook.url == TEST_WEBHOOK_PRESET_URL + assert webhook.http_method == "GET" + assert webhook.data == organization.org_title + + +@pytest.mark.django_db +def test_invalid_update_webhook_from_preset( + make_organization_and_user_with_plugin_token, webhook_preset_api_setup, make_user_auth_headers, make_custom_webhook +): + organization, user, token = make_organization_and_user_with_plugin_token() + webhook = make_custom_webhook( + name="the_webhook", + organization=organization, + trigger_type=Webhook.TRIGGER_ALERT_GROUP_CREATED, + preset=TEST_WEBHOOK_PRESET_ID, + ) + + client = APIClient() + url = reverse("api-internal:webhooks-detail", kwargs={"pk": webhook.public_primary_key}) + + data = { + "preset": "some_other_preset", + } + response = client.put(url, data, format="json", **make_user_auth_headers(user, token)) + assert response.status_code == status.HTTP_400_BAD_REQUEST + assert response.json()["preset"][0] == "This field once set cannot be modified." + + data = { + "data": "some_other_data", + } + response = client.put(url, data, format="json", **make_user_auth_headers(user, token)) + assert response.status_code == status.HTTP_400_BAD_REQUEST diff --git a/engine/apps/api/tests/test_webhooks.py b/engine/apps/api/tests/test_webhooks.py index e6c2624d..867e366f 100644 --- a/engine/apps/api/tests/test_webhooks.py +++ b/engine/apps/api/tests/test_webhooks.py @@ -66,6 +66,7 @@ def test_get_list_webhooks(webhook_internal_api_setup, make_user_auth_headers): "trigger_template": None, "trigger_type": "0", "trigger_type_name": "Escalation step", + "preset": None, } ] @@ -108,6 +109,7 @@ def test_get_detail_webhook(webhook_internal_api_setup, make_user_auth_headers): "trigger_template": None, "trigger_type": "0", "trigger_type_name": "Escalation step", + "preset": None, } response = client.get(url, format="json", **make_user_auth_headers(user, token)) @@ -124,7 +126,8 @@ def test_create_webhook(webhook_internal_api_setup, make_user_auth_headers): data = { "name": "the_webhook", "url": TEST_URL, - "trigger_type": str(Webhook.TRIGGER_ALERT_GROUP_CREATED), + "trigger_type": Webhook.TRIGGER_ALERT_GROUP_CREATED, + "http_method": "POST", "team": None, } response = client.post(url, data, format="json", **make_user_auth_headers(user, token)) @@ -152,7 +155,9 @@ def test_create_webhook(webhook_internal_api_setup, make_user_auth_headers): "event_data": "", }, "trigger_template": None, + "trigger_type": str(data["trigger_type"]), "trigger_type_name": "Alert Group Created", + "preset": None, } assert response.status_code == status.HTTP_201_CREATED assert response.json() == expected_response @@ -179,7 +184,8 @@ def test_create_valid_templated_field(webhook_internal_api_setup, make_user_auth "name": "webhook_with_valid_data", "url": TEST_URL, field_name: value, - "trigger_type": str(Webhook.TRIGGER_ALERT_GROUP_CREATED), + "trigger_type": Webhook.TRIGGER_ALERT_GROUP_CREATED, + "http_method": "POST", "team": None, } @@ -209,7 +215,9 @@ def test_create_valid_templated_field(webhook_internal_api_setup, make_user_auth "event_data": "", }, "trigger_template": None, + "trigger_type": str(data["trigger_type"]), "trigger_type_name": "Alert Group Created", + "preset": None, } # update expected value for changed field expected_response[field_name] = value @@ -236,7 +244,8 @@ def test_create_invalid_templated_field(webhook_internal_api_setup, make_user_au "name": "webhook_with_valid_data", "url": TEST_URL, field_name: value, - "trigger_type": str(Webhook.TRIGGER_ALERT_GROUP_CREATED), + "trigger_type": Webhook.TRIGGER_ALERT_GROUP_CREATED, + "http_method": "POST", "team": None, } @@ -253,7 +262,8 @@ def test_update_webhook(webhook_internal_api_setup, make_user_auth_headers): data = { "name": "github_button_updated", "url": "https://github.com/", - "trigger_type": str(Webhook.TRIGGER_ALERT_GROUP_CREATED), + "trigger_type": Webhook.TRIGGER_ALERT_GROUP_CREATED, + "http_method": "POST", "team": None, } response = client.put( @@ -547,7 +557,8 @@ def test_webhook_field_masking(webhook_internal_api_setup, make_user_auth_header data = { "name": "the_webhook", "url": TEST_URL, - "trigger_type": str(Webhook.TRIGGER_ALERT_GROUP_CREATED), + "trigger_type": Webhook.TRIGGER_ALERT_GROUP_CREATED, + "http_method": "POST", "team": None, "password": "secret_password", "authorization_header": "auth 1234", @@ -579,7 +590,9 @@ def test_webhook_field_masking(webhook_internal_api_setup, make_user_auth_header "event_data": "", }, "trigger_template": None, + "trigger_type": str(data["trigger_type"]), "trigger_type_name": "Alert Group Created", + "preset": None, } assert response.status_code == status.HTTP_201_CREATED @@ -598,7 +611,8 @@ def test_webhook_copy(webhook_internal_api_setup, make_user_auth_headers): data = { "name": "the_webhook", "url": TEST_URL, - "trigger_type": str(Webhook.TRIGGER_ALERT_GROUP_CREATED), + "trigger_type": Webhook.TRIGGER_ALERT_GROUP_CREATED, + "http_method": "POST", "team": None, "password": "secret_password", "authorization_header": "auth 1234", @@ -635,7 +649,9 @@ def test_webhook_copy(webhook_internal_api_setup, make_user_auth_headers): "event_data": "", }, "trigger_template": None, + "trigger_type": str(data["trigger_type"]), "trigger_type_name": "Alert Group Created", + "preset": None, } assert response3.status_code == status.HTTP_201_CREATED @@ -644,3 +660,49 @@ def test_webhook_copy(webhook_internal_api_setup, make_user_auth_headers): assert webhook.authorization_header == data["authorization_header"] assert webhook.id != to_copy["id"] assert webhook.user == user + + +@pytest.mark.django_db +def test_create_invalid_missing_fields(webhook_internal_api_setup, make_user_auth_headers): + user, token, webhook = webhook_internal_api_setup + client = APIClient() + url = reverse("api-internal:webhooks-list") + + data = {"url": TEST_URL, "trigger_type": Webhook.TRIGGER_ALERT_GROUP_CREATED, "http_method": "POST"} + response = client.post(url, data, format="json", **make_user_auth_headers(user, token)) + assert response.status_code == status.HTTP_400_BAD_REQUEST + assert response.json()["name"][0] == "This field is required." + + data = {"name": "test webhook 1", "trigger_type": Webhook.TRIGGER_ALERT_GROUP_CREATED, "http_method": "POST"} + response = client.post(url, data, format="json", **make_user_auth_headers(user, token)) + assert response.status_code == status.HTTP_400_BAD_REQUEST + assert response.json()["url"][0] == "This field is required." + + data = {"name": "test webhook 2", "url": TEST_URL, "http_method": "POST"} + response = client.post(url, data, format="json", **make_user_auth_headers(user, token)) + assert response.status_code == status.HTTP_400_BAD_REQUEST + assert response.json()["trigger_type"][0] == "This field is required." + + data = { + "name": "test webhook 3", + "url": TEST_URL, + "trigger_type": Webhook.TRIGGER_ALERT_GROUP_CREATED, + } + response = client.post(url, data, format="json", **make_user_auth_headers(user, token)) + assert response.status_code == status.HTTP_400_BAD_REQUEST + assert response.json()["http_method"][0] == "This field must be one of ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS']." + + data = { + "name": "test webhook 3", + "url": TEST_URL, + "trigger_type": Webhook.TRIGGER_ALERT_GROUP_CREATED, + "http_method": "TOAST", + } + response = client.post(url, data, format="json", **make_user_auth_headers(user, token)) + assert response.status_code == status.HTTP_400_BAD_REQUEST + assert response.json()["http_method"][0] == "This field must be one of ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS']." + + data = {"name": "test webhook 3", "url": TEST_URL, "trigger_type": 2000000, "http_method": "POST"} + response = client.post(url, data, format="json", **make_user_auth_headers(user, token)) + assert response.status_code == status.HTTP_400_BAD_REQUEST + assert response.json()["trigger_type"][0] == "This field is required." diff --git a/engine/apps/api/views/webhooks.py b/engine/apps/api/views/webhooks.py index f3674cf2..bd7dc8d7 100644 --- a/engine/apps/api/views/webhooks.py +++ b/engine/apps/api/views/webhooks.py @@ -1,4 +1,5 @@ import json +from dataclasses import asdict from django.core.exceptions import ObjectDoesNotExist from django_filters import rest_framework as filters @@ -14,6 +15,7 @@ from apps.api.permissions import RBACPermission from apps.api.serializers.webhook import WebhookResponseSerializer, WebhookSerializer from apps.auth_token.auth import PluginAuthentication from apps.webhooks.models import Webhook, WebhookResponse +from apps.webhooks.presets.preset_options import WebhookPresetOptions from apps.webhooks.utils import apply_jinja_template_for_json from common.api_helpers.exceptions import BadRequest from common.api_helpers.filters import ByTeamModelFieldFilterMixin, ModelFieldFilterMixin, TeamModelMultipleChoiceFilter @@ -52,6 +54,7 @@ class WebhooksView(TeamFilteringMixin, PublicPrimaryKeyMixin, ModelViewSet): "destroy": [RBACPermission.Permissions.OUTGOING_WEBHOOKS_WRITE], "responses": [RBACPermission.Permissions.OUTGOING_WEBHOOKS_READ], "preview_template": [RBACPermission.Permissions.OUTGOING_WEBHOOKS_WRITE], + "preset_options": [RBACPermission.Permissions.OUTGOING_WEBHOOKS_READ], } model = Webhook @@ -179,3 +182,8 @@ class WebhooksView(TeamFilteringMixin, PublicPrimaryKeyMixin, ModelViewSet): response = {"preview": result} return Response(response, status=status.HTTP_200_OK) + + @action(methods=["get"], detail=False) + def preset_options(self, request): + result = [asdict(preset) for preset in WebhookPresetOptions.WEBHOOK_PRESET_CHOICES] + return Response(result) diff --git a/engine/apps/public_api/serializers/webhooks.py b/engine/apps/public_api/serializers/webhooks.py index 70f3d6a4..eb25a53c 100644 --- a/engine/apps/public_api/serializers/webhooks.py +++ b/engine/apps/public_api/serializers/webhooks.py @@ -12,6 +12,8 @@ from common.api_helpers.utils import CurrentOrganizationDefault, CurrentTeamDefa from common.jinja_templater import apply_jinja_template from common.jinja_templater.apply_jinja_template import JinjaTemplateError, JinjaTemplateWarning +PRESET_VALIDATION_MESSAGE = "Preset webhooks must be modified through web UI" + INTEGRATION_FILTER_MESSAGE = "integration_filter must be a list of valid integration ids" @@ -73,6 +75,7 @@ class WebhookCreateSerializer(serializers.ModelSerializer): "http_method", "trigger_type", "integration_filter", + "preset", ] extra_kwargs = { "name": {"required": True, "allow_null": False, "allow_blank": False}, @@ -157,6 +160,14 @@ class WebhookCreateSerializer(serializers.ModelSerializer): raise serializers.ValidationError(INTEGRATION_FILTER_MESSAGE) return integration_filter + def validate_preset(self, preset): + raise serializers.ValidationError(PRESET_VALIDATION_MESSAGE) + + def validate(self, data): + if self.instance and self.instance.preset: + raise serializers.ValidationError(PRESET_VALIDATION_MESSAGE) + return data + class WebhookUpdateSerializer(WebhookCreateSerializer): trigger_type = WebhookTriggerTypeField(required=False) diff --git a/engine/apps/public_api/tests/test_webhooks.py b/engine/apps/public_api/tests/test_webhooks.py index efe7870f..0c9f9b78 100644 --- a/engine/apps/public_api/tests/test_webhooks.py +++ b/engine/apps/public_api/tests/test_webhooks.py @@ -5,7 +5,9 @@ from django.urls import reverse from rest_framework import status from rest_framework.test import APIClient +from apps.public_api.serializers.webhooks import PRESET_VALIDATION_MESSAGE from apps.webhooks.models import Webhook +from apps.webhooks.tests.test_webhook_presets import TEST_WEBHOOK_PRESET_ID def _get_expected_result(webhook): @@ -25,6 +27,7 @@ def _get_expected_result(webhook): "http_method": webhook.http_method, "trigger_type": Webhook.PUBLIC_TRIGGER_TYPES_MAP[webhook.trigger_type], "integration_filter": webhook.integration_filter, + "preset": webhook.preset, } @@ -357,3 +360,70 @@ def test_webhook_validate_integration_filters( assert response.status_code == 200 assert response.data["integration_filter"] == data["integration_filter"] assert webhook.integration_filter == data["integration_filter"] + + +@pytest.mark.django_db +def test_get_webhook_with_preset( + make_organization_and_user_with_token, + make_custom_webhook, + webhook_preset_api_setup, +): + organization, user, token = make_organization_and_user_with_token() + client = APIClient() + + webhook = make_custom_webhook(organization=organization, preset=TEST_WEBHOOK_PRESET_ID) + url = reverse("api-public:webhooks-list") + response = client.get(url, format="json", HTTP_AUTHORIZATION=f"{token}") + + expected_payload = { + "count": 1, + "next": None, + "previous": None, + "results": [_get_expected_result(webhook)], + "current_page_number": 1, + "page_size": 50, + "total_pages": 1, + } + + assert response.status_code == status.HTTP_200_OK + assert response.data == expected_payload + + +@pytest.mark.django_db +def test_webhook_block_preset_create( + make_organization_and_user_with_token, + webhook_preset_api_setup, +): + organization, user, token = make_organization_and_user_with_token() + client = APIClient() + url = reverse("api-public:webhooks-list") + + data = { + "name": "Test outgoing webhook with nested data", + "trigger_type": "acknowledge", + "preset": TEST_WEBHOOK_PRESET_ID, + } + + response = client.post(url, data=data, format="json", HTTP_AUTHORIZATION=f"{token}") + assert response.status_code == status.HTTP_400_BAD_REQUEST + assert response.json()["preset"][0] == PRESET_VALIDATION_MESSAGE + + +@pytest.mark.django_db +def test_webhook_block_preset_update( + make_organization_and_user_with_token, + make_custom_webhook, + webhook_preset_api_setup, +): + organization, user, token = make_organization_and_user_with_token() + client = APIClient() + webhook = make_custom_webhook(organization=organization, preset=TEST_WEBHOOK_PRESET_ID) + webhook.refresh_from_db() + + url = reverse("api-public:webhooks-detail", kwargs={"pk": webhook.public_primary_key}) + data = { + "name": "Test rename preset webhook", + } + response = client.put(url, data=data, format="json", HTTP_AUTHORIZATION=f"{token}") + assert response.status_code == status.HTTP_400_BAD_REQUEST + assert response.json()["non_field_errors"][0] == PRESET_VALIDATION_MESSAGE diff --git a/engine/apps/webhooks/migrations/0011_auto_20230920_1813.py b/engine/apps/webhooks/migrations/0011_auto_20230920_1813.py new file mode 100644 index 00000000..76fbcd5f --- /dev/null +++ b/engine/apps/webhooks/migrations/0011_auto_20230920_1813.py @@ -0,0 +1,23 @@ +# Generated by Django 3.2.20 on 2023-09-20 18:13 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('webhooks', '0010_alter_webhook_trigger_type'), + ] + + operations = [ + migrations.AddField( + model_name='webhook', + name='preset', + field=models.CharField(blank=True, default=None, max_length=100, null=True), + ), + migrations.AlterField( + model_name='webhook', + name='http_method', + field=models.CharField(default='POST', max_length=32, null=True), + ), + ] diff --git a/engine/apps/webhooks/models/webhook.py b/engine/apps/webhooks/models/webhook.py index fb8956af..eee3a367 100644 --- a/engine/apps/webhooks/models/webhook.py +++ b/engine/apps/webhooks/models/webhook.py @@ -94,6 +94,8 @@ class Webhook(models.Model): (TRIGGER_UNACKNOWLEDGE, "Unacknowledged"), ) + ALL_TRIGGER_TYPES = [i[0] for i in TRIGGER_TYPES] + PUBLIC_TRIGGER_TYPES_MAP = { TRIGGER_ESCALATION_STEP: "escalation", TRIGGER_ALERT_GROUP_CREATED: "alert group created", @@ -137,11 +139,12 @@ class Webhook(models.Model): url = models.TextField(null=True, default=None) data = models.TextField(null=True, default=None) forward_all = models.BooleanField(default=True) - http_method = models.CharField(max_length=32, default="POST") + http_method = models.CharField(max_length=32, default="POST", null=True) trigger_type = models.IntegerField(choices=TRIGGER_TYPES, default=TRIGGER_ESCALATION_STEP, null=True) is_webhook_enabled = models.BooleanField(null=True, default=True) integration_filter = models.JSONField(default=None, null=True, blank=True) is_legacy = models.BooleanField(null=True, default=False) + preset = models.CharField(max_length=100, null=True, blank=True, default=None) class Meta: unique_together = ("name", "organization") diff --git a/engine/apps/webhooks/presets/__init__.py b/engine/apps/webhooks/presets/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/engine/apps/webhooks/presets/advanced.py b/engine/apps/webhooks/presets/advanced.py new file mode 100644 index 00000000..1983943e --- /dev/null +++ b/engine/apps/webhooks/presets/advanced.py @@ -0,0 +1,19 @@ +from apps.webhooks.models import Webhook +from apps.webhooks.presets.preset import WebhookPreset, WebhookPresetMetadata + + +class AdvancedWebhookPreset(WebhookPreset): + def _metadata(self) -> WebhookPresetMetadata: + return WebhookPresetMetadata( + id="advanced_webhook", + name="Advanced", + logo="webhook", + description="An advanced webhook with all available settings and template options.", + controlled_fields=[], + ) + + def override_parameters_before_save(self, webhook: Webhook): + pass + + def override_parameters_at_runtime(self, webhook: Webhook): + pass diff --git a/engine/apps/webhooks/presets/preset.py b/engine/apps/webhooks/presets/preset.py new file mode 100644 index 00000000..8e946476 --- /dev/null +++ b/engine/apps/webhooks/presets/preset.py @@ -0,0 +1,36 @@ +from abc import ABC, abstractmethod +from dataclasses import dataclass +from typing import List + +from django.utils.functional import cached_property + +from apps.webhooks.models import Webhook + + +@dataclass +class WebhookPresetMetadata: + id: str + name: str + logo: str + description: str + controlled_fields: List[str] + + +class WebhookPreset(ABC): + @cached_property + def metadata(self) -> WebhookPresetMetadata: + return self._metadata() + + @abstractmethod + def _metadata(self) -> WebhookPresetMetadata: + raise NotImplementedError + + @abstractmethod + def override_parameters_before_save(self, webhook: Webhook): + """Implement this to write parameters before the webhook is saved to the database""" + pass + + @abstractmethod + def override_parameters_at_runtime(self, webhook: Webhook): + """Implement this to write parameters before the webhook is executed (These will not be persisted)""" + pass diff --git a/engine/apps/webhooks/presets/preset_options.py b/engine/apps/webhooks/presets/preset_options.py new file mode 100644 index 00000000..cc267f11 --- /dev/null +++ b/engine/apps/webhooks/presets/preset_options.py @@ -0,0 +1,30 @@ +from importlib import import_module + +from django.conf import settings +from django.db.models.signals import pre_save +from django.dispatch import receiver + +from apps.webhooks.models import Webhook + + +class WebhookPresetOptions: + WEBHOOK_PRESETS = {} + for webhook_preset_config in settings.INSTALLED_WEBHOOK_PRESETS: + module_path, class_name = webhook_preset_config.rsplit(".", 1) + module = import_module(module_path) + preset = getattr(module, class_name)() + WEBHOOK_PRESETS[preset.metadata.id] = preset + + WEBHOOK_PRESET_CHOICES = [webhook_preset.metadata for webhook_preset in WEBHOOK_PRESETS.values()] + + +@receiver(pre_save, sender=Webhook) +def listen_for_webhook_save(sender: Webhook, instance: Webhook, raw: bool, *args, **kwargs) -> None: + if instance.preset: + if instance.preset in WebhookPresetOptions.WEBHOOK_PRESETS: + WebhookPresetOptions.WEBHOOK_PRESETS[instance.preset].override_parameters_before_save(instance) + else: + raise NotImplementedError(f"Webhook references unknown preset implementation {instance.preset}") + + +pre_save.connect(listen_for_webhook_save, Webhook) diff --git a/engine/apps/webhooks/presets/simple.py b/engine/apps/webhooks/presets/simple.py new file mode 100644 index 00000000..dc1db970 --- /dev/null +++ b/engine/apps/webhooks/presets/simple.py @@ -0,0 +1,32 @@ +from apps.webhooks.models import Webhook +from apps.webhooks.presets.preset import WebhookPreset, WebhookPresetMetadata + + +class SimpleWebhookPreset(WebhookPreset): + def _metadata(self) -> WebhookPresetMetadata: + return WebhookPresetMetadata( + id="simple_webhook", + name="Simple", + logo="webhook", + description="A simple webhook which sends the alert group data to a given URL. Triggered as an escalation step.", + controlled_fields=[ + "trigger_type", + "http_method", + "integration_filter", + "headers", + "username", + "password", + "authorization_header", + "trigger_template", + "forward_all", + "data", + ], + ) + + def override_parameters_before_save(self, webhook: Webhook): + webhook.http_method = "POST" + webhook.trigger_type = Webhook.TRIGGER_ESCALATION_STEP + webhook.forward_all = True + + def override_parameters_at_runtime(self, webhook: Webhook): + pass diff --git a/engine/apps/webhooks/tasks/trigger_webhook.py b/engine/apps/webhooks/tasks/trigger_webhook.py index 579624bf..68bfdf22 100644 --- a/engine/apps/webhooks/tasks/trigger_webhook.py +++ b/engine/apps/webhooks/tasks/trigger_webhook.py @@ -11,6 +11,7 @@ from apps.base.models import UserNotificationPolicyLogRecord from apps.user_management.models import User from apps.webhooks.models import Webhook, WebhookResponse from apps.webhooks.models.webhook import WEBHOOK_FIELD_PLACEHOLDER +from apps.webhooks.presets.preset_options import WebhookPresetOptions from apps.webhooks.utils import ( InvalidWebhookData, InvalidWebhookHeaders, @@ -116,6 +117,12 @@ def make_request(webhook, alert_group, data): exception = error = None try: + if webhook.preset: + if webhook.preset not in WebhookPresetOptions.WEBHOOK_PRESETS: + raise Exception(f"Invalid preset {webhook.preset}") + else: + WebhookPresetOptions.WEBHOOK_PRESETS[webhook.preset].override_parameters_at_runtime(webhook) + if not webhook.check_integration_filter(alert_group): status["request_trigger"] = NOT_FROM_SELECTED_INTEGRATION return False, status, None, None @@ -168,7 +175,7 @@ def execute_webhook(webhook_pk, alert_group_id, user_id, escalation_policy_id): try: webhook = Webhook.objects.get(pk=webhook_pk) except Webhook.DoesNotExist: - logger.warn(f"Webhook {webhook_pk} does not exist") + logger.warning(f"Webhook {webhook_pk} does not exist") return try: diff --git a/engine/apps/webhooks/tests/test_webhook_presets.py b/engine/apps/webhooks/tests/test_webhook_presets.py new file mode 100644 index 00000000..70c95151 --- /dev/null +++ b/engine/apps/webhooks/tests/test_webhook_presets.py @@ -0,0 +1,160 @@ +from unittest.mock import patch + +import pytest + +from apps.webhooks.models import Webhook +from apps.webhooks.presets.preset import WebhookPreset, WebhookPresetMetadata +from apps.webhooks.tasks.trigger_webhook import make_request +from apps.webhooks.tests.test_trigger_webhook import MockResponse + +TEST_WEBHOOK_PRESET_URL = "https://test123.com" +TEST_WEBHOOK_PRESET_NAME = "Test Webhook" +TEST_WEBHOOK_PRESET_ID = "test_webhook" +TEST_WEBHOOK_LOGO = "test_logo" +TEST_WEBHOOK_PRESET_DESCRIPTION = "Description of test webhook preset" +TEST_WEBHOOK_PRESET_CONTROLLED_FIELDS = ["url", "http_method", "data", "authorization_header"] +TEST_WEBHOOK_AUTHORIZATION_HEADER = "Test Auth header 12345" +INVALID_PRESET_ID = "invalid_preset_id" + + +class TestWebhookPreset(WebhookPreset): + def _metadata(self) -> WebhookPresetMetadata: + return WebhookPresetMetadata( + id=TEST_WEBHOOK_PRESET_ID, + name=TEST_WEBHOOK_PRESET_NAME, + logo=TEST_WEBHOOK_LOGO, + description=TEST_WEBHOOK_PRESET_DESCRIPTION, + controlled_fields=TEST_WEBHOOK_PRESET_CONTROLLED_FIELDS, + ) + + def override_parameters_before_save(self, webhook: Webhook): + webhook.data = webhook.organization.org_title + webhook.url = TEST_WEBHOOK_PRESET_URL + webhook.http_method = "GET" + + def override_parameters_at_runtime(self, webhook: Webhook): + webhook.authorization_header = TEST_WEBHOOK_AUTHORIZATION_HEADER + + +@pytest.mark.django_db +def test_create_webhook_from_preset(make_organization, webhook_preset_api_setup, make_custom_webhook): + organization = make_organization() + webhook = make_custom_webhook( + name="the_webhook", + organization=organization, + trigger_type=Webhook.TRIGGER_ALERT_GROUP_CREATED, + preset=TEST_WEBHOOK_PRESET_ID, + ) + + webhook.refresh_from_db() + assert webhook.url == TEST_WEBHOOK_PRESET_URL + assert webhook.http_method == "GET" + assert webhook.data == organization.org_title + assert webhook.authorization_header is None + + +@pytest.mark.django_db +def test_create_webhook_from_invalid_preset(make_organization, webhook_preset_api_setup, make_custom_webhook): + organization = make_organization() + expected = None + try: + make_custom_webhook( + name="the_webhook", + organization=organization, + trigger_type=Webhook.TRIGGER_ALERT_GROUP_CREATED, + preset=INVALID_PRESET_ID, + ) + except NotImplementedError as e: + expected = e + + assert expected.args[0] == f"Webhook references unknown preset implementation {INVALID_PRESET_ID}" + + +@pytest.mark.django_db +def test_update_webhook_from_preset(make_organization, webhook_preset_api_setup, make_custom_webhook): + organization = make_organization() + webhook = make_custom_webhook( + name="the_webhook", + organization=organization, + trigger_type=Webhook.TRIGGER_ALERT_GROUP_CREATED, + preset=TEST_WEBHOOK_PRESET_ID, + ) + + webhook.refresh_from_db() + webhook.http_method = "POST" + webhook.save() + + webhook.refresh_from_db() + assert webhook.http_method == "GET" + + +@pytest.mark.django_db +def test_update_webhook_from_invalid_preset(make_organization, webhook_preset_api_setup, make_custom_webhook): + organization = make_organization() + webhook = make_custom_webhook( + name="the_webhook", + organization=organization, + trigger_type=Webhook.TRIGGER_ALERT_GROUP_CREATED, + preset=TEST_WEBHOOK_PRESET_ID, + ) + webhook.refresh_from_db() + webhook.preset = INVALID_PRESET_ID + + try: + webhook.save() + except NotImplementedError as e: + expected = e + + assert expected.args[0] == f"Webhook references unknown preset implementation {INVALID_PRESET_ID}" + + webhook.refresh_from_db() + assert webhook.preset == TEST_WEBHOOK_PRESET_ID + + +@pytest.mark.django_db +def test_webhook_preset_runtime_override(make_organization, webhook_preset_api_setup, make_custom_webhook): + organization = make_organization() + webhook = make_custom_webhook( + name="the_webhook", + organization=organization, + trigger_type=Webhook.TRIGGER_ALERT_GROUP_CREATED, + preset=TEST_WEBHOOK_PRESET_ID, + ) + + with patch.object(webhook, "build_url"): + response = MockResponse() + with patch.object(webhook, "make_request", return_value=response) as mock_make_request: + triggered, webhook_status, error, exception = make_request(webhook, None, None) + assert mock_make_request.call_args.args[1]["headers"]["Authorization"] == TEST_WEBHOOK_AUTHORIZATION_HEADER + assert triggered + assert error is None + assert exception is None + + webhook.refresh_from_db() + assert webhook.authorization_header is None + + +@pytest.mark.django_db +def test_webhook_invalid_preset_runtime_override(make_organization, webhook_preset_api_setup, make_custom_webhook): + organization = make_organization() + webhook = make_custom_webhook( + name="the_webhook", + organization=organization, + trigger_type=Webhook.TRIGGER_ALERT_GROUP_CREATED, + ) + webhook.refresh_from_db() + + expected_error = f"Invalid preset {INVALID_PRESET_ID}" + Webhook.objects.filter(id=webhook.id).update(preset=INVALID_PRESET_ID) + webhook.refresh_from_db() + with patch.object(webhook, "build_url"): + with patch.object(webhook, "make_request") as mock_make_request: + triggered, webhook_status, error, exception = make_request(webhook, None, None) + mock_make_request.assert_not_called() + assert triggered + assert webhook_status["content"] == expected_error + assert error == expected_error + assert exception.args[0] == expected_error + + webhook.refresh_from_db() + assert webhook.authorization_header is None diff --git a/engine/conftest.py b/engine/conftest.py index aca32ffe..270b1ec2 100644 --- a/engine/conftest.py +++ b/engine/conftest.py @@ -86,7 +86,9 @@ from apps.telegram.tests.factories import ( ) from apps.user_management.models.user import User, listen_for_user_model_save from apps.user_management.tests.factories import OrganizationFactory, RegionFactory, TeamFactory, UserFactory +from apps.webhooks.presets.preset_options import WebhookPresetOptions from apps.webhooks.tests.factories import CustomWebhookFactory, WebhookResponseFactory +from apps.webhooks.tests.test_webhook_presets import TEST_WEBHOOK_PRESET_ID, TestWebhookPreset register(OrganizationFactory) register(UserFactory) @@ -907,3 +909,11 @@ def shift_swap_request_setup( return ssr, beneficiary, benefactor return _shift_swap_request_setup + + +@pytest.fixture() +def webhook_preset_api_setup(): + WebhookPresetOptions.WEBHOOK_PRESETS = {TEST_WEBHOOK_PRESET_ID: TestWebhookPreset()} + WebhookPresetOptions.WEBHOOK_PRESET_CHOICES = [ + preset.metadata for preset in WebhookPresetOptions.WEBHOOK_PRESETS.values() + ] diff --git a/engine/settings/base.py b/engine/settings/base.py index 3c1d1694..b8023ca9 100644 --- a/engine/settings/base.py +++ b/engine/settings/base.py @@ -723,6 +723,11 @@ INSTALLED_ONCALL_INTEGRATIONS = [ "config_integrations.direct_paging", ] +INSTALLED_WEBHOOK_PRESETS = [ + "apps.webhooks.presets.simple.SimpleWebhookPreset", + "apps.webhooks.presets.advanced.AdvancedWebhookPreset", +] + if IS_OPEN_SOURCE: INSTALLED_APPS += ["apps.oss_installation", "apps.zvonok"] # noqa diff --git a/grafana-plugin/src/containers/OutgoingWebhookForm/CommonWebhookPresetIcons.config.tsx b/grafana-plugin/src/containers/OutgoingWebhookForm/CommonWebhookPresetIcons.config.tsx new file mode 100644 index 00000000..5ce2cc07 --- /dev/null +++ b/grafana-plugin/src/containers/OutgoingWebhookForm/CommonWebhookPresetIcons.config.tsx @@ -0,0 +1,3 @@ +import { ReactElement } from 'react'; + +export const commonWebhookPresetIconsConfig: { [id: string]: () => ReactElement } = {}; diff --git a/grafana-plugin/src/containers/OutgoingWebhookForm/OutgoingWebhookForm.config.tsx b/grafana-plugin/src/containers/OutgoingWebhookForm/OutgoingWebhookForm.config.tsx index 78fab274..d299ca94 100644 --- a/grafana-plugin/src/containers/OutgoingWebhookForm/OutgoingWebhookForm.config.tsx +++ b/grafana-plugin/src/containers/OutgoingWebhookForm/OutgoingWebhookForm.config.tsx @@ -4,6 +4,7 @@ import { SelectableValue } from '@grafana/data'; import Emoji from 'react-emoji-render'; import { FormItem, FormItemType } from 'components/GForm/GForm.types'; +import { OutgoingWebhookPreset } from 'models/outgoing_webhook/outgoing_webhook.types'; import { KeyValuePair } from 'utils'; import { generateAssignToTeamInputDescription } from 'utils/consts'; @@ -18,182 +19,226 @@ export const WebhookTriggerType = { Unacknowledged: new KeyValuePair('7', 'Unacknowledged'), }; -export const form: { name: string; fields: FormItem[] } = { - name: 'OutgoingWebhook', - fields: [ - { - name: 'name', - type: FormItemType.Input, - validation: { required: true }, - }, - { - name: 'is_webhook_enabled', - label: 'Enabled', - normalize: (value) => Boolean(value), - type: FormItemType.Switch, - }, - { - name: 'team', - label: 'Assign to Team', - description: `${generateAssignToTeamInputDescription( - 'Outgoing Webhooks' - )} This setting does not effect execution of the webhook.`, - type: FormItemType.GSelect, - extra: { - modelName: 'grafanaTeamStore', - displayField: 'name', - valueField: 'id', - showSearch: true, - allowClear: true, +export function createForm(presets: OutgoingWebhookPreset[]): { name: string; fields: FormItem[] } { + return { + name: 'OutgoingWebhook', + fields: [ + { + name: 'name', + type: FormItemType.Input, + validation: { required: true }, }, - }, - { - name: 'trigger_type', - label: 'Trigger Type', - description: 'The type of event which will cause this webhook to execute.', - type: FormItemType.Select, - extra: { - options: [ - { - value: WebhookTriggerType.EscalationStep.key, - label: WebhookTriggerType.EscalationStep.value, - }, - { - value: WebhookTriggerType.AlertGroupCreated.key, - label: WebhookTriggerType.AlertGroupCreated.value, - }, - { - value: WebhookTriggerType.Acknowledged.key, - label: WebhookTriggerType.Acknowledged.value, - }, - { - value: WebhookTriggerType.Resolved.key, - label: WebhookTriggerType.Resolved.value, - }, - { - value: WebhookTriggerType.Silenced.key, - label: WebhookTriggerType.Silenced.value, - }, - { - value: WebhookTriggerType.Unsilenced.key, - label: WebhookTriggerType.Unsilenced.value, - }, - { - value: WebhookTriggerType.Unresolved.key, - label: WebhookTriggerType.Unresolved.value, - }, - { - value: WebhookTriggerType.Unacknowledged.key, - label: WebhookTriggerType.Unacknowledged.value, - }, - ], + { + name: 'is_webhook_enabled', + label: 'Enabled', + normalize: (value) => Boolean(value), + type: FormItemType.Switch, }, - validation: { required: true }, - normalize: (value) => value, - }, - { - 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: 'team', + label: 'Assign to Team', + description: `${generateAssignToTeamInputDescription( + 'Outgoing Webhooks' + )} This setting does not effect execution of the webhook.`, + type: FormItemType.GSelect, + extra: { + modelName: 'grafanaTeamStore', + displayField: 'name', + valueField: 'id', + showSearch: true, + allowClear: true, + placeholder: 'Choose (Optional)', + }, }, - validation: { required: true }, - normalize: (value) => value, - }, - { - name: 'integration_filter', - label: 'Integrations', - type: FormItemType.MultiSelect, - isVisible: (data) => { - return data.trigger_type !== WebhookTriggerType.EscalationStep.key; + { + name: 'trigger_type', + label: 'Trigger Type', + description: 'The type of event which will cause this webhook to execute.', + type: FormItemType.Select, + extra: { + placeholder: 'Choose (Required)', + options: [ + { + value: WebhookTriggerType.EscalationStep.key, + label: WebhookTriggerType.EscalationStep.value, + }, + { + value: WebhookTriggerType.AlertGroupCreated.key, + label: WebhookTriggerType.AlertGroupCreated.value, + }, + { + value: WebhookTriggerType.Acknowledged.key, + label: WebhookTriggerType.Acknowledged.value, + }, + { + value: WebhookTriggerType.Resolved.key, + label: WebhookTriggerType.Resolved.value, + }, + { + value: WebhookTriggerType.Silenced.key, + label: WebhookTriggerType.Silenced.value, + }, + { + value: WebhookTriggerType.Unsilenced.key, + label: WebhookTriggerType.Unsilenced.value, + }, + { + value: WebhookTriggerType.Unresolved.key, + label: WebhookTriggerType.Unresolved.value, + }, + { + value: WebhookTriggerType.Unacknowledged.key, + label: WebhookTriggerType.Unacknowledged.value, + }, + ], + }, + isVisible: (data) => { + return isPresetFieldVisible(data.preset, presets, 'trigger_type'); + }, + normalize: (value) => value, }, - extra: { - modelName: 'alertReceiveChannelStore', - displayField: 'verbal_name', - valueField: 'id', - showSearch: true, - getOptionLabel: (item: SelectableValue) => , + { + name: 'http_method', + label: 'HTTP Method', + type: FormItemType.Select, + extra: { + placeholder: 'Choose (Required)', + options: [ + { + value: 'GET', + label: 'GET', + }, + { + value: 'POST', + label: 'POST', + }, + { + value: 'PUT', + label: 'PUT', + }, + { + value: 'DELETE', + label: 'DELETE', + }, + { + value: 'OPTIONS', + label: 'OPTIONS', + }, + ], + }, + isVisible: (data) => isPresetFieldVisible(data.preset, presets, 'http_method'), + normalize: (value) => value, }, - validation: { required: true }, - description: - 'Integrations that this webhook applies to. If this is empty the webhook will execute for all integrations', - }, - { - name: 'url', - label: 'Webhook URL', - type: FormItemType.Monaco, - validation: { required: true }, - extra: { - height: 30, + { + name: 'integration_filter', + label: 'Integrations', + type: FormItemType.MultiSelect, + isVisible: (data) => { + return ( + isPresetFieldVisible(data.preset, presets, 'integration_filter') && + data.trigger_type !== WebhookTriggerType.EscalationStep.key + ); + }, + extra: { + placeholder: 'Choose (Optional)', + modelName: 'alertReceiveChannelStore', + displayField: 'verbal_name', + valueField: 'id', + showSearch: true, + getOptionLabel: (item: SelectableValue) => , + }, + description: + 'Integrations that this webhook applies to. If this is empty the webhook will execute for all integrations', }, - }, - { - name: 'headers', - label: 'Webhook Headers', - description: 'Request headers should be in JSON format.', - type: FormItemType.Monaco, - extra: { - rows: 3, + { + name: 'url', + label: 'Webhook URL', + type: FormItemType.Monaco, + extra: { + height: 30, + }, + isVisible: (data) => { + return isPresetFieldVisible(data.preset, presets, 'url'); + }, }, - }, - { - name: 'username', - type: FormItemType.Input, - }, - { - name: 'password', - type: FormItemType.Password, - }, - { - name: 'authorization_header', - description: - 'Value of the Authorization header, do not need to prefix with "Authorization:". For example: Bearer AbCdEf123456', - type: FormItemType.Password, - }, - { - name: 'trigger_template', - type: FormItemType.Monaco, - description: - 'Trigger template is used to conditionally execute the webhook based on incoming data. The trigger template must be empty or evaluate to true or 1 for the webhook to be sent', - extra: { - rows: 2, + { + name: 'headers', + label: 'Webhook Headers', + description: 'Request headers should be in JSON format.', + type: FormItemType.Monaco, + extra: { + rows: 3, + }, + isVisible: (data) => { + return isPresetFieldVisible(data.preset, presets, 'headers'); + }, }, - }, - { - name: 'forward_all', - normalize: (value) => Boolean(value), - type: FormItemType.Switch, - description: "Forwards whole payload of the alert group and context data to the webhook's url as POST/PUT data", - }, - { - name: 'data', - getDisabled: (data) => Boolean(data?.forward_all), - type: FormItemType.Monaco, - description: - 'Available variables: {{ event }}, {{ user }}, {{ alert_group }}, {{ alert_group_id }}, {{ alert_payload }}, {{ integration }}, {{ notified_users }}, {{ users_to_be_notified }}, {{ responses }}', - extra: {}, - }, - ], -}; + { + name: 'username', + type: FormItemType.Input, + isVisible: (data) => { + return isPresetFieldVisible(data.preset, presets, 'username'); + }, + }, + { + name: 'password', + type: FormItemType.Password, + isVisible: (data) => { + return isPresetFieldVisible(data.preset, presets, 'password'); + }, + }, + { + name: 'authorization_header', + description: + 'Value of the Authorization header, do not need to prefix with "Authorization:". For example: Bearer AbCdEf123456', + type: FormItemType.Password, + isVisible: (data) => { + return isPresetFieldVisible(data.preset, presets, 'authorization_header'); + }, + }, + { + name: 'trigger_template', + type: FormItemType.Monaco, + description: + 'Trigger template is used to conditionally execute the webhook based on incoming data. The trigger template must be empty or evaluate to true or 1 for the webhook to be sent', + extra: { + rows: 2, + }, + isVisible: (data) => { + return isPresetFieldVisible(data.preset, presets, 'trigger_template'); + }, + }, + { + name: 'forward_all', + normalize: (value) => (value ? Boolean(value) : value), + type: FormItemType.Switch, + description: "Forwards whole payload of the alert group and context data to the webhook's url as POST/PUT data", + isVisible: (data) => { + return isPresetFieldVisible(data.preset, presets, 'forward_all'); + }, + }, + { + name: 'data', + getDisabled: (data) => Boolean(data?.forward_all), + type: FormItemType.Monaco, + description: + 'Available variables: {{ event }}, {{ user }}, {{ alert_group }}, {{ alert_group_id }}, {{ alert_payload }}, {{ integration }}, {{ notified_users }}, {{ users_to_be_notified }}, {{ responses }}', + extra: {}, + isVisible: (data) => { + return isPresetFieldVisible(data.preset, presets, 'data'); + }, + }, + ], + }; +} + +function isPresetFieldVisible(presetId: string, presets: OutgoingWebhookPreset[], fieldName: string) { + if (presetId == null) { + return true; + } + const selectedPreset = presets.find((item) => item.id === presetId); + if (selectedPreset && selectedPreset.controlled_fields.includes(fieldName)) { + return false; + } + return true; +} diff --git a/grafana-plugin/src/containers/OutgoingWebhookForm/OutgoingWebhookForm.module.css b/grafana-plugin/src/containers/OutgoingWebhookForm/OutgoingWebhookForm.module.css index a4613c64..3a41fb24 100644 --- a/grafana-plugin/src/containers/OutgoingWebhookForm/OutgoingWebhookForm.module.css +++ b/grafana-plugin/src/containers/OutgoingWebhookForm/OutgoingWebhookForm.module.css @@ -3,7 +3,7 @@ } .title { - margin: 16px 0 0 16px; + margin: 0 0 0 16px; } .content { @@ -28,3 +28,31 @@ .webhooks__drawerContent .cursor.monaco-mouse-cursor-text { display: none !important; } + +.cards { + display: flex; + flex-wrap: wrap; + gap: 24px; + overflow: auto; + scroll-snap-type: y mandatory; + width: 100%; +} + +.card { + width: 100%; + height: 106px; + scroll-snap-align: start; + scroll-snap-stop: normal; + display: flex; + flex-direction: row; + align-items: center; + justify-content: flex-start; + cursor: pointer; + position: relative; + gap: 20px; +} + +.search-integration { + width: 100%; + margin-bottom: 24px; +} diff --git a/grafana-plugin/src/containers/OutgoingWebhookForm/OutgoingWebhookForm.tsx b/grafana-plugin/src/containers/OutgoingWebhookForm/OutgoingWebhookForm.tsx index a006e5d1..61bfc8fe 100644 --- a/grafana-plugin/src/containers/OutgoingWebhookForm/OutgoingWebhookForm.tsx +++ b/grafana-plugin/src/containers/OutgoingWebhookForm/OutgoingWebhookForm.tsx @@ -1,24 +1,39 @@ -import React, { useCallback, useState } from 'react'; +import React, { ChangeEvent, useCallback, useState } from 'react'; -import { Button, ConfirmModal, ConfirmModalProps, Drawer, HorizontalGroup, Tab, TabsBar } from '@grafana/ui'; +import { + Button, + ConfirmModal, + ConfirmModalProps, + Drawer, + EmptySearchResult, + HorizontalGroup, + Input, + Tab, + TabsBar, + VerticalGroup, +} from '@grafana/ui'; import cn from 'classnames/bind'; import { observer } from 'mobx-react'; import { useHistory } from 'react-router-dom'; +import Block from 'components/GBlock/Block'; import GForm from 'components/GForm/GForm'; import { FormItem, FormItemType } from 'components/GForm/GForm.types'; +import IntegrationLogo from 'components/IntegrationLogo/IntegrationLogo'; +import { logoCoors } from 'components/IntegrationLogo/IntegrationLogo.config'; import Text from 'components/Text/Text'; +import { webhookPresetIcons } from 'containers/OutgoingWebhookForm/WebhookPresetIcons.config'; import OutgoingWebhookStatus from 'containers/OutgoingWebhookStatus/OutgoingWebhookStatus'; import WebhooksTemplateEditor from 'containers/WebhooksTemplateEditor/WebhooksTemplateEditor'; import { WithPermissionControlTooltip } from 'containers/WithPermissionControl/WithPermissionControlTooltip'; -import { OutgoingWebhook } from 'models/outgoing_webhook/outgoing_webhook.types'; +import { OutgoingWebhook, OutgoingWebhookPreset } from 'models/outgoing_webhook/outgoing_webhook.types'; import { WebhookFormActionType } from 'pages/outgoing_webhooks/OutgoingWebhooks.types'; import { useStore } from 'state/useStore'; import { KeyValuePair } from 'utils'; import { UserActions } from 'utils/authorization'; import { PLUGIN_ROOT } from 'utils/consts'; -import { form } from './OutgoingWebhookForm.config'; +import { createForm } from './OutgoingWebhookForm.config'; import styles from 'containers/OutgoingWebhookForm/OutgoingWebhookForm.module.css'; @@ -45,10 +60,15 @@ const OutgoingWebhookForm = observer((props: OutgoingWebhookFormProps) => { const [activeTab, setActiveTab] = useState( action === WebhookFormActionType.EDIT_SETTINGS ? WebhookTabs.Settings.key : WebhookTabs.LastRun.key ); + const [showPresetsListDrawer, setShowPresetsListDrawer] = useState(id === 'new'); + const [showCreateWebhookDrawer, setShowCreateWebhookDrawer] = useState(false); + const [selectedPreset, setSelectedPreset] = useState(undefined); + const [filterValue, setFilterValue] = useState(''); const { outgoingWebhookStore } = useStore(); const isNew = action === WebhookFormActionType.NEW; const isNewOrCopy = isNew || action === WebhookFormActionType.COPY; + const form = createForm(outgoingWebhookStore.outgoingWebhookPresets); const handleSubmit = useCallback( (data: Partial) => { @@ -104,10 +124,17 @@ const OutgoingWebhookForm = observer((props: OutgoingWebhookFormProps) => { | { is_webhook_enabled: boolean; is_legacy: boolean; + preset: string; }; if (isNew) { - data = { is_webhook_enabled: true, is_legacy: false }; + data = { + is_webhook_enabled: true, + is_legacy: false, + preset: selectedPreset?.id, + trigger_type: null, + http_method: 'POST', + }; } else if (isNewOrCopy) { data = { ...outgoingWebhookStore.items[id], is_legacy: false, name: '' }; } else { @@ -123,27 +150,69 @@ const OutgoingWebhookForm = observer((props: OutgoingWebhookFormProps) => { } const formElement = ; + const createWebhookParameters = ( + <> + +
{renderWebhookForm()}
+
+ {templateToEdit && ( + { + onFormChangeFn?.fn(value); + setTemplateToEdit(undefined); + }} + onHide={() => setTemplateToEdit(undefined)} + template={templateToEdit} + /> + )} + + ); - if (action === WebhookFormActionType.NEW || action === WebhookFormActionType.COPY) { - // show just the creation form, not the tabs + const presets = outgoingWebhookStore.outgoingWebhookPresets.filter((preset: OutgoingWebhookPreset) => + preset.name.toLowerCase().includes(filterValue.toLowerCase()) + ); + + if (action === WebhookFormActionType.NEW) { return ( <> - -
{renderWebhookForm()}
-
- {templateToEdit && ( - { - onFormChangeFn?.fn(value); - setTemplateToEdit(undefined); - }} - onHide={() => setTemplateToEdit(undefined)} - template={templateToEdit} - /> + {showPresetsListDrawer && ( + +
+ + + Outgoing webhooks can send alert data to other systems. They can be triggered by various conditions + and can use templates to transform data to fit the recipient system. Presets listed below provide a + starting point to customize these connections. + + + {presets.length > 8 && ( +
+ ) => setFilterValue(e.currentTarget.value)} + /> +
+ )} + + +
+
+
)} + {(showCreateWebhookDrawer || !showPresetsListDrawer) && createWebhookParameters} ); + } else if (action === WebhookFormActionType.COPY) { + return createWebhookParameters; } return ( @@ -200,6 +269,12 @@ const OutgoingWebhookForm = observer((props: OutgoingWebhookFormProps) => { ); + function onBlockClick(preset: OutgoingWebhookPreset) { + setSelectedPreset(preset); + setShowCreateWebhookDrawer(true); + setShowPresetsListDrawer(false); + } + function renderWebhookForm() { return ( <> @@ -207,9 +282,21 @@ const OutgoingWebhookForm = observer((props: OutgoingWebhookFormProps) => {
- + {id === 'new' ? ( + + ) : ( + + )} diff --git a/grafana-plugin/src/state/rootBaseStore/index.ts b/grafana-plugin/src/state/rootBaseStore/index.ts index 086e9402..fa9c8c72 100644 --- a/grafana-plugin/src/state/rootBaseStore/index.ts +++ b/grafana-plugin/src/state/rootBaseStore/index.ts @@ -130,6 +130,7 @@ export class RootBaseStore { this.userStore.updateNotificationPolicyOptions(), this.userStore.updateNotifyByOptions(), this.alertReceiveChannelStore.updateAlertReceiveChannelOptions(), + this.outgoingWebhookStore.updateOutgoingWebhookPresets(), this.escalationPolicyStore.updateWebEscalationPolicyOptions(), this.escalationPolicyStore.updateEscalationPolicyOptions(), this.escalationPolicyStore.updateNumMinutesInWindowOptions(),