Merge pull request #5 from grafana/grafana_cloud_notifications

Grafana cloud notifications
This commit is contained in:
Innokentii Konstantinov 2022-06-09 13:13:03 +04:00 committed by GitHub
commit 111935d552
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
58 changed files with 1671 additions and 162 deletions

View file

@ -1,5 +1,5 @@
from apps.alerts.incident_appearance.templaters.alert_templater import AlertTemplater
from common.utils import clean_markup
from common.utils import clean_markup, escape_for_twilio_phone_call
class AlertPhoneCallTemplater(AlertTemplater):
@ -24,8 +24,4 @@ class AlertPhoneCallTemplater(AlertTemplater):
return sf.format(data)
def _escape(self, data):
# https://www.twilio.com/docs/api/errors/12100
data = data.replace("&", "&")
data = data.replace(">", ">")
data = data.replace("<", "&lt;")
return data
return escape_for_twilio_phone_call(data)

View file

@ -12,6 +12,7 @@ from apps.alerts.constants import NEXT_ESCALATION_DELAY
from apps.alerts.incident_appearance.renderers.web_renderer import AlertGroupWebRenderer
from apps.alerts.signals import user_notification_action_triggered_signal
from apps.base.messaging import get_messaging_backend_from_id
from apps.base.utils import live_settings
from common.custom_celery_tasks import shared_dedicated_queue_retry_task
from .task_logger import task_logger
@ -258,10 +259,20 @@ def perform_notification(log_record_pk):
return
if notification_channel == UserNotificationPolicy.NotificationChannel.SMS:
SMSMessage.send_sms(user, alert_group, notification_policy)
SMSMessage.send_sms(
user,
alert_group,
notification_policy,
is_cloud_notification=live_settings.GRAFANA_CLOUD_NOTIFICATIONS_ENABLED,
)
elif notification_channel == UserNotificationPolicy.NotificationChannel.PHONE_CALL:
PhoneCall.make_call(user, alert_group, notification_policy)
PhoneCall.make_call(
user,
alert_group,
notification_policy,
is_cloud_notification=live_settings.GRAFANA_CLOUD_NOTIFICATIONS_ENABLED,
)
elif notification_channel == UserNotificationPolicy.NotificationChannel.TELEGRAM:
if alert_group.notify_in_telegram_enabled is True:

View file

@ -3,7 +3,13 @@ from django.urls import reverse
from rest_framework import status
from rest_framework.test import APIClient
from apps.api.views.features import FEATURE_LIVE_SETTINGS, FEATURE_SLACK, FEATURE_TELEGRAM
from apps.api.views.features import (
FEATURE_GRAFANA_CLOUD_CONNECTION,
FEATURE_GRAFANA_CLOUD_NOTIFICATIONS,
FEATURE_LIVE_SETTINGS,
FEATURE_SLACK,
FEATURE_TELEGRAM,
)
@pytest.mark.django_db
@ -30,15 +36,24 @@ def test_select_features_all_enabled(
make_user_auth_headers,
):
organization, user, token = make_organization_and_user_with_plugin_token()
settings.OSS_INSTALLATION = True
settings.FEATURE_SLACK_INTEGRATION_ENABLED = True
settings.FEATURE_TELEGRAM_INTEGRATION_ENABLED = True
settings.FEATURE_LIVE_SETTINGS_ENABLED = True
settings.FEATURE_GRAFANA_CLOUD_CONNECTION = True
settings.FEATURE_GRAFANA_CLOUD_NOTIFICATIONS = True
client = APIClient()
url = reverse("api-internal:features")
response = client.get(url, format="json", **make_user_auth_headers(user, token))
assert response.status_code == status.HTTP_200_OK
assert response.json() == [FEATURE_SLACK, FEATURE_TELEGRAM, FEATURE_LIVE_SETTINGS]
assert response.json() == [
FEATURE_SLACK,
FEATURE_TELEGRAM,
FEATURE_GRAFANA_CLOUD_CONNECTION,
FEATURE_LIVE_SETTINGS,
FEATURE_GRAFANA_CLOUD_NOTIFICATIONS,
]
@pytest.mark.django_db
@ -48,9 +63,12 @@ def test_select_features_all_disabled(
make_user_auth_headers,
):
organization, user, token = make_organization_and_user_with_plugin_token()
settings.OSS_INSTALLATION = False
settings.FEATURE_SLACK_INTEGRATION_ENABLED = False
settings.FEATURE_TELEGRAM_INTEGRATION_ENABLED = False
settings.FEATURE_LIVE_SETTINGS_ENABLED = False
settings.FEATURE_GRAFANA_CLOUD_CONNECTION = False
settings.FEATURE_GRAFANA_CLOUD_NOTIFICATIONS = FEATURE_GRAFANA_CLOUD_NOTIFICATIONS
client = APIClient()
url = reverse("api-internal:features")
response = client.get(url, format="json", **make_user_auth_headers(user, token))

View file

@ -4,11 +4,14 @@ from rest_framework.response import Response
from rest_framework.views import APIView
from apps.auth_token.auth import PluginAuthentication
from apps.base.utils import live_settings
FEATURE_SLACK = "slack"
FEATURE_TELEGRAM = "telegram"
FEATURE_LIVE_SETTINGS = "live_settings"
MOBILE_APP_PUSH_NOTIFICATIONS = "mobile_app"
FEATURE_GRAFANA_CLOUD_NOTIFICATIONS = "grafana_cloud_notifications"
FEATURE_GRAFANA_CLOUD_CONNECTION = "grafana_cloud_connection"
class FeaturesAPIView(APIView):
@ -31,9 +34,6 @@ class FeaturesAPIView(APIView):
if settings.FEATURE_TELEGRAM_INTEGRATION_ENABLED:
enabled_features.append(FEATURE_TELEGRAM)
if settings.FEATURE_LIVE_SETTINGS_ENABLED:
enabled_features.append(FEATURE_LIVE_SETTINGS)
if settings.MOBILE_APP_PUSH_NOTIFICATIONS_ENABLED:
DynamicSetting = apps.get_model("base", "DynamicSetting")
mobile_app_settings = DynamicSetting.objects.get_or_create(
@ -48,4 +48,12 @@ class FeaturesAPIView(APIView):
if request.auth.organization.pk in mobile_app_settings.json_value["org_ids"]:
enabled_features.append(MOBILE_APP_PUSH_NOTIFICATIONS)
if settings.OSS_INSTALLATION:
# Features below should be enabled only in OSS
enabled_features.append(FEATURE_GRAFANA_CLOUD_CONNECTION)
if settings.FEATURE_LIVE_SETTINGS_ENABLED:
enabled_features.append(FEATURE_LIVE_SETTINGS)
if live_settings.GRAFANA_CLOUD_NOTIFICATIONS_ENABLED:
enabled_features.append(FEATURE_GRAFANA_CLOUD_NOTIFICATIONS)
return enabled_features

View file

@ -32,7 +32,11 @@ class LiveSettingViewSet(PublicPrimaryKeyMixin, viewsets.ModelViewSet):
def get_queryset(self):
LiveSetting.populate_settings_if_needed()
return LiveSetting.objects.filter(name__in=LiveSetting.AVAILABLE_NAMES).order_by("name")
queryset = LiveSetting.objects.filter(name__in=LiveSetting.AVAILABLE_NAMES).order_by("name")
search = self.request.query_params.get("search", None)
if search:
queryset = queryset.filter(name=search)
return queryset
def perform_update(self, serializer):
new_value = serializer.validated_data["value"]
@ -66,6 +70,17 @@ class LiveSettingViewSet(PublicPrimaryKeyMixin, viewsets.ModelViewSet):
if sti is not None:
unpopulate_slack_user_identities.apply_async((sti.pk, True), countdown=0)
if instance.name == "GRAFANA_CLOUD_ONCALL_TOKEN":
from apps.oss_installation.models import CloudConnector
try:
old_token = live_settings.GRAFANA_CLOUD_ONCALL_TOKEN
except ImproperlyConfigured:
old_token = None
if old_token != new_value:
CloudConnector.remove_sync()
def _reset_telegram_integration(self, new_token):
# tell Telegram to cancel sending events from old bot
with suppress(ImproperlyConfigured, error.InvalidToken, error.Unauthorized):

View file

@ -44,6 +44,7 @@ class LiveSetting(models.Model):
"SEND_ANONYMOUS_USAGE_STATS",
"GRAFANA_CLOUD_ONCALL_TOKEN",
"GRAFANA_CLOUD_ONCALL_HEARTBEAT_ENABLED",
"GRAFANA_CLOUD_NOTIFICATIONS_ENABLED",
)
DESCRIPTIONS = {
@ -106,6 +107,7 @@ class LiveSetting(models.Model):
),
"GRAFANA_CLOUD_ONCALL_TOKEN": "Secret token for Grafana Cloud OnCall instance.",
"GRAFANA_CLOUD_ONCALL_HEARTBEAT_ENABLED": "Enable hearbeat integration with Grafana Cloud OnCall.",
"GRAFANA_CLOUD_NOTIFICATIONS_ENABLED": "Enable SMS/call notifications via Grafana Cloud OnCall",
}
SECRET_SETTING_NAMES = (
@ -171,4 +173,5 @@ class LiveSetting(models.Model):
)
self.error = LiveSettingValidator(live_setting=self).get_error()
super().save(*args, **kwargs)

View file

@ -94,6 +94,13 @@ class LiveSettingValidator:
except Exception as e:
return f"Telegram error: {str(e)}"
@classmethod
def _check_grafana_cloud_oncall_token(cls, grafana_oncall_token):
from apps.oss_installation.models import CloudConnector
_, err = CloudConnector.sync_with_cloud(grafana_oncall_token)
return err
@staticmethod
def _is_email_valid(email):
return re.match(r"^[^@]+@[^@]+\.[^@]+$", email)

View file

@ -0,0 +1,4 @@
CLOUD_NOT_SYNCED = 0
CLOUD_SYNCED_USER_NOT_FOUND = 1
CLOUD_SYNCED_PHONE_NOT_VERIFIED = 2
CLOUD_SYNCED_PHONE_VERIFIED = 3

View file

@ -30,4 +30,20 @@ class Migration(migrations.Migration):
('report_sent_at', models.DateTimeField(default=None, null=True)),
],
),
migrations.CreateModel(
name='CloudConnector',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('cloud_url', models.URLField()),
],
),
migrations.CreateModel(
name='CloudUserIdentity',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('phone_number_verified', models.BooleanField(default=False)),
('cloud_id', models.CharField(max_length=20)),
('email', models.EmailField(max_length=254)),
],
),
]

View file

@ -1,2 +1,4 @@
from .heartbeat import CloudHeartbeat # noqa: F401
from .cloud_connector import CloudConnector # noqa: F401
from .cloud_heartbeat import CloudHeartbeat # noqa: F401
from .cloud_user_identity import CloudUserIdentity # noqa: F401
from .oss_installation import OssInstallation # noqa: F401

View file

