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/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/api/tests/test_features.py b/engine/apps/api/tests/test_features.py index e391b8fb..30b37944 100644 --- a/engine/apps/api/tests/test_features.py +++ b/engine/apps/api/tests/test_features.py @@ -3,7 +3,13 @@ from django.urls import reverse from rest_framework import status from rest_framework.test import APIClient -from apps.api.views.features import FEATURE_LIVE_SETTINGS, FEATURE_SLACK, FEATURE_TELEGRAM +from apps.api.views.features import ( + FEATURE_GRAFANA_CLOUD_CONNECTION, + FEATURE_GRAFANA_CLOUD_NOTIFICATIONS, + FEATURE_LIVE_SETTINGS, + FEATURE_SLACK, + FEATURE_TELEGRAM, +) @pytest.mark.django_db @@ -30,15 +36,24 @@ def test_select_features_all_enabled( make_user_auth_headers, ): organization, user, token = make_organization_and_user_with_plugin_token() + settings.OSS_INSTALLATION = True settings.FEATURE_SLACK_INTEGRATION_ENABLED = True settings.FEATURE_TELEGRAM_INTEGRATION_ENABLED = True settings.FEATURE_LIVE_SETTINGS_ENABLED = True + settings.FEATURE_GRAFANA_CLOUD_CONNECTION = True + settings.FEATURE_GRAFANA_CLOUD_NOTIFICATIONS = True client = APIClient() url = reverse("api-internal:features") response = client.get(url, format="json", **make_user_auth_headers(user, token)) assert response.status_code == status.HTTP_200_OK - assert response.json() == [FEATURE_SLACK, FEATURE_TELEGRAM, FEATURE_LIVE_SETTINGS] + assert response.json() == [ + FEATURE_SLACK, + FEATURE_TELEGRAM, + FEATURE_GRAFANA_CLOUD_CONNECTION, + FEATURE_LIVE_SETTINGS, + FEATURE_GRAFANA_CLOUD_NOTIFICATIONS, + ] @pytest.mark.django_db @@ -48,9 +63,12 @@ def test_select_features_all_disabled( make_user_auth_headers, ): organization, user, token = make_organization_and_user_with_plugin_token() + settings.OSS_INSTALLATION = False settings.FEATURE_SLACK_INTEGRATION_ENABLED = False settings.FEATURE_TELEGRAM_INTEGRATION_ENABLED = False settings.FEATURE_LIVE_SETTINGS_ENABLED = False + settings.FEATURE_GRAFANA_CLOUD_CONNECTION = False + settings.FEATURE_GRAFANA_CLOUD_NOTIFICATIONS = FEATURE_GRAFANA_CLOUD_NOTIFICATIONS client = APIClient() url = reverse("api-internal:features") response = client.get(url, format="json", **make_user_auth_headers(user, token)) diff --git a/engine/apps/api/views/features.py b/engine/apps/api/views/features.py index 6a4285de..805308a9 100644 --- a/engine/apps/api/views/features.py +++ b/engine/apps/api/views/features.py @@ -4,11 +4,14 @@ 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" +FEATURE_GRAFANA_CLOUD_CONNECTION = "grafana_cloud_connection" class FeaturesAPIView(APIView): @@ -31,9 +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 settings.MOBILE_APP_PUSH_NOTIFICATIONS_ENABLED: DynamicSetting = apps.get_model("base", "DynamicSetting") mobile_app_settings = DynamicSetting.objects.get_or_create( @@ -48,4 +48,12 @@ 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 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) + if live_settings.GRAFANA_CLOUD_NOTIFICATIONS_ENABLED: + enabled_features.append(FEATURE_GRAFANA_CLOUD_NOTIFICATIONS) + return enabled_features diff --git a/engine/apps/api/views/live_setting.py b/engine/apps/api/views/live_setting.py index 2ed6d723..80dbd6a7 100644 --- a/engine/apps/api/views/live_setting.py +++ b/engine/apps/api/views/live_setting.py @@ -32,7 +32,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"] @@ -66,6 +70,17 @@ 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": + from apps.oss_installation.models import CloudConnector + + 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 c08ab11f..ca3331de 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 = ( @@ -171,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 7342d00e..8339e295 100644 --- a/engine/apps/base/utils.py +++ b/engine/apps/base/utils.py @@ -94,6 +94,13 @@ class LiveSettingValidator: except Exception as e: return f"Telegram error: {str(e)}" + @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 + @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 new file mode 100644 index 00000000..11f3dc48 --- /dev/null +++ b/engine/apps/oss_installation/constants.py @@ -0,0 +1,4 @@ +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/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/__init__.py b/engine/apps/oss_installation/models/__init__.py index 53dea35e..beab1774 100644 --- a/engine/apps/oss_installation/models/__init__.py +++ b/engine/apps/oss_installation/models/__init__.py @@ -1,2 +1,4 @@ -from .heartbeat import CloudHeartbeat # 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 .oss_installation import OssInstallation # noqa: F401 diff --git a/engine/apps/oss_installation/models/cloud_connector.py b/engine/apps/oss_installation/models/cloud_connector.py new file mode 100644 index 00000000..38541bf5 --- /dev/null +++ b/engine/apps/oss_installation/models/cloud_connector.py @@ -0,0 +1,155 @@ +import logging +from urllib.parse import urljoin + +import requests +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__) + + +class CloudConnector(models.Model): + """ + CloudOrganizationConnector model represents connection between oss organization and cloud organization. + """ + + cloud_url = models.URLField() + + @classmethod + 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 = 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(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: + 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 = "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]: + 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" + + 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") + + fetch_next_page = True + users_fetched = 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}" + ) + 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 + 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)}") + error_msg = f"Unable to sync with cloud" + users_fetched = False + break + + 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"], + ) + ) + + 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): + 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") + error_msg = "GRAFANA_CLOUD_ONCALL_TOKEN is not set" + else: + 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: + 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.email).delete() + CloudUserIdentity.objects.create( + email=user.email, + 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" + + return sync_status, error_msg + + @classmethod + def remove_sync(cls): + 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/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 new file mode 100644 index 00000000..ec83ac2f --- /dev/null +++ b/engine/apps/oss_installation/models/cloud_user_identity.py @@ -0,0 +1,7 @@ +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() 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/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/serializers/cloud_user.py b/engine/apps/oss_installation/serializers/cloud_user.py new file mode 100644 index 00000000..228a33c9 --- /dev/null +++ b/engine/apps/oss_installation/serializers/cloud_user.py @@ -0,0 +1,37 @@ +from urllib.parse import urljoin + +from rest_framework import serializers + +import apps.oss_installation.constants as cloud_constants +from apps.oss_installation.models import CloudConnector, CloudUserIdentity +from apps.user_management.models import User + + +class CloudUserSerializer(serializers.ModelSerializer): + cloud_data = serializers.SerializerMethodField() + + class Meta: + model = User + fields = ["cloud_data"] + + def get_cloud_data(self, obj): + link = None + status = cloud_constants.CLOUD_NOT_SYNCED + 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: + 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_PHONE_NOT_VERIFIED + 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 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/apps/oss_installation/urls.py b/engine/apps/oss_installation/urls.py index 956ffe74..9ff5efc2 100644 --- a/engine/apps/oss_installation/urls.py +++ b/engine/apps/oss_installation/urls.py @@ -1,7 +1,14 @@ -from common.api_helpers.optional_slash_router import optional_slash_path +from django.urls import include, path -from .views import CloudHeartbeatStatusView +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 = [ - optional_slash_path("cloud_heartbeat_status", CloudHeartbeatStatusView.as_view(), name="cloud_heartbeat_status"), + path("", include(router.urls)), + optional_slash_path("cloud_users", CloudUsersView.as_view(), name="cloud-users-list"), + 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..aef70aa4 100644 --- a/engine/apps/oss_installation/utils.py +++ b/engine/apps/oss_installation/utils.py @@ -1,19 +1,24 @@ +import logging from contextlib import suppress +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 + +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) diff --git a/engine/apps/oss_installation/views/__init__.py b/engine/apps/oss_installation/views/__init__.py index 0716482b..2b206cac 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_connection import CloudConnectionView # 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..6acbef57 --- /dev/null +++ b/engine/apps/oss_installation/views/cloud_connection.py @@ -0,0 +1,42 @@ +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 + +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, + "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}") + + 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) 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_users.py b/engine/apps/oss_installation/views/cloud_users.py new file mode 100644 index 00000000..2f740b64 --- /dev/null +++ b/engine/apps/oss_installation/views/cloud_users.py @@ -0,0 +1,106 @@ +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 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 +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): + authentication_classes = (PluginAuthentication,) + permission_classes = (IsAuthenticated, IsAdmin) + + def get(self, request): + organization = request.user.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() + + results = self.paginate_queryset(queryset, request, view=self) + + emails = list(queryset.values_list("email", flat=True)) + cloud_identities = list(CloudUserIdentity.objects.filter(email__in=emails)) + cloud_identities = {cloud_identity.email: cloud_identity for cloud_identity in cloud_identities} + + response = [] + + connector = CloudConnector.objects.first() + + for user in results: + link = None + 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}" + ) + + response.append( + { + "id": user.public_primary_key, + "email": user.email, + "username": user.username, + "cloud_data": {"status": status, "link": link}, + } + ) + + return self.get_paginated_response(response) + + def post(self, request): + 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}) + 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_permissions = { + AnyRole: ("retrieve",), + IsAdmin: ("sync",), + } + action_object_permissions = { + IsOwnerOrAdmin: ("retrieve", "sync"), + } + 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(self, request, pk): + user = self.get_object() + 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}) + else: + return Response(status=status.HTTP_400_BAD_REQUEST, data={"detail": "Grafana Cloud is not connected"}) 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/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/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/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} 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..896fe7a1 --- /dev/null +++ b/engine/apps/public_api/views/phone_notifications.py @@ -0,0 +1,76 @@ +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.public_api.throttlers.phone_notification_throttler import PhoneNotificationThrottler +from apps.twilioapp.models import PhoneCall, SMSMessage + + +class PhoneNotificationDataSerializer(serializers.Serializer): + email = serializers.EmailField() + message = serializers.CharField(max_length=1024) + + +class MakeCallView(APIView): + authentication_classes = (ApiTokenAuthentication,) + permission_classes = (IsAuthenticated,) + + throttle_classes = [ + PhoneNotificationThrottler, + ] + + def post(self, request): + serializer = PhoneNotificationDataSerializer(data=request.data) + serializer.is_valid(raise_exception=True) + + response_data = {} + organization = self.request.auth.organization + 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, data=response_data) + except PhoneCall.PhoneCallsLimitExceeded: + return Response(status=status.HTTP_400_BAD_REQUEST, data={"error": "limit-exceeded"}) + + return Response(status=status.HTTP_200_OK, data=response_data) + + +class SendSMSView(APIView): + authentication_classes = (ApiTokenAuthentication,) + permission_classes = (IsAuthenticated,) + + throttle_classes = [ + PhoneNotificationThrottler, + ] + + def post(self, request): + serializer = PhoneNotificationDataSerializer(data=request.data) + serializer.is_valid(raise_exception=True) + + response_data = {} + organization = self.request.auth.organization + 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_grafana_cloud_sms(user, serializer.validated_data["message"]) + except TwilioRestException: + return Response(status=status.HTTP_503_SERVICE_UNAVAILABLE, data=response_data) + except SMSMessage.SMSLimitExceeded: + return Response(status=status.HTTP_400_BAD_REQUEST, data={"error": "limit-exceeded"}) + + return Response(status=status.HTTP_200_OK, data=response_data) 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/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), + ), + ] diff --git a/engine/apps/twilioapp/models/phone_call.py b/engine/apps/twilioapp/models/phone_call.py index 7d5ae0f9..ad594237 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 @@ -9,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__) @@ -34,8 +39,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 +122,17 @@ 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""" + + class CloudSendError(Exception): + """Error making call through cloud""" + def process_digit(self, digit): """The function process pressed digit at time of call to user @@ -138,57 +156,58 @@ class PhoneCall(models.Model): return bool(self.represents_alert_group.slack_message) @classmethod - def make_call(cls, user, alert_group, notification_policy): - UserNotificationPolicyLogRecord = apps.get_model("base", "UserNotificationPolicyLogRecord") + 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") - 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, - notification_policy=notification_policy, - ) - - 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() + 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: + 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, + 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, + ) + 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 +222,41 @@ 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): + message_body = escape_for_twilio_phone_call(clean_markup(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..393ad0b4 100644 --- a/engine/apps/twilioapp/models/sms_message.py +++ b/engine/apps/twilioapp/models/sms_message.py @@ -1,13 +1,18 @@ 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 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__) @@ -36,7 +41,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 cloud notifications don't create logs on its delivery status. + return log_record = None if status == TwilioMessageStatuses.DELIVERED: @@ -90,6 +97,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,66 +107,73 @@ 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""" + + 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") - 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 - ) - - 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 + renderer = AlertGroupSmsRenderer(alert_group) + message_body = renderer.render() + try: + if is_cloud_notification: + cls._send_cloud_sms(user, message_body) 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: + 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, + 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, + ) + 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 +188,41 @@ 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): + message_body = clean_markup(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) diff --git a/engine/engine/urls.py b/engine/engine/urls.py index 9e55241a..518c5608 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: urlpatterns += [ path("api/internal/v1/", include("apps.oss_installation.urls")), ] 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 diff --git a/engine/settings/base.py b/engine/settings/base.py index b2150a47..0495440e 100644 --- a/engine/settings/base.py +++ b/engine/settings/base.py @@ -6,7 +6,10 @@ from celery.schedules import crontab from common.utils import getenv_boolean VERSION = "dev-oss" -SEND_ANONYMOUS_USAGE_STATS = False +# Indicates if instance is OSS installation. +# It is needed to plug-in oss urls. +OSS_INSTALLATION = getenv_boolean("OSS", False) +SEND_ANONYMOUS_USAGE_STATS = getenv_boolean("SEND_ANONYMOUS_USAGE_STATS", default=True) # License is OpenSource or Cloud OPEN_SOURCE_LICENSE_NAME = "OpenSource" @@ -49,7 +52,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 +74,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,10 +417,6 @@ 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_TOKEN = os.environ.get("GRAFANA_CLOUD_ONCALL_TOKEN", None) -GRAFANA_CLOUD_ONCALL_HEARTBEAT_ENABLED = getenv_boolean("GRAFANA_CLOUD_ONCALL_HEARTBEAT_ENABLED", default=True) - GRAFANA_INCIDENT_STATIC_API_KEY = os.environ.get("GRAFANA_INCIDENT_STATIC_API_KEY", None) DATA_UPLOAD_MAX_MEMORY_SIZE = 5242880 diff --git a/engine/settings/ci-test.py b/engine/settings/ci-test.py index f3c012a0..5389cbd5 100644 --- a/engine/settings/ci-test.py +++ b/engine/settings/ci-test.py @@ -27,3 +27,5 @@ TWILIO_AUTH_TOKEN = "dummy_twilio_auth_token" FEATURE_EXTRA_MESSAGING_BACKENDS_ENABLED = True EXTRA_MESSAGING_BACKENDS = ["apps.base.tests.messaging_backend.TestOnlyBackend"] +OSS_INSTALLATION = True +INSTALLED_APPS += ["apps.oss_installation"] # noqa diff --git a/grafana-plugin/src/GrafanaPluginRootPage.tsx b/grafana-plugin/src/GrafanaPluginRootPage.tsx index 5eb10a1f..b6f0cc65 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); }; }, []); @@ -116,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/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..7cbb0f4b 100644 --- a/grafana-plugin/src/containers/UserSettings/parts/index.tsx +++ b/grafana-plugin/src/containers/UserSettings/parts/index.tsx @@ -1,17 +1,20 @@ -import React, { useCallback } from 'react'; +import React, { useCallback, useEffect } 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'; 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'; 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'; @@ -100,8 +103,11 @@ interface TabsContentProps { isDesktopOrLaptop: boolean; } -export const TabsContent = (props: TabsContentProps) => { +export const TabsContent = observer((props: TabsContentProps) => { const { id, activeTab, onTabChange, isDesktopOrLaptop } = props; + useEffect(() => { + store.updateFeatures(); + }, []); const store = useStore(); const { userStore } = store; @@ -124,9 +130,12 @@ export const TabsContent = (props: TabsContentProps) => { ))} {activeTab === UserSettingsTab.NotificationSettings && } - {activeTab === UserSettingsTab.PhoneVerification && ( - - )} + {activeTab === UserSettingsTab.PhoneVerification && + (store.hasFeature(AppFeature.CloudNotifications) ? ( + + ) : ( + + ))} {activeTab === UserSettingsTab.MobileAppVerification && ( )} @@ -134,4 +143,4 @@ export const TabsContent = (props: TabsContentProps) => { {activeTab === UserSettingsTab.TelegramInfo && } ); -}; +}); 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..4a9d6f20 --- /dev/null +++ b/grafana-plugin/src/containers/UserSettings/parts/tabs/CloudPhoneSettings/CloudPhoneSettings.tsx @@ -0,0 +1,162 @@ +import React, { useCallback, useEffect, useState } from 'react'; + +import { getLocationSrv, LocationUpdate } from '@grafana/runtime'; +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'; +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'; + +const cx = cn.bind(styles); + +interface CloudPhoneSettingsProps extends WithStoreProps {} + +const CloudPhoneSettings = observer((props: CloudPhoneSettingsProps) => { + const store = useStore(); + const [syncing, setSyncing] = useState(false); + const [userStatus, setUserStatus] = useState(0); + const [userLink, setUserLink] = useState(null); + + useEffect(() => { + getCloudUserInfo(); + }, []); + + const handleLinkClick = (link: string) => { + getLocationSrv().update({ partial: false, path: link }); + }; + + const syncUser = async () => { + setSyncing(true); + await store.cloudStore.syncCloudUser(store.userStore.currentUserPk); + setSyncing(false); + }; + + const getCloudUserInfo = async () => { + const cloudUser = await store.cloudStore.getCloudUser(store.userStore.currentUserPk); + setUserStatus(cloudUser?.cloud_data?.status); + setUserLink(cloudUser?.cloud_data?.link); + }; + + 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 + + ); + 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 ( + <> + {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. + + )} + + ); +}); + +export default withMobXProviderContext(CloudPhoneSettings); 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) => ( } = {}; + + @observable.shallow + items: { [id: string]: Cloud } = {}; + + constructor(rootStore: RootStore) { + super(rootStore); + + this.path = '/cloud_users/'; + } + + @action + async updateItems(page = 1) { + const { count, results } = await makeRequest(this.path, { + params: { 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((id: Cloud['id']) => this.items?.[id]), + }; + } + + async syncCloudUsers() { + return await makeRequest(`${this.path}`, { method: 'POST' }); + } + + async syncCloudUser(id: string) { + return await makeRequest(`${this.path}`, { method: 'POST' }); + } + + async getCloudUser(id: string) { + return await makeRequest(`${this.path}${id}`, { method: 'GET' }); + } + + async getCloudConnectionStatus() { + return await makeRequest(`/cloud_connection/`, { method: 'GET' }); + } + + @action + async disconnectToCloud() { + return await makeRequest(`/cloud_connection/`, { method: 'DELETE' }); + } +} 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..15658b3d --- /dev/null +++ b/grafana-plugin/src/models/cloud/cloud.types.ts @@ -0,0 +1,9 @@ +export interface Cloud { + id: string; + username: string; + email: string; + cloud_data?: { + status?: number; + link?: string; + }; +} 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/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..14f11ba5 --- /dev/null +++ b/grafana-plugin/src/pages/cloud/CloudPage.module.css @@ -0,0 +1,66 @@ +.info-block { + width: 70%; + min-width: 1100px; + padding: 24px; +} + +.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%; +} + +.user-row { + height: 32px; +} + +.cloud-page-title { + margin-top: 24px; +} + +.cloud-oncall-name { + color: #f55f3e; +} + +.block-icon { + 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; +} + +.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 new file mode 100644 index 00000000..c02cf162 --- /dev/null +++ b/grafana-plugin/src/pages/cloud/CloudPage.tsx @@ -0,0 +1,395 @@ +import React, { useCallback, useEffect, useState } from 'react'; + +import { getLocationSrv, LocationUpdate } from '@grafana/runtime'; +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'; +import PluginLink from 'components/PluginLink/PluginLink'; +import Text from 'components/Text/Text'; +import WithConfirm from 'components/WithConfirm/WithConfirm'; +import { CrossCircleIcon, HeartIcon } 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 { openErrorNotification } from 'utils'; + +import styles from './CloudPage.module.css'; + +const cx = cn.bind(styles); + +interface CloudPageProps extends WithStoreProps {} +const ITEMS_PER_PAGE = 50; + +const CloudPage = observer((props: CloudPageProps) => { + const store = useStore(); + const [page, setPage] = useState(1); + const [cloudApiKey, setCloudApiKey] = useState(''); + 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(page); + store.cloudStore.getCloudConnectionStatus().then((cloudStatus) => { + setCloudIsConnected(cloudStatus.cloud_connection_status); + setHeartbitStatus(cloudStatus.cloud_heartbeat_enabled); + setHeartbitLink(cloudStatus.cloud_heartbeat_link); + getApiKeyFromGlobalSettings(); + }); + }, [cloudIsConnected]); + + 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 = () => { + setShowConfirmationModal(true); + }; + + const disconnectCloudOncall = () => { + setCloudIsConnected(false); + 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'); + 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 = async () => { + setSyncingUsers(true); + await store.cloudStore.syncCloudUsers(); + await store.cloudStore.updateItems(); + setSyncingUsers(false); + }; + + const handleLinkClick = (link: string) => { + getLocationSrv().update({ partial: false, path: link }); + }; + + const renderButtons = (user: Cloud) => { + switch (user?.cloud_data?.status) { + case 0: + return null; + case 1: + return null; + case 2: + return ( + + ); + case 3: + return ( + + ); + default: + return null; + } + }; + + const renderStatus = (user: Cloud) => { + switch (user?.cloud_data?.status) { + case 0: + return Grafana Cloud is not synced; + case 1: + 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; + } + }; + + const renderStatusIcon = (user: Cloud) => { + switch (user?.cloud_data?.status) { + case 0: + return ( +
+ +
+ ); + case 1: + return ( +
+ +
+ ); + + case 2: + return ; + case 3: + return ; + default: + return ( +
+ +
+ ); + } + }; + + const renderEmail = (user: Cloud) => { + return {user.email}; + }; + + const columns = [ + { + width: '2%', + render: renderStatusIcon, + key: 'statusIcon', + }, + { + width: '28%', + render: renderEmail, + key: 'email', + }, + { + width: '50%', + render: renderStatus, + key: 'status', + }, + { + width: '20%', + render: renderButtons, + key: 'buttons', + align: 'actions', + }, + ]; + + 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! Only users with Admin or Editor role will be synced.' + } + + + ( +
+ + + {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 === undefined ? ( + + ) : cloudIsConnected ? ( + ConnectedBlock + ) : ( + DisconnectedBlock + )} + + {showConfirmationModal && ( + setShowConfirmationModal(false)} + > + + + + + + )} + +
+ ); +}); + +export default withMobXProviderContext(CloudPage); diff --git a/grafana-plugin/src/pages/index.ts b/grafana-plugin/src/pages/index.ts index cd2c68a3..5df8fde2 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,13 @@ export const pages: PageDefinition[] = [ text: 'Migrate From Amixr.IO', hideFromTabs: true, }, + { + component: CloudPage, + icon: 'cloud', + id: 'cloud', + text: 'Cloud', + role: 'Admin', + }, { component: Test, icon: 'cog', diff --git a/grafana-plugin/src/state/features.ts b/grafana-plugin/src/state/features.ts index 8363575c..bf915f19 100644 --- a/grafana-plugin/src/state/features.ts +++ b/grafana-plugin/src/state/features.ts @@ -3,4 +3,6 @@ export enum AppFeature { Telegram = 'telegram', LiveSettings = 'live_settings', MobileApp = 'mobile_app', + CloudNotifications = 'grafana_cloud_notifications', + CloudConnection = 'grafana_cloud_connection', } 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/utils/hooks.ts b/grafana-plugin/src/utils/hooks.ts index bb456564..b26ff31e 100644 --- a/grafana-plugin/src/utils/hooks.ts +++ b/grafana-plugin/src/utils/hooks.ts @@ -16,6 +16,7 @@ type Args = { orgRole: 'Viewer' | 'Editor' | 'Admin'; }; enableLiveSettings: boolean; + enableCloudPage: boolean; }; export function useForceUpdate() { @@ -23,7 +24,7 @@ export function useForceUpdate() { return () => setValue((value) => value + 1); } -export function useNavModel({ meta, pages, path, page, grafanaUser, enableLiveSettings }: Args) { +export function useNavModel({ meta, pages, path, page, grafanaUser, enableLiveSettings, enableCloudPage }: Args) { return useMemo(() => { const tabs: NavModelItem[] = []; @@ -36,7 +37,8 @@ export function useNavModel({ meta, pages, path, page, grafanaUser, enableLiveSe hideFromTabs: hideFromTabs || (role === 'Admin' && grafanaUser.orgRole !== role) || - (id === 'live-settings' && !enableLiveSettings), + (id === 'live-settings' && !enableLiveSettings) || + (id === 'cloud' && !enableCloudPage), }); if (page === id) { @@ -61,7 +63,7 @@ export function useNavModel({ meta, pages, path, page, grafanaUser, enableLiveSe node, main: node, }; - }, [meta.info.logos.large, pages, path, page, enableLiveSettings]); + }, [meta.info.logos.large, pages, path, page, enableLiveSettings, enableCloudPage]); } export function usePrevious(value: any) { 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);