diff --git a/docs/sources/configure/integrations/outgoing-webhooks/index.md b/docs/sources/configure/integrations/outgoing-webhooks/index.md
index 526d5422b4..0bd79870c4 100644
--- a/docs/sources/configure/integrations/outgoing-webhooks/index.md
+++ b/docs/sources/configure/integrations/outgoing-webhooks/index.md
@@ -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
diff --git a/engine/apps/api/tests/test_alert_receive_channel.py b/engine/apps/api/tests/test_alert_receive_channel.py
index 396bb80ece..6aaed48c8b 100644
--- a/engine/apps/api/tests/test_alert_receive_channel.py
+++ b/engine/apps/api/tests/test_alert_receive_channel.py
@@ -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,
}
diff --git a/engine/apps/api/tests/test_webhooks.py b/engine/apps/api/tests/test_webhooks.py
index 101c941257..a3b9109638 100644
--- a/engine/apps/api/tests/test_webhooks.py
+++ b/engine/apps/api/tests/test_webhooks.py
@@ -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
diff --git a/engine/apps/api/views/webhooks.py b/engine/apps/api/views/webhooks.py
index f32b31ff55..d249ef87e6 100644
--- a/engine/apps/api/views/webhooks.py
+++ b/engine/apps/api/views/webhooks.py
@@ -3,7 +3,8 @@
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.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.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_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 @@ def get_object_from_organization(self):
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 @@ def filters(self, request):
"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 @@ def filters(self, request):
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 @@ def responses(self, request, pk):
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 @@ def preview_template(self, request, pk):
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)
diff --git a/engine/apps/grafana_plugin/tasks/sync_v2.py b/engine/apps/grafana_plugin/tasks/sync_v2.py
index 433eb4b2a8..1a479aa776 100644
--- a/engine/apps/grafana_plugin/tasks/sync_v2.py
+++ b/engine/apps/grafana_plugin/tasks/sync_v2.py
@@ -1,7 +1,7 @@
import logging
from celery.utils.log import get_task_logger
-from django.utils import timezone
+from django.conf import settings
from apps.grafana_plugin.helpers.client import GrafanaAPIClient
from apps.grafana_plugin.helpers.gcom import get_active_instance_ids
@@ -12,10 +12,6 @@
logger.setLevel(logging.DEBUG)
-SYNC_PERIOD = timezone.timedelta(minutes=4)
-SYNC_BATCH_SIZE = 500
-
-
@shared_dedicated_queue_retry_task(autoretry_for=(Exception,), retry_backoff=True, max_retries=0)
def start_sync_organizations_v2():
organization_qs = Organization.objects.all()
@@ -30,20 +26,22 @@ def start_sync_organizations_v2():
logger.info(f"Found {len(organization_qs)} active organizations")
batch = []
+ batch_index = 0
+ task_countdown_seconds = 0
for org in organization_qs:
if GrafanaAPIClient.validate_grafana_token_format(org.api_token):
batch.append(org.pk)
- if len(batch) == SYNC_BATCH_SIZE:
- sync_organizations_v2.apply_async(
- (batch,),
- )
+ if len(batch) == settings.SYNC_V2_BATCH_SIZE:
+ sync_organizations_v2.apply_async((batch,), countdown=task_countdown_seconds)
batch = []
+ batch_index += 1
+ if batch_index == settings.SYNC_V2_MAX_TASKS:
+ batch_index = 0
+ task_countdown_seconds += settings.SYNC_V2_PERIOD_SECONDS
else:
logger.info(f"Skipping stack_slug={org.stack_slug}, api_token format is invalid or not set")
if batch:
- sync_organizations_v2.apply_async(
- (batch,),
- )
+ sync_organizations_v2.apply_async((batch,), countdown=task_countdown_seconds)
@shared_dedicated_queue_retry_task(autoretry_for=(Exception,), retry_backoff=True, max_retries=0)
diff --git a/engine/apps/grafana_plugin/tests/test_sync_v2.py b/engine/apps/grafana_plugin/tests/test_sync_v2.py
index 1915285b18..704ff9a3dd 100644
--- a/engine/apps/grafana_plugin/tests/test_sync_v2.py
+++ b/engine/apps/grafana_plugin/tests/test_sync_v2.py
@@ -1,7 +1,7 @@
import gzip
import json
from dataclasses import asdict
-from unittest.mock import patch
+from unittest.mock import call, patch
import pytest
from django.urls import reverse
@@ -159,3 +159,34 @@ def test_sync_team_serialization(test_team, validation_pass):
except ValidationError as e:
validation_error = e
assert (validation_error is None) == validation_pass
+
+
+@pytest.mark.django_db
+def test_sync_batch_tasks(make_organization, settings):
+ settings.SYNC_V2_MAX_TASKS = 2
+ settings.SYNC_V2_PERIOD_SECONDS = 10
+ settings.SYNC_V2_BATCH_SIZE = 2
+
+ for _ in range(9):
+ make_organization(api_token="glsa_abcdefghijklmnopqrstuvwxyz")
+
+ expected_calls = [
+ call(size=2, countdown=0),
+ call(size=2, countdown=0),
+ call(size=2, countdown=10),
+ call(size=2, countdown=10),
+ call(size=1, countdown=20),
+ ]
+ with patch("apps.grafana_plugin.tasks.sync_v2.sync_organizations_v2.apply_async", return_value=None) as mock_sync:
+ start_sync_organizations_v2()
+
+ def check_call(actual, expected):
+ return (
+ len(actual.args[0][0]) == expected.kwargs["size"]
+ and actual.kwargs["countdown"] == expected.kwargs["countdown"]
+ )
+
+ for actual_call, expected_call in zip(mock_sync.call_args_list, expected_calls):
+ assert check_call(actual_call, expected_call)
+
+ assert mock_sync.call_count == len(expected_calls)
diff --git a/engine/apps/webhooks/migrations/0008_auto_20230712_1613.py b/engine/apps/webhooks/migrations/0008_auto_20230712_1613.py
index 4d4a107cd4..0421630e14 100644
--- a/engine/apps/webhooks/migrations/0008_auto_20230712_1613.py
+++ b/engine/apps/webhooks/migrations/0008_auto_20230712_1613.py
@@ -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,
)
diff --git a/engine/apps/webhooks/migrations/0017_alter_webhook_trigger_type_and_more.py b/engine/apps/webhooks/migrations/0017_alter_webhook_trigger_type_and_more.py
new file mode 100644
index 0000000000..b4dc498ba9
--- /dev/null
+++ b/engine/apps/webhooks/migrations/0017_alter_webhook_trigger_type_and_more.py
@@ -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')]),
+ ),
+ ]
diff --git a/engine/apps/webhooks/models/webhook.py b/engine/apps/webhooks/models/webhook.py
index 15cbd4bf08..9d65a5d774 100644
--- a/engine/apps/webhooks/models/webhook.py
+++ b/engine/apps/webhooks/models/webhook.py
@@ -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)
diff --git a/engine/apps/webhooks/presets/simple.py b/engine/apps/webhooks/presets/simple.py
index ab62f6d384..57453e19a3 100644
--- a/engine/apps/webhooks/presets/simple.py
+++ b/engine/apps/webhooks/presets/simple.py
@@ -27,7 +27,7 @@ def _metadata(self) -> WebhookPresetMetadata:
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):
diff --git a/engine/apps/webhooks/tasks/trigger_webhook.py b/engine/apps/webhooks/tasks/trigger_webhook.py
index ea89b2e26c..803beb9f31 100644
--- a/engine/apps/webhooks/tasks/trigger_webhook.py
+++ b/engine/apps/webhooks/tasks/trigger_webhook.py
@@ -8,6 +8,7 @@
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 @@
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,
diff --git a/engine/apps/webhooks/tests/test_trigger_webhook.py b/engine/apps/webhooks/tests/test_trigger_webhook.py
index 85b3966ae1..152020f6fa 100644
--- a/engine/apps/webhooks/tests/test_trigger_webhook.py
+++ b/engine/apps/webhooks/tests/test_trigger_webhook.py
@@ -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 (
diff --git a/engine/settings/base.py b/engine/settings/base.py
index 2957c2ab8f..788f958fb8 100644
--- a/engine/settings/base.py
+++ b/engine/settings/base.py
@@ -346,6 +346,7 @@ class DatabaseTypes:
"/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",
@@ -962,3 +963,7 @@ class BrokerTypes:
DETACHED_INTEGRATIONS_SERVER = getenv_boolean("DETACHED_INTEGRATIONS_SERVER", default=False)
ACKNOWLEDGE_REMINDER_TASK_EXPIRY_DAYS = os.environ.get("ACKNOWLEDGE_REMINDER_TASK_EXPIRY_DAYS", default=14)
+
+SYNC_V2_MAX_TASKS = getenv_integer("SYNC_V2_MAX_TASKS", 10)
+SYNC_V2_PERIOD_SECONDS = getenv_integer("SYNC_V2_PERIOD_SECONDS", 300)
+SYNC_V2_BATCH_SIZE = getenv_integer("SYNC_V2_BATCH_SIZE", 500)
diff --git a/grafana-plugin/e2e-tests/.env.example b/grafana-plugin/e2e-tests/.env.example
deleted file mode 100644
index 9d3195e7bf..0000000000
--- a/grafana-plugin/e2e-tests/.env.example
+++ /dev/null
@@ -1,7 +0,0 @@
-GRAFANA_VIEWER_USERNAME=viewer
-GRAFANA_VIEWER_PASSWORD=viewer
-GRAFANA_EDITOR_USERNAME=editor
-GRAFANA_EDITOR_PASSWORD=editor
-GRAFANA_ADMIN_USERNAME=oncall
-GRAFANA_ADMIN_PASSWORD=oncall
-IS_OPEN_SOURCE=True
diff --git a/grafana-plugin/e2e-tests/schedules/schedulesList.test.ts b/grafana-plugin/e2e-tests/schedules/schedulesList.test.ts
index 31c50c1fcf..018ebdcfb8 100644
--- a/grafana-plugin/e2e-tests/schedules/schedulesList.test.ts
+++ b/grafana-plugin/e2e-tests/schedules/schedulesList.test.ts
@@ -10,6 +10,7 @@ test('schedule calendar and list of schedules is correctly displayed', async ({
await createOnCallSchedule(page, onCallScheduleName, userName);
await goToOnCallPage(page, 'schedules');
+ await page.waitForLoadState('networkidle');
// schedule slots are present in calendar
const nbOfSlotsInCalendar = await page.getByTestId('schedule-slot').count();
diff --git a/grafana-plugin/package.json b/grafana-plugin/package.json
index 7a5cd85678..58c9fa07be 100644
--- a/grafana-plugin/package.json
+++ b/grafana-plugin/package.json
@@ -13,6 +13,7 @@
"labels:unlink": "pnpm --dir ../../gops-labels/frontend unlink",
"mage:build-dev": "go mod download && mage -v build:debug",
"mage:watch": "go mod download && mage -v watch",
+ "mod:download": "go mod download",
"test-utc": "TZ=UTC jest --verbose --testNamePattern '^((?!@london-tz).)*$'",
"test-london-tz": "TZ=Europe/London jest --verbose --testNamePattern '@london-tz'",
"test": "PLUGIN_ID=grafana-oncall-app pnpm test-utc && pnpm test-london-tz",
@@ -84,7 +85,6 @@
"copy-webpack-plugin": "^11.0.0",
"css-loader": "^6.7.3",
"dompurify": "^2.3.12",
- "dotenv": "^16.4.0",
"eslint": "^8.25.0",
"eslint-plugin-deprecation": "^2.0.0",
"eslint-plugin-jsdoc": "^44.2.4",
@@ -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",
diff --git a/grafana-plugin/playwright.config.ts b/grafana-plugin/playwright.config.ts
index a0aa9d6bb1..c345c682d7 100644
--- a/grafana-plugin/playwright.config.ts
+++ b/grafana-plugin/playwright.config.ts
@@ -1,11 +1,6 @@
import { PlaywrightTestProject, defineConfig, devices } from '@playwright/test';
import path from 'path';
-/**
- * Read environment variables from file.
- * https://github.com/motdotla/dotenv
- */
-require('dotenv').config({ path: path.resolve(process.cwd(), 'e2e-tests/.env') });
export const VIEWER_USER_STORAGE_STATE = path.join(process.cwd(), 'e2e-tests/.auth/viewer.json');
export const EDITOR_USER_STORAGE_STATE = path.join(process.cwd(), 'e2e-tests/.auth/editor.json');
diff --git a/grafana-plugin/pnpm-lock.yaml b/grafana-plugin/pnpm-lock.yaml
index 54791ebf0e..acb438ea5c 100644
--- a/grafana-plugin/pnpm-lock.yaml
+++ b/grafana-plugin/pnpm-lock.yaml
@@ -253,9 +253,6 @@ importers:
dompurify:
specifier: ^2.3.12
version: 2.5.6
- dotenv:
- specifier: ^16.4.0
- version: 16.4.5
eslint:
specifier: ^8.25.0
version: 8.57.0
@@ -320,8 +317,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 +2148,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'}
@@ -2715,10 +2715,6 @@ packages:
dot-case@3.0.4:
resolution: {integrity: sha512-Kv5nKlh6yRrdrGvxeJ2e5y2eRUpkUosIW4A2AS38zwSz27zu7ufDwQPi5Jhs3XAlGNetl3bmnGhQsMtkKJnj3w==}
- dotenv@16.4.5:
- resolution: {integrity: sha512-ZmdL2rui+eB2YwhsWzjInR8LldtZHGDoQ1ugH85ppHKwpUHL7j7rN0Ti9NCnGiQbhaZ11FpR+7ao1dNsmduNUg==}
- engines: {node: '>=12'}
-
downshift@9.0.8:
resolution: {integrity: sha512-59BWD7+hSUQIM1DeNPLirNNnZIO9qMdIK5GQ/Uo8q34gT4B78RBlb9dhzgnh0HfQTJj4T/JKYD8KoLAlMWnTsA==}
peerDependencies:
@@ -4475,8 +4471,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 +8753,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:
@@ -9319,8 +9317,6 @@ snapshots:
no-case: 3.0.4
tslib: 2.5.3
- dotenv@16.4.5: {}
-
downshift@9.0.8(react@18.2.0):
dependencies:
'@babel/runtime': 7.25.6
@@ -11516,10 +11512,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
diff --git a/grafana-plugin/src/components/ExtensionLinkMenu/ExtensionLinkDropdown.tsx b/grafana-plugin/src/components/ExtensionLinkMenu/ExtensionLinkDropdown.tsx
index 29b0545e21..c432dc23e1 100644
--- a/grafana-plugin/src/components/ExtensionLinkMenu/ExtensionLinkDropdown.tsx
+++ b/grafana-plugin/src/components/ExtensionLinkMenu/ExtensionLinkDropdown.tsx
@@ -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 = (