From 572b33623e569185649997f219d360d4b705961d Mon Sep 17 00:00:00 2001 From: Michael Derynck Date: Thu, 7 Sep 2023 13:49:33 -0600 Subject: [PATCH 01/20] WIP add presets for webhooks --- engine/apps/api/tests/test_webhook_presets.py | 58 +++++++++++++++++++ engine/apps/api/views/webhooks.py | 27 +++++++++ engine/apps/webhooks/models/webhook.py | 1 + engine/apps/webhooks/presets/__init__.py | 0 .../apps/webhooks/presets/custom_webhook.py | 19 ++++++ .../apps/webhooks/presets/preset_options.py | 23 ++++++++ engine/settings/base.py | 4 ++ 7 files changed, 132 insertions(+) create mode 100644 engine/apps/api/tests/test_webhook_presets.py create mode 100644 engine/apps/webhooks/presets/__init__.py create mode 100644 engine/apps/webhooks/presets/custom_webhook.py create mode 100644 engine/apps/webhooks/presets/preset_options.py 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 0000000000..ceb3f4801b --- /dev/null +++ b/engine/apps/api/tests/test_webhook_presets.py @@ -0,0 +1,58 @@ +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.presets.preset_options import WebhookPresetOptions + +TEST_WEBHOOK_PRESET_FACTORY_NAME = "test webhook preset instance" +TEST_WEBHOOK_PRESET_NAME = "Test Webhook" +TEST_WEBHOOK_PRESET_ID = "test_webhook" + + +def webhook_preset_factory(organization): + webhook = Webhook() + webhook.name = TEST_WEBHOOK_PRESET_FACTORY_NAME + webhook.data = organization.org_title + return webhook + + +@pytest.fixture() +def webhook_preset_api_setup(make_organization_and_user_with_plugin_token, make_custom_webhook): + organization, user, token = make_organization_and_user_with_plugin_token() + WebhookPresetOptions.WEBHOOK_PRESET_CHOICES = ((TEST_WEBHOOK_PRESET_ID, TEST_WEBHOOK_PRESET_NAME),) + WebhookPresetOptions.WEBHOOK_PRESET_FACTORIES[TEST_WEBHOOK_PRESET_ID] = webhook_preset_factory + return user, token, organization + + +@pytest.mark.django_db +def test_get_webhook_preset_options(webhook_preset_api_setup, make_user_auth_headers): + user, token, organization = webhook_preset_api_setup + 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]["value"] == TEST_WEBHOOK_PRESET_ID + assert response.data[0]["display_name"] == TEST_WEBHOOK_PRESET_NAME + + +@pytest.mark.django_db +def test_get_webhook_preset_from_preset(webhook_preset_api_setup, make_user_auth_headers): + user, token, organization = webhook_preset_api_setup + client = APIClient() + url = reverse("api-internal:webhooks-preset") + + response = client.get(f"{url}", format="json", **make_user_auth_headers(user, token)) + assert response.status_code == status.HTTP_400_BAD_REQUEST + + response = client.get(f"{url}?id=nonexistent", format="json", **make_user_auth_headers(user, token)) + assert response.status_code == status.HTTP_404_NOT_FOUND + + response = client.get(f"{url}?id={TEST_WEBHOOK_PRESET_ID}", format="json", **make_user_auth_headers(user, token)) + + assert response.status_code == status.HTTP_200_OK + assert response.data["name"] == TEST_WEBHOOK_PRESET_FACTORY_NAME + assert response.data["data"] == organization.org_title diff --git a/engine/apps/api/views/webhooks.py b/engine/apps/api/views/webhooks.py index f3674cf2ce..61f2380d50 100644 --- a/engine/apps/api/views/webhooks.py +++ b/engine/apps/api/views/webhooks.py @@ -14,6 +14,7 @@ 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 +53,8 @@ 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], + "preset": [RBACPermission.Permissions.OUTGOING_WEBHOOKS_WRITE], } model = Webhook @@ -179,3 +182,27 @@ def preview_template(self, request, pk): response = {"preview": result} return Response(response, status=status.HTTP_200_OK) + + @action(methods=["get"], detail=False) + def preset_options(self, request): + choices = [] + for webhook_preset_id, webhook_preset_title in WebhookPresetOptions.WEBHOOK_PRESET_CHOICES: + choices.append( + { + "value": webhook_preset_id, + "display_name": webhook_preset_title, + } + ) + return Response(choices) + + @action(methods=["get"], detail=False) + def preset(self, request): + preset_id = request.query_params.get("id", None) + if not preset_id: + raise BadRequest(detail="id query parameter is required") + if preset_id not in WebhookPresetOptions.WEBHOOK_PRESET_FACTORIES: + raise NotFound + + webhook = WebhookPresetOptions.WEBHOOK_PRESET_FACTORIES[preset_id](self.request.auth.organization) + serializer = WebhookSerializer(webhook) + return Response(serializer.data) diff --git a/engine/apps/webhooks/models/webhook.py b/engine/apps/webhooks/models/webhook.py index fb8956afc8..4228380467 100644 --- a/engine/apps/webhooks/models/webhook.py +++ b/engine/apps/webhooks/models/webhook.py @@ -142,6 +142,7 @@ class Webhook(models.Model): 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, 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 0000000000..e69de29bb2 diff --git a/engine/apps/webhooks/presets/custom_webhook.py b/engine/apps/webhooks/presets/custom_webhook.py new file mode 100644 index 0000000000..6a7b0fbb1a --- /dev/null +++ b/engine/apps/webhooks/presets/custom_webhook.py @@ -0,0 +1,19 @@ +from apps.webhooks.models import Webhook + +id = "custom_webhook" +title = "Custom Webhook" + + +def create_webhook(organization): + webhook = Webhook() + webhook.http_method = "POST" + webhook.trigger_type = Webhook.TRIGGER_ESCALATION_STEP + return webhook + + +def validate(organization, webhook): + pass + + +def post_process(organization, webhook): + pass diff --git a/engine/apps/webhooks/presets/preset_options.py b/engine/apps/webhooks/presets/preset_options.py new file mode 100644 index 0000000000..97ea316153 --- /dev/null +++ b/engine/apps/webhooks/presets/preset_options.py @@ -0,0 +1,23 @@ +import importlib + +from django.conf import settings + + +class WebhookPresetOptions: + _config = tuple( + (importlib.import_module(webhook_preset_config) for webhook_preset_config in settings.INSTALLED_WEBHOOK_PRESETS) + ) + + WEBHOOK_PRESET_CHOICES = tuple( + ( + ( + webhook_preset.id, + webhook_preset.title, + ) + for webhook_preset in _config + ) + ) + + WEBHOOK_PRESET_FACTORIES = {webhook_preset.id: webhook_preset.create_webhook for webhook_preset in _config} + WEBHOOK_PRESET_VALIDATORS = {webhook_preset.id: webhook_preset.validate for webhook_preset in _config} + WEBHOOK_PRESET_POST_PROCESSORS = {webhook_preset.id: webhook_preset.post_process for webhook_preset in _config} diff --git a/engine/settings/base.py b/engine/settings/base.py index 3c1d169409..ab74c4cded 100644 --- a/engine/settings/base.py +++ b/engine/settings/base.py @@ -723,6 +723,10 @@ class BrokerTypes: "config_integrations.direct_paging", ] +INSTALLED_WEBHOOK_PRESETS = [ + "apps.webhooks.presets.custom_webhook", +] + if IS_OPEN_SOURCE: INSTALLED_APPS += ["apps.oss_installation", "apps.zvonok"] # noqa From 8dcb84b6576bde3228f2e8ee3131d6a353159c36 Mon Sep 17 00:00:00 2001 From: Michael Derynck Date: Mon, 11 Sep 2023 20:07:10 -0600 Subject: [PATCH 02/20] WIP add presets for webhooks --- engine/apps/api/serializers/webhook.py | 4 +- engine/apps/api/tests/test_webhook_presets.py | 46 +- engine/apps/api/views/webhooks.py | 23 +- .../migrations/0011_webhook_preset.py | 18 + .../apps/webhooks/presets/custom_webhook.py | 19 - .../apps/webhooks/presets/preset_options.py | 31 +- engine/apps/webhooks/presets/simple.py | 25 ++ engine/settings/base.py | 2 +- .../OutgoingWebhookForm.config.tsx | 395 ++++++++++-------- .../OutgoingWebhookForm.tsx | 8 +- .../outgoing_webhook/outgoing_webhook.ts | 45 +- .../outgoing_webhook.types.ts | 8 + .../src/state/rootBaseStore/index.ts | 3 +- 13 files changed, 368 insertions(+), 259 deletions(-) create mode 100644 engine/apps/webhooks/migrations/0011_webhook_preset.py delete mode 100644 engine/apps/webhooks/presets/custom_webhook.py create mode 100644 engine/apps/webhooks/presets/simple.py diff --git a/engine/apps/api/serializers/webhook.py b/engine/apps/api/serializers/webhook.py index 2a38200cf2..e7353f9d0d 100644 --- a/engine/apps/api/serializers/webhook.py +++ b/engine/apps/api/serializers/webhook.py @@ -31,7 +31,7 @@ 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) + trigger_type = serializers.CharField(allow_null=True, required=False) forward_all = serializers.BooleanField(allow_null=True, required=False) last_response_log = serializers.SerializerMethodField() trigger_type_name = serializers.SerializerMethodField() @@ -59,10 +59,10 @@ class Meta: "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"])] diff --git a/engine/apps/api/tests/test_webhook_presets.py b/engine/apps/api/tests/test_webhook_presets.py index ceb3f4801b..7cf643a3b9 100644 --- a/engine/apps/api/tests/test_webhook_presets.py +++ b/engine/apps/api/tests/test_webhook_presets.py @@ -9,20 +9,27 @@ TEST_WEBHOOK_PRESET_FACTORY_NAME = "test webhook preset instance" TEST_WEBHOOK_PRESET_NAME = "Test Webhook" TEST_WEBHOOK_PRESET_ID = "test_webhook" +TEST_WEBHOOK_PRESET_DESCRIPTION = "Description of test webhook preset" +TEST_WEBHOOK_PRESET_IGNORED_FIELDS = ["url", "http_method"] -def webhook_preset_factory(organization): - webhook = Webhook() - webhook.name = TEST_WEBHOOK_PRESET_FACTORY_NAME - webhook.data = organization.org_title - return webhook +def webhook_preset_override(instance: Webhook, created: bool): + instance.name = TEST_WEBHOOK_PRESET_FACTORY_NAME + instance.data = instance.organization.org_title @pytest.fixture() def webhook_preset_api_setup(make_organization_and_user_with_plugin_token, make_custom_webhook): organization, user, token = make_organization_and_user_with_plugin_token() - WebhookPresetOptions.WEBHOOK_PRESET_CHOICES = ((TEST_WEBHOOK_PRESET_ID, TEST_WEBHOOK_PRESET_NAME),) - WebhookPresetOptions.WEBHOOK_PRESET_FACTORIES[TEST_WEBHOOK_PRESET_ID] = webhook_preset_factory + WebhookPresetOptions.WEBHOOK_PRESET_CHOICES = [ + { + "id": TEST_WEBHOOK_PRESET_ID, + "name": TEST_WEBHOOK_PRESET_NAME, + "description": TEST_WEBHOOK_PRESET_DESCRIPTION, + "ignored_fields": TEST_WEBHOOK_PRESET_IGNORED_FIELDS, + } + ] + WebhookPresetOptions.WEBHOOK_PRESET_OVERRIDE[TEST_WEBHOOK_PRESET_ID] = webhook_preset_override return user, token, organization @@ -35,24 +42,7 @@ def test_get_webhook_preset_options(webhook_preset_api_setup, make_user_auth_hea response = client.get(url, format="json", **make_user_auth_headers(user, token)) assert response.status_code == status.HTTP_200_OK - assert response.data[0]["value"] == TEST_WEBHOOK_PRESET_ID - assert response.data[0]["display_name"] == TEST_WEBHOOK_PRESET_NAME - - -@pytest.mark.django_db -def test_get_webhook_preset_from_preset(webhook_preset_api_setup, make_user_auth_headers): - user, token, organization = webhook_preset_api_setup - client = APIClient() - url = reverse("api-internal:webhooks-preset") - - response = client.get(f"{url}", format="json", **make_user_auth_headers(user, token)) - assert response.status_code == status.HTTP_400_BAD_REQUEST - - response = client.get(f"{url}?id=nonexistent", format="json", **make_user_auth_headers(user, token)) - assert response.status_code == status.HTTP_404_NOT_FOUND - - response = client.get(f"{url}?id={TEST_WEBHOOK_PRESET_ID}", format="json", **make_user_auth_headers(user, token)) - - assert response.status_code == status.HTTP_200_OK - assert response.data["name"] == TEST_WEBHOOK_PRESET_FACTORY_NAME - assert response.data["data"] == organization.org_title + assert response.data[0]["id"] == TEST_WEBHOOK_PRESET_ID + assert response.data[0]["name"] == TEST_WEBHOOK_PRESET_NAME + assert response.data[0]["description"] == TEST_WEBHOOK_PRESET_DESCRIPTION + assert response.data[0]["ignored_fields"] == TEST_WEBHOOK_PRESET_IGNORED_FIELDS diff --git a/engine/apps/api/views/webhooks.py b/engine/apps/api/views/webhooks.py index 61f2380d50..35b5b3afa7 100644 --- a/engine/apps/api/views/webhooks.py +++ b/engine/apps/api/views/webhooks.py @@ -185,24 +185,5 @@ def preview_template(self, request, pk): @action(methods=["get"], detail=False) def preset_options(self, request): - choices = [] - for webhook_preset_id, webhook_preset_title in WebhookPresetOptions.WEBHOOK_PRESET_CHOICES: - choices.append( - { - "value": webhook_preset_id, - "display_name": webhook_preset_title, - } - ) - return Response(choices) - - @action(methods=["get"], detail=False) - def preset(self, request): - preset_id = request.query_params.get("id", None) - if not preset_id: - raise BadRequest(detail="id query parameter is required") - if preset_id not in WebhookPresetOptions.WEBHOOK_PRESET_FACTORIES: - raise NotFound - - webhook = WebhookPresetOptions.WEBHOOK_PRESET_FACTORIES[preset_id](self.request.auth.organization) - serializer = WebhookSerializer(webhook) - return Response(serializer.data) + result = list(WebhookPresetOptions.WEBHOOK_PRESET_CHOICES) + return Response(result) diff --git a/engine/apps/webhooks/migrations/0011_webhook_preset.py b/engine/apps/webhooks/migrations/0011_webhook_preset.py new file mode 100644 index 0000000000..bdedc690b2 --- /dev/null +++ b/engine/apps/webhooks/migrations/0011_webhook_preset.py @@ -0,0 +1,18 @@ +# Generated by Django 3.2.20 on 2023-09-11 22:17 + +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(default=None, max_length=100, null=True), + ), + ] diff --git a/engine/apps/webhooks/presets/custom_webhook.py b/engine/apps/webhooks/presets/custom_webhook.py deleted file mode 100644 index 6a7b0fbb1a..0000000000 --- a/engine/apps/webhooks/presets/custom_webhook.py +++ /dev/null @@ -1,19 +0,0 @@ -from apps.webhooks.models import Webhook - -id = "custom_webhook" -title = "Custom Webhook" - - -def create_webhook(organization): - webhook = Webhook() - webhook.http_method = "POST" - webhook.trigger_type = Webhook.TRIGGER_ESCALATION_STEP - return webhook - - -def validate(organization, webhook): - pass - - -def post_process(organization, webhook): - pass diff --git a/engine/apps/webhooks/presets/preset_options.py b/engine/apps/webhooks/presets/preset_options.py index 97ea316153..b298622648 100644 --- a/engine/apps/webhooks/presets/preset_options.py +++ b/engine/apps/webhooks/presets/preset_options.py @@ -1,6 +1,10 @@ import importlib 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: @@ -8,16 +12,19 @@ class WebhookPresetOptions: (importlib.import_module(webhook_preset_config) for webhook_preset_config in settings.INSTALLED_WEBHOOK_PRESETS) ) - WEBHOOK_PRESET_CHOICES = tuple( - ( - ( - webhook_preset.id, - webhook_preset.title, - ) - for webhook_preset in _config - ) - ) + WEBHOOK_PRESET_CHOICES = [webhook_preset.metadata for webhook_preset in _config] + WEBHOOK_PRESET_OVERRIDE = { + webhook_preset.metadata["id"]: webhook_preset.override_webhook_parameters for webhook_preset in _config + } + + +@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_PRESET_OVERRIDE: + WebhookPresetOptions.WEBHOOK_PRESET_OVERRIDE[instance.preset](instance) + else: + raise NotImplementedError(f"Webhook references unknown preset implementation {instance.preset}") + - WEBHOOK_PRESET_FACTORIES = {webhook_preset.id: webhook_preset.create_webhook for webhook_preset in _config} - WEBHOOK_PRESET_VALIDATORS = {webhook_preset.id: webhook_preset.validate for webhook_preset in _config} - WEBHOOK_PRESET_POST_PROCESSORS = {webhook_preset.id: webhook_preset.post_process for webhook_preset in _config} +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 0000000000..614a0314fd --- /dev/null +++ b/engine/apps/webhooks/presets/simple.py @@ -0,0 +1,25 @@ +from apps.webhooks.models import Webhook + +metadata = { + "id": "simple_webhook", + "name": "Simple", + "description": "A simple webhook which POSTs the alert group data to a given URL. Triggered as an escalation step and hiding advanced webhook parameters", + "ignored_fields": [ + "trigger_type", + "http_method", + "integration_filter", + "headers", + "username", + "password", + "authorization_header", + "trigger_template", + "forward_all", + "data", + ], +} + + +def override_webhook_parameters(instance: Webhook): + instance.http_method = "POST" + instance.trigger_type = Webhook.TRIGGER_ESCALATION_STEP + instance.forward_all = True diff --git a/engine/settings/base.py b/engine/settings/base.py index ab74c4cded..6b07db14f2 100644 --- a/engine/settings/base.py +++ b/engine/settings/base.py @@ -724,7 +724,7 @@ class BrokerTypes: ] INSTALLED_WEBHOOK_PRESETS = [ - "apps.webhooks.presets.custom_webhook", + "apps.webhooks.presets.simple", ] if IS_OPEN_SOURCE: diff --git a/grafana-plugin/src/containers/OutgoingWebhookForm/OutgoingWebhookForm.config.tsx b/grafana-plugin/src/containers/OutgoingWebhookForm/OutgoingWebhookForm.config.tsx index 78fab274ea..9f5f002176 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 { OutgoingWebhookPresetStore } from 'models/outgoing_webhook/outgoing_webhook'; import { KeyValuePair } from 'utils'; import { generateAssignToTeamInputDescription } from 'utils/consts'; @@ -18,182 +19,234 @@ 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(store: OutgoingWebhookPresetStore): { 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: 'preset', + label: 'Preset', + type: FormItemType.GSelect, + extra: { + modelName: 'outgoingWebhookPresetsStore', + displayField: 'name', + valueField: 'id', + showSearch: true, + allowClear: true, + }, }, - validation: { required: true }, - normalize: (value) => value, - }, - { - name: 'integration_filter', - label: 'Integrations', - type: FormItemType.MultiSelect, - isVisible: (data) => { - return data.trigger_type !== WebhookTriggerType.EscalationStep.key; + { + 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, + }, }, - extra: { - modelName: 'alertReceiveChannelStore', - displayField: 'verbal_name', - valueField: 'id', - showSearch: true, - getOptionLabel: (item: SelectableValue) => , + { + 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, + }, + ], + }, + isVisible: (data) => { + return isPresetFieldVisible(store, data.preset, 'trigger_type'); + }, + 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: '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', + }, + ], + }, + isVisible: (data) => isPresetFieldVisible(store, data.preset, 'http_method'), + normalize: (value) => value, }, - }, - { - name: 'headers', - label: 'Webhook Headers', - description: 'Request headers should be in JSON format.', - type: FormItemType.Monaco, - extra: { - rows: 3, + { + name: 'integration_filter', + label: 'Integrations', + type: FormItemType.MultiSelect, + isVisible: (data) => { + return ( + isPresetFieldVisible(store, data.preset, 'integration_filter') && + data.trigger_type !== WebhookTriggerType.EscalationStep.key + ); + }, + extra: { + 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: '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: 'url', + label: 'Webhook URL', + type: FormItemType.Monaco, + extra: { + height: 30, + }, + isVisible: (data) => { + return isPresetFieldVisible(store, data.preset, 'url'); + }, }, - }, - { - 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: 'headers', + label: 'Webhook Headers', + description: 'Request headers should be in JSON format.', + type: FormItemType.Monaco, + extra: { + rows: 3, + }, + isVisible: (data) => { + return isPresetFieldVisible(store, data.preset, 'headers'); + }, + }, + { + name: 'username', + type: FormItemType.Input, + isVisible: (data) => { + return isPresetFieldVisible(store, data.preset, 'username'); + }, + }, + { + name: 'password', + type: FormItemType.Password, + isVisible: (data) => { + return isPresetFieldVisible(store, data.preset, '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(store, data.preset, '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(store, data.preset, 'trigger_template'); + }, + }, + { + 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", + isVisible: (data) => { + return isPresetFieldVisible(store, data.preset, '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(store, data.preset, 'data'); + }, + }, + ], + }; +} + +function isPresetFieldVisible(store: OutgoingWebhookPresetStore, presetId: string, fieldName: string) { + if (!presetId) { + return true; + } + const preset = store.items[presetId]; + if (preset && preset.ignored_fields.includes(fieldName)) { + return false; + } + return true; +} diff --git a/grafana-plugin/src/containers/OutgoingWebhookForm/OutgoingWebhookForm.tsx b/grafana-plugin/src/containers/OutgoingWebhookForm/OutgoingWebhookForm.tsx index a006e5d110..63ec7a1dac 100644 --- a/grafana-plugin/src/containers/OutgoingWebhookForm/OutgoingWebhookForm.tsx +++ b/grafana-plugin/src/containers/OutgoingWebhookForm/OutgoingWebhookForm.tsx @@ -18,7 +18,7 @@ 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'; @@ -46,9 +46,10 @@ const OutgoingWebhookForm = observer((props: OutgoingWebhookFormProps) => { action === WebhookFormActionType.EDIT_SETTINGS ? WebhookTabs.Settings.key : WebhookTabs.LastRun.key ); - const { outgoingWebhookStore } = useStore(); + const { outgoingWebhookStore, outgoingWebhookPresetsStore } = useStore(); const isNew = action === WebhookFormActionType.NEW; const isNewOrCopy = isNew || action === WebhookFormActionType.COPY; + const form = createForm(outgoingWebhookPresetsStore); const handleSubmit = useCallback( (data: Partial) => { @@ -251,7 +252,8 @@ const WebhookTabsContent: React.FC = ({ formElement, }) => { const [confirmationModal, setConfirmationModal] = useState(undefined); - + const { outgoingWebhookPresetsStore } = useStore(); + const form = createForm(outgoingWebhookPresetsStore); return (
{confirmationModal && ( diff --git a/grafana-plugin/src/models/outgoing_webhook/outgoing_webhook.ts b/grafana-plugin/src/models/outgoing_webhook/outgoing_webhook.ts index 7668d1e1e7..97599e1c0b 100644 --- a/grafana-plugin/src/models/outgoing_webhook/outgoing_webhook.ts +++ b/grafana-plugin/src/models/outgoing_webhook/outgoing_webhook.ts @@ -4,7 +4,7 @@ import BaseStore from 'models/base_store'; import { makeRequest } from 'network'; import { RootStore } from 'state'; -import { OutgoingWebhook } from './outgoing_webhook.types'; +import { OutgoingWebhook, OutgoingWebhookPreset } from './outgoing_webhook.types'; export class OutgoingWebhookStore extends BaseStore { @observable.shallow @@ -98,3 +98,46 @@ export class OutgoingWebhookStore extends BaseStore { }); } } + +export class OutgoingWebhookPresetStore extends BaseStore { + @observable.shallow + items: { [id: string]: OutgoingWebhookPreset } = {}; + + @observable.shallow + searchResult: { [key: string]: Array } = {}; + + constructor(rootStore: RootStore) { + super(rootStore); + + this.path = '/webhooks/preset_options'; + } + + @action + async updateItems(query = '') { + const results = await this.getAll(); + + this.items = { + ...this.items, + ...results.reduce( + (acc: { [key: number]: OutgoingWebhookPreset }, item: OutgoingWebhookPreset) => ({ + ...acc, + [item.id]: item, + }), + {} + ), + }; + + this.searchResult = { + ...this.searchResult, + [query]: results.map((item: OutgoingWebhookPreset) => item.id), + }; + } + + getSearchResult(query = '') { + if (!this.searchResult[query]) { + return undefined; + } + + return this.searchResult[query].map((id: OutgoingWebhookPreset['id']) => this.items[id]); + } +} diff --git a/grafana-plugin/src/models/outgoing_webhook/outgoing_webhook.types.ts b/grafana-plugin/src/models/outgoing_webhook/outgoing_webhook.types.ts index e3d98c9a32..1e59a12d2c 100644 --- a/grafana-plugin/src/models/outgoing_webhook/outgoing_webhook.types.ts +++ b/grafana-plugin/src/models/outgoing_webhook/outgoing_webhook.types.ts @@ -18,6 +18,7 @@ export interface OutgoingWebhook { last_response_log?: OutgoingWebhookResponse; is_webhook_enabled: boolean; is_legacy: boolean; + preset: string; } export interface OutgoingWebhookResponse { @@ -30,3 +31,10 @@ export interface OutgoingWebhookResponse { content: string; event_data: string; } + +export interface OutgoingWebhookPreset { + id: string; + name: string; + description: string; + ignored_fields: string[]; +} diff --git a/grafana-plugin/src/state/rootBaseStore/index.ts b/grafana-plugin/src/state/rootBaseStore/index.ts index 086e94023e..9dd194ac25 100644 --- a/grafana-plugin/src/state/rootBaseStore/index.ts +++ b/grafana-plugin/src/state/rootBaseStore/index.ts @@ -20,7 +20,7 @@ import { GlobalSettingStore } from 'models/global_setting/global_setting'; import { GrafanaTeamStore } from 'models/grafana_team/grafana_team'; import { HeartbeatStore } from 'models/heartbeat/heartbeat'; import { OrganizationStore } from 'models/organization/organization'; -import { OutgoingWebhookStore } from 'models/outgoing_webhook/outgoing_webhook'; +import { OutgoingWebhookPresetStore, OutgoingWebhookStore } from 'models/outgoing_webhook/outgoing_webhook'; import { ResolutionNotesStore } from 'models/resolution_note/resolution_note'; import { ScheduleStore } from 'models/schedule/schedule'; import { SlackStore } from 'models/slack/slack'; @@ -93,6 +93,7 @@ export class RootBaseStore { grafanaTeamStore = new GrafanaTeamStore(this); alertReceiveChannelStore = new AlertReceiveChannelStore(this); outgoingWebhookStore = new OutgoingWebhookStore(this); + outgoingWebhookPresetsStore = new OutgoingWebhookPresetStore(this); alertReceiveChannelFiltersStore = new AlertReceiveChannelFiltersStore(this); escalationChainStore = new EscalationChainStore(this); escalationPolicyStore = new EscalationPolicyStore(this); From 084f9e082d7305d68f09c1d6592b88cb1339dfb6 Mon Sep 17 00:00:00 2001 From: Michael Derynck Date: Tue, 12 Sep 2023 14:24:05 -0600 Subject: [PATCH 03/20] Fix tests --- engine/apps/api/tests/test_webhooks.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/engine/apps/api/tests/test_webhooks.py b/engine/apps/api/tests/test_webhooks.py index e6c2624d23..1e275bb52b 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)) @@ -153,6 +155,7 @@ def test_create_webhook(webhook_internal_api_setup, make_user_auth_headers): }, "trigger_template": None, "trigger_type_name": "Alert Group Created", + "preset": None, } assert response.status_code == status.HTTP_201_CREATED assert response.json() == expected_response @@ -210,6 +213,7 @@ def test_create_valid_templated_field(webhook_internal_api_setup, make_user_auth }, "trigger_template": None, "trigger_type_name": "Alert Group Created", + "preset": None, } # update expected value for changed field expected_response[field_name] = value @@ -580,6 +584,7 @@ def test_webhook_field_masking(webhook_internal_api_setup, make_user_auth_header }, "trigger_template": None, "trigger_type_name": "Alert Group Created", + "preset": None, } assert response.status_code == status.HTTP_201_CREATED @@ -636,6 +641,7 @@ def test_webhook_copy(webhook_internal_api_setup, make_user_auth_headers): }, "trigger_template": None, "trigger_type_name": "Alert Group Created", + "preset": None, } assert response3.status_code == status.HTTP_201_CREATED From 427b22270161da7eb4b54d5dd9adcdf1a7a66eec Mon Sep 17 00:00:00 2001 From: Michael Derynck Date: Wed, 13 Sep 2023 16:28:23 -0600 Subject: [PATCH 04/20] Add webhook preset selection page --- engine/apps/api/serializers/webhook.py | 31 +++- engine/apps/api/tests/test_webhook_presets.py | 3 + engine/apps/webhooks/models/webhook.py | 2 + engine/apps/webhooks/presets/advanced.py | 13 ++ .../apps/webhooks/presets/preset_options.py | 1 + engine/apps/webhooks/presets/simple.py | 3 +- engine/settings/base.py | 1 + .../OutgoingWebhookForm.config.tsx | 46 ++--- .../OutgoingWebhookForm.module.css | 28 +++ .../OutgoingWebhookForm.tsx | 170 +++++++++++++++--- .../outgoing_webhook/outgoing_webhook.ts | 46 +---- .../outgoing_webhook.types.ts | 1 + .../outgoing_webhooks/OutgoingWebhooks.tsx | 2 +- .../src/state/rootBaseStore/index.ts | 4 +- 14 files changed, 249 insertions(+), 102 deletions(-) create mode 100644 engine/apps/webhooks/presets/advanced.py diff --git a/engine/apps/api/serializers/webhook.py b/engine/apps/api/serializers/webhook.py index e7353f9d0d..fd90e75113 100644 --- a/engine/apps/api/serializers/webhook.py +++ b/engine/apps/api/serializers/webhook.py @@ -4,7 +4,8 @@ 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,7 +32,6 @@ 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(allow_null=True, required=False) forward_all = serializers.BooleanField(allow_null=True, required=False) last_response_log = serializers.SerializerMethodField() trigger_type_name = serializers.SerializerMethodField() @@ -111,10 +111,26 @@ def validate_headers(self, headers): return self._validate_template_field(headers) def validate_url(self, url): - if not url: + if self.is_field_ignored("url"): return None + if not url: + raise serializers.ValidationError(detail="is required") return self._validate_template_field(url) + def validate_http_method(self, http_method): + if self.is_field_ignored("http_method"): + return None + if http_method not in PUBLIC_WEBHOOK_HTTP_METHODS: + raise serializers.ValidationError(detail=f"must be one of {PUBLIC_WEBHOOK_HTTP_METHODS}") + return http_method + + def validate_trigger_type(self, trigger_type): + if self.is_field_ignored("trigger_type"): + return None + if trigger_type not in Webhook.ALL_TRIGGER_TYPES: + raise serializers.ValidationError(detail="is required") + return trigger_type + def validate_data(self, data): if not data: return None @@ -133,3 +149,12 @@ def get_trigger_type_name(self, obj): 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_ignored(self, field_name): + preset_id = self.initial_data["preset"] + if preset_id: + preset_metadata = WebhookPresetOptions.WEBHOOK_PRESET_METADATA[preset_id] + ignored_fields = preset_metadata["ignored_fields"] + if field_name not in ignored_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 index 7cf643a3b9..8047995806 100644 --- a/engine/apps/api/tests/test_webhook_presets.py +++ b/engine/apps/api/tests/test_webhook_presets.py @@ -9,6 +9,7 @@ TEST_WEBHOOK_PRESET_FACTORY_NAME = "test webhook preset instance" 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_IGNORED_FIELDS = ["url", "http_method"] @@ -25,6 +26,7 @@ def webhook_preset_api_setup(make_organization_and_user_with_plugin_token, make_ { "id": TEST_WEBHOOK_PRESET_ID, "name": TEST_WEBHOOK_PRESET_NAME, + "logo": TEST_WEBHOOK_LOGO, "description": TEST_WEBHOOK_PRESET_DESCRIPTION, "ignored_fields": TEST_WEBHOOK_PRESET_IGNORED_FIELDS, } @@ -44,5 +46,6 @@ def test_get_webhook_preset_options(webhook_preset_api_setup, make_user_auth_hea 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]["ignored_fields"] == TEST_WEBHOOK_PRESET_IGNORED_FIELDS diff --git a/engine/apps/webhooks/models/webhook.py b/engine/apps/webhooks/models/webhook.py index 4228380467..fa6531ced5 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", diff --git a/engine/apps/webhooks/presets/advanced.py b/engine/apps/webhooks/presets/advanced.py new file mode 100644 index 0000000000..d864648433 --- /dev/null +++ b/engine/apps/webhooks/presets/advanced.py @@ -0,0 +1,13 @@ +from apps.webhooks.models import Webhook + +metadata = { + "id": "advanced_webhook", + "name": "Advanced", + "logo": "webhook", + "description": "An advanced webhook with all available settings and template options.", + "ignored_fields": [], +} + + +def override_webhook_parameters(instance: Webhook): + pass diff --git a/engine/apps/webhooks/presets/preset_options.py b/engine/apps/webhooks/presets/preset_options.py index b298622648..e9ce986bfb 100644 --- a/engine/apps/webhooks/presets/preset_options.py +++ b/engine/apps/webhooks/presets/preset_options.py @@ -13,6 +13,7 @@ class WebhookPresetOptions: ) WEBHOOK_PRESET_CHOICES = [webhook_preset.metadata for webhook_preset in _config] + WEBHOOK_PRESET_METADATA = {webhook_preset.metadata["id"]: webhook_preset.metadata for webhook_preset in _config} WEBHOOK_PRESET_OVERRIDE = { webhook_preset.metadata["id"]: webhook_preset.override_webhook_parameters for webhook_preset in _config } diff --git a/engine/apps/webhooks/presets/simple.py b/engine/apps/webhooks/presets/simple.py index 614a0314fd..44da921275 100644 --- a/engine/apps/webhooks/presets/simple.py +++ b/engine/apps/webhooks/presets/simple.py @@ -3,7 +3,8 @@ metadata = { "id": "simple_webhook", "name": "Simple", - "description": "A simple webhook which POSTs the alert group data to a given URL. Triggered as an escalation step and hiding advanced webhook parameters", + "logo": "webhook", + "description": "A simple webhook which sends the alert group data to a given URL. Triggered as an escalation step.", "ignored_fields": [ "trigger_type", "http_method", diff --git a/engine/settings/base.py b/engine/settings/base.py index 6b07db14f2..1d595ce73b 100644 --- a/engine/settings/base.py +++ b/engine/settings/base.py @@ -725,6 +725,7 @@ class BrokerTypes: INSTALLED_WEBHOOK_PRESETS = [ "apps.webhooks.presets.simple", + "apps.webhooks.presets.advanced", ] if IS_OPEN_SOURCE: diff --git a/grafana-plugin/src/containers/OutgoingWebhookForm/OutgoingWebhookForm.config.tsx b/grafana-plugin/src/containers/OutgoingWebhookForm/OutgoingWebhookForm.config.tsx index 9f5f002176..3e34552d0f 100644 --- a/grafana-plugin/src/containers/OutgoingWebhookForm/OutgoingWebhookForm.config.tsx +++ b/grafana-plugin/src/containers/OutgoingWebhookForm/OutgoingWebhookForm.config.tsx @@ -4,7 +4,7 @@ import { SelectableValue } from '@grafana/data'; import Emoji from 'react-emoji-render'; import { FormItem, FormItemType } from 'components/GForm/GForm.types'; -import { OutgoingWebhookPresetStore } from 'models/outgoing_webhook/outgoing_webhook'; +import { OutgoingWebhookPreset } from 'models/outgoing_webhook/outgoing_webhook.types'; import { KeyValuePair } from 'utils'; import { generateAssignToTeamInputDescription } from 'utils/consts'; @@ -19,7 +19,7 @@ export const WebhookTriggerType = { Unacknowledged: new KeyValuePair('7', 'Unacknowledged'), }; -export function createForm(store: OutgoingWebhookPresetStore): { name: string; fields: FormItem[] } { +export function createForm(presets: OutgoingWebhookPreset[]): { name: string; fields: FormItem[] } { return { name: 'OutgoingWebhook', fields: [ @@ -34,18 +34,6 @@ export function createForm(store: OutgoingWebhookPresetStore): { name: string; f normalize: (value) => Boolean(value), type: FormItemType.Switch, }, - { - name: 'preset', - label: 'Preset', - type: FormItemType.GSelect, - extra: { - modelName: 'outgoingWebhookPresetsStore', - displayField: 'name', - valueField: 'id', - showSearch: true, - allowClear: true, - }, - }, { name: 'team', label: 'Assign to Team', @@ -103,7 +91,7 @@ export function createForm(store: OutgoingWebhookPresetStore): { name: string; f ], }, isVisible: (data) => { - return isPresetFieldVisible(store, data.preset, 'trigger_type'); + return isPresetFieldVisible(data.preset, presets, 'trigger_type'); }, normalize: (value) => value, }, @@ -135,7 +123,7 @@ export function createForm(store: OutgoingWebhookPresetStore): { name: string; f }, ], }, - isVisible: (data) => isPresetFieldVisible(store, data.preset, 'http_method'), + isVisible: (data) => isPresetFieldVisible(data.preset, presets, 'http_method'), normalize: (value) => value, }, { @@ -144,7 +132,7 @@ export function createForm(store: OutgoingWebhookPresetStore): { name: string; f type: FormItemType.MultiSelect, isVisible: (data) => { return ( - isPresetFieldVisible(store, data.preset, 'integration_filter') && + isPresetFieldVisible(data.preset, presets, 'integration_filter') && data.trigger_type !== WebhookTriggerType.EscalationStep.key ); }, @@ -166,7 +154,7 @@ export function createForm(store: OutgoingWebhookPresetStore): { name: string; f height: 30, }, isVisible: (data) => { - return isPresetFieldVisible(store, data.preset, 'url'); + return isPresetFieldVisible(data.preset, presets, 'url'); }, }, { @@ -178,21 +166,21 @@ export function createForm(store: OutgoingWebhookPresetStore): { name: string; f rows: 3, }, isVisible: (data) => { - return isPresetFieldVisible(store, data.preset, 'headers'); + return isPresetFieldVisible(data.preset, presets, 'headers'); }, }, { name: 'username', type: FormItemType.Input, isVisible: (data) => { - return isPresetFieldVisible(store, data.preset, 'username'); + return isPresetFieldVisible(data.preset, presets, 'username'); }, }, { name: 'password', type: FormItemType.Password, isVisible: (data) => { - return isPresetFieldVisible(store, data.preset, 'password'); + return isPresetFieldVisible(data.preset, presets, 'password'); }, }, { @@ -201,7 +189,7 @@ export function createForm(store: OutgoingWebhookPresetStore): { name: string; f 'Value of the Authorization header, do not need to prefix with "Authorization:". For example: Bearer AbCdEf123456', type: FormItemType.Password, isVisible: (data) => { - return isPresetFieldVisible(store, data.preset, 'authorization_header'); + return isPresetFieldVisible(data.preset, presets, 'authorization_header'); }, }, { @@ -213,7 +201,7 @@ export function createForm(store: OutgoingWebhookPresetStore): { name: string; f rows: 2, }, isVisible: (data) => { - return isPresetFieldVisible(store, data.preset, 'trigger_template'); + return isPresetFieldVisible(data.preset, presets, 'trigger_template'); }, }, { @@ -222,7 +210,7 @@ export function createForm(store: OutgoingWebhookPresetStore): { name: string; f 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(store, data.preset, 'forward_all'); + return isPresetFieldVisible(data.preset, presets, 'forward_all'); }, }, { @@ -233,19 +221,19 @@ export function createForm(store: OutgoingWebhookPresetStore): { name: string; f 'Available variables: {{ event }}, {{ user }}, {{ alert_group }}, {{ alert_group_id }}, {{ alert_payload }}, {{ integration }}, {{ notified_users }}, {{ users_to_be_notified }}, {{ responses }}', extra: {}, isVisible: (data) => { - return isPresetFieldVisible(store, data.preset, 'data'); + return isPresetFieldVisible(data.preset, presets, 'data'); }, }, ], }; } -function isPresetFieldVisible(store: OutgoingWebhookPresetStore, presetId: string, fieldName: string) { - if (!presetId) { +function isPresetFieldVisible(presetId: string, presets: OutgoingWebhookPreset[], fieldName: string) { + if (presetId == null) { return true; } - const preset = store.items[presetId]; - if (preset && preset.ignored_fields.includes(fieldName)) { + const selectedPreset = presets.find((item) => item.id === presetId); + if (selectedPreset && selectedPreset.ignored_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 a4613c6435..9969392c4d 100644 --- a/grafana-plugin/src/containers/OutgoingWebhookForm/OutgoingWebhookForm.module.css +++ b/grafana-plugin/src/containers/OutgoingWebhookForm/OutgoingWebhookForm.module.css @@ -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 63ec7a1dac..55d66ea59c 100644 --- a/grafana-plugin/src/containers/OutgoingWebhookForm/OutgoingWebhookForm.tsx +++ b/grafana-plugin/src/containers/OutgoingWebhookForm/OutgoingWebhookForm.tsx @@ -1,17 +1,30 @@ -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 Text from 'components/Text/Text'; 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'; @@ -45,11 +58,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, outgoingWebhookPresetsStore } = useStore(); + const { outgoingWebhookStore } = useStore(); const isNew = action === WebhookFormActionType.NEW; const isNewOrCopy = isNew || action === WebhookFormActionType.COPY; - const form = createForm(outgoingWebhookPresetsStore); + const form = createForm(outgoingWebhookStore.outgoingWebhookPresets); const handleSubmit = useCallback( (data: Partial) => { @@ -105,10 +122,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 { @@ -124,27 +148,67 @@ const OutgoingWebhookForm = observer((props: OutgoingWebhookFormProps) => { } const formElement = ; + const createWebhookParameters = ( + <> + +
{renderWebhookForm()}
+
+ {templateToEdit && ( + { + onFormChangeFn?.fn(value); + setTemplateToEdit(undefined); + }} + onHide={() => setTemplateToEdit(undefined)} + template={templateToEdit} + /> + )} + + ); + + const presets = outgoingWebhookStore.outgoingWebhookPresets.filter((preset: OutgoingWebhookPreset) => + preset.name.toLowerCase().includes(filterValue.toLowerCase()) + ); - if (action === WebhookFormActionType.NEW || action === WebhookFormActionType.COPY) { - // show just the creation form, not the tabs + 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. + + +
+ ) => setFilterValue(e.currentTarget.value)} + /> +
+ + +
+
+
)} + {(showCreateWebhookDrawer || !showPresetsListDrawer) && createWebhookParameters} ); + } else if (action === WebhookFormActionType.COPY) { + return createWebhookParameters; } return ( @@ -201,6 +265,12 @@ const OutgoingWebhookForm = observer((props: OutgoingWebhookFormProps) => { ); + function onBlockClick(preset: OutgoingWebhookPreset) { + setSelectedPreset(preset); + setShowCreateWebhookDrawer(true); + setShowPresetsListDrawer(false); + } + function renderWebhookForm() { return ( <> @@ -208,9 +278,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 9dd194ac25..fa9c8c7214 100644 --- a/grafana-plugin/src/state/rootBaseStore/index.ts +++ b/grafana-plugin/src/state/rootBaseStore/index.ts @@ -20,7 +20,7 @@ import { GlobalSettingStore } from 'models/global_setting/global_setting'; import { GrafanaTeamStore } from 'models/grafana_team/grafana_team'; import { HeartbeatStore } from 'models/heartbeat/heartbeat'; import { OrganizationStore } from 'models/organization/organization'; -import { OutgoingWebhookPresetStore, OutgoingWebhookStore } from 'models/outgoing_webhook/outgoing_webhook'; +import { OutgoingWebhookStore } from 'models/outgoing_webhook/outgoing_webhook'; import { ResolutionNotesStore } from 'models/resolution_note/resolution_note'; import { ScheduleStore } from 'models/schedule/schedule'; import { SlackStore } from 'models/slack/slack'; @@ -93,7 +93,6 @@ export class RootBaseStore { grafanaTeamStore = new GrafanaTeamStore(this); alertReceiveChannelStore = new AlertReceiveChannelStore(this); outgoingWebhookStore = new OutgoingWebhookStore(this); - outgoingWebhookPresetsStore = new OutgoingWebhookPresetStore(this); alertReceiveChannelFiltersStore = new AlertReceiveChannelFiltersStore(this); escalationChainStore = new EscalationChainStore(this); escalationPolicyStore = new EscalationPolicyStore(this); @@ -131,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(), From f90cb9b957855e6e4a404da3777056f0cac40e36 Mon Sep 17 00:00:00 2001 From: Michael Derynck Date: Wed, 13 Sep 2023 16:30:52 -0600 Subject: [PATCH 05/20] Remove unused permission --- engine/apps/api/views/webhooks.py | 1 - 1 file changed, 1 deletion(-) diff --git a/engine/apps/api/views/webhooks.py b/engine/apps/api/views/webhooks.py index 35b5b3afa7..2975ee415c 100644 --- a/engine/apps/api/views/webhooks.py +++ b/engine/apps/api/views/webhooks.py @@ -54,7 +54,6 @@ class WebhooksView(TeamFilteringMixin, PublicPrimaryKeyMixin, ModelViewSet): "responses": [RBACPermission.Permissions.OUTGOING_WEBHOOKS_READ], "preview_template": [RBACPermission.Permissions.OUTGOING_WEBHOOKS_WRITE], "preset_options": [RBACPermission.Permissions.OUTGOING_WEBHOOKS_READ], - "preset": [RBACPermission.Permissions.OUTGOING_WEBHOOKS_WRITE], } model = Webhook From 5f52ec9ed6096de7bf470f6796b60794f9a72cdb Mon Sep 17 00:00:00 2001 From: Michael Derynck Date: Thu, 14 Sep 2023 15:59:17 -0600 Subject: [PATCH 06/20] Update docs --- docs/sources/outgoing-webhooks/_index.md | 6 ++++-- engine/apps/api/serializers/webhook.py | 5 +++++ 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/docs/sources/outgoing-webhooks/_index.md b/docs/sources/outgoing-webhooks/_index.md index 19cbf10c8f..1bfe5a3c67 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 **Create 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 +field described below. ### Outgoing webhook fields diff --git a/engine/apps/api/serializers/webhook.py b/engine/apps/api/serializers/webhook.py index fd90e75113..288143be46 100644 --- a/engine/apps/api/serializers/webhook.py +++ b/engine/apps/api/serializers/webhook.py @@ -141,6 +141,11 @@ def validate_forward_all(self, data): return False return data + def validate_preset(self, preset): + if self.instance and self.instance.preset != preset: + raise serializers.ValidationError(detail="once set cannot be modified") + return preset + def get_last_response_log(self, obj): return WebhookResponseSerializer(obj.responses.all().last()).data From 9067aea1974119f638cb7211153860aaac6705bc Mon Sep 17 00:00:00 2001 From: Michael Derynck Date: Thu, 14 Sep 2023 18:15:15 -0600 Subject: [PATCH 07/20] Add/fix tests --- engine/apps/api/serializers/webhook.py | 28 ++++++-- engine/apps/api/tests/test_webhook_presets.py | 61 ++++++++++++++++- engine/apps/api/tests/test_webhooks.py | 68 ++++++++++++++++--- 3 files changed, 139 insertions(+), 18 deletions(-) diff --git a/engine/apps/api/serializers/webhook.py b/engine/apps/api/serializers/webhook.py index 288143be46..c0914554ac 100644 --- a/engine/apps/api/serializers/webhook.py +++ b/engine/apps/api/serializers/webhook.py @@ -78,6 +78,11 @@ def to_representation(self, instance): def to_internal_value(self, data): webhook = self.instance + # Some fields are conditionally required, add none values for missing required fields + for key in ["url", "http_method", "trigger_type"]: + if key not in data: + 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( @@ -112,23 +117,26 @@ def validate_headers(self, headers): def validate_url(self, url): if self.is_field_ignored("url"): - return None + return url + if not url: - raise serializers.ValidationError(detail="is required") + 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_ignored("http_method"): - return None + return http_method + if http_method not in PUBLIC_WEBHOOK_HTTP_METHODS: - raise serializers.ValidationError(detail=f"must be one of {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_ignored("trigger_type"): - return None + return trigger_type + if trigger_type not in Webhook.ALL_TRIGGER_TYPES: - raise serializers.ValidationError(detail="is required") + raise serializers.ValidationError(detail="This field is required.") return trigger_type def validate_data(self, data): @@ -143,7 +151,7 @@ def validate_forward_all(self, data): def validate_preset(self, preset): if self.instance and self.instance.preset != preset: - raise serializers.ValidationError(detail="once set cannot be modified") + raise serializers.ValidationError(detail="This field once set cannot be modified.") return preset def get_last_response_log(self, obj): @@ -156,8 +164,14 @@ def get_trigger_type_name(self, obj): return trigger_type_name def is_field_ignored(self, field_name): + if "preset" not in self.initial_data: + return False + preset_id = self.initial_data["preset"] if preset_id: + if preset_id not in WebhookPresetOptions.WEBHOOK_PRESET_METADATA: + raise serializers.ValidationError(detail=f"unknown preset {preset_id} referenced") + preset_metadata = WebhookPresetOptions.WEBHOOK_PRESET_METADATA[preset_id] ignored_fields = preset_metadata["ignored_fields"] if field_name not in ignored_fields: diff --git a/engine/apps/api/tests/test_webhook_presets.py b/engine/apps/api/tests/test_webhook_presets.py index 8047995806..ef881ecfae 100644 --- a/engine/apps/api/tests/test_webhook_presets.py +++ b/engine/apps/api/tests/test_webhook_presets.py @@ -4,9 +4,10 @@ from rest_framework.test import APIClient from apps.webhooks.models import Webhook +from apps.webhooks.models.webhook import WEBHOOK_FIELD_PLACEHOLDER from apps.webhooks.presets.preset_options import WebhookPresetOptions -TEST_WEBHOOK_PRESET_FACTORY_NAME = "test webhook preset instance" +TEST_WEBHOOK_PRESET_URL = "https://test123.com" TEST_WEBHOOK_PRESET_NAME = "Test Webhook" TEST_WEBHOOK_PRESET_ID = "test_webhook" TEST_WEBHOOK_LOGO = "test_logo" @@ -14,9 +15,10 @@ TEST_WEBHOOK_PRESET_IGNORED_FIELDS = ["url", "http_method"] -def webhook_preset_override(instance: Webhook, created: bool): - instance.name = TEST_WEBHOOK_PRESET_FACTORY_NAME +def webhook_preset_override(instance: Webhook): instance.data = instance.organization.org_title + instance.url = TEST_WEBHOOK_PRESET_URL + instance.http_method = "GET" @pytest.fixture() @@ -32,6 +34,9 @@ def webhook_preset_api_setup(make_organization_and_user_with_plugin_token, make_ } ] WebhookPresetOptions.WEBHOOK_PRESET_OVERRIDE[TEST_WEBHOOK_PRESET_ID] = webhook_preset_override + WebhookPresetOptions.WEBHOOK_PRESET_METADATA[TEST_WEBHOOK_PRESET_ID] = WebhookPresetOptions.WEBHOOK_PRESET_CHOICES[ + 0 + ] return user, token, organization @@ -49,3 +54,53 @@ def test_get_webhook_preset_options(webhook_preset_api_setup, make_user_auth_hea assert response.data[0]["logo"] == TEST_WEBHOOK_LOGO assert response.data[0]["description"] == TEST_WEBHOOK_PRESET_DESCRIPTION assert response.data[0]["ignored_fields"] == TEST_WEBHOOK_PRESET_IGNORED_FIELDS + + +@pytest.mark.django_db +def test_create_webhook_from_preset(webhook_preset_api_setup, make_user_auth_headers): + user, token, organization = webhook_preset_api_setup + client = APIClient() + url = reverse("api-internal:webhooks-list") + + data = { + "name": "the_webhook", + "trigger_type": Webhook.TRIGGER_ALERT_GROUP_CREATED, + "team": None, + "password": "secret_password", + "authorization_header": "auth 1234", + "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": WEBHOOK_FIELD_PLACEHOLDER, + "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_name": "Alert Group Created", + } + + assert response.status_code == status.HTTP_201_CREATED + assert response.json() == expected_response + assert webhook.password == data["password"] + assert webhook.authorization_header == data["authorization_header"] diff --git a/engine/apps/api/tests/test_webhooks.py b/engine/apps/api/tests/test_webhooks.py index 1e275bb52b..98014866f8 100644 --- a/engine/apps/api/tests/test_webhooks.py +++ b/engine/apps/api/tests/test_webhooks.py @@ -64,7 +64,7 @@ def test_get_list_webhooks(webhook_internal_api_setup, make_user_auth_headers): "event_data": "", }, "trigger_template": None, - "trigger_type": "0", + "trigger_type": 0, "trigger_type_name": "Escalation step", "preset": None, } @@ -107,7 +107,7 @@ def test_get_detail_webhook(webhook_internal_api_setup, make_user_auth_headers): "event_data": "", }, "trigger_template": None, - "trigger_type": "0", + "trigger_type": 0, "trigger_type_name": "Escalation step", "preset": None, } @@ -126,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)) @@ -182,7 +183,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, } @@ -240,7 +242,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, } @@ -257,7 +260,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( @@ -551,7 +555,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", @@ -603,7 +608,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", @@ -650,3 +656,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 may not be null." + + 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] == '"2000000" is not a valid choice.' From f409d31ee8ff71b465524d7a9e66bcaeac49f18d Mon Sep 17 00:00:00 2001 From: Michael Derynck Date: Thu, 14 Sep 2023 18:17:19 -0600 Subject: [PATCH 08/20] Update CHANGELOG, only show search if number of webhooks don't fit on screen --- CHANGELOG.md | 4 ++++ .../OutgoingWebhookForm.tsx | 18 ++++++++++-------- 2 files changed, 14 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6b67f826dd..623d0329b2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## Unreleased +### Added + +- Presets for webhooks @mderynck ([#2996](https://github.com/grafana/oncall/pull/2996)) + ### Fixed - Fix Slack access token length issue by @toolchainX ([#3016](https://github.com/grafana/oncall/pull/3016)) diff --git a/grafana-plugin/src/containers/OutgoingWebhookForm/OutgoingWebhookForm.tsx b/grafana-plugin/src/containers/OutgoingWebhookForm/OutgoingWebhookForm.tsx index 55d66ea59c..b179d88a94 100644 --- a/grafana-plugin/src/containers/OutgoingWebhookForm/OutgoingWebhookForm.tsx +++ b/grafana-plugin/src/containers/OutgoingWebhookForm/OutgoingWebhookForm.tsx @@ -190,14 +190,16 @@ const OutgoingWebhookForm = observer((props: OutgoingWebhookFormProps) => { starting point to customize these connections. -
- ) => setFilterValue(e.currentTarget.value)} - /> -
+ {presets.length > 8 && ( +
+ ) => setFilterValue(e.currentTarget.value)} + /> +
+ )} From ba39c3d9094699ea81ecd128a59dbc39ae61a554 Mon Sep 17 00:00:00 2001 From: Michael Derynck Date: Mon, 18 Sep 2023 19:40:57 -0600 Subject: [PATCH 09/20] More validation, block presets in public API, improve field labelling in UI --- engine/apps/api/serializers/webhook.py | 37 ++++- engine/apps/api/tests/test_webhook_presets.py | 133 +++++++++++------- engine/apps/api/tests/test_webhooks.py | 12 +- .../apps/public_api/serializers/webhooks.py | 11 ++ engine/apps/public_api/tests/test_webhooks.py | 70 +++++++++ ...k_preset.py => 0011_auto_20230918_1852.py} | 7 +- engine/apps/webhooks/models/webhook.py | 2 +- .../webhooks/tests/test_webhook_presets.py | 46 ++++++ engine/conftest.py | 33 +++++ .../src/assets/img/grafana_ml_icon.svg | 1 + .../IntegrationLogo.module.css | 6 + .../OutgoingWebhookForm.config.tsx | 6 +- .../OutgoingWebhookForm.module.css | 2 +- 13 files changed, 300 insertions(+), 66 deletions(-) rename engine/apps/webhooks/migrations/{0011_webhook_preset.py => 0011_auto_20230918_1852.py} (61%) create mode 100644 engine/apps/webhooks/tests/test_webhook_presets.py create mode 100644 grafana-plugin/src/assets/img/grafana_ml_icon.svg diff --git a/engine/apps/api/serializers/webhook.py b/engine/apps/api/serializers/webhook.py index c0914554ac..2e06d82c6d 100644 --- a/engine/apps/api/serializers/webhook.py +++ b/engine/apps/api/serializers/webhook.py @@ -34,6 +34,7 @@ class WebhookSerializer(serializers.ModelSerializer): user = serializers.HiddenField(default=CurrentUserDefault()) 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: @@ -61,9 +62,6 @@ class Meta: "integration_filter", "preset", ] - extra_kwargs = { - "name": {"required": True, "allow_null": False, "allow_blank": False}, - } validators = [UniqueTogetherValidator(queryset=Webhook.objects.all(), fields=["name", "organization"])] @@ -79,9 +77,14 @@ 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: - data[key] = None + 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: @@ -135,7 +138,7 @@ def validate_trigger_type(self, trigger_type): if self.is_field_ignored("trigger_type"): return trigger_type - if trigger_type not in Webhook.ALL_TRIGGER_TYPES: + 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 @@ -152,6 +155,23 @@ def validate_forward_all(self, 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_PRESET_METADATA: + raise serializers.ValidationError(detail=f"{preset} is not a valid preset id.") + + preset_metadata = WebhookPresetOptions.WEBHOOK_PRESET_METADATA[preset] + ignored_fields = preset_metadata["ignored_fields"] + for ignored in ignored_fields: + if ignored in self.initial_data: + if self.instance: + if self.initial_data[ignored] != getattr(self.instance, ignored): + raise serializers.ValidationError( + detail=f"{ignored} is controlled by preset, cannot update" + ) + elif self.initial_data[ignored] is not None: + raise serializers.ValidationError(detail=f"{ignored} is controlled by preset, cannot create") + return preset def get_last_response_log(self, obj): @@ -164,10 +184,13 @@ def get_trigger_type_name(self, obj): return trigger_type_name def is_field_ignored(self, field_name): - if "preset" not in self.initial_data: + if self.instance: + if not self.instance.preset: + return False + elif "preset" not in self.initial_data: return False - preset_id = self.initial_data["preset"] + preset_id = self.instance.preset if self.instance else self.initial_data["preset"] if preset_id: if preset_id not in WebhookPresetOptions.WEBHOOK_PRESET_METADATA: raise serializers.ValidationError(detail=f"unknown preset {preset_id} referenced") diff --git a/engine/apps/api/tests/test_webhook_presets.py b/engine/apps/api/tests/test_webhook_presets.py index ef881ecfae..a898a52fff 100644 --- a/engine/apps/api/tests/test_webhook_presets.py +++ b/engine/apps/api/tests/test_webhook_presets.py @@ -5,60 +5,14 @@ from apps.webhooks.models import Webhook from apps.webhooks.models.webhook import WEBHOOK_FIELD_PLACEHOLDER -from apps.webhooks.presets.preset_options import WebhookPresetOptions - -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_IGNORED_FIELDS = ["url", "http_method"] - - -def webhook_preset_override(instance: Webhook): - instance.data = instance.organization.org_title - instance.url = TEST_WEBHOOK_PRESET_URL - instance.http_method = "GET" - - -@pytest.fixture() -def webhook_preset_api_setup(make_organization_and_user_with_plugin_token, make_custom_webhook): - organization, user, token = make_organization_and_user_with_plugin_token() - WebhookPresetOptions.WEBHOOK_PRESET_CHOICES = [ - { - "id": TEST_WEBHOOK_PRESET_ID, - "name": TEST_WEBHOOK_PRESET_NAME, - "logo": TEST_WEBHOOK_LOGO, - "description": TEST_WEBHOOK_PRESET_DESCRIPTION, - "ignored_fields": TEST_WEBHOOK_PRESET_IGNORED_FIELDS, - } - ] - WebhookPresetOptions.WEBHOOK_PRESET_OVERRIDE[TEST_WEBHOOK_PRESET_ID] = webhook_preset_override - WebhookPresetOptions.WEBHOOK_PRESET_METADATA[TEST_WEBHOOK_PRESET_ID] = WebhookPresetOptions.WEBHOOK_PRESET_CHOICES[ - 0 - ] - return user, token, organization +from conftest import TEST_WEBHOOK_PRESET_ID, TEST_WEBHOOK_PRESET_URL @pytest.mark.django_db -def test_get_webhook_preset_options(webhook_preset_api_setup, make_user_auth_headers): - user, token, organization = webhook_preset_api_setup - 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]["ignored_fields"] == TEST_WEBHOOK_PRESET_IGNORED_FIELDS - - -@pytest.mark.django_db -def test_create_webhook_from_preset(webhook_preset_api_setup, make_user_auth_headers): - user, token, organization = webhook_preset_api_setup +def test_create_webhook_from_preset( + make_organization_and_user_with_plugin_token, webhook_preset_api_setup, make_user_auth_headers +): + user, token, organization = make_organization_and_user_with_plugin_token client = APIClient() url = reverse("api-internal:webhooks-list") @@ -104,3 +58,80 @@ def test_create_webhook_from_preset(webhook_preset_api_setup, make_user_auth_hea assert response.json() == expected_response assert webhook.password == data["password"] assert webhook.authorization_header == data["authorization_header"] + + +@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 +): + user, token, organization = 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 +): + user, token, organization = 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 +): + user, token, organization = 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 98014866f8..867e366ff1 100644 --- a/engine/apps/api/tests/test_webhooks.py +++ b/engine/apps/api/tests/test_webhooks.py @@ -64,7 +64,7 @@ def test_get_list_webhooks(webhook_internal_api_setup, make_user_auth_headers): "event_data": "", }, "trigger_template": None, - "trigger_type": 0, + "trigger_type": "0", "trigger_type_name": "Escalation step", "preset": None, } @@ -107,7 +107,7 @@ def test_get_detail_webhook(webhook_internal_api_setup, make_user_auth_headers): "event_data": "", }, "trigger_template": None, - "trigger_type": 0, + "trigger_type": "0", "trigger_type_name": "Escalation step", "preset": None, } @@ -155,6 +155,7 @@ 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, } @@ -214,6 +215,7 @@ 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, } @@ -588,6 +590,7 @@ 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, } @@ -646,6 +649,7 @@ 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, } @@ -686,7 +690,7 @@ def test_create_invalid_missing_fields(webhook_internal_api_setup, make_user_aut } 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 may not be null." + assert response.json()["http_method"][0] == "This field must be one of ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS']." data = { "name": "test webhook 3", @@ -701,4 +705,4 @@ def test_create_invalid_missing_fields(webhook_internal_api_setup, make_user_aut 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] == '"2000000" is not a valid choice.' + assert response.json()["trigger_type"][0] == "This field is required." diff --git a/engine/apps/public_api/serializers/webhooks.py b/engine/apps/public_api/serializers/webhooks.py index 44a92634f8..5711c49c74 100644 --- a/engine/apps/public_api/serializers/webhooks.py +++ b/engine/apps/public_api/serializers/webhooks.py @@ -12,6 +12,8 @@ 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 Meta: "http_method", "trigger_type", "integration_filter", + "preset", ] extra_kwargs = { "name": {"required": True, "allow_null": False, "allow_blank": False}, @@ -149,6 +152,14 @@ def validate_integration_filter(self, integration_filter): 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 0e6feb3fbe..86a90b66d4 100644 --- a/engine/apps/public_api/tests/test_webhooks.py +++ b/engine/apps/public_api/tests/test_webhooks.py @@ -5,7 +5,9 @@ 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 conftest 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, } @@ -318,3 +321,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_webhook_preset.py b/engine/apps/webhooks/migrations/0011_auto_20230918_1852.py similarity index 61% rename from engine/apps/webhooks/migrations/0011_webhook_preset.py rename to engine/apps/webhooks/migrations/0011_auto_20230918_1852.py index bdedc690b2..6d7e81ee4f 100644 --- a/engine/apps/webhooks/migrations/0011_webhook_preset.py +++ b/engine/apps/webhooks/migrations/0011_auto_20230918_1852.py @@ -1,4 +1,4 @@ -# Generated by Django 3.2.20 on 2023-09-11 22:17 +# Generated by Django 3.2.20 on 2023-09-18 18:52 from django.db import migrations, models @@ -15,4 +15,9 @@ class Migration(migrations.Migration): name='preset', field=models.CharField(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 fa6531ced5..1085617cbc 100644 --- a/engine/apps/webhooks/models/webhook.py +++ b/engine/apps/webhooks/models/webhook.py @@ -139,7 +139,7 @@ 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) 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 0000000000..d38735d95a --- /dev/null +++ b/engine/apps/webhooks/tests/test_webhook_presets.py @@ -0,0 +1,46 @@ +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 conftest import ( + TEST_WEBHOOK_LOGO, + TEST_WEBHOOK_PRESET_DESCRIPTION, + TEST_WEBHOOK_PRESET_ID, + TEST_WEBHOOK_PRESET_IGNORED_FIELDS, + TEST_WEBHOOK_PRESET_NAME, + TEST_WEBHOOK_PRESET_URL, +) + + +@pytest.mark.django_db +def test_get_webhook_preset_options(webhook_preset_api_setup, make_user_auth_headers): + user, token, organization = webhook_preset_api_setup + 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]["ignored_fields"] == TEST_WEBHOOK_PRESET_IGNORED_FIELDS + + +@pytest.mark.django_db +def test_create_webhook_from_preset(webhook_preset_api_setup, make_user_auth_headers, make_custom_webhook): + user, token, organization = webhook_preset_api_setup + 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 diff --git a/engine/conftest.py b/engine/conftest.py index aca32ffe01..5bfb47ce0c 100644 --- a/engine/conftest.py +++ b/engine/conftest.py @@ -86,6 +86,8 @@ ) 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.models import Webhook +from apps.webhooks.presets.preset_options import WebhookPresetOptions from apps.webhooks.tests.factories import CustomWebhookFactory, WebhookResponseFactory register(OrganizationFactory) @@ -907,3 +909,34 @@ def _shift_swap_request_setup(**kwargs): return ssr, beneficiary, benefactor return _shift_swap_request_setup + + +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_IGNORED_FIELDS = ["url", "http_method", "data"] + + +def webhook_preset_override(instance: Webhook): + instance.data = instance.organization.org_title + instance.url = TEST_WEBHOOK_PRESET_URL + instance.http_method = "GET" + + +@pytest.fixture() +def webhook_preset_api_setup(): + WebhookPresetOptions.WEBHOOK_PRESET_CHOICES = [ + { + "id": TEST_WEBHOOK_PRESET_ID, + "name": TEST_WEBHOOK_PRESET_NAME, + "logo": TEST_WEBHOOK_LOGO, + "description": TEST_WEBHOOK_PRESET_DESCRIPTION, + "ignored_fields": TEST_WEBHOOK_PRESET_IGNORED_FIELDS, + } + ] + WebhookPresetOptions.WEBHOOK_PRESET_OVERRIDE[TEST_WEBHOOK_PRESET_ID] = webhook_preset_override + WebhookPresetOptions.WEBHOOK_PRESET_METADATA[TEST_WEBHOOK_PRESET_ID] = WebhookPresetOptions.WEBHOOK_PRESET_CHOICES[ + 0 + ] diff --git a/grafana-plugin/src/assets/img/grafana_ml_icon.svg b/grafana-plugin/src/assets/img/grafana_ml_icon.svg new file mode 100644 index 0000000000..3ebe54581e --- /dev/null +++ b/grafana-plugin/src/assets/img/grafana_ml_icon.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/grafana-plugin/src/components/IntegrationLogo/IntegrationLogo.module.css b/grafana-plugin/src/components/IntegrationLogo/IntegrationLogo.module.css index 04d9690215..3fbe276973 100644 --- a/grafana-plugin/src/components/IntegrationLogo/IntegrationLogo.module.css +++ b/grafana-plugin/src/components/IntegrationLogo/IntegrationLogo.module.css @@ -24,6 +24,12 @@ background-size: 100% !important; } +.bg_GrafanaSift, +.bg_GrafanaMachineLearning { + background: url(../../assets/img/grafana_ml_icon.svg); + background-size: 100% !important; +} + .bg_InboundEmail { background: url(../../assets/img/inbound-email.png); background-size: 100% !important; diff --git a/grafana-plugin/src/containers/OutgoingWebhookForm/OutgoingWebhookForm.config.tsx b/grafana-plugin/src/containers/OutgoingWebhookForm/OutgoingWebhookForm.config.tsx index 3e34552d0f..97a001f303 100644 --- a/grafana-plugin/src/containers/OutgoingWebhookForm/OutgoingWebhookForm.config.tsx +++ b/grafana-plugin/src/containers/OutgoingWebhookForm/OutgoingWebhookForm.config.tsx @@ -47,6 +47,7 @@ export function createForm(presets: OutgoingWebhookPreset[]): { name: string; fi valueField: 'id', showSearch: true, allowClear: true, + placeholder: 'Choose (Optional)', }, }, { @@ -55,6 +56,7 @@ export function createForm(presets: OutgoingWebhookPreset[]): { name: string; fi description: 'The type of event which will cause this webhook to execute.', type: FormItemType.Select, extra: { + placeholder: 'Choose (Required)', options: [ { value: WebhookTriggerType.EscalationStep.key, @@ -100,6 +102,7 @@ export function createForm(presets: OutgoingWebhookPreset[]): { name: string; fi label: 'HTTP Method', type: FormItemType.Select, extra: { + placeholder: 'Choose (Required)', options: [ { value: 'GET', @@ -137,6 +140,7 @@ export function createForm(presets: OutgoingWebhookPreset[]): { name: string; fi ); }, extra: { + placeholder: 'Choose (Optional)', modelName: 'alertReceiveChannelStore', displayField: 'verbal_name', valueField: 'id', @@ -206,7 +210,7 @@ export function createForm(presets: OutgoingWebhookPreset[]): { name: string; fi }, { name: 'forward_all', - normalize: (value) => Boolean(value), + 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) => { diff --git a/grafana-plugin/src/containers/OutgoingWebhookForm/OutgoingWebhookForm.module.css b/grafana-plugin/src/containers/OutgoingWebhookForm/OutgoingWebhookForm.module.css index 9969392c4d..3a41fb24a3 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 { From a7c6bc3e0c583d8195f35ea44e7e8d1fbdec22b8 Mon Sep 17 00:00:00 2001 From: Michael Derynck Date: Mon, 18 Sep 2023 20:03:21 -0600 Subject: [PATCH 10/20] Merge dev --- engine/apps/api/tests/test_webhook_presets.py | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/engine/apps/api/tests/test_webhook_presets.py b/engine/apps/api/tests/test_webhook_presets.py index a898a52fff..67bb322b22 100644 --- a/engine/apps/api/tests/test_webhook_presets.py +++ b/engine/apps/api/tests/test_webhook_presets.py @@ -12,7 +12,7 @@ def test_create_webhook_from_preset( make_organization_and_user_with_plugin_token, webhook_preset_api_setup, make_user_auth_headers ): - user, token, organization = make_organization_and_user_with_plugin_token + organization, user, token = make_organization_and_user_with_plugin_token() client = APIClient() url = reverse("api-internal:webhooks-list") @@ -51,6 +51,7 @@ def test_create_webhook_from_preset( "event_data": "", }, "trigger_template": None, + "trigger_type": str(data["trigger_type"]), "trigger_type_name": "Alert Group Created", } @@ -64,7 +65,7 @@ def test_create_webhook_from_preset( def test_invalid_create_webhook_with_preset( make_organization_and_user_with_plugin_token, webhook_preset_api_setup, make_user_auth_headers ): - user, token, organization = make_organization_and_user_with_plugin_token + organization, user, token = make_organization_and_user_with_plugin_token() client = APIClient() url = reverse("api-internal:webhooks-list") @@ -83,7 +84,7 @@ def test_invalid_create_webhook_with_preset( def test_update_webhook_from_preset( make_organization_and_user_with_plugin_token, webhook_preset_api_setup, make_user_auth_headers, make_custom_webhook ): - user, token, organization = make_organization_and_user_with_plugin_token + organization, user, token = make_organization_and_user_with_plugin_token() webhook = make_custom_webhook( name="the_webhook", organization=organization, @@ -112,7 +113,7 @@ def test_update_webhook_from_preset( 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 ): - user, token, organization = make_organization_and_user_with_plugin_token + organization, user, token = make_organization_and_user_with_plugin_token() webhook = make_custom_webhook( name="the_webhook", organization=organization, From 5c040713672da61963c64021239810d9ea587e3b Mon Sep 17 00:00:00 2001 From: Michael Derynck Date: Mon, 18 Sep 2023 20:28:18 -0600 Subject: [PATCH 11/20] Fix tests --- engine/apps/api/tests/test_webhook_presets.py | 27 +++++++++++++++++- .../webhooks/tests/test_webhook_presets.py | 28 +------------------ 2 files changed, 27 insertions(+), 28 deletions(-) diff --git a/engine/apps/api/tests/test_webhook_presets.py b/engine/apps/api/tests/test_webhook_presets.py index 67bb322b22..54005d9265 100644 --- a/engine/apps/api/tests/test_webhook_presets.py +++ b/engine/apps/api/tests/test_webhook_presets.py @@ -5,7 +5,32 @@ from apps.webhooks.models import Webhook from apps.webhooks.models.webhook import WEBHOOK_FIELD_PLACEHOLDER -from conftest import TEST_WEBHOOK_PRESET_ID, TEST_WEBHOOK_PRESET_URL +from conftest import ( + TEST_WEBHOOK_LOGO, + TEST_WEBHOOK_PRESET_DESCRIPTION, + TEST_WEBHOOK_PRESET_ID, + TEST_WEBHOOK_PRESET_IGNORED_FIELDS, + 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]["ignored_fields"] == TEST_WEBHOOK_PRESET_IGNORED_FIELDS @pytest.mark.django_db diff --git a/engine/apps/webhooks/tests/test_webhook_presets.py b/engine/apps/webhooks/tests/test_webhook_presets.py index d38735d95a..a0f4557fbd 100644 --- a/engine/apps/webhooks/tests/test_webhook_presets.py +++ b/engine/apps/webhooks/tests/test_webhook_presets.py @@ -1,33 +1,7 @@ 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 conftest import ( - TEST_WEBHOOK_LOGO, - TEST_WEBHOOK_PRESET_DESCRIPTION, - TEST_WEBHOOK_PRESET_ID, - TEST_WEBHOOK_PRESET_IGNORED_FIELDS, - TEST_WEBHOOK_PRESET_NAME, - TEST_WEBHOOK_PRESET_URL, -) - - -@pytest.mark.django_db -def test_get_webhook_preset_options(webhook_preset_api_setup, make_user_auth_headers): - user, token, organization = webhook_preset_api_setup - 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]["ignored_fields"] == TEST_WEBHOOK_PRESET_IGNORED_FIELDS +from conftest import TEST_WEBHOOK_PRESET_ID, TEST_WEBHOOK_PRESET_URL @pytest.mark.django_db From 4d9a9392476d87fba0204ed2d063c6ddb6f1eedc Mon Sep 17 00:00:00 2001 From: Michael Derynck Date: Mon, 18 Sep 2023 20:37:08 -0600 Subject: [PATCH 12/20] Fix tests --- engine/apps/webhooks/tests/test_webhook_presets.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/engine/apps/webhooks/tests/test_webhook_presets.py b/engine/apps/webhooks/tests/test_webhook_presets.py index a0f4557fbd..b103e41761 100644 --- a/engine/apps/webhooks/tests/test_webhook_presets.py +++ b/engine/apps/webhooks/tests/test_webhook_presets.py @@ -5,8 +5,8 @@ @pytest.mark.django_db -def test_create_webhook_from_preset(webhook_preset_api_setup, make_user_auth_headers, make_custom_webhook): - user, token, organization = webhook_preset_api_setup +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, From 45f0314c56ef0a84e59d816964d5ce6eae69d8fd Mon Sep 17 00:00:00 2001 From: Michael Derynck Date: Tue, 19 Sep 2023 21:21:40 -0600 Subject: [PATCH 13/20] Use abstract class for presets, add hook to change parameters at runtime. --- engine/apps/webhooks/presets/preset.py | 0 grafana-plugin/src/assets/img/grafana_ml_icon.svg | 1 - 2 files changed, 1 deletion(-) create mode 100644 engine/apps/webhooks/presets/preset.py delete mode 100644 grafana-plugin/src/assets/img/grafana_ml_icon.svg diff --git a/engine/apps/webhooks/presets/preset.py b/engine/apps/webhooks/presets/preset.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/grafana-plugin/src/assets/img/grafana_ml_icon.svg b/grafana-plugin/src/assets/img/grafana_ml_icon.svg deleted file mode 100644 index 3ebe54581e..0000000000 --- a/grafana-plugin/src/assets/img/grafana_ml_icon.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file From 4afad1a36e53a0936a997bc8e18a76fb758f1381 Mon Sep 17 00:00:00 2001 From: Michael Derynck Date: Tue, 19 Sep 2023 21:33:28 -0600 Subject: [PATCH 14/20] Use abstract class for presets, add hook to change parameters at runtime. --- docs/sources/outgoing-webhooks/_index.md | 2 +- engine/apps/api/serializers/webhook.py | 34 ++++++------- engine/apps/api/tests/test_webhook_presets.py | 4 +- engine/apps/api/views/webhooks.py | 3 +- engine/apps/webhooks/presets/advanced.py | 24 +++++---- engine/apps/webhooks/presets/preset.py | 36 +++++++++++++ .../apps/webhooks/presets/preset_options.py | 21 ++++---- engine/apps/webhooks/presets/simple.py | 50 +++++++++++-------- engine/apps/webhooks/tasks/trigger_webhook.py | 7 +++ .../webhooks/tests/test_webhook_presets.py | 28 ++++++++++- engine/conftest.py | 29 ++--------- engine/settings/base.py | 4 +- .../IntegrationLogo.module.css | 6 --- .../OutgoingWebhookForm.config.tsx | 2 +- .../outgoing_webhook.types.ts | 2 +- .../outgoing_webhooks/OutgoingWebhooks.tsx | 2 +- 16 files changed, 153 insertions(+), 101 deletions(-) diff --git a/docs/sources/outgoing-webhooks/_index.md b/docs/sources/outgoing-webhooks/_index.md index 1bfe5a3c67..82e1c2323b 100644 --- a/docs/sources/outgoing-webhooks/_index.md +++ b/docs/sources/outgoing-webhooks/_index.md @@ -30,7 +30,7 @@ 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 click **Create Webhook** and then +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 field described below. diff --git a/engine/apps/api/serializers/webhook.py b/engine/apps/api/serializers/webhook.py index 2e06d82c6d..832292ceb1 100644 --- a/engine/apps/api/serializers/webhook.py +++ b/engine/apps/api/serializers/webhook.py @@ -119,7 +119,7 @@ def validate_headers(self, headers): return self._validate_template_field(headers) def validate_url(self, url): - if self.is_field_ignored("url"): + if self.is_field_controlled("url"): return url if not url: @@ -127,7 +127,7 @@ def validate_url(self, url): return self._validate_template_field(url) def validate_http_method(self, http_method): - if self.is_field_ignored("http_method"): + if self.is_field_controlled("http_method"): return http_method if http_method not in PUBLIC_WEBHOOK_HTTP_METHODS: @@ -135,7 +135,7 @@ def validate_http_method(self, http_method): return http_method def validate_trigger_type(self, trigger_type): - if self.is_field_ignored("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: @@ -157,20 +157,21 @@ def validate_preset(self, preset): raise serializers.ValidationError(detail="This field once set cannot be modified.") if preset: - if preset not in WebhookPresetOptions.WEBHOOK_PRESET_METADATA: + if preset not in WebhookPresetOptions.WEBHOOK_PRESETS: raise serializers.ValidationError(detail=f"{preset} is not a valid preset id.") - preset_metadata = WebhookPresetOptions.WEBHOOK_PRESET_METADATA[preset] - ignored_fields = preset_metadata["ignored_fields"] - for ignored in ignored_fields: - if ignored in self.initial_data: + 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[ignored] != getattr(self.instance, ignored): + if self.initial_data[controlled_field] != getattr(self.instance, controlled_field): raise serializers.ValidationError( - detail=f"{ignored} is controlled by preset, cannot update" + detail=f"{controlled_field} is controlled by preset, cannot update" ) - elif self.initial_data[ignored] is not None: - raise serializers.ValidationError(detail=f"{ignored} is controlled by preset, cannot create") + elif self.initial_data[controlled_field] is not None: + raise serializers.ValidationError( + detail=f"{controlled_field} is controlled by preset, cannot create" + ) return preset @@ -183,7 +184,7 @@ def get_trigger_type_name(self, obj): trigger_type_name = Webhook.TRIGGER_TYPES[int(obj.trigger_type)][1] return trigger_type_name - def is_field_ignored(self, field_name): + def is_field_controlled(self, field_name): if self.instance: if not self.instance.preset: return False @@ -192,11 +193,10 @@ def is_field_ignored(self, field_name): preset_id = self.instance.preset if self.instance else self.initial_data["preset"] if preset_id: - if preset_id not in WebhookPresetOptions.WEBHOOK_PRESET_METADATA: + if preset_id not in WebhookPresetOptions.WEBHOOK_PRESETS: raise serializers.ValidationError(detail=f"unknown preset {preset_id} referenced") - preset_metadata = WebhookPresetOptions.WEBHOOK_PRESET_METADATA[preset_id] - ignored_fields = preset_metadata["ignored_fields"] - if field_name not in ignored_fields: + 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 index 54005d9265..92b2dfbf5d 100644 --- a/engine/apps/api/tests/test_webhook_presets.py +++ b/engine/apps/api/tests/test_webhook_presets.py @@ -5,7 +5,7 @@ from apps.webhooks.models import Webhook from apps.webhooks.models.webhook import WEBHOOK_FIELD_PLACEHOLDER -from conftest import ( +from apps.webhooks.tests.test_webhook_presets import ( TEST_WEBHOOK_LOGO, TEST_WEBHOOK_PRESET_DESCRIPTION, TEST_WEBHOOK_PRESET_ID, @@ -30,7 +30,7 @@ def test_get_webhook_preset_options( 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]["ignored_fields"] == TEST_WEBHOOK_PRESET_IGNORED_FIELDS + assert response.data[0]["controlled_fields"] == TEST_WEBHOOK_PRESET_IGNORED_FIELDS @pytest.mark.django_db diff --git a/engine/apps/api/views/webhooks.py b/engine/apps/api/views/webhooks.py index 2975ee415c..bd7dc8d7e4 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 @@ -184,5 +185,5 @@ def preview_template(self, request, pk): @action(methods=["get"], detail=False) def preset_options(self, request): - result = list(WebhookPresetOptions.WEBHOOK_PRESET_CHOICES) + result = [asdict(preset) for preset in WebhookPresetOptions.WEBHOOK_PRESET_CHOICES] return Response(result) diff --git a/engine/apps/webhooks/presets/advanced.py b/engine/apps/webhooks/presets/advanced.py index d864648433..1983943e13 100644 --- a/engine/apps/webhooks/presets/advanced.py +++ b/engine/apps/webhooks/presets/advanced.py @@ -1,13 +1,19 @@ from apps.webhooks.models import Webhook +from apps.webhooks.presets.preset import WebhookPreset, WebhookPresetMetadata -metadata = { - "id": "advanced_webhook", - "name": "Advanced", - "logo": "webhook", - "description": "An advanced webhook with all available settings and template options.", - "ignored_fields": [], -} +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_webhook_parameters(instance: Webhook): - pass + 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 index e69de29bb2..8e946476f2 100644 --- a/engine/apps/webhooks/presets/preset.py +++ 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 index e9ce986bfb..cc267f1172 100644 --- a/engine/apps/webhooks/presets/preset_options.py +++ b/engine/apps/webhooks/presets/preset_options.py @@ -1,4 +1,4 @@ -import importlib +from importlib import import_module from django.conf import settings from django.db.models.signals import pre_save @@ -8,22 +8,21 @@ class WebhookPresetOptions: - _config = tuple( - (importlib.import_module(webhook_preset_config) for webhook_preset_config in settings.INSTALLED_WEBHOOK_PRESETS) - ) + 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 _config] - WEBHOOK_PRESET_METADATA = {webhook_preset.metadata["id"]: webhook_preset.metadata for webhook_preset in _config} - WEBHOOK_PRESET_OVERRIDE = { - webhook_preset.metadata["id"]: webhook_preset.override_webhook_parameters for webhook_preset in _config - } + 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_PRESET_OVERRIDE: - WebhookPresetOptions.WEBHOOK_PRESET_OVERRIDE[instance.preset](instance) + 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}") diff --git a/engine/apps/webhooks/presets/simple.py b/engine/apps/webhooks/presets/simple.py index 44da921275..dc1db970cc 100644 --- a/engine/apps/webhooks/presets/simple.py +++ b/engine/apps/webhooks/presets/simple.py @@ -1,26 +1,32 @@ from apps.webhooks.models import Webhook +from apps.webhooks.presets.preset import WebhookPreset, WebhookPresetMetadata -metadata = { - "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.", - "ignored_fields": [ - "trigger_type", - "http_method", - "integration_filter", - "headers", - "username", - "password", - "authorization_header", - "trigger_template", - "forward_all", - "data", - ], -} +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_webhook_parameters(instance: Webhook): - instance.http_method = "POST" - instance.trigger_type = Webhook.TRIGGER_ESCALATION_STEP - instance.forward_all = True + 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 579624bfcb..2c97f2f371 100644 --- a/engine/apps/webhooks/tasks/trigger_webhook.py +++ b/engine/apps/webhooks/tasks/trigger_webhook.py @@ -11,6 +11,7 @@ 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 diff --git a/engine/apps/webhooks/tests/test_webhook_presets.py b/engine/apps/webhooks/tests/test_webhook_presets.py index b103e41761..81242e2394 100644 --- a/engine/apps/webhooks/tests/test_webhook_presets.py +++ b/engine/apps/webhooks/tests/test_webhook_presets.py @@ -1,7 +1,33 @@ import pytest from apps.webhooks.models import Webhook -from conftest import TEST_WEBHOOK_PRESET_ID, TEST_WEBHOOK_PRESET_URL +from apps.webhooks.presets.preset import WebhookPreset, WebhookPresetMetadata + +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_IGNORED_FIELDS = ["url", "http_method", "data"] + + +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_IGNORED_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): + pass @pytest.mark.django_db diff --git a/engine/conftest.py b/engine/conftest.py index 5bfb47ce0c..270b1ec270 100644 --- a/engine/conftest.py +++ b/engine/conftest.py @@ -86,9 +86,9 @@ ) 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.models import Webhook 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) @@ -911,32 +911,9 @@ def _shift_swap_request_setup(**kwargs): return _shift_swap_request_setup -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_IGNORED_FIELDS = ["url", "http_method", "data"] - - -def webhook_preset_override(instance: Webhook): - instance.data = instance.organization.org_title - instance.url = TEST_WEBHOOK_PRESET_URL - instance.http_method = "GET" - - @pytest.fixture() def webhook_preset_api_setup(): + WebhookPresetOptions.WEBHOOK_PRESETS = {TEST_WEBHOOK_PRESET_ID: TestWebhookPreset()} WebhookPresetOptions.WEBHOOK_PRESET_CHOICES = [ - { - "id": TEST_WEBHOOK_PRESET_ID, - "name": TEST_WEBHOOK_PRESET_NAME, - "logo": TEST_WEBHOOK_LOGO, - "description": TEST_WEBHOOK_PRESET_DESCRIPTION, - "ignored_fields": TEST_WEBHOOK_PRESET_IGNORED_FIELDS, - } - ] - WebhookPresetOptions.WEBHOOK_PRESET_OVERRIDE[TEST_WEBHOOK_PRESET_ID] = webhook_preset_override - WebhookPresetOptions.WEBHOOK_PRESET_METADATA[TEST_WEBHOOK_PRESET_ID] = WebhookPresetOptions.WEBHOOK_PRESET_CHOICES[ - 0 + preset.metadata for preset in WebhookPresetOptions.WEBHOOK_PRESETS.values() ] diff --git a/engine/settings/base.py b/engine/settings/base.py index 1d595ce73b..b8023ca94c 100644 --- a/engine/settings/base.py +++ b/engine/settings/base.py @@ -724,8 +724,8 @@ class BrokerTypes: ] INSTALLED_WEBHOOK_PRESETS = [ - "apps.webhooks.presets.simple", - "apps.webhooks.presets.advanced", + "apps.webhooks.presets.simple.SimpleWebhookPreset", + "apps.webhooks.presets.advanced.AdvancedWebhookPreset", ] if IS_OPEN_SOURCE: diff --git a/grafana-plugin/src/components/IntegrationLogo/IntegrationLogo.module.css b/grafana-plugin/src/components/IntegrationLogo/IntegrationLogo.module.css index 3fbe276973..04d9690215 100644 --- a/grafana-plugin/src/components/IntegrationLogo/IntegrationLogo.module.css +++ b/grafana-plugin/src/components/IntegrationLogo/IntegrationLogo.module.css @@ -24,12 +24,6 @@ background-size: 100% !important; } -.bg_GrafanaSift, -.bg_GrafanaMachineLearning { - background: url(../../assets/img/grafana_ml_icon.svg); - background-size: 100% !important; -} - .bg_InboundEmail { background: url(../../assets/img/inbound-email.png); background-size: 100% !important; diff --git a/grafana-plugin/src/containers/OutgoingWebhookForm/OutgoingWebhookForm.config.tsx b/grafana-plugin/src/containers/OutgoingWebhookForm/OutgoingWebhookForm.config.tsx index 97a001f303..d299ca94bb 100644 --- a/grafana-plugin/src/containers/OutgoingWebhookForm/OutgoingWebhookForm.config.tsx +++ b/grafana-plugin/src/containers/OutgoingWebhookForm/OutgoingWebhookForm.config.tsx @@ -237,7 +237,7 @@ function isPresetFieldVisible(presetId: string, presets: OutgoingWebhookPreset[] return true; } const selectedPreset = presets.find((item) => item.id === presetId); - if (selectedPreset && selectedPreset.ignored_fields.includes(fieldName)) { + if (selectedPreset && selectedPreset.controlled_fields.includes(fieldName)) { return false; } return true; diff --git a/grafana-plugin/src/models/outgoing_webhook/outgoing_webhook.types.ts b/grafana-plugin/src/models/outgoing_webhook/outgoing_webhook.types.ts index 231686c7f7..88d58af30e 100644 --- a/grafana-plugin/src/models/outgoing_webhook/outgoing_webhook.types.ts +++ b/grafana-plugin/src/models/outgoing_webhook/outgoing_webhook.types.ts @@ -37,5 +37,5 @@ export interface OutgoingWebhookPreset { name: string; description: string; logo: string; - ignored_fields: string[]; + controlled_fields: string[]; } diff --git a/grafana-plugin/src/pages/outgoing_webhooks/OutgoingWebhooks.tsx b/grafana-plugin/src/pages/outgoing_webhooks/OutgoingWebhooks.tsx index 96113075f0..d61294ee8d 100644 --- a/grafana-plugin/src/pages/outgoing_webhooks/OutgoingWebhooks.tsx +++ b/grafana-plugin/src/pages/outgoing_webhooks/OutgoingWebhooks.tsx @@ -186,7 +186,7 @@ class OutgoingWebhooks extends React.Component From 6373fe2a00d0271937bfee68791396cbbce2d257 Mon Sep 17 00:00:00 2001 From: Michael Derynck Date: Wed, 20 Sep 2023 12:25:27 -0600 Subject: [PATCH 15/20] Fix migration --- ...{0011_auto_20230918_1852.py => 0011_auto_20230920_1813.py} | 4 ++-- engine/apps/webhooks/models/webhook.py | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) rename engine/apps/webhooks/migrations/{0011_auto_20230918_1852.py => 0011_auto_20230920_1813.py} (77%) diff --git a/engine/apps/webhooks/migrations/0011_auto_20230918_1852.py b/engine/apps/webhooks/migrations/0011_auto_20230920_1813.py similarity index 77% rename from engine/apps/webhooks/migrations/0011_auto_20230918_1852.py rename to engine/apps/webhooks/migrations/0011_auto_20230920_1813.py index 6d7e81ee4f..76fbcd5f98 100644 --- a/engine/apps/webhooks/migrations/0011_auto_20230918_1852.py +++ b/engine/apps/webhooks/migrations/0011_auto_20230920_1813.py @@ -1,4 +1,4 @@ -# Generated by Django 3.2.20 on 2023-09-18 18:52 +# Generated by Django 3.2.20 on 2023-09-20 18:13 from django.db import migrations, models @@ -13,7 +13,7 @@ class Migration(migrations.Migration): migrations.AddField( model_name='webhook', name='preset', - field=models.CharField(default=None, max_length=100, null=True), + field=models.CharField(blank=True, default=None, max_length=100, null=True), ), migrations.AlterField( model_name='webhook', diff --git a/engine/apps/webhooks/models/webhook.py b/engine/apps/webhooks/models/webhook.py index 1085617cbc..eee3a36727 100644 --- a/engine/apps/webhooks/models/webhook.py +++ b/engine/apps/webhooks/models/webhook.py @@ -144,7 +144,7 @@ class Webhook(models.Model): 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, default=None) + preset = models.CharField(max_length=100, null=True, blank=True, default=None) class Meta: unique_together = ("name", "organization") From a2faf4a62545c13a88d97f001f2240f8e76922b2 Mon Sep 17 00:00:00 2001 From: Michael Derynck Date: Wed, 20 Sep 2023 12:42:37 -0600 Subject: [PATCH 16/20] Fix test --- engine/apps/api/tests/test_webhook_presets.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/engine/apps/api/tests/test_webhook_presets.py b/engine/apps/api/tests/test_webhook_presets.py index 2783fcf90e..e87f758718 100644 --- a/engine/apps/api/tests/test_webhook_presets.py +++ b/engine/apps/api/tests/test_webhook_presets.py @@ -46,7 +46,6 @@ def test_create_webhook_from_preset( "trigger_type": Webhook.TRIGGER_ALERT_GROUP_CREATED, "team": None, "password": "secret_password", - "authorization_header": "auth 1234", "preset": TEST_WEBHOOK_PRESET_ID, } response = client.post(url, data, format="json", **make_user_auth_headers(user, token)) @@ -58,7 +57,7 @@ def test_create_webhook_from_preset( "data": organization.org_title, "username": None, "password": WEBHOOK_FIELD_PLACEHOLDER, - "authorization_header": WEBHOOK_FIELD_PLACEHOLDER, + "authorization_header": None, "forward_all": True, "headers": None, "http_method": "GET", @@ -83,7 +82,6 @@ def test_create_webhook_from_preset( assert response.status_code == status.HTTP_201_CREATED assert response.json() == expected_response assert webhook.password == data["password"] - assert webhook.authorization_header == data["authorization_header"] @pytest.mark.django_db From 33ecededc060607849434cd09f02594de5433e1f Mon Sep 17 00:00:00 2001 From: Michael Derynck Date: Tue, 26 Sep 2023 09:24:13 -0600 Subject: [PATCH 17/20] Update docs/sources/outgoing-webhooks/_index.md Co-authored-by: Joey Orlando --- docs/sources/outgoing-webhooks/_index.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/sources/outgoing-webhooks/_index.md b/docs/sources/outgoing-webhooks/_index.md index 82e1c2323b..8469466e3d 100644 --- a/docs/sources/outgoing-webhooks/_index.md +++ b/docs/sources/outgoing-webhooks/_index.md @@ -33,7 +33,7 @@ To create an outgoing webhook navigate to **Outgoing Webhooks** and click **+ Cr 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 -field described below. +fields described below. ### Outgoing webhook fields From d9a5ddabedc1ba3f19c87d2933a997af221481e4 Mon Sep 17 00:00:00 2001 From: Michael Derynck Date: Tue, 26 Sep 2023 16:27:45 -0600 Subject: [PATCH 18/20] Fix import for test --- engine/apps/public_api/tests/test_webhooks.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/engine/apps/public_api/tests/test_webhooks.py b/engine/apps/public_api/tests/test_webhooks.py index 86a90b66d4..ea931dcd08 100644 --- a/engine/apps/public_api/tests/test_webhooks.py +++ b/engine/apps/public_api/tests/test_webhooks.py @@ -7,7 +7,7 @@ from apps.public_api.serializers.webhooks import PRESET_VALIDATION_MESSAGE from apps.webhooks.models import Webhook -from conftest import TEST_WEBHOOK_PRESET_ID +from apps.webhooks.tests.test_webhook_presets import TEST_WEBHOOK_PRESET_ID def _get_expected_result(webhook): From e5433a77bb6e5cf6c7b6bc5ab74ce96658255480 Mon Sep 17 00:00:00 2001 From: Michael Derynck Date: Tue, 26 Sep 2023 17:42:09 -0600 Subject: [PATCH 19/20] Fallback to webhook icon if other not found --- .../src/containers/OutgoingWebhookForm/OutgoingWebhookForm.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/grafana-plugin/src/containers/OutgoingWebhookForm/OutgoingWebhookForm.tsx b/grafana-plugin/src/containers/OutgoingWebhookForm/OutgoingWebhookForm.tsx index c6fe7081b9..1f2d1c4f49 100644 --- a/grafana-plugin/src/containers/OutgoingWebhookForm/OutgoingWebhookForm.tsx +++ b/grafana-plugin/src/containers/OutgoingWebhookForm/OutgoingWebhookForm.tsx @@ -407,12 +407,13 @@ const WebhookPresetBlocks: React.FC<{ {presets.length ? ( presets.map((preset) => { let tmpIcons = webhookPresetIcons; - let logo = <>?; + let logo = ; if (preset.logo in logoCoors) { logo = ; } else if (preset.logo in tmpIcons) { logo = tmpIcons[preset.logo](); } + return ( onBlockClick(preset)} key={preset.id} className={cx('card')}>
{logo}
From 684fe50f1b3af3509e8f2526e8f1916613304f11 Mon Sep 17 00:00:00 2001 From: Michael Derynck Date: Wed, 27 Sep 2023 06:02:55 -0600 Subject: [PATCH 20/20] Fix import to include additional preset icons --- .../OutgoingWebhookForm/OutgoingWebhookForm.tsx | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/grafana-plugin/src/containers/OutgoingWebhookForm/OutgoingWebhookForm.tsx b/grafana-plugin/src/containers/OutgoingWebhookForm/OutgoingWebhookForm.tsx index 1f2d1c4f49..61bfc8fe39 100644 --- a/grafana-plugin/src/containers/OutgoingWebhookForm/OutgoingWebhookForm.tsx +++ b/grafana-plugin/src/containers/OutgoingWebhookForm/OutgoingWebhookForm.tsx @@ -22,6 +22,7 @@ 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'; @@ -33,7 +34,6 @@ import { UserActions } from 'utils/authorization'; import { PLUGIN_ROOT } from 'utils/consts'; import { createForm } from './OutgoingWebhookForm.config'; -import { webhookPresetIcons } from './WebhookPresetIcons.config'; import styles from 'containers/OutgoingWebhookForm/OutgoingWebhookForm.module.css'; @@ -406,14 +406,12 @@ const WebhookPresetBlocks: React.FC<{
{presets.length ? ( presets.map((preset) => { - let tmpIcons = webhookPresetIcons; let logo = ; if (preset.logo in logoCoors) { logo = ; - } else if (preset.logo in tmpIcons) { - logo = tmpIcons[preset.logo](); + } else if (preset.logo in webhookPresetIcons) { + logo = webhookPresetIcons[preset.logo](); } - return ( onBlockClick(preset)} key={preset.id} className={cx('card')}>
{logo}