From acd0c44c33f1279d4dd17a9ffcb0b523c56c9c97 Mon Sep 17 00:00:00 2001 From: Innokentii Konstantinov Date: Tue, 20 Feb 2024 14:42:51 +0800 Subject: [PATCH] Support prescribed labels (#3848) # What this PR does **Cleanup label typing:** 1. LabelParam -> two separate types LabekKey and LabelValue 2. LabelData -> renamed to LabelPair. 3. LabelKeyData -> renamed to LabelOption Data is not giving any info about what this type represents. 4. Remove LabelsData and LabelsKeysData types. They are just list of types listed above and with new naming it feels obsolete. 5. ValueData removed. LabelPair is used instead. 6. Rework AlertGroupCustomLabel to use LabelKey type for key to make type system more consistent. Name model type AlertGroupCustomLabel**DB** and api type AlertGroupCustomLabel**API** to clearly distinguish them. **Split update_labels_cache into two tasks** update_label_option_cache and update_label_pairs_cache. Original task was expecting array of LabelsData (now it's LabelPair) OR one LabelKeyData ( now it's LabelOption). I believe having one function with two sp different argument types makes it more complicated for understanding. **Make OnCall backend support prescribed labels**. OnCall will sync and store "prescribed" field for key and values, so Label dropdown able to disable editing for certain labels. ## Which issue(s) this PR fixes ## Checklist - [x] Unit, integration, and e2e (if applicable) tests updated - [ ] Documentation added (or `pr:no public docs` PR label added if not required) - [x] `CHANGELOG.md` updated (or `pr:no changelog` PR label added if not required) --------- Co-authored-by: Maxim Mordasov Co-authored-by: Yulya Artyukhina --- CHANGELOG.md | 1 + engine/apps/alerts/models/alert.py | 6 +- .../alerts/models/alert_receive_channel.py | 4 +- engine/apps/alerts/models/channel_filter.py | 6 +- .../api/serializers/alert_receive_channel.py | 79 ++++++++------ engine/apps/api/serializers/labels.py | 10 +- .../api/tests/test_alert_receive_channel.py | 100 +++++++++++++++--- engine/apps/api/tests/test_labels.py | 6 +- engine/apps/api/tests/test_webhooks.py | 16 ++- engine/apps/api/views/labels.py | 73 +++++++------ engine/apps/labels/alert_group_labels.py | 8 +- engine/apps/labels/client.py | 22 ++-- ...e_prescribed_labelvaluecache_prescribed.py | 23 ++++ engine/apps/labels/models.py | 37 ++++--- engine/apps/labels/tasks.py | 94 +++++++++++++--- engine/apps/labels/tests/test_labels.py | 12 +-- engine/apps/labels/tests/test_labels_cache.py | 22 ++-- engine/apps/labels/types.py | 30 +++++- engine/apps/labels/utils.py | 28 ----- .../jinja_templater/apply_jinja_template.py | 4 +- engine/settings/celery_task_routes.py | 2 + grafana-plugin/package.json | 2 +- .../src/components/LabelTag/LabelTag.tsx | 85 +++++++++++++++ .../IntegrationLabelsForm.tsx | 57 +++++----- .../src/containers/Labels/Labels.tsx | 63 ++++++----- .../src/models/label/label.helpers.ts | 15 +++ grafana-plugin/src/models/label/label.ts | 25 +---- .../oncall-api/autogenerated-api.types.d.ts | 2 + grafana-plugin/yarn.lock | 70 +++++++++++- 29 files changed, 634 insertions(+), 268 deletions(-) create mode 100644 engine/apps/labels/migrations/0005_labelkeycache_prescribed_labelvaluecache_prescribed.py create mode 100644 grafana-plugin/src/components/LabelTag/LabelTag.tsx create mode 100644 grafana-plugin/src/models/label/label.helpers.ts 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"