v1.3.9
This commit is contained in:
commit
2baacbd188
70 changed files with 1192 additions and 1464 deletions
12
CHANGELOG.md
12
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
|
||||
|
|
|
|||
|
|
@ -85,10 +85,6 @@ features:
|
|||
display_name: <YOUR_BOT_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
|
||||
|
|
|
|||
27
engine/apps/alerts/migrations/0020_auto_20230711_1532.py
Normal file
27
engine/apps/alerts/migrations/0020_auto_20230711_1532.py
Normal file
|
|
@ -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',
|
||||
),
|
||||
]
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -22,6 +22,7 @@ class WebhookResponseSerializer(serializers.ModelSerializer):
|
|||
"request_data",
|
||||
"status_code",
|
||||
"content",
|
||||
"event_data",
|
||||
]
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
),
|
||||
]
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
@ -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',
|
||||
),
|
||||
]
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
),
|
||||
]
|
||||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
@ -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))
|
||||
|
|
@ -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")),
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"},
|
||||
|
|
|
|||
|
|
@ -88,14 +88,7 @@ const chooseDropdownValue = async ({ page, value, optionExactMatch = true }: Sel
|
|||
|
||||
export const selectDropdownValue = async (args: SelectDropdownValueArgs): Promise<Locator> => {
|
||||
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;
|
||||
|
|
|
|||
|
|
@ -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. */
|
||||
|
|
|
|||
|
|
@ -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) => {
|
|||
<VerticalGroup style={{ width: '100%' }}>
|
||||
{groups[activeGroup].map((template) => (
|
||||
<TemplatePreview
|
||||
active={template.name === activeTemplate?.name}
|
||||
templatePage={TEMPLATE_PAGE.Integrations}
|
||||
key={template.name}
|
||||
templateName={template.name}
|
||||
templateBody={tempValues[template.name] ?? templates[template.name]}
|
||||
onEditClick={getTemplatePreviewEditClickHandler(template.name)}
|
||||
alertReceiveChannelId={alertReceiveChannelId}
|
||||
alertGroupId={alertGroupId}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<InputControl
|
||||
control={control}
|
||||
name={formItem.name}
|
||||
render={({ field: { ...field } }) => {
|
||||
return (
|
||||
<MonacoEditor
|
||||
{...field}
|
||||
{...formItem.extra}
|
||||
showLineNumbers={false}
|
||||
monacoOptions={{
|
||||
...MONACO_READONLY_CONFIG,
|
||||
readOnly: formItem.isReadOnly,
|
||||
}}
|
||||
onChange={(value) => onChangeFn(field, value)}
|
||||
/>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
|
|
@ -117,7 +147,7 @@ function renderFormControl(formItem: FormItem, register: any, control: any, onCh
|
|||
|
||||
class GForm extends React.Component<GFormProps, {}> {
|
||||
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<GFormProps, {}> {
|
|||
return null;
|
||||
}
|
||||
|
||||
const formControl = renderFormControl(formItem, register, control, (field, value) => {
|
||||
field?.onChange(value);
|
||||
this.forceUpdate();
|
||||
});
|
||||
|
||||
return (
|
||||
<Field
|
||||
key={formIndex}
|
||||
|
|
@ -140,10 +175,9 @@ class GForm extends React.Component<GFormProps, {}> {
|
|||
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}
|
||||
</Field>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
},
|
||||
};
|
||||
|
|
@ -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<MonacoEditorProps> = (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]
|
||||
);
|
||||
|
||||
|
|
|
|||
|
|
@ -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[]) : [])
|
||||
|
|
|
|||
|
|
@ -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<ExpandedIntegrationRouteDisplayP
|
|||
height={MONACO_INPUT_HEIGHT_SMALL}
|
||||
data={templates}
|
||||
showLineNumbers={false}
|
||||
monacoOptions={MONACO_OPTIONS}
|
||||
monacoOptions={MONACO_READONLY_CONFIG}
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
|
|
|
|||
|
|
@ -6,13 +6,14 @@ import cn from 'classnames/bind';
|
|||
import IntegrationBlockItem from 'components/Integrations/IntegrationBlockItem';
|
||||
import IntegrationTemplateBlock from 'components/Integrations/IntegrationTemplateBlock';
|
||||
import MonacoEditor from 'components/MonacoEditor/MonacoEditor';
|
||||
import { MONACO_READONLY_CONFIG } from 'components/MonacoEditor/MonacoEditor.config';
|
||||
import Text from 'components/Text/Text';
|
||||
import { templatesToRender } from 'containers/IntegrationContainers/IntegrationTemplatesList.config';
|
||||
import { AlertReceiveChannel } from 'models/alert_receive_channel/alert_receive_channel.types';
|
||||
import { AlertTemplatesDTO } from 'models/alert_templates';
|
||||
import IntegrationHelper from 'pages/integration/Integration.helper';
|
||||
import styles from 'pages/integration/Integration.module.scss';
|
||||
import { MONACO_INPUT_HEIGHT_TALL, MONACO_OPTIONS } from 'pages/integration/IntegrationCommon.config';
|
||||
import { MONACO_INPUT_HEIGHT_TALL } from 'pages/integration/IntegrationCommon.config';
|
||||
import { useStore } from 'state/useStore';
|
||||
import { openErrorNotification, openNotification } from 'utils';
|
||||
|
||||
|
|
@ -108,7 +109,7 @@ const IntegrationTemplateList: React.FC<IntegrationTemplateListProps> = ({
|
|||
height={contents.height}
|
||||
data={templates}
|
||||
showLineNumbers={false}
|
||||
monacoOptions={MONACO_OPTIONS}
|
||||
monacoOptions={MONACO_READONLY_CONFIG}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import React, { useCallback, useState, useEffect } from 'react';
|
||||
|
||||
import { Button, HorizontalGroup, Drawer, VerticalGroup, Icon } from '@grafana/ui';
|
||||
import { Button, HorizontalGroup, Drawer, VerticalGroup } from '@grafana/ui';
|
||||
import cn from 'classnames/bind';
|
||||
import { debounce } from 'lodash-es';
|
||||
import { observer } from 'mobx-react';
|
||||
|
|
@ -12,11 +12,10 @@ import {
|
|||
slackMessageTemplateCheatSheet,
|
||||
genericTemplateCheatSheet,
|
||||
} from 'components/CheatSheet/CheatSheet.config';
|
||||
import Block from 'components/GBlock/Block';
|
||||
import MonacoEditor from 'components/MonacoEditor/MonacoEditor';
|
||||
import Text from 'components/Text/Text';
|
||||
import TemplatePreview from 'containers/TemplatePreview/TemplatePreview';
|
||||
import TemplatesAlertGroupsList from 'containers/TemplatesAlertGroupsList/TemplatesAlertGroupsList';
|
||||
import TemplateResult from 'containers/TemplateResult/TemplateResult';
|
||||
import TemplatesAlertGroupsList, { TEMPLATE_PAGE } from 'containers/TemplatesAlertGroupsList/TemplatesAlertGroupsList';
|
||||
import { WithPermissionControlTooltip } from 'containers/WithPermissionControl/WithPermissionControlTooltip';
|
||||
import { AlertReceiveChannel } from 'models/alert_receive_channel/alert_receive_channel.types';
|
||||
import { AlertTemplatesDTO } from 'models/alert_templates';
|
||||
|
|
@ -188,43 +187,15 @@ const IntegrationTemplate = observer((props: IntegrationTemplateProps) => {
|
|||
<div className={cx('container-wrapper')}>
|
||||
<div className={cx('container')} id={'content-container-id'}>
|
||||
<TemplatesAlertGroupsList
|
||||
templatePage={TEMPLATE_PAGE.Integrations}
|
||||
alertReceiveChannelId={id}
|
||||
onEditPayload={onEditPayload}
|
||||
onSelectAlertGroup={onSelectAlertGroup}
|
||||
templates={templates}
|
||||
onLoadAlertGroupsList={onLoadAlertGroupsList}
|
||||
/>
|
||||
{isCheatSheetVisible ? (
|
||||
<CheatSheet
|
||||
cheatSheetName={template.displayName}
|
||||
cheatSheetData={getCheatSheet(template.name)}
|
||||
onClose={onCloseCheatSheet}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<div className={cx('template-block-codeeditor')}>
|
||||
<div className={cx('template-editor-block-title')}>
|
||||
<HorizontalGroup justify="space-between" align="center" wrap>
|
||||
<Text>Template editor</Text>
|
||||
|
||||
<Button variant="secondary" fill="outline" onClick={onShowCheatSheet} icon="book" size="sm">
|
||||
Cheatsheet
|
||||
</Button>
|
||||
</HorizontalGroup>
|
||||
</div>
|
||||
<div className={cx('template-editor-block-content')}>
|
||||
<MonacoEditor
|
||||
value={changedTemplateBody}
|
||||
data={templates}
|
||||
showLineNumbers={true}
|
||||
height={editorHeight}
|
||||
onChange={getChangeHandler()}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
<Result
|
||||
{renderCheatSheet()}
|
||||
<TemplateResult
|
||||
alertReceiveChannelId={id}
|
||||
template={template}
|
||||
templateBody={changedTemplateBody}
|
||||
|
|
@ -238,88 +209,43 @@ const IntegrationTemplate = observer((props: IntegrationTemplateProps) => {
|
|||
</div>
|
||||
</Drawer>
|
||||
);
|
||||
|
||||
function renderCheatSheet() {
|
||||
if (isCheatSheetVisible) {
|
||||
return (
|
||||
<CheatSheet
|
||||
cheatSheetName={template.displayName}
|
||||
cheatSheetData={getCheatSheet(template.name)}
|
||||
onClose={onCloseCheatSheet}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className={cx('template-block-codeeditor')}>
|
||||
<div className={cx('template-editor-block-title')}>
|
||||
<HorizontalGroup justify="space-between" align="center" wrap>
|
||||
<Text>Template editor</Text>
|
||||
|
||||
<Button variant="secondary" fill="outline" onClick={onShowCheatSheet} icon="book" size="sm">
|
||||
Cheatsheet
|
||||
</Button>
|
||||
</HorizontalGroup>
|
||||
</div>
|
||||
<div className={cx('template-editor-block-content')}>
|
||||
<MonacoEditor
|
||||
value={changedTemplateBody}
|
||||
data={templates}
|
||||
showLineNumbers={true}
|
||||
height={editorHeight}
|
||||
onChange={getChangeHandler()}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
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 (
|
||||
<div className={cx('template-block-result')}>
|
||||
<div className={cx('template-block-title')}>
|
||||
<HorizontalGroup justify="space-between">
|
||||
<Text>Result</Text>
|
||||
</HorizontalGroup>
|
||||
</div>
|
||||
<div className={cx('result')}>
|
||||
{payload || error ? (
|
||||
<VerticalGroup spacing="lg">
|
||||
{error ? (
|
||||
<Block bordered fullWidth withBackground>
|
||||
<Text>{error}</Text>
|
||||
</Block>
|
||||
) : (
|
||||
<Block bordered fullWidth withBackground>
|
||||
<TemplatePreview
|
||||
key={template.name}
|
||||
templateName={template.name}
|
||||
templateBody={templateBody}
|
||||
templateType={template.type}
|
||||
templateIsRoute={template.isRoute}
|
||||
alertReceiveChannelId={alertReceiveChannelId}
|
||||
payload={payload}
|
||||
/>
|
||||
</Block>
|
||||
)}
|
||||
|
||||
{template?.additionalData?.additionalDescription && (
|
||||
<Text type="secondary">{template?.additionalData.additionalDescription}</Text>
|
||||
)}
|
||||
|
||||
{template?.additionalData?.chatOpsName && isAlertGroupExisting && (
|
||||
<VerticalGroup>
|
||||
<Button onClick={() => onSaveAndFollowLink(chatOpsPermalink)}>
|
||||
<HorizontalGroup spacing="xs" align="center">
|
||||
Save and open Alert Group in {template.additionalData.chatOpsDisplayName}{' '}
|
||||
<Icon name="external-link-alt" />
|
||||
</HorizontalGroup>
|
||||
</Button>
|
||||
|
||||
{template.additionalData.data && <Text type="secondary">{template.additionalData.data}</Text>}
|
||||
</VerticalGroup>
|
||||
)}
|
||||
</VerticalGroup>
|
||||
) : (
|
||||
<div>
|
||||
<Block bordered fullWidth className={cx('block-style')} withBackground>
|
||||
<Text>← Select alert group or "Use custom payload"</Text>
|
||||
</Block>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default IntegrationTemplate;
|
||||
|
|
|
|||
|
|
@ -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: {},
|
||||
},
|
||||
],
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<string>(
|
||||
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 (
|
||||
<div className={cx('form-row')}>
|
||||
<div className={cx('form-field')}>{renderedControl}</div>
|
||||
<Button
|
||||
icon="edit"
|
||||
variant="secondary"
|
||||
onClick={getTemplateEditClickHandler(formItem, values, setFormFieldValue)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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 = <GForm form={form} data={data} onSubmit={handleSubmit} onFieldRender={enrchField} />;
|
||||
|
||||
if (action === WebhookFormActionType.NEW || action === WebhookFormActionType.COPY) {
|
||||
// show just the creation form, not the tabs
|
||||
return (
|
||||
<Drawer scrollableContent title={'Create Outgoing Webhook'} onClose={onHide} closeOnMaskClick={false}>
|
||||
{renderWebhookForm()}
|
||||
</Drawer>
|
||||
<>
|
||||
<Drawer scrollableContent title={'Create Outgoing Webhook'} onClose={onHide} closeOnMaskClick={false}>
|
||||
<div className="webhooks__drawerContent">{renderWebhookForm()}</div>
|
||||
</Drawer>
|
||||
{templateToEdit && (
|
||||
<WebhooksTemplateEditor
|
||||
id={id}
|
||||
handleSubmit={(value) => {
|
||||
onFormChangeFn?.fn(value);
|
||||
setTemplateToEdit(undefined);
|
||||
}}
|
||||
onHide={() => setTemplateToEdit(undefined)}
|
||||
template={templateToEdit}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
// show tabbed drawer (edit/live_run)
|
||||
<Drawer scrollableContent title={'Outgoing webhook details'} onClose={onHide} closeOnMaskClick={false}>
|
||||
<TabsBar>
|
||||
<Tab
|
||||
key={WebhookTabs.Settings.key}
|
||||
onChangeTab={() => {
|
||||
setActiveTab(WebhookTabs.Settings.key);
|
||||
history.push(`${PLUGIN_ROOT}/outgoing_webhooks_2/edit/${id}`);
|
||||
}}
|
||||
active={activeTab === WebhookTabs.Settings.key}
|
||||
label={WebhookTabs.Settings.value}
|
||||
/>
|
||||
<>
|
||||
<Drawer scrollableContent title={'Outgoing webhook details'} onClose={onHide} closeOnMaskClick={false}>
|
||||
<div className={cx('webhooks__drawerContent')}>
|
||||
<TabsBar>
|
||||
<Tab
|
||||
key={WebhookTabs.Settings.key}
|
||||
onChangeTab={() => {
|
||||
setActiveTab(WebhookTabs.Settings.key);
|
||||
history.push(`${PLUGIN_ROOT}/outgoing_webhooks_2/edit/${id}`);
|
||||
}}
|
||||
active={activeTab === WebhookTabs.Settings.key}
|
||||
label={WebhookTabs.Settings.value}
|
||||
/>
|
||||
|
||||
<Tab
|
||||
key={WebhookTabs.LastRun.key}
|
||||
onChangeTab={() => {
|
||||
setActiveTab(WebhookTabs.LastRun.key);
|
||||
history.push(`${PLUGIN_ROOT}/outgoing_webhooks_2/last_run/${id}`);
|
||||
}}
|
||||
active={activeTab === WebhookTabs.LastRun.key}
|
||||
label={WebhookTabs.LastRun.value}
|
||||
/>
|
||||
</TabsBar>
|
||||
<Tab
|
||||
key={WebhookTabs.LastRun.key}
|
||||
onChangeTab={() => {
|
||||
setActiveTab(WebhookTabs.LastRun.key);
|
||||
history.push(`${PLUGIN_ROOT}/outgoing_webhooks_2/last_run/${id}`);
|
||||
}}
|
||||
active={activeTab === WebhookTabs.LastRun.key}
|
||||
label={WebhookTabs.LastRun.value}
|
||||
/>
|
||||
</TabsBar>
|
||||
|
||||
<WebhookTabsContent
|
||||
id={id}
|
||||
action={action}
|
||||
activeTab={activeTab}
|
||||
data={data}
|
||||
handleSubmit={handleSubmit}
|
||||
onDelete={onDelete}
|
||||
onHide={onHide}
|
||||
onUpdate={onUpdate}
|
||||
/>
|
||||
</Drawer>
|
||||
<WebhookTabsContent
|
||||
id={id}
|
||||
action={action}
|
||||
activeTab={activeTab}
|
||||
data={data}
|
||||
handleSubmit={handleSubmit}
|
||||
onDelete={onDelete}
|
||||
onHide={onHide}
|
||||
onUpdate={onUpdate}
|
||||
formElement={formElement}
|
||||
/>
|
||||
</div>
|
||||
</Drawer>
|
||||
{templateToEdit && (
|
||||
<WebhooksTemplateEditor
|
||||
id={id}
|
||||
handleSubmit={(value) => {
|
||||
onFormChangeFn?.fn(value);
|
||||
setTemplateToEdit(undefined);
|
||||
}}
|
||||
onHide={() => setTemplateToEdit(undefined)}
|
||||
template={templateToEdit}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
function renderWebhookForm() {
|
||||
return (
|
||||
<>
|
||||
<div className={cx('content')} data-testid="test__outgoingWebhook2EditForm">
|
||||
<GForm form={form} data={data} onSubmit={handleSubmit} />
|
||||
<GForm form={form} data={data} onSubmit={handleSubmit} onFieldRender={enrchField} />
|
||||
<div className={cx('buttons')}>
|
||||
<HorizontalGroup justify={'flex-end'}>
|
||||
<Button variant="secondary" onClick={onHide}>
|
||||
|
|
@ -170,6 +230,7 @@ interface WebhookTabsProps {
|
|||
onUpdate: () => void;
|
||||
onDelete: () => void;
|
||||
handleSubmit: (data: Partial<OutgoingWebhook2>) => void;
|
||||
formElement: React.ReactElement;
|
||||
}
|
||||
|
||||
const WebhookTabsContent: React.FC<WebhookTabsProps> = ({
|
||||
|
|
@ -177,10 +238,10 @@ const WebhookTabsContent: React.FC<WebhookTabsProps> = ({
|
|||
action,
|
||||
activeTab,
|
||||
data,
|
||||
handleSubmit,
|
||||
onHide,
|
||||
onUpdate,
|
||||
onDelete,
|
||||
formElement,
|
||||
}) => {
|
||||
const [confirmationModal, setConfirmationModal] = useState<ConfirmModalProps>(undefined);
|
||||
|
||||
|
|
@ -193,7 +254,7 @@ const WebhookTabsContent: React.FC<WebhookTabsProps> = ({
|
|||
{activeTab === WebhookTabs.Settings.key && (
|
||||
<>
|
||||
<div className={cx('content')} data-testid="test__outgoingWebhook2EditForm">
|
||||
<GForm form={form} data={data} onSubmit={handleSubmit} />
|
||||
{formElement}
|
||||
<div className={cx('buttons')}>
|
||||
<HorizontalGroup justify={'flex-end'}>
|
||||
<Button variant="secondary" onClick={onHide}>
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import React, { useCallback, useEffect, useMemo, useReducer, useState } from 'react';
|
||||
import React, { useCallback, useMemo, useReducer, useState } from 'react';
|
||||
|
||||
import { SelectableValue } from '@grafana/data';
|
||||
import { AsyncMultiSelect, AsyncSelect } from '@grafana/ui';
|
||||
|
|
@ -6,6 +6,7 @@ import { inject, observer } from 'mobx-react';
|
|||
|
||||
import { makeRequest, isNetworkError } from 'network';
|
||||
import { UserAction, generateMissingPermissionMessage } from 'utils/authorization';
|
||||
import { useDebouncedCallback } from 'utils/hooks';
|
||||
|
||||
interface RemoteSelectProps {
|
||||
autoFocus?: boolean;
|
||||
|
|
@ -67,24 +68,20 @@ const RemoteSelect = inject('store')(
|
|||
|
||||
const [options, setOptions] = useReducer(mergeOptions, []);
|
||||
|
||||
const loadOptionsCallback = useCallback(async (query?: string): Promise<SelectableValue[]> => {
|
||||
const loadOptionsCallback = useDebouncedCallback(async (query: string, cb) => {
|
||||
try {
|
||||
const data = await makeRequest(href, { params: { search: query } });
|
||||
const options = getOptions(data.results || data);
|
||||
setOptions(options);
|
||||
|
||||
return options;
|
||||
cb(options);
|
||||
} catch (e) {
|
||||
if (isNetworkError(e) && e.response.status === 403 && requiredUserAction) {
|
||||
setNoOptionsMessage(generateMissingPermissionMessage(requiredUserAction));
|
||||
}
|
||||
return [];
|
||||
cb([]);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
loadOptionsCallback();
|
||||
}, []);
|
||||
}, 250);
|
||||
|
||||
const onChangeCallback = useCallback(
|
||||
(option) => {
|
||||
|
|
@ -127,7 +124,7 @@ const RemoteSelect = inject('store')(
|
|||
isSearchable={showSearch}
|
||||
value={value}
|
||||
onChange={onChangeCallback}
|
||||
defaultOptions={options}
|
||||
defaultOptions
|
||||
loadOptions={loadOptionsCallback}
|
||||
getOptionLabel={getOptionLabel}
|
||||
noOptionsMessage={noOptionsMessage}
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import { observer } from 'mobx-react';
|
|||
import Text from 'components/Text/Text';
|
||||
import { AlertReceiveChannel } from 'models/alert_receive_channel/alert_receive_channel.types';
|
||||
import { Alert } from 'models/alertgroup/alertgroup.types';
|
||||
import { OutgoingWebhook2 } from 'models/outgoing_webhook_2/outgoing_webhook_2.types';
|
||||
import { useStore } from 'state/useStore';
|
||||
import { openErrorNotification } from 'utils';
|
||||
import { useDebouncedCallback } from 'utils/hooks';
|
||||
|
|
@ -23,28 +24,43 @@ interface TemplatePreviewProps {
|
|||
templateIsRoute?: boolean;
|
||||
payload?: JSON;
|
||||
alertReceiveChannelId: AlertReceiveChannel['id'];
|
||||
onEditClick?: () => void;
|
||||
alertGroupId?: Alert['pk'];
|
||||
active?: boolean;
|
||||
onResult?: (result) => void;
|
||||
outgoingWebhookId?: OutgoingWebhook2['id'];
|
||||
templatePage: TEMPLATE_PAGE;
|
||||
}
|
||||
interface ConditionalResult {
|
||||
isResult?: boolean;
|
||||
value?: string;
|
||||
}
|
||||
|
||||
export enum TEMPLATE_PAGE {
|
||||
Integrations,
|
||||
Webhooks,
|
||||
}
|
||||
|
||||
const TemplatePreview = observer((props: TemplatePreviewProps) => {
|
||||
const { templateName, templateBody, templateType, payload, alertReceiveChannelId, alertGroupId, templateIsRoute } =
|
||||
props;
|
||||
const {
|
||||
templateName,
|
||||
templateBody,
|
||||
templateType,
|
||||
payload,
|
||||
alertReceiveChannelId,
|
||||
outgoingWebhookId,
|
||||
alertGroupId,
|
||||
templateIsRoute,
|
||||
templatePage,
|
||||
} = props;
|
||||
|
||||
const [result, setResult] = useState<{ preview: string | null } | undefined>(undefined);
|
||||
const [conditionalResult, setConditionalResult] = useState<ConditionalResult>({});
|
||||
|
||||
const store = useStore();
|
||||
const { alertReceiveChannelStore, alertGroupStore } = store;
|
||||
const { alertReceiveChannelStore, alertGroupStore, outgoingWebhook2Store } = store;
|
||||
|
||||
const handleTemplateBodyChange = useDebouncedCallback(() => {
|
||||
(alertGroupId
|
||||
(templatePage === TEMPLATE_PAGE.Webhooks
|
||||
? outgoingWebhook2Store.renderPreview(outgoingWebhookId, templateName, templateBody, payload)
|
||||
: alertGroupId
|
||||
? alertGroupStore.renderPreview(alertGroupId, templateName, templateBody)
|
||||
: alertReceiveChannelStore.renderPreview(alertReceiveChannelId, templateName, templateBody, payload)
|
||||
)
|
||||
|
|
|
|||
105
grafana-plugin/src/containers/TemplateResult/TemplateResult.tsx
Normal file
105
grafana-plugin/src/containers/TemplateResult/TemplateResult.tsx
Normal file
|
|
@ -0,0 +1,105 @@
|
|||
import React from 'react';
|
||||
|
||||
import { Button, HorizontalGroup, Icon, VerticalGroup } from '@grafana/ui';
|
||||
import cn from 'classnames/bind';
|
||||
|
||||
import { TemplateForEdit } from 'components/AlertTemplates/CommonAlertTemplatesForm.config';
|
||||
import Block from 'components/GBlock/Block';
|
||||
import Text from 'components/Text/Text';
|
||||
import styles from 'containers/IntegrationTemplate/IntegrationTemplate.module.scss';
|
||||
import TemplatePreview, { TEMPLATE_PAGE } from 'containers/TemplatePreview/TemplatePreview';
|
||||
import { AlertReceiveChannel } from 'models/alert_receive_channel/alert_receive_channel.types';
|
||||
import { OutgoingWebhook2 } from 'models/outgoing_webhook_2/outgoing_webhook_2.types';
|
||||
|
||||
const cx = cn.bind(styles);
|
||||
|
||||
interface ResultProps {
|
||||
alertReceiveChannelId?: AlertReceiveChannel['id'];
|
||||
outgoingWebhookId?: OutgoingWebhook2['id'];
|
||||
templateBody: string;
|
||||
template: TemplateForEdit;
|
||||
isAlertGroupExisting?: boolean;
|
||||
chatOpsPermalink?: string;
|
||||
payload?: JSON;
|
||||
error?: string;
|
||||
onSaveAndFollowLink?: (link: string) => void;
|
||||
templateIsRoute?: boolean;
|
||||
templatePage?: TEMPLATE_PAGE;
|
||||
}
|
||||
|
||||
const TemplateResult = (props: ResultProps) => {
|
||||
const {
|
||||
alertReceiveChannelId,
|
||||
outgoingWebhookId,
|
||||
template,
|
||||
templateBody,
|
||||
chatOpsPermalink,
|
||||
payload,
|
||||
error,
|
||||
isAlertGroupExisting,
|
||||
onSaveAndFollowLink,
|
||||
templatePage = TEMPLATE_PAGE.Integrations,
|
||||
} = props;
|
||||
|
||||
return (
|
||||
<div className={cx('template-block-result')}>
|
||||
<div className={cx('template-block-title')}>
|
||||
<HorizontalGroup justify="space-between">
|
||||
<Text>Result</Text>
|
||||
</HorizontalGroup>
|
||||
</div>
|
||||
<div className={cx('result')}>
|
||||
{payload || error ? (
|
||||
<VerticalGroup spacing="lg">
|
||||
{error ? (
|
||||
<Block bordered fullWidth withBackground>
|
||||
<Text>{error}</Text>
|
||||
</Block>
|
||||
) : (
|
||||
<Block bordered fullWidth withBackground>
|
||||
<TemplatePreview
|
||||
key={template.name}
|
||||
templatePage={templatePage}
|
||||
templateName={template.name}
|
||||
templateBody={templateBody}
|
||||
templateType={template.type}
|
||||
templateIsRoute={template.isRoute}
|
||||
alertReceiveChannelId={alertReceiveChannelId}
|
||||
outgoingWebhookId={outgoingWebhookId}
|
||||
payload={payload}
|
||||
/>
|
||||
</Block>
|
||||
)}
|
||||
|
||||
{template?.additionalData?.additionalDescription && (
|
||||
<Text type="secondary">{template?.additionalData.additionalDescription}</Text>
|
||||
)}
|
||||
|
||||
{template?.additionalData?.chatOpsName && isAlertGroupExisting && (
|
||||
<VerticalGroup>
|
||||
<Button onClick={() => onSaveAndFollowLink(chatOpsPermalink)}>
|
||||
<HorizontalGroup spacing="xs" align="center">
|
||||
Save and open Alert Group in {template.additionalData.chatOpsDisplayName}{' '}
|
||||
<Icon name="external-link-alt" />
|
||||
</HorizontalGroup>
|
||||
</Button>
|
||||
|
||||
{template.additionalData.data && <Text type="secondary">{template.additionalData.data}</Text>}
|
||||
</VerticalGroup>
|
||||
)}
|
||||
</VerticalGroup>
|
||||
) : (
|
||||
<div>
|
||||
<Block bordered fullWidth className={cx('block-style')} withBackground>
|
||||
<Text>
|
||||
← Select {templatePage === TEMPLATE_PAGE.Webhooks ? 'event' : 'alert group'} or "Use custom payload"
|
||||
</Text>
|
||||
</Block>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default TemplateResult;
|
||||
|
|
@ -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<string>(undefined);
|
||||
const [selectedAlertName, setSelectedAlertName] = useState<string>(undefined);
|
||||
const [outgoingWebhookLastResponses, setOutgoingWebhookLastResponses] =
|
||||
useState<OutgoingWebhook2Response[]>(undefined);
|
||||
|
||||
const [selectedTitle, setSelectedTitle] = useState<string>(undefined);
|
||||
const [selectedPayload, setSelectedPayload] = useState<string>(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 (
|
||||
<div className={cx('template-block-list')} id="alerts-content-container-id">
|
||||
{isEditMode ? renderSelectedPayloadInEditMode() : renderSelectedPayloadInReadOnlyMode()}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cx('template-block-list')} id="alerts-content-container-id">
|
||||
{selectedAlertPayload ? (
|
||||
{isEditMode ? (
|
||||
<>
|
||||
{isEditMode ? (
|
||||
<>
|
||||
<div className={cx('template-block-title-edit-mode')}>
|
||||
<HorizontalGroup justify="space-between">
|
||||
<Text>Edit custom payload</Text>
|
||||
<div className={cx('template-block-title-edit-mode')}>
|
||||
<HorizontalGroup justify="space-between">
|
||||
<Text>Edit custom payload</Text>
|
||||
|
||||
<HorizontalGroup>
|
||||
<IconButton name="times" onClick={() => returnToListView()} />
|
||||
</HorizontalGroup>
|
||||
</HorizontalGroup>
|
||||
</div>
|
||||
<div className={cx('alert-groups-editor')}>
|
||||
<MonacoEditor
|
||||
value={JSON.stringify(selectedAlertPayload, null, 4)}
|
||||
data={templates}
|
||||
height={getCodeEditorHeight()}
|
||||
onChange={getChangeHandler()}
|
||||
showLineNumbers
|
||||
useAutoCompleteList={false}
|
||||
language={MONACO_LANGUAGE.json}
|
||||
monacoOptions={MONACO_PAYLOAD_OPTIONS}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className={cx('template-block-title')}>
|
||||
<div className={cx('selected-alert-name-container')}>
|
||||
<div className={cx('selected-alert-name')}>
|
||||
<Text>{selectedAlertName}</Text>
|
||||
</div>
|
||||
<div className={cx('title-action-icons')}>
|
||||
<IconButton name="edit" onClick={() => setIsEditMode(true)} />
|
||||
<IconButton name="times" onClick={() => returnToListView()} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className={cx('alert-groups-editor')}>
|
||||
<TooltipBadge
|
||||
borderType="primary"
|
||||
text="Last alert payload"
|
||||
tooltipTitle=""
|
||||
tooltipContent=""
|
||||
className={cx('alert-groups-last-payload-badge')}
|
||||
/>
|
||||
<div className={cx('alert-groups-editor-withBadge')}>
|
||||
{/* Editor used for Editing Given Payload */}
|
||||
<MonacoEditor
|
||||
value={JSON.stringify(selectedAlertPayload, null, 4)}
|
||||
data={undefined}
|
||||
disabled
|
||||
height={getCodeEditorHeightWithBadge()}
|
||||
onChange={getChangeHandler()}
|
||||
useAutoCompleteList={false}
|
||||
language={MONACO_LANGUAGE.json}
|
||||
monacoOptions={{
|
||||
...MONACO_PAYLOAD_OPTIONS,
|
||||
readOnly: true,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
<HorizontalGroup>
|
||||
<IconButton name="times" onClick={returnToListView} />
|
||||
</HorizontalGroup>
|
||||
</HorizontalGroup>
|
||||
</div>
|
||||
<div className={cx('alert-groups-editor')}>
|
||||
<MonacoEditor
|
||||
value={null}
|
||||
disabled={true}
|
||||
useAutoCompleteList={false}
|
||||
language={MONACO_LANGUAGE.json}
|
||||
data={templates}
|
||||
monacoOptions={{
|
||||
...MONACO_EDITABLE_CONFIG,
|
||||
readOnly: false,
|
||||
}}
|
||||
height={getCodeEditorHeight()}
|
||||
onChange={getChangeHandler()}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{isEditMode ? (
|
||||
<>
|
||||
<div className={cx('template-block-title-edit-mode')}>
|
||||
<HorizontalGroup justify="space-between">
|
||||
<Text>Edit custom payload</Text>
|
||||
<div className={cx('template-block-title')}>
|
||||
<HorizontalGroup justify="space-between" wrap>
|
||||
<HorizontalGroup>
|
||||
<Text>{heading}</Text>
|
||||
{/* <Tooltip content="Here will be information about alert groups" placement="top">
|
||||
<Icon name="info-circle" />
|
||||
</Tooltip> */}
|
||||
</HorizontalGroup>
|
||||
|
||||
<HorizontalGroup>
|
||||
<IconButton name="times" onClick={() => returnToListView()} />
|
||||
</HorizontalGroup>
|
||||
</HorizontalGroup>
|
||||
</div>
|
||||
<div className={cx('alert-groups-editor')}>
|
||||
<MonacoEditor
|
||||
value={null}
|
||||
disabled={true}
|
||||
useAutoCompleteList={false}
|
||||
language={MONACO_LANGUAGE.json}
|
||||
data={templates}
|
||||
monacoOptions={{
|
||||
...MONACO_PAYLOAD_OPTIONS,
|
||||
readOnly: false,
|
||||
}}
|
||||
height={getCodeEditorHeight()}
|
||||
onChange={getChangeHandler()}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className={cx('template-block-title')}>
|
||||
<HorizontalGroup justify="space-between" wrap>
|
||||
<HorizontalGroup>
|
||||
<Text>Recent Alert groups</Text>
|
||||
<Tooltip content="Here will be information about alert groups" placement="top">
|
||||
<Icon name="info-circle" />
|
||||
</Tooltip>
|
||||
</HorizontalGroup>
|
||||
|
||||
<Button variant="secondary" fill="outline" onClick={() => setIsEditMode(true)} size="sm">
|
||||
Use custom payload
|
||||
</Button>
|
||||
</HorizontalGroup>
|
||||
</div>
|
||||
<div className={cx('alert-groups-list')}>
|
||||
{alertGroupsList ? (
|
||||
<>
|
||||
{alertGroupsList?.length > 0 ? (
|
||||
<>
|
||||
{alertGroupsList.map((alertGroup) => {
|
||||
return (
|
||||
<div
|
||||
key={alertGroup.pk}
|
||||
onClick={() => getAlertGroupPayload(alertGroup.pk)}
|
||||
className={cx('alert-groups-list-item')}
|
||||
>
|
||||
<Text type="link"> {getAlertGroupName(alertGroup)}</Text>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
) : (
|
||||
<Badge
|
||||
color="blue"
|
||||
text={
|
||||
<div className={cx('no-alert-groups-badge')}>
|
||||
<Icon name="info-circle" />
|
||||
<Text>
|
||||
This integration did not receive any alerts. Use custom payload example to preview
|
||||
results.
|
||||
</Text>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<LoadingPlaceholder text="Loading alert groups..." />
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
<Button variant="secondary" fill="outline" onClick={() => setIsEditMode(true)} size="sm">
|
||||
Use custom payload
|
||||
</Button>
|
||||
</HorizontalGroup>
|
||||
</div>
|
||||
<div className={cx('alert-groups-list')}>
|
||||
{templatePage === TEMPLATE_PAGE.Webhooks ? renderOutgoingWebhookLastResponses() : renderAlertGroupList()}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
function renderOutgoingWebhookLastResponses() {
|
||||
if (outgoingwebhookId !== 'new' && !outgoingWebhookLastResponses) {
|
||||
return <LoadingPlaceholder text="Loading last events..." />;
|
||||
}
|
||||
|
||||
if (outgoingWebhookLastResponses?.length) {
|
||||
return outgoingWebhookLastResponses
|
||||
.filter((response) => response.event_data)
|
||||
.map((response) => {
|
||||
return (
|
||||
<div
|
||||
key={response.timestamp}
|
||||
onClick={() => handleOutgoingWebhookResponseSelect(response)}
|
||||
className={cx('alert-groups-list-item')}
|
||||
>
|
||||
<Text type="link"> {response.timestamp}</Text>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
} else {
|
||||
return (
|
||||
<Badge
|
||||
color="blue"
|
||||
text={
|
||||
<div className={cx('no-alert-groups-badge')}>
|
||||
<Icon name="info-circle" />
|
||||
<Text>
|
||||
This outgoing webhook did not receive any events. Use custom payload example to preview results.
|
||||
</Text>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function renderAlertGroupList() {
|
||||
if (!alertGroupsList) {
|
||||
return <LoadingPlaceholder text="Loading alert groups..." />;
|
||||
}
|
||||
|
||||
if (alertGroupsList.length) {
|
||||
return alertGroupsList.map((alertGroup) => {
|
||||
return (
|
||||
<div
|
||||
key={alertGroup.pk}
|
||||
onClick={() => getAlertGroupPayload(alertGroup.pk)}
|
||||
className={cx('alert-groups-list-item')}
|
||||
>
|
||||
<Text type="link"> {getAlertGroupName(alertGroup)}</Text>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
} else {
|
||||
return (
|
||||
<Badge
|
||||
color="blue"
|
||||
text={
|
||||
<div className={cx('no-alert-groups-badge')}>
|
||||
<Icon name="info-circle" />
|
||||
<Text>This integration did not receive any alerts. Use custom payload example to preview results.</Text>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function renderSelectedPayloadInEditMode() {
|
||||
return (
|
||||
<>
|
||||
<div className={cx('template-block-title-edit-mode')}>
|
||||
<HorizontalGroup justify="space-between">
|
||||
<Text>Edit custom payload</Text>
|
||||
|
||||
<HorizontalGroup>
|
||||
<IconButton name="times" onClick={() => returnToListView()} />
|
||||
</HorizontalGroup>
|
||||
</HorizontalGroup>
|
||||
</div>
|
||||
<div className={cx('alert-groups-editor')}>
|
||||
<MonacoEditor
|
||||
value={JSON.stringify(selectedPayload, null, 4)}
|
||||
data={templates}
|
||||
height={getCodeEditorHeight()}
|
||||
onChange={getChangeHandler()}
|
||||
showLineNumbers
|
||||
useAutoCompleteList={false}
|
||||
language={MONACO_LANGUAGE.json}
|
||||
monacoOptions={MONACO_EDITABLE_CONFIG}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function renderSelectedPayloadInReadOnlyMode() {
|
||||
return (
|
||||
<>
|
||||
<div className={cx('template-block-title')}>
|
||||
<div className={cx('selected-alert-name-container')}>
|
||||
<div className={cx('selected-alert-name')}>
|
||||
<Text>{selectedTitle}</Text>
|
||||
</div>
|
||||
<div className={cx('title-action-icons')}>
|
||||
<IconButton name="edit" onClick={() => setIsEditMode(true)} />
|
||||
<IconButton name="times" onClick={() => returnToListView()} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className={cx('alert-groups-editor')}>
|
||||
<TooltipBadge
|
||||
borderType="primary"
|
||||
text="Payload"
|
||||
tooltipTitle=""
|
||||
tooltipContent=""
|
||||
className={cx('alert-groups-last-payload-badge')}
|
||||
/>
|
||||
<div className={cx('alert-groups-editor-withBadge')}>
|
||||
{/* Editor used for Editing Given Payload */}
|
||||
<MonacoEditor
|
||||
value={JSON.stringify(selectedPayload, null, 4)}
|
||||
data={undefined}
|
||||
disabled
|
||||
height={getCodeEditorHeightWithBadge()}
|
||||
onChange={getChangeHandler()}
|
||||
useAutoCompleteList={false}
|
||||
language={MONACO_LANGUAGE.json}
|
||||
monacoOptions={{
|
||||
...MONACO_EDITABLE_CONFIG,
|
||||
readOnly: true,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
export default TemplatesAlertGroupsList;
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
|
@ -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<WebhooksTemplateEditorProps> = ({ template, id, onHide, handleSubmit }) => {
|
||||
const [isCheatSheetVisible] = useState(false);
|
||||
const [changedTemplateBody, setChangedTemplateBody] = useState<string>(template.value);
|
||||
const [editorHeight, setEditorHeight] = useState<string>(undefined);
|
||||
const [selectedPayload, setSelectedPayload] = useState(undefined);
|
||||
const [resultError, setResultError] = useState<string>(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 (
|
||||
<Drawer
|
||||
title={
|
||||
<div className={cx('title-container')}>
|
||||
<HorizontalGroup justify="space-between" align="flex-start">
|
||||
<VerticalGroup>
|
||||
<Text.Title level={3}>Edit {template.displayName} template</Text.Title>
|
||||
{template.description && <Text type="secondary">{template.description}</Text>}
|
||||
</VerticalGroup>
|
||||
|
||||
<HorizontalGroup>
|
||||
<WithPermissionControlTooltip userAction={UserActions.OutgoingWebhooksWrite}>
|
||||
<Button variant="secondary" onClick={onHide}>
|
||||
Cancel
|
||||
</Button>
|
||||
</WithPermissionControlTooltip>
|
||||
<WithPermissionControlTooltip userAction={UserActions.OutgoingWebhooksWrite}>
|
||||
<Button variant="primary" onClick={() => handleSubmit(changedTemplateBody)}>
|
||||
Save
|
||||
</Button>
|
||||
</WithPermissionControlTooltip>
|
||||
</HorizontalGroup>
|
||||
</HorizontalGroup>
|
||||
</div>
|
||||
}
|
||||
onClose={onHide}
|
||||
closeOnMaskClick={false}
|
||||
width="95%"
|
||||
>
|
||||
<div className={cx('container-wrapper')}>
|
||||
<div className={cx('container')} id={'content-container-id'}>
|
||||
<TemplatesAlertGroupsList
|
||||
heading="Last events"
|
||||
templatePage={TEMPLATE_PAGE.Webhooks}
|
||||
outgoingwebhookId={id}
|
||||
onEditPayload={onEditPayload}
|
||||
templates={
|
||||
{
|
||||
// TODO: this is just some dummy data, this will need replaced with an actual Webhook Template
|
||||
acknowledge_condition_template: null,
|
||||
acknowledge_condition_template_is_default: true,
|
||||
} as any
|
||||
}
|
||||
onLoadAlertGroupsList={(_isRecentAlertExisting: boolean) => {}}
|
||||
/>
|
||||
|
||||
{isCheatSheetVisible ? (
|
||||
<CheatSheet
|
||||
cheatSheetName={template.displayName}
|
||||
cheatSheetData={getCheatSheet(template.name)}
|
||||
onClose={onCloseCheatSheet}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<div className={cx('template-block-codeeditor')}>
|
||||
<div className={cx('template-editor-block-title')}>
|
||||
<HorizontalGroup justify="space-between" align="center" wrap>
|
||||
<Text>Template editor</Text>
|
||||
|
||||
{/* <Button variant="secondary" fill="outline" onClick={onShowCheatSheet} icon="book" size="sm">
|
||||
Cheatsheet
|
||||
</Button> */}
|
||||
</HorizontalGroup>
|
||||
</div>
|
||||
<div className={cx('template-editor-block-content')}>
|
||||
<MonacoEditor
|
||||
value={template.value}
|
||||
data={{ payload_example: selectedPayload }}
|
||||
showLineNumbers={true}
|
||||
height={editorHeight}
|
||||
onChange={getChangeHandler()}
|
||||
suggestionPrefix=""
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
<TemplateResult
|
||||
templatePage={TEMPLATE_PAGE.Webhooks}
|
||||
outgoingWebhookId={id}
|
||||
template={template}
|
||||
templateBody={changedTemplateBody}
|
||||
isAlertGroupExisting={false}
|
||||
chatOpsPermalink={undefined}
|
||||
payload={selectedPayload}
|
||||
error={resultError}
|
||||
onSaveAndFollowLink={undefined}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Drawer>
|
||||
);
|
||||
|
||||
// function onShowCheatSheet() {}
|
||||
|
||||
function onCloseCheatSheet() {}
|
||||
|
||||
function getCheatSheet(_templateName: string) {
|
||||
return undefined;
|
||||
}
|
||||
};
|
||||
|
||||
export default WebhooksTemplateEditor;
|
||||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -28,4 +28,5 @@ export interface OutgoingWebhook2Response {
|
|||
request_data: string;
|
||||
status_code: string;
|
||||
content: string;
|
||||
event_data: string;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +0,0 @@
|
|||
export interface TeamDTO {
|
||||
is_free_version: boolean;
|
||||
cached_name: string;
|
||||
}
|
||||
|
|
@ -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<Team>) {
|
||||
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,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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<IntegrationSendDemoPayloadModalP
|
|||
useAutoCompleteList={false}
|
||||
language={MONACO_LANGUAGE.json}
|
||||
data={undefined}
|
||||
monacoOptions={MONACO_PAYLOAD_OPTIONS}
|
||||
monacoOptions={MONACO_EDITABLE_CONFIG}
|
||||
showLineNumbers={false}
|
||||
onChange={onPayloadChangeDebounced}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
||||
|
|
|
|||
|
|
@ -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<string>) {
|
||||
const normalizedQuery = { ...query };
|
||||
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue