diff --git a/CHANGELOG.md b/CHANGELOG.md index f8ee2920..a698488e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -29,6 +29,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added - Update plugin OnCaller role permissions ([#3145](https://github.com/grafana/oncall/pull/3145)) +- Add labels implementation for OnCall integrations under the feature flag ([#3014](https://github.com/grafana/oncall/pull/3014)) ### Fixed diff --git a/engine/apps/api/serializers/alert_receive_channel.py b/engine/apps/api/serializers/alert_receive_channel.py index abeb687b..07d4be8c 100644 --- a/engine/apps/api/serializers/alert_receive_channel.py +++ b/engine/apps/api/serializers/alert_receive_channel.py @@ -21,6 +21,7 @@ from common.jinja_templater import apply_jinja_template, jinja_template_env from common.jinja_templater.apply_jinja_template import JinjaTemplateWarning from .integration_heartbeat import IntegrationHeartBeatSerializer +from .labels import LabelsSerializerMixin def valid_jinja_template_for_serializer_method_field(template): @@ -32,7 +33,7 @@ def valid_jinja_template_for_serializer_method_field(template): pass -class AlertReceiveChannelSerializer(EagerLoadingMixin, serializers.ModelSerializer): +class AlertReceiveChannelSerializer(EagerLoadingMixin, LabelsSerializerMixin, serializers.ModelSerializer): id = serializers.CharField(read_only=True, source="public_primary_key") integration_url = serializers.ReadOnlyField() alert_count = serializers.SerializerMethodField() @@ -58,7 +59,7 @@ class AlertReceiveChannelSerializer(EagerLoadingMixin, serializers.ModelSerializ # integration heartbeat is in PREFETCH_RELATED not by mistake. # With using of select_related ORM builds strange join # which leads to incorrect heartbeat-alert_receive_channel binding in result - PREFETCH_RELATED = ["channel_filters", "integration_heartbeat"] + PREFETCH_RELATED = ["channel_filters", "integration_heartbeat", "labels", "labels__key", "labels__value"] SELECT_RELATED = ["organization", "author"] class Meta: @@ -93,6 +94,7 @@ class AlertReceiveChannelSerializer(EagerLoadingMixin, serializers.ModelSerializ "is_based_on_alertmanager", "inbound_email", "is_legacy", + "labels", ] read_only_fields = [ "created_at", @@ -125,6 +127,7 @@ class AlertReceiveChannelSerializer(EagerLoadingMixin, serializers.ModelSerializ if _integration.slug == integration: is_able_to_autoresolve = _integration.is_able_to_autoresolve + labels = validated_data.pop("labels", None) try: instance = AlertReceiveChannel.create( **validated_data, @@ -134,12 +137,15 @@ class AlertReceiveChannelSerializer(EagerLoadingMixin, serializers.ModelSerializ ) except AlertReceiveChannel.DuplicateDirectPagingError: raise BadRequest(detail=AlertReceiveChannel.DuplicateDirectPagingError.DETAIL) - + self.update_labels_association_if_needed(labels, instance, organization) return instance - def update(self, *args, **kwargs): + def update(self, instance, validated_data): + labels = validated_data.pop("labels", None) + organization = self.context["request"].auth.organization + self.update_labels_association_if_needed(labels, instance, organization) try: - return super().update(*args, **kwargs) + return super().update(instance, validated_data) except AlertReceiveChannel.DuplicateDirectPagingError: raise BadRequest(detail=AlertReceiveChannel.DuplicateDirectPagingError.DETAIL) diff --git a/engine/apps/api/serializers/labels.py b/engine/apps/api/serializers/labels.py new file mode 100644 index 00000000..b349059a --- /dev/null +++ b/engine/apps/api/serializers/labels.py @@ -0,0 +1,55 @@ +from rest_framework import serializers + +from apps.labels.models import AssociatedLabel, LabelKeyCache, LabelValueCache +from apps.labels.utils import is_labels_feature_enabled + + +class LabelKeySerializer(serializers.ModelSerializer): + id = serializers.CharField() + + class Meta: + model = LabelKeyCache + fields = ( + "id", + "name", + ) + + +class LabelValueSerializer(serializers.ModelSerializer): + id = serializers.CharField() + + class Meta: + model = LabelValueCache + fields = ( + "id", + "name", + ) + + +class LabelSerializer(serializers.Serializer): + key = LabelKeySerializer() + value = LabelValueSerializer() + + +class LabelKeyValuesSerializer(serializers.Serializer): + key = LabelKeySerializer() + values = LabelValueSerializer(many=True) + + +class LabelReprSerializer(serializers.Serializer): + name = serializers.CharField() + + +class LabelsSerializerMixin(serializers.Serializer): + labels = LabelSerializer(many=True, required=False) + + def validate_labels(self, labels): + if labels: + keys = {label["key"]["id"] for label in labels} + if len(keys) != len(labels): + raise serializers.ValidationError(detail="Duplicate label key") + return labels + + def update_labels_association_if_needed(self, labels, instance, organization): + if labels is not None and is_labels_feature_enabled(organization): + AssociatedLabel.update_association(labels, instance, organization) diff --git a/engine/apps/api/tests/test_alert_receive_channel.py b/engine/apps/api/tests/test_alert_receive_channel.py index 2539a1a0..04003de1 100644 --- a/engine/apps/api/tests/test_alert_receive_channel.py +++ b/engine/apps/api/tests/test_alert_receive_channel.py @@ -1213,3 +1213,117 @@ def test_alert_receive_channel_contact_points_wrong_integration( else: response = client.post(url, format="json", data={}, **make_user_auth_headers(user, token)) assert response.status_code == status.HTTP_400_BAD_REQUEST + + +@pytest.mark.django_db +def test_integration_filter_by_labels( + make_organization_and_user_with_plugin_token, + make_alert_receive_channel, + make_integration_label_association, + make_user_auth_headers, +): + organization, user, token = make_organization_and_user_with_plugin_token() + alert_receive_channel_1 = make_alert_receive_channel(organization) + alert_receive_channel_2 = make_alert_receive_channel(organization) + associated_label_1 = make_integration_label_association(organization, alert_receive_channel_1) + associated_label_2 = make_integration_label_association(organization, alert_receive_channel_1) + alert_receive_channel_2.labels.create( + key=associated_label_1.key, value=associated_label_1.value, organization=organization + ) + + client = APIClient() + url = reverse("api-internal:alert_receive_channel-list") + response = client.get( + f"{url}?label={associated_label_1.key_id}:{associated_label_1.value_id}", + content_type="application/json", + **make_user_auth_headers(user, token), + ) + + assert response.status_code == status.HTTP_200_OK + assert len(response.json()["results"]) == 2 + + response = client.get( + f"{url}?label={associated_label_1.key_id}:{associated_label_1.value_id}" + f"&label={associated_label_2.key_id}:{associated_label_2.value_id}", + content_type="application/json", + **make_user_auth_headers(user, token), + ) + + assert response.status_code == status.HTTP_200_OK + assert len(response.json()["results"]) == 1 + assert response.json()["results"][0]["id"] == alert_receive_channel_1.public_primary_key + + +@pytest.mark.django_db +def test_update_alert_receive_channel_labels( + make_organization_and_user_with_plugin_token, + make_alert_receive_channel, + make_integration_label_association, + make_user_auth_headers, +): + organization, user, token = make_organization_and_user_with_plugin_token() + alert_receive_channel = make_alert_receive_channel(organization) + client = APIClient() + + url = reverse("api-internal:alert_receive_channel-detail", kwargs={"pk": alert_receive_channel.public_primary_key}) + key_id = "testkey" + value_id = "testvalue" + data = {"labels": [{"key": {"id": key_id, "name": "test"}, "value": {"id": value_id, "name": "testv"}}]} + response = client.patch( + url, + data=json.dumps(data), + content_type="application/json", + **make_user_auth_headers(user, token), + ) + + alert_receive_channel.refresh_from_db() + + assert response.status_code == status.HTTP_200_OK + assert alert_receive_channel.labels.count() == 1 + label = alert_receive_channel.labels.first() + assert label.key_id == key_id + assert label.value_id == value_id + + response = client.patch( + url, + data=json.dumps({"labels": []}), + content_type="application/json", + **make_user_auth_headers(user, token), + ) + + alert_receive_channel.refresh_from_db() + + assert response.status_code == status.HTTP_200_OK + assert alert_receive_channel.labels.count() == 0 + + +@pytest.mark.django_db +def test_update_alert_receive_channel_labels_duplicate_key( + make_organization_and_user_with_plugin_token, + make_alert_receive_channel, + make_integration_label_association, + make_user_auth_headers, +): + organization, user, token = make_organization_and_user_with_plugin_token() + alert_receive_channel = make_alert_receive_channel(organization) + client = APIClient() + + url = reverse("api-internal:alert_receive_channel-detail", kwargs={"pk": alert_receive_channel.public_primary_key}) + key_id = "testkey" + data = { + "labels": [ + {"key": {"id": key_id, "name": "test"}, "value": {"id": "testvalue1", "name": "testv1"}}, + {"key": {"id": key_id, "name": "test"}, "value": {"id": "testvalue2", "name": "testv2"}}, + ] + } + response = client.patch( + url, + data=json.dumps(data), + content_type="application/json", + **make_user_auth_headers(user, token), + ) + + alert_receive_channel.refresh_from_db() + + assert response.status_code == status.HTTP_400_BAD_REQUEST + assert alert_receive_channel.labels.count() == 0 diff --git a/engine/apps/api/tests/test_labels.py b/engine/apps/api/tests/test_labels.py new file mode 100644 index 00000000..219c268e --- /dev/null +++ b/engine/apps/api/tests/test_labels.py @@ -0,0 +1,223 @@ +from unittest.mock import patch + +import pytest +from django.urls import reverse +from rest_framework import status +from rest_framework.test import APIClient + + +@patch( + "apps.labels.client.LabelsAPIClient.get_keys", + return_value=([{"name": "team", "id": "keyid123"}], {"status_code": status.HTTP_200_OK}), +) +@pytest.mark.django_db +def test_labels_get_keys( + mocked_get_labels_keys, + make_organization_and_user_with_plugin_token, + make_user_auth_headers, + make_alert_receive_channel, +): + organization, user, token = make_organization_and_user_with_plugin_token() + client = APIClient() + url = reverse("api-internal:get_keys") + response = client.get(url, format="json", **make_user_auth_headers(user, token)) + expected_result = [{"name": "team", "id": "keyid123"}] + + assert mocked_get_labels_keys.called + assert response.status_code == status.HTTP_200_OK + assert response.json() == expected_result + + +@patch( + "apps.labels.client.LabelsAPIClient.get_values", + return_value=( + {"key": {"id": "keyid123", "name": "team"}, "values": [{"id": "valueid123", "name": "yolo"}]}, + {"status_code": status.HTTP_200_OK}, + ), +) +@pytest.mark.django_db +def test_get_update_key_get( + mocked_get_values, + make_organization_and_user_with_plugin_token, + make_user_auth_headers, + make_alert_receive_channel, +): + organization, user, token = make_organization_and_user_with_plugin_token() + client = APIClient() + url = reverse("api-internal:get_update_key", kwargs={"key_id": "keyid123"}) + response = client.get(url, format="json", **make_user_auth_headers(user, token)) + expected_result = {"key": {"id": "keyid123", "name": "team"}, "values": [{"id": "valueid123", "name": "yolo"}]} + + assert mocked_get_values.called + assert response.status_code == status.HTTP_200_OK + assert response.json() == expected_result + + +@patch( + "apps.labels.client.LabelsAPIClient.rename_key", + return_value=( + {"key": {"id": "keyid123", "name": "team"}, "values": [{"id": "valueid123", "name": "yolo"}]}, + {"status_code": status.HTTP_200_OK}, + ), +) +@pytest.mark.django_db +def test_get_update_key_put( + mocked_rename_key, + make_organization_and_user_with_plugin_token, + make_user_auth_headers, + make_alert_receive_channel, +): + organization, user, token = make_organization_and_user_with_plugin_token() + client = APIClient() + url = reverse("api-internal:get_update_key", kwargs={"key_id": "keyid123"}) + data = {"name": "team"} + response = client.put(url, format="json", **make_user_auth_headers(user, token), data=data) + expected_result = {"key": {"id": "keyid123", "name": "team"}, "values": [{"id": "valueid123", "name": "yolo"}]} + + assert mocked_rename_key.called + assert response.status_code == status.HTTP_200_OK + assert response.json() == expected_result + + +@patch( + "apps.labels.client.LabelsAPIClient.add_value", + return_value=( + {"key": {"id": "keyid123", "name": "team"}, "values": [{"id": "valueid123", "name": "yolo"}]}, + {"status_code": status.HTTP_200_OK}, + ), +) +@pytest.mark.django_db +def test_add_value( + mocked_add_value, + make_organization_and_user_with_plugin_token, + make_user_auth_headers, + make_alert_receive_channel, +): + organization, user, token = make_organization_and_user_with_plugin_token() + client = APIClient() + url = reverse("api-internal:add_value", kwargs={"key_id": "keyid123"}) + data = {"name": "yolo"} + response = client.post(url, format="json", **make_user_auth_headers(user, token), data=data) + expected_result = {"key": {"id": "keyid123", "name": "team"}, "values": [{"id": "valueid123", "name": "yolo"}]} + + assert mocked_add_value.called + assert response.status_code == status.HTTP_200_OK + assert response.json() == expected_result + + +@patch( + "apps.labels.client.LabelsAPIClient.rename_value", + return_value=( + {"key": {"id": "keyid123", "name": "team"}, "values": [{"id": "valueid123", "name": "yolo"}]}, + {"status_code": status.HTTP_200_OK}, + ), +) +@pytest.mark.django_db +def test_rename_value( + mocked_rename_value, + make_organization_and_user_with_plugin_token, + make_user_auth_headers, + make_alert_receive_channel, +): + organization, user, token = make_organization_and_user_with_plugin_token() + client = APIClient() + url = reverse("api-internal:get_update_value", kwargs={"key_id": "keyid123", "value_id": "valueid123"}) + data = {"name": "yolo"} + response = client.put(url, format="json", **make_user_auth_headers(user, token), data=data) + expected_result = {"key": {"id": "keyid123", "name": "team"}, "values": [{"id": "valueid123", "name": "yolo"}]} + + assert mocked_rename_value.called + assert response.status_code == status.HTTP_200_OK + assert response.json() == expected_result + + +@patch( + "apps.labels.client.LabelsAPIClient.get_value", + return_value=( + {"id": "valueid123", "name": "yolo"}, + {"status_code": status.HTTP_200_OK}, + ), +) +@pytest.mark.django_db +def test_get_value( + mocked_get_value, + make_organization_and_user_with_plugin_token, + make_user_auth_headers, + make_alert_receive_channel, +): + organization, user, token = make_organization_and_user_with_plugin_token() + client = APIClient() + url = reverse("api-internal:get_update_value", kwargs={"key_id": "keyid123", "value_id": "valueid123"}) + response = client.get(url, format="json", **make_user_auth_headers(user, token)) + expected_result = {"id": "valueid123", "name": "yolo"} + + assert mocked_get_value.called + assert response.status_code == status.HTTP_200_OK + assert response.json() == expected_result + + +@patch( + "apps.labels.client.LabelsAPIClient.create_label", + return_value=( + {"key": {"id": "keyid123", "name": "team"}, "values": [{"id": "valueid123", "name": "yolo"}]}, + {"status_code": status.HTTP_201_CREATED}, + ), +) +@pytest.mark.django_db +def test_labels_create_label( + mocked_create_label, + make_organization_and_user_with_plugin_token, + make_user_auth_headers, + make_alert_receive_channel, +): + organization, user, token = make_organization_and_user_with_plugin_token() + client = APIClient() + url = reverse("api-internal:create_label") + data = {"key": {"name": "team"}, "values": [{"name": "yolo"}]} + expected_result = {"key": {"id": "keyid123", "name": "team"}, "values": [{"id": "valueid123", "name": "yolo"}]} + response = client.post(url, format="json", data=data, **make_user_auth_headers(user, token)) + + assert mocked_create_label.called + assert response.status_code == status.HTTP_201_CREATED + assert response.json() == expected_result + + +@pytest.mark.django_db +def test_labels_feature_false( + make_organization_and_user_with_plugin_token, + make_user_auth_headers, + make_alert_receive_channel, + settings, +): + setattr(settings, "FEATURE_LABELS_ENABLED", False) + + organization, user, token = make_organization_and_user_with_plugin_token() + client = APIClient() + + url = reverse("api-internal:get_keys") + response = client.get(url, format="json", **make_user_auth_headers(user, token)) + assert response.status_code == status.HTTP_404_NOT_FOUND + + url = reverse("api-internal:get_update_key", kwargs={"key_id": "keyid123"}) + response = client.get(url, format="json", **make_user_auth_headers(user, token)) + assert response.status_code == status.HTTP_404_NOT_FOUND + + url = reverse("api-internal:get_update_key", kwargs={"key_id": "keyid123"}) + response = client.put(url, format="json", **make_user_auth_headers(user, token), data={}) + assert response.status_code == status.HTTP_404_NOT_FOUND + + url = reverse("api-internal:add_value", kwargs={"key_id": "keyid123"}) + response = client.post(url, format="json", **make_user_auth_headers(user, token), data={}) + assert response.status_code == status.HTTP_404_NOT_FOUND + + url = reverse("api-internal:get_update_value", kwargs={"key_id": "keyid123", "value_id": "valueid123"}) + response = client.get(url, format="json", **make_user_auth_headers(user, token)) + assert response.status_code == status.HTTP_404_NOT_FOUND + + url = reverse("api-internal:get_update_value", kwargs={"key_id": "keyid123", "value_id": "valueid123"}) + response = client.put(url, format="json", **make_user_auth_headers(user, token), data={}) + assert response.status_code == status.HTTP_404_NOT_FOUND + + url = reverse("api-internal:create_label") + response = client.post(url, format="json", data={}, **make_user_auth_headers(user, token)) + assert response.status_code == status.HTTP_404_NOT_FOUND diff --git a/engine/apps/api/urls.py b/engine/apps/api/urls.py index d5f3dc43..7bd3b703 100644 --- a/engine/apps/api/urls.py +++ b/engine/apps/api/urls.py @@ -13,6 +13,7 @@ from .views.escalation_chain import EscalationChainViewSet from .views.escalation_policy import EscalationPolicyView from .views.features import FeaturesAPIView from .views.integration_heartbeat import IntegrationHeartBeatView +from .views.labels import LabelsViewSet from .views.live_setting import LiveSettingViewSet from .views.on_call_shifts import OnCallShiftView from .views.organization import ( @@ -110,3 +111,21 @@ urlpatterns += [ path(r"login//", auth.overridden_login_slack_auth, name="slack-auth"), path(r"complete//", auth.overridden_complete_slack_auth, name="complete-slack-auth"), ] + +urlpatterns += [ + re_path(r"^labels/keys/?$", LabelsViewSet.as_view({"get": "get_keys"}), name="get_keys"), + re_path( + r"^labels/id/(?P[\w\-]+)/?$", + LabelsViewSet.as_view({"get": "get_key", "put": "rename_key"}), + name="get_update_key", + ), + re_path( + r"^labels/id/(?P[\w\-]+)/values/?$", LabelsViewSet.as_view({"post": "add_value"}), name="add_value" + ), + re_path( + r"^labels/id/(?P[\w\-]+)/values/(?P[\w\-]+)/?$", + LabelsViewSet.as_view({"put": "rename_value", "get": "get_value"}), + name="get_update_value", + ), + re_path(r"^labels/?$", LabelsViewSet.as_view({"post": "create_label"}), name="create_label"), +] diff --git a/engine/apps/api/views/alert_receive_channel.py b/engine/apps/api/views/alert_receive_channel.py index d58de905..14e1a9ce 100644 --- a/engine/apps/api/views/alert_receive_channel.py +++ b/engine/apps/api/views/alert_receive_channel.py @@ -18,8 +18,10 @@ from apps.api.serializers.alert_receive_channel import ( FilterAlertReceiveChannelSerializer, ) from apps.api.throttlers import DemoAlertThrottler +from apps.api.views.labels import LabelsAssociatingMixin from apps.auth_token.auth import PluginAuthentication from apps.integrations.legacy_prefix import has_legacy_prefix, remove_legacy_prefix +from apps.labels.utils import is_labels_feature_enabled from apps.mobile_app.auth import MobileAppAuthTokenAuthentication from common.api_helpers.exceptions import BadRequest from common.api_helpers.filters import ByTeamModelFieldFilterMixin, TeamModelMultipleChoiceFilter @@ -71,6 +73,7 @@ class AlertReceiveChannelView( PublicPrimaryKeyMixin, FilterSerializerMixin, UpdateSerializerMixin, + LabelsAssociatingMixin, ModelViewSet, ): authentication_classes = ( @@ -153,6 +156,8 @@ class AlertReceiveChannelView( if not ignore_filtering_by_available_teams: queryset = queryset.filter(*self.available_teams_lookup_args).distinct() + queryset = self.filter_by_labels(queryset) + return queryset def paginate_queryset(self, queryset): @@ -259,6 +264,7 @@ class AlertReceiveChannelView( @action(methods=["get"], detail=False) def filters(self, request): + organization = self.request.auth.organization filter_name = request.query_params.get("search", None) api_root = "/api/internal/v1/" @@ -277,6 +283,15 @@ class AlertReceiveChannelView( }, ] + if is_labels_feature_enabled(organization): + filter_options.append( + { + "name": "label", + "display_name": "Label", + "type": "labels", + } + ) + if filter_name is not None: filter_options = list(filter(lambda f: filter_name in f["name"], filter_options)) diff --git a/engine/apps/api/views/features.py b/engine/apps/api/views/features.py index d0c4334c..c06d2514 100644 --- a/engine/apps/api/views/features.py +++ b/engine/apps/api/views/features.py @@ -6,6 +6,7 @@ from rest_framework.views import APIView from apps.auth_token.auth import PluginAuthentication from apps.base.utils import live_settings +from apps.labels.utils import is_labels_feature_enabled FEATURE_SLACK = "slack" FEATURE_TELEGRAM = "telegram" @@ -13,6 +14,7 @@ FEATURE_LIVE_SETTINGS = "live_settings" FEATURE_GRAFANA_CLOUD_NOTIFICATIONS = "grafana_cloud_notifications" FEATURE_GRAFANA_CLOUD_CONNECTION = "grafana_cloud_connection" FEATURE_GRAFANA_ALERTING_V2 = "grafana_alerting_v2" +FEATURE_LABELS = "labels" class FeaturesAPIView(APIView): @@ -57,4 +59,7 @@ class FeaturesAPIView(APIView): if settings.FEATURE_GRAFANA_ALERTING_V2_ENABLED: enabled_features.append(FEATURE_GRAFANA_ALERTING_V2) + if is_labels_feature_enabled(self.request.auth.organization): + enabled_features.append(FEATURE_LABELS) + return enabled_features diff --git a/engine/apps/api/views/labels.py b/engine/apps/api/views/labels.py new file mode 100644 index 00000000..d20f9d03 --- /dev/null +++ b/engine/apps/api/views/labels.py @@ -0,0 +1,155 @@ +import logging + +from drf_spectacular.utils import extend_schema, inline_serializer +from rest_framework.exceptions import NotFound +from rest_framework.permissions import IsAuthenticated +from rest_framework.response import Response +from rest_framework.viewsets import ViewSet + +from apps.alerts.models import AlertReceiveChannel +from apps.api.serializers.labels import ( + LabelKeySerializer, + LabelKeyValuesSerializer, + LabelReprSerializer, + LabelValueSerializer, +) +from apps.auth_token.auth import PluginAuthentication +from apps.labels.client import LabelsAPIClient +from apps.labels.tasks import update_instances_labels_cache, update_labels_cache +from apps.labels.utils import is_labels_feature_enabled +from common.api_helpers.exceptions import BadRequest + +logger = logging.getLogger(__name__) + + +class LabelsViewSet(ViewSet): + """ + Proxy requests to labels-app to create/update labels + """ + + authentication_classes = (PluginAuthentication,) + permission_classes = (IsAuthenticated,) + # todo: permissions on create/update labels + + def initial(self, request, *args, **kwargs): + super().initial(request, *args, **kwargs) + if not is_labels_feature_enabled(self.request.auth.organization): + raise NotFound + + @extend_schema(responses=LabelKeySerializer(many=True)) + def get_keys(self, request): + """List of labels keys""" + organization = self.request.auth.organization + result, response_info = LabelsAPIClient(organization.grafana_url, organization.api_token).get_keys() + return Response(result, status=response_info["status_code"]) + + @extend_schema(responses=LabelKeyValuesSerializer) + def get_key(self, request, key_id): + """Key with the list of values""" + organization = self.request.auth.organization + result, response_info = LabelsAPIClient(organization.grafana_url, organization.api_token).get_values(key_id) + self._update_labels_cache(result) + return Response(result, status=response_info["status_code"]) + + @extend_schema(responses=LabelValueSerializer) + def get_value(self, request, key_id, value_id): + """Value name""" + organization = self.request.auth.organization + result, response_info = LabelsAPIClient(organization.grafana_url, organization.api_token).get_value( + key_id, value_id + ) + self._update_labels_cache(result) + return Response(result, status=response_info["status_code"]) + + @extend_schema(request=LabelReprSerializer, responses=LabelKeyValuesSerializer) + def rename_key(self, request, key_id): + """Rename the key""" + organization = self.request.auth.organization + label_data = self.request.data + if not label_data: + raise BadRequest(detail="name is required") + result, response_info = LabelsAPIClient(organization.grafana_url, organization.api_token).rename_key( + key_id, label_data + ) + self._update_labels_cache(result) + return Response(result, status=response_info["status_code"]) + + @extend_schema( + request=inline_serializer( + name="LabelCreateSerializer", + fields={"key": LabelReprSerializer(), "values": LabelReprSerializer(many=True)}, + many=True, + ), + responses={201: LabelKeyValuesSerializer}, + ) + def create_label(self, request): + """Create a new label key with values(Optional)""" + organization = self.request.auth.organization + label_data = self.request.data + if not label_data: + raise BadRequest(detail="key data (name, values) is required") + result, response_info = LabelsAPIClient(organization.grafana_url, organization.api_token).create_label( + label_data + ) + return Response(result, status=response_info["status_code"]) + + @extend_schema(request=LabelReprSerializer, responses=LabelKeyValuesSerializer) + def add_value(self, request, key_id): + """Add a new value to the key""" + organization = self.request.auth.organization + label_data = self.request.data + if not label_data: + raise BadRequest(detail="name is required") + result, response_info = LabelsAPIClient(organization.grafana_url, organization.api_token).add_value( + key_id, label_data + ) + return Response(result, status=response_info["status_code"]) + + @extend_schema(request=LabelReprSerializer, responses=LabelKeyValuesSerializer) + def rename_value(self, request, key_id, value_id): + """Rename the value""" + organization = self.request.auth.organization + label_data = self.request.data + if not label_data: + raise BadRequest(detail="name is required") + result, response_info = LabelsAPIClient(organization.grafana_url, organization.api_token).rename_value( + key_id, value_id, label_data + ) + self._update_labels_cache(result) + return Response(result, status=response_info["status_code"]) + + def _update_labels_cache(self, label_data): + if not label_data: + return + serializer = LabelKeyValuesSerializer(data=label_data) + if serializer.is_valid(): + update_labels_cache.apply_async((label_data,)) + + +class LabelsAssociatingMixin: # use for labelable objects views (ex. AlertReceiveChannelView) + def filter_by_labels(self, queryset): + """Call this method in `get_queryset()` to add filtering by labels""" + if not is_labels_feature_enabled(self.request.auth.organization): + return queryset + labels = self.request.query_params.getlist("label") # ["key1:value1", "key2:value2"] + if not labels: + return queryset + for label in labels: + label_data = label.split(":") + if len(label_data) != 2: # ["key1", "value1"] + continue + key_id, value_id = label_data + queryset &= AlertReceiveChannel.objects_with_deleted.filter( + labels__key_id=key_id, labels__value_id=value_id + ).distinct() + return queryset + + def paginate_queryset(self, queryset): + organization = self.request.auth.organization + data = super().paginate_queryset(queryset) + if not is_labels_feature_enabled(self.request.auth.organization): + return data + ids = [d.id for d in data] + logger.info(f"start update_instances_labels_cache for ids: {ids}") + update_instances_labels_cache.apply_async((organization.id, ids, self.model.__name__)) + return data diff --git a/engine/apps/grafana_plugin/helpers/client.py b/engine/apps/grafana_plugin/helpers/client.py index 5f95da11..6597dd54 100644 --- a/engine/apps/grafana_plugin/helpers/client.py +++ b/engine/apps/grafana_plugin/helpers/client.py @@ -106,6 +106,9 @@ class APIClient: def api_post(self, endpoint: str, body: typing.Optional[typing.Dict] = None, **kwargs) -> APIClientResponse[_RT]: return self.call_api(endpoint, requests.post, body, **kwargs) + def api_put(self, endpoint: str, body: typing.Optional[typing.Dict] = None, **kwargs) -> APIClientResponse[_RT]: + return self.call_api(endpoint, requests.put, body, **kwargs) + def call_api( self, endpoint: str, http_method: HttpMethod, body: typing.Optional[typing.Dict] = None, **kwargs ) -> APIClientResponse[_RT]: diff --git a/engine/apps/labels/__init__.py b/engine/apps/labels/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/engine/apps/labels/client.py b/engine/apps/labels/client.py new file mode 100644 index 00000000..66718b0d --- /dev/null +++ b/engine/apps/labels/client.py @@ -0,0 +1,42 @@ +import typing +from urllib.parse import urljoin + +from apps.grafana_plugin.helpers.client import APIClient + +if typing.TYPE_CHECKING: + from apps.labels.utils import LabelKeyData, LabelsKeysData, LabelUpdateParam + + +class LabelsAPIClient(APIClient): + LABELS_API_URL = "/api/plugins/grafana-labels/resources/v1/labels/" + + def __init__(self, api_url: str, api_token: str) -> None: + super().__init__(api_url, api_token) + self.api_url = urljoin(api_url, self.LABELS_API_URL) + + def create_label(self, label_data: "LabelUpdateParam") -> typing.Tuple[typing.Optional["LabelKeyData"], dict]: + return self.api_post("", label_data) + + def get_keys(self) -> typing.Tuple[typing.Optional["LabelsKeysData"], dict]: + return self.api_get("keys") + + def get_values(self, key_id: str) -> typing.Tuple[typing.Optional["LabelKeyData"], dict]: + return self.api_get(f"id/{key_id}") + + def get_value(self, key_id: str, value_id: str) -> typing.Tuple[typing.Optional["LabelKeyData"], dict]: + return self.api_get(f"id/{key_id}/values/{value_id}") + + def add_value( + self, key_id: str, label_data: "LabelUpdateParam" + ) -> typing.Tuple[typing.Optional["LabelKeyData"], dict]: + return self.api_post(f"id/{key_id}/values", label_data) + + def rename_key( + self, key_id: str, label_data: "LabelUpdateParam" + ) -> typing.Tuple[typing.Optional["LabelKeyData"], dict]: + return self.api_put(f"id/{key_id}", label_data) + + def rename_value( + self, key_id: str, value_id: str, label_data: "LabelUpdateParam" + ) -> typing.Tuple[typing.Optional["LabelKeyData"], dict]: + return self.api_put(f"id/{key_id}/values/{value_id}", label_data) diff --git a/engine/apps/labels/migrations/0001_initial.py b/engine/apps/labels/migrations/0001_initial.py new file mode 100644 index 00000000..1857bbae --- /dev/null +++ b/engine/apps/labels/migrations/0001_initial.py @@ -0,0 +1,48 @@ +# Generated by Django 3.2.20 on 2023-09-26 09:22 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ('alerts', '0032_remove_alertgroup_slack_message_state'), + ('user_management', '0014_auto_20230728_0802'), + ] + + operations = [ + migrations.CreateModel( + name='LabelKeyCache', + fields=[ + ('id', models.CharField(editable=False, max_length=36, primary_key=True, serialize=False)), + ('name', models.CharField(max_length=200)), + ('last_synced', models.DateTimeField(auto_now=True)), + ('organization', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='user_management.organization')), + ], + ), + migrations.CreateModel( + name='LabelValueCache', + fields=[ + ('id', models.CharField(editable=False, max_length=36, primary_key=True, serialize=False)), + ('name', models.CharField(max_length=200)), + ('last_synced', models.DateTimeField(auto_now=True)), + ('key', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='values', to='labels.labelkeycache')), + ], + ), + migrations.CreateModel( + name='AlertReceiveChannelAssociatedLabel', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('alert_receive_channel', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='labels', to='alerts.alertreceivechannel')), + ('key', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='labels.labelkeycache')), + ('organization', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='labels', to='user_management.organization')), + ('value', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='labels.labelvaluecache')), + ], + options={ + 'unique_together': {('key_id', 'value_id', 'alert_receive_channel_id')}, + }, + ), + ] diff --git a/engine/apps/labels/migrations/__init__.py b/engine/apps/labels/migrations/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/engine/apps/labels/models.py b/engine/apps/labels/models.py new file mode 100644 index 00000000..6d753572 --- /dev/null +++ b/engine/apps/labels/models.py @@ -0,0 +1,114 @@ +import typing + +from django.db import models +from django.utils import timezone + +from apps.labels.tasks import update_labels_cache +from apps.labels.utils import LABEL_OUTDATED_TIMEOUT_MINUTES, LabelsData + +if typing.TYPE_CHECKING: + from apps.user_management.models import Organization + + +class LabelKeyCache(models.Model): + id = models.CharField(primary_key=True, editable=False, max_length=36) + name = models.CharField(max_length=200) + organization = models.ForeignKey("user_management.Organization", on_delete=models.CASCADE) + last_synced = models.DateTimeField(auto_now=True) + + @property + def is_outdated(self) -> bool: + return timezone.now() - self.last_synced > timezone.timedelta(minutes=LABEL_OUTDATED_TIMEOUT_MINUTES) + + +class LabelValueCache(models.Model): + id = models.CharField(primary_key=True, editable=False, max_length=36) + name = models.CharField(max_length=200) + key = models.ForeignKey("labels.LabelKeyCache", on_delete=models.CASCADE, related_name="values") + last_synced = models.DateTimeField(auto_now=True) + + @property + def is_outdated(self) -> bool: + return timezone.now() - self.last_synced > timezone.timedelta(minutes=LABEL_OUTDATED_TIMEOUT_MINUTES) + + +class AssociatedLabel(models.Model): + """ + Abstract model, is used to keep information about label association with other instances + (integrations, schedules, etc.). To add ability to associate labels with a type of instances , + inhere this model and add a foreign key to the instance model. + + Attention: add `AssociatedLabel` to the end of the name of inheritor (example: AlertReceiveChannelAssociatedLabel) + """ + + key = models.ForeignKey(LabelKeyCache, on_delete=models.CASCADE) + value = models.ForeignKey(LabelValueCache, on_delete=models.CASCADE) + organization = models.ForeignKey("user_management.Organization", on_delete=models.CASCADE, related_name="labels") + + class Meta: + abstract = True + + @staticmethod + def update_association(labels_data: "LabelsData", instance: models.Model, organization: "Organization") -> None: + """ + Update label associations for selected instance: delete associations with labels that are not in `labels_data`, + create new associations and labels, if needed. + Then call celery task to update cache for labels from `labels_data` + + instance: the model instance that the labels are associated with (e.g. AlertReceiveChannel instance) + """ + labels_data_keys = {label["key"]["id"]: label["key"]["name"] for label in labels_data} + labels_data_values = {label["value"]["id"]: label["value"]["name"] for label in labels_data} + + # delete associations with labels that are not presented in labels_data + instance.labels.exclude(key_id__in=labels_data_keys.keys(), value_id__in=labels_data_values.keys()).delete() + + labels_keys = [] + labels_values = [] + labels_associations = [] + + for label_data in labels_data: + key_id = label_data["key"]["id"] + key_name = label_data["key"]["name"] + value_id = label_data["value"]["id"] + value_name = label_data["value"]["name"] + + label_key = LabelKeyCache(id=key_id, name=key_name, organization=organization) + labels_keys.append(label_key) + + label_value = LabelValueCache(id=value_id, name=value_name, key_id=key_id) + labels_values.append(label_value) + associated_instance = {instance.labels.field.name: instance} + labels_associations.append( + instance.labels.model( + key_id=key_id, value_id=value_id, organization=organization, **associated_instance + ) + ) + + # create labels cache and associations that don't exist + LabelKeyCache.objects.bulk_create(labels_keys, ignore_conflicts=True, batch_size=5000) + LabelValueCache.objects.bulk_create(labels_values, ignore_conflicts=True, batch_size=5000) + instance.labels.model.objects.bulk_create(labels_associations, ignore_conflicts=True, batch_size=5000) + + update_labels_cache.apply_async((labels_data,)) + + @staticmethod + def get_associating_label_field_name() -> str: + """Returns ForeignKey field name for the associated model""" + raise NotImplementedError + + +class AlertReceiveChannelAssociatedLabel(AssociatedLabel): + """Keeps information about label association with alert receive channel instances""" + + alert_receive_channel = models.ForeignKey( + "alerts.AlertReceiveChannel", on_delete=models.CASCADE, related_name="labels" + ) + + class Meta: + unique_together = ["key_id", "value_id", "alert_receive_channel_id"] + + @staticmethod + def get_associating_label_field_name() -> str: + """Returns ForeignKey field name for the associated model""" + return "alert_receive_channel" diff --git a/engine/apps/labels/tasks.py b/engine/apps/labels/tasks.py new file mode 100644 index 00000000..65c7c550 --- /dev/null +++ b/engine/apps/labels/tasks.py @@ -0,0 +1,91 @@ +import logging +import typing + +from celery.utils.log import get_task_logger +from django.conf import settings +from django.utils import timezone + +from apps.labels.client import LabelsAPIClient +from apps.labels.utils import LABEL_OUTDATED_TIMEOUT_MINUTES, LabelKeyData, LabelsData, get_associating_label_model +from apps.user_management.models import Organization +from common.custom_celery_tasks import shared_dedicated_queue_retry_task + +logger = get_task_logger(__name__) +logger.setLevel(logging.DEBUG) + + +class ValueData(typing.TypedDict): + value_name: str + key_name: str + + +def unify_labels_data(labels_data: LabelsData | LabelKeyData) -> typing.Dict[str, ValueData]: + values_data: typing.Dict[str, ValueData] + if isinstance(labels_data, list): # LabelsData + values_data = { + label["value"]["id"]: {"value_name": label["value"]["name"], "key_name": label["key"]["name"]} + for label in labels_data + } + else: # LabelKeyData + values_data = { + label["id"]: {"value_name": label["name"], "key_name": labels_data["key"]["name"]} + for label in labels_data["values"] + } + return values_data + + +@shared_dedicated_queue_retry_task( + autoretry_for=(Exception,), retry_backoff=True, max_retries=1 if settings.DEBUG else None +) +def update_labels_cache(labels_data: LabelsData | LabelKeyData): + from apps.labels.models import LabelKeyCache, LabelValueCache + + values_data: typing.Dict[str, ValueData] = unify_labels_data(labels_data) + values = LabelValueCache.objects.filter(id__in=values_data).select_related("key") + now = timezone.now() + + if not values: + return + + keys_to_update = set() + + for value in values: + if value.name != values_data[value.id]["value_name"]: + value.name = values_data[value.id]["value_name"] + value.last_synced = now + + if value.key.name != values_data[value.id]["key_name"]: + value.key.name = values_data[value.id]["key_name"] + value.key.last_synced = now + keys_to_update.add(value.key) + + LabelKeyCache.objects.bulk_update(keys_to_update, fields=["name", "last_synced"]) + LabelValueCache.objects.bulk_update(values, fields=["name", "last_synced"]) + + +@shared_dedicated_queue_retry_task( + autoretry_for=(Exception,), retry_backoff=True, max_retries=1 if settings.DEBUG else 10 +) +def update_instances_labels_cache(organization_id: int, instance_ids: typing.List[int], instance_model_name: str): + from apps.labels.models import LabelValueCache + + now = timezone.now() + organization = Organization.objects.get(id=organization_id) + + model = get_associating_label_model(instance_model_name) + field_name = model.get_associating_label_field_name() + associated_instances = {f"{field_name}_id__in": instance_ids} + values_ids = model.objects.filter(**associated_instances).values_list("value_id", flat=True) + outdated_last_synced = now - timezone.timedelta(minutes=LABEL_OUTDATED_TIMEOUT_MINUTES) + values = LabelValueCache.objects.filter(id__in=values_ids, last_synced__lte=outdated_last_synced) + + if not values: + return + + keys_ids = set(value.key_id for value in values) + + client = LabelsAPIClient(organization.grafana_url, organization.api_token) + for key_id in keys_ids: + label_data, _ = client.get_values(key_id) + if label_data: + update_labels_cache.apply_async((label_data,)) diff --git a/engine/apps/labels/tests/__init__.py b/engine/apps/labels/tests/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/engine/apps/labels/tests/factories.py b/engine/apps/labels/tests/factories.py new file mode 100644 index 00000000..db5aa8bd --- /dev/null +++ b/engine/apps/labels/tests/factories.py @@ -0,0 +1,25 @@ +import factory + +from apps.labels.models import AlertReceiveChannelAssociatedLabel, LabelKeyCache, LabelValueCache +from common.utils import UniqueFaker + + +class LabelKeyFactory(factory.DjangoModelFactory): + id = UniqueFaker("word") + name = UniqueFaker("word") + + class Meta: + model = LabelKeyCache + + +class LabelValueFactory(factory.DjangoModelFactory): + id = UniqueFaker("word") + name = UniqueFaker("word") + + class Meta: + model = LabelValueCache + + +class AlertReceiveChannelAssociatedLabelFactory(factory.DjangoModelFactory): + class Meta: + model = AlertReceiveChannelAssociatedLabel diff --git a/engine/apps/labels/tests/test_labels.py b/engine/apps/labels/tests/test_labels.py new file mode 100644 index 00000000..78b3790a --- /dev/null +++ b/engine/apps/labels/tests/test_labels.py @@ -0,0 +1,89 @@ +import pytest + +from apps.alerts.models import AlertReceiveChannel +from apps.labels.models import AlertReceiveChannelAssociatedLabel, AssociatedLabel, LabelValueCache +from apps.labels.utils import get_associating_label_model + + +@pytest.mark.django_db +def test_label_associate_new_label(make_organization, make_alert_receive_channel): + organization = make_organization() + alert_receive_channel = make_alert_receive_channel(organization) + label_key_id = "testkeyid" + label_value_id = "testvalueid" + labels_data = [ + { + "key": {"id": label_key_id, "name": "testkey"}, + "value": {"id": label_value_id, "name": "testvalue"}, + } + ] + + assert not alert_receive_channel.labels.exists() + assert not LabelValueCache.objects.filter(key_id=label_key_id, id=label_value_id).exists() + + AssociatedLabel.update_association(labels_data, alert_receive_channel, organization) + assert len(alert_receive_channel.labels.all()) == 1 + assert alert_receive_channel.labels.get(key_id=label_key_id, value_id=label_value_id) + + +@pytest.mark.django_db +def test_label_associate_existing_label(make_label_key_and_value, make_organization, make_alert_receive_channel): + organization = make_organization() + alert_receive_channel = make_alert_receive_channel(organization) + label_key, label_value = make_label_key_and_value(organization) + labels_data = [ + { + "key": {"id": label_key.id, "name": label_key.name}, + "value": {"id": label_value.id, "name": label_value.name}, + } + ] + assert not alert_receive_channel.labels.exists() + AssociatedLabel.update_association(labels_data, alert_receive_channel, organization) + assert len(alert_receive_channel.labels.all()) == 1 + assert alert_receive_channel.labels.filter(key=label_key, value=label_value).exists() + + +@pytest.mark.django_db +def test_label_update_association_by_removing_label( + make_integration_label_association, make_organization, make_alert_receive_channel +): + organization = make_organization() + alert_receive_channel = make_alert_receive_channel(organization) + label_association_1 = make_integration_label_association(organization, alert_receive_channel) + label_association_2 = make_integration_label_association(organization, alert_receive_channel) + labels_data = [ + { + "key": {"id": label_association_1.key_id, "name": label_association_1.key.name}, + "value": {"id": label_association_1.value_id, "name": label_association_1.value.name}, + } + ] + + assert len(alert_receive_channel.labels.all()) == 2 + assert alert_receive_channel.labels.filter( + key=label_association_1.key_id, value=label_association_1.value_id + ).exists() + assert alert_receive_channel.labels.filter( + key=label_association_2.key_id, value=label_association_2.value_id + ).exists() + + # update labels association by removing label_association_2 + AssociatedLabel.update_association(labels_data, alert_receive_channel, organization) + assert len(alert_receive_channel.labels.all()) == 1 + assert alert_receive_channel.labels.filter( + key=label_association_1.key_id, value=label_association_1.value_id + ).exists() + assert not alert_receive_channel.labels.filter( + key=label_association_2.key_id, value=label_association_2.value_id + ).exists() + + +@pytest.mark.django_db +def test_get_associating_label_model(): + model_name = AlertReceiveChannel.__name__ + expected_result = AlertReceiveChannelAssociatedLabel + result = get_associating_label_model(model_name) + assert result == expected_result + + wrong_model_name = "SomeModel" + with pytest.raises(LookupError): + get_associating_label_model(wrong_model_name) diff --git a/engine/apps/labels/tests/test_labels_cache.py b/engine/apps/labels/tests/test_labels_cache.py new file mode 100644 index 00000000..6e895707 --- /dev/null +++ b/engine/apps/labels/tests/test_labels_cache.py @@ -0,0 +1,136 @@ +from unittest.mock import call, patch + +import pytest +from django.utils import timezone + +from apps.labels.models import LabelKeyCache, LabelValueCache +from apps.labels.tasks import update_instances_labels_cache, update_labels_cache +from apps.labels.utils import LABEL_OUTDATED_TIMEOUT_MINUTES + + +@pytest.mark.django_db +def test_update_labels_cache_for_key(make_organization, make_label_key_and_value, make_label_value): + organization = make_organization() + label_key, label_value1 = make_label_key_and_value(organization) + label_value2 = make_label_value(label_key) + new_key_name = "updatekeyname" + new_value1_name = "updatevalue1name" + old_value2_name = label_value2.name + last_synced = label_key.last_synced + + label_data = { + "key": {"id": label_key.id, "name": new_key_name}, + "values": [{"id": label_value1.id, "name": new_value1_name}, {"id": label_value2.id, "name": old_value2_name}], + } + assert label_key.name != new_key_name + assert label_value1.name != new_value1_name + + update_labels_cache(label_data) + + label_key.refresh_from_db() + label_value1.refresh_from_db() + label_value2.refresh_from_db() + + for label_cache in (label_key, label_value1, label_value2): + assert label_cache.last_synced > last_synced + + assert label_key.name == new_key_name + assert label_value1.name == new_value1_name + assert label_value2.name == old_value2_name + + +@pytest.mark.django_db +def test_update_labels_cache(make_organization, make_label_key_and_value, make_label_value): + organization = make_organization() + + label_key1, label_value1_1 = make_label_key_and_value(organization) + label_key2, label_value2_1 = make_label_key_and_value(organization) + label_value2_2 = make_label_value(label_key2) + new_key1_name = "updatekey1name" + new_value1_1_name = "updatevalue11name" + old_key2_name = label_key2.name + old_value2_1_name = label_value2_1.name + new_value2_2_name = "updatevalue22name" + last_synced = label_key1.last_synced + + labels_data = [ + { + "key": {"id": label_key1.id, "name": new_key1_name}, + "value": {"id": label_value1_1.id, "name": new_value1_1_name}, + }, + { + "key": {"id": label_key2.id, "name": old_key2_name}, + "value": {"id": label_value2_1.id, "name": old_value2_1_name}, + }, + { + "key": {"id": label_key2.id, "name": old_key2_name}, + "value": {"id": label_value2_2.id, "name": new_value2_2_name}, + }, + ] + + assert label_key1.name != new_key1_name + assert label_value1_1.name != new_value1_1_name + assert label_value2_2.name != new_value2_2_name + + update_labels_cache(labels_data) + + for label_cache in (label_key1, label_key2, label_value1_1, label_value2_1, label_value2_2): + label_cache.refresh_from_db() + assert label_cache.last_synced > last_synced + + assert label_key1.name == new_key1_name + assert label_value1_1.name == new_value1_1_name + + assert label_key2.name == old_key2_name + assert label_value2_1.name == old_value2_1_name + assert label_value2_2.name == new_value2_2_name + + +@pytest.mark.django_db +def test_update_instances_labels_cache_recently_synced( + make_organization, make_alert_receive_channel, make_integration_label_association +): + organization = make_organization() + alert_receive_channel = make_alert_receive_channel(organization) + label_association = make_integration_label_association(organization, alert_receive_channel) + + assert not label_association.key.is_outdated + assert not label_association.value.is_outdated + + with patch("apps.labels.client.LabelsAPIClient.get_values") as mock_get_values: + with patch("apps.labels.tasks.update_labels_cache.apply_async") as mock_update_cache: + update_instances_labels_cache( + organization.id, [alert_receive_channel.id], alert_receive_channel._meta.model.__name__ + ) + assert not mock_get_values.called + assert not mock_update_cache.called + + +@pytest.mark.django_db +def test_update_instances_labels_cache_outdated( + make_organization, make_alert_receive_channel, make_integration_label_association +): + organization = make_organization() + alert_receive_channel = make_alert_receive_channel(organization) + label_association = make_integration_label_association(organization, alert_receive_channel) + outdated_last_synced = timezone.now() - timezone.timedelta(minutes=LABEL_OUTDATED_TIMEOUT_MINUTES + 1) + + LabelKeyCache.objects.filter(id=label_association.key_id).update(last_synced=outdated_last_synced) + LabelValueCache.objects.filter(id=label_association.value_id).update(last_synced=outdated_last_synced) + label_association.refresh_from_db() + assert label_association.key.is_outdated + assert label_association.value.is_outdated + + label_data = { + "key": {"id": label_association.key.id, "name": label_association.key.name}, + "values": [{"id": label_association.value.id, "name": label_association.value.name}], + } + + with patch("apps.labels.client.LabelsAPIClient.get_values", return_value=(label_data, None)) as mock_get_values: + with patch("apps.labels.tasks.update_labels_cache.apply_async") as mock_update_cache: + update_instances_labels_cache( + organization.id, [alert_receive_channel.id], alert_receive_channel._meta.model.__name__ + ) + assert mock_get_values.called + assert mock_update_cache.called + assert mock_update_cache.call_args == call((label_data,)) diff --git a/engine/apps/labels/utils.py b/engine/apps/labels/utils.py new file mode 100644 index 00000000..b4acbf23 --- /dev/null +++ b/engine/apps/labels/utils.py @@ -0,0 +1,54 @@ +import logging +import typing + +from django.apps import apps # noqa: I251 +from django.conf import settings + +if typing.TYPE_CHECKING: + from apps.labels.models import AssociatedLabel + +logger = logging.getLogger(__name__) + +LABEL_OUTDATED_TIMEOUT_MINUTES = 30 +ASSOCIATED_MODEL_NAME = "AssociatedLabel" + + +class LabelUpdateParam(typing.TypedDict): + name: str + + +class LabelParams(typing.TypedDict): + id: str + name: str + + +class LabelData(typing.TypedDict): + key: LabelParams + value: LabelParams + + +class LabelKeyData(typing.TypedDict): + key: LabelParams + values: typing.List[LabelParams] + + +LabelsData = typing.List[LabelData] +LabelsKeysData = typing.List[LabelParams] + + +def get_associating_label_model(obj_model_name: str) -> typing.Type["AssociatedLabel"]: + associating_label_model_name = obj_model_name + ASSOCIATED_MODEL_NAME + label_model = apps.get_model("labels", associating_label_model_name) + return label_model + + +def is_labels_feature_enabled(organization) -> bool: + # check FEATURE_LABELS_ENABLED in settings + # checking labels feature flag per organization will be added later + + logger.info( + "is_labels_feature_enabled: " + f"FEATURE_LABELS_ENABLED={settings.FEATURE_LABELS_ENABLED} " + f"organization={organization.id}" + ) + return settings.FEATURE_LABELS_ENABLED diff --git a/engine/conftest.py b/engine/conftest.py index ae810e51..59ab1f33 100644 --- a/engine/conftest.py +++ b/engine/conftest.py @@ -57,6 +57,7 @@ from apps.base.tests.factories import ( ) from apps.email.tests.factories import EmailMessageFactory from apps.heartbeat.tests.factories import IntegrationHeartBeatFactory +from apps.labels.tests.factories import AlertReceiveChannelAssociatedLabelFactory, LabelKeyFactory, LabelValueFactory from apps.mobile_app.models import MobileAppAuthToken, MobileAppVerificationToken from apps.phone_notifications.phone_backend import PhoneBackend from apps.phone_notifications.tests.factories import PhoneCallRecordFactory, SMSRecordFactory @@ -128,6 +129,10 @@ register(EmailMessageFactory) register(IntegrationHeartBeatFactory) register(LiveSettingFactory) +register(LabelKeyFactory) +register(LabelValueFactory) +register(AlertReceiveChannelAssociatedLabelFactory) + IS_RBAC_ENABLED = os.getenv("ONCALL_TESTING_RBAC_ENABLED", "True") == "True" @@ -173,6 +178,11 @@ def mock_apply_async(monkeypatch): monkeypatch.setattr(Task, "apply_async", mock_apply_async) +@pytest.fixture(autouse=True) +def mock_is_labels_feature_enabled(settings): + setattr(settings, "FEATURE_LABELS_ENABLED", True) + + @pytest.fixture def make_organization(): def _make_organization(**kwargs): @@ -918,3 +928,40 @@ def webhook_preset_api_setup(): WebhookPresetOptions.WEBHOOK_PRESET_CHOICES = [ preset.metadata for preset in WebhookPresetOptions.WEBHOOK_PRESETS.values() ] + + +@pytest.fixture +def make_label_key(): + def _make_label_key(organization, **kwargs): + return LabelKeyFactory(organization=organization, **kwargs) + + return _make_label_key + + +@pytest.fixture +def make_label_value(): + def _make_label_value(key, **kwargs): + return LabelValueFactory(key=key, **kwargs) + + return _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) + return key, value + + return _make_label_key_and_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) + return AlertReceiveChannelAssociatedLabelFactory( + alert_receive_channel=alert_receive_channel, organization=organization, key=key, value=value, **kwargs + ) + + return _make_integration_label_association diff --git a/engine/settings/base.py b/engine/settings/base.py index a231e023..339e88eb 100644 --- a/engine/settings/base.py +++ b/engine/settings/base.py @@ -66,6 +66,7 @@ FEATURE_MULTIREGION_ENABLED = getenv_boolean("FEATURE_MULTIREGION_ENABLED", defa FEATURE_INBOUND_EMAIL_ENABLED = getenv_boolean("FEATURE_INBOUND_EMAIL_ENABLED", default=True) FEATURE_PROMETHEUS_EXPORTER_ENABLED = getenv_boolean("FEATURE_PROMETHEUS_EXPORTER_ENABLED", default=False) FEATURE_GRAFANA_ALERTING_V2_ENABLED = getenv_boolean("FEATURE_GRAFANA_ALERTING_V2_ENABLED", default=False) +FEATURE_LABELS_ENABLED = getenv_boolean("FEATURE_LABELS_ENABLED", default=False) GRAFANA_CLOUD_ONCALL_HEARTBEAT_ENABLED = getenv_boolean("GRAFANA_CLOUD_ONCALL_HEARTBEAT_ENABLED", default=True) GRAFANA_CLOUD_NOTIFICATIONS_ENABLED = getenv_boolean("GRAFANA_CLOUD_NOTIFICATIONS_ENABLED", default=True) @@ -265,6 +266,7 @@ INSTALLED_APPS = [ "apps.grafana_plugin", "apps.webhooks", "apps.metrics_exporter", + "apps.labels", "corsheaders", "debug_toolbar", "social_django", @@ -311,6 +313,7 @@ if SWAGGER_UI_SETTINGS_URL: SPECTACULAR_INCLUDED_PATHS = [ "/features", "/alertgroups", + "/labels", ] MIDDLEWARE = [ diff --git a/engine/settings/celery_task_routes.py b/engine/settings/celery_task_routes.py index af0e72da..8cd9d409 100644 --- a/engine/settings/celery_task_routes.py +++ b/engine/settings/celery_task_routes.py @@ -13,6 +13,8 @@ CELERY_TASK_ROUTES = { "common.oncall_gateway.tasks.delete_slack_connector_async_v2": {"queue": "default"}, "apps.heartbeat.tasks.integration_heartbeat_checkup": {"queue": "default"}, "apps.heartbeat.tasks.process_heartbeat_task": {"queue": "default"}, + "apps.labels.tasks.update_labels_cache": {"queue": "default"}, + "apps.labels.tasks.update_instances_labels_cache": {"queue": "default"}, "apps.metrics_exporter.tasks.start_calculate_and_cache_metrics": {"queue": "default"}, "apps.metrics_exporter.tasks.start_recalculation_for_new_metric": {"queue": "default"}, "apps.metrics_exporter.tasks.save_organizations_ids_in_cache": {"queue": "default"}, diff --git a/grafana-plugin/package.json b/grafana-plugin/package.json index 22a57d6a..3fa5aaa1 100644 --- a/grafana-plugin/package.json +++ b/grafana-plugin/package.json @@ -113,6 +113,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.1.0", "@grafana/runtime": "9.3.0-beta1", "@grafana/ui": "^9.4.7", "@opentelemetry/api": "^1.3.0", diff --git a/grafana-plugin/src/components/IntegrationsFilters/IntegrationsFilters.module.css b/grafana-plugin/src/components/IntegrationsFilters/IntegrationsFilters.module.css deleted file mode 100644 index fc45422a..00000000 --- a/grafana-plugin/src/components/IntegrationsFilters/IntegrationsFilters.module.css +++ /dev/null @@ -1,17 +0,0 @@ -.root { - display: block; -} - -.integrationsFilters { - display: flex; - position: relative; -} - -.searchIntegrationClear { - position: absolute; - right: 0; -} - -.searchIntegrationInput { - max-width: 400px; -} diff --git a/grafana-plugin/src/components/IntegrationsFilters/IntegrationsFilters.tsx b/grafana-plugin/src/components/IntegrationsFilters/IntegrationsFilters.tsx deleted file mode 100644 index b6b6a455..00000000 --- a/grafana-plugin/src/components/IntegrationsFilters/IntegrationsFilters.tsx +++ /dev/null @@ -1,55 +0,0 @@ -import React, { ChangeEvent, FC, useCallback } from 'react'; - -import { Icon, Input, Button } from '@grafana/ui'; -import cn from 'classnames/bind'; - -import styles from 'components/IntegrationsFilters/IntegrationsFilters.module.css'; - -export interface Filters { - searchTerm: string; -} - -interface IntegrationsFiltersProps { - value: Filters; - onChange: (filters: Filters) => void; -} - -const cx = cn.bind(styles); - -const IntegrationsFilters: FC = (props) => { - const { value, onChange } = props; - - const onSearchTermChangeCallback = useCallback( - (e: ChangeEvent) => { - const filters = { - ...value, - searchTerm: e.currentTarget.value, - }; - - onChange(filters); - }, - [onChange, value] - ); - - const handleClear = useCallback(() => { - onChange({ searchTerm: '' }); - }, [onChange]); - - return ( -
- } - className={cx('search', 'control', 'searchIntegrationInput')} - placeholder="Search integrations..." - value={value.searchTerm} - onChange={onSearchTermChangeCallback} - /> - -
- ); -}; - -export default IntegrationsFilters; diff --git a/grafana-plugin/src/components/LabelsFilter/LabelsFilter.tsx b/grafana-plugin/src/components/LabelsFilter/LabelsFilter.tsx new file mode 100644 index 00000000..652941f6 --- /dev/null +++ b/grafana-plugin/src/components/LabelsFilter/LabelsFilter.tsx @@ -0,0 +1,60 @@ +import React, { FC, useCallback, useMemo, useState } from 'react'; + +import { AsyncMultiSelect } from '@grafana/ui'; + +interface Value { + key: { [labelField: string]: any }; + value: { [labelField: string]: any }; +} + +interface LabelsFilterProps { + autoFocus: boolean; + labelField: string; + onLoadOptions: (search: string) => Promise; + value: Value[]; + onChange: (value: Value[]) => void; +} + +const LabelsFilter: FC = (props) => { + const { autoFocus, value: propsValue, labelField: FieldName = 'name', onLoadOptions, onChange } = props; + + const [search, setSearch] = useState(''); + + const handleChange = useCallback((value) => { + onChange(value.map((v) => v.value)); + }, []); + + const handleLoadOptions = (search) => { + return onLoadOptions(search).then((options) => + options.map((v) => ({ + label: `${v.key[FieldName]} : ${v.value[FieldName]}`, + value: v, + })) + ); + }; + + const value = useMemo( + () => + propsValue.map((v) => ({ + label: `${v.key[FieldName]} : ${v.value[FieldName]}`, + value: v, + })), + [propsValue] + ); + + return ( + + ); +}; + +export default LabelsFilter; diff --git a/grafana-plugin/src/components/TooltipBadge/TooltipBadge.module.scss b/grafana-plugin/src/components/TooltipBadge/TooltipBadge.module.scss index 99f79199..e5391509 100644 --- a/grafana-plugin/src/components/TooltipBadge/TooltipBadge.module.scss +++ b/grafana-plugin/src/components/TooltipBadge/TooltipBadge.module.scss @@ -3,12 +3,20 @@ line-height: 16px; padding: 3px 4px; border-radius: 2px; + display: inline-block; &--primary { background: var(--tag-background-primary); border: 1px solid var(--tag-border-primary); color: var(--tag-text-primary); } + + &--secondary { + background: var(--background-secondary); + border: var(--border); + color: var(--primary-text-color); + } + &--warning { background: var(--tag-background-warning); border: 1px solid var(--tag-border-warning); diff --git a/grafana-plugin/src/components/TooltipBadge/TooltipBadge.tsx b/grafana-plugin/src/components/TooltipBadge/TooltipBadge.tsx index 84e89a94..2d36667b 100644 --- a/grafana-plugin/src/components/TooltipBadge/TooltipBadge.tsx +++ b/grafana-plugin/src/components/TooltipBadge/TooltipBadge.tsx @@ -67,7 +67,7 @@ const TooltipBadge: FC = (props) => { > {renderIcon()} - {text && ( + {text !== undefined && ( { const store = useStore(); const history = useHistory(); + const labelsRef = useRef(null); + const { id, onHide, onSubmit, isTableView = true } = props; const { alertReceiveChannelStore, @@ -64,6 +68,7 @@ const IntegrationForm = observer((props: IntegrationFormProps) => { const [selectedOption, setSelectedOption] = useState(undefined); const [showIntegrarionsListDrawer, setShowIntegrarionsListDrawer] = useState(id === 'new'); const [allContactPoints, setAllContactPoints] = useState([]); + const [errors, setErrors] = useState>(); useEffect(() => { (async function () { @@ -73,7 +78,7 @@ const IntegrationForm = observer((props: IntegrationFormProps) => { const data = id === 'new' - ? { integration: selectedOption?.value, team: user?.current_team } + ? { integration: selectedOption?.value, team: user?.current_team, labels: [] } : prepareForEdit(alertReceiveChannelStore.items[id]); const { alertReceiveChannelOptions } = alertReceiveChannelStore; @@ -128,6 +133,12 @@ const IntegrationForm = observer((props: IntegrationFormProps) => { + {store.hasFeature(AppFeature.Labels) && ( +
+ +
+ )} + {isTableView && } @@ -163,6 +174,10 @@ const IntegrationForm = observer((props: IntegrationFormProps) => { async function handleSubmit(data): Promise { const { alert_manager, contact_point, is_existing: isExisting } = data; + const labels = labelsRef.current?.getValue(); + + data = { ...data, labels }; + const matchingAlertManager = allContactPoints.find((cp) => cp.uid === alert_manager); const hasContactPointInput = alert_manager && contact_point; @@ -175,16 +190,17 @@ const IntegrationForm = observer((props: IntegrationFormProps) => { return; } - let apiResponseData: void | AlertReceiveChannel; const isCreate = id === 'new'; - if (isCreate) { - apiResponseData = await createNewIntegration(); - } else { - apiResponseData = await alertReceiveChannelStore.update(id, data); - } + try { + if (isCreate) { + await createNewIntegration(); + } else { + await alertReceiveChannelStore.update(id, data, undefined, true); + } + } catch (error) { + setErrors(error.response.data); - if (!apiResponseData) { openErrorNotification( `There was an issue ${isCreate ? 'creating' : 'updating'} the integration. Please try again.` ); diff --git a/grafana-plugin/src/containers/Labels/Labels.module.css b/grafana-plugin/src/containers/Labels/Labels.module.css new file mode 100644 index 00000000..c3a2af63 --- /dev/null +++ b/grafana-plugin/src/containers/Labels/Labels.module.css @@ -0,0 +1,2 @@ +.root { +} diff --git a/grafana-plugin/src/containers/Labels/Labels.tsx b/grafana-plugin/src/containers/Labels/Labels.tsx new file mode 100644 index 00000000..732c123b --- /dev/null +++ b/grafana-plugin/src/containers/Labels/Labels.tsx @@ -0,0 +1,107 @@ +import React, { forwardRef, useCallback, useImperativeHandle, useState } from 'react'; + +import '@grafana/labels/dist/theme.css'; +import ServiceLabels from '@grafana/labels'; +import { Field } from '@grafana/ui'; +import cn from 'classnames/bind'; +import { observer } from 'mobx-react'; + +import { LabelKeyValue } from 'models/label/label.types'; +import { useStore } from 'state/useStore'; +import { openErrorNotification } from 'utils'; + +import styles from './Labels.module.css'; + +const cx = cn.bind(styles); + +interface LabelsProps { + value: LabelKeyValue[]; + errors: any; +} + +const Labels = observer( + forwardRef(function Labels2(props: LabelsProps, ref) { + const { value: defaultValue, errors: propsErrors } = props; + + // propsErrors are 'external' caused by attaching/detaching labels to oncall entities, + // state errors are errors caused by CRUD operations on labels storage + + const [value, setValue] = useState(defaultValue); + + const { labelsStore } = useStore(); + + useImperativeHandle( + ref, + () => { + return { + getValue() { + return value; + }, + }; + }, + [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 ( +
+ + {}} + onUpdateError={onUpdateError} + errors={{ ...propsErrors }} + onDataUpdate={setValue} + /> + +
+ ); + }) +); + +function onUpdateError(res) { + if (res?.response?.status === 409) { + openErrorNotification(`Duplicate values are not allowed`); + } else { + openErrorNotification('An error has occurred. Please try again'); + } +} + +export default Labels; diff --git a/grafana-plugin/src/containers/Labels/LabelsFilter.tsx b/grafana-plugin/src/containers/Labels/LabelsFilter.tsx new file mode 100644 index 00000000..e3d61f4d --- /dev/null +++ b/grafana-plugin/src/containers/Labels/LabelsFilter.tsx @@ -0,0 +1,86 @@ +import React, { useEffect, useState } from 'react'; + +import { SelectableValue } from '@grafana/data'; +import cn from 'classnames/bind'; +import { observer } from 'mobx-react'; + +import LabelsFilterComponent from 'components/LabelsFilter/LabelsFilter'; +import { useStore } from 'state/useStore'; + +import styles from './Labels.module.css'; + +const cx = cn.bind(styles); + +interface LabelsFilterProps { + autoFocus: boolean; + className: string; + value: string[]; + onChange: (value: Array<{ key: SelectableValue; value: SelectableValue }>) => void; +} + +const LabelsFilter = observer((props: LabelsFilterProps) => { + const { className, autoFocus, value: propsValue, onChange } = props; + + const [value, setValue] = useState([]); + + const [keys, setKeys] = useState([]); + + const { labelsStore } = useStore(); + + useEffect(() => { + labelsStore.loadKeys().then(setKeys); + }, []); + + useEffect(() => { + const keyValuePairs = (propsValue || []).map((k) => k.split(':')); + + const promises = keyValuePairs.map(([keyId]) => labelsStore.loadValuesForKey(keyId)); + + const fetchKeyValues = async () => await Promise.all(promises); + + fetchKeyValues().then((list) => { + const value = list.map(({ key, values }, index) => ({ + key, + value: values.find((v) => v.id === keyValuePairs[index][1]) || {}, + })); + + setValue(value); + }); + }, [propsValue, keys]); + + const handleLoadOptions = (search) => { + if (!search) { + return Promise.resolve([]); + } + + return new Promise((resolve) => { + const keysFiltered = keys.filter((k) => k.name.toLowerCase().includes(search.toLowerCase())); + + const promises = keysFiltered.map((key) => labelsStore.loadValuesForKey(key.id)); + + Promise.all(promises).then((list) => { + const options = list.reduce((memo, { key, values }) => { + const options = values.map((value) => ({ key, value })); + + return [...memo, ...options]; + }, []); + + resolve(options); + }); + }); + }; + + return ( +
+ +
+ ); +}); + +export default LabelsFilter; diff --git a/grafana-plugin/src/containers/RemoteFilters/RemoteFilters.helpers.ts b/grafana-plugin/src/containers/RemoteFilters/RemoteFilters.helpers.ts index c66be54c..e0addb9f 100644 --- a/grafana-plugin/src/containers/RemoteFilters/RemoteFilters.helpers.ts +++ b/grafana-plugin/src/containers/RemoteFilters/RemoteFilters.helpers.ts @@ -22,7 +22,7 @@ export function parseFilters( let value: any = rawValue; - if (filterOption.type === 'options' || filterOption.type === 'team_select') { + if (filterOption.type === 'options' || filterOption.type === 'team_select' || filterOption.type === 'labels') { if (!Array.isArray(rawValue)) { value = [rawValue]; } diff --git a/grafana-plugin/src/containers/RemoteFilters/RemoteFilters.tsx b/grafana-plugin/src/containers/RemoteFilters/RemoteFilters.tsx index 37825c23..129996f6 100644 --- a/grafana-plugin/src/containers/RemoteFilters/RemoteFilters.tsx +++ b/grafana-plugin/src/containers/RemoteFilters/RemoteFilters.tsx @@ -2,7 +2,6 @@ import React, { Component } from 'react'; import { SelectableValue, TimeRange } from '@grafana/data'; import { - IconButton, InlineSwitch, MultiSelect, TimeRangeInput, @@ -11,6 +10,7 @@ import { Input, Icon, Tooltip, + Button, } from '@grafana/ui'; import { capitalCase } from 'change-case'; import cn from 'classnames/bind'; @@ -20,6 +20,7 @@ import moment from 'moment-timezone'; import Emoji from 'react-emoji-render'; import Text from 'components/Text/Text'; +import LabelsFilter from 'containers/Labels/LabelsFilter'; import RemoteSelect from 'containers/RemoteSelect/RemoteSelect'; import TeamName from 'containers/TeamName/TeamName'; import { FiltersValues } from 'models/filters/filters.types'; @@ -63,6 +64,21 @@ class RemoteFilters extends Component { searchRef = React.createRef(); + componentDidUpdate(prevProps: Readonly): void { + const { store, query } = this.props; + const { filtersStore } = store; + + if (prevProps.query !== query && filtersStore.needToParseFilters) { + filtersStore.needToParseFilters = false; + + const { filterOptions } = this.state; + + let { filters, values } = parseFilters(query, filterOptions, query); + + this.setState({ filterOptions, filters, values }, () => this.onChange()); + } + } + async componentDidMount() { const { query, page, store, defaultFilters } = this.props; @@ -125,7 +141,13 @@ class RemoteFilters extends Component { )} : {this.renderFilterOption(filterOption)} - +