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 <maxim.mordasov@grafana.com>
Co-authored-by: Yulya Artyukhina <Ferril.darkdiver@gmail.com>
This commit is contained in:
Innokentii Konstantinov 2024-02-20 14:42:51 +08:00 committed by GitHub
parent fde2214949
commit acd0c44c33
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
29 changed files with 634 additions and 268 deletions

View file

@ -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

View file

@ -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

View file

@ -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: [<LABEL_KEY_ID>, <LABEL_VALUE_ID>, None]

View file

@ -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:

View file

@ -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)
]

View file

@ -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:

View file

@ -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 = {

View file

@ -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

View file

@ -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,
}

View file

@ -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 = (

View file

@ -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:

View file

@ -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)

View file

@ -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),
),
]

View file

@ -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:

View file

@ -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,))

View file

@ -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},
}
]

View file

@ -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()

View file

@ -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]

View file

@ -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)

View file

@ -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)

View file

@ -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"},

View file

@ -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",

View file

@ -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<LabelTagProps> = (props: LabelTagProps) => {
const { label, value, size = 'sm' } = props;
const color = getLabelColor(label);
const styles = useStyles2((theme) => getStyles(theme, color, size));
return (
<div className={styles.wrapper} role="listitem">
<HorizontalGroup spacing="none">
<div className={styles.label}>{label ?? ''}</div>
<div className={styles.value}>{value}</div>
</HorizontalGroup>
</div>
);
};
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)};
`,
};
};

View file

@ -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 (
<VerticalGroup>
@ -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}
/>
<Dropdown
overlay={

View file

@ -1,10 +1,11 @@
import React, { forwardRef, useCallback, useImperativeHandle, useState } from 'react';
import React, { forwardRef, useImperativeHandle, useState } from 'react';
import { ServiceLabels, ServiceLabelsProps } from '@grafana/labels';
import { ServiceLabelsProps, ServiceLabels } from '@grafana/labels';
import { Field, Label } from '@grafana/ui';
import { isEmpty } from 'lodash-es';
import { observer } from 'mobx-react';
import { splitToGroups } from 'models/label/label.helpers';
import { LabelKeyValue } from 'models/label/label.types';
import { useStore } from 'state/useStore';
import { openErrorNotification } from 'utils/utils';
@ -46,20 +47,32 @@ const _Labels = observer(
[value]
);
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');
}
}
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 (
<div>
<Field label={<Label description={<div className="u-padding-vertical-xs">{description}</div>}>Labels</Label>}>
<ServiceLabels
loadById
value={value}
onLoadKeys={cachedOnLoadKeys()}
onLoadValuesForKey={cachedOnLoadValuesForKey()}
onLoadKeys={onLoadKeys}
onLoadValuesForKey={onLoadValuesForKey}
onCreateKey={labelsStore.createKey}
onUpdateKey={labelsStore.updateKey}
onCreateValue={labelsStore.createValue}
@ -118,6 +115,8 @@ const _Labels = observer(
onUpdateError={onUpdateError}
errors={isValid() ? {} : { ...propsErrors }}
onDataUpdate={onChange}
getIsKeyEditable={(option) => !option.prescribed}
getIsValueEditable={(option) => !option.prescribed}
/>
</Field>
</div>

View file

@ -0,0 +1,15 @@
import { ApiSchemas } from 'network/oncall-api/api.types';
export const splitToGroups = (labels: Array<ApiSchemas['LabelKey']> | Array<ApiSchemas['LabelValue']>) => {
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: [] },
]
);
};

View file

@ -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<ApiSchemas['LabelKey']> = [];
@observable.shallow
public values: { [key: string]: Array<ApiSchemas['LabelValue']> } = {};
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 };
}

View file

@ -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;

View file

@ -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"