Merge branch 'dev'

This commit is contained in:
Ildar Iskhakov 2023-03-23 10:47:29 +08:00
commit bbb9384ea1
22 changed files with 558 additions and 129 deletions

View file

@ -1,22 +1,32 @@
from django.apps import apps
from django.core.exceptions import ObjectDoesNotExist, PermissionDenied
from django_filters import rest_framework as filters
from rest_framework.decorators import action
from rest_framework.exceptions import NotFound
from rest_framework.filters import SearchFilter
from rest_framework.permissions import IsAuthenticated
from rest_framework.response import Response
from rest_framework.viewsets import ModelViewSet
from apps.api.permissions import RBACPermission
from apps.api.serializers.webhook import WebhookSerializer
from apps.auth_token.auth import PluginAuthentication
from apps.webhooks.models import Webhook
from common.api_helpers.filters import ByTeamModelFieldFilterMixin, ModelFieldFilterMixin, TeamModelMultipleChoiceFilter
from common.api_helpers.mixins import PublicPrimaryKeyMixin, TeamFilteringMixin
class WebhooksFilter(ByTeamModelFieldFilterMixin, ModelFieldFilterMixin, filters.FilterSet):
team = TeamModelMultipleChoiceFilter()
class WebhooksView(TeamFilteringMixin, PublicPrimaryKeyMixin, ModelViewSet):
authentication_classes = (PluginAuthentication,)
permission_classes = (IsAuthenticated, RBACPermission)
rbac_permissions = {
"metadata": [RBACPermission.Permissions.OUTGOING_WEBHOOKS_READ],
"filters": [RBACPermission.Permissions.OUTGOING_WEBHOOKS_READ],
"list": [RBACPermission.Permissions.OUTGOING_WEBHOOKS_READ],
"retrieve": [RBACPermission.Permissions.OUTGOING_WEBHOOKS_READ],
"create": [RBACPermission.Permissions.OUTGOING_WEBHOOKS_WRITE],
@ -28,6 +38,10 @@ class WebhooksView(TeamFilteringMixin, PublicPrimaryKeyMixin, ModelViewSet):
model = Webhook
serializer_class = WebhookSerializer
filter_backends = [SearchFilter, filters.DjangoFilterBackend]
search_fields = ["public_primary_key", "name"]
filterset_class = WebhooksFilter
def get_queryset(self, ignore_filtering_by_available_teams=False):
queryset = Webhook.objects.filter(
organization=self.request.auth.organization,
@ -48,9 +62,8 @@ class WebhooksView(TeamFilteringMixin, PublicPrimaryKeyMixin, ModelViewSet):
# use this method to get the object from the whole organization instead of the current team
pk = self.kwargs["pk"]
organization = self.request.auth.organization
try:
obj = organization.webhooks.get(public_primary_key=pk)
obj = organization.webhooks.filter(*self.available_teams_lookup_args).get(public_primary_key=pk)
except ObjectDoesNotExist:
raise NotFound
@ -83,3 +96,22 @@ class WebhooksView(TeamFilteringMixin, PublicPrimaryKeyMixin, ModelViewSet):
)[0]
if self.request.auth.organization.pk not in enabled_webhooks_2_orgs.json_value["org_ids"]:
raise PermissionDenied("Webhooks 2 not enabled for organization. Permission denied.")
@action(methods=["get"], detail=False)
def filters(self, request):
filter_name = request.query_params.get("search", None)
api_root = "/api/internal/v1/"
filter_options = [
{
"name": "team",
"type": "team_select",
"href": api_root + "teams/",
"global": True,
},
]
if filter_name is not None:
filter_options = list(filter(lambda f: filter_name in f["name"], filter_options))
return Response(filter_options)

View file

@ -0,0 +1,31 @@
# Generated by Django 3.2.18 on 2023-03-21 15:53
import django.core.validators
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('user_management', '0009_organization_cluster_slug'),
('mobile_app', '0002_alter_mobileappauthtoken_user'),
]
operations = [
migrations.CreateModel(
name='MobileAppUserSettings',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('default_notification_sound_name', models.CharField(default='default_sound', max_length=100)),
('default_notification_volume_type', models.CharField(choices=[('constant', 'Constant'), ('intensifying', 'Intensifying')], default='constant', max_length=50)),
('default_notification_volume', models.FloatField(default=0.8, validators=[django.core.validators.MinValueValidator(0.0), django.core.validators.MaxValueValidator(1.0)])),
('default_notification_volume_override', models.BooleanField(default=False)),
('important_notification_sound_name', models.CharField(default='default_sound_important', max_length=100)),
('important_notification_volume_type', models.CharField(choices=[('constant', 'Constant'), ('intensifying', 'Intensifying')], default='constant', max_length=50)),
('important_notification_volume', models.FloatField(default=0.8, validators=[django.core.validators.MinValueValidator(0.0), django.core.validators.MaxValueValidator(1.0)])),
('important_notification_override_dnd', models.BooleanField(default=True)),
('user', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, to='user_management.user')),
],
),
]

View file

@ -1,6 +1,7 @@
from typing import Tuple
from django.conf import settings
from django.core import validators
from django.db import models
from django.utils import timezone
@ -68,3 +69,41 @@ class MobileAppAuthToken(BaseAuthToken):
organization=organization,
)
return instance, token_string
class MobileAppUserSettings(models.Model):
# Sound names are stored without extension, extension is added when sending push notifications
IOS_SOUND_NAME_EXTENSION = ".aiff"
ANDROID_SOUND_NAME_EXTENSION = ".mp3"
class VolumeType(models.TextChoices):
CONSTANT = "constant"
INTENSIFYING = "intensifying"
user = models.OneToOneField(to=User, null=False, on_delete=models.CASCADE)
# Push notification settings for default notifications
default_notification_sound_name = models.CharField(max_length=100, default="default_sound")
default_notification_volume_type = models.CharField(
max_length=50, choices=VolumeType.choices, default=VolumeType.CONSTANT
)
# APNS only allows to specify volume for critical notifications,
# so "default_notification_volume" and "default_notification_volume_override" are only used on Android
default_notification_volume = models.FloatField(
validators=[validators.MinValueValidator(0.0), validators.MaxValueValidator(1.0)], default=0.8
)
default_notification_volume_override = models.BooleanField(default=False)
# Push notification settings for important notifications
important_notification_sound_name = models.CharField(max_length=100, default="default_sound_important")
important_notification_volume_type = models.CharField(
max_length=50, choices=VolumeType.choices, default=VolumeType.CONSTANT
)
important_notification_volume = models.FloatField(
validators=[validators.MinValueValidator(0.0), validators.MaxValueValidator(1.0)], default=0.8
)
# For the "Mobile push important" step it's possible to make notifications non-critical
# if "override DND" setting is disabled in the app
important_notification_override_dnd = models.BooleanField(default=True)

View file

@ -0,0 +1,18 @@
from rest_framework import serializers
from apps.mobile_app.models import MobileAppUserSettings
class MobileAppUserSettingsSerializer(serializers.ModelSerializer):
class Meta:
model = MobileAppUserSettings
fields = (
"default_notification_sound_name",
"default_notification_volume_type",
"default_notification_volume",
"default_notification_volume_override",
"important_notification_sound_name",
"important_notification_volume_type",
"important_notification_volume",
"important_notification_override_dnd",
)

View file

@ -67,63 +67,8 @@ def notify_user_async(user_pk, alert_group_pk, notification_policy_pk, critical)
logger.error(f"Error while sending a mobile push notification: user {user_pk} has no device set up")
return
thread_id = f"{alert_group.channel.organization.public_primary_key}:{alert_group.public_primary_key}"
number_of_alerts = alert_group.alerts.count()
alert_title = "New Critical Alert" if critical else "New Alert"
alert_subtitle = get_push_notification_message(alert_group)
status_verbose = "Firing" # TODO: we should probably de-duplicate this text
if alert_group.resolved:
status_verbose = alert_group.get_resolve_text()
elif alert_group.acknowledged:
status_verbose = alert_group.get_acknowledge_text()
if number_of_alerts <= 10:
alerts_count_str = str(number_of_alerts)
else:
alert_count_rounded = (number_of_alerts // 10) * 10
alerts_count_str = f"{alert_count_rounded}+"
alert_body = f"Status: {status_verbose}, alerts: {alerts_count_str}"
message = Message(
token=device_to_notify.registration_id,
data={
# from the docs..
# A dictionary of data fields (optional). All keys and values in the dictionary must be strings
#
# alert_group.status is an int so it must be casted...
"orgId": alert_group.channel.organization.public_primary_key,
"orgName": alert_group.channel.organization.stack_slug,
"alertGroupId": alert_group.public_primary_key,
"status": str(alert_group.status),
"type": "oncall.critical_message" if critical else "oncall.message",
"title": alert_title,
"subtitle": alert_subtitle,
"body": alert_body,
"thread_id": thread_id,
},
apns=APNSConfig(
payload=APNSPayload(
aps=Aps(
thread_id=thread_id,
badge=number_of_alerts,
alert=ApsAlert(title=alert_title, subtitle=alert_subtitle, body=alert_body),
sound=CriticalSound(
critical=1 if critical else 0,
name="ambulance.aiff" if critical else "bingbong.aiff",
volume=1,
),
custom_data={
"interruption-level": "critical" if critical else "time-sensitive",
},
),
),
),
)
logger.debug(f"Sending push notification with message: {message}; thread-id: {thread_id};")
message = _get_fcm_message(alert_group, user, device_to_notify.registration_id, critical)
logger.debug(f"Sending push notification with message: {message};")
if settings.IS_OPEN_SOURCE:
# FCM relay uses cloud connection to send push notifications
@ -168,3 +113,94 @@ def send_push_notification_to_fcm_relay(message):
response.raise_for_status()
return response
def _get_fcm_message(alert_group, user, registration_id, critical):
# avoid circular import
from apps.mobile_app.models import MobileAppUserSettings
thread_id = f"{alert_group.channel.organization.public_primary_key}:{alert_group.public_primary_key}"
number_of_alerts = alert_group.alerts.count()
alert_title = "New Critical Alert" if critical else "New Alert"
alert_subtitle = get_push_notification_message(alert_group)
status_verbose = "Firing" # TODO: we should probably de-duplicate this text
if alert_group.resolved:
status_verbose = alert_group.get_resolve_text()
elif alert_group.acknowledged:
status_verbose = alert_group.get_acknowledge_text()
if number_of_alerts <= 10:
alerts_count_str = str(number_of_alerts)
else:
alert_count_rounded = (number_of_alerts // 10) * 10
alerts_count_str = f"{alert_count_rounded}+"
alert_body = f"Status: {status_verbose}, alerts: {alerts_count_str}"
mobile_app_user_settings, _ = MobileAppUserSettings.objects.get_or_create(user=user)
# APNS only allows to specify volume for critical notifications
apns_volume = mobile_app_user_settings.important_notification_volume if critical else None
apns_sound_name = (
mobile_app_user_settings.important_notification_sound_name
if critical
else mobile_app_user_settings.default_notification_sound_name
) + MobileAppUserSettings.IOS_SOUND_NAME_EXTENSION # iOS app expects the filename to have an extension
return Message(
token=registration_id,
data={
# from the docs..
# A dictionary of data fields (optional). All keys and values in the dictionary must be strings
#
# alert_group.status is an int so it must be casted...
"orgId": alert_group.channel.organization.public_primary_key,
"orgName": alert_group.channel.organization.stack_slug,
"alertGroupId": alert_group.public_primary_key,
"status": str(alert_group.status),
"type": "oncall.critical_message" if critical else "oncall.message",
"title": alert_title,
"subtitle": alert_subtitle,
"body": alert_body,
"thread_id": thread_id,
# Pass user settings, so the Android app can use them to play the correct sound and volume
"default_notification_sound_name": (
mobile_app_user_settings.default_notification_sound_name
+ MobileAppUserSettings.ANDROID_SOUND_NAME_EXTENSION
),
"default_notification_volume_type": mobile_app_user_settings.default_notification_volume_type,
"default_notification_volume": str(mobile_app_user_settings.default_notification_volume),
"default_notification_volume_override": json.dumps(
mobile_app_user_settings.default_notification_volume_override
),
"important_notification_sound_name": (
mobile_app_user_settings.important_notification_sound_name
+ MobileAppUserSettings.ANDROID_SOUND_NAME_EXTENSION
),
"important_notification_volume_type": mobile_app_user_settings.important_notification_volume_type,
"important_notification_volume": str(mobile_app_user_settings.important_notification_volume),
"important_notification_override_dnd": json.dumps(
mobile_app_user_settings.important_notification_override_dnd
),
},
apns=APNSConfig(
payload=APNSPayload(
aps=Aps(
thread_id=thread_id,
badge=number_of_alerts,
alert=ApsAlert(title=alert_title, subtitle=alert_subtitle, body=alert_body),
sound=CriticalSound(
# The notification shouldn't be critical if the user has disabled "override DND" setting
critical=(critical and mobile_app_user_settings.important_notification_override_dnd),
name=apns_sound_name,
volume=apns_volume,
),
custom_data={
"interruption-level": "critical" if critical else "time-sensitive",
},
),
),
),
)

View file

@ -5,7 +5,8 @@ from fcm_django.models import FCMDevice
from firebase_admin.exceptions import FirebaseError
from apps.base.models import UserNotificationPolicy, UserNotificationPolicyLogRecord
from apps.mobile_app.tasks import notify_user_async
from apps.mobile_app.models import MobileAppUserSettings
from apps.mobile_app.tasks import _get_fcm_message, notify_user_async
from apps.oss_installation.models import CloudConnector
MOBILE_APP_BACKEND_ID = 5
@ -209,3 +210,86 @@ def test_notify_user_retry(
notification_policy_pk=notification_policy.pk,
critical=False,
)
@pytest.mark.django_db
def test_fcm_message_user_settings(
make_organization_and_user, make_alert_receive_channel, make_alert_group, make_alert
):
organization, user = make_organization_and_user()
device = FCMDevice.objects.create(user=user, registration_id="test_device_id")
alert_receive_channel = make_alert_receive_channel(organization=organization)
alert_group = make_alert_group(alert_receive_channel)
make_alert(alert_group=alert_group, raw_request_data={})
message = _get_fcm_message(alert_group, user, device.registration_id, critical=False)
# Check user settings are passed to FCM message
assert message.data["default_notification_sound_name"] == "default_sound.mp3"
assert message.data["default_notification_volume_type"] == "constant"
assert message.data["default_notification_volume_override"] == "false"
assert message.data["default_notification_volume"] == "0.8"
assert message.data["important_notification_sound_name"] == "default_sound_important.mp3"
assert message.data["important_notification_volume_type"] == "constant"
assert message.data["important_notification_volume"] == "0.8"
assert message.data["important_notification_override_dnd"] == "true"
# Check APNS notification sound is set correctly
apns_sound = message.apns.payload.aps.sound
assert apns_sound.critical is False
assert apns_sound.name == "default_sound.aiff"
assert apns_sound.volume is None # APNS doesn't allow to specify volume for non-critical notifications
@pytest.mark.django_db
def test_fcm_message_user_settings_critical(
make_organization_and_user, make_alert_receive_channel, make_alert_group, make_alert
):
organization, user = make_organization_and_user()
device = FCMDevice.objects.create(user=user, registration_id="test_device_id")
alert_receive_channel = make_alert_receive_channel(organization=organization)
alert_group = make_alert_group(alert_receive_channel)
make_alert(alert_group=alert_group, raw_request_data={})
message = _get_fcm_message(alert_group, user, device.registration_id, critical=True)
# Check user settings are passed to FCM message
assert message.data["default_notification_sound_name"] == "default_sound.mp3"
assert message.data["default_notification_volume_type"] == "constant"
assert message.data["default_notification_volume_override"] == "false"
assert message.data["default_notification_volume"] == "0.8"
assert message.data["important_notification_sound_name"] == "default_sound_important.mp3"
assert message.data["important_notification_volume_type"] == "constant"
assert message.data["important_notification_volume"] == "0.8"
assert message.data["important_notification_override_dnd"] == "true"
# Check APNS notification sound is set correctly
apns_sound = message.apns.payload.aps.sound
assert apns_sound.critical is True
assert apns_sound.name == "default_sound_important.aiff"
assert apns_sound.volume == 0.8
@pytest.mark.django_db
def test_fcm_message_user_settings_critical_override_dnd_disabled(
make_organization_and_user, make_alert_receive_channel, make_alert_group, make_alert
):
organization, user = make_organization_and_user()
device = FCMDevice.objects.create(user=user, registration_id="test_device_id")
alert_receive_channel = make_alert_receive_channel(organization=organization)
alert_group = make_alert_group(alert_receive_channel)
make_alert(alert_group=alert_group, raw_request_data={})
# Disable important notification override DND
MobileAppUserSettings.objects.create(user=user, important_notification_override_dnd=False)
message = _get_fcm_message(alert_group, user, device.registration_id, critical=True)
# Check user settings are passed to FCM message
assert message.data["important_notification_override_dnd"] == "false"
# Check APNS notification sound is set correctly
apns_sound = message.apns.payload.aps.sound
assert apns_sound.critical is False

View file

@ -0,0 +1,51 @@
import pytest
from django.urls import reverse
from rest_framework import status
from rest_framework.test import APIClient
@pytest.mark.django_db
def test_user_settings_get(make_organization_and_user_with_mobile_app_auth_token):
organization, user, auth_token = make_organization_and_user_with_mobile_app_auth_token()
client = APIClient()
url = reverse("mobile_app:user_settings")
response = client.get(url, HTTP_AUTHORIZATION=auth_token)
assert response.status_code == status.HTTP_200_OK
# Check the default values are correct
assert response.json() == {
"default_notification_sound_name": "default_sound",
"default_notification_volume_type": "constant",
"default_notification_volume": 0.8,
"default_notification_volume_override": False,
"important_notification_sound_name": "default_sound_important",
"important_notification_volume_type": "constant",
"important_notification_volume": 0.8,
"important_notification_override_dnd": True,
}
@pytest.mark.django_db
def test_user_settings_put(make_organization_and_user_with_mobile_app_auth_token):
organization, user, auth_token = make_organization_and_user_with_mobile_app_auth_token()
client = APIClient()
url = reverse("mobile_app:user_settings")
data = {
"default_notification_sound_name": "test_default",
"default_notification_volume_type": "intensifying",
"default_notification_volume": 1,
"default_notification_volume_override": True,
"important_notification_sound_name": "test_important",
"important_notification_volume_type": "intensifying",
"important_notification_volume": 1,
"important_notification_override_dnd": False,
}
response = client.put(url, data=data, format="json", HTTP_AUTHORIZATION=auth_token)
assert response.status_code == status.HTTP_200_OK
# Check the values are updated correctly
assert response.json() == data

View file

@ -1,5 +1,5 @@
from apps.mobile_app.fcm_relay import FCMRelayView
from apps.mobile_app.views import FCMDeviceAuthorizedViewSet, MobileAppAuthTokenAPIView
from apps.mobile_app.views import FCMDeviceAuthorizedViewSet, MobileAppAuthTokenAPIView, MobileAppUserSettingsAPIView
from common.api_helpers.optional_slash_router import OptionalSlashRouter, optional_slash_path
app_name = "mobile_app"
@ -10,6 +10,7 @@ router.register("fcm", FCMDeviceAuthorizedViewSet, basename="fcm")
urlpatterns = [
*router.urls,
optional_slash_path("auth_token", MobileAppAuthTokenAPIView.as_view(), name="auth_token"),
optional_slash_path("user_settings", MobileAppUserSettingsAPIView.as_view(), name="user_settings"),
]
urlpatterns += [

View file

@ -1,11 +1,13 @@
from fcm_django.api.rest_framework import FCMDeviceAuthorizedViewSet as BaseFCMDeviceAuthorizedViewSet
from rest_framework import status
from rest_framework import generics, status
from rest_framework.exceptions import NotFound
from rest_framework.permissions import IsAuthenticated
from rest_framework.response import Response
from rest_framework.views import APIView
from apps.mobile_app.auth import MobileAppAuthTokenAuthentication, MobileAppVerificationTokenAuthentication
from apps.mobile_app.models import MobileAppAuthToken
from apps.mobile_app.models import MobileAppAuthToken, MobileAppUserSettings
from apps.mobile_app.serializers import MobileAppUserSettingsSerializer
class FCMDeviceAuthorizedViewSet(BaseFCMDeviceAuthorizedViewSet):
@ -50,3 +52,13 @@ class MobileAppAuthTokenAPIView(APIView):
raise NotFound
return Response(status=status.HTTP_204_NO_CONTENT)
class MobileAppUserSettingsAPIView(generics.RetrieveUpdateAPIView):
authentication_classes = (MobileAppAuthTokenAuthentication,)
permission_classes = (IsAuthenticated,)
serializer_class = MobileAppUserSettingsSerializer
def get_object(self):
mobile_app_settings, _ = MobileAppUserSettings.objects.get_or_create(user=self.request.user)
return mobile_app_settings

View file

@ -55,7 +55,7 @@ from apps.base.tests.factories import (
)
from apps.email.tests.factories import EmailMessageFactory
from apps.heartbeat.tests.factories import IntegrationHeartBeatFactory
from apps.mobile_app.models import MobileAppVerificationToken
from apps.mobile_app.models import MobileAppAuthToken, MobileAppVerificationToken
from apps.schedules.tests.factories import (
CustomOnCallShiftFactory,
OnCallScheduleCalendarFactory,
@ -185,6 +185,14 @@ def make_mobile_app_verification_token_for_user():
return _make_mobile_app_verification_token_for_user
@pytest.fixture
def make_mobile_app_auth_token_for_user():
def _make_mobile_app_auth_token_for_user(user, organization):
return MobileAppAuthToken.create_auth_token(user, organization)
return _make_mobile_app_auth_token_for_user
@pytest.fixture
def make_public_api_token():
def _make_public_api_token(user, organization, name="test_api_token"):
@ -685,6 +693,20 @@ def make_organization_and_user_with_mobile_app_verification_token(
return _make_organization_and_user_with_mobile_app_verification_token
@pytest.fixture()
def make_organization_and_user_with_mobile_app_auth_token(
make_organization_and_user, make_mobile_app_auth_token_for_user
):
def _make_organization_and_user_with_mobile_app_auth_token(
role: typing.Optional[LegacyAccessControlRole] = None,
):
organization, user = make_organization_and_user(role)
_, token = make_mobile_app_auth_token_for_user(user, organization)
return organization, user, token
return _make_organization_and_user_with_mobile_app_auth_token
@pytest.fixture()
def mock_send_user_notification_signal(monkeypatch):
def mocked_send_signal(*args, **kwargs):

View file

@ -29,11 +29,17 @@ const GList = <T extends WithId>(props: GListProps<T>) => {
};
}, []);
const selectedRef = useRef<HTMLDivElement>();
const itemsRef = useRef(null);
useEffect(() => {
if (autoScroll && selectedRef.current) {
const selectedElement = selectedRef.current;
if (autoScroll && selectedId) {
const map = getMap();
const selectedElement = map.get(selectedId);
if (!selectedElement) {
return;
}
const divToScroll = selectedElement.parentElement.parentElement;
const maxScroll = Math.max(0, selectedElement.parentElement.offsetHeight - divToScroll.offsetHeight);
@ -49,7 +55,14 @@ const GList = <T extends WithId>(props: GListProps<T>) => {
behavior: 'smooth',
});
}
}, [autoScroll, selectedRef.current]);
}, [selectedId, autoScroll]);
function getMap() {
if (!itemsRef.current) {
itemsRef.current = new Map();
}
return itemsRef.current;
}
return (
<div className={cx('root')}>
@ -57,8 +70,11 @@ const GList = <T extends WithId>(props: GListProps<T>) => {
items.map((item) => (
<div
ref={(node) => {
if (item.id === selectedId) {
selectedRef.current = node;
const map = getMap();
if (node) {
map.set(item.id, node);
} else {
map.delete(item.id);
}
}}
key={item.id}

View file

@ -27,7 +27,7 @@ const ManualAlertGroup: FC<ManualAlertGroupProps> = (props) => {
const [userResponders, setUserResponders] = useState([]);
const [scheduleResponders, setScheduleResponders] = useState([]);
const { onHide, onCreate } = props;
const data = {};
const data = { team: store.userStore.currentUser?.current_team };
const handleFormSubmit = async (data) => {
store.directPagingStore

View file

@ -84,13 +84,13 @@ const AlertReceiveChannelCard = observer((props: AlertReceiveChannelCardProps) =
</PluginLink>
)}
</HorizontalGroup>
<HorizontalGroup>
<HorizontalGroup spacing="xs">
<IntegrationLogo scale={0.08} integration={integration} />
<Text type="secondary" size="small">
{integration?.display_name}
</Text>
</HorizontalGroup>
<TeamName team={grafanaTeamStore.items[alertReceiveChannel.team]} />
<TeamName team={grafanaTeamStore.items[alertReceiveChannel.team]} size="small" />
</VerticalGroup>
</HorizontalGroup>
</div>

View file

@ -45,7 +45,7 @@ const EscalationChainCard = observer((props: AlertReceiveChannelCardProps) => {
}
/>
</HorizontalGroup>
<TeamName team={grafanaTeamStore.items[escalationChain.team]} />
<TeamName team={grafanaTeamStore.items[escalationChain.team]} size="small" />
</VerticalGroup>
</HorizontalGroup>
</div>

View file

@ -8,6 +8,20 @@ export const form: { name: string; fields: FormItem[] } = {
type: FormItemType.Input,
validation: { required: true },
},
{
name: 'team',
label: 'Assign to team',
description:
'Assigning to the teams allows you to filter Outgoing Webhooks and configure their visibility. Go to OnCall -> Settings -> Team and Access Settings for more details',
type: FormItemType.GSelect,
extra: {
modelName: 'grafanaTeamStore',
displayField: 'name',
valueField: 'id',
showSearch: true,
allowClear: true,
},
},
{
name: 'trigger_type',
label: 'Trigger type',

View file

@ -0,0 +1,3 @@
.avatar {
margin-right: 4px;
}

View file

@ -1,12 +1,17 @@
import React from 'react';
import { Badge, Tooltip } from '@grafana/ui';
import cn from 'classnames/bind';
import { observer } from 'mobx-react';
import Avatar from 'components/Avatar/Avatar';
import Text from 'components/Text/Text';
import { GrafanaTeam } from 'models/grafana_team/grafana_team.types';
import styles from './TeamName.module.css';
const cx = cn.bind(styles);
interface TeamNameProps {
team: GrafanaTeam;
size?: 'small' | 'medium' | 'large';
@ -15,17 +20,17 @@ interface TeamNameProps {
const TeamName = observer((props: TeamNameProps) => {
const { team, size } = props;
if (!team) {
return <></>;
return null;
}
if (team.id === 'null') {
return <Badge text={team.name} color={'blue'} tooltip={'Resource is not assigned to any team (ex General team)'} />;
}
return (
<Text type="secondary" size={size ? size : 'medium'}>
<Avatar size="small" src={team.avatar_url} />{' '}
<Avatar size="small" src={team.avatar_url} className={cx('avatar')} />
<Tooltip placement="top" content={'Resource is assigned to ' + team.name}>
<span>{team.name}</span>
</Tooltip>{' '}
</Tooltip>
</Text>
);
});

View file

@ -1,6 +1,6 @@
import React, { useCallback, useState } from 'react';
import { Badge, Button, Field, HorizontalGroup, Modal, RadioButtonGroup, Tooltip, VerticalGroup } from '@grafana/ui';
import { Badge, Button, Field, HorizontalGroup, Modal, RadioButtonList, Tooltip, VerticalGroup } from '@grafana/ui';
import { observer } from 'mobx-react';
import Avatar from 'components/Avatar/Avatar';
@ -146,12 +146,14 @@ const TeamModal = ({ teamId, onHide }: TeamModalProps) => {
const { grafanaTeamStore } = store;
const team = grafanaTeamStore.items[teamId];
const [shareResourceToAll, setShareResourceToAll] = useState<boolean>(team.is_sharing_resources_to_all);
const [shareResourceToAll, setShareResourceToAll] = useState<string>(
String(Number(team.is_sharing_resources_to_all))
);
const handleSubmit = useCallback(() => {
Promise.all([grafanaTeamStore.updateTeam(teamId, { is_sharing_resources_to_all: shareResourceToAll })]).then(
onHide
);
Promise.all([
grafanaTeamStore.updateTeam(teamId, { is_sharing_resources_to_all: Boolean(Number(shareResourceToAll)) }),
]).then(onHide);
}, [shareResourceToAll]);
return (
@ -167,14 +169,17 @@ const TeamModal = ({ teamId, onHide }: TeamModalProps) => {
<WithPermissionControlTooltip userAction={UserActions.AlertGroupsWrite}>
<VerticalGroup>
<Field label="Who can see the team name and access the team resources">
<RadioButtonGroup
options={[
{ label: 'All Users', value: true },
{ label: 'Team members and admins', value: false },
]}
value={shareResourceToAll}
onChange={setShareResourceToAll}
/>
<div style={{ marginTop: '8px' }}>
<RadioButtonList
name="shareResourceToAll"
options={[
{ label: 'All Users', value: '1' },
{ label: 'Team members and admins', value: '0' },
]}
value={shareResourceToAll}
onChange={setShareResourceToAll}
/>
</div>
</Field>
</VerticalGroup>
</WithPermissionControlTooltip>
@ -184,7 +189,7 @@ const TeamModal = ({ teamId, onHide }: TeamModalProps) => {
Cancel
</Button>
<Button onClick={handleSubmit} variant="primary">
Submit
Save
</Button>
</HorizontalGroup>
</Modal>

View file

@ -1,6 +1,7 @@
import { action, observable } from 'mobx';
import BaseStore from 'models/base_store';
import { OutgoingWebhook } from 'models/outgoing_webhook/outgoing_webhook.types';
import { makeRequest } from 'network';
import { RootStore } from 'state';
@ -52,9 +53,11 @@ export class OutgoingWebhook2Store extends BaseStore {
}
@action
async updateItems(query = '') {
async updateItems(query: any = '') {
const params = typeof query === 'string' ? { search: query } : query;
const results = await makeRequest(`${this.path}`, {
params: { search: query },
params,
});
this.items = {
@ -68,9 +71,11 @@ export class OutgoingWebhook2Store extends BaseStore {
),
};
const key = typeof query === 'string' ? query : '';
this.searchResult = {
...this.searchResult,
[query]: results.map((item: OutgoingWebhook2) => item.id),
[key]: results.map((item: OutgoingWebhook) => item.id),
};
}

View file

@ -69,8 +69,6 @@ class EscalationChainsPage extends React.Component<EscalationChainsPageProps, Es
const { escalationChainStore } = store;
const searchResult = escalationChainStore.getSearchResult();
let selectedEscalationChain: EscalationChain['id'];
if (id) {
let escalationChain = await escalationChainStore
@ -87,22 +85,25 @@ class EscalationChainsPage extends React.Component<EscalationChainsPageProps, Es
}
}
if (!selectedEscalationChain) {
selectedEscalationChain = searchResult[0]?.id;
}
if (selectedEscalationChain) {
this.enrichExtraEscalationChainsAndSelect(selectedEscalationChain);
} else {
this.setState({ selectedEscalationChain: undefined });
}
};
handleEsclalationSelect = (id: EscalationChain['id']) => {
const { history } = this.props;
history.push(`${PLUGIN_ROOT}/escalations/${id}${window.location.search}`);
};
setSelectedEscalationChain = async (escalationChainId: EscalationChain['id']) => {
const { store, history } = this.props;
const { store } = this.props;
const { escalationChainStore } = store;
this.setState({ selectedEscalationChain: escalationChainId }, () => {
history.push(`${PLUGIN_ROOT}/escalations/${escalationChainId || ''}${window.location.search}`);
if (escalationChainId) {
escalationChainStore.updateEscalationChainDetails(escalationChainId);
}
@ -167,7 +168,7 @@ class EscalationChainsPage extends React.Component<EscalationChainsPageProps, Es
selectedId={selectedEscalationChain}
items={data}
itemKey="id"
onSelect={this.setSelectedEscalationChain}
onSelect={this.handleEsclalationSelect}
>
{(item) => <EscalationChainCard id={item.id} />}
</GList>
@ -234,8 +235,14 @@ class EscalationChainsPage extends React.Component<EscalationChainsPageProps, Es
}
handleFiltersChange = (filters: FiltersValues, isOnMount = false) => {
const {
match: {
params: { id },
},
} = this.props;
this.setState({ escalationChainsFilters: filters, extraEscalationChains: undefined }, () => {
if (isOnMount) {
if (isOnMount && id) {
this.applyFilters().then(this.parseQueryParams);
} else {
this.applyFilters().then(this.autoSelectEscalationChain);
@ -244,14 +251,15 @@ class EscalationChainsPage extends React.Component<EscalationChainsPageProps, Es
};
autoSelectEscalationChain = () => {
const { store } = this.props;
const { store, history } = this.props;
const { selectedEscalationChain } = this.state;
const { escalationChainStore } = store;
const searchResult = escalationChainStore.getSearchResult();
if (!searchResult.find((escalationChain: EscalationChain) => escalationChain.id === selectedEscalationChain)) {
this.setSelectedEscalationChain(searchResult[0]?.id);
const id = searchResult[0]?.id;
history.push(`${PLUGIN_ROOT}/escalations/${id || ''}${window.location.search}`);
}
};
@ -358,7 +366,11 @@ class EscalationChainsPage extends React.Component<EscalationChainsPageProps, Es
};
handleEscalationChainCreate = async (id: EscalationChain['id']) => {
this.enrichExtraEscalationChainsAndSelect(id);
const { history } = this.props;
await this.applyFilters();
history.push(`${PLUGIN_ROOT}/escalations/${id}${window.location.search}`);
};
enrichExtraEscalationChainsAndSelect = async (id: EscalationChain['id']) => {
@ -389,7 +401,7 @@ class EscalationChainsPage extends React.Component<EscalationChainsPageProps, Es
};
handleDeleteEscalationChain = () => {
const { store } = this.props;
const { store, history } = this.props;
const { escalationChainStore } = store;
const { selectedEscalationChain, extraEscalationChains } = this.state;
@ -413,7 +425,7 @@ class EscalationChainsPage extends React.Component<EscalationChainsPageProps, Es
const newSelected = escalationChains[index - 1] || escalationChains[0];
this.setSelectedEscalationChain(newSelected?.id);
history.push(`${PLUGIN_ROOT}/escalations/${newSelected?.id || ''}${window.location.search}`);
});
};

View file

@ -86,7 +86,7 @@ class Integrations extends React.Component<IntegrationsProps, IntegrationsState>
} = this.props;
const { alertReceiveChannelStore } = store;
let selectedAlertReceiveChannel = store.selectedAlertReceiveChannel;
let selectedAlertReceiveChannel = undefined;
if (id) {
let alertReceiveChannel = await alertReceiveChannelStore
@ -110,6 +110,8 @@ class Integrations extends React.Component<IntegrationsProps, IntegrationsState>
if (selectedAlertReceiveChannel) {
this.enrichAlertReceiveChannelsAndSelect(selectedAlertReceiveChannel);
} else {
store.selectedAlertReceiveChannel = undefined;
}
};
@ -309,28 +311,29 @@ class Integrations extends React.Component<IntegrationsProps, IntegrationsState>
delete alertReceiveChanneltoPoll[alertReceiveChannelId];
}
alertReceiveChannelStore.deleteAlertReceiveChannel(alertReceiveChannelId).then(async () => {
await this.applyFilters();
alertReceiveChannelStore
.deleteAlertReceiveChannel(alertReceiveChannelId)
.then(this.applyFilters)
.then(() => {
if (alertReceiveChannelId === store.selectedAlertReceiveChannel) {
if (extraAlertReceiveChannels) {
const newExtraAlertReceiveChannels = extraAlertReceiveChannels.filter(
(alertReceiveChannel) => alertReceiveChannel.id !== alertReceiveChannelId
);
if (alertReceiveChannelId === store.selectedAlertReceiveChannel) {
if (extraAlertReceiveChannels) {
const newExtraAlertReceiveChannels = extraAlertReceiveChannels.filter(
(alertReceiveChannel) => alertReceiveChannel.id !== alertReceiveChannelId
this.setState({ extraAlertReceiveChannels: newExtraAlertReceiveChannels });
}
const searchResult = alertReceiveChannelStore.getSearchResult();
const index = searchResult.findIndex(
(alertReceiveChannel: AlertReceiveChannel) => alertReceiveChannel.id === store.selectedAlertReceiveChannel
);
const newSelected = searchResult[index - 1] || searchResult[0];
this.setState({ extraAlertReceiveChannels: newExtraAlertReceiveChannels });
history.push(`${PLUGIN_ROOT}/integrations/${newSelected?.id || ''}${window.location.search}`);
}
const searchResult = alertReceiveChannelStore.getSearchResult();
const index = searchResult.findIndex(
(alertReceiveChannel: AlertReceiveChannel) => alertReceiveChannel.id === store.selectedAlertReceiveChannel
);
const newSelected = searchResult[index - 1] || searchResult[0];
history.push(`${PLUGIN_ROOT}/integrations/${newSelected?.id || ''}${window.location.search}`);
}
});
});
};
applyFilters = () => {
@ -363,7 +366,7 @@ class Integrations extends React.Component<IntegrationsProps, IntegrationsState>
},
} = this.props;
this.setState({ integrationsFilters }, () => {
this.setState({ integrationsFilters, extraAlertReceiveChannels: undefined }, () => {
this.applyFilters().then(() => {
if (isOnMount && id) {
this.parseQueryParams();

View file

@ -17,8 +17,12 @@ import Text from 'components/Text/Text';
import WithConfirm from 'components/WithConfirm/WithConfirm';
import OutgoingWebhook2Form from 'containers/OutgoingWebhook2Form/OutgoingWebhook2Form';
import OutgoingWebhook2Status from 'containers/OutgoingWebhook2Status/OutgoingWebhook2Status';
import RemoteFilters from 'containers/RemoteFilters/RemoteFilters';
import TeamName from 'containers/TeamName/TeamName';
import { WithPermissionControlTooltip } from 'containers/WithPermissionControl/WithPermissionControlTooltip';
import { ActionDTO } from 'models/action';
import { FiltersValues } from 'models/filters/filters.types';
import { OutgoingWebhook } from 'models/outgoing_webhook/outgoing_webhook.types';
import { OutgoingWebhook2 } from 'models/outgoing_webhook_2/outgoing_webhook_2.types';
import { makeRequest } from 'network';
import { PageProps, WithStoreProps } from 'state/types';
@ -122,6 +126,11 @@ class OutgoingWebhooks2 extends React.Component<OutgoingWebhooks2Props, Outgoing
title: 'Last run',
dataIndex: 'last_run',
},
{
width: '15%',
title: 'Team',
render: (item: OutgoingWebhook) => this.renderTeam(item, store.grafanaTeamStore.items),
},
{
width: '20%',
key: 'action',
@ -139,6 +148,7 @@ class OutgoingWebhooks2 extends React.Component<OutgoingWebhooks2Props, Outgoing
{() => (
<>
<div className={cx('root')}>
{this.renderOutgoingWebhooksFilters()}
<GTable
emptyText={webhooks ? 'No outgoing webhooks found' : 'Loading...'}
title={() => (
@ -191,6 +201,36 @@ class OutgoingWebhooks2 extends React.Component<OutgoingWebhooks2Props, Outgoing
);
}
renderOutgoingWebhooksFilters() {
const { query, store } = this.props;
return (
<div className={cx('filters')}>
<RemoteFilters
query={query}
page="webhooks"
grafanaTeamStore={store.grafanaTeamStore}
onChange={this.handleFiltersChange}
/>
</div>
);
}
handleFiltersChange = (filters: FiltersValues, isOnMount) => {
const { store } = this.props;
const { outgoingWebhook2Store } = store;
outgoingWebhook2Store.updateItems(filters).then(() => {
if (isOnMount) {
this.parseQueryParams();
}
});
};
renderTeam(record: OutgoingWebhook, teams: any) {
return <TeamName team={teams[record.team]} />;
}
renderActionButtons = (record: ActionDTO) => {
return (
<HorizontalGroup justify="flex-end">