diff --git a/CHANGELOG.md b/CHANGELOG.md index e6fd2a9d..be740c80 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,18 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## v1.3.9 (2023-07-12) + +### Added + +- Bring new Jinja editor to webhooks ([2344](https://github.com/grafana/oncall/issues/2344)) + +### Fixed + +- Add debounce on Select UI components to avoid making API search requests on each key-down event by + @maskin25 ([#2466](https://github.com/grafana/oncall/pull/2466)) +- Fixed schedules slack notifications for deleted organizations ([#2493](https://github.com/grafana/oncall/pull/2493)) + ## v1.3.8 (2023-07-11) ### Added diff --git a/docs/sources/open-source/_index.md b/docs/sources/open-source/_index.md index 10fcc2c3..27ca9e60 100644 --- a/docs/sources/open-source/_index.md +++ b/docs/sources/open-source/_index.md @@ -85,10 +85,6 @@ features: display_name: always_online: true shortcuts: - - name: Create a new incident - type: message - callback_id: incident_create - description: Creates a new OnCall incident - name: Add to resolution note type: message callback_id: add_resolution_note diff --git a/engine/apps/alerts/migrations/0020_auto_20230711_1532.py b/engine/apps/alerts/migrations/0020_auto_20230711_1532.py new file mode 100644 index 00000000..9a505f2e --- /dev/null +++ b/engine/apps/alerts/migrations/0020_auto_20230711_1532.py @@ -0,0 +1,27 @@ +# Generated by Django 3.2.20 on 2023-07-11 15:32 + +from django.db import migrations, models +import django_migration_linter as linter + + +class Migration(migrations.Migration): + + dependencies = [ + ('alerts', '0019_auto_20230705_1619'), + ] + + operations = [ + linter.IgnoreMigration(), + migrations.RemoveField( + model_name='alertgroup', + name='active_cache_for_web_calculation_id', + ), + migrations.RemoveField( + model_name='alertgroup', + name='estimate_escalation_finish_time', + ), + migrations.RemoveField( + model_name='alertgroup', + name='cached_render_for_web', + ), + ] diff --git a/engine/apps/alerts/models/alert_group.py b/engine/apps/alerts/models/alert_group.py index a9552849..1cf41fec 100644 --- a/engine/apps/alerts/models/alert_group.py +++ b/engine/apps/alerts/models/alert_group.py @@ -16,7 +16,6 @@ from django.db.models.signals import post_save from django.dispatch import receiver from django.utils import timezone from django.utils.functional import cached_property -from django_deprecate_fields import deprecate_field from apps.alerts.constants import AlertGroupState from apps.alerts.escalation_snapshot import EscalationSnapshotMixin @@ -163,7 +162,7 @@ class AlertGroup(AlertGroupSlackRenderingMixin, EscalationSnapshotMixin, models. (USER, "user"), (NOT_YET, "not yet"), (LAST_STEP, "last escalation step"), - (ARCHIVED, "archived"), + (ARCHIVED, "archived"), # deprecated. don't use (WIPED, "wiped"), (DISABLE_MAINTENANCE, "stop maintenance"), (NOT_YET_STOP_AUTORESOLVE, "not yet, autoresolve disabled"), @@ -327,10 +326,6 @@ class AlertGroup(AlertGroupSlackRenderingMixin, EscalationSnapshotMixin, models. related_name="dependent_alert_groups", ) - # cached_render_for_web and active_cache_for_web_calculation_id are deprecated - cached_render_for_web = models.JSONField(default=dict) - active_cache_for_web_calculation_id = models.CharField(max_length=100, null=True, default=None) - # NOTE: we should probably migrate this field to models.UUIDField as it's ONLY ever being # set to the result of uuid.uuid1 last_unique_unacknowledge_process_id: UUID | None = models.CharField(max_length=100, null=True, default=None) @@ -361,9 +356,6 @@ class AlertGroup(AlertGroupSlackRenderingMixin, EscalationSnapshotMixin, models. raw_escalation_snapshot = JSONField(null=True, default=None) - # THIS FIELD IS DEPRECATED AND SHOULD EVENTUALLY BE REMOVED - estimate_escalation_finish_time = deprecate_field(models.DateTimeField(null=True, default=None)) - # This field is used for constraints so we can use get_or_create() in concurrent calls # https://docs.djangoproject.com/en/3.2/ref/models/querysets/#get-or-create # Combined with unique_together below, it allows only one alert group with @@ -715,38 +707,6 @@ class AlertGroup(AlertGroupSlackRenderingMixin, EscalationSnapshotMixin, models. for dependent_alert_group in self.dependent_alert_groups.all(): dependent_alert_group.resolve_by_source() - # deprecated - def resolve_by_archivation(self): - AlertGroupLogRecord = apps.get_model("alerts", "AlertGroupLogRecord") - # if incident was silenced, unsilence it without starting escalation - if self.silenced: - self.un_silence() - self.log_records.create( - type=AlertGroupLogRecord.TYPE_UN_SILENCE, - silence_delay=None, - reason="Resolve by archivation", - ) - self.archive() - self.stop_escalation() - if not self.resolved: - self.resolve(resolved_by=AlertGroup.ARCHIVED) - - log_record = self.log_records.create(type=AlertGroupLogRecord.TYPE_RESOLVED) - - logger.debug( - f"send alert_group_action_triggered_signal for alert_group {self.pk}, " - f"log record {log_record.pk} with type '{log_record.get_type_display()}', action source: archivation" - ) - - alert_group_action_triggered_signal.send( - sender=self.resolve_by_archivation, - log_record=log_record.pk, - action_source=None, - ) - - for dependent_alert_group in self.dependent_alert_groups.all(): - dependent_alert_group.resolve_by_archivation() - def resolve_by_last_step(self): AlertGroupLogRecord = apps.get_model("alerts", "AlertGroupLogRecord") initial_state = self.state diff --git a/engine/apps/alerts/tasks/__init__.py b/engine/apps/alerts/tasks/__init__.py index 8df7d0c0..09fbba79 100644 --- a/engine/apps/alerts/tasks/__init__.py +++ b/engine/apps/alerts/tasks/__init__.py @@ -22,7 +22,6 @@ from .resolve_alert_group_by_source_if_needed import resolve_alert_group_by_sour from .resolve_by_last_step import resolve_by_last_step_task # noqa: F401 from .send_alert_group_signal import send_alert_group_signal # noqa: F401 from .send_update_log_report_signal import send_update_log_report_signal # noqa: F401 -from .send_update_postmortem_signal import send_update_postmortem_signal # noqa: F401 from .send_update_resolution_note_signal import send_update_resolution_note_signal # noqa: F401 from .sync_grafana_alerting_contact_points import sync_grafana_alerting_contact_points # noqa: F401 from .unsilence import unsilence_task # noqa: F401 diff --git a/engine/apps/alerts/tasks/resolve_alert_group_by_source_if_needed.py b/engine/apps/alerts/tasks/resolve_alert_group_by_source_if_needed.py index ade38de9..fc4040a2 100644 --- a/engine/apps/alerts/tasks/resolve_alert_group_by_source_if_needed.py +++ b/engine/apps/alerts/tasks/resolve_alert_group_by_source_if_needed.py @@ -31,7 +31,7 @@ def resolve_alert_group_by_source_if_needed(alert_group_pk): alert_group.save(update_fields=["resolved_by"]) if alert_group.resolved_by == alert_group.NOT_YET_STOP_AUTORESOLVE: return "alert_group is too big to auto-resolve" - print("YOLO") + last_alert = AlertForAlertManager.objects.get(pk=alert_group.alerts.last().pk) if alert_group.is_alert_a_resolve_signal(last_alert): alert_group.resolve_by_source() diff --git a/engine/apps/alerts/tasks/send_update_postmortem_signal.py b/engine/apps/alerts/tasks/send_update_postmortem_signal.py deleted file mode 100644 index 92217e1e..00000000 --- a/engine/apps/alerts/tasks/send_update_postmortem_signal.py +++ /dev/null @@ -1,29 +0,0 @@ -from django.apps import apps -from django.conf import settings - -from apps.alerts.signals import alert_group_update_resolution_note_signal -from common.custom_celery_tasks import shared_dedicated_queue_retry_task - - -@shared_dedicated_queue_retry_task( - autoretry_for=(Exception,), retry_backoff=True, max_retries=1 if settings.DEBUG else None -) -def send_update_postmortem_signal(alert_group_pk, resolution_note_pk=None): - # Legacy task, remove - AlertGroup = apps.get_model("alerts", "AlertGroup") - ResolutionNote = apps.get_model("alerts", "ResolutionNote") - - alert_group = AlertGroup.unarchived_objects.filter(pk=alert_group_pk).first() - if alert_group is None: - print("Sent signal to update postmortem, but alert group is archived or does not exist") - return - - resolution_note = None - if resolution_note_pk is not None: - resolution_note = ResolutionNote.objects_with_deleted.get(pk=resolution_note_pk) - - alert_group_update_resolution_note_signal.send( - sender=send_update_postmortem_signal, - alert_group=alert_group, - resolution_note=resolution_note, - ) diff --git a/engine/apps/api/serializers/organization.py b/engine/apps/api/serializers/organization.py index 742c280f..da345d62 100644 --- a/engine/apps/api/serializers/organization.py +++ b/engine/apps/api/serializers/organization.py @@ -1,31 +1,15 @@ -import datetime from dataclasses import asdict -import humanize -import pytz from django.apps import apps -from django.utils import timezone -from rest_framework import fields, serializers +from rest_framework import serializers from apps.base.models import LiveSetting from apps.phone_notifications.phone_provider import get_phone_provider from apps.slack.models import SlackTeamIdentity -from apps.slack.tasks import resolve_archived_incidents_for_organization, unarchive_incidents_for_organization from apps.user_management.models import Organization from common.api_helpers.mixins import EagerLoadingMixin -class CustomDateField(fields.TimeField): - def to_internal_value(self, data): - try: - archive_datetime = datetime.datetime.fromisoformat(data).astimezone(pytz.UTC) - except (TypeError, ValueError): - raise serializers.ValidationError({"archive_alerts_from": ["Invalid date format"]}) - if archive_datetime.date() >= timezone.now().date(): - raise serializers.ValidationError({"archive_alerts_from": ["Invalid date. Date must be less than today."]}) - return archive_datetime - - class FastSlackTeamIdentitySerializer(serializers.ModelSerializer): class Meta: model = SlackTeamIdentity @@ -37,7 +21,6 @@ class OrganizationSerializer(EagerLoadingMixin, serializers.ModelSerializer): slack_team_identity = FastSlackTeamIdentitySerializer(read_only=True) name = serializers.CharField(required=False, allow_null=True, allow_blank=True, source="org_title") - # name_slug = serializers.CharField(required=False, allow_null=True, allow_blank=False) maintenance_till = serializers.ReadOnlyField(source="till_maintenance_timestamp") slack_channel = serializers.SerializerMethodField() @@ -48,21 +31,15 @@ class OrganizationSerializer(EagerLoadingMixin, serializers.ModelSerializer): fields = [ "pk", "name", - # "name_slug", - # "is_new_version", "slack_team_identity", "maintenance_mode", "maintenance_till", - # "incident_retention_web_report", - # "number_of_employees", "slack_channel", ] read_only_fields = [ - "is_new_version", "slack_team_identity", "maintenance_mode", "maintenance_till", - # "incident_retention_web_report", ] def get_slack_channel(self, obj): @@ -82,22 +59,18 @@ class OrganizationSerializer(EagerLoadingMixin, serializers.ModelSerializer): class CurrentOrganizationSerializer(OrganizationSerializer): - limits = serializers.SerializerMethodField() env_status = serializers.SerializerMethodField() banner = serializers.SerializerMethodField() class Meta(OrganizationSerializer.Meta): fields = [ *OrganizationSerializer.Meta.fields, - "limits", - "archive_alerts_from", "is_resolution_note_required", "env_status", "banner", ] read_only_fields = [ *OrganizationSerializer.Meta.read_only_fields, - "limits", "banner", ] @@ -109,10 +82,6 @@ class CurrentOrganizationSerializer(OrganizationSerializer): )[0] return banner.json_value - def get_limits(self, obj): - user = self.context["request"].user - return obj.notifications_limit_web_report(user) - def get_env_status(self, obj): # deprecated in favour of ConfigAPIView. # All new env statuses should be added there @@ -122,44 +91,9 @@ class CurrentOrganizationSerializer(OrganizationSerializer): phone_provider_config = get_phone_provider().flags return { "telegram_configured": telegram_configured, - "twilio_configured": phone_provider_config.configured, # keep for backward compatibility "phone_provider": asdict(phone_provider_config), } - def get_stats(self, obj): - if isinstance(obj.cached_seconds_saved_by_amixr, int): - verbal_time_saved_by_amixr = humanize.naturaldelta( - datetime.timedelta(seconds=obj.cached_seconds_saved_by_amixr) - ) - else: - verbal_time_saved_by_amixr = None - - result = { - "grouped_percent": obj.cached_grouped_percent, - "alerts_count": obj.cached_alerts_count, - "noise_reduction": obj.cached_noise_reduction, - "average_response_time": humanize.naturaldelta(obj.cached_average_response_time), - "verbal_time_saved_by_amixr": verbal_time_saved_by_amixr, - } - - return result - - def update(self, instance, validated_data): - current_archive_date = instance.archive_alerts_from - archive_alerts_from = validated_data.get("archive_alerts_from") - - result = super().update(instance, validated_data) - if archive_alerts_from is not None and current_archive_date != archive_alerts_from: - if current_archive_date > archive_alerts_from: - unarchive_incidents_for_organization.apply_async( - (instance.pk,), - ) - resolve_archived_incidents_for_organization.apply_async( - (instance.pk,), - ) - - return result - class FastOrganizationSerializer(serializers.ModelSerializer): pk = serializers.CharField(read_only=True, source="public_primary_key") diff --git a/engine/apps/api/serializers/webhook.py b/engine/apps/api/serializers/webhook.py index 72d6a935..e12c0b94 100644 --- a/engine/apps/api/serializers/webhook.py +++ b/engine/apps/api/serializers/webhook.py @@ -22,6 +22,7 @@ class WebhookResponseSerializer(serializers.ModelSerializer): "request_data", "status_code", "content", + "event_data", ] diff --git a/engine/apps/api/tests/test_subscription.py b/engine/apps/api/tests/test_subscription.py deleted file mode 100644 index 2753784b..00000000 --- a/engine/apps/api/tests/test_subscription.py +++ /dev/null @@ -1,39 +0,0 @@ -from unittest.mock import patch - -import pytest -from django.urls import reverse -from rest_framework import status -from rest_framework.response import Response -from rest_framework.test import APIClient - -from apps.api.permissions import LegacyAccessControlRole - - -@pytest.mark.django_db -@pytest.mark.parametrize( - "role,expected_status", - [ - (LegacyAccessControlRole.ADMIN, status.HTTP_200_OK), - (LegacyAccessControlRole.EDITOR, status.HTTP_200_OK), - (LegacyAccessControlRole.VIEWER, status.HTTP_200_OK), - ], -) -def test_subscription_retrieve_permissions( - make_organization_and_user_with_plugin_token, - make_user_auth_headers, - role, - expected_status, -): - _, user, token = make_organization_and_user_with_plugin_token(role) - client = APIClient() - - url = reverse("api-internal:subscription") - with patch( - "apps.api.views.subscription.SubscriptionView.get", - return_value=Response( - status=status.HTTP_200_OK, - ), - ): - response = client.get(url, format="json", **make_user_auth_headers(user, token)) - - assert response.status_code == expected_status diff --git a/engine/apps/api/tests/test_webhooks.py b/engine/apps/api/tests/test_webhooks.py index f379425b..d3f55a8f 100644 --- a/engine/apps/api/tests/test_webhooks.py +++ b/engine/apps/api/tests/test_webhooks.py @@ -9,6 +9,7 @@ from rest_framework.response import Response from rest_framework.test import APIClient from apps.api.permissions import LegacyAccessControlRole +from apps.api.views.webhooks import RECENT_RESPONSE_LIMIT, WEBHOOK_URL from apps.webhooks.models import Webhook from apps.webhooks.models.webhook import WEBHOOK_FIELD_PLACEHOLDER @@ -61,6 +62,7 @@ def test_get_list_webhooks(webhook_internal_api_setup, make_user_auth_headers): "status_code": None, "request_trigger": "", "url": "", + "event_data": "", }, "trigger_template": None, "trigger_type": None, @@ -102,6 +104,7 @@ def test_get_detail_webhook(webhook_internal_api_setup, make_user_auth_headers): "status_code": None, "request_trigger": "", "url": "", + "event_data": "", }, "trigger_template": None, "trigger_type": None, @@ -148,6 +151,7 @@ def test_create_webhook(mocked_check_webhooks_2_enabled, webhook_internal_api_se "status_code": None, "request_trigger": "", "url": "", + "event_data": "", }, "trigger_template": None, "trigger_type_name": "Alert Group Created", @@ -207,6 +211,7 @@ def test_create_valid_templated_field( "status_code": None, "request_trigger": "", "url": "", + "event_data": "", }, "trigger_template": None, "trigger_type_name": "Alert Group Created", @@ -485,3 +490,59 @@ def test_webhook_from_other_team_without_flag( response = client.get(url, format="json", **make_user_auth_headers(user, token)) assert response.status_code == status.HTTP_200_OK + + +@pytest.mark.django_db +def test_get_webhook_responses( + make_organization_and_user_with_plugin_token, + make_team, + make_user_auth_headers, + make_custom_webhook, + make_webhook_response, +): + organization, user, token = make_organization_and_user_with_plugin_token() + team = make_team(organization) + webhook = make_custom_webhook( + organization=organization, team=team, trigger_type=Webhook.TRIGGER_ALERT_GROUP_CREATED + ) + for i in range(0, RECENT_RESPONSE_LIMIT + 1): + make_webhook_response( + webhook=webhook, + trigger_type=webhook.trigger_type, + status_code=200, + content=json.dumps({"id": "third-party-id"}), + event_data=json.dumps({"test": f"{i}"}), + ) + + client = APIClient() + url = reverse("api-internal:webhooks-responses", kwargs={"pk": webhook.public_primary_key}) + response = client.get(url, format="json", **make_user_auth_headers(user, token)) + assert response.status_code == status.HTTP_200_OK + assert len(response.data) == RECENT_RESPONSE_LIMIT + + +@pytest.mark.django_db +@pytest.mark.parametrize( + "test_template, test_payload, expected_result", + [ + ("https://test.com", None, "https://test.com"), + ("https://test.com", "", "https://test.com"), + ("{{ name }}", {"name": "test_1"}, "test_1"), + ("{{ name }}", '{"name": "test_1"}', "test_1"), + ], +) +def test_webhook_preview_template( + webhook_internal_api_setup, make_user_auth_headers, test_template, test_payload, expected_result +): + user, token, webhook = webhook_internal_api_setup + client = APIClient() + url = reverse("api-internal:webhooks-preview-template", kwargs={"pk": webhook.public_primary_key}) + data = { + "template_name": WEBHOOK_URL, + "template_body": test_template, + "payload": test_payload, + } + + response = client.post(url, data, format="json", **make_user_auth_headers(user, token)) + assert response.status_code == status.HTTP_200_OK + assert response.data["preview"] == expected_result diff --git a/engine/apps/api/urls.py b/engine/apps/api/urls.py index 02f20f62..ffd52356 100644 --- a/engine/apps/api/urls.py +++ b/engine/apps/api/urls.py @@ -35,7 +35,6 @@ from .views.slack_team_settings import ( SlackTeamSettingsAPIView, UnAcknowledgeTimeoutOptionsAPIView, ) -from .views.subscription import SubscriptionView from .views.team import TeamViewSet from .views.telegram_channels import TelegramChannelViewSet from .views.user import CurrentUserView, UserView @@ -83,7 +82,6 @@ urlpatterns = [ GetChannelVerificationCode.as_view(), name="api-get-channel-verification-code", ), - optional_slash_path("current_subscription", SubscriptionView.as_view(), name="subscription"), optional_slash_path("terraform_file", TerraformGitOpsView.as_view(), name="terraform_file"), optional_slash_path("terraform_imports", TerraformStateView.as_view(), name="terraform_imports"), optional_slash_path("maintenance", MaintenanceAPIView.as_view(), name="maintenance"), diff --git a/engine/apps/api/views/alert_group.py b/engine/apps/api/views/alert_group.py index 164c5919..0378decf 100644 --- a/engine/apps/api/views/alert_group.py +++ b/engine/apps/api/views/alert_group.py @@ -364,9 +364,6 @@ class AlertGroupView( alert_group_pks = [alert_group.pk for alert_group in alert_groups] queryset = AlertGroup.all_objects.filter(pk__in=alert_group_pks).order_by("-pk") - # do not load cached_render_for_web as it's deprecated and can be very large - queryset = queryset.defer("cached_render_for_web") - queryset = self.get_serializer_class().setup_eager_loading(queryset) alert_groups = list(queryset) diff --git a/engine/apps/api/views/subscription.py b/engine/apps/api/views/subscription.py deleted file mode 100644 index 47b0e5f0..00000000 --- a/engine/apps/api/views/subscription.py +++ /dev/null @@ -1,16 +0,0 @@ -from rest_framework.permissions import IsAuthenticated -from rest_framework.response import Response -from rest_framework.views import APIView - -from apps.auth_token.auth import PluginAuthentication - - -class SubscriptionView(APIView): - authentication_classes = (PluginAuthentication,) - permission_classes = (IsAuthenticated,) - - def get(self, request): - raise NotImplementedError - organization = self.request.auth.organization - user = self.request.user - return Response(organization.get_subscription_web_report_for_user(user)) diff --git a/engine/apps/api/views/webhooks.py b/engine/apps/api/views/webhooks.py index 3b76b99a..940ee683 100644 --- a/engine/apps/api/views/webhooks.py +++ b/engine/apps/api/views/webhooks.py @@ -1,5 +1,8 @@ +import json + from django.core.exceptions import ObjectDoesNotExist, PermissionDenied from django_filters import rest_framework as filters +from rest_framework import status from rest_framework.decorators import action from rest_framework.exceptions import NotFound from rest_framework.filters import SearchFilter @@ -8,12 +11,25 @@ from rest_framework.response import Response from rest_framework.viewsets import ModelViewSet from apps.api.permissions import RBACPermission -from apps.api.serializers.webhook import WebhookSerializer +from apps.api.serializers.webhook import WebhookResponseSerializer, WebhookSerializer from apps.auth_token.auth import PluginAuthentication -from apps.webhooks.models import Webhook -from apps.webhooks.utils import is_webhooks_enabled_for_organization +from apps.webhooks.models import Webhook, WebhookResponse +from apps.webhooks.utils import apply_jinja_template_for_json, is_webhooks_enabled_for_organization +from common.api_helpers.exceptions import BadRequest from common.api_helpers.filters import ByTeamModelFieldFilterMixin, ModelFieldFilterMixin, TeamModelMultipleChoiceFilter from common.api_helpers.mixins import PublicPrimaryKeyMixin, TeamFilteringMixin +from common.jinja_templater.apply_jinja_template import JinjaTemplateError, JinjaTemplateWarning + +NEW_WEBHOOK_PK = "new" + +RECENT_RESPONSE_LIMIT = 20 + +WEBHOOK_URL = "url" +WEBHOOK_HEADERS = "headers" +WEBHOOK_TRIGGER_TEMPLATE = "trigger_template" +WEBHOOK_TRIGGER_DATA = "data" + +WEBHOOK_TEMPLATE_NAMES = [WEBHOOK_URL, WEBHOOK_HEADERS, WEBHOOK_TRIGGER_TEMPLATE, WEBHOOK_TRIGGER_DATA] class WebhooksFilter(ByTeamModelFieldFilterMixin, ModelFieldFilterMixin, filters.FilterSet): @@ -33,6 +49,8 @@ class WebhooksView(TeamFilteringMixin, PublicPrimaryKeyMixin, ModelViewSet): "update": [RBACPermission.Permissions.OUTGOING_WEBHOOKS_WRITE], "partial_update": [RBACPermission.Permissions.OUTGOING_WEBHOOKS_WRITE], "destroy": [RBACPermission.Permissions.OUTGOING_WEBHOOKS_WRITE], + "responses": [RBACPermission.Permissions.OUTGOING_WEBHOOKS_READ], + "preview_template": [RBACPermission.Permissions.OUTGOING_WEBHOOKS_WRITE], } model = Webhook @@ -106,3 +124,49 @@ class WebhooksView(TeamFilteringMixin, PublicPrimaryKeyMixin, ModelViewSet): filter_options = list(filter(lambda f: filter_name in f["name"], filter_options)) return Response(filter_options) + + @action(methods=["get"], detail=True) + def responses(self, request, pk): + if pk == NEW_WEBHOOK_PK: + return Response([], status=status.HTTP_200_OK) + + webhook = self.get_object() + queryset = WebhookResponse.objects.filter(webhook_id=webhook.id, trigger_type=webhook.trigger_type).order_by( + "-timestamp" + )[:RECENT_RESPONSE_LIMIT] + response_serializer = WebhookResponseSerializer(queryset, many=True) + return Response(response_serializer.data) + + @action(methods=["post"], detail=True) + def preview_template(self, request, pk): + if pk != NEW_WEBHOOK_PK: + self.get_object() # Check webhook exists + + template_body = request.data.get("template_body", None) + template_name = request.data.get("template_name", None) + payload = request.data.get("payload", None) + + if not payload: + response = {"preview": template_body} + return Response(response, status=status.HTTP_200_OK) + + if isinstance(payload, str): + try: + payload = json.loads(payload) + except json.JSONDecodeError: + raise BadRequest(detail={"payload": "Could not parse json"}) + + if template_body is None or template_name is None: + response = {"preview": None} + return Response(response, status=status.HTTP_200_OK) + + if template_name not in WEBHOOK_TEMPLATE_NAMES: + raise BadRequest(detail={"template_name": "Unknown template name"}) + + try: + result = apply_jinja_template_for_json(template_body, payload) + except (JinjaTemplateError, JinjaTemplateWarning) as e: + return Response({"preview": e.fallback_message}, status.HTTP_200_OK) + + response = {"preview": result} + return Response(response, status=status.HTTP_200_OK) diff --git a/engine/apps/slack/scenarios/alertgroup_appearance.py b/engine/apps/slack/scenarios/alertgroup_appearance.py index dc1ea8a2..befcfcbf 100644 --- a/engine/apps/slack/scenarios/alertgroup_appearance.py +++ b/engine/apps/slack/scenarios/alertgroup_appearance.py @@ -5,10 +5,10 @@ from django.apps import apps from apps.api.permissions import RBACPermission from apps.slack.scenarios import scenario_step -from .step_mixins import AlertGroupActionsMixin, CheckAlertIsUnarchivedMixin +from .step_mixins import AlertGroupActionsMixin -class OpenAlertAppearanceDialogStep(CheckAlertIsUnarchivedMixin, AlertGroupActionsMixin, scenario_step.ScenarioStep): +class OpenAlertAppearanceDialogStep(AlertGroupActionsMixin, scenario_step.ScenarioStep): REQUIRED_PERMISSIONS = [RBACPermission.Permissions.CHATOPS_WRITE] def process_scenario(self, slack_user_identity, slack_team_identity, payload): @@ -17,9 +17,6 @@ class OpenAlertAppearanceDialogStep(CheckAlertIsUnarchivedMixin, AlertGroupActio self.open_unauthorized_warning(payload) return - if not self.check_alert_is_unarchived(slack_team_identity, payload, alert_group): - return - private_metadata = { "organization_id": self.organization.pk if self.organization else alert_group.organization.pk, "alert_group_pk": alert_group.pk, diff --git a/engine/apps/slack/scenarios/distribute_alerts.py b/engine/apps/slack/scenarios/distribute_alerts.py index 0c0b1f79..b07b0598 100644 --- a/engine/apps/slack/scenarios/distribute_alerts.py +++ b/engine/apps/slack/scenarios/distribute_alerts.py @@ -35,7 +35,7 @@ from apps.slack.tasks import ( from apps.slack.utils import get_cache_key_update_incident_slack_message from common.utils import clean_markup, is_string_with_visible_characters -from .step_mixins import AlertGroupActionsMixin, CheckAlertIsUnarchivedMixin +from .step_mixins import AlertGroupActionsMixin ATTACH_TO_ALERT_GROUPS_LIMIT = 20 @@ -216,11 +216,7 @@ class AlertShootingStep(scenario_step.ScenarioStep): pass -class InviteOtherPersonToIncident( - CheckAlertIsUnarchivedMixin, - AlertGroupActionsMixin, - scenario_step.ScenarioStep, -): +class InviteOtherPersonToIncident(AlertGroupActionsMixin, scenario_step.ScenarioStep): REQUIRED_PERMISSIONS = [RBACPermission.Permissions.CHATOPS_WRITE] def process_scenario(self, slack_user_identity, slack_team_identity, payload): @@ -233,9 +229,6 @@ class InviteOtherPersonToIncident( selected_user = None - if not self.check_alert_is_unarchived(slack_team_identity, payload, alert_group): - return - try: # user selection selected_user_id = json.loads(payload["actions"][0]["selected_option"]["value"])["user_id"] @@ -255,11 +248,7 @@ class InviteOtherPersonToIncident( self.alert_group_slack_service.update_alert_group_slack_message(alert_group) -class SilenceGroupStep( - CheckAlertIsUnarchivedMixin, - AlertGroupActionsMixin, - scenario_step.ScenarioStep, -): +class SilenceGroupStep(AlertGroupActionsMixin, scenario_step.ScenarioStep): REQUIRED_PERMISSIONS = [RBACPermission.Permissions.CHATOPS_WRITE] def process_scenario(self, slack_user_identity, slack_team_identity, payload): @@ -275,19 +264,14 @@ class SilenceGroupStep( # Deprecated handler kept for backward compatibility (so older Slack messages can still be processed) silence_delay = int(value) - if self.check_alert_is_unarchived(slack_team_identity, payload, alert_group): - alert_group.silence_by_user(self.user, silence_delay, action_source=ActionSource.SLACK) + alert_group.silence_by_user(self.user, silence_delay, action_source=ActionSource.SLACK) def process_signal(self, log_record): alert_group = log_record.alert_group self.alert_group_slack_service.update_alert_group_slack_message(alert_group) -class UnSilenceGroupStep( - CheckAlertIsUnarchivedMixin, - AlertGroupActionsMixin, - scenario_step.ScenarioStep, -): +class UnSilenceGroupStep(AlertGroupActionsMixin, scenario_step.ScenarioStep): REQUIRED_PERMISSIONS = [RBACPermission.Permissions.CHATOPS_WRITE] def process_scenario(self, slack_user_identity, slack_team_identity, payload): @@ -296,19 +280,14 @@ class UnSilenceGroupStep( self.open_unauthorized_warning(payload) return - if self.check_alert_is_unarchived(slack_team_identity, payload, alert_group): - alert_group.un_silence_by_user(self.user, action_source=ActionSource.SLACK) + alert_group.un_silence_by_user(self.user, action_source=ActionSource.SLACK) def process_signal(self, log_record): alert_group = log_record.alert_group self.alert_group_slack_service.update_alert_group_slack_message(alert_group) -class SelectAttachGroupStep( - CheckAlertIsUnarchivedMixin, - AlertGroupActionsMixin, - scenario_step.ScenarioStep, -): +class SelectAttachGroupStep(AlertGroupActionsMixin, scenario_step.ScenarioStep): REQUIRED_PERMISSIONS = [RBACPermission.Permissions.CHATOPS_WRITE] def process_scenario(self, slack_user_identity, slack_team_identity, payload): @@ -317,9 +296,6 @@ class SelectAttachGroupStep( self.open_unauthorized_warning(payload) return - if not self.check_alert_is_unarchived(slack_team_identity, payload, alert_group): - return - blocks = [] view = { "callback_id": AttachGroupStep.routing_uid(), @@ -455,11 +431,7 @@ class SelectAttachGroupStep( return blocks -class AttachGroupStep( - CheckAlertIsUnarchivedMixin, - AlertGroupActionsMixin, - scenario_step.ScenarioStep, -): +class AttachGroupStep(AlertGroupActionsMixin, scenario_step.ScenarioStep): REQUIRED_PERMISSIONS = [] # Permissions are handled in SelectAttachGroupStep def process_signal(self, log_record): @@ -502,19 +474,11 @@ class AttachGroupStep( root_alert_group = AlertGroup.all_objects.get(pk=root_alert_group_pk) alert_group = self.get_alert_group(slack_team_identity, payload) - if self.check_alert_is_unarchived(slack_team_identity, payload, alert_group) and self.check_alert_is_unarchived( - slack_team_identity, payload, root_alert_group - ): - alert_group.attach_by_user(self.user, root_alert_group, action_source=ActionSource.SLACK) - else: - self.alert_group_slack_service.update_alert_group_slack_message(alert_group) + + alert_group.attach_by_user(self.user, root_alert_group, action_source=ActionSource.SLACK) -class UnAttachGroupStep( - CheckAlertIsUnarchivedMixin, - AlertGroupActionsMixin, - scenario_step.ScenarioStep, -): +class UnAttachGroupStep(AlertGroupActionsMixin, scenario_step.ScenarioStep): REQUIRED_PERMISSIONS = [RBACPermission.Permissions.CHATOPS_WRITE] def process_scenario(self, slack_user_identity, slack_team_identity, payload): @@ -523,15 +487,14 @@ class UnAttachGroupStep( self.open_unauthorized_warning(payload) return - if self.check_alert_is_unarchived(slack_team_identity, payload, alert_group): - alert_group.un_attach_by_user(self.user, action_source=ActionSource.SLACK) + alert_group.un_attach_by_user(self.user, action_source=ActionSource.SLACK) def process_signal(self, log_record): alert_group = log_record.alert_group self.alert_group_slack_service.update_alert_group_slack_message(alert_group) -class StopInvitationProcess(CheckAlertIsUnarchivedMixin, AlertGroupActionsMixin, scenario_step.ScenarioStep): +class StopInvitationProcess(AlertGroupActionsMixin, scenario_step.ScenarioStep): REQUIRED_PERMISSIONS = [RBACPermission.Permissions.CHATOPS_WRITE] def process_scenario(self, slack_user_identity, slack_team_identity, payload): @@ -540,9 +503,6 @@ class StopInvitationProcess(CheckAlertIsUnarchivedMixin, AlertGroupActionsMixin, self.open_unauthorized_warning(payload) return - if not self.check_alert_is_unarchived(slack_team_identity, payload, alert_group): - return - try: value = json.loads(payload["actions"][0]["value"]) invitation_id = value["invitation_id"] @@ -556,11 +516,7 @@ class StopInvitationProcess(CheckAlertIsUnarchivedMixin, AlertGroupActionsMixin, self.alert_group_slack_service.update_alert_group_slack_message(log_record.invitation.alert_group) -class CustomButtonProcessStep( - CheckAlertIsUnarchivedMixin, - AlertGroupActionsMixin, - scenario_step.ScenarioStep, -): +class CustomButtonProcessStep(AlertGroupActionsMixin, scenario_step.ScenarioStep): REQUIRED_PERMISSIONS = [RBACPermission.Permissions.CHATOPS_WRITE] def process_scenario(self, slack_user_identity, slack_team_identity, payload): @@ -570,23 +526,22 @@ class CustomButtonProcessStep( self.open_unauthorized_warning(payload) return - if self.check_alert_is_unarchived(slack_team_identity, payload, alert_group): - custom_button_pk = payload["actions"][0]["name"].split("_")[1] - alert_group_pk = payload["actions"][0]["name"].split("_")[2] - try: - CustomButtom.objects.get(pk=custom_button_pk) - except CustomButtom.DoesNotExist: - warning_text = "Oops! This button was deleted" - self.open_warning_window(payload, warning_text=warning_text) - self.alert_group_slack_service.update_alert_group_slack_message(alert_group) - else: - custom_button_result.apply_async( - args=( - custom_button_pk, - alert_group_pk, - ), - kwargs={"user_pk": self.user.pk}, - ) + custom_button_pk = payload["actions"][0]["name"].split("_")[1] + alert_group_pk = payload["actions"][0]["name"].split("_")[2] + try: + CustomButtom.objects.get(pk=custom_button_pk) + except CustomButtom.DoesNotExist: + warning_text = "Oops! This button was deleted" + self.open_warning_window(payload, warning_text=warning_text) + self.alert_group_slack_service.update_alert_group_slack_message(alert_group) + else: + custom_button_result.apply_async( + args=( + custom_button_pk, + alert_group_pk, + ), + kwargs={"user_pk": self.user.pk}, + ) def process_signal(self, log_record): alert_group = log_record.alert_group @@ -618,11 +573,7 @@ class CustomButtonProcessStep( ) -class ResolveGroupStep( - CheckAlertIsUnarchivedMixin, - AlertGroupActionsMixin, - scenario_step.ScenarioStep, -): +class ResolveGroupStep(AlertGroupActionsMixin, scenario_step.ScenarioStep): REQUIRED_PERMISSIONS = [RBACPermission.Permissions.CHATOPS_WRITE] def process_scenario(self, slack_user_identity, slack_team_identity, payload): @@ -633,9 +584,6 @@ class ResolveGroupStep( self.open_unauthorized_warning(payload) return - if not self.check_alert_is_unarchived(slack_team_identity, payload, alert_group): - return - if alert_group.is_maintenance_incident: alert_group.stop_maintenance(self.user) else: @@ -661,11 +609,7 @@ class ResolveGroupStep( self.alert_group_slack_service.update_alert_group_slack_message(alert_group) -class UnResolveGroupStep( - CheckAlertIsUnarchivedMixin, - AlertGroupActionsMixin, - scenario_step.ScenarioStep, -): +class UnResolveGroupStep(AlertGroupActionsMixin, scenario_step.ScenarioStep): REQUIRED_PERMISSIONS = [RBACPermission.Permissions.CHATOPS_WRITE] def process_scenario(self, slack_user_identity, slack_team_identity, payload): @@ -674,19 +618,14 @@ class UnResolveGroupStep( self.open_unauthorized_warning(payload) return - if self.check_alert_is_unarchived(slack_team_identity, payload, alert_group): - alert_group.un_resolve_by_user(self.user, action_source=ActionSource.SLACK) + alert_group.un_resolve_by_user(self.user, action_source=ActionSource.SLACK) def process_signal(self, log_record): alert_group = log_record.alert_group self.alert_group_slack_service.update_alert_group_slack_message(alert_group) -class AcknowledgeGroupStep( - CheckAlertIsUnarchivedMixin, - AlertGroupActionsMixin, - scenario_step.ScenarioStep, -): +class AcknowledgeGroupStep(AlertGroupActionsMixin, scenario_step.ScenarioStep): REQUIRED_PERMISSIONS = [RBACPermission.Permissions.CHATOPS_WRITE] def process_scenario(self, slack_user_identity, slack_team_identity, payload): @@ -695,19 +634,14 @@ class AcknowledgeGroupStep( self.open_unauthorized_warning(payload) return - if self.check_alert_is_unarchived(slack_team_identity, payload, alert_group): - alert_group.acknowledge_by_user(self.user, action_source=ActionSource.SLACK) + alert_group.acknowledge_by_user(self.user, action_source=ActionSource.SLACK) def process_signal(self, log_record): alert_group = log_record.alert_group self.alert_group_slack_service.update_alert_group_slack_message(alert_group) -class UnAcknowledgeGroupStep( - CheckAlertIsUnarchivedMixin, - AlertGroupActionsMixin, - scenario_step.ScenarioStep, -): +class UnAcknowledgeGroupStep(AlertGroupActionsMixin, scenario_step.ScenarioStep): REQUIRED_PERMISSIONS = [RBACPermission.Permissions.CHATOPS_WRITE] def process_scenario(self, slack_user_identity, slack_team_identity, payload): @@ -716,8 +650,7 @@ class UnAcknowledgeGroupStep( self.open_unauthorized_warning(payload) return - if self.check_alert_is_unarchived(slack_team_identity, payload, alert_group): - alert_group.un_acknowledge_by_user(self.user, action_source=ActionSource.SLACK) + alert_group.un_acknowledge_by_user(self.user, action_source=ActionSource.SLACK) def process_signal(self, log_record): AlertGroupLogRecord = apps.get_model("alerts", "AlertGroupLogRecord") diff --git a/engine/apps/slack/scenarios/manual_incident.py b/engine/apps/slack/scenarios/manual_incident.py index 0b3dbbb7..bca16d62 100644 --- a/engine/apps/slack/scenarios/manual_incident.py +++ b/engine/apps/slack/scenarios/manual_incident.py @@ -17,134 +17,6 @@ MANUAL_INCIDENT_MESSAGE_INPUT_ID = "manual_incident_message_input" DEFAULT_TEAM_VALUE = "default_team" -class StartCreateIncidentFromMessage(scenario_step.ScenarioStep): - """ - StartCreateIncidentFromMessage triggers creation of a manual incident from the slack message via submenu - """ - - callback_id = [ - "incident_create", - "incident_create_staging", - "incident_create_develop", - ] - - def process_scenario(self, slack_user_identity, slack_team_identity, payload): - input_id_prefix = _generate_input_id_prefix() - - channel_id = payload["channel"]["id"] - try: - image_url = payload["message"]["files"][0]["permalink"] - except KeyError: - image_url = None - private_metadata = { - "channel_id": channel_id, - "image_url": image_url, - "message": { - "user": payload["message"].get("user"), - "text": payload["message"].get("text"), - "ts": payload["message"].get("ts"), - }, - "input_id_prefix": input_id_prefix, - "with_title_and_message_inputs": False, - "submit_routing_uid": FinishCreateIncidentFromMessage.routing_uid(), - } - - blocks = _get_manual_incident_initial_form_fields( - slack_team_identity, slack_user_identity, input_id_prefix, payload - ) - view = _get_manual_incident_form_view( - FinishCreateIncidentFromMessage.routing_uid(), blocks, json.dumps(private_metadata) - ) - self._slack_client.api_call( - "views.open", - trigger_id=payload["trigger_id"], - view=view, - ) - - -class FinishCreateIncidentFromMessage(scenario_step.ScenarioStep): - """ - FinishCreateIncidentFromMessage creates a manual incident from the slack message via submenu - """ - - def process_scenario(self, slack_user_identity, slack_team_identity, payload): - Alert = apps.get_model("alerts", "Alert") - - private_metadata = json.loads(payload["view"]["private_metadata"]) - - channel_id = private_metadata["channel_id"] - - input_id_prefix = private_metadata["input_id_prefix"] - selected_organization = _get_selected_org_from_payload(payload, input_id_prefix) - selected_team = _get_selected_team_from_payload(payload, input_id_prefix) - selected_route = _get_selected_route_from_payload(payload, input_id_prefix) - - user = slack_user_identity.get_user(selected_organization) - alert_receive_channel = AlertReceiveChannel.get_or_create_manual_integration( - organization=selected_organization, - team=selected_team, - integration=AlertReceiveChannel.INTEGRATION_MANUAL, - deleted_at=None, - defaults={ - "author": user, - "verbal_name": f"Manual incidents ({selected_team.name if selected_team else 'General'} team)", - }, - ) - - author_username = slack_user_identity.slack_verbal - try: - permalink = self._slack_client.api_call( - "chat.getPermalink", - channel=private_metadata["channel_id"], - message_ts=private_metadata["message"]["ts"], - ) - permalink = permalink.get("permalink", None) - except SlackAPIException: - permalink = None - title = "Message from {}".format(author_username) - message = private_metadata["message"]["text"] - - # Deprecated, use custom oncall property instead. - # update private metadata in payload to use it in alert rendering - payload["view"]["private_metadata"] = private_metadata - payload["view"]["private_metadata"]["author_username"] = author_username - # Custom oncall property in payload to simplify rendering - payload["oncall"] = {} - payload["oncall"]["title"] = title - payload["oncall"]["message"] = message - payload["oncall"]["author_username"] = author_username - payload["oncall"]["permalink"] = permalink - Alert.create( - title=title, - message=message, - image_url=private_metadata["image_url"], - # Link to the slack message is not here bc it redirects to browser - link_to_upstream_details=None, - alert_receive_channel=alert_receive_channel, - raw_request_data=payload, - integration_unique_data={"created_by": user.get_username_with_slack_verbal()}, - force_route_id=selected_route.pk, - ) - - try: - self._slack_client.api_call( - "chat.postEphemeral", - channel=channel_id, - user=slack_user_identity.slack_id, - text=":white_check_mark: Alert successfully submitted", - ) - except SlackAPIException as e: - if e.response["error"] == "channel_not_found" or e.response["error"] == "user_not_in_channel": - self._slack_client.api_call( - "chat.postEphemeral", - channel=slack_user_identity.im_channel_id, - user=slack_user_identity.slack_id, - text=":white_check_mark: Alert successfully submitted", - ) - else: - raise e - - class StartCreateIncidentFromSlashCommand(scenario_step.ScenarioStep): """ StartCreateIncidentFromSlashCommand triggers creation of a manual incident from the slack message via slash command @@ -646,11 +518,6 @@ def _generate_input_id_prefix(): STEPS_ROUTING = [ - { - "payload_type": scenario_step.PAYLOAD_TYPE_MESSAGE_ACTION, - "message_action_callback_id": StartCreateIncidentFromMessage.callback_id, - "step": StartCreateIncidentFromMessage, - }, { "payload_type": scenario_step.PAYLOAD_TYPE_BLOCK_ACTIONS, "block_action_type": scenario_step.BLOCK_ACTION_TYPE_STATIC_SELECT, @@ -669,11 +536,6 @@ STEPS_ROUTING = [ "block_action_id": OnRouteChange.routing_uid(), "step": OnRouteChange, }, - { - "payload_type": scenario_step.PAYLOAD_TYPE_VIEW_SUBMISSION, - "view_callback_id": FinishCreateIncidentFromMessage.routing_uid(), - "step": FinishCreateIncidentFromMessage, - }, { "payload_type": scenario_step.PAYLOAD_TYPE_SLASH_COMMAND, "command_name": StartCreateIncidentFromSlashCommand.command_name, diff --git a/engine/apps/slack/scenarios/resolution_note.py b/engine/apps/slack/scenarios/resolution_note.py index d3e76222..13de3060 100644 --- a/engine/apps/slack/scenarios/resolution_note.py +++ b/engine/apps/slack/scenarios/resolution_note.py @@ -11,13 +11,13 @@ from apps.slack.slack_client.exceptions import SlackAPIException from apps.user_management.models import User from common.api_helpers.utils import create_engine_url -from .step_mixins import AlertGroupActionsMixin, CheckAlertIsUnarchivedMixin +from .step_mixins import AlertGroupActionsMixin logger = logging.getLogger(__name__) logger.setLevel(logging.DEBUG) -class AddToResolutionNoteStep(CheckAlertIsUnarchivedMixin, scenario_step.ScenarioStep): +class AddToResolutionNoteStep(scenario_step.ScenarioStep): callback_id = [ "add_resolution_note", "add_resolution_note_staging", @@ -61,9 +61,6 @@ class AddToResolutionNoteStep(CheckAlertIsUnarchivedMixin, scenario_step.Scenari ) raise e - if not self.check_alert_is_unarchived(slack_team_identity, payload, alert_group): - return - if payload["message"]["type"] == "message" and "user" in payload["message"]: message_ts = payload["message_ts"] thread_ts = payload["message"]["thread_ts"] @@ -373,7 +370,7 @@ class UpdateResolutionNoteStep(scenario_step.ScenarioStep): return blocks -class ResolutionNoteModalStep(CheckAlertIsUnarchivedMixin, AlertGroupActionsMixin, scenario_step.ScenarioStep): +class ResolutionNoteModalStep(AlertGroupActionsMixin, scenario_step.ScenarioStep): REQUIRED_PERMISSIONS = [RBACPermission.Permissions.CHATOPS_WRITE] RESOLUTION_NOTE_TEXT_BLOCK_ID = "resolution_note_text" RESOLUTION_NOTE_MESSAGES_MAX_COUNT = 25 @@ -396,9 +393,6 @@ class ResolutionNoteModalStep(CheckAlertIsUnarchivedMixin, AlertGroupActionsMixi action_resolve = value.get("action_resolve", False) channel_id = payload["channel"]["id"] if "channel" in payload else None - if not self.check_alert_is_unarchived(slack_team_identity, payload, alert_group): - return - blocks = [] if channel_id: diff --git a/engine/apps/slack/scenarios/step_mixins.py b/engine/apps/slack/scenarios/step_mixins.py index 0f04be99..428c6ce5 100644 --- a/engine/apps/slack/scenarios/step_mixins.py +++ b/engine/apps/slack/scenarios/step_mixins.py @@ -160,15 +160,3 @@ class AlertGroupActionsMixin: channel_id=channel_id, ) return slack_message.get_alert_group() - - -class CheckAlertIsUnarchivedMixin: - def check_alert_is_unarchived(self, slack_team_identity, payload, alert_group, warning=True): - alert_group_is_unarchived = alert_group.started_at.date() > self.organization.archive_alerts_from - if not alert_group_is_unarchived: - if warning: - warning_text = "Action is impossible: the Alert is archived." - self.open_warning_window(payload, warning_text) - if not alert_group.resolved or not alert_group.is_archived: - alert_group.resolve_by_archivation() - return alert_group_is_unarchived diff --git a/engine/apps/slack/tasks.py b/engine/apps/slack/tasks.py index 07fb2969..df90e161 100644 --- a/engine/apps/slack/tasks.py +++ b/engine/apps/slack/tasks.py @@ -1,6 +1,5 @@ import logging import random -import time from celery.utils.log import get_task_logger from django.apps import apps @@ -135,91 +134,6 @@ def check_slack_message_exists_before_post_message_to_thread( ).save() -@shared_dedicated_queue_retry_task(autoretry_for=(Exception,), retry_backoff=True) -def resolve_archived_incidents_for_organization(organization_id): - Organization = apps.get_model("user_management", "Organization") - AlertGroup = apps.get_model("alerts", "AlertGroup") - - organization = Organization.objects.get(pk=organization_id) - - alert_groups_queryset = AlertGroup.unarchived_objects.filter( - channel__organization=organization, - started_at__date__lte=organization.archive_alerts_from, - resolved=False, - ) - - for alert_group in alert_groups_queryset: - try: - alert_group.resolve_by_archivation() - except SlackAPIException as e: - if e.response["error"] == "channel_not_found": # Todo: investigate and remove this hack - print(e) - elif e.response["error"] == "rate_limited" or e.response["error"] == "ratelimited": - if "headers" in e.response and e.response["headers"].get("Retry-After") is not None: - delay = int(e.response["headers"]["Retry-After"]) - else: - delay = random.randint(1, 10) - resolve_archived_incidents_for_organization.apply_async((organization_id,), countdown=delay) - else: - raise e - - -@shared_dedicated_queue_retry_task(autoretry_for=(Exception,), retry_backoff=True) -def unarchive_incidents_for_organization(organization_id): - Organization = apps.get_model("user_management", "Organization") - AlertGroup = apps.get_model("alerts", "AlertGroup") - SlackMessage = apps.get_model("slack", "SlackMessage") - - organization = Organization.objects.get(pk=organization_id) - - alert_groups_queryset = AlertGroup.all_objects.filter( - channel__organization=organization, - started_at__date__gt=organization.archive_alerts_from, - is_archived=True, - ) - # convert qs to list to prevent it from changing after qs update - alert_groups_with_slack_message = list( - alert_groups_queryset.select_related("slack_message").filter(slack_message__isnull=False) - ) - - alert_groups_queryset.update(is_archived=False) - slack_team_identity = organization.slack_team_identity - if slack_team_identity is not None: - sc = SlackClientWithErrorHandling(slack_team_identity.bot_access_token) - slack_messages_to_create = [] - - for alert_group_with_slack_message in alert_groups_with_slack_message: - try: - result = sc.api_call( - "chat.postMessage", - channel=alert_group_with_slack_message.slack_message.channel_id, - thread_ts=alert_group_with_slack_message.slack_message.slack_id, - text="Incident has been unarchived", - ) - except SlackAPIException as e: - if e.response["error"] == "channel_not_found": - print(e) - elif e.response["error"] == "rate_limited" or e.response["error"] == "ratelimited": - if "headers" in e.response and e.response["headers"].get("Retry-After") is not None: - delay = int(e.response["headers"]["Retry-After"]) - else: - delay = random.randint(1, 10) - time.sleep(delay) - else: - raise e - else: - slack_message = SlackMessage( - slack_id=result["ts"], - organization=organization, - _slack_team_identity=slack_team_identity, - channel_id=alert_group_with_slack_message.slack_message.channel_id, - alert_group_id=alert_group_with_slack_message.pk, - ) - slack_messages_to_create.append(slack_message) - - SlackMessage.objects.bulk_create(slack_messages_to_create, batch_size=5000) - - @shared_dedicated_queue_retry_task(autoretry_for=(Exception,), retry_backoff=True, max_retries=1) def send_message_to_thread_if_bot_not_in_channel(alert_group_pk, slack_team_identity_pk, channel_id): """ @@ -241,43 +155,6 @@ def send_message_to_thread_if_bot_not_in_channel(alert_group_pk, slack_team_iden AlertGroupSlackService(slack_team_identity, sc).publish_message_to_alert_group_thread(alert_group, text=text) -@shared_dedicated_queue_retry_task(autoretry_for=(Exception,), retry_backoff=True, max_retries=1) -def send_debug_message_to_thread(alert_group_pk, slack_team_identity_pk): - AlertGroup = apps.get_model("alerts", "AlertGroup") - SlackTeamIdentity = apps.get_model("slack", "SlackTeamIdentity") - SlackMessage = apps.get_model("slack", "SlackMessage") - - slack_team_identity = SlackTeamIdentity.objects.get(pk=slack_team_identity_pk) - current_alert_group = AlertGroup.all_objects.get(pk=alert_group_pk) - try: - channel_id = current_alert_group.slack_message.channel_id - except AttributeError: - print("SlackMessage object doesn't exist for the alert group") - return None - - blocks = [] - text = "Escalations are silenced due to Debug mode" - blocks.append({"type": "section", "text": {"type": "mrkdwn", "text": text}}) - sc = SlackClientWithErrorHandling(slack_team_identity.bot_access_token) - - result = sc.api_call( - "chat.postMessage", - channel=channel_id, - text=text, - attachments=[], - thread_ts=current_alert_group.slack_message.slack_id, - mrkdwn=True, - blocks=blocks, - ) - SlackMessage( - slack_id=result["ts"], - organization=current_alert_group.channel.organization, - _slack_team_identity=slack_team_identity, - channel_id=channel_id, - alert_group=current_alert_group, - ).save() - - @shared_dedicated_queue_retry_task(autoretry_for=(Exception,), retry_backoff=True, max_retries=0) def unpopulate_slack_user_identities(organization_pk, force=False, ts=None): User = apps.get_model("user_management", "User") @@ -381,60 +258,6 @@ def populate_slack_user_identities(organization_pk): SlackUserIdentity.objects.bulk_update(slack_user_identities_to_update, fields_to_update, batch_size=5000) -@shared_dedicated_queue_retry_task() -def refresh_slack_user_identity_emails(): - SlackUserIdentity = apps.get_model("slack", "SlackUserIdentity") - - qs = ( - SlackUserIdentity.all_objects.filter(cached_slack_email="") - .exclude(deleted=True) - .exclude(cached_is_bot=True) - .exclude( - cached_name="user_not_found", - ) - .exclude(slack_team_identity__cached_name="no_enough_permissions_to_retrieve") - .exclude(slack_team_identity__detected_token_revoked__isnull=False) - ) - - total = qs.count() - for index, slack_user_identity in enumerate(qs, start=1): - try: - sc = SlackClientWithErrorHandling(slack_user_identity.slack_team_identity.bot_access_token) - result = sc.api_call("users.info", user=slack_user_identity.slack_id) - - if "email" in result.get("user").get("profile", None): - slack_user_identity.cached_slack_email = result["user"]["profile"]["email"] - slack_user_identity.save(update_fields=["cached_slack_email"]) - logger.info(f"({index}/{total}). Email is found") - elif result.get("user").get("is_bot") is True or result.get("user").get("id") == SLACK_BOT_ID: - slack_user_identity.cached_is_bot = True - slack_user_identity.save(update_fields=["cached_is_bot"]) - logger.info(f"({index}/{total}). Bot is found") - elif result.get("user").get("deleted") is True: - slack_user_identity.deleted = True - slack_user_identity.save(update_fields=["deleted"]) - logger.info(f"({index}/{total}). Deleted is found") - elif result.get("user").get("is_stranger", False): - # case: strangers or external members, - # see https://api.slack.com/enterprise/shared-channels - slack_user_identity.is_stranger = True - slack_user_identity.save(update_fields=["is_stranger"]) - logger.info(f"({index}/{total}). Stranger or external user detected.") - else: - logger.error( - f"({index}/{total}). Error!!! Email definition error for SlackUserIdentity pk: " - f"{slack_user_identity.pk}. It will be generated unknown_email." - ) - except SlackAPIException as e: - # case: user_not_found - if e.response["error"] == "user_not_found": - slack_user_identity.is_not_found = True - slack_user_identity.save(update_fields=["is_not_found"]) - logger.info(f"({index}/{total}). User_not_found detected.") - else: - logger.error(f"({index}/{total}). Error!!! Exception: {e}") - - @shared_dedicated_queue_retry_task( autoretry_for=(Exception,), retry_backoff=True, max_retries=1 if settings.DEBUG else None ) diff --git a/engine/apps/slack/tests/test_scenario_steps/test_alert_group_actions.py b/engine/apps/slack/tests/test_scenario_steps/test_alert_group_actions.py index 38f9b012..9f91aac3 100644 --- a/engine/apps/slack/tests/test_scenario_steps/test_alert_group_actions.py +++ b/engine/apps/slack/tests/test_scenario_steps/test_alert_group_actions.py @@ -90,7 +90,6 @@ def test_alert_group_actions_unauthorized( organization, user, slack_team_identity, slack_user_identity = make_organization_and_user_with_slack_identities( role=LegacyAccessControlRole.VIEWER ) - organization.refresh_from_db() # without this there's something weird with organization.archive_alerts_from alert_receive_channel = make_alert_receive_channel(organization) alert_group = make_alert_group(alert_receive_channel) @@ -262,7 +261,6 @@ def test_step_acknowledge( slack_channel = make_slack_channel(slack_team_identity, slack_id=SLACK_CHANNEL_ID) organization = make_organization(pk=ORGANIZATION_ID, slack_team_identity=slack_team_identity) - organization.refresh_from_db() # without this there's something weird with organization.archive_alerts_from user = make_user(organization=organization, slack_user_identity=slack_user_identity) alert_receive_channel = make_alert_receive_channel(organization) @@ -312,7 +310,6 @@ def test_step_unacknowledge( slack_channel = make_slack_channel(slack_team_identity, slack_id=SLACK_CHANNEL_ID) organization = make_organization(pk=ORGANIZATION_ID, slack_team_identity=slack_team_identity) - organization.refresh_from_db() # without this there's something weird with organization.archive_alerts_from user = make_user(organization=organization, slack_user_identity=slack_user_identity) alert_receive_channel = make_alert_receive_channel(organization) @@ -362,7 +359,6 @@ def test_step_resolve( slack_channel = make_slack_channel(slack_team_identity, slack_id=SLACK_CHANNEL_ID) organization = make_organization(pk=ORGANIZATION_ID, slack_team_identity=slack_team_identity) - organization.refresh_from_db() # without this there's something weird with organization.archive_alerts_from user = make_user(organization=organization, slack_user_identity=slack_user_identity) alert_receive_channel = make_alert_receive_channel(organization) @@ -412,7 +408,6 @@ def test_step_unresolve( slack_channel = make_slack_channel(slack_team_identity, slack_id=SLACK_CHANNEL_ID) organization = make_organization(pk=ORGANIZATION_ID, slack_team_identity=slack_team_identity) - organization.refresh_from_db() # without this there's something weird with organization.archive_alerts_from user = make_user(organization=organization, slack_user_identity=slack_user_identity) alert_receive_channel = make_alert_receive_channel(organization) @@ -467,7 +462,6 @@ def test_step_invite( slack_channel = make_slack_channel(slack_team_identity, slack_id=SLACK_CHANNEL_ID) organization = make_organization(pk=ORGANIZATION_ID, slack_team_identity=slack_team_identity) - organization.refresh_from_db() # without this there's something weird with organization.archive_alerts_from user = make_user(organization=organization, slack_user_identity=slack_user_identity) second_user = make_user(organization=organization, pk=USER_ID) @@ -529,7 +523,6 @@ def test_step_stop_invite( slack_channel = make_slack_channel(slack_team_identity, slack_id=SLACK_CHANNEL_ID) organization = make_organization(pk=ORGANIZATION_ID, slack_team_identity=slack_team_identity) - organization.refresh_from_db() # without this there's something weird with organization.archive_alerts_from user = make_user(organization=organization, slack_user_identity=slack_user_identity) second_user = make_user(organization=organization, pk=USER_ID) @@ -587,7 +580,6 @@ def test_step_silence( slack_channel = make_slack_channel(slack_team_identity, slack_id=SLACK_CHANNEL_ID) organization = make_organization(pk=ORGANIZATION_ID, slack_team_identity=slack_team_identity) - organization.refresh_from_db() # without this there's something weird with organization.archive_alerts_from user = make_user(organization=organization, slack_user_identity=slack_user_identity) alert_receive_channel = make_alert_receive_channel(organization) @@ -642,7 +634,6 @@ def test_step_unsilence( slack_channel = make_slack_channel(slack_team_identity, slack_id=SLACK_CHANNEL_ID) organization = make_organization(pk=ORGANIZATION_ID, slack_team_identity=slack_team_identity) - organization.refresh_from_db() # without this there's something weird with organization.archive_alerts_from user = make_user(organization=organization, slack_user_identity=slack_user_identity) alert_receive_channel = make_alert_receive_channel(organization) @@ -686,7 +677,6 @@ def test_step_select_attach( slack_channel = make_slack_channel(slack_team_identity, slack_id=SLACK_CHANNEL_ID) organization = make_organization(pk=ORGANIZATION_ID, slack_team_identity=slack_team_identity) - organization.refresh_from_db() # without this there's something weird with organization.archive_alerts_from user = make_user(organization=organization, slack_user_identity=slack_user_identity) alert_receive_channel = make_alert_receive_channel(organization) @@ -743,7 +733,6 @@ def test_step_unattach( slack_channel = make_slack_channel(slack_team_identity, slack_id=SLACK_CHANNEL_ID) organization = make_organization(pk=ORGANIZATION_ID, slack_team_identity=slack_team_identity) - organization.refresh_from_db() # without this there's something weird with organization.archive_alerts_from user = make_user(organization=organization, slack_user_identity=slack_user_identity) alert_receive_channel = make_alert_receive_channel(organization) @@ -800,7 +789,6 @@ def test_step_format_alert( slack_channel = make_slack_channel(slack_team_identity, slack_id=SLACK_CHANNEL_ID) organization = make_organization(pk=ORGANIZATION_ID, slack_team_identity=slack_team_identity) - organization.refresh_from_db() # without this there's something weird with organization.archive_alerts_from user = make_user(organization=organization, slack_user_identity=slack_user_identity) alert_receive_channel = make_alert_receive_channel(organization) @@ -826,7 +814,6 @@ def test_step_resolution_note( make_organization_and_user_with_slack_identities, make_alert_receive_channel, make_alert_group, make_alert ): organization, user, slack_team_identity, slack_user_identity = make_organization_and_user_with_slack_identities() - organization.refresh_from_db() # without this there's something weird with organization.archive_alerts_from alert_receive_channel = make_alert_receive_channel(organization) alert_group = make_alert_group(alert_receive_channel) diff --git a/engine/apps/slack/tests/test_scenario_steps/test_resolution_note.py b/engine/apps/slack/tests/test_scenario_steps/test_resolution_note.py index cc463b01..1e31a8f2 100644 --- a/engine/apps/slack/tests/test_scenario_steps/test_resolution_note.py +++ b/engine/apps/slack/tests/test_scenario_steps/test_resolution_note.py @@ -198,7 +198,6 @@ def test_resolution_notes_modal_closed_before_update( ResolutionNoteModalStep = ScenarioStep.get_step("resolution_note", "ResolutionNoteModalStep") organization, user, slack_team_identity, slack_user_identity = make_organization_and_user_with_slack_identities() - organization.refresh_from_db() # without this there's something weird with organization.archive_alerts_from alert_receive_channel = make_alert_receive_channel(organization) alert_group = make_alert_group(alert_receive_channel) diff --git a/engine/apps/twilioapp/migrations/0007_delete_twiliologrecord.py b/engine/apps/twilioapp/migrations/0007_delete_twiliologrecord.py new file mode 100644 index 00000000..33e260ee --- /dev/null +++ b/engine/apps/twilioapp/migrations/0007_delete_twiliologrecord.py @@ -0,0 +1,18 @@ +# Generated by Django 3.2.20 on 2023-07-12 05:32 + +from django.db import migrations +import django_migration_linter as linter + + +class Migration(migrations.Migration): + + dependencies = [ + ('twilioapp', '0006_auto_20230601_0807'), + ] + + operations = [ + linter.IgnoreMigration(), + migrations.DeleteModel( + name='TwilioLogRecord', + ), + ] diff --git a/engine/apps/twilioapp/models/__init__.py b/engine/apps/twilioapp/models/__init__.py index ba7b537d..519f3bbe 100644 --- a/engine/apps/twilioapp/models/__init__.py +++ b/engine/apps/twilioapp/models/__init__.py @@ -1,4 +1,3 @@ -from .twilio_log_record import TwilioLogRecord # noqa: F401 from .twilio_phone_call import TwilioCallStatuses, TwilioPhoneCall # noqa: F401 from .twilio_sender import ( # noqa: F401 TwilioAccount, diff --git a/engine/apps/twilioapp/models/twilio_log_record.py b/engine/apps/twilioapp/models/twilio_log_record.py deleted file mode 100644 index e26b7046..00000000 --- a/engine/apps/twilioapp/models/twilio_log_record.py +++ /dev/null @@ -1,47 +0,0 @@ -from django.db import models - - -class TwilioLogRecordType(object): - VERIFICATION_START = 10 - VERIFICATION_CHECK = 20 - - CHOICES = ((VERIFICATION_START, "verification start"), (VERIFICATION_CHECK, "verification check")) - - -class TwilioLogRecordStatus(object): - # For verification and check it has used the same statuses - # https://www.twilio.com/docs/verify/api/verification#verification-response-properties - # https://www.twilio.com/docs/verify/api/verification-check - - PENDING = 10 - APPROVED = 20 - DENIED = 30 - # Our customized status for TwilioException - ERROR = 40 - - CHOICES = ((PENDING, "pending"), (APPROVED, "approved"), (DENIED, "denied"), (ERROR, "error")) - - DETERMINANT = {"pending": PENDING, "approved": APPROVED, "denied": DENIED, "error": ERROR} - - -# Deprecated model. Kept here for backward compatibility, should be removed after phone notificator release -class TwilioLogRecord(models.Model): - user = models.ForeignKey("user_management.User", on_delete=models.CASCADE) - - phone_number = models.CharField(max_length=16) - - type = models.PositiveSmallIntegerField( - choices=TwilioLogRecordType.CHOICES, default=TwilioLogRecordType.VERIFICATION_START - ) - - status = models.PositiveSmallIntegerField( - choices=TwilioLogRecordStatus.CHOICES, default=TwilioLogRecordStatus.PENDING - ) - - payload = models.TextField(null=True, default=None) - - error_message = models.TextField(null=True, default=None) - - succeed = models.BooleanField(default=False) - - created_at = models.DateTimeField(auto_now_add=True) diff --git a/engine/apps/user_management/migrations/0012_auto_20230711_1554.py b/engine/apps/user_management/migrations/0012_auto_20230711_1554.py new file mode 100644 index 00000000..ba74ed1d --- /dev/null +++ b/engine/apps/user_management/migrations/0012_auto_20230711_1554.py @@ -0,0 +1,23 @@ +# Generated by Django 3.2.20 on 2023-07-11 15:54 + +from django.db import migrations +import django_migration_linter as linter + + +class Migration(migrations.Migration): + + dependencies = [ + ('user_management', '0011_auto_20230411_1358'), + ] + + operations = [ + linter.IgnoreMigration(), + migrations.RemoveField( + model_name='organization', + name='archive_alerts_from', + ), + migrations.RemoveField( + model_name='organization', + name='is_amixr_migration_started', + ), + ] diff --git a/engine/apps/user_management/models/organization.py b/engine/apps/user_management/models/organization.py index 0afbbd1c..fa2b8b2d 100644 --- a/engine/apps/user_management/models/organization.py +++ b/engine/apps/user_management/models/organization.py @@ -160,8 +160,6 @@ class Organization(MaintainableObject): is_resolution_note_required = models.BooleanField(default=False) - archive_alerts_from = models.DateField(default="1970-01-01") - # TODO: this field is specific to slack and will be moved to a different model slack_team_identity = models.ForeignKey( "slack.SlackTeamIdentity", on_delete=models.PROTECT, null=True, default=None, related_name="organizations" @@ -237,7 +235,6 @@ class Organization(MaintainableObject): PRICING_CHOICES = ((FREE_PUBLIC_BETA_PRICING, "Free public beta"),) pricing_version = models.PositiveIntegerField(choices=PRICING_CHOICES, default=FREE_PUBLIC_BETA_PRICING) - is_amixr_migration_started = models.BooleanField(default=False) is_rbac_permissions_enabled = models.BooleanField(default=False) is_grafana_incident_enabled = models.BooleanField(default=False) @@ -293,7 +290,7 @@ class Organization(MaintainableObject): """ Following methods: - phone_calls_left, sms_left, emails_left, notifications_limit_web_report + phone_calls_left, sms_left, emails_left serve for calculating notifications' limits and composed from self.subscription_strategy. """ @@ -307,9 +304,6 @@ class Organization(MaintainableObject): def emails_left(self, user): return self.subscription_strategy.emails_left(user) - def notifications_limit_web_report(self, user): - return self.subscription_strategy.notifications_limit_web_report(user) - def set_general_log_channel(self, channel_id, channel_name, user): if self.general_log_channel_id != channel_id: old_general_log_channel_id = self.slack_team_identity.cached_channels.filter( @@ -352,7 +346,6 @@ class Organization(MaintainableObject): return { "name": self.org_title, "is_resolution_note_required": self.is_resolution_note_required, - "archive_alerts_from": self.archive_alerts_from.isoformat(), } @property diff --git a/engine/apps/user_management/subscription_strategy/base_subsription_strategy.py b/engine/apps/user_management/subscription_strategy/base_subsription_strategy.py index 5d7c61da..029d6310 100644 --- a/engine/apps/user_management/subscription_strategy/base_subsription_strategy.py +++ b/engine/apps/user_management/subscription_strategy/base_subsription_strategy.py @@ -5,10 +5,6 @@ class BaseSubscriptionStrategy(ABC): def __init__(self, organization): self.organization = organization - @abstractmethod - def notifications_limit_web_report(self, user): - raise NotImplementedError - @abstractmethod def phone_calls_left(self, user): raise NotImplementedError diff --git a/engine/apps/user_management/subscription_strategy/free_public_beta_subscription_strategy.py b/engine/apps/user_management/subscription_strategy/free_public_beta_subscription_strategy.py index 116a985f..1b469ebf 100644 --- a/engine/apps/user_management/subscription_strategy/free_public_beta_subscription_strategy.py +++ b/engine/apps/user_management/subscription_strategy/free_public_beta_subscription_strategy.py @@ -31,25 +31,6 @@ class FreePublicBetaSubscriptionStrategy(BaseSubscriptionStrategy): ).count() return self._emails_limit - emails_today - def notifications_limit_web_report(self, user): - limits_to_show = [] - left = self._calculate_phone_notifications_left(user) - limit = self._phone_notifications_limit - limits_to_show.append({"limit_title": "Phone Calls & SMS", "total": limit, "left": left}) - show_limits_warning = left <= limit * 0.2 # Show limit popup if less than 20% of notifications left - - warning_text = ( - f"You{'' if left == 0 else ' almost'} have exceeded the limit of phone calls and sms:" - f" {left} of {limit} left." - ) - - return { - "period_title": "Daily limit", - "limits_to_show": limits_to_show, - "show_limits_warning": show_limits_warning, - "warning_text": warning_text, - } - def _calculate_phone_notifications_left(self, user): """ Count sms and calls together and they have common limit. diff --git a/engine/apps/webhooks/migrations/0007_webhookresponse_event_data.py b/engine/apps/webhooks/migrations/0007_webhookresponse_event_data.py new file mode 100644 index 00000000..f2772a09 --- /dev/null +++ b/engine/apps/webhooks/migrations/0007_webhookresponse_event_data.py @@ -0,0 +1,18 @@ +# Generated by Django 3.2.19 on 2023-07-05 18:46 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('webhooks', '0006_auto_20230426_1631'), + ] + + operations = [ + migrations.AddField( + model_name='webhookresponse', + name='event_data', + field=models.TextField(default=None, null=True), + ), + ] diff --git a/engine/apps/webhooks/models/webhook.py b/engine/apps/webhooks/models/webhook.py index d9992a5e..d3d8098d 100644 --- a/engine/apps/webhooks/models/webhook.py +++ b/engine/apps/webhooks/models/webhook.py @@ -293,6 +293,7 @@ class WebhookResponse(models.Model): url = models.TextField(null=True, default=None) status_code = models.IntegerField(default=None, null=True) content = models.TextField(null=True, default=None) + event_data = models.TextField(null=True, default=None) def json(self): if self.content: diff --git a/engine/apps/webhooks/tasks/trigger_webhook.py b/engine/apps/webhooks/tasks/trigger_webhook.py index 3073c2b6..75585c3b 100644 --- a/engine/apps/webhooks/tasks/trigger_webhook.py +++ b/engine/apps/webhooks/tasks/trigger_webhook.py @@ -110,6 +110,7 @@ def make_request(webhook, alert_group, data): "status_code": None, "content": None, "webhook": webhook, + "event_data": json.dumps(data), } exception = error = None diff --git a/engine/engine/logging/formatters.py b/engine/engine/logging/formatters.py deleted file mode 100644 index 9ad7f029..00000000 --- a/engine/engine/logging/formatters.py +++ /dev/null @@ -1,18 +0,0 @@ -from django.conf import settings -from pythonjsonlogger import jsonlogger - - -class CustomStackdriverJsonFormatter(jsonlogger.JsonFormatter): - def add_fields(self, log_record, record, message_dict): - super(CustomStackdriverJsonFormatter, self).add_fields(log_record, record, message_dict) - if ( - settings.GCP_PROJECT_ID - and log_record["request_id"] is not None - and len(log_record["request_id"].split("/")) == 2 - ): - trace = log_record["request_id"].split("/") - log_record["logging.googleapis.com/trace"] = f"projects/{settings.GCP_PROJECT_ID}/traces/{trace[0]}" - if "levelname" in log_record: - log_record["severity"] = log_record["levelname"] - if "exc_info" in log_record: - log_record["@type"] = "type.googleapis.com/google.devtools.clouderrorreporting.v1beta1.ReportedErrorEvent" diff --git a/engine/engine/management/commands/restart_escalation.py b/engine/engine/management/commands/restart_escalation.py deleted file mode 100644 index 8ceaf01d..00000000 --- a/engine/engine/management/commands/restart_escalation.py +++ /dev/null @@ -1,87 +0,0 @@ -from celery import uuid as celery_uuid -from django.core.management import BaseCommand -from django.db.models import Q -from django.utils import timezone - -from apps.alerts.models import AlertGroup, AlertReceiveChannel -from apps.alerts.tasks import escalate_alert_group, unsilence_task - - -class Command(BaseCommand): - def add_arguments(self, parser): - group = parser.add_mutually_exclusive_group(required=True) - - group.add_argument("--alert_group_ids", type=int, nargs="+", help="Alert group IDs to restart escalation for.") - group.add_argument( - "--all", action="store_true", help="Restart escalation for all alert groups with unfinished escalation." - ) - - def handle(self, *args, **options): - alert_group_ids = options["alert_group_ids"] - restart_all = options["all"] - - if restart_all: - alert_groups = AlertGroup.all_objects.filter( - ~Q(channel__integration=AlertReceiveChannel.INTEGRATION_MAINTENANCE), - ~Q(silenced=True, silenced_until__isnull=True), # filter silenced forever alert_groups - Q(Q(is_escalation_finished=False) | Q(silenced_until__isnull=False)), - resolved=False, - acknowledged=False, - root_alert_group=None, - ) - else: - alert_groups = AlertGroup.all_objects.filter( - pk__in=alert_group_ids, - ) - - if not alert_groups: - self.stdout.write("No escalations to restart.") - return - - tasks = [] - alert_groups_to_update = [] - now = timezone.now() - - for alert_group in alert_groups: - task_id = celery_uuid() - # if incident was silenced, start unsilence_task - if alert_group.is_silenced_for_period: - alert_group.unsilence_task_uuid = task_id - - escalation_start_time = max(now, alert_group.silenced_until) - alert_groups_to_update.append(alert_group) - - tasks.append( - unsilence_task.signature( - args=(alert_group.pk,), - immutable=True, - task_id=task_id, - eta=escalation_start_time, - ) - ) - # otherwise start escalate_alert_group task - else: - if alert_group.escalation_snapshot: - alert_group.active_escalation_id = task_id - alert_groups_to_update.append(alert_group) - - tasks.append( - escalate_alert_group.signature( - args=(alert_group.pk,), - immutable=True, - task_id=task_id, - eta=alert_group.next_step_eta, - ) - ) - - AlertGroup.all_objects.bulk_update( - alert_groups_to_update, - ["active_escalation_id", "unsilence_task_uuid"], - batch_size=5000, - ) - - for task in tasks: - task.apply_async() - - restarted_alert_group_ids = ", ".join(str(alert_group.pk) for alert_group in alert_groups) - self.stdout.write("Escalations restarted for alert groups: {}".format(restarted_alert_group_ids)) diff --git a/engine/engine/urls.py b/engine/engine/urls.py index 83ede866..eb1a66fd 100644 --- a/engine/engine/urls.py +++ b/engine/engine/urls.py @@ -31,8 +31,6 @@ paths_to_work_even_when_maintenance_mode_is_active = [ urlpatterns = [ *paths_to_work_even_when_maintenance_mode_is_active, - # path('slow/', SlowView.as_view()), - # path('exception/', ExceptionView.as_view()), path(settings.ONCALL_DJANGO_ADMIN_PATH, admin.site.urls), path("api/gi/v1/", include("apps.api_for_grafana_incident.urls", namespace="api-gi")), path("api/internal/v1/", include("apps.api.urls", namespace="api-internal")), diff --git a/engine/engine/views.py b/engine/engine/views.py index 410bf048..7f3e2f5d 100644 --- a/engine/engine/views.py +++ b/engine/engine/views.py @@ -1,17 +1,9 @@ -import time - from django.conf import settings from django.core.cache import cache from django.http import HttpResponse, JsonResponse from django.views.generic import View from apps.integrations.mixins import AlertChannelDefiningMixin -from common.custom_celery_tasks import shared_dedicated_queue_retry_task - - -@shared_dedicated_queue_retry_task(ignore_result=True) -def health_check_task(): - return "Ok" class HealthCheckView(View): @@ -60,17 +52,6 @@ class StartupProbeView(View): return HttpResponse("Ok") -class SlowView(View): - def get(self, request): - time.sleep(1.5) - return HttpResponse("Slept well.") - - -class ExceptionView(View): - def get(self, request): - raise Exception("Trying exception!") - - class MaintenanceModeStatusView(View): def get(self, _request): return JsonResponse( diff --git a/engine/requirements.txt b/engine/requirements.txt index 5baa8a0d..02fc9f4b 100644 --- a/engine/requirements.txt +++ b/engine/requirements.txt @@ -25,7 +25,6 @@ beautifulsoup4==4.12.2 social-auth-app-django==5.0.0 cryptography==38.0.4 # version 39.0.0 introduced an issue - https://stackoverflow.com/a/75053968/3902555 factory-boy<3.0 -python-json-logger==2.0.1 django-log-request-id==1.6.0 django-polymorphic==3.0.0 django-rest-polymorphic==0.1.9 diff --git a/engine/settings/prod_without_db.py b/engine/settings/prod_without_db.py index 355e81d2..eeb67edc 100644 --- a/engine/settings/prod_without_db.py +++ b/engine/settings/prod_without_db.py @@ -46,8 +46,6 @@ SECURE_HSTS_SECONDS = 360000 CELERY_TASK_ROUTES = { # DEFAULT "apps.alerts.tasks.call_ack_url.call_ack_url": {"queue": "default"}, - "apps.alerts.tasks.cache_alert_group_for_web.cache_alert_group_for_web": {"queue": "default"}, - "apps.alerts.tasks.cache_alert_group_for_web.schedule_cache_for_alert_group": {"queue": "default"}, "apps.alerts.tasks.create_contact_points_for_datasource.create_contact_points_for_datasource": {"queue": "default"}, "apps.alerts.tasks.sync_grafana_alerting_contact_points.sync_grafana_alerting_contact_points": {"queue": "default"}, "apps.alerts.tasks.delete_alert_group.delete_alert_group": {"queue": "default"}, @@ -78,7 +76,6 @@ CELERY_TASK_ROUTES = { "apps.schedules.tasks.notify_about_empty_shifts_in_schedule.start_notify_about_empty_shifts_in_schedule": { "queue": "default" }, - "engine.views.health_check_task": {"queue": "default"}, # CRITICAL "apps.alerts.tasks.acknowledge_reminder.acknowledge_reminder_task": {"queue": "critical"}, "apps.alerts.tasks.acknowledge_reminder.unacknowledge_timeout_task": {"queue": "critical"}, @@ -99,7 +96,6 @@ CELERY_TASK_ROUTES = { }, "apps.alerts.tasks.resolve_by_last_step.resolve_by_last_step_task": {"queue": "critical"}, "apps.alerts.tasks.send_update_log_report_signal.send_update_log_report_signal": {"queue": "critical"}, - "apps.alerts.tasks.send_update_postmortem_signal.send_update_postmortem_signal": {"queue": "critical"}, "apps.alerts.tasks.send_update_resolution_note_signal.send_update_resolution_note_signal": {"queue": "critical"}, "apps.alerts.tasks.unsilence.unsilence_task": {"queue": "critical"}, "apps.base.tasks.process_failed_to_invoke_celery_tasks": {"queue": "critical"}, @@ -135,12 +131,8 @@ CELERY_TASK_ROUTES = { "apps.slack.tasks.populate_slack_usergroups_for_team": {"queue": "slack"}, "apps.slack.tasks.post_or_update_log_report_message_task": {"queue": "slack"}, "apps.slack.tasks.post_slack_rate_limit_message": {"queue": "slack"}, - "apps.slack.tasks.refresh_slack_user_identity_emails": {"queue": "slack"}, - "apps.slack.tasks.resolve_archived_incidents_for_organization": {"queue": "slack"}, - "apps.slack.tasks.send_debug_message_to_thread": {"queue": "slack"}, "apps.slack.tasks.send_message_to_thread_if_bot_not_in_channel": {"queue": "slack"}, "apps.slack.tasks.start_update_slack_user_group_for_schedules": {"queue": "slack"}, - "apps.slack.tasks.unarchive_incidents_for_organization": {"queue": "slack"}, "apps.slack.tasks.unpopulate_slack_user_identities": {"queue": "slack"}, "apps.slack.tasks.update_incident_slack_message": {"queue": "slack"}, "apps.slack.tasks.update_slack_user_group_for_schedules": {"queue": "slack"}, diff --git a/grafana-plugin/integration-tests/utils/forms.ts b/grafana-plugin/integration-tests/utils/forms.ts index 30fb8cdd..fb00b9dd 100644 --- a/grafana-plugin/integration-tests/utils/forms.ts +++ b/grafana-plugin/integration-tests/utils/forms.ts @@ -88,14 +88,7 @@ const chooseDropdownValue = async ({ page, value, optionExactMatch = true }: Sel export const selectDropdownValue = async (args: SelectDropdownValueArgs): Promise => { const selectElement = await openSelect(args); - - /** - * use the select search to filter down the options - * TODO: get rid of the slice when we fix the GSelect component.. - * without slicing this would fire off an API request for every key-stroke - */ - await selectElement.type(args.value.slice(0, 5)); - + await selectElement.type(args.value); await chooseDropdownValue(args); return selectElement; diff --git a/grafana-plugin/playwright.config.ts b/grafana-plugin/playwright.config.ts index bab2ea80..424a03e3 100644 --- a/grafana-plugin/playwright.config.ts +++ b/grafana-plugin/playwright.config.ts @@ -38,7 +38,7 @@ const config: PlaywrightTestConfig = { * to flaky tests.. let's just retry failed tests. If the same test fails 3 times, you know something must be up */ retries: !!process.env.CI ? 3 : 0, - workers: !!process.env.CI ? 2 : 1, + workers: 3, /* Reporter to use. See https://playwright.dev/docs/test-reporters */ reporter: 'html', /* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */ diff --git a/grafana-plugin/src/components/AlertTemplates/AlertTemplatesForm.tsx b/grafana-plugin/src/components/AlertTemplates/AlertTemplatesForm.tsx index 984042a4..66cf2a3a 100644 --- a/grafana-plugin/src/components/AlertTemplates/AlertTemplatesForm.tsx +++ b/grafana-plugin/src/components/AlertTemplates/AlertTemplatesForm.tsx @@ -12,7 +12,7 @@ import Block from 'components/GBlock/Block'; import MonacoEditor from 'components/MonacoEditor/MonacoEditor'; import SourceCode from 'components/SourceCode/SourceCode'; import Text from 'components/Text/Text'; -import TemplatePreview from 'containers/TemplatePreview/TemplatePreview'; +import TemplatePreview, { TEMPLATE_PAGE } from 'containers/TemplatePreview/TemplatePreview'; import { WithPermissionControlTooltip } from 'containers/WithPermissionControl/WithPermissionControlTooltip'; import { AlertReceiveChannel } from 'models/alert_receive_channel/alert_receive_channel.types'; import { Alert } from 'models/alertgroup/alertgroup.types'; @@ -132,13 +132,6 @@ const AlertTemplatesForm = (props: AlertTemplatesFormProps) => { } }, [activeGroup]); - const getTemplatePreviewEditClickHandler = (templateName: string) => { - return () => { - const template = templatesToRender.find((template) => template.name === templateName); - setActiveTemplate(template); - }; - }; - useEffect(() => { if (!activeTemplate && filteredTemplatesToRender.length) { setActiveTemplate(filteredTemplatesToRender[0]); @@ -261,11 +254,10 @@ const AlertTemplatesForm = (props: AlertTemplatesFormProps) => { {groups[activeGroup].map((template) => ( diff --git a/grafana-plugin/src/components/GForm/GForm.tsx b/grafana-plugin/src/components/GForm/GForm.tsx index a7e6b145..d60de4e8 100644 --- a/grafana-plugin/src/components/GForm/GForm.tsx +++ b/grafana-plugin/src/components/GForm/GForm.tsx @@ -6,6 +6,8 @@ import cn from 'classnames/bind'; import Collapse from 'components/Collapse/Collapse'; import { FormItem, FormItemType } from 'components/GForm/GForm.types'; +import MonacoEditor from 'components/MonacoEditor/MonacoEditor'; +import { MONACO_READONLY_CONFIG } from 'components/MonacoEditor/MonacoEditor.config'; import GSelect from 'containers/GSelect/GSelect'; import RemoteSelect from 'containers/RemoteSelect/RemoteSelect'; @@ -17,6 +19,12 @@ interface GFormProps { form: { name: string; fields: FormItem[] }; data: any; onSubmit: (data: any) => void; + onFieldRender?: ( + formItem: FormItem, + renderedControl: React.ReactElement, + values: any, + setValue: (value: string) => void + ) => React.ReactElement; } const nullNormalizer = (value: string) => { @@ -110,6 +118,28 @@ function renderFormControl(formItem: FormItem, register: any, control: any, onCh /> ); + case FormItemType.Monaco: + return ( + { + return ( + onChangeFn(field, value)} + /> + ); + }} + /> + ); + default: return null; } @@ -117,7 +147,7 @@ function renderFormControl(formItem: FormItem, register: any, control: any, onCh class GForm extends React.Component { render() { - const { form, data } = this.props; + const { form, data, onFieldRender } = this.props; const openFields = form.fields.filter((field) => !field.collapsed); const collapsedfields = form.fields.filter((field) => field.collapsed); @@ -131,6 +161,11 @@ class GForm extends React.Component { return null; } + const formControl = renderFormControl(formItem, register, control, (field, value) => { + field?.onChange(value); + this.forceUpdate(); + }); + return ( { error={formItem.label ? `${formItem.label} is required` : `${capitalCase(formItem.name)} is required`} description={formItem.description} > - {renderFormControl(formItem, register, control, (field, value) => { - field?.onChange(value); - this.forceUpdate(); - })} + {onFieldRender + ? onFieldRender(formItem, formControl, getValues(), (value) => setValue(formItem.name, value)) + : formControl} ); }; diff --git a/grafana-plugin/src/components/GForm/GForm.types.ts b/grafana-plugin/src/components/GForm/GForm.types.ts index a5adbc4f..4795a3a1 100644 --- a/grafana-plugin/src/components/GForm/GForm.types.ts +++ b/grafana-plugin/src/components/GForm/GForm.types.ts @@ -7,12 +7,14 @@ export enum FormItemType { 'GSelect' = 'gselect', 'Switch' = 'switch', 'RemoteSelect' = 'remoteselect', + 'Monaco' = 'monaco', } export interface FormItem { name: string; label?: string; type: FormItemType; + isReadOnly?: boolean; description?: string; normalize?: (value: any) => any; isVisible?: (data: any) => any; diff --git a/grafana-plugin/src/components/MonacoEditor/MonacoEditor.config.ts b/grafana-plugin/src/components/MonacoEditor/MonacoEditor.config.ts new file mode 100644 index 00000000..bd0ea60d --- /dev/null +++ b/grafana-plugin/src/components/MonacoEditor/MonacoEditor.config.ts @@ -0,0 +1,26 @@ +// Mostly used for input fields where we're hiding scrollbars +export const MONACO_READONLY_CONFIG = { + renderLineHighlight: false, + readOnly: true, + scrollbar: { + vertical: 'hidden', + horizontal: 'hidden', + verticalScrollbarSize: 0, + handleMouseWheel: false, + }, + hideCursorInOverviewRuler: true, + minimap: { enabled: false }, + cursorStyle: { + display: 'none', + }, +}; + +export const MONACO_EDITABLE_CONFIG = { + renderLineHighlight: false, + readOnly: false, + hideCursorInOverviewRuler: true, + minimap: { enabled: false }, + cursorStyle: { + display: 'none', + }, +}; diff --git a/grafana-plugin/src/components/MonacoEditor/MonacoEditor.tsx b/grafana-plugin/src/components/MonacoEditor/MonacoEditor.tsx index ba0b9f25..77e5af37 100644 --- a/grafana-plugin/src/components/MonacoEditor/MonacoEditor.tsx +++ b/grafana-plugin/src/components/MonacoEditor/MonacoEditor.tsx @@ -11,7 +11,7 @@ declare const monaco: any; interface MonacoEditorProps { value: string; disabled?: boolean; - height?: string; + height?: string | number; focus?: boolean; data: any; showLineNumbers?: boolean; @@ -20,6 +20,7 @@ interface MonacoEditorProps { onChange?: (value: string) => void; loading?: boolean; monacoOptions?: any; + suggestionPrefix?: string; } export enum MONACO_LANGUAGE { @@ -48,15 +49,18 @@ const MonacoEditor: FC = (props) => { monacoOptions, showLineNumbers = true, loading = false, + suggestionPrefix = 'payload.', } = props; const autoCompleteList = useCallback( () => - [...PREDEFINED_TERMS, ...getPaths(data?.payload_example).map((str) => `payload.${str}`)].map((str) => ({ - label: str, - insertText: str, - kind: CodeEditorSuggestionItemKind.Field, - })), + [...PREDEFINED_TERMS, ...getPaths(data?.payload_example).map((str) => `${suggestionPrefix}${str}`)].map( + (str) => ({ + label: str, + insertText: str, + kind: CodeEditorSuggestionItemKind.Field, + }) + ), [data?.payload_example] ); diff --git a/grafana-plugin/src/containers/GSelect/GSelect.tsx b/grafana-plugin/src/containers/GSelect/GSelect.tsx index 16fb6db1..38c3e351 100644 --- a/grafana-plugin/src/containers/GSelect/GSelect.tsx +++ b/grafana-plugin/src/containers/GSelect/GSelect.tsx @@ -7,6 +7,7 @@ import { get, isNil } from 'lodash-es'; import { observer } from 'mobx-react'; import { useStore } from 'state/useStore'; +import { useDebouncedCallback } from 'utils/hooks'; import styles from './GSelect.module.scss'; @@ -89,30 +90,22 @@ const GSelect = observer((props: GSelectProps) => { [model, onChange] ); - /** - * without debouncing this function when search is available - * we risk hammering the API endpoint for every single key stroke - * some context on 250ms as the choice here - https://stackoverflow.com/a/44755058/3902555 - */ - const loadOptions = (query: string) => { - return model.updateItems(query).then(() => { + const loadOptions = useDebouncedCallback((query: string, cb) => { + model.updateItems(query).then(() => { const searchResult = model.getSearchResult(query); let items = Array.isArray(searchResult.results) ? searchResult.results : searchResult; if (filterOptions) { items = items.filter((opt: any) => filterOptions(opt[valueField])); } - - return items.map((item: any) => ({ + const options = items.map((item: any) => ({ value: item[valueField], label: get(item, displayField), imgUrl: item.avatar_url, description: getDescription && getDescription(item), })); + cb(options); }); - }; - - // TODO: why doesn't this work properly? - // const loadOptions = debounce(_loadOptions, showSearch ? 250 : 0); + }, 250); const values = isMulti ? (value ? (value as string[]) : []) diff --git a/grafana-plugin/src/containers/IntegrationContainers/ExpandedIntegrationRouteDisplay/ExpandedIntegrationRouteDisplay.tsx b/grafana-plugin/src/containers/IntegrationContainers/ExpandedIntegrationRouteDisplay/ExpandedIntegrationRouteDisplay.tsx index 3f6b5025..cff20d32 100644 --- a/grafana-plugin/src/containers/IntegrationContainers/ExpandedIntegrationRouteDisplay/ExpandedIntegrationRouteDisplay.tsx +++ b/grafana-plugin/src/containers/IntegrationContainers/ExpandedIntegrationRouteDisplay/ExpandedIntegrationRouteDisplay.tsx @@ -20,6 +20,7 @@ import HamburgerMenu from 'components/HamburgerMenu/HamburgerMenu'; import IntegrationBlock from 'components/Integrations/IntegrationBlock'; import IntegrationBlockItem from 'components/Integrations/IntegrationBlockItem'; import MonacoEditor from 'components/MonacoEditor/MonacoEditor'; +import { MONACO_READONLY_CONFIG } from 'components/MonacoEditor/MonacoEditor.config'; import PluginLink from 'components/PluginLink/PluginLink'; import Text from 'components/Text/Text'; import TooltipBadge from 'components/TooltipBadge/TooltipBadge'; @@ -35,7 +36,7 @@ import { ChannelFilter } from 'models/channel_filter/channel_filter.types'; import { EscalationChain } from 'models/escalation_chain/escalation_chain.types'; import CommonIntegrationHelper from 'pages/integration/CommonIntegration.helper'; import IntegrationHelper from 'pages/integration/Integration.helper'; -import { MONACO_INPUT_HEIGHT_SMALL, MONACO_OPTIONS } from 'pages/integration/IntegrationCommon.config'; +import { MONACO_INPUT_HEIGHT_SMALL } from 'pages/integration/IntegrationCommon.config'; import { useStore } from 'state/useStore'; import { openNotification } from 'utils'; import { UserActions } from 'utils/authorization'; @@ -164,7 +165,7 @@ const ExpandedIntegrationRouteDisplay: React.FC - - -
- -
- - - )} - { ); + + function renderCheatSheet() { + if (isCheatSheetVisible) { + return ( + + ); + } + + return ( + <> +
+
+ + Template editor + + + +
+
+ +
+
+ + ); + } }); -interface ResultProps { - alertReceiveChannelId: AlertReceiveChannel['id']; - // templateName: string; - templateBody: string; - template: TemplateForEdit; - isAlertGroupExisting?: boolean; - chatOpsPermalink?: string; - payload?: JSON; - error?: string; - onSaveAndFollowLink?: (link: string) => void; - templateIsRoute?: boolean; -} - -const Result = (props: ResultProps) => { - const { - alertReceiveChannelId, - template, - templateBody, - chatOpsPermalink, - payload, - error, - isAlertGroupExisting, - onSaveAndFollowLink, - } = props; - - return ( -
-
- - Result - -
-
- {payload || error ? ( - - {error ? ( - - {error} - - ) : ( - - - - )} - - {template?.additionalData?.additionalDescription && ( - {template?.additionalData.additionalDescription} - )} - - {template?.additionalData?.chatOpsName && isAlertGroupExisting && ( - - - - {template.additionalData.data && {template.additionalData.data}} - - )} - - ) : ( -
- - ← Select alert group or "Use custom payload" - -
- )} -
-
- ); -}; - export default IntegrationTemplate; diff --git a/grafana-plugin/src/containers/OutgoingWebhook2Form/OutgoingWebhook2Form.config.tsx b/grafana-plugin/src/containers/OutgoingWebhook2Form/OutgoingWebhook2Form.config.tsx index e9713449..f7b6eb68 100644 --- a/grafana-plugin/src/containers/OutgoingWebhook2Form/OutgoingWebhook2Form.config.tsx +++ b/grafana-plugin/src/containers/OutgoingWebhook2Form/OutgoingWebhook2Form.config.tsx @@ -141,14 +141,18 @@ export const form: { name: string; fields: FormItem[] } = { { name: 'url', label: 'Webhook URL', - type: FormItemType.Input, + type: FormItemType.Monaco, validation: { required: true }, + extra: { + height: 30, + }, }, { name: 'headers', label: 'Webhook Headers', description: 'Request headers should be in JSON format.', - type: FormItemType.TextArea, + type: FormItemType.Monaco, + isReadOnly: true, extra: { rows: 3, }, @@ -169,7 +173,8 @@ export const form: { name: string; fields: FormItem[] } = { }, { name: 'trigger_template', - type: FormItemType.TextArea, + type: FormItemType.Monaco, + isReadOnly: true, description: 'Trigger template is used to conditionally execute the webhook based on incoming data. The trigger template must be empty or evaluate to true or 1 for the webhook to be sent', extra: { @@ -185,12 +190,11 @@ export const form: { name: string; fields: FormItem[] } = { { name: 'data', getDisabled: (data) => Boolean(data?.forward_all), - type: FormItemType.TextArea, + type: FormItemType.Monaco, + isReadOnly: true, description: 'Available variables: {{ event }}, {{ user }}, {{ alert_group }}, {{ alert_group_id }}, {{ alert_payload }}, {{ integration }}, {{ notified_users }}, {{ users_to_be_notified }}, {{ responses }}', - extra: { - rows: 9, - }, + extra: {}, }, ], }; diff --git a/grafana-plugin/src/containers/OutgoingWebhook2Form/OutgoingWebhook2Form.module.css b/grafana-plugin/src/containers/OutgoingWebhook2Form/OutgoingWebhook2Form.module.css index c335c84b..a4613c64 100644 --- a/grafana-plugin/src/containers/OutgoingWebhook2Form/OutgoingWebhook2Form.module.css +++ b/grafana-plugin/src/containers/OutgoingWebhook2Form/OutgoingWebhook2Form.module.css @@ -13,3 +13,18 @@ .tabs__content { padding-top: 16px; } + +.form-row { + display: flex; + flex-wrap: nowrap; + gap: 4px; +} + +.form-field { + flex-grow: 1; +} + +/* TODO: figure out why this is not picked */ +.webhooks__drawerContent .cursor.monaco-mouse-cursor-text { + display: none !important; +} diff --git a/grafana-plugin/src/containers/OutgoingWebhook2Form/OutgoingWebhook2Form.tsx b/grafana-plugin/src/containers/OutgoingWebhook2Form/OutgoingWebhook2Form.tsx index e7baf4df..2f915fc9 100644 --- a/grafana-plugin/src/containers/OutgoingWebhook2Form/OutgoingWebhook2Form.tsx +++ b/grafana-plugin/src/containers/OutgoingWebhook2Form/OutgoingWebhook2Form.tsx @@ -6,8 +6,10 @@ import { observer } from 'mobx-react'; import { useHistory } from 'react-router-dom'; import GForm from 'components/GForm/GForm'; +import { FormItem, FormItemType } from 'components/GForm/GForm.types'; import Text from 'components/Text/Text'; import OutgoingWebhook2Status from 'containers/OutgoingWebhook2Status/OutgoingWebhook2Status'; +import WebhooksTemplateEditor from 'containers/WebhooksTemplateEditor/WebhooksTemplateEditor'; import { WithPermissionControlTooltip } from 'containers/WithPermissionControl/WithPermissionControlTooltip'; import { OutgoingWebhook2 } from 'models/outgoing_webhook_2/outgoing_webhook_2.types'; import { WebhookFormActionType } from 'pages/outgoing_webhooks_2/OutgoingWebhooks2.types'; @@ -38,6 +40,8 @@ export const WebhookTabs = { const OutgoingWebhook2Form = observer((props: OutgoingWebhook2FormProps) => { const history = useHistory(); const { id, action, onUpdate, onHide, onDelete } = props; + const [onFormChangeFn, setOnFormChangeFn] = useState<{ fn: (value: string) => void }>(undefined); + const [templateToEdit, setTemplateToEdit] = useState(undefined); const [activeTab, setActiveTab] = useState( action === WebhookFormActionType.EDIT_SETTINGS ? WebhookTabs.Settings.key : WebhookTabs.LastRun.key ); @@ -56,6 +60,31 @@ const OutgoingWebhook2Form = observer((props: OutgoingWebhook2FormProps) => { [id] ); + const getTemplateEditClickHandler = (formItem: FormItem, values, setFormFieldValue) => { + return () => { + const formValue = values[formItem.name]; + setTemplateToEdit({ value: formValue, displayName: undefined, description: undefined, name: formItem.name }); + setOnFormChangeFn({ fn: (value) => setFormFieldValue(value) }); + }; + }; + + const enrchField = (formItem: FormItem, renderedControl: React.ReactElement, values, setFormFieldValue) => { + if (formItem.type === FormItemType.Monaco) { + return ( +
+
{renderedControl}
+
+ ); + } + + return renderedControl; + }; + if ( (action === WebhookFormActionType.EDIT_SETTINGS || action === WebhookFormActionType.VIEW_LAST_RUN) && !outgoingWebhook2Store.items[id] @@ -86,58 +115,89 @@ const OutgoingWebhook2Form = observer((props: OutgoingWebhook2FormProps) => { return null; } + const formElement = ; + if (action === WebhookFormActionType.NEW || action === WebhookFormActionType.COPY) { // show just the creation form, not the tabs return ( - - {renderWebhookForm()} - + <> + +
{renderWebhookForm()}
+
+ {templateToEdit && ( + { + onFormChangeFn?.fn(value); + setTemplateToEdit(undefined); + }} + onHide={() => setTemplateToEdit(undefined)} + template={templateToEdit} + /> + )} + ); } return ( // show tabbed drawer (edit/live_run) - - - { - setActiveTab(WebhookTabs.Settings.key); - history.push(`${PLUGIN_ROOT}/outgoing_webhooks_2/edit/${id}`); - }} - active={activeTab === WebhookTabs.Settings.key} - label={WebhookTabs.Settings.value} - /> + <> + +
+ + { + setActiveTab(WebhookTabs.Settings.key); + history.push(`${PLUGIN_ROOT}/outgoing_webhooks_2/edit/${id}`); + }} + active={activeTab === WebhookTabs.Settings.key} + label={WebhookTabs.Settings.value} + /> - { - setActiveTab(WebhookTabs.LastRun.key); - history.push(`${PLUGIN_ROOT}/outgoing_webhooks_2/last_run/${id}`); - }} - active={activeTab === WebhookTabs.LastRun.key} - label={WebhookTabs.LastRun.value} - /> - + { + setActiveTab(WebhookTabs.LastRun.key); + history.push(`${PLUGIN_ROOT}/outgoing_webhooks_2/last_run/${id}`); + }} + active={activeTab === WebhookTabs.LastRun.key} + label={WebhookTabs.LastRun.value} + /> + - - + +
+
+ {templateToEdit && ( + { + onFormChangeFn?.fn(value); + setTemplateToEdit(undefined); + }} + onHide={() => setTemplateToEdit(undefined)} + template={templateToEdit} + /> + )} + ); function renderWebhookForm() { return ( <>
- +
+ + {template.additionalData.data && {template.additionalData.data}} + + )} + + ) : ( +
+ + + ← Select {templatePage === TEMPLATE_PAGE.Webhooks ? 'event' : 'alert group'} or "Use custom payload" + + +
+ )} +
+
+ ); +}; + +export default TemplateResult; diff --git a/grafana-plugin/src/containers/TemplatesAlertGroupsList/TemplatesAlertGroupsList.tsx b/grafana-plugin/src/containers/TemplatesAlertGroupsList/TemplatesAlertGroupsList.tsx index 30aedff5..0452f94c 100644 --- a/grafana-plugin/src/containers/TemplatesAlertGroupsList/TemplatesAlertGroupsList.tsx +++ b/grafana-plugin/src/containers/TemplatesAlertGroupsList/TemplatesAlertGroupsList.tsx @@ -1,16 +1,17 @@ import React, { useEffect, useState } from 'react'; -import { Button, HorizontalGroup, Tooltip, Icon, IconButton, Badge, LoadingPlaceholder } from '@grafana/ui'; +import { Button, HorizontalGroup, Icon, IconButton, Badge, LoadingPlaceholder } from '@grafana/ui'; import cn from 'classnames/bind'; import { debounce } from 'lodash-es'; import MonacoEditor, { MONACO_LANGUAGE } from 'components/MonacoEditor/MonacoEditor'; +import { MONACO_EDITABLE_CONFIG } from 'components/MonacoEditor/MonacoEditor.config'; import Text from 'components/Text/Text'; import TooltipBadge from 'components/TooltipBadge/TooltipBadge'; import { AlertReceiveChannel } from 'models/alert_receive_channel/alert_receive_channel.types'; import { AlertTemplatesDTO } from 'models/alert_templates'; import { Alert } from 'models/alertgroup/alertgroup.types'; -import { MONACO_PAYLOAD_OPTIONS } from 'pages/integration/IntegrationCommon.config'; +import { OutgoingWebhook2, OutgoingWebhook2Response } from 'models/outgoing_webhook_2/outgoing_webhook_2.types'; import { useStore } from 'state/useStore'; import styles from './TemplatesAlertGroupsList.module.css'; @@ -19,27 +20,55 @@ const cx = cn.bind(styles); const HEADER_OF_CONTAINER_HEIGHT = 59; const BADGE_WITH_PADDINGS_HEIGHT = 42; +export enum TEMPLATE_PAGE { + Integrations, + Webhooks, +} + interface TemplatesAlertGroupsListProps { + templatePage: TEMPLATE_PAGE; templates: AlertTemplatesDTO[]; - alertReceiveChannelId: AlertReceiveChannel['id']; + alertReceiveChannelId?: AlertReceiveChannel['id']; + outgoingwebhookId?: OutgoingWebhook2['id']; + heading?: string; + onSelectAlertGroup?: (alertGroup: Alert) => void; + onEditPayload?: (payload: string) => void; onLoadAlertGroupsList?: (isRecentAlertExising: boolean) => void; } const TemplatesAlertGroupsList = (props: TemplatesAlertGroupsListProps) => { - const { alertReceiveChannelId, templates, onEditPayload, onSelectAlertGroup, onLoadAlertGroupsList } = props; + const { + templatePage, + heading = 'Recent Alert groups', + alertReceiveChannelId, + outgoingwebhookId, + templates, + onEditPayload, + onSelectAlertGroup, + onLoadAlertGroupsList, + } = props; const store = useStore(); const [alertGroupsList, setAlertGroupsList] = useState(undefined); - const [selectedAlertPayload, setSelectedAlertPayload] = useState(undefined); - const [selectedAlertName, setSelectedAlertName] = useState(undefined); + const [outgoingWebhookLastResponses, setOutgoingWebhookLastResponses] = + useState(undefined); + + const [selectedTitle, setSelectedTitle] = useState(undefined); + const [selectedPayload, setSelectedPayload] = useState(undefined); const [isEditMode, setIsEditMode] = useState(false); useEffect(() => { - store.alertGroupStore.getAlertGroupsForIntegration(alertReceiveChannelId).then((result) => { - setAlertGroupsList(result.slice(0, 30)); - onLoadAlertGroupsList(result.length > 0); - }); + if (templatePage === TEMPLATE_PAGE.Webhooks) { + if (outgoingwebhookId !== 'new') { + store.outgoingWebhook2Store.getLastResponses(outgoingwebhookId).then(setOutgoingWebhookLastResponses); + } + } else if (templatePage === TEMPLATE_PAGE.Integrations) { + store.alertGroupStore.getAlertGroupsForIntegration(alertReceiveChannelId).then((result) => { + setAlertGroupsList(result.slice(0, 30)); + onLoadAlertGroupsList(result.length > 0); + }); + } }, []); const getCodeEditorHeight = () => { @@ -62,181 +91,240 @@ const TemplatesAlertGroupsList = (props: TemplatesAlertGroupsListProps) => { const returnToListView = () => { setIsEditMode(false); - setSelectedAlertPayload(undefined); + setSelectedPayload(undefined); onEditPayload(null); }; + // for Integrations + const getAlertGroupPayload = async (id) => { const groupedAlert = await store.alertGroupStore.getAlertsFromGroup(id); const currentIncidentRawResponse = await store.alertGroupStore.getPayloadForIncident(groupedAlert?.alerts[0]?.id); - setSelectedAlertName(getAlertGroupName(groupedAlert)); - setSelectedAlertPayload(currentIncidentRawResponse?.raw_request_data); + setSelectedTitle(getAlertGroupName(groupedAlert)); + setSelectedPayload(currentIncidentRawResponse?.raw_request_data); + + // ? onSelectAlertGroup(groupedAlert); onEditPayload(JSON.stringify(currentIncidentRawResponse?.raw_request_data)); }; const getAlertGroupName = (alertGroup: Alert) => { + // Integrations page return alertGroup.inside_organization_number - ? `#${alertGroup.inside_organization_number} ${alertGroup.render_for_web.title}` - : alertGroup.render_for_web.title; + ? `#${alertGroup.inside_organization_number} ${alertGroup.render_for_web?.title}` + : alertGroup.render_for_web?.title; }; + // for Outgoing webhooks + + const handleOutgoingWebhookResponseSelect = (response: OutgoingWebhook2Response) => { + setSelectedTitle(response.timestamp); + + setSelectedPayload(JSON.parse(response.event_data)); + + onEditPayload(response.event_data); + }; + + if (selectedPayload) { + // IF selected we either display it as ReadOnly or in EditMode + return ( +
+ {isEditMode ? renderSelectedPayloadInEditMode() : renderSelectedPayloadInReadOnlyMode()} +
+ ); + } + return (
- {selectedAlertPayload ? ( + {isEditMode ? ( <> - {isEditMode ? ( - <> -
- - Edit custom payload +
+ + Edit custom payload - - returnToListView()} /> - - -
-
- -
- - ) : ( - <> -
-
-
- {selectedAlertName} -
-
- setIsEditMode(true)} /> - returnToListView()} /> -
-
-
-
- -
- {/* Editor used for Editing Given Payload */} - -
-
- - )} + + + +
+
+
+ +
) : ( <> - {isEditMode ? ( - <> -
- - Edit custom payload +
+ + + {heading} + {/* + + */} + - - returnToListView()} /> - - -
-
- -
- - ) : ( - <> -
- - - Recent Alert groups - - - - - - - -
-
- {alertGroupsList ? ( - <> - {alertGroupsList?.length > 0 ? ( - <> - {alertGroupsList.map((alertGroup) => { - return ( -
getAlertGroupPayload(alertGroup.pk)} - className={cx('alert-groups-list-item')} - > - {getAlertGroupName(alertGroup)} -
- ); - })} - - ) : ( - - - - This integration did not receive any alerts. Use custom payload example to preview - results. - -
- } - /> - )} - - ) : ( - - )} -
- - )} + + +
+
+ {templatePage === TEMPLATE_PAGE.Webhooks ? renderOutgoingWebhookLastResponses() : renderAlertGroupList()} +
)} ); + + function renderOutgoingWebhookLastResponses() { + if (outgoingwebhookId !== 'new' && !outgoingWebhookLastResponses) { + return ; + } + + if (outgoingWebhookLastResponses?.length) { + return outgoingWebhookLastResponses + .filter((response) => response.event_data) + .map((response) => { + return ( +
handleOutgoingWebhookResponseSelect(response)} + className={cx('alert-groups-list-item')} + > + {response.timestamp} +
+ ); + }); + } else { + return ( + + + + This outgoing webhook did not receive any events. Use custom payload example to preview results. + + + } + /> + ); + } + } + + function renderAlertGroupList() { + if (!alertGroupsList) { + return ; + } + + if (alertGroupsList.length) { + return alertGroupsList.map((alertGroup) => { + return ( +
getAlertGroupPayload(alertGroup.pk)} + className={cx('alert-groups-list-item')} + > + {getAlertGroupName(alertGroup)} +
+ ); + }); + } else { + return ( + + + This integration did not receive any alerts. Use custom payload example to preview results. + + } + /> + ); + } + } + + function renderSelectedPayloadInEditMode() { + return ( + <> +
+ + Edit custom payload + + + returnToListView()} /> + + +
+
+ +
+ + ); + } + + function renderSelectedPayloadInReadOnlyMode() { + return ( + <> +
+
+
+ {selectedTitle} +
+
+ setIsEditMode(true)} /> + returnToListView()} /> +
+
+
+
+ +
+ {/* Editor used for Editing Given Payload */} + +
+
+ + ); + } }; export default TemplatesAlertGroupsList; diff --git a/grafana-plugin/src/containers/TemplatesAlertGroupsList/WebhooksDefaultAlertGroup.ts b/grafana-plugin/src/containers/TemplatesAlertGroupsList/WebhooksDefaultAlertGroup.ts new file mode 100644 index 00000000..1dcef0c5 --- /dev/null +++ b/grafana-plugin/src/containers/TemplatesAlertGroupsList/WebhooksDefaultAlertGroup.ts @@ -0,0 +1,62 @@ +export const WebhooksDefaultAlertGroup = { + pk: '0', + event: { + type: 'resolve', + time: '2023-04-19T21:59:21.714058+00:00', + }, + user: { + id: 'UVMX6YI9VY9PV', + username: 'admin', + email: 'admin@localhost', + }, + alert_group: { + id: 'I6HNZGUFG4K11', + integration_id: 'CZ7URAT4V3QF2', + route_id: 'RKHXJKVZYYVST', + alerts_count: 1, + state: 'resolved', + created_at: '2023-04-19T21:53:48.231148Z', + resolved_at: '2023-04-19T21:59:21.714058Z', + acknowledged_at: '2023-04-19T21:54:39.029347Z', + title: 'Incident', + permalinks: { + slack: null, + telegram: null, + web: 'https://**********.grafana.net/a/grafana-oncall-app/alert-groups/I6HNZGUFG4K11', + }, + }, + alert_group_id: 'I6HNZGUFG4K11', + alert_payload: { + endsAt: '0001-01-01T00:00:00Z', + labels: { + region: 'eu-1', + alertname: 'TestAlert', + }, + status: 'firing', + startsAt: '2018-12-25T15:47:47.377363608Z', + amixr_demo: true, + annotations: { + description: 'This alert was sent by user for the demonstration purposes', + }, + generatorURL: '', + }, + integration: { + id: 'CZ7URAT4V3QF2', + type: 'webhook', + name: 'Main Integration - Webhook', + team: 'Webhooks Demo', + }, + notified_users: [], + users_to_be_notified: [], + responses: { + WHP936BM1GPVHQ: { + id: '7Qw7TbPmzppRnhLvK3AdkQ', + created_at: '15:53:50', + status: 'new', + content: { + message: 'Ticket created!', + region: 'eu', + }, + }, + }, +}; diff --git a/grafana-plugin/src/containers/WebhooksTemplateEditor/WebhooksTemplateEditor.tsx b/grafana-plugin/src/containers/WebhooksTemplateEditor/WebhooksTemplateEditor.tsx new file mode 100644 index 00000000..e9a84ed1 --- /dev/null +++ b/grafana-plugin/src/containers/WebhooksTemplateEditor/WebhooksTemplateEditor.tsx @@ -0,0 +1,176 @@ +import React, { useEffect, useState } from 'react'; + +import { Button, Drawer, HorizontalGroup, VerticalGroup } from '@grafana/ui'; +import cn from 'classnames/bind'; +import { debounce } from 'lodash-es'; + +import CheatSheet from 'components/CheatSheet/CheatSheet'; +import MonacoEditor from 'components/MonacoEditor/MonacoEditor'; +import Text from 'components/Text/Text'; +import styles from 'containers/IntegrationTemplate/IntegrationTemplate.module.scss'; +import TemplateResult from 'containers/TemplateResult/TemplateResult'; +import TemplatesAlertGroupsList, { TEMPLATE_PAGE } from 'containers/TemplatesAlertGroupsList/TemplatesAlertGroupsList'; +import { WithPermissionControlTooltip } from 'containers/WithPermissionControl/WithPermissionControlTooltip'; +import { OutgoingWebhook2 } from 'models/outgoing_webhook_2/outgoing_webhook_2.types'; +import { waitForElement } from 'utils/DOM'; +import { UserActions } from 'utils/authorization'; + +const cx = cn.bind(styles); + +interface Template { + value: string; + displayName: string; + description: string; + name: undefined; +} + +interface WebhooksTemplateEditorProps { + template: Template; + id: OutgoingWebhook2['id']; + onHide: () => void; + handleSubmit: (template: string) => void; +} + +const WebhooksTemplateEditor: React.FC = ({ template, id, onHide, handleSubmit }) => { + const [isCheatSheetVisible] = useState(false); + const [changedTemplateBody, setChangedTemplateBody] = useState(template.value); + const [editorHeight, setEditorHeight] = useState(undefined); + const [selectedPayload, setSelectedPayload] = useState(undefined); + const [resultError, setResultError] = useState(undefined); + + useEffect(() => { + waitForElement('#content-container-id').then(() => { + const mainDiv = document.getElementById('content-container-id'); + const height = mainDiv?.getBoundingClientRect().height - 59; + setEditorHeight(`${height}px`); + }); + }, []); + + const getChangeHandler = () => { + return debounce((value: string) => { + setChangedTemplateBody(value); + }, 500); + }; + + const onEditPayload = (alertPayload: string) => { + if (alertPayload !== null) { + try { + const jsonPayload = JSON.parse(alertPayload); + if (typeof jsonPayload === 'object') { + setResultError(undefined); + setSelectedPayload(JSON.parse(alertPayload)); + } else { + setResultError('Please check your JSON format'); + } + } catch (e) { + setResultError(e.message); + } + } else { + setResultError(undefined); + setSelectedPayload(undefined); + } + }; + + return ( + + + + Edit {template.displayName} template + {template.description && {template.description}} + + + + + + + + + + + + + } + onClose={onHide} + closeOnMaskClick={false} + width="95%" + > +
+
+ {}} + /> + + {isCheatSheetVisible ? ( + + ) : ( + <> +
+
+ + Template editor + + {/* */} + +
+
+ +
+
+ + )} + +
+
+
+ ); + + // function onShowCheatSheet() {} + + function onCloseCheatSheet() {} + + function getCheatSheet(_templateName: string) { + return undefined; + } +}; + +export default WebhooksTemplateEditor; diff --git a/grafana-plugin/src/models/alert_receive_channel/alert_receive_channel.ts b/grafana-plugin/src/models/alert_receive_channel/alert_receive_channel.ts index 1de4d5b1..51769285 100644 --- a/grafana-plugin/src/models/alert_receive_channel/alert_receive_channel.ts +++ b/grafana-plugin/src/models/alert_receive_channel/alert_receive_channel.ts @@ -102,11 +102,11 @@ export class AlertReceiveChannelStore extends BaseStore { async updateItems(query: any = '') { const params = typeof query === 'string' ? { search: query } : query; - const result = await makeRequest(this.path, { params }); + const { results } = await makeRequest(this.path, { params }); this.items = { ...this.items, - ...result.reduce( + ...results.reduce( (acc: { [key: number]: AlertReceiveChannel }, item: AlertReceiveChannel) => ({ ...acc, [item.id]: omit(item, 'heartbeat'), @@ -115,9 +115,9 @@ export class AlertReceiveChannelStore extends BaseStore { ), }; - this.searchResult = result.map((item: AlertReceiveChannel) => item.id); + this.searchResult = results.map((item: AlertReceiveChannel) => item.id); - const heartbeats = result.reduce((acc: any, alertReceiveChannel: AlertReceiveChannel) => { + const heartbeats = results.reduce((acc: any, alertReceiveChannel: AlertReceiveChannel) => { if (alertReceiveChannel.heartbeat) { acc[alertReceiveChannel.heartbeat.id] = alertReceiveChannel.heartbeat; } @@ -130,7 +130,7 @@ export class AlertReceiveChannelStore extends BaseStore { ...heartbeats, }; - const alertReceiveChannelToHeartbeat = result.reduce((acc: any, alertReceiveChannel: AlertReceiveChannel) => { + const alertReceiveChannelToHeartbeat = results.reduce((acc: any, alertReceiveChannel: AlertReceiveChannel) => { if (alertReceiveChannel.heartbeat) { acc[alertReceiveChannel.id] = alertReceiveChannel.heartbeat.id; } @@ -145,7 +145,7 @@ export class AlertReceiveChannelStore extends BaseStore { this.updateCounters(); - return result; + return results; } async updatePaginatedItems(query: any = '', page = 1) { diff --git a/grafana-plugin/src/models/grafana_team/grafana_team.ts b/grafana-plugin/src/models/grafana_team/grafana_team.ts index 243d3af2..8ca9a7b1 100644 --- a/grafana-plugin/src/models/grafana_team/grafana_team.ts +++ b/grafana-plugin/src/models/grafana_team/grafana_team.ts @@ -2,6 +2,7 @@ import { action, observable } from 'mobx'; import BaseStore from 'models/base_store'; import { GrafanaTeam } from 'models/grafana_team/grafana_team.types'; +import { makeRequest } from 'network'; import { RootStore } from 'state'; export class GrafanaTeamStore extends BaseStore { @@ -29,7 +30,9 @@ export class GrafanaTeamStore extends BaseStore { @action async updateItems(query = '') { - const result = await this.getAll(); + const result = await makeRequest(`${this.path}`, { + params: { search: query }, + }); this.items = { ...this.items, diff --git a/grafana-plugin/src/models/outgoing_webhook_2/outgoing_webhook_2.ts b/grafana-plugin/src/models/outgoing_webhook_2/outgoing_webhook_2.ts index b23f8a86..5f8ff367 100644 --- a/grafana-plugin/src/models/outgoing_webhook_2/outgoing_webhook_2.ts +++ b/grafana-plugin/src/models/outgoing_webhook_2/outgoing_webhook_2.ts @@ -94,4 +94,17 @@ export class OutgoingWebhook2Store extends BaseStore { return this.searchResult[query].map((outgoingWebhook2Id: OutgoingWebhook2['id']) => this.items[outgoingWebhook2Id]); } + + async getLastResponses(id: OutgoingWebhook2['id']) { + const result = await makeRequest(`${this.path}${id}/responses`, {}); + + return result; + } + + async renderPreview(id: OutgoingWebhook2['id'], template_name: string, template_body: string, payload) { + return await makeRequest(`${this.path}${id}/preview_template/`, { + method: 'POST', + data: { template_name, template_body, payload }, + }); + } } diff --git a/grafana-plugin/src/models/outgoing_webhook_2/outgoing_webhook_2.types.ts b/grafana-plugin/src/models/outgoing_webhook_2/outgoing_webhook_2.types.ts index 8eab346e..5035e930 100644 --- a/grafana-plugin/src/models/outgoing_webhook_2/outgoing_webhook_2.types.ts +++ b/grafana-plugin/src/models/outgoing_webhook_2/outgoing_webhook_2.types.ts @@ -28,4 +28,5 @@ export interface OutgoingWebhook2Response { request_data: string; status_code: string; content: string; + event_data: string; } diff --git a/grafana-plugin/src/models/team.ts b/grafana-plugin/src/models/team.ts deleted file mode 100644 index 776ed378..00000000 --- a/grafana-plugin/src/models/team.ts +++ /dev/null @@ -1,4 +0,0 @@ -export interface TeamDTO { - is_free_version: boolean; - cached_name: string; -} diff --git a/grafana-plugin/src/models/team/team.ts b/grafana-plugin/src/models/team/team.ts index 1f01add5..22abcbac 100644 --- a/grafana-plugin/src/models/team/team.ts +++ b/grafana-plugin/src/models/team/team.ts @@ -2,10 +2,7 @@ import { action, observable } from 'mobx'; import BaseStore from 'models/base_store'; import { makeRequest } from 'network'; -import { Mixpanel } from 'services/mixpanel'; import { RootStore } from 'state'; -import { openErrorNotification } from 'utils'; -import { getPathnameByTeamNameSlug } from 'utils/url'; import { Team } from './team.types'; @@ -30,38 +27,6 @@ export class TeamStore extends BaseStore { this.currentTeam = await makeRequest('/current_team/', {}); } - @action - async setCurrentTeam(teamId: Team['pk']) { - this.redirectingToProperTeam = true; - - const team = await makeRequest(`/current_team/`, { - method: 'POST', - data: { team_id: teamId }, - }); - - const pathName = getPathnameByTeamNameSlug(team.name_slug); - - window.location.pathname = pathName; - } - - @action - async addTeam(data: Partial) { - let createdTeam; - try { - createdTeam = await makeRequest('/teams/', { - method: 'POST', - data, - }); - } catch (e) { - openErrorNotification(e.response.data); - return; - } - - this.setCurrentTeam(createdTeam.pk); - - Mixpanel.track('Add Team', null); - } - @action async saveCurrentTeam(data: any) { this.currentTeam = await makeRequest('/current_team/', { @@ -69,59 +34,4 @@ export class TeamStore extends BaseStore { data, }); } - - @action - async justSaveCurrentTeam(data: any) { - return await makeRequest('/current_team/', { - method: 'PUT', - data, - }); - } - - @action - async getTelegramVerificationCode(pk: Team['pk']) { - const response = await makeRequest(`/teams/${pk}/get_telegram_verification_code/`, { - withCredentials: true, - }); - - return response; - } - - @action - async unlinkTelegram(pk: Team['pk']) { - const response = await makeRequest(`/teams/${pk}/unlink_telegram/`, { - method: 'POST', - withCredentials: true, - }); - - return response; - } - - @action - async getInvitationLink() { - const response = await makeRequest('/invitation_link/', { - withCredentials: true, - }); - - return response; - } - - @action - async joinToTeam(invitation_token: string) { - const response = await makeRequest('/join_to_team/', { - method: 'POST', - params: { invitation_token, token: invitation_token }, - withCredentials: true, - }); - - return response; - } - - @action - async updateTeam(_teamId: Team['pk']) { - await makeRequest(this.path, { - params: {}, - withCredentials: true, - }); - } } diff --git a/grafana-plugin/src/models/team/team.types.ts b/grafana-plugin/src/models/team/team.types.ts index 9fdc1df6..315a53ae 100644 --- a/grafana-plugin/src/models/team/team.types.ts +++ b/grafana-plugin/src/models/team/team.types.ts @@ -1,27 +1,7 @@ import { SlackChannel } from 'models/slack_channel/slack_channel.types'; -export enum SubscriptionStatus { - OK, - VIOLATION, - HARD_VIOLATION, -} - -export interface Limit { - left: number; - limit_title: string; - total: number; -} - export interface Team { pk: string; - is_free_version: boolean; - limits: { - period_title: string; - show_limits_popup: boolean; - limits_to_show: Limit[]; - show_limits_warning: boolean; - warning_text: string; - }; banner: { title: string; body: string; @@ -33,7 +13,6 @@ export interface Team { discussion_group_name: string; }; name: string; - name_slug: string; slack_team_identity: { general_log_channel_id: string; general_log_channel_pk: string; @@ -42,29 +21,10 @@ export interface Team { slack_channel: SlackChannel | null; - number_of_employees: number; - - subscription_status: SubscriptionStatus; - - stats: { - alerts_count: number; - average_response_time: string; - grouped_percent: number; - noise_reduction: number; - verbal_time_saved_by_amixr: string; - }; - - incident_retention_web_report: { - num_month_available: number; - incidents_hidden: number; - } | null; - // ex team settings - archive_alerts_from: string; is_resolution_note_required: boolean; env_status: { - twilio_configured: boolean; telegram_configured: boolean; phone_provider: { configured: boolean; diff --git a/grafana-plugin/src/pages/integration/Integration.tsx b/grafana-plugin/src/pages/integration/Integration.tsx index 54a2eff8..0bd83838 100644 --- a/grafana-plugin/src/pages/integration/Integration.tsx +++ b/grafana-plugin/src/pages/integration/Integration.tsx @@ -32,6 +32,7 @@ import IntegrationInputField from 'components/IntegrationInputField/IntegrationI import IntegrationLogo from 'components/IntegrationLogo/IntegrationLogo'; import IntegrationBlock from 'components/Integrations/IntegrationBlock'; import MonacoEditor, { MONACO_LANGUAGE } from 'components/MonacoEditor/MonacoEditor'; +import { MONACO_EDITABLE_CONFIG } from 'components/MonacoEditor/MonacoEditor.config'; import PageErrorHandlingWrapper, { PageBaseState } from 'components/PageErrorHandlingWrapper/PageErrorHandlingWrapper'; import { initErrorDataState } from 'components/PageErrorHandlingWrapper/PageErrorHandlingWrapper.helpers'; import PluginLink from 'components/PluginLink/PluginLink'; @@ -71,8 +72,6 @@ import { UserActions } from 'utils/authorization'; import { PLUGIN_ROOT } from 'utils/consts'; import sanitize from 'utils/sanitize'; -import { MONACO_PAYLOAD_OPTIONS } from './IntegrationCommon.config'; - const cx = cn.bind(styles); interface IntegrationProps extends WithStoreProps, PageProps, RouteComponentProps<{ id: string }> {} @@ -673,7 +672,7 @@ const IntegrationSendDemoPayloadModal: React.FC diff --git a/grafana-plugin/src/pages/integration/IntegrationCommon.config.ts b/grafana-plugin/src/pages/integration/IntegrationCommon.config.ts index 6856896c..c4590765 100644 --- a/grafana-plugin/src/pages/integration/IntegrationCommon.config.ts +++ b/grafana-plugin/src/pages/integration/IntegrationCommon.config.ts @@ -3,33 +3,6 @@ import { KeyValuePair } from 'utils'; export const TEXTAREA_ROWS_COUNT = 4; export const MAX_CHARACTERS_COUNT = 50; -// Mostly used for input fields where we're hiding scrollbars -export const MONACO_OPTIONS = { - renderLineHighlight: false, - readOnly: true, - scrollbar: { - vertical: 'hidden', - horizontal: 'hidden', - verticalScrollbarSize: 0, - handleMouseWheel: false, - }, - hideCursorInOverviewRuler: true, - minimap: { enabled: false }, - cursorStyle: { - display: 'none', - }, -}; - -export const MONACO_PAYLOAD_OPTIONS = { - renderLineHighlight: false, - readOnly: false, - hideCursorInOverviewRuler: true, - minimap: { enabled: false }, - cursorStyle: { - display: 'none', - }, -}; - export const MONACO_INPUT_HEIGHT_SMALL = '32px'; export const MONACO_INPUT_HEIGHT_TALL = '120px'; diff --git a/grafana-plugin/src/utils/url.ts b/grafana-plugin/src/utils/url.ts index f286f996..01e7cbae 100644 --- a/grafana-plugin/src/utils/url.ts +++ b/grafana-plugin/src/utils/url.ts @@ -2,18 +2,6 @@ import qs, { ParsedQuery } from 'query-string'; import { PLUGIN_ROOT } from './consts'; -export function getTeamNameSlugFromUrl(): string | undefined { - const teamName = window.location.pathname.split('/')[2]; - return teamName === 'admin' || teamName === 'auth' ? undefined : teamName; -} - -export function getPathnameByTeamNameSlug(teamNameSlug: string): string { - return window.location.pathname - .split('/') - .map((part: string, index) => (index === 2 ? teamNameSlug : part)) - .join('/'); -} - export function getPathFromQueryParams(query: ParsedQuery) { const normalizedQuery = { ...query }; diff --git a/grafana-plugin/yarn.lock b/grafana-plugin/yarn.lock index 91edc1f7..ba5c2d6a 100644 --- a/grafana-plugin/yarn.lock +++ b/grafana-plugin/yarn.lock @@ -12981,28 +12981,21 @@ semver-compare@^1.0.0: integrity sha512-YM3/ITh2MJ5MtzaM429anh+x2jiLVjqILF4m4oyQB18W7Ggea7BfqdH/wGMK7dDiMghv/6WG7znWMwUDzJiXow== "semver@2 || 3 || 4 || 5", semver@^5.6.0: - version "5.7.1" - resolved "https://registry.yarnpkg.com/semver/-/semver-5.7.1.tgz#a954f931aeba508d307bbf069eff0c01c96116f7" - integrity sha512-sauaDf/PZdVgrLTNYHRtpXa1iRiKcaebiKQ1BJdpQlWH2lCvexQdX55snPFyK7QzpudqbCI0qXFfOasHdyNDGQ== + version "5.7.2" + resolved "https://registry.yarnpkg.com/semver/-/semver-5.7.2.tgz#48d55db737c3287cd4835e17fa13feace1c41ef8" + integrity sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g== -semver@7.x, semver@^7.3.2, semver@^7.3.4, semver@^7.3.5, semver@^7.3.7, semver@^7.3.8: - version "7.3.8" - resolved "https://registry.yarnpkg.com/semver/-/semver-7.3.8.tgz#07a78feafb3f7b32347d725e33de7e2a2df67798" - integrity sha512-NB1ctGL5rlHrPJtFDVIVzTyQylMLu9N9VICA6HSFJo8MCGVTMW6gfpicwKmmK/dAjTOrqu5l63JJOpDSrAis3A== +semver@7.x, semver@^7.3.2, semver@^7.3.4, semver@^7.3.5, semver@^7.3.7, semver@^7.3.8, semver@^7.5.1: + version "7.5.4" + resolved "https://registry.yarnpkg.com/semver/-/semver-7.5.4.tgz#483986ec4ed38e1c6c48c34894a9182dbff68a6e" + integrity sha512-1bCSESV6Pv+i21Hvpxp3Dx+pSD8lIPt8uVjRrxAUt/nbswYc+tK6Y2btiULjd4+fnq15PX+nqQDC7Oft7WkwcA== dependencies: lru-cache "^6.0.0" semver@^6.0.0, semver@^6.1.1, semver@^6.1.2, semver@^6.3.0: - version "6.3.0" - resolved "https://registry.yarnpkg.com/semver/-/semver-6.3.0.tgz#ee0a64c8af5e8ceea67687b133761e1becbd1d3d" - integrity sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw== - -semver@^7.5.1: - version "7.5.1" - resolved "https://registry.yarnpkg.com/semver/-/semver-7.5.1.tgz#c90c4d631cf74720e46b21c1d37ea07edfab91ec" - integrity sha512-Wvss5ivl8TMRZXXESstBA4uR5iXgEN/VC5/sOcuXdVLzcdkz4HWetIoRfG5gb5X+ij/G9rw9YoGn3QoQ8OCSpw== - dependencies: - lru-cache "^6.0.0" + version "6.3.1" + resolved "https://registry.yarnpkg.com/semver/-/semver-6.3.1.tgz#556d2ef8689146e46dcea4bfdd095f3434dffcb4" + integrity sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA== sentence-case@^2.1.0: version "2.1.1"