Add manual trigger support for webhooks (#4934)

Related to https://github.com/grafana/oncall/issues/4748

- Added support for additional filters when getting webhooks:
- `GET
/api/plugins/grafana-oncall-app/resources/webhooks/?integration=CALBFV7RRDH93`
  (filter webhooks that are enabled for the specified integration)
- `GET
/api/plugins/grafana-oncall-app/resources/webhooks/?trigger_type=0`
  (filter webhooks with the given trigger type)
- Allow triggering a Manual webhook using an alert group as context:
`POST /api/plugins/grafana-oncall-app/resources/webhooks/<webhook public
ID>/trigger_manual`
Example payload: `{"alert_group": "I4A4I1UPSA7IC"}`
(will return a 200 OK on success)

---------

Co-authored-by: Rares Mardare <rares.mardare@grafana.com>
This commit is contained in:
Matias Bordese 2024-09-09 09:17:23 -03:00 committed by GitHub
parent fc07a22c56
commit e93858e136
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
24 changed files with 1117 additions and 187 deletions

View file

@ -108,7 +108,7 @@ This setting does not restrict outgoing webhook execution to events from the sel
The type of event that will cause this outgoing webhook to execute. The types of triggers are:
- [Escalation Step](#escalation-step)
- [Manual or Escalation Step](#escalation-step)
- [Alert Group Created](#alert-group-created)
- [Acknowledged](#acknowledged)
- [Resolved](#resolved)
@ -480,6 +480,7 @@ Now the result is correct:
`event.type` `escalation`
This event will trigger when the outgoing webhook is included as a step in an escalation chain.
Webhooks with this trigger type can also be manually triggered in the context of an alert group in the web UI.
### Alert Group Created

View file

@ -2130,7 +2130,7 @@ def _webhook_data(webhook_id=ANY, webhook_name=ANY, webhook_url=ANY, alert_recei
"team": None,
"trigger_template": None,
"trigger_type": "0",
"trigger_type_name": "Escalation step",
"trigger_type_name": "Manual or escalation step",
"url": webhook_url,
"username": None,
}

View file

@ -69,7 +69,7 @@ def test_get_list_webhooks(webhook_internal_api_setup, make_custom_webhook, make
},
"trigger_template": None,
"trigger_type": "0",
"trigger_type_name": "Escalation step",
"trigger_type_name": "Manual or escalation step",
"preset": None,
}
]
@ -113,7 +113,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",
"trigger_type_name": "Manual or escalation step",
"preset": None,
}
@ -161,7 +161,7 @@ def test_get_detail_connected_integration_webhook(
},
"trigger_template": None,
"trigger_type": "0",
"trigger_type_name": "Escalation step",
"trigger_type_name": "Manual or escalation step",
"preset": None,
}
@ -858,6 +858,90 @@ def test_create_invalid_missing_fields(webhook_internal_api_setup, make_user_aut
assert response.json()["trigger_type"][0] == "This field is required."
@pytest.mark.django_db
def test_webhook_filter_by_trigger_type(
make_organization_and_user_with_plugin_token,
make_custom_webhook,
make_user_auth_headers,
):
organization, user, token = make_organization_and_user_with_plugin_token()
webhook_on_ack = make_custom_webhook(organization, trigger_type=Webhook.TRIGGER_ACKNOWLEDGE)
make_custom_webhook(organization, trigger_type=Webhook.TRIGGER_MANUAL)
client = APIClient()
# no filter
url = reverse("api-internal:webhooks-list")
response = client.get(
url,
content_type="application/json",
**make_user_auth_headers(user, token),
)
assert response.status_code == status.HTTP_200_OK
assert len(response.json()) == 2
# test filter on type
url = reverse("api-internal:webhooks-list")
response = client.get(
f"{url}?trigger_type={Webhook.TRIGGER_ACKNOWLEDGE}",
content_type="application/json",
**make_user_auth_headers(user, token),
)
assert response.status_code == status.HTTP_200_OK
assert len(response.json()) == 1
assert response.json()[0]["id"] == webhook_on_ack.public_primary_key
# test filter empty results
response = client.get(
f"{url}?trigger_type={Webhook.TRIGGER_STATUS_CHANGE}",
content_type="application/json",
**make_user_auth_headers(user, token),
)
assert len(response.json()) == 0
@pytest.mark.django_db
def test_webhook_filter_by_integration(
make_organization_and_user_with_plugin_token,
make_alert_receive_channel,
make_custom_webhook,
make_user_auth_headers,
):
organization, user, token = make_organization_and_user_with_plugin_token()
webhook_all = make_custom_webhook(organization)
integration = make_alert_receive_channel(organization)
webhook_for_integration = make_custom_webhook(organization)
webhook_for_integration.filtered_integrations.add(integration)
another_integration = make_alert_receive_channel(organization)
another_webhook = make_custom_webhook(organization)
another_webhook.filtered_integrations.add(another_integration)
client = APIClient()
# no filter
url = reverse("api-internal:webhooks-list")
response = client.get(
url,
content_type="application/json",
**make_user_auth_headers(user, token),
)
assert response.status_code == status.HTTP_200_OK
assert len(response.json()) == 3
# test filter on integration
url = reverse("api-internal:webhooks-list")
response = client.get(
f"{url}?integration={integration.public_primary_key}",
content_type="application/json",
**make_user_auth_headers(user, token),
)
assert response.status_code == status.HTTP_200_OK
assert len(response.json()) == 2
expected = {webhook_all.public_primary_key, webhook_for_integration.public_primary_key}
assert set(w["id"] for w in response.json()) == expected
@pytest.mark.django_db
def test_webhook_filter_by_labels(
make_organization_and_user_with_plugin_token,
@ -1079,3 +1163,93 @@ def test_team_not_updated_if_not_in_data(
webhook.refresh_from_db()
assert webhook.team == team
@pytest.mark.django_db
def test_webhook_trigger_manual(
make_organization_and_user_with_plugin_token,
make_organization,
make_alert_receive_channel,
make_alert_group,
make_custom_webhook,
make_user_auth_headers,
):
organization, user, token = make_organization_and_user_with_plugin_token()
integration = make_alert_receive_channel(organization)
alert_group = make_alert_group(integration)
webhook_on_ack = make_custom_webhook(organization, trigger_type=Webhook.TRIGGER_ACKNOWLEDGE)
webhook_manual = make_custom_webhook(organization, trigger_type=Webhook.TRIGGER_MANUAL)
client = APIClient()
url = reverse("api-internal:webhooks-trigger-manual", kwargs={"pk": webhook_manual.public_primary_key})
data = {"alert_group": alert_group.public_primary_key}
# success
with patch("apps.api.views.webhooks.execute_webhook") as mock_execute:
response = client.post(
url,
data=json.dumps(data),
content_type="application/json",
**make_user_auth_headers(user, token),
)
assert response.status_code == status.HTTP_200_OK
mock_execute.apply_async.assert_called_once_with(
(webhook_manual.pk, alert_group.pk, user.pk, None), kwargs={"trigger_type": Webhook.TRIGGER_MANUAL}
)
# filtering integration
webhook_manual.filtered_integrations.add(integration)
with patch("apps.api.views.webhooks.execute_webhook") as mock_execute:
response = client.post(
url,
data=json.dumps(data),
content_type="application/json",
**make_user_auth_headers(user, token),
)
assert response.status_code == status.HTTP_200_OK
mock_execute.apply_async.assert_called_once_with(
(webhook_manual.pk, alert_group.pk, user.pk, None), kwargs={"trigger_type": Webhook.TRIGGER_MANUAL}
)
# exclude integration
another_integration = make_alert_receive_channel(organization)
webhook_manual.filtered_integrations.set([another_integration])
with patch("apps.api.views.webhooks.execute_webhook") as mock_execute:
response = client.post(
url,
data=json.dumps(data),
content_type="application/json",
**make_user_auth_headers(user, token),
)
assert response.status_code == status.HTTP_404_NOT_FOUND
assert mock_execute.apply_async.call_count == 0
# invalid trigger type
url = reverse("api-internal:webhooks-trigger-manual", kwargs={"pk": webhook_on_ack.public_primary_key})
data = {"alert_group": alert_group.public_primary_key}
with patch("apps.api.views.webhooks.execute_webhook") as mock_execute:
response = client.post(
url,
data=json.dumps(data),
content_type="application/json",
**make_user_auth_headers(user, token),
)
assert response.status_code == status.HTTP_400_BAD_REQUEST
assert mock_execute.apply_async.call_count == 0
# alert group from different org
another_org = make_organization()
another_org_integration = make_alert_receive_channel(another_org)
another_org_alert_group = make_alert_group(another_org_integration)
url = reverse("api-internal:webhooks-trigger-manual", kwargs={"pk": webhook_manual.public_primary_key})
data = {"alert_group": another_org_alert_group.public_primary_key}
with patch("apps.api.views.webhooks.execute_webhook") as mock_execute:
response = client.post(
url,
data=json.dumps(data),
content_type="application/json",
**make_user_auth_headers(user, token),
)
assert response.status_code == status.HTTP_404_NOT_FOUND
assert mock_execute.apply_async.call_count == 0

View file

@ -3,7 +3,8 @@ from dataclasses import asdict
from django.core.exceptions import ObjectDoesNotExist
from django_filters import rest_framework as filters
from rest_framework import status
from drf_spectacular.utils import extend_schema, inline_serializer
from rest_framework import serializers, status
from rest_framework.decorators import action
from rest_framework.exceptions import NotFound
from rest_framework.filters import SearchFilter
@ -11,6 +12,7 @@ from rest_framework.permissions import IsAuthenticated
from rest_framework.response import Response
from rest_framework.viewsets import ModelViewSet
from apps.alerts.models import AlertGroup, AlertReceiveChannel
from apps.api.label_filtering import parse_label_query
from apps.api.permissions import RBACPermission
from apps.api.serializers.webhook import WebhookResponseSerializer, WebhookSerializer
@ -19,9 +21,15 @@ from apps.auth_token.auth import PluginAuthentication
from apps.labels.utils import is_labels_feature_enabled
from apps.webhooks.models import Webhook, WebhookResponse
from apps.webhooks.presets.preset_options import WebhookPresetOptions
from apps.webhooks.tasks import execute_webhook
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
from common.api_helpers.filters import (
ByTeamModelFieldFilterMixin,
ModelFieldFilterMixin,
MultipleChoiceCharFilter,
TeamModelMultipleChoiceFilter,
)
from common.api_helpers.mixins import PublicPrimaryKeyMixin, TeamFilteringMixin
from common.insight_log import EntityEvent, write_resource_insight_log
from common.jinja_templater.apply_jinja_template import JinjaTemplateError, JinjaTemplateWarning
@ -38,8 +46,30 @@ WEBHOOK_TRIGGER_DATA = "data"
WEBHOOK_TEMPLATE_NAMES = [WEBHOOK_URL, WEBHOOK_HEADERS, WEBHOOK_TRIGGER_TEMPLATE, WEBHOOK_TRIGGER_DATA]
def get_integration_queryset(request):
if request is None:
return AlertReceiveChannel.objects.none()
return AlertReceiveChannel.objects_with_maintenance.filter(organization=request.user.organization)
class WebhooksFilter(ByTeamModelFieldFilterMixin, ModelFieldFilterMixin, filters.FilterSet):
team = TeamModelMultipleChoiceFilter()
trigger_type = filters.MultipleChoiceFilter(choices=Webhook.TRIGGER_TYPES)
integration = MultipleChoiceCharFilter(
field_name="filtered_integrations",
queryset=get_integration_queryset,
to_field_name="public_primary_key",
method="filter_integration",
)
def filter_integration(self, queryset, name, value):
if not value:
return queryset
lookup_kwargs = {f"{name}__in": value}
# include webhooks without filtered_integrations set (ie. apply to all integrations)
queryset = queryset.filter(**lookup_kwargs) | queryset.filter(filtered_integrations__isnull=True)
return queryset
class WebhooksView(TeamFilteringMixin, PublicPrimaryKeyMixin[Webhook], ModelViewSet):
@ -58,6 +88,7 @@ class WebhooksView(TeamFilteringMixin, PublicPrimaryKeyMixin[Webhook], ModelView
"responses": [RBACPermission.Permissions.OUTGOING_WEBHOOKS_READ],
"preview_template": [RBACPermission.Permissions.OUTGOING_WEBHOOKS_WRITE],
"preset_options": [RBACPermission.Permissions.OUTGOING_WEBHOOKS_READ],
"trigger_manual": [RBACPermission.Permissions.OUTGOING_WEBHOOKS_READ],
}
model = Webhook
@ -138,6 +169,19 @@ class WebhooksView(TeamFilteringMixin, PublicPrimaryKeyMixin[Webhook], ModelView
return obj
@extend_schema(
responses=inline_serializer(
name="WebhookFilters",
fields={
"name": serializers.CharField(),
"display_name": serializers.CharField(required=False),
"type": serializers.CharField(),
"href": serializers.CharField(),
"global": serializers.BooleanField(required=False),
},
many=True,
)
)
@action(methods=["get"], detail=False)
def filters(self, request):
api_root = "/api/internal/v1/"
@ -150,6 +194,12 @@ class WebhooksView(TeamFilteringMixin, PublicPrimaryKeyMixin[Webhook], ModelView
"href": api_root + "teams/",
"global": True,
},
{
"name": "trigger_type",
"type": "options",
"options": [{"display_name": label, "value": value} for value, label in Webhook.TRIGGER_TYPES],
},
{"name": "integration", "type": "options", "href": api_root + "alert_receive_channels/?filters=true"},
]
if is_labels_feature_enabled(self.request.auth.organization):
@ -163,8 +213,10 @@ class WebhooksView(TeamFilteringMixin, PublicPrimaryKeyMixin[Webhook], ModelView
return Response(filter_options)
@extend_schema(responses=WebhookResponseSerializer(many=True))
@action(methods=["get"], detail=True)
def responses(self, request, pk):
"""Return recent responses data for the webhook."""
if pk == NEW_WEBHOOK_PK:
return Response([], status=status.HTTP_200_OK)
@ -175,8 +227,25 @@ class WebhooksView(TeamFilteringMixin, PublicPrimaryKeyMixin[Webhook], ModelView
response_serializer = WebhookResponseSerializer(queryset, many=True)
return Response(response_serializer.data)
@extend_schema(
request=inline_serializer(
name="WebhookPreviewTemplateRequest",
fields={
"template_body": serializers.CharField(required=False, allow_null=True),
"template_name": serializers.CharField(required=False, allow_null=True),
"payload": serializers.DictField(required=False, allow_null=True),
},
),
responses=inline_serializer(
name="WebhookPreviewTemplateResponse",
fields={
"preview": serializers.CharField(allow_null=True),
},
),
)
@action(methods=["post"], detail=True)
def preview_template(self, request, pk):
"""Return webhook template preview."""
if pk != NEW_WEBHOOK_PK:
self.get_object() # Check webhook exists
@ -209,7 +278,61 @@ class WebhooksView(TeamFilteringMixin, PublicPrimaryKeyMixin[Webhook], ModelView
response = {"preview": result}
return Response(response, status=status.HTTP_200_OK)
@extend_schema(
responses={
status.HTTP_200_OK: inline_serializer(
name="WebhookPresetOptions",
fields={
"id": serializers.CharField(),
"name": serializers.CharField(),
"logo": serializers.CharField(),
"description": serializers.CharField(),
"controlled_fields": serializers.ListField(child=serializers.CharField()),
},
)
},
)
@action(methods=["get"], detail=False)
def preset_options(self, request):
"""Return available webhook preset options."""
result = [asdict(preset) for preset in WebhookPresetOptions.WEBHOOK_PRESET_CHOICES]
return Response(result)
@extend_schema(
request=inline_serializer(
name="WebhookTriggerManual",
fields={
"alert_group": serializers.CharField(),
},
),
responses={status.HTTP_200_OK: None},
)
@action(methods=["post"], detail=True)
def trigger_manual(self, request, pk):
"""Trigger specified webhook in the context of the given alert group."""
user = self.request.user
organization = self.request.auth.organization
webhook = self.get_object()
if webhook.trigger_type != Webhook.TRIGGER_MANUAL:
raise BadRequest(detail={"trigger_type": "This webhook is not manually triggerable."})
alert_group_ppk = request.data.get("alert_group")
if not alert_group_ppk:
raise BadRequest(detail={"alert_group": "This field is required."})
alert_groups = AlertGroup.objects.filter(
channel__organization=organization,
public_primary_key=alert_group_ppk,
)
# check for filtered integrations
if webhook.filtered_integrations.exists():
alert_groups = alert_groups.filter(channel_id__in=webhook.filtered_integrations.all())
try:
alert_group = alert_groups.get()
except ObjectDoesNotExist:
raise NotFound
execute_webhook.apply_async(
(webhook.pk, alert_group.pk, user.pk, None), kwargs={"trigger_type": Webhook.TRIGGER_MANUAL}
)
return Response(status=status.HTTP_200_OK)

View file

@ -30,7 +30,7 @@ def convert_custom_button_to_webhook(apps, schema_editor):
username=cb.user,
password=cb.password,
authorization_header=cb.authorization_header,
trigger_type=Webhook.TRIGGER_ESCALATION_STEP,
trigger_type=Webhook.TRIGGER_MANUAL,
forward_all=cb.forward_whole_payload,
data=cb.data,
)

View file

@ -0,0 +1,23 @@
# Generated by Django 4.2.15 on 2024-08-29 17:48
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('webhooks', '0016_auto_20240402_1341'),
]
operations = [
migrations.AlterField(
model_name='webhook',
name='trigger_type',
field=models.IntegerField(choices=[(0, 'Manual or escalation step'), (1, 'Alert Group Created'), (2, 'Acknowledged'), (3, 'Resolved'), (4, 'Silenced'), (5, 'Unsilenced'), (6, 'Unresolved'), (7, 'Unacknowledged'), (8, 'Status change')], default=0, null=True),
),
migrations.AlterField(
model_name='webhookresponse',
name='trigger_type',
field=models.IntegerField(choices=[(0, 'Manual or escalation step'), (1, 'Alert Group Created'), (2, 'Acknowledged'), (3, 'Resolved'), (4, 'Silenced'), (5, 'Unsilenced'), (6, 'Unresolved'), (7, 'Unacknowledged'), (8, 'Status change')]),
),
]

View file

@ -79,7 +79,7 @@ class Webhook(models.Model):
objects_with_deleted = models.Manager()
(
TRIGGER_ESCALATION_STEP,
TRIGGER_MANUAL,
TRIGGER_ALERT_GROUP_CREATED,
TRIGGER_ACKNOWLEDGE,
TRIGGER_RESOLVE,
@ -92,7 +92,7 @@ class Webhook(models.Model):
# Must be the same order as previous
TRIGGER_TYPES = (
(TRIGGER_ESCALATION_STEP, "Escalation step"),
(TRIGGER_MANUAL, "Manual or escalation step"),
(TRIGGER_ALERT_GROUP_CREATED, "Alert Group Created"),
(TRIGGER_ACKNOWLEDGE, "Acknowledged"),
(TRIGGER_RESOLVE, "Resolved"),
@ -114,7 +114,7 @@ class Webhook(models.Model):
}
PUBLIC_TRIGGER_TYPES_MAP = {
TRIGGER_ESCALATION_STEP: "escalation",
TRIGGER_MANUAL: "escalation",
TRIGGER_ALERT_GROUP_CREATED: "alert group created",
TRIGGER_ACKNOWLEDGE: "acknowledge",
TRIGGER_RESOLVE: "resolve",
@ -158,7 +158,7 @@ class Webhook(models.Model):
data = models.TextField(null=True, default=None)
forward_all = models.BooleanField(default=True)
http_method = models.CharField(max_length=32, default="POST", null=True)
trigger_type = models.IntegerField(choices=TRIGGER_TYPES, default=TRIGGER_ESCALATION_STEP, null=True)
trigger_type = models.IntegerField(choices=TRIGGER_TYPES, default=TRIGGER_MANUAL, null=True)
is_webhook_enabled = models.BooleanField(null=True, default=True)
# NOTE: integration_filter is deprecated (to be removed), use filtered_integrations instead
integration_filter = models.JSONField(default=None, null=True, blank=True)

View file

@ -27,7 +27,7 @@ class SimpleWebhookPreset(WebhookPreset):
def override_parameters_before_save(self, webhook: Webhook):
webhook.http_method = "POST"
webhook.trigger_type = Webhook.TRIGGER_ESCALATION_STEP
webhook.trigger_type = Webhook.TRIGGER_MANUAL
webhook.forward_all = True
def override_parameters_at_runtime(self, webhook: Webhook):

View file

@ -8,6 +8,7 @@ import requests
from celery.utils.log import get_task_logger
from django.conf import settings
from django.db.models import Prefetch
from django.utils import timezone
from apps.alerts.models import AlertGroup, AlertGroupLogRecord, EscalationPolicy
from apps.base.models import UserNotificationPolicyLogRecord
@ -42,7 +43,7 @@ TRIGGER_TYPE_TO_LABEL = {
Webhook.TRIGGER_SILENCE: "silence",
Webhook.TRIGGER_UNSILENCE: "unsilence",
Webhook.TRIGGER_UNRESOLVE: "unresolve",
Webhook.TRIGGER_ESCALATION_STEP: "escalation",
Webhook.TRIGGER_MANUAL: "escalation",
Webhook.TRIGGER_UNACKNOWLEDGE: "unacknowledge",
Webhook.TRIGGER_STATUS_CHANGE: "status change",
}
@ -106,6 +107,8 @@ def _build_payload(
elif payload_trigger_type == Webhook.TRIGGER_SILENCE:
event["time"] = _isoformat_date(alert_group.silenced_at)
event["until"] = _isoformat_date(alert_group.silenced_until)
elif payload_trigger_type == Webhook.TRIGGER_MANUAL:
event["time"] = _isoformat_date(timezone.now())
# include latest response data per webhook in the event input data
# exclude past responses from webhook being executed
@ -248,8 +251,9 @@ def execute_webhook(webhook_pk, alert_group_id, user_id, escalation_policy_id, t
triggered, status, error, exception = make_request(webhook, alert_group, data)
# create response entry only if webhook was triggered
response = None
if triggered:
WebhookResponse.objects.create(
response = WebhookResponse.objects.create(
alert_group=alert_group,
trigger_type=trigger_type or webhook.trigger_type,
**status,
@ -266,6 +270,9 @@ def execute_webhook(webhook_pk, alert_group_id, user_id, escalation_policy_id, t
# create log record
error_code = None
log_type = AlertGroupLogRecord.TYPE_CUSTOM_WEBHOOK_TRIGGERED
trigger_log = TRIGGER_TYPE_TO_LABEL[webhook.trigger_type]
if webhook.trigger_type == Webhook.TRIGGER_MANUAL and escalation_policy is None:
trigger_log = None # triggered manually
reason = str(status["status_code"])
if error is not None:
log_type = AlertGroupLogRecord.TYPE_ESCALATION_FAILED
@ -281,7 +288,8 @@ def execute_webhook(webhook_pk, alert_group_id, user_id, escalation_policy_id, t
step_specific_info={
"webhook_name": webhook.name,
"webhook_id": webhook.public_primary_key,
"trigger": TRIGGER_TYPE_TO_LABEL[webhook.trigger_type],
"trigger": trigger_log,
"response_id": response.pk if response else None,
},
escalation_policy=escalation_policy,
escalation_policy_step=step,

View file

@ -269,6 +269,7 @@ def test_execute_webhook_ok(
"trigger": "acknowledge",
"webhook_id": webhook.public_primary_key,
"webhook_name": webhook.name,
"response_id": log.id,
}
assert log_record.step_specific_info == expected_info
assert log_record.escalation_policy is None
@ -296,7 +297,7 @@ def test_execute_webhook_via_escalation_ok(
organization=organization,
url="https://something/{{ alert_group_id }}/",
http_method="POST",
trigger_type=Webhook.TRIGGER_ESCALATION_STEP,
trigger_type=Webhook.TRIGGER_MANUAL,
trigger_template="{{{{ alert_group.integration_id == '{}' }}}}".format(
alert_receive_channel.public_primary_key
),
@ -325,6 +326,7 @@ def test_execute_webhook_via_escalation_ok(
"trigger": "escalation",
"webhook_id": webhook.public_primary_key,
"webhook_name": webhook.name,
"response_id": webhook.responses.all()[0].id,
}
assert log_record.step_specific_info == expected_info
assert log_record.escalation_policy == escalation_policy
@ -728,6 +730,7 @@ def test_execute_webhook_errors(
"trigger": "resolve",
"webhook_id": webhook.public_primary_key,
"webhook_name": webhook.name,
"response_id": log.id,
}
assert log_record.step_specific_info == expected_info
assert log_record.reason == expected_error
@ -779,6 +782,7 @@ def test_execute_webhook_ssl_error(
"trigger": "resolve",
"webhook_id": webhook.public_primary_key,
"webhook_name": webhook.name,
"response_id": webhook.responses.all()[0].id,
}
assert log_record.reason == expected_error
assert (

View file

@ -346,6 +346,7 @@ SPECTACULAR_INCLUDED_PATHS = [
"/features",
"/alertgroups",
"/alert_receive_channels",
"/webhooks",
# current user endpoint 👇, without trailing slash we pick-up /user_group endpoints, which we don't want for now
"/user/",
"/users",

View file

@ -106,7 +106,7 @@
"lodash-es": "^4.17.21",
"mailslurp-client": "^15.14.1",
"moment-timezone": "0.5.45",
"openapi-typescript": "^7.0.0-next.4",
"openapi-typescript": "^7.4.0",
"postcss-loader": "^7.0.1",
"prettier": "^2.8.7",
"react-test-renderer": "^18.0.2",

View file

@ -320,8 +320,8 @@ importers:
specifier: 0.5.45
version: 0.5.45
openapi-typescript:
specifier: ^7.0.0-next.4
version: 7.3.3(typescript@5.1.6)
specifier: ^7.4.0
version: 7.4.0(typescript@5.1.6)
postcss-loader:
specifier: ^7.0.1
version: 7.3.4(postcss@8.4.43)(typescript@5.1.6)(webpack@5.94.0(@swc/core@1.7.22(@swc/helpers@0.5.12))(webpack-cli@5.1.4))
@ -2151,6 +2151,9 @@ packages:
change-case@4.1.2:
resolution: {integrity: sha512-bSxY2ws9OtviILG1EiY5K7NNxkqg/JnRnFxLtKQ96JaviiIxi7djMrSd0ECT9AC+lttClmYwKw53BWpOMblo7A==}
change-case@5.4.4:
resolution: {integrity: sha512-HRQyTk2/YPEkt9TnUPbOpr64Uw3KOicFWPVBb+xiHvd6eBx/qPr9xqfBFDT8P2vWsvvz4jbEkfDe71W3VyNu2w==}
char-regex@1.0.2:
resolution: {integrity: sha512-kWWXztvZ5SBQV+eRgKFeh8q5sLuZY2+8WUIzlxWVTg+oGwY14qylx1KbKzHd8P6ZYkAg0xyIDU9JMHhyJMZ1jw==}
engines: {node: '>=10'}
@ -4475,8 +4478,8 @@ packages:
openapi-typescript-helpers@0.0.5:
resolution: {integrity: sha512-MRffg93t0hgGZbYTxg60hkRIK2sRuEOHEtCUgMuLgbCC33TMQ68AmxskzUlauzZYD47+ENeGV/ElI7qnWqrAxA==}
openapi-typescript@7.3.3:
resolution: {integrity: sha512-NkUBI8fr5mg/3s001UPfUiBpKmHtSjkvFQO/IipCrQal5d5nGFoev1OXdxr7J9PHTswrAqU2hKdpoCL6OnammA==}
openapi-typescript@7.4.0:
resolution: {integrity: sha512-u4iVuTGkzKG4rHFUMA/IFXTks9tYVQzkowZsScMOdzJSvIF10qSNySWHTwnN2fD+MEeWFAM8i1f3IUBlgS92eQ==}
hasBin: true
peerDependencies:
typescript: ^5.x
@ -8757,6 +8760,8 @@ snapshots:
snake-case: 3.0.4
tslib: 2.5.3
change-case@5.4.4: {}
char-regex@1.0.2: {}
chokidar@3.6.0:
@ -11516,10 +11521,11 @@ snapshots:
openapi-typescript-helpers@0.0.5: {}
openapi-typescript@7.3.3(typescript@5.1.6):
openapi-typescript@7.4.0(typescript@5.1.6):
dependencies:
'@redocly/openapi-core': 1.22.1(supports-color@9.4.0)
ansi-colors: 4.1.3
change-case: 5.4.4
parse-json: 8.1.0
supports-color: 9.4.0
typescript: 5.1.6

View file

@ -1,20 +1,24 @@
import React, { ReactElement, useMemo, useState } from 'react';
import React, { ReactElement, useEffect, useMemo, useState } from 'react';
import { PluginExtensionLink } from '@grafana/data';
import { PluginExtensionLink, SelectableValue } from '@grafana/data';
import {
type GetPluginExtensionsOptions,
getPluginLinkExtensions,
usePluginLinks as originalUsePluginLinks,
} from '@grafana/runtime';
import { Dropdown, ToolbarButton } from '@grafana/ui';
import { Button, Dropdown, Modal, Select, Stack, ToolbarButton } from '@grafana/ui';
import { OnCallPluginExtensionPoints } from 'app-types';
import { StackSize } from 'helpers/consts';
import { observer } from 'mobx-react';
import { ActionKey } from 'models/loader/action-keys';
import { ApiSchemas } from 'network/oncall-api/api.types';
import { useStore } from 'state/useStore';
import { ExtensionLinkMenu } from './ExtensionLinkMenu';
interface Props {
incident: ApiSchemas['AlertGroup'];
alertGroup: ApiSchemas['AlertGroup'];
extensionPointId: OnCallPluginExtensionPoints;
declareIncidentLink?: string;
grafanaIncidentId: string | null;
@ -24,36 +28,116 @@ interface Props {
const usePluginLinks = originalUsePluginLinks === undefined ? usePluginLinksFallback : originalUsePluginLinks;
export function ExtensionLinkDropdown({
incident,
alertGroup,
extensionPointId,
declareIncidentLink,
grafanaIncidentId,
}: Props): ReactElement | null {
const [isOpen, setIsOpen] = useState(false);
const context = useExtensionPointContext(incident);
const [isTriggerWebhookModalOpen, setIsTriggerWebhookModalOpen] = useState(false);
const context = useExtensionPointContext(alertGroup);
const { links, isLoading } = usePluginLinks({ context, extensionPointId, limitPerPlugin: 3 });
if (links.length === 0 || isLoading) {
if (isLoading) {
return null;
}
const onOpenTriggerWebhookModal = async () => {
setIsOpen(false);
setIsTriggerWebhookModalOpen(true);
};
const menu = (
<ExtensionLinkMenu
extensions={links}
webhookModal={{
onOpenModal: onOpenTriggerWebhookModal,
}}
declareIncidentLink={declareIncidentLink}
grafanaIncidentId={grafanaIncidentId}
/>
);
return (
<Dropdown onVisibleChange={setIsOpen} placement="bottom-start" overlay={menu}>
<ToolbarButton aria-label="Actions" variant="canvas" isOpen={isOpen}>
Actions
</ToolbarButton>
</Dropdown>
<div>
<TriggerManualWebhookModal
alertGroup={alertGroup}
isModalOpen={isTriggerWebhookModalOpen}
setIsModalOpen={setIsTriggerWebhookModalOpen}
/>
<Dropdown onVisibleChange={setIsOpen} placement="bottom-start" overlay={menu}>
<ToolbarButton aria-label="Actions" variant="canvas" isOpen={isOpen}>
Actions
</ToolbarButton>
</Dropdown>
</div>
);
}
interface TriggerManualWebhookModalProps {
alertGroup: ApiSchemas['AlertGroup'];
isModalOpen: boolean;
setIsModalOpen: (isOpen: boolean) => void;
}
const TriggerManualWebhookModal = observer(
({ isModalOpen, setIsModalOpen, alertGroup }: TriggerManualWebhookModalProps) => {
const store = useStore();
const [selectedWebhookOption, setSelectedWebhookOption] = useState<SelectableValue<string>>(null);
useEffect(() => {
(async () => {
if (isModalOpen) {
await store.outgoingWebhookStore.updateItems(
{
trigger_type: 0,
integration: alertGroup.alert_receive_channel.id,
},
true
);
}
})();
}, [isModalOpen]);
return (
<Modal isOpen={isModalOpen} title={'Select outgoing webhook to trigger'} onDismiss={() => setIsModalOpen(false)}>
<Stack direction="column" gap={StackSize.lg}>
<Select
isLoading={store.loaderStore.isLoading(ActionKey.FETCH_WEBHOOKS)}
menuShouldPortal
value={selectedWebhookOption}
onChange={(option) => setSelectedWebhookOption(option)}
options={Object.values(store.outgoingWebhookStore.items).map((item) => ({
label: item.name,
value: item.id,
}))}
/>
<Stack gap={StackSize.md} justifyContent={'flex-end'}>
<Button variant="secondary" onClick={() => setIsModalOpen(false)}>
Cancel
</Button>
<Button
variant="primary"
onClick={onTriggerWebhook}
disabled={selectedWebhookOption === null || store.loaderStore.isLoading(ActionKey.TRIGGER_MANUAL_WEBHOOK)}
>
Trigger webhook
</Button>
</Stack>
</Stack>
</Modal>
);
async function onTriggerWebhook() {
await store.outgoingWebhookStore.triggerManualWebhook(selectedWebhookOption.value, alertGroup.pk);
setIsModalOpen(false);
setSelectedWebhookOption(null);
}
}
);
function useExtensionPointContext(incident: ApiSchemas['AlertGroup']): PluginExtensionOnCallAlertGroupContext {
return { alertGroup: incident };
}

View file

@ -6,8 +6,13 @@ import { getPluginId } from 'helpers/consts';
import { truncateTitle } from 'helpers/string';
import { PluginBridge, SupportedPlugin } from 'components/PluginBridge/PluginBridge';
import { RenderConditionally } from 'components/RenderConditionally/RenderConditionally';
type Props = {
webhookModal: {
onOpenModal: () => void;
};
extensions: PluginExtensionLink[];
// We require this to be passed in so we can continue to
// create a custom Declare incident link. Once the Incident plugin
@ -16,34 +21,63 @@ type Props = {
grafanaIncidentId?: string;
};
export function ExtensionLinkMenu({ extensions, declareIncidentLink, grafanaIncidentId }: Props): ReactElement | null {
export function ExtensionLinkMenu({
extensions,
declareIncidentLink,
grafanaIncidentId,
webhookModal,
}: Props): ReactElement | null {
const { categorised, uncategorised } = useExtensionLinksByCategory(extensions);
const showDivider = uncategorised.length > 0 && Object.keys(categorised).length > 0;
return (
<Menu>
<>
<DeclareIncidentMenuItem
<IRMActionSection
webhookModal={webhookModal}
extensions={extensions}
declareIncidentLink={declareIncidentLink}
grafanaIncidentId={grafanaIncidentId}
/>
{Object.keys(categorised).map((category) => (
<Menu.Group key={category} label={truncateTitle(category, 25)}>
{renderItems(categorised[category])}
</Menu.Group>
))}
{showDivider && <Menu.Divider key="divider" />}
{renderItems(uncategorised)}
<RenderConditionally shouldRender={extensions.length > 0}>
{Object.keys(categorised).map((category) => (
<Menu.Group key={category} label={truncateTitle(category, 25)}>
{renderItems(categorised[category])}
</Menu.Group>
))}
{showDivider && <Menu.Divider key="divider" />}
{renderItems(uncategorised)}
</RenderConditionally>
</>
</Menu>
);
}
const IRMActionSection: React.FC<Props> = ({ webhookModal, extensions, declareIncidentLink, grafanaIncidentId }) => {
return (
<Menu.Group key={'IRM'} label={'IRM'}>
<Menu.Item icon={'upload'} key={'triggerWebhook'} label={'Trigger webhook'} onClick={webhookModal.onOpenModal} />
{extensions.length > 0 && (
<DeclareIncidentMenuItem
extensions={extensions}
declareIncidentLink={declareIncidentLink}
grafanaIncidentId={grafanaIncidentId}
/>
)}
</Menu.Group>
);
};
// This menu item is a temporary workaround for the fact that the Incident plugin doesn't
// register its own extension link.
// TODO: remove this once Incident is definitely registering its own extension link.
function DeclareIncidentMenuItem({ extensions, declareIncidentLink, grafanaIncidentId }: Props): ReactElement | null {
function DeclareIncidentMenuItem({
extensions,
declareIncidentLink,
grafanaIncidentId,
}: Omit<Props, 'webhookModal'>): ReactElement | null {
const declareIncidentExtensionLink = extensions.find(
(extension) => extension.pluginId === 'grafana-incident-app' && extension.title === 'Declare incident'
);
@ -61,7 +95,7 @@ function DeclareIncidentMenuItem({ extensions, declareIncidentLink, grafanaIncid
return (
<PluginBridge plugin={SupportedPlugin.Incident}>
<Menu.Group key={'Declare incident'} label={'Incident'}>
<>
{renderItems([
{
type: PluginExtensionTypes.link,
@ -72,7 +106,7 @@ function DeclareIncidentMenuItem({ extensions, declareIncidentLink, grafanaIncid
pluginId: getPluginId(),
} as Partial<PluginExtensionLink>,
])}
</Menu.Group>
</>
</PluginBridge>
);
}

View file

@ -475,7 +475,7 @@ class _EscalationPolicy extends React.Component<EscalationPolicyProps, any> {
width={'auto'}
filterOptions={(id) => {
const webhook = outgoingWebhookStore.items[id];
return webhook.trigger_type_name === 'Escalation step';
return webhook.trigger_type === '0';
}}
/>
</WithPermissionControlTooltip>

View file

@ -61,20 +61,22 @@ export const ColumnsModal: React.FC<ColumnsModalProps> = observer(
<Stack direction="column" gap={StackSize.md}>
<div className={styles.content}>
<Stack direction="column" gap={StackSize.md}>
<Input
className={styles.input}
autoFocus
placeholder="Search..."
ref={inputRef}
onChange={debouncedOnInputChange}
/>
<Stack direction="column" gap={StackSize.xs}>
<Input
className={styles.input}
autoFocus
placeholder="Search..."
ref={inputRef}
onChange={debouncedOnInputChange}
/>
{inputRef?.current?.value === '' && (
<Text type="primary">
{availableKeysForSearching.length} {pluralize('item', availableKeysForSearching.length)} available.
Type to see suggestions
</Text>
)}
{inputRef?.current?.value === '' && (
<Text type="primary">
{availableKeysForSearching.length} {pluralize('item', availableKeysForSearching.length)} available.
Type to see suggestions
</Text>
)}
</Stack>
{inputRef?.current?.value && searchResults.length && (
<Stack direction="column" gap={StackSize.none}>

View file

@ -147,7 +147,7 @@ export const ColumnsSelectorWrapper: React.FC<ColumnsSelectorWrapperProps> = obs
id="toggletip-button"
onClick={() => setIsFloatingDisplayOpen(!isFloatingDisplayOpen)}
>
<Stack gap={StackSize.xs}>
<Stack gap={StackSize.xs} alignItems={'center'}>
Columns
<Icon name="angle-down" />
</Stack>

View file

@ -52,9 +52,9 @@ export const TemplatePreview = observer((props: TemplatePreviewProps) => {
templatePage,
} = props;
const [result, setResult] = useState<{ preview: string | null; is_valid_json_object?: boolean } | undefined>(
undefined
);
const [result, setResult] = useState<
ApiSchemas['WebhookPreviewTemplateResponse'] & { is_valid_json_object?: boolean }
>(undefined);
const [conditionalResult, setConditionalResult] = useState<ConditionalResult>({});
const store = useStore();
@ -62,11 +62,21 @@ export const TemplatePreview = observer((props: TemplatePreviewProps) => {
const handleTemplateBodyChange = useDebouncedCallback(async () => {
try {
const data = await (templatePage === TemplatePage.Webhooks
? outgoingWebhookStore.renderPreview(outgoingWebhookId, templateName, templateBody, payload)
: alertGroupId
? AlertGroupHelper.renderPreview(alertGroupId, templateName, templateBody)
: AlertReceiveChannelHelper.renderPreview(alertReceiveChannelId, templateName, templateBody, payload));
let data: ApiSchemas['WebhookPreviewTemplateResponse'] & { is_valid_json_object?: boolean } = undefined;
if (templatePage === TemplatePage.Webhooks) {
data = await outgoingWebhookStore.renderPreview(outgoingWebhookId, templateName, templateBody, payload);
} else if (alertGroupId) {
data = await AlertGroupHelper.renderPreview(alertGroupId, templateName, templateBody);
} else {
data = await AlertReceiveChannelHelper.renderPreview(
alertReceiveChannelId,
templateName,
templateBody,
payload
);
}
setResult(data);
if (data?.preview === 'True') {

View file

@ -19,4 +19,6 @@ export enum ActionKey {
FETCH_INTEGRATION_CHANNELS = 'FETCH_INTEGRATION_CHANNELS',
CONNECT_INTEGRATION_CHANNELS = 'CONNECT_INTEGRATION_CHANNELS',
FETCH_INTEGRATIONS_AVAILABLE_FOR_CONNECTION = 'FETCH_INTEGRATIONS_AVAILABLE_FOR_CONNECTION',
FETCH_WEBHOOKS = 'FETCH_WEBHOOKS',
TRIGGER_MANUAL_WEBHOOK = 'TRIGGER_MANUAL_WEBHOOK',
}

View file

@ -1,8 +1,11 @@
import { AutoLoadingState, WithGlobalNotification } from 'helpers/decorators';
import { action, observable, makeObservable, runInAction } from 'mobx';
import { BaseStore } from 'models/base_store';
import { ActionKey } from 'models/loader/action-keys';
import { makeRequest } from 'network/network';
import { ApiSchemas } from 'network/oncall-api/api.types';
import { onCallApi } from 'network/oncall-api/http-client';
import { RootStore } from 'state/rootStore';
import { OutgoingWebhookPreset } from './outgoing_webhook.types';
@ -64,7 +67,8 @@ export class OutgoingWebhookStore extends BaseStore {
}
@action.bound
async updateItems(query: any = '') {
@AutoLoadingState(ActionKey.FETCH_WEBHOOKS)
async updateItems(query: any = '', forceUpdate = false) {
const params = typeof query === 'string' ? { search: query } : query;
const results = await makeRequest(`${this.path}`, {
@ -73,7 +77,7 @@ export class OutgoingWebhookStore extends BaseStore {
runInAction(() => {
this.items = {
...this.items,
...(forceUpdate ? {} : this.items),
...results.reduce(
(acc: { [key: number]: ApiSchemas['Webhook'] }, item: ApiSchemas['Webhook']) => ({
...acc,
@ -92,6 +96,18 @@ export class OutgoingWebhookStore extends BaseStore {
});
}
@action.bound
@AutoLoadingState(ActionKey.TRIGGER_MANUAL_WEBHOOK)
@WithGlobalNotification({ success: 'Webhook has been triggered successfully.', failure: 'Failed to trigger webhook' })
async triggerManualWebhook(id: ApiSchemas['Webhook']['id'], alertGroupId: ApiSchemas['AlertGroup']['pk']) {
await onCallApi().POST(`/webhooks/{id}/trigger_manual/`, {
params: { path: { id } },
body: {
alert_group: alertGroupId,
},
});
}
getSearchResult = (query = '') => {
if (!this.searchResult[query]) {
return undefined;
@ -108,11 +124,18 @@ export class OutgoingWebhookStore extends BaseStore {
return result;
}
async renderPreview(id: ApiSchemas['Webhook']['id'], template_name: string, template_body: string, payload) {
return await makeRequest(`${this.path}${id}/preview_template/`, {
method: 'POST',
data: { template_name, template_body, payload },
});
async renderPreview(
id: ApiSchemas['Webhook']['id'],
template_name: string,
template_body: string,
payload: { [key: string]: unknown } = undefined
) {
return (
await onCallApi().POST('/webhooks/{id}/preview_template/', {
params: { path: { id } },
body: { template_name, template_body, payload },
})
).data;
}
@action.bound

View file

@ -20,7 +20,7 @@ export interface OutgoingWebhookPreset {
}
export const WebhookTriggerType = {
EscalationStep: new KeyValuePair('0', 'Escalation Step'),
EscalationStep: new KeyValuePair('0', 'Manual or Escalation Step'),
AlertGroupCreated: new KeyValuePair('1', 'Alert Group Created'),
Acknowledged: new KeyValuePair('2', 'Acknowledged'),
Resolved: new KeyValuePair('3', 'Resolved'),

File diff suppressed because it is too large Load diff

View file

@ -473,7 +473,7 @@ class _IncidentPage extends React.Component<IncidentPageProps, IncidentPageState
onUnsilence: this.getUnsilenceClickHandler(incident.pk),
})}
<ExtensionLinkDropdown
incident={incident}
alertGroup={incident}
extensionPointId={OnCallPluginExtensionPoints.AlertGroupAction}
declareIncidentLink={incident.declare_incident_link}
grafanaIncidentId={incident.grafana_incident_id}