@ -0,0 +1,155 @@
import logging
from urllib.parse import urljoin
import requests
from django.db import models, transaction
from apps.base.utils import live_settings
from apps.oss_installation.models.cloud_user_identity import CloudUserIdentity
from apps.user_management.models import User
from common.constants.role import Role
from settings.base import GRAFANA_CLOUD_ONCALL_API_URL
logger = logging.getLogger(__name__)
class CloudConnector(models.Model):
"""
CloudOrganizationConnector model represents connection between oss organization and cloud organization.
"""
cloud_url = models.URLField()
@classmethod
def sync_with_cloud(cls, token=None):
"""
sync_with_cloud sync organization with cloud organization defined by provided GRAFANA_CLOUD_ONCALL_TOKEN.
"""
sync_status = False
error_msg = None
api_token = token or live_settings.GRAFANA_CLOUD_ONCALL_TOKEN
if api_token is None:
logger.warning("Unable to sync with cloud. GRAFANA_CLOUD_ONCALL_TOKEN is not set")
error_msg = "GRAFANA_CLOUD_ONCALL_TOKEN is not set"
else:
info_url = urljoin(GRAFANA_CLOUD_ONCALL_API_URL, "api/v1/info/")
try:
r = requests.get(info_url, headers={"AUTHORIZATION": api_token}, timeout=5)
if r.status_code == 200:
connector, _ = cls.objects.get_or_create()
connector.cloud_url = r.json()["url"]
connector.save()
elif r.status_code == 403:
logger.warning("Unable to sync with cloud. GRAFANA_CLOUD_ONCALL_TOKEN is invalid")
error_msg = "Invalid token"
else:
error_msg = f"Non-200 HTTP code. Got {r.status_code}"
except requests.exceptions.RequestException as e:
logger.warning(f"Unable to sync with cloud. Request exception {str(e)}")
error_msg = f"Unable to sync with cloud"
return sync_status, error_msg
def sync_users_with_cloud(self) -> tuple[bool, str]:
sync_status = False
error_msg = None
api_token = live_settings.GRAFANA_CLOUD_ONCALL_TOKEN
if api_token is None:
logger.warning("Unable to sync with cloud. GRAFANA_CLOUD_ONCALL_TOKEN is not set")
error_msg = "GRAFANA_CLOUD_ONCALL_TOKEN is not set"
existing_emails = list(User.objects.filter(role__in=(Role.ADMIN, Role.EDITOR)).values_list("email", flat=True))
matching_users = []
users_url = urljoin(GRAFANA_CLOUD_ONCALL_API_URL, "api/v1/users")
fetch_next_page = True
users_fetched = True
page = 1
while fetch_next_page:
try:
url = urljoin(users_url, f"?page={page}&?short=true")
r = requests.get(url, headers={"AUTHORIZATION": api_token}, timeout=5)
if r.status_code != 200:
logger.warning(
f"Unable to fetch page {page} while sync_users_with_cloud. Response status code {r.status_code}"
)
error_msg = f"Non-200 HTTP code. Got {r.status_code}"
users_fetched = False
break
data = r.json()
matching_users.extend(list(filter(lambda u: (u["email"] in existing_emails), data["results"])))
page += 1
if data["next"] is None:
fetch_next_page = False
except requests.exceptions.RequestException as e:
logger.warning(f"Unable to sync users with cloud. Request exception {str(e)}")
error_msg = f"Unable to sync with cloud"
users_fetched = False
break
if users_fetched:
with transaction.atomic():
cloud_users_identities_to_create = []
for user in matching_users:
cloud_users_identities_to_create.append(
CloudUserIdentity(
cloud_id=user["id"],
email=user["email"],
phone_number_verified=user["is_phone_number_verified"],
)
)
CloudUserIdentity.objects.all().delete()
CloudUserIdentity.objects.bulk_create(cloud_users_identities_to_create, batch_size=1000)
sync_status = True
return sync_status, error_msg
def sync_user_with_cloud(self, user):
sync_status = False
error_msg = None
api_token = live_settings.GRAFANA_CLOUD_ONCALL_TOKEN
if api_token is None:
logger.warning(f"Unable to sync_user_with cloud user_id {user.id}. GRAFANA_CLOUD_ONCALL_TOKEN is not set")
error_msg = "GRAFANA_CLOUD_ONCALL_TOKEN is not set"
else:
url = urljoin(GRAFANA_CLOUD_ONCALL_API_URL, f"api/v1/users/?email={user.email}")
try:
r = requests.get(url, headers={"AUTHORIZATION": api_token}, timeout=5)
if r.status_code != 200:
logger.warning(
f"Unable to sync_user_with_cloud user_id {user.id}. Response status code {r.status_code}"
)
error_msg = f"Non-200 HTTP code. Got {r.status_code}"
else:
data = r.json()
if len(data["results"]) != 0:
cloud_used_data = data["results"][0]
with transaction.atomic():
CloudUserIdentity.objects.filter(email=user.email).delete()
CloudUserIdentity.objects.create(
email=user.email,
phone_number_verified=cloud_used_data["is_phone_number_verified"],
cloud_id=cloud_used_data["id"],
)
sync_status = True
else:
logger.warning(
f"Unable to sync_user_with_cloud user_id {user.id}. User with {user.email} not found"
)
error_msg = f"User with email not found {user.email}"
except requests.exceptions.RequestException as e:
logger.warning(f"Unable to sync_user_with cloud user_id {user.id}. Request exception {str(e)}")
error_msg = f"Unable to sync with cloud"
return sync_status, error_msg
@classmethod
def remove_sync(cls):
from apps.oss_installation.models import CloudHeartbeat
cls.objects.all().delete()
CloudUserIdentity.objects.all().delete()
CloudHeartbeat.objects.all().delete()

View file

@ -0,0 +1,7 @@
from django.db import models
class CloudUserIdentity(models.Model):
phone_number_verified = models.BooleanField(default=False)
cloud_id = models.CharField(max_length=20)
email = models.EmailField()

View file

@ -1,9 +1,16 @@
import logging
import uuid
from django.db import models
logger = logging.getLogger(__name__)
class OssInstallation(models.Model):
"""
OssInstallation is model to track installation of OSS OnCall version.
"""
installation_id = models.UUIDField(default=uuid.uuid4, editable=False)
created_at = models.DateTimeField(auto_now=True)
report_sent_at = models.DateTimeField(null=True, default=None)

View file

@ -0,0 +1 @@
from .cloud_user import CloudUserSerializer # noqa: F401

View file

@ -0,0 +1,37 @@
from urllib.parse import urljoin
from rest_framework import serializers
import apps.oss_installation.constants as cloud_constants
from apps.oss_installation.models import CloudConnector, CloudUserIdentity
from apps.user_management.models import User
class CloudUserSerializer(serializers.ModelSerializer):
cloud_data = serializers.SerializerMethodField()
class Meta:
model = User
fields = ["cloud_data"]
def get_cloud_data(self, obj):
link = None
status = cloud_constants.CLOUD_NOT_SYNCED
connector = CloudConnector.objects.filter().first()
if connector is not None:
cloud_user_identity = CloudUserIdentity.objects.filter(email=obj.email).first()
if cloud_user_identity is None:
status = cloud_constants.CLOUD_SYNCED_USER_NOT_FOUND
link = connector.cloud_url
elif not cloud_user_identity.phone_number_verified:
status = cloud_constants.CLOUD_SYNCED_PHONE_NOT_VERIFIED
link = urljoin(
connector.cloud_url, f"a/grafana-oncall-app/?page=users&p=1&id={cloud_user_identity.cloud_id}"
)
else:
status = cloud_constants.CLOUD_SYNCED_PHONE_VERIFIED
link = urljoin(
connector.cloud_url, f"a/grafana-oncall-app/?page=users&p=1&id={cloud_user_identity.cloud_id}"
)
cloud_data = {"status": status, "link": link}
return cloud_data

View file

@ -7,7 +7,7 @@ from django.utils import timezone
from rest_framework import status
from apps.base.utils import live_settings
from apps.oss_installation.models import CloudHeartbeat, OssInstallation
from apps.oss_installation.models import CloudConnector, CloudHeartbeat, OssInstallation
from apps.oss_installation.usage_stats import UsageStatsService
from common.custom_celery_tasks import shared_dedicated_queue_retry_task
@ -93,3 +93,17 @@ def send_cloud_heartbeat():
if cloud_heartbeat.pk is not None:
cloud_heartbeat.save()
logger.info("Finish send cloud heartbeat")
@shared_dedicated_queue_retry_task()
def sync_users_with_cloud():
logger.info("Start sync_users_with_cloud")
connector = CloudConnector.objects.first()
if connector is not None:
status, error = connector.sync_users_with_cloud()
log_message = "Users synced. Status {status}."
if error:
log_message += f" Error {error}"
logger.info(log_message)
else:
logger.info("Grafana Cloud is not connected")

View file

@ -1,7 +1,14 @@
from common.api_helpers.optional_slash_router import optional_slash_path
from django.urls import include, path
from .views import CloudHeartbeatStatusView
from common.api_helpers.optional_slash_router import OptionalSlashRouter, optional_slash_path
from .views import CloudConnectionView, CloudUsersView, CloudUserView
router = OptionalSlashRouter()
router.register("cloud_users", CloudUserView, basename="cloud-users")
urlpatterns = [
optional_slash_path("cloud_heartbeat_status", CloudHeartbeatStatusView.as_view(), name="cloud_heartbeat_status"),
path("", include(router.urls)),
optional_slash_path("cloud_users", CloudUsersView.as_view(), name="cloud-users-list"),
optional_slash_path("cloud_connection", CloudConnectionView.as_view(), name="cloud-connection-status"),
]

View file

@ -1,19 +1,24 @@
import logging
from contextlib import suppress
from django.apps import apps
from django.utils import timezone
from apps.alerts.models import AlertGroupLogRecord, EscalationPolicy
from apps.base.models import UserNotificationPolicyLogRecord
from apps.public_api.constants import DEMO_USER_ID
from apps.schedules.ical_utils import list_users_to_notify_from_ical_for_period
from apps.schedules.models import OnCallSchedule
from apps.user_management.models import User
logger = logging.getLogger(__name__)
def active_oss_users_count():
"""
active_oss_users_count returns count of active users of oss installation.
"""
OnCallSchedule = apps.get_model("schedules", "OnCallSchedule")
AlertGroupLogRecord = apps.get_model("alerts", "AlertGroupLogRecord")
EscalationPolicy = apps.get_model("alerts", "EscalationPolicy")
UserNotificationPolicyLogRecord = apps.get_model("base", "UserNotificationPolicyLogRecord")
User = apps.get_model("user_management", "User")
# Take logs for previous 24 hours
start = timezone.now() - timezone.timedelta(hours=24)

View file

@ -1 +1,2 @@
from .cloud_heartbeat_status import CloudHeartbeatStatusView # noqa: F401
from .cloud_connection import CloudConnectionView # noqa: F401
from .cloud_users import CloudUsersView, CloudUserView # noqa: F401

View file

@ -0,0 +1,42 @@
from urllib.parse import urljoin
from rest_framework import status
from rest_framework.permissions import IsAuthenticated
from rest_framework.response import Response
from rest_framework.views import APIView
from apps.api.permissions import IsAdmin
from apps.auth_token.auth import PluginAuthentication
from apps.base.utils import live_settings
from apps.oss_installation.models import CloudConnector, CloudHeartbeat
class CloudConnectionView(APIView):
authentication_classes = (PluginAuthentication,)
permission_classes = (IsAuthenticated, IsAdmin)
def get(self, request):
connector = CloudConnector.objects.first()
heartbeat = CloudHeartbeat.objects.first()
response = {
"cloud_connection_status": connector is not None,
"cloud_notifications_enabled": live_settings.GRAFANA_CLOUD_NOTIFICATIONS_ENABLED,
"cloud_heartbeat_enabled": live_settings.GRAFANA_CLOUD_ONCALL_HEARTBEAT_ENABLED,
"cloud_heartbeat_link": self._get_heartbeat_link(connector, heartbeat),
"cloud_heartbeat_status": heartbeat is not None and heartbeat.success,
}
return Response(response)
def _get_heartbeat_link(self, connector, heartbeat):
if connector is None:
return None
if heartbeat is None:
return None
return urljoin(connector.cloud_url, f"a/grafana-oncall-app/?page=integrations1&id={heartbeat.integration_id}")
def delete(self, request):
connector = CloudConnector.objects.first()
if connector is None:
return Response(status=status.HTTP_404_NOT_FOUND)
connector.remove_sync()
return Response(status=status.HTTP_204_NO_CONTENT)

View file

@ -1,15 +0,0 @@
from rest_framework.permissions import IsAuthenticated
from rest_framework.response import Response
from rest_framework.views import APIView
from apps.auth_token.auth import PluginAuthentication
from apps.oss_installation.models import CloudHeartbeat
class CloudHeartbeatStatusView(APIView):
authentication_classes = (PluginAuthentication,)
permission_classes = (IsAuthenticated,)
def get(self, request):
response = {"status": CloudHeartbeat.status()}
return Response(response)

View file

@ -0,0 +1,106 @@
from urllib.parse import urljoin
from rest_framework import mixins, status, viewsets
from rest_framework.decorators import action
from rest_framework.permissions import IsAuthenticated
from rest_framework.response import Response
from rest_framework.views import APIView
import apps.oss_installation.constants as cloud_constants
from apps.api.permissions import ActionPermission, AnyRole, IsAdmin, IsOwnerOrAdmin
from apps.auth_token.auth import PluginAuthentication
from apps.oss_installation.models import CloudConnector, CloudUserIdentity
from apps.oss_installation.serializers import CloudUserSerializer
from apps.user_management.models import User
from common.api_helpers.mixins import PublicPrimaryKeyMixin
from common.api_helpers.paginators import HundredPageSizePaginator
from common.constants.role import Role
class CloudUsersView(HundredPageSizePaginator, APIView):
authentication_classes = (PluginAuthentication,)
permission_classes = (IsAuthenticated, IsAdmin)
def get(self, request):
organization = request.user.organization
queryset = User.objects.filter(organization=organization, role__in=[Role.ADMIN, Role.EDITOR])
if request.user.current_team is not None:
queryset = queryset.filter(teams=request.user.current_team).distinct()
results = self.paginate_queryset(queryset, request, view=self)
emails = list(queryset.values_list("email", flat=True))
cloud_identities = list(CloudUserIdentity.objects.filter(email__in=emails))
cloud_identities = {cloud_identity.email: cloud_identity for cloud_identity in cloud_identities}
response = []
connector = CloudConnector.objects.first()
for user in results:
link = None
status = cloud_constants.CLOUD_NOT_SYNCED
if connector is not None:
status = cloud_constants.CLOUD_SYNCED_USER_NOT_FOUND
cloud_identity = cloud_identities.get(user.email, None)
if cloud_identity:
status = cloud_constants.CLOUD_SYNCED_PHONE_NOT_VERIFIED
is_phone_verified = cloud_identity.phone_number_verified
if is_phone_verified:
status = cloud_constants.CLOUD_SYNCED_PHONE_VERIFIED
link = urljoin(
connector.cloud_url, f"a/grafana-oncall-app/?page=users&p=1&id={cloud_identity.cloud_id}"
)
response.append(
{
"id": user.public_primary_key,
"email": user.email,
"username": user.username,
"cloud_data": {"status": status, "link": link},
}
)
return self.get_paginated_response(response)
def post(self, request):
connector = CloudConnector.objects.first()
if connector is not None:
sync_status, err = connector.sync_users_with_cloud()
return Response(status=status.HTTP_200_OK, data={"status": sync_status, "error": err})
else:
return Response(status=status.HTTP_400_BAD_REQUEST, data={"detail": "Grafana Cloud is not connected"})
class CloudUserView(
PublicPrimaryKeyMixin,
mixins.RetrieveModelMixin,
viewsets.GenericViewSet,
):
authentication_classes = (PluginAuthentication,)
permission_classes = (IsAuthenticated, ActionPermission)
action_permissions = {
AnyRole: ("retrieve",),
IsAdmin: ("sync",),
}
action_object_permissions = {
IsOwnerOrAdmin: ("retrieve", "sync"),
}
serializer_class = CloudUserSerializer
def get_queryset(self):
queryset = User.objects.filter(organization=self.request.user.organization)
return queryset
@action(detail=True, methods=["post"])
def sync(self, request, pk):
user = self.get_object()
connector = CloudConnector.objects.first()
if connector is not None:
sync_status, err = connector.sync_user_with_cloud(user)
return Response(status=status.HTTP_200_OK, data={"status": sync_status, "error": err})
else:
return Response(status=status.HTTP_400_BAD_REQUEST, data={"detail": "Grafana Cloud is not connected"})

View file

@ -0,0 +1,3 @@
from .info_throttler import InfoThrottler # noqa: F401
from .phone_notification_throttler import PhoneNotificationThrottler # noqa: F401
from .user_throttle import UserThrottle # noqa: F401

View file

@ -0,0 +1,6 @@
from rest_framework.throttling import UserRateThrottle
class InfoThrottler(UserRateThrottle):
scope = "info"
rate = "100/m"

View file

@ -0,0 +1,6 @@
from rest_framework.throttling import UserRateThrottle
class PhoneNotificationThrottler(UserRateThrottle):
scope = "phone_notification"
rate = "60/m"

View file

@ -30,4 +30,6 @@ router.register(r"teams", views.TeamView, basename="teams")
urlpatterns = [
path("", include(router.urls)),
optional_slash_path("info", views.InfoView.as_view(), name="info"),
optional_slash_path("make_call", views.MakeCallView.as_view(), name="make_call"),
optional_slash_path("send_sms", views.SendSMSView.as_view(), name="send_sms"),
]

View file

@ -8,6 +8,7 @@ from .integrations import IntegrationView # noqa: F401
from .on_call_shifts import CustomOnCallShiftView # noqa: F401
from .organizations import OrganizationView # noqa: F401
from .personal_notifications import PersonalNotificationView # noqa: F401
from .phone_notifications import MakeCallView, SendSMSView # noqa: F401
from .resolution_notes import ResolutionNoteView # noqa: F401
from .routes import ChannelFilterView # noqa: F401
from .schedules import OnCallScheduleChannelView # noqa: F401

View file

@ -3,14 +3,14 @@ from rest_framework.response import Response
from rest_framework.views import APIView
from apps.auth_token.auth import ApiTokenAuthentication
from apps.public_api.throttlers.user_throttle import UserThrottle
from apps.public_api.throttlers import InfoThrottler
class InfoView(APIView):
authentication_classes = (ApiTokenAuthentication,)
permission_classes = (IsAuthenticated,)
throttle_classes = [UserThrottle]
throttle_classes = [InfoThrottler]
def get(self, request):
response = {"url": self.request.auth.organization.grafana_url}

View file

@ -0,0 +1,76 @@
from rest_framework import serializers, status
from rest_framework.permissions import IsAuthenticated
from rest_framework.response import Response
from rest_framework.views import APIView
from twilio.base.exceptions import TwilioRestException
from apps.auth_token.auth import ApiTokenAuthentication
from apps.public_api.throttlers.phone_notification_throttler import PhoneNotificationThrottler
from apps.twilioapp.models import PhoneCall, SMSMessage
class PhoneNotificationDataSerializer(serializers.Serializer):
email = serializers.EmailField()
message = serializers.CharField(max_length=1024)
class MakeCallView(APIView):
authentication_classes = (ApiTokenAuthentication,)
permission_classes = (IsAuthenticated,)
throttle_classes = [
PhoneNotificationThrottler,
]
def post(self, request):
serializer = PhoneNotificationDataSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
response_data = {}
organization = self.request.auth.organization
user = organization.users.filter(
email=serializer.validated_data["email"], _verified_phone_number__isnull=False
).first()
if user is None:
response_data = {"error": "user-not-found"}
return Response(status=status.HTTP_404_NOT_FOUND, data=response_data)
try:
PhoneCall.make_grafana_cloud_call(user, serializer.validated_data["message"])
except TwilioRestException:
return Response(status=status.HTTP_503_SERVICE_UNAVAILABLE, data=response_data)
except PhoneCall.PhoneCallsLimitExceeded:
return Response(status=status.HTTP_400_BAD_REQUEST, data={"error": "limit-exceeded"})
return Response(status=status.HTTP_200_OK, data=response_data)
class SendSMSView(APIView):
authentication_classes = (ApiTokenAuthentication,)
permission_classes = (IsAuthenticated,)
throttle_classes = [
PhoneNotificationThrottler,
]
def post(self, request):
serializer = PhoneNotificationDataSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
response_data = {}
organization = self.request.auth.organization
user = organization.users.filter(
email=serializer.validated_data["email"], _verified_phone_number__isnull=False
).first()
if user is None:
response_data = {"error": "user-not-found"}
return Response(status=status.HTTP_404_NOT_FOUND, data=response_data)
try:
SMSMessage.send_grafana_cloud_sms(user, serializer.validated_data["message"])
except TwilioRestException:
return Response(status=status.HTTP_503_SERVICE_UNAVAILABLE, data=response_data)
except SMSMessage.SMSLimitExceeded:
return Response(status=status.HTTP_400_BAD_REQUEST, data={"error": "limit-exceeded"})
return Response(status=status.HTTP_200_OK, data=response_data)

View file

@ -33,12 +33,16 @@ class UserView(RateLimitHeadersMixin, ShortSerializerMixin, DemoTokenMixin, Read
def get_queryset(self):
username = self.request.query_params.get("username")
email = self.request.query_params.get("email")
is_short_request = self.request.query_params.get("short", "false") == "true"
queryset = self.request.auth.organization.users.filter(role__in=[Role.ADMIN, Role.EDITOR]).distinct()
if username is not None:
queryset = queryset.filter(username=username)
if email is not None:
queryset = queryset.filter(email=email)
if not is_short_request:
queryset = self.serializer_class.setup_eager_loading(queryset)
return queryset.order_by("id")

View file

@ -0,0 +1,23 @@
# Generated by Django 3.2.5 on 2022-06-04 10:08
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('twilioapp', '0001_squashed_initial'),
]
operations = [
migrations.AddField(
model_name='phonecall',
name='grafana_cloud_notification',
field=models.BooleanField(default=False),
),
migrations.AddField(
model_name='smsmessage',
name='grafana_cloud_notification',
field=models.BooleanField(default=False),
),
]

View file

@ -1,7 +1,11 @@
import logging
from urllib.parse import urljoin
import requests
from django.apps import apps
from django.conf import settings
from django.db import models
from rest_framework import status
from twilio.base.exceptions import TwilioRestException
from apps.alerts.constants import ActionSource
@ -9,6 +13,7 @@ from apps.alerts.incident_appearance.renderers.phone_call_renderer import AlertG
from apps.alerts.signals import user_notification_action_triggered_signal
from apps.twilioapp.constants import TwilioCallStatuses
from apps.twilioapp.twilio_client import twilio_client
from common.utils import clean_markup, escape_for_twilio_phone_call
logger = logging.getLogger(__name__)
@ -34,8 +39,10 @@ class PhoneCallManager(models.Manager):
if phone_call_qs.exists() and status:
phone_call_qs.update(status=status)
phone_call = phone_call_qs.first()
if phone_call.grafana_cloud_notification:
# If call was made via grafana twilio it is don't needed to create logs on it's delivery status.
return
log_record = None
if status == TwilioCallStatuses.COMPLETED:
log_record = UserNotificationPolicyLogRecord(
@ -115,6 +122,17 @@ class PhoneCall(models.Model):
created_at = models.DateTimeField(auto_now_add=True)
grafana_cloud_notification = models.BooleanField(default=False)
class PhoneCallsLimitExceeded(Exception):
"""Phone calls limit exceeded"""
class PhoneNumberNotVerifiedError(Exception):
"""Phone number is not verified"""
class CloudSendError(Exception):
"""Error making call through cloud"""
def process_digit(self, digit):
"""The function process pressed digit at time of call to user
@ -138,57 +156,58 @@ class PhoneCall(models.Model):
return bool(self.represents_alert_group.slack_message)
@classmethod
def make_call(cls, user, alert_group, notification_policy):
UserNotificationPolicyLogRecord = apps.get_model("base", "UserNotificationPolicyLogRecord")
def _make_cloud_call(cls, user, message_body):
url = urljoin(settings.GRAFANA_CLOUD_ONCALL_API_URL, "api/v1/make_call")
auth = {"Authorization": settings.GRAFANA_CLOUD_ONCALL_TOKEN}
data = {
"email": user.email,
"message": message_body,
}
try:
response = requests.post(url, headers=auth, data=data, timeout=5)
except requests.exceptions.RequestException as e:
logger.warning(f"Unable to make call through cloud. Request exception {str(e)}")
raise PhoneCall.CloudSendError("Unable to make call through cloud: request failed")
organization = alert_group.channel.organization
log_record = None
if user.verified_phone_number:
# Create a PhoneCall object in db
phone_call = PhoneCall(
represents_alert_group=alert_group,
receiver=user,
notification_policy=notification_policy,
)
phone_calls_left = organization.phone_calls_left(user)
if phone_calls_left > 0:
phone_call.exceeded_limit = False
renderer = AlertGroupPhoneCallRenderer(alert_group)
message_body = renderer.render()
if phone_calls_left < 3:
message_body += " {} phone calls left. Contact your admin.".format(phone_calls_left)
try:
twilio_call = twilio_client.make_call(message_body, user.verified_phone_number)
except TwilioRestException:
log_record = UserNotificationPolicyLogRecord(
author=user,
type=UserNotificationPolicyLogRecord.TYPE_PERSONAL_NOTIFICATION_FAILED,
notification_policy=notification_policy,
alert_group=alert_group,
notification_error_code=UserNotificationPolicyLogRecord.ERROR_NOTIFICATION_NOT_ABLE_TO_CALL,
notification_step=notification_policy.step if notification_policy else None,
notification_channel=notification_policy.notify_by if notification_policy else None,
)
else:
if twilio_call.status and twilio_call.sid:
phone_call.status = TwilioCallStatuses.DETERMINANT.get(twilio_call.status, None)
phone_call.sid = twilio_call.sid
else:
log_record = UserNotificationPolicyLogRecord(
author=user,
type=UserNotificationPolicyLogRecord.TYPE_PERSONAL_NOTIFICATION_FAILED,
notification_policy=notification_policy,
alert_group=alert_group,
notification_error_code=UserNotificationPolicyLogRecord.ERROR_NOTIFICATION_PHONE_CALLS_LIMIT_EXCEEDED,
notification_step=notification_policy.step if notification_policy else None,
notification_channel=notification_policy.notify_by if notification_policy else None,
)
phone_call.exceeded_limit = True
phone_call.save()
if response.status_code == status.HTTP_400_BAD_REQUEST and response.json().get("error") == "limit-exceeded":
raise PhoneCall.PhoneCallsLimitExceeded("Organization calls limit exceeded")
elif response.status_code == status.HTTP_404_NOT_FOUND:
raise PhoneCall.CloudSendError("Unable to make call through cloud: user not found")
else:
raise PhoneCall.CloudSendError("Unable to make call through cloud: server error")
@classmethod
def make_call(cls, user, alert_group, notification_policy, is_cloud_notification=False):
UserNotificationPolicyLogRecord = apps.get_model("base", "UserNotificationPolicyLogRecord")
log_record = None
renderer = AlertGroupPhoneCallRenderer(alert_group)
message_body = renderer.render()
try:
if is_cloud_notification:
cls._make_cloud_call(user, message_body)
else:
cls._make_call(user, message_body, alert_group=alert_group, notification_policy=notification_policy)
except (TwilioRestException, PhoneCall.CloudSendError):
log_record = UserNotificationPolicyLogRecord(
author=user,
type=UserNotificationPolicyLogRecord.TYPE_PERSONAL_NOTIFICATION_FAILED,
notification_policy=notification_policy,
alert_group=alert_group,
notification_error_code=UserNotificationPolicyLogRecord.ERROR_NOTIFICATION_NOT_ABLE_TO_CALL,
notification_step=notification_policy.step if notification_policy else None,
notification_channel=notification_policy.notify_by if notification_policy else None,
)
except PhoneCall.PhoneCallsLimitExceeded:
log_record = UserNotificationPolicyLogRecord(
author=user,
type=UserNotificationPolicyLogRecord.TYPE_PERSONAL_NOTIFICATION_FAILED,
notification_policy=notification_policy,
alert_group=alert_group,
notification_error_code=UserNotificationPolicyLogRecord.ERROR_NOTIFICATION_PHONE_CALLS_LIMIT_EXCEEDED,
notification_step=notification_policy.step if notification_policy else None,
notification_channel=notification_policy.notify_by if notification_policy else None,
)
except PhoneCall.PhoneNumberNotVerifiedError:
log_record = UserNotificationPolicyLogRecord(
author=user,
type=UserNotificationPolicyLogRecord.TYPE_PERSONAL_NOTIFICATION_FAILED,
@ -203,6 +222,41 @@ class PhoneCall(models.Model):
log_record.save()
user_notification_action_triggered_signal.send(sender=PhoneCall.make_call, log_record=log_record)
@classmethod
def make_grafana_cloud_call(cls, user, message_body):
message_body = escape_for_twilio_phone_call(clean_markup(message_body))
cls._make_call(user, message_body, grafana_cloud=True)
@classmethod
def _make_call(cls, user, message_body, alert_group=None, notification_policy=None, grafana_cloud=False):
if not user.verified_phone_number:
raise PhoneCall.PhoneNumberNotVerifiedError("User phone number is not verified")
phone_call = PhoneCall(
represents_alert_group=alert_group,
receiver=user,
notification_policy=notification_policy,
grafana_cloud_notification=grafana_cloud,
)
phone_calls_left = user.organization.phone_calls_left(user)
if phone_calls_left <= 0:
phone_call.exceeded_limit = True
phone_call.save()
raise PhoneCall.PhoneCallsLimitExceeded("Organization calls limit exceeded")
phone_call.exceeded_limit = False
if phone_calls_left < 3:
message_body += " {} phone calls left. Contact your admin.".format(phone_calls_left)
twilio_call = twilio_client.make_call(message_body, user.verified_phone_number)
if twilio_call.status and twilio_call.sid:
phone_call.status = TwilioCallStatuses.DETERMINANT.get(twilio_call.status, None)
phone_call.sid = twilio_call.sid
phone_call.save()
return phone_call
@staticmethod
def get_error_code_by_twilio_status(status):
UserNotificationPolicyLogRecord = apps.get_model("base", "UserNotificationPolicyLogRecord")

View file

@ -1,13 +1,18 @@
import logging
from urllib.parse import urljoin
import requests
from django.apps import apps
from django.conf import settings
from django.db import models
from rest_framework import status
from twilio.base.exceptions import TwilioRestException
from apps.alerts.incident_appearance.renderers.sms_renderer import AlertGroupSmsRenderer
from apps.alerts.signals import user_notification_action_triggered_signal
from apps.twilioapp.constants import TwilioMessageStatuses
from apps.twilioapp.twilio_client import twilio_client
from common.utils import clean_markup
logger = logging.getLogger(__name__)
@ -36,7 +41,9 @@ class SMSMessageManager(models.Manager):
sms_message_qs.update(status=status)
sms_message = sms_message_qs.first()
if sms_message.grafana_cloud_notification:
# If sms was sent via grafana cloud notifications don't create logs on its delivery status.
return
log_record = None
if status == TwilioMessageStatuses.DELIVERED:
@ -90,6 +97,7 @@ class SMSMessage(models.Model):
null=True,
choices=TwilioMessageStatuses.CHOICES,
)
grafana_cloud_notification = models.BooleanField(default=False)
# https://www.twilio.com/docs/sms/api/message-resource#message-properties
sid = models.CharField(
@ -99,66 +107,73 @@ class SMSMessage(models.Model):
created_at = models.DateTimeField(auto_now_add=True)
class SMSLimitExceeded(Exception):
"""SMS limit exceeded"""
class PhoneNumberNotVerifiedError(Exception):
"""Phone number is not verified"""
class CloudSendError(Exception):
"""SMS sending through cloud error"""
@property
def created_for_slack(self):
return bool(self.represents_alert_group.slack_message)
@classmethod
def send_sms(cls, user, alert_group, notification_policy):
def _send_cloud_sms(cls, user, message_body):
url = urljoin(settings.GRAFANA_CLOUD_ONCALL_API_URL, "api/v1/send_sms")
auth = {"Authorization": settings.GRAFANA_CLOUD_ONCALL_TOKEN}
data = {
"email": user.email,
"message": message_body,
}
try:
response = requests.post(url, headers=auth, data=data, timeout=5)
except requests.exceptions.RequestException as e:
logger.warning(f"Unable to send SMS through cloud. Request exception {str(e)}")
raise SMSMessage.CloudSendError("Unable to send SMS through cloud: request failed")
if response.status_code == status.HTTP_400_BAD_REQUEST and response.json().get("error") == "limit-exceeded":
raise SMSMessage.SMSLimitExceeded("Organization sms limit exceeded")
elif response.status_code == status.HTTP_404_NOT_FOUND:
raise SMSMessage.CloudSendError("Unable to send SMS through cloud: user not found")
else:
raise SMSMessage.CloudSendError("Unable to send SMS through cloud: server error")
@classmethod
def send_sms(cls, user, alert_group, notification_policy, is_cloud_notification=False):
UserNotificationPolicyLogRecord = apps.get_model("base", "UserNotificationPolicyLogRecord")
organization = alert_group.channel.organization
log_record = None
if user.verified_phone_number:
# Create an SMS object in db
sms_message = SMSMessage(
represents_alert_group=alert_group, receiver=user, notification_policy=notification_policy
)
sms_left = organization.sms_left(user)
if sms_left > 0:
# Mark is as successfully sent
sms_message.exceeded_limit = False
# Render alert message for sms
renderer = AlertGroupSmsRenderer(alert_group)
message_body = renderer.render()
# Notify if close to limit
if sms_left < 3:
message_body += " {} sms left. Contact your admin.".format(sms_left)
# Send an sms
try:
twilio_message = twilio_client.send_message(message_body, user.verified_phone_number)
except TwilioRestException:
log_record = UserNotificationPolicyLogRecord(
author=user,
type=UserNotificationPolicyLogRecord.TYPE_PERSONAL_NOTIFICATION_FAILED,
notification_policy=notification_policy,
alert_group=alert_group,
notification_error_code=UserNotificationPolicyLogRecord.ERROR_NOTIFICATION_NOT_ABLE_TO_SEND_SMS,
notification_step=notification_policy.step if notification_policy else None,
notification_channel=notification_policy.notify_by if notification_policy else None,
)
else:
if twilio_message.status and twilio_message.sid:
sms_message.status = TwilioMessageStatuses.DETERMINANT.get(twilio_message.status, None)
sms_message.sid = twilio_message.sid
renderer = AlertGroupSmsRenderer(alert_group)
message_body = renderer.render()
try:
if is_cloud_notification:
cls._send_cloud_sms(user, message_body)
else:
# If no more sms left, mark as exceeded limit
log_record = UserNotificationPolicyLogRecord(
author=user,
type=UserNotificationPolicyLogRecord.TYPE_PERSONAL_NOTIFICATION_FAILED,
notification_policy=notification_policy,
alert_group=alert_group,
notification_error_code=UserNotificationPolicyLogRecord.ERROR_NOTIFICATION_SMS_LIMIT_EXCEEDED,
notification_step=notification_policy.step if notification_policy else None,
notification_channel=notification_policy.notify_by if notification_policy else None,
)
sms_message.exceeded_limit = True
# Save object
sms_message.save()
else:
cls._send_sms(user, message_body, alert_group=alert_group, notification_policy=notification_policy)
except (TwilioRestException, SMSMessage.CloudSendError):
log_record = UserNotificationPolicyLogRecord(
author=user,
type=UserNotificationPolicyLogRecord.TYPE_PERSONAL_NOTIFICATION_FAILED,
notification_policy=notification_policy,
alert_group=alert_group,
notification_error_code=UserNotificationPolicyLogRecord.ERROR_NOTIFICATION_NOT_ABLE_TO_SEND_SMS,
notification_step=notification_policy.step if notification_policy else None,
notification_channel=notification_policy.notify_by if notification_policy else None,
)
except SMSMessage.SMSLimitExceeded:
log_record = UserNotificationPolicyLogRecord(
author=user,
type=UserNotificationPolicyLogRecord.TYPE_PERSONAL_NOTIFICATION_FAILED,
notification_policy=notification_policy,
alert_group=alert_group,
notification_error_code=UserNotificationPolicyLogRecord.ERROR_NOTIFICATION_SMS_LIMIT_EXCEEDED,
notification_step=notification_policy.step if notification_policy else None,
notification_channel=notification_policy.notify_by if notification_policy else None,
)
except SMSMessage.PhoneNumberNotVerifiedError:
log_record = UserNotificationPolicyLogRecord(
author=user,
type=UserNotificationPolicyLogRecord.TYPE_PERSONAL_NOTIFICATION_FAILED,
@ -173,6 +188,41 @@ class SMSMessage(models.Model):
log_record.save()
user_notification_action_triggered_signal.send(sender=SMSMessage.send_sms, log_record=log_record)
@classmethod
def send_grafana_cloud_sms(cls, user, message_body):
message_body = clean_markup(message_body)
cls._send_sms(user, message_body, grafana_cloud=True)
@classmethod
def _send_sms(cls, user, message_body, alert_group=None, notification_policy=None, grafana_cloud=False):
if not user.verified_phone_number:
raise SMSMessage.PhoneNumberNotVerifiedError("User phone number is not verified")
sms_message = SMSMessage(
represents_alert_group=alert_group,
receiver=user,
notification_policy=notification_policy,
grafana_cloud_notification=grafana_cloud,
)
sms_left = user.organization.sms_left(user)
if sms_left <= 0:
sms_message.exceeded_limit = True
sms_message.save()
raise SMSMessage.SMSLimitExceeded("Organization sms limit exceeded")
sms_message.exceeded_limit = False
if sms_left < 3:
message_body += " {} sms left. Contact your admin.".format(sms_left)
twilio_message = twilio_client.send_message(message_body, user.verified_phone_number)
if twilio_message.status and twilio_message.sid:
sms_message.status = TwilioMessageStatuses.DETERMINANT.get(twilio_message.status, None)
sms_message.sid = twilio_message.sid
sms_message.save()
return sms_message
@staticmethod
def get_error_code_by_twilio_status(status):
UserNotificationPolicyLogRecord = apps.get_model("base", "UserNotificationPolicyLogRecord")

View file

@ -177,6 +177,14 @@ def clean_markup(text):
return cleaned
def escape_for_twilio_phone_call(text):
# https://www.twilio.com/docs/api/errors/12100
text = text.replace("&", "&amp;")
text = text.replace(">", "&gt;")
text = text.replace("<", "&lt;")
return text
def escape_html(text):
return html.escape(text)

View file

@ -54,7 +54,7 @@ if settings.FEATURE_SLACK_INTEGRATION_ENABLED:
path("slack/", include("apps.slack.urls")),
]
if settings.OSS_INSTALLATION_FEATURES_ENABLED:
if settings.OSS_INSTALLATION:
urlpatterns += [
path("api/internal/v1/", include("apps.oss_installation.urls")),
]

View file

@ -40,6 +40,7 @@ if TESTING:
# TODO: OSS: Add these setting to oss settings file. Add Version there too.
OSS_INSTALLATION_FEATURES_ENABLED = True
SEND_ANONYMOUS_USAGE_STATS = True
INSTALLED_APPS += ["apps.oss_installation"] # noqa
@ -55,4 +56,8 @@ CELERY_BEAT_SCHEDULE["send_cloud_heartbeat"] = { # noqa
"args": (),
} # noqa
SEND_ANONYMOUS_USAGE_STATS = True
CELERY_BEAT_SCHEDULE["sync_users_with_cloud"] = { # noqa
"task": "apps.oss_installation.tasks.sync_users_with_cloud",
"schedule": crontab(hour="*/12"), # noqa
"args": (),
} # noqa

View file

@ -6,7 +6,10 @@ from celery.schedules import crontab
from common.utils import getenv_boolean
VERSION = "dev-oss"
SEND_ANONYMOUS_USAGE_STATS = False
# Indicates if instance is OSS installation.
# It is needed to plug-in oss urls.
OSS_INSTALLATION = getenv_boolean("OSS", False)
SEND_ANONYMOUS_USAGE_STATS = getenv_boolean("SEND_ANONYMOUS_USAGE_STATS", default=True)
# License is OpenSource or Cloud
OPEN_SOURCE_LICENSE_NAME = "OpenSource"
@ -49,7 +52,8 @@ FEATURE_LIVE_SETTINGS_ENABLED = getenv_boolean("FEATURE_LIVE_SETTINGS_ENABLED",
FEATURE_TELEGRAM_INTEGRATION_ENABLED = getenv_boolean("FEATURE_TELEGRAM_INTEGRATION_ENABLED", default=False)
FEATURE_EMAIL_INTEGRATION_ENABLED = getenv_boolean("FEATURE_EMAIL_INTEGRATION_ENABLED", default=False)
FEATURE_SLACK_INTEGRATION_ENABLED = getenv_boolean("FEATURE_SLACK_INTEGRATION_ENABLED", default=False)
OSS_INSTALLATION_FEATURES_ENABLED = False
GRAFANA_CLOUD_ONCALL_HEARTBEAT_ENABLED = getenv_boolean("GRAFANA_CLOUD_ONCALL_HEARTBEAT_ENABLED", default=True)
GRAFANA_CLOUD_NOTIFICATIONS_ENABLED = getenv_boolean("GRAFANA_CLOUD_NOTIFICATIONS_ENABLED", default=True)
TWILIO_ACCOUNT_SID = os.environ.get("TWILIO_ACCOUNT_SID")
TWILIO_AUTH_TOKEN = os.environ.get("TWILIO_AUTH_TOKEN")
@ -70,6 +74,10 @@ SENDGRID_FROM_EMAIL = os.environ.get("SENDGRID_FROM_EMAIL")
SENDGRID_SECRET_KEY = os.environ.get("SENDGRID_SECRET_KEY")
SENDGRID_INBOUND_EMAIL_DOMAIN = os.environ.get("SENDGRID_INBOUND_EMAIL_DOMAIN")
# For Grafana Cloud integration
GRAFANA_CLOUD_ONCALL_API_URL = os.environ.get("GRAFANA_CLOUD_ONCALL_API_URL", "https://a-prod-us-central-0.grafana.net")
GRAFANA_CLOUD_ONCALL_TOKEN = os.environ.get("GRAFANA_CLOUD_ONCALL_TOKEN", None)
# Application definition
INSTALLED_APPS = [
@ -409,10 +417,6 @@ SELF_HOSTED_SETTINGS = {
"ORG_TITLE": "Self-Hosted Organization",
}
GRAFANA_CLOUD_ONCALL_API_URL = os.environ.get("GRAFANA_CLOUD_ONCALL_API_URL", "https://a-prod-us-central-0.grafana.net")
GRAFANA_CLOUD_ONCALL_TOKEN = os.environ.get("GRAFANA_CLOUD_ONCALL_TOKEN", None)
GRAFANA_CLOUD_ONCALL_HEARTBEAT_ENABLED = getenv_boolean("GRAFANA_CLOUD_ONCALL_HEARTBEAT_ENABLED", default=True)
GRAFANA_INCIDENT_STATIC_API_KEY = os.environ.get("GRAFANA_INCIDENT_STATIC_API_KEY", None)
DATA_UPLOAD_MAX_MEMORY_SIZE = 5242880

View file

@ -27,3 +27,5 @@ TWILIO_AUTH_TOKEN = "dummy_twilio_auth_token"
FEATURE_EXTRA_MESSAGING_BACKENDS_ENABLED = True
EXTRA_MESSAGING_BACKENDS = ["apps.base.tests.messaging_backend.TestOnlyBackend"]
OSS_INSTALLATION = True
INSTALLED_APPS += ["apps.oss_installation"] # noqa

View file

@ -100,9 +100,11 @@ export const Root = observer((props: AppRootProps) => {
const style = document.createElement('style');
document.head.appendChild(style);
const index = style.sheet.insertRule('.page-body {max-width: unset !important}');
const index2 = style.sheet.insertRule('.page-container {max-width: unset !important}');
return () => {
style.sheet.removeRule(index);
style.sheet.removeRule(index2);
};
}, []);
@ -116,6 +118,7 @@ export const Root = observer((props: AppRootProps) => {
meta,
grafanaUser: window.grafanaBootData.user,
enableLiveSettings: store.hasFeature(AppFeature.LiveSettings),
enableCloudPage: store.hasFeature(AppFeature.CloudConnection),
}),
[meta, pathWithoutLeadingSlash, page, store.features]
)

View file

@ -5,12 +5,12 @@ import cn from 'classnames/bind';
import { observer } from 'mobx-react';
import { useMediaQuery } from 'react-responsive';
import { Tabs, TabsContent } from 'containers/UserSettings/parts';
import { User as UserType } from 'models/user/user.types';
import { AppFeature } from 'state/features';
import { useStore } from 'state/useStore';
import { UserSettingsTab } from './UserSettings.types';
import { Tabs, TabsContent } from './parts';
import styles from './UserSettings.module.css';
@ -58,7 +58,8 @@ const UserSettings = observer((props: UserFormProps) => {
setActiveTab(tab);
}, []);
const isModalWide = activeTab === UserSettingsTab.UserInfo && isDesktopOrLaptop;
const isModalWide =
(activeTab === UserSettingsTab.UserInfo && isDesktopOrLaptop) || activeTab === UserSettingsTab.PhoneVerification;
const [showNotificationSettingsTab, showSlackConnectionTab, showTelegramConnectionTab, showMobileAppVerificationTab] =
[

View file

@ -1,17 +1,20 @@
import React, { useCallback } from 'react';
import React, { useCallback, useEffect } from 'react';
import { Tab, TabContent, TabsBar } from '@grafana/ui';
import cn from 'classnames/bind';
import { observer } from 'mobx-react';
import Block from 'components/GBlock/Block';
import MobileAppVerification from 'containers/MobileAppVerification/MobileAppVerification';
import { UserSettingsTab } from 'containers/UserSettings/UserSettings.types';
import { SlackTab } from 'containers/UserSettings/parts/tabs//SlackTab/SlackTab';
import CloudPhoneSettings from 'containers/UserSettings/parts/tabs/CloudPhoneSettings/CloudPhoneSettings';
import { NotificationSettingsTab } from 'containers/UserSettings/parts/tabs/NotificationSettingsTab';
import PhoneVerification from 'containers/UserSettings/parts/tabs/PhoneVerification/PhoneVerification';
import TelegramInfo from 'containers/UserSettings/parts/tabs/TelegramInfo/TelegramInfo';
import { UserInfoTab } from 'containers/UserSettings/parts/tabs/UserInfoTab/UserInfoTab';
import { User } from 'models/user/user.types';
import { AppFeature } from 'state/features';
import { useStore } from 'state/useStore';
import styles from 'containers/UserSettings/parts/index.module.css';
@ -100,8 +103,11 @@ interface TabsContentProps {
isDesktopOrLaptop: boolean;
}
export const TabsContent = (props: TabsContentProps) => {
export const TabsContent = observer((props: TabsContentProps) => {
const { id, activeTab, onTabChange, isDesktopOrLaptop } = props;
useEffect(() => {
store.updateFeatures();
}, []);
const store = useStore();
const { userStore } = store;
@ -124,9 +130,12 @@ export const TabsContent = (props: TabsContentProps) => {
<UserInfoTab id={id} onTabChange={onTabChange} />
))}
{activeTab === UserSettingsTab.NotificationSettings && <NotificationSettingsTab id={id} />}
{activeTab === UserSettingsTab.PhoneVerification && (
<PhoneVerification userPk={id} phone={storeUser.unverified_phone_number || '+'} />
)}
{activeTab === UserSettingsTab.PhoneVerification &&
(store.hasFeature(AppFeature.CloudNotifications) ? (
<CloudPhoneSettings />
) : (
<PhoneVerification userPk={id} phone={storeUser.unverified_phone_number || '+'} />
))}
{activeTab === UserSettingsTab.MobileAppVerification && (
<MobileAppVerification userPk={id} phone={storeUser.unverified_phone_number || '+'} />
)}
@ -134,4 +143,4 @@ export const TabsContent = (props: TabsContentProps) => {
{activeTab === UserSettingsTab.TelegramInfo && <TelegramInfo />}
</TabContent>
);
};
});

View file

@ -0,0 +1,162 @@
import React, { useCallback, useEffect, useState } from 'react';
import { getLocationSrv, LocationUpdate } from '@grafana/runtime';
import {
Field,
Input,
Button,
Modal,
HorizontalGroup,
Alert,
Icon,
VerticalGroup,
Table,
LoadingPlaceholder,
} from '@grafana/ui';
import cn from 'classnames/bind';
import { observer } from 'mobx-react';
import Block from 'components/GBlock/Block';
import GTable from 'components/GTable/GTable';
import PluginLink from 'components/PluginLink/PluginLink';
import Text from 'components/Text/Text';
import WithConfirm from 'components/WithConfirm/WithConfirm';
import { User as UserType } from 'models/user/user.types';
import { AppFeature } from 'state/features';
import { WithStoreProps } from 'state/types';
import { useStore } from 'state/useStore';
import { UserAction } from 'state/userAction';
import { withMobXProviderContext } from 'state/withStore';
import styles from './CloudPhoneSettings.module.css';
const cx = cn.bind(styles);
interface CloudPhoneSettingsProps extends WithStoreProps {}
const CloudPhoneSettings = observer((props: CloudPhoneSettingsProps) => {
const store = useStore();
const [syncing, setSyncing] = useState<boolean>(false);
const [userStatus, setUserStatus] = useState<number>(0);
const [userLink, setUserLink] = useState<string>(null);
useEffect(() => {
getCloudUserInfo();
}, []);
const handleLinkClick = (link: string) => {
getLocationSrv().update({ partial: false, path: link });
};
const syncUser = async () => {
setSyncing(true);
await store.cloudStore.syncCloudUser(store.userStore.currentUserPk);
setSyncing(false);
};
const getCloudUserInfo = async () => {
const cloudUser = await store.cloudStore.getCloudUser(store.userStore.currentUserPk);
setUserStatus(cloudUser?.cloud_data?.status);
setUserLink(cloudUser?.cloud_data?.link);
};
const UserCloudStatus = () => {
switch (userStatus) {
case 0:
if (store.hasFeature(AppFeature.CloudNotifications)) {
return (
<VerticalGroup spacing="lg">
<Text>Your account successfully matched, but Cloud is not connected. </Text>
<PluginLink query={{ page: 'cloud' }}>
<Button variant="secondary" icon="external-link-alt">
Open Grafana Cloud page
</Button>
</PluginLink>
</VerticalGroup>
);
}
return (
<VerticalGroup spacing="lg">
<Text>Grafana Cloud is not synced</Text>
</VerticalGroup>
);
case 1:
return (
<VerticalGroup spacing="lg">
<Text>
{
'We cant find a matching account in the connected Grafana Cloud instance (matching happens by e-mail). '
}
</Text>
<Button variant="primary" onClick={() => handleLinkClick(userLink)}>
Sign up in Grafana Cloud
</Button>
</VerticalGroup>
);
case 2:
return (
<VerticalGroup spacing="lg">
<Text>
Your account successfully matched with the Grafana Cloud account. Please verify your phone number.{' '}
</Text>
<Button variant="secondary" icon="external-link-alt" onClick={() => handleLinkClick(userLink)}>
Verify phone number in Grafana Cloud
</Button>
</VerticalGroup>
);
case 3:
return (
<VerticalGroup spacing="lg">
<Text>
Your account successfully matched with the Grafana Cloud account. Your phone number is verified.{' '}
</Text>
<Button variant="secondary" icon="external-link-alt" onClick={() => handleLinkClick(userLink)}>
Open account in Grafana Cloud
</Button>
</VerticalGroup>
);
default:
return (
<VerticalGroup spacing="lg">
<Text>
{
'We cant find a matching account in the connected Grafana Cloud instance (matching happens by e-mail). '
}
</Text>
<Button variant="primary" onClick={() => handleLinkClick(userLink)}>
Sign up in Grafana Cloud
</Button>
</VerticalGroup>
);
}
};
return (
<>
{store.isUserActionAllowed(UserAction.UpdateOtherUsersSettings) ? (
<VerticalGroup spacing="lg">
<HorizontalGroup justify="space-between">
<Text.Title level={3}>OnCall use Grafana Cloud for SMS and phone call notifications</Text.Title>
{syncing ? (
<Button variant="secondary" icon="sync" disabled>
Updating...
</Button>
) : (
<Button variant="secondary" icon="sync" onClick={syncUser}>
Update
</Button>
)}
</HorizontalGroup>
{!syncing ? <UserCloudStatus /> : <LoadingPlaceholder text="Loading..." />}
</VerticalGroup>
) : (
<VerticalGroup spacing="lg">
<Text.Title level={3}>OnCall use Grafana Cloud for SMS and phone call notifications</Text.Title>
<Text>You do not have permission to perform this action. Ask an admin to upgrade your permissions.</Text>
</VerticalGroup>
)}
</>
);
});
export default withMobXProviderContext(CloudPhoneSettings);

View file

@ -0,0 +1,8 @@
<svg width="15px" height="15px" viewBox="0 0 15 15" fill="none" xmlns="http://www.w3.org/2000/svg">
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="M0.877075 7.49988C0.877075 3.84219 3.84222 0.877045 7.49991 0.877045C11.1576 0.877045 14.1227 3.84219 14.1227 7.49988C14.1227 11.1575 11.1576 14.1227 7.49991 14.1227C3.84222 14.1227 0.877075 11.1575 0.877075 7.49988ZM7.49991 1.82704C4.36689 1.82704 1.82708 4.36686 1.82708 7.49988C1.82708 10.6329 4.36689 13.1727 7.49991 13.1727C10.6329 13.1727 13.1727 10.6329 13.1727 7.49988C13.1727 4.36686 10.6329 1.82704 7.49991 1.82704ZM9.85358 5.14644C10.0488 5.3417 10.0488 5.65829 9.85358 5.85355L8.20713 7.49999L9.85358 9.14644C10.0488 9.3417 10.0488 9.65829 9.85358 9.85355C9.65832 10.0488 9.34173 10.0488 9.14647 9.85355L7.50002 8.2071L5.85358 9.85355C5.65832 10.0488 5.34173 10.0488 5.14647 9.85355C4.95121 9.65829 4.95121 9.3417 5.14647 9.14644L6.79292 7.49999L5.14647 5.85355C4.95121 5.65829 4.95121 5.3417 5.14647 5.14644C5.34173 4.95118 5.65832 4.95118 5.85358 5.14644L7.50002 6.79289L9.14647 5.14644C9.34173 4.95118 9.65832 4.95118 9.85358 5.14644Z"
fill="currentColor"
/>
</svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

View file

@ -0,0 +1,24 @@
<?xml version="1.0" encoding="iso-8859-1"?>
<!-- Generator: Adobe Illustrator 19.0.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
<svg
version="1.1"
width="16"
height="16"
id="Capa_1"
xmlns="http://www.w3.org/2000/svg"
x="0px"
y="0px"
fill="currentColor"
viewBox="0 0 490.4 490.4"
>
<g>
<path
d="M222.5,453.7c6.1,6.1,14.3,9.5,22.9,9.5c8.5,0,16.9-3.5,22.9-9.5L448,274c27.3-27.3,42.3-63.6,42.4-102.1
c0-38.6-15-74.9-42.3-102.2S384.6,27.4,346,27.4c-37.9,0-73.6,14.5-100.7,40.9c-27.2-26.5-63-41.1-101-41.1
c-38.5,0-74.7,15-102,42.2C15,96.7,0,133,0,171.6c0,38.5,15.1,74.8,42.4,102.1L222.5,453.7z M59.7,86.8
c22.6-22.6,52.7-35.1,84.7-35.1s62.2,12.5,84.9,35.2l7.4,7.4c2.3,2.3,5.4,3.6,8.7,3.6l0,0c3.2,0,6.4-1.3,8.7-3.6l7.2-7.2
c22.7-22.7,52.8-35.2,84.9-35.2c32,0,62.1,12.5,84.7,35.1c22.7,22.7,35.1,52.8,35.1,84.8s-12.5,62.1-35.2,84.8L251,436.4
c-2.9,2.9-8.2,2.9-11.2,0l-180-180c-22.7-22.7-35.2-52.8-35.2-84.8C24.6,139.6,37.1,109.5,59.7,86.8z"
/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1 KiB

View file

@ -168,6 +168,42 @@ export const HeartRedIcon = (props: IconProps) => (
</svg>
);
export const HeartIcon = (props: IconProps) => (
<svg
version="1.1"
width="16"
height="16"
id="Capa_1"
xmlns="http://www.w3.org/2000/svg"
x="0px"
y="0px"
fill="currentColor"
viewBox="0 0 490.4 490.4"
>
<g>
<path
d="M222.5,453.7c6.1,6.1,14.3,9.5,22.9,9.5c8.5,0,16.9-3.5,22.9-9.5L448,274c27.3-27.3,42.3-63.6,42.4-102.1
c0-38.6-15-74.9-42.3-102.2S384.6,27.4,346,27.4c-37.9,0-73.6,14.5-100.7,40.9c-27.2-26.5-63-41.1-101-41.1
c-38.5,0-74.7,15-102,42.2C15,96.7,0,133,0,171.6c0,38.5,15.1,74.8,42.4,102.1L222.5,453.7z M59.7,86.8
c22.6-22.6,52.7-35.1,84.7-35.1s62.2,12.5,84.9,35.2l7.4,7.4c2.3,2.3,5.4,3.6,8.7,3.6l0,0c3.2,0,6.4-1.3,8.7-3.6l7.2-7.2
c22.7-22.7,52.8-35.2,84.9-35.2c32,0,62.1,12.5,84.7,35.1c22.7,22.7,35.1,52.8,35.1,84.8s-12.5,62.1-35.2,84.8L251,436.4
c-2.9,2.9-8.2,2.9-11.2,0l-180-180c-22.7-22.7-35.2-52.8-35.2-84.8C24.6,139.6,37.1,109.5,59.7,86.8z"
/>
</g>
</svg>
);
export const CrossCircleIcon = (props: IconProps) => (
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path
fillRule="evenodd"
clipRule="evenodd"
d="M0.877075 7.49988C0.877075 3.84219 3.84222 0.877045 7.49991 0.877045C11.1576 0.877045 14.1227 3.84219 14.1227 7.49988C14.1227 11.1575 11.1576 14.1227 7.49991 14.1227C3.84222 14.1227 0.877075 11.1575 0.877075 7.49988ZM7.49991 1.82704C4.36689 1.82704 1.82708 4.36686 1.82708 7.49988C1.82708 10.6329 4.36689 13.1727 7.49991 13.1727C10.6329 13.1727 13.1727 10.6329 13.1727 7.49988C13.1727 4.36686 10.6329 1.82704 7.49991 1.82704ZM9.85358 5.14644C10.0488 5.3417 10.0488 5.65829 9.85358 5.85355L8.20713 7.49999L9.85358 9.14644C10.0488 9.3417 10.0488 9.65829 9.85358 9.85355C9.65832 10.0488 9.34173 10.0488 9.14647 9.85355L7.50002 8.2071L5.85358 9.85355C5.65832 10.0488 5.34173 10.0488 5.14647 9.85355C4.95121 9.65829 4.95121 9.3417 5.14647 9.14644L6.79292 7.49999L5.14647 5.85355C4.95121 5.65829 4.95121 5.3417 5.14647 5.14644C5.34173 4.95118 5.65832 4.95118 5.85358 5.14644L7.50002 6.79289L9.14647 5.14644C9.34173 4.95118 9.65832 4.95118 9.85358 5.14644Z"
fill="currentColor"
/>
</svg>
);
export const GrafanaIcon = (props: IconProps) => (
<svg width="12" height="12" viewBox="0 0 12 12" fill="currentColor" xmlns="http://www.w3.org/2000/svg">
<path

View file

@ -0,0 +1,77 @@
import { get } from 'lodash-es';
import { action, computed, observable } from 'mobx';
import BaseStore from 'models/base_store';
import { NotificationPolicyType } from 'models/notification_policy';
import { User } from 'models/user/user.types';
import { makeRequest } from 'network';
import { Mixpanel } from 'services/mixpanel';
import { RootStore } from 'state';
import { move } from 'state/helpers';
import { Cloud } from './cloud.types';
export class CloudStore extends BaseStore {
@observable.shallow
searchResult: { count?: number; results?: Array<Cloud['id']> } = {};
@observable.shallow
items: { [id: string]: Cloud } = {};
constructor(rootStore: RootStore) {
super(rootStore);
this.path = '/cloud_users/';
}
@action
async updateItems(page = 1) {
const { count, results } = await makeRequest(this.path, {
params: { page },
});
this.items = {
...this.items,
...results.reduce(
(acc: { [key: number]: Cloud }, item: Cloud) => ({
...acc,
[item.id]: item,
}),
{}
),
};
this.searchResult = {
count,
results: results.map((item: Cloud) => item.id),
};
}
getSearchResult() {
return {
count: this.searchResult.count,
results: this.searchResult.results && this.searchResult.results.map((id: Cloud['id']) => this.items?.[id]),
};
}
async syncCloudUsers() {
return await makeRequest(`${this.path}`, { method: 'POST' });
}
async syncCloudUser(id: string) {
return await makeRequest(`${this.path}`, { method: 'POST' });
}
async getCloudUser(id: string) {
return await makeRequest(`${this.path}${id}`, { method: 'GET' });
}
async getCloudConnectionStatus() {
return await makeRequest(`/cloud_connection/`, { method: 'GET' });
}
@action
async disconnectToCloud() {
return await makeRequest(`/cloud_connection/`, { method: 'DELETE' });
}
}

View file

@ -0,0 +1,9 @@
export interface Cloud {
id: string;
username: string;
email: string;
cloud_data?: {
status?: number;
link?: string;
};
}

View file

@ -60,4 +60,9 @@ export class GlobalSettingStore extends BaseStore {
return this.searchResult[query].map((globalSettingId: GlobalSetting['id']) => this.items[globalSettingId]);
}
async getGlobalSettingItemByName(name: string) {
const results = await this.getAll();
return results.find((element: { name: string }) => element.name === name);
}
}

View file

@ -50,4 +50,6 @@ export interface User {
permissions: UserAction[];
trigger_video_call?: boolean;
export_url?: string;
status?: number;
link?: string;
}

View file

@ -0,0 +1,66 @@
.info-block {
width: 70%;
min-width: 1100px;
padding: 24px;
}
.warning-message {
color: var(--warning-text-color);
}
.success-message {
color: var(--success-text-color);
}
.error-message {
color: var(--error-text-color);
}
.user-table {
margin-top: 24px;
width: 100%;
}
.user-row {
height: 32px;
}
.cloud-page-title {
margin-top: 24px;
}
.cloud-oncall-name {
color: #f55f3e;
}
.block-icon {
color: var(--secondary-text-color);
}
.error-icon {
display: inline-block;
white-space: break-spaces;
line-height: 20px;
color: var(--error-text-color);
}
.error-icon svg {
vertical-align: middle;
}
.heart-icon {
color: var(--secondary-text-color);
margin-right: 8px;
}
.block-button {
margin-top: 24px;
}
.table-title {
margin-bottom: 16px;
}
.table-button {
float: right;
}

View file

@ -0,0 +1,395 @@
import React, { useCallback, useEffect, useState } from 'react';
import { getLocationSrv, LocationUpdate } from '@grafana/runtime';
import {
Field,
Input,
Button,
Modal,
HorizontalGroup,
Alert,
Icon,
VerticalGroup,
Table,
LoadingPlaceholder,
} from '@grafana/ui';
import cn from 'classnames/bind';
import { observer } from 'mobx-react';
import Block from 'components/GBlock/Block';
import GTable from 'components/GTable/GTable';
import PluginLink from 'components/PluginLink/PluginLink';
import Text from 'components/Text/Text';
import WithConfirm from 'components/WithConfirm/WithConfirm';
import { CrossCircleIcon, HeartIcon } from 'icons';
import { Cloud } from 'models/cloud/cloud.types';
import { WithStoreProps } from 'state/types';
import { useStore } from 'state/useStore';
import { withMobXProviderContext } from 'state/withStore';
import { openErrorNotification } from 'utils';
import styles from './CloudPage.module.css';
const cx = cn.bind(styles);
interface CloudPageProps extends WithStoreProps {}
const ITEMS_PER_PAGE = 50;
const CloudPage = observer((props: CloudPageProps) => {
const store = useStore();
const [page, setPage] = useState<number>(1);
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 [showConfirmationModal, setShowConfirmationModal] = useState<boolean>(false);
const [syncingUsers, setSyncingUsers] = useState<boolean>(false);
useEffect(() => {
store.cloudStore.updateItems(page);
store.cloudStore.getCloudConnectionStatus().then((cloudStatus) => {
setCloudIsConnected(cloudStatus.cloud_connection_status);
setHeartbitStatus(cloudStatus.cloud_heartbeat_enabled);
setHeartbitLink(cloudStatus.cloud_heartbeat_link);
getApiKeyFromGlobalSettings();
});
}, [cloudIsConnected]);
const { count, results } = store.cloudStore.getSearchResult();
const handleChangePage = (page: number) => {
setPage(page);
store.cloudStore.updateItems(page);
};
const handleChangeCloudApiKey = useCallback((e) => {
setCloudApiKey(e.target.value);
setApiKeyError(false);
}, []);
const saveKeyAndConnect = () => {
setShowConfirmationModal(true);
};
const disconnectCloudOncall = () => {
setCloudIsConnected(false);
store.cloudStore.disconnectToCloud();
};
const getApiKeyFromGlobalSettings = async () => {
const globalSettingItem = await store.globalSettingStore.getGlobalSettingItemByName('GRAFANA_CLOUD_ONCALL_TOKEN');
if (cloudIsConnected === false) {
setCloudApiKey(globalSettingItem?.value);
}
};
const connectToCloud = async () => {
setShowConfirmationModal(false);
const globalSettingItem = await store.globalSettingStore.getGlobalSettingItemByName('GRAFANA_CLOUD_ONCALL_TOKEN');
store.globalSettingStore
.update(globalSettingItem?.id, { name: 'GRAFANA_CLOUD_ONCALL_TOKEN', value: cloudApiKey })
.then((response) => {
if (response.error) {
setCloudIsConnected(false);
setApiKeyError(true);
openErrorNotification(response.error);
} else {
setCloudIsConnected(true);
syncUsers();
}
});
};
const syncUsers = async () => {
setSyncingUsers(true);
await store.cloudStore.syncCloudUsers();
await store.cloudStore.updateItems();
setSyncingUsers(false);
};
const handleLinkClick = (link: string) => {
getLocationSrv().update({ partial: false, path: link });
};
const renderButtons = (user: Cloud) => {
switch (user?.cloud_data?.status) {
case 0:
return null;
case 1:
return null;
case 2:
return (
<Button
variant="secondary"
icon="external-link-alt"
size="sm"
className={cx('table-button')}
onClick={() => handleLinkClick(user?.cloud_data?.link)}
>
Open profile in Cloud
</Button>
);
case 3:
return (
<Button
variant="secondary"
icon="external-link-alt"
size="sm"
className={cx('table-button')}
onClick={() => handleLinkClick(user?.cloud_data?.link)}
>
Configure notifications
</Button>
);
default:
return null;
}
};
const renderStatus = (user: Cloud) => {
switch (user?.cloud_data?.status) {
case 0:
return <Text className={cx('error-message')}>Grafana Cloud is not synced</Text>;
case 1:
return <Text className={cx('error-message')}>User not found in Grafana Cloud</Text>;
case 2:
return <Text type="warning">Phone number is not verified in Grafana Cloud</Text>;
case 3:
return <Text type="success">Phone number verified</Text>;
default:
return <Text className={cx('error-message')}>User not found in Grafana Cloud</Text>;
}
};
const renderStatusIcon = (user: Cloud) => {
switch (user?.cloud_data?.status) {
case 0:
return (
<div className={cx('error-icon')}>
<CrossCircleIcon />
</div>
);
case 1:
return (
<div className={cx('error-icon')}>
<CrossCircleIcon />
</div>
);
case 2:
return <Icon className={cx('warning-message')} name="exclamation-triangle" />;
case 3:
return <Icon className={cx('success-message')} name="check-circle" />;
default:
return (
<div className={cx('error-message')}>
<CrossCircleIcon />
</div>
);
}
};
const renderEmail = (user: Cloud) => {
return <Text type="primary">{user.email}</Text>;
};
const columns = [
{
width: '2%',
render: renderStatusIcon,
key: 'statusIcon',
},
{
width: '28%',
render: renderEmail,
key: 'email',
},
{
width: '50%',
render: renderStatus,
key: 'status',
},
{
width: '20%',
render: renderButtons,
key: 'buttons',
align: 'actions',
},
];
const ConnectedBlock = (
<VerticalGroup spacing="lg">
<Block withBackground bordered className={cx('info-block')}>
<VerticalGroup>
<Text.Title level={4}>
<Icon name="check" className={cx('block-icon')} size="lg" /> Cloud OnCall API key
</Text.Title>
<Text type="secondary">Cloud OnCall is sucessfully connected.</Text>
<WithConfirm title="Are you sure to disconnect Cloud OnCall?" confirmText="Disconnect">
<Button variant="destructive" onClick={disconnectCloudOncall} size="md" className={cx('block-button')}>
Disconnect
</Button>
</WithConfirm>
</VerticalGroup>
</Block>
<Block bordered withBackground className={cx('info-block')}>
<VerticalGroup>
<Text.Title level={4}>
<span className={cx('heart-icon')}>
<HeartIcon />
</span>
Monitor cloud instance with heartbeat
</Text.Title>
<Text type="secondary">
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>
)}
</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>
<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">
{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
</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>
</Block>
</VerticalGroup>
);
const DisconnectedBlock = (
<VerticalGroup spacing="lg">
<Block withBackground bordered className={cx('info-block')}>
<VerticalGroup>
<Text.Title level={4}>
<Icon name="sync" className={cx('block-icon')} size="lg" /> Cloud OnCall API key
</Text.Title>
<Field
label=""
description="Find it in you Cloud OnCall -> Settings page"
style={{ width: '100%' }}
invalid={apiKeyError}
>
<Input id="cloudApiKey" onChange={handleChangeCloudApiKey} defaultValue={cloudApiKey} />
</Field>
<Button variant="primary" onClick={saveKeyAndConnect} disabled={!cloudApiKey} size="md">
Save key and connect
</Button>
</VerticalGroup>
</Block>
<Block bordered withBackground className={cx('info-block')}>
<VerticalGroup>
<Text.Title level={4}>
<span className={cx('block-icon')}>
<HeartIcon />
</span>{' '}
Monitor cloud instance with heartbeat
</Text.Title>
<Text type="secondary">
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>
</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>
<Text type="secondary">Users matched between OSS and Cloud OnCall currently unavialable.</Text>
</VerticalGroup>
</Block>
</VerticalGroup>
);
return (
<div className={cx('root')}>
<VerticalGroup spacing="lg">
<Text.Title level={3} className={cx('cloud-page-title')}>
Connect Open Source OnCall and <Text className={cx('cloud-oncall-name')}>Cloud OnCall</Text>
</Text.Title>
{cloudIsConnected === undefined ? (
<LoadingPlaceholder text="Loading..." />
) : cloudIsConnected ? (
ConnectedBlock
) : (
DisconnectedBlock
)}
{showConfirmationModal && (
<Modal
isOpen
title="Are you sure you want to connect to cloud?"
onDismiss={() => setShowConfirmationModal(false)}
>
<HorizontalGroup>
<Button variant="primary" onClick={connectToCloud}>
Continue
</Button>
<Button variant="secondary" onClick={() => setShowConfirmationModal(false)}>
Cancel
</Button>
</HorizontalGroup>
</Modal>
)}
</VerticalGroup>
</div>
);
});
export default withMobXProviderContext(CloudPage);

View file

@ -3,6 +3,7 @@ import React from 'react';
import { AppRootProps } from '@grafana/data';
import ChatOpsPage from 'pages/chat-ops/ChatOps';
import CloudPage from 'pages/cloud/CloudPage';
import EscalationsChainsPage from 'pages/escalation-chains/EscalationChains';
import IncidentPage2 from 'pages/incident/Incident';
import IncidentsPage2 from 'pages/incidents/Incidents';
@ -116,6 +117,13 @@ export const pages: PageDefinition[] = [
text: 'Migrate From Amixr.IO',
hideFromTabs: true,
},
{
component: CloudPage,
icon: 'cloud',
id: 'cloud',
text: 'Cloud',
role: 'Admin',
},
{
component: Test,
icon: 'cog',

View file

@ -3,4 +3,6 @@ export enum AppFeature {
Telegram = 'telegram',
LiveSettings = 'live_settings',
MobileApp = 'mobile_app',
CloudNotifications = 'grafana_cloud_notifications',
CloudConnection = 'grafana_cloud_connection',
}

View file

@ -9,6 +9,7 @@ import { AlertReceiveChannel } from 'models/alert_receive_channel/alert_receive_
import { AlertReceiveChannelFiltersStore } from 'models/alert_receive_channel_filters/alert_receive_channel_filters';
import { AlertGroupStore } from 'models/alertgroup/alertgroup';
import { ApiTokenStore } from 'models/api_token/api_token';
import { CloudStore } from 'models/cloud/cloud';
import { EscalationChainStore } from 'models/escalation_chain/escalation_chain';
import { EscalationPolicyStore } from 'models/escalation_policy/escalation_policy';
import { GlobalSettingStore } from 'models/global_setting/global_setting';
@ -81,6 +82,7 @@ export class RootBaseStore {
// --------------------------
userStore: UserStore = new UserStore(this);
cloudStore: CloudStore = new CloudStore(this);
grafanaTeamStore: GrafanaTeamStore = new GrafanaTeamStore(this);
alertReceiveChannelStore: AlertReceiveChannelStore = new AlertReceiveChannelStore(this);
outgoingWebhookStore: OutgoingWebhookStore = new OutgoingWebhookStore(this);

View file

@ -16,6 +16,7 @@ type Args = {
orgRole: 'Viewer' | 'Editor' | 'Admin';
};
enableLiveSettings: boolean;
enableCloudPage: boolean;
};
export function useForceUpdate() {
@ -23,7 +24,7 @@ export function useForceUpdate() {
return () => setValue((value) => value + 1);
}
export function useNavModel({ meta, pages, path, page, grafanaUser, enableLiveSettings }: Args) {
export function useNavModel({ meta, pages, path, page, grafanaUser, enableLiveSettings, enableCloudPage }: Args) {
return useMemo(() => {
const tabs: NavModelItem[] = [];
@ -36,7 +37,8 @@ export function useNavModel({ meta, pages, path, page, grafanaUser, enableLiveSe
hideFromTabs:
hideFromTabs ||
(role === 'Admin' && grafanaUser.orgRole !== role) ||
(id === 'live-settings' && !enableLiveSettings),
(id === 'live-settings' && !enableLiveSettings) ||
(id === 'cloud' && !enableCloudPage),
});
if (page === id) {
@ -61,7 +63,7 @@ export function useNavModel({ meta, pages, path, page, grafanaUser, enableLiveSe
node,
main: node,
};
}, [meta.info.logos.large, pages, path, page, enableLiveSettings]);
}, [meta.info.logos.large, pages, path, page, enableLiveSettings, enableCloudPage]);
}
export function usePrevious(value: any) {

View file

@ -22,6 +22,8 @@
--secondary-text-color: rgba(36, 41, 46, 0.75);
--disabled-text-color: rgba(36, 41, 46, 0.5);
--warning-text-color: #8a6c00;
--success-text-color: rgb(10, 118, 78);
--error-text-color: rgb(207, 14, 91);
--primary-text-link: #1f62e0;
--timeline-icon-background: rgba(70, 76, 84, 0);
--timeline-icon-background-resolution-note: rgba(50, 116, 217, 0);
@ -38,6 +40,8 @@
--secondary-text-color: rgba(204, 204, 220, 0.65);
--disabled-text-color: rgba(204, 204, 220, 0.4);
--warning-text-color: #f8d06b;
--success-text-color: rgb(108, 207, 142);
--error-text-color: rgb(255, 82, 134);
--primary-text-link: #6e9fff;
--timeline-icon-background: rgba(70, 76, 84, 1);
--timeline-icon-background-resolution-note: rgba(50, 116, 217, 1);