diff --git a/CHANGELOG.md b/CHANGELOG.md index 57ee5001..58a1357c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +– Support prescribed labels ([#3848](https://github.com/grafana/oncall/pull/3848)) - Add status change trigger type to webhooks ([#3920](https://github.com/grafana/oncall/pull/3920)) ### Fixed diff --git a/engine/apps/alerts/models/alert.py b/engine/apps/alerts/models/alert.py index 52b388fc..4751f883 100644 --- a/engine/apps/alerts/models/alert.py +++ b/engine/apps/alerts/models/alert.py @@ -15,7 +15,7 @@ from apps.alerts.incident_appearance.templaters import TemplateLoader from apps.alerts.signals import alert_group_escalation_snapshot_built from apps.alerts.tasks.distribute_alert import send_alert_create_signal from apps.labels.alert_group_labels import assign_labels, gather_labels_from_alert_receive_channel_and_raw_request_data -from apps.labels.types import Labels +from apps.labels.types import AlertLabels from common.jinja_templater import apply_jinja_template_to_alert_payload_and_labels from common.jinja_templater.apply_jinja_template import ( JinjaTemplateError, @@ -221,7 +221,7 @@ class Alert(models.Model): template_name: str, alert_receive_channel: "AlertReceiveChannel", raw_request_data: RawRequestData, - labels: typing.Optional[Labels], + labels: typing.Optional[AlertLabels], use_error_msg_as_fallback=False, check_if_templated_value_is_truthy=False, ) -> typing.Union[str, None, bool]: @@ -246,7 +246,7 @@ class Alert(models.Model): cls, alert_receive_channel: "AlertReceiveChannel", raw_request_data: RawRequestData, - labels: typing.Optional[Labels], + labels: typing.Optional[AlertLabels], is_demo=False, ) -> "AlertGroup.GroupData": from apps.alerts.models import AlertGroup diff --git a/engine/apps/alerts/models/alert_receive_channel.py b/engine/apps/alerts/models/alert_receive_channel.py index daa1392c..c35fbbb7 100644 --- a/engine/apps/alerts/models/alert_receive_channel.py +++ b/engine/apps/alerts/models/alert_receive_channel.py @@ -294,8 +294,8 @@ 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]] | None - alert_group_labels_custom: AlertGroupCustomLabels = models.JSONField(null=True, default=None) + AlertGroupCustomLabelsDB = list[tuple[str, str | None, str | None]] | None + alert_group_labels_custom: AlertGroupCustomLabelsDB = models.JSONField(null=True, default=None) """ Stores "custom labels" for alert group labels. Custom labels can be either "plain" or "templated". For plain labels, the format is: [, , None] diff --git a/engine/apps/alerts/models/channel_filter.py b/engine/apps/alerts/models/channel_filter.py index 43c0e043..a8df73bb 100644 --- a/engine/apps/alerts/models/channel_filter.py +++ b/engine/apps/alerts/models/channel_filter.py @@ -20,7 +20,7 @@ if typing.TYPE_CHECKING: from django.db.models.manager import RelatedManager from apps.alerts.models import Alert, AlertGroup, AlertReceiveChannel - from apps.labels.types import Labels + from apps.labels.types import AlertLabels logger = logging.getLogger(__name__) @@ -120,12 +120,12 @@ class ChannelFilter(OrderedModel): return None def is_satisfying( - self, raw_request_data: "Alert.RawRequestData", alert_labels: typing.Optional["Labels"] = None + self, raw_request_data: "Alert.RawRequestData", alert_labels: typing.Optional["AlertLabels"] = None ) -> bool: return self.is_default or self.check_filter(raw_request_data, alert_labels) def check_filter( - self, raw_request_data: "Alert.RawRequestData", alert_labels: typing.Optional["Labels"] = None + self, raw_request_data: "Alert.RawRequestData", alert_labels: typing.Optional["AlertLabels"] = None ) -> bool: if self.filtering_term_type == ChannelFilter.FILTERING_TERM_TYPE_JINJA2: try: diff --git a/engine/apps/api/serializers/alert_receive_channel.py b/engine/apps/api/serializers/alert_receive_channel.py index 1edd7bd3..5f1e0f82 100644 --- a/engine/apps/api/serializers/alert_receive_channel.py +++ b/engine/apps/api/serializers/alert_receive_channel.py @@ -15,6 +15,7 @@ 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.labels.types import LabelKey from apps.user_management.models import Organization from common.api_helpers.custom_fields import TeamPrimaryKeyRelatedField from common.api_helpers.exceptions import BadRequest @@ -25,41 +26,45 @@ from .integration_heartbeat import IntegrationHeartBeatSerializer from .labels import LabelsSerializerMixin -class AlertGroupCustomLabelKey(typing.TypedDict): - id: str - name: str - - -class AlertGroupCustomLabelValue(typing.TypedDict): +# AlertGroupCustomLabelValue represents custom alert group label value for API requests +# It handles two types of label's value: +# 1. Just Label Value from a label repo for a static label +# 2. Templated Label value which is actually a jinja template for a dynamic label. +class AlertGroupCustomLabelValueAPI(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 + prescribed: bool # Indicates of selected label value is prescribed. Not applicable for templated values. -class AlertGroupCustomLabel(typing.TypedDict): - key: AlertGroupCustomLabelKey - value: AlertGroupCustomLabelValue +# AlertGroupCustomLabel represents Alert group custom label for API requests +# Key is just a LabelKey from label repo, while value could be value from repo or a jinja template. +class AlertGroupCustomLabelAPI(typing.TypedDict): + key: LabelKey + value: AlertGroupCustomLabelValueAPI -AlertGroupCustomLabels = list[AlertGroupCustomLabel] +AlertGroupCustomLabelsAPI = list[AlertGroupCustomLabelAPI] class IntegrationAlertGroupLabels(typing.TypedDict): inheritable: dict[str, bool] - custom: AlertGroupCustomLabels + custom: AlertGroupCustomLabelsAPI template: str | None class CustomLabelSerializer(serializers.Serializer): - """This serializer is consistent with apps.api.serializers.labels.LabelSerializer, but allows null for value ID.""" + """This serializer is consistent with apps.api.serializers.labels.LabelPairSerializer, but allows null for value ID.""" class CustomLabelKeySerializer(serializers.Serializer): id = serializers.CharField() name = serializers.CharField() + prescribed = serializers.BooleanField(default=False) class CustomLabelValueSerializer(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() + prescribed = serializers.BooleanField(default=False) key = CustomLabelKeySerializer() value = CustomLabelValueSerializer() @@ -112,16 +117,26 @@ class IntegrationAlertGroupLabelsSerializer(serializers.Serializer): return instance @staticmethod - def _create_custom_labels(organization: Organization, labels: AlertGroupCustomLabels) -> None: + def _create_custom_labels(organization: Organization, labels: AlertGroupCustomLabelsAPI) -> None: """Create LabelKeyCache and LabelValueCache objects for custom labels.""" label_keys = [ - LabelKeyCache(id=label["key"]["id"], name=label["key"]["name"], organization=organization) + LabelKeyCache( + id=label["key"]["id"], + name=label["key"]["name"], + prescribed=label["key"]["prescribed"], + organization=organization, + ) for label in labels ] label_values = [ - LabelValueCache(id=label["value"]["id"], name=label["value"]["name"], key_id=label["key"]["id"]) + LabelValueCache( + id=label["value"]["id"], + name=label["value"]["name"], + prescribed=label["value"]["prescribed"], + key_id=label["key"]["id"], + ) for label in labels if label["value"]["id"] # don't create LabelValueCache objects for templated labels ] @@ -147,8 +162,8 @@ class IntegrationAlertGroupLabelsSerializer(serializers.Serializer): @staticmethod def _custom_labels_to_internal_value( - custom_labels: AlertGroupCustomLabels, - ) -> AlertReceiveChannel.AlertGroupCustomLabels: + custom_labels: AlertGroupCustomLabelsAPI, + ) -> AlertReceiveChannel.AlertGroupCustomLabelsDB: """Convert custom labels from API representation to the schema used by the JSONField on the model.""" return [ @@ -158,8 +173,8 @@ class IntegrationAlertGroupLabelsSerializer(serializers.Serializer): @staticmethod def _custom_labels_to_representation( - custom_labels: AlertReceiveChannel.AlertGroupCustomLabels, - ) -> AlertGroupCustomLabels: + custom_labels: AlertReceiveChannel.AlertGroupCustomLabelsDB, + ) -> AlertGroupCustomLabelsAPI: """ 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. @@ -170,17 +185,19 @@ class IntegrationAlertGroupLabelsSerializer(serializers.Serializer): if custom_labels is None: return [] - # 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") + # build index of keys id to name and prescribed flag + label_key_index = { + k.id: {"name": k.name, "prescribed": k.prescribed} + for k in LabelKeyCache.objects.filter(id__in=[label[0] for label in custom_labels]).only( + "id", "name", "prescribed" + ) } - # get up-to-date label value names - label_value_names = { - v.id: v.name + # build index of values id to name and prescribed flag + label_value_index = { + v.id: {"name": v.name, "prescribed": v.prescribed} for v in LabelValueCache.objects.filter(id__in=[label[1] for label in custom_labels if label[1]]).only( - "id", "name" + "id", "name", "prescribed" ) } @@ -188,15 +205,17 @@ class IntegrationAlertGroupLabelsSerializer(serializers.Serializer): { "key": { "id": key_id, - "name": label_key_names[key_id], + "name": label_key_index[key_id]["name"], + "prescribed": label_key_index[key_id]["prescribed"], }, "value": { "id": value_id if value_id else None, - "name": label_value_names[value_id] if value_id else typing.cast(str, template), + "name": label_value_index[value_id]["name"] if value_id else typing.cast(str, template), + "prescribed": label_value_index[value_id]["prescribed"] if value_id else False, }, } 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) + if key_id in label_key_index and (value_id in label_value_index or not value_id) ] diff --git a/engine/apps/api/serializers/labels.py b/engine/apps/api/serializers/labels.py index b349059a..ba021042 100644 --- a/engine/apps/api/serializers/labels.py +++ b/engine/apps/api/serializers/labels.py @@ -6,32 +6,36 @@ from apps.labels.utils import is_labels_feature_enabled class LabelKeySerializer(serializers.ModelSerializer): id = serializers.CharField() + prescribed = serializers.BooleanField(default=False) class Meta: model = LabelKeyCache fields = ( "id", "name", + "prescribed", ) class LabelValueSerializer(serializers.ModelSerializer): id = serializers.CharField() + prescribed = serializers.BooleanField(default=False) class Meta: model = LabelValueCache fields = ( "id", "name", + "prescribed", ) -class LabelSerializer(serializers.Serializer): +class LabelPairSerializer(serializers.Serializer): key = LabelKeySerializer() value = LabelValueSerializer() -class LabelKeyValuesSerializer(serializers.Serializer): +class LabelOptionSerializer(serializers.Serializer): key = LabelKeySerializer() values = LabelValueSerializer(many=True) @@ -41,7 +45,7 @@ class LabelReprSerializer(serializers.Serializer): class LabelsSerializerMixin(serializers.Serializer): - labels = LabelSerializer(many=True, required=False) + labels = LabelPairSerializer(many=True, required=False) def validate_labels(self, labels): if labels: diff --git a/engine/apps/api/tests/test_alert_receive_channel.py b/engine/apps/api/tests/test_alert_receive_channel.py index c1ba4e3c..67de03b7 100644 --- a/engine/apps/api/tests/test_alert_receive_channel.py +++ b/engine/apps/api/tests/test_alert_receive_channel.py @@ -1378,7 +1378,14 @@ def test_update_alert_receive_channel_labels( 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"}}]} + data = { + "labels": [ + { + "key": {"id": key_id, "name": "test", "prescribed": False}, + "value": {"id": value_id, "name": "testv", "prescribed": False}, + } + ] + } response = client.patch( url, data=json.dumps(data), @@ -1407,6 +1414,59 @@ def test_update_alert_receive_channel_labels( assert alert_receive_channel.labels.count() == 0 +@pytest.mark.django_db +def test_update_alert_receive_channel_presribed_labels( + 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}) + key_id = "testkey" + value_id = "testvalue" + data = { + "labels": [ + { + "key": {"id": key_id, "name": "test", "prescribed": True}, + "value": {"id": value_id, "name": "testv", "prescribed": True}, + } + ] + } + 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 + + # Check if cached labels are prescribed + assert label.key.prescribed is True + assert label.value.prescribed is True + + 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, @@ -1475,12 +1535,12 @@ def test_alert_group_labels_get( "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.id, "name": label_key.name, "prescribed": False}, + "value": {"id": label_value.id, "name": label_value.name, "prescribed": False}, }, { - "key": {"id": label_key_1.id, "name": label_key_1.name}, - "value": {"id": None, "name": "{{ payload.foo }}"}, + "key": {"id": label_key_1.id, "name": label_key_1.name, "prescribed": False}, + "value": {"id": None, "name": "{{ payload.foo }}", "prescribed": False}, }, ], "template": template, @@ -1503,18 +1563,22 @@ def test_alert_group_labels_put( 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}, + "key": {"id": label_2.key.id, "name": label_2.key.name, "prescribed": False}, + "value": {"id": label_2.value.id, "name": label_2.value.name, "prescribed": False}, }, # plain label not present in DB cache { - "key": {"id": "hello", "name": "world"}, - "value": {"id": "foo", "name": "bar"}, + "key": {"id": "hello", "name": "world", "prescribed": False}, + "value": {"id": "foo", "name": "bar", "prescribed": False}, }, # templated label { - "key": {"id": label_3.key.id, "name": label_3.key.name}, - "value": {"id": None, "name": "{{ payload.foo }}"}, + "key": {"id": label_3.key.id, "name": label_3.key.name, "prescribed": False}, + "value": { + "id": None, + "name": "{{ payload.foo }}", + "prescribed": False, + }, }, ] template = "{{ payload.labels | tojson }}" # advanced template @@ -1573,10 +1637,20 @@ def test_alert_group_labels_put_none( def test_alert_group_labels_post(alert_receive_channel_internal_api_setup, make_user_auth_headers): user, token, _ = alert_receive_channel_internal_api_setup - labels = [{"key": {"id": "test", "name": "test"}, "value": {"id": "123", "name": "123"}}] + labels = [ + { + "key": {"id": "test", "name": "test", "prescribed": False}, + "value": {"id": "123", "name": "123", "prescribed": False}, + } + ] alert_group_labels = { "inheritable": {"test": False}, - "custom": [{"key": {"id": "test", "name": "test"}, "value": {"id": "123", "name": "123"}}], + "custom": [ + { + "key": {"id": "test", "name": "test", "prescribed": False}, + "value": {"id": "123", "name": "123", "prescribed": False}, + } + ], "template": "{{ payload.labels | tojson }}", } data = { diff --git a/engine/apps/api/tests/test_labels.py b/engine/apps/api/tests/test_labels.py index 051b95f9..a0bccb3a 100644 --- a/engine/apps/api/tests/test_labels.py +++ b/engine/apps/api/tests/test_labels.py @@ -37,7 +37,7 @@ def test_labels_get_keys( @patch( - "apps.labels.client.LabelsAPIClient.get_values", + "apps.labels.client.LabelsAPIClient.get_label_by_key_id", return_value=( {"key": {"id": "keyid123", "name": "team"}, "values": [{"id": "valueid123", "name": "yolo"}]}, MockResponse(status_code=200), @@ -45,7 +45,7 @@ def test_labels_get_keys( ) @pytest.mark.django_db def test_get_update_key_get( - mocked_get_values, + mocked_get_label_by_key_id, make_organization_and_user_with_plugin_token, make_user_auth_headers, ): @@ -55,7 +55,7 @@ def test_get_update_key_get( 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 mocked_get_label_by_key_id.called assert response.status_code == status.HTTP_200_OK assert response.json() == expected_result diff --git a/engine/apps/api/tests/test_webhooks.py b/engine/apps/api/tests/test_webhooks.py index 278f7870..6ddd7c94 100644 --- a/engine/apps/api/tests/test_webhooks.py +++ b/engine/apps/api/tests/test_webhooks.py @@ -787,7 +787,14 @@ def test_update_webhook_labels( url = reverse("api-internal:webhooks-detail", kwargs={"pk": webhook.public_primary_key}) key_id = "testkey" value_id = "testvalue" - data = {"labels": [{"key": {"id": key_id, "name": "test"}, "value": {"id": value_id, "name": "testv"}}]} + data = { + "labels": [ + { + "key": {"id": key_id, "name": "test", "prescribed": False}, + "value": {"id": value_id, "name": "testv", "prescribed": False}, + } + ] + } response = client.patch( url, data=json.dumps(data), @@ -833,7 +840,12 @@ def test_create_webhook_with_labels( "url": TEST_URL, "trigger_type": Webhook.TRIGGER_ALERT_GROUP_CREATED, "http_method": "POST", - "labels": [{"key": {"id": key_id, "name": "test"}, "value": {"id": value_id, "name": "testv"}}], + "labels": [ + { + "key": {"id": key_id, "name": "test", "prescribed": False}, + "value": {"id": value_id, "name": "testv", "prescribed": False}, + } + ], "team": None, } diff --git a/engine/apps/api/views/labels.py b/engine/apps/api/views/labels.py index 5c0fa271..4ab74bb3 100644 --- a/engine/apps/api/views/labels.py +++ b/engine/apps/api/views/labels.py @@ -10,13 +10,13 @@ from rest_framework.viewsets import ViewSet from apps.api.permissions import BasicRolePermission, LegacyAccessControlRole from apps.api.serializers.labels import ( LabelKeySerializer, - LabelKeyValuesSerializer, + LabelOptionSerializer, LabelReprSerializer, LabelValueSerializer, ) from apps.auth_token.auth import PluginAuthentication from apps.labels.client import LabelsAPIClient, LabelsRepoAPIException -from apps.labels.tasks import update_instances_labels_cache, update_labels_cache +from apps.labels.tasks import update_instances_labels_cache, update_label_option_cache from apps.labels.utils import is_labels_feature_enabled from common.api_helpers.exceptions import BadRequest @@ -51,37 +51,42 @@ class LabelsViewSet(LabelsFeatureFlagViewSet): def get_keys(self, request): """List of labels keys""" organization = self.request.auth.organization - result, response = LabelsAPIClient(organization.grafana_url, organization.api_token).get_keys() - return Response(result, status=response.status_code) + keys, response = LabelsAPIClient(organization.grafana_url, organization.api_token).get_keys() + return Response(keys, status=response.status_code) - @extend_schema(responses=LabelKeyValuesSerializer) + @extend_schema(responses=LabelOptionSerializer) def get_key(self, request, key_id): - """Key with the list of values""" + """ + get_key returns LabelOption – key with the list of values + """ organization = self.request.auth.organization - result, response = LabelsAPIClient(organization.grafana_url, organization.api_token).get_values(key_id) - self._update_labels_cache(result) - return Response(result, status=response.status_code) + label_option, response = LabelsAPIClient(organization.grafana_url, organization.api_token).get_label_by_key_id( + key_id + ) + self._update_labels_cache(label_option) + return Response(label_option, status=response.status_code) @extend_schema(responses=LabelValueSerializer) def get_value(self, request, key_id, value_id): - """Value name""" + """get_value returns a Value""" organization = self.request.auth.organization - result, response = LabelsAPIClient(organization.grafana_url, organization.api_token).get_value(key_id, value_id) - self._update_labels_cache(result) - return Response(result, status=response.status_code) + value, response = LabelsAPIClient(organization.grafana_url, organization.api_token).get_value(key_id, value_id) + # TODO: update_labels_cache expects LabelOption, but get value returns a Value. Investigate, temporary disable. + # self._update_labels_cache(value) + return Response(value, status=response.status_code) - @extend_schema(request=LabelReprSerializer, responses=LabelKeyValuesSerializer) + @extend_schema(request=LabelReprSerializer, responses=LabelOptionSerializer) 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 = LabelsAPIClient(organization.grafana_url, organization.api_token).rename_key( + label_option, response = LabelsAPIClient(organization.grafana_url, organization.api_token).rename_key( key_id, label_data ) - self._update_labels_cache(result) - return Response(result, status=response.status_code) + self._update_labels_cache(label_option) + return Response(label_option, status=response.status_code) @extend_schema( request=inline_serializer( @@ -89,7 +94,7 @@ class LabelsViewSet(LabelsFeatureFlagViewSet): fields={"key": LabelReprSerializer(), "values": LabelReprSerializer(many=True)}, many=True, ), - responses={201: LabelKeyValuesSerializer}, + responses={201: LabelOptionSerializer}, ) def create_label(self, request): """Create a new label key with values(Optional)""" @@ -97,42 +102,44 @@ class LabelsViewSet(LabelsFeatureFlagViewSet): label_data = self.request.data if not label_data: raise BadRequest(detail="key data (name, values) is required") - result, response = LabelsAPIClient(organization.grafana_url, organization.api_token).create_label(label_data) - return Response(result, status=response.status_code) + label_option, response = LabelsAPIClient(organization.grafana_url, organization.api_token).create_label( + label_data + ) + return Response(label_option, status=response.status_code) - @extend_schema(request=LabelReprSerializer, responses=LabelKeyValuesSerializer) + @extend_schema(request=LabelReprSerializer, responses=LabelOptionSerializer) 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 = LabelsAPIClient(organization.grafana_url, organization.api_token).add_value( + label_option, response = LabelsAPIClient(organization.grafana_url, organization.api_token).add_value( key_id, label_data ) - status = response.status_code - return Response(result, status=status) + return Response(label_option, status=response.status_code) - @extend_schema(request=LabelReprSerializer, responses=LabelKeyValuesSerializer) + @extend_schema(request=LabelReprSerializer, responses=LabelOptionSerializer) 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 = LabelsAPIClient(organization.grafana_url, organization.api_token).rename_value( + label_option, response = LabelsAPIClient(organization.grafana_url, organization.api_token).rename_value( key_id, value_id, label_data ) status = response.status_code - self._update_labels_cache(result) - return Response(result, status=status) + self._update_labels_cache(label_option) + return Response(label_option, status=status) - def _update_labels_cache(self, label_data): - if not label_data: + def _update_labels_cache(self, label_option): + if not label_option: return - serializer = LabelKeyValuesSerializer(data=label_data) + serializer = LabelOptionSerializer(data=label_option) if serializer.is_valid(): - update_labels_cache.apply_async((label_data,)) + update_label_option_cache.apply_async((label_option,)) + # update_labels_cache.apply_async((label_data,)) def handle_exception(self, exc): if isinstance(exc, LabelsRepoAPIException): @@ -169,7 +176,7 @@ class AlertGroupLabelsViewSet(LabelsFeatureFlagViewSet): names = self.request.auth.organization.alert_group_labels.values_list("key_name", flat=True).distinct() return Response([{"id": name, "name": name} for name in names]) - @extend_schema(responses=LabelKeyValuesSerializer) + @extend_schema(responses=LabelOptionSerializer) def get_key(self, request, key_id): """Key with the list of values. IDs and names are interchangeable (see get_keys() for more details).""" values = ( diff --git a/engine/apps/labels/alert_group_labels.py b/engine/apps/labels/alert_group_labels.py index 1dcd6873..f63e60cd 100644 --- a/engine/apps/labels/alert_group_labels.py +++ b/engine/apps/labels/alert_group_labels.py @@ -23,7 +23,7 @@ MAX_LABELS_PER_ALERT_GROUP = 15 def gather_labels_from_alert_receive_channel_and_raw_request_data( alert_receive_channel: "AlertReceiveChannel", raw_request_data: "Alert.RawRequestData" -) -> typing.Optional[types.Labels]: +) -> typing.Optional[types.AlertLabels]: if not is_labels_feature_enabled(alert_receive_channel.organization): return None @@ -43,7 +43,7 @@ def gather_labels_from_alert_receive_channel_and_raw_request_data( def assign_labels( - alert_group: "AlertGroup", alert_receive_channel: "AlertReceiveChannel", labels: typing.Optional[types.Labels] + alert_group: "AlertGroup", alert_receive_channel: "AlertReceiveChannel", labels: typing.Optional[types.AlertLabels] ) -> None: from apps.labels.models import AlertGroupAssociatedLabel @@ -78,7 +78,7 @@ def assign_labels( def _custom_labels( alert_receive_channel: "AlertReceiveChannel", raw_request_data: "Alert.RawRequestData" -) -> types.Labels: +) -> types.AlertLabels: from apps.labels.models import MAX_VALUE_NAME_LENGTH, LabelKeyCache, LabelValueCache if alert_receive_channel.alert_group_labels_custom is None: @@ -143,7 +143,7 @@ def _custom_labels( def _template_labels( alert_receive_channel: "AlertReceiveChannel", raw_request_data: "Alert.RawRequestData" -) -> types.Labels: +) -> types.AlertLabels: from apps.labels.models import MAX_KEY_NAME_LENGTH, MAX_VALUE_NAME_LENGTH if not alert_receive_channel.alert_group_labels_template: diff --git a/engine/apps/labels/client.py b/engine/apps/labels/client.py index 2b457307..0e9a63dd 100644 --- a/engine/apps/labels/client.py +++ b/engine/apps/labels/client.py @@ -6,7 +6,11 @@ import requests from django.conf import settings if typing.TYPE_CHECKING: - from apps.labels.utils import LabelKeyData, LabelsKeysData, LabelUpdateParam + from apps.labels.types import LabelKey, LabelOption, LabelValue + + +class LabelUpdateParam(typing.TypedDict): + name: str class LabelsRepoAPIException(Exception): @@ -37,20 +41,22 @@ class LabelsAPIClient: def create_label( self, label_data: "LabelUpdateParam" - ) -> typing.Tuple[typing.Optional["LabelKeyData"], requests.models.Response]: + ) -> typing.Tuple[typing.Optional["LabelOption"], requests.models.Response]: url = self.api_url response = requests.post(url, json=label_data, timeout=TIMEOUT, headers=self._request_headers) self._check_response(response) return response.json(), response - def get_keys(self) -> typing.Tuple[typing.Optional["LabelsKeysData"], requests.models.Response]: + def get_keys(self) -> typing.Tuple[typing.Optional[typing.List["LabelKey"]], requests.models.Response]: url = urljoin(self.api_url, "keys") response = requests.get(url, timeout=TIMEOUT, headers=self._request_headers) self._check_response(response) return response.json(), response - def get_values(self, key_id: str) -> typing.Tuple[typing.Optional["LabelKeyData"], requests.models.Response]: + def get_label_by_key_id( + self, key_id: str + ) -> typing.Tuple[typing.Optional["LabelOption"], requests.models.Response]: url = urljoin(self.api_url, f"id/{key_id}") response = requests.get(url, timeout=TIMEOUT, headers=self._request_headers) @@ -59,7 +65,7 @@ class LabelsAPIClient: def get_value( self, key_id: str, value_id: str - ) -> typing.Tuple[typing.Optional["LabelKeyData"], requests.models.Response]: + ) -> typing.Tuple[typing.Optional["LabelValue"], requests.models.Response]: url = urljoin(self.api_url, f"id/{key_id}/values/{value_id}") response = requests.get(url, timeout=TIMEOUT, headers=self._request_headers) @@ -68,7 +74,7 @@ class LabelsAPIClient: def add_value( self, key_id: str, label_data: "LabelUpdateParam" - ) -> typing.Tuple[typing.Optional["LabelKeyData"], requests.models.Response]: + ) -> typing.Tuple[typing.Optional["LabelOption"], requests.models.Response]: url = urljoin(self.api_url, f"id/{key_id}/values") response = requests.post(url, json=label_data, timeout=TIMEOUT, headers=self._request_headers) @@ -77,7 +83,7 @@ class LabelsAPIClient: def rename_key( self, key_id: str, label_data: "LabelUpdateParam" - ) -> typing.Tuple[typing.Optional["LabelKeyData"], requests.models.Response]: + ) -> typing.Tuple[typing.Optional["LabelOption"], requests.models.Response]: url = urljoin(self.api_url, f"id/{key_id}") response = requests.put(url, json=label_data, timeout=TIMEOUT, headers=self._request_headers) @@ -86,7 +92,7 @@ class LabelsAPIClient: def rename_value( self, key_id: str, value_id: str, label_data: "LabelUpdateParam" - ) -> typing.Tuple[typing.Optional["LabelKeyData"], requests.models.Response]: + ) -> typing.Tuple[typing.Optional["LabelOption"], requests.models.Response]: url = urljoin(self.api_url, f"id/{key_id}/values/{value_id}") response = requests.put(url, json=label_data, timeout=TIMEOUT, headers=self._request_headers) diff --git a/engine/apps/labels/migrations/0005_labelkeycache_prescribed_labelvaluecache_prescribed.py b/engine/apps/labels/migrations/0005_labelkeycache_prescribed_labelvaluecache_prescribed.py new file mode 100644 index 00000000..189a72da --- /dev/null +++ b/engine/apps/labels/migrations/0005_labelkeycache_prescribed_labelvaluecache_prescribed.py @@ -0,0 +1,23 @@ +# Generated by Django 4.2.7 on 2024-02-07 09:32 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('labels', '0004_webhookassociatedlabel'), + ] + + operations = [ + migrations.AddField( + model_name='labelkeycache', + name='prescribed', + field=models.BooleanField(default=False, null=True), + ), + migrations.AddField( + model_name='labelvaluecache', + name='prescribed', + field=models.BooleanField(default=False, null=True), + ), + ] diff --git a/engine/apps/labels/models.py b/engine/apps/labels/models.py index 14cd446c..8a4a626d 100644 --- a/engine/apps/labels/models.py +++ b/engine/apps/labels/models.py @@ -3,8 +3,9 @@ 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 +from apps.labels.tasks import update_label_pairs_cache +from apps.labels.types import LabelPair +from apps.labels.utils import LABEL_OUTDATED_TIMEOUT_MINUTES if typing.TYPE_CHECKING: from apps.user_management.models import Organization @@ -19,6 +20,7 @@ class LabelKeyCache(models.Model): 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) + prescribed = models.BooleanField(default=False, null=True) @property def is_outdated(self) -> bool: @@ -30,6 +32,7 @@ class LabelValueCache(models.Model): 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) + prescribed = models.BooleanField(default=False, null=True) @property def is_outdated(self) -> bool: @@ -53,7 +56,9 @@ class AssociatedLabel(models.Model): abstract = True @staticmethod - def update_association(labels_data: "LabelsData", instance: models.Model, organization: "Organization") -> None: + def update_association( + label_pairs: typing.List[LabelPair], 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. @@ -61,8 +66,8 @@ class AssociatedLabel(models.Model): 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} + labels_data_keys = {label["key"]["id"]: label["key"]["name"] for label in label_pairs} + labels_data_values = {label["value"]["id"]: label["value"]["name"] for label in label_pairs} # 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() @@ -71,16 +76,19 @@ class AssociatedLabel(models.Model): 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"] + for label_pair in label_pairs: + key_id = label_pair["key"]["id"] + key_name = label_pair["key"]["name"] + key_prescribed = label_pair["key"]["prescribed"] - label_key = LabelKeyCache(id=key_id, name=key_name, organization=organization) + value_id = label_pair["value"]["id"] + value_name = label_pair["value"]["name"] + value_prescribed = label_pair["value"]["prescribed"] + + label_key = LabelKeyCache(id=key_id, name=key_name, organization=organization, prescribed=key_prescribed) labels_keys.append(label_key) - label_value = LabelValueCache(id=value_id, name=value_name, key_id=key_id) + label_value = LabelValueCache(id=value_id, name=value_name, key_id=key_id, prescribed=value_prescribed) labels_values.append(label_value) associated_instance = {instance.labels.field.name: instance} labels_associations.append( @@ -89,12 +97,13 @@ class AssociatedLabel(models.Model): ) ) - # create labels cache and associations that don't exist + # create labels cache and associations that don't exist. + # Ignoring conflicts because some labels might laready exist. They will be updates in task. 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,)) + update_label_pairs_cache.apply_async((label_pairs,)) @staticmethod def get_associating_label_field_name() -> str: diff --git a/engine/apps/labels/tasks.py b/engine/apps/labels/tasks.py index 956d244b..9ed1147f 100644 --- a/engine/apps/labels/tasks.py +++ b/engine/apps/labels/tasks.py @@ -6,13 +6,8 @@ from django.conf import settings from django.utils import timezone from apps.labels.client import LabelsAPIClient, LabelsRepoAPIException -from apps.labels.utils import ( - LABEL_OUTDATED_TIMEOUT_MINUTES, - LabelKeyData, - LabelsData, - ValueData, - get_associating_label_model, -) +from apps.labels.types import LabelOption, LabelPair +from apps.labels.utils import LABEL_OUTDATED_TIMEOUT_MINUTES, get_associating_label_model from apps.user_management.models import Organization from common.custom_celery_tasks import shared_dedicated_queue_retry_task @@ -20,17 +15,24 @@ logger = get_task_logger(__name__) logger.setLevel(logging.DEBUG) -def unify_labels_data(labels_data: LabelsData | LabelKeyData) -> typing.Dict[str, ValueData]: - values_data: typing.Dict[str, ValueData] +class KVPair(typing.TypedDict): + value_name: str + key_name: str + + +def unify_labels_data(labels_data: typing.List[LabelOption] | LabelOption) -> typing.Dict[str, KVPair]: + # Returns map of value id to value data. + # Deprecated and left for backward compatibility. + values_data: typing.Dict[str, KVPair] 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 + else: # LabelOption values_data = { - label["id"]: {"value_name": label["name"], "key_name": labels_data["key"]["name"]} - for label in labels_data["values"] + value["id"]: {"value_name": value["name"], "key_name": labels_data["key"]["name"]} + for value in labels_data["values"] } return values_data @@ -38,7 +40,14 @@ def unify_labels_data(labels_data: LabelsData | LabelKeyData) -> typing.Dict[str @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): +def update_labels_cache(labels_data: typing.List[LabelOption] | LabelOption): + """ + 1. Expects map of value_id -> value_name, key_name + 2. Fetches values filtered by map key + 3. Updates value and key if it's name different + 4. Updates value key name and updates if's name different + Deprecated and left for backward compatibility. + """ from apps.labels.models import LabelKeyCache, LabelValueCache # this is a quick fix for tasks with wrong labels_data and can be removed later since handling this error happens in @@ -46,7 +55,7 @@ def update_labels_cache(labels_data: LabelsData | LabelKeyData): if isinstance(labels_data, dict) and labels_data.get("error"): return - values_data: typing.Dict[str, ValueData] = unify_labels_data(labels_data) + values_data: typing.Dict[str, KVPair] = unify_labels_data(labels_data) values = LabelValueCache.objects.filter(id__in=values_data).select_related("key") now = timezone.now() @@ -69,6 +78,57 @@ def update_labels_cache(labels_data: LabelsData | LabelKeyData): 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 None +) +def update_label_option_cache(label_option: LabelOption): + """ + update_label_cache updates cache for label's key, and it's every value + """ + values_id_to_pair = {value["id"]: {"value": value, "key": label_option["key"]} for value in label_option["values"]} + _update_labels_cache(values_id_to_pair) + + +@shared_dedicated_queue_retry_task( + autoretry_for=(Exception,), retry_backoff=True, max_retries=1 if settings.DEBUG else None +) +def update_label_pairs_cache(label_pairs: typing.List[LabelPair]): + """ + update_label_pair updates cache for list of LabelPairs. + """ + value_id_to_pair = {label["value"]["id"]: {"value": label["value"], "key": label["key"]} for label in label_pairs} + _update_labels_cache(value_id_to_pair) + + +def _update_labels_cache(values_id_to_pair: typing.Dict[str, LabelPair]): + """ + _update_labels_cache updates LabelKeyCache and LabelValueCache. + It expects dict { value_id: [value_name, key_name] } and will fetch and update LabelKeyCache and LabelValueCache. + """ + from apps.labels.models import LabelKeyCache, LabelValueCache + + values = LabelValueCache.objects.filter(id__in=values_id_to_pair).select_related("key") + now = timezone.now() + + if not values: + return + + keys_to_update = set() + + for value in values: + value.name = values_id_to_pair[value.id]["value"]["name"] + value.prescribed = values_id_to_pair[value.id]["value"]["prescribed"] + value.last_synced = now + + value.key.name = values_id_to_pair[value.id]["key"]["name"] + value.key.prescribed = values_id_to_pair[value.id]["key"]["prescribed"] + value.key.last_synced = now + keys_to_update.add(value.key) + + LabelKeyCache.objects.bulk_update(keys_to_update, fields=["name", "last_synced", "prescribed"]) + LabelValueCache.objects.bulk_update(values, fields=["name", "last_synced", "prescribed"]) + + @shared_dedicated_queue_retry_task( autoretry_for=(Exception,), retry_backoff=True, max_retries=1 if settings.DEBUG else 10 ) @@ -93,12 +153,12 @@ def update_instances_labels_cache(organization_id: int, instance_ids: typing.Lis client = LabelsAPIClient(organization.grafana_url, organization.api_token) for key_id in keys_ids: try: - label_data, _ = client.get_values(key_id) + label_option, _ = client.get_label_by_key_id(key_id) except LabelsRepoAPIException as e: logger.warning( f"Error on get label data: organization: {organization_id}, key_id {key_id}, error: {e}, " f"error message: {e.msg}" ) continue - if label_data: - update_labels_cache.apply_async((label_data,)) + if label_option: + update_label_option_cache.apply_async((label_option,)) diff --git a/engine/apps/labels/tests/test_labels.py b/engine/apps/labels/tests/test_labels.py index bb1d5502..594d0e9b 100644 --- a/engine/apps/labels/tests/test_labels.py +++ b/engine/apps/labels/tests/test_labels.py @@ -60,8 +60,8 @@ def test_label_associate_new_label(make_organization, make_alert_receive_channel label_value_id = "testvalueid" labels_data = [ { - "key": {"id": label_key_id, "name": "testkey"}, - "value": {"id": label_value_id, "name": "testvalue"}, + "key": {"id": label_key_id, "name": "testkey", "prescribed": False}, + "value": {"id": label_value_id, "name": "testvalue", "prescribed": False}, } ] @@ -80,8 +80,8 @@ def test_label_associate_existing_label(make_label_key_and_value, make_organizat 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}, + "key": {"id": label_key.id, "name": label_key.name, "prescribed": False}, + "value": {"id": label_value.id, "name": label_value.name, "prescribed": False}, } ] assert not alert_receive_channel.labels.exists() @@ -100,8 +100,8 @@ def test_label_update_association_by_removing_label( 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}, + "key": {"id": label_association_1.key_id, "name": label_association_1.key.name, "prescribed": False}, + "value": {"id": label_association_1.value_id, "name": label_association_1.value.name, "prescribed": False}, } ] diff --git a/engine/apps/labels/tests/test_labels_cache.py b/engine/apps/labels/tests/test_labels_cache.py index 8eec3a71..c22516b1 100644 --- a/engine/apps/labels/tests/test_labels_cache.py +++ b/engine/apps/labels/tests/test_labels_cache.py @@ -98,12 +98,12 @@ def test_update_instances_labels_cache_recently_synced( 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.client.LabelsAPIClient.get_label_by_key_id") as mock_get_label_by_key_id: 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_get_label_by_key_id.called assert not mock_update_cache.called @@ -122,19 +122,21 @@ def test_update_instances_labels_cache_outdated( assert label_association.key.is_outdated assert label_association.value.is_outdated - label_data = { + label_option = { "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: + with patch( + "apps.labels.client.LabelsAPIClient.get_label_by_key_id", return_value=(label_option, None) + ) as mock_get_label_by_key_id: + with patch("apps.labels.tasks.update_label_option_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_get_label_by_key_id.called assert mock_update_cache.called - assert mock_update_cache.call_args == call((label_data,)) + assert mock_update_cache.call_args == call((label_option,)) @pytest.mark.django_db @@ -150,11 +152,11 @@ def test_update_instances_labels_cache_error( LabelValueCache.objects.filter(id=label_association.value_id).update(last_synced=outdated_last_synced) with patch( - "apps.labels.client.LabelsAPIClient.get_values", side_effect=LabelsRepoAPIException("test", "test") - ) as mock_get_values: + "apps.labels.client.LabelsAPIClient.get_label_by_key_id", side_effect=LabelsRepoAPIException("test", "test") + ) as mock_get_label_by_key_id: 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__ ) - mock_get_values.assert_called_once_with(label_association.key_id) + mock_get_label_by_key_id.assert_called_once_with(label_association.key_id) mock_update_cache.assert_not_called() diff --git a/engine/apps/labels/types.py b/engine/apps/labels/types.py index 173b2b84..58f67e98 100644 --- a/engine/apps/labels/types.py +++ b/engine/apps/labels/types.py @@ -1,3 +1,31 @@ import typing -Labels = typing.Dict[str, str] + +# LabelKey represents label key from label repo +class LabelKey(typing.TypedDict): + id: str + name: str + prescribed: bool + + +# LabelValue represents one of the values associated with the LabelKey from label repo +class LabelValue(typing.TypedDict): + id: str + name: str + prescribed: bool + + +# Label Pair is a KV pair identifying one label. +class LabelPair(typing.TypedDict): + key: LabelKey + value: LabelValue + + +# LabelOption represents key and array of available values +class LabelOption(typing.TypedDict): + key: LabelKey + values: typing.List[LabelValue] + + +# Alert Labels represents k:v pair applied to alert +AlertLabels = typing.Dict[str, str] diff --git a/engine/apps/labels/utils.py b/engine/apps/labels/utils.py index d4376d60..a8601596 100644 --- a/engine/apps/labels/utils.py +++ b/engine/apps/labels/utils.py @@ -16,34 +16,6 @@ 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 ValueData(typing.TypedDict): - value_name: str - key_name: str - - -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) diff --git a/engine/common/jinja_templater/apply_jinja_template.py b/engine/common/jinja_templater/apply_jinja_template.py index 51284b49..b26b8b1c 100644 --- a/engine/common/jinja_templater/apply_jinja_template.py +++ b/engine/common/jinja_templater/apply_jinja_template.py @@ -11,7 +11,7 @@ logger = logging.getLogger(__name__) if typing.TYPE_CHECKING: from apps.alerts.models import Alert - from apps.labels.types import Labels + from apps.labels.types import AlertLabels class JinjaTemplateError(Exception): @@ -53,7 +53,7 @@ def apply_jinja_template( def apply_jinja_template_to_alert_payload_and_labels( - template: str, payload: typing.Optional["Alert.RawRequestData"], labels: typing.Optional["Labels"] + template: str, payload: typing.Optional["Alert.RawRequestData"], labels: typing.Optional["AlertLabels"] ) -> str: return apply_jinja_template(template, payload=payload, labels=labels) diff --git a/engine/settings/celery_task_routes.py b/engine/settings/celery_task_routes.py index 73dea53d..d77ee25c 100644 --- a/engine/settings/celery_task_routes.py +++ b/engine/settings/celery_task_routes.py @@ -17,6 +17,8 @@ CELERY_TASK_ROUTES = { "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.labels.tasks.update_label_option_cache": {"queue": "default"}, + "apps.labels.tasks.update_label_pairs_cache": {"queue": "default"}, "apps.metrics_exporter.tasks.start_calculate_and_cache_metrics": {"queue": "default"}, "apps.metrics_exporter.tasks.update_metrics_for_alert_group": {"queue": "default"}, "apps.metrics_exporter.tasks.update_metrics_for_user": {"queue": "default"}, diff --git a/grafana-plugin/package.json b/grafana-plugin/package.json index df7ab847..dfdc90c8 100644 --- a/grafana-plugin/package.json +++ b/grafana-plugin/package.json @@ -124,7 +124,7 @@ "@grafana/data": "^10.2.3", "@grafana/faro-web-sdk": "^1.0.0-beta4", "@grafana/faro-web-tracing": "^1.0.0-beta4", - "@grafana/labels": "~1.4.4", + "@grafana/labels": "1.5.0", "@grafana/runtime": "^10.2.2", "@grafana/scenes": "^1.28.0", "@grafana/schema": "^10.2.2", diff --git a/grafana-plugin/src/components/LabelTag/LabelTag.tsx b/grafana-plugin/src/components/LabelTag/LabelTag.tsx new file mode 100644 index 00000000..7a3c0143 --- /dev/null +++ b/grafana-plugin/src/components/LabelTag/LabelTag.tsx @@ -0,0 +1,85 @@ +import React from 'react'; + +import { css } from '@emotion/css'; +import { GrafanaTheme2 } from '@grafana/data'; +import { HorizontalGroup, getTagColorsFromName, useStyles2 } from '@grafana/ui'; +import tinycolor2 from 'tinycolor2'; + +export interface LabelTagProps { + label: string; + value: string; + size?: LabelTagSize; +} + +export type LabelTagSize = 'md' | 'sm'; + +export const LabelTag: React.FC = (props: LabelTagProps) => { + const { label, value, size = 'sm' } = props; + + const color = getLabelColor(label); + + const styles = useStyles2((theme) => getStyles(theme, color, size)); + + return ( +
+ +
{label ?? ''}
+
{value}
+
+
+ ); +}; + +function getLabelColor(input: string): string { + return getTagColorsFromName(input).color; +} + +const getStyles = (theme: GrafanaTheme2, color?: string, size?: string) => { + const backgroundColor = color ?? theme.colors.secondary.main; + + const borderColor = theme.isDark + ? tinycolor2(backgroundColor).lighten(5).toString() + : tinycolor2(backgroundColor).darken(5).toString(); + + const valueBackgroundColor = theme.isDark + ? tinycolor2(backgroundColor).darken(5).toString() + : tinycolor2(backgroundColor).lighten(5).toString(); + + const fontColor = color + ? tinycolor2.mostReadable(backgroundColor, ['#000', '#fff']).toString() + : theme.colors.text.primary; + + const padding = + size === 'md' ? `${theme.spacing(0.33)} ${theme.spacing(1)}` : `${theme.spacing(0.2)} ${theme.spacing(0.6)}`; + + return { + wrapper: css` + color: ${fontColor}; + font-size: ${theme.typography.bodySmall.fontSize}; + + border-radius: ${theme.shape.borderRadius(2)}; + `, + label: css` + display: flex; + align-items: center; + color: inherit; + + padding: ${padding}; + background: ${backgroundColor}; + + border: solid 1px ${borderColor}; + border-top-left-radius: ${theme.shape.borderRadius(2)}; + border-bottom-left-radius: ${theme.shape.borderRadius(2)}; + `, + value: css` + color: inherit; + padding: ${padding}; + background: ${valueBackgroundColor}; + + border: solid 1px ${borderColor}; + border-left: none; + border-top-right-radius: ${theme.shape.borderRadius(2)}; + border-bottom-right-radius: ${theme.shape.borderRadius(2)}; + `, + }; +}; diff --git a/grafana-plugin/src/containers/IntegrationLabelsForm/IntegrationLabelsForm.tsx b/grafana-plugin/src/containers/IntegrationLabelsForm/IntegrationLabelsForm.tsx index dc150c93..73d5f979 100644 --- a/grafana-plugin/src/containers/IntegrationLabelsForm/IntegrationLabelsForm.tsx +++ b/grafana-plugin/src/containers/IntegrationLabelsForm/IntegrationLabelsForm.tsx @@ -1,4 +1,4 @@ -import React, { ChangeEvent, useCallback, useState } from 'react'; +import React, { ChangeEvent, useState } from 'react'; import { ServiceLabels } from '@grafana/labels'; import { @@ -22,6 +22,7 @@ import { RenderConditionally } from 'components/RenderConditionally/RenderCondit import { Text } from 'components/Text/Text'; import { IntegrationTemplate } from 'containers/IntegrationTemplate/IntegrationTemplate'; import { AlertReceiveChannel } from 'models/alert_receive_channel/alert_receive_channel.types'; +import { splitToGroups } from 'models/label/label.helpers'; import { LabelsErrors } from 'models/label/label.types'; import { ApiSchemas } from 'network/oncall-api/api.types'; import { LabelTemplateOptions } from 'pages/integration/IntegrationCommon.config'; @@ -281,36 +282,34 @@ const CustomLabels = (props: CustomLabelsProps) => { }); }; - const cachedOnLoadKeys = useCallback(() => { + const onLoadKeys = async (search?: string) => { 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())); - }; - }, []); + try { + result = await labelsStore.loadKeys(search); + } catch (error) { + openErrorNotification('There was an error processing your request. Please try again'); + } - const cachedOnLoadValuesForKey = useCallback(() => { + const groups = splitToGroups(result); + + return groups; + }; + + const onLoadValuesForKey = async (key: string, search?: string) => { 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())); - }; - }, []); + try { + const { values } = await labelsStore.loadValuesForKey(key, search); + result = values; + } catch (error) { + openErrorNotification('There was an error processing your request. Please try again'); + } + + const groups = splitToGroups(result); + + return groups; + }; return ( @@ -328,8 +327,8 @@ const CustomLabels = (props: CustomLabelsProps) => { inputWidth={INPUT_WIDTH} errors={customLabelsErrors} value={alertGroupLabels.custom} - onLoadKeys={cachedOnLoadKeys()} - onLoadValuesForKey={cachedOnLoadValuesForKey()} + onLoadKeys={onLoadKeys} + onLoadValuesForKey={onLoadValuesForKey} onCreateKey={labelsStore.createKey} onUpdateKey={labelsStore.updateKey} onCreateValue={labelsStore.createValue} @@ -377,6 +376,8 @@ const CustomLabels = (props: CustomLabelsProps) => { custom: value, }); }} + getIsKeyEditable={(option) => !option.prescribed} + getIsValueEditable={(option) => !option.prescribed} /> { + const onLoadKeys = async (search?: string) => { 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'); - } - } + try { + result = await labelsStore.loadKeys(search); + } catch (error) { + openErrorNotification('There was an error processing your request. Please try again'); + } - return result.filter((k) => k.name.toLowerCase().includes(search.toLowerCase())); - }; - }, []); + const groups = splitToGroups(result); + + return groups; + }; + + const onLoadValuesForKey = async (key: string, search?: string) => { + let result = undefined; + try { + const { values } = await labelsStore.loadValuesForKey(key, search); + result = values; + } catch (error) { + openErrorNotification('There was an error processing your request. Please try again'); + } + + const groups = splitToGroups(result); + + return groups; + }; const isValid = () => { return ( @@ -86,30 +99,14 @@ const _Labels = observer( ); }; - 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 (
{description}
}>Labels}> !option.prescribed} + getIsValueEditable={(option) => !option.prescribed} /> diff --git a/grafana-plugin/src/models/label/label.helpers.ts b/grafana-plugin/src/models/label/label.helpers.ts new file mode 100644 index 00000000..0e9e1744 --- /dev/null +++ b/grafana-plugin/src/models/label/label.helpers.ts @@ -0,0 +1,15 @@ +import { ApiSchemas } from 'network/oncall-api/api.types'; + +export const splitToGroups = (labels: Array | Array) => { + return labels.reduce( + (memo, option) => { + memo.find(({ name }) => name === (option.prescribed ? 'System' : 'User added')).options.push(option); + + return memo; + }, + [ + { name: 'System', id: 'system', expanded: true, options: [] }, + { name: 'User added', id: 'user_added', expanded: true, options: [] }, + ] + ); +}; diff --git a/grafana-plugin/src/models/label/label.ts b/grafana-plugin/src/models/label/label.ts index 96b9baef..199d7740 100644 --- a/grafana-plugin/src/models/label/label.ts +++ b/grafana-plugin/src/models/label/label.ts @@ -1,4 +1,4 @@ -import { action, observable, makeObservable, runInAction } from 'mobx'; +import { action, makeObservable } from 'mobx'; import { BaseStore } from 'models/base_store'; import { makeRequest } from 'network/network'; @@ -8,12 +8,6 @@ import { RootStore } from 'state/rootStore'; import { WithGlobalNotification } from 'utils/decorators'; export class LabelStore extends BaseStore { - @observable.shallow - public keys: Array = []; - - @observable.shallow - public values: { [key: string]: Array } = {}; - constructor(rootStore: RootStore) { super(rootStore); @@ -23,14 +17,12 @@ export class LabelStore extends BaseStore { } @action.bound - public async loadKeys() { + public async loadKeys(search = '') { const { data } = await onCallApi.GET('/labels/keys/', undefined); - runInAction(() => { - this.keys = data; - }); + const filtered = data.filter((k) => k.name.toLowerCase().includes(search.toLowerCase())); - return data; + return filtered; } @action.bound @@ -43,14 +35,7 @@ export class LabelStore extends BaseStore { params: { search }, }); - const filteredValues = result.values.filter((v) => v.name.toLowerCase().includes(search.toLowerCase())); // TODO remove after backend search implementation - - runInAction(() => { - this.values = { - ...this.values, - [key]: filteredValues, - }; - }); + const filteredValues = result.values.filter((v) => v.name.toLowerCase().includes(search.toLowerCase())); return { ...result, values: filteredValues }; } diff --git a/grafana-plugin/src/network/oncall-api/autogenerated-api.types.d.ts b/grafana-plugin/src/network/oncall-api/autogenerated-api.types.d.ts index 966546a0..2559c9be 100644 --- a/grafana-plugin/src/network/oncall-api/autogenerated-api.types.d.ts +++ b/grafana-plugin/src/network/oncall-api/autogenerated-api.types.d.ts @@ -571,6 +571,7 @@ export interface components { LabelKey: { id: string; name: string; + prescribed?: boolean; }; LabelKeyValues: { key: components['schemas']['LabelKey']; @@ -582,6 +583,7 @@ export interface components { LabelValue: { id: string; name: string; + prescribed?: boolean; }; PaginatedAlertGroupListList: { next?: string | null; diff --git a/grafana-plugin/yarn.lock b/grafana-plugin/yarn.lock index 0db6c506..1613e4c3 100644 --- a/grafana-plugin/yarn.lock +++ b/grafana-plugin/yarn.lock @@ -1372,6 +1372,11 @@ resolved "https://registry.yarnpkg.com/@csstools/selector-specificity/-/selector-specificity-2.0.2.tgz#1bfafe4b7ed0f3e4105837e056e0a89b108ebe36" integrity sha512-IkpVW/ehM1hWKln4fCA3NzJU8KwD+kIOvPZA4cqxoJHtE21CCzjyp+Kxbu0i5I4tBNOlXPL9mjwnWlL0VEG4Fg== +"@discoveryjs/json-ext@0.5.7": + version "0.5.7" + resolved "https://registry.yarnpkg.com/@discoveryjs/json-ext/-/json-ext-0.5.7.tgz#1d572bfbbe14b7704e0ba0f39b74815b84870d70" + integrity sha512-dBVuXR082gk3jsFp7Rd/JI4kytwGHecnCoTtXFb7DB6CNHp4rg5k1bhg0nWdLGLnOV71lmDzGQaLMy8iPLY0pw== + "@dnd-kit/accessibility@^3.0.0": version "3.0.1" resolved "https://registry.yarnpkg.com/@dnd-kit/accessibility/-/accessibility-3.0.1.tgz#3ccbefdfca595b0a23a5dc57d3de96bc6935641c" @@ -2021,17 +2026,19 @@ "@opentelemetry/sdk-trace-web" "^1.8.0" "@opentelemetry/semantic-conventions" "^1.8.0" -"@grafana/labels@~1.4.4": - version "1.4.4" - resolved "https://registry.yarnpkg.com/@grafana/labels/-/labels-1.4.4.tgz#f8fc6e99fa42f416c9b8def73929545c78f372e2" - integrity sha512-QJqBATeKHrUNTETnqTKFVeevjP/Z4N1m7gbzhZ6hOMs7vPt75csWZ7pMjURNU/UXmP5uoEADJnj9rw8kvrAtHQ== +"@grafana/labels@1.5.0": + version "1.5.0" + resolved "https://registry.yarnpkg.com/@grafana/labels/-/labels-1.5.0.tgz#e960df1248ba26b5ff06fe8fcde1824022061ce0" + integrity sha512-xcX1R0e0usKGLBy4XiOu2QcG1PVhn0uvrN4uvKLRVvbp5jJmSAKqhnHjSDTH/p14MUdsz4lBU1IAKgvGvRAVVQ== dependencies: "@emotion/css" "^11.11.2" "@grafana/ui" "^10.0.0" change-case "^4.1.2" + lodash-es "^4.17.21" react "^18.0.0" react-dom "^18.0.0" tinycolor2 "1.6.0" + webpack-bundle-analyzer "^4.10.1" "@grafana/runtime@^10.2.2": version "10.2.2" @@ -3240,6 +3247,11 @@ resolved "https://registry.yarnpkg.com/@polka/url/-/url-1.0.0-next.21.tgz#5de5a2385a35309427f6011992b544514d559aa1" integrity sha512-a5Sab1C4/icpTZVzZc5Ghpz88yQtGOyNqYXcZgOssB2uuAr+wF/MvN6bgtW32q7HHrvBki+BsZ0OuNv6EV3K9g== +"@polka/url@^1.0.0-next.24": + version "1.0.0-next.24" + resolved "https://registry.yarnpkg.com/@polka/url/-/url-1.0.0-next.24.tgz#58601079e11784d20f82d0585865bb42305c4df3" + integrity sha512-2LuNTFBIO0m7kKIQvvPHN6UE63VjpmL9rnEEaOOaiSPbZK+zUOYIzBAWcED+3XYzhYsd/0mD57VdxAEqqV52CQ== + "@popperjs/core@2.11.6", "@popperjs/core@^2.11.5": version "2.11.6" resolved "https://registry.yarnpkg.com/@popperjs/core/-/core-2.11.6.tgz#cee20bd55e68a1720bdab363ecf0c821ded4cd45" @@ -6920,6 +6932,11 @@ dayjs@^1.11.5: resolved "https://registry.yarnpkg.com/dayjs/-/dayjs-1.11.6.tgz#2e79a226314ec3ec904e3ee1dd5a4f5e5b1c7afb" integrity sha512-zZbY5giJAinCG+7AGaw0wIhNZ6J8AhWuSXKvuc1KAyMiRsvGQWqh4L+MomvhdAYjN+lqvVCMq1I41e3YHvXkyQ== +debounce@^1.2.1: + version "1.2.1" + resolved "https://registry.yarnpkg.com/debounce/-/debounce-1.2.1.tgz#38881d8f4166a5c5848020c11827b834bcb3e0a5" + integrity sha512-XRRe6Glud4rd/ZGQfiV1ruXSfbvfJedlV9Y6zOlP+2K04vBYiJEte6stfFkCP03aMnY5tsipamumUjL14fofug== + debug@4, debug@^4.0.0, debug@^4.1.0, debug@^4.1.1, debug@^4.2.0, debug@^4.3.1, debug@^4.3.2, debug@^4.3.4: version "4.3.4" resolved "https://registry.yarnpkg.com/debug/-/debug-4.3.4.tgz#1319f6579357f2338d3337d2cdd4914bb5dcc865" @@ -8702,7 +8719,7 @@ html-encoding-sniffer@^2.0.1: dependencies: whatwg-encoding "^1.0.5" -html-escaper@^2.0.0: +html-escaper@^2.0.0, html-escaper@^2.0.2: version "2.0.2" resolved "https://registry.yarnpkg.com/html-escaper/-/html-escaper-2.0.2.tgz#dfd60027da36a36dfcbe236262c00a5822681453" integrity sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg== @@ -9320,6 +9337,11 @@ is-plain-object@^2.0.3, is-plain-object@^2.0.4: dependencies: isobject "^3.0.1" +is-plain-object@^5.0.0: + version "5.0.0" + resolved "https://registry.yarnpkg.com/is-plain-object/-/is-plain-object-5.0.0.tgz#4427f50ab3429e9025ea7d52e9043a9ef4159344" + integrity sha512-VRSzKkbMm5jMDoKLbltAkFQ5Qr7VDiTFGXxYFXXowVj387GeGNOCsOH6Msy00SGZ3Fp84b1Naa1psqgcCIEP5Q== + is-potential-custom-element-name@^1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz#171ed6f19e3ac554394edf78caa05784a45bebb5" @@ -10878,6 +10900,11 @@ mrmime@^1.0.0: resolved "https://registry.yarnpkg.com/mrmime/-/mrmime-1.0.1.tgz#5f90c825fad4bdd41dc914eff5d1a8cfdaf24f27" integrity sha512-hzzEagAgDyoU1Q6yg5uI+AorQgdvMCur3FcKf7NhMKWsaYg+RnbTyHRa/9IlLF9rf455MOCtcqqrQQ83pPP7Uw== +mrmime@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/mrmime/-/mrmime-2.0.0.tgz#151082a6e06e59a9a39b46b3e14d5cfe92b3abb4" + integrity sha512-eu38+hdgojoyq63s+yTpN4XMBdt5l8HhMhc4VKLO9KM5caLIBvUm4thi7fFaxyTmCKeNnXZ5pAlBwCUnhA09uw== + ms@2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/ms/-/ms-2.0.0.tgz#5608aeadfc00be6c2901df5f9861788de0d597c8" @@ -14024,6 +14051,15 @@ sirv@^1.0.7: mrmime "^1.0.0" totalist "^1.0.0" +sirv@^2.0.3: + version "2.0.4" + resolved "https://registry.yarnpkg.com/sirv/-/sirv-2.0.4.tgz#5dd9a725c578e34e449f332703eb2a74e46a29b0" + integrity sha512-94Bdh3cC2PKrbgSOUqTiGPWVZeSiXfKOVZNJniWoqrWrRkB1CJzBU3NEbiTsPcYy1lDsANA/THzS+9WBiy5nfQ== + dependencies: + "@polka/url" "^1.0.0-next.24" + mrmime "^2.0.0" + totalist "^3.0.0" + sisteransi@^1.0.5: version "1.0.5" resolved "https://registry.yarnpkg.com/sisteransi/-/sisteransi-1.0.5.tgz#134d681297756437cc05ca01370d3a7a571075ed" @@ -14917,6 +14953,11 @@ totalist@^1.0.0: resolved "https://registry.yarnpkg.com/totalist/-/totalist-1.1.0.tgz#a4d65a3e546517701e3e5c37a47a70ac97fe56df" integrity sha512-gduQwd1rOdDMGxFG1gEvhV88Oirdo2p+KjoYFU7k2g+i7n6AFFbDQ5kMPUsW0pNbfQsB/cwXvT1i4Bue0s9g5g== +totalist@^3.0.0: + version "3.0.1" + resolved "https://registry.yarnpkg.com/totalist/-/totalist-3.0.1.tgz#ba3a3d600c915b1a97872348f79c127475f6acf8" + integrity sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ== + tough-cookie@^4.0.0: version "4.1.3" resolved "https://registry.yarnpkg.com/tough-cookie/-/tough-cookie-4.1.3.tgz#97b9adb0728b42280aa3d814b6b999b2ff0318bf" @@ -15515,6 +15556,25 @@ webidl-conversions@^6.1.0: resolved "https://registry.yarnpkg.com/webidl-conversions/-/webidl-conversions-6.1.0.tgz#9111b4d7ea80acd40f5270d666621afa78b69514" integrity sha512-qBIvFLGiBpLjfwmYAaHPXsn+ho5xZnGvyGvsarywGNc8VyQJUMHJ8OBKGGrPER0okBeMDaan4mNBlgBROxuI8w== +webpack-bundle-analyzer@^4.10.1: + version "4.10.1" + resolved "https://registry.yarnpkg.com/webpack-bundle-analyzer/-/webpack-bundle-analyzer-4.10.1.tgz#84b7473b630a7b8c21c741f81d8fe4593208b454" + integrity sha512-s3P7pgexgT/HTUSYgxJyn28A+99mmLq4HsJepMPzu0R8ImJc52QNqaFYW1Z2z2uIb1/J3eYgaAWVpaC+v/1aAQ== + dependencies: + "@discoveryjs/json-ext" "0.5.7" + acorn "^8.0.4" + acorn-walk "^8.0.0" + commander "^7.2.0" + debounce "^1.2.1" + escape-string-regexp "^4.0.0" + gzip-size "^6.0.0" + html-escaper "^2.0.2" + is-plain-object "^5.0.0" + opener "^1.5.2" + picocolors "^1.0.0" + sirv "^2.0.3" + ws "^7.3.1" + webpack-bundle-analyzer@^4.6.1: version "4.7.0" resolved "https://registry.yarnpkg.com/webpack-bundle-analyzer/-/webpack-bundle-analyzer-4.7.0.tgz#33c1c485a7fcae8627c547b5c3328b46de733c66"