Merge pull request #5 from grafana/grafana_cloud_notifications
Grafana cloud notifications
This commit is contained in:
commit
111935d552
58 changed files with 1671 additions and 162 deletions
|
|
@ -1,5 +1,5 @@
|
|||
from apps.alerts.incident_appearance.templaters.alert_templater import AlertTemplater
|
||||
from common.utils import clean_markup
|
||||
from common.utils import clean_markup, escape_for_twilio_phone_call
|
||||
|
||||
|
||||
class AlertPhoneCallTemplater(AlertTemplater):
|
||||
|
|
@ -24,8 +24,4 @@ class AlertPhoneCallTemplater(AlertTemplater):
|
|||
return sf.format(data)
|
||||
|
||||
def _escape(self, data):
|
||||
# https://www.twilio.com/docs/api/errors/12100
|
||||
data = data.replace("&", "&")
|
||||
data = data.replace(">", ">")
|
||||
data = data.replace("<", "<")
|
||||
return data
|
||||
return escape_for_twilio_phone_call(data)
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
4
engine/apps/oss_installation/constants.py
Normal file
4
engine/apps/oss_installation/constants.py
Normal 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
|
||||
|
|
@ -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)),
|
||||
],
|
||||
),
|
||||
]
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
155
engine/apps/oss_installation/models/cloud_connector.py
Normal file
155
engine/apps/oss_installation/models/cloud_connector.py
Normal 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()
|
||||
|
|
@ -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()
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
1
engine/apps/oss_installation/serializers/__init__.py
Normal file
1
engine/apps/oss_installation/serializers/__init__.py
Normal file
|
|
@ -0,0 +1 @@
|
|||
from .cloud_user import CloudUserSerializer # noqa: F401
|
||||
37
engine/apps/oss_installation/serializers/cloud_user.py
Normal file
37
engine/apps/oss_installation/serializers/cloud_user.py
Normal 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
|
||||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
]
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
42
engine/apps/oss_installation/views/cloud_connection.py
Normal file
42
engine/apps/oss_installation/views/cloud_connection.py
Normal 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)
|
||||
|
|
@ -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)
|
||||
106
engine/apps/oss_installation/views/cloud_users.py
Normal file
106
engine/apps/oss_installation/views/cloud_users.py
Normal 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"})
|
||||
|
|
@ -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
|
||||
6
engine/apps/public_api/throttlers/info_throttler.py
Normal file
6
engine/apps/public_api/throttlers/info_throttler.py
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
from rest_framework.throttling import UserRateThrottle
|
||||
|
||||
|
||||
class InfoThrottler(UserRateThrottle):
|
||||
scope = "info"
|
||||
rate = "100/m"
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
from rest_framework.throttling import UserRateThrottle
|
||||
|
||||
|
||||
class PhoneNotificationThrottler(UserRateThrottle):
|
||||
scope = "phone_notification"
|
||||
rate = "60/m"
|
||||
|
|
@ -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"),
|
||||
]
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
76
engine/apps/public_api/views/phone_notifications.py
Normal file
76
engine/apps/public_api/views/phone_notifications.py
Normal 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)
|
||||
|
|
@ -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")
|
||||
|
|
|
|||
23
engine/apps/twilioapp/migrations/0002_auto_20220604_1008.py
Normal file
23
engine/apps/twilioapp/migrations/0002_auto_20220604_1008.py
Normal 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),
|
||||
),
|
||||
]
|
||||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -177,6 +177,14 @@ def clean_markup(text):
|
|||
return cleaned
|
||||
|
||||
|
||||
def escape_for_twilio_phone_call(text):
|
||||
# https://www.twilio.com/docs/api/errors/12100
|
||||
text = text.replace("&", "&")
|
||||
text = text.replace(">", ">")
|
||||
text = text.replace("<", "<")
|
||||
return text
|
||||
|
||||
|
||||
def escape_html(text):
|
||||
return html.escape(text)
|
||||
|
||||
|
|
|
|||
|
|
@ -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")),
|
||||
]
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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] =
|
||||
[
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
});
|
||||
|
|
|
|||
|
|
@ -0,0 +1,3 @@
|
|||
.test {
|
||||
color: grey;
|
||||
}
|
||||
|
|
@ -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 can’t 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 can’t 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);
|
||||
8
grafana-plugin/src/icons/cross-circled.svg
Normal file
8
grafana-plugin/src/icons/cross-circled.svg
Normal 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 |
24
grafana-plugin/src/icons/heart-line.svg
Normal file
24
grafana-plugin/src/icons/heart-line.svg
Normal 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 |
|
|
@ -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
|
||||
|
|
|
|||
77
grafana-plugin/src/models/cloud/cloud.ts
Normal file
77
grafana-plugin/src/models/cloud/cloud.ts
Normal 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' });
|
||||
}
|
||||
}
|
||||
9
grafana-plugin/src/models/cloud/cloud.types.ts
Normal file
9
grafana-plugin/src/models/cloud/cloud.types.ts
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
export interface Cloud {
|
||||
id: string;
|
||||
username: string;
|
||||
email: string;
|
||||
cloud_data?: {
|
||||
status?: number;
|
||||
link?: string;
|
||||
};
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -50,4 +50,6 @@ export interface User {
|
|||
permissions: UserAction[];
|
||||
trigger_video_call?: boolean;
|
||||
export_url?: string;
|
||||
status?: number;
|
||||
link?: string;
|
||||
}
|
||||
|
|
|
|||
66
grafana-plugin/src/pages/cloud/CloudPage.module.css
Normal file
66
grafana-plugin/src/pages/cloud/CloudPage.module.css
Normal 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;
|
||||
}
|
||||
395
grafana-plugin/src/pages/cloud/CloudPage.tsx
Normal file
395
grafana-plugin/src/pages/cloud/CloudPage.tsx
Normal 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);
|
||||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -3,4 +3,6 @@ export enum AppFeature {
|
|||
Telegram = 'telegram',
|
||||
LiveSettings = 'live_settings',
|
||||
MobileApp = 'mobile_app',
|
||||
CloudNotifications = 'grafana_cloud_notifications',
|
||||
CloudConnection = 'grafana_cloud_connection',
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue