commit
fd132c2eb3
44 changed files with 831 additions and 170 deletions
15
CHANGELOG.md
15
CHANGELOG.md
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
aiohttp==3.8.5
|
||||
aiohttp==3.8.6
|
||||
Faker==16.4.0
|
||||
tqdm==4.64.1
|
||||
|
|
|
|||
|
|
@ -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"))
|
||||
|
|
|
|||
|
|
@ -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']"
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
18
engine/apps/api/tests/test_openapi_schema.py
Normal file
18
engine/apps/api/tests/test_openapi_schema.py
Normal 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"]
|
||||
|
|
@ -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]
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
),
|
||||
]
|
||||
|
|
@ -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"]
|
||||
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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"}
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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 = {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,13 @@
|
|||
.labels-list {
|
||||
margin: 0;
|
||||
list-style-type: none;
|
||||
|
||||
> li {
|
||||
margin: 10px 0;
|
||||
}
|
||||
}
|
||||
|
||||
.buttons {
|
||||
width: 100%;
|
||||
margin-top: 30px;
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
.body {
|
||||
.container {
|
||||
margin: 15px -15px;
|
||||
padding: 15px 0;
|
||||
border-top: var(--border-medium);
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -301,7 +301,6 @@ const TemplatesAlertGroupsList = (props: TemplatesAlertGroupsListProps) => {
|
|||
<TooltipBadge
|
||||
borderType="primary"
|
||||
text="Payload"
|
||||
tooltipTitle=""
|
||||
tooltipContent=""
|
||||
className={cx('alert-groups-last-payload-badge')}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -30,9 +30,6 @@ export class AlertGroupStore extends BaseStore {
|
|||
@observable
|
||||
alertGroupsLoading = false;
|
||||
|
||||
@observable
|
||||
needToParseFilters = false;
|
||||
|
||||
@observable
|
||||
incidentFilters: any;
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>) => {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
};
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue