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:
parent
b3add5c9b8
commit
48bfe86d62
35 changed files with 590 additions and 295 deletions
|
|
@ -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 = [
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
],
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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.",
|
||||
|
|
|
|||
110
engine/apps/oss_installation/cloud_heartbeat.py
Normal file
110
engine/apps/oss_installation/cloud_heartbeat.py
Normal 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}")
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
]
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
27
engine/apps/oss_installation/views/cloud_heartbeat.py
Normal file
27
engine/apps/oss_installation/views/cloud_heartbeat.py
Normal 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"})
|
||||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -30,3 +30,7 @@
|
|||
.warning-icon {
|
||||
color: var(--warning-text-color);
|
||||
}
|
||||
|
||||
.error-message {
|
||||
color: var(--error-text-color);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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%;
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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' });
|
||||
}
|
||||
|
|
|
|||
|
|
@ -52,4 +52,5 @@ export interface User {
|
|||
export_url?: string;
|
||||
status?: number;
|
||||
link?: string;
|
||||
cloud_connection_status?: number;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -25,7 +25,8 @@
|
|||
height: 32px;
|
||||
}
|
||||
|
||||
.cloud-page-title {
|
||||
.cloud-page-title,
|
||||
.heartbit-button {
|
||||
margin-top: 24px;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -18,3 +18,7 @@
|
|||
.check-icon {
|
||||
color: green;
|
||||
}
|
||||
|
||||
.description-style > a {
|
||||
color: var(--primary-text-link);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -197,6 +197,7 @@ class LiveSettings extends React.Component<LiveSettingsProps, LiveSettingsState>
|
|||
dangerouslySetInnerHTML={{
|
||||
__html: item.description,
|
||||
}}
|
||||
className={cx('description-style')}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue