Merge pull request #3294 from grafana/dev

Dev to main
This commit is contained in:
Michael Derynck 2023-11-07 11:32:23 -07:00 committed by GitHub
commit 80b7e52bf1
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
25 changed files with 405 additions and 94 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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 cant find a matching account in the connected Grafana Cloud instance (matching by e-mail
We cant 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 cant find a matching account in the connected Grafana Cloud instance (matching by e-mail
We cant 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...

View file

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

View file

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

View file

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

View file

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

View file

@ -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 : '';
}