Add cloud connection statuses on user page (#34)

* Add cloud connection statuses on user page

* Add fixes for oncall hobby docker-compose installation

* Fix for links and for cloud user status at User settings

* Delete cloud api token on cloud disconnect

* Merge branch 'dev' into cloud_connection_statuses_on_user_page

* Fix cloud statuses for users

* Fix usagestats service

* Fix phone verification message in users table

* added request after syncing user

* Add endpoint to create CloudHeartbeat and polish code

* Fix imports

* Check token and heartbeat setting in setup_hertbeat_integration

* Add macthed_users_count in cloud users

* Sync users on token change

* Fix query param

* Fix tests

* Heartbit button logic, tab width fix, coount users fix

* Solve problem of existent cloud heartbeat integration

* Solve problem of existent cloud heartbeat integration 2

* Solve problem of existent cloud heartbeat integration 3

* fix build

* build fix, styles for env variables description

Co-authored-by: Ildar Iskhakov <ildar.iskhakov@grafana.com>
Co-authored-by: Yulia Shanyrova <yulia.shanyrova@grafana.com>
This commit is contained in:
Innokentii Konstantinov 2022-06-13 16:29:08 +04:00 committed by GitHub
parent b3add5c9b8
commit 48bfe86d62
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
35 changed files with 590 additions and 295 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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.Component<NotificationPolicyProps,
}
_renderPhoneNote() {
const { phoneVerified } = this.props;
const { phoneStatus } = this.props;
return phoneVerified ? (
<PolicyNote type="success">Phone number is verified</PolicyNote>
) : (
<PolicyNote type="danger">Phone number is not verified</PolicyNote>
);
switch (phoneStatus) {
case 0:
return <PolicyNote type="danger">Cloud is not synced</PolicyNote>;
case 1:
return <PolicyNote type="danger">User is not matched with cloud</PolicyNote>;
case 2:
return <PolicyNote type="danger">Phone number is not verified</PolicyNote>;
case 3:
return <PolicyNote type="success">Phone number is verified</PolicyNote>;
default:
return null;
}
}
_renderTelegramNote() {

View file

@ -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 (
<div className={cx('root')}>
@ -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}

View file

@ -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 <Text className={cx('error-message')}>Cloud is not synced</Text>;
case 1:
return (
<VerticalGroup>
<Text className={cx('error-message')}>User is not matched with cloud</Text>
<Button size="sm" fill="text" onClick={handleClickConfirmPhoneButton}>
Sign Up to Cloud
</Button>
</VerticalGroup>
);
case 2:
return (
<VerticalGroup>
<Text type="warning">Phone number is not verified in Grafana Cloud</Text>
<Button size="sm" fill="text" onClick={handleClickConfirmPhoneButton}>
Verify or change
</Button>
</VerticalGroup>
);
case 3:
return (
<VerticalGroup>
<Text type="success">Phone number verified</Text>
<Button size="sm" fill="text" onClick={handleClickConfirmPhoneButton}>
Change
</Button>
</VerticalGroup>
);
default:
return (
<VerticalGroup>
<Text className={cx('error-message')}>User is not matched with cloud</Text>
<Button size="sm" fill="text" onClick={handleClickConfirmPhoneButton}>
Sign Up to Cloud
</Button>
</VerticalGroup>
);
}
};
return (
<div className={cx('user-item')}>
<Label>Verified phone number:</Label>
<span className={cx('user-value')}>{storeUser.verified_phone_number || '—'}</span>
{storeUser.verified_phone_number ? (
<div>
<Text type="secondary">Phone number is verified</Text>
<Button size="sm" fill="text" onClick={handleClickConfirmPhoneButton}>
Change
</Button>
</div>
) : storeUser.unverified_phone_number ? (
<div>
<Text type="warning">Phone number is not verified</Text>
<Button size="sm" fill="text" onClick={handleClickConfirmPhoneButton}>
Verify or change
</Button>
</div>
{store.hasFeature(AppFeature.CloudNotifications) ? (
<>
<Label>Cloud phone status:</Label>
{cloudVersionPhone(storeUser)}
</>
) : (
<div>
<Text type="warning">Phone number is not added</Text>
<Button size="sm" fill="text" onClick={handleClickConfirmPhoneButton}>
Add
</Button>
</div>
<>
<Label>Verified phone number:</Label>
<span className={cx('user-value')}>{storeUser.verified_phone_number || '—'}</span>
{storeUser.verified_phone_number ? (
<div>
<Text type="secondary">Phone number is verified</Text>
<Button size="sm" fill="text" onClick={handleClickConfirmPhoneButton}>
Change
</Button>
</div>
) : storeUser.unverified_phone_number ? (
<div>
<Text type="warning">Phone number is not verified</Text>
<Button size="sm" fill="text" onClick={handleClickConfirmPhoneButton}>
Verify or change
</Button>
</div>
) : (
<div>
<Text type="warning">Phone number is not added</Text>
<Button size="sm" fill="text" onClick={handleClickConfirmPhoneButton}>
Add
</Button>
</div>
)}
</>
)}
</div>
);

View file

@ -30,3 +30,7 @@
.warning-icon {
color: var(--warning-text-color);
}
.error-message {
color: var(--error-text-color);
}

View file

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

View file

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

View file

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

View file

@ -13,7 +13,7 @@ import { Cloud } from './cloud.types';
export class CloudStore extends BaseStore {
@observable.shallow
searchResult: { count?: number; results?: Array<Cloud['id']> } = {};
searchResult: { matched_users_count?: number; results?: Array<Cloud['id']> } = {};
@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' });
}

View file

@ -52,4 +52,5 @@ export interface User {
export_url?: string;
status?: number;
link?: string;
cloud_connection_status?: number;
}

View file

@ -25,7 +25,8 @@
height: 32px;
}
.cloud-page-title {
.cloud-page-title,
.heartbit-button {
margin-top: 24px;
}

View file

@ -41,8 +41,9 @@ const CloudPage = observer((props: CloudPageProps) => {
const [cloudApiKey, setCloudApiKey] = useState<string>('');
const [apiKeyError, setApiKeyError] = useState<boolean>(false);
const [cloudIsConnected, setCloudIsConnected] = useState<boolean>(undefined);
const [heartbitLink, setHeartbitLink] = useState<string>(null);
const [heartbitStatus, setHeartbitStatus] = useState<boolean>(false);
const [cloudNotificationsEnabled, setCloudNotificationsEnabled] = useState<boolean>(false);
const [heartbeatLink, setheartbeatLink] = useState<string>(null);
const [heartbeatEnabled, setheartbeatEnabled] = useState<boolean>(false);
const [showConfirmationModal, setShowConfirmationModal] = useState<boolean>(false);
const [syncingUsers, setSyncingUsers] = useState<boolean>(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.
</Text>
{heartbitStatus && heartbitLink && (
<Button
variant="secondary"
icon="external-link-alt"
className={cx('block-button')}
onClick={() => handleLinkClick(heartbitLink)}
>
Configure escalations in Cloud OnCall
</Button>
)}
<div className={cx('heartbeat-button')}>
{heartbeatEnabled ? (
heartbeatLink ? (
<Button
variant="secondary"
icon="external-link-alt"
className={cx('block-button')}
onClick={() => handleLinkClick(heartbeatLink)}
>
Configure escalations in Cloud OnCall
</Button>
) : (
<Text type="secondary">Heartbeat will be created in a moment automatically</Text>
)
) : (
<Text type="secondary">Heartbeat is not enabled. You can go to the Env Variables tab and enable it</Text>
)}
</div>
</VerticalGroup>
</Block>
<Block bordered withBackground className={cx('info-block')}>
<VerticalGroup>
<Text.Title level={4}>
<Icon name="bell" className={cx('block-icon')} size="lg" /> SMS and phone call notifications
</Text.Title>
{cloudNotificationsEnabled ? (
<VerticalGroup>
<Text.Title level={4}>
<Icon name="bell" className={cx('block-icon')} size="lg" /> SMS and phone call notifications
</Text.Title>
<div style={{ width: '100%' }}>
<div style={{ width: '100%' }}>
<Text type="secondary">
{
'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.'
}
</Text>
<GTable
className={cx('user-table')}
rowClassName={cx('user-row')}
showHeader={false}
emptyText={results ? 'No variables found' : 'Loading...'}
title={() => (
<div className={cx('table-title')}>
<HorizontalGroup justify="space-between">
<Text type="secondary">
{matched_users_count ? matched_users_count : 0} user
{matched_users_count === 1 ? '' : 's'}
{` matched between OSS and Cloud OnCall`}
</Text>
{syncingUsers ? (
<Button variant="primary" onClick={syncUsers} icon="sync" disabled>
Syncing...
</Button>
) : (
<Button variant="primary" onClick={syncUsers} icon="sync">
Sync users (Editors and Admins)
</Button>
)}
</HorizontalGroup>
</div>
)}
rowKey="id"
// @ts-ignore
columns={columns}
data={results}
pagination={{
page,
total: Math.ceil((matched_users_count || 0) / ITEMS_PER_PAGE),
onChange: handleChangePage,
}}
/>
</div>
</VerticalGroup>
) : (
<VerticalGroup>
<Text.Title level={4}>
<Icon name="bell" className={cx('block-icon')} size="lg" /> SMS and phone call notifications
</Text.Title>
<Text type="secondary">
{
'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'}
</Text>
<GTable
className={cx('user-table')}
rowClassName={cx('user-row')}
showHeader={false}
emptyText={results ? 'No variables found' : 'Loading...'}
title={() => (
<div className={cx('table-title')}>
<HorizontalGroup justify="space-between">
<Text type="secondary">
{count ? count : 0}
{` users matched between OSS and Cloud OnCall`}
</Text>
{syncingUsers ? (
<Button variant="primary" onClick={syncUsers} icon="sync" disabled>
Syncing...
</Button>
) : (
<Button variant="primary" onClick={syncUsers} icon="sync">
Sync users (Editors and Admins)
</Button>
)}
</HorizontalGroup>
</div>
)}
rowKey="id"
// @ts-ignore
columns={columns}
data={results}
pagination={{
page,
total: Math.ceil((count || 0) / ITEMS_PER_PAGE),
onChange: handleChangePage,
}}
/>
</div>
</VerticalGroup>
</VerticalGroup>
)}
</Block>
</VerticalGroup>
);
@ -324,7 +341,7 @@ const CloudPage = observer((props: CloudPageProps) => {
style={{ width: '100%' }}
invalid={apiKeyError}
>
<Input id="cloudApiKey" onChange={handleChangeCloudApiKey} defaultValue={cloudApiKey} />
<Input id="cloudApiKey" onChange={handleChangeCloudApiKey} />
</Field>
<Button variant="primary" onClick={saveKeyAndConnect} disabled={!cloudApiKey} size="md">
Save key and connect
@ -334,9 +351,9 @@ const CloudPage = observer((props: CloudPageProps) => {
<Block bordered withBackground className={cx('info-block')}>
<VerticalGroup>
<Text.Title level={4}>
<span className={cx('block-icon')}>
<span className={cx('heart-icon')}>
<HeartIcon />
</span>{' '}
</span>
Monitor cloud instance with heartbeat
</Text.Title>
<Text type="secondary">

View file

@ -18,3 +18,7 @@
.check-icon {
color: green;
}
.description-style > a {
color: var(--primary-text-link);
}

View file

@ -197,6 +197,7 @@ class LiveSettings extends React.Component<LiveSettingsProps, LiveSettingsState>
dangerouslySetInnerHTML={{
__html: item.description,
}}
className={cx('description-style')}
/>
);
};

View file

@ -50,3 +50,22 @@
margin-right: 8px;
color: var(--warning-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;
}
.warning-message {
color: var(--warning-text-color);
}
.success-message {
color: var(--success-text-color);
}

View file

@ -14,8 +14,10 @@ import Text from 'components/Text/Text';
import UsersFilters from 'components/UsersFilters/UsersFilters';
import UserSettings from 'containers/UserSettings/UserSettings';
import { WithPermissionControl } from 'containers/WithPermissionControl/WithPermissionControl';
import { CrossCircleIcon } from 'icons';
import { getRole } from 'models/user/user.helpers';
import { User, User as UserType, UserRole } from 'models/user/user.types';
import { AppFeature } from 'state/features';
import { WithStoreProps } from 'state/types';
import { UserAction } from 'state/userAction';
import { withMobXProviderContext } from 'state/withStore';
@ -290,10 +292,37 @@ class Users extends React.Component<UsersProps, UsersState> {
};
renderNote = (user: UserType) => {
if (!user.verified_phone_number || !user.slack_user_identity) {
const { store } = this.props;
let phone_verified;
let phone_verified_message;
if (store.hasFeature(AppFeature.CloudNotifications)) {
// If cloud notifications is enabled show message about its status, not local phone verification.
switch (user.cloud_connection_status) {
case 0:
phone_verified = false;
phone_verified_message = 'Cloud is not synced';
break;
case 1:
phone_verified = false;
phone_verified_message = 'User not matched with cloud';
break;
case 2:
phone_verified = false;
phone_verified_message = 'Phone number is not verified in Grafana Cloud';
break;
case 3:
phone_verified = false;
phone_verified_message = 'Phone number is verified in Grafana Cloud';
break;
}
} else {
phone_verified = user.verified_phone_number;
phone_verified_message = 'Phone not verified';
}
if (!phone_verified || !user.slack_user_identity || !user.telegram_configuration) {
let texts = [];
if (!user.verified_phone_number) {
texts.push('Phone not verified');
if (!phone_verified) {
texts.push(phone_verified_message);
}
if (!user.slack_user_identity) {
texts.push('Slack not verified');