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:
parent
fc07a22c56
commit
e93858e136
24 changed files with 1117 additions and 187 deletions
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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')]),
|
||||
),
|
||||
]
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
16
grafana-plugin/pnpm-lock.yaml
generated
16
grafana-plugin/pnpm-lock.yaml
generated
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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}>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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') {
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue