Merge pull request #3369 from grafana/dev

v1.3.59
This commit is contained in:
Vadim Stepanov 2023-11-16 16:38:19 +00:00 committed by GitHub
commit fd132c2eb3
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
44 changed files with 831 additions and 170 deletions

View file

@ -7,6 +7,21 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## Unreleased
## v1.3.59 (2023-11-16)
### Added
- Populate `users` field of the public Shift GET API with `rolling_users` from the type override created from web UI([#3303](https://github.com/grafana/oncall/pull/3303))
- Do not retry to update slack user group on every API error ([#3363](https://github.com/grafana/oncall/pull/3363))
- Allow specifying a comma-separated list of redis-servers to the `REDIS_URI` engine environment variable by @joeyorlando
([#3368](https://github.com/grafana/oncall/pull/3368))
### Fixed
- Fixed recurrency limit issue in the Rotation Modal ([#3358](https://github.com/grafana/oncall/pull/3358))
- Added dragging boundary constraints for Rotation Modal and show scroll for the users list ([#3365](https://github.com/grafana/oncall/pull/3365))
- Delete direct paging integration on team delete by @vadimkerr ([#3367](https://github.com/grafana/oncall/pull/3367))
## v1.3.58 (2023-11-14)
### Added

View file

@ -1,3 +1,3 @@
aiohttp==3.8.5
aiohttp==3.8.6
Faker==16.4.0
tqdm==4.64.1

View file

@ -5,6 +5,7 @@ from django.utils.text import Truncator
from apps.alerts.incident_appearance.renderers.base_renderer import AlertBaseRenderer, AlertGroupBaseRenderer
from apps.alerts.incident_appearance.templaters import AlertSlackTemplater
from apps.slack.constants import BLOCK_SECTION_TEXT_MAX_SIZE
from apps.slack.scenarios.scenario_step import ScenarioStep
from apps.slack.types import Block
from common.utils import is_string_with_visible_characters, str_or_backup
@ -23,7 +24,6 @@ class AlertSlackRenderer(AlertBaseRenderer):
return AlertSlackTemplater
def render_alert_blocks(self) -> Block.AnyBlocks:
BLOCK_SECTION_TEXT_MAX_SIZE = 2800
blocks: Block.AnyBlocks = []
title = Truncator(str_or_backup(self.templated_alert.title, "Alert"))

View file

@ -179,6 +179,7 @@ class AlertGroupSlackRenderingMixin:
class AlertGroup(AlertGroupSlackRenderingMixin, EscalationSnapshotMixin, models.Model):
acknowledged_by_user: typing.Optional["User"]
alerts: "RelatedManager['Alert']"
dependent_alert_groups: "RelatedManager['AlertGroup']"
channel: "AlertReceiveChannel"
@ -187,7 +188,9 @@ class AlertGroup(AlertGroupSlackRenderingMixin, EscalationSnapshotMixin, models.
resolution_notes: "RelatedManager['ResolutionNote']"
resolution_note_slack_messages: "RelatedManager['ResolutionNoteSlackMessage']"
resolved_by_alert: typing.Optional["Alert"]
resolved_by_user: typing.Optional["User"]
root_alert_group: typing.Optional["AlertGroup"]
silenced_by_user: typing.Optional["User"]
slack_log_message: typing.Optional["SlackMessage"]
slack_messages: "RelatedManager['SlackMessage']"
users: "RelatedManager['User']"

View file

@ -87,6 +87,10 @@ def number_to_smiles_translator(number):
return "".join(reversed(smileset))
class IntegrationAlertGroupLabels(typing.TypedDict):
inheritable: typing.Dict[str, bool]
class AlertReceiveChannelQueryset(models.QuerySet):
def delete(self):
self.update(deleted_at=timezone.now())
@ -261,6 +265,7 @@ class AlertReceiveChannel(IntegrationOptionsMixin, MaintainableObject):
# Don't allow multiple Direct Paging integrations per team
if (
self.integration == AlertReceiveChannel.INTEGRATION_DIRECT_PAGING
and not self.deleted_at
and AlertReceiveChannel.objects.filter(
organization=self.organization, team=self.team, integration=self.integration
)
@ -638,6 +643,21 @@ class AlertReceiveChannel(IntegrationOptionsMixin, MaintainableObject):
result["team"] = "General"
return result
@property
def alert_group_labels(self) -> IntegrationAlertGroupLabels:
"""
Alert group labels configuration for the integration used by AlertReceiveChannelSerializer.
See AlertReceiveChannelAssociatedLabel.inheritable for more details.
"""
return {"inheritable": {label.key_id: label.inheritable for label in self.labels.all()}}
@alert_group_labels.setter
def alert_group_labels(self, value: IntegrationAlertGroupLabels) -> None:
"""Setter for alert_group_labels used by AlertReceiveChannelSerializer"""
inheritable_key_ids = [key_id for key_id, inheritable in value["inheritable"].items() if inheritable]
self.labels.filter(key_id__in=inheritable_key_ids).update(inheritable=True)
self.labels.filter(~Q(key_id__in=inheritable_key_ids)).update(inheritable=False)
@receiver(post_save, sender=AlertReceiveChannel)
def listen_for_alertreceivechannel_model_save(

View file

@ -1,21 +1,22 @@
import datetime
import logging
import typing
from django.core.cache import cache
from django.utils import timezone
from drf_spectacular.utils import extend_schema_field, inline_serializer
from rest_framework import serializers
from apps.alerts.incident_appearance.renderers.classic_markdown_renderer import AlertGroupClassicMarkdownRenderer
from apps.alerts.incident_appearance.renderers.web_renderer import AlertGroupWebRenderer
from apps.alerts.models import AlertGroup
from apps.alerts.models.alert_group import PagedUser
from common.api_helpers.custom_fields import TeamPrimaryKeyRelatedField
from common.api_helpers.mixins import EagerLoadingMixin
from .alert import AlertSerializer
from .alert_receive_channel import FastAlertReceiveChannelSerializer
from .alerts_field_cache_buster_mixin import AlertsFieldCacheBusterMixin
from .user import FastUserSerializer, PagedUserSerializer, UserShortSerializer
from .user import FastUserSerializer, UserShortSerializer
logger = logging.getLogger(__name__)
logger.setLevel(logging.DEBUG)
@ -90,7 +91,7 @@ class ShortAlertGroupSerializer(AlertGroupFieldsCacheSerializerMixin, serializer
},
)
)
def get_render_for_web(self, obj):
def get_render_for_web(self, obj: "AlertGroup"):
last_alert = obj.alerts.last()
if last_alert is None:
return {}
@ -102,7 +103,9 @@ class ShortAlertGroupSerializer(AlertGroupFieldsCacheSerializerMixin, serializer
)
class AlertGroupListSerializer(EagerLoadingMixin, AlertGroupFieldsCacheSerializerMixin, serializers.ModelSerializer):
class AlertGroupListSerializer(
EagerLoadingMixin, AlertGroupFieldsCacheSerializerMixin, serializers.ModelSerializer[AlertGroup]
):
pk = serializers.CharField(read_only=True, source="public_primary_key")
alert_receive_channel = FastAlertReceiveChannelSerializer(source="channel")
status = serializers.ReadOnlyField()
@ -116,7 +119,6 @@ class AlertGroupListSerializer(EagerLoadingMixin, AlertGroupFieldsCacheSerialize
alerts_count = serializers.IntegerField(read_only=True)
render_for_web = serializers.SerializerMethodField()
render_for_classic_markdown = serializers.SerializerMethodField()
labels = AlertGroupLabelSerializer(many=True, read_only=True)
@ -158,7 +160,6 @@ class AlertGroupListSerializer(EagerLoadingMixin, AlertGroupFieldsCacheSerialize
"silenced_until",
"related_users",
"render_for_web",
"render_for_classic_markdown",
"dependent_alert_groups",
"root_alert_group",
"status",
@ -179,7 +180,7 @@ class AlertGroupListSerializer(EagerLoadingMixin, AlertGroupFieldsCacheSerialize
},
)
)
def get_render_for_web(self, obj):
def get_render_for_web(self, obj: "AlertGroup"):
if not obj.last_alert:
return {}
return AlertGroupFieldsCacheSerializerMixin.get_or_set_web_template_field(
@ -189,21 +190,12 @@ class AlertGroupListSerializer(EagerLoadingMixin, AlertGroupFieldsCacheSerialize
AlertGroupWebRenderer,
)
def get_render_for_classic_markdown(self, obj):
"""Deprecated. TODO: remove"""
if not obj.last_alert:
return {}
return AlertGroupFieldsCacheSerializerMixin.get_or_set_web_template_field(
obj,
obj.last_alert,
AlertGroupFieldsCacheSerializerMixin.RENDER_FOR_CLASSIC_MARKDOWN_FIELD_NAME,
AlertGroupClassicMarkdownRenderer,
)
@extend_schema_field(UserShortSerializer(many=True))
def get_related_users(self, obj):
users_ids = set()
users = []
def get_related_users(self, obj: "AlertGroup"):
from apps.user_management.models import User
users_ids: typing.Set[str] = set()
users: typing.List[User] = []
# add resolved and acknowledged by_user explicitly because logs are already prefetched
# when def acknowledge/resolve are called in view.
@ -241,7 +233,7 @@ class AlertGroupSerializer(AlertGroupListSerializer):
"paged_users",
]
def get_last_alert_at(self, obj) -> datetime.datetime:
def get_last_alert_at(self, obj: "AlertGroup") -> datetime.datetime:
last_alert = obj.alerts.last()
if not last_alert:
@ -250,7 +242,7 @@ class AlertGroupSerializer(AlertGroupListSerializer):
return last_alert.created_at
@extend_schema_field(AlertSerializer(many=True))
def get_limited_alerts(self, obj):
def get_limited_alerts(self, obj: "AlertGroup"):
"""
Overriding default alerts because there are alert_groups with thousands of them.
It's just too slow, we need to cut here.
@ -258,6 +250,5 @@ class AlertGroupSerializer(AlertGroupListSerializer):
alerts = obj.alerts.order_by("-pk")[:100]
return AlertSerializer(alerts, many=True).data
@extend_schema_field(PagedUserSerializer(many=True))
def get_paged_users(self, obj):
def get_paged_users(self, obj: "AlertGroup") -> typing.List[PagedUser]:
return obj.get_paged_users()

View file

@ -1,3 +1,4 @@
import typing
from collections import OrderedDict
from django.conf import settings
@ -33,7 +34,15 @@ def valid_jinja_template_for_serializer_method_field(template):
pass
class AlertReceiveChannelSerializer(EagerLoadingMixin, LabelsSerializerMixin, serializers.ModelSerializer):
class IntegrationAlertGroupLabelsSerializer(serializers.Serializer):
"""Alert group labels configuration for the integration. See AlertReceiveChannel.alert_group_labels for details."""
inheritable = serializers.DictField(child=serializers.BooleanField())
class AlertReceiveChannelSerializer(
EagerLoadingMixin, LabelsSerializerMixin, serializers.ModelSerializer[AlertReceiveChannel]
):
id = serializers.CharField(read_only=True, source="public_primary_key")
integration_url = serializers.ReadOnlyField()
alert_count = serializers.SerializerMethodField()
@ -55,6 +64,7 @@ class AlertReceiveChannelSerializer(EagerLoadingMixin, LabelsSerializerMixin, se
connected_escalations_chains_count = serializers.SerializerMethodField()
inbound_email = serializers.CharField(required=False)
is_legacy = serializers.SerializerMethodField()
alert_group_labels = IntegrationAlertGroupLabelsSerializer(required=False)
# integration heartbeat is in PREFETCH_RELATED not by mistake.
# With using of select_related ORM builds strange join
@ -95,6 +105,7 @@ class AlertReceiveChannelSerializer(EagerLoadingMixin, LabelsSerializerMixin, se
"inbound_email",
"is_legacy",
"labels",
"alert_group_labels",
]
read_only_fields = [
"created_at",
@ -128,6 +139,7 @@ class AlertReceiveChannelSerializer(EagerLoadingMixin, LabelsSerializerMixin, se
is_able_to_autoresolve = _integration.is_able_to_autoresolve
labels = validated_data.pop("labels", None)
alert_group_labels = validated_data.pop("alert_group_labels", None)
try:
instance = AlertReceiveChannel.create(
**validated_data,
@ -137,7 +149,12 @@ class AlertReceiveChannelSerializer(EagerLoadingMixin, LabelsSerializerMixin, se
)
except AlertReceiveChannel.DuplicateDirectPagingError:
raise BadRequest(detail=AlertReceiveChannel.DuplicateDirectPagingError.DETAIL)
# Create label associations first, then update inheritable labels
self.update_labels_association_if_needed(labels, instance, organization)
if alert_group_labels:
instance.alert_group_labels = alert_group_labels
return instance
def update(self, instance, validated_data):
@ -149,12 +166,12 @@ class AlertReceiveChannelSerializer(EagerLoadingMixin, LabelsSerializerMixin, se
except AlertReceiveChannel.DuplicateDirectPagingError:
raise BadRequest(detail=AlertReceiveChannel.DuplicateDirectPagingError.DETAIL)
def get_instructions(self, obj):
def get_instructions(self, obj: "AlertReceiveChannel"):
# Deprecated, kept for api-backward compatibility
return ""
# MethodFields are used instead of relevant properties because of properties hit db on each instance in queryset
def get_default_channel_filter(self, obj):
def get_default_channel_filter(self, obj: "AlertReceiveChannel"):
for filter in obj.channel_filters.all():
if filter.is_default:
return filter.public_primary_key
@ -178,29 +195,29 @@ class AlertReceiveChannelSerializer(EagerLoadingMixin, LabelsSerializerMixin, se
else:
raise serializers.ValidationError(detail="Integration with this name already exists")
def get_heartbeat(self, obj):
def get_heartbeat(self, obj: "AlertReceiveChannel"):
try:
heartbeat = obj.integration_heartbeat
except ObjectDoesNotExist:
return None
return IntegrationHeartBeatSerializer(heartbeat).data
def get_allow_delete(self, obj):
def get_allow_delete(self, obj: "AlertReceiveChannel"):
return True
def get_alert_count(self, obj):
def get_alert_count(self, obj: "AlertReceiveChannel"):
return 0
def get_alert_groups_count(self, obj):
def get_alert_groups_count(self, obj: "AlertReceiveChannel"):
return 0
def get_routes_count(self, obj) -> int:
def get_routes_count(self, obj: "AlertReceiveChannel") -> int:
return obj.channel_filters.count()
def get_is_legacy(self, obj) -> bool:
def get_is_legacy(self, obj: "AlertReceiveChannel") -> bool:
return has_legacy_prefix(obj.integration)
def get_connected_escalations_chains_count(self, obj) -> int:
def get_connected_escalations_chains_count(self, obj: "AlertReceiveChannel") -> int:
return (
ChannelFilter.objects.filter(alert_receive_channel=obj, escalation_chain__isnull=False)
.values("escalation_chain")
@ -214,7 +231,7 @@ class AlertReceiveChannelUpdateSerializer(AlertReceiveChannelSerializer):
read_only_fields = [*AlertReceiveChannelSerializer.Meta.read_only_fields, "integration"]
class FastAlertReceiveChannelSerializer(serializers.ModelSerializer):
class FastAlertReceiveChannelSerializer(serializers.ModelSerializer[AlertReceiveChannel]):
id = serializers.CharField(read_only=True, source="public_primary_key")
integration = serializers.CharField(read_only=True)
deleted = serializers.SerializerMethodField()
@ -223,27 +240,30 @@ class FastAlertReceiveChannelSerializer(serializers.ModelSerializer):
model = AlertReceiveChannel
fields = ["id", "integration", "verbal_name", "deleted"]
def get_deleted(self, obj):
def get_deleted(self, obj: "AlertReceiveChannel") -> bool:
return obj.deleted_at is not None
class FilterAlertReceiveChannelSerializer(serializers.ModelSerializer):
value = serializers.SerializerMethodField()
class FilterAlertReceiveChannelSerializer(serializers.ModelSerializer[AlertReceiveChannel]):
# don't use get_value as the method name, otherwise this will override the get_value method on
# serializers.ModelSerializer, which may cause unexpected behavior (+ this violates the "Lisov substition
# principle" which mypy complains about)
value = serializers.SerializerMethodField(method_name="_get_value")
display_name = serializers.SerializerMethodField()
class Meta:
model = AlertReceiveChannel
fields = ["value", "display_name", "integration_url"]
def get_value(self, obj):
def _get_value(self, obj: "AlertReceiveChannel"):
return obj.public_primary_key
def get_display_name(self, obj):
def get_display_name(self, obj: "AlertReceiveChannel"):
display_name = obj.verbal_name or AlertReceiveChannel.INTEGRATION_CHOICES[obj.integration][1]
return display_name
class AlertReceiveChannelTemplatesSerializer(EagerLoadingMixin, serializers.ModelSerializer):
class AlertReceiveChannelTemplatesSerializer(EagerLoadingMixin, serializers.ModelSerializer[AlertReceiveChannel]):
id = serializers.CharField(read_only=True, source="public_primary_key")
payload_example = SerializerMethodField()
@ -259,7 +279,7 @@ class AlertReceiveChannelTemplatesSerializer(EagerLoadingMixin, serializers.Mode
]
extra_kwargs = {"integration": {"required": True}}
def get_payload_example(self, obj):
def get_payload_example(self, obj: "AlertReceiveChannel"):
from apps.alerts.models import AlertGroup
if "alert_group_id" in self.context["request"].query_params:
@ -276,7 +296,7 @@ class AlertReceiveChannelTemplatesSerializer(EagerLoadingMixin, serializers.Mode
except AttributeError:
return None
def get_is_based_on_alertmanager(self, obj):
def get_is_based_on_alertmanager(self, obj: "AlertReceiveChannel"):
return obj.based_on_alertmanager
# Override method to pass field_name directly in set_value to handle None values for WritableSerializerField
@ -352,7 +372,7 @@ class AlertReceiveChannelTemplatesSerializer(EagerLoadingMixin, serializers.Mode
set_value(ret, [field_name], value)
return errors
def to_representation(self, obj):
def to_representation(self, obj: "AlertReceiveChannel"):
ret = super().to_representation(obj)
core_templates = self._get_core_templates(obj)
@ -364,7 +384,7 @@ class AlertReceiveChannelTemplatesSerializer(EagerLoadingMixin, serializers.Mode
return ret
def _get_messaging_backend_templates(self, obj):
def _get_messaging_backend_templates(self, obj: "AlertReceiveChannel"):
"""Return additional messaging backend templates if any."""
templates = {}
for backend_id, backend in get_messaging_backends():
@ -383,7 +403,7 @@ class AlertReceiveChannelTemplatesSerializer(EagerLoadingMixin, serializers.Mode
templates[f"{field_name}_is_default"] = is_default
return templates
def _get_core_templates(self, obj):
def _get_core_templates(self, obj: "AlertReceiveChannel"):
core_templates = {}
for template_name in self.core_templates_names:
@ -396,10 +416,9 @@ class AlertReceiveChannelTemplatesSerializer(EagerLoadingMixin, serializers.Mode
return core_templates
@property
def core_templates_names(self):
def core_templates_names(self) -> typing.List[str]:
"""
core_templates_names returns names of templates introduced before messaging backends system with respect to
enabled integrations.
returns names of templates introduced before messaging backends system with respect to enabled integrations.
"""
core_templates = [
"web_title_template",
@ -413,21 +432,16 @@ class AlertReceiveChannelTemplatesSerializer(EagerLoadingMixin, serializers.Mode
"acknowledge_condition_template",
]
slack_integration_required_templates = [
"slack_title_template",
"slack_message_template",
"slack_image_url_template",
]
telegram_integration_required_templates = [
"telegram_title_template",
"telegram_message_template",
"telegram_image_url_template",
]
apppend = []
if settings.FEATURE_SLACK_INTEGRATION_ENABLED:
core_templates += slack_integration_required_templates
core_templates += [
"slack_title_template",
"slack_message_template",
"slack_image_url_template",
]
if settings.FEATURE_TELEGRAM_INTEGRATION_ENABLED:
core_templates += telegram_integration_required_templates
return apppend + core_templates
core_templates += [
"telegram_title_template",
"telegram_message_template",
"telegram_image_url_template",
]
return core_templates

View file

@ -5,8 +5,7 @@ from django.core.cache import cache
class AlertsFieldCacheBusterMixin:
RENDER_FOR_WEB_FIELD_NAME = "render_for_web"
RENDER_FOR_CLASSIC_MARKDOWN_FIELD_NAME = "render_for_classic_markdown"
ALL_FIELD_NAMES = [RENDER_FOR_WEB_FIELD_NAME, RENDER_FOR_CLASSIC_MARKDOWN_FIELD_NAME]
ALL_FIELD_NAMES = [RENDER_FOR_WEB_FIELD_NAME]
@classmethod
def calculate_cache_key(cls, field_name: str, obj: typing.Any) -> str:

View file

@ -851,6 +851,35 @@ def test_update_alert_receive_channels_direct_paging(
assert response.json()["detail"] == AlertReceiveChannel.DuplicateDirectPagingError.DETAIL
@pytest.mark.django_db
def test_delete_alert_receive_channel_direct_paging_duplicate(
make_organization_and_user_with_plugin_token, make_team, make_alert_receive_channel, make_user_auth_headers
):
"""Check that it's possible to delete direct paging integration even if there is a duplicate for the team."""
organization, user, token = make_organization_and_user_with_plugin_token()
integration = make_alert_receive_channel(
organization, integration=AlertReceiveChannel.INTEGRATION_DIRECT_PAGING, team=None
)
# Create a team, add direct paging integration to it, then delete the team.
# There will be 2 direct paging integrations for the team "No team" as a result.
team = make_team(organization)
make_alert_receive_channel(organization, integration=AlertReceiveChannel.INTEGRATION_DIRECT_PAGING, team=team)
team.delete()
assert (
organization.alert_receive_channels.filter(
integration=AlertReceiveChannel.INTEGRATION_DIRECT_PAGING, team=None
).count()
== 2
)
client = APIClient()
url = reverse("api-internal:alert_receive_channel-detail", kwargs={"pk": integration.public_primary_key})
response = client.delete(url, **make_user_auth_headers(user, token))
assert response.status_code == status.HTTP_204_NO_CONTENT
@pytest.mark.django_db
def test_start_maintenance_integration(
make_user_auth_headers,
@ -1350,3 +1379,69 @@ def test_update_alert_receive_channel_labels_duplicate_key(
assert response.status_code == status.HTTP_400_BAD_REQUEST
assert alert_receive_channel.labels.count() == 0
@pytest.mark.django_db
def test_alert_group_labels_get(
make_organization_and_user_with_plugin_token,
make_alert_receive_channel,
make_integration_label_association,
make_user_auth_headers,
):
organization, user, token = make_organization_and_user_with_plugin_token()
alert_receive_channel = make_alert_receive_channel(organization)
client = APIClient()
url = reverse("api-internal:alert_receive_channel-detail", kwargs={"pk": alert_receive_channel.public_primary_key})
response = client.get(url, **make_user_auth_headers(user, token))
assert response.status_code == status.HTTP_200_OK
assert response.json()["alert_group_labels"] == {"inheritable": {}}
label = make_integration_label_association(organization, alert_receive_channel)
response = client.get(url, **make_user_auth_headers(user, token))
assert response.status_code == status.HTTP_200_OK
assert response.json()["alert_group_labels"] == {"inheritable": {label.key_id: True}}
@pytest.mark.django_db
def test_alert_group_labels_put(
make_organization_and_user_with_plugin_token,
make_alert_receive_channel,
make_integration_label_association,
make_user_auth_headers,
):
organization, user, token = make_organization_and_user_with_plugin_token()
alert_receive_channel = make_alert_receive_channel(organization)
label_1 = make_integration_label_association(organization, alert_receive_channel)
label_2 = make_integration_label_association(organization, alert_receive_channel, inheritable=False)
client = APIClient()
url = reverse("api-internal:alert_receive_channel-detail", kwargs={"pk": alert_receive_channel.public_primary_key})
data = {"alert_group_labels": {"inheritable": {label_1.key_id: False, label_2.key_id: True}}}
response = client.put(url, data, format="json", **make_user_auth_headers(user, token))
assert response.status_code == status.HTTP_200_OK
assert response.json()["alert_group_labels"] == {"inheritable": {label_1.key_id: False, label_2.key_id: True}}
@pytest.mark.django_db
def test_alert_group_labels_post(alert_receive_channel_internal_api_setup, make_user_auth_headers):
user, token, _ = alert_receive_channel_internal_api_setup
labels = [{"key": {"id": "test", "name": "test"}, "value": {"id": "123", "name": "123"}}]
alert_group_labels = {"inheritable": {"test": False}}
data = {
"integration": AlertReceiveChannel.INTEGRATION_GRAFANA,
"team": None,
"labels": labels,
"alert_group_labels": alert_group_labels,
}
client = APIClient()
url = reverse("api-internal:alert_receive_channel-list")
response = client.post(url, data, format="json", **make_user_auth_headers(user, token))
assert response.status_code == status.HTTP_201_CREATED
assert response.json()["labels"] == labels
assert response.json()["alert_group_labels"] == alert_group_labels

View file

@ -0,0 +1,18 @@
import pytest
import yaml
from django.test import override_settings
from django.urls import reverse
from rest_framework import status
from rest_framework.test import APIClient
@pytest.mark.django_db
@override_settings(DRF_SPECTACULAR_ENABLED=True)
def test_fetching_the_openapi_schema_works(settings, reload_urls):
reload_urls()
client = APIClient()
response = client.get(reverse("schema"))
assert response.status_code == status.HTTP_200_OK
assert yaml.safe_load(response.content)["info"]["title"] == settings.SPECTACULAR_SETTINGS["TITLE"]

View file

@ -5,7 +5,7 @@ from django.db.models import Count, Max, Q
from django.utils import timezone
from django_filters import rest_framework as filters
from django_filters.widgets import RangeWidget
from drf_spectacular.utils import extend_schema, inline_serializer
from drf_spectacular.utils import extend_schema, extend_schema_view, inline_serializer
from rest_framework import mixins, serializers, status, viewsets
from rest_framework.decorators import action
from rest_framework.exceptions import NotFound
@ -267,6 +267,12 @@ class AlertGroupTeamFilteringMixin(TeamFilteringMixin):
return Response(data={"error_code": "wrong_team"}, status=status.HTTP_403_FORBIDDEN)
@extend_schema_view(
list=extend_schema(description="Fetch a list of alert groups"),
retrieve=extend_schema(description="Fetch a single alert group"),
destroy=extend_schema(description="Delete an alert group"),
preview_template=extend_schema(description="Preview a template for an alert group"),
)
class AlertGroupView(
PreviewTemplateMixin,
AlertGroupTeamFilteringMixin,
@ -290,8 +296,6 @@ class AlertGroupView(
"filters": [RBACPermission.Permissions.ALERT_GROUPS_READ],
"silence_options": [RBACPermission.Permissions.ALERT_GROUPS_READ],
"bulk_action_options": [RBACPermission.Permissions.ALERT_GROUPS_READ],
"create": [RBACPermission.Permissions.ALERT_GROUPS_WRITE],
"update": [RBACPermission.Permissions.ALERT_GROUPS_WRITE],
"destroy": [RBACPermission.Permissions.ALERT_GROUPS_WRITE],
"acknowledge": [RBACPermission.Permissions.ALERT_GROUPS_WRITE],
"unacknowledge": [RBACPermission.Permissions.ALERT_GROUPS_WRITE],
@ -478,7 +482,9 @@ class AlertGroupView(
@extend_schema(responses=inline_serializer(name="AlertGroupStats", fields={"count": serializers.IntegerField()}))
@action(detail=False)
def stats(self, *args, **kwargs):
"""Return number of alert groups capped at 100001"""
"""
Return number of alert groups capped at 100001
"""
MAX_COUNT = 100001
alert_groups = self.filter_queryset(self.get_queryset())[:MAX_COUNT]
count = alert_groups.count()
@ -491,6 +497,9 @@ class AlertGroupView(
@action(methods=["post"], detail=True)
def acknowledge(self, request, pk):
"""
Acknowledge an alert group
"""
alert_group = self.get_object()
if alert_group.is_maintenance_incident:
raise BadRequest(detail="Can't acknowledge maintenance alert group")
@ -502,6 +511,9 @@ class AlertGroupView(
@action(methods=["post"], detail=True)
def unacknowledge(self, request, pk):
"""
Unacknowledge an alert group
"""
alert_group = self.get_object()
if alert_group.is_maintenance_incident:
raise BadRequest(detail="Can't unacknowledge maintenance alert group")
@ -521,6 +533,9 @@ class AlertGroupView(
@action(methods=["post"], detail=True)
def resolve(self, request, pk):
"""
Resolve an alert group
"""
alert_group = self.get_object()
organization = self.request.user.organization
@ -563,6 +578,9 @@ class AlertGroupView(
@action(methods=["post"], detail=True)
def unresolve(self, request, pk):
"""
Unresolve an alert group
"""
alert_group = self.get_object()
if alert_group.is_maintenance_incident:
raise BadRequest(detail="Can't unresolve maintenance alert group")
@ -603,6 +621,9 @@ class AlertGroupView(
@action(methods=["post"], detail=True)
def unattach(self, request, pk=None):
"""
Unattach an alert group that is already attached to another alert group
"""
alert_group = self.get_object()
if alert_group.is_maintenance_incident:
raise BadRequest(detail="Can't unattach maintenance alert group")
@ -614,6 +635,9 @@ class AlertGroupView(
@action(methods=["post"], detail=True)
def silence(self, request, pk=None):
"""
Silence an alert group for a specified delay
"""
alert_group = self.get_object()
delay = request.data.get("delay")
@ -635,6 +659,9 @@ class AlertGroupView(
)
@action(methods=["get"], detail=False)
def silence_options(self, request):
"""
Retrieve a list of valid silence options
"""
data = [
{"value": value, "display_name": display_name} for value, display_name in AlertGroup.SILENCE_DELAY_OPTIONS
]
@ -642,6 +669,9 @@ class AlertGroupView(
@action(methods=["post"], detail=True)
def unsilence(self, request, pk=None):
"""
Unsilence a silenced alert group
"""
alert_group = self.get_object()
if not alert_group.silenced:
@ -662,6 +692,9 @@ class AlertGroupView(
@action(methods=["post"], detail=True)
def unpage_user(self, request, pk=None):
"""
Remove a user that was directly paged for the alert group
"""
organization = request.auth.organization
from_user = request.user
alert_group = self.get_object()
@ -681,6 +714,9 @@ class AlertGroupView(
@action(methods=["get"], detail=False)
def filters(self, request):
"""
Retrieve a list of valid filter options that can be used to filter alert groups
"""
filter_name = request.query_params.get("search", None)
api_root = "/api/internal/v1/"
@ -780,6 +816,9 @@ class AlertGroupView(
@action(methods=["post"], detail=False)
def bulk_action(self, request):
"""
Perform a bulk action on a list of alert groups
"""
alert_group_public_pks = self.request.data.get("alert_group_pks", [])
action_with_incidents = self.request.data.get("action", None)
delay = self.request.data.get("delay")
@ -807,6 +846,9 @@ class AlertGroupView(
@action(methods=["get"], detail=False)
def bulk_action_options(self, request):
"""
Retrieve a list of valid bulk action options
"""
return Response(
[{"value": action_name, "display_name": action_name} for action_name in AlertGroup.BULK_ACTIONS]
)

View file

@ -0,0 +1,18 @@
# Generated by Django 4.2.6 on 2023-11-09 10:42
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('labels', '0002_alertgroupassociatedlabel_and_more'),
]
operations = [
migrations.AddField(
model_name='alertreceivechannelassociatedlabel',
name='inheritable',
field=models.BooleanField(default=True, null=True),
),
]

View file

@ -105,6 +105,9 @@ class AlertReceiveChannelAssociatedLabel(AssociatedLabel):
"alerts.AlertReceiveChannel", on_delete=models.CASCADE, related_name="labels"
)
# If inheritable is True, then the label will be passed down to alert groups
inheritable = models.BooleanField(default=True, null=True)
class Meta:
unique_together = ["key_id", "value_id", "alert_receive_channel_id"]

View file

@ -32,6 +32,7 @@ def test_assign_labels(make_organization, make_alert_receive_channel, make_integ
organization = make_organization()
alert_receive_channel = make_alert_receive_channel(organization)
label = make_integration_label_association(organization, alert_receive_channel)
make_integration_label_association(organization, alert_receive_channel, inheritable=False)
alert = Alert.create(
title="the title",

View file

@ -55,8 +55,7 @@ def assign_labels(alert_group: "AlertGroup", alert_receive_channel: "AlertReceiv
if not is_labels_feature_enabled(alert_receive_channel.organization):
return
# inherit all labels from the integration
# FIXME: this is a temporary solution before we have a UI for configuring inherited labels
# inherit labels from the integration
alert_group_labels = [
AlertGroupAssociatedLabel(
alert_group=alert_group,
@ -64,6 +63,6 @@ def assign_labels(alert_group: "AlertGroup", alert_receive_channel: "AlertReceiv
key_name=label.key.name,
value_name=label.value.name,
)
for label in alert_receive_channel.labels.all().select_related("key", "value")
for label in alert_receive_channel.labels.filter(inheritable=True).select_related("key", "value")
]
AlertGroupAssociatedLabel.objects.bulk_create(alert_group_labels)

View file

@ -256,6 +256,15 @@ class CustomOnCallShiftSerializer(EagerLoadingMixin, serializers.ModelSerializer
result["rotation_start"] = instance.rotation_start.strftime("%Y-%m-%dT%H:%M:%S")
if instance.until is not None:
result["until"] = instance.until.strftime("%Y-%m-%dT%H:%M:%S")
# Populate "users" field using "rolling_users" field for web overrides
# To support the behavior of the web UI, which creates overrides populating the rolling_users field
if (
result["type"] == CustomOnCallShift.PUBLIC_TYPE_CHOICES_MAP[CustomOnCallShift.TYPE_OVERRIDE]
and instance.source == CustomOnCallShift.SOURCE_WEB
and result["rolling_users"] is not None
):
result["users"] = list({u for r in result["rolling_users"] for u in r})
result = self._get_fields_to_represent(instance, result)
return result

View file

@ -165,6 +165,51 @@ def test_get_override_on_call_shift(make_organization_and_user_with_token, make_
assert response.data == result
@pytest.mark.django_db
def test_get_web_override_shift(
make_organization_and_user_with_token, make_user_for_organization, make_on_call_shift, make_schedule
):
organization, user, token = make_organization_and_user_with_token()
client = APIClient()
start_data = timezone.now().replace(microsecond=0)
schedule = make_schedule(organization, schedule_class=OnCallScheduleCalendar)
data = {
"name": "override shift",
"start": start_data,
"rotation_start": start_data,
"duration": timezone.timedelta(hours=9),
"schedule": schedule,
}
on_call_shift = make_on_call_shift(
organization=organization,
source=CustomOnCallShift.SOURCE_WEB,
shift_type=CustomOnCallShift.TYPE_OVERRIDE,
**data,
)
on_call_shift.add_rolling_users([[user]])
url = reverse("api-public:on_call_shifts-detail", kwargs={"pk": on_call_shift.public_primary_key})
response = client.get(url, format="json", HTTP_AUTHORIZATION=f"{token}")
result = {
"id": on_call_shift.public_primary_key,
"team_id": None,
"schedule": schedule.public_primary_key,
"name": on_call_shift.name,
"type": "override",
"time_zone": None,
"start": on_call_shift.start.strftime("%Y-%m-%dT%H:%M:%S"),
"rotation_start": on_call_shift.start.strftime("%Y-%m-%dT%H:%M:%S"),
"duration": int(on_call_shift.duration.total_seconds()),
"users": list({user.public_primary_key}),
}
assert response.status_code == status.HTTP_200_OK
assert response.data == result
@pytest.mark.django_db
def test_create_on_call_shift(make_organization_and_user_with_token):
_, user, token = make_organization_and_user_with_token()

View file

@ -12,6 +12,7 @@ SLACK_RATE_LIMIT_TIMEOUT = datetime.timedelta(minutes=5)
SLACK_RATE_LIMIT_DELAY = 10
CACHE_UPDATE_INCIDENT_SLACK_MESSAGE_LIFETIME = 60 * 10
BLOCK_SECTION_TEXT_MAX_SIZE = 2800
PRIVATE_METADATA_MAX_LENGTH = 3000
DIVIDER: Block.Divider = {"type": "divider"}

View file

@ -53,7 +53,11 @@ class SlackAPIInvalidAuthError(SlackAPIError):
class SlackAPIUsergroupNotFoundError(SlackAPIError):
errors = ("no_such_subteam",)
errors = ("no_such_subteam", "subteam_not_found")
class SlackAPIInvalidUsersError(SlackAPIError):
errors = ("invalid_users",)
class SlackAPIChannelNotFoundError(SlackAPIError):

View file

@ -10,7 +10,13 @@ from django.utils import timezone
from apps.api.permissions import RBACPermission
from apps.slack.client import SlackClient
from apps.slack.errors import SlackAPIError, SlackAPIPermissionDeniedError
from apps.slack.errors import (
SlackAPIError,
SlackAPIInvalidUsersError,
SlackAPIPermissionDeniedError,
SlackAPITokenError,
SlackAPIUsergroupNotFoundError,
)
from apps.slack.models import SlackTeamIdentity
from apps.user_management.models.user import User
from common.public_primary_keys import generate_public_primary_key, increase_public_primary_key_length
@ -112,10 +118,15 @@ class SlackUserGroup(models.Model):
def update_members(self, slack_ids):
sc = SlackClient(self.slack_team_identity)
sc.usergroups_users_update(usergroup=self.slack_id, users=slack_ids)
self.members = slack_ids
self.save(update_fields=("members",))
try:
sc.usergroups_users_update(usergroup=self.slack_id, users=slack_ids)
except (SlackAPITokenError, SlackAPIUsergroupNotFoundError, SlackAPIInvalidUsersError) as err:
logger.warning(f"Slack usergroup update failed: {err}")
except SlackAPIError:
raise
else:
self.members = slack_ids
self.save(update_fields=("members",))
def get_users_from_members_for_organization(self, organization):
return organization.users.filter(

View file

@ -4,9 +4,10 @@ import logging
import typing
from django.db.models import Q
from django.utils.text import Truncator
from apps.api.permissions import RBACPermission
from apps.slack.constants import DIVIDER
from apps.slack.constants import BLOCK_SECTION_TEXT_MAX_SIZE, DIVIDER
from apps.slack.errors import (
SlackAPIChannelArchivedError,
SlackAPIChannelInactiveError,
@ -295,9 +296,10 @@ class UpdateResolutionNoteStep(scenario_step.ScenarioStep):
def get_resolution_note_blocks(self, resolution_note: "ResolutionNote") -> Block.AnyBlocks:
blocks: Block.AnyBlocks = []
author_verbal = resolution_note.author_verbal(mention=False)
resolution_note_text = Truncator(resolution_note.text)
resolution_note_text_block = {
"type": "section",
"text": {"type": "mrkdwn", "text": resolution_note.text},
"text": {"type": "mrkdwn", "text": resolution_note_text.chars(BLOCK_SECTION_TEXT_MAX_SIZE)},
}
blocks.append(resolution_note_text_block)
context_block = {

View file

@ -4,6 +4,7 @@ from unittest.mock import patch
import pytest
from apps.slack.client import SlackClient
from apps.slack.constants import BLOCK_SECTION_TEXT_MAX_SIZE
from apps.slack.errors import SlackAPIViewNotFoundError
from apps.slack.scenarios.scenario_step import ScenarioStep
from apps.slack.tests.conftest import build_slack_response
@ -106,6 +107,47 @@ def test_get_resolution_notes_blocks_non_empty(
assert blocks == expected_blocks
@pytest.mark.django_db
def test_get_resolution_note_blocks_truncate_text(
make_organization_and_user_with_slack_identities,
make_alert_receive_channel,
make_alert_group,
make_resolution_note,
):
UpdateResolutionNoteStep = ScenarioStep.get_step("resolution_note", "UpdateResolutionNoteStep")
organization, user, slack_team_identity, _ = make_organization_and_user_with_slack_identities()
step = UpdateResolutionNoteStep(slack_team_identity)
alert_receive_channel = make_alert_receive_channel(organization)
alert_group = make_alert_group(alert_receive_channel)
resolution_note = make_resolution_note(alert_group=alert_group, author=user, message_text="a" * 3000)
author_verbal = resolution_note.author_verbal(mention=False)
blocks = step.get_resolution_note_blocks(resolution_note)
expected_blocks = [
{
"type": "section",
"text": {
"type": "mrkdwn",
# text is truncated, ellipsis added
"text": resolution_note.text[: BLOCK_SECTION_TEXT_MAX_SIZE - 1] + "",
},
},
{
"type": "context",
"elements": [
{
"type": "mrkdwn",
"text": f"{author_verbal} resolution note from {resolution_note.get_source_display()}.",
}
],
},
]
assert blocks == expected_blocks
@pytest.mark.django_db
def test_get_resolution_notes_blocks_latest_limit(
make_organization_and_user_with_slack_identities,

View file

@ -4,6 +4,12 @@ import pytest
from apps.schedules.models.on_call_schedule import OnCallScheduleQuerySet, OnCallScheduleWeb
from apps.slack.client import SlackClient
from apps.slack.errors import (
SlackAPIError,
SlackAPIInvalidUsersError,
SlackAPITokenError,
SlackAPIUsergroupNotFoundError,
)
from apps.slack.models import SlackUserGroup
from apps.slack.tasks import (
populate_slack_usergroups_for_team,
@ -20,14 +26,49 @@ def test_update_members(make_organization_with_slack_team_identity, make_slack_u
user_group = make_slack_user_group(slack_team_identity)
slack_ids = ["slack_id_1", "slack_id_2"]
with patch.object(SlackClient, "api_call") as mock:
with patch("apps.slack.client.SlackClient.usergroups_users_update") as mock_update:
user_group.update_members(slack_ids)
mock.assert_called()
mock_update.assert_called_once()
assert user_group.members == slack_ids
@pytest.mark.django_db
@pytest.mark.parametrize("exception", [SlackAPITokenError, SlackAPIUsergroupNotFoundError, SlackAPIInvalidUsersError])
def test_slack_user_group_update_errors(
make_organization_with_slack_team_identity,
make_slack_user_group,
exception,
):
organization, slack_team_identity = make_organization_with_slack_team_identity()
user_group = make_slack_user_group(slack_team_identity=slack_team_identity)
slack_ids = ["slack_id_1", "slack_id_2"]
with patch("apps.slack.client.SlackClient.usergroups_users_update", side_effect=exception("Error")) as mock_update:
user_group.update_members(slack_ids)
mock_update.assert_called_once()
assert user_group.members is None
@pytest.mark.django_db
def test_slack_user_group_update_errors_raise(
make_organization_with_slack_team_identity,
make_slack_user_group,
):
organization, slack_team_identity = make_organization_with_slack_team_identity()
user_group = make_slack_user_group(slack_team_identity=slack_team_identity)
slack_ids = ["slack_id_1", "slack_id_2"]
with patch(
"apps.slack.client.SlackClient.usergroups_users_update", side_effect=SlackAPIError("Error")
) as mock_update:
with pytest.raises(SlackAPIError):
user_group.update_members(slack_ids)
mock_update.assert_called_once()
@pytest.mark.django_db
def test_oncall_slack_user_identities(
make_organization_with_slack_team_identity,

View file

@ -95,8 +95,11 @@ class TeamManager(models.Manager["Team"]):
for integration in direct_paging_integrations_to_create:
metrics_add_integration_to_cache(integration)
# delete excess teams
# delete excess teams and their direct paging integrations
team_ids_to_delete = existing_team_ids - grafana_teams.keys()
organization.alert_receive_channels.filter(
team__team_id__in=team_ids_to_delete, integration=AlertReceiveChannel.INTEGRATION_DIRECT_PAGING
).delete()
organization.teams.filter(team_id__in=team_ids_to_delete).delete()
# collect teams diffs to update metrics cache

View file

@ -101,9 +101,13 @@ def test_sync_users_for_organization_role_none(make_organization, make_user_for_
@pytest.mark.django_db
def test_sync_teams_for_organization(make_organization, make_team):
def test_sync_teams_for_organization(make_organization, make_team, make_alert_receive_channel):
organization = make_organization()
teams = tuple(make_team(organization, team_id=team_id) for team_id in (1, 2))
direct_paging_integrations = tuple(
make_alert_receive_channel(organization, integration=AlertReceiveChannel.INTEGRATION_DIRECT_PAGING, team=team)
for team in teams
)
api_teams = tuple(
{"id": team_id, "name": "Test", "email": "test@test.test", "avatarUrl": "test.test/test"} for team_id in (2, 3)
@ -113,14 +117,16 @@ def test_sync_teams_for_organization(make_organization, make_team):
assert organization.teams.count() == 2
# check that excess teams are deleted
# check that excess teams and direct paging integrations are deleted
assert not organization.teams.filter(pk=teams[0].pk).exists()
assert not organization.alert_receive_channels.filter(pk=direct_paging_integrations[0].pk).exists()
# check that existing teams are updated
updated_team = organization.teams.filter(pk=teams[1].pk).first()
assert updated_team is not None
assert updated_team.name == api_teams[0]["name"]
assert updated_team.email == api_teams[0]["email"]
assert organization.alert_receive_channels.filter(pk=direct_paging_integrations[1].pk).exists()
# check that missing teams are created
created_team = organization.teams.filter(team_id=api_teams[1]["id"]).first()

View file

@ -57,5 +57,5 @@ urllib3==1.26.18
prometheus_client==0.16.0
lxml==4.9.2
babel==2.12.1
drf-spectacular==0.26.2
drf-spectacular==0.26.5
grpcio==1.57.0

View file

@ -220,9 +220,7 @@ if REDIS_USE_SSL:
CACHES = {
"default": {
"BACKEND": "redis_cache.RedisCache",
"LOCATION": [
REDIS_URI,
],
"LOCATION": REDIS_URI,
"OPTIONS": {
"DB": REDIS_DATABASE,
"PARSER_CLASS": "redis.connection.HiredisParser",

View file

@ -11,9 +11,9 @@ interface TooltipBadgeProps {
className?: string;
borderType: Partial<TextType>;
text?: number | string;
tooltipTitle: string;
tooltipContent: React.ReactNode;
tooltipTitle?: string;
icon?: IconName;
customIcon?: React.ReactNode;
addPadding?: boolean;

View file

@ -24,25 +24,6 @@
margin-top: 12px;
}
/*
.separator::before {
display: block;
content: '';
flex-grow: 1;
border-bottom: var(--border-medium);
height: 0;
margin-right: 5px;
}
.separator::after {
display: block;
content: '';
flex-grow: 1;
border-bottom: var(--border-medium);
height: 0;
margin-left: 5px;
} */
.groups {
width: 100%;
padding: 0;
@ -51,8 +32,8 @@
display: flex;
flex-direction: column;
gap: 1px;
max-height: calc(100vh - 600px);
overflow: scroll;
max-height: 300px;
overflow: auto;
}
.user {

View file

@ -0,0 +1,13 @@
.labels-list {
margin: 0;
list-style-type: none;
> li {
margin: 10px 0;
}
}
.buttons {
width: 100%;
margin-top: 30px;
}

View file

@ -0,0 +1,105 @@
import React, { useState } from 'react';
import { Button, Drawer, HorizontalGroup, Icon, InlineSwitch, Input, Label, Tooltip, VerticalGroup } from '@grafana/ui';
import cn from 'classnames/bind';
import { observer } from 'mobx-react';
import Text from 'components/Text/Text';
import { AlertReceiveChannel } from 'models/alert_receive_channel/alert_receive_channel.types';
import { LabelKey } from 'models/label/label.types';
import { useStore } from 'state/useStore';
import styles from './IntegrationLabelsForm.module.css';
const cx = cn.bind(styles);
interface IntegrationLabelsFormProps {
id: AlertReceiveChannel['id'];
onSubmit: () => void;
onHide: () => void;
onOpenIntegraionSettings: (id: AlertReceiveChannel['id']) => void;
}
const IntegrationLabelsForm = observer((props: IntegrationLabelsFormProps) => {
const { id, onHide, onSubmit, onOpenIntegraionSettings } = props;
const store = useStore();
const { alertReceiveChannelStore } = store;
const alertReceiveChannel = alertReceiveChannelStore.items[id];
const [alertGroupLabels, setAlertGroupLabels] = useState(alertReceiveChannel.alert_group_labels);
const handleSave = () => {
alertReceiveChannelStore.saveAlertReceiveChannel(id, { alert_group_labels: alertGroupLabels });
onSubmit();
onHide();
};
const handleOpenIntegrationSettings = () => {
onHide();
onOpenIntegraionSettings(id);
};
const getInheritanceChangeHandler = (keyId: LabelKey['id']) => {
return (event: React.ChangeEvent<HTMLInputElement>) => {
setAlertGroupLabels((alertGroupLabels) => ({
...alertGroupLabels,
inheritable: { ...alertGroupLabels.inheritable, [keyId]: event.target.checked },
}));
};
};
return (
<Drawer scrollableContent title="Alert group labels" onClose={onHide} closeOnMaskClick={false} width="640px">
<VerticalGroup>
<HorizontalGroup spacing="xs" align="flex-start">
<Label>Inherited labels</Label>
<Tooltip content="Labels inherited from integration">
<Icon name="info-circle" className={cx('extra-fields__icon')} />
</Tooltip>
</HorizontalGroup>
<ul className={cx('labels-list')}>
{alertReceiveChannel.labels.length ? (
alertReceiveChannel.labels.map((label) => (
<li key={label.key.id}>
<HorizontalGroup spacing="xs">
<Input width={38} value={label.key.name} disabled />
<Input width={31} value={label.value.name} disabled />
<InlineSwitch
value={alertGroupLabels.inheritable[label.key.id]}
transparent
onChange={getInheritanceChangeHandler(label.key.id)}
/>
</HorizontalGroup>
</li>
))
) : (
<VerticalGroup>
<Text type="secondary">There are no labels to inherit yet</Text>
<Text type="link" onClick={handleOpenIntegrationSettings} clickable>
Add labels to the integration
</Text>
</VerticalGroup>
)}
</ul>
<div className={cx('buttons')}>
<HorizontalGroup justify="flex-end">
<Button variant="secondary" onClick={onHide}>
Close
</Button>
<Button variant="primary" onClick={handleSave}>
Save
</Button>
</HorizontalGroup>
</div>
</VerticalGroup>
</Drawer>
);
});
export default IntegrationLabelsForm;

View file

@ -38,7 +38,7 @@ import styles from './RemoteFilters.module.css';
const cx = cn.bind(styles);
interface RemoteFiltersProps extends WithStoreProps {
onChange: (filters: { [key: string]: any }, isOnMount: boolean, invalidateFn: () => boolean) => void;
onChange: (filters: Record<string, any>, isOnMount: boolean, invalidateFn: () => boolean) => void;
query: KeyValue;
page: PAGE;
defaultFilters?: FiltersValues;
@ -49,7 +49,7 @@ interface RemoteFiltersProps extends WithStoreProps {
interface RemoteFiltersState {
filterOptions?: FilterOption[];
filters: FilterOption[];
values: { [key: string]: any };
values: Record<string, any>;
hadInteraction: boolean;
lastRequestId: string;
}

View file

@ -1,4 +1,4 @@
.body {
.container {
margin: 15px -15px;
padding: 15px 0;
border-top: var(--border-medium);

View file

@ -15,7 +15,7 @@ import {
import cn from 'classnames/bind';
import dayjs from 'dayjs';
import { observer } from 'mobx-react';
import Draggable from 'react-draggable';
import Draggable, { DraggableData, DraggableEvent } from 'react-draggable';
import Block from 'components/GBlock/Block';
import Modal from 'components/Modal/Modal';
@ -106,6 +106,10 @@ const RotationForm = observer((props: RotationFormProps) => {
const [errors, setErrors] = useState<{ [key: string]: string[] }>({});
const [bounds, setDraggableBounds] = useState<{ left: number; right: number; top: number; bottom: number }>(
undefined
);
const [rotationName, setRotationName] = useState<string>(`[L${layerPriority}] Rotation`);
const [isOpen, setIsOpen] = useState<boolean>(false);
const [offsetTop, setOffsetTop] = useState<number>(0);
@ -283,22 +287,19 @@ const RotationForm = observer((props: RotationFormProps) => {
[showActiveOnSelectedPartOfDay, showActiveOnSelectedDays, repeatEveryValue]
);
const handleRepeatEveryValueChange = useCallback(
(option) => {
const value = Math.floor(Number(option.value));
if (isNaN(value) || value < 1) {
return;
}
const handleRepeatEveryValueChange = (option) => {
const value = Math.floor(Number(option.value));
if (isNaN(value) || value < 1) {
return;
}
setShiftPeriodDefaultValue(undefined);
setRepeatEveryValue(value);
setShiftPeriodDefaultValue(undefined);
setRepeatEveryValue(value);
if (!showActiveOnSelectedPartOfDay) {
setShiftEnd(shiftStart.add(value, repeatEveryPeriodToUnitName[repeatEveryPeriod]));
}
},
[showActiveOnSelectedPartOfDay, repeatEveryPeriod]
);
if (!showActiveOnSelectedPartOfDay) {
setShiftEnd(rotationStart.add(value, repeatEveryPeriodToUnitName[repeatEveryPeriod]));
}
};
const handleRotationStartChange = useCallback(
(value) => {
@ -426,7 +427,8 @@ const RotationForm = observer((props: RotationFormProps) => {
handle=".drag-handler"
defaultClassName={cx('draggable')}
positionOffset={{ x: 0, y: offsetTop }}
bounds={{ top: 0 }}
bounds={bounds || 'body'}
onStart={onDraggableInit}
>
<div {...props}>{children}</div>
</Draggable>
@ -460,7 +462,7 @@ const RotationForm = observer((props: RotationFormProps) => {
</HorizontalGroup>
</HorizontalGroup>
</div>
<div className={cx('body')}>
<div className={cx('container')}>
<div className={cx('content')}>
<VerticalGroup spacing="none">
{hasUpdatedShift && (
@ -687,6 +689,20 @@ const RotationForm = observer((props: RotationFormProps) => {
)}
</>
);
function onDraggableInit(_e: DraggableEvent, data: DraggableData) {
if (!data) {
return;
}
const scrollbarView = document.querySelector('.scrollbar-view')?.getBoundingClientRect();
const x = data.node.offsetLeft;
const top = -data.node.offsetTop + (scrollbarView?.top || 100);
const bottom = window.innerHeight - (data.node.offsetTop + data.node.offsetHeight);
setDraggableBounds({ left: -x, right: x, top: top - offsetTop, bottom: bottom - offsetTop });
}
});
interface ShiftPeriodProps {

View file

@ -301,7 +301,6 @@ const TemplatesAlertGroupsList = (props: TemplatesAlertGroupsListProps) => {
<TooltipBadge
borderType="primary"
text="Payload"
tooltipTitle=""
tooltipContent=""
className={cx('alert-groups-last-payload-badge')}
/>

View file

@ -49,6 +49,7 @@ export interface AlertReceiveChannel {
allow_delete: boolean;
deleted?: boolean;
labels: LabelKeyValue[];
alert_group_labels: { inheritable: Record<LabelKeyValue['key']['id'], boolean> };
}
export interface AlertReceiveChannelChoice {

View file

@ -30,9 +30,6 @@ export class AlertGroupStore extends BaseStore {
@observable
alertGroupsLoading = false;
@observable
needToParseFilters = false;
@observable
incidentFilters: any;

View file

@ -1,6 +1,7 @@
import { AlertReceiveChannel } from 'models/alert_receive_channel/alert_receive_channel.types';
import { Channel } from 'models/channel';
import { GrafanaTeam } from 'models/grafana_team/grafana_team.types';
import { LabelKeyValue } from 'models/label/label.types';
import { PagedUser, User } from 'models/user/user.types';
export enum IncidentStatus {
@ -82,6 +83,7 @@ export interface Alert {
paged_users: PagedUser[];
team: GrafanaTeam['id'];
grafana_incident_id: string | null;
labels: LabelKeyValue[];
// set by client
loading?: boolean;

View file

@ -24,7 +24,7 @@ export class FiltersStore extends BaseStore {
private _globalValues: FiltersValues = {};
@observable
public needToParseFilters = false;
needToParseFilters = false;
constructor(rootStore: RootStore) {
super(rootStore);
@ -35,6 +35,11 @@ export class FiltersStore extends BaseStore {
}
}
@action
setNeedToParseFilters(value: boolean) {
this.needToParseFilters = value;
}
set globalValues(value: any) {
this._globalValues = value;

View file

@ -1,5 +1,6 @@
import React, { useState, SyntheticEvent } from 'react';
import { LabelTag } from '@grafana/labels';
import {
Button,
HorizontalGroup,
@ -34,6 +35,7 @@ import { PluginBridge, SupportedPlugin } from 'components/PluginBridge/PluginBri
import PluginLink from 'components/PluginLink/PluginLink';
import SourceCode from 'components/SourceCode/SourceCode';
import Text from 'components/Text/Text';
import TooltipBadge from 'components/TooltipBadge/TooltipBadge';
import AddResponders from 'containers/AddResponders/AddResponders';
import { prepareForUpdate } from 'containers/AddResponders/AddResponders.helpers';
import { UserResponder } from 'containers/AddResponders/AddResponders.types';
@ -50,6 +52,7 @@ import {
import { ResolutionNoteSourceTypesToDisplayName } from 'models/resolution_note/resolution_note.types';
import { User } from 'models/user/user.types';
import { IncidentDropdown } from 'pages/incidents/parts/IncidentDropdown';
import { AppFeature } from 'state/features';
import { PageProps, WithStoreProps } from 'state/types';
import { useStore } from 'state/useStore';
import { withMobXProviderContext } from 'state/withStore';
@ -339,6 +342,22 @@ class IncidentPage extends React.Component<IncidentPageProps, IncidentPageState>
/>
</div>
{Boolean(store.hasFeature(AppFeature.Labels) && incident.labels.length) && (
<TooltipBadge
borderType="secondary"
icon="tag-alt"
addPadding
text={incident.labels.length}
tooltipContent={
<VerticalGroup spacing="sm">
{incident.labels.map((label) => (
<LabelTag label={label.key.name} value={label.value.name} key={label.key.id} />
))}
</VerticalGroup>
}
/>
)}
{integration && (
<HorizontalGroup>
<PluginLink

View file

@ -1,5 +1,6 @@
import React, { SyntheticEvent } from 'react';
import { LabelTag } from '@grafana/labels';
import { Button, HorizontalGroup, Icon, VerticalGroup } from '@grafana/ui';
import cn from 'classnames/bind';
import { observer } from 'mobx-react';
@ -15,6 +16,7 @@ import ManualAlertGroup from 'components/ManualAlertGroup/ManualAlertGroup';
import PluginLink from 'components/PluginLink/PluginLink';
import Text from 'components/Text/Text';
import TextEllipsisTooltip from 'components/TextEllipsisTooltip/TextEllipsisTooltip';
import TooltipBadge from 'components/TooltipBadge/TooltipBadge';
import Tutorial from 'components/Tutorial/Tutorial';
import { TutorialStep } from 'components/Tutorial/Tutorial.types';
import { IncidentsFiltersType } from 'containers/IncidentsFilters/IncidentFilters.types';
@ -22,7 +24,9 @@ import RemoteFilters from 'containers/RemoteFilters/RemoteFilters';
import TeamName from 'containers/TeamName/TeamName';
import { WithPermissionControlTooltip } from 'containers/WithPermissionControl/WithPermissionControlTooltip';
import { Alert, Alert as AlertType, AlertAction, IncidentStatus } from 'models/alertgroup/alertgroup.types';
import { LabelKeyValue } from 'models/label/label.types';
import { renderRelatedUsers } from 'pages/incident/Incident.helpers';
import { AppFeature } from 'state/features';
import { PageProps, WithStoreProps } from 'state/types';
import { withMobXProviderContext } from 'state/withStore';
import LocationHelper from 'utils/LocationHelper';
@ -44,7 +48,7 @@ interface IncidentsPageProps extends WithStoreProps, PageProps, RouteComponentPr
interface IncidentsPageState {
selectedIncidentIds: Array<Alert['pk']>;
affectedRows: { [key: string]: boolean };
filters?: IncidentsFiltersType;
filters?: Record<string, any>;
pagination: Pagination;
showAddAlertGroupForm: boolean;
}
@ -583,6 +587,37 @@ class Incidents extends React.Component<IncidentsPageProps, IncidentsPageState>
);
}
renderLabels(item: AlertType) {
if (!item.labels.length) {
return null;
}
return (
<TooltipBadge
borderType="secondary"
icon="tag-alt"
addPadding
text={item.labels?.length}
tooltipContent={
<VerticalGroup spacing="sm">
{item.labels.map((label) => (
<HorizontalGroup spacing="sm" key={label.key.id}>
<LabelTag label={label.key.name} value={label.value.name} key={label.key.id} />
<Button
size="sm"
icon="filter"
tooltip="Apply filter"
variant="secondary"
onClick={this.getApplyLabelFilterClickHandler(label)}
/>
</HorizontalGroup>
))}
</VerticalGroup>
}
/>
);
}
renderTeam(record: AlertType, teams: any) {
return (
<TextEllipsisTooltip placement="top" content={teams[record.team]?.name}>
@ -591,6 +626,29 @@ class Incidents extends React.Component<IncidentsPageProps, IncidentsPageState>
);
}
getApplyLabelFilterClickHandler = (label: LabelKeyValue) => {
const {
store: { filtersStore },
} = this.props;
return () => {
const {
filters: { label: oldLabelFilter = [] },
} = this.state;
const labelToAddString = `${label.key.id}:${label.value.id}`;
if (oldLabelFilter.some((label) => label === labelToAddString)) {
return;
}
const newLabelFilter = [...oldLabelFilter, labelToAddString];
LocationHelper.update({ label: newLabelFilter }, 'partial');
filtersStore.setNeedToParseFilters(true);
};
};
shouldShowPagination() {
const { alertGroupStore } = this.props.store;
@ -610,7 +668,7 @@ class Incidents extends React.Component<IncidentsPageProps, IncidentsPageState>
getTableColumns(): Array<{ width: string; title: string; key: string; render }> {
const { store } = this.props;
return [
const columns = [
{
width: '140px',
title: 'Status',
@ -660,6 +718,18 @@ class Incidents extends React.Component<IncidentsPageProps, IncidentsPageState>
render: renderRelatedUsers,
},
];
if (store.hasFeature(AppFeature.Labels)) {
columns.splice(-2, 0, {
width: '5%',
title: 'Labels',
key: 'labels',
render: (item: AlertType) => this.renderLabels(item),
});
columns.find((column) => column.key === 'title').width = '30%';
}
return columns;
}
getOnActionButtonClick = (incidentId: string, action: AlertAction): ((e: SyntheticEvent) => Promise<void>) => {

View file

@ -44,6 +44,7 @@ import ExpandedIntegrationRouteDisplay from 'containers/IntegrationContainers/Ex
import IntegrationHeartbeatForm from 'containers/IntegrationContainers/IntegrationHeartbeatForm/IntegrationHeartbeatForm';
import IntegrationTemplateList from 'containers/IntegrationContainers/IntegrationTemplatesList';
import IntegrationForm from 'containers/IntegrationForm/IntegrationForm';
import IntegrationLabelsForm from 'containers/IntegrationLabelsForm/IntegrationLabelsForm';
import IntegrationTemplate from 'containers/IntegrationTemplate/IntegrationTemplate';
import MaintenanceForm from 'containers/MaintenanceForm/MaintenanceForm';
import TeamName from 'containers/TeamName/TeamName';
@ -731,7 +732,8 @@ const IntegrationActions: React.FC<IntegrationActionsProps> = ({
isLegacyIntegration,
changeIsTemplateSettingsOpen,
}) => {
const { alertReceiveChannelStore } = useStore();
const store = useStore();
const { alertReceiveChannelStore } = store;
const history = useHistory();
@ -747,6 +749,7 @@ const IntegrationActions: React.FC<IntegrationActionsProps> = ({
}>(undefined);
const [isIntegrationSettingsOpen, setIsIntegrationSettingsOpen] = useState(false);
const [labelsFormOpen, setLabelsFormOpen] = useState(false);
const [isHeartbeatFormOpen, setIsHeartbeatFormOpen] = useState(false);
const [isDemoModalOpen, setIsDemoModalOpen] = useState(false);
const [maintenanceData, setMaintenanceData] = useState<{
@ -789,6 +792,19 @@ const IntegrationActions: React.FC<IntegrationActionsProps> = ({
/>
)}
{labelsFormOpen && (
<IntegrationLabelsForm
onHide={() => {
setLabelsFormOpen(false);
}}
onSubmit={() => alertReceiveChannelStore.updateItem(alertReceiveChannel['id'])}
id={alertReceiveChannel['id']}
onOpenIntegraionSettings={() => {
setIsIntegrationSettingsOpen(true);
}}
/>
)}
{isHeartbeatFormOpen && (
<IntegrationHeartbeatForm
alertReceveChannelId={alertReceiveChannel['id']}
@ -826,6 +842,14 @@ const IntegrationActions: React.FC<IntegrationActionsProps> = ({
<Text type="primary">Integration Settings</Text>
</div>
{store.hasFeature(AppFeature.Labels) && (
<WithPermissionControlTooltip userAction={UserActions.IntegrationsWrite}>
<div className={cx('integration__actionItem')} onClick={() => openLabelsForm()}>
<Text type="primary">Alert group labels</Text>
</div>
</WithPermissionControlTooltip>
)}
{showHeartbeatSettings() && (
<WithPermissionControlTooltip key="ok" userAction={UserActions.IntegrationsWrite}>
<div
@ -1015,6 +1039,10 @@ const IntegrationActions: React.FC<IntegrationActionsProps> = ({
setIsIntegrationSettingsOpen(true);
}
function openLabelsForm() {
setLabelsFormOpen(true);
}
function openStartMaintenance() {
setMaintenanceData({ disabled: true, alert_receive_channel_id: alertReceiveChannel.id });
}
@ -1061,20 +1089,17 @@ const IntegrationHeader: React.FC<IntegrationHeaderProps> = ({
</PluginLink>
)}
{renderLabels && (
{Boolean(renderLabels && alertReceiveChannel.labels.length) && (
<TooltipBadge
tooltipTitle=""
borderType="secondary"
icon="tag-alt"
addPadding
text={alertReceiveChannel.labels.length}
tooltipContent={
<VerticalGroup spacing="sm">
{alertReceiveChannel.labels.length
? alertReceiveChannel.labels.map((label) => (
<LabelTag label={label.key.name} value={label.value.name} key={label.key.id} />
))
: 'No labels attached'}
{alertReceiveChannel.labels.map((label) => (
<LabelTag label={label.key.name} value={label.value.name} key={label.key.id} />
))}
</VerticalGroup>
}
/>

View file

@ -34,6 +34,7 @@ import TextEllipsisTooltip from 'components/TextEllipsisTooltip/TextEllipsisTool
import TooltipBadge from 'components/TooltipBadge/TooltipBadge';
import { WithContextMenu } from 'components/WithContextMenu/WithContextMenu';
import IntegrationForm from 'containers/IntegrationForm/IntegrationForm';
import IntegrationLabelsForm from 'containers/IntegrationLabelsForm/IntegrationLabelsForm';
import RemoteFilters from 'containers/RemoteFilters/RemoteFilters';
import TeamName from 'containers/TeamName/TeamName';
import { WithPermissionControlTooltip } from 'containers/WithPermissionControl/WithPermissionControlTooltip';
@ -80,6 +81,7 @@ const FILTERS_DEBOUNCE_MS = 500;
interface IntegrationsState extends PageBaseState {
integrationsFilters: SupportedIntegrationFilters;
alertReceiveChannelId?: AlertReceiveChannel['id'] | 'new';
alertReceiveChannelIdToShowLabels?: AlertReceiveChannel['id'];
confirmationModal: {
isOpen: boolean;
title: any;
@ -192,7 +194,13 @@ class Integrations extends React.Component<IntegrationsProps, IntegrationsState>
render() {
const { store, query } = this.props;
const { alertReceiveChannelId, confirmationModal, activeTab, integrationsFilters } = this.state;
const {
alertReceiveChannelId,
alertReceiveChannelIdToShowLabels,
confirmationModal,
activeTab,
integrationsFilters,
} = this.state;
const { alertReceiveChannelStore } = store;
const { count, results, page_size } = alertReceiveChannelStore.getPaginatedSearchResult();
@ -289,6 +297,19 @@ class Integrations extends React.Component<IntegrationsProps, IntegrationsState>
/>
)}
{alertReceiveChannelIdToShowLabels && (
<IntegrationLabelsForm
onHide={() => {
this.setState({ alertReceiveChannelIdToShowLabels: undefined });
}}
onSubmit={this.update}
id={alertReceiveChannelIdToShowLabels}
onOpenIntegraionSettings={(id: AlertReceiveChannel['id']) => {
this.setState({ alertReceiveChannelId: id });
}}
/>
)}
{confirmationModal && (
<ConfirmModal
isOpen={confirmationModal.isOpen}
@ -369,7 +390,6 @@ class Integrations extends React.Component<IntegrationsProps, IntegrationsState>
borderType="primary"
placement="top"
text={alertReceiveChannelCounter?.alerts_count + '/' + alertReceiveChannelCounter?.alert_groups_count}
tooltipTitle=""
tooltipContent={
alertReceiveChannelCounter?.alerts_count +
' alert' +
@ -452,29 +472,30 @@ class Integrations extends React.Component<IntegrationsProps, IntegrationsState>
}
renderLabels(item: AlertReceiveChannel) {
if (!item.labels.length) {
return null;
}
return (
<TooltipBadge
tooltipTitle=""
borderType="secondary"
icon="tag-alt"
addPadding
text={item.labels?.length}
tooltipContent={
<VerticalGroup spacing="sm">
{item.labels?.length
? item.labels.map((label) => (
<HorizontalGroup spacing="sm" key={label.key.id}>
<LabelTag label={label.key.name} value={label.value.name} key={label.key.id} />
<Button
size="sm"
icon="filter"
tooltip="Apply filter"
variant="secondary"
onClick={this.getApplyLabelFilterClickHandler(label)}
/>
</HorizontalGroup>
))
: 'No labels attached'}
{item.labels.map((label) => (
<HorizontalGroup spacing="sm" key={label.key.id}>
<LabelTag label={label.key.name} value={label.value.name} key={label.key.id} />
<Button
size="sm"
icon="filter"
tooltip="Apply filter"
variant="secondary"
onClick={this.getApplyLabelFilterClickHandler(label)}
/>
</HorizontalGroup>
))}
</VerticalGroup>
}
/>
@ -490,6 +511,8 @@ class Integrations extends React.Component<IntegrationsProps, IntegrationsState>
}
renderButtons = (item: AlertReceiveChannel) => {
const { store } = this.props;
return (
<WithContextMenu
renderMenuItems={() => (
@ -500,6 +523,14 @@ class Integrations extends React.Component<IntegrationsProps, IntegrationsState>
</div>
</WithPermissionControlTooltip>
{store.hasFeature(AppFeature.Labels) && (
<WithPermissionControlTooltip key="edit" userAction={UserActions.IntegrationsWrite}>
<div className={cx('integrations-actionItem')} onClick={() => this.onLabelsEditClick(item.id)}>
<Text type="primary">Alert group labels</Text>
</div>
</WithPermissionControlTooltip>
)}
<CopyToClipboard text={item.id} onCopy={() => openNotification('Integration ID has been copied')}>
<div className={cx('integrations-actionItem')}>
<HorizontalGroup spacing={'xs'}>
@ -631,6 +662,10 @@ class Integrations extends React.Component<IntegrationsProps, IntegrationsState>
this.setState({ alertReceiveChannelId: id });
};
onLabelsEditClick = (id: AlertReceiveChannel['id']) => {
this.setState({ alertReceiveChannelIdToShowLabels: id });
};
handleDeleteAlertReceiveChannel = (alertReceiveChannelId: AlertReceiveChannel['id']) => {
const { store } = this.props;
@ -666,7 +701,7 @@ class Integrations extends React.Component<IntegrationsProps, IntegrationsState>
LocationHelper.update({ label: newLabelFilter }, 'partial');
filtersStore.needToParseFilters = true;
filtersStore.setNeedToParseFilters(true);
};
};

View file

@ -192,6 +192,19 @@ externalRedis:
passwordKey: ""
```
### Running split ingestion and API services
You can run a detached service for handling integrations by setting up the following variables:
```yaml
detached_integrations:
enabled: true
detached_integrations_service:
enabled: true
```
This will run an integrations-only service listening by default in port 30003.
### Set up Slack and Telegram
You can set up Slack connection via following variables: