This commit is contained in:
Joey Orlando 2023-07-12 08:43:09 +02:00 committed by GitHub
commit 2baacbd188
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
70 changed files with 1192 additions and 1464 deletions

View file

@ -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

View file

@ -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

View 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',
),
]

View file

@ -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

View file

@ -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

View file

@ -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()

View file

@ -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,
)

View file

@ -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")

View file

@ -22,6 +22,7 @@ class WebhookResponseSerializer(serializers.ModelSerializer):
"request_data",
"status_code",
"content",
"event_data",
]

View file

@ -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

View file

@ -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

View file

@ -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"),

View file

@ -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)

View file

@ -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))

View file

@ -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)

View file

@ -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,

View file

@ -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")

View file

@ -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,

View file

@ -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:

View file

@ -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

View file

@ -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
)

View file

@ -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)

View file

@ -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)

View file

@ -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',
),
]

View file

@ -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,

View file

@ -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)

View file

@ -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',
),
]

View file

@ -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

View file

@ -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

View file

@ -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.

View file

@ -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),
),
]

View file

@ -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:

View file

@ -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

View file

@ -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"

View file

@ -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))

View file

@ -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")),

View file

@ -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(

View file

@ -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

View file

@ -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"},

View file

@ -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;

View file

@ -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. */

View file

@ -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}
/>

View file

@ -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>
);
};

View file

@ -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;

View file

@ -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',
},
};

View file

@ -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]
);

View file

@ -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[]) : [])

View file

@ -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

View file

@ -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>
)}

View file

@ -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;

View file

@ -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: {},
},
],
};

View file

@ -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;
}

View file

@ -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}>

View file

@ -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}

View file

@ -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)
)

View 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;

View file

@ -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;

View file

@ -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',
},
},
},
};

View file

@ -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;

View file

@ -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) {

View file

@ -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,

View file

@ -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 },
});
}
}

View file

@ -28,4 +28,5 @@ export interface OutgoingWebhook2Response {
request_data: string;
status_code: string;
content: string;
event_data: string;
}

View file

@ -1,4 +0,0 @@
export interface TeamDTO {
is_free_version: boolean;
cached_name: string;
}

View file

@ -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,
});
}
}

View file

@ -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;

View file

@ -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}
/>

View file

@ -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';

View file

@ -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 };

View file

@ -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"