commit
80b7e52bf1
25 changed files with 405 additions and 94 deletions
12
CHANGELOG.md
12
CHANGELOG.md
|
|
@ -5,7 +5,17 @@ All notable changes to this project will be documented in this file.
|
|||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
## Unreleased
|
||||
## v1.3.55 (2023-11-07)
|
||||
|
||||
### Changed
|
||||
|
||||
- Unify naming of Grafana Cloud / Cloud OnCall / Grafana Cloud OnCall
|
||||
so that it's always Grafana Cloud OnCall ([#3279](https://github.com/grafana/oncall/pull/3279))
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fix escalation policy importance going back to default by @vadimkerr ([#3282](https://github.com/grafana/oncall/pull/3282))
|
||||
- Improve user permissions query ([#3291](https://github.com/grafana/oncall/pull/3291))
|
||||
|
||||
## v1.3.54 (2023-11-06)
|
||||
|
||||
|
|
|
|||
|
|
@ -44,14 +44,14 @@ To access your QR code:
|
|||
1. Navigate to the **Users** tab, then tap **View my profile**
|
||||
1. tap **Mobile app connection** in your profile
|
||||
|
||||
>**Note**: The QR code will timeout for security purposes - Screenshots of the QR code are unlikely to work for authentication.
|
||||
> **Note**: The QR code will timeout for security purposes - Screenshots of the QR code are unlikely to work for authentication.
|
||||
|
||||
### Connect to your open source Grafana OnCall account
|
||||
|
||||
Grafana OnCall OSS relies on Grafana Cloud as on relay for push notifications.
|
||||
You must first connect your Grafana OnCall OSS to Grafana Cloud for the mobile app to work.
|
||||
Grafana OnCall OSS relies on Grafana Cloud OnCall as on relay for push notifications.
|
||||
You must first connect your Grafana OnCall OSS to Grafana Cloud OnCall for the mobile app to work.
|
||||
|
||||
To connect to Grafana Cloud, refer to the Cloud page in your OSS Grafana OnCall instance.
|
||||
To connect to Grafana Cloud OnCall, refer to the Cloud page in your OSS Grafana OnCall instance.
|
||||
|
||||
For Grafana OnCall OSS, the QR code includes an authentication token along with a backend URL.
|
||||
Your Grafana OnCall OSS instance should be reachable from the same network as your mobile device, preferably from the internet.
|
||||
|
|
|
|||
|
|
@ -197,13 +197,13 @@ Refer to the following steps to configure the Telegram integration:
|
|||
|
||||
## Grafana OSS-Cloud Setup
|
||||
|
||||
The benefits of connecting to Grafana Cloud include:
|
||||
The benefits of connecting to Grafana Cloud OnCall include:
|
||||
|
||||
- Cloud OnCall could monitor OSS OnCall uptime using heartbeat
|
||||
- Grafana Cloud OnCall could monitor OSS OnCall uptime using heartbeat
|
||||
- SMS for user notifications
|
||||
- Phone calls for user notifications.
|
||||
|
||||
To connect to Grafana Cloud, refer to the **Cloud** page in your OSS Grafana OnCall instance.
|
||||
To connect to Grafana Cloud OnCall, refer to the **Cloud** page in your OSS Grafana OnCall instance.
|
||||
|
||||
## Supported Phone Providers
|
||||
|
||||
|
|
|
|||
|
|
@ -239,7 +239,7 @@ class EscalationSnapshotMixin:
|
|||
self.raw_escalation_snapshot["next_step_eta"] = updated_next_step_eta.strftime("%Y-%m-%dT%H:%M:%S.%fZ")
|
||||
return self.raw_escalation_snapshot
|
||||
|
||||
def start_escalation_if_needed(self, countdown=START_ESCALATION_DELAY, eta=None):
|
||||
def start_escalation_if_needed(self, countdown=START_ESCALATION_DELAY, eta=None, continue_escalation=False):
|
||||
"""
|
||||
:type self:AlertGroup
|
||||
"""
|
||||
|
|
@ -259,9 +259,11 @@ class EscalationSnapshotMixin:
|
|||
|
||||
logger.debug(f"Start escalation for alert group with pk: {self.pk}")
|
||||
|
||||
# take raw escalation snapshot from db if escalation is paused
|
||||
# take raw escalation snapshot from db if escalation is paused or `continue_escalation` flag is True
|
||||
raw_escalation_snapshot = (
|
||||
self.build_raw_escalation_snapshot() if not self.pause_escalation else self.raw_escalation_snapshot
|
||||
self.raw_escalation_snapshot
|
||||
if self.pause_escalation or continue_escalation
|
||||
else self.build_raw_escalation_snapshot()
|
||||
)
|
||||
task_id = celery_uuid()
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,21 @@
|
|||
# Generated by Django 4.2.6 on 2023-11-03 23:02
|
||||
|
||||
import common.migrations.remove_field
|
||||
from django.db import migrations
|
||||
import django_migration_linter as linter
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('alerts', '0037_remove_alertgroup_is_restricted_state'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
linter.IgnoreMigration(),
|
||||
common.migrations.remove_field.RemoveFieldDB(
|
||||
model_name='AlertGroup',
|
||||
name='is_restricted',
|
||||
remove_state_migration=('alerts', '0037_remove_alertgroup_is_restricted_state'),
|
||||
),
|
||||
]
|
||||
|
|
@ -143,6 +143,19 @@ class AlertGroupQuerySet(models.QuerySet):
|
|||
pass
|
||||
raise
|
||||
|
||||
def filter_active(self, *args, **kwargs):
|
||||
# filter alert groups with active escalation
|
||||
return super().filter(
|
||||
*args,
|
||||
~Q(silenced=True, silenced_until__isnull=True), # filter silenced forever alert_groups
|
||||
**kwargs,
|
||||
maintenance_uuid__isnull=True,
|
||||
is_escalation_finished=False,
|
||||
resolved=False,
|
||||
acknowledged=False,
|
||||
root_alert_group=None,
|
||||
)
|
||||
|
||||
|
||||
class AlertGroupSlackRenderingMixin:
|
||||
"""
|
||||
|
|
|
|||
|
|
@ -4,7 +4,6 @@ import typing
|
|||
import requests
|
||||
from celery import shared_task
|
||||
from django.conf import settings
|
||||
from django.db.models import Q
|
||||
from django.utils import timezone
|
||||
|
||||
from apps.alerts.tasks.task_logger import task_logger
|
||||
|
|
@ -100,18 +99,9 @@ def check_escalation_finished_task() -> None:
|
|||
now = timezone.now() - datetime.timedelta(minutes=5)
|
||||
two_days_ago = now - datetime.timedelta(days=2)
|
||||
|
||||
alert_groups = AlertGroup.objects.using(get_random_readonly_database_key_if_present_otherwise_default()).filter(
|
||||
~Q(silenced=True, silenced_until__isnull=True), # filter silenced forever alert_groups
|
||||
# here we should query maintenance_uuid rather than joining on channel__integration
|
||||
# and checking for something like ~Q(channel__integration=AlertReceiveChannel.INTEGRATION_MAINTENANCE)
|
||||
# this avoids an unnecessary join
|
||||
maintenance_uuid__isnull=True,
|
||||
is_escalation_finished=False,
|
||||
resolved=False,
|
||||
acknowledged=False,
|
||||
root_alert_group=None,
|
||||
started_at__range=(two_days_ago, now),
|
||||
)
|
||||
alert_groups = AlertGroup.objects.using(
|
||||
get_random_readonly_database_key_if_present_otherwise_default()
|
||||
).filter_active(started_at__range=(two_days_ago, now))
|
||||
|
||||
task_logger.info(
|
||||
f"There are {len(alert_groups)} alert group(s) to audit"
|
||||
|
|
|
|||
|
|
@ -546,3 +546,34 @@ def test_alert_group_get_paged_users(
|
|||
paged_users = alert_group.get_paged_users()
|
||||
assert len(paged_users) == 1
|
||||
assert alert_group.get_paged_users()[0]["pk"] == user.public_primary_key
|
||||
|
||||
|
||||
@patch("apps.alerts.models.AlertGroup.start_unsilence_task", return_value=None)
|
||||
@pytest.mark.django_db
|
||||
def test_filter_active_alert_groups(
|
||||
mocked_start_unsilence_task,
|
||||
make_organization_and_user,
|
||||
make_alert_receive_channel,
|
||||
make_alert_group,
|
||||
):
|
||||
organization, user = make_organization_and_user()
|
||||
alert_receive_channel = make_alert_receive_channel(organization)
|
||||
|
||||
# alert groups with active escalation
|
||||
alert_group_active = make_alert_group(alert_receive_channel)
|
||||
alert_group_active_silenced = make_alert_group(alert_receive_channel)
|
||||
alert_group_active_silenced.silence_by_user(user, silence_delay=1800) # silence by period
|
||||
# alert groups with inactive escalation
|
||||
alert_group_1 = make_alert_group(alert_receive_channel)
|
||||
alert_group_1.acknowledge_by_user(user)
|
||||
alert_group_2 = make_alert_group(alert_receive_channel)
|
||||
alert_group_2.resolve_by_user(user)
|
||||
alert_group_3 = make_alert_group(alert_receive_channel)
|
||||
alert_group_3.attach_by_user(user, alert_group_active)
|
||||
alert_group_4 = make_alert_group(alert_receive_channel)
|
||||
alert_group_4.silence_by_user(user, silence_delay=None) # silence forever
|
||||
|
||||
active_alert_groups = AlertGroup.objects.filter_active()
|
||||
assert active_alert_groups.count() == 2
|
||||
assert alert_group_active in active_alert_groups
|
||||
assert alert_group_active_silenced in active_alert_groups
|
||||
|
|
|
|||
|
|
@ -2698,12 +2698,13 @@ def test_shifts_for_user_only_two_users_with_shifts(
|
|||
|
||||
now = timezone.now()
|
||||
today = now.replace(hour=0, minute=0, second=0, microsecond=0)
|
||||
tomorrow = today + timezone.timedelta(days=1)
|
||||
start_date = today - timezone.timedelta(days=2)
|
||||
days = 7
|
||||
|
||||
data = {
|
||||
"start": now + timezone.timedelta(hours=1),
|
||||
"rotation_start": now + timezone.timedelta(hours=1),
|
||||
"start": tomorrow + timezone.timedelta(hours=1),
|
||||
"rotation_start": tomorrow + timezone.timedelta(hours=1),
|
||||
"duration": timezone.timedelta(hours=2),
|
||||
"priority_level": 1,
|
||||
"frequency": CustomOnCallShift.FREQUENCY_DAILY,
|
||||
|
|
@ -2733,7 +2734,7 @@ def test_shifts_for_user_only_two_users_with_shifts(
|
|||
passed_shifts, current_shifts, upcoming_shifts = schedule.shifts_for_user(current_user, start_date, days)
|
||||
assert len(passed_shifts) == 0
|
||||
assert len(current_shifts) == 0
|
||||
assert len(upcoming_shifts) == 5
|
||||
assert len(upcoming_shifts) == 4
|
||||
for shift in upcoming_shifts:
|
||||
users = {u["pk"] for u in shift["users"]}
|
||||
assert current_user.public_primary_key in users
|
||||
|
|
|
|||
|
|
@ -154,3 +154,25 @@ def test_populate_slack_usergroups_for_team(
|
|||
assert usergroup.handle == "test_handle"
|
||||
assert usergroup.members == ["test_user_1", "test_user_2"]
|
||||
assert usergroup.is_active
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_get_users_from_members_for_organization(
|
||||
make_organization_with_slack_team_identity,
|
||||
make_slack_user_group,
|
||||
make_user_with_slack_user_identity,
|
||||
):
|
||||
organization, slack_team_identity = make_organization_with_slack_team_identity()
|
||||
|
||||
user_1, slack_user_identity_1 = make_user_with_slack_user_identity(
|
||||
slack_team_identity, organization, slack_id="slack_id_1"
|
||||
)
|
||||
user_2, slack_user_identity_2 = make_user_with_slack_user_identity(
|
||||
slack_team_identity, organization, slack_id="slack_id_2"
|
||||
)
|
||||
user_group = make_slack_user_group(slack_team_identity)
|
||||
user_group.members = ["slack_id_1", "slack_id_2"]
|
||||
user_group.save(update_fields=["members"])
|
||||
|
||||
users = user_group.get_users_from_members_for_organization(organization)
|
||||
assert set(users) == {user_1, user_2}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import datetime
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
import typing
|
||||
from urllib.parse import urljoin
|
||||
|
||||
|
|
@ -34,6 +35,10 @@ if typing.TYPE_CHECKING:
|
|||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class PermissionsQuery(typing.TypedDict):
|
||||
permissions__contains: typing.Dict
|
||||
|
||||
|
||||
class PermissionsRegexQuery(typing.TypedDict):
|
||||
permissions__regex: str
|
||||
|
||||
|
|
@ -387,7 +392,7 @@ class User(models.Model):
|
|||
@staticmethod
|
||||
def build_permissions_query(
|
||||
permission: LegacyAccessControlCompatiblePermission, organization
|
||||
) -> typing.Union[PermissionsRegexQuery, RoleInQuery]:
|
||||
) -> typing.Union[PermissionsQuery, PermissionsRegexQuery, RoleInQuery]:
|
||||
"""
|
||||
This method returns a django query filter that is compatible with RBAC
|
||||
as well as legacy "basic" role based authorization. If a permission is provided we simply do
|
||||
|
|
@ -399,7 +404,11 @@ class User(models.Model):
|
|||
"""
|
||||
if organization.is_rbac_permissions_enabled:
|
||||
# https://stackoverflow.com/a/50251879
|
||||
return PermissionsRegexQuery(permissions__regex=r".*{0}.*".format(permission.value))
|
||||
if settings.DATABASE_TYPE == settings.DATABASE_TYPES.SQLITE3:
|
||||
# https://docs.djangoproject.com/en/4.2/topics/db/queries/#contains
|
||||
return PermissionsRegexQuery(permissions__regex=re.escape(permission.value))
|
||||
required_permission = {"action": permission.value}
|
||||
return PermissionsQuery(permissions__contains=[required_permission])
|
||||
return RoleInQuery(role__lte=permission.fallback_role.value)
|
||||
|
||||
def get_or_create_notification_policies(self, important=False):
|
||||
|
|
|
|||
158
engine/engine/management/commands/continue_escalation.py
Normal file
158
engine/engine/management/commands/continue_escalation.py
Normal file
|
|
@ -0,0 +1,158 @@
|
|||
from celery import uuid as celery_uuid
|
||||
from django.core.management import BaseCommand
|
||||
from django.utils import timezone
|
||||
|
||||
from apps.alerts.models import AlertGroup
|
||||
from apps.alerts.tasks import escalate_alert_group, unsilence_task
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
"""
|
||||
Start escalation for alert groups from the point it was stopped with optionally start unsilence task for silenced
|
||||
alert groups.
|
||||
|
||||
Usage example:
|
||||
`python manage.py continue_escalation -ppk "ppk1" "ppk2"` - continue escalation for alert groups with these
|
||||
public pks
|
||||
`python manage.py continue_escalation -id 1 2 -uns` - continue escalation for alert groups with these ids and
|
||||
schedule unsilence task for silenced alert groups
|
||||
"""
|
||||
|
||||
def add_arguments(self, parser):
|
||||
group = parser.add_mutually_exclusive_group(required=True)
|
||||
|
||||
group.add_argument(
|
||||
"-id", "--alert_group_ids", type=int, nargs="+", help="Alert group IDs to restart escalation for."
|
||||
)
|
||||
group.add_argument(
|
||||
"-ppk", "--alert_group_ppk", type=str, nargs="+", help="Alert group public pks to restart escalation for."
|
||||
)
|
||||
group.add_argument(
|
||||
"--all", action="store_true", help="Restart escalation for all alert groups with unfinished escalation."
|
||||
)
|
||||
parser.add_argument(
|
||||
"-uns", "--unsilence_task", action="store_true", help="Restart unsilence task for selected alert groups."
|
||||
)
|
||||
parser.add_argument( # used for cases with migrated organizations to actualize data in escalation snapshot
|
||||
"-rebuild",
|
||||
"--rebuild_escalation_snapshot",
|
||||
action="store_true",
|
||||
help="Rebuild escalation snapshot for selected alert groups.",
|
||||
)
|
||||
|
||||
def handle(self, *args, **options):
|
||||
alert_group_ids = options["alert_group_ids"]
|
||||
alert_group_ppk = options["alert_group_ppk"]
|
||||
restart_all = options["all"]
|
||||
restart_unsilence_task = options["unsilence_task"]
|
||||
rebuild_escalation_snapshot = options["rebuild_escalation_snapshot"]
|
||||
|
||||
if restart_all:
|
||||
self.stdout.write("Processing restart escalation for all active alert groups...")
|
||||
alert_groups = AlertGroup.objects.filter_active()
|
||||
elif alert_group_ids:
|
||||
self.stdout.write(f"Processing restart escalation for alert groups with ids: {alert_group_ids}...")
|
||||
alert_groups = AlertGroup.objects.filter(
|
||||
pk__in=alert_group_ids,
|
||||
raw_escalation_snapshot__isnull=False,
|
||||
)
|
||||
else:
|
||||
self.stdout.write(f"Processing restart escalation for alert groups with ppks: {alert_group_ppk}...")
|
||||
alert_groups = AlertGroup.objects.filter(
|
||||
public_primary_key__in=alert_group_ppk,
|
||||
raw_escalation_snapshot__isnull=False,
|
||||
)
|
||||
|
||||
if not alert_groups:
|
||||
self.stdout.write("No escalations to restart.")
|
||||
return
|
||||
|
||||
tasks = []
|
||||
alert_groups_to_update = []
|
||||
now = timezone.now()
|
||||
|
||||
for alert_group in alert_groups:
|
||||
# rebuild escalation snapshot with keeping information about current escalation step
|
||||
# this is used for migrated organizations to actualize data in escalation snapshot
|
||||
if rebuild_escalation_snapshot:
|
||||
self._write_stdout_log(
|
||||
restart_all,
|
||||
f"rebuild escalation snapshot for alert group (id: {alert_group.id}, ppk: "
|
||||
f"{alert_group.public_primary_key})",
|
||||
)
|
||||
original_escalation_snapshot = alert_group.raw_escalation_snapshot
|
||||
new_escalation_snapshot = alert_group.build_raw_escalation_snapshot()
|
||||
snapshot_fields_to_copy = ["last_active_escalation_policy_order", "next_step_eta", "pause_escalation"]
|
||||
for field in snapshot_fields_to_copy:
|
||||
new_escalation_snapshot[field] = original_escalation_snapshot[field]
|
||||
alert_group.raw_escalation_snapshot = new_escalation_snapshot
|
||||
|
||||
task_id = celery_uuid()
|
||||
# if incident was silenced, start unsilence_task
|
||||
if alert_group.is_silenced_for_period:
|
||||
if not restart_unsilence_task:
|
||||
self._write_stdout_log(
|
||||
restart_all,
|
||||
f"alert group (id: {alert_group.id}, ppk: {alert_group.public_primary_key}) is silenced, skip",
|
||||
)
|
||||
continue
|
||||
self._write_stdout_log(
|
||||
restart_all,
|
||||
f"alert group (id: {alert_group.id}, ppk: {alert_group.public_primary_key}) is silenced, "
|
||||
f"scheduling unsilence task",
|
||||
)
|
||||
alert_group.unsilence_task_uuid = task_id
|
||||
|
||||
escalation_start_time = max(now, alert_group.silenced_until)
|
||||
alert_groups_to_update.append(alert_group)
|
||||
|
||||
tasks.append(
|
||||
unsilence_task.signature(
|
||||
args=(alert_group.pk,),
|
||||
immutable=True,
|
||||
task_id=task_id,
|
||||
eta=escalation_start_time,
|
||||
)
|
||||
)
|
||||
# otherwise start escalate_alert_group task
|
||||
elif alert_group.escalation_snapshot:
|
||||
self._write_stdout_log(
|
||||
restart_all,
|
||||
f"Run escalation for alert group (id: {alert_group.id}, ppk: {alert_group.public_primary_key})",
|
||||
)
|
||||
alert_group.active_escalation_id = task_id
|
||||
alert_groups_to_update.append(alert_group)
|
||||
|
||||
tasks.append(
|
||||
escalate_alert_group.signature(
|
||||
args=(alert_group.pk,),
|
||||
immutable=True,
|
||||
task_id=task_id,
|
||||
eta=alert_group.next_step_eta,
|
||||
)
|
||||
)
|
||||
else:
|
||||
self._write_stdout_log(
|
||||
restart_all,
|
||||
f"alert group (id: {alert_group.id}, ppk: {alert_group.public_primary_key}) doesn't have escalation"
|
||||
f" snapshot, skip",
|
||||
)
|
||||
|
||||
AlertGroup.objects.bulk_update(
|
||||
alert_groups_to_update,
|
||||
["active_escalation_id", "unsilence_task_uuid", "raw_escalation_snapshot"],
|
||||
batch_size=5000,
|
||||
)
|
||||
|
||||
for task in tasks:
|
||||
task.apply_async()
|
||||
|
||||
restarted_alert_group_ids = ", ".join(
|
||||
f"(id: {str(alert_group.pk)}, ppk: {alert_group.public_primary_key})" for alert_group in alert_groups
|
||||
)
|
||||
self.stdout.write(f"Escalations restarted for alert groups: {restarted_alert_group_ids}")
|
||||
|
||||
def _write_stdout_log(self, restart_all, text):
|
||||
"""Write log if restart escalation not for all alert groups"""
|
||||
if not restart_all:
|
||||
self.stdout.write(text)
|
||||
|
|
@ -15,7 +15,7 @@ def test_detached_integrations_startupprobe_populates_integrations_cache():
|
|||
response = client.get("/startupprobe/")
|
||||
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
mock_update_cache.assert_called_once
|
||||
mock_update_cache.assert_called_once()
|
||||
|
||||
|
||||
def test_startupprobe_populates_integrations_cache():
|
||||
|
|
@ -27,4 +27,4 @@ def test_startupprobe_populates_integrations_cache():
|
|||
response = client.get("/startupprobe/")
|
||||
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
mock_update_cache.assert_called_once
|
||||
mock_update_cache.assert_called_once()
|
||||
|
|
|
|||
|
|
@ -120,6 +120,7 @@ DATABASE_DEFAULTS = {
|
|||
},
|
||||
}
|
||||
|
||||
DATABASE_TYPES = DatabaseTypes
|
||||
DATABASE_NAME = os.getenv("DATABASE_NAME") or os.getenv("MYSQL_DB_NAME")
|
||||
DATABASE_USER = os.getenv("DATABASE_USER") or os.getenv("MYSQL_USER")
|
||||
DATABASE_PASSWORD = os.getenv("DATABASE_PASSWORD") or os.getenv("MYSQL_PASSWORD")
|
||||
|
|
|
|||
|
|
@ -0,0 +1,19 @@
|
|||
import {expect, test} from "../fixtures";
|
||||
import {generateRandomValue} from "../utils/forms";
|
||||
import {createEscalationChain, EscalationStep, selectEscalationStepValue} from "../utils/escalationChain";
|
||||
|
||||
test('escalation policy does not go back to "Default" after adding users to notify', async ({ adminRolePage }) => {
|
||||
const { page, userName } = adminRolePage;
|
||||
const escalationChainName = generateRandomValue();
|
||||
|
||||
// create important escalation step
|
||||
await createEscalationChain(page, escalationChainName, EscalationStep.NotifyUsers, null, true);
|
||||
// add user to notify
|
||||
await selectEscalationStepValue(page, EscalationStep.NotifyUsers, userName);
|
||||
|
||||
// reload and check if important is still selected
|
||||
await page.reload();
|
||||
await page.waitForLoadState('networkidle');
|
||||
|
||||
expect(await page.locator('text=Important').isVisible()).toBe(true);
|
||||
});
|
||||
|
|
@ -17,7 +17,8 @@ export const createEscalationChain = async (
|
|||
page: Page,
|
||||
escalationChainName: string,
|
||||
escalationStep?: EscalationStep,
|
||||
escalationStepValue?: string
|
||||
escalationStepValue?: string,
|
||||
important?: boolean
|
||||
): Promise<void> => {
|
||||
// go to the escalation chains page
|
||||
await goToOnCallPage(page, 'escalations');
|
||||
|
|
@ -40,18 +41,32 @@ export const createEscalationChain = async (
|
|||
await clickButton({ page, buttonText: 'Create' });
|
||||
await expect(page.getByTestId('escalation-chain-name')).toHaveText(escalationChainName);
|
||||
|
||||
if (!escalationStep || !escalationStepValue) {
|
||||
return;
|
||||
if (escalationStep) {
|
||||
// add an escalation step
|
||||
await selectDropdownValue({
|
||||
page, selectType: 'grafanaSelect', placeholderText: 'Add escalation step...', value: escalationStep,
|
||||
});
|
||||
|
||||
// toggle important
|
||||
if (important) {
|
||||
await selectDropdownValue({
|
||||
page,
|
||||
selectType: 'grafanaSelect',
|
||||
placeholderText: "Default",
|
||||
value: "Important",
|
||||
});
|
||||
}
|
||||
|
||||
// select the escalation step value (e.g. user or schedule)
|
||||
if (escalationStepValue) {await selectEscalationStepValue(page, escalationStep, escalationStepValue);}
|
||||
}
|
||||
};
|
||||
|
||||
// add an escalation step
|
||||
await selectDropdownValue({
|
||||
page,
|
||||
selectType: 'grafanaSelect',
|
||||
placeholderText: 'Add escalation step...',
|
||||
value: escalationStep,
|
||||
});
|
||||
|
||||
export const selectEscalationStepValue = async (
|
||||
page: Page,
|
||||
escalationStep: EscalationStep,
|
||||
escalationStepValue: string
|
||||
): Promise<void> => {
|
||||
await selectDropdownValue({
|
||||
page,
|
||||
selectType: 'grafanaSelect',
|
||||
|
|
|
|||
|
|
@ -45,15 +45,15 @@ const MobileAppConnection = observer(({ userPk }: Props) => {
|
|||
return (
|
||||
<WithPermissionControlDisplay userAction={UserActions.UserSettingsWrite}>
|
||||
<VerticalGroup spacing="lg">
|
||||
<Text type="secondary">Please connect Cloud OnCall to use the mobile app</Text>
|
||||
<Text type="secondary">Please connect Grafana Cloud OnCall to use the mobile app</Text>
|
||||
<WithPermissionControlDisplay
|
||||
userAction={UserActions.OtherSettingsWrite}
|
||||
message="You do not have permission to perform this action. Ask an admin to connect Cloud OnCall or upgrade your
|
||||
message="You do not have permission to perform this action. Ask an admin to connect Grafana Cloud OnCall or upgrade your
|
||||
permissions."
|
||||
>
|
||||
<PluginLink query={{ page: 'cloud' }}>
|
||||
<Button variant="secondary" icon="external-link-alt">
|
||||
Connect Cloud OnCall
|
||||
Connect Grafana Cloud OnCall
|
||||
</Button>
|
||||
</PluginLink>
|
||||
</WithPermissionControlDisplay>
|
||||
|
|
|
|||
|
|
@ -2901,7 +2901,7 @@ exports[`MobileAppConnection it shows a warning when cloud is not connected 1`]
|
|||
<span
|
||||
class="root text text--secondary text--medium"
|
||||
>
|
||||
Please connect Cloud OnCall to use the mobile app
|
||||
Please connect Grafana Cloud OnCall to use the mobile app
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
|
|
@ -2933,7 +2933,7 @@ exports[`MobileAppConnection it shows a warning when cloud is not connected 1`]
|
|||
<span
|
||||
class="css-1mhnkuh"
|
||||
>
|
||||
Connect Cloud OnCall
|
||||
Connect Grafana Cloud OnCall
|
||||
</span>
|
||||
</button>
|
||||
</a>
|
||||
|
|
|
|||
|
|
@ -35,11 +35,11 @@ const PhoneConnector = (props: PhoneConnectorProps) => {
|
|||
<InlineField
|
||||
label="Phone"
|
||||
labelWidth={12}
|
||||
tooltip={'OnCall uses Grafana Cloud for SMS and phone call notifications'}
|
||||
tooltip={'OnCall uses Grafana Cloud OnCall for SMS and phone call notifications'}
|
||||
>
|
||||
<Button onClick={handleClickConfirmPhoneButton}>Connect to Cloud</Button>
|
||||
<Button onClick={handleClickConfirmPhoneButton}>Connect to Grafana Cloud OnCall</Button>
|
||||
</InlineField>
|
||||
<Alert title="This instance is not connected to Cloud OnCall" severity="warning" />
|
||||
<Alert title="This instance is not connected to Grafana Cloud OnCall" severity="warning" />
|
||||
</>
|
||||
);
|
||||
|
||||
|
|
@ -49,7 +49,7 @@ const PhoneConnector = (props: PhoneConnectorProps) => {
|
|||
<InlineField
|
||||
label="Phone"
|
||||
labelWidth={12}
|
||||
tooltip={'OnCall uses Grafana Cloud for SMS and phone call notifications'}
|
||||
tooltip={'OnCall uses Grafana Cloud OnCall for SMS and phone call notifications'}
|
||||
>
|
||||
<Button onClick={handleClickConfirmPhoneButton}>Reload from Cloud</Button>
|
||||
</InlineField>
|
||||
|
|
@ -63,11 +63,11 @@ const PhoneConnector = (props: PhoneConnectorProps) => {
|
|||
<InlineField
|
||||
label="Phone"
|
||||
labelWidth={12}
|
||||
tooltip={'OnCall uses Grafana Cloud for SMS and phone call notifications'}
|
||||
tooltip={'OnCall uses Grafana Cloud OnCall for SMS and phone call notifications'}
|
||||
>
|
||||
<Button onClick={handleClickConfirmPhoneButton}>Verify in Cloud</Button>
|
||||
</InlineField>
|
||||
<Alert title="Phone number is not verified in Grafana Cloud" severity="warning" />
|
||||
<Alert title="Phone number is not verified in Grafana Cloud OnCall" severity="warning" />
|
||||
</>
|
||||
);
|
||||
case 3:
|
||||
|
|
@ -76,7 +76,7 @@ const PhoneConnector = (props: PhoneConnectorProps) => {
|
|||
<InlineField
|
||||
label="Phone"
|
||||
labelWidth={12}
|
||||
tooltip={'OnCall uses Grafana Cloud for SMS and phone call notifications'}
|
||||
tooltip={'OnCall uses Grafana Cloud OnCall for SMS and phone call notifications'}
|
||||
>
|
||||
<Button onClick={handleClickConfirmPhoneButton}>Change in Cloud</Button>
|
||||
</InlineField>
|
||||
|
|
@ -90,7 +90,7 @@ const PhoneConnector = (props: PhoneConnectorProps) => {
|
|||
label="Phone"
|
||||
disabled={true}
|
||||
labelWidth={12}
|
||||
tooltip={'OnCall uses Grafana Cloud for SMS and phone call notifications'}
|
||||
tooltip={'OnCall uses Grafana Cloud OnCall for SMS and phone call notifications'}
|
||||
>
|
||||
<Button onClick={handleClickConfirmPhoneButton}>Reload from Cloud</Button>
|
||||
</InlineField>
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import React, { useEffect, useState } from 'react';
|
||||
|
||||
import { Button, VerticalGroup, LoadingPlaceholder } from '@grafana/ui';
|
||||
import { Button, LoadingPlaceholder, VerticalGroup } from '@grafana/ui';
|
||||
import { observer } from 'mobx-react';
|
||||
|
||||
import PluginLink from 'components/PluginLink/PluginLink';
|
||||
|
|
@ -53,10 +53,10 @@ const CloudPhoneSettings = observer((props: CloudPhoneSettingsProps) => {
|
|||
case 0:
|
||||
return (
|
||||
<VerticalGroup spacing="lg">
|
||||
<Text>Cloud notifications enabled, but Grafana Cloud instance is not connected.</Text>
|
||||
<Text>Cloud notifications enabled, but Grafana Cloud OnCall instance is not connected.</Text>
|
||||
<PluginLink query={{ page: 'cloud' }}>
|
||||
<Button variant="secondary" icon="external-link-alt">
|
||||
Open Grafana Cloud page
|
||||
Open Grafana Cloud OnCall page
|
||||
</Button>
|
||||
</PluginLink>
|
||||
</VerticalGroup>
|
||||
|
|
@ -65,11 +65,11 @@ const CloudPhoneSettings = observer((props: CloudPhoneSettingsProps) => {
|
|||
return (
|
||||
<VerticalGroup spacing="lg">
|
||||
<Text>
|
||||
We can’t find a matching account in the connected Grafana Cloud instance (matching by e-mail
|
||||
We can’t find a matching account in the connected Grafana Cloud OnCall instance (matching by e-mail
|
||||
{email && ': ' + email}).
|
||||
</Text>
|
||||
<Button variant="primary" onClick={() => handleLinkClick(userLink)}>
|
||||
Sign up in Grafana Cloud
|
||||
Sign up in Grafana Cloud OnCall
|
||||
</Button>
|
||||
</VerticalGroup>
|
||||
);
|
||||
|
|
@ -77,10 +77,10 @@ const CloudPhoneSettings = observer((props: CloudPhoneSettingsProps) => {
|
|||
return (
|
||||
<VerticalGroup spacing="lg">
|
||||
<Text>
|
||||
Your account successfully matched with the Grafana Cloud account. Please verify your phone number.{' '}
|
||||
Your account successfully matched with the Grafana Cloud OnCall account. Please verify your phone number.{' '}
|
||||
</Text>
|
||||
<Button variant="secondary" icon="external-link-alt" onClick={() => handleLinkClick(userLink)}>
|
||||
Verify phone number in Grafana Cloud
|
||||
Verify phone number in Grafana Cloud OnCall
|
||||
</Button>
|
||||
</VerticalGroup>
|
||||
);
|
||||
|
|
@ -88,10 +88,10 @@ const CloudPhoneSettings = observer((props: CloudPhoneSettingsProps) => {
|
|||
return (
|
||||
<VerticalGroup spacing="lg">
|
||||
<Text>
|
||||
Your account successfully matched with the Grafana Cloud account. Your phone number is verified.{' '}
|
||||
Your account successfully matched with the Grafana Cloud OnCall account. Your phone number is verified.{' '}
|
||||
</Text>
|
||||
<Button variant="secondary" icon="external-link-alt" onClick={() => handleLinkClick(userLink)}>
|
||||
Open account in Grafana Cloud
|
||||
Open account in Grafana Cloud OnCall
|
||||
</Button>
|
||||
</VerticalGroup>
|
||||
);
|
||||
|
|
@ -99,11 +99,11 @@ const CloudPhoneSettings = observer((props: CloudPhoneSettingsProps) => {
|
|||
return (
|
||||
<VerticalGroup spacing="lg">
|
||||
<Text>
|
||||
We can’t find a matching account in the connected Grafana Cloud instance (matching by e-mail
|
||||
We can’t find a matching account in the connected Grafana Cloud OnCall instance (matching by e-mail
|
||||
{email && ': ' + email}).
|
||||
</Text>
|
||||
<Button variant="primary" onClick={() => handleLinkClick(userLink)}>
|
||||
Sign up in Grafana Cloud
|
||||
Sign up in Grafana Cloud OnCall
|
||||
</Button>
|
||||
</VerticalGroup>
|
||||
);
|
||||
|
|
@ -113,10 +113,10 @@ const CloudPhoneSettings = observer((props: CloudPhoneSettingsProps) => {
|
|||
return (
|
||||
<WithPermissionControlDisplay
|
||||
userAction={UserActions.UserSettingsWrite}
|
||||
title="OnCall uses Grafana Cloud for SMS and phone call notifications"
|
||||
title="OnCall uses Grafana Cloud OnCall for SMS and phone call notifications"
|
||||
>
|
||||
<VerticalGroup spacing="lg">
|
||||
<Text.Title level={3}>OnCall uses Grafana Cloud for SMS and phone call notifications</Text.Title>
|
||||
<Text.Title level={3}>OnCall uses Grafana Cloud OnCall for SMS and phone call notifications</Text.Title>
|
||||
{syncing ? (
|
||||
<Button icon="sync" variant="secondary" disabled>
|
||||
Updating...
|
||||
|
|
|
|||
|
|
@ -3,5 +3,5 @@ import { pick } from 'lodash-es';
|
|||
import { EscalationPolicy } from './escalation_policy.types';
|
||||
|
||||
export function prepareEscalationPolicy(value: EscalationPolicy) {
|
||||
return pick(value, ['step']);
|
||||
return pick(value, ['step', 'important']);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -57,6 +57,7 @@ import { openNotification } from 'utils';
|
|||
import { UserActions } from 'utils/authorization';
|
||||
import { PLUGIN_ROOT } from 'utils/consts';
|
||||
import sanitize from 'utils/sanitize';
|
||||
import { parseURL } from 'utils/url';
|
||||
|
||||
import { getActionButtons } from './Incident.helpers';
|
||||
import styles from './Incident.module.scss';
|
||||
|
|
@ -269,6 +270,8 @@ class IncidentPage extends React.Component<IncidentPageProps, IncidentPageState>
|
|||
|
||||
const integrationNameWithEmojies = <Emoji text={incident.alert_receive_channel.verbal_name} />;
|
||||
|
||||
const sourceLink = incident?.render_for_web?.source_link;
|
||||
|
||||
return (
|
||||
<Block className={cx('block')}>
|
||||
<VerticalGroup>
|
||||
|
|
@ -370,17 +373,19 @@ class IncidentPage extends React.Component<IncidentPageProps, IncidentPageState>
|
|||
<Tooltip
|
||||
placement="top"
|
||||
content={
|
||||
incident.render_for_web.source_link === null
|
||||
sourceLink === null
|
||||
? `The integration template Source Link is empty`
|
||||
: parseURL(sourceLink) === ''
|
||||
? 'The Integration template Source Link is invalid'
|
||||
: 'Go to source'
|
||||
}
|
||||
>
|
||||
<a href={incident.render_for_web.source_link} target="_blank" rel="noreferrer">
|
||||
<a href={parseURL(sourceLink) || undefined} target="_blank" rel="noreferrer">
|
||||
<Button
|
||||
variant="secondary"
|
||||
fill="outline"
|
||||
size="sm"
|
||||
disabled={incident.render_for_web.source_link === null}
|
||||
disabled={sourceLink === null || parseURL(sourceLink) === ''}
|
||||
className={cx('label-button')}
|
||||
icon="external-link-alt"
|
||||
>
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import React, { useCallback, useEffect, useState } from 'react';
|
||||
|
||||
import { Field, Input, Button, HorizontalGroup, Icon, VerticalGroup, LoadingPlaceholder } from '@grafana/ui';
|
||||
import { Button, Field, HorizontalGroup, Icon, Input, LoadingPlaceholder, VerticalGroup } from '@grafana/ui';
|
||||
import cn from 'classnames/bind';
|
||||
import { observer } from 'mobx-react';
|
||||
import { RouteComponentProps, withRouter } from 'react-router-dom';
|
||||
|
|
@ -15,7 +15,7 @@ import { WithStoreProps } from 'state/types';
|
|||
import { useStore } from 'state/useStore';
|
||||
import { withMobXProviderContext } from 'state/withStore';
|
||||
import { openErrorNotification } from 'utils';
|
||||
import { determineRequiredAuthString, UserActions } from 'utils/authorization';
|
||||
import { UserActions, determineRequiredAuthString } from 'utils/authorization';
|
||||
import { PLUGIN_ROOT } from 'utils/consts';
|
||||
|
||||
import styles from './CloudPage.module.css';
|
||||
|
|
@ -135,16 +135,16 @@ const CloudPage = observer((props: CloudPageProps) => {
|
|||
const renderStatus = (user: Cloud) => {
|
||||
switch (user?.cloud_data?.status) {
|
||||
case 0:
|
||||
return <Text className={cx('error-message')}>Grafana Cloud is not synced</Text>;
|
||||
return <Text className={cx('error-message')}>Grafana Cloud OnCall is not synced</Text>;
|
||||
case 1:
|
||||
return <Text className={cx('error-message')}>User not found in Grafana Cloud</Text>;
|
||||
return <Text className={cx('error-message')}>User not found in Grafana Cloud OnCall</Text>;
|
||||
case 2:
|
||||
return <Text type="warning">Phone number is not verified in Grafana Cloud</Text>;
|
||||
return <Text type="warning">Phone number is not verified in Grafana Cloud OnCall</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>;
|
||||
return <Text className={cx('error-message')}>User not found in Grafana Cloud OnCall</Text>;
|
||||
}
|
||||
};
|
||||
|
||||
|
|
@ -209,9 +209,9 @@ const CloudPage = observer((props: CloudPageProps) => {
|
|||
<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
|
||||
<Icon name="check" className={cx('block-icon')} size="lg" /> Grafana Cloud OnCall API key
|
||||
</Text.Title>
|
||||
<Text type="secondary">Cloud OnCall is sucessfully connected.</Text>
|
||||
<Text type="secondary">Grafana 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')}>
|
||||
|
|
@ -261,7 +261,7 @@ const CloudPage = observer((props: CloudPageProps) => {
|
|||
|
||||
<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 notifications in personal settings! Users must have ${determineRequiredAuthString(
|
||||
{`Ask your users to sign up in Grafana Cloud OnCall, verify phone number and feel free to set up SMS & phone call notifications in personal settings! Users must have ${determineRequiredAuthString(
|
||||
UserActions.NotificationsRead
|
||||
)} in order to be synced.`}
|
||||
</Text>
|
||||
|
|
@ -277,7 +277,7 @@ const CloudPage = observer((props: CloudPageProps) => {
|
|||
<Text type="secondary">
|
||||
{matched_users_count ? matched_users_count : 0} user
|
||||
{matched_users_count === 1 ? '' : 's'}
|
||||
{` matched between OSS and Cloud OnCall`}
|
||||
{` matched between OSS and Grafana Cloud OnCall`}
|
||||
</Text>
|
||||
<Button variant="primary" onClick={syncUsers} icon="sync" disabled={syncingUsers}>
|
||||
{syncingUsers ? 'Syncing...' : 'Sync users'}
|
||||
|
|
@ -314,8 +314,8 @@ const CloudPage = observer((props: CloudPageProps) => {
|
|||
<Icon name="mobile-android" className={cx('block-icon')} size="lg" /> Mobile app push notifications
|
||||
</Text.Title>
|
||||
<Text type="secondary">
|
||||
Connecting to Cloud OnCall enables sending push notifications on mobile devices using the Grafana OnCall
|
||||
mobile app.
|
||||
Connecting to Grafana Cloud OnCall enables sending push notifications on mobile devices using the Grafana
|
||||
OnCall mobile app.
|
||||
</Text>
|
||||
</VerticalGroup>
|
||||
</Block>
|
||||
|
|
@ -327,11 +327,11 @@ const CloudPage = observer((props: CloudPageProps) => {
|
|||
<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
|
||||
<Icon name="sync" className={cx('block-icon')} size="lg" /> Grafana Cloud OnCall API key
|
||||
</Text.Title>
|
||||
<Field
|
||||
label=""
|
||||
description="Find it on the Settings page of OnCall, within your Cloud Grafana instance"
|
||||
description="Find it on the Settings page of OnCall, within your Grafana Cloud OnCall instance"
|
||||
style={{ width: '100%' }}
|
||||
invalid={apiKeyError}
|
||||
>
|
||||
|
|
@ -351,8 +351,9 @@ const CloudPage = observer((props: CloudPageProps) => {
|
|||
Monitor instance with heartbeat
|
||||
</Text.Title>
|
||||
<Text type="secondary">
|
||||
Once connected, this OnCall instance will send heartbeats every 3 minutes to the Cloud Grafana instance. If
|
||||
no heartbeats are received within 10 minutes, the Cloud Grafana instance will issue an alert.
|
||||
Once connected, this OnCall instance will send heartbeats every 3 minutes to the Grafana Cloud OnCall
|
||||
instance. If no heartbeats are received within 10 minutes, the Grafana Cloud OnCall instance will issue an
|
||||
alert.
|
||||
</Text>
|
||||
</VerticalGroup>
|
||||
</Block>
|
||||
|
|
@ -363,7 +364,8 @@ const CloudPage = observer((props: CloudPageProps) => {
|
|||
</Text.Title>
|
||||
|
||||
<Text type="secondary">
|
||||
Connecting to Cloud OnCall enables sending SMS and phone call notifications using Cloud Grafana OnCall.
|
||||
Connecting to Grafana Cloud OnCall enables sending SMS and phone call notifications using Grafana Cloud
|
||||
OnCall.
|
||||
</Text>
|
||||
</VerticalGroup>
|
||||
</Block>
|
||||
|
|
@ -373,7 +375,7 @@ const CloudPage = observer((props: CloudPageProps) => {
|
|||
<Icon name="mobile-android" className={cx('block-icon')} size="lg" /> Mobile app push notifications
|
||||
</Text.Title>
|
||||
<Text type="secondary">
|
||||
Connecting to Cloud Grafana OnCall enables sending push notifications on mobile devices using the Grafana
|
||||
Connecting to Grafana Cloud OnCall enables sending push notifications on mobile devices using the Grafana
|
||||
OnCall mobile app.
|
||||
</Text>
|
||||
</VerticalGroup>
|
||||
|
|
@ -385,7 +387,7 @@ const CloudPage = observer((props: CloudPageProps) => {
|
|||
<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>
|
||||
Connect Open Source OnCall and <Text className={cx('cloud-oncall-name')}>Grafana Cloud OnCall</Text>
|
||||
</Text.Title>
|
||||
{cloudIsConnected === undefined ? (
|
||||
<LoadingPlaceholder text="Loading..." />
|
||||
|
|
|
|||
|
|
@ -24,7 +24,7 @@ import { AppFeature } from 'state/features';
|
|||
import { PageProps, WithStoreProps } from 'state/types';
|
||||
import { withMobXProviderContext } from 'state/withStore';
|
||||
import LocationHelper from 'utils/LocationHelper';
|
||||
import { generateMissingPermissionMessage, isUserActionAllowed, UserActions } from 'utils/authorization';
|
||||
import { UserActions, generateMissingPermissionMessage, isUserActionAllowed } from 'utils/authorization';
|
||||
import { PAGE, PLUGIN_ROOT } from 'utils/consts';
|
||||
|
||||
import { getUserRowClassNameFn } from './Users.helpers';
|
||||
|
|
@ -343,10 +343,10 @@ class Users extends React.Component<UsersProps, UsersState> {
|
|||
warnings.push('User not matched with cloud');
|
||||
break;
|
||||
case 2:
|
||||
warnings.push('Phone number is not verified in Grafana Cloud');
|
||||
warnings.push('Phone number is not verified in Grafana Cloud OnCall');
|
||||
break;
|
||||
case 3:
|
||||
phone_verified = true; // Phone is verified in Grafana Cloud, no need to show warning to the user
|
||||
phone_verified = true; // Phone is verified in Grafana Cloud OnCall, no need to show warning to the user
|
||||
break;
|
||||
}
|
||||
} else if (!phone_verified) {
|
||||
|
|
|
|||
|
|
@ -28,3 +28,15 @@ export function getPathFromQueryParams(query: ParsedQuery<string>) {
|
|||
|
||||
return path;
|
||||
}
|
||||
|
||||
export function parseURL(url: string) {
|
||||
let parsedUrl: URL;
|
||||
|
||||
try {
|
||||
parsedUrl = new URL(url);
|
||||
} catch (ex) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return parsedUrl.protocol === 'http:' || parsedUrl.protocol === 'https:' ? url : '';
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue