From 0cdd2d7b8b3f47ca74fbdbc4909a72c4c3373d0c Mon Sep 17 00:00:00 2001 From: Innokentii Konstantinov Date: Fri, 3 Jun 2022 19:47:25 +0400 Subject: [PATCH 01/35] First touch on grafana cloud notifications --- .../templaters/phone_call_templater.py | 8 +- engine/apps/oss_installation/cloud_sync.py | 0 engine/apps/oss_installation/constants.py | 1 + .../apps/oss_installation/models/__init__.py | 2 + .../models/cloud_organization_connector.py | 138 ++++++++++++++++++ .../oss_installation/models/cloud_users.py | 14 ++ .../models/oss_installation.py | 7 + engine/apps/public_api/urls.py | 2 + engine/apps/public_api/views/__init__.py | 1 + .../public_api/views/phone_notifications.py | 68 +++++++++ engine/apps/public_api/views/users.py | 4 + engine/apps/twilioapp/models/phone_call.py | 115 +++++++++------ engine/apps/twilioapp/models/sms_message.py | 119 ++++++++------- engine/common/utils.py | 8 + 14 files changed, 383 insertions(+), 104 deletions(-) create mode 100644 engine/apps/oss_installation/cloud_sync.py create mode 100644 engine/apps/oss_installation/constants.py create mode 100644 engine/apps/oss_installation/models/cloud_organization_connector.py create mode 100644 engine/apps/oss_installation/models/cloud_users.py create mode 100644 engine/apps/public_api/views/phone_notifications.py diff --git a/engine/apps/alerts/incident_appearance/templaters/phone_call_templater.py b/engine/apps/alerts/incident_appearance/templaters/phone_call_templater.py index 6f9997d7..3d0127ca 100644 --- a/engine/apps/alerts/incident_appearance/templaters/phone_call_templater.py +++ b/engine/apps/alerts/incident_appearance/templaters/phone_call_templater.py @@ -1,5 +1,5 @@ from apps.alerts.incident_appearance.templaters.alert_templater import AlertTemplater -from common.utils import clean_markup +from common.utils import clean_markup, escape_for_twilio_phone_call class AlertPhoneCallTemplater(AlertTemplater): @@ -24,8 +24,4 @@ class AlertPhoneCallTemplater(AlertTemplater): return sf.format(data) def _escape(self, data): - # https://www.twilio.com/docs/api/errors/12100 - data = data.replace("&", "&") - data = data.replace(">", ">") - data = data.replace("<", "<") - return data + return escape_for_twilio_phone_call(data) diff --git a/engine/apps/oss_installation/cloud_sync.py b/engine/apps/oss_installation/cloud_sync.py new file mode 100644 index 00000000..e69de29b diff --git a/engine/apps/oss_installation/constants.py b/engine/apps/oss_installation/constants.py new file mode 100644 index 00000000..c6c3b88b --- /dev/null +++ b/engine/apps/oss_installation/constants.py @@ -0,0 +1 @@ +CLOUD_URL = "https://a-prod-us-central-0.grafana.net/" diff --git a/engine/apps/oss_installation/models/__init__.py b/engine/apps/oss_installation/models/__init__.py index 53dea35e..80721219 100644 --- a/engine/apps/oss_installation/models/__init__.py +++ b/engine/apps/oss_installation/models/__init__.py @@ -1,2 +1,4 @@ +from .cloud_organization_connector import CloudOrganizationConnector # noqa: F401 +from .cloud_users import CloudUserIdentity # noqa: F401 from .heartbeat import CloudHeartbeat # noqa: F401 from .oss_installation import OssInstallation # noqa: F401 diff --git a/engine/apps/oss_installation/models/cloud_organization_connector.py b/engine/apps/oss_installation/models/cloud_organization_connector.py new file mode 100644 index 00000000..36316457 --- /dev/null +++ b/engine/apps/oss_installation/models/cloud_organization_connector.py @@ -0,0 +1,138 @@ +import logging +from urllib.parse import urljoin + +import requests +from django.db import models + +from apps.base.utils import live_settings +from apps.oss_installation.constants import CLOUD_URL +from apps.oss_installation.models.cloud_users import CloudUserIdentity +from apps.user_management.models import User + +logger = logging.getLogger(__name__) + + +class CloudOrganizationConnector(models.Model): + """ + CloudOrganizationConnector model represents connection between oss organization and cloud organization. + """ + + cloud_url = models.URLField() + organization = models.OneToOneField( + "user_management.organization", related_name="cloud_connector", on_delete=models.CASCADE + ) + + @classmethod + def sync_with_cloud(cls, organization) -> bool: + """ + sync_with_cloud sync organization with cloud organization defined by provided GRAFANA_CLOUD_ONCALL_TOKEN. + """ + sync_status = False + + api_token = live_settings.GRAFANA_CLOUD_ONCALL_TOKEN + if api_token is None: + logger.warning("Unable to sync with cloud. GRAFANA_CLOUD_ONCALL_TOKEN is not set") + else: + info_url = urljoin(CLOUD_URL, "api/v1/info/") + try: + r = requests.get(info_url, headers={"AUTHORIZATION": api_token}, timeout=5) + if r.status_code == 200: + cls.objects.update_or_create(organization=organization, defaults={"cloud_url": r.json()["url"]}) + sync_status = True + if r.status_code == 403: + logger.warning("Unable to sync with cloud. GRAFANA_CLOUD_ONCALL_TOKEN is invalid") + except requests.exceptions.RequestException as e: + logger.warning(f"Unable to sync with cloud. Request exception {str(e)}") + return sync_status + + def sync_users_with_cloud(self): + api_token = live_settings.GRAFANA_CLOUD_ONCALL_TOKEN + if api_token is None: + logger.warning("Unable to sync with cloud. GRAFANA_CLOUD_ONCALL_TOKEN is not set") + return + + existing_emails = list(User.objects.filter(organization=self.organization).values_list("email", flat=True)) + # existing_cloud_ids = list( + # CloudUserIdentity.objects.filter(organization=self.organization).values_list("cloud_id", flat=True) + # ) + matching_users = [] + users_url = urljoin(CLOUD_URL, "api/v1/users") + + existing_cloud_identities = list(CloudUserIdentity.objects.filter(organization=self.organization)) + existing_cloud_ids = list(map(lambda u: u.cloud_id, existing_cloud_identities)) + + fetch_next_page = True + page = 1 + while fetch_next_page: + try: + url = urljoin(users_url, f"?page={page}&?short=true") + r = requests.get(url, headers={"AUTHORIZATION": api_token}, timeout=5) + if r.status_code != 200: + logger.warning( + f"Unable to fetch page {page} while sync_users_with_cloud. Response status code {r.status_code}" + ) + if r.status_code == 429 or r.status_code == 403: + break + data = r.json() + matching_users.extend(list(filter(lambda u: (u["email"] in existing_emails), data["results"]))) + page += 1 + if data["next"] is None: + fetch_next_page = False + except requests.exceptions.RequestException as e: + logger.warning(f"Unable to sync users with cloud. Request exception {str(e)}") + break + + cloud_users_identities_to_update = {} + + cloud_users_identities_to_create = [] + for user in matching_users: + if user["id"] in existing_cloud_ids: + cloud_users_identities_to_update[user["id"]] = user + else: + cloud_users_identities_to_create.append( + CloudUserIdentity( + cloud_id=user["id"], + email=user["email"], + phone_number_verified=user["is_phone_number_verified"], + organization=self.organization, + ) + ) + + for i in existing_cloud_identities: + i.email = cloud_users_identities_to_update[i.cloud_id]["email"] + i.phone_number_verified = cloud_users_identities_to_update[i.cloud_id]["is_phone_number_verified"] + + # TODO: Grafana Twilio: check if data validation needed. + CloudUserIdentity.objects.bulk_create(cloud_users_identities_to_create, batch_size=1000) + CloudUserIdentity.objects.bulk_update( + existing_cloud_identities, ["email", "phone_number_verified"], batch_size=1000 + ) + + def sync_user_with_cloud(self, user): + api_token = live_settings.GRAFANA_CLOUD_ONCALL_TOKEN + if api_token is None: + logger.warning(f"Unable to sync_user_with cloud user_id {user.id}. GRAFANA_CLOUD_ONCALL_TOKEN is not set") + return + + url = urljoin(CLOUD_URL, f"api/v1/users/?email={user.email}") + try: + r = requests.get(url, headers={"AUTHORIZATION": api_token}, timeout=5) + if r.status_code != 200: + logger.warning( + f"Unable to sync_user_with_cloud user_id {user.id}. Response status code {r.status_code}" + ) + return + data = r.json() + if len(data["results"]) != 0: + cloud_used_data = data["results"][0] + CloudUserIdentity.objects.update_or_create( + email=user.email, + defaults={ + "phone_number_verified": cloud_used_data["is_phone_number_verified"], + "cloud_id": cloud_used_data["id"], + }, + ) + else: + logger.warning(f"Unable to sync_user_with_cloud user_id {user.id}. User with {user.email} not found") + except requests.exceptions.RequestException as e: + logger.warning(f"Unable to sync_user_with cloud user_id {user.id}. Request exception {str(e)}") diff --git a/engine/apps/oss_installation/models/cloud_users.py b/engine/apps/oss_installation/models/cloud_users.py new file mode 100644 index 00000000..cbef16c0 --- /dev/null +++ b/engine/apps/oss_installation/models/cloud_users.py @@ -0,0 +1,14 @@ +from django.db import models + + +class CloudUserIdentity(models.Model): + phone_number_verified = models.BooleanField(default=False) + cloud_id = models.CharField(max_length=20) + email = models.EmailField() + organization = models.ForeignKey( + "user_management.Organization", on_delete=models.CASCADE, related_name="cloud_users" + ) + + class Meta: + # TODO: Grafana Twilio: Check if this constraint needed + unique_together = ("cloud_id", "organization") diff --git a/engine/apps/oss_installation/models/oss_installation.py b/engine/apps/oss_installation/models/oss_installation.py index 9e4dd3dd..2e553fcf 100644 --- a/engine/apps/oss_installation/models/oss_installation.py +++ b/engine/apps/oss_installation/models/oss_installation.py @@ -1,9 +1,16 @@ +import logging import uuid from django.db import models +logger = logging.getLogger(__name__) + class OssInstallation(models.Model): + """ + OssInstallation is model to track installation of OSS OnCall version. + """ + installation_id = models.UUIDField(default=uuid.uuid4, editable=False) created_at = models.DateTimeField(auto_now=True) report_sent_at = models.DateTimeField(null=True, default=None) diff --git a/engine/apps/public_api/urls.py b/engine/apps/public_api/urls.py index 95fa447a..a91898df 100644 --- a/engine/apps/public_api/urls.py +++ b/engine/apps/public_api/urls.py @@ -30,4 +30,6 @@ router.register(r"teams", views.TeamView, basename="teams") urlpatterns = [ path("", include(router.urls)), optional_slash_path("info", views.InfoView.as_view(), name="info"), + optional_slash_path("make_call", views.MakeCallView.as_view(), name="make_call"), + optional_slash_path("send_sms", views.SendSMSView.as_view(), name="send_sms"), ] diff --git a/engine/apps/public_api/views/__init__.py b/engine/apps/public_api/views/__init__.py index 1892d123..4ffcec04 100644 --- a/engine/apps/public_api/views/__init__.py +++ b/engine/apps/public_api/views/__init__.py @@ -8,6 +8,7 @@ from .integrations import IntegrationView # noqa: F401 from .on_call_shifts import CustomOnCallShiftView # noqa: F401 from .organizations import OrganizationView # noqa: F401 from .personal_notifications import PersonalNotificationView # noqa: F401 +from .phone_notifications import MakeCallView, SendSMSView # noqa: F401 from .resolution_notes import ResolutionNoteView # noqa: F401 from .routes import ChannelFilterView # noqa: F401 from .schedules import OnCallScheduleChannelView # noqa: F401 diff --git a/engine/apps/public_api/views/phone_notifications.py b/engine/apps/public_api/views/phone_notifications.py new file mode 100644 index 00000000..eed301a2 --- /dev/null +++ b/engine/apps/public_api/views/phone_notifications.py @@ -0,0 +1,68 @@ +# TODO: move to serializers +from rest_framework import serializers, status +from rest_framework.permissions import IsAuthenticated +from rest_framework.response import Response +from rest_framework.views import APIView +from twilio.base.exceptions import TwilioRestException + +from apps.auth_token.auth import ApiTokenAuthentication +from apps.twilioapp.models import PhoneCall, SMSMessage + + +class PhoneNotificationDataSerializer(serializers.Serializer): + email = serializers.EmailField() + message = serializers.CharField(max_length=200) + + +class MakeCallView(APIView): + authentication_classes = (ApiTokenAuthentication,) + permission_classes = (IsAuthenticated,) + + # TODO: add ratelimit + + def post(self, request): + # TODO: Grafana Twilio: + # 1. Validate user's email + # 2. Validate payload: clean_markup, escape_for_twilio_phone_call + # 3. Create LogRecord (User notification policy or implement new one) + serializer = PhoneNotificationDataSerializer(data=request.data) + serializer.is_valid(raise_exception=True) + + organization = self.request.auth.organization + # TODO: filter by verified phone number? + user = organization.users.filter(email=serializer.validated_data["email"]).first() + if not user or not user.verified_phone_number: + return Response(status=status.HTTP_400_BAD_REQUEST) + + try: + PhoneCall.make_grafana_cloud_call(user, serializer.validated_data["message"]) + except TwilioRestException: + return Response(status=status.HTTP_503_SERVICE_UNAVAILABLE) + except PhoneCall.PhoneCallsLimitExceeded: + return Response(status=status.HTTP_400_BAD_REQUEST) + + return Response(status=status.HTTP_200_OK) + + +class SendSMSView(APIView): + authentication_classes = (ApiTokenAuthentication,) + permission_classes = (IsAuthenticated,) + + def post(self, request): + serializer = PhoneNotificationDataSerializer(data=request.data) + serializer.is_valid(raise_exception=True) + + organization = self.request.auth.organization + # TODO: filter by verified phone number? + user = organization.users.filter(email=serializer.validated_data["email"]).first() + if not user or not user.verified_phone_number: + return Response(status=status.HTTP_400_BAD_REQUEST) + + try: + SMSMessage.send_cloud_sms(user, serializer.validated_data["message"]) + except TwilioRestException: + return Response(status=status.HTTP_503_SERVICE_UNAVAILABLE) + except SMSMessage.SMSLimitExceeded: + return Response(status=status.HTTP_400_BAD_REQUEST) + + return Response(status=status.HTTP_200_OK) diff --git a/engine/apps/public_api/views/users.py b/engine/apps/public_api/views/users.py index 815c6553..99a32a85 100644 --- a/engine/apps/public_api/views/users.py +++ b/engine/apps/public_api/views/users.py @@ -33,12 +33,16 @@ class UserView(RateLimitHeadersMixin, ShortSerializerMixin, DemoTokenMixin, Read def get_queryset(self): username = self.request.query_params.get("username") + email = self.request.query_params.get("email") is_short_request = self.request.query_params.get("short", "false") == "true" queryset = self.request.auth.organization.users.filter(role__in=[Role.ADMIN, Role.EDITOR]).distinct() if username is not None: queryset = queryset.filter(username=username) + if email is not None: + queryset = queryset.filter(email=email) + if not is_short_request: queryset = self.serializer_class.setup_eager_loading(queryset) return queryset.order_by("id") diff --git a/engine/apps/twilioapp/models/phone_call.py b/engine/apps/twilioapp/models/phone_call.py index 7d5ae0f9..2cfe44b4 100644 --- a/engine/apps/twilioapp/models/phone_call.py +++ b/engine/apps/twilioapp/models/phone_call.py @@ -34,8 +34,10 @@ class PhoneCallManager(models.Manager): if phone_call_qs.exists() and status: phone_call_qs.update(status=status) - phone_call = phone_call_qs.first() + if phone_call.grafana_cloud_notification: + # If call was made via grafana twilio it is don't needed to create logs on it's delivery status. + return log_record = None if status == TwilioCallStatuses.COMPLETED: log_record = UserNotificationPolicyLogRecord( @@ -115,6 +117,14 @@ class PhoneCall(models.Model): created_at = models.DateTimeField(auto_now_add=True) + grafana_cloud_notification = models.BooleanField(default=False) + + class PhoneCallsLimitExceeded(Exception): + """Phone calls limit exceeded""" + + class PhoneNumberNotVerifiedError(Exception): + """Phone number is not verified""" + def process_digit(self, digit): """The function process pressed digit at time of call to user @@ -140,55 +150,32 @@ class PhoneCall(models.Model): @classmethod def make_call(cls, user, alert_group, notification_policy): UserNotificationPolicyLogRecord = apps.get_model("base", "UserNotificationPolicyLogRecord") - - organization = alert_group.channel.organization - log_record = None - if user.verified_phone_number: - # Create a PhoneCall object in db - phone_call = PhoneCall( - represents_alert_group=alert_group, - receiver=user, + renderer = AlertGroupPhoneCallRenderer(alert_group) + message_body = renderer.render() + try: + cls._make_call(user, message_body, alert_group=alert_group, notification_policy=notification_policy) + except TwilioRestException: + log_record = UserNotificationPolicyLogRecord( + author=user, + type=UserNotificationPolicyLogRecord.TYPE_PERSONAL_NOTIFICATION_FAILED, notification_policy=notification_policy, + alert_group=alert_group, + notification_error_code=UserNotificationPolicyLogRecord.ERROR_NOTIFICATION_NOT_ABLE_TO_CALL, + notification_step=notification_policy.step if notification_policy else None, + notification_channel=notification_policy.notify_by if notification_policy else None, ) - - phone_calls_left = organization.phone_calls_left(user) - - if phone_calls_left > 0: - phone_call.exceeded_limit = False - renderer = AlertGroupPhoneCallRenderer(alert_group) - message_body = renderer.render() - if phone_calls_left < 3: - message_body += " {} phone calls left. Contact your admin.".format(phone_calls_left) - try: - twilio_call = twilio_client.make_call(message_body, user.verified_phone_number) - except TwilioRestException: - log_record = UserNotificationPolicyLogRecord( - author=user, - type=UserNotificationPolicyLogRecord.TYPE_PERSONAL_NOTIFICATION_FAILED, - notification_policy=notification_policy, - alert_group=alert_group, - notification_error_code=UserNotificationPolicyLogRecord.ERROR_NOTIFICATION_NOT_ABLE_TO_CALL, - notification_step=notification_policy.step if notification_policy else None, - notification_channel=notification_policy.notify_by if notification_policy else None, - ) - else: - if twilio_call.status and twilio_call.sid: - phone_call.status = TwilioCallStatuses.DETERMINANT.get(twilio_call.status, None) - phone_call.sid = twilio_call.sid - else: - log_record = UserNotificationPolicyLogRecord( - author=user, - type=UserNotificationPolicyLogRecord.TYPE_PERSONAL_NOTIFICATION_FAILED, - notification_policy=notification_policy, - alert_group=alert_group, - notification_error_code=UserNotificationPolicyLogRecord.ERROR_NOTIFICATION_PHONE_CALLS_LIMIT_EXCEEDED, - notification_step=notification_policy.step if notification_policy else None, - notification_channel=notification_policy.notify_by if notification_policy else None, - ) - phone_call.exceeded_limit = True - phone_call.save() - else: + except PhoneCall.PhoneCallsLimitExceeded: + log_record = UserNotificationPolicyLogRecord( + author=user, + type=UserNotificationPolicyLogRecord.TYPE_PERSONAL_NOTIFICATION_FAILED, + notification_policy=notification_policy, + alert_group=alert_group, + notification_error_code=UserNotificationPolicyLogRecord.ERROR_NOTIFICATION_PHONE_CALLS_LIMIT_EXCEEDED, + notification_step=notification_policy.step if notification_policy else None, + notification_channel=notification_policy.notify_by if notification_policy else None, + ) + except PhoneCall.PhoneNumberNotVerifiedError: log_record = UserNotificationPolicyLogRecord( author=user, type=UserNotificationPolicyLogRecord.TYPE_PERSONAL_NOTIFICATION_FAILED, @@ -203,6 +190,40 @@ class PhoneCall(models.Model): log_record.save() user_notification_action_triggered_signal.send(sender=PhoneCall.make_call, log_record=log_record) + @classmethod + def make_grafana_cloud_call(cls, user, message_body): + cls._make_call(user, message_body, grafana_cloud=True) + + @classmethod + def _make_call(cls, user, message_body, alert_group=None, notification_policy=None, grafana_cloud=False): + if not user.verified_phone_number: + raise PhoneCall.PhoneNumberNotVerifiedError("User phone number is not verified") + + phone_call = PhoneCall( + represents_alert_group=alert_group, + receiver=user, + notification_policy=notification_policy, + grafana_cloud_notification=grafana_cloud, + ) + phone_calls_left = user.organization.phone_calls_left(user) + + if phone_calls_left <= 0: + phone_call.exceeded_limit = True + phone_call.save() + raise PhoneCall.PhoneCallsLimitExceeded("Organization calls limit exceeded") + + phone_call.exceeded_limit = False + if phone_calls_left < 3: + message_body += " {} phone calls left. Contact your admin.".format(phone_calls_left) + + twilio_call = twilio_client.make_call(message_body, user.verified_phone_number) + if twilio_call.status and twilio_call.sid: + phone_call.status = TwilioCallStatuses.DETERMINANT.get(twilio_call.status, None) + phone_call.sid = twilio_call.sid + phone_call.save() + + return phone_call + @staticmethod def get_error_code_by_twilio_status(status): UserNotificationPolicyLogRecord = apps.get_model("base", "UserNotificationPolicyLogRecord") diff --git a/engine/apps/twilioapp/models/sms_message.py b/engine/apps/twilioapp/models/sms_message.py index 09404e56..4046f464 100644 --- a/engine/apps/twilioapp/models/sms_message.py +++ b/engine/apps/twilioapp/models/sms_message.py @@ -36,7 +36,9 @@ class SMSMessageManager(models.Manager): sms_message_qs.update(status=status) sms_message = sms_message_qs.first() - + if sms_message.grafana_cloud_notification: + # If sms was sent via grafana twilio it is don't needed to create logs on it's delivery status. + return log_record = None if status == TwilioMessageStatuses.DELIVERED: @@ -90,6 +92,7 @@ class SMSMessage(models.Model): null=True, choices=TwilioMessageStatuses.CHOICES, ) + grafana_cloud_notification = models.BooleanField(default=False) # https://www.twilio.com/docs/sms/api/message-resource#message-properties sid = models.CharField( @@ -99,6 +102,12 @@ class SMSMessage(models.Model): created_at = models.DateTimeField(auto_now_add=True) + class SMSLimitExceeded(Exception): + """SMS limit exceeded""" + + class PhoneNumberNotVerifiedError(Exception): + """Phone number is not verified""" + @property def created_for_slack(self): return bool(self.represents_alert_group.slack_message) @@ -107,58 +116,32 @@ class SMSMessage(models.Model): def send_sms(cls, user, alert_group, notification_policy): UserNotificationPolicyLogRecord = apps.get_model("base", "UserNotificationPolicyLogRecord") - organization = alert_group.channel.organization - log_record = None - if user.verified_phone_number: - # Create an SMS object in db - sms_message = SMSMessage( - represents_alert_group=alert_group, receiver=user, notification_policy=notification_policy + renderer = AlertGroupSmsRenderer(alert_group) + message_body = renderer.render() + try: + cls._send_sms(user, message_body, alert_group=alert_group, notification_policy=notification_policy) + except TwilioRestException: + log_record = UserNotificationPolicyLogRecord( + author=user, + type=UserNotificationPolicyLogRecord.TYPE_PERSONAL_NOTIFICATION_FAILED, + notification_policy=notification_policy, + alert_group=alert_group, + notification_error_code=UserNotificationPolicyLogRecord.ERROR_NOTIFICATION_NOT_ABLE_TO_SEND_SMS, + notification_step=notification_policy.step if notification_policy else None, + notification_channel=notification_policy.notify_by if notification_policy else None, ) - - sms_left = organization.sms_left(user) - if sms_left > 0: - # Mark is as successfully sent - sms_message.exceeded_limit = False - # Render alert message for sms - renderer = AlertGroupSmsRenderer(alert_group) - message_body = renderer.render() - # Notify if close to limit - if sms_left < 3: - message_body += " {} sms left. Contact your admin.".format(sms_left) - # Send an sms - try: - twilio_message = twilio_client.send_message(message_body, user.verified_phone_number) - except TwilioRestException: - log_record = UserNotificationPolicyLogRecord( - author=user, - type=UserNotificationPolicyLogRecord.TYPE_PERSONAL_NOTIFICATION_FAILED, - notification_policy=notification_policy, - alert_group=alert_group, - notification_error_code=UserNotificationPolicyLogRecord.ERROR_NOTIFICATION_NOT_ABLE_TO_SEND_SMS, - notification_step=notification_policy.step if notification_policy else None, - notification_channel=notification_policy.notify_by if notification_policy else None, - ) - else: - if twilio_message.status and twilio_message.sid: - sms_message.status = TwilioMessageStatuses.DETERMINANT.get(twilio_message.status, None) - sms_message.sid = twilio_message.sid - else: - # If no more sms left, mark as exceeded limit - log_record = UserNotificationPolicyLogRecord( - author=user, - type=UserNotificationPolicyLogRecord.TYPE_PERSONAL_NOTIFICATION_FAILED, - notification_policy=notification_policy, - alert_group=alert_group, - notification_error_code=UserNotificationPolicyLogRecord.ERROR_NOTIFICATION_SMS_LIMIT_EXCEEDED, - notification_step=notification_policy.step if notification_policy else None, - notification_channel=notification_policy.notify_by if notification_policy else None, - ) - sms_message.exceeded_limit = True - - # Save object - sms_message.save() - else: + except SMSMessage.SMSLimitExceeded: + log_record = UserNotificationPolicyLogRecord( + author=user, + type=UserNotificationPolicyLogRecord.TYPE_PERSONAL_NOTIFICATION_FAILED, + notification_policy=notification_policy, + alert_group=alert_group, + notification_error_code=UserNotificationPolicyLogRecord.ERROR_NOTIFICATION_SMS_LIMIT_EXCEEDED, + notification_step=notification_policy.step if notification_policy else None, + notification_channel=notification_policy.notify_by if notification_policy else None, + ) + except SMSMessage.PhoneNumberNotVerifiedError: log_record = UserNotificationPolicyLogRecord( author=user, type=UserNotificationPolicyLogRecord.TYPE_PERSONAL_NOTIFICATION_FAILED, @@ -173,6 +156,40 @@ class SMSMessage(models.Model): log_record.save() user_notification_action_triggered_signal.send(sender=SMSMessage.send_sms, log_record=log_record) + @classmethod + def send_grafana_cloud_sms(cls, user, message_body): + cls._send_sms(user, message_body, grafana_cloud=True) + + @classmethod + def _send_sms(cls, user, message_body, alert_group=None, notification_policy=None, grafana_cloud=False): + if not user.verified_phone_number: + raise SMSMessage.PhoneNumberNotVerifiedError("User phone number is not verified") + + sms_message = SMSMessage( + represents_alert_group=alert_group, + receiver=user, + notification_policy=notification_policy, + grafana_cloud_notification=grafana_cloud, + ) + sms_left = user.organization.sms_left(user) + + if sms_left <= 0: + sms_message.exceeded_limit = True + sms_message.save() + raise SMSMessage.SMSLimitExceeded("Organization sms limit exceeded") + + sms_message.exceeded_limit = False + if sms_left < 3: + message_body += " {} sms left. Contact your admin.".format(sms_left) + + twilio_message = twilio_client.send_message(message_body, user.verified_phone_number) + if twilio_message.status and twilio_message.sid: + sms_message.status = TwilioMessageStatuses.DETERMINANT.get(twilio_message.status, None) + sms_message.sid = twilio_message.sid + sms_message.save() + + return sms_message + @staticmethod def get_error_code_by_twilio_status(status): UserNotificationPolicyLogRecord = apps.get_model("base", "UserNotificationPolicyLogRecord") diff --git a/engine/common/utils.py b/engine/common/utils.py index 4b9ef9c1..7507bf97 100644 --- a/engine/common/utils.py +++ b/engine/common/utils.py @@ -177,6 +177,14 @@ def clean_markup(text): return cleaned +def escape_for_twilio_phone_call(text): + # https://www.twilio.com/docs/api/errors/12100 + text = text.replace("&", "&") + text = text.replace(">", ">") + text = text.replace("<", "<") + return text + + def escape_html(text): return html.escape(text) From f68d3f214664cb49ec687f59ee30cbc8f25ca69c Mon Sep 17 00:00:00 2001 From: Matias Bordese Date: Fri, 3 Jun 2022 14:59:43 -0300 Subject: [PATCH 02/35] Plug in sms/phone cloud notifications --- engine/apps/alerts/tasks/notify_user.py | 15 ++++++- engine/apps/base/models/live_setting.py | 2 + .../public_api/views/phone_notifications.py | 43 ++++++++++--------- engine/apps/twilioapp/models/phone_call.py | 37 ++++++++++++++-- engine/apps/twilioapp/models/sms_message.py | 37 ++++++++++++++-- engine/settings/base.py | 1 + 6 files changed, 106 insertions(+), 29 deletions(-) diff --git a/engine/apps/alerts/tasks/notify_user.py b/engine/apps/alerts/tasks/notify_user.py index 05a9456f..e2c8cb23 100644 --- a/engine/apps/alerts/tasks/notify_user.py +++ b/engine/apps/alerts/tasks/notify_user.py @@ -12,6 +12,7 @@ from apps.alerts.constants import NEXT_ESCALATION_DELAY from apps.alerts.incident_appearance.renderers.web_renderer import AlertGroupWebRenderer from apps.alerts.signals import user_notification_action_triggered_signal from apps.base.messaging import get_messaging_backend_from_id +from apps.base.utils import live_settings from common.custom_celery_tasks import shared_dedicated_queue_retry_task from .task_logger import task_logger @@ -258,10 +259,20 @@ def perform_notification(log_record_pk): return if notification_channel == UserNotificationPolicy.NotificationChannel.SMS: - SMSMessage.send_sms(user, alert_group, notification_policy) + SMSMessage.send_sms( + user, + alert_group, + notification_policy, + is_cloud_notification=live_settings.GRAFANA_CLOUD_NOTIFICATIONS_ENABLED, + ) elif notification_channel == UserNotificationPolicy.NotificationChannel.PHONE_CALL: - PhoneCall.make_call(user, alert_group, notification_policy) + PhoneCall.make_call( + user, + alert_group, + notification_policy, + is_cloud_notification=live_settings.GRAFANA_CLOUD_NOTIFICATIONS_ENABLED, + ) elif notification_channel == UserNotificationPolicy.NotificationChannel.TELEGRAM: if alert_group.notify_in_telegram_enabled is True: diff --git a/engine/apps/base/models/live_setting.py b/engine/apps/base/models/live_setting.py index c08ab11f..1c0b806a 100644 --- a/engine/apps/base/models/live_setting.py +++ b/engine/apps/base/models/live_setting.py @@ -44,6 +44,7 @@ class LiveSetting(models.Model): "SEND_ANONYMOUS_USAGE_STATS", "GRAFANA_CLOUD_ONCALL_TOKEN", "GRAFANA_CLOUD_ONCALL_HEARTBEAT_ENABLED", + "GRAFANA_CLOUD_NOTIFICATIONS_ENABLED", ) DESCRIPTIONS = { @@ -106,6 +107,7 @@ class LiveSetting(models.Model): ), "GRAFANA_CLOUD_ONCALL_TOKEN": "Secret token for Grafana Cloud OnCall instance.", "GRAFANA_CLOUD_ONCALL_HEARTBEAT_ENABLED": "Enable hearbeat integration with Grafana Cloud OnCall.", + "GRAFANA_CLOUD_NOTIFICATIONS_ENABLED": "Enable SMS/call notifications via Grafana Cloud OnCall", } SECRET_SETTING_NAMES = ( diff --git a/engine/apps/public_api/views/phone_notifications.py b/engine/apps/public_api/views/phone_notifications.py index eed301a2..5269d4a9 100644 --- a/engine/apps/public_api/views/phone_notifications.py +++ b/engine/apps/public_api/views/phone_notifications.py @@ -1,4 +1,3 @@ -# TODO: move to serializers from rest_framework import serializers, status from rest_framework.permissions import IsAuthenticated from rest_framework.response import Response @@ -11,7 +10,7 @@ from apps.twilioapp.models import PhoneCall, SMSMessage class PhoneNotificationDataSerializer(serializers.Serializer): email = serializers.EmailField() - message = serializers.CharField(max_length=200) + message = serializers.CharField(max_length=1024) class MakeCallView(APIView): @@ -21,27 +20,26 @@ class MakeCallView(APIView): # TODO: add ratelimit def post(self, request): - # TODO: Grafana Twilio: - # 1. Validate user's email - # 2. Validate payload: clean_markup, escape_for_twilio_phone_call - # 3. Create LogRecord (User notification policy or implement new one) serializer = PhoneNotificationDataSerializer(data=request.data) serializer.is_valid(raise_exception=True) + response_data = {} organization = self.request.auth.organization - # TODO: filter by verified phone number? - user = organization.users.filter(email=serializer.validated_data["email"]).first() - if not user or not user.verified_phone_number: - return Response(status=status.HTTP_400_BAD_REQUEST) + user = organization.users.filter( + email=serializer.validated_data["email"], _verified_phone_number__isnull=False + ).first() + if user is None: + response_data = {"error": "user-not-found"} + return Response(status=status.HTTP_404_NOT_FOUND, data=response_data) try: PhoneCall.make_grafana_cloud_call(user, serializer.validated_data["message"]) except TwilioRestException: - return Response(status=status.HTTP_503_SERVICE_UNAVAILABLE) + return Response(status=status.HTTP_503_SERVICE_UNAVAILABLE, data=response_data) except PhoneCall.PhoneCallsLimitExceeded: - return Response(status=status.HTTP_400_BAD_REQUEST) + return Response(status=status.HTTP_400_BAD_REQUEST, data={"error": "limit-exceeded"}) - return Response(status=status.HTTP_200_OK) + return Response(status=status.HTTP_200_OK, data=response_data) class SendSMSView(APIView): @@ -52,17 +50,20 @@ class SendSMSView(APIView): serializer = PhoneNotificationDataSerializer(data=request.data) serializer.is_valid(raise_exception=True) + response_data = {} organization = self.request.auth.organization - # TODO: filter by verified phone number? - user = organization.users.filter(email=serializer.validated_data["email"]).first() - if not user or not user.verified_phone_number: - return Response(status=status.HTTP_400_BAD_REQUEST) + user = organization.users.filter( + email=serializer.validated_data["email"], _verified_phone_number__isnull=False + ).first() + if user is None: + response_data = {"error": "user-not-found"} + return Response(status=status.HTTP_404_NOT_FOUND, data=response_data) try: - SMSMessage.send_cloud_sms(user, serializer.validated_data["message"]) + SMSMessage.send_grafana_cloud_sms(user, serializer.validated_data["message"]) except TwilioRestException: - return Response(status=status.HTTP_503_SERVICE_UNAVAILABLE) + return Response(status=status.HTTP_503_SERVICE_UNAVAILABLE, data=response_data) except SMSMessage.SMSLimitExceeded: - return Response(status=status.HTTP_400_BAD_REQUEST) + return Response(status=status.HTTP_400_BAD_REQUEST, data={"error": "limit-exceeded"}) - return Response(status=status.HTTP_200_OK) + return Response(status=status.HTTP_200_OK, data=response_data) diff --git a/engine/apps/twilioapp/models/phone_call.py b/engine/apps/twilioapp/models/phone_call.py index 2cfe44b4..72389811 100644 --- a/engine/apps/twilioapp/models/phone_call.py +++ b/engine/apps/twilioapp/models/phone_call.py @@ -1,7 +1,11 @@ import logging +from urllib.parse import urljoin +import requests from django.apps import apps +from django.conf import settings from django.db import models +from rest_framework import status from twilio.base.exceptions import TwilioRestException from apps.alerts.constants import ActionSource @@ -125,6 +129,9 @@ class PhoneCall(models.Model): class PhoneNumberNotVerifiedError(Exception): """Phone number is not verified""" + class CloudSendError(Exception): + """Error making call through cloud""" + def process_digit(self, digit): """The function process pressed digit at time of call to user @@ -148,14 +155,38 @@ class PhoneCall(models.Model): return bool(self.represents_alert_group.slack_message) @classmethod - def make_call(cls, user, alert_group, notification_policy): + def _make_cloud_call(cls, user, message_body): + url = urljoin(settings.GRAFANA_CLOUD_ONCALL_API_URL, "api/v1/make_call") + auth = {"Authorization": settings.GRAFANA_CLOUD_ONCALL_TOKEN} + data = { + "email": user.email, + "message": message_body, + } + try: + response = requests.post(url, headers=auth, data=data, timeout=5) + except requests.exceptions.RequestException as e: + logger.warning(f"Unable to make call through cloud. Request exception {str(e)}") + raise PhoneCall.CloudSendError("Unable to make call through cloud: request failed") + + if response.status_code == status.HTTP_400_BAD_REQUEST and response.json().get("error") == "limit-exceeded": + raise PhoneCall.PhoneCallsLimitExceeded("Organization calls limit exceeded") + elif response.status_code == status.HTTP_404_NOT_FOUND: + raise PhoneCall.CloudSendError("Unable to make call through cloud: user not found") + else: + raise PhoneCall.CloudSendError("Unable to make call through cloud: server error") + + @classmethod + def make_call(cls, user, alert_group, notification_policy, is_cloud_notification=False): UserNotificationPolicyLogRecord = apps.get_model("base", "UserNotificationPolicyLogRecord") log_record = None renderer = AlertGroupPhoneCallRenderer(alert_group) message_body = renderer.render() try: - cls._make_call(user, message_body, alert_group=alert_group, notification_policy=notification_policy) - except TwilioRestException: + if is_cloud_notification: + cls._make_cloud_call(user, message_body) + else: + cls._make_call(user, message_body, alert_group=alert_group, notification_policy=notification_policy) + except (TwilioRestException, PhoneCall.CloudSendError): log_record = UserNotificationPolicyLogRecord( author=user, type=UserNotificationPolicyLogRecord.TYPE_PERSONAL_NOTIFICATION_FAILED, diff --git a/engine/apps/twilioapp/models/sms_message.py b/engine/apps/twilioapp/models/sms_message.py index 4046f464..433419e5 100644 --- a/engine/apps/twilioapp/models/sms_message.py +++ b/engine/apps/twilioapp/models/sms_message.py @@ -1,7 +1,11 @@ import logging +from urllib.parse import urljoin +import requests from django.apps import apps +from django.conf import settings from django.db import models +from rest_framework import status from twilio.base.exceptions import TwilioRestException from apps.alerts.incident_appearance.renderers.sms_renderer import AlertGroupSmsRenderer @@ -108,20 +112,47 @@ class SMSMessage(models.Model): class PhoneNumberNotVerifiedError(Exception): """Phone number is not verified""" + class CloudSendError(Exception): + """SMS sending through cloud error""" + @property def created_for_slack(self): return bool(self.represents_alert_group.slack_message) @classmethod - def send_sms(cls, user, alert_group, notification_policy): + def _send_cloud_sms(cls, user, message_body): + url = urljoin(settings.GRAFANA_CLOUD_ONCALL_API_URL, "api/v1/send_sms") + auth = {"Authorization": settings.GRAFANA_CLOUD_ONCALL_TOKEN} + data = { + "email": user.email, + "message": message_body, + } + try: + response = requests.post(url, headers=auth, data=data, timeout=5) + except requests.exceptions.RequestException as e: + logger.warning(f"Unable to send SMS through cloud. Request exception {str(e)}") + raise SMSMessage.CloudSendError("Unable to send SMS through cloud: request failed") + + if response.status_code == status.HTTP_400_BAD_REQUEST and response.json().get("error") == "limit-exceeded": + raise SMSMessage.SMSLimitExceeded("Organization sms limit exceeded") + elif response.status_code == status.HTTP_404_NOT_FOUND: + raise SMSMessage.CloudSendError("Unable to send SMS through cloud: user not found") + else: + raise SMSMessage.CloudSendError("Unable to send SMS through cloud: server error") + + @classmethod + def send_sms(cls, user, alert_group, notification_policy, is_cloud_notification=False): UserNotificationPolicyLogRecord = apps.get_model("base", "UserNotificationPolicyLogRecord") log_record = None renderer = AlertGroupSmsRenderer(alert_group) message_body = renderer.render() try: - cls._send_sms(user, message_body, alert_group=alert_group, notification_policy=notification_policy) - except TwilioRestException: + if is_cloud_notification: + cls._send_cloud_sms(user, message_body) + else: + cls._send_sms(user, message_body, alert_group=alert_group, notification_policy=notification_policy) + except (TwilioRestException, SMSMessage.CloudSendError): log_record = UserNotificationPolicyLogRecord( author=user, type=UserNotificationPolicyLogRecord.TYPE_PERSONAL_NOTIFICATION_FAILED, diff --git a/engine/settings/base.py b/engine/settings/base.py index b2150a47..9bb227f9 100644 --- a/engine/settings/base.py +++ b/engine/settings/base.py @@ -412,6 +412,7 @@ SELF_HOSTED_SETTINGS = { GRAFANA_CLOUD_ONCALL_API_URL = os.environ.get("GRAFANA_CLOUD_ONCALL_API_URL", "https://a-prod-us-central-0.grafana.net") GRAFANA_CLOUD_ONCALL_TOKEN = os.environ.get("GRAFANA_CLOUD_ONCALL_TOKEN", None) GRAFANA_CLOUD_ONCALL_HEARTBEAT_ENABLED = getenv_boolean("GRAFANA_CLOUD_ONCALL_HEARTBEAT_ENABLED", default=True) +GRAFANA_CLOUD_NOTIFICATIONS_ENABLED = getenv_boolean("GRAFANA_CLOUD_NOTIFICATIONS_ENABLED", default=True) GRAFANA_INCIDENT_STATIC_API_KEY = os.environ.get("GRAFANA_INCIDENT_STATIC_API_KEY", None) From 5e494531eb93b52a2d1be98fcc25531509146eaa Mon Sep 17 00:00:00 2001 From: Innokentii Konstantinov Date: Fri, 3 Jun 2022 23:03:54 +0400 Subject: [PATCH 03/35] Add CloudUsersView --- .../models/cloud_organization_connector.py | 2 +- .../oss_installation/models/cloud_users.py | 2 +- engine/apps/oss_installation/urls.py | 3 +- .../apps/oss_installation/views/__init__.py | 1 + .../oss_installation/views/cloud_users.py | 53 +++++++++++++++++++ engine/apps/twilioapp/models/sms_message.py | 2 +- 6 files changed, 59 insertions(+), 4 deletions(-) create mode 100644 engine/apps/oss_installation/views/cloud_users.py diff --git a/engine/apps/oss_installation/models/cloud_organization_connector.py b/engine/apps/oss_installation/models/cloud_organization_connector.py index 36316457..a142ddcb 100644 --- a/engine/apps/oss_installation/models/cloud_organization_connector.py +++ b/engine/apps/oss_installation/models/cloud_organization_connector.py @@ -102,7 +102,7 @@ class CloudOrganizationConnector(models.Model): i.email = cloud_users_identities_to_update[i.cloud_id]["email"] i.phone_number_verified = cloud_users_identities_to_update[i.cloud_id]["is_phone_number_verified"] - # TODO: Grafana Twilio: check if data validation needed. + # TODO: Grafana CN: check if data validation needed. CloudUserIdentity.objects.bulk_create(cloud_users_identities_to_create, batch_size=1000) CloudUserIdentity.objects.bulk_update( existing_cloud_identities, ["email", "phone_number_verified"], batch_size=1000 diff --git a/engine/apps/oss_installation/models/cloud_users.py b/engine/apps/oss_installation/models/cloud_users.py index cbef16c0..5eb87f91 100644 --- a/engine/apps/oss_installation/models/cloud_users.py +++ b/engine/apps/oss_installation/models/cloud_users.py @@ -10,5 +10,5 @@ class CloudUserIdentity(models.Model): ) class Meta: - # TODO: Grafana Twilio: Check if this constraint needed + # TODO: Grafana CN: Check if this constraint needed unique_together = ("cloud_id", "organization") diff --git a/engine/apps/oss_installation/urls.py b/engine/apps/oss_installation/urls.py index 956ffe74..cfa876e2 100644 --- a/engine/apps/oss_installation/urls.py +++ b/engine/apps/oss_installation/urls.py @@ -1,7 +1,8 @@ from common.api_helpers.optional_slash_router import optional_slash_path -from .views import CloudHeartbeatStatusView +from .views import CloudHeartbeatStatusView, CloudUsersView urlpatterns = [ optional_slash_path("cloud_heartbeat_status", CloudHeartbeatStatusView.as_view(), name="cloud_heartbeat_status"), + optional_slash_path("cloud_users", CloudUsersView.as_view(), name="cloud_users"), ] diff --git a/engine/apps/oss_installation/views/__init__.py b/engine/apps/oss_installation/views/__init__.py index 0716482b..98caf343 100644 --- a/engine/apps/oss_installation/views/__init__.py +++ b/engine/apps/oss_installation/views/__init__.py @@ -1 +1,2 @@ from .cloud_heartbeat_status import CloudHeartbeatStatusView # noqa: F401 +from .cloud_users import CloudUsersView # noqa: F401 diff --git a/engine/apps/oss_installation/views/cloud_users.py b/engine/apps/oss_installation/views/cloud_users.py new file mode 100644 index 00000000..a1f93343 --- /dev/null +++ b/engine/apps/oss_installation/views/cloud_users.py @@ -0,0 +1,53 @@ +from urllib.parse import urljoin + +from rest_framework.permissions import IsAuthenticated +from rest_framework.views import APIView + +from apps.auth_token.auth import PluginAuthentication +from apps.oss_installation.models import CloudOrganizationConnector, CloudUserIdentity +from apps.user_management.models import User +from common.api_helpers.paginators import HundredPageSizePaginator + + +class CloudUsersView(HundredPageSizePaginator, APIView): + authentication_classes = (PluginAuthentication,) + # TODO: Grafana CN - permissions, ratelimit + permission_classes = (IsAuthenticated,) + + def get(self, request): + queryset = User.objects.filter(organization=self.request.user.organization) + + if self.request.user.current_team is not None: + queryset = queryset.filter(teams=self.request.user.current_team).distinct() + + results = self.paginate_queryset(queryset, request, view=self) + + emails = list(queryset.values_list("email", flat=True)) + cloud_identities = list( + CloudUserIdentity.objects.filter(organization=self.request.user.organization, email__in=emails) + ) + cloud_identities = {cloud_identity.email: cloud_identity for cloud_identity in cloud_identities} + + response = [] + + connector = CloudOrganizationConnector.objects.first() + + for user in results: + cloud_identity = cloud_identities.get(user.email, None) + link = None + status = 0 + if cloud_identity: + status = 1 + is_phone_verified = cloud_identity.phone_number_verified + if is_phone_verified: + status = 2 + link = urljoin( + connector.cloud_url, f"a/grafana-oncall-app/?page=users&p=1&id={cloud_identity.cloud_id}" + ) + + # TODO: Grafana CN - decide if emails is needed. If yes - don't forget to check that they mustn't be shown to users + response.append( + {"id": user.public_primary_key, "username": user.username, "cloud_sync_status": status, "link": link} + ) + + return self.get_paginated_response(response) diff --git a/engine/apps/twilioapp/models/sms_message.py b/engine/apps/twilioapp/models/sms_message.py index 433419e5..c18dd7e8 100644 --- a/engine/apps/twilioapp/models/sms_message.py +++ b/engine/apps/twilioapp/models/sms_message.py @@ -41,7 +41,7 @@ class SMSMessageManager(models.Manager): sms_message = sms_message_qs.first() if sms_message.grafana_cloud_notification: - # If sms was sent via grafana twilio it is don't needed to create logs on it's delivery status. + # If sms was sent via grafana cloud notifications don't create logs on its delivery status. return log_record = None From 75f319fb5d0ac650ae4057ba79612ff54e069aa4 Mon Sep 17 00:00:00 2001 From: Innokentii Konstantinov Date: Sat, 4 Jun 2022 16:49:10 +0400 Subject: [PATCH 04/35] Add CloudUsersView and CloudUserView --- engine/apps/api/views/features.py | 5 ++ engine/apps/base/utils.py | 17 ++++++ engine/apps/oss_installation/constants.py | 5 ++ .../apps/oss_installation/models/__init__.py | 2 +- .../models/cloud_organization_connector.py | 5 +- ...{cloud_users.py => cloud_user_identity.py} | 0 engine/apps/oss_installation/urls.py | 14 ++++- .../apps/oss_installation/views/cloud_user.py | 61 +++++++++++++++++++ .../oss_installation/views/cloud_users.py | 36 ++++++----- 9 files changed, 125 insertions(+), 20 deletions(-) rename engine/apps/oss_installation/models/{cloud_users.py => cloud_user_identity.py} (100%) create mode 100644 engine/apps/oss_installation/views/cloud_user.py diff --git a/engine/apps/api/views/features.py b/engine/apps/api/views/features.py index 6a4285de..79ed373b 100644 --- a/engine/apps/api/views/features.py +++ b/engine/apps/api/views/features.py @@ -4,11 +4,13 @@ from rest_framework.response import Response from rest_framework.views import APIView from apps.auth_token.auth import PluginAuthentication +from apps.base.utils import live_settings FEATURE_SLACK = "slack" FEATURE_TELEGRAM = "telegram" FEATURE_LIVE_SETTINGS = "live_settings" MOBILE_APP_PUSH_NOTIFICATIONS = "mobile_app" +FEATURE_GRAFANA_CLOUD_NOTIFICATIONS = "grafana_cloud_notifications" class FeaturesAPIView(APIView): @@ -34,6 +36,9 @@ class FeaturesAPIView(APIView): if settings.FEATURE_LIVE_SETTINGS_ENABLED: enabled_features.append(FEATURE_LIVE_SETTINGS) + if live_settings.GRAFANA_CLOUD_NOTIFICATIONS_ENABLED: + enabled_features.append(FEATURE_GRAFANA_CLOUD_NOTIFICATIONS) + if settings.MOBILE_APP_PUSH_NOTIFICATIONS_ENABLED: DynamicSetting = apps.get_model("base", "DynamicSetting") mobile_app_settings = DynamicSetting.objects.get_or_create( diff --git a/engine/apps/base/utils.py b/engine/apps/base/utils.py index 7342d00e..8ea5801e 100644 --- a/engine/apps/base/utils.py +++ b/engine/apps/base/utils.py @@ -1,7 +1,10 @@ import json import re +from urllib.parse import urljoin +import requests.exceptions from django.apps import apps +from django.conf import settings from python_http_client import UnauthorizedError from sendgrid import SendGridAPIClient from telegram import Bot @@ -94,6 +97,20 @@ class LiveSettingValidator: except Exception as e: return f"Telegram error: {str(e)}" + @classmethod + def _check_grafana_cloud_oncall_token(cls, grafan_oncall_token): + try: + info_url = urljoin(settings.GRAFANA_CLOUD_ONCALL_API_URL, "api/v1/info/") + r = requests.get(info_url, headers={"AUTHORIZATION": grafan_oncall_token}, timeout=5) + if r.status_code == 200: + return + elif r.status_code == 403: + return f"Invalid token." + else: + return f"Non-200 HTTP code. Got {r.status_code}" + except requests.exceptions.RequestException as e: + return f"Error {str(e)}" + @staticmethod def _is_email_valid(email): return re.match(r"^[^@]+@[^@]+\.[^@]+$", email) diff --git a/engine/apps/oss_installation/constants.py b/engine/apps/oss_installation/constants.py index c6c3b88b..db777bb3 100644 --- a/engine/apps/oss_installation/constants.py +++ b/engine/apps/oss_installation/constants.py @@ -1 +1,6 @@ CLOUD_URL = "https://a-prod-us-central-0.grafana.net/" + +CLOUD_NOT_SYNCED = 0 +CLOUD_SYNCED_USER_NOT_FOUND = 1 +CLOUD_SYNCED_PHONE_NOT_VERIFIED = 2 +CLOUD_SYNCED_PHONE_VERIFIED = 3 diff --git a/engine/apps/oss_installation/models/__init__.py b/engine/apps/oss_installation/models/__init__.py index 80721219..2ee74128 100644 --- a/engine/apps/oss_installation/models/__init__.py +++ b/engine/apps/oss_installation/models/__init__.py @@ -1,4 +1,4 @@ from .cloud_organization_connector import CloudOrganizationConnector # noqa: F401 -from .cloud_users import CloudUserIdentity # noqa: F401 +from .cloud_user_identity import CloudUserIdentity # noqa: F401 from .heartbeat import CloudHeartbeat # noqa: F401 from .oss_installation import OssInstallation # noqa: F401 diff --git a/engine/apps/oss_installation/models/cloud_organization_connector.py b/engine/apps/oss_installation/models/cloud_organization_connector.py index a142ddcb..732be38e 100644 --- a/engine/apps/oss_installation/models/cloud_organization_connector.py +++ b/engine/apps/oss_installation/models/cloud_organization_connector.py @@ -6,7 +6,7 @@ from django.db import models from apps.base.utils import live_settings from apps.oss_installation.constants import CLOUD_URL -from apps.oss_installation.models.cloud_users import CloudUserIdentity +from apps.oss_installation.models.cloud_user_identity import CloudUserIdentity from apps.user_management.models import User logger = logging.getLogger(__name__) @@ -59,7 +59,7 @@ class CloudOrganizationConnector(models.Model): users_url = urljoin(CLOUD_URL, "api/v1/users") existing_cloud_identities = list(CloudUserIdentity.objects.filter(organization=self.organization)) - existing_cloud_ids = list(map(lambda u: u.cloud_id, existing_cloud_identities)) + existing_cloud_ids = list(map(lambda identity: identity.cloud_id, existing_cloud_identities)) fetch_next_page = True page = 1 @@ -102,7 +102,6 @@ class CloudOrganizationConnector(models.Model): i.email = cloud_users_identities_to_update[i.cloud_id]["email"] i.phone_number_verified = cloud_users_identities_to_update[i.cloud_id]["is_phone_number_verified"] - # TODO: Grafana CN: check if data validation needed. CloudUserIdentity.objects.bulk_create(cloud_users_identities_to_create, batch_size=1000) CloudUserIdentity.objects.bulk_update( existing_cloud_identities, ["email", "phone_number_verified"], batch_size=1000 diff --git a/engine/apps/oss_installation/models/cloud_users.py b/engine/apps/oss_installation/models/cloud_user_identity.py similarity index 100% rename from engine/apps/oss_installation/models/cloud_users.py rename to engine/apps/oss_installation/models/cloud_user_identity.py diff --git a/engine/apps/oss_installation/urls.py b/engine/apps/oss_installation/urls.py index cfa876e2..86bb640b 100644 --- a/engine/apps/oss_installation/urls.py +++ b/engine/apps/oss_installation/urls.py @@ -1,8 +1,20 @@ +from django.urls import path + from common.api_helpers.optional_slash_router import optional_slash_path from .views import CloudHeartbeatStatusView, CloudUsersView +from .views.cloud_user import CloudUserView urlpatterns = [ optional_slash_path("cloud_heartbeat_status", CloudHeartbeatStatusView.as_view(), name="cloud_heartbeat_status"), - optional_slash_path("cloud_users", CloudUsersView.as_view(), name="cloud_users"), + optional_slash_path("cloud_users", CloudUsersView.as_view(), name="cloud-users-list"), + path( + "cloud_users/", + CloudUserView.as_view( + { + "get": "retrieve", + } + ), + name="cloud-user-detail", + ), ] diff --git a/engine/apps/oss_installation/views/cloud_user.py b/engine/apps/oss_installation/views/cloud_user.py new file mode 100644 index 00000000..5f5805cb --- /dev/null +++ b/engine/apps/oss_installation/views/cloud_user.py @@ -0,0 +1,61 @@ +from urllib.parse import urljoin + +from rest_framework import mixins, serializers, viewsets +from rest_framework.permissions import IsAuthenticated + +import apps.oss_installation.constants as cloud_constants +from apps.api.permissions import ActionPermission, IsOwnerOrAdmin +from apps.auth_token.auth import PluginAuthentication +from apps.oss_installation.models import CloudOrganizationConnector, CloudUserIdentity +from apps.user_management.models import User +from common.api_helpers.mixins import PublicPrimaryKeyMixin + + +class CloudUserSerializer(serializers.ModelSerializer): + cloud_data = serializers.SerializerMethodField() + + class Meta: + model = User + fields = ["sync_data"] + + def get_cloud_data(self, obj): + link = None + status = cloud_constants.CLOUD_NOT_SYNCED + connector = CloudOrganizationConnector.objects.filter( + organization=self.context["request"].auth.organization + ).first() + if connector is not None: + cloud_user_identity = CloudUserIdentity.objects.filter(email=obj.email).first() + if cloud_user_identity is None: + status = cloud_constants.CLOUD_SYNCED_USER_NOT_FOUND + link = connector.cloud_url + elif not cloud_user_identity.phone_number_verified: + status = cloud_constants.CLOUD_SYNCED_USER_NOT_FOUND + link = urljoin( + connector.cloud_url, f"a/grafana-oncall-app/?page=users&p=1&id={cloud_user_identity.cloud_id}" + ) + else: + status = cloud_constants.CLOUD_SYNCED_PHONE_VERIFIED + link = urljoin( + connector.cloud_url, f"a/grafana-oncall-app/?page=users&p=1&id={cloud_user_identity.cloud_id}" + ) + cloud_data = {"status": status, "link": link} + return cloud_data + + +class CloudUserView( + PublicPrimaryKeyMixin, + mixins.RetrieveModelMixin, + viewsets.GenericViewSet, +): + authentication_classes = (PluginAuthentication,) + permission_classes = (IsAuthenticated, ActionPermission) + + action_object_permissions = { + IsOwnerOrAdmin: ("retrieve",), + } + serializer_class = CloudUserSerializer + + def get_queryset(self): + queryset = User.objects.filter(organization=self.request.user.organization) + return queryset diff --git a/engine/apps/oss_installation/views/cloud_users.py b/engine/apps/oss_installation/views/cloud_users.py index a1f93343..af3a5cd8 100644 --- a/engine/apps/oss_installation/views/cloud_users.py +++ b/engine/apps/oss_installation/views/cloud_users.py @@ -3,6 +3,8 @@ from urllib.parse import urljoin from rest_framework.permissions import IsAuthenticated from rest_framework.views import APIView +import apps.oss_installation.constants as cloud_constants +from apps.api.permissions import IsAdmin from apps.auth_token.auth import PluginAuthentication from apps.oss_installation.models import CloudOrganizationConnector, CloudUserIdentity from apps.user_management.models import User @@ -11,8 +13,7 @@ from common.api_helpers.paginators import HundredPageSizePaginator class CloudUsersView(HundredPageSizePaginator, APIView): authentication_classes = (PluginAuthentication,) - # TODO: Grafana CN - permissions, ratelimit - permission_classes = (IsAuthenticated,) + permission_classes = (IsAuthenticated, IsAdmin) def get(self, request): queryset = User.objects.filter(organization=self.request.user.organization) @@ -31,23 +32,28 @@ class CloudUsersView(HundredPageSizePaginator, APIView): response = [] connector = CloudOrganizationConnector.objects.first() - for user in results: - cloud_identity = cloud_identities.get(user.email, None) link = None - status = 0 - if cloud_identity: - status = 1 - is_phone_verified = cloud_identity.phone_number_verified - if is_phone_verified: - status = 2 - link = urljoin( - connector.cloud_url, f"a/grafana-oncall-app/?page=users&p=1&id={cloud_identity.cloud_id}" - ) + status = cloud_constants.CLOUD_NOT_SYNCED + if connector is not None: + status = cloud_constants.CLOUD_SYNCED_USER_NOT_FOUND + cloud_identity = cloud_identities.get(user.email, None) + if cloud_identity: + status = cloud_constants.CLOUD_SYNCED_PHONE_NOT_VERIFIED + is_phone_verified = cloud_identity.phone_number_verified + if is_phone_verified: + status = cloud_constants.CLOUD_SYNCED_PHONE_VERIFIED + link = urljoin( + connector.cloud_url, f"a/grafana-oncall-app/?page=users&p=1&id={cloud_identity.cloud_id}" + ) - # TODO: Grafana CN - decide if emails is needed. If yes - don't forget to check that they mustn't be shown to users response.append( - {"id": user.public_primary_key, "username": user.username, "cloud_sync_status": status, "link": link} + { + "id": user.public_primary_key, + "email": user.email, + "username": user.username, + "cloud_data": {"status": status, "link": link}, + } ) return self.get_paginated_response(response) From d182d4c305c72bd3500279af03431e607ebe9821 Mon Sep 17 00:00:00 2001 From: Innokentii Konstantinov Date: Sun, 5 Jun 2022 13:15:17 +0400 Subject: [PATCH 05/35] Simplify sync with cloud system --- .../models/cloud_organization_connector.py | 38 ++++++------------- .../models/cloud_user_identity.py | 3 +- 2 files changed, 12 insertions(+), 29 deletions(-) diff --git a/engine/apps/oss_installation/models/cloud_organization_connector.py b/engine/apps/oss_installation/models/cloud_organization_connector.py index 732be38e..c4037895 100644 --- a/engine/apps/oss_installation/models/cloud_organization_connector.py +++ b/engine/apps/oss_installation/models/cloud_organization_connector.py @@ -2,7 +2,7 @@ import logging from urllib.parse import urljoin import requests -from django.db import models +from django.db import models, transaction from apps.base.utils import live_settings from apps.oss_installation.constants import CLOUD_URL @@ -52,15 +52,9 @@ class CloudOrganizationConnector(models.Model): return existing_emails = list(User.objects.filter(organization=self.organization).values_list("email", flat=True)) - # existing_cloud_ids = list( - # CloudUserIdentity.objects.filter(organization=self.organization).values_list("cloud_id", flat=True) - # ) matching_users = [] users_url = urljoin(CLOUD_URL, "api/v1/users") - existing_cloud_identities = list(CloudUserIdentity.objects.filter(organization=self.organization)) - existing_cloud_ids = list(map(lambda identity: identity.cloud_id, existing_cloud_identities)) - fetch_next_page = True page = 1 while fetch_next_page: @@ -82,13 +76,9 @@ class CloudOrganizationConnector(models.Model): logger.warning(f"Unable to sync users with cloud. Request exception {str(e)}") break - cloud_users_identities_to_update = {} - - cloud_users_identities_to_create = [] - for user in matching_users: - if user["id"] in existing_cloud_ids: - cloud_users_identities_to_update[user["id"]] = user - else: + with transaction.atomic(): + cloud_users_identities_to_create = [] + for user in matching_users: cloud_users_identities_to_create.append( CloudUserIdentity( cloud_id=user["id"], @@ -98,14 +88,8 @@ class CloudOrganizationConnector(models.Model): ) ) - for i in existing_cloud_identities: - i.email = cloud_users_identities_to_update[i.cloud_id]["email"] - i.phone_number_verified = cloud_users_identities_to_update[i.cloud_id]["is_phone_number_verified"] - - CloudUserIdentity.objects.bulk_create(cloud_users_identities_to_create, batch_size=1000) - CloudUserIdentity.objects.bulk_update( - existing_cloud_identities, ["email", "phone_number_verified"], batch_size=1000 - ) + CloudUserIdentity.objects.filter(organization=self.organization).delete() + CloudUserIdentity.objects.bulk_create(cloud_users_identities_to_create, batch_size=1000) def sync_user_with_cloud(self, user): api_token = live_settings.GRAFANA_CLOUD_ONCALL_TOKEN @@ -124,12 +108,12 @@ class CloudOrganizationConnector(models.Model): data = r.json() if len(data["results"]) != 0: cloud_used_data = data["results"][0] - CloudUserIdentity.objects.update_or_create( + CloudUserIdentity.objects.filter(email=user.emai).delete() + CloudUserIdentity.objects.create( email=user.email, - defaults={ - "phone_number_verified": cloud_used_data["is_phone_number_verified"], - "cloud_id": cloud_used_data["id"], - }, + organization=user.organization, + phone_number_verified=cloud_used_data["is_phone_number_verified"], + cloud_id=cloud_used_data["id"], ) else: logger.warning(f"Unable to sync_user_with_cloud user_id {user.id}. User with {user.email} not found") diff --git a/engine/apps/oss_installation/models/cloud_user_identity.py b/engine/apps/oss_installation/models/cloud_user_identity.py index 5eb87f91..fca0eebe 100644 --- a/engine/apps/oss_installation/models/cloud_user_identity.py +++ b/engine/apps/oss_installation/models/cloud_user_identity.py @@ -10,5 +10,4 @@ class CloudUserIdentity(models.Model): ) class Meta: - # TODO: Grafana CN: Check if this constraint needed - unique_together = ("cloud_id", "organization") + unique_together = ("email", "organization") From 4ef1ba9eb59ddd0ec39f21e9c80803c9afa88393 Mon Sep 17 00:00:00 2001 From: Innokentii Konstantinov Date: Sun, 5 Jun 2022 19:09:40 +0400 Subject: [PATCH 06/35] Add views --- engine/apps/base/utils.py | 2 +- .../models/cloud_organization_connector.py | 112 +++++++++++------- .../oss_installation/serializers/__init__.py | 1 + .../{views => serializers}/cloud_user.py | 24 +--- engine/apps/oss_installation/urls.py | 4 +- .../apps/oss_installation/views/__init__.py | 3 +- .../oss_installation/views/cloud_status.py | 19 +++ .../oss_installation/views/cloud_users.py | 60 ++++++++-- 8 files changed, 148 insertions(+), 77 deletions(-) create mode 100644 engine/apps/oss_installation/serializers/__init__.py rename engine/apps/oss_installation/{views => serializers}/cloud_user.py (66%) create mode 100644 engine/apps/oss_installation/views/cloud_status.py diff --git a/engine/apps/base/utils.py b/engine/apps/base/utils.py index 8ea5801e..0f9f04b7 100644 --- a/engine/apps/base/utils.py +++ b/engine/apps/base/utils.py @@ -105,7 +105,7 @@ class LiveSettingValidator: if r.status_code == 200: return elif r.status_code == 403: - return f"Invalid token." + return f"Invalid token" else: return f"Non-200 HTTP code. Got {r.status_code}" except requests.exceptions.RequestException as e: diff --git a/engine/apps/oss_installation/models/cloud_organization_connector.py b/engine/apps/oss_installation/models/cloud_organization_connector.py index c4037895..126d9b65 100644 --- a/engine/apps/oss_installation/models/cloud_organization_connector.py +++ b/engine/apps/oss_installation/models/cloud_organization_connector.py @@ -23,15 +23,17 @@ class CloudOrganizationConnector(models.Model): ) @classmethod - def sync_with_cloud(cls, organization) -> bool: + def sync_with_cloud(cls, organization): """ sync_with_cloud sync organization with cloud organization defined by provided GRAFANA_CLOUD_ONCALL_TOKEN. """ sync_status = False + error_msg = None api_token = live_settings.GRAFANA_CLOUD_ONCALL_TOKEN if api_token is None: logger.warning("Unable to sync with cloud. GRAFANA_CLOUD_ONCALL_TOKEN is not set") + error_msg = "GRAFANA_CLOUD_ONCALL_TOKEN is not set" else: info_url = urljoin(CLOUD_URL, "api/v1/info/") try: @@ -39,23 +41,31 @@ class CloudOrganizationConnector(models.Model): if r.status_code == 200: cls.objects.update_or_create(organization=organization, defaults={"cloud_url": r.json()["url"]}) sync_status = True - if r.status_code == 403: + elif r.status_code == 403: logger.warning("Unable to sync with cloud. GRAFANA_CLOUD_ONCALL_TOKEN is invalid") + error_msg = "GRAFANA_CLOUD_ONCALL_TOKEN is invalid" + else: + error_msg = f"Non-200 HTTP code. Got {r.status_code}" except requests.exceptions.RequestException as e: logger.warning(f"Unable to sync with cloud. Request exception {str(e)}") - return sync_status + error_msg = f"Unable to sync with cloud" + return sync_status, error_msg + + def sync_users_with_cloud(self) -> tuple[bool, str]: + sync_status = False + error_msg = None - def sync_users_with_cloud(self): api_token = live_settings.GRAFANA_CLOUD_ONCALL_TOKEN if api_token is None: logger.warning("Unable to sync with cloud. GRAFANA_CLOUD_ONCALL_TOKEN is not set") - return + error_msg = "GRAFANA_CLOUD_ONCALL_TOKEN is not set" existing_emails = list(User.objects.filter(organization=self.organization).values_list("email", flat=True)) matching_users = [] users_url = urljoin(CLOUD_URL, "api/v1/users") fetch_next_page = True + users_fetched = True page = 1 while fetch_next_page: try: @@ -65,8 +75,9 @@ class CloudOrganizationConnector(models.Model): logger.warning( f"Unable to fetch page {page} while sync_users_with_cloud. Response status code {r.status_code}" ) - if r.status_code == 429 or r.status_code == 403: - break + error_msg = f"Non-200 HTTP code. Got {r.status_code}" + users_fetched = False + break data = r.json() matching_users.extend(list(filter(lambda u: (u["email"] in existing_emails), data["results"]))) page += 1 @@ -74,48 +85,65 @@ class CloudOrganizationConnector(models.Model): fetch_next_page = False except requests.exceptions.RequestException as e: logger.warning(f"Unable to sync users with cloud. Request exception {str(e)}") + error_msg = f"Unable to sync with cloud" + users_fetched = False break - with transaction.atomic(): - cloud_users_identities_to_create = [] - for user in matching_users: - cloud_users_identities_to_create.append( - CloudUserIdentity( - cloud_id=user["id"], - email=user["email"], - phone_number_verified=user["is_phone_number_verified"], - organization=self.organization, + if users_fetched: + with transaction.atomic(): + cloud_users_identities_to_create = [] + for user in matching_users: + cloud_users_identities_to_create.append( + CloudUserIdentity( + cloud_id=user["id"], + email=user["email"], + phone_number_verified=user["is_phone_number_verified"], + organization=self.organization, + ) ) - ) - CloudUserIdentity.objects.filter(organization=self.organization).delete() - CloudUserIdentity.objects.bulk_create(cloud_users_identities_to_create, batch_size=1000) + CloudUserIdentity.objects.filter(organization=self.organization).delete() + CloudUserIdentity.objects.bulk_create(cloud_users_identities_to_create, batch_size=1000) + + return sync_status, error_msg def sync_user_with_cloud(self, user): + sync_status = False + error_msg = None + api_token = live_settings.GRAFANA_CLOUD_ONCALL_TOKEN if api_token is None: logger.warning(f"Unable to sync_user_with cloud user_id {user.id}. GRAFANA_CLOUD_ONCALL_TOKEN is not set") - return + error_msg = "GRAFANA_CLOUD_ONCALL_TOKEN is not set" + else: + url = urljoin(CLOUD_URL, f"api/v1/users/?email={user.email}") + try: + r = requests.get(url, headers={"AUTHORIZATION": api_token}, timeout=5) + if r.status_code != 200: + logger.warning( + f"Unable to sync_user_with_cloud user_id {user.id}. Response status code {r.status_code}" + ) + error_msg = f"Non-200 HTTP code. Got {r.status_code}" + else: + data = r.json() + if len(data["results"]) != 0: + cloud_used_data = data["results"][0] + with transaction.atomic(): + CloudUserIdentity.objects.filter(email=user.emai).delete() + CloudUserIdentity.objects.create( + email=user.email, + organization=user.organization, + phone_number_verified=cloud_used_data["is_phone_number_verified"], + cloud_id=cloud_used_data["id"], + ) + sync_status = True + else: + logger.warning( + f"Unable to sync_user_with_cloud user_id {user.id}. User with {user.email} not found" + ) + error_msg = f"User with email not found {user.email}" + except requests.exceptions.RequestException as e: + logger.warning(f"Unable to sync_user_with cloud user_id {user.id}. Request exception {str(e)}") + error_msg = f"Unable to sync with cloud" - url = urljoin(CLOUD_URL, f"api/v1/users/?email={user.email}") - try: - r = requests.get(url, headers={"AUTHORIZATION": api_token}, timeout=5) - if r.status_code != 200: - logger.warning( - f"Unable to sync_user_with_cloud user_id {user.id}. Response status code {r.status_code}" - ) - return - data = r.json() - if len(data["results"]) != 0: - cloud_used_data = data["results"][0] - CloudUserIdentity.objects.filter(email=user.emai).delete() - CloudUserIdentity.objects.create( - email=user.email, - organization=user.organization, - phone_number_verified=cloud_used_data["is_phone_number_verified"], - cloud_id=cloud_used_data["id"], - ) - else: - logger.warning(f"Unable to sync_user_with_cloud user_id {user.id}. User with {user.email} not found") - except requests.exceptions.RequestException as e: - logger.warning(f"Unable to sync_user_with cloud user_id {user.id}. Request exception {str(e)}") + return sync_status, error_msg diff --git a/engine/apps/oss_installation/serializers/__init__.py b/engine/apps/oss_installation/serializers/__init__.py new file mode 100644 index 00000000..991cf99b --- /dev/null +++ b/engine/apps/oss_installation/serializers/__init__.py @@ -0,0 +1 @@ +from .cloud_user import CloudUserSerializer # noqa: F401 diff --git a/engine/apps/oss_installation/views/cloud_user.py b/engine/apps/oss_installation/serializers/cloud_user.py similarity index 66% rename from engine/apps/oss_installation/views/cloud_user.py rename to engine/apps/oss_installation/serializers/cloud_user.py index 5f5805cb..50b857b4 100644 --- a/engine/apps/oss_installation/views/cloud_user.py +++ b/engine/apps/oss_installation/serializers/cloud_user.py @@ -1,14 +1,10 @@ from urllib.parse import urljoin -from rest_framework import mixins, serializers, viewsets -from rest_framework.permissions import IsAuthenticated +from rest_framework import serializers import apps.oss_installation.constants as cloud_constants -from apps.api.permissions import ActionPermission, IsOwnerOrAdmin -from apps.auth_token.auth import PluginAuthentication from apps.oss_installation.models import CloudOrganizationConnector, CloudUserIdentity from apps.user_management.models import User -from common.api_helpers.mixins import PublicPrimaryKeyMixin class CloudUserSerializer(serializers.ModelSerializer): @@ -41,21 +37,3 @@ class CloudUserSerializer(serializers.ModelSerializer): ) cloud_data = {"status": status, "link": link} return cloud_data - - -class CloudUserView( - PublicPrimaryKeyMixin, - mixins.RetrieveModelMixin, - viewsets.GenericViewSet, -): - authentication_classes = (PluginAuthentication,) - permission_classes = (IsAuthenticated, ActionPermission) - - action_object_permissions = { - IsOwnerOrAdmin: ("retrieve",), - } - serializer_class = CloudUserSerializer - - def get_queryset(self): - queryset = User.objects.filter(organization=self.request.user.organization) - return queryset diff --git a/engine/apps/oss_installation/urls.py b/engine/apps/oss_installation/urls.py index 86bb640b..2a1e6d5f 100644 --- a/engine/apps/oss_installation/urls.py +++ b/engine/apps/oss_installation/urls.py @@ -2,8 +2,7 @@ from django.urls import path from common.api_helpers.optional_slash_router import optional_slash_path -from .views import CloudHeartbeatStatusView, CloudUsersView -from .views.cloud_user import CloudUserView +from .views import CloudConnectionStatusView, CloudHeartbeatStatusView, CloudUsersView, CloudUserView urlpatterns = [ optional_slash_path("cloud_heartbeat_status", CloudHeartbeatStatusView.as_view(), name="cloud_heartbeat_status"), @@ -17,4 +16,5 @@ urlpatterns = [ ), name="cloud-user-detail", ), + optional_slash_path("cloud_connection_status", CloudConnectionStatusView.as_view(), name="cloud-connection-status"), ] diff --git a/engine/apps/oss_installation/views/__init__.py b/engine/apps/oss_installation/views/__init__.py index 98caf343..66a3be93 100644 --- a/engine/apps/oss_installation/views/__init__.py +++ b/engine/apps/oss_installation/views/__init__.py @@ -1,2 +1,3 @@ from .cloud_heartbeat_status import CloudHeartbeatStatusView # noqa: F401 -from .cloud_users import CloudUsersView # noqa: F401 +from .cloud_status import CloudConnectionStatusView # noqa: F401 +from .cloud_users import CloudUsersView, CloudUserView # noqa: F401 diff --git a/engine/apps/oss_installation/views/cloud_status.py b/engine/apps/oss_installation/views/cloud_status.py new file mode 100644 index 00000000..825fa757 --- /dev/null +++ b/engine/apps/oss_installation/views/cloud_status.py @@ -0,0 +1,19 @@ +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 +from apps.oss_installation.models import CloudOrganizationConnector + + +class CloudConnectionStatusView(APIView): + authentication_classes = (PluginAuthentication,) + permission_classes = (IsAuthenticated,) + + def get(self, request): + connector = CloudOrganizationConnector.objects.filter(organization=request.user.organization).first() + + response = { + "cloud_connection_status": connector is not None, + } + return Response(response) diff --git a/engine/apps/oss_installation/views/cloud_users.py b/engine/apps/oss_installation/views/cloud_users.py index af3a5cd8..d4bfd345 100644 --- a/engine/apps/oss_installation/views/cloud_users.py +++ b/engine/apps/oss_installation/views/cloud_users.py @@ -1,13 +1,18 @@ from urllib.parse import urljoin +from rest_framework import mixins, status, viewsets +from rest_framework.decorators import action from rest_framework.permissions import IsAuthenticated +from rest_framework.response import Response from rest_framework.views import APIView import apps.oss_installation.constants as cloud_constants -from apps.api.permissions import IsAdmin +from apps.api.permissions import ActionPermission, IsAdmin, IsOwnerOrAdmin from apps.auth_token.auth import PluginAuthentication from apps.oss_installation.models import CloudOrganizationConnector, CloudUserIdentity +from apps.oss_installation.serializers import CloudUserSerializer from apps.user_management.models import User +from common.api_helpers.mixins import PublicPrimaryKeyMixin from common.api_helpers.paginators import HundredPageSizePaginator @@ -16,22 +21,23 @@ class CloudUsersView(HundredPageSizePaginator, APIView): permission_classes = (IsAuthenticated, IsAdmin) def get(self, request): - queryset = User.objects.filter(organization=self.request.user.organization) + organization = request.user.organization - if self.request.user.current_team is not None: - queryset = queryset.filter(teams=self.request.user.current_team).distinct() + queryset = User.objects.filter(organization=organization) + + if request.user.current_team is not None: + queryset = queryset.filter(teams=request.user.current_team).distinct() results = self.paginate_queryset(queryset, request, view=self) emails = list(queryset.values_list("email", flat=True)) - cloud_identities = list( - CloudUserIdentity.objects.filter(organization=self.request.user.organization, email__in=emails) - ) + cloud_identities = list(CloudUserIdentity.objects.filter(organization=organization, email__in=emails)) cloud_identities = {cloud_identity.email: cloud_identity for cloud_identity in cloud_identities} response = [] - connector = CloudOrganizationConnector.objects.first() + connector = CloudOrganizationConnector.objects.filter(organization=organization) + for user in results: link = None status = cloud_constants.CLOUD_NOT_SYNCED @@ -57,3 +63,41 @@ class CloudUsersView(HundredPageSizePaginator, APIView): ) return self.get_paginated_response(response) + + def post(self, request): + organization = request.user.organization + + connector = CloudOrganizationConnector.objects.filter(organization=organization) + if connector is not None: + sync_status, err = connector.sync_users_with_cloud() + return Response(status=status.HTTP_200_OK, data={"status": sync_status, "error": err}) + else: + return Response(status=status.HTTP_400_BAD_REQUEST, data={"detail": "Grafana Cloud is not connected"}) + + +class CloudUserView( + PublicPrimaryKeyMixin, + mixins.RetrieveModelMixin, + viewsets.GenericViewSet, +): + authentication_classes = (PluginAuthentication,) + permission_classes = (IsAuthenticated, ActionPermission) + + action_object_permissions = { + IsOwnerOrAdmin: ("retrieve",), + } + serializer_class = CloudUserSerializer + + def get_queryset(self): + queryset = User.objects.filter(organization=self.request.user.organization) + return queryset + + @action(detail=True, methods=["post"]) + def sync_with_cloud(self, request, pk): + user = self.get_object() + connector = CloudOrganizationConnector.objects.filter(organization=request["request"].auth.organization).first() + if connector is not None: + sync_status, err = connector.sync_user_with_cloud(user) + return Response(status=status.HTTP_200_OK, data={"status": sync_status, "error": err}) + else: + return Response(status=status.HTTP_400_BAD_REQUEST, data={"detail": "Grafana Cloud is not connected"}) From 848a1d066ce71bd70932fec2cb922f6912cac3c6 Mon Sep 17 00:00:00 2001 From: Yulia Shanyrova Date: Mon, 6 Jun 2022 13:23:34 +0200 Subject: [PATCH 07/35] Cloud tab WIP --- grafana-plugin/src/GrafanaPluginRootPage.tsx | 2 + .../containers/UserSettings/UserSettings.tsx | 5 +- .../containers/UserSettings/parts/index.tsx | 13 +- .../CloudPhoneSettings.module.css | 3 + .../CloudPhoneSettings/CloudPhoneSettings.tsx | 83 ++++++ grafana-plugin/src/models/cloud/cloud.ts | 59 ++++ .../src/models/cloud/cloud.types.ts | 6 + grafana-plugin/src/models/user/user.types.ts | 2 + .../src/pages/cloud/CloudPage.module.css | 24 ++ grafana-plugin/src/pages/cloud/CloudPage.tsx | 260 ++++++++++++++++++ grafana-plugin/src/pages/index.ts | 7 + grafana-plugin/src/state/rootBaseStore.ts | 2 + grafana-plugin/src/vars.css | 4 + 13 files changed, 464 insertions(+), 6 deletions(-) create mode 100644 grafana-plugin/src/containers/UserSettings/parts/tabs/CloudPhoneSettings/CloudPhoneSettings.module.css create mode 100644 grafana-plugin/src/containers/UserSettings/parts/tabs/CloudPhoneSettings/CloudPhoneSettings.tsx create mode 100644 grafana-plugin/src/models/cloud/cloud.ts create mode 100644 grafana-plugin/src/models/cloud/cloud.types.ts create mode 100644 grafana-plugin/src/pages/cloud/CloudPage.module.css create mode 100644 grafana-plugin/src/pages/cloud/CloudPage.tsx diff --git a/grafana-plugin/src/GrafanaPluginRootPage.tsx b/grafana-plugin/src/GrafanaPluginRootPage.tsx index a3276a5f..aacc6f44 100644 --- a/grafana-plugin/src/GrafanaPluginRootPage.tsx +++ b/grafana-plugin/src/GrafanaPluginRootPage.tsx @@ -100,9 +100,11 @@ export const Root = observer((props: AppRootProps) => { const style = document.createElement('style'); document.head.appendChild(style); const index = style.sheet.insertRule('.page-body {max-width: unset !important}'); + const index2 = style.sheet.insertRule('.page-container {max-width: unset !important}'); return () => { style.sheet.removeRule(index); + style.sheet.removeRule(index2); }; }, []); diff --git a/grafana-plugin/src/containers/UserSettings/UserSettings.tsx b/grafana-plugin/src/containers/UserSettings/UserSettings.tsx index ca00871b..3ce67136 100644 --- a/grafana-plugin/src/containers/UserSettings/UserSettings.tsx +++ b/grafana-plugin/src/containers/UserSettings/UserSettings.tsx @@ -5,12 +5,12 @@ import cn from 'classnames/bind'; import { observer } from 'mobx-react'; import { useMediaQuery } from 'react-responsive'; -import { Tabs, TabsContent } from 'containers/UserSettings/parts'; import { User as UserType } from 'models/user/user.types'; import { AppFeature } from 'state/features'; import { useStore } from 'state/useStore'; import { UserSettingsTab } from './UserSettings.types'; +import { Tabs, TabsContent } from './parts'; import styles from './UserSettings.module.css'; @@ -58,7 +58,8 @@ const UserSettings = observer((props: UserFormProps) => { setActiveTab(tab); }, []); - const isModalWide = activeTab === UserSettingsTab.UserInfo && isDesktopOrLaptop; + const isModalWide = + (activeTab === UserSettingsTab.UserInfo && isDesktopOrLaptop) || activeTab === UserSettingsTab.PhoneVerification; const [showNotificationSettingsTab, showSlackConnectionTab, showTelegramConnectionTab, showMobileAppVerificationTab] = [ diff --git a/grafana-plugin/src/containers/UserSettings/parts/index.tsx b/grafana-plugin/src/containers/UserSettings/parts/index.tsx index 19e598ed..7f966bfc 100644 --- a/grafana-plugin/src/containers/UserSettings/parts/index.tsx +++ b/grafana-plugin/src/containers/UserSettings/parts/index.tsx @@ -1,4 +1,4 @@ -import React, { useCallback } from 'react'; +import React, { useCallback, useState } from 'react'; import { Tab, TabContent, TabsBar } from '@grafana/ui'; import cn from 'classnames/bind'; @@ -7,6 +7,7 @@ import Block from 'components/GBlock/Block'; import MobileAppVerification from 'containers/MobileAppVerification/MobileAppVerification'; import { UserSettingsTab } from 'containers/UserSettings/UserSettings.types'; import { SlackTab } from 'containers/UserSettings/parts/tabs//SlackTab/SlackTab'; +import CloudPhoneSettings from 'containers/UserSettings/parts/tabs/CloudPhoneSettings/CloudPhoneSettings'; import { NotificationSettingsTab } from 'containers/UserSettings/parts/tabs/NotificationSettingsTab'; import PhoneVerification from 'containers/UserSettings/parts/tabs/PhoneVerification/PhoneVerification'; import TelegramInfo from 'containers/UserSettings/parts/tabs/TelegramInfo/TelegramInfo'; @@ -105,6 +106,7 @@ export const TabsContent = (props: TabsContentProps) => { const store = useStore(); const { userStore } = store; + const [isPhoneEnabled, setIsPhoneEnabled] = useState(false); const storeUser = userStore.items[id]; @@ -124,9 +126,12 @@ export const TabsContent = (props: TabsContentProps) => { ))} {activeTab === UserSettingsTab.NotificationSettings && } - {activeTab === UserSettingsTab.PhoneVerification && ( - - )} + {activeTab === UserSettingsTab.PhoneVerification && + (isPhoneEnabled ? ( + + ) : ( + + ))} {activeTab === UserSettingsTab.MobileAppVerification && ( )} diff --git a/grafana-plugin/src/containers/UserSettings/parts/tabs/CloudPhoneSettings/CloudPhoneSettings.module.css b/grafana-plugin/src/containers/UserSettings/parts/tabs/CloudPhoneSettings/CloudPhoneSettings.module.css new file mode 100644 index 00000000..ab86c434 --- /dev/null +++ b/grafana-plugin/src/containers/UserSettings/parts/tabs/CloudPhoneSettings/CloudPhoneSettings.module.css @@ -0,0 +1,3 @@ +.test { + color: grey; +} diff --git a/grafana-plugin/src/containers/UserSettings/parts/tabs/CloudPhoneSettings/CloudPhoneSettings.tsx b/grafana-plugin/src/containers/UserSettings/parts/tabs/CloudPhoneSettings/CloudPhoneSettings.tsx new file mode 100644 index 00000000..08f94cd6 --- /dev/null +++ b/grafana-plugin/src/containers/UserSettings/parts/tabs/CloudPhoneSettings/CloudPhoneSettings.tsx @@ -0,0 +1,83 @@ +import React, { useCallback, useEffect, useState } from 'react'; + +import { getLocationSrv, LocationUpdate } from '@grafana/runtime'; +import { Field, Input, Button, Modal, HorizontalGroup, Alert, Icon, VerticalGroup, Table } from '@grafana/ui'; +import cn from 'classnames/bind'; + +import Block from 'components/GBlock/Block'; +import GTable from 'components/GTable/GTable'; +import PluginLink from 'components/PluginLink/PluginLink'; +import Text from 'components/Text/Text'; +import WithConfirm from 'components/WithConfirm/WithConfirm'; +import { User as UserType } from 'models/user/user.types'; +import { WithStoreProps } from 'state/types'; +import { withMobXProviderContext } from 'state/withStore'; + +import styles from './CloudPhoneSettings.module.css'; + +const cx = cn.bind(styles); + +interface CloudPhoneSettingsProps extends WithStoreProps {} + +const CloudPhoneSettings = (props: CloudPhoneSettingsProps) => { + const [isAccountMatched, setIsAccountMatched] = useState(true); + const [isPhoneVerified, setIsPhoneVerified] = useState(true); + + const signUpGrafanaCloud = () => { + console.log('Sign UP'); + }; + const handleLinkClick = (link: string) => { + getLocationSrv().update({ partial: false, path: link }); + }; + + return ( + + + OnCall use Grafana Cloud for SMS and phone call notifications + + + {isAccountMatched ? ( + isPhoneVerified ? ( + + + Your account successfully matched with the Grafana Cloud account. Please verify your phone number.{' '} + + + + ) : ( + + + Your account successfully matched with the Grafana Cloud account. Your phone number is verified. + + + + ) + ) : ( + + + {'We can’t find a matching account in the connected Grafana Cloud instance (matching happens by e-mail). '} + + + + )} + + ); +}; + +export default withMobXProviderContext(CloudPhoneSettings); diff --git a/grafana-plugin/src/models/cloud/cloud.ts b/grafana-plugin/src/models/cloud/cloud.ts new file mode 100644 index 00000000..d5c40049 --- /dev/null +++ b/grafana-plugin/src/models/cloud/cloud.ts @@ -0,0 +1,59 @@ +import { get } from 'lodash-es'; +import { action, computed, observable } from 'mobx'; + +import BaseStore from 'models/base_store'; +import { NotificationPolicyType } from 'models/notification_policy'; +import { makeRequest } from 'network'; +import { Mixpanel } from 'services/mixpanel'; +import { RootStore } from 'state'; +import { move } from 'state/helpers'; + +import { Cloud } from './cloud.types'; + +export class CloudStore extends BaseStore { + @observable.shallow + searchResult: { count?: number; results?: Array } = {}; + + @observable.shallow + items: { [id: string]: Cloud } = {}; + + constructor(rootStore: RootStore) { + super(rootStore); + + this.path = '/cloud_users/'; + } + + @action + async updateItems(f: any = { searchTerm: '' }, page = 1) { + const filters = typeof f === 'string' ? { searchTerm: f } : f; // for GSelect compatibility + const { searchTerm: search } = filters; + const { count, results } = await makeRequest(this.path, { + params: { search, page }, + }); + + this.items = { + ...this.items, + ...results.reduce( + (acc: { [key: number]: Cloud }, item: Cloud) => ({ + ...acc, + [item.id]: item, + }), + {} + ), + }; + + this.searchResult = { + count, + results: results.map((item: Cloud) => item.id), + }; + } + + getSearchResult() { + return { + count: this.searchResult.count, + results: + this.searchResult.results && + this.searchResult.results.map((cloud_user_id: Cloud['id']) => this.items?.[cloud_user_id]), + }; + } +} diff --git a/grafana-plugin/src/models/cloud/cloud.types.ts b/grafana-plugin/src/models/cloud/cloud.types.ts new file mode 100644 index 00000000..2aa411a1 --- /dev/null +++ b/grafana-plugin/src/models/cloud/cloud.types.ts @@ -0,0 +1,6 @@ +export interface Cloud { + id: string; + username: string; + cloud_sync_status?: number; + link?: string; +} diff --git a/grafana-plugin/src/models/user/user.types.ts b/grafana-plugin/src/models/user/user.types.ts index cb4e03bf..4dd3f00a 100644 --- a/grafana-plugin/src/models/user/user.types.ts +++ b/grafana-plugin/src/models/user/user.types.ts @@ -50,4 +50,6 @@ export interface User { permissions: UserAction[]; trigger_video_call?: boolean; export_url?: string; + status?: number; + link?: string; } diff --git a/grafana-plugin/src/pages/cloud/CloudPage.module.css b/grafana-plugin/src/pages/cloud/CloudPage.module.css new file mode 100644 index 00000000..387b4c57 --- /dev/null +++ b/grafana-plugin/src/pages/cloud/CloudPage.module.css @@ -0,0 +1,24 @@ +.info-block { + width: 70%; +} + +.warning-message { + color: var(--warning-text-color); +} + +.success-message { + color: var(--success-text-color); +} + +.error-message { + color: var(--error-text-color); +} + +.user-table { + margin-top: 24px; + width: 100%; +} + +.cloud-oncall-name { + color: #f55f3e; +} diff --git a/grafana-plugin/src/pages/cloud/CloudPage.tsx b/grafana-plugin/src/pages/cloud/CloudPage.tsx new file mode 100644 index 00000000..e92d849f --- /dev/null +++ b/grafana-plugin/src/pages/cloud/CloudPage.tsx @@ -0,0 +1,260 @@ +import React, { useCallback, useEffect, useState } from 'react'; + +import { getLocationSrv, LocationUpdate } from '@grafana/runtime'; +import { Field, Input, Button, Modal, HorizontalGroup, Alert, Icon, VerticalGroup, Table } from '@grafana/ui'; +import cn from 'classnames/bind'; + +import Block from 'components/GBlock/Block'; +import GTable from 'components/GTable/GTable'; +import PluginLink from 'components/PluginLink/PluginLink'; +import Text from 'components/Text/Text'; +import WithConfirm from 'components/WithConfirm/WithConfirm'; +import { HeartGreenIcon, HeartRedIcon } from 'icons'; +import { Cloud } from 'models/cloud/cloud.types'; +import { WithStoreProps } from 'state/types'; +import { useStore } from 'state/useStore'; +import { withMobXProviderContext } from 'state/withStore'; + +import styles from './CloudPage.module.css'; + +const cx = cn.bind(styles); + +interface CloudPageProps extends WithStoreProps {} + +const CloudPage = (props: CloudPageProps) => { + const store = useStore(); + const [cloudApiKey, setCloudApiKey] = useState(''); + const [cloudIsConnected, setCloudIsConnected] = useState(true); + const [showConfirmationModal, setShowConfirmationModal] = useState(false); + + useEffect(() => { + store.cloudStore.updateItems(); + }, []); + + const usersCount = 3; + const data = [ + { id: 'yshanyrova', username: 'y.shanyrova@grafana.com', cloud_sync_status: 2, link: '/test/abc' }, + { id: 'amixradmin', username: 'amixr-admin@grafana.com', cloud_sync_status: 1, link: '/test/qwerty' }, + { id: 'amixr', username: 'amixr@grafana.com', cloud_sync_status: undefined, link: undefined }, + ]; + + // const data = store.cloudStore.getSearchResult(); + const handleChangeCloudApiKey = useCallback((e) => { + setCloudApiKey(e.target.value); + }, []); + + const saveKeyAndConnect = () => { + setShowConfirmationModal(true); + }; + + const disconnectCloudOncall = () => { + console.log('disconnected'); + setCloudIsConnected(false); + }; + + const connectToCloud = () => { + console.log('CONNECT TO CLOUD'); + setCloudIsConnected(true); + setShowConfirmationModal(false); + }; + + const syncUsers = () => { + console.log('Sync Users'); + }; + + const handleLinkClick = (link: string) => { + getLocationSrv().update({ partial: false, path: link }); + }; + + const renderButtons = (user: Cloud) => { + switch (user.cloud_sync_status) { + case 0: + return null; + case 1: + return ( + + ); + case 2: + return ( + + ); + default: + return null; + } + }; + + const renderStatus = (user: Cloud) => { + switch (user.cloud_sync_status) { + case 0: + return User not found in the Grafana Cloud; + case 1: + return Phone number verified; + + case 2: + return Phone number is not verified in Grafana Cloud; + default: + return User not found in Grafana Cloud; + } + }; + + const renderStatusIcon = (user: Cloud) => { + switch (user.cloud_sync_status) { + case 0: + return ; + case 1: + return ; + + case 2: + return ; + default: + return ; + } + }; + + const renderEmail = (user: Cloud) => { + return {user.username}; + }; + + const columns = [ + { + width: '5%', + render: renderStatusIcon, + key: 'statusIcon', + }, + { + width: '30%', + render: renderEmail, + key: 'email', + }, + { + width: '35%', + render: renderStatus, + key: 'status', + }, + { + width: '30%', + render: renderButtons, + key: 'buttons', + align: 'actions', + }, + ]; + + return ( +
+ + + Connect Open Source OnCall and Cloud OnCall + + + {cloudIsConnected ? ( + + + Cloud OnCall API key + + Cloud OnCall is sucessfully connected. + + + + + + ) : ( + + + Cloud OnCall API key + + + + + + + )} + + + {showConfirmationModal && ( + setShowConfirmationModal(false)} + > + + + + + + )} + + + + + Monitor cloud instance with heartbeat + + + Once connected, current OnCall instance will send heartbeats every 3 minutes to the cloud Instance. If no + heartbeat will be received in 10 minutes, cloud instance will issue an alert. + + {cloudIsConnected && ( + + )} + + + + + + + SMS and phone call notifications + + {cloudIsConnected ? ( +
+ + { + 'Ask your users to sign up in Grafana Cloud, verify phone number and feel free to set up SMS & phone call notificaitons in personal settings!' + } + + + ( + + {`${usersCount} users matched between OSS and Cloud OnCall`} + + + )} + rowKey="id" + // @ts-ignore + columns={columns} + data={data} + /> +
+ ) : ( + Users matched between OSS and Cloud OnCall currently unavialable. + )} +
+
+
+
+ ); +}; + +export default withMobXProviderContext(CloudPage); diff --git a/grafana-plugin/src/pages/index.ts b/grafana-plugin/src/pages/index.ts index cd2c68a3..e7891eca 100644 --- a/grafana-plugin/src/pages/index.ts +++ b/grafana-plugin/src/pages/index.ts @@ -3,6 +3,7 @@ import React from 'react'; import { AppRootProps } from '@grafana/data'; import ChatOpsPage from 'pages/chat-ops/ChatOps'; +import CloudPage from 'pages/cloud/CloudPage'; import EscalationsChainsPage from 'pages/escalation-chains/EscalationChains'; import IncidentPage2 from 'pages/incident/Incident'; import IncidentsPage2 from 'pages/incidents/Incidents'; @@ -116,6 +117,12 @@ export const pages: PageDefinition[] = [ text: 'Migrate From Amixr.IO', hideFromTabs: true, }, + { + component: CloudPage, + icon: 'cloud', + id: 'cloud', + text: 'Cloud', + }, { component: Test, icon: 'cog', diff --git a/grafana-plugin/src/state/rootBaseStore.ts b/grafana-plugin/src/state/rootBaseStore.ts index 5900ab1f..331f6ca1 100644 --- a/grafana-plugin/src/state/rootBaseStore.ts +++ b/grafana-plugin/src/state/rootBaseStore.ts @@ -9,6 +9,7 @@ import { AlertReceiveChannel } from 'models/alert_receive_channel/alert_receive_ import { AlertReceiveChannelFiltersStore } from 'models/alert_receive_channel_filters/alert_receive_channel_filters'; import { AlertGroupStore } from 'models/alertgroup/alertgroup'; import { ApiTokenStore } from 'models/api_token/api_token'; +import { CloudStore } from 'models/cloud/cloud'; import { EscalationChainStore } from 'models/escalation_chain/escalation_chain'; import { EscalationPolicyStore } from 'models/escalation_policy/escalation_policy'; import { GlobalSettingStore } from 'models/global_setting/global_setting'; @@ -81,6 +82,7 @@ export class RootBaseStore { // -------------------------- userStore: UserStore = new UserStore(this); + cloudStore: CloudStore = new CloudStore(this); grafanaTeamStore: GrafanaTeamStore = new GrafanaTeamStore(this); alertReceiveChannelStore: AlertReceiveChannelStore = new AlertReceiveChannelStore(this); outgoingWebhookStore: OutgoingWebhookStore = new OutgoingWebhookStore(this); diff --git a/grafana-plugin/src/vars.css b/grafana-plugin/src/vars.css index a0af933b..0216e04c 100644 --- a/grafana-plugin/src/vars.css +++ b/grafana-plugin/src/vars.css @@ -22,6 +22,8 @@ --secondary-text-color: rgba(36, 41, 46, 0.75); --disabled-text-color: rgba(36, 41, 46, 0.5); --warning-text-color: #8a6c00; + --success-text-color: rgb(10, 118, 78); + --error-text-color: rgb(207, 14, 91); --primary-text-link: #1f62e0; --timeline-icon-background: rgba(70, 76, 84, 0); --timeline-icon-background-resolution-note: rgba(50, 116, 217, 0); @@ -38,6 +40,8 @@ --secondary-text-color: rgba(204, 204, 220, 0.65); --disabled-text-color: rgba(204, 204, 220, 0.4); --warning-text-color: #f8d06b; + --success-text-color: rgb(108, 207, 142); + --error-text-color: rgb(255, 82, 134); --primary-text-link: #6e9fff; --timeline-icon-background: rgba(70, 76, 84, 1); --timeline-icon-background-resolution-note: rgba(50, 116, 217, 1); From 829ed8230b8bade00f4d1648ac4276e5e24a4b0f Mon Sep 17 00:00:00 2001 From: Innokentii Konstantinov Date: Mon, 6 Jun 2022 16:02:09 +0400 Subject: [PATCH 08/35] Make CloudConnection instance wide --- engine/apps/api/views/live_setting.py | 10 +++++ engine/apps/base/models/live_setting.py | 1 + engine/apps/base/utils.py | 20 +++------ engine/apps/oss_installation/constants.py | 2 - .../apps/oss_installation/models/__init__.py | 4 +- ...zation_connector.py => cloud_connector.py} | 41 +++++++++++-------- .../{heartbeat.py => cloud_heartbeat.py} | 0 .../models/cloud_user_identity.py | 12 +++--- .../serializers/cloud_user.py | 6 +-- engine/apps/oss_installation/urls.py | 5 +-- engine/apps/oss_installation/utils.py | 36 ++++++++++++++-- .../apps/oss_installation/views/__init__.py | 4 +- .../views/cloud_connection.py | 35 ++++++++++++++++ .../views/cloud_heartbeat_status.py | 15 ------- .../oss_installation/views/cloud_status.py | 19 --------- .../oss_installation/views/cloud_users.py | 12 +++--- engine/engine/urls.py | 2 +- engine/settings/base.py | 2 +- 18 files changed, 128 insertions(+), 98 deletions(-) rename engine/apps/oss_installation/models/{cloud_organization_connector.py => cloud_connector.py} (83%) rename engine/apps/oss_installation/models/{heartbeat.py => cloud_heartbeat.py} (100%) create mode 100644 engine/apps/oss_installation/views/cloud_connection.py delete mode 100644 engine/apps/oss_installation/views/cloud_heartbeat_status.py delete mode 100644 engine/apps/oss_installation/views/cloud_status.py diff --git a/engine/apps/api/views/live_setting.py b/engine/apps/api/views/live_setting.py index 2ed6d723..4c9b7beb 100644 --- a/engine/apps/api/views/live_setting.py +++ b/engine/apps/api/views/live_setting.py @@ -12,6 +12,7 @@ from apps.api.serializers.live_setting import LiveSettingSerializer from apps.auth_token.auth import PluginAuthentication from apps.base.models import LiveSetting from apps.base.utils import live_settings +from apps.oss_installation.models import CloudConnector from apps.slack.tasks import unpopulate_slack_user_identities from apps.telegram.client import TelegramClient from apps.telegram.tasks import register_telegram_webhook @@ -66,6 +67,15 @@ class LiveSettingViewSet(PublicPrimaryKeyMixin, viewsets.ModelViewSet): if sti is not None: unpopulate_slack_user_identities.apply_async((sti.pk, True), countdown=0) + if instance.name == "GRAFANA_CLOUD_ONCALL_TOKEN": + try: + old_token = live_settings.GRAFANA_CLOUD_ONCALL_TOKEN + except ImproperlyConfigured: + old_token = None + + if old_token != new_value: + CloudConnector.remove_sync() + def _reset_telegram_integration(self, new_token): # tell Telegram to cancel sending events from old bot with suppress(ImproperlyConfigured, error.InvalidToken, error.Unauthorized): diff --git a/engine/apps/base/models/live_setting.py b/engine/apps/base/models/live_setting.py index 1c0b806a..ca3331de 100644 --- a/engine/apps/base/models/live_setting.py +++ b/engine/apps/base/models/live_setting.py @@ -173,4 +173,5 @@ class LiveSetting(models.Model): ) self.error = LiveSettingValidator(live_setting=self).get_error() + super().save(*args, **kwargs) diff --git a/engine/apps/base/utils.py b/engine/apps/base/utils.py index 0f9f04b7..a3b5a657 100644 --- a/engine/apps/base/utils.py +++ b/engine/apps/base/utils.py @@ -1,16 +1,15 @@ import json import re -from urllib.parse import urljoin -import requests.exceptions from django.apps import apps -from django.conf import settings from python_http_client import UnauthorizedError from sendgrid import SendGridAPIClient from telegram import Bot from twilio.base.exceptions import TwilioException from twilio.rest import Client +from apps.oss_installation.models import CloudConnector + class LiveSettingProxy: def __dir__(self): @@ -98,18 +97,9 @@ class LiveSettingValidator: return f"Telegram error: {str(e)}" @classmethod - def _check_grafana_cloud_oncall_token(cls, grafan_oncall_token): - try: - info_url = urljoin(settings.GRAFANA_CLOUD_ONCALL_API_URL, "api/v1/info/") - r = requests.get(info_url, headers={"AUTHORIZATION": grafan_oncall_token}, timeout=5) - if r.status_code == 200: - return - elif r.status_code == 403: - return f"Invalid token" - else: - return f"Non-200 HTTP code. Got {r.status_code}" - except requests.exceptions.RequestException as e: - return f"Error {str(e)}" + def _check_grafana_cloud_oncall_token(cls, grafana_oncall_token): + _, err = CloudConnector.sync_with_cloud(grafana_oncall_token) + return err @staticmethod def _is_email_valid(email): diff --git a/engine/apps/oss_installation/constants.py b/engine/apps/oss_installation/constants.py index db777bb3..11f3dc48 100644 --- a/engine/apps/oss_installation/constants.py +++ b/engine/apps/oss_installation/constants.py @@ -1,5 +1,3 @@ -CLOUD_URL = "https://a-prod-us-central-0.grafana.net/" - CLOUD_NOT_SYNCED = 0 CLOUD_SYNCED_USER_NOT_FOUND = 1 CLOUD_SYNCED_PHONE_NOT_VERIFIED = 2 diff --git a/engine/apps/oss_installation/models/__init__.py b/engine/apps/oss_installation/models/__init__.py index 2ee74128..beab1774 100644 --- a/engine/apps/oss_installation/models/__init__.py +++ b/engine/apps/oss_installation/models/__init__.py @@ -1,4 +1,4 @@ -from .cloud_organization_connector import CloudOrganizationConnector # noqa: F401 +from .cloud_connector import CloudConnector # noqa: F401 +from .cloud_heartbeat import CloudHeartbeat # noqa: F401 from .cloud_user_identity import CloudUserIdentity # noqa: F401 -from .heartbeat import CloudHeartbeat # noqa: F401 from .oss_installation import OssInstallation # noqa: F401 diff --git a/engine/apps/oss_installation/models/cloud_organization_connector.py b/engine/apps/oss_installation/models/cloud_connector.py similarity index 83% rename from engine/apps/oss_installation/models/cloud_organization_connector.py rename to engine/apps/oss_installation/models/cloud_connector.py index 126d9b65..1434b1ba 100644 --- a/engine/apps/oss_installation/models/cloud_organization_connector.py +++ b/engine/apps/oss_installation/models/cloud_connector.py @@ -5,50 +5,53 @@ import requests from django.db import models, transaction from apps.base.utils import live_settings -from apps.oss_installation.constants import CLOUD_URL +from apps.oss_installation.models import CloudHeartbeat from apps.oss_installation.models.cloud_user_identity import CloudUserIdentity from apps.user_management.models import User +from settings.base import GRAFANA_CLOUD_ONCALL_API_URL logger = logging.getLogger(__name__) -class CloudOrganizationConnector(models.Model): +class CloudConnector(models.Model): """ CloudOrganizationConnector model represents connection between oss organization and cloud organization. """ cloud_url = models.URLField() - organization = models.OneToOneField( - "user_management.organization", related_name="cloud_connector", on_delete=models.CASCADE - ) + # organization = models.OneToOneField( + # "user_management.organization", related_name="cloud_connector", on_delete=models.CASCADE + # ) @classmethod - def sync_with_cloud(cls, organization): + def sync_with_cloud(cls, token=None): """ sync_with_cloud sync organization with cloud organization defined by provided GRAFANA_CLOUD_ONCALL_TOKEN. """ sync_status = False error_msg = None - api_token = live_settings.GRAFANA_CLOUD_ONCALL_TOKEN + api_token = token or live_settings.GRAFANA_CLOUD_ONCALL_TOKEN if api_token is None: logger.warning("Unable to sync with cloud. GRAFANA_CLOUD_ONCALL_TOKEN is not set") error_msg = "GRAFANA_CLOUD_ONCALL_TOKEN is not set" else: - info_url = urljoin(CLOUD_URL, "api/v1/info/") + info_url = urljoin(GRAFANA_CLOUD_ONCALL_API_URL, "api/v1/info/") try: r = requests.get(info_url, headers={"AUTHORIZATION": api_token}, timeout=5) if r.status_code == 200: - cls.objects.update_or_create(organization=organization, defaults={"cloud_url": r.json()["url"]}) - sync_status = True + connector = cls.objects.get_or_create() + connector.cloud_url = r.json()["url"] + connector.save() elif r.status_code == 403: logger.warning("Unable to sync with cloud. GRAFANA_CLOUD_ONCALL_TOKEN is invalid") - error_msg = "GRAFANA_CLOUD_ONCALL_TOKEN is invalid" + error_msg = "Invalid token" else: error_msg = f"Non-200 HTTP code. Got {r.status_code}" except requests.exceptions.RequestException as e: logger.warning(f"Unable to sync with cloud. Request exception {str(e)}") error_msg = f"Unable to sync with cloud" + return sync_status, error_msg def sync_users_with_cloud(self) -> tuple[bool, str]: @@ -60,9 +63,9 @@ class CloudOrganizationConnector(models.Model): logger.warning("Unable to sync with cloud. GRAFANA_CLOUD_ONCALL_TOKEN is not set") error_msg = "GRAFANA_CLOUD_ONCALL_TOKEN is not set" - existing_emails = list(User.objects.filter(organization=self.organization).values_list("email", flat=True)) + existing_emails = list(User.objects.values_list("email", flat=True)) matching_users = [] - users_url = urljoin(CLOUD_URL, "api/v1/users") + users_url = urljoin(GRAFANA_CLOUD_ONCALL_API_URL, "api/v1/users") fetch_next_page = True users_fetched = True @@ -98,11 +101,10 @@ class CloudOrganizationConnector(models.Model): cloud_id=user["id"], email=user["email"], phone_number_verified=user["is_phone_number_verified"], - organization=self.organization, ) ) - CloudUserIdentity.objects.filter(organization=self.organization).delete() + CloudUserIdentity.objects.delete() CloudUserIdentity.objects.bulk_create(cloud_users_identities_to_create, batch_size=1000) return sync_status, error_msg @@ -116,7 +118,7 @@ class CloudOrganizationConnector(models.Model): logger.warning(f"Unable to sync_user_with cloud user_id {user.id}. GRAFANA_CLOUD_ONCALL_TOKEN is not set") error_msg = "GRAFANA_CLOUD_ONCALL_TOKEN is not set" else: - url = urljoin(CLOUD_URL, f"api/v1/users/?email={user.email}") + url = urljoin(GRAFANA_CLOUD_ONCALL_API_URL, f"api/v1/users/?email={user.email}") try: r = requests.get(url, headers={"AUTHORIZATION": api_token}, timeout=5) if r.status_code != 200: @@ -132,7 +134,6 @@ class CloudOrganizationConnector(models.Model): CloudUserIdentity.objects.filter(email=user.emai).delete() CloudUserIdentity.objects.create( email=user.email, - organization=user.organization, phone_number_verified=cloud_used_data["is_phone_number_verified"], cloud_id=cloud_used_data["id"], ) @@ -147,3 +148,9 @@ class CloudOrganizationConnector(models.Model): error_msg = f"Unable to sync with cloud" return sync_status, error_msg + + @classmethod + def remove_sync(cls): + cls.objects.delete() + CloudUserIdentity.objects.delete() + CloudHeartbeat.objects.delete() diff --git a/engine/apps/oss_installation/models/heartbeat.py b/engine/apps/oss_installation/models/cloud_heartbeat.py similarity index 100% rename from engine/apps/oss_installation/models/heartbeat.py rename to engine/apps/oss_installation/models/cloud_heartbeat.py diff --git a/engine/apps/oss_installation/models/cloud_user_identity.py b/engine/apps/oss_installation/models/cloud_user_identity.py index fca0eebe..1918ddcb 100644 --- a/engine/apps/oss_installation/models/cloud_user_identity.py +++ b/engine/apps/oss_installation/models/cloud_user_identity.py @@ -5,9 +5,9 @@ class CloudUserIdentity(models.Model): phone_number_verified = models.BooleanField(default=False) cloud_id = models.CharField(max_length=20) email = models.EmailField() - organization = models.ForeignKey( - "user_management.Organization", on_delete=models.CASCADE, related_name="cloud_users" - ) - - class Meta: - unique_together = ("email", "organization") + # organization = models.ForeignKey( + # "user_management.Organization", on_delete=models.CASCADE, related_name="cloud_users" + # ) + # + # class Meta: + # unique_together = ("email", "organization") diff --git a/engine/apps/oss_installation/serializers/cloud_user.py b/engine/apps/oss_installation/serializers/cloud_user.py index 50b857b4..d8e35791 100644 --- a/engine/apps/oss_installation/serializers/cloud_user.py +++ b/engine/apps/oss_installation/serializers/cloud_user.py @@ -3,7 +3,7 @@ from urllib.parse import urljoin from rest_framework import serializers import apps.oss_installation.constants as cloud_constants -from apps.oss_installation.models import CloudOrganizationConnector, CloudUserIdentity +from apps.oss_installation.models import CloudConnector, CloudUserIdentity from apps.user_management.models import User @@ -17,9 +17,7 @@ class CloudUserSerializer(serializers.ModelSerializer): def get_cloud_data(self, obj): link = None status = cloud_constants.CLOUD_NOT_SYNCED - connector = CloudOrganizationConnector.objects.filter( - organization=self.context["request"].auth.organization - ).first() + connector = CloudConnector.objects.filter().first() if connector is not None: cloud_user_identity = CloudUserIdentity.objects.filter(email=obj.email).first() if cloud_user_identity is None: diff --git a/engine/apps/oss_installation/urls.py b/engine/apps/oss_installation/urls.py index 2a1e6d5f..25708249 100644 --- a/engine/apps/oss_installation/urls.py +++ b/engine/apps/oss_installation/urls.py @@ -2,10 +2,9 @@ from django.urls import path from common.api_helpers.optional_slash_router import optional_slash_path -from .views import CloudConnectionStatusView, CloudHeartbeatStatusView, CloudUsersView, CloudUserView +from .views import CloudConnectionView, CloudUsersView, CloudUserView urlpatterns = [ - optional_slash_path("cloud_heartbeat_status", CloudHeartbeatStatusView.as_view(), name="cloud_heartbeat_status"), optional_slash_path("cloud_users", CloudUsersView.as_view(), name="cloud-users-list"), path( "cloud_users/", @@ -16,5 +15,5 @@ urlpatterns = [ ), name="cloud-user-detail", ), - optional_slash_path("cloud_connection_status", CloudConnectionStatusView.as_view(), name="cloud-connection-status"), + optional_slash_path("cloud_connection", CloudConnectionView.as_view(), name="cloud-connection-status"), ] diff --git a/engine/apps/oss_installation/utils.py b/engine/apps/oss_installation/utils.py index fcfb537c..c0ca366c 100644 --- a/engine/apps/oss_installation/utils.py +++ b/engine/apps/oss_installation/utils.py @@ -1,19 +1,27 @@ +import logging from contextlib import suppress +from urllib.parse import urljoin +import requests +from django.apps import apps from django.utils import timezone -from apps.alerts.models import AlertGroupLogRecord, EscalationPolicy -from apps.base.models import UserNotificationPolicyLogRecord from apps.public_api.constants import DEMO_USER_ID from apps.schedules.ical_utils import list_users_to_notify_from_ical_for_period -from apps.schedules.models import OnCallSchedule -from apps.user_management.models import User +from settings.base import GRAFANA_CLOUD_ONCALL_API_URL + +logger = logging.getLogger(__name__) def active_oss_users_count(): """ active_oss_users_count returns count of active users of oss installation. """ + OnCallSchedule = apps.get_model("schedules", "OnCallSchedule") + AlertGroupLogRecord = apps.get_model("alerts", "AlertGroupLogRecord") + EscalationPolicy = apps.get_model("alerts", "EscalationPolicy") + UserNotificationPolicyLogRecord = apps.get_model("base", "UserNotificationPolicyLogRecord") + User = apps.get_model("user_management", "User") # Take logs for previous 24 hours start = timezone.now() - timezone.timedelta(hours=24) @@ -68,3 +76,23 @@ def active_oss_users_count(): with suppress(KeyError): unique_active_users.remove(demo_user.pk) return len(unique_active_users) + + +def get_cloud_instance_info(api_token): + success = False + error_msg = None + r = None + info_url = urljoin(GRAFANA_CLOUD_ONCALL_API_URL, "api/v1/info/") + try: + r = requests.get(info_url, headers={"AUTHORIZATION": api_token}, timeout=5) + if r.status_code == 200: + success = True + elif r.status_code == 403: + logger.warning("Unable to sync with cloud. GRAFANA_CLOUD_ONCALL_TOKEN is invalid") + error_msg = "Invalid token" + else: + error_msg = f"Non-200 HTTP code. Got {r.status_code}" + except requests.exceptions.RequestException as e: + logger.warning(f"Unable to sync with cloud. Request exception {str(e)}") + error_msg = f"Unable to sync with cloud" + return success, error_msg, r diff --git a/engine/apps/oss_installation/views/__init__.py b/engine/apps/oss_installation/views/__init__.py index 66a3be93..9cbe8980 100644 --- a/engine/apps/oss_installation/views/__init__.py +++ b/engine/apps/oss_installation/views/__init__.py @@ -1,3 +1,3 @@ -from .cloud_heartbeat_status import CloudHeartbeatStatusView # noqa: F401 -from .cloud_status import CloudConnectionStatusView # noqa: F401 +from .cloud_connection import CloudConnectionView # noqa: F401 +from .cloud_heartbeat import CloudHeartbeatStatusView # noqa: F401 from .cloud_users import CloudUsersView, CloudUserView # noqa: F401 diff --git a/engine/apps/oss_installation/views/cloud_connection.py b/engine/apps/oss_installation/views/cloud_connection.py new file mode 100644 index 00000000..cf8e4713 --- /dev/null +++ b/engine/apps/oss_installation/views/cloud_connection.py @@ -0,0 +1,35 @@ +from urllib.parse import urljoin + +from rest_framework.permissions import IsAuthenticated +from rest_framework.response import Response +from rest_framework.views import APIView + +from apps.api.permissions import IsAdmin +from apps.auth_token.auth import PluginAuthentication +from apps.base.utils import live_settings +from apps.oss_installation.models import CloudConnector, CloudHeartbeat + + +class CloudConnectionView(APIView): + authentication_classes = (PluginAuthentication,) + permission_classes = (IsAuthenticated, IsAdmin) + + def get(self, request): + connector = CloudConnector.objects.first() + heartbeat = CloudHeartbeat.objects.first() + response = { + "cloud_connection_status": connector is not None, + "token": live_settings.GRAFANA_CLOUD_ONCALL_TOKEN, + "cloud_notifications_enabled": live_settings.GRAFANA_CLOUD_NOTIFICATIONS_ENABLED, + "cloud_heartbeat_enabled": live_settings.GRAFANA_CLOUD_ONCALL_HEARTBEAT_ENABLED, + "cloud_heartbeat_link": self._get_heartbeat_link(connector, heartbeat), + "cloud_heartbeat_status": heartbeat is not None and heartbeat.success, + } + return Response(response) + + def _get_heartbeat_link(self, connector, heartbeat): + if connector is None: + return None + if heartbeat is None: + return None + return urljoin(connector.cloud_url, f"a/grafana-oncall-app/?page=integrations1&id={heartbeat.integration_id}") diff --git a/engine/apps/oss_installation/views/cloud_heartbeat_status.py b/engine/apps/oss_installation/views/cloud_heartbeat_status.py deleted file mode 100644 index be553641..00000000 --- a/engine/apps/oss_installation/views/cloud_heartbeat_status.py +++ /dev/null @@ -1,15 +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 -from apps.oss_installation.models import CloudHeartbeat - - -class CloudHeartbeatStatusView(APIView): - authentication_classes = (PluginAuthentication,) - permission_classes = (IsAuthenticated,) - - def get(self, request): - response = {"status": CloudHeartbeat.status()} - return Response(response) diff --git a/engine/apps/oss_installation/views/cloud_status.py b/engine/apps/oss_installation/views/cloud_status.py deleted file mode 100644 index 825fa757..00000000 --- a/engine/apps/oss_installation/views/cloud_status.py +++ /dev/null @@ -1,19 +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 -from apps.oss_installation.models import CloudOrganizationConnector - - -class CloudConnectionStatusView(APIView): - authentication_classes = (PluginAuthentication,) - permission_classes = (IsAuthenticated,) - - def get(self, request): - connector = CloudOrganizationConnector.objects.filter(organization=request.user.organization).first() - - response = { - "cloud_connection_status": connector is not None, - } - return Response(response) diff --git a/engine/apps/oss_installation/views/cloud_users.py b/engine/apps/oss_installation/views/cloud_users.py index d4bfd345..ab28c677 100644 --- a/engine/apps/oss_installation/views/cloud_users.py +++ b/engine/apps/oss_installation/views/cloud_users.py @@ -9,7 +9,7 @@ from rest_framework.views import APIView import apps.oss_installation.constants as cloud_constants from apps.api.permissions import ActionPermission, IsAdmin, IsOwnerOrAdmin from apps.auth_token.auth import PluginAuthentication -from apps.oss_installation.models import CloudOrganizationConnector, CloudUserIdentity +from apps.oss_installation.models import CloudConnector, CloudUserIdentity from apps.oss_installation.serializers import CloudUserSerializer from apps.user_management.models import User from common.api_helpers.mixins import PublicPrimaryKeyMixin @@ -31,12 +31,12 @@ class CloudUsersView(HundredPageSizePaginator, APIView): results = self.paginate_queryset(queryset, request, view=self) emails = list(queryset.values_list("email", flat=True)) - cloud_identities = list(CloudUserIdentity.objects.filter(organization=organization, email__in=emails)) + cloud_identities = list(CloudUserIdentity.objects.filter(email__in=emails)) cloud_identities = {cloud_identity.email: cloud_identity for cloud_identity in cloud_identities} response = [] - connector = CloudOrganizationConnector.objects.filter(organization=organization) + connector = CloudConnector.objects.first() for user in results: link = None @@ -65,9 +65,7 @@ class CloudUsersView(HundredPageSizePaginator, APIView): return self.get_paginated_response(response) def post(self, request): - organization = request.user.organization - - connector = CloudOrganizationConnector.objects.filter(organization=organization) + connector = CloudConnector.objects.first() if connector is not None: sync_status, err = connector.sync_users_with_cloud() return Response(status=status.HTTP_200_OK, data={"status": sync_status, "error": err}) @@ -95,7 +93,7 @@ class CloudUserView( @action(detail=True, methods=["post"]) def sync_with_cloud(self, request, pk): user = self.get_object() - connector = CloudOrganizationConnector.objects.filter(organization=request["request"].auth.organization).first() + connector = CloudConnector.objects.first() if connector is not None: sync_status, err = connector.sync_user_with_cloud(user) return Response(status=status.HTTP_200_OK, data={"status": sync_status, "error": err}) diff --git a/engine/engine/urls.py b/engine/engine/urls.py index 9e55241a..702e2907 100644 --- a/engine/engine/urls.py +++ b/engine/engine/urls.py @@ -54,7 +54,7 @@ if settings.FEATURE_SLACK_INTEGRATION_ENABLED: path("slack/", include("apps.slack.urls")), ] -if settings.OSS_INSTALLATION_FEATURES_ENABLED: +if settings.OSS_INSTALLATION_FEATURES_ENABLED or True: urlpatterns += [ path("api/internal/v1/", include("apps.oss_installation.urls")), ] diff --git a/engine/settings/base.py b/engine/settings/base.py index 9bb227f9..281a8a86 100644 --- a/engine/settings/base.py +++ b/engine/settings/base.py @@ -409,7 +409,7 @@ SELF_HOSTED_SETTINGS = { "ORG_TITLE": "Self-Hosted Organization", } -GRAFANA_CLOUD_ONCALL_API_URL = os.environ.get("GRAFANA_CLOUD_ONCALL_API_URL", "https://a-prod-us-central-0.grafana.net") +GRAFANA_CLOUD_ONCALL_API_URL = "https://a-02-dev-us-central-0.grafana.net/" GRAFANA_CLOUD_ONCALL_TOKEN = os.environ.get("GRAFANA_CLOUD_ONCALL_TOKEN", None) GRAFANA_CLOUD_ONCALL_HEARTBEAT_ENABLED = getenv_boolean("GRAFANA_CLOUD_ONCALL_HEARTBEAT_ENABLED", default=True) GRAFANA_CLOUD_NOTIFICATIONS_ENABLED = getenv_boolean("GRAFANA_CLOUD_NOTIFICATIONS_ENABLED", default=True) From 874824017289e8c38f3cedc52534e21d9622f224 Mon Sep 17 00:00:00 2001 From: Innokentii Konstantinov Date: Mon, 6 Jun 2022 16:20:05 +0400 Subject: [PATCH 09/35] Remove token from cloud_connection view --- engine/apps/oss_installation/views/cloud_connection.py | 1 - 1 file changed, 1 deletion(-) diff --git a/engine/apps/oss_installation/views/cloud_connection.py b/engine/apps/oss_installation/views/cloud_connection.py index cf8e4713..5fe2ba47 100644 --- a/engine/apps/oss_installation/views/cloud_connection.py +++ b/engine/apps/oss_installation/views/cloud_connection.py @@ -19,7 +19,6 @@ class CloudConnectionView(APIView): heartbeat = CloudHeartbeat.objects.first() response = { "cloud_connection_status": connector is not None, - "token": live_settings.GRAFANA_CLOUD_ONCALL_TOKEN, "cloud_notifications_enabled": live_settings.GRAFANA_CLOUD_NOTIFICATIONS_ENABLED, "cloud_heartbeat_enabled": live_settings.GRAFANA_CLOUD_ONCALL_HEARTBEAT_ENABLED, "cloud_heartbeat_link": self._get_heartbeat_link(connector, heartbeat), From ae0845d6a7adb30305ff3ab746e033ab2564dfd2 Mon Sep 17 00:00:00 2001 From: Innokentii Konstantinov Date: Mon, 6 Jun 2022 16:36:49 +0400 Subject: [PATCH 10/35] Add migration --- engine/apps/base/utils.py | 4 ++-- .../migrations/0001_squashed_initial.py | 16 ++++++++++++++++ .../oss_installation/models/cloud_connector.py | 15 ++++++++------- engine/apps/oss_installation/views/__init__.py | 1 - 4 files changed, 26 insertions(+), 10 deletions(-) diff --git a/engine/apps/base/utils.py b/engine/apps/base/utils.py index a3b5a657..8339e295 100644 --- a/engine/apps/base/utils.py +++ b/engine/apps/base/utils.py @@ -8,8 +8,6 @@ from telegram import Bot from twilio.base.exceptions import TwilioException from twilio.rest import Client -from apps.oss_installation.models import CloudConnector - class LiveSettingProxy: def __dir__(self): @@ -98,6 +96,8 @@ class LiveSettingValidator: @classmethod def _check_grafana_cloud_oncall_token(cls, grafana_oncall_token): + from apps.oss_installation.models import CloudConnector + _, err = CloudConnector.sync_with_cloud(grafana_oncall_token) return err diff --git a/engine/apps/oss_installation/migrations/0001_squashed_initial.py b/engine/apps/oss_installation/migrations/0001_squashed_initial.py index dac55f47..b1a34cbd 100644 --- a/engine/apps/oss_installation/migrations/0001_squashed_initial.py +++ b/engine/apps/oss_installation/migrations/0001_squashed_initial.py @@ -30,4 +30,20 @@ class Migration(migrations.Migration): ('report_sent_at', models.DateTimeField(default=None, null=True)), ], ), + migrations.CreateModel( + name='CloudConnector', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('cloud_url', models.URLField()), + ], + ), + migrations.CreateModel( + name='CloudUserIdentity', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('phone_number_verified', models.BooleanField(default=False)), + ('cloud_id', models.CharField(max_length=20)), + ('email', models.EmailField(max_length=254)), + ], + ), ] diff --git a/engine/apps/oss_installation/models/cloud_connector.py b/engine/apps/oss_installation/models/cloud_connector.py index 1434b1ba..39edc18c 100644 --- a/engine/apps/oss_installation/models/cloud_connector.py +++ b/engine/apps/oss_installation/models/cloud_connector.py @@ -5,7 +5,6 @@ import requests from django.db import models, transaction from apps.base.utils import live_settings -from apps.oss_installation.models import CloudHeartbeat from apps.oss_installation.models.cloud_user_identity import CloudUserIdentity from apps.user_management.models import User from settings.base import GRAFANA_CLOUD_ONCALL_API_URL @@ -40,7 +39,7 @@ class CloudConnector(models.Model): try: r = requests.get(info_url, headers={"AUTHORIZATION": api_token}, timeout=5) if r.status_code == 200: - connector = cls.objects.get_or_create() + connector, _ = cls.objects.get_or_create() connector.cloud_url = r.json()["url"] connector.save() elif r.status_code == 403: @@ -104,9 +103,9 @@ class CloudConnector(models.Model): ) ) - CloudUserIdentity.objects.delete() + CloudUserIdentity.objects.all().delete() CloudUserIdentity.objects.bulk_create(cloud_users_identities_to_create, batch_size=1000) - + sync_status = True return sync_status, error_msg def sync_user_with_cloud(self, user): @@ -151,6 +150,8 @@ class CloudConnector(models.Model): @classmethod def remove_sync(cls): - cls.objects.delete() - CloudUserIdentity.objects.delete() - CloudHeartbeat.objects.delete() + from apps.oss_installation.models import CloudHeartbeat + + cls.objects.all().delete() + CloudUserIdentity.objects.all().delete() + CloudHeartbeat.objects.all().delete() diff --git a/engine/apps/oss_installation/views/__init__.py b/engine/apps/oss_installation/views/__init__.py index 9cbe8980..2b206cac 100644 --- a/engine/apps/oss_installation/views/__init__.py +++ b/engine/apps/oss_installation/views/__init__.py @@ -1,3 +1,2 @@ from .cloud_connection import CloudConnectionView # noqa: F401 -from .cloud_heartbeat import CloudHeartbeatStatusView # noqa: F401 from .cloud_users import CloudUsersView, CloudUserView # noqa: F401 From 5d18e636a2fb2dafe2d4be4fddebe4fd59ea6b13 Mon Sep 17 00:00:00 2001 From: Innokentii Konstantinov Date: Mon, 6 Jun 2022 18:08:32 +0400 Subject: [PATCH 11/35] Fix live_settings search --- engine/apps/api/views/live_setting.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/engine/apps/api/views/live_setting.py b/engine/apps/api/views/live_setting.py index 4c9b7beb..bd0fb4fd 100644 --- a/engine/apps/api/views/live_setting.py +++ b/engine/apps/api/views/live_setting.py @@ -33,7 +33,11 @@ class LiveSettingViewSet(PublicPrimaryKeyMixin, viewsets.ModelViewSet): def get_queryset(self): LiveSetting.populate_settings_if_needed() - return LiveSetting.objects.filter(name__in=LiveSetting.AVAILABLE_NAMES).order_by("name") + queryset = LiveSetting.objects.filter(name__in=LiveSetting.AVAILABLE_NAMES).order_by("name") + search = self.request.query_params.get("search", None) + if search: + queryset = queryset.filter(name=search) + return queryset def perform_update(self, serializer): new_value = serializer.validated_data["value"] From 5bd16f051b95601c796c072ef87325fca0085bab Mon Sep 17 00:00:00 2001 From: Yulia Shanyrova Date: Mon, 6 Jun 2022 17:00:32 +0200 Subject: [PATCH 12/35] endpoints WIP --- grafana-plugin/src/models/cloud/cloud.ts | 18 ++++++++++++++++++ .../src/pages/cloud/CloudPage.module.css | 4 ++++ grafana-plugin/src/pages/cloud/CloudPage.tsx | 5 +++-- 3 files changed, 25 insertions(+), 2 deletions(-) diff --git a/grafana-plugin/src/models/cloud/cloud.ts b/grafana-plugin/src/models/cloud/cloud.ts index d5c40049..f8e09e71 100644 --- a/grafana-plugin/src/models/cloud/cloud.ts +++ b/grafana-plugin/src/models/cloud/cloud.ts @@ -56,4 +56,22 @@ export class CloudStore extends BaseStore { this.searchResult.results.map((cloud_user_id: Cloud['id']) => this.items?.[cloud_user_id]), }; } + + async syncCloudUsers() { + return await makeRequest(`${this.path}sync_with_cloud`, { method: 'POST' }); + } + + async getCloudConnectionStatus() { + return await makeRequest(`/cloud_connection/`, { method: 'GET' }); + } + + @action + async connectToCloud(token: string) { + return await makeRequest(`/live_settings/`, { method: 'PUT', params: { token } }); + } + + @action + async disconnectToCloud() { + return await makeRequest(`/live_settings/`, { method: 'DELETE' }); + } } diff --git a/grafana-plugin/src/pages/cloud/CloudPage.module.css b/grafana-plugin/src/pages/cloud/CloudPage.module.css index 387b4c57..9597b6ab 100644 --- a/grafana-plugin/src/pages/cloud/CloudPage.module.css +++ b/grafana-plugin/src/pages/cloud/CloudPage.module.css @@ -19,6 +19,10 @@ width: 100%; } +.cloud-page-title { + margin-top: 24px; +} + .cloud-oncall-name { color: #f55f3e; } diff --git a/grafana-plugin/src/pages/cloud/CloudPage.tsx b/grafana-plugin/src/pages/cloud/CloudPage.tsx index e92d849f..21be94f3 100644 --- a/grafana-plugin/src/pages/cloud/CloudPage.tsx +++ b/grafana-plugin/src/pages/cloud/CloudPage.tsx @@ -50,12 +50,13 @@ const CloudPage = (props: CloudPageProps) => { const disconnectCloudOncall = () => { console.log('disconnected'); setCloudIsConnected(false); + store.cloudStore.disconnectToCloud(); }; const connectToCloud = () => { - console.log('CONNECT TO CLOUD'); setCloudIsConnected(true); setShowConfirmationModal(false); + store.cloudStore.connectToCloud(cloudApiKey); }; const syncUsers = () => { @@ -146,7 +147,7 @@ const CloudPage = (props: CloudPageProps) => { return (
- + Connect Open Source OnCall and Cloud OnCall From 5a0150192ee71e22811144f99bdb7c557fd52177 Mon Sep 17 00:00:00 2001 From: Innokentii Konstantinov Date: Mon, 6 Jun 2022 19:05:50 +0400 Subject: [PATCH 13/35] Push migration --- .../migrations/0002_auto_20220604_1008.py | 23 +++++++++++++++++++ 1 file changed, 23 insertions(+) create mode 100644 engine/apps/twilioapp/migrations/0002_auto_20220604_1008.py diff --git a/engine/apps/twilioapp/migrations/0002_auto_20220604_1008.py b/engine/apps/twilioapp/migrations/0002_auto_20220604_1008.py new file mode 100644 index 00000000..cddd898c --- /dev/null +++ b/engine/apps/twilioapp/migrations/0002_auto_20220604_1008.py @@ -0,0 +1,23 @@ +# Generated by Django 3.2.5 on 2022-06-04 10:08 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('twilioapp', '0001_squashed_initial'), + ] + + operations = [ + migrations.AddField( + model_name='phonecall', + name='grafana_cloud_notification', + field=models.BooleanField(default=False), + ), + migrations.AddField( + model_name='smsmessage', + name='grafana_cloud_notification', + field=models.BooleanField(default=False), + ), + ] From 7d0f8d4177f27a1b0ff3df74e5f441bb86ee8d41 Mon Sep 17 00:00:00 2001 From: Yulia Shanyrova Date: Tue, 7 Jun 2022 11:23:41 +0200 Subject: [PATCH 14/35] endpoints WIP --- .../CloudPhoneSettings/CloudPhoneSettings.tsx | 8 +- grafana-plugin/src/icons/cross-circled.svg | 8 ++ grafana-plugin/src/icons/heart-line.svg | 24 +++++ grafana-plugin/src/icons/index.tsx | 36 +++++++ grafana-plugin/src/models/cloud/cloud.ts | 21 ++--- .../src/models/cloud/cloud.types.ts | 7 +- .../src/pages/cloud/CloudPage.module.css | 22 +++++ grafana-plugin/src/pages/cloud/CloudPage.tsx | 94 +++++++++++++------ 8 files changed, 174 insertions(+), 46 deletions(-) create mode 100644 grafana-plugin/src/icons/cross-circled.svg create mode 100644 grafana-plugin/src/icons/heart-line.svg diff --git a/grafana-plugin/src/containers/UserSettings/parts/tabs/CloudPhoneSettings/CloudPhoneSettings.tsx b/grafana-plugin/src/containers/UserSettings/parts/tabs/CloudPhoneSettings/CloudPhoneSettings.tsx index 08f94cd6..dd833057 100644 --- a/grafana-plugin/src/containers/UserSettings/parts/tabs/CloudPhoneSettings/CloudPhoneSettings.tsx +++ b/grafana-plugin/src/containers/UserSettings/parts/tabs/CloudPhoneSettings/CloudPhoneSettings.tsx @@ -11,6 +11,7 @@ import Text from 'components/Text/Text'; import WithConfirm from 'components/WithConfirm/WithConfirm'; import { User as UserType } from 'models/user/user.types'; import { WithStoreProps } from 'state/types'; +import { useStore } from 'state/useStore'; import { withMobXProviderContext } from 'state/withStore'; import styles from './CloudPhoneSettings.module.css'; @@ -20,21 +21,22 @@ const cx = cn.bind(styles); interface CloudPhoneSettingsProps extends WithStoreProps {} const CloudPhoneSettings = (props: CloudPhoneSettingsProps) => { + const store = useStore(); const [isAccountMatched, setIsAccountMatched] = useState(true); const [isPhoneVerified, setIsPhoneVerified] = useState(true); const signUpGrafanaCloud = () => { console.log('Sign UP'); }; - const handleLinkClick = (link: string) => { - getLocationSrv().update({ partial: false, path: link }); + const handleLinkClick = () => { + store.cloudStore.syncCloudUser(store.userStore.currentUserPk); }; return ( OnCall use Grafana Cloud for SMS and phone call notifications - diff --git a/grafana-plugin/src/icons/cross-circled.svg b/grafana-plugin/src/icons/cross-circled.svg new file mode 100644 index 00000000..f468d638 --- /dev/null +++ b/grafana-plugin/src/icons/cross-circled.svg @@ -0,0 +1,8 @@ + + + diff --git a/grafana-plugin/src/icons/heart-line.svg b/grafana-plugin/src/icons/heart-line.svg new file mode 100644 index 00000000..6c063e81 --- /dev/null +++ b/grafana-plugin/src/icons/heart-line.svg @@ -0,0 +1,24 @@ + + + + + + + diff --git a/grafana-plugin/src/icons/index.tsx b/grafana-plugin/src/icons/index.tsx index fc1b0d3a..7b77d8f6 100644 --- a/grafana-plugin/src/icons/index.tsx +++ b/grafana-plugin/src/icons/index.tsx @@ -168,6 +168,42 @@ export const HeartRedIcon = (props: IconProps) => ( ); +export const HeartIcon = (props: IconProps) => ( + + + + + +); + +export const CrossCircleIcon = (props: IconProps) => ( + + + +); + export const GrafanaIcon = (props: IconProps) => ( this.items?.[cloud_user_id]), + results: this.searchResult.results && this.searchResult.results.map((id: Cloud['id']) => this.items?.[id]), }; } async syncCloudUsers() { - return await makeRequest(`${this.path}sync_with_cloud`, { method: 'POST' }); + return await makeRequest(`${this.path}`, { method: 'POST' }); + } + + async syncCloudUser(id: string) { + return await makeRequest(`${this.path}${id}/sync_with_cloud/`, { method: 'POST' }); } async getCloudConnectionStatus() { @@ -66,9 +67,7 @@ export class CloudStore extends BaseStore { } @action - async connectToCloud(token: string) { - return await makeRequest(`/live_settings/`, { method: 'PUT', params: { token } }); - } + async connectToCloud(token: string) {} @action async disconnectToCloud() { diff --git a/grafana-plugin/src/models/cloud/cloud.types.ts b/grafana-plugin/src/models/cloud/cloud.types.ts index 2aa411a1..15658b3d 100644 --- a/grafana-plugin/src/models/cloud/cloud.types.ts +++ b/grafana-plugin/src/models/cloud/cloud.types.ts @@ -1,6 +1,9 @@ export interface Cloud { id: string; username: string; - cloud_sync_status?: number; - link?: string; + email: string; + cloud_data?: { + status?: number; + link?: string; + }; } diff --git a/grafana-plugin/src/pages/cloud/CloudPage.module.css b/grafana-plugin/src/pages/cloud/CloudPage.module.css index 9597b6ab..ba98f153 100644 --- a/grafana-plugin/src/pages/cloud/CloudPage.module.css +++ b/grafana-plugin/src/pages/cloud/CloudPage.module.css @@ -1,5 +1,7 @@ .info-block { width: 70%; + min-width: 1100px; + padding: 24px; } .warning-message { @@ -19,6 +21,10 @@ width: 100%; } +.user-row { + height: 32px; +} + .cloud-page-title { margin-top: 24px; } @@ -26,3 +32,19 @@ .cloud-oncall-name { color: #f55f3e; } + +.block-icon { + color: var(--secondary-text-color); +} + +.block-button { + margin-top: 24px; +} + +.table-title { + margin-bottom: 16px; +} + +.table-button { + float: right; +} diff --git a/grafana-plugin/src/pages/cloud/CloudPage.tsx b/grafana-plugin/src/pages/cloud/CloudPage.tsx index 21be94f3..5aa1b8fa 100644 --- a/grafana-plugin/src/pages/cloud/CloudPage.tsx +++ b/grafana-plugin/src/pages/cloud/CloudPage.tsx @@ -9,7 +9,7 @@ import GTable from 'components/GTable/GTable'; import PluginLink from 'components/PluginLink/PluginLink'; import Text from 'components/Text/Text'; import WithConfirm from 'components/WithConfirm/WithConfirm'; -import { HeartGreenIcon, HeartRedIcon } from 'icons'; +import { CrossCircleIcon, HeartIcon } from 'icons'; import { Cloud } from 'models/cloud/cloud.types'; import { WithStoreProps } from 'state/types'; import { useStore } from 'state/useStore'; @@ -29,16 +29,19 @@ const CloudPage = (props: CloudPageProps) => { useEffect(() => { store.cloudStore.updateItems(); + store.cloudStore.getCloudConnectionStatus().then((cloudStatus) => { + setCloudIsConnected(cloudStatus.cloud_connection_status); + }); }, []); - const usersCount = 3; const data = [ - { id: 'yshanyrova', username: 'y.shanyrova@grafana.com', cloud_sync_status: 2, link: '/test/abc' }, - { id: 'amixradmin', username: 'amixr-admin@grafana.com', cloud_sync_status: 1, link: '/test/qwerty' }, - { id: 'amixr', username: 'amixr@grafana.com', cloud_sync_status: undefined, link: undefined }, + { id: 'yshanyrova', email: 'y.shanyrova@grafana.com', cloud_data: { status: 2, link: '/test/abc' } }, + { id: 'amixradmin', email: 'amixr-admin@grafana.com', cloud_data: { status: 1, link: '/test/abc' } }, + { id: 'amixr', email: 'amixr@grafana.com', cloud_data: { status: undefined, link: '/test/abc' } }, ]; - // const data = store.cloudStore.getSearchResult(); + // const { count, results } = store.cloudStore.getSearchResult(); + const handleChangeCloudApiKey = useCallback((e) => { setCloudApiKey(e.target.value); }, []); @@ -56,11 +59,12 @@ const CloudPage = (props: CloudPageProps) => { const connectToCloud = () => { setCloudIsConnected(true); setShowConfirmationModal(false); + // store.cloudStore.update('') store.cloudStore.connectToCloud(cloudApiKey); }; const syncUsers = () => { - console.log('Sync Users'); + store.cloudStore.syncCloudUsers(); }; const handleLinkClick = (link: string) => { @@ -68,18 +72,30 @@ const CloudPage = (props: CloudPageProps) => { }; const renderButtons = (user: Cloud) => { - switch (user.cloud_sync_status) { + switch (user?.cloud_data?.status) { case 0: return null; case 1: return ( - + + + ); case 1: return ; case 2: return ; default: - return ; + return ( + + + + ); } }; const renderEmail = (user: Cloud) => { - return {user.username}; + return {user.email}; }; const columns = [ { - width: '5%', + width: '2%', render: renderStatusIcon, key: 'statusIcon', }, { - width: '30%', + width: '28%', render: renderEmail, key: 'email', }, { - width: '35%', + width: '50%', render: renderStatus, key: 'status', }, { - width: '30%', + width: '20%', render: renderButtons, key: 'buttons', align: 'actions', @@ -154,12 +178,12 @@ const CloudPage = (props: CloudPageProps) => { {cloudIsConnected ? ( - Cloud OnCall API key + Cloud OnCall API key Cloud OnCall is sucessfully connected. - @@ -167,7 +191,7 @@ const CloudPage = (props: CloudPageProps) => { ) : ( - Cloud OnCall API key + Cloud OnCall API key @@ -199,7 +223,10 @@ const CloudPage = (props: CloudPageProps) => { - Monitor cloud instance with heartbeat + + + {' '} + Monitor cloud instance with heartbeat Once connected, current OnCall instance will send heartbeats every 3 minutes to the cloud Instance. If no @@ -209,6 +236,7 @@ const CloudPage = (props: CloudPageProps) => { - +
+ + + {/* {count ? count : 0} */} + {`3 users matched between OSS and Cloud OnCall`} + + + +
)} rowKey="id" // @ts-ignore From 308e59c76985a4a553837e8830dfdad8b38e0404 Mon Sep 17 00:00:00 2001 From: Innokentii Konstantinov Date: Wed, 8 Jun 2022 12:07:30 +0400 Subject: [PATCH 15/35] Add disconnect cloud endpoint --- engine/apps/oss_installation/views/cloud_connection.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/engine/apps/oss_installation/views/cloud_connection.py b/engine/apps/oss_installation/views/cloud_connection.py index 5fe2ba47..6acbef57 100644 --- a/engine/apps/oss_installation/views/cloud_connection.py +++ b/engine/apps/oss_installation/views/cloud_connection.py @@ -1,5 +1,6 @@ from urllib.parse import urljoin +from rest_framework import status from rest_framework.permissions import IsAuthenticated from rest_framework.response import Response from rest_framework.views import APIView @@ -32,3 +33,10 @@ class CloudConnectionView(APIView): if heartbeat is None: return None return urljoin(connector.cloud_url, f"a/grafana-oncall-app/?page=integrations1&id={heartbeat.integration_id}") + + def delete(self, request): + connector = CloudConnector.objects.first() + if connector is None: + return Response(status=status.HTTP_404_NOT_FOUND) + connector.remove_sync() + return Response(status=status.HTTP_204_NO_CONTENT) From 6d7c478bfcf497dacafa6555a226e735e914beec Mon Sep 17 00:00:00 2001 From: Innokentii Konstantinov Date: Wed, 8 Jun 2022 12:23:24 +0400 Subject: [PATCH 16/35] Add periodic task to sync users with cloud --- engine/apps/oss_installation/tasks.py | 16 +++++++++++++++- engine/settings/all_in_one.py | 7 ++++++- 2 files changed, 21 insertions(+), 2 deletions(-) diff --git a/engine/apps/oss_installation/tasks.py b/engine/apps/oss_installation/tasks.py index 2c11a54a..2bb54991 100644 --- a/engine/apps/oss_installation/tasks.py +++ b/engine/apps/oss_installation/tasks.py @@ -7,7 +7,7 @@ from django.utils import timezone from rest_framework import status from apps.base.utils import live_settings -from apps.oss_installation.models import CloudHeartbeat, OssInstallation +from apps.oss_installation.models import CloudConnector, CloudHeartbeat, OssInstallation from apps.oss_installation.usage_stats import UsageStatsService from common.custom_celery_tasks import shared_dedicated_queue_retry_task @@ -93,3 +93,17 @@ def send_cloud_heartbeat(): if cloud_heartbeat.pk is not None: cloud_heartbeat.save() logger.info("Finish send cloud heartbeat") + + +@shared_dedicated_queue_retry_task() +def sync_users_with_cloud(): + logger.info("Start sync_users_with_cloud") + connector = CloudConnector.objects.first() + if connector is not None: + status, error = connector.sync_users_with_cloud() + log_message = "Users synced. Status {status}." + if error: + log_message += f" Error {error}" + logger.info(log_message) + else: + logger.info("Grafana Cloud is not connected") diff --git a/engine/settings/all_in_one.py b/engine/settings/all_in_one.py index e2196274..221edd52 100644 --- a/engine/settings/all_in_one.py +++ b/engine/settings/all_in_one.py @@ -40,6 +40,7 @@ if TESTING: # TODO: OSS: Add these setting to oss settings file. Add Version there too. OSS_INSTALLATION_FEATURES_ENABLED = True +SEND_ANONYMOUS_USAGE_STATS = True INSTALLED_APPS += ["apps.oss_installation"] # noqa @@ -55,4 +56,8 @@ CELERY_BEAT_SCHEDULE["send_cloud_heartbeat"] = { # noqa "args": (), } # noqa -SEND_ANONYMOUS_USAGE_STATS = True +CELERY_BEAT_SCHEDULE["sync_users_with_cloud"] = { # noqa + "task": "apps.oss_installation.tasks.sync_users_with_cloud", + "schedule": crontab(hour="*/12"), # noqa + "args": (), +} # noqa From 893da302e146da12512c2b51d0db2bdf25f79046 Mon Sep 17 00:00:00 2001 From: Innokentii Konstantinov Date: Wed, 8 Jun 2022 15:04:23 +0400 Subject: [PATCH 17/35] Fix cloud_users view --- .../oss_installation/serializers/cloud_user.py | 2 +- engine/apps/oss_installation/urls.py | 17 ++++++----------- .../apps/oss_installation/views/cloud_users.py | 10 +++++++--- 3 files changed, 14 insertions(+), 15 deletions(-) diff --git a/engine/apps/oss_installation/serializers/cloud_user.py b/engine/apps/oss_installation/serializers/cloud_user.py index d8e35791..52f2d0e0 100644 --- a/engine/apps/oss_installation/serializers/cloud_user.py +++ b/engine/apps/oss_installation/serializers/cloud_user.py @@ -12,7 +12,7 @@ class CloudUserSerializer(serializers.ModelSerializer): class Meta: model = User - fields = ["sync_data"] + fields = ["cloud_data"] def get_cloud_data(self, obj): link = None diff --git a/engine/apps/oss_installation/urls.py b/engine/apps/oss_installation/urls.py index 25708249..9ff5efc2 100644 --- a/engine/apps/oss_installation/urls.py +++ b/engine/apps/oss_installation/urls.py @@ -1,19 +1,14 @@ -from django.urls import path +from django.urls import include, path -from common.api_helpers.optional_slash_router import optional_slash_path +from common.api_helpers.optional_slash_router import OptionalSlashRouter, optional_slash_path from .views import CloudConnectionView, CloudUsersView, CloudUserView +router = OptionalSlashRouter() +router.register("cloud_users", CloudUserView, basename="cloud-users") + urlpatterns = [ + path("", include(router.urls)), optional_slash_path("cloud_users", CloudUsersView.as_view(), name="cloud-users-list"), - path( - "cloud_users/", - CloudUserView.as_view( - { - "get": "retrieve", - } - ), - name="cloud-user-detail", - ), optional_slash_path("cloud_connection", CloudConnectionView.as_view(), name="cloud-connection-status"), ] diff --git a/engine/apps/oss_installation/views/cloud_users.py b/engine/apps/oss_installation/views/cloud_users.py index ab28c677..5f6cc67f 100644 --- a/engine/apps/oss_installation/views/cloud_users.py +++ b/engine/apps/oss_installation/views/cloud_users.py @@ -7,7 +7,7 @@ from rest_framework.response import Response from rest_framework.views import APIView import apps.oss_installation.constants as cloud_constants -from apps.api.permissions import ActionPermission, IsAdmin, IsOwnerOrAdmin +from apps.api.permissions import ActionPermission, AnyRole, IsAdmin, IsOwnerOrAdmin from apps.auth_token.auth import PluginAuthentication from apps.oss_installation.models import CloudConnector, CloudUserIdentity from apps.oss_installation.serializers import CloudUserSerializer @@ -81,8 +81,12 @@ class CloudUserView( authentication_classes = (PluginAuthentication,) permission_classes = (IsAuthenticated, ActionPermission) + action_permissions = { + AnyRole: ("retrieve",), + IsAdmin: ("sync",), + } action_object_permissions = { - IsOwnerOrAdmin: ("retrieve",), + IsOwnerOrAdmin: ("retrieve", "sync"), } serializer_class = CloudUserSerializer @@ -91,7 +95,7 @@ class CloudUserView( return queryset @action(detail=True, methods=["post"]) - def sync_with_cloud(self, request, pk): + def sync(self, request, pk): user = self.get_object() connector = CloudConnector.objects.first() if connector is not None: From 1f49265079353199a4060ad31c63cb476b2aa6f8 Mon Sep 17 00:00:00 2001 From: Innokentii Konstantinov Date: Wed, 8 Jun 2022 15:38:08 +0400 Subject: [PATCH 18/35] Fix sync_user_with_cloud --- engine/apps/oss_installation/models/cloud_connector.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/engine/apps/oss_installation/models/cloud_connector.py b/engine/apps/oss_installation/models/cloud_connector.py index 39edc18c..589de0d7 100644 --- a/engine/apps/oss_installation/models/cloud_connector.py +++ b/engine/apps/oss_installation/models/cloud_connector.py @@ -7,6 +7,7 @@ from django.db import models, transaction from apps.base.utils import live_settings from apps.oss_installation.models.cloud_user_identity import CloudUserIdentity from apps.user_management.models import User +from common.constants.role import Role from settings.base import GRAFANA_CLOUD_ONCALL_API_URL logger = logging.getLogger(__name__) @@ -62,7 +63,7 @@ class CloudConnector(models.Model): logger.warning("Unable to sync with cloud. GRAFANA_CLOUD_ONCALL_TOKEN is not set") error_msg = "GRAFANA_CLOUD_ONCALL_TOKEN is not set" - existing_emails = list(User.objects.values_list("email", flat=True)) + existing_emails = list(User.objects.filter(role__in=(Role.ADMIN, Role.EDITOR)).values_list("email", flat=True)) matching_users = [] users_url = urljoin(GRAFANA_CLOUD_ONCALL_API_URL, "api/v1/users") @@ -130,7 +131,7 @@ class CloudConnector(models.Model): if len(data["results"]) != 0: cloud_used_data = data["results"][0] with transaction.atomic(): - CloudUserIdentity.objects.filter(email=user.emai).delete() + CloudUserIdentity.objects.filter(email=user.email).delete() CloudUserIdentity.objects.create( email=user.email, phone_number_verified=cloud_used_data["is_phone_number_verified"], From 82ec0c9324017d0279d82f5c5fba1f6bfc86df83 Mon Sep 17 00:00:00 2001 From: Yulia Shanyrova Date: Wed, 8 Jun 2022 14:03:52 +0200 Subject: [PATCH 19/35] Cloud notifications enpoints --- .../containers/UserSettings/parts/index.tsx | 9 +- .../CloudPhoneSettings/CloudPhoneSettings.tsx | 142 ++++--- grafana-plugin/src/models/cloud/cloud.ts | 7 +- .../models/global_setting/global_setting.ts | 5 + .../src/pages/cloud/CloudPage.module.css | 16 + grafana-plugin/src/pages/cloud/CloudPage.tsx | 359 +++++++++++------- grafana-plugin/src/plugin.json | 7 + grafana-plugin/src/state/features.ts | 1 + 8 files changed, 359 insertions(+), 187 deletions(-) diff --git a/grafana-plugin/src/containers/UserSettings/parts/index.tsx b/grafana-plugin/src/containers/UserSettings/parts/index.tsx index 7f966bfc..62f14daf 100644 --- a/grafana-plugin/src/containers/UserSettings/parts/index.tsx +++ b/grafana-plugin/src/containers/UserSettings/parts/index.tsx @@ -2,6 +2,7 @@ import React, { useCallback, useState } from 'react'; import { Tab, TabContent, TabsBar } from '@grafana/ui'; import cn from 'classnames/bind'; +import { observer } from 'mobx-react'; import Block from 'components/GBlock/Block'; import MobileAppVerification from 'containers/MobileAppVerification/MobileAppVerification'; @@ -13,6 +14,7 @@ import PhoneVerification from 'containers/UserSettings/parts/tabs/PhoneVerificat import TelegramInfo from 'containers/UserSettings/parts/tabs/TelegramInfo/TelegramInfo'; import { UserInfoTab } from 'containers/UserSettings/parts/tabs/UserInfoTab/UserInfoTab'; import { User } from 'models/user/user.types'; +import { AppFeature } from 'state/features'; import { useStore } from 'state/useStore'; import styles from 'containers/UserSettings/parts/index.module.css'; @@ -101,12 +103,11 @@ interface TabsContentProps { isDesktopOrLaptop: boolean; } -export const TabsContent = (props: TabsContentProps) => { +export const TabsContent = observer((props: TabsContentProps) => { const { id, activeTab, onTabChange, isDesktopOrLaptop } = props; const store = useStore(); const { userStore } = store; - const [isPhoneEnabled, setIsPhoneEnabled] = useState(false); const storeUser = userStore.items[id]; @@ -127,7 +128,7 @@ export const TabsContent = (props: TabsContentProps) => { ))} {activeTab === UserSettingsTab.NotificationSettings && } {activeTab === UserSettingsTab.PhoneVerification && - (isPhoneEnabled ? ( + (store.hasFeature(AppFeature.CloudNotifications) ? ( ) : ( @@ -139,4 +140,4 @@ export const TabsContent = (props: TabsContentProps) => { {activeTab === UserSettingsTab.TelegramInfo && } ); -}; +}); diff --git a/grafana-plugin/src/containers/UserSettings/parts/tabs/CloudPhoneSettings/CloudPhoneSettings.tsx b/grafana-plugin/src/containers/UserSettings/parts/tabs/CloudPhoneSettings/CloudPhoneSettings.tsx index dd833057..a5ea00f1 100644 --- a/grafana-plugin/src/containers/UserSettings/parts/tabs/CloudPhoneSettings/CloudPhoneSettings.tsx +++ b/grafana-plugin/src/containers/UserSettings/parts/tabs/CloudPhoneSettings/CloudPhoneSettings.tsx @@ -1,8 +1,20 @@ import React, { useCallback, useEffect, useState } from 'react'; import { getLocationSrv, LocationUpdate } from '@grafana/runtime'; -import { Field, Input, Button, Modal, HorizontalGroup, Alert, Icon, VerticalGroup, Table } from '@grafana/ui'; +import { + Field, + Input, + Button, + Modal, + HorizontalGroup, + Alert, + Icon, + VerticalGroup, + Table, + LoadingPlaceholder, +} from '@grafana/ui'; import cn from 'classnames/bind'; +import { observer } from 'mobx-react'; import Block from 'components/GBlock/Block'; import GTable from 'components/GTable/GTable'; @@ -20,66 +32,106 @@ const cx = cn.bind(styles); interface CloudPhoneSettingsProps extends WithStoreProps {} -const CloudPhoneSettings = (props: CloudPhoneSettingsProps) => { +const CloudPhoneSettings = observer((props: CloudPhoneSettingsProps) => { const store = useStore(); const [isAccountMatched, setIsAccountMatched] = useState(true); const [isPhoneVerified, setIsPhoneVerified] = useState(true); + const [userStatus, setUserStatus] = useState(0); + const [userLink, setUserLink] = useState(null); - const signUpGrafanaCloud = () => { - console.log('Sign UP'); + useEffect(() => { + getCloudUserInfo(); + }, []); + + const handleLinkClick = (link: string) => { + getLocationSrv().update({ partial: false, path: link }); }; - const handleLinkClick = () => { + + const syncUser = () => { store.cloudStore.syncCloudUser(store.userStore.currentUserPk); }; + const getCloudUserInfo = async () => { + await store.cloudStore.updateItems(); + const { count, results } = await store.cloudStore.getSearchResult(); + console.log('RES', results); + const cloudUser = + results && (await results.find((element: { id: string }) => element.id === store.userStore.currentUserPk)); + console.log('CLOUD USER', cloudUser); + setUserStatus(cloudUser?.cloud_data?.status); + setUserLink(cloudUser?.cloud_data?.link); + }; + + const UserCloudStatus = () => { + switch (userStatus) { + case 0: + return ( + + Grafana Cloud is not synced + + ); + case 1: + return ( + + + { + 'We can’t find a matching account in the connected Grafana Cloud instance (matching happens by e-mail). ' + } + + + + ); + case 2: + return ( + + + Your account successfully matched with the Grafana Cloud account. Please verify your phone number.{' '} + + + + ); + case 3: + return ( + + + Your account successfully matched with the Grafana Cloud account. Your phone number is verified.{' '} + + + + ); + default: + return ( + + + { + 'We can’t find a matching account in the connected Grafana Cloud instance (matching happens by e-mail). ' + } + + + + ); + } + }; + return ( OnCall use Grafana Cloud for SMS and phone call notifications - - {isAccountMatched ? ( - isPhoneVerified ? ( - - - Your account successfully matched with the Grafana Cloud account. Please verify your phone number.{' '} - - - - ) : ( - - - Your account successfully matched with the Grafana Cloud account. Your phone number is verified. - - - - ) - ) : ( - - - {'We can’t find a matching account in the connected Grafana Cloud instance (matching happens by e-mail). '} - - - - )} + {userStatus ? : } ); -}; +}); export default withMobXProviderContext(CloudPhoneSettings); diff --git a/grafana-plugin/src/models/cloud/cloud.ts b/grafana-plugin/src/models/cloud/cloud.ts index f41512dd..f1dd54f2 100644 --- a/grafana-plugin/src/models/cloud/cloud.ts +++ b/grafana-plugin/src/models/cloud/cloud.ts @@ -59,18 +59,15 @@ export class CloudStore extends BaseStore { } async syncCloudUser(id: string) { - return await makeRequest(`${this.path}${id}/sync_with_cloud/`, { method: 'POST' }); + return await makeRequest(`${this.path}${id}/sync/`, { method: 'POST' }); } async getCloudConnectionStatus() { return await makeRequest(`/cloud_connection/`, { method: 'GET' }); } - @action - async connectToCloud(token: string) {} - @action async disconnectToCloud() { - return await makeRequest(`/live_settings/`, { method: 'DELETE' }); + return await makeRequest(`/cloud_connection/`, { method: 'DELETE' }); } } diff --git a/grafana-plugin/src/models/global_setting/global_setting.ts b/grafana-plugin/src/models/global_setting/global_setting.ts index a7e6deb0..edcb2986 100644 --- a/grafana-plugin/src/models/global_setting/global_setting.ts +++ b/grafana-plugin/src/models/global_setting/global_setting.ts @@ -60,4 +60,9 @@ export class GlobalSettingStore extends BaseStore { return this.searchResult[query].map((globalSettingId: GlobalSetting['id']) => this.items[globalSettingId]); } + + async getGlobalSettingItemByName(name: string) { + const results = await this.getAll(); + return results.find((element: { name: string }) => element.name === name); + } } diff --git a/grafana-plugin/src/pages/cloud/CloudPage.module.css b/grafana-plugin/src/pages/cloud/CloudPage.module.css index ba98f153..14f11ba5 100644 --- a/grafana-plugin/src/pages/cloud/CloudPage.module.css +++ b/grafana-plugin/src/pages/cloud/CloudPage.module.css @@ -37,6 +37,22 @@ color: var(--secondary-text-color); } +.error-icon { + display: inline-block; + white-space: break-spaces; + line-height: 20px; + color: var(--error-text-color); +} + +.error-icon svg { + vertical-align: middle; +} + +.heart-icon { + color: var(--secondary-text-color); + margin-right: 8px; +} + .block-button { margin-top: 24px; } diff --git a/grafana-plugin/src/pages/cloud/CloudPage.tsx b/grafana-plugin/src/pages/cloud/CloudPage.tsx index 5aa1b8fa..5c185597 100644 --- a/grafana-plugin/src/pages/cloud/CloudPage.tsx +++ b/grafana-plugin/src/pages/cloud/CloudPage.tsx @@ -1,8 +1,20 @@ import React, { useCallback, useEffect, useState } from 'react'; import { getLocationSrv, LocationUpdate } from '@grafana/runtime'; -import { Field, Input, Button, Modal, HorizontalGroup, Alert, Icon, VerticalGroup, Table } from '@grafana/ui'; +import { + Field, + Input, + Button, + Modal, + HorizontalGroup, + Alert, + Icon, + VerticalGroup, + Table, + LoadingPlaceholder, +} from '@grafana/ui'; import cn from 'classnames/bind'; +import { observer } from 'mobx-react'; import Block from 'components/GBlock/Block'; import GTable from 'components/GTable/GTable'; @@ -14,36 +26,45 @@ import { Cloud } from 'models/cloud/cloud.types'; import { WithStoreProps } from 'state/types'; import { useStore } from 'state/useStore'; import { withMobXProviderContext } from 'state/withStore'; +import { openErrorNotification } from 'utils'; import styles from './CloudPage.module.css'; const cx = cn.bind(styles); interface CloudPageProps extends WithStoreProps {} +const ITEMS_PER_PAGE = 1; -const CloudPage = (props: CloudPageProps) => { +const CloudPage = observer((props: CloudPageProps) => { const store = useStore(); + const [page, setPage] = useState(1); const [cloudApiKey, setCloudApiKey] = useState(''); - const [cloudIsConnected, setCloudIsConnected] = useState(true); + const [apiKeyError, setApiKeyError] = useState(false); + const [cloudIsConnected, setCloudIsConnected] = useState(undefined); + const [heartbitLink, setHeartbitLink] = useState(null); + const [heartbitStatus, setHeartbitStatus] = useState(false); const [showConfirmationModal, setShowConfirmationModal] = useState(false); + const [syncingUsers, setSyncingUsers] = useState(false); useEffect(() => { - store.cloudStore.updateItems(); + store.cloudStore.updateItems(page); store.cloudStore.getCloudConnectionStatus().then((cloudStatus) => { setCloudIsConnected(cloudStatus.cloud_connection_status); + setHeartbitStatus(cloudStatus.cloud_heartbeat_enabled); + setHeartbitLink(cloudStatus.cloud_heartbeat_link); }); }, []); - const data = [ - { id: 'yshanyrova', email: 'y.shanyrova@grafana.com', cloud_data: { status: 2, link: '/test/abc' } }, - { id: 'amixradmin', email: 'amixr-admin@grafana.com', cloud_data: { status: 1, link: '/test/abc' } }, - { id: 'amixr', email: 'amixr@grafana.com', cloud_data: { status: undefined, link: '/test/abc' } }, - ]; + const { count, results } = store.cloudStore.getSearchResult(); - // const { count, results } = store.cloudStore.getSearchResult(); + const handleChangePage = (page: number) => { + setPage(page); + store.cloudStore.updateItems(page); + }; const handleChangeCloudApiKey = useCallback((e) => { setCloudApiKey(e.target.value); + setApiKeyError(false); }, []); const saveKeyAndConnect = () => { @@ -51,20 +72,32 @@ const CloudPage = (props: CloudPageProps) => { }; const disconnectCloudOncall = () => { - console.log('disconnected'); setCloudIsConnected(false); store.cloudStore.disconnectToCloud(); }; - const connectToCloud = () => { - setCloudIsConnected(true); + const connectToCloud = async () => { setShowConfirmationModal(false); - // store.cloudStore.update('') - store.cloudStore.connectToCloud(cloudApiKey); + const globalSettingItem = await store.globalSettingStore.getGlobalSettingItemByName('GRAFANA_CLOUD_ONCALL_TOKEN'); + store.globalSettingStore + .update(globalSettingItem?.id, { name: 'GRAFANA_CLOUD_ONCALL_TOKEN', value: cloudApiKey }) + .then((response) => { + if (response.error) { + setCloudIsConnected(false); + setApiKeyError(true); + openErrorNotification(response.error); + } else { + setCloudIsConnected(true); + syncUsers(); + } + }); }; - const syncUsers = () => { - store.cloudStore.syncCloudUsers(); + const syncUsers = async () => { + setSyncingUsers(true); + await store.cloudStore.syncCloudUsers(); + await store.cloudStore.updateItems(); + setSyncingUsers(false); }; const handleLinkClick = (link: string) => { @@ -76,17 +109,7 @@ const CloudPage = (props: CloudPageProps) => { case 0: return null; case 1: - return ( - - ); + return null; case 2: return ( ); + case 3: + return ( + + ); default: return null; } @@ -107,12 +142,14 @@ const CloudPage = (props: CloudPageProps) => { const renderStatus = (user: Cloud) => { switch (user?.cloud_data?.status) { case 0: - return User not found in the Grafana Cloud; + return Grafana Cloud is not synced; case 1: - return Phone number verified; - + return User not found in Grafana Cloud; case 2: return Phone number is not verified in Grafana Cloud; + case 3: + return Phone number verified; + default: return User not found in Grafana Cloud; } @@ -122,20 +159,26 @@ const CloudPage = (props: CloudPageProps) => { switch (user?.cloud_data?.status) { case 0: return ( - +
- +
); case 1: - return ; + return ( +
+ +
+ ); case 2: return ; + case 3: + return ; default: return ( - +
- +
); } }; @@ -168,40 +211,158 @@ const CloudPage = (props: CloudPageProps) => { }, ]; + const ConnectedBlock = ( + + + + + Cloud OnCall API key + + Cloud OnCall is sucessfully connected. + + + + + + + + + + + + + Monitor cloud instance with heartbeat + + + Once connected, current OnCall instance will send heartbeats every 3 minutes to the cloud Instance. If no + heartbeat will be received in 10 minutes, cloud instance will issue an alert. + + {heartbitStatus && heartbitLink && ( + + )} + + + + + + SMS and phone call notifications + + +
+ + { + 'Ask your users to sign up in Grafana Cloud, verify phone number and feel free to set up SMS & phone call notificaitons in personal settings!' + } + + + ( +
+ + + {count ? count : 0} + {` users matched between OSS and Cloud OnCall`} + + {syncingUsers ? ( + + ) : ( + + )} + +
+ )} + rowKey="id" + // @ts-ignore + columns={columns} + data={results} + pagination={{ + page, + total: Math.ceil((count || 0) / ITEMS_PER_PAGE), + onChange: handleChangePage, + }} + /> +
+
+
+
+ ); + + const DisconnectedBlock = ( + + + + + Cloud OnCall API key + + + + + + + + + + + + + {' '} + Monitor cloud instance with heartbeat + + + Once connected, current OnCall instance will send heartbeats every 3 minutes to the cloud Instance. If no + heartbeat will be received in 10 minutes, cloud instance will issue an alert. + + + + + + + SMS and phone call notifications + + + Users matched between OSS and Cloud OnCall currently unavialable. + + + + ); + return (
Connect Open Source OnCall and Cloud OnCall - - {cloudIsConnected ? ( - - - Cloud OnCall API key - - Cloud OnCall is sucessfully connected. - - - - - - ) : ( - - - Cloud OnCall API key - - - - - - - )} - + {cloudIsConnected === undefined ? ( + + ) : cloudIsConnected ? ( + ConnectedBlock + ) : ( + DisconnectedBlock + )} {showConfirmationModal && ( { )} - - - - - - - {' '} - Monitor cloud instance with heartbeat - - - Once connected, current OnCall instance will send heartbeats every 3 minutes to the cloud Instance. If no - heartbeat will be received in 10 minutes, cloud instance will issue an alert. - - {cloudIsConnected && ( - - )} - - - - - - - SMS and phone call notifications - - {cloudIsConnected ? ( -
- - { - 'Ask your users to sign up in Grafana Cloud, verify phone number and feel free to set up SMS & phone call notificaitons in personal settings!' - } - - - ( -
- - - {/* {count ? count : 0} */} - {`3 users matched between OSS and Cloud OnCall`} - - - -
- )} - rowKey="id" - // @ts-ignore - columns={columns} - data={data} - /> -
- ) : ( - Users matched between OSS and Cloud OnCall currently unavialable. - )} -
-
); -}; +}); export default withMobXProviderContext(CloudPage); diff --git a/grafana-plugin/src/plugin.json b/grafana-plugin/src/plugin.json index 4f5132f1..38a4d7bc 100644 --- a/grafana-plugin/src/plugin.json +++ b/grafana-plugin/src/plugin.json @@ -98,6 +98,13 @@ "path": "/a/grafana-oncall-app/?page=outgoing_webhooks", "role": "Viewer", "addToNav": true + }, + { + "type": "page", + "name": "Cloud", + "path": "/a/grafana-oncall-app/?page=cloud", + "role": "Editor", + "addToNav": true } ], "routes": [ diff --git a/grafana-plugin/src/state/features.ts b/grafana-plugin/src/state/features.ts index 8363575c..91a92d8c 100644 --- a/grafana-plugin/src/state/features.ts +++ b/grafana-plugin/src/state/features.ts @@ -3,4 +3,5 @@ export enum AppFeature { Telegram = 'telegram', LiveSettings = 'live_settings', MobileApp = 'mobile_app', + CloudNotifications = 'grafana_cloud_notifications', } From ef92ec8aea43786e0a2654bbad9dd935c1ce5288 Mon Sep 17 00:00:00 2001 From: Yulia Shanyrova Date: Wed, 8 Jun 2022 14:28:59 +0200 Subject: [PATCH 20/35] pagination fix --- grafana-plugin/src/pages/cloud/CloudPage.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/grafana-plugin/src/pages/cloud/CloudPage.tsx b/grafana-plugin/src/pages/cloud/CloudPage.tsx index 5c185597..804c7863 100644 --- a/grafana-plugin/src/pages/cloud/CloudPage.tsx +++ b/grafana-plugin/src/pages/cloud/CloudPage.tsx @@ -33,7 +33,7 @@ import styles from './CloudPage.module.css'; const cx = cn.bind(styles); interface CloudPageProps extends WithStoreProps {} -const ITEMS_PER_PAGE = 1; +const ITEMS_PER_PAGE = 50; const CloudPage = observer((props: CloudPageProps) => { const store = useStore(); From 5a8bd8493f4735d7b5702cfdd2e0862ae8b627c9 Mon Sep 17 00:00:00 2001 From: Yulia Shanyrova Date: Wed, 8 Jun 2022 15:07:49 +0200 Subject: [PATCH 21/35] endpoint for cloud user --- .../parts/tabs/CloudPhoneSettings/CloudPhoneSettings.tsx | 7 +------ grafana-plugin/src/models/cloud/cloud.ts | 6 +++++- 2 files changed, 6 insertions(+), 7 deletions(-) diff --git a/grafana-plugin/src/containers/UserSettings/parts/tabs/CloudPhoneSettings/CloudPhoneSettings.tsx b/grafana-plugin/src/containers/UserSettings/parts/tabs/CloudPhoneSettings/CloudPhoneSettings.tsx index a5ea00f1..07544bea 100644 --- a/grafana-plugin/src/containers/UserSettings/parts/tabs/CloudPhoneSettings/CloudPhoneSettings.tsx +++ b/grafana-plugin/src/containers/UserSettings/parts/tabs/CloudPhoneSettings/CloudPhoneSettings.tsx @@ -52,12 +52,7 @@ const CloudPhoneSettings = observer((props: CloudPhoneSettingsProps) => { }; const getCloudUserInfo = async () => { - await store.cloudStore.updateItems(); - const { count, results } = await store.cloudStore.getSearchResult(); - console.log('RES', results); - const cloudUser = - results && (await results.find((element: { id: string }) => element.id === store.userStore.currentUserPk)); - console.log('CLOUD USER', cloudUser); + const cloudUser = await store.cloudStore.getCloudUser(store.userStore.currentUserPk); setUserStatus(cloudUser?.cloud_data?.status); setUserLink(cloudUser?.cloud_data?.link); }; diff --git a/grafana-plugin/src/models/cloud/cloud.ts b/grafana-plugin/src/models/cloud/cloud.ts index f1dd54f2..f93c1d46 100644 --- a/grafana-plugin/src/models/cloud/cloud.ts +++ b/grafana-plugin/src/models/cloud/cloud.ts @@ -59,7 +59,11 @@ export class CloudStore extends BaseStore { } async syncCloudUser(id: string) { - return await makeRequest(`${this.path}${id}/sync/`, { method: 'POST' }); + return await makeRequest(`${this.path}`, { method: 'POST' }); + } + + async getCloudUser(id: string) { + return await makeRequest(`${this.path}${id}`, { method: 'GET' }); } async getCloudConnectionStatus() { From c82e06a1e08b97898e89f01e16f706e7735c8cfe Mon Sep 17 00:00:00 2001 From: Innokentii Konstantinov Date: Wed, 8 Jun 2022 17:12:29 +0400 Subject: [PATCH 22/35] Add "grafana_cloud_connection" feature --- engine/apps/api/views/features.py | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/engine/apps/api/views/features.py b/engine/apps/api/views/features.py index 79ed373b..11b861ff 100644 --- a/engine/apps/api/views/features.py +++ b/engine/apps/api/views/features.py @@ -11,6 +11,7 @@ FEATURE_TELEGRAM = "telegram" FEATURE_LIVE_SETTINGS = "live_settings" MOBILE_APP_PUSH_NOTIFICATIONS = "mobile_app" FEATURE_GRAFANA_CLOUD_NOTIFICATIONS = "grafana_cloud_notifications" +FEATURE_GRAFANA_CLOUD_CONNECTION = "grafana_cloud_connection" class FeaturesAPIView(APIView): @@ -33,12 +34,6 @@ class FeaturesAPIView(APIView): if settings.FEATURE_TELEGRAM_INTEGRATION_ENABLED: enabled_features.append(FEATURE_TELEGRAM) - if settings.FEATURE_LIVE_SETTINGS_ENABLED: - enabled_features.append(FEATURE_LIVE_SETTINGS) - - if live_settings.GRAFANA_CLOUD_NOTIFICATIONS_ENABLED: - enabled_features.append(FEATURE_GRAFANA_CLOUD_NOTIFICATIONS) - if settings.MOBILE_APP_PUSH_NOTIFICATIONS_ENABLED: DynamicSetting = apps.get_model("base", "DynamicSetting") mobile_app_settings = DynamicSetting.objects.get_or_create( @@ -53,4 +48,11 @@ class FeaturesAPIView(APIView): if request.auth.organization.pk in mobile_app_settings.json_value["org_ids"]: enabled_features.append(MOBILE_APP_PUSH_NOTIFICATIONS) + if settings.OSS_INSTALLATION_FEATURES_ENABLED: + enabled_features.append(FEATURE_GRAFANA_CLOUD_CONNECTION) + if settings.FEATURE_LIVE_SETTINGS_ENABLED: + enabled_features.append(FEATURE_LIVE_SETTINGS) + if live_settings.GRAFANA_CLOUD_NOTIFICATIONS_ENABLED: + enabled_features.append(FEATURE_GRAFANA_CLOUD_NOTIFICATIONS) + return enabled_features From c1f9899e5f216576d3425ba46acd30699a844b60 Mon Sep 17 00:00:00 2001 From: Innokentii Konstantinov Date: Wed, 8 Jun 2022 17:31:12 +0400 Subject: [PATCH 23/35] Clean up settings --- engine/settings/base.py | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/engine/settings/base.py b/engine/settings/base.py index 281a8a86..507ae112 100644 --- a/engine/settings/base.py +++ b/engine/settings/base.py @@ -6,7 +6,8 @@ from celery.schedules import crontab from common.utils import getenv_boolean VERSION = "dev-oss" -SEND_ANONYMOUS_USAGE_STATS = False +OSS = getenv_boolean("OSS", True) +SEND_ANONYMOUS_USAGE_STATS = getenv_boolean("SEND_ANONYMOUS_USAGE_STATS", default=True) # License is OpenSource or Cloud OPEN_SOURCE_LICENSE_NAME = "OpenSource" @@ -49,7 +50,8 @@ FEATURE_LIVE_SETTINGS_ENABLED = getenv_boolean("FEATURE_LIVE_SETTINGS_ENABLED", FEATURE_TELEGRAM_INTEGRATION_ENABLED = getenv_boolean("FEATURE_TELEGRAM_INTEGRATION_ENABLED", default=False) FEATURE_EMAIL_INTEGRATION_ENABLED = getenv_boolean("FEATURE_EMAIL_INTEGRATION_ENABLED", default=False) FEATURE_SLACK_INTEGRATION_ENABLED = getenv_boolean("FEATURE_SLACK_INTEGRATION_ENABLED", default=False) -OSS_INSTALLATION_FEATURES_ENABLED = False +GRAFANA_CLOUD_ONCALL_HEARTBEAT_ENABLED = getenv_boolean("GRAFANA_CLOUD_ONCALL_HEARTBEAT_ENABLED", default=True) +GRAFANA_CLOUD_NOTIFICATIONS_ENABLED = getenv_boolean("GRAFANA_CLOUD_NOTIFICATIONS_ENABLED", default=True) TWILIO_ACCOUNT_SID = os.environ.get("TWILIO_ACCOUNT_SID") TWILIO_AUTH_TOKEN = os.environ.get("TWILIO_AUTH_TOKEN") @@ -70,6 +72,10 @@ SENDGRID_FROM_EMAIL = os.environ.get("SENDGRID_FROM_EMAIL") SENDGRID_SECRET_KEY = os.environ.get("SENDGRID_SECRET_KEY") SENDGRID_INBOUND_EMAIL_DOMAIN = os.environ.get("SENDGRID_INBOUND_EMAIL_DOMAIN") +# For Grafana Cloud integration +GRAFANA_CLOUD_ONCALL_API_URL = os.environ.get("GRAFANA_CLOUD_ONCALL_API_URL", "https://a-prod-us-central-0.grafana.net") +GRAFANA_CLOUD_ONCALL_TOKEN = os.environ.get("GRAFANA_CLOUD_ONCALL_TOKEN", None) + # Application definition INSTALLED_APPS = [ @@ -409,11 +415,6 @@ SELF_HOSTED_SETTINGS = { "ORG_TITLE": "Self-Hosted Organization", } -GRAFANA_CLOUD_ONCALL_API_URL = "https://a-02-dev-us-central-0.grafana.net/" -GRAFANA_CLOUD_ONCALL_TOKEN = os.environ.get("GRAFANA_CLOUD_ONCALL_TOKEN", None) -GRAFANA_CLOUD_ONCALL_HEARTBEAT_ENABLED = getenv_boolean("GRAFANA_CLOUD_ONCALL_HEARTBEAT_ENABLED", default=True) -GRAFANA_CLOUD_NOTIFICATIONS_ENABLED = getenv_boolean("GRAFANA_CLOUD_NOTIFICATIONS_ENABLED", default=True) - GRAFANA_INCIDENT_STATIC_API_KEY = os.environ.get("GRAFANA_INCIDENT_STATIC_API_KEY", None) DATA_UPLOAD_MAX_MEMORY_SIZE = 5242880 From e277534f32383967521b534cd3491ba64f0e79d4 Mon Sep 17 00:00:00 2001 From: Innokentii Konstantinov Date: Wed, 8 Jun 2022 17:31:55 +0400 Subject: [PATCH 24/35] Sync only admins and editors --- engine/apps/api/views/features.py | 2 +- engine/apps/oss_installation/views/cloud_users.py | 3 ++- .../public_api/throttlers/phone_notification_throttler.py | 6 ++++++ engine/apps/public_api/views/phone_notifications.py | 5 ++++- engine/apps/twilioapp/models/phone_call.py | 2 ++ engine/apps/twilioapp/models/sms_message.py | 2 ++ engine/engine/urls.py | 2 +- 7 files changed, 18 insertions(+), 4 deletions(-) create mode 100644 engine/apps/public_api/throttlers/phone_notification_throttler.py diff --git a/engine/apps/api/views/features.py b/engine/apps/api/views/features.py index 11b861ff..81d0825a 100644 --- a/engine/apps/api/views/features.py +++ b/engine/apps/api/views/features.py @@ -48,7 +48,7 @@ class FeaturesAPIView(APIView): if request.auth.organization.pk in mobile_app_settings.json_value["org_ids"]: enabled_features.append(MOBILE_APP_PUSH_NOTIFICATIONS) - if settings.OSS_INSTALLATION_FEATURES_ENABLED: + if settings.OSS: enabled_features.append(FEATURE_GRAFANA_CLOUD_CONNECTION) if settings.FEATURE_LIVE_SETTINGS_ENABLED: enabled_features.append(FEATURE_LIVE_SETTINGS) diff --git a/engine/apps/oss_installation/views/cloud_users.py b/engine/apps/oss_installation/views/cloud_users.py index 5f6cc67f..2f740b64 100644 --- a/engine/apps/oss_installation/views/cloud_users.py +++ b/engine/apps/oss_installation/views/cloud_users.py @@ -14,6 +14,7 @@ from apps.oss_installation.serializers import CloudUserSerializer from apps.user_management.models import User from common.api_helpers.mixins import PublicPrimaryKeyMixin from common.api_helpers.paginators import HundredPageSizePaginator +from common.constants.role import Role class CloudUsersView(HundredPageSizePaginator, APIView): @@ -23,7 +24,7 @@ class CloudUsersView(HundredPageSizePaginator, APIView): def get(self, request): organization = request.user.organization - queryset = User.objects.filter(organization=organization) + queryset = User.objects.filter(organization=organization, role__in=[Role.ADMIN, Role.EDITOR]) if request.user.current_team is not None: queryset = queryset.filter(teams=request.user.current_team).distinct() diff --git a/engine/apps/public_api/throttlers/phone_notification_throttler.py b/engine/apps/public_api/throttlers/phone_notification_throttler.py new file mode 100644 index 00000000..a66e19a1 --- /dev/null +++ b/engine/apps/public_api/throttlers/phone_notification_throttler.py @@ -0,0 +1,6 @@ +from rest_framework.throttling import UserRateThrottle + + +class PhoneNotificationThrottler(UserRateThrottle): + scope = "phone_notification" + rate = "60/m" diff --git a/engine/apps/public_api/views/phone_notifications.py b/engine/apps/public_api/views/phone_notifications.py index 5269d4a9..b53e7b1d 100644 --- a/engine/apps/public_api/views/phone_notifications.py +++ b/engine/apps/public_api/views/phone_notifications.py @@ -5,6 +5,7 @@ from rest_framework.views import APIView from twilio.base.exceptions import TwilioRestException from apps.auth_token.auth import ApiTokenAuthentication +from apps.public_api.throttlers.phone_notification_throttler import PhoneNotificationThrottler from apps.twilioapp.models import PhoneCall, SMSMessage @@ -17,7 +18,9 @@ class MakeCallView(APIView): authentication_classes = (ApiTokenAuthentication,) permission_classes = (IsAuthenticated,) - # TODO: add ratelimit + throttle_classes = [ + PhoneNotificationThrottler, + ] def post(self, request): serializer = PhoneNotificationDataSerializer(data=request.data) diff --git a/engine/apps/twilioapp/models/phone_call.py b/engine/apps/twilioapp/models/phone_call.py index 72389811..ad594237 100644 --- a/engine/apps/twilioapp/models/phone_call.py +++ b/engine/apps/twilioapp/models/phone_call.py @@ -13,6 +13,7 @@ from apps.alerts.incident_appearance.renderers.phone_call_renderer import AlertG from apps.alerts.signals import user_notification_action_triggered_signal from apps.twilioapp.constants import TwilioCallStatuses from apps.twilioapp.twilio_client import twilio_client +from common.utils import clean_markup, escape_for_twilio_phone_call logger = logging.getLogger(__name__) @@ -223,6 +224,7 @@ class PhoneCall(models.Model): @classmethod def make_grafana_cloud_call(cls, user, message_body): + message_body = escape_for_twilio_phone_call(clean_markup(message_body)) cls._make_call(user, message_body, grafana_cloud=True) @classmethod diff --git a/engine/apps/twilioapp/models/sms_message.py b/engine/apps/twilioapp/models/sms_message.py index c18dd7e8..393ad0b4 100644 --- a/engine/apps/twilioapp/models/sms_message.py +++ b/engine/apps/twilioapp/models/sms_message.py @@ -12,6 +12,7 @@ from apps.alerts.incident_appearance.renderers.sms_renderer import AlertGroupSms from apps.alerts.signals import user_notification_action_triggered_signal from apps.twilioapp.constants import TwilioMessageStatuses from apps.twilioapp.twilio_client import twilio_client +from common.utils import clean_markup logger = logging.getLogger(__name__) @@ -189,6 +190,7 @@ class SMSMessage(models.Model): @classmethod def send_grafana_cloud_sms(cls, user, message_body): + message_body = clean_markup(message_body) cls._send_sms(user, message_body, grafana_cloud=True) @classmethod diff --git a/engine/engine/urls.py b/engine/engine/urls.py index 702e2907..d2eda753 100644 --- a/engine/engine/urls.py +++ b/engine/engine/urls.py @@ -54,7 +54,7 @@ if settings.FEATURE_SLACK_INTEGRATION_ENABLED: path("slack/", include("apps.slack.urls")), ] -if settings.OSS_INSTALLATION_FEATURES_ENABLED or True: +if settings.OSS: urlpatterns += [ path("api/internal/v1/", include("apps.oss_installation.urls")), ] From 5be51de1eae16cc4f2ae6d234c5ea2fb14812f94 Mon Sep 17 00:00:00 2001 From: Innokentii Konstantinov Date: Wed, 8 Jun 2022 18:14:50 +0400 Subject: [PATCH 25/35] Sync only admins and editors --- engine/apps/api/views/features.py | 1 + 1 file changed, 1 insertion(+) diff --git a/engine/apps/api/views/features.py b/engine/apps/api/views/features.py index 81d0825a..4f106a89 100644 --- a/engine/apps/api/views/features.py +++ b/engine/apps/api/views/features.py @@ -49,6 +49,7 @@ class FeaturesAPIView(APIView): enabled_features.append(MOBILE_APP_PUSH_NOTIFICATIONS) if settings.OSS: + # Features below should be enabled only in OSS enabled_features.append(FEATURE_GRAFANA_CLOUD_CONNECTION) if settings.FEATURE_LIVE_SETTINGS_ENABLED: enabled_features.append(FEATURE_LIVE_SETTINGS) From af06d6491b7b0c34da9d46463c38605d03d8aae0 Mon Sep 17 00:00:00 2001 From: Innokentii Konstantinov Date: Wed, 8 Jun 2022 18:25:58 +0400 Subject: [PATCH 26/35] Add info throttler --- engine/apps/public_api/throttlers/__init__.py | 3 +++ engine/apps/public_api/throttlers/info_throttler.py | 6 ++++++ engine/apps/public_api/views/info.py | 4 ++-- 3 files changed, 11 insertions(+), 2 deletions(-) create mode 100644 engine/apps/public_api/throttlers/info_throttler.py diff --git a/engine/apps/public_api/throttlers/__init__.py b/engine/apps/public_api/throttlers/__init__.py index e69de29b..20dc00d7 100644 --- a/engine/apps/public_api/throttlers/__init__.py +++ b/engine/apps/public_api/throttlers/__init__.py @@ -0,0 +1,3 @@ +from .info_throttler import InfoThrottler # noqa: F401 +from .phone_notification_throttler import PhoneNotificationThrottler # noqa: F401 +from .user_throttle import UserThrottle # noqa: F401 diff --git a/engine/apps/public_api/throttlers/info_throttler.py b/engine/apps/public_api/throttlers/info_throttler.py new file mode 100644 index 00000000..a48bce22 --- /dev/null +++ b/engine/apps/public_api/throttlers/info_throttler.py @@ -0,0 +1,6 @@ +from rest_framework.throttling import UserRateThrottle + + +class InfoThrottler(UserRateThrottle): + scope = "info" + rate = "100/m" diff --git a/engine/apps/public_api/views/info.py b/engine/apps/public_api/views/info.py index f9649181..f9cc13ca 100644 --- a/engine/apps/public_api/views/info.py +++ b/engine/apps/public_api/views/info.py @@ -3,14 +3,14 @@ from rest_framework.response import Response from rest_framework.views import APIView from apps.auth_token.auth import ApiTokenAuthentication -from apps.public_api.throttlers.user_throttle import UserThrottle +from apps.public_api.throttlers import InfoThrottler class InfoView(APIView): authentication_classes = (ApiTokenAuthentication,) permission_classes = (IsAuthenticated,) - throttle_classes = [UserThrottle] + throttle_classes = [InfoThrottler] def get(self, request): response = {"url": self.request.auth.organization.grafana_url} From fc53cd014eb8959f8811bb97c25b4ae726bd61aa Mon Sep 17 00:00:00 2001 From: Yulia Shanyrova Date: Wed, 8 Jun 2022 16:09:34 +0200 Subject: [PATCH 27/35] Chnages regarding env variables and Viewer role --- grafana-plugin/src/GrafanaPluginRootPage.tsx | 1 + .../containers/UserSettings/parts/index.tsx | 5 +- .../CloudPhoneSettings/CloudPhoneSettings.tsx | 56 ++++++++++++++----- grafana-plugin/src/pages/cloud/CloudPage.tsx | 13 ++++- grafana-plugin/src/pages/index.ts | 1 + grafana-plugin/src/plugin.json | 7 --- grafana-plugin/src/state/features.ts | 1 + grafana-plugin/src/utils/hooks.ts | 8 ++- 8 files changed, 65 insertions(+), 27 deletions(-) diff --git a/grafana-plugin/src/GrafanaPluginRootPage.tsx b/grafana-plugin/src/GrafanaPluginRootPage.tsx index aacc6f44..81c3c573 100644 --- a/grafana-plugin/src/GrafanaPluginRootPage.tsx +++ b/grafana-plugin/src/GrafanaPluginRootPage.tsx @@ -118,6 +118,7 @@ export const Root = observer((props: AppRootProps) => { meta, grafanaUser: window.grafanaBootData.user, enableLiveSettings: store.hasFeature(AppFeature.LiveSettings), + enableCloudPage: store.hasFeature(AppFeature.CloudConnection), }), [meta, pathWithoutLeadingSlash, page, store.features] ) diff --git a/grafana-plugin/src/containers/UserSettings/parts/index.tsx b/grafana-plugin/src/containers/UserSettings/parts/index.tsx index 62f14daf..7cbb0f4b 100644 --- a/grafana-plugin/src/containers/UserSettings/parts/index.tsx +++ b/grafana-plugin/src/containers/UserSettings/parts/index.tsx @@ -1,4 +1,4 @@ -import React, { useCallback, useState } from 'react'; +import React, { useCallback, useEffect } from 'react'; import { Tab, TabContent, TabsBar } from '@grafana/ui'; import cn from 'classnames/bind'; @@ -105,6 +105,9 @@ interface TabsContentProps { export const TabsContent = observer((props: TabsContentProps) => { const { id, activeTab, onTabChange, isDesktopOrLaptop } = props; + useEffect(() => { + store.updateFeatures(); + }, []); const store = useStore(); const { userStore } = store; diff --git a/grafana-plugin/src/containers/UserSettings/parts/tabs/CloudPhoneSettings/CloudPhoneSettings.tsx b/grafana-plugin/src/containers/UserSettings/parts/tabs/CloudPhoneSettings/CloudPhoneSettings.tsx index 07544bea..4a9d6f20 100644 --- a/grafana-plugin/src/containers/UserSettings/parts/tabs/CloudPhoneSettings/CloudPhoneSettings.tsx +++ b/grafana-plugin/src/containers/UserSettings/parts/tabs/CloudPhoneSettings/CloudPhoneSettings.tsx @@ -22,8 +22,10 @@ import PluginLink from 'components/PluginLink/PluginLink'; import Text from 'components/Text/Text'; import WithConfirm from 'components/WithConfirm/WithConfirm'; import { User as UserType } from 'models/user/user.types'; +import { AppFeature } from 'state/features'; import { WithStoreProps } from 'state/types'; import { useStore } from 'state/useStore'; +import { UserAction } from 'state/userAction'; import { withMobXProviderContext } from 'state/withStore'; import styles from './CloudPhoneSettings.module.css'; @@ -34,8 +36,7 @@ interface CloudPhoneSettingsProps extends WithStoreProps {} const CloudPhoneSettings = observer((props: CloudPhoneSettingsProps) => { const store = useStore(); - const [isAccountMatched, setIsAccountMatched] = useState(true); - const [isPhoneVerified, setIsPhoneVerified] = useState(true); + const [syncing, setSyncing] = useState(false); const [userStatus, setUserStatus] = useState(0); const [userLink, setUserLink] = useState(null); @@ -47,8 +48,10 @@ const CloudPhoneSettings = observer((props: CloudPhoneSettingsProps) => { getLocationSrv().update({ partial: false, path: link }); }; - const syncUser = () => { - store.cloudStore.syncCloudUser(store.userStore.currentUserPk); + const syncUser = async () => { + setSyncing(true); + await store.cloudStore.syncCloudUser(store.userStore.currentUserPk); + setSyncing(false); }; const getCloudUserInfo = async () => { @@ -60,6 +63,18 @@ const CloudPhoneSettings = observer((props: CloudPhoneSettingsProps) => { const UserCloudStatus = () => { switch (userStatus) { case 0: + if (store.hasFeature(AppFeature.CloudNotifications)) { + return ( + + Your account successfully matched, but Cloud is not connected. + + + + + ); + } return ( Grafana Cloud is not synced @@ -117,15 +132,30 @@ const CloudPhoneSettings = observer((props: CloudPhoneSettingsProps) => { }; return ( - - - OnCall use Grafana Cloud for SMS and phone call notifications - - - {userStatus ? : } - + <> + {store.isUserActionAllowed(UserAction.UpdateOtherUsersSettings) ? ( + + + OnCall use Grafana Cloud for SMS and phone call notifications + {syncing ? ( + + ) : ( + + )} + + {!syncing ? : } + + ) : ( + + OnCall use Grafana Cloud for SMS and phone call notifications + You do not have permission to perform this action. Ask an admin to upgrade your permissions. + + )} + ); }); diff --git a/grafana-plugin/src/pages/cloud/CloudPage.tsx b/grafana-plugin/src/pages/cloud/CloudPage.tsx index 804c7863..c02cf162 100644 --- a/grafana-plugin/src/pages/cloud/CloudPage.tsx +++ b/grafana-plugin/src/pages/cloud/CloudPage.tsx @@ -52,8 +52,9 @@ const CloudPage = observer((props: CloudPageProps) => { setCloudIsConnected(cloudStatus.cloud_connection_status); setHeartbitStatus(cloudStatus.cloud_heartbeat_enabled); setHeartbitLink(cloudStatus.cloud_heartbeat_link); + getApiKeyFromGlobalSettings(); }); - }, []); + }, [cloudIsConnected]); const { count, results } = store.cloudStore.getSearchResult(); @@ -76,6 +77,12 @@ const CloudPage = observer((props: CloudPageProps) => { store.cloudStore.disconnectToCloud(); }; + const getApiKeyFromGlobalSettings = async () => { + const globalSettingItem = await store.globalSettingStore.getGlobalSettingItemByName('GRAFANA_CLOUD_ONCALL_TOKEN'); + if (cloudIsConnected === false) { + setCloudApiKey(globalSettingItem?.value); + } + }; const connectToCloud = async () => { setShowConfirmationModal(false); const globalSettingItem = await store.globalSettingStore.getGlobalSettingItemByName('GRAFANA_CLOUD_ONCALL_TOKEN'); @@ -260,7 +267,7 @@ const CloudPage = observer((props: CloudPageProps) => {
{ - 'Ask your users to sign up in Grafana Cloud, verify phone number and feel free to set up SMS & phone call notificaitons in personal settings!' + 'Ask your users to sign up in Grafana Cloud, verify phone number and feel free to set up SMS & phone call notificaitons in personal settings! Only users with Admin or Editor role will be synced.' } @@ -317,7 +324,7 @@ const CloudPage = observer((props: CloudPageProps) => { style={{ width: '100%' }} invalid={apiKeyError} > - +