diff --git a/engine/apps/api/serializers/user.py b/engine/apps/api/serializers/user.py index e9ec91b2..db0db0ed 100644 --- a/engine/apps/api/serializers/user.py +++ b/engine/apps/api/serializers/user.py @@ -1,9 +1,12 @@ +from django.conf import settings from rest_framework import serializers from apps.api.serializers.telegram import TelegramToUserConnectorSerializer from apps.base.constants import ADMIN_PERMISSIONS, ALL_ROLES_PERMISSIONS, EDITOR_PERMISSIONS from apps.base.messaging import get_messaging_backends from apps.base.models import UserNotificationPolicy +from apps.base.utils import live_settings +from apps.oss_installation.utils import cloud_user_identity_status from apps.twilioapp.utils import check_phone_number_is_valid from apps.user_management.models import User from common.api_helpers.custom_fields import TeamPrimaryKeyRelatedField @@ -30,6 +33,7 @@ class UserSerializer(DynamicFieldsModelSerializer, EagerLoadingMixin): permissions = serializers.SerializerMethodField() notification_chain_verbal = serializers.SerializerMethodField() + cloud_connection_status = serializers.SerializerMethodField() SELECT_RELATED = ["telegram_verification_code", "telegram_connection", "organization", "slack_user_identity"] @@ -50,6 +54,7 @@ class UserSerializer(DynamicFieldsModelSerializer, EagerLoadingMixin): "messaging_backends", "permissions", "notification_chain_verbal", + "cloud_connection_status", ] read_only_fields = [ "email", @@ -88,6 +93,15 @@ class UserSerializer(DynamicFieldsModelSerializer, EagerLoadingMixin): default, important = UserNotificationPolicy.get_short_verbals_for_user(user=obj) return {"default": " - ".join(default), "important": " - ".join(important)} + def get_cloud_connection_status(self, obj): + if settings.OSS_INSTALLATION and live_settings.GRAFANA_CLOUD_NOTIFICATIONS_ENABLED: + connector = self.context.get("connector", None) + identities = self.context.get("cloud_identities", {}) + identity = identities.get(obj.email, None) + status, _ = cloud_user_identity_status(connector, identity) + return status + return None + class UserHiddenFieldsSerializer(UserSerializer): available_for_all_roles_fields = [ diff --git a/engine/apps/api/tests/test_user.py b/engine/apps/api/tests/test_user.py index 5731ed17..dd23feb5 100644 --- a/engine/apps/api/tests/test_user.py +++ b/engine/apps/api/tests/test_user.py @@ -75,6 +75,7 @@ def test_update_user_cant_change_email_and_username( "user": admin.username, } }, + "cloud_connection_status": 0, "permissions": ADMIN_PERMISSIONS, "notification_chain_verbal": {"default": "", "important": ""}, "slack_user_identity": None, @@ -124,6 +125,7 @@ def test_list_users( "notification_chain_verbal": {"default": "", "important": ""}, "slack_user_identity": None, "avatar": admin.avatar_url, + "cloud_connection_status": 0, }, { "pk": editor.public_primary_key, @@ -144,6 +146,7 @@ def test_list_users( "notification_chain_verbal": {"default": "", "important": ""}, "slack_user_identity": None, "avatar": editor.avatar_url, + "cloud_connection_status": 0, }, ], } diff --git a/engine/apps/api/views/live_setting.py b/engine/apps/api/views/live_setting.py index 80dbd6a7..1718bd15 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.tasks import sync_users_with_cloud from apps.slack.tasks import unpopulate_slack_user_identities from apps.telegram.client import TelegramClient from apps.telegram.tasks import register_telegram_webhook @@ -41,8 +42,10 @@ class LiveSettingViewSet(PublicPrimaryKeyMixin, viewsets.ModelViewSet): def perform_update(self, serializer): new_value = serializer.validated_data["value"] self._update_hook(new_value) - - super().perform_update(serializer) + instance = serializer.save() + sync_users = self.request.query_params.get("sync_users", "true") == "true" + if instance.name == "GRAFANA_CLOUD_ONCALL_TOKEN" and sync_users: + sync_users_with_cloud.apply_async() def perform_destroy(self, instance): new_value = instance.default_value diff --git a/engine/apps/api/views/user.py b/engine/apps/api/views/user.py index ee0a75de..e7d20a32 100644 --- a/engine/apps/api/views/user.py +++ b/engine/apps/api/views/user.py @@ -34,6 +34,7 @@ from apps.auth_token.models import UserScheduleExportAuthToken from apps.auth_token.models.mobile_app_auth_token import MobileAppAuthToken from apps.auth_token.models.mobile_app_verification_token import MobileAppVerificationToken from apps.base.messaging import get_messaging_backend_from_id +from apps.base.utils import live_settings from apps.telegram.client import TelegramClient from apps.telegram.models import TelegramVerificationCode from apps.twilioapp.phone_manager import PhoneManager @@ -56,7 +57,19 @@ class CurrentUserView(APIView): permission_classes = (IsAuthenticated,) def get(self, request): - serializer = UserSerializer(request.user, context={"request": self.request}) + context = {"request": self.request, "format": self.format_kwarg, "view": self} + + if settings.OSS_INSTALLATION and live_settings.GRAFANA_CLOUD_NOTIFICATIONS_ENABLED: + from apps.oss_installation.models import CloudConnector, CloudUserIdentity + + connector = CloudConnector.objects.first() + if connector is not None: + cloud_identities = list(CloudUserIdentity.objects.filter(email__in=[request.user.email])) + cloud_identities = {cloud_identity.email: cloud_identity for cloud_identity in cloud_identities} + context["cloud_identities"] = cloud_identities + context["connector"] = connector + + serializer = UserSerializer(request.user, context=context) return Response(serializer.data) def put(self, request): @@ -179,6 +192,46 @@ class UserView( return queryset.order_by("id") + def list(self, request, *args, **kwargs): + queryset = self.filter_queryset(self.get_queryset()) + + page = self.paginate_queryset(queryset) + if page is not None: + context = {"request": self.request, "format": self.format_kwarg, "view": self} + if settings.OSS_INSTALLATION: + if live_settings.GRAFANA_CLOUD_NOTIFICATIONS_ENABLED: + from apps.oss_installation.models import CloudConnector, CloudUserIdentity + + connector = CloudConnector.objects.first() + if connector is not None: + 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} + context["cloud_identities"] = cloud_identities + context["connector"] = connector + serializer = self.get_serializer(page, many=True, context=context) + return self.get_paginated_response(serializer.data) + + serializer = self.get_serializer(queryset, many=True) + return Response(serializer.data) + + def retrieve(self, request, *args, **kwargs): + context = {"request": self.request, "format": self.format_kwarg, "view": self} + instance = self.get_object() + + if settings.OSS_INSTALLATION and live_settings.GRAFANA_CLOUD_NOTIFICATIONS_ENABLED: + from apps.oss_installation.models import CloudConnector, CloudUserIdentity + + connector = CloudConnector.objects.first() + if connector is not None: + cloud_identities = list(CloudUserIdentity.objects.filter(email__in=[instance.email])) + cloud_identities = {cloud_identity.email: cloud_identity for cloud_identity in cloud_identities} + context["cloud_identities"] = cloud_identities + context["connector"] = connector + + serializer = self.get_serializer(instance, context=context) + return Response(serializer.data) + def current(self, request): serializer = UserSerializer(self.get_queryset().get(pk=self.request.user.pk)) return Response(serializer.data) diff --git a/engine/apps/base/models/live_setting.py b/engine/apps/base/models/live_setting.py index ca3331de..a4594b59 100644 --- a/engine/apps/base/models/live_setting.py +++ b/engine/apps/base/models/live_setting.py @@ -103,7 +103,7 @@ class LiveSetting(models.Model): "SEND_ANONYMOUS_USAGE_STATS": ( "Grafana OnCall will send anonymous, but uniquely-identifiable usage analytics to Grafana Labs." " These statistics are sent to https://stats.grafana.org/. For more information on what's sent, look at" - "https://github.com/..." # TODO: add url to usage stats code + " https://github.com/grafana/oncall/blob/dev/engine/apps/oss_installation/usage_stats.py#L29" ), "GRAFANA_CLOUD_ONCALL_TOKEN": "Secret token for Grafana Cloud OnCall instance.", "GRAFANA_CLOUD_ONCALL_HEARTBEAT_ENABLED": "Enable hearbeat integration with Grafana Cloud OnCall.", diff --git a/engine/apps/oss_installation/cloud_heartbeat.py b/engine/apps/oss_installation/cloud_heartbeat.py new file mode 100644 index 00000000..e94873ec --- /dev/null +++ b/engine/apps/oss_installation/cloud_heartbeat.py @@ -0,0 +1,110 @@ +import logging +import random +from urllib.parse import urljoin + +import requests +from django.apps import apps +from django.conf import settings +from rest_framework import status + +from apps.base.utils import live_settings + +logger = logging.getLogger(__name__) + + +def setup_heartbeat_integration(name=None): + """Setup Grafana Cloud OnCall heartbeat integration.""" + CloudHeartbeat = apps.get_model("oss_installation", "CloudHeartbeat") + + cloud_heartbeat = None + api_token = live_settings.GRAFANA_CLOUD_ONCALL_TOKEN + if not live_settings.GRAFANA_CLOUD_ONCALL_HEARTBEAT_ENABLED or not api_token: + return cloud_heartbeat + # don't specify a team in the data, so heartbeat integration will be created in the General. + name = name or f"OnCall Cloud Heartbeat {settings.BASE_URL}" + data = {"type": "formatted_webhook", "name": name} + url = urljoin(settings.GRAFANA_CLOUD_ONCALL_API_URL, "/api/v1/integrations/") + try: + headers = {"Authorization": api_token} + r = requests.post(url=url, data=data, headers=headers, timeout=5) + if r.status_code == status.HTTP_201_CREATED: + response_data = r.json() + cloud_heartbeat, _ = CloudHeartbeat.objects.update_or_create( + defaults={"integration_id": response_data["id"], "integration_url": response_data["heartbeat"]["link"]} + ) + if r.status_code == status.HTTP_400_BAD_REQUEST: + response_data = r.json() + error = response_data["detail"] + if error == "Integration with this name already exists": + response = requests.get(url=f"{url}?name={name}", headers=headers) + integrations = response.json().get("results", []) + if len(integrations) == 1: + integration = integrations[0] + cloud_heartbeat, updated = CloudHeartbeat.objects.update_or_create( + defaults={ + "integration_id": integration["id"], + "integration_url": integration["heartbeat"]["link"], + } + ) + else: + setup_heartbeat_integration(f"{name}{ random.randint(1, 1024)}") + except requests.Timeout: + logger.warning("Unable to create cloud heartbeat integration. Request timeout.") + except requests.exceptions.RequestException as e: + logger.warning(f"Unable to create cloud heartbeat integration. Request exception {str(e)}.") + return cloud_heartbeat + + +def send_cloud_heartbeat(): + CloudHeartbeat = apps.get_model("oss_installation", "CloudHeartbeat") + CloudConnector = apps.get_model("oss_installation", "CloudConnector") + """Send heartbeat to Grafana Cloud OnCall integration.""" + if not live_settings.GRAFANA_CLOUD_ONCALL_HEARTBEAT_ENABLED or not live_settings.GRAFANA_CLOUD_ONCALL_TOKEN: + logger.info( + "Unable to send cloud heartbeat. Check values for GRAFANA_CLOUD_ONCALL_HEARTBEAT_ENABLED and GRAFANA_CLOUD_ONCALL_TOKEN." + ) + return + connector = CloudConnector.objects.first() + if connector is None: + logger.info("Unable to send cloud heartbeat. Cloud is not connected") + return + logger.info("Start send cloud heartbeat") + try: + cloud_heartbeat = CloudHeartbeat.objects.get() + except CloudHeartbeat.DoesNotExist: + cloud_heartbeat = setup_heartbeat_integration() + + if cloud_heartbeat is None: + logger.warning("Unable to setup cloud heartbeat integration.") + return + cloud_heartbeat.success = False + try: + response = requests.get(cloud_heartbeat.integration_url, timeout=5) + logger.info(f"Send cloud heartbeat with response {response.status_code}") + except requests.Timeout: + logger.warning("Unable to send cloud heartbeat. Request timeout.") + except requests.exceptions.RequestException as e: + logger.warning(f"Unable to send cloud heartbeat. Request exception {str(e)}.") + else: + if response.status_code == status.HTTP_200_OK: + cloud_heartbeat.success = True + logger.info("Successfully send cloud heartbeat") + elif response.status_code == status.HTTP_403_FORBIDDEN: + # check for 403 because AlertChannelDefiningMixin returns 403 if no integration was found. + logger.info("Failed to send cloud heartbeat. Integration was not created yet") + # force re-creation on next run + cloud_heartbeat.delete() + else: + logger.info(f"Failed to send cloud heartbeat. response {response.status_code}") + # save result of cloud heartbeat if it wasn't deleted + if cloud_heartbeat.pk is not None: + cloud_heartbeat.save() + logger.info("Finish send cloud heartbeat") + + +def get_heartbeat_link(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/serializers/cloud_user.py b/engine/apps/oss_installation/serializers/cloud_user.py index 228a33c9..53ccd808 100644 --- a/engine/apps/oss_installation/serializers/cloud_user.py +++ b/engine/apps/oss_installation/serializers/cloud_user.py @@ -1,9 +1,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 CloudConnector, CloudUserIdentity +from apps.oss_installation.utils import cloud_user_identity_status from apps.user_management.models import User @@ -15,23 +13,8 @@ class CloudUserSerializer(serializers.ModelSerializer): 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_user_identity = CloudUserIdentity.objects.filter(email=obj.email).first() + status, link = cloud_user_identity_status(connector, cloud_user_identity) 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 2bb54991..56e3678a 100644 --- a/engine/apps/oss_installation/tasks.py +++ b/engine/apps/oss_installation/tasks.py @@ -1,13 +1,9 @@ -from urllib.parse import urljoin - -import requests from celery.utils.log import get_task_logger -from django.conf import settings +from django.apps import apps from django.utils import timezone -from rest_framework import status from apps.base.utils import live_settings -from apps.oss_installation.models import CloudConnector, CloudHeartbeat, OssInstallation +from apps.oss_installation.cloud_heartbeat import send_cloud_heartbeat from apps.oss_installation.usage_stats import UsageStatsService from common.custom_celery_tasks import shared_dedicated_queue_retry_task @@ -17,6 +13,8 @@ logger = get_task_logger(__name__) @shared_dedicated_queue_retry_task() def send_usage_stats_report(): logger.info("Start send_usage_stats_report") + OssInstallation = apps.get_model("oss_installation", "OssInstallation") + installation = OssInstallation.objects.get_or_create()[0] enabled = live_settings.SEND_ANONYMOUS_USAGE_STATS if enabled: @@ -30,80 +28,24 @@ def send_usage_stats_report(): logger.info("Finish send_usage_stats_report") -def _setup_heartbeat_integration(): - """Setup Grafana Cloud OnCall heartbeat integration.""" - cloud_heartbeat = None - api_token = live_settings.GRAFANA_CLOUD_ONCALL_TOKEN - # don't specify a team in the data, so heartbeat integration will be created in the General. - data = {"type": "formatted_webhook", "name": f"OnCall {settings.BASE_URL}"} - url = urljoin(settings.GRAFANA_CLOUD_ONCALL_API_URL, "/api/v1/integrations/") - try: - headers = {"Authorization": api_token} - r = requests.post(url=url, data=data, headers=headers, timeout=5) - if r.status_code == status.HTTP_201_CREATED: - response_data = r.json() - cloud_heartbeat, _ = CloudHeartbeat.objects.update_or_create( - defaults={"integration_id": response_data["id"], "integration_url": response_data["heartbeat"]["link"]} - ) - except requests.Timeout: - logger.warning("Unable to create cloud heartbeat integration. Request timeout.") - except requests.exceptions.RequestException as e: - logger.warning(f"Unable to create cloud heartbeat integration. Request exception {str(e)}.") - return cloud_heartbeat - - @shared_dedicated_queue_retry_task() -def send_cloud_heartbeat(): - """Send heartbeat to Grafana Cloud OnCall integration.""" - if not live_settings.GRAFANA_CLOUD_ONCALL_HEARTBEAT_ENABLED or not live_settings.GRAFANA_CLOUD_ONCALL_TOKEN: - logger.info( - "Unable to send cloud heartbeat. Check values for GRAFANA_CLOUD_ONCALL_HEARTBEAT_ENABLED and GRAFANA_CLOUD_ONCALL_TOKEN." - ) - return - - logger.info("Start send cloud heartbeat") - try: - cloud_heartbeat = CloudHeartbeat.objects.get() - except CloudHeartbeat.DoesNotExist: - cloud_heartbeat = _setup_heartbeat_integration() - - if cloud_heartbeat is None: - logger.warning("Unable to setup cloud heartbeat integration.") - return - cloud_heartbeat.success = False - try: - response = requests.get(cloud_heartbeat.integration_url, timeout=5) - logger.info(f"Send cloud heartbeat with response {response.status_code}") - except requests.Timeout: - logger.warning("Unable to send cloud heartbeat. Request timeout.") - except requests.exceptions.RequestException as e: - logger.warning(f"Unable to send cloud heartbeat. Request exception {str(e)}.") - else: - if response.status_code == status.HTTP_200_OK: - cloud_heartbeat.success = True - logger.info("Successfully send cloud heartbeat") - elif response.status_code == status.HTTP_403_FORBIDDEN: - # check for 403 because AlertChannelDefiningMixin returns 403 if no integration was found. - logger.info("Failed to send cloud heartbeat. Integration was not created yet") - # force re-creation on next run - cloud_heartbeat.delete() - else: - logger.info(f"Failed to send cloud heartbeat. response {response.status_code}") - # save result of cloud heartbeat if it wasn't deleted - if cloud_heartbeat.pk is not None: - cloud_heartbeat.save() - logger.info("Finish send cloud heartbeat") +def send_cloud_heartbeat_task(): + send_cloud_heartbeat() @shared_dedicated_queue_retry_task() def sync_users_with_cloud(): + CloudConnector = apps.get_model("oss_installation", "CloudConnector") 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) + if live_settings.GRAFANA_CLOUD_NOTIFICATIONS_ENABLED: + 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") else: - logger.info("Grafana Cloud is not connected") + logger.info("GRAFANA_CLOUD_NOTIFICATIONS_ENABLED is not enabled") diff --git a/engine/apps/oss_installation/urls.py b/engine/apps/oss_installation/urls.py index 9ff5efc2..ddf04020 100644 --- a/engine/apps/oss_installation/urls.py +++ b/engine/apps/oss_installation/urls.py @@ -2,7 +2,7 @@ from django.urls import include, path from common.api_helpers.optional_slash_router import OptionalSlashRouter, optional_slash_path -from .views import CloudConnectionView, CloudUsersView, CloudUserView +from .views import CloudConnectionView, CloudHeartbeatView, CloudUsersView, CloudUserView router = OptionalSlashRouter() router.register("cloud_users", CloudUserView, basename="cloud-users") @@ -11,4 +11,5 @@ urlpatterns = [ 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"), + optional_slash_path("cloud_heartbeat", CloudHeartbeatView.as_view(), name="cloud-heartbeat"), ] diff --git a/engine/apps/oss_installation/usage_stats.py b/engine/apps/oss_installation/usage_stats.py index db90cce8..b3a1bd43 100644 --- a/engine/apps/oss_installation/usage_stats.py +++ b/engine/apps/oss_installation/usage_stats.py @@ -3,11 +3,11 @@ import platform from dataclasses import asdict, dataclass import requests +from django.apps import apps from django.conf import settings from django.db.models import Sum from apps.alerts.models import AlertGroupCounter -from apps.oss_installation.models import OssInstallation from apps.oss_installation.utils import active_oss_users_count USAGE_STATS_URL = "https://stats.grafana.org/oncall-usage-report" @@ -27,9 +27,12 @@ class UsageStatsReport: class UsageStatsService: def get_usage_stats_report(self): + OssInstallation = apps.get_model("oss_installation", "OssInstallation") metrics = {} metrics["active_users_count"] = active_oss_users_count() - total_alert_groups = AlertGroupCounter.objects.aggregate(Sum("value")).get("value__sum", 0) + total_alert_groups = AlertGroupCounter.objects.aggregate(Sum("value")).get("value__sum", None) + if total_alert_groups is None: + total_alert_groups = 0 metrics["alert_groups_count"] = total_alert_groups usage_stats_id = OssInstallation.objects.get_or_create()[0].installation_id diff --git a/engine/apps/oss_installation/utils.py b/engine/apps/oss_installation/utils.py index c8a0e65b..4aad084a 100644 --- a/engine/apps/oss_installation/utils.py +++ b/engine/apps/oss_installation/utils.py @@ -1,8 +1,10 @@ import logging +from urllib.parse import urljoin from django.apps import apps from django.utils import timezone +from apps.oss_installation import constants as oss_constants from apps.schedules.ical_utils import list_users_to_notify_from_ical_for_period logger = logging.getLogger(__name__) @@ -65,3 +67,20 @@ def active_oss_users_count(): unique_active_users.add(user.pk) return len(unique_active_users) + + +def cloud_user_identity_status(connector, identity): + link = None + if connector is None: + status = oss_constants.CLOUD_NOT_SYNCED + elif identity is None: + status = oss_constants.CLOUD_SYNCED_USER_NOT_FOUND + link = connector.cloud_url + else: + if identity.phone_number_verified: + status = oss_constants.CLOUD_SYNCED_PHONE_VERIFIED + else: + status = oss_constants.CLOUD_SYNCED_PHONE_NOT_VERIFIED + + link = urljoin(connector.cloud_url, f"a/grafana-oncall-app/?page=users&p=1&id={identity.cloud_id}") + return status, link diff --git a/engine/apps/oss_installation/views/__init__.py b/engine/apps/oss_installation/views/__init__.py index 2b206cac..b3c50ba3 100644 --- a/engine/apps/oss_installation/views/__init__.py +++ b/engine/apps/oss_installation/views/__init__.py @@ -1,2 +1,3 @@ from .cloud_connection import CloudConnectionView # noqa: F401 +from .cloud_heartbeat import CloudHeartbeatView # 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 index 6acbef57..21b6624c 100644 --- a/engine/apps/oss_installation/views/cloud_connection.py +++ b/engine/apps/oss_installation/views/cloud_connection.py @@ -1,5 +1,3 @@ -from urllib.parse import urljoin - from rest_framework import status from rest_framework.permissions import IsAuthenticated from rest_framework.response import Response @@ -7,7 +5,9 @@ from rest_framework.views import APIView from apps.api.permissions import IsAdmin from apps.auth_token.auth import PluginAuthentication +from apps.base.models import LiveSetting from apps.base.utils import live_settings +from apps.oss_installation.cloud_heartbeat import get_heartbeat_link from apps.oss_installation.models import CloudConnector, CloudHeartbeat @@ -22,19 +22,16 @@ class CloudConnectionView(APIView): "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_link": 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): + s = LiveSetting.objects.filter(name="GRAFANA_CLOUD_ONCALL_TOKEN").first() + if s is not None: + s.value = None + s.save() connector = CloudConnector.objects.first() if connector is None: return Response(status=status.HTTP_404_NOT_FOUND) diff --git a/engine/apps/oss_installation/views/cloud_heartbeat.py b/engine/apps/oss_installation/views/cloud_heartbeat.py new file mode 100644 index 00000000..932087c3 --- /dev/null +++ b/engine/apps/oss_installation/views/cloud_heartbeat.py @@ -0,0 +1,27 @@ +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.oss_installation.cloud_heartbeat import get_heartbeat_link, setup_heartbeat_integration +from apps.oss_installation.models import CloudConnector, CloudHeartbeat + + +class CloudHeartbeatView(APIView): + authentication_classes = (PluginAuthentication,) + permission_classes = (IsAuthenticated, IsAdmin) + + def post(self, request): + connector = CloudConnector.objects.first() + if connector is not None: + try: + CloudHeartbeat.objects.get() + return Response(status=status.HTTP_400_BAD_REQUEST, data={"detail": "Cloud heartbeat already exists"}) + except CloudHeartbeat.DoesNotExist: + heartbeat = setup_heartbeat_integration() + link = get_heartbeat_link(connector, heartbeat) + return Response(status=status.HTTP_200_OK, data={"link": link}) + else: + return Response(status=status.HTTP_400_BAD_REQUEST, data={"detail": "Grafana Cloud is not connected"}) diff --git a/engine/apps/oss_installation/views/cloud_users.py b/engine/apps/oss_installation/views/cloud_users.py index 2f740b64..3eb7685b 100644 --- a/engine/apps/oss_installation/views/cloud_users.py +++ b/engine/apps/oss_installation/views/cloud_users.py @@ -1,4 +1,4 @@ -from urllib.parse import urljoin +from collections import OrderedDict from rest_framework import mixins, status, viewsets from rest_framework.decorators import action @@ -6,11 +6,11 @@ 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.oss_installation.utils import cloud_user_identity_status from apps.user_management.models import User from common.api_helpers.mixins import PublicPrimaryKeyMixin from common.api_helpers.paginators import HundredPageSizePaginator @@ -28,10 +28,10 @@ class CloudUsersView(HundredPageSizePaginator, APIView): if request.user.current_team is not None: queryset = queryset.filter(teams=request.user.current_team).distinct() + emails = list(queryset.values_list("email", flat=True)) 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} @@ -40,20 +40,8 @@ class CloudUsersView(HundredPageSizePaginator, APIView): 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}" - ) - + cloud_identity = cloud_identities.get(user.email, None) + status, link = cloud_user_identity_status(connector, cloud_identity) response.append( { "id": user.public_primary_key, @@ -63,7 +51,20 @@ class CloudUsersView(HundredPageSizePaginator, APIView): } ) - return self.get_paginated_response(response) + return self.get_paginated_response_with_matched_users_count(response, len(cloud_identities)) + + def get_paginated_response_with_matched_users_count(self, data, matched_users_count): + return Response( + OrderedDict( + [ + ("count", self.page.paginator.count), + ("matched_users_count", matched_users_count), + ("next", self.get_next_link()), + ("previous", self.get_previous_link()), + ("results", data), + ] + ) + ) def post(self, request): connector = CloudConnector.objects.first() diff --git a/engine/apps/public_api/views/integrations.py b/engine/apps/public_api/views/integrations.py index 0e5fac35..447c5b2b 100644 --- a/engine/apps/public_api/views/integrations.py +++ b/engine/apps/public_api/views/integrations.py @@ -41,6 +41,10 @@ class IntegrationView( queryset = AlertReceiveChannel.objects.filter(organization=self.request.auth.organization).order_by( "created_at" ) + name = self.request.query_params.get("name", None) + if name is not None: + queryset = queryset.filter(verbal_name=name) + queryset = self.filter_queryset(queryset) queryset = self.serializer_class.setup_eager_loading(queryset) queryset = queryset.annotate(alert_groups_count_annotated=Count("alert_groups", distinct=True)) return queryset diff --git a/engine/settings/all_in_one.py b/engine/settings/all_in_one.py index 221edd52..5c90e3e3 100644 --- a/engine/settings/all_in_one.py +++ b/engine/settings/all_in_one.py @@ -1,5 +1,4 @@ import sys -from random import randrange from .prod_without_db import * # noqa @@ -37,27 +36,3 @@ CELERY_BROKER_URL = "redis://localhost:6379/0" if TESTING: TELEGRAM_TOKEN = "0000000000:XXXXXXXXXXXXXXXXXXXXXXXXXXXX-XXXXXX" TWILIO_AUTH_TOKEN = "twilio_auth_token" - -# 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 - -CELERY_BEAT_SCHEDULE["send_usage_stats"] = { # noqa - "task": "apps.oss_installation.tasks.send_usage_stats_report", - "schedule": crontab(hour=0, minute=randrange(0, 59)), # Send stats report at a random minute past midnight # noqa - "args": (), -} # noqa - -CELERY_BEAT_SCHEDULE["send_cloud_heartbeat"] = { # noqa - "task": "apps.oss_installation.tasks.send_cloud_heartbeat", - "schedule": crontab(minute="*/3"), # noqa - "args": (), -} # noqa - -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 9b6cca81..b24c2f17 100644 --- a/engine/settings/base.py +++ b/engine/settings/base.py @@ -1,4 +1,5 @@ import os +from random import randrange from urllib.parse import urljoin from celery.schedules import crontab @@ -7,8 +8,8 @@ from common.utils import getenv_boolean VERSION = "dev-oss" # Indicates if instance is OSS installation. -# It is needed to plug-in oss urls. -OSS_INSTALLATION = getenv_boolean("OSS", False) +# It is needed to plug-in oss application and urls. +OSS_INSTALLATION = getenv_boolean("GRAFANA_ONCALL_OSS_INSTALLATION", True) SEND_ANONYMOUS_USAGE_STATS = getenv_boolean("SEND_ANONYMOUS_USAGE_STATS", default=True) # License is OpenSource or Cloud @@ -441,3 +442,26 @@ INSTALLED_ONCALL_INTEGRATIONS = [ "config_integrations.manual", "config_integrations.slack_channel", ] + +if OSS_INSTALLATION: + INSTALLED_APPS += ["apps.oss_installation"] # noqa + + CELERY_BEAT_SCHEDULE["send_usage_stats"] = { # noqa + "task": "apps.oss_installation.tasks.send_usage_stats_report", + "schedule": crontab( + hour=0, minute=randrange(0, 59) + ), # Send stats report at a random minute past midnight # noqa + "args": (), + } # noqa + + CELERY_BEAT_SCHEDULE["send_cloud_heartbeat"] = { # noqa + "task": "apps.oss_installation.tasks.send_cloud_heartbeat_task", + "schedule": crontab(minute="*/3"), # noqa + "args": (), + } # noqa + + 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/ci-test.py b/engine/settings/ci-test.py index 5389cbd5..f3c012a0 100644 --- a/engine/settings/ci-test.py +++ b/engine/settings/ci-test.py @@ -27,5 +27,3 @@ 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/engine/settings/hobby.py b/engine/settings/hobby.py index 4b2a4e8f..3bd73c13 100644 --- a/engine/settings/hobby.py +++ b/engine/settings/hobby.py @@ -36,22 +36,3 @@ MIRAGE_CIPHER_IV = "1234567890abcdef" # use default APPEND_SLASH = False SECURE_SSL_REDIRECT = False - -# TODO: OSS: Add these setting to oss settings file. Add Version there too. -OSS_INSTALLATION_FEATURES_ENABLED = True - -INSTALLED_APPS += ["apps.oss_installation"] # noqa - -CELERY_BEAT_SCHEDULE["send_usage_stats"] = { # noqa - "task": "apps.oss_installation.tasks.send_usage_stats_report", - "schedule": crontab(hour=0, minute=randrange(0, 59)), # Send stats report at a random minute past midnight # noqa - "args": (), -} # noqa - -CELERY_BEAT_SCHEDULE["send_cloud_heartbeat"] = { # noqa - "task": "apps.oss_installation.tasks.send_cloud_heartbeat", - "schedule": crontab(minute="*/3"), # noqa - "args": (), -} # noqa - -SEND_ANONYMOUS_USAGE_STATS = True diff --git a/grafana-plugin/src/components/Policy/NotificationPolicy.tsx b/grafana-plugin/src/components/Policy/NotificationPolicy.tsx index 040f4b43..29254a04 100644 --- a/grafana-plugin/src/components/Policy/NotificationPolicy.tsx +++ b/grafana-plugin/src/components/Policy/NotificationPolicy.tsx @@ -35,7 +35,7 @@ export interface NotificationPolicyProps { waitDelays?: WaitDelay[]; notifyByOptions?: NotifyBy[]; telegramVerified: boolean; - phoneVerified: boolean; + phoneStatus: number; color: string; number: number; userAction: UserAction; @@ -115,13 +115,21 @@ export class NotificationPolicy extends React.ComponentPhone number is verified - ) : ( - Phone number is not verified - ); + switch (phoneStatus) { + case 0: + return Cloud is not synced; + case 1: + return User is not matched with cloud; + case 2: + return Phone number is not verified; + case 3: + return Phone number is verified; + + default: + return null; + } } _renderTelegramNote() { diff --git a/grafana-plugin/src/containers/PersonalNotificationSettings/PersonalNotificationSettings.tsx b/grafana-plugin/src/containers/PersonalNotificationSettings/PersonalNotificationSettings.tsx index 4812b0ae..a9d2205e 100644 --- a/grafana-plugin/src/containers/PersonalNotificationSettings/PersonalNotificationSettings.tsx +++ b/grafana-plugin/src/containers/PersonalNotificationSettings/PersonalNotificationSettings.tsx @@ -12,6 +12,7 @@ import Timeline from 'components/Timeline/Timeline'; import { WithPermissionControl } from 'containers/WithPermissionControl/WithPermissionControl'; import { NotificationPolicyType } from 'models/notification_policy'; import { User as UserType } from 'models/user/user.types'; +import { AppFeature } from 'state/features'; import { useStore } from 'state/useStore'; import { UserAction } from 'state/userAction'; @@ -105,6 +106,12 @@ const PersonalNotificationSettings = observer((props: PersonalNotificationSettin const user = userStore.items[userPk]; const userAction = isCurrent ? UserAction.UpdateOwnSettings : UserAction.UpdateNotificationPolicies; + const getPhoneStatus = () => { + if (store.hasFeature(AppFeature.CloudNotifications)) { + return user.cloud_connection_status; + } + return Number(user.verified_phone_number) + 2; + }; return (
@@ -124,7 +131,7 @@ const PersonalNotificationSettings = observer((props: PersonalNotificationSettin index={index} number={index + 1} telegramVerified={Boolean(user.telegram_configuration)} - phoneVerified={Boolean(user && user.verified_phone_number)} + phoneStatus={getPhoneStatus()} slackTeamIdentity={store.teamStore.currentTeam?.slack_team_identity} slackUserIdentity={user.slack_user_identity} data={notificationPolicy} diff --git a/grafana-plugin/src/containers/UserSettings/parts/connectors/PhoneConnector.tsx b/grafana-plugin/src/containers/UserSettings/parts/connectors/PhoneConnector.tsx index b00cf868..05ec4509 100644 --- a/grafana-plugin/src/containers/UserSettings/parts/connectors/PhoneConnector.tsx +++ b/grafana-plugin/src/containers/UserSettings/parts/connectors/PhoneConnector.tsx @@ -1,11 +1,12 @@ import React, { useCallback } from 'react'; -import { Button, Label } from '@grafana/ui'; +import { Button, Label, VerticalGroup } from '@grafana/ui'; import cn from 'classnames/bind'; import Text from 'components/Text/Text'; import { UserSettingsTab } from 'containers/UserSettings/UserSettings.types'; import { User } from 'models/user/user.types'; +import { AppFeature } from 'state/features'; import { useStore } from 'state/useStore'; import styles from './index.module.css'; @@ -29,31 +30,85 @@ const PhoneConnector = (props: PhoneConnectorProps) => { onTabChange(UserSettingsTab.PhoneVerification); }, [storeUser?.unverified_phone_number]); + const cloudVersionPhone = (user: User) => { + switch (user.cloud_connection_status) { + case 0: + return Cloud is not synced; + + case 1: + return ( + + User is not matched with cloud + + + ); + + case 2: + return ( + + Phone number is not verified in Grafana Cloud + + + ); + case 3: + return ( + + Phone number verified + + + ); + default: + return ( + + User is not matched with cloud + + + ); + } + }; + return (
- - {storeUser.verified_phone_number || '—'} - {storeUser.verified_phone_number ? ( -
- Phone number is verified - -
- ) : storeUser.unverified_phone_number ? ( -
- Phone number is not verified - -
+ {store.hasFeature(AppFeature.CloudNotifications) ? ( + <> + + {cloudVersionPhone(storeUser)} + ) : ( -
- Phone number is not added - -
+ <> + + {storeUser.verified_phone_number || '—'} + {storeUser.verified_phone_number ? ( +
+ Phone number is verified + +
+ ) : storeUser.unverified_phone_number ? ( +
+ Phone number is not verified + +
+ ) : ( +
+ Phone number is not added + +
+ )} + )}
); diff --git a/grafana-plugin/src/containers/UserSettings/parts/connectors/index.module.css b/grafana-plugin/src/containers/UserSettings/parts/connectors/index.module.css index 0e32b304..04f4550e 100644 --- a/grafana-plugin/src/containers/UserSettings/parts/connectors/index.module.css +++ b/grafana-plugin/src/containers/UserSettings/parts/connectors/index.module.css @@ -30,3 +30,7 @@ .warning-icon { color: var(--warning-text-color); } + +.error-message { + color: var(--error-text-color); +} 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 842869f7..724b4712 100644 --- a/grafana-plugin/src/containers/UserSettings/parts/tabs/CloudPhoneSettings/CloudPhoneSettings.tsx +++ b/grafana-plugin/src/containers/UserSettings/parts/tabs/CloudPhoneSettings/CloudPhoneSettings.tsx @@ -48,12 +48,15 @@ const CloudPhoneSettings = observer((props: CloudPhoneSettingsProps) => { }, []); const handleLinkClick = (link: string) => { - getLocationSrv().update({ partial: false, path: link }); + window.location.replace(link); }; const syncUser = async () => { setSyncing(true); await store.cloudStore.syncCloudUser(userPk); + const cloudUser = await store.cloudStore.getCloudUser(userPk); + setUserStatus(cloudUser?.cloud_data?.status); + setUserLink(cloudUser?.cloud_data?.link); setSyncing(false); }; diff --git a/grafana-plugin/src/index.css b/grafana-plugin/src/index.css index 93b9dfe1..eeeff2de 100644 --- a/grafana-plugin/src/index.css +++ b/grafana-plugin/src/index.css @@ -30,13 +30,13 @@ background: var(--highlighted-row-bg); } -@media (max-width: 1440px) { +@media (max-width: 1540px) { .page-header__tabs > ul > li > a > div { display: none; } } -@media (max-width: 1200px) { +@media (max-width: 1300px) { .sidemenu { position: fixed !important; height: 100%; diff --git a/grafana-plugin/src/models/base_store.ts b/grafana-plugin/src/models/base_store.ts index 9af0c5d4..ab46fe3c 100644 --- a/grafana-plugin/src/models/base_store.ts +++ b/grafana-plugin/src/models/base_store.ts @@ -52,10 +52,11 @@ export default class BaseStore { } @action - async update(id: any, data: any) { + async update(id: any, data: any, params: any = null) { const result = await makeRequest(`${this.path}${id}/`, { method: 'PUT', data, + params: params, }).catch(this.onApiError); // Update env_status field for current team diff --git a/grafana-plugin/src/models/cloud/cloud.ts b/grafana-plugin/src/models/cloud/cloud.ts index d917075b..fa19125a 100644 --- a/grafana-plugin/src/models/cloud/cloud.ts +++ b/grafana-plugin/src/models/cloud/cloud.ts @@ -13,7 +13,7 @@ import { Cloud } from './cloud.types'; export class CloudStore extends BaseStore { @observable.shallow - searchResult: { count?: number; results?: Array } = {}; + searchResult: { matched_users_count?: number; results?: Array } = {}; @observable.shallow items: { [id: string]: Cloud } = {}; @@ -26,7 +26,7 @@ export class CloudStore extends BaseStore { @action async updateItems(page = 1) { - const { count, results } = await makeRequest(this.path, { + const { matched_users_count, results } = await makeRequest(this.path, { params: { page }, }); @@ -42,14 +42,14 @@ export class CloudStore extends BaseStore { }; this.searchResult = { - count, + matched_users_count, results: results.map((item: Cloud) => item.id), }; } getSearchResult() { return { - count: this.searchResult.count, + matched_users_count: this.searchResult.matched_users_count, results: this.searchResult.results && this.searchResult.results.map((id: Cloud['id']) => this.items?.[id]), }; } @@ -62,6 +62,12 @@ export class CloudStore extends BaseStore { return await makeRequest(`${this.path}${id}/sync/`, { method: 'POST' }); } + async getCloudHeartbeat() { + return await makeRequest(`/cloud_heartbeat/`, { method: 'POST' }).catch((error) => { + console.log(error); + }); + } + async getCloudUser(id: string) { return await makeRequest(`${this.path}${id}`, { method: 'GET' }); } diff --git a/grafana-plugin/src/models/user/user.types.ts b/grafana-plugin/src/models/user/user.types.ts index 4dd3f00a..4f1ba2ed 100644 --- a/grafana-plugin/src/models/user/user.types.ts +++ b/grafana-plugin/src/models/user/user.types.ts @@ -52,4 +52,5 @@ export interface User { export_url?: string; status?: number; link?: string; + cloud_connection_status?: number; } diff --git a/grafana-plugin/src/pages/cloud/CloudPage.module.css b/grafana-plugin/src/pages/cloud/CloudPage.module.css index 14f11ba5..416d2a70 100644 --- a/grafana-plugin/src/pages/cloud/CloudPage.module.css +++ b/grafana-plugin/src/pages/cloud/CloudPage.module.css @@ -25,7 +25,8 @@ height: 32px; } -.cloud-page-title { +.cloud-page-title, +.heartbit-button { margin-top: 24px; } diff --git a/grafana-plugin/src/pages/cloud/CloudPage.tsx b/grafana-plugin/src/pages/cloud/CloudPage.tsx index 02ae2493..d81ce0c9 100644 --- a/grafana-plugin/src/pages/cloud/CloudPage.tsx +++ b/grafana-plugin/src/pages/cloud/CloudPage.tsx @@ -41,8 +41,9 @@ const CloudPage = observer((props: CloudPageProps) => { 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 [cloudNotificationsEnabled, setCloudNotificationsEnabled] = useState(false); + const [heartbeatLink, setheartbeatLink] = useState(null); + const [heartbeatEnabled, setheartbeatEnabled] = useState(false); const [showConfirmationModal, setShowConfirmationModal] = useState(false); const [syncingUsers, setSyncingUsers] = useState(false); @@ -50,13 +51,13 @@ const CloudPage = observer((props: CloudPageProps) => { 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(); + setheartbeatEnabled(cloudStatus.cloud_heartbeat_enabled); + setheartbeatLink(cloudStatus.cloud_heartbeat_link); + setCloudNotificationsEnabled(cloudStatus.cloud_notifications_enabled); }); }, [cloudIsConnected]); - const { count, results } = store.cloudStore.getSearchResult(); + const { matched_users_count, results } = store.cloudStore.getSearchResult(); const handleChangePage = (page: number) => { setPage(page); @@ -77,18 +78,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'); store.globalSettingStore - .update(globalSettingItem?.id, { name: 'GRAFANA_CLOUD_ONCALL_TOKEN', value: cloudApiKey }) - .then((response) => { + .update(globalSettingItem?.id, { name: 'GRAFANA_CLOUD_ONCALL_TOKEN', value: cloudApiKey }, { sync_users: false }) + .then(async (response) => { if (response.error) { setCloudIsConnected(false); setApiKeyError(true); @@ -96,6 +91,8 @@ const CloudPage = observer((props: CloudPageProps) => { } else { setCloudIsConnected(true); syncUsers(); + const heartbeatData: { link: string } = await store.cloudStore.getCloudHeartbeat(); + setheartbeatLink(heartbeatData?.link); } }); }; @@ -108,7 +105,7 @@ const CloudPage = observer((props: CloudPageProps) => { }; const handleLinkClick = (link: string) => { - getLocationSrv().update({ partial: false, path: link }); + window.location.replace(link); }; const renderButtons = (user: Cloud) => { @@ -246,67 +243,87 @@ const CloudPage = observer((props: CloudPageProps) => { 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 && ( - - )} +
+ {heartbeatEnabled ? ( + heartbeatLink ? ( + + ) : ( + Heartbeat will be created in a moment automatically + ) + ) : ( + Heartbeat is not enabled. You can go to the Env Variables tab and enable it + )} +
- - - SMS and phone call notifications - + {cloudNotificationsEnabled ? ( + + + 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.' + } + + + ( +
+ + + {matched_users_count ? matched_users_count : 0} user + {matched_users_count === 1 ? '' : 's'} + {` matched between OSS and Cloud OnCall`} + + {syncingUsers ? ( + + ) : ( + + )} + +
+ )} + rowKey="id" + // @ts-ignore + columns={columns} + data={results} + pagination={{ + page, + total: Math.ceil((matched_users_count || 0) / ITEMS_PER_PAGE), + onChange: handleChangePage, + }} + /> +
+ + ) : ( + + + 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.' - } + {'Please enable Grafana cloud notification to be able to see list of cloud users'} - - ( -
- - - {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, - }} - /> -
-
+
+ )}
); @@ -324,7 +341,7 @@ const CloudPage = observer((props: CloudPageProps) => { style={{ width: '100%' }} invalid={apiKeyError} > - +