From 5fac6aeac5a70b17fb23affb1c3797a3949b8e97 Mon Sep 17 00:00:00 2001 From: Vadim Stepanov Date: Mon, 27 Nov 2023 16:55:31 +0000 Subject: [PATCH] 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 Co-authored-by: Rares Mardare --- ...nnel_alert_group_labels_custom_and_more.py | 23 ++ engine/apps/alerts/models/alert.py | 4 +- engine/apps/alerts/models/alert_group.py | 2 + .../alerts/models/alert_receive_channel.py | 32 +- .../api/serializers/alert_receive_channel.py | 198 ++++++++++- engine/apps/api/serializers/channel_filter.py | 2 +- .../api/tests/test_alert_receive_channel.py | 106 +++++- .../test_alert_receive_channel_template.py | 31 ++ engine/apps/labels/alert_group_labels.py | 158 +++++++++ engine/apps/labels/models.py | 12 +- engine/apps/labels/tests/test_alert_group.py | 59 +++- engine/apps/labels/utils.py | 38 +- engine/apps/public_api/serializers/routes.py | 2 +- engine/common/api_helpers/mixins.py | 20 +- engine/common/api_helpers/utils.py | 11 + engine/conftest.py | 30 +- grafana-plugin/package.json | 2 +- grafana-plugin/playwright.config.ts | 2 +- .../LabelsTooltipBadge/LabelsTooltipBadge.tsx | 2 +- .../components/MonacoEditor/MonacoEditor.tsx | 1 + .../IntegrationLabelsForm.tsx | 333 +++++++++++++++--- .../IntegrationTemplate.tsx | 13 +- .../src/containers/Labels/Labels.tsx | 2 +- .../alert_receive_channel.types.ts | 6 +- grafana-plugin/yarn.lock | 8 +- 25 files changed, 935 insertions(+), 162 deletions(-) create mode 100644 engine/apps/alerts/migrations/0040_alertreceivechannel_alert_group_labels_custom_and_more.py create mode 100644 engine/apps/labels/alert_group_labels.py diff --git a/engine/apps/alerts/migrations/0040_alertreceivechannel_alert_group_labels_custom_and_more.py b/engine/apps/alerts/migrations/0040_alertreceivechannel_alert_group_labels_custom_and_more.py new file mode 100644 index 00000000..d08dbe4a --- /dev/null +++ b/engine/apps/alerts/migrations/0040_alertreceivechannel_alert_group_labels_custom_and_more.py @@ -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), + ), + ] diff --git a/engine/apps/alerts/models/alert.py b/engine/apps/alerts/models/alert.py index 79f458db..203f208a 100644 --- a/engine/apps/alerts/models/alert.py +++ b/engine/apps/alerts/models/alert.py @@ -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) diff --git a/engine/apps/alerts/models/alert_group.py b/engine/apps/alerts/models/alert_group.py index 6f6aec12..cbdb587a 100644 --- a/engine/apps/alerts/models/alert_group.py +++ b/engine/apps/alerts/models/alert_group.py @@ -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() diff --git a/engine/apps/alerts/models/alert_receive_channel.py b/engine/apps/alerts/models/alert_receive_channel.py index fb465193..2688b4c8 100644 --- a/engine/apps/alerts/models/alert_receive_channel.py +++ b/engine/apps/alerts/models/alert_receive_channel.py @@ -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: [, , None] + For templated labels, the format is: [, None, ] + """ + + 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( diff --git a/engine/apps/api/serializers/alert_receive_channel.py b/engine/apps/api/serializers/alert_receive_channel.py index ad9ddaf5..45390996 100644 --- a/engine/apps/api/serializers/alert_receive_channel.py +++ b/engine/apps/api/serializers/alert_receive_channel.py @@ -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: diff --git a/engine/apps/api/serializers/channel_filter.py b/engine/apps/api/serializers/channel_filter.py index b9239d6d..7815a584 100644 --- a/engine/apps/api/serializers/channel_filter.py +++ b/engine/apps/api/serializers/channel_filter.py @@ -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 diff --git a/engine/apps/api/tests/test_alert_receive_channel.py b/engine/apps/api/tests/test_alert_receive_channel.py index 989171db..4855a87c 100644 --- a/engine/apps/api/tests/test_alert_receive_channel.py +++ b/engine/apps/api/tests/test_alert_receive_channel.py @@ -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 }}" diff --git a/engine/apps/api/tests/test_alert_receive_channel_template.py b/engine/apps/api/tests/test_alert_receive_channel_template.py index 111696cd..f494d776 100644 --- a/engine/apps/api/tests/test_alert_receive_channel_template.py +++ b/engine/apps/api/tests/test_alert_receive_channel_template.py @@ -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, diff --git a/engine/apps/labels/alert_group_labels.py b/engine/apps/labels/alert_group_labels.py new file mode 100644 index 00000000..df735ca1 --- /dev/null +++ b/engine/apps/labels/alert_group_labels.py @@ -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 diff --git a/engine/apps/labels/models.py b/engine/apps/labels/models.py index 28947e75..14cd446c 100644 --- a/engine/apps/labels/models.py +++ b/engine/apps/labels/models.py @@ -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 = [ diff --git a/engine/apps/labels/tests/test_alert_group.py b/engine/apps/labels/tests/test_alert_group.py index a5ac35ca..32144377 100644 --- a/engine/apps/labels/tests/test_alert_group.py +++ b/engine/apps/labels/tests/test_alert_group.py @@ -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"), + ] diff --git a/engine/apps/labels/utils.py b/engine/apps/labels/utils.py index 46b0583a..bacf86c0 100644 --- a/engine/apps/labels/utils.py +++ b/engine/apps/labels/utils.py @@ -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()} diff --git a/engine/apps/public_api/serializers/routes.py b/engine/apps/public_api/serializers/routes.py index 1907c9e9..bbe9ff55 100644 --- a/engine/apps/public_api/serializers/routes.py +++ b/engine/apps/public_api/serializers/routes.py @@ -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 diff --git a/engine/common/api_helpers/mixins.py b/engine/common/api_helpers/mixins.py index 4204095f..5f15f137 100644 --- a/engine/common/api_helpers/mixins.py +++ b/engine/common/api_helpers/mixins.py @@ -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): diff --git a/engine/common/api_helpers/utils.py b/engine/common/api_helpers/utils.py index 1233c4b0..0ec76200 100644 --- a/engine/common/api_helpers/utils.py +++ b/engine/common/api_helpers/utils.py @@ -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 diff --git a/engine/conftest.py b/engine/conftest.py index 4f30e34a..1698befe 100644 --- a/engine/conftest.py +++ b/engine/conftest.py @@ -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 ) diff --git a/grafana-plugin/package.json b/grafana-plugin/package.json index 3b7bda98..0178bc57 100644 --- a/grafana-plugin/package.json +++ b/grafana-plugin/package.json @@ -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", diff --git a/grafana-plugin/playwright.config.ts b/grafana-plugin/playwright.config.ts index 689cd6cc..835639e5 100644 --- a/grafana-plugin/playwright.config.ts +++ b/grafana-plugin/playwright.config.ts @@ -1,4 +1,4 @@ -import { PlaywrightTestConfig, PlaywrightTestProject, defineConfig, devices } from '@playwright/test'; +import { PlaywrightTestProject, defineConfig, devices } from '@playwright/test'; import path from 'path'; /** diff --git a/grafana-plugin/src/components/LabelsTooltipBadge/LabelsTooltipBadge.tsx b/grafana-plugin/src/components/LabelsTooltipBadge/LabelsTooltipBadge.tsx index 5ec9d084..a2b9262d 100644 --- a/grafana-plugin/src/components/LabelsTooltipBadge/LabelsTooltipBadge.tsx +++ b/grafana-plugin/src/components/LabelsTooltipBadge/LabelsTooltipBadge.tsx @@ -22,7 +22,7 @@ const LabelsTooltipBadge: FC = ({ labels, onClick }) => {labels.map((label) => ( - + - - - - - + + +
+ + + + +
+ + + {customLabelIndexToShowTemplateEditor !== undefined && ( + setCustomLabelIndexToShowTemplateEditor(undefined)} + onUpdateTemplates={({ alert_group_labels }) => { + const newCustom = [...alertGroupLabels.custom]; + newCustom[customLabelIndexToShowTemplateEditor].value.name = alert_group_labels; + + setAlertGroupLabels({ + ...alertGroupLabels, + custom: newCustom, + }); + + setCustomLabelIndexToShowTemplateEditor(undefined); + }} + /> + )} + {showTemplateEditor && ( + 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 ( + + + + + { + 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 ( + { + onShowTemplateEditor(index); + }} + /> + } + onChange={(e: ChangeEvent) => { + 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, + }); + }} + /> + + + + + } + > + + + + ); +}; + export default IntegrationLabelsForm; diff --git a/grafana-plugin/src/containers/IntegrationTemplate/IntegrationTemplate.tsx b/grafana-plugin/src/containers/IntegrationTemplate/IntegrationTemplate.tsx index 7376ee96..98e98b71 100644 --- a/grafana-plugin/src/containers/IntegrationTemplate/IntegrationTemplate.tsx +++ b/grafana-plugin/src/containers/IntegrationTemplate/IntegrationTemplate.tsx @@ -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(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(() => { diff --git a/grafana-plugin/src/containers/Labels/Labels.tsx b/grafana-plugin/src/containers/Labels/Labels.tsx index 21c4d46a..60fc1324 100644 --- a/grafana-plugin/src/containers/Labels/Labels.tsx +++ b/grafana-plugin/src/containers/Labels/Labels.tsx @@ -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'; diff --git a/grafana-plugin/src/models/alert_receive_channel/alert_receive_channel.types.ts b/grafana-plugin/src/models/alert_receive_channel/alert_receive_channel.types.ts index ec366bf2..fd3ea806 100644 --- a/grafana-plugin/src/models/alert_receive_channel/alert_receive_channel.types.ts +++ b/grafana-plugin/src/models/alert_receive_channel/alert_receive_channel.types.ts @@ -49,7 +49,11 @@ export interface AlertReceiveChannel { allow_delete: boolean; deleted?: boolean; labels: LabelKeyValue[]; - alert_group_labels: { inheritable: Record }; + alert_group_labels: { + inheritable: Record; + custom: LabelKeyValue[]; + template: string; + }; } export interface AlertReceiveChannelChoice { diff --git a/grafana-plugin/yarn.lock b/grafana-plugin/yarn.lock index 2b225b09..3f55fb20 100644 --- a/grafana-plugin/yarn.lock +++ b/grafana-plugin/yarn.lock @@ -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"