Add webhook presets (#2996)

# What this PR does
Add a system similar to how we select integrations when creating
webhooks so that the user has a description of what webhookds do and
does not have to write complex templates for common webhook use cases.
Presets allow us to create the contents of the webhooks in code and
define which fields are controlled by the preset. Some specifics:

- Newly created webhooks must choose between Simple, Advanced or another
predefined system
- Simple is always an escalation step and will post the entire payload
to the given URL
- Advanced is the same as no preset which is our current view where all
fields are available
- There are no changes for all existing webhooks with empty preset
fields
- Once a webhook is created with a preset the preset cannot be changed
- Fields in the webhook that are populated by code will give a
validation error if they are modified
- In the public API webhooks with presets are returned for viewing but
cannot be created or modified. This restriction is in place because the
Web UI provides the context for which fields to use with a preset. The
public API is for interacting with webhooks where all fields are
defined.

To define a preset create a file with metadata and an override function.
The metadata drives validation and what to display in the UI. There are
two functions one is connected to the pre_save hook of the Webhook model
for persistent changes, the other replaces parameters at execution time
for ephemeral changes. See the simple and advanced presets as an
example. The file must be listed in settings in
`INSTALLED_WEBHOOK_PRESETS` to be enabled at runtime..

## Which issue(s) this PR fixes

## Checklist

- [x] Unit, integration, and e2e (if applicable) tests updated
- [x] Documentation added (or `pr:no public docs` PR label added if not
required)
- [x] `CHANGELOG.md` updated (or `pr:no changelog` PR label added if not
required)

---------

Co-authored-by: Joey Orlando <joey.orlando@grafana.com>
This commit is contained in:
Michael Derynck 2023-09-27 07:22:52 -06:00 committed by GitHub
parent bba6eb333e
commit b5a8b8b168
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
28 changed files with 1150 additions and 215 deletions

View file

@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Added
- Presets for webhooks @mderynck ([#2996](https://github.com/grafana/oncall/pull/2996))
- Unify breadcrumbs behaviour with other Grafana Apps and main core ([#1906](https://github.com/grafana/oncall/issues/1906))
- Add `enable_web_overrides` option to schedules public API ([#3062](https://github.com/grafana/oncall/pull/3062))

View file

@ -30,8 +30,10 @@ Jinja2 templates to customize the request being sent.
## Creating an outgoing webhook
To create an outgoing webhook navigate to **Outgoing Webhooks** and click **+ Create**. On this screen outgoing
webhooks can be viewed, edited and deleted. To create the outgoing webhook populate the required fields and
click **Create Webhook**
webhooks can be viewed, edited and deleted. To create the outgoing webhook click **New Outgoing Webhook** and then
select a preset based on what you want to do. A simple webhook will POST alert group data as a selectable escalation
step to the specified url. If you require more customization use the advanced webhook which provides all of the
fields described below.
### Outgoing webhook fields

View file

@ -4,7 +4,8 @@ from rest_framework import serializers
from rest_framework.validators import UniqueTogetherValidator
from apps.webhooks.models import Webhook, WebhookResponse
from apps.webhooks.models.webhook import WEBHOOK_FIELD_PLACEHOLDER
from apps.webhooks.models.webhook import PUBLIC_WEBHOOK_HTTP_METHODS, WEBHOOK_FIELD_PLACEHOLDER
from apps.webhooks.presets.preset_options import WebhookPresetOptions
from common.api_helpers.custom_fields import TeamPrimaryKeyRelatedField
from common.api_helpers.utils import CurrentOrganizationDefault, CurrentTeamDefault, CurrentUserDefault
from common.jinja_templater import apply_jinja_template
@ -31,9 +32,9 @@ class WebhookSerializer(serializers.ModelSerializer):
organization = serializers.HiddenField(default=CurrentOrganizationDefault())
team = TeamPrimaryKeyRelatedField(allow_null=True, default=CurrentTeamDefault())
user = serializers.HiddenField(default=CurrentUserDefault())
trigger_type = serializers.CharField(required=True)
forward_all = serializers.BooleanField(allow_null=True, required=False)
last_response_log = serializers.SerializerMethodField()
trigger_type = serializers.CharField(allow_null=True)
trigger_type_name = serializers.SerializerMethodField()
class Meta:
@ -59,11 +60,8 @@ class WebhookSerializer(serializers.ModelSerializer):
"trigger_type_name",
"last_response_log",
"integration_filter",
"preset",
]
extra_kwargs = {
"name": {"required": True, "allow_null": False, "allow_blank": False},
"url": {"required": True, "allow_null": False, "allow_blank": False},
}
validators = [UniqueTogetherValidator(queryset=Webhook.objects.all(), fields=["name", "organization"])]
@ -78,6 +76,16 @@ class WebhookSerializer(serializers.ModelSerializer):
def to_internal_value(self, data):
webhook = self.instance
# Some fields are conditionally required, add none values for missing required fields
if webhook and webhook.preset and "preset" not in data:
data["preset"] = webhook.preset
for key in ["url", "http_method", "trigger_type"]:
if key not in data:
if self.instance:
data[key] = getattr(self.instance, key)
else:
data[key] = None
# If webhook is being copied instance won't exist to copy values from
if not webhook and "id" in data:
webhook = Webhook.objects.get(
@ -111,10 +119,29 @@ class WebhookSerializer(serializers.ModelSerializer):
return self._validate_template_field(headers)
def validate_url(self, url):
if self.is_field_controlled("url"):
return url
if not url:
return None
raise serializers.ValidationError(detail="This field is required.")
return self._validate_template_field(url)
def validate_http_method(self, http_method):
if self.is_field_controlled("http_method"):
return http_method
if http_method not in PUBLIC_WEBHOOK_HTTP_METHODS:
raise serializers.ValidationError(detail=f"This field must be one of {PUBLIC_WEBHOOK_HTTP_METHODS}.")
return http_method
def validate_trigger_type(self, trigger_type):
if self.is_field_controlled("trigger_type"):
return trigger_type
if not trigger_type or int(trigger_type) not in Webhook.ALL_TRIGGER_TYPES:
raise serializers.ValidationError(detail="This field is required.")
return trigger_type
def validate_data(self, data):
if not data:
return None
@ -125,6 +152,29 @@ class WebhookSerializer(serializers.ModelSerializer):
return False
return data
def validate_preset(self, preset):
if self.instance and self.instance.preset != preset:
raise serializers.ValidationError(detail="This field once set cannot be modified.")
if preset:
if preset not in WebhookPresetOptions.WEBHOOK_PRESETS:
raise serializers.ValidationError(detail=f"{preset} is not a valid preset id.")
preset_metadata = WebhookPresetOptions.WEBHOOK_PRESETS[preset].metadata
for controlled_field in preset_metadata.controlled_fields:
if controlled_field in self.initial_data:
if self.instance:
if self.initial_data[controlled_field] != getattr(self.instance, controlled_field):
raise serializers.ValidationError(
detail=f"{controlled_field} is controlled by preset, cannot update"
)
elif self.initial_data[controlled_field] is not None:
raise serializers.ValidationError(
detail=f"{controlled_field} is controlled by preset, cannot create"
)
return preset
def get_last_response_log(self, obj):
return WebhookResponseSerializer(obj.responses.all().last()).data
@ -133,3 +183,20 @@ class WebhookSerializer(serializers.ModelSerializer):
if obj.trigger_type is not None:
trigger_type_name = Webhook.TRIGGER_TYPES[int(obj.trigger_type)][1]
return trigger_type_name
def is_field_controlled(self, field_name):
if self.instance:
if not self.instance.preset:
return False
elif "preset" not in self.initial_data:
return False
preset_id = self.instance.preset if self.instance else self.initial_data["preset"]
if preset_id:
if preset_id not in WebhookPresetOptions.WEBHOOK_PRESETS:
raise serializers.ValidationError(detail=f"unknown preset {preset_id} referenced")
preset = WebhookPresetOptions.WEBHOOK_PRESETS[preset_id]
if field_name not in preset.metadata.controlled_fields:
return False
return True

View file

@ -0,0 +1,161 @@
import pytest
from django.urls import reverse
from rest_framework import status
from rest_framework.test import APIClient
from apps.webhooks.models import Webhook
from apps.webhooks.models.webhook import WEBHOOK_FIELD_PLACEHOLDER
from apps.webhooks.tests.test_webhook_presets import (
TEST_WEBHOOK_LOGO,
TEST_WEBHOOK_PRESET_CONTROLLED_FIELDS,
TEST_WEBHOOK_PRESET_DESCRIPTION,
TEST_WEBHOOK_PRESET_ID,
TEST_WEBHOOK_PRESET_NAME,
TEST_WEBHOOK_PRESET_URL,
)
@pytest.mark.django_db
def test_get_webhook_preset_options(
make_organization_and_user_with_plugin_token, webhook_preset_api_setup, make_user_auth_headers
):
organization, user, token = make_organization_and_user_with_plugin_token()
client = APIClient()
url = reverse("api-internal:webhooks-preset-options")
response = client.get(url, format="json", **make_user_auth_headers(user, token))
assert response.status_code == status.HTTP_200_OK
assert response.data[0]["id"] == TEST_WEBHOOK_PRESET_ID
assert response.data[0]["name"] == TEST_WEBHOOK_PRESET_NAME
assert response.data[0]["logo"] == TEST_WEBHOOK_LOGO
assert response.data[0]["description"] == TEST_WEBHOOK_PRESET_DESCRIPTION
assert response.data[0]["controlled_fields"] == TEST_WEBHOOK_PRESET_CONTROLLED_FIELDS
@pytest.mark.django_db
def test_create_webhook_from_preset(
make_organization_and_user_with_plugin_token, webhook_preset_api_setup, make_user_auth_headers
):
organization, user, token = make_organization_and_user_with_plugin_token()
client = APIClient()
url = reverse("api-internal:webhooks-list")
data = {
"name": "the_webhook",
"trigger_type": Webhook.TRIGGER_ALERT_GROUP_CREATED,
"team": None,
"password": "secret_password",
"preset": TEST_WEBHOOK_PRESET_ID,
}
response = client.post(url, data, format="json", **make_user_auth_headers(user, token))
webhook = Webhook.objects.get(public_primary_key=response.data["id"])
expected_response = data | {
"id": webhook.public_primary_key,
"url": TEST_WEBHOOK_PRESET_URL,
"data": organization.org_title,
"username": None,
"password": WEBHOOK_FIELD_PLACEHOLDER,
"authorization_header": None,
"forward_all": True,
"headers": None,
"http_method": "GET",
"integration_filter": None,
"is_webhook_enabled": True,
"is_legacy": False,
"last_response_log": {
"request_data": "",
"request_headers": "",
"timestamp": None,
"content": "",
"status_code": None,
"request_trigger": "",
"url": "",
"event_data": "",
},
"trigger_template": None,
"trigger_type": str(data["trigger_type"]),
"trigger_type_name": "Alert Group Created",
}
assert response.status_code == status.HTTP_201_CREATED
assert response.json() == expected_response
assert webhook.password == data["password"]
@pytest.mark.django_db
def test_invalid_create_webhook_with_preset(
make_organization_and_user_with_plugin_token, webhook_preset_api_setup, make_user_auth_headers
):
organization, user, token = make_organization_and_user_with_plugin_token()
client = APIClient()
url = reverse("api-internal:webhooks-list")
data = {
"name": "the_webhook",
"trigger_type": Webhook.TRIGGER_ALERT_GROUP_CREATED,
"url": "https://test12345.com",
"preset": TEST_WEBHOOK_PRESET_ID,
}
response = client.post(url, data, format="json", **make_user_auth_headers(user, token))
assert response.status_code == status.HTTP_400_BAD_REQUEST
assert response.json()["preset"][0] == "url is controlled by preset, cannot create"
@pytest.mark.django_db
def test_update_webhook_from_preset(
make_organization_and_user_with_plugin_token, webhook_preset_api_setup, make_user_auth_headers, make_custom_webhook
):
organization, user, token = make_organization_and_user_with_plugin_token()
webhook = make_custom_webhook(
name="the_webhook",
organization=organization,
trigger_type=Webhook.TRIGGER_ALERT_GROUP_CREATED,
preset=TEST_WEBHOOK_PRESET_ID,
)
client = APIClient()
url = reverse("api-internal:webhooks-detail", kwargs={"pk": webhook.public_primary_key})
data = {
"name": "the_webhook 2",
}
response = client.put(url, data, format="json", **make_user_auth_headers(user, token))
assert response.status_code == status.HTTP_200_OK
assert response.json()["name"] == data["name"]
webhook.refresh_from_db()
assert webhook.name == data["name"]
assert webhook.url == TEST_WEBHOOK_PRESET_URL
assert webhook.http_method == "GET"
assert webhook.data == organization.org_title
@pytest.mark.django_db
def test_invalid_update_webhook_from_preset(
make_organization_and_user_with_plugin_token, webhook_preset_api_setup, make_user_auth_headers, make_custom_webhook
):
organization, user, token = make_organization_and_user_with_plugin_token()
webhook = make_custom_webhook(
name="the_webhook",
organization=organization,
trigger_type=Webhook.TRIGGER_ALERT_GROUP_CREATED,
preset=TEST_WEBHOOK_PRESET_ID,
)
client = APIClient()
url = reverse("api-internal:webhooks-detail", kwargs={"pk": webhook.public_primary_key})
data = {
"preset": "some_other_preset",
}
response = client.put(url, data, format="json", **make_user_auth_headers(user, token))
assert response.status_code == status.HTTP_400_BAD_REQUEST
assert response.json()["preset"][0] == "This field once set cannot be modified."
data = {
"data": "some_other_data",
}
response = client.put(url, data, format="json", **make_user_auth_headers(user, token))
assert response.status_code == status.HTTP_400_BAD_REQUEST

View file

@ -66,6 +66,7 @@ def test_get_list_webhooks(webhook_internal_api_setup, make_user_auth_headers):
"trigger_template": None,
"trigger_type": "0",
"trigger_type_name": "Escalation step",
"preset": None,
}
]
@ -108,6 +109,7 @@ def test_get_detail_webhook(webhook_internal_api_setup, make_user_auth_headers):
"trigger_template": None,
"trigger_type": "0",
"trigger_type_name": "Escalation step",
"preset": None,
}
response = client.get(url, format="json", **make_user_auth_headers(user, token))
@ -124,7 +126,8 @@ def test_create_webhook(webhook_internal_api_setup, make_user_auth_headers):
data = {
"name": "the_webhook",
"url": TEST_URL,
"trigger_type": str(Webhook.TRIGGER_ALERT_GROUP_CREATED),
"trigger_type": Webhook.TRIGGER_ALERT_GROUP_CREATED,
"http_method": "POST",
"team": None,
}
response = client.post(url, data, format="json", **make_user_auth_headers(user, token))
@ -152,7 +155,9 @@ def test_create_webhook(webhook_internal_api_setup, make_user_auth_headers):
"event_data": "",
},
"trigger_template": None,
"trigger_type": str(data["trigger_type"]),
"trigger_type_name": "Alert Group Created",
"preset": None,
}
assert response.status_code == status.HTTP_201_CREATED
assert response.json() == expected_response
@ -179,7 +184,8 @@ def test_create_valid_templated_field(webhook_internal_api_setup, make_user_auth
"name": "webhook_with_valid_data",
"url": TEST_URL,
field_name: value,
"trigger_type": str(Webhook.TRIGGER_ALERT_GROUP_CREATED),
"trigger_type": Webhook.TRIGGER_ALERT_GROUP_CREATED,
"http_method": "POST",
"team": None,
}
@ -209,7 +215,9 @@ def test_create_valid_templated_field(webhook_internal_api_setup, make_user_auth
"event_data": "",
},
"trigger_template": None,
"trigger_type": str(data["trigger_type"]),
"trigger_type_name": "Alert Group Created",
"preset": None,
}
# update expected value for changed field
expected_response[field_name] = value
@ -236,7 +244,8 @@ def test_create_invalid_templated_field(webhook_internal_api_setup, make_user_au
"name": "webhook_with_valid_data",
"url": TEST_URL,
field_name: value,
"trigger_type": str(Webhook.TRIGGER_ALERT_GROUP_CREATED),
"trigger_type": Webhook.TRIGGER_ALERT_GROUP_CREATED,
"http_method": "POST",
"team": None,
}
@ -253,7 +262,8 @@ def test_update_webhook(webhook_internal_api_setup, make_user_auth_headers):
data = {
"name": "github_button_updated",
"url": "https://github.com/",
"trigger_type": str(Webhook.TRIGGER_ALERT_GROUP_CREATED),
"trigger_type": Webhook.TRIGGER_ALERT_GROUP_CREATED,
"http_method": "POST",
"team": None,
}
response = client.put(
@ -547,7 +557,8 @@ def test_webhook_field_masking(webhook_internal_api_setup, make_user_auth_header
data = {
"name": "the_webhook",
"url": TEST_URL,
"trigger_type": str(Webhook.TRIGGER_ALERT_GROUP_CREATED),
"trigger_type": Webhook.TRIGGER_ALERT_GROUP_CREATED,
"http_method": "POST",
"team": None,
"password": "secret_password",
"authorization_header": "auth 1234",
@ -579,7 +590,9 @@ def test_webhook_field_masking(webhook_internal_api_setup, make_user_auth_header
"event_data": "",
},
"trigger_template": None,
"trigger_type": str(data["trigger_type"]),
"trigger_type_name": "Alert Group Created",
"preset": None,
}
assert response.status_code == status.HTTP_201_CREATED
@ -598,7 +611,8 @@ def test_webhook_copy(webhook_internal_api_setup, make_user_auth_headers):
data = {
"name": "the_webhook",
"url": TEST_URL,
"trigger_type": str(Webhook.TRIGGER_ALERT_GROUP_CREATED),
"trigger_type": Webhook.TRIGGER_ALERT_GROUP_CREATED,
"http_method": "POST",
"team": None,
"password": "secret_password",
"authorization_header": "auth 1234",
@ -635,7 +649,9 @@ def test_webhook_copy(webhook_internal_api_setup, make_user_auth_headers):
"event_data": "",
},
"trigger_template": None,
"trigger_type": str(data["trigger_type"]),
"trigger_type_name": "Alert Group Created",
"preset": None,
}
assert response3.status_code == status.HTTP_201_CREATED
@ -644,3 +660,49 @@ def test_webhook_copy(webhook_internal_api_setup, make_user_auth_headers):
assert webhook.authorization_header == data["authorization_header"]
assert webhook.id != to_copy["id"]
assert webhook.user == user
@pytest.mark.django_db
def test_create_invalid_missing_fields(webhook_internal_api_setup, make_user_auth_headers):
user, token, webhook = webhook_internal_api_setup
client = APIClient()
url = reverse("api-internal:webhooks-list")
data = {"url": TEST_URL, "trigger_type": Webhook.TRIGGER_ALERT_GROUP_CREATED, "http_method": "POST"}
response = client.post(url, data, format="json", **make_user_auth_headers(user, token))
assert response.status_code == status.HTTP_400_BAD_REQUEST
assert response.json()["name"][0] == "This field is required."
data = {"name": "test webhook 1", "trigger_type": Webhook.TRIGGER_ALERT_GROUP_CREATED, "http_method": "POST"}
response = client.post(url, data, format="json", **make_user_auth_headers(user, token))
assert response.status_code == status.HTTP_400_BAD_REQUEST
assert response.json()["url"][0] == "This field is required."
data = {"name": "test webhook 2", "url": TEST_URL, "http_method": "POST"}
response = client.post(url, data, format="json", **make_user_auth_headers(user, token))
assert response.status_code == status.HTTP_400_BAD_REQUEST
assert response.json()["trigger_type"][0] == "This field is required."
data = {
"name": "test webhook 3",
"url": TEST_URL,
"trigger_type": Webhook.TRIGGER_ALERT_GROUP_CREATED,
}
response = client.post(url, data, format="json", **make_user_auth_headers(user, token))
assert response.status_code == status.HTTP_400_BAD_REQUEST
assert response.json()["http_method"][0] == "This field must be one of ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS']."
data = {
"name": "test webhook 3",
"url": TEST_URL,
"trigger_type": Webhook.TRIGGER_ALERT_GROUP_CREATED,
"http_method": "TOAST",
}
response = client.post(url, data, format="json", **make_user_auth_headers(user, token))
assert response.status_code == status.HTTP_400_BAD_REQUEST
assert response.json()["http_method"][0] == "This field must be one of ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS']."
data = {"name": "test webhook 3", "url": TEST_URL, "trigger_type": 2000000, "http_method": "POST"}
response = client.post(url, data, format="json", **make_user_auth_headers(user, token))
assert response.status_code == status.HTTP_400_BAD_REQUEST
assert response.json()["trigger_type"][0] == "This field is required."

View file

@ -1,4 +1,5 @@
import json
from dataclasses import asdict
from django.core.exceptions import ObjectDoesNotExist
from django_filters import rest_framework as filters
@ -14,6 +15,7 @@ from apps.api.permissions import RBACPermission
from apps.api.serializers.webhook import WebhookResponseSerializer, WebhookSerializer
from apps.auth_token.auth import PluginAuthentication
from apps.webhooks.models import Webhook, WebhookResponse
from apps.webhooks.presets.preset_options import WebhookPresetOptions
from apps.webhooks.utils import apply_jinja_template_for_json
from common.api_helpers.exceptions import BadRequest
from common.api_helpers.filters import ByTeamModelFieldFilterMixin, ModelFieldFilterMixin, TeamModelMultipleChoiceFilter
@ -52,6 +54,7 @@ class WebhooksView(TeamFilteringMixin, PublicPrimaryKeyMixin, ModelViewSet):
"destroy": [RBACPermission.Permissions.OUTGOING_WEBHOOKS_WRITE],
"responses": [RBACPermission.Permissions.OUTGOING_WEBHOOKS_READ],
"preview_template": [RBACPermission.Permissions.OUTGOING_WEBHOOKS_WRITE],
"preset_options": [RBACPermission.Permissions.OUTGOING_WEBHOOKS_READ],
}
model = Webhook
@ -179,3 +182,8 @@ class WebhooksView(TeamFilteringMixin, PublicPrimaryKeyMixin, ModelViewSet):
response = {"preview": result}
return Response(response, status=status.HTTP_200_OK)
@action(methods=["get"], detail=False)
def preset_options(self, request):
result = [asdict(preset) for preset in WebhookPresetOptions.WEBHOOK_PRESET_CHOICES]
return Response(result)

View file

@ -12,6 +12,8 @@ from common.api_helpers.utils import CurrentOrganizationDefault, CurrentTeamDefa
from common.jinja_templater import apply_jinja_template
from common.jinja_templater.apply_jinja_template import JinjaTemplateError, JinjaTemplateWarning
PRESET_VALIDATION_MESSAGE = "Preset webhooks must be modified through web UI"
INTEGRATION_FILTER_MESSAGE = "integration_filter must be a list of valid integration ids"
@ -73,6 +75,7 @@ class WebhookCreateSerializer(serializers.ModelSerializer):
"http_method",
"trigger_type",
"integration_filter",
"preset",
]
extra_kwargs = {
"name": {"required": True, "allow_null": False, "allow_blank": False},
@ -157,6 +160,14 @@ class WebhookCreateSerializer(serializers.ModelSerializer):
raise serializers.ValidationError(INTEGRATION_FILTER_MESSAGE)
return integration_filter
def validate_preset(self, preset):
raise serializers.ValidationError(PRESET_VALIDATION_MESSAGE)
def validate(self, data):
if self.instance and self.instance.preset:
raise serializers.ValidationError(PRESET_VALIDATION_MESSAGE)
return data
class WebhookUpdateSerializer(WebhookCreateSerializer):
trigger_type = WebhookTriggerTypeField(required=False)

View file

@ -5,7 +5,9 @@ from django.urls import reverse
from rest_framework import status
from rest_framework.test import APIClient
from apps.public_api.serializers.webhooks import PRESET_VALIDATION_MESSAGE
from apps.webhooks.models import Webhook
from apps.webhooks.tests.test_webhook_presets import TEST_WEBHOOK_PRESET_ID
def _get_expected_result(webhook):
@ -25,6 +27,7 @@ def _get_expected_result(webhook):
"http_method": webhook.http_method,
"trigger_type": Webhook.PUBLIC_TRIGGER_TYPES_MAP[webhook.trigger_type],
"integration_filter": webhook.integration_filter,
"preset": webhook.preset,
}
@ -357,3 +360,70 @@ def test_webhook_validate_integration_filters(
assert response.status_code == 200
assert response.data["integration_filter"] == data["integration_filter"]
assert webhook.integration_filter == data["integration_filter"]
@pytest.mark.django_db
def test_get_webhook_with_preset(
make_organization_and_user_with_token,
make_custom_webhook,
webhook_preset_api_setup,
):
organization, user, token = make_organization_and_user_with_token()
client = APIClient()
webhook = make_custom_webhook(organization=organization, preset=TEST_WEBHOOK_PRESET_ID)
url = reverse("api-public:webhooks-list")
response = client.get(url, format="json", HTTP_AUTHORIZATION=f"{token}")
expected_payload = {
"count": 1,
"next": None,
"previous": None,
"results": [_get_expected_result(webhook)],
"current_page_number": 1,
"page_size": 50,
"total_pages": 1,
}
assert response.status_code == status.HTTP_200_OK
assert response.data == expected_payload
@pytest.mark.django_db
def test_webhook_block_preset_create(
make_organization_and_user_with_token,
webhook_preset_api_setup,
):
organization, user, token = make_organization_and_user_with_token()
client = APIClient()
url = reverse("api-public:webhooks-list")
data = {
"name": "Test outgoing webhook with nested data",
"trigger_type": "acknowledge",
"preset": TEST_WEBHOOK_PRESET_ID,
}
response = client.post(url, data=data, format="json", HTTP_AUTHORIZATION=f"{token}")
assert response.status_code == status.HTTP_400_BAD_REQUEST
assert response.json()["preset"][0] == PRESET_VALIDATION_MESSAGE
@pytest.mark.django_db
def test_webhook_block_preset_update(
make_organization_and_user_with_token,
make_custom_webhook,
webhook_preset_api_setup,
):
organization, user, token = make_organization_and_user_with_token()
client = APIClient()
webhook = make_custom_webhook(organization=organization, preset=TEST_WEBHOOK_PRESET_ID)
webhook.refresh_from_db()
url = reverse("api-public:webhooks-detail", kwargs={"pk": webhook.public_primary_key})
data = {
"name": "Test rename preset webhook",
}
response = client.put(url, data=data, format="json", HTTP_AUTHORIZATION=f"{token}")
assert response.status_code == status.HTTP_400_BAD_REQUEST
assert response.json()["non_field_errors"][0] == PRESET_VALIDATION_MESSAGE

View file

@ -0,0 +1,23 @@
# Generated by Django 3.2.20 on 2023-09-20 18:13
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('webhooks', '0010_alter_webhook_trigger_type'),
]
operations = [
migrations.AddField(
model_name='webhook',
name='preset',
field=models.CharField(blank=True, default=None, max_length=100, null=True),
),
migrations.AlterField(
model_name='webhook',
name='http_method',
field=models.CharField(default='POST', max_length=32, null=True),
),
]

View file

@ -94,6 +94,8 @@ class Webhook(models.Model):
(TRIGGER_UNACKNOWLEDGE, "Unacknowledged"),
)
ALL_TRIGGER_TYPES = [i[0] for i in TRIGGER_TYPES]
PUBLIC_TRIGGER_TYPES_MAP = {
TRIGGER_ESCALATION_STEP: "escalation",
TRIGGER_ALERT_GROUP_CREATED: "alert group created",
@ -137,11 +139,12 @@ class Webhook(models.Model):
url = models.TextField(null=True, default=None)
data = models.TextField(null=True, default=None)
forward_all = models.BooleanField(default=True)
http_method = models.CharField(max_length=32, default="POST")
http_method = models.CharField(max_length=32, default="POST", null=True)
trigger_type = models.IntegerField(choices=TRIGGER_TYPES, default=TRIGGER_ESCALATION_STEP, null=True)
is_webhook_enabled = models.BooleanField(null=True, default=True)
integration_filter = models.JSONField(default=None, null=True, blank=True)
is_legacy = models.BooleanField(null=True, default=False)
preset = models.CharField(max_length=100, null=True, blank=True, default=None)
class Meta:
unique_together = ("name", "organization")

View file

View file

@ -0,0 +1,19 @@
from apps.webhooks.models import Webhook
from apps.webhooks.presets.preset import WebhookPreset, WebhookPresetMetadata
class AdvancedWebhookPreset(WebhookPreset):
def _metadata(self) -> WebhookPresetMetadata:
return WebhookPresetMetadata(
id="advanced_webhook",
name="Advanced",
logo="webhook",
description="An advanced webhook with all available settings and template options.",
controlled_fields=[],
)
def override_parameters_before_save(self, webhook: Webhook):
pass
def override_parameters_at_runtime(self, webhook: Webhook):
pass

View file

@ -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

View file

@ -0,0 +1,30 @@
from importlib import import_module
from django.conf import settings
from django.db.models.signals import pre_save
from django.dispatch import receiver
from apps.webhooks.models import Webhook
class WebhookPresetOptions:
WEBHOOK_PRESETS = {}
for webhook_preset_config in settings.INSTALLED_WEBHOOK_PRESETS:
module_path, class_name = webhook_preset_config.rsplit(".", 1)
module = import_module(module_path)
preset = getattr(module, class_name)()
WEBHOOK_PRESETS[preset.metadata.id] = preset
WEBHOOK_PRESET_CHOICES = [webhook_preset.metadata for webhook_preset in WEBHOOK_PRESETS.values()]
@receiver(pre_save, sender=Webhook)
def listen_for_webhook_save(sender: Webhook, instance: Webhook, raw: bool, *args, **kwargs) -> None:
if instance.preset:
if instance.preset in WebhookPresetOptions.WEBHOOK_PRESETS:
WebhookPresetOptions.WEBHOOK_PRESETS[instance.preset].override_parameters_before_save(instance)
else:
raise NotImplementedError(f"Webhook references unknown preset implementation {instance.preset}")
pre_save.connect(listen_for_webhook_save, Webhook)

View file

@ -0,0 +1,32 @@
from apps.webhooks.models import Webhook
from apps.webhooks.presets.preset import WebhookPreset, WebhookPresetMetadata
class SimpleWebhookPreset(WebhookPreset):
def _metadata(self) -> WebhookPresetMetadata:
return WebhookPresetMetadata(
id="simple_webhook",
name="Simple",
logo="webhook",
description="A simple webhook which sends the alert group data to a given URL. Triggered as an escalation step.",
controlled_fields=[
"trigger_type",
"http_method",
"integration_filter",
"headers",
"username",
"password",
"authorization_header",
"trigger_template",
"forward_all",
"data",
],
)
def override_parameters_before_save(self, webhook: Webhook):
webhook.http_method = "POST"
webhook.trigger_type = Webhook.TRIGGER_ESCALATION_STEP
webhook.forward_all = True
def override_parameters_at_runtime(self, webhook: Webhook):
pass

View file

@ -11,6 +11,7 @@ from apps.base.models import UserNotificationPolicyLogRecord
from apps.user_management.models import User
from apps.webhooks.models import Webhook, WebhookResponse
from apps.webhooks.models.webhook import WEBHOOK_FIELD_PLACEHOLDER
from apps.webhooks.presets.preset_options import WebhookPresetOptions
from apps.webhooks.utils import (
InvalidWebhookData,
InvalidWebhookHeaders,
@ -116,6 +117,12 @@ def make_request(webhook, alert_group, data):
exception = error = None
try:
if webhook.preset:
if webhook.preset not in WebhookPresetOptions.WEBHOOK_PRESETS:
raise Exception(f"Invalid preset {webhook.preset}")
else:
WebhookPresetOptions.WEBHOOK_PRESETS[webhook.preset].override_parameters_at_runtime(webhook)
if not webhook.check_integration_filter(alert_group):
status["request_trigger"] = NOT_FROM_SELECTED_INTEGRATION
return False, status, None, None
@ -168,7 +175,7 @@ def execute_webhook(webhook_pk, alert_group_id, user_id, escalation_policy_id):
try:
webhook = Webhook.objects.get(pk=webhook_pk)
except Webhook.DoesNotExist:
logger.warn(f"Webhook {webhook_pk} does not exist")
logger.warning(f"Webhook {webhook_pk} does not exist")
return
try:

View file

@ -0,0 +1,160 @@
from unittest.mock import patch
import pytest
from apps.webhooks.models import Webhook
from apps.webhooks.presets.preset import WebhookPreset, WebhookPresetMetadata
from apps.webhooks.tasks.trigger_webhook import make_request
from apps.webhooks.tests.test_trigger_webhook import MockResponse
TEST_WEBHOOK_PRESET_URL = "https://test123.com"
TEST_WEBHOOK_PRESET_NAME = "Test Webhook"
TEST_WEBHOOK_PRESET_ID = "test_webhook"
TEST_WEBHOOK_LOGO = "test_logo"
TEST_WEBHOOK_PRESET_DESCRIPTION = "Description of test webhook preset"
TEST_WEBHOOK_PRESET_CONTROLLED_FIELDS = ["url", "http_method", "data", "authorization_header"]
TEST_WEBHOOK_AUTHORIZATION_HEADER = "Test Auth header 12345"
INVALID_PRESET_ID = "invalid_preset_id"
class TestWebhookPreset(WebhookPreset):
def _metadata(self) -> WebhookPresetMetadata:
return WebhookPresetMetadata(
id=TEST_WEBHOOK_PRESET_ID,
name=TEST_WEBHOOK_PRESET_NAME,
logo=TEST_WEBHOOK_LOGO,
description=TEST_WEBHOOK_PRESET_DESCRIPTION,
controlled_fields=TEST_WEBHOOK_PRESET_CONTROLLED_FIELDS,
)
def override_parameters_before_save(self, webhook: Webhook):
webhook.data = webhook.organization.org_title
webhook.url = TEST_WEBHOOK_PRESET_URL
webhook.http_method = "GET"
def override_parameters_at_runtime(self, webhook: Webhook):
webhook.authorization_header = TEST_WEBHOOK_AUTHORIZATION_HEADER
@pytest.mark.django_db
def test_create_webhook_from_preset(make_organization, webhook_preset_api_setup, make_custom_webhook):
organization = make_organization()
webhook = make_custom_webhook(
name="the_webhook",
organization=organization,
trigger_type=Webhook.TRIGGER_ALERT_GROUP_CREATED,
preset=TEST_WEBHOOK_PRESET_ID,
)
webhook.refresh_from_db()
assert webhook.url == TEST_WEBHOOK_PRESET_URL
assert webhook.http_method == "GET"
assert webhook.data == organization.org_title
assert webhook.authorization_header is None
@pytest.mark.django_db
def test_create_webhook_from_invalid_preset(make_organization, webhook_preset_api_setup, make_custom_webhook):
organization = make_organization()
expected = None
try:
make_custom_webhook(
name="the_webhook",
organization=organization,
trigger_type=Webhook.TRIGGER_ALERT_GROUP_CREATED,
preset=INVALID_PRESET_ID,
)
except NotImplementedError as e:
expected = e
assert expected.args[0] == f"Webhook references unknown preset implementation {INVALID_PRESET_ID}"
@pytest.mark.django_db
def test_update_webhook_from_preset(make_organization, webhook_preset_api_setup, make_custom_webhook):
organization = make_organization()
webhook = make_custom_webhook(
name="the_webhook",
organization=organization,
trigger_type=Webhook.TRIGGER_ALERT_GROUP_CREATED,
preset=TEST_WEBHOOK_PRESET_ID,
)
webhook.refresh_from_db()
webhook.http_method = "POST"
webhook.save()
webhook.refresh_from_db()
assert webhook.http_method == "GET"
@pytest.mark.django_db
def test_update_webhook_from_invalid_preset(make_organization, webhook_preset_api_setup, make_custom_webhook):
organization = make_organization()
webhook = make_custom_webhook(
name="the_webhook",
organization=organization,
trigger_type=Webhook.TRIGGER_ALERT_GROUP_CREATED,
preset=TEST_WEBHOOK_PRESET_ID,
)
webhook.refresh_from_db()
webhook.preset = INVALID_PRESET_ID
try:
webhook.save()
except NotImplementedError as e:
expected = e
assert expected.args[0] == f"Webhook references unknown preset implementation {INVALID_PRESET_ID}"
webhook.refresh_from_db()
assert webhook.preset == TEST_WEBHOOK_PRESET_ID
@pytest.mark.django_db
def test_webhook_preset_runtime_override(make_organization, webhook_preset_api_setup, make_custom_webhook):
organization = make_organization()
webhook = make_custom_webhook(
name="the_webhook",
organization=organization,
trigger_type=Webhook.TRIGGER_ALERT_GROUP_CREATED,
preset=TEST_WEBHOOK_PRESET_ID,
)
with patch.object(webhook, "build_url"):
response = MockResponse()
with patch.object(webhook, "make_request", return_value=response) as mock_make_request:
triggered, webhook_status, error, exception = make_request(webhook, None, None)
assert mock_make_request.call_args.args[1]["headers"]["Authorization"] == TEST_WEBHOOK_AUTHORIZATION_HEADER
assert triggered
assert error is None
assert exception is None
webhook.refresh_from_db()
assert webhook.authorization_header is None
@pytest.mark.django_db
def test_webhook_invalid_preset_runtime_override(make_organization, webhook_preset_api_setup, make_custom_webhook):
organization = make_organization()
webhook = make_custom_webhook(
name="the_webhook",
organization=organization,
trigger_type=Webhook.TRIGGER_ALERT_GROUP_CREATED,
)
webhook.refresh_from_db()
expected_error = f"Invalid preset {INVALID_PRESET_ID}"
Webhook.objects.filter(id=webhook.id).update(preset=INVALID_PRESET_ID)
webhook.refresh_from_db()
with patch.object(webhook, "build_url"):
with patch.object(webhook, "make_request") as mock_make_request:
triggered, webhook_status, error, exception = make_request(webhook, None, None)
mock_make_request.assert_not_called()
assert triggered
assert webhook_status["content"] == expected_error
assert error == expected_error
assert exception.args[0] == expected_error
webhook.refresh_from_db()
assert webhook.authorization_header is None

View file

@ -86,7 +86,9 @@ from apps.telegram.tests.factories import (
)
from apps.user_management.models.user import User, listen_for_user_model_save
from apps.user_management.tests.factories import OrganizationFactory, RegionFactory, TeamFactory, UserFactory
from apps.webhooks.presets.preset_options import WebhookPresetOptions
from apps.webhooks.tests.factories import CustomWebhookFactory, WebhookResponseFactory
from apps.webhooks.tests.test_webhook_presets import TEST_WEBHOOK_PRESET_ID, TestWebhookPreset
register(OrganizationFactory)
register(UserFactory)
@ -907,3 +909,11 @@ def shift_swap_request_setup(
return ssr, beneficiary, benefactor
return _shift_swap_request_setup
@pytest.fixture()
def webhook_preset_api_setup():
WebhookPresetOptions.WEBHOOK_PRESETS = {TEST_WEBHOOK_PRESET_ID: TestWebhookPreset()}
WebhookPresetOptions.WEBHOOK_PRESET_CHOICES = [
preset.metadata for preset in WebhookPresetOptions.WEBHOOK_PRESETS.values()
]

View file

@ -723,6 +723,11 @@ INSTALLED_ONCALL_INTEGRATIONS = [
"config_integrations.direct_paging",
]
INSTALLED_WEBHOOK_PRESETS = [
"apps.webhooks.presets.simple.SimpleWebhookPreset",
"apps.webhooks.presets.advanced.AdvancedWebhookPreset",
]
if IS_OPEN_SOURCE:
INSTALLED_APPS += ["apps.oss_installation", "apps.zvonok"] # noqa

View file

@ -0,0 +1,3 @@
import { ReactElement } from 'react';
export const commonWebhookPresetIconsConfig: { [id: string]: () => ReactElement } = {};

View file

@ -4,6 +4,7 @@ import { SelectableValue } from '@grafana/data';
import Emoji from 'react-emoji-render';
import { FormItem, FormItemType } from 'components/GForm/GForm.types';
import { OutgoingWebhookPreset } from 'models/outgoing_webhook/outgoing_webhook.types';
import { KeyValuePair } from 'utils';
import { generateAssignToTeamInputDescription } from 'utils/consts';
@ -18,182 +19,226 @@ export const WebhookTriggerType = {
Unacknowledged: new KeyValuePair('7', 'Unacknowledged'),
};
export const form: { name: string; fields: FormItem[] } = {
name: 'OutgoingWebhook',
fields: [
{
name: 'name',
type: FormItemType.Input,
validation: { required: true },
},
{
name: 'is_webhook_enabled',
label: 'Enabled',
normalize: (value) => Boolean(value),
type: FormItemType.Switch,
},
{
name: 'team',
label: 'Assign to Team',
description: `${generateAssignToTeamInputDescription(
'Outgoing Webhooks'
)} This setting does not effect execution of the webhook.`,
type: FormItemType.GSelect,
extra: {
modelName: 'grafanaTeamStore',
displayField: 'name',
valueField: 'id',
showSearch: true,
allowClear: true,
export function createForm(presets: OutgoingWebhookPreset[]): { name: string; fields: FormItem[] } {
return {
name: 'OutgoingWebhook',
fields: [
{
name: 'name',
type: FormItemType.Input,
validation: { required: true },
},
},
{
name: 'trigger_type',
label: 'Trigger Type',
description: 'The type of event which will cause this webhook to execute.',
type: FormItemType.Select,
extra: {
options: [
{
value: WebhookTriggerType.EscalationStep.key,
label: WebhookTriggerType.EscalationStep.value,
},
{
value: WebhookTriggerType.AlertGroupCreated.key,
label: WebhookTriggerType.AlertGroupCreated.value,
},
{
value: WebhookTriggerType.Acknowledged.key,
label: WebhookTriggerType.Acknowledged.value,
},
{
value: WebhookTriggerType.Resolved.key,
label: WebhookTriggerType.Resolved.value,
},
{
value: WebhookTriggerType.Silenced.key,
label: WebhookTriggerType.Silenced.value,
},
{
value: WebhookTriggerType.Unsilenced.key,
label: WebhookTriggerType.Unsilenced.value,
},
{
value: WebhookTriggerType.Unresolved.key,
label: WebhookTriggerType.Unresolved.value,
},
{
value: WebhookTriggerType.Unacknowledged.key,
label: WebhookTriggerType.Unacknowledged.value,
},
],
{
name: 'is_webhook_enabled',
label: 'Enabled',
normalize: (value) => Boolean(value),
type: FormItemType.Switch,
},
validation: { required: true },
normalize: (value) => value,
},
{
name: 'http_method',
label: 'HTTP Method',
type: FormItemType.Select,
extra: {
options: [
{
value: 'GET',
label: 'GET',
},
{
value: 'POST',
label: 'POST',
},
{
value: 'PUT',
label: 'PUT',
},
{
value: 'DELETE',
label: 'DELETE',
},
{
value: 'OPTIONS',
label: 'OPTIONS',
},
],
{
name: 'team',
label: 'Assign to Team',
description: `${generateAssignToTeamInputDescription(
'Outgoing Webhooks'
)} This setting does not effect execution of the webhook.`,
type: FormItemType.GSelect,
extra: {
modelName: 'grafanaTeamStore',
displayField: 'name',
valueField: 'id',
showSearch: true,
allowClear: true,
placeholder: 'Choose (Optional)',
},
},
validation: { required: true },
normalize: (value) => value,
},
{
name: 'integration_filter',
label: 'Integrations',
type: FormItemType.MultiSelect,
isVisible: (data) => {
return data.trigger_type !== WebhookTriggerType.EscalationStep.key;
{
name: 'trigger_type',
label: 'Trigger Type',
description: 'The type of event which will cause this webhook to execute.',
type: FormItemType.Select,
extra: {
placeholder: 'Choose (Required)',
options: [
{
value: WebhookTriggerType.EscalationStep.key,
label: WebhookTriggerType.EscalationStep.value,
},
{
value: WebhookTriggerType.AlertGroupCreated.key,
label: WebhookTriggerType.AlertGroupCreated.value,
},
{
value: WebhookTriggerType.Acknowledged.key,
label: WebhookTriggerType.Acknowledged.value,
},
{
value: WebhookTriggerType.Resolved.key,
label: WebhookTriggerType.Resolved.value,
},
{
value: WebhookTriggerType.Silenced.key,
label: WebhookTriggerType.Silenced.value,
},
{
value: WebhookTriggerType.Unsilenced.key,
label: WebhookTriggerType.Unsilenced.value,
},
{
value: WebhookTriggerType.Unresolved.key,
label: WebhookTriggerType.Unresolved.value,
},
{
value: WebhookTriggerType.Unacknowledged.key,
label: WebhookTriggerType.Unacknowledged.value,
},
],
},
isVisible: (data) => {
return isPresetFieldVisible(data.preset, presets, 'trigger_type');
},
normalize: (value) => value,
},
extra: {
modelName: 'alertReceiveChannelStore',
displayField: 'verbal_name',
valueField: 'id',
showSearch: true,
getOptionLabel: (item: SelectableValue) => <Emoji text={item?.label || ''} />,
{
name: 'http_method',
label: 'HTTP Method',
type: FormItemType.Select,
extra: {
placeholder: 'Choose (Required)',
options: [
{
value: 'GET',
label: 'GET',
},
{
value: 'POST',
label: 'POST',
},
{
value: 'PUT',
label: 'PUT',
},
{
value: 'DELETE',
label: 'DELETE',
},
{
value: 'OPTIONS',
label: 'OPTIONS',
},
],
},
isVisible: (data) => isPresetFieldVisible(data.preset, presets, 'http_method'),
normalize: (value) => value,
},
validation: { required: true },
description:
'Integrations that this webhook applies to. If this is empty the webhook will execute for all integrations',
},
{
name: 'url',
label: 'Webhook URL',
type: FormItemType.Monaco,
validation: { required: true },
extra: {
height: 30,
{
name: 'integration_filter',
label: 'Integrations',
type: FormItemType.MultiSelect,
isVisible: (data) => {
return (
isPresetFieldVisible(data.preset, presets, 'integration_filter') &&
data.trigger_type !== WebhookTriggerType.EscalationStep.key
);
},
extra: {
placeholder: 'Choose (Optional)',
modelName: 'alertReceiveChannelStore',
displayField: 'verbal_name',
valueField: 'id',
showSearch: true,
getOptionLabel: (item: SelectableValue) => <Emoji text={item?.label || ''} />,
},
description:
'Integrations that this webhook applies to. If this is empty the webhook will execute for all integrations',
},
},
{
name: 'headers',
label: 'Webhook Headers',
description: 'Request headers should be in JSON format.',
type: FormItemType.Monaco,
extra: {
rows: 3,
{
name: 'url',
label: 'Webhook URL',
type: FormItemType.Monaco,
extra: {
height: 30,
},
isVisible: (data) => {
return isPresetFieldVisible(data.preset, presets, 'url');
},
},
},
{
name: 'username',
type: FormItemType.Input,
},
{
name: 'password',
type: FormItemType.Password,
},
{
name: 'authorization_header',
description:
'Value of the Authorization header, do not need to prefix with "Authorization:". For example: Bearer AbCdEf123456',
type: FormItemType.Password,
},
{
name: 'trigger_template',
type: FormItemType.Monaco,
description:
'Trigger template is used to conditionally execute the webhook based on incoming data. The trigger template must be empty or evaluate to true or 1 for the webhook to be sent',
extra: {
rows: 2,
{
name: 'headers',
label: 'Webhook Headers',
description: 'Request headers should be in JSON format.',
type: FormItemType.Monaco,
extra: {
rows: 3,
},
isVisible: (data) => {
return isPresetFieldVisible(data.preset, presets, 'headers');
},
},
},
{
name: 'forward_all',
normalize: (value) => Boolean(value),
type: FormItemType.Switch,
description: "Forwards whole payload of the alert group and context data to the webhook's url as POST/PUT data",
},
{
name: 'data',
getDisabled: (data) => Boolean(data?.forward_all),
type: FormItemType.Monaco,
description:
'Available variables: {{ event }}, {{ user }}, {{ alert_group }}, {{ alert_group_id }}, {{ alert_payload }}, {{ integration }}, {{ notified_users }}, {{ users_to_be_notified }}, {{ responses }}',
extra: {},
},
],
};
{
name: 'username',
type: FormItemType.Input,
isVisible: (data) => {
return isPresetFieldVisible(data.preset, presets, 'username');
},
},
{
name: 'password',
type: FormItemType.Password,
isVisible: (data) => {
return isPresetFieldVisible(data.preset, presets, 'password');
},
},
{
name: 'authorization_header',
description:
'Value of the Authorization header, do not need to prefix with "Authorization:". For example: Bearer AbCdEf123456',
type: FormItemType.Password,
isVisible: (data) => {
return isPresetFieldVisible(data.preset, presets, 'authorization_header');
},
},
{
name: 'trigger_template',
type: FormItemType.Monaco,
description:
'Trigger template is used to conditionally execute the webhook based on incoming data. The trigger template must be empty or evaluate to true or 1 for the webhook to be sent',
extra: {
rows: 2,
},
isVisible: (data) => {
return isPresetFieldVisible(data.preset, presets, 'trigger_template');
},
},
{
name: 'forward_all',
normalize: (value) => (value ? Boolean(value) : value),
type: FormItemType.Switch,
description: "Forwards whole payload of the alert group and context data to the webhook's url as POST/PUT data",
isVisible: (data) => {
return isPresetFieldVisible(data.preset, presets, 'forward_all');
},
},
{
name: 'data',
getDisabled: (data) => Boolean(data?.forward_all),
type: FormItemType.Monaco,
description:
'Available variables: {{ event }}, {{ user }}, {{ alert_group }}, {{ alert_group_id }}, {{ alert_payload }}, {{ integration }}, {{ notified_users }}, {{ users_to_be_notified }}, {{ responses }}',
extra: {},
isVisible: (data) => {
return isPresetFieldVisible(data.preset, presets, 'data');
},
},
],
};
}
function isPresetFieldVisible(presetId: string, presets: OutgoingWebhookPreset[], fieldName: string) {
if (presetId == null) {
return true;
}
const selectedPreset = presets.find((item) => item.id === presetId);
if (selectedPreset && selectedPreset.controlled_fields.includes(fieldName)) {
return false;
}
return true;
}

View file

@ -3,7 +3,7 @@
}
.title {
margin: 16px 0 0 16px;
margin: 0 0 0 16px;
}
.content {
@ -28,3 +28,31 @@
.webhooks__drawerContent .cursor.monaco-mouse-cursor-text {
display: none !important;
}
.cards {
display: flex;
flex-wrap: wrap;
gap: 24px;
overflow: auto;
scroll-snap-type: y mandatory;
width: 100%;
}
.card {
width: 100%;
height: 106px;
scroll-snap-align: start;
scroll-snap-stop: normal;
display: flex;
flex-direction: row;
align-items: center;
justify-content: flex-start;
cursor: pointer;
position: relative;
gap: 20px;
}
.search-integration {
width: 100%;
margin-bottom: 24px;
}

View file

@ -1,24 +1,39 @@
import React, { useCallback, useState } from 'react';
import React, { ChangeEvent, useCallback, useState } from 'react';
import { Button, ConfirmModal, ConfirmModalProps, Drawer, HorizontalGroup, Tab, TabsBar } from '@grafana/ui';
import {
Button,
ConfirmModal,
ConfirmModalProps,
Drawer,
EmptySearchResult,
HorizontalGroup,
Input,
Tab,
TabsBar,
VerticalGroup,
} from '@grafana/ui';
import cn from 'classnames/bind';
import { observer } from 'mobx-react';
import { useHistory } from 'react-router-dom';
import Block from 'components/GBlock/Block';
import GForm from 'components/GForm/GForm';
import { FormItem, FormItemType } from 'components/GForm/GForm.types';
import IntegrationLogo from 'components/IntegrationLogo/IntegrationLogo';
import { logoCoors } from 'components/IntegrationLogo/IntegrationLogo.config';
import Text from 'components/Text/Text';
import { webhookPresetIcons } from 'containers/OutgoingWebhookForm/WebhookPresetIcons.config';
import OutgoingWebhookStatus from 'containers/OutgoingWebhookStatus/OutgoingWebhookStatus';
import WebhooksTemplateEditor from 'containers/WebhooksTemplateEditor/WebhooksTemplateEditor';
import { WithPermissionControlTooltip } from 'containers/WithPermissionControl/WithPermissionControlTooltip';
import { OutgoingWebhook } from 'models/outgoing_webhook/outgoing_webhook.types';
import { OutgoingWebhook, OutgoingWebhookPreset } from 'models/outgoing_webhook/outgoing_webhook.types';
import { WebhookFormActionType } from 'pages/outgoing_webhooks/OutgoingWebhooks.types';
import { useStore } from 'state/useStore';
import { KeyValuePair } from 'utils';
import { UserActions } from 'utils/authorization';
import { PLUGIN_ROOT } from 'utils/consts';
import { form } from './OutgoingWebhookForm.config';
import { createForm } from './OutgoingWebhookForm.config';
import styles from 'containers/OutgoingWebhookForm/OutgoingWebhookForm.module.css';
@ -45,10 +60,15 @@ const OutgoingWebhookForm = observer((props: OutgoingWebhookFormProps) => {
const [activeTab, setActiveTab] = useState<string>(
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<OutgoingWebhookPreset>(undefined);
const [filterValue, setFilterValue] = useState('');
const { outgoingWebhookStore } = useStore();
const isNew = action === WebhookFormActionType.NEW;
const isNewOrCopy = isNew || action === WebhookFormActionType.COPY;
const form = createForm(outgoingWebhookStore.outgoingWebhookPresets);
const handleSubmit = useCallback(
(data: Partial<OutgoingWebhook>) => {
@ -104,10 +124,17 @@ const OutgoingWebhookForm = observer((props: OutgoingWebhookFormProps) => {
| {
is_webhook_enabled: boolean;
is_legacy: boolean;
preset: string;
};
if (isNew) {
data = { is_webhook_enabled: true, is_legacy: false };
data = {
is_webhook_enabled: true,
is_legacy: false,
preset: selectedPreset?.id,
trigger_type: null,
http_method: 'POST',
};
} else if (isNewOrCopy) {
data = { ...outgoingWebhookStore.items[id], is_legacy: false, name: '' };
} else {
@ -123,27 +150,69 @@ const OutgoingWebhookForm = observer((props: OutgoingWebhookFormProps) => {
}
const formElement = <GForm form={form} data={data} onSubmit={handleSubmit} onFieldRender={enrchField} />;
const createWebhookParameters = (
<>
<Drawer scrollableContent title={'New Outgoing Webhook'} onClose={onHide} closeOnMaskClick={false}>
<div className="webhooks__drawerContent">{renderWebhookForm()}</div>
</Drawer>
{templateToEdit && (
<WebhooksTemplateEditor
id={id}
handleSubmit={(value) => {
onFormChangeFn?.fn(value);
setTemplateToEdit(undefined);
}}
onHide={() => setTemplateToEdit(undefined)}
template={templateToEdit}
/>
)}
</>
);
if (action === WebhookFormActionType.NEW || action === WebhookFormActionType.COPY) {
// show just the creation form, not the tabs
const presets = outgoingWebhookStore.outgoingWebhookPresets.filter((preset: OutgoingWebhookPreset) =>
preset.name.toLowerCase().includes(filterValue.toLowerCase())
);
if (action === WebhookFormActionType.NEW) {
return (
<>
<Drawer scrollableContent title={'Create Outgoing Webhook'} onClose={onHide} closeOnMaskClick={false}>
<div className="webhooks__drawerContent">{renderWebhookForm()}</div>
</Drawer>
{templateToEdit && (
<WebhooksTemplateEditor
id={id}
handleSubmit={(value) => {
onFormChangeFn?.fn(value);
setTemplateToEdit(undefined);
}}
onHide={() => setTemplateToEdit(undefined)}
template={templateToEdit}
/>
{showPresetsListDrawer && (
<Drawer
scrollableContent
title="New Outgoing Webhook"
onClose={onHide}
closeOnMaskClick={false}
width="640px"
>
<div className={cx('content')}>
<VerticalGroup>
<Text type="secondary">
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.
</Text>
{presets.length > 8 && (
<div className={cx('search-integration')}>
<Input
autoFocus
value={filterValue}
placeholder="Search webhook presets ..."
onChange={(e: ChangeEvent<HTMLInputElement>) => setFilterValue(e.currentTarget.value)}
/>
</div>
)}
<WebhookPresetBlocks presets={presets} onBlockClick={onBlockClick} />
</VerticalGroup>
</div>
</Drawer>
)}
{(showCreateWebhookDrawer || !showPresetsListDrawer) && createWebhookParameters}
</>
);
} else if (action === WebhookFormActionType.COPY) {
return createWebhookParameters;
}
return (
@ -200,6 +269,12 @@ const OutgoingWebhookForm = observer((props: OutgoingWebhookFormProps) => {
</>
);
function onBlockClick(preset: OutgoingWebhookPreset) {
setSelectedPreset(preset);
setShowCreateWebhookDrawer(true);
setShowPresetsListDrawer(false);
}
function renderWebhookForm() {
return (
<>
@ -207,9 +282,21 @@ const OutgoingWebhookForm = observer((props: OutgoingWebhookFormProps) => {
<GForm form={form} data={data} onSubmit={handleSubmit} onFieldRender={enrchField} />
<div className={cx('buttons')}>
<HorizontalGroup justify={'flex-end'}>
<Button variant="secondary" onClick={onHide}>
Cancel
</Button>
{id === 'new' ? (
<Button
variant="secondary"
onClick={() => {
setShowCreateWebhookDrawer(false);
setShowPresetsListDrawer(true);
}}
>
Back
</Button>
) : (
<Button variant="secondary" onClick={onHide}>
Cancel
</Button>
)}
<WithPermissionControlTooltip userAction={UserActions.OutgoingWebhooksWrite}>
<Button form={form.name} type="submit" disabled={data.is_legacy}>
{isNewOrCopy ? 'Create' : 'Update'} Webhook
@ -232,6 +319,7 @@ interface WebhookTabsProps {
| {
is_webhook_enabled: boolean;
is_legacy: boolean;
preset: string;
};
onHide: () => void;
onUpdate: () => void;
@ -251,7 +339,8 @@ const WebhookTabsContent: React.FC<WebhookTabsProps> = ({
formElement,
}) => {
const [confirmationModal, setConfirmationModal] = useState<ConfirmModalProps>(undefined);
const { outgoingWebhookStore } = useStore();
const form = createForm(outgoingWebhookStore.outgoingWebhookPresets);
return (
<div className={cx('tabs__content')}>
{confirmationModal && (
@ -309,4 +398,43 @@ const WebhookTabsContent: React.FC<WebhookTabsProps> = ({
);
};
const WebhookPresetBlocks: React.FC<{
presets: OutgoingWebhookPreset[];
onBlockClick: (preset: OutgoingWebhookPreset) => void;
}> = ({ presets, onBlockClick }) => {
return (
<div className={cx('cards')} data-testid="create-outgoing-webhook-modal">
{presets.length ? (
presets.map((preset) => {
let logo = <IntegrationLogo integration={{ value: 'webhook', display_name: preset.name }} scale={0.2} />;
if (preset.logo in logoCoors) {
logo = <IntegrationLogo integration={{ value: preset.logo, display_name: preset.name }} scale={0.2} />;
} else if (preset.logo in webhookPresetIcons) {
logo = webhookPresetIcons[preset.logo]();
}
return (
<Block bordered hover shadowed onClick={() => onBlockClick(preset)} key={preset.id} className={cx('card')}>
<div className={cx('card-bg')}>{logo}</div>
<div className={cx('title')}>
<VerticalGroup spacing="xs">
<HorizontalGroup>
<Text strong data-testid="webhook-preset-display-name">
{preset.name}
</Text>
</HorizontalGroup>
<Text type="secondary" size="small">
{preset.description}
</Text>
</VerticalGroup>
</div>
</Block>
);
})
) : (
<EmptySearchResult>Could not find anything matching your query</EmptySearchResult>
)}
</div>
);
};
export default OutgoingWebhookForm;

View file

@ -0,0 +1,5 @@
import { ReactElement } from 'react';
import { commonWebhookPresetIconsConfig } from './CommonWebhookPresetIcons.config';
export const webhookPresetIcons: { [id: string]: () => ReactElement } = commonWebhookPresetIconsConfig;

View file

@ -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
@ -13,6 +13,9 @@ export class OutgoingWebhookStore extends BaseStore {
@observable.shallow
searchResult: { [key: string]: Array<OutgoingWebhook['id']> } = {};
@observable.shallow
outgoingWebhookPresets: OutgoingWebhookPreset[] = [];
constructor(rootStore: RootStore) {
super(rootStore);
@ -97,4 +100,10 @@ export class OutgoingWebhookStore extends BaseStore {
data: { template_name, template_body, payload },
});
}
@action
async updateOutgoingWebhookPresets() {
const response = await makeRequest(`/webhooks/preset_options/`, {});
this.outgoingWebhookPresets = response;
}
}

View file

@ -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,11 @@ export interface OutgoingWebhookResponse {
content: string;
event_data: string;
}
export interface OutgoingWebhookPreset {
id: string;
name: string;
description: string;
logo: string;
controlled_fields: string[];
}

View file

@ -186,7 +186,7 @@ class OutgoingWebhooks extends React.Component<OutgoingWebhooksProps, OutgoingWe
>
<WithPermissionControlTooltip userAction={UserActions.OutgoingWebhooksWrite}>
<Button variant="primary" icon="plus">
Create
New Outgoing Webhook
</Button>
</WithPermissionControlTooltip>
</PluginLink>

View file

@ -130,6 +130,7 @@ export class RootBaseStore {
this.userStore.updateNotificationPolicyOptions(),
this.userStore.updateNotifyByOptions(),
this.alertReceiveChannelStore.updateAlertReceiveChannelOptions(),
this.outgoingWebhookStore.updateOutgoingWebhookPresets(),
this.escalationPolicyStore.updateWebEscalationPolicyOptions(),
this.escalationPolicyStore.updateEscalationPolicyOptions(),
this.escalationPolicyStore.updateNumMinutesInWindowOptions(),