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 = ( ); return ( - - - Actions - - +
+ + + + + Actions + + +
); } +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>(null); + + useEffect(() => { + (async () => { + if (isModalOpen) { + await store.outgoingWebhookStore.updateItems( + { + trigger_type: 0, + integration: alertGroup.alert_receive_channel.id, + }, + true + ); + } + })(); + }, [isModalOpen]); + + return ( + setIsModalOpen(false)}> + + - - {inputRef?.current?.value === '' && ( - - {availableKeysForSearching.length} {pluralize('item', availableKeysForSearching.length)} available. - Type to see suggestions - - )} + + + + {inputRef?.current?.value === '' && ( + + {availableKeysForSearching.length} {pluralize('item', availableKeysForSearching.length)} available. + Type to see suggestions + + )} + {inputRef?.current?.value && searchResults.length && ( diff --git a/grafana-plugin/src/containers/ColumnsSelectorWrapper/ColumnsSelectorWrapper.tsx b/grafana-plugin/src/containers/ColumnsSelectorWrapper/ColumnsSelectorWrapper.tsx index c7a9bada87..5d9d7d9729 100644 --- a/grafana-plugin/src/containers/ColumnsSelectorWrapper/ColumnsSelectorWrapper.tsx +++ b/grafana-plugin/src/containers/ColumnsSelectorWrapper/ColumnsSelectorWrapper.tsx @@ -147,7 +147,7 @@ export const ColumnsSelectorWrapper: React.FC = obs id="toggletip-button" onClick={() => setIsFloatingDisplayOpen(!isFloatingDisplayOpen)} > - + Columns diff --git a/grafana-plugin/src/containers/TemplatePreview/TemplatePreview.tsx b/grafana-plugin/src/containers/TemplatePreview/TemplatePreview.tsx index d993af3398..dca26505df 100644 --- a/grafana-plugin/src/containers/TemplatePreview/TemplatePreview.tsx +++ b/grafana-plugin/src/containers/TemplatePreview/TemplatePreview.tsx @@ -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({}); 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') { diff --git a/grafana-plugin/src/models/loader/action-keys.ts b/grafana-plugin/src/models/loader/action-keys.ts index f89476f17b..824ae3c458 100644 --- a/grafana-plugin/src/models/loader/action-keys.ts +++ b/grafana-plugin/src/models/loader/action-keys.ts @@ -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', } diff --git a/grafana-plugin/src/models/outgoing_webhook/outgoing_webhook.ts b/grafana-plugin/src/models/outgoing_webhook/outgoing_webhook.ts index e5a36e5f6b..30ca4638a0 100644 --- a/grafana-plugin/src/models/outgoing_webhook/outgoing_webhook.ts +++ b/grafana-plugin/src/models/outgoing_webhook/outgoing_webhook.ts @@ -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 diff --git a/grafana-plugin/src/models/outgoing_webhook/outgoing_webhook.types.ts b/grafana-plugin/src/models/outgoing_webhook/outgoing_webhook.types.ts index 661404ef5b..5ba380367a 100644 --- a/grafana-plugin/src/models/outgoing_webhook/outgoing_webhook.types.ts +++ b/grafana-plugin/src/models/outgoing_webhook/outgoing_webhook.types.ts @@ -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'), diff --git a/grafana-plugin/src/network/oncall-api/autogenerated-api.types.d.ts b/grafana-plugin/src/network/oncall-api/autogenerated-api.types.d.ts index 25c6f0760b..19836b72cc 100644 --- a/grafana-plugin/src/network/oncall-api/autogenerated-api.types.d.ts +++ b/grafana-plugin/src/network/oncall-api/autogenerated-api.types.d.ts @@ -1347,6 +1347,136 @@ export interface paths { patch?: never; trace?: never; }; + '/webhooks/': { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** @description This mixin returns 403 and {"error_code": "wrong_team", "owner_team": {"name", "id", "email", "avatar_url"}} + * in case a requested instance doesn't belong to user's current_team. */ + get: operations['webhooks_list']; + put?: never; + /** @description This mixin returns 403 and {"error_code": "wrong_team", "owner_team": {"name", "id", "email", "avatar_url"}} + * in case a requested instance doesn't belong to user's current_team. */ + post: operations['webhooks_create']; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + '/webhooks/{id}/': { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** @description This mixin returns 403 and {"error_code": "wrong_team", "owner_team": {"name", "id", "email", "avatar_url"}} + * in case a requested instance doesn't belong to user's current_team. */ + get: operations['webhooks_retrieve']; + /** @description This mixin returns 403 and {"error_code": "wrong_team", "owner_team": {"name", "id", "email", "avatar_url"}} + * in case a requested instance doesn't belong to user's current_team. */ + put: operations['webhooks_update']; + post?: never; + /** @description This mixin returns 403 and {"error_code": "wrong_team", "owner_team": {"name", "id", "email", "avatar_url"}} + * in case a requested instance doesn't belong to user's current_team. */ + delete: operations['webhooks_destroy']; + options?: never; + head?: never; + /** @description This mixin returns 403 and {"error_code": "wrong_team", "owner_team": {"name", "id", "email", "avatar_url"}} + * in case a requested instance doesn't belong to user's current_team. */ + patch: operations['webhooks_partial_update']; + trace?: never; + }; + '/webhooks/{id}/preview_template/': { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** @description Return webhook template preview. */ + post: operations['webhooks_preview_template_create']; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + '/webhooks/{id}/responses/': { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** @description Return recent responses data for the webhook. */ + get: operations['webhooks_responses_list']; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + '/webhooks/{id}/trigger_manual/': { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** @description Trigger specified webhook in the context of the given alert group. */ + post: operations['webhooks_trigger_manual_create']; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + '/webhooks/filters/': { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** @description This mixin returns 403 and {"error_code": "wrong_team", "owner_team": {"name", "id", "email", "avatar_url"}} + * in case a requested instance doesn't belong to user's current_team. */ + get: operations['webhooks_filters_list']; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + '/webhooks/preset_options/': { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** @description Return available webhook preset options. */ + get: operations['webhooks_preset_options_retrieve']; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; } export type webhooks = Record; export interface components { @@ -1396,7 +1526,7 @@ export interface components { silenced_until?: string | null; /** Format: date-time */ readonly started_at: string; - readonly related_users: Array; + readonly related_users: components['schemas']['UserShort'][]; readonly render_for_web: | { title: string; @@ -1405,22 +1535,22 @@ export interface components { source_link: string | null; } | Record; - dependent_alert_groups: Array; + dependent_alert_groups: components['schemas']['ShortAlertGroup'][]; root_alert_group: components['schemas']['ShortAlertGroup']; readonly status: number; /** @description Generate a link for AlertGroup to declare Grafana Incident by click */ readonly declare_incident_link: string; team: string | null; grafana_incident_id?: string | null; - readonly labels: Array; + readonly labels: components['schemas']['AlertGroupLabel'][]; readonly permalinks: { slack: string | null; slack_app: string | null; telegram: string | null; web: string; }; - readonly alerts: Array; - readonly render_after_resolve_report_json: Array<{ + readonly alerts: components['schemas']['Alert'][]; + readonly render_after_resolve_report_json: { time: string; action: string; /** @enum {string} */ @@ -1433,11 +1563,11 @@ export interface components { avatar: string; avatar_full: string; }; - }>; + }[]; readonly slack_permalink: string | null; /** Format: date-time */ readonly last_alert_at: string; - readonly paged_users: Array<{ + readonly paged_users: { id: number; username: string; name: string; @@ -1445,13 +1575,13 @@ export interface components { avatar: string; avatar_full: string; important: boolean; - }>; - readonly external_urls: Array<{ + }[]; + readonly external_urls: { integration: string; integration_type: string; external_id: string; url: string; - }>; + }[]; }; AlertGroupAttach: { root_alert_group_pk: string; @@ -1508,7 +1638,7 @@ export interface components { silenced_until?: string | null; /** Format: date-time */ readonly started_at: string; - readonly related_users: Array; + readonly related_users: components['schemas']['UserShort'][]; readonly render_for_web: | { title: string; @@ -1517,14 +1647,14 @@ export interface components { source_link: string | null; } | Record; - dependent_alert_groups: Array; + dependent_alert_groups: components['schemas']['ShortAlertGroup'][]; root_alert_group: components['schemas']['ShortAlertGroup']; readonly status: number; /** @description Generate a link for AlertGroup to declare Grafana Incident by click */ readonly declare_incident_link: string; team: string | null; grafana_incident_id?: string | null; - readonly labels: Array; + readonly labels: components['schemas']['AlertGroupLabel'][]; readonly permalinks: { slack: string | null; slack_app: string | null; @@ -1624,7 +1754,7 @@ export interface components { readonly is_based_on_alertmanager: boolean; readonly inbound_email: string; readonly is_legacy: boolean; - labels?: Array; + labels?: components['schemas']['LabelPair'][]; alert_group_labels?: components['schemas']['IntegrationAlertGroupLabels']; /** Format: date-time */ readonly alertmanager_v2_migrated_at: string | null; @@ -1641,15 +1771,15 @@ export interface components { AlertReceiveChannelConnectedContactPoints: { uid: string; name: string; - contact_points: Array; + contact_points: components['schemas']['AlertReceiveChannelConnectedContactPointsInner'][]; }; AlertReceiveChannelConnectedContactPointsInner: { name: string; notification_connected: boolean; }; AlertReceiveChannelConnection: { - readonly source_alert_receive_channels: Array; - readonly connected_alert_receive_channels: Array; + readonly source_alert_receive_channels: components['schemas']['AlertReceiveChannelSourceChannel'][]; + readonly connected_alert_receive_channels: components['schemas']['AlertReceiveChannelConnectedChannel'][]; }; AlertReceiveChannelContactPoints: { uid: string; @@ -1691,7 +1821,7 @@ export interface components { readonly is_based_on_alertmanager: boolean; readonly inbound_email: string; readonly is_legacy: boolean; - labels?: Array; + labels?: components['schemas']['LabelPair'][]; alert_group_labels?: components['schemas']['IntegrationAlertGroupLabels']; /** Format: date-time */ readonly alertmanager_v2_migrated_at: string | null; @@ -1776,7 +1906,7 @@ export interface components { readonly is_based_on_alertmanager: boolean; readonly inbound_email: string; readonly is_legacy: boolean; - labels?: Array; + labels?: components['schemas']['LabelPair'][]; alert_group_labels?: components['schemas']['IntegrationAlertGroupLabels']; /** Format: date-time */ readonly alertmanager_v2_migrated_at: string | null; @@ -1795,7 +1925,6 @@ export interface components { readonly role: components['schemas']['RoleEnum']; /** Format: uri */ readonly avatar: string; - /** Format: uri */ readonly avatar_full: string; timezone?: string | null; working_hours?: components['schemas']['WorkingHours']; @@ -1805,11 +1934,9 @@ export interface components { readonly slack_user_identity: components['schemas']['SlackUserIdentity']; readonly telegram_configuration: components['schemas']['TelegramToUserConnector']; readonly messaging_backends: { - [key: string]: - | { - [key: string]: unknown; - } - | undefined; + [key: string]: { + [key: string]: unknown; + }; }; readonly notification_chain_verbal: { default: string; @@ -1820,7 +1947,7 @@ export interface components { readonly has_google_oauth2_connected: boolean; readonly is_currently_oncall: boolean; google_calendar_settings?: components['schemas']['GoogleCalendarSettings']; - readonly rbac_permissions: Array; + readonly rbac_permissions: components['schemas']['UserPermission'][]; readonly google_oauth2_token_is_missing_scopes: boolean; }; /** @description This serializer is consistent with apps.api.serializers.labels.LabelPairSerializer, but allows null for value ID. */ @@ -1885,9 +2012,9 @@ export interface components { /** @description Alert group labels configuration for the integration. See AlertReceiveChannel.alert_group_labels for details. */ IntegrationAlertGroupLabels: { inheritable: { - [key: string]: boolean | undefined; + [key: string]: boolean; }; - custom: Array; + custom: components['schemas']['CustomLabel'][]; template: string | null; }; /** @@ -1980,7 +2107,7 @@ export interface components { }; LabelCreate: { key: components['schemas']['LabelRepr']; - values: Array; + values: components['schemas']['LabelRepr'][]; }; LabelKey: { id: string; @@ -1990,7 +2117,7 @@ export interface components { }; LabelOption: { key: components['schemas']['LabelKey']; - values: Array; + values: components['schemas']['LabelValue'][]; }; LabelPair: { key: components['schemas']['LabelKey']; @@ -2016,7 +2143,6 @@ export interface components { readonly role: components['schemas']['RoleEnum']; /** Format: uri */ readonly avatar: string; - /** Format: uri */ readonly avatar_full: string; timezone?: string | null; working_hours?: components['schemas']['WorkingHours']; @@ -2026,11 +2152,9 @@ export interface components { readonly slack_user_identity: components['schemas']['SlackUserIdentity']; readonly telegram_configuration: components['schemas']['TelegramToUserConnector']; readonly messaging_backends: { - [key: string]: - | { - [key: string]: unknown; - } - | undefined; + [key: string]: { + [key: string]: unknown; + }; }; readonly notification_chain_verbal: { default: string; @@ -2065,7 +2189,7 @@ export interface components { * @example http://api.example.org/accounts/?cursor=cj0xJnA9NDg3 */ previous?: string | null; - results: Array; + results: components['schemas']['AlertGroupList'][]; page_size?: number; }; PaginatedAlertReceiveChannelPolymorphicList: { @@ -2081,7 +2205,7 @@ export interface components { * @example http://api.example.org/accounts/?page=2 */ previous?: string | null; - results: Array; + results: components['schemas']['AlertReceiveChannelPolymorphic'][]; page_size?: number; current_page_number?: number; total_pages?: number; @@ -2099,7 +2223,7 @@ export interface components { * @example http://api.example.org/accounts/?page=2 */ previous?: string | null; - results: Array; + results: components['schemas']['UserPolymorphic'][]; page_size?: number; current_page_number?: number; total_pages?: number; @@ -2139,7 +2263,7 @@ export interface components { readonly is_based_on_alertmanager?: boolean; readonly inbound_email?: string; readonly is_legacy?: boolean; - labels?: Array; + labels?: components['schemas']['LabelPair'][]; alert_group_labels?: components['schemas']['IntegrationAlertGroupLabels']; /** Format: date-time */ readonly alertmanager_v2_migrated_at?: string | null; @@ -2156,7 +2280,6 @@ export interface components { readonly role?: components['schemas']['RoleEnum']; /** Format: uri */ readonly avatar?: string; - /** Format: uri */ readonly avatar_full?: string; timezone?: string | null; working_hours?: components['schemas']['WorkingHours']; @@ -2166,11 +2289,9 @@ export interface components { readonly slack_user_identity?: components['schemas']['SlackUserIdentity']; readonly telegram_configuration?: components['schemas']['TelegramToUserConnector']; readonly messaging_backends?: { - [key: string]: - | { - [key: string]: unknown; - } - | undefined; + [key: string]: { + [key: string]: unknown; + }; }; readonly notification_chain_verbal?: { default: string; @@ -2182,6 +2303,28 @@ export interface components { readonly is_currently_oncall?: boolean; google_calendar_settings?: components['schemas']['GoogleCalendarSettings']; }; + PatchedWebhook: { + readonly id?: string; + name?: string | null; + is_webhook_enabled?: boolean | null; + is_legacy?: boolean | null; + team?: string | null; + username?: string | null; + password?: string | null; + authorization_header?: string | null; + trigger_template?: string | null; + headers?: string | null; + url?: string | null; + data?: string | null; + forward_all?: boolean | null; + http_method?: string | null; + trigger_type?: string | null; + readonly trigger_type_name?: string; + readonly last_response_log?: string; + integration_filter?: string[]; + preset?: string | null; + labels?: components['schemas']['LabelPair'][]; + }; PreviewTemplateRequest: { template_body?: string | null; template_name?: string | null; @@ -2283,7 +2426,6 @@ export interface components { readonly role: components['schemas']['RoleEnum']; /** Format: uri */ readonly avatar: string; - /** Format: uri */ readonly avatar_full: string; timezone?: string | null; working_hours?: components['schemas']['WorkingHours']; @@ -2293,11 +2435,9 @@ export interface components { readonly slack_user_identity: components['schemas']['SlackUserIdentity']; readonly telegram_configuration: components['schemas']['TelegramToUserConnector']; readonly messaging_backends: { - [key: string]: - | { - [key: string]: unknown; - } - | undefined; + [key: string]: { + [key: string]: unknown; + }; }; readonly notification_chain_verbal: { default: string; @@ -2345,10 +2485,10 @@ export interface components { username: string; pk: string; avatar: string; - avatar_full: string; + readonly avatar_full: string; name: string; readonly timezone: string | null; - readonly teams: Array; + readonly teams: components['schemas']['FastTeam'][]; readonly is_currently_oncall: boolean; }; UserPermission: { @@ -2362,7 +2502,7 @@ export interface components { username: string; pk: string; avatar: string; - avatar_full: string; + readonly avatar_full: string; }; Value: { id: string; @@ -2388,16 +2528,54 @@ export interface components { readonly last_response_log: string; integration_filter?: string[]; preset?: string | null; - labels?: Array; + labels?: components['schemas']['LabelPair'][]; + }; + WebhookFilters: { + name: string; + display_name?: string; + type: string; + href: string; + global?: boolean; + }; + WebhookPresetOptions: { + id: string; + name: string; + logo: string; + description: string; + controlled_fields: string[]; + }; + WebhookPreviewTemplateRequest: { + template_body?: string | null; + template_name?: string | null; + payload?: { + [key: string]: unknown; + } | null; + }; + WebhookPreviewTemplateResponse: { + preview: string | null; + }; + WebhookResponse: { + /** Format: date-time */ + timestamp?: string; + url?: string | null; + request_trigger?: string | null; + request_headers?: string | null; + request_data?: string | null; + status_code?: number | null; + content?: string | null; + event_data?: string | null; + }; + WebhookTriggerManual: { + alert_group: string; }; WorkingHours: { - monday: Array; - tuesday: Array; - wednesday: Array; - thursday: Array; - friday: Array; - saturday: Array; - sunday: Array; + monday: components['schemas']['WorkingHoursPeriod'][]; + tuesday: components['schemas']['WorkingHoursPeriod'][]; + wednesday: components['schemas']['WorkingHoursPeriod'][]; + thursday: components['schemas']['WorkingHoursPeriod'][]; + friday: components['schemas']['WorkingHoursPeriod'][]; + saturday: components['schemas']['WorkingHoursPeriod'][]; + sunday: components['schemas']['WorkingHoursPeriod'][]; }; WorkingHoursPeriod: { start: string; @@ -2448,7 +2626,7 @@ export interface operations { * * `jira` - Jira * * `zendesk` - Zendesk * * `appdynamics` - AppDynamics */ - integration?: Array< + integration?: ( | 'alertmanager' | 'amazon_sns' | 'appdynamics' @@ -2481,7 +2659,7 @@ export interface operations { | 'webhook' | 'zabbix' | 'zendesk' - >; + )[]; /** @description * `grafana_alerting` - Grafana Alerting * * `webhook` - Webhook * * `alertmanager` - Alertmanager @@ -2514,7 +2692,7 @@ export interface operations { * * `jira` - Jira * * `zendesk` - Zendesk * * `appdynamics` - AppDynamics */ - integration_ne?: Array< + integration_ne?: ( | 'alertmanager' | 'amazon_sns' | 'appdynamics' @@ -2547,10 +2725,10 @@ export interface operations { | 'webhook' | 'zabbix' | 'zendesk' - >; + )[]; /** @description * `0` - Debug * * `1` - Maintenance */ - maintenance_mode?: Array<0 | 1>; + maintenance_mode?: (0 | 1 | null)[]; /** @description A page number within the paginated result set. */ page?: number; /** @description Number of results to return per page. */ @@ -2826,9 +3004,9 @@ export interface operations { }; requestBody: { content: { - 'application/json': Array; - 'application/x-www-form-urlencoded': Array; - 'multipart/form-data': Array; + 'application/json': components['schemas']['AlertReceiveChannelNewConnection'][]; + 'application/x-www-form-urlencoded': components['schemas']['AlertReceiveChannelNewConnection'][]; + 'multipart/form-data': components['schemas']['AlertReceiveChannelNewConnection'][]; }; }; responses: { @@ -2910,7 +3088,7 @@ export interface operations { [name: string]: unknown; }; content: { - 'application/json': Array; + 'application/json': components['schemas']['AlertReceiveChannelConnectedContactPoints'][]; }; }; }; @@ -2933,12 +3111,10 @@ export interface operations { }; content: { 'application/json': { - [key: string]: - | { - alerts_count: number; - alert_groups_count: number; - } - | undefined; + [key: string]: { + alerts_count: number; + alert_groups_count: number; + }; }; }; }; @@ -3188,7 +3364,7 @@ export interface operations { [name: string]: unknown; }; content: { - 'application/json': Array; + 'application/json': components['schemas']['Webhook'][]; }; }; }; @@ -3286,7 +3462,7 @@ export interface operations { [name: string]: unknown; }; content: { - 'application/json': Array; + 'application/json': components['schemas']['AlertReceiveChannelContactPoints'][]; }; }; }; @@ -3306,12 +3482,10 @@ export interface operations { }; content: { 'application/json': { - [key: string]: - | { - alerts_count: number; - alert_groups_count: number; - } - | undefined; + [key: string]: { + alerts_count: number; + alert_groups_count: number; + }; }; }; }; @@ -3331,7 +3505,7 @@ export interface operations { [name: string]: unknown; }; content: { - 'application/json': Array; + 'application/json': components['schemas']['AlertReceiveChannelFilters'][]; }; }; }; @@ -3350,7 +3524,7 @@ export interface operations { [name: string]: unknown; }; content: { - 'application/json': Array; + 'application/json': components['schemas']['AlertReceiveChannelIntegrationOptions'][]; }; }; }; @@ -3430,7 +3604,7 @@ export interface operations { * * `1` - Acknowledged * * `2` - Resolved * * `3` - Silenced */ - status?: Array<0 | 1 | 2 | 3>; + status?: (0 | 1 | 2 | 3)[]; with_resolution_note?: boolean; }; header?: never; @@ -3801,7 +3975,7 @@ export interface operations { [name: string]: unknown; }; content: { - 'application/json': Array; + 'application/json': components['schemas']['AlertGroupBulkActionOptions'][]; }; }; }; @@ -3820,7 +3994,7 @@ export interface operations { [name: string]: unknown; }; content: { - 'application/json': Array; + 'application/json': components['schemas']['AlertGroupFilters'][]; }; }; }; @@ -3860,7 +4034,7 @@ export interface operations { [name: string]: unknown; }; content: { - 'application/json': Array; + 'application/json': components['schemas']['LabelKey'][]; }; }; }; @@ -3879,7 +4053,7 @@ export interface operations { [name: string]: unknown; }; content: { - 'application/json': Array; + 'application/json': components['schemas']['AlertGroupSilenceOptions'][]; }; }; }; @@ -3904,7 +4078,7 @@ export interface operations { * * `1` - Acknowledged * * `2` - Resolved * * `3` - Silenced */ - status?: Array<0 | 1 | 2 | 3>; + status?: (0 | 1 | 2 | 3)[]; with_resolution_note?: boolean; }; header?: never; @@ -3977,7 +4151,7 @@ export interface operations { [name: string]: unknown; }; content: { - 'application/json': Array< + 'application/json': ( | 'msteams' | 'slack' | 'unified_slack' @@ -3988,7 +4162,7 @@ export interface operations { | 'grafana_alerting_v2' | 'labels' | 'google_oauth2' - >; + )[]; }; }; }; @@ -4002,9 +4176,9 @@ export interface operations { }; requestBody: { content: { - 'application/json': Array; - 'application/x-www-form-urlencoded': Array; - 'multipart/form-data': Array; + 'application/json': components['schemas']['LabelCreate'][]; + 'application/x-www-form-urlencoded': components['schemas']['LabelCreate'][]; + 'multipart/form-data': components['schemas']['LabelCreate'][]; }; }; responses: { @@ -4157,7 +4331,7 @@ export interface operations { [name: string]: unknown; }; content: { - 'application/json': Array; + 'application/json': components['schemas']['LabelKey'][]; }; }; }; @@ -4309,7 +4483,7 @@ export interface operations { * * `1` - EDITOR * * `2` - VIEWER * * `3` - NONE */ - roles?: Array<0 | 1 | 2 | 3>; + roles?: (0 | 1 | 2 | 3)[]; /** @description A search term. */ search?: string; team?: string[]; @@ -4730,7 +4904,7 @@ export interface operations { [name: string]: unknown; }; content: { - 'application/json': Array<{ + 'application/json': { schedule_id: string; schedule_name: string; is_oncall: boolean; @@ -4740,7 +4914,7 @@ export interface operations { start: string; /** Format: date-time */ end: string; - users: Array<{ + users: { display_name: string; pk: string; email: string; @@ -4754,7 +4928,7 @@ export interface operations { avatar_full: string; } | null; } | null; - }>; + }[]; missing_users: string[]; priority_level: number | null; source: string | null; @@ -4772,7 +4946,7 @@ export interface operations { start: string; /** Format: date-time */ end: string; - users: Array<{ + users: { display_name: string; pk: string; email: string; @@ -4786,7 +4960,7 @@ export interface operations { avatar_full: string; } | null; } | null; - }>; + }[]; missing_users: string[]; priority_level: number | null; source: string | null; @@ -4798,7 +4972,7 @@ export interface operations { pk: string; }; } | null; - }>; + }[]; }; }; }; @@ -4840,7 +5014,7 @@ export interface operations { [name: string]: unknown; }; content: { - 'application/json': Array; + 'application/json': components['schemas']['UserFilters'][]; }; }; }; @@ -4864,4 +5038,265 @@ export interface operations { }; }; }; + webhooks_list: { + parameters: { + query?: { + /** @description A search term. */ + search?: string; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + 200: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['Webhook'][]; + }; + }; + }; + }; + webhooks_create: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + 'application/json': components['schemas']['Webhook']; + 'application/x-www-form-urlencoded': components['schemas']['Webhook']; + 'multipart/form-data': components['schemas']['Webhook']; + }; + }; + responses: { + 201: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['Webhook']; + }; + }; + }; + }; + webhooks_retrieve: { + parameters: { + query?: never; + header?: never; + path: { + /** @description A string identifying this resource. */ + id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + 200: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['Webhook']; + }; + }; + }; + }; + webhooks_update: { + parameters: { + query?: never; + header?: never; + path: { + /** @description A string identifying this resource. */ + id: string; + }; + cookie?: never; + }; + requestBody: { + content: { + 'application/json': components['schemas']['Webhook']; + 'application/x-www-form-urlencoded': components['schemas']['Webhook']; + 'multipart/form-data': components['schemas']['Webhook']; + }; + }; + responses: { + 200: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['Webhook']; + }; + }; + }; + }; + webhooks_destroy: { + parameters: { + query?: never; + header?: never; + path: { + /** @description A string identifying this resource. */ + id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description No response body */ + 204: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + webhooks_partial_update: { + parameters: { + query?: never; + header?: never; + path: { + /** @description A string identifying this resource. */ + id: string; + }; + cookie?: never; + }; + requestBody?: { + content: { + 'application/json': components['schemas']['PatchedWebhook']; + 'application/x-www-form-urlencoded': components['schemas']['PatchedWebhook']; + 'multipart/form-data': components['schemas']['PatchedWebhook']; + }; + }; + responses: { + 200: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['Webhook']; + }; + }; + }; + }; + webhooks_preview_template_create: { + parameters: { + query?: never; + header?: never; + path: { + /** @description A string identifying this resource. */ + id: string; + }; + cookie?: never; + }; + requestBody?: { + content: { + 'application/json': components['schemas']['WebhookPreviewTemplateRequest']; + 'application/x-www-form-urlencoded': components['schemas']['WebhookPreviewTemplateRequest']; + 'multipart/form-data': components['schemas']['WebhookPreviewTemplateRequest']; + }; + }; + responses: { + 200: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['WebhookPreviewTemplateResponse']; + }; + }; + }; + }; + webhooks_responses_list: { + parameters: { + query?: never; + header?: never; + path: { + /** @description A string identifying this resource. */ + id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + 200: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['WebhookResponse'][]; + }; + }; + }; + }; + webhooks_trigger_manual_create: { + parameters: { + query?: never; + header?: never; + path: { + /** @description A string identifying this resource. */ + id: string; + }; + cookie?: never; + }; + requestBody: { + content: { + 'application/json': components['schemas']['WebhookTriggerManual']; + 'application/x-www-form-urlencoded': components['schemas']['WebhookTriggerManual']; + 'multipart/form-data': components['schemas']['WebhookTriggerManual']; + }; + }; + responses: { + /** @description No response body */ + 200: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + webhooks_filters_list: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + 200: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['WebhookFilters'][]; + }; + }; + }; + }; + webhooks_preset_options_retrieve: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + 200: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['WebhookPresetOptions']; + }; + }; + }; + }; } diff --git a/grafana-plugin/src/pages/incident/Incident.tsx b/grafana-plugin/src/pages/incident/Incident.tsx index bedd93687b..bb2a898449 100644 --- a/grafana-plugin/src/pages/incident/Incident.tsx +++ b/grafana-plugin/src/pages/incident/Incident.tsx @@ -473,7 +473,7 @@ class _IncidentPage extends React.Component { const styles = useStyles2(getStyles); const { - organizationStore: { currentOrganization, saveCurrentOrganization }, + organizationStore, + organizationStore: { currentOrganization }, pluginStore: { apiUrlFromStatus }, } = useStore(); @@ -41,7 +42,7 @@ export const MainSettings = observer(() => { { - saveCurrentOrganization({ + organizationStore.saveCurrentOrganization({ is_resolution_note_required: event.currentTarget.checked, }); }}