Alert group payload labels (#3385)
# What this PR does Adds an ability to extract labels from alert group payload. See [demo](https://www.loom.com/share/cf2b746eea974547b76f44298e32a54f?sid=67ed1e58-40ed-4136-a201-6482fb7773d3). ## Which issue(s) this PR fixes https://github.com/grafana/oncall-private/issues/2304 ## Checklist - [x] Unit, integration, and e2e (if applicable) tests updated - [x] Documentation added (or `pr:no public docs` PR label added if not required) - [x] `CHANGELOG.md` updated (or `pr:no changelog` PR label added if not required) --------- Co-authored-by: Maxim Mordasov <maxim.mordasov@grafana.com> Co-authored-by: Rares Mardare <rares.mardare@grafana.com>
This commit is contained in:
parent
2acd85a3e6
commit
5fac6aeac5
25 changed files with 935 additions and 162 deletions
|
|
@ -0,0 +1,23 @@
|
|||
# Generated by Django 4.2.7 on 2023-11-22 12:14
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('alerts', '0039_remove_alertreceivechannel_unique_integration_name'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='alertreceivechannel',
|
||||
name='alert_group_labels_custom',
|
||||
field=models.JSONField(default=list, null=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='alertreceivechannel',
|
||||
name='alert_group_labels_template',
|
||||
field=models.TextField(default=None, null=True),
|
||||
),
|
||||
]
|
||||
|
|
@ -12,7 +12,7 @@ from django.db.models import JSONField
|
|||
from apps.alerts import tasks
|
||||
from apps.alerts.constants import TASK_DELAY_SECONDS
|
||||
from apps.alerts.incident_appearance.templaters import TemplateLoader
|
||||
from apps.labels.utils import assign_labels
|
||||
from apps.labels.alert_group_labels import assign_labels
|
||||
from common.jinja_templater import apply_jinja_template
|
||||
from common.jinja_templater.apply_jinja_template import JinjaTemplateError, JinjaTemplateWarning
|
||||
from common.public_primary_keys import generate_public_primary_key, increase_public_primary_key_length
|
||||
|
|
@ -108,7 +108,7 @@ class Alert(models.Model):
|
|||
)
|
||||
|
||||
if group_created:
|
||||
assign_labels(group, alert_receive_channel)
|
||||
assign_labels(group, alert_receive_channel, raw_request_data)
|
||||
group.log_records.create(type=AlertGroupLogRecord.TYPE_REGISTERED)
|
||||
group.log_records.create(type=AlertGroupLogRecord.TYPE_ROUTE_ASSIGNED)
|
||||
|
||||
|
|
|
|||
|
|
@ -43,6 +43,7 @@ if typing.TYPE_CHECKING:
|
|||
ResolutionNoteSlackMessage,
|
||||
)
|
||||
from apps.base.models import UserNotificationPolicyLogRecord
|
||||
from apps.labels.models import AlertGroupAssociatedLabel
|
||||
from apps.slack.models import SlackMessage
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
|
@ -194,6 +195,7 @@ class AlertGroup(AlertGroupSlackRenderingMixin, EscalationSnapshotMixin, models.
|
|||
slack_log_message: typing.Optional["SlackMessage"]
|
||||
slack_messages: "RelatedManager['SlackMessage']"
|
||||
users: "RelatedManager['User']"
|
||||
labels: "RelatedManager['AlertGroupAssociatedLabel']"
|
||||
|
||||
objects: models.Manager["AlertGroup"] = AlertGroupQuerySet.as_manager()
|
||||
|
||||
|
|
|
|||
|
|
@ -42,6 +42,7 @@ if typing.TYPE_CHECKING:
|
|||
from django.db.models.manager import RelatedManager
|
||||
|
||||
from apps.alerts.models import AlertGroup, ChannelFilter
|
||||
from apps.labels.models import AlertReceiveChannelAssociatedLabel
|
||||
from apps.user_management.models import Organization, Team
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
|
@ -87,10 +88,6 @@ 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())
|
||||
|
|
@ -123,6 +120,7 @@ class AlertReceiveChannel(IntegrationOptionsMixin, MaintainableObject):
|
|||
channel_filters: "RelatedManager['ChannelFilter']"
|
||||
organization: "Organization"
|
||||
team: typing.Optional["Team"]
|
||||
labels: "RelatedManager['AlertReceiveChannelAssociatedLabel']"
|
||||
|
||||
objects = AlertReceiveChannelManager()
|
||||
objects_with_maintenance = AlertReceiveChannelManagerWithMaintenance()
|
||||
|
|
@ -206,6 +204,17 @@ class AlertReceiveChannel(IntegrationOptionsMixin, MaintainableObject):
|
|||
rate_limited_in_slack_at = models.DateTimeField(null=True, default=None)
|
||||
rate_limit_message_task_id = models.CharField(max_length=100, null=True, default=None)
|
||||
|
||||
AlertGroupCustomLabels = list[tuple[str, str | None, str | None]]
|
||||
alert_group_labels_custom: AlertGroupCustomLabels = models.JSONField(null=True, default=list)
|
||||
"""
|
||||
Stores "custom labels" for alert group labels. Custom labels can be either "plain" or "templated".
|
||||
For plain labels, the format is: [<LABEL_KEY_ID>, <LABEL_VALUE_ID>, None]
|
||||
For templated labels, the format is: [<LABEL_KEY_ID>, None, <JINJA2_TEMPLATE>]
|
||||
"""
|
||||
|
||||
alert_group_labels_template: str | None = models.TextField(null=True, default=None)
|
||||
"""Stores a Jinja2 template for "advanced label templating" for alert group labels."""
|
||||
|
||||
def __str__(self):
|
||||
short_name_with_emojis = emojize(self.short_name, language="alias")
|
||||
return f"{self.pk}: {short_name_with_emojis}"
|
||||
|
|
@ -635,21 +644,6 @@ 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(
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ from collections import OrderedDict
|
|||
from django.conf import settings
|
||||
from django.core.exceptions import ObjectDoesNotExist
|
||||
from django.core.exceptions import ValidationError as DjangoValidationError
|
||||
from django.db.models import Q
|
||||
from jinja2 import TemplateSyntaxError
|
||||
from rest_framework import serializers
|
||||
from rest_framework.exceptions import ValidationError
|
||||
|
|
@ -14,30 +15,188 @@ from apps.alerts.models import AlertReceiveChannel
|
|||
from apps.alerts.models.channel_filter import ChannelFilter
|
||||
from apps.base.messaging import get_messaging_backends
|
||||
from apps.integrations.legacy_prefix import has_legacy_prefix
|
||||
from apps.labels.models import LabelKeyCache, LabelValueCache
|
||||
from apps.user_management.models import Organization
|
||||
from common.api_helpers.custom_fields import TeamPrimaryKeyRelatedField
|
||||
from common.api_helpers.exceptions import BadRequest
|
||||
from common.api_helpers.mixins import APPEARANCE_TEMPLATE_NAMES, EagerLoadingMixin
|
||||
from common.api_helpers.utils import CurrentTeamDefault
|
||||
from common.jinja_templater import apply_jinja_template, jinja_template_env
|
||||
from common.jinja_templater.apply_jinja_template import JinjaTemplateWarning
|
||||
from common.jinja_templater import jinja_template_env
|
||||
|
||||
from .integration_heartbeat import IntegrationHeartBeatSerializer
|
||||
from .labels import LabelsSerializerMixin
|
||||
|
||||
|
||||
def valid_jinja_template_for_serializer_method_field(template):
|
||||
for _, val in template.items():
|
||||
try:
|
||||
apply_jinja_template(val, payload={})
|
||||
except JinjaTemplateWarning:
|
||||
# Suppress warnings, template may be valid with payload
|
||||
pass
|
||||
class AlertGroupCustomLabelKey(typing.TypedDict):
|
||||
id: str
|
||||
name: str
|
||||
|
||||
|
||||
class AlertGroupCustomLabelValue(typing.TypedDict):
|
||||
id: str | None # None for templated labels, label value ID for plain labels
|
||||
name: str # Jinja template for templated labels, label value name for plain labels
|
||||
|
||||
|
||||
class AlertGroupCustomLabel(typing.TypedDict):
|
||||
key: AlertGroupCustomLabelKey
|
||||
value: AlertGroupCustomLabelValue
|
||||
|
||||
|
||||
AlertGroupCustomLabels = list[AlertGroupCustomLabel]
|
||||
|
||||
|
||||
class IntegrationAlertGroupLabels(typing.TypedDict):
|
||||
inheritable: dict[str, bool]
|
||||
custom: AlertGroupCustomLabels
|
||||
template: str | None
|
||||
|
||||
|
||||
class CustomLabelSerializer(serializers.Serializer):
|
||||
"""This serializer is consistent with apps.api.serializers.labels.LabelSerializer, but allows null for value ID."""
|
||||
|
||||
class KeySerializer(serializers.Serializer):
|
||||
id = serializers.CharField()
|
||||
name = serializers.CharField()
|
||||
|
||||
class ValueSerializer(serializers.Serializer):
|
||||
# ID is null for templated labels. For such labels, the "name" value is a Jinja2 template.
|
||||
id = serializers.CharField(allow_null=True)
|
||||
name = serializers.CharField()
|
||||
|
||||
key = KeySerializer()
|
||||
value = ValueSerializer()
|
||||
|
||||
|
||||
class IntegrationAlertGroupLabelsSerializer(serializers.Serializer):
|
||||
"""Alert group labels configuration for the integration. See AlertReceiveChannel.alert_group_labels for details."""
|
||||
|
||||
inheritable = serializers.DictField(child=serializers.BooleanField())
|
||||
custom = CustomLabelSerializer(many=True)
|
||||
template = serializers.CharField(allow_null=True)
|
||||
|
||||
@staticmethod
|
||||
def pop_alert_group_labels(validated_data: dict) -> IntegrationAlertGroupLabels | None:
|
||||
"""Get alert group labels from validated data."""
|
||||
|
||||
# the "alert_group_labels" field is optional, so either all 3 fields are present or none
|
||||
if "inheritable" not in validated_data:
|
||||
return None
|
||||
|
||||
return {
|
||||
"inheritable": validated_data.pop("inheritable"),
|
||||
"custom": validated_data.pop("custom"),
|
||||
"template": validated_data.pop("template"),
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def update(
|
||||
cls, instance: AlertReceiveChannel, alert_group_labels: IntegrationAlertGroupLabels | None
|
||||
) -> AlertReceiveChannel:
|
||||
if alert_group_labels is None:
|
||||
return instance
|
||||
|
||||
# update inheritable labels
|
||||
inheritable_key_ids = [
|
||||
key_id for key_id, inheritable in alert_group_labels["inheritable"].items() if inheritable
|
||||
]
|
||||
instance.labels.filter(key_id__in=inheritable_key_ids).update(inheritable=True)
|
||||
instance.labels.filter(~Q(key_id__in=inheritable_key_ids)).update(inheritable=False)
|
||||
|
||||
# update DB cache for custom labels
|
||||
cls._create_custom_labels(instance.organization, alert_group_labels["custom"])
|
||||
# update custom labels
|
||||
instance.alert_group_labels_custom = cls._custom_labels_to_internal_value(alert_group_labels["custom"])
|
||||
|
||||
# update template
|
||||
instance.alert_group_labels_template = alert_group_labels["template"]
|
||||
|
||||
instance.save(update_fields=["alert_group_labels_custom", "alert_group_labels_template"])
|
||||
return instance
|
||||
|
||||
@staticmethod
|
||||
def _create_custom_labels(organization: Organization, labels: AlertGroupCustomLabels) -> None:
|
||||
"""Create LabelKeyCache and LabelValueCache objects for custom labels."""
|
||||
|
||||
label_keys = [
|
||||
LabelKeyCache(id=label["key"]["id"], name=label["key"]["name"], organization=organization)
|
||||
for label in labels
|
||||
]
|
||||
|
||||
label_values = [
|
||||
LabelValueCache(id=label["value"]["id"], name=label["value"]["name"], key_id=label["key"]["id"])
|
||||
for label in labels
|
||||
if label["value"]["id"] # don't create LabelValueCache objects for templated labels
|
||||
]
|
||||
|
||||
LabelKeyCache.objects.bulk_create(label_keys, ignore_conflicts=True, batch_size=5000)
|
||||
LabelValueCache.objects.bulk_create(label_values, ignore_conflicts=True, batch_size=5000)
|
||||
|
||||
@classmethod
|
||||
def to_representation(cls, instance: AlertReceiveChannel) -> IntegrationAlertGroupLabels:
|
||||
"""
|
||||
The API representation of alert group labels is very different from the underlying model.
|
||||
|
||||
"inheritable" is based on AlertReceiveChannelAssociatedLabel.inheritable, a property of another model.
|
||||
"custom" is based on AlertReceiveChannel.alert_group_labels_custom, a JSONField with a different schema.
|
||||
"template" is based on AlertReceiveChannel.alert_group_labels_template, this one is straightforward.
|
||||
"""
|
||||
|
||||
return {
|
||||
"inheritable": {label.key_id: label.inheritable for label in instance.labels.all()},
|
||||
"custom": cls._custom_labels_to_representation(instance.alert_group_labels_custom),
|
||||
"template": instance.alert_group_labels_template,
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _custom_labels_to_internal_value(
|
||||
custom_labels: AlertGroupCustomLabels,
|
||||
) -> AlertReceiveChannel.AlertGroupCustomLabels:
|
||||
"""Convert custom labels from API representation to the schema used by the JSONField on the model."""
|
||||
|
||||
return [
|
||||
[label["key"]["id"], label["value"]["id"], None if label["value"]["id"] else label["value"]["name"]]
|
||||
for label in custom_labels
|
||||
]
|
||||
|
||||
@staticmethod
|
||||
def _custom_labels_to_representation(
|
||||
custom_labels: AlertReceiveChannel.AlertGroupCustomLabels,
|
||||
) -> AlertGroupCustomLabels:
|
||||
"""
|
||||
Inverse of the _custom_labels_to_internal_value method above.
|
||||
Fetches label names from DB cache, so the API response schema is consistent with other label endpoints.
|
||||
"""
|
||||
|
||||
from apps.labels.models import LabelKeyCache, LabelValueCache
|
||||
|
||||
# get up-to-date label key names
|
||||
label_key_names = {
|
||||
k.id: k.name
|
||||
for k in LabelKeyCache.objects.filter(id__in=[label[0] for label in custom_labels]).only("id", "name")
|
||||
}
|
||||
|
||||
# get up-to-date label value names
|
||||
label_value_names = {
|
||||
v.id: v.name
|
||||
for v in LabelValueCache.objects.filter(id__in=[label[1] for label in custom_labels if label[1]]).only(
|
||||
"id", "name"
|
||||
)
|
||||
}
|
||||
|
||||
return [
|
||||
{
|
||||
"key": {
|
||||
"id": key_id,
|
||||
"name": label_key_names[key_id],
|
||||
},
|
||||
"value": {
|
||||
"id": value_id if value_id else None,
|
||||
"name": label_value_names[value_id] if value_id else typing.cast(str, template),
|
||||
},
|
||||
}
|
||||
for key_id, value_id, template in custom_labels
|
||||
if key_id in label_key_names and (value_id in label_value_names or not value_id)
|
||||
]
|
||||
|
||||
|
||||
class AlertReceiveChannelSerializer(
|
||||
|
|
@ -64,7 +223,7 @@ class AlertReceiveChannelSerializer(
|
|||
connected_escalations_chains_count = serializers.SerializerMethodField()
|
||||
inbound_email = serializers.CharField(required=False)
|
||||
is_legacy = serializers.SerializerMethodField()
|
||||
alert_group_labels = IntegrationAlertGroupLabelsSerializer(required=False)
|
||||
alert_group_labels = IntegrationAlertGroupLabelsSerializer(source="*", required=False)
|
||||
|
||||
# integration heartbeat is in PREFETCH_RELATED not by mistake.
|
||||
# With using of select_related ORM builds strange join
|
||||
|
|
@ -138,8 +297,10 @@ class AlertReceiveChannelSerializer(
|
|||
if _integration.slug == integration:
|
||||
is_able_to_autoresolve = _integration.is_able_to_autoresolve
|
||||
|
||||
# pop associated labels and alert group labels, so they are not passed to AlertReceiveChannel.create
|
||||
labels = validated_data.pop("labels", None)
|
||||
alert_group_labels = validated_data.pop("alert_group_labels", None)
|
||||
alert_group_labels = IntegrationAlertGroupLabelsSerializer.pop_alert_group_labels(validated_data)
|
||||
|
||||
try:
|
||||
instance = AlertReceiveChannel.create(
|
||||
**validated_data,
|
||||
|
|
@ -150,17 +311,22 @@ class AlertReceiveChannelSerializer(
|
|||
except AlertReceiveChannel.DuplicateDirectPagingError:
|
||||
raise BadRequest(detail=AlertReceiveChannel.DuplicateDirectPagingError.DETAIL)
|
||||
|
||||
# Create label associations first, then update inheritable labels
|
||||
# Create label associations first, then update alert group labels
|
||||
self.update_labels_association_if_needed(labels, instance, organization)
|
||||
if alert_group_labels:
|
||||
instance.alert_group_labels = alert_group_labels
|
||||
instance = IntegrationAlertGroupLabelsSerializer.update(instance, alert_group_labels)
|
||||
|
||||
return instance
|
||||
|
||||
def update(self, instance, validated_data):
|
||||
# update associated labels
|
||||
labels = validated_data.pop("labels", None)
|
||||
organization = self.context["request"].auth.organization
|
||||
self.update_labels_association_if_needed(labels, instance, organization)
|
||||
self.update_labels_association_if_needed(labels, instance, self.context["request"].auth.organization)
|
||||
|
||||
# update alert group labels
|
||||
instance = IntegrationAlertGroupLabelsSerializer.update(
|
||||
instance, IntegrationAlertGroupLabelsSerializer.pop_alert_group_labels(validated_data)
|
||||
)
|
||||
|
||||
try:
|
||||
return super().update(instance, validated_data)
|
||||
except AlertReceiveChannel.DuplicateDirectPagingError:
|
||||
|
|
|
|||
|
|
@ -3,12 +3,12 @@ import typing
|
|||
from rest_framework import serializers
|
||||
|
||||
from apps.alerts.models import AlertReceiveChannel, ChannelFilter, EscalationChain
|
||||
from apps.api.serializers.alert_receive_channel import valid_jinja_template_for_serializer_method_field
|
||||
from apps.base.messaging import get_messaging_backend_from_id
|
||||
from apps.telegram.models import TelegramToOrganizationConnector
|
||||
from common.api_helpers.custom_fields import OrganizationFilteredPrimaryKeyRelatedField
|
||||
from common.api_helpers.exceptions import BadRequest
|
||||
from common.api_helpers.mixins import EagerLoadingMixin
|
||||
from common.api_helpers.utils import valid_jinja_template_for_serializer_method_field
|
||||
from common.jinja_templater.apply_jinja_template import JinjaTemplateError
|
||||
from common.utils import is_regex_valid
|
||||
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ from rest_framework.test import APIClient
|
|||
|
||||
from apps.alerts.models import AlertReceiveChannel, EscalationPolicy
|
||||
from apps.api.permissions import LegacyAccessControlRole
|
||||
from apps.labels.models import LabelKeyCache, LabelValueCache
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
|
|
@ -1383,23 +1384,49 @@ def test_update_alert_receive_channel_labels_duplicate_key(
|
|||
def test_alert_group_labels_get(
|
||||
make_organization_and_user_with_plugin_token,
|
||||
make_alert_receive_channel,
|
||||
make_label_key_and_value,
|
||||
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_key, label_value = make_label_key_and_value(organization)
|
||||
label_key_1, _ = make_label_key_and_value(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": {}}
|
||||
assert response.json()["alert_group_labels"] == {"inheritable": {}, "custom": [], "template": None}
|
||||
|
||||
label = make_integration_label_association(organization, alert_receive_channel)
|
||||
|
||||
template = "{{ payload.labels | tojson }}"
|
||||
alert_receive_channel.alert_group_labels_template = template
|
||||
|
||||
alert_receive_channel.alert_group_labels_custom = [
|
||||
(label_key.id, label_value.id, None),
|
||||
(label_key_1.id, None, "{{ payload.foo }}"),
|
||||
]
|
||||
alert_receive_channel.save(update_fields=["alert_group_labels_custom", "alert_group_labels_template"])
|
||||
|
||||
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}}
|
||||
assert response.json()["alert_group_labels"] == {
|
||||
"inheritable": {label.key_id: True},
|
||||
"custom": [
|
||||
{
|
||||
"key": {"id": label_key.id, "name": label_key.name},
|
||||
"value": {"id": label_value.id, "name": label_value.name},
|
||||
},
|
||||
{
|
||||
"key": {"id": label_key_1.id, "name": label_key_1.name},
|
||||
"value": {"id": None, "name": "{{ payload.foo }}"},
|
||||
},
|
||||
],
|
||||
"template": template,
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
|
|
@ -1413,14 +1440,75 @@ def test_alert_group_labels_put(
|
|||
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)
|
||||
label_3 = make_integration_label_association(organization, alert_receive_channel, inheritable=False)
|
||||
|
||||
custom = [
|
||||
# plain label
|
||||
{
|
||||
"key": {"id": label_2.key.id, "name": label_2.key.name},
|
||||
"value": {"id": label_2.value.id, "name": label_2.value.name},
|
||||
},
|
||||
# plain label not present in DB cache
|
||||
{
|
||||
"key": {"id": "hello", "name": "world"},
|
||||
"value": {"id": "foo", "name": "bar"},
|
||||
},
|
||||
# templated label
|
||||
{
|
||||
"key": {"id": label_3.key.id, "name": label_3.key.name},
|
||||
"value": {"id": None, "name": "{{ payload.foo }}"},
|
||||
},
|
||||
]
|
||||
template = "{{ payload.labels | tojson }}" # advanced template
|
||||
|
||||
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}}}
|
||||
data = {
|
||||
"alert_group_labels": {
|
||||
"inheritable": {label_1.key_id: False, label_2.key_id: True, label_3.key_id: False},
|
||||
"custom": custom,
|
||||
"template": template,
|
||||
}
|
||||
}
|
||||
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}}
|
||||
assert response.json()["alert_group_labels"] == {
|
||||
"inheritable": {label_1.key_id: False, label_2.key_id: True, label_3.key_id: False},
|
||||
"custom": custom,
|
||||
"template": template,
|
||||
}
|
||||
|
||||
alert_receive_channel.refresh_from_db()
|
||||
assert alert_receive_channel.alert_group_labels_custom == [
|
||||
[label_2.key_id, label_2.value_id, None],
|
||||
["hello", "foo", None],
|
||||
[label_3.key_id, None, "{{ payload.foo }}"],
|
||||
]
|
||||
assert alert_receive_channel.alert_group_labels_template == template
|
||||
|
||||
# check label keys & values are created
|
||||
key = LabelKeyCache.objects.filter(id="hello", name="world", organization=organization).first()
|
||||
assert key is not None
|
||||
assert LabelValueCache.objects.filter(key=key, id="foo", name="bar").exists()
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_alert_group_labels_put_none(
|
||||
make_organization_and_user_with_plugin_token,
|
||||
make_alert_receive_channel,
|
||||
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.put(url, {"verbal_name": "123"}, format="json", **make_user_auth_headers(user, token))
|
||||
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
assert response.json()["verbal_name"] == "123"
|
||||
assert response.json()["alert_group_labels"] == {"inheritable": {}, "custom": [], "template": None}
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
|
|
@ -1428,7 +1516,11 @@ def test_alert_group_labels_post(alert_receive_channel_internal_api_setup, make_
|
|||
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}}
|
||||
alert_group_labels = {
|
||||
"inheritable": {"test": False},
|
||||
"custom": [{"key": {"id": "test", "name": "test"}, "value": {"id": "123", "name": "123"}}],
|
||||
"template": "{{ payload.labels | tojson }}",
|
||||
}
|
||||
data = {
|
||||
"integration": AlertReceiveChannel.INTEGRATION_GRAFANA,
|
||||
"team": None,
|
||||
|
|
@ -1443,3 +1535,7 @@ def test_alert_group_labels_post(alert_receive_channel_internal_api_setup, make_
|
|||
assert response.status_code == status.HTTP_201_CREATED
|
||||
assert response.json()["labels"] == labels
|
||||
assert response.json()["alert_group_labels"] == alert_group_labels
|
||||
|
||||
alert_receive_channel = AlertReceiveChannel.objects.get(public_primary_key=response.json()["id"])
|
||||
assert alert_receive_channel.alert_group_labels_custom == [["test", "123", None]]
|
||||
assert alert_receive_channel.alert_group_labels_template == "{{ payload.labels | tojson }}"
|
||||
|
|
|
|||
|
|
@ -337,6 +337,37 @@ def test_preview_alert_receive_channel_backend_templater(
|
|||
assert response.json() == {"preview": "title: alert!"}
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_preview_alert_group_labels(
|
||||
make_organization_and_user_with_plugin_token,
|
||||
make_user_auth_headers,
|
||||
make_alert_receive_channel,
|
||||
make_channel_filter,
|
||||
make_alert_group,
|
||||
make_alert,
|
||||
):
|
||||
organization, user, token = make_organization_and_user_with_plugin_token()
|
||||
alert_receive_channel = make_alert_receive_channel(organization)
|
||||
default_channel_filter = make_channel_filter(alert_receive_channel, is_default=True)
|
||||
alert_group = make_alert_group(alert_receive_channel, channel_filter=default_channel_filter)
|
||||
make_alert(alert_group=alert_group, raw_request_data={"labels": {"1": "2"}})
|
||||
|
||||
client = APIClient()
|
||||
url = reverse(
|
||||
"api-internal:alert_receive_channel-preview-template",
|
||||
kwargs={"pk": alert_receive_channel.public_primary_key},
|
||||
)
|
||||
|
||||
data = {
|
||||
"template_body": "{{ payload.labels | tojson }}",
|
||||
"template_name": "alert_group_labels",
|
||||
}
|
||||
response = client.post(url, format="json", data=data, **make_user_auth_headers(user, token))
|
||||
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
assert response.json() == {"preview": '{"1": "2"}'}
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_update_alert_receive_channel_templates(
|
||||
make_organization_and_user_with_plugin_token,
|
||||
|
|
|
|||
158
engine/apps/labels/alert_group_labels.py
Normal file
158
engine/apps/labels/alert_group_labels.py
Normal file
|
|
@ -0,0 +1,158 @@
|
|||
import json
|
||||
import logging
|
||||
import typing
|
||||
|
||||
from apps.labels.utils import is_labels_feature_enabled
|
||||
from common.jinja_templater import apply_jinja_template
|
||||
from common.jinja_templater.apply_jinja_template import JinjaTemplateError, JinjaTemplateWarning
|
||||
|
||||
if typing.TYPE_CHECKING:
|
||||
from apps.alerts.models import AlertGroup, AlertReceiveChannel
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# What can be used as a label key/value coming out from the template
|
||||
LABEL_VALUE_TYPES = (str, int, float, bool)
|
||||
|
||||
|
||||
def assign_labels(
|
||||
alert_group: "AlertGroup", alert_receive_channel: "AlertReceiveChannel", raw_request_data: typing.Any
|
||||
) -> None:
|
||||
from apps.labels.models import AlertGroupAssociatedLabel
|
||||
|
||||
if not is_labels_feature_enabled(alert_receive_channel.organization):
|
||||
return
|
||||
|
||||
# inherit labels from the integration
|
||||
labels = {
|
||||
label.key.name: label.value.name
|
||||
for label in alert_receive_channel.labels.filter(inheritable=True).select_related("key", "value")
|
||||
}
|
||||
|
||||
# apply custom labels
|
||||
labels.update(_custom_labels(alert_receive_channel, raw_request_data))
|
||||
|
||||
# apply template labels
|
||||
labels.update(_template_labels(alert_receive_channel, raw_request_data))
|
||||
|
||||
# create associated labels
|
||||
alert_group_labels = [
|
||||
AlertGroupAssociatedLabel(
|
||||
alert_group=alert_group,
|
||||
organization=alert_receive_channel.organization,
|
||||
key_name=key,
|
||||
value_name=value,
|
||||
)
|
||||
for key, value in labels.items()
|
||||
]
|
||||
# sort associated labels by key and value
|
||||
alert_group_labels.sort(key=lambda label: (label.key_name, label.value_name))
|
||||
# bulk create associated labels
|
||||
AlertGroupAssociatedLabel.objects.bulk_create(alert_group_labels)
|
||||
|
||||
|
||||
def _custom_labels(alert_receive_channel: "AlertReceiveChannel", raw_request_data: typing.Any) -> dict[str, str]:
|
||||
from apps.labels.models import MAX_VALUE_NAME_LENGTH, LabelKeyCache, LabelValueCache
|
||||
|
||||
# fetch up-to-date label key names
|
||||
label_key_names = {
|
||||
k.id: k.name
|
||||
for k in LabelKeyCache.objects.filter(
|
||||
id__in=[label[0] for label in alert_receive_channel.alert_group_labels_custom]
|
||||
).only("id", "name")
|
||||
}
|
||||
|
||||
# fetch up-to-date label value names
|
||||
label_value_names = {
|
||||
v.id: v.name
|
||||
for v in LabelValueCache.objects.filter(
|
||||
id__in=[label[1] for label in alert_receive_channel.alert_group_labels_custom if label[1]]
|
||||
).only("id", "name")
|
||||
}
|
||||
|
||||
rendered_labels = {}
|
||||
for label in alert_receive_channel.alert_group_labels_custom:
|
||||
key_id, value_id, template = label
|
||||
|
||||
if key_id in label_key_names:
|
||||
key = label_key_names[key_id]
|
||||
else:
|
||||
logger.warning("Label key cache not found. %s", key_id)
|
||||
continue
|
||||
|
||||
if value_id:
|
||||
if value_id in label_value_names:
|
||||
rendered_labels[key] = label_value_names[value_id]
|
||||
else:
|
||||
logger.warning("Label value cache not found. %s", value_id)
|
||||
continue
|
||||
else:
|
||||
try:
|
||||
rendered_labels[key] = apply_jinja_template(template, raw_request_data)
|
||||
except (JinjaTemplateError, JinjaTemplateWarning) as e:
|
||||
logger.warning("Failed to apply template. %s", e.fallback_message)
|
||||
continue
|
||||
|
||||
labels = {}
|
||||
for key in rendered_labels:
|
||||
value = rendered_labels[key]
|
||||
|
||||
# check value length
|
||||
if len(value) > MAX_VALUE_NAME_LENGTH:
|
||||
logger.warning("Template result value is too long. %s", value)
|
||||
continue
|
||||
|
||||
labels[key] = value
|
||||
|
||||
return labels
|
||||
|
||||
|
||||
def _template_labels(alert_receive_channel: "AlertReceiveChannel", raw_request_data: typing.Any) -> dict[str, str]:
|
||||
from apps.labels.models import MAX_KEY_NAME_LENGTH, MAX_VALUE_NAME_LENGTH
|
||||
|
||||
if not alert_receive_channel.alert_group_labels_template:
|
||||
return {}
|
||||
|
||||
try:
|
||||
rendered = apply_jinja_template(alert_receive_channel.alert_group_labels_template, raw_request_data)
|
||||
except (JinjaTemplateError, JinjaTemplateWarning) as e:
|
||||
logger.warning("Failed to apply template. %s", e.fallback_message)
|
||||
return {}
|
||||
|
||||
try:
|
||||
rendered_labels = json.loads(rendered)
|
||||
except (TypeError, json.JSONDecodeError):
|
||||
logger.warning("Failed to parse template result. %s", rendered)
|
||||
return {}
|
||||
|
||||
if not isinstance(rendered_labels, dict):
|
||||
logger.warning("Template result is not a dict. %s", rendered_labels)
|
||||
return {}
|
||||
|
||||
labels = {}
|
||||
for key in rendered_labels:
|
||||
value = rendered_labels[key]
|
||||
|
||||
# check value type
|
||||
if not isinstance(value, LABEL_VALUE_TYPES):
|
||||
logger.warning("Template result value has invalid type. %s", value)
|
||||
continue
|
||||
|
||||
# convert value to string
|
||||
value = str(value)
|
||||
|
||||
# check key length
|
||||
if len(key) > MAX_KEY_NAME_LENGTH:
|
||||
logger.warning("Template result key is too long. %s", key)
|
||||
continue
|
||||
|
||||
# check value length
|
||||
if len(value) > MAX_VALUE_NAME_LENGTH:
|
||||
logger.warning("Template result value is too long. %s", value)
|
||||
continue
|
||||
|
||||
labels[key] = value
|
||||
|
||||
return labels
|
||||
|
|
@ -10,9 +10,13 @@ if typing.TYPE_CHECKING:
|
|||
from apps.user_management.models import Organization
|
||||
|
||||
|
||||
MAX_KEY_NAME_LENGTH = 200
|
||||
MAX_VALUE_NAME_LENGTH = 200
|
||||
|
||||
|
||||
class LabelKeyCache(models.Model):
|
||||
id = models.CharField(primary_key=True, editable=False, max_length=36)
|
||||
name = models.CharField(max_length=200)
|
||||
name = models.CharField(max_length=MAX_KEY_NAME_LENGTH)
|
||||
organization = models.ForeignKey("user_management.Organization", on_delete=models.CASCADE)
|
||||
last_synced = models.DateTimeField(auto_now=True)
|
||||
|
||||
|
|
@ -23,7 +27,7 @@ class LabelKeyCache(models.Model):
|
|||
|
||||
class LabelValueCache(models.Model):
|
||||
id = models.CharField(primary_key=True, editable=False, max_length=36)
|
||||
name = models.CharField(max_length=200)
|
||||
name = models.CharField(max_length=MAX_VALUE_NAME_LENGTH)
|
||||
key = models.ForeignKey("labels.LabelKeyCache", on_delete=models.CASCADE, related_name="values")
|
||||
last_synced = models.DateTimeField(auto_now=True)
|
||||
|
||||
|
|
@ -129,8 +133,8 @@ class AlertGroupAssociatedLabel(models.Model):
|
|||
"user_management.Organization", on_delete=models.CASCADE, related_name="alert_group_labels"
|
||||
)
|
||||
|
||||
key_name = models.CharField(max_length=200)
|
||||
value_name = models.CharField(max_length=200)
|
||||
key_name = models.CharField(max_length=MAX_KEY_NAME_LENGTH)
|
||||
value_name = models.CharField(max_length=MAX_VALUE_NAME_LENGTH)
|
||||
|
||||
class Meta:
|
||||
constraints = [
|
||||
|
|
|
|||
|
|
@ -3,9 +3,13 @@ from unittest import mock
|
|||
import pytest
|
||||
|
||||
from apps.alerts.models import Alert
|
||||
from apps.labels.models import MAX_KEY_NAME_LENGTH, MAX_VALUE_NAME_LENGTH
|
||||
|
||||
TOO_LONG_KEY_NAME = "k" * (MAX_KEY_NAME_LENGTH + 1)
|
||||
TOO_LONG_VALUE_NAME = "v" * (MAX_VALUE_NAME_LENGTH + 1)
|
||||
|
||||
|
||||
@mock.patch("apps.labels.utils.is_labels_feature_enabled", return_value=False)
|
||||
@mock.patch("apps.labels.alert_group_labels.is_labels_feature_enabled", return_value=False)
|
||||
@pytest.mark.django_db
|
||||
def test_assign_labels_feature_flag_disabled(
|
||||
_, make_organization, make_alert_receive_channel, make_integration_label_association
|
||||
|
|
@ -28,22 +32,59 @@ def test_assign_labels_feature_flag_disabled(
|
|||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_assign_labels(make_organization, make_alert_receive_channel, make_integration_label_association):
|
||||
def test_assign_labels(
|
||||
make_organization,
|
||||
make_alert_receive_channel,
|
||||
make_label_key_and_value,
|
||||
make_label_key,
|
||||
make_integration_label_association,
|
||||
):
|
||||
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)
|
||||
|
||||
# create label repo labels
|
||||
label_key, label_value = make_label_key_and_value(organization, key_name="a", value_name="b")
|
||||
label_key_1 = make_label_key(organization=organization, key_name="c")
|
||||
label_key_2 = make_label_key(organization=organization)
|
||||
label_key_3 = make_label_key(organization=organization)
|
||||
|
||||
# create alert receive channel with all 3 types of labels
|
||||
alert_receive_channel = make_alert_receive_channel(
|
||||
organization,
|
||||
alert_group_labels_custom=[
|
||||
[label_key.id, label_value.id, None], # plain label
|
||||
["nonexistent", label_value.id, None], # plain label with nonexistent key ID
|
||||
[label_key_2.id, "nonexistent", None], # plain label with nonexistent value ID
|
||||
[label_key_1.id, None, "{{ payload.c }}"], # templated label
|
||||
[label_key_3.id, None, TOO_LONG_VALUE_NAME], # templated label too long
|
||||
],
|
||||
alert_group_labels_template="{{ payload.advanced_template | tojson }}",
|
||||
)
|
||||
make_integration_label_association(organization, alert_receive_channel, key_name="e", value_name="f")
|
||||
|
||||
# create alert group
|
||||
alert = Alert.create(
|
||||
title="the title",
|
||||
message="the message",
|
||||
alert_receive_channel=alert_receive_channel,
|
||||
raw_request_data={},
|
||||
raw_request_data={
|
||||
"c": "d",
|
||||
"advanced_template": {
|
||||
"g": 123,
|
||||
"too_long": TOO_LONG_VALUE_NAME,
|
||||
TOO_LONG_KEY_NAME: "too_long",
|
||||
"invalid_type": {"test": "test"},
|
||||
},
|
||||
"extra": "hi",
|
||||
},
|
||||
integration_unique_data={},
|
||||
image_url=None,
|
||||
link_to_upstream_details=None,
|
||||
)
|
||||
|
||||
assert alert.group.labels.count() == 1
|
||||
assert alert.group.labels.first().key_name == label.key.name
|
||||
assert alert.group.labels.first().value_name == label.value.name
|
||||
# check alert group labels are assigned correctly, in the lexicographical order
|
||||
assert [(label.key_name, label.value_name) for label in alert.group.labels.all()] == [
|
||||
("a", "b"),
|
||||
("c", "d"),
|
||||
("e", "f"),
|
||||
("g", "123"),
|
||||
]
|
||||
|
|
|
|||
|
|
@ -1,13 +1,16 @@
|
|||
import logging
|
||||
import typing
|
||||
|
||||
from django.apps import apps # noqa: I251
|
||||
from django.conf import settings
|
||||
|
||||
if typing.TYPE_CHECKING:
|
||||
from apps.alerts.models import AlertGroup, AlertReceiveChannel
|
||||
from apps.alerts.models import AlertGroup
|
||||
from apps.labels.models import AssociatedLabel
|
||||
from apps.user_management.models import Organization
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
LABEL_OUTDATED_TIMEOUT_MINUTES = 30
|
||||
ASSOCIATED_MODEL_NAME = "AssociatedLabel"
|
||||
|
|
@ -54,35 +57,10 @@ def is_labels_feature_enabled(organization: "Organization") -> bool:
|
|||
)
|
||||
|
||||
|
||||
def assign_labels(alert_group: "AlertGroup", alert_receive_channel: "AlertReceiveChannel") -> None:
|
||||
from apps.labels.models import AlertGroupAssociatedLabel
|
||||
|
||||
if not is_labels_feature_enabled(alert_receive_channel.organization):
|
||||
return
|
||||
|
||||
# inherit labels from the integration
|
||||
alert_group_labels = [
|
||||
AlertGroupAssociatedLabel(
|
||||
alert_group=alert_group,
|
||||
organization=alert_receive_channel.organization,
|
||||
key_name=label.key.name,
|
||||
value_name=label.value.name,
|
||||
)
|
||||
for label in alert_receive_channel.labels.filter(inheritable=True).select_related("key", "value")
|
||||
]
|
||||
AlertGroupAssociatedLabel.objects.bulk_create(alert_group_labels)
|
||||
def get_label_verbal(obj: typing.Any) -> dict[str, str]:
|
||||
return {label.key.name: label.value.name for label in obj.labels.all().select_related("key", "value")}
|
||||
|
||||
|
||||
def get_label_verbal(labelable) -> typing.Dict[str, str]:
|
||||
"""
|
||||
label_verbal returns dict of labels' key and values names for the given object
|
||||
"""
|
||||
return {label.key.name: label.value.name for label in labelable.labels.all().select_related("key", "value")}
|
||||
|
||||
|
||||
def get_alert_group_label_verbal(alert_group: "AlertGroup") -> typing.Dict[str, str]:
|
||||
"""
|
||||
get_alert_group_label_verbal returns dict of labels' key and values names for the given alert group.
|
||||
It's different from get_label_verbal, because AlertGroupAssociated labels store key/value_name, not key/value_id
|
||||
"""
|
||||
def get_alert_group_label_verbal(alert_group: "AlertGroup") -> dict[str, str]:
|
||||
"""This is different from get_label_verbal because alert group labels store key/value names, not IDs"""
|
||||
return {label.key_name: label.value_name for label in alert_group.labels.all()}
|
||||
|
|
|
|||
|
|
@ -1,10 +1,10 @@
|
|||
from rest_framework import fields, serializers
|
||||
|
||||
from apps.alerts.models import AlertReceiveChannel, ChannelFilter, EscalationChain
|
||||
from apps.api.serializers.alert_receive_channel import valid_jinja_template_for_serializer_method_field
|
||||
from apps.base.messaging import get_messaging_backend_from_id, get_messaging_backends
|
||||
from common.api_helpers.custom_fields import OrganizationFilteredPrimaryKeyRelatedField
|
||||
from common.api_helpers.exceptions import BadRequest
|
||||
from common.api_helpers.utils import valid_jinja_template_for_serializer_method_field
|
||||
from common.jinja_templater.apply_jinja_template import JinjaTemplateError
|
||||
from common.ordered_model.serializer import OrderedModelSerializer
|
||||
from common.utils import is_regex_valid
|
||||
|
|
|
|||
|
|
@ -248,6 +248,7 @@ ACKNOWLEDGE_CONDITION = "acknowledge_condition"
|
|||
GROUPING_ID = "grouping_id"
|
||||
SOURCE_LINK = "source_link"
|
||||
ROUTE = "route"
|
||||
ALERT_GROUP_LABELS = "alert_group_labels"
|
||||
|
||||
NOTIFICATION_CHANNEL_TO_TEMPLATER_MAP = {
|
||||
SLACK: AlertSlackTemplater,
|
||||
|
|
@ -264,9 +265,15 @@ for backend_id, backend in get_messaging_backends():
|
|||
NOTIFICATION_CHANNEL_TO_TEMPLATER_MAP[backend.slug] = backend.get_templater_class()
|
||||
|
||||
APPEARANCE_TEMPLATE_NAMES = [TITLE, MESSAGE, IMAGE_URL]
|
||||
BEHAVIOUR_TEMPLATE_NAMES = [RESOLVE_CONDITION, ACKNOWLEDGE_CONDITION, GROUPING_ID, SOURCE_LINK]
|
||||
ROUTE_TEMPLATE_NAMES = [ROUTE]
|
||||
ALL_TEMPLATE_NAMES = APPEARANCE_TEMPLATE_NAMES + BEHAVIOUR_TEMPLATE_NAMES + ROUTE_TEMPLATE_NAMES
|
||||
BEHAVIOUR_TEMPLATE_NAMES = [
|
||||
RESOLVE_CONDITION,
|
||||
ACKNOWLEDGE_CONDITION,
|
||||
GROUPING_ID,
|
||||
SOURCE_LINK,
|
||||
ROUTE,
|
||||
ALERT_GROUP_LABELS,
|
||||
]
|
||||
ALL_TEMPLATE_NAMES = APPEARANCE_TEMPLATE_NAMES + BEHAVIOUR_TEMPLATE_NAMES
|
||||
|
||||
|
||||
class PreviewTemplateException(Exception):
|
||||
|
|
@ -326,11 +333,6 @@ class PreviewTemplateMixin:
|
|||
templated_attr = apply_jinja_template(template_body, payload=alert_to_template.raw_request_data)
|
||||
except (JinjaTemplateError, JinjaTemplateWarning) as e:
|
||||
return Response({"preview": e.fallback_message}, status.HTTP_200_OK)
|
||||
elif attr_name in ROUTE_TEMPLATE_NAMES:
|
||||
try:
|
||||
templated_attr = apply_jinja_template(template_body, payload=alert_to_template.raw_request_data)
|
||||
except (JinjaTemplateError, JinjaTemplateWarning) as e:
|
||||
return Response({"preview": e.fallback_message}, status.HTTP_200_OK)
|
||||
else:
|
||||
templated_attr = None
|
||||
response = {"preview": templated_attr}
|
||||
|
|
@ -346,8 +348,6 @@ class PreviewTemplateMixin:
|
|||
destination = None
|
||||
if template_param.startswith(tuple(BEHAVIOUR_TEMPLATE_NAMES)):
|
||||
attr_name = template_param
|
||||
if template_param.startswith(tuple(ROUTE_TEMPLATE_NAMES)):
|
||||
attr_name = template_param
|
||||
elif template_param.startswith(tuple(NOTIFICATION_CHANNEL_OPTIONS)):
|
||||
for notification_channel in NOTIFICATION_CHANNEL_OPTIONS:
|
||||
if template_param.startswith(notification_channel):
|
||||
|
|
|
|||
|
|
@ -14,6 +14,8 @@ from rest_framework.request import Request
|
|||
|
||||
from apps.schedules.ical_utils import fetch_ical_file
|
||||
from common.api_helpers.exceptions import BadRequest
|
||||
from common.jinja_templater import apply_jinja_template
|
||||
from common.jinja_templater.apply_jinja_template import JinjaTemplateWarning
|
||||
from common.timezones import raise_exception_if_not_valid_timezone
|
||||
|
||||
|
||||
|
|
@ -165,3 +167,12 @@ def check_phone_number_is_valid(phone_number):
|
|||
|
||||
def serialize_datetime_as_utc_timestamp(dt: datetime.datetime) -> str:
|
||||
return dt.strftime("%Y-%m-%dT%H:%M:%S.%fZ")
|
||||
|
||||
|
||||
def valid_jinja_template_for_serializer_method_field(template):
|
||||
for _, val in template.items():
|
||||
try:
|
||||
apply_jinja_template(val, payload={})
|
||||
except JinjaTemplateWarning:
|
||||
# Suppress warnings, template may be valid with payload
|
||||
pass
|
||||
|
|
|
|||
|
|
@ -954,7 +954,13 @@ def webhook_preset_api_setup():
|
|||
|
||||
@pytest.fixture
|
||||
def make_label_key():
|
||||
def _make_label_key(organization, **kwargs):
|
||||
def _make_label_key(organization, key_id=None, key_name=None, **kwargs):
|
||||
if key_id is not None:
|
||||
kwargs["id"] = key_id
|
||||
|
||||
if key_name is not None:
|
||||
kwargs["name"] = key_name
|
||||
|
||||
return LabelKeyFactory(organization=organization, **kwargs)
|
||||
|
||||
return _make_label_key
|
||||
|
|
@ -962,7 +968,13 @@ def make_label_key():
|
|||
|
||||
@pytest.fixture
|
||||
def make_label_value():
|
||||
def _make_label_value(key, **kwargs):
|
||||
def _make_label_value(key, value_id=None, value_name=None, **kwargs):
|
||||
if value_id is not None:
|
||||
kwargs["id"] = value_id
|
||||
|
||||
if value_name is not None:
|
||||
kwargs["name"] = value_name
|
||||
|
||||
return LabelValueFactory(key=key, **kwargs)
|
||||
|
||||
return _make_label_value
|
||||
|
|
@ -970,9 +982,9 @@ def make_label_value():
|
|||
|
||||
@pytest.fixture
|
||||
def make_label_key_and_value(make_label_key, make_label_value):
|
||||
def _make_label_key_and_value(organization):
|
||||
key = make_label_key(organization=organization)
|
||||
value = make_label_value(key=key)
|
||||
def _make_label_key_and_value(organization, key_id=None, key_name=None, value_id=None, value_name=None):
|
||||
key = make_label_key(organization=organization, key_id=key_id, key_name=key_name)
|
||||
value = make_label_value(key=key, value_id=value_id, value_name=value_name)
|
||||
return key, value
|
||||
|
||||
return _make_label_key_and_value
|
||||
|
|
@ -980,8 +992,12 @@ def make_label_key_and_value(make_label_key, make_label_value):
|
|||
|
||||
@pytest.fixture
|
||||
def make_integration_label_association(make_label_key_and_value):
|
||||
def _make_integration_label_association(organization, alert_receive_channel, **kwargs):
|
||||
key, value = make_label_key_and_value(organization)
|
||||
def _make_integration_label_association(
|
||||
organization, alert_receive_channel, key_id=None, key_name=None, value_id=None, value_name=None, **kwargs
|
||||
):
|
||||
key, value = make_label_key_and_value(
|
||||
organization, key_id=key_id, key_name=key_name, value_id=value_id, value_name=value_name
|
||||
)
|
||||
return AlertReceiveChannelAssociatedLabelFactory(
|
||||
alert_receive_channel=alert_receive_channel, organization=organization, key=key, value=value, **kwargs
|
||||
)
|
||||
|
|
|
|||
|
|
@ -115,7 +115,7 @@
|
|||
"@grafana/data": "^9.2.4",
|
||||
"@grafana/faro-web-sdk": "^1.0.0-beta4",
|
||||
"@grafana/faro-web-tracing": "^1.0.0-beta4",
|
||||
"@grafana/labels": "~1.2.1",
|
||||
"@grafana/labels": "1.3.4",
|
||||
"@grafana/runtime": "9.3.0-beta1",
|
||||
"@grafana/ui": "^9.4.7",
|
||||
"@opentelemetry/api": "^1.3.0",
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { PlaywrightTestConfig, PlaywrightTestProject, defineConfig, devices } from '@playwright/test';
|
||||
import { PlaywrightTestProject, defineConfig, devices } from '@playwright/test';
|
||||
|
||||
import path from 'path';
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -22,7 +22,7 @@ const LabelsTooltipBadge: FC<LabelsTooltipBadgeProps> = ({ labels, onClick }) =>
|
|||
<VerticalGroup spacing="sm">
|
||||
{labels.map((label) => (
|
||||
<HorizontalGroup spacing="sm" key={label.key.id}>
|
||||
<LabelTag label={label.key.name} value={label.value.name} key={label.key.id} />
|
||||
<LabelTag label={label.key.name} value={label.value.name} />
|
||||
<Button
|
||||
size="sm"
|
||||
icon="filter"
|
||||
|
|
|
|||
|
|
@ -99,6 +99,7 @@ const MonacoEditor: FC<MonacoEditorProps> = (props) => {
|
|||
height={height}
|
||||
onEditorDidMount={handleMount}
|
||||
getSuggestions={useAutoCompleteList ? autoCompleteList : undefined}
|
||||
containerStyles="u-width-100"
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,18 +1,37 @@
|
|||
import React, { useState } from 'react';
|
||||
import React, { ChangeEvent, useCallback, useState } from 'react';
|
||||
|
||||
import { Button, Drawer, HorizontalGroup, Icon, InlineSwitch, Input, Label, Tooltip, VerticalGroup } from '@grafana/ui';
|
||||
import { ServiceLabels } from '@grafana/labels';
|
||||
import {
|
||||
Button,
|
||||
Drawer,
|
||||
Dropdown,
|
||||
HorizontalGroup,
|
||||
Icon,
|
||||
InlineSwitch,
|
||||
Input,
|
||||
Label,
|
||||
Menu,
|
||||
Tooltip,
|
||||
VerticalGroup,
|
||||
} from '@grafana/ui';
|
||||
import cn from 'classnames/bind';
|
||||
import { observer } from 'mobx-react';
|
||||
|
||||
import Collapse from 'components/Collapse/Collapse';
|
||||
import MonacoEditor, { MONACO_LANGUAGE } from 'components/MonacoEditor/MonacoEditor';
|
||||
import Text from 'components/Text/Text';
|
||||
import IntegrationTemplate from 'containers/IntegrationTemplate/IntegrationTemplate';
|
||||
import { AlertReceiveChannel } from 'models/alert_receive_channel/alert_receive_channel.types';
|
||||
import { LabelKey } from 'models/label/label.types';
|
||||
import { useStore } from 'state/useStore';
|
||||
import { openErrorNotification } from 'utils';
|
||||
|
||||
import styles from './IntegrationLabelsForm.module.css';
|
||||
|
||||
const cx = cn.bind(styles);
|
||||
|
||||
const INPUT_WIDTH = 280;
|
||||
|
||||
interface IntegrationLabelsFormProps {
|
||||
id: AlertReceiveChannel['id'];
|
||||
onSubmit: () => void;
|
||||
|
|
@ -25,9 +44,13 @@ const IntegrationLabelsForm = observer((props: IntegrationLabelsFormProps) => {
|
|||
|
||||
const store = useStore();
|
||||
|
||||
const [showTemplateEditor, setShowTemplateEditor] = useState<boolean>(false);
|
||||
const [customLabelIndexToShowTemplateEditor, setCustomLabelIndexToShowTemplateEditor] = useState<number>(undefined);
|
||||
|
||||
const { alertReceiveChannelStore } = store;
|
||||
|
||||
const alertReceiveChannel = alertReceiveChannelStore.items[id];
|
||||
const templates = alertReceiveChannelStore.templates[id];
|
||||
|
||||
const [alertGroupLabels, setAlertGroupLabels] = useState(alertReceiveChannel.alert_group_labels);
|
||||
|
||||
|
|
@ -55,51 +78,273 @@ const IntegrationLabelsForm = observer((props: IntegrationLabelsFormProps) => {
|
|||
};
|
||||
|
||||
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>
|
||||
))
|
||||
) : (
|
||||
<>
|
||||
<Drawer scrollableContent title="Alert group labels" onClose={onHide} closeOnMaskClick={false} width="640px">
|
||||
<VerticalGroup spacing="lg">
|
||||
<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>
|
||||
{alertReceiveChannel.labels.length ? (
|
||||
<ul className={cx('labels-list')}>
|
||||
{alertReceiveChannel.labels.map((label) => (
|
||||
<li key={label.key.id}>
|
||||
<HorizontalGroup spacing="xs">
|
||||
<Input width={INPUT_WIDTH / 8} value={label.key.name} disabled />
|
||||
<Input width={INPUT_WIDTH / 8} value={label.value.name} disabled />
|
||||
<InlineSwitch
|
||||
value={alertGroupLabels.inheritable[label.key.id]}
|
||||
transparent
|
||||
onChange={getInheritanceChangeHandler(label.key.id)}
|
||||
/>
|
||||
</HorizontalGroup>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : (
|
||||
<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>
|
||||
)}
|
||||
</VerticalGroup>
|
||||
|
||||
<CustomLabels
|
||||
alertGroupLabels={alertGroupLabels}
|
||||
onChange={setAlertGroupLabels}
|
||||
onShowTemplateEditor={setCustomLabelIndexToShowTemplateEditor}
|
||||
/>
|
||||
|
||||
<Collapse isOpen={false} label="Advanced label templating">
|
||||
<VerticalGroup>
|
||||
<Text type="secondary">There are no labels to inherit yet</Text>
|
||||
<Text type="link" onClick={handleOpenIntegrationSettings} clickable>
|
||||
Add labels to the integration
|
||||
</Text>
|
||||
<HorizontalGroup justify="space-between" style={{ marginBottom: '10px' }}>
|
||||
<Text type="secondary">Jinja2 template to parse all labels at once</Text>
|
||||
<Button
|
||||
variant="secondary"
|
||||
icon="edit"
|
||||
onClick={() => {
|
||||
setShowTemplateEditor(true);
|
||||
}}
|
||||
/>
|
||||
</HorizontalGroup>
|
||||
<MonacoEditor
|
||||
value={alertGroupLabels.template}
|
||||
height="200px"
|
||||
data={{}}
|
||||
showLineNumbers={false}
|
||||
language={MONACO_LANGUAGE.jinja2}
|
||||
onChange={(value) => {
|
||||
setAlertGroupLabels({ ...alertGroupLabels, template: value });
|
||||
}}
|
||||
/>
|
||||
</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>
|
||||
</Collapse>
|
||||
|
||||
<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>
|
||||
{customLabelIndexToShowTemplateEditor !== undefined && (
|
||||
<IntegrationTemplate
|
||||
id={id}
|
||||
template={{
|
||||
name: 'alert_group_labels',
|
||||
displayName: ``,
|
||||
}}
|
||||
templates={templates}
|
||||
templateBody={alertGroupLabels.custom[customLabelIndexToShowTemplateEditor].value.name}
|
||||
onHide={() => setCustomLabelIndexToShowTemplateEditor(undefined)}
|
||||
onUpdateTemplates={({ alert_group_labels }) => {
|
||||
const newCustom = [...alertGroupLabels.custom];
|
||||
newCustom[customLabelIndexToShowTemplateEditor].value.name = alert_group_labels;
|
||||
|
||||
setAlertGroupLabels({
|
||||
...alertGroupLabels,
|
||||
custom: newCustom,
|
||||
});
|
||||
|
||||
setCustomLabelIndexToShowTemplateEditor(undefined);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{showTemplateEditor && (
|
||||
<IntegrationTemplate
|
||||
id={id}
|
||||
template={{
|
||||
name: 'alert_group_labels',
|
||||
displayName: ``,
|
||||
}}
|
||||
templates={templates}
|
||||
templateBody={alertGroupLabels.template}
|
||||
onHide={() => setShowTemplateEditor(false)}
|
||||
onUpdateTemplates={({ alert_group_labels }) => {
|
||||
setAlertGroupLabels({
|
||||
...alertGroupLabels,
|
||||
template: alert_group_labels,
|
||||
});
|
||||
|
||||
setShowTemplateEditor(undefined);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
||||
interface CustomLabelsProps {
|
||||
alertGroupLabels: AlertReceiveChannel['alert_group_labels'];
|
||||
onChange: (value: AlertReceiveChannel['alert_group_labels']) => void;
|
||||
onShowTemplateEditor: (index: number) => void;
|
||||
}
|
||||
|
||||
const CustomLabels = (props: CustomLabelsProps) => {
|
||||
const { alertGroupLabels, onChange, onShowTemplateEditor } = props;
|
||||
|
||||
const { labelsStore } = useStore();
|
||||
|
||||
const handlePlainLabelAdd = () => {
|
||||
onChange({
|
||||
...alertGroupLabels,
|
||||
custom: [
|
||||
...alertGroupLabels.custom,
|
||||
{
|
||||
key: { id: undefined, name: undefined },
|
||||
value: { id: undefined, name: undefined },
|
||||
},
|
||||
],
|
||||
});
|
||||
};
|
||||
const handleTemplatedLabelAdd = () => {
|
||||
onChange({
|
||||
...alertGroupLabels,
|
||||
custom: [
|
||||
...alertGroupLabels.custom,
|
||||
{
|
||||
key: { id: undefined, name: undefined },
|
||||
value: { id: null, name: undefined }, // id = null means it's a templated value
|
||||
},
|
||||
],
|
||||
});
|
||||
};
|
||||
|
||||
const cachedOnLoadKeys = useCallback(() => {
|
||||
let result = undefined;
|
||||
return async (search?: string) => {
|
||||
if (!result) {
|
||||
try {
|
||||
result = await labelsStore.loadKeys();
|
||||
} catch (error) {
|
||||
openErrorNotification('There was an error processing your request. Please try again');
|
||||
}
|
||||
}
|
||||
|
||||
return result.filter((k) => k.name.toLowerCase().includes(search.toLowerCase()));
|
||||
};
|
||||
}, []);
|
||||
|
||||
const cachedOnLoadValuesForKey = useCallback(() => {
|
||||
let result = undefined;
|
||||
return async (key: string, search?: string) => {
|
||||
if (!result) {
|
||||
try {
|
||||
const { values } = await labelsStore.loadValuesForKey(key, search);
|
||||
result = values;
|
||||
} catch (error) {
|
||||
openErrorNotification('There was an error processing your request. Please try again');
|
||||
}
|
||||
}
|
||||
|
||||
return result.filter((k) => k.name.toLowerCase().includes(search.toLowerCase()));
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<VerticalGroup>
|
||||
<HorizontalGroup spacing="xs" align="flex-start">
|
||||
<Label>Custom labels</Label>
|
||||
</HorizontalGroup>
|
||||
<ServiceLabels
|
||||
isAddingDisabled
|
||||
loadById
|
||||
inputWidth={INPUT_WIDTH}
|
||||
value={alertGroupLabels.custom}
|
||||
onLoadKeys={cachedOnLoadKeys()}
|
||||
onLoadValuesForKey={cachedOnLoadValuesForKey()}
|
||||
onCreateKey={labelsStore.createKey.bind(labelsStore)}
|
||||
onUpdateKey={labelsStore.updateKey.bind(labelsStore)}
|
||||
onCreateValue={labelsStore.createValue.bind(labelsStore)}
|
||||
onUpdateValue={labelsStore.updateKeyValue.bind(labelsStore)}
|
||||
onUpdateError={(res) => {
|
||||
if (res?.response?.status === 409) {
|
||||
openErrorNotification(`Duplicate values are not allowed`);
|
||||
} else {
|
||||
openErrorNotification('An error has occurred. Please try again');
|
||||
}
|
||||
}}
|
||||
renderValue={(option, index, renderValueDefault) => {
|
||||
if (option.value.id === null) {
|
||||
return (
|
||||
<Input
|
||||
placeholder="Jinja2 template"
|
||||
autoFocus
|
||||
disabled={!alertGroupLabels.custom[index].key.id}
|
||||
width={INPUT_WIDTH / 8}
|
||||
value={option.value.name}
|
||||
addonAfter={
|
||||
<Button
|
||||
variant="secondary"
|
||||
icon="edit"
|
||||
onClick={() => {
|
||||
onShowTemplateEditor(index);
|
||||
}}
|
||||
/>
|
||||
}
|
||||
onChange={(e: ChangeEvent<HTMLInputElement>) => {
|
||||
const newCustom = [...alertGroupLabels.custom];
|
||||
newCustom[index].value.name = e.currentTarget.value;
|
||||
|
||||
onChange({ ...alertGroupLabels, custom: newCustom });
|
||||
}}
|
||||
/>
|
||||
);
|
||||
} else {
|
||||
return renderValueDefault(option, index);
|
||||
}
|
||||
}}
|
||||
onDataUpdate={(value) => {
|
||||
onChange({
|
||||
...alertGroupLabels,
|
||||
custom: value,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
<Dropdown
|
||||
overlay={
|
||||
<Menu>
|
||||
<Menu.Item label="Plain label" onClick={handlePlainLabelAdd} />
|
||||
<Menu.Item label="Templated label" onClick={handleTemplatedLabelAdd} />
|
||||
</Menu>
|
||||
}
|
||||
>
|
||||
<Button variant="secondary" icon="plus">
|
||||
Add
|
||||
</Button>
|
||||
</Dropdown>
|
||||
</VerticalGroup>
|
||||
);
|
||||
};
|
||||
|
||||
export default IntegrationLabelsForm;
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import cn from 'classnames/bind';
|
|||
import { debounce } from 'lodash-es';
|
||||
import { observer } from 'mobx-react';
|
||||
|
||||
import { templateForEdit } from 'components/AlertTemplates/AlertTemplatesForm.config';
|
||||
import { TemplateForEdit } from 'components/AlertTemplates/CommonAlertTemplatesForm.config';
|
||||
import CheatSheet from 'components/CheatSheet/CheatSheet';
|
||||
import {
|
||||
|
|
@ -38,7 +39,7 @@ interface IntegrationTemplateProps {
|
|||
templates: AlertTemplatesDTO[];
|
||||
onHide: () => void;
|
||||
onUpdateTemplates: (values: any) => void;
|
||||
onUpdateRoute: (values: any, channelFilterId?: ChannelFilter['id']) => void;
|
||||
onUpdateRoute?: (values: any, channelFilterId?: ChannelFilter['id']) => void;
|
||||
}
|
||||
|
||||
const IntegrationTemplate = observer((props: IntegrationTemplateProps) => {
|
||||
|
|
@ -53,11 +54,13 @@ const IntegrationTemplate = observer((props: IntegrationTemplateProps) => {
|
|||
const [isRecentAlertGroupExisting, setIsRecentAlertGroupExisting] = useState<boolean>(false);
|
||||
|
||||
useEffect(() => {
|
||||
const locationParams: any = { template: template.name };
|
||||
if (template.isRoute) {
|
||||
locationParams.routeId = channelFilterId;
|
||||
if (templateForEdit[template.name]) {
|
||||
const locationParams: any = { template: template.name };
|
||||
if (template.isRoute) {
|
||||
locationParams.routeId = channelFilterId;
|
||||
}
|
||||
LocationHelper.update(locationParams, 'partial');
|
||||
}
|
||||
LocationHelper.update(locationParams, 'partial');
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import React, { forwardRef, useCallback, useImperativeHandle, useState } from 'react';
|
||||
|
||||
import ServiceLabels, { ServiceLabelsProps } from '@grafana/labels';
|
||||
import { ServiceLabels, ServiceLabelsProps } from '@grafana/labels';
|
||||
import { Field } from '@grafana/ui';
|
||||
import cn from 'classnames/bind';
|
||||
import { isEmpty } from 'lodash-es';
|
||||
|
|
|
|||
|
|
@ -49,7 +49,11 @@ export interface AlertReceiveChannel {
|
|||
allow_delete: boolean;
|
||||
deleted?: boolean;
|
||||
labels: LabelKeyValue[];
|
||||
alert_group_labels: { inheritable: Record<LabelKeyValue['key']['id'], boolean> };
|
||||
alert_group_labels: {
|
||||
inheritable: Record<LabelKeyValue['key']['id'], boolean>;
|
||||
custom: LabelKeyValue[];
|
||||
template: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface AlertReceiveChannelChoice {
|
||||
|
|
|
|||
|
|
@ -1966,10 +1966,10 @@
|
|||
"@opentelemetry/sdk-trace-web" "^1.8.0"
|
||||
"@opentelemetry/semantic-conventions" "^1.8.0"
|
||||
|
||||
"@grafana/labels@~1.2.1":
|
||||
version "1.2.1"
|
||||
resolved "https://registry.yarnpkg.com/@grafana/labels/-/labels-1.2.1.tgz#4113d584bf5cd826d011f957cb69c90bd0416ea8"
|
||||
integrity sha512-Nlqqvjwh0MjWsqnfpYbKdYwByeKSmEpiit5mKd6Mnnbc5Hxb8ORIruMr40lTxxWLEnDfhENcAs6pvlBuIMG7tQ==
|
||||
"@grafana/labels@1.3.4":
|
||||
version "1.3.4"
|
||||
resolved "https://registry.yarnpkg.com/@grafana/labels/-/labels-1.3.4.tgz#8d9cdd215a80a1da1045d402c037be85d7efd6f5"
|
||||
integrity sha512-YYCuLGvtrMz7KkbMc6qoNJQr6drDLo6mMI27LcqsTDMHCNO3uJWpzC1Q2Y9MIwctIuTFYhbgfLvIunEegCx6PQ==
|
||||
dependencies:
|
||||
"@emotion/css" "^11.11.2"
|
||||
"@grafana/ui" "^10.0.0"
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue