Add labels implementation for integration (#3014)

# What this PR does
Adds labels implementation for integrations:
- ability to create/update labels on creating/updating integration
- ability to associate labels to integrations
- cache for label reprs on OnCall side
- feature flag to enable/disable labels

## Which issue(s) this PR fixes
https://github.com/grafana/oncall-private/issues/2157

## Checklist

- [x] Unit, integration, and e2e (if applicable) tests updated
- [x] Documentation added (or `pr:no public docs` PR label added if not
required)
- [x] `CHANGELOG.md` updated (or `pr:no changelog` PR label added if not
required)

---------

Co-authored-by: Maxim <maxim.mordasov@grafana.com>
Co-authored-by: Rares Mardare <rares.mardare@grafana.com>
This commit is contained in:
Yulya Artyukhina 2023-10-20 09:30:11 +02:00 committed by GitHub
parent bb0bee421e
commit 24f4969f61
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
51 changed files with 2759 additions and 110 deletions

View file

@ -29,6 +29,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Added
- Update plugin OnCaller role permissions ([#3145](https://github.com/grafana/oncall/pull/3145))
- Add labels implementation for OnCall integrations under the feature flag ([#3014](https://github.com/grafana/oncall/pull/3014))
### Fixed

View file

@ -21,6 +21,7 @@ from common.jinja_templater import apply_jinja_template, jinja_template_env
from common.jinja_templater.apply_jinja_template import JinjaTemplateWarning
from .integration_heartbeat import IntegrationHeartBeatSerializer
from .labels import LabelsSerializerMixin
def valid_jinja_template_for_serializer_method_field(template):
@ -32,7 +33,7 @@ def valid_jinja_template_for_serializer_method_field(template):
pass
class AlertReceiveChannelSerializer(EagerLoadingMixin, serializers.ModelSerializer):
class AlertReceiveChannelSerializer(EagerLoadingMixin, LabelsSerializerMixin, serializers.ModelSerializer):
id = serializers.CharField(read_only=True, source="public_primary_key")
integration_url = serializers.ReadOnlyField()
alert_count = serializers.SerializerMethodField()
@ -58,7 +59,7 @@ class AlertReceiveChannelSerializer(EagerLoadingMixin, serializers.ModelSerializ
# integration heartbeat is in PREFETCH_RELATED not by mistake.
# With using of select_related ORM builds strange join
# which leads to incorrect heartbeat-alert_receive_channel binding in result
PREFETCH_RELATED = ["channel_filters", "integration_heartbeat"]
PREFETCH_RELATED = ["channel_filters", "integration_heartbeat", "labels", "labels__key", "labels__value"]
SELECT_RELATED = ["organization", "author"]
class Meta:
@ -93,6 +94,7 @@ class AlertReceiveChannelSerializer(EagerLoadingMixin, serializers.ModelSerializ
"is_based_on_alertmanager",
"inbound_email",
"is_legacy",
"labels",
]
read_only_fields = [
"created_at",
@ -125,6 +127,7 @@ class AlertReceiveChannelSerializer(EagerLoadingMixin, serializers.ModelSerializ
if _integration.slug == integration:
is_able_to_autoresolve = _integration.is_able_to_autoresolve
labels = validated_data.pop("labels", None)
try:
instance = AlertReceiveChannel.create(
**validated_data,
@ -134,12 +137,15 @@ class AlertReceiveChannelSerializer(EagerLoadingMixin, serializers.ModelSerializ
)
except AlertReceiveChannel.DuplicateDirectPagingError:
raise BadRequest(detail=AlertReceiveChannel.DuplicateDirectPagingError.DETAIL)
self.update_labels_association_if_needed(labels, instance, organization)
return instance
def update(self, *args, **kwargs):
def update(self, instance, validated_data):
labels = validated_data.pop("labels", None)
organization = self.context["request"].auth.organization
self.update_labels_association_if_needed(labels, instance, organization)
try:
return super().update(*args, **kwargs)
return super().update(instance, validated_data)
except AlertReceiveChannel.DuplicateDirectPagingError:
raise BadRequest(detail=AlertReceiveChannel.DuplicateDirectPagingError.DETAIL)

View file

@ -0,0 +1,55 @@
from rest_framework import serializers
from apps.labels.models import AssociatedLabel, LabelKeyCache, LabelValueCache
from apps.labels.utils import is_labels_feature_enabled
class LabelKeySerializer(serializers.ModelSerializer):
id = serializers.CharField()
class Meta:
model = LabelKeyCache
fields = (
"id",
"name",
)
class LabelValueSerializer(serializers.ModelSerializer):
id = serializers.CharField()
class Meta:
model = LabelValueCache
fields = (
"id",
"name",
)
class LabelSerializer(serializers.Serializer):
key = LabelKeySerializer()
value = LabelValueSerializer()
class LabelKeyValuesSerializer(serializers.Serializer):
key = LabelKeySerializer()
values = LabelValueSerializer(many=True)
class LabelReprSerializer(serializers.Serializer):
name = serializers.CharField()
class LabelsSerializerMixin(serializers.Serializer):
labels = LabelSerializer(many=True, required=False)
def validate_labels(self, labels):
if labels:
keys = {label["key"]["id"] for label in labels}
if len(keys) != len(labels):
raise serializers.ValidationError(detail="Duplicate label key")
return labels
def update_labels_association_if_needed(self, labels, instance, organization):
if labels is not None and is_labels_feature_enabled(organization):
AssociatedLabel.update_association(labels, instance, organization)

View file

@ -1213,3 +1213,117 @@ def test_alert_receive_channel_contact_points_wrong_integration(
else:
response = client.post(url, format="json", data={}, **make_user_auth_headers(user, token))
assert response.status_code == status.HTTP_400_BAD_REQUEST
@pytest.mark.django_db
def test_integration_filter_by_labels(
make_organization_and_user_with_plugin_token,
make_alert_receive_channel,
make_integration_label_association,
make_user_auth_headers,
):
organization, user, token = make_organization_and_user_with_plugin_token()
alert_receive_channel_1 = make_alert_receive_channel(organization)
alert_receive_channel_2 = make_alert_receive_channel(organization)
associated_label_1 = make_integration_label_association(organization, alert_receive_channel_1)
associated_label_2 = make_integration_label_association(organization, alert_receive_channel_1)
alert_receive_channel_2.labels.create(
key=associated_label_1.key, value=associated_label_1.value, organization=organization
)
client = APIClient()
url = reverse("api-internal:alert_receive_channel-list")
response = client.get(
f"{url}?label={associated_label_1.key_id}:{associated_label_1.value_id}",
content_type="application/json",
**make_user_auth_headers(user, token),
)
assert response.status_code == status.HTTP_200_OK
assert len(response.json()["results"]) == 2
response = client.get(
f"{url}?label={associated_label_1.key_id}:{associated_label_1.value_id}"
f"&label={associated_label_2.key_id}:{associated_label_2.value_id}",
content_type="application/json",
**make_user_auth_headers(user, token),
)
assert response.status_code == status.HTTP_200_OK
assert len(response.json()["results"]) == 1
assert response.json()["results"][0]["id"] == alert_receive_channel_1.public_primary_key
@pytest.mark.django_db
def test_update_alert_receive_channel_labels(
make_organization_and_user_with_plugin_token,
make_alert_receive_channel,
make_integration_label_association,
make_user_auth_headers,
):
organization, user, token = make_organization_and_user_with_plugin_token()
alert_receive_channel = make_alert_receive_channel(organization)
client = APIClient()
url = reverse("api-internal:alert_receive_channel-detail", kwargs={"pk": alert_receive_channel.public_primary_key})
key_id = "testkey"
value_id = "testvalue"
data = {"labels": [{"key": {"id": key_id, "name": "test"}, "value": {"id": value_id, "name": "testv"}}]}
response = client.patch(
url,
data=json.dumps(data),
content_type="application/json",
**make_user_auth_headers(user, token),
)
alert_receive_channel.refresh_from_db()
assert response.status_code == status.HTTP_200_OK
assert alert_receive_channel.labels.count() == 1
label = alert_receive_channel.labels.first()
assert label.key_id == key_id
assert label.value_id == value_id
response = client.patch(
url,
data=json.dumps({"labels": []}),
content_type="application/json",
**make_user_auth_headers(user, token),
)
alert_receive_channel.refresh_from_db()
assert response.status_code == status.HTTP_200_OK
assert alert_receive_channel.labels.count() == 0
@pytest.mark.django_db
def test_update_alert_receive_channel_labels_duplicate_key(
make_organization_and_user_with_plugin_token,
make_alert_receive_channel,
make_integration_label_association,
make_user_auth_headers,
):
organization, user, token = make_organization_and_user_with_plugin_token()
alert_receive_channel = make_alert_receive_channel(organization)
client = APIClient()
url = reverse("api-internal:alert_receive_channel-detail", kwargs={"pk": alert_receive_channel.public_primary_key})
key_id = "testkey"
data = {
"labels": [
{"key": {"id": key_id, "name": "test"}, "value": {"id": "testvalue1", "name": "testv1"}},
{"key": {"id": key_id, "name": "test"}, "value": {"id": "testvalue2", "name": "testv2"}},
]
}
response = client.patch(
url,
data=json.dumps(data),
content_type="application/json",
**make_user_auth_headers(user, token),
)
alert_receive_channel.refresh_from_db()
assert response.status_code == status.HTTP_400_BAD_REQUEST
assert alert_receive_channel.labels.count() == 0

View file

@ -0,0 +1,223 @@
from unittest.mock import patch
import pytest
from django.urls import reverse
from rest_framework import status
from rest_framework.test import APIClient
@patch(
"apps.labels.client.LabelsAPIClient.get_keys",
return_value=([{"name": "team", "id": "keyid123"}], {"status_code": status.HTTP_200_OK}),
)
@pytest.mark.django_db
def test_labels_get_keys(
mocked_get_labels_keys,
make_organization_and_user_with_plugin_token,
make_user_auth_headers,
make_alert_receive_channel,
):
organization, user, token = make_organization_and_user_with_plugin_token()
client = APIClient()
url = reverse("api-internal:get_keys")
response = client.get(url, format="json", **make_user_auth_headers(user, token))
expected_result = [{"name": "team", "id": "keyid123"}]
assert mocked_get_labels_keys.called
assert response.status_code == status.HTTP_200_OK
assert response.json() == expected_result
@patch(
"apps.labels.client.LabelsAPIClient.get_values",
return_value=(
{"key": {"id": "keyid123", "name": "team"}, "values": [{"id": "valueid123", "name": "yolo"}]},
{"status_code": status.HTTP_200_OK},
),
)
@pytest.mark.django_db
def test_get_update_key_get(
mocked_get_values,
make_organization_and_user_with_plugin_token,
make_user_auth_headers,
make_alert_receive_channel,
):
organization, user, token = make_organization_and_user_with_plugin_token()
client = APIClient()
url = reverse("api-internal:get_update_key", kwargs={"key_id": "keyid123"})
response = client.get(url, format="json", **make_user_auth_headers(user, token))
expected_result = {"key": {"id": "keyid123", "name": "team"}, "values": [{"id": "valueid123", "name": "yolo"}]}
assert mocked_get_values.called
assert response.status_code == status.HTTP_200_OK
assert response.json() == expected_result
@patch(
"apps.labels.client.LabelsAPIClient.rename_key",
return_value=(
{"key": {"id": "keyid123", "name": "team"}, "values": [{"id": "valueid123", "name": "yolo"}]},
{"status_code": status.HTTP_200_OK},
),
)
@pytest.mark.django_db
def test_get_update_key_put(
mocked_rename_key,
make_organization_and_user_with_plugin_token,
make_user_auth_headers,
make_alert_receive_channel,
):
organization, user, token = make_organization_and_user_with_plugin_token()
client = APIClient()
url = reverse("api-internal:get_update_key", kwargs={"key_id": "keyid123"})
data = {"name": "team"}
response = client.put(url, format="json", **make_user_auth_headers(user, token), data=data)
expected_result = {"key": {"id": "keyid123", "name": "team"}, "values": [{"id": "valueid123", "name": "yolo"}]}
assert mocked_rename_key.called
assert response.status_code == status.HTTP_200_OK
assert response.json() == expected_result
@patch(
"apps.labels.client.LabelsAPIClient.add_value",
return_value=(
{"key": {"id": "keyid123", "name": "team"}, "values": [{"id": "valueid123", "name": "yolo"}]},
{"status_code": status.HTTP_200_OK},
),
)
@pytest.mark.django_db
def test_add_value(
mocked_add_value,
make_organization_and_user_with_plugin_token,
make_user_auth_headers,
make_alert_receive_channel,
):
organization, user, token = make_organization_and_user_with_plugin_token()
client = APIClient()
url = reverse("api-internal:add_value", kwargs={"key_id": "keyid123"})
data = {"name": "yolo"}
response = client.post(url, format="json", **make_user_auth_headers(user, token), data=data)
expected_result = {"key": {"id": "keyid123", "name": "team"}, "values": [{"id": "valueid123", "name": "yolo"}]}
assert mocked_add_value.called
assert response.status_code == status.HTTP_200_OK
assert response.json() == expected_result
@patch(
"apps.labels.client.LabelsAPIClient.rename_value",
return_value=(
{"key": {"id": "keyid123", "name": "team"}, "values": [{"id": "valueid123", "name": "yolo"}]},
{"status_code": status.HTTP_200_OK},
),
)
@pytest.mark.django_db
def test_rename_value(
mocked_rename_value,
make_organization_and_user_with_plugin_token,
make_user_auth_headers,
make_alert_receive_channel,
):
organization, user, token = make_organization_and_user_with_plugin_token()
client = APIClient()
url = reverse("api-internal:get_update_value", kwargs={"key_id": "keyid123", "value_id": "valueid123"})
data = {"name": "yolo"}
response = client.put(url, format="json", **make_user_auth_headers(user, token), data=data)
expected_result = {"key": {"id": "keyid123", "name": "team"}, "values": [{"id": "valueid123", "name": "yolo"}]}
assert mocked_rename_value.called
assert response.status_code == status.HTTP_200_OK
assert response.json() == expected_result
@patch(
"apps.labels.client.LabelsAPIClient.get_value",
return_value=(
{"id": "valueid123", "name": "yolo"},
{"status_code": status.HTTP_200_OK},
),
)
@pytest.mark.django_db
def test_get_value(
mocked_get_value,
make_organization_and_user_with_plugin_token,
make_user_auth_headers,
make_alert_receive_channel,
):
organization, user, token = make_organization_and_user_with_plugin_token()
client = APIClient()
url = reverse("api-internal:get_update_value", kwargs={"key_id": "keyid123", "value_id": "valueid123"})
response = client.get(url, format="json", **make_user_auth_headers(user, token))
expected_result = {"id": "valueid123", "name": "yolo"}
assert mocked_get_value.called
assert response.status_code == status.HTTP_200_OK
assert response.json() == expected_result
@patch(
"apps.labels.client.LabelsAPIClient.create_label",
return_value=(
{"key": {"id": "keyid123", "name": "team"}, "values": [{"id": "valueid123", "name": "yolo"}]},
{"status_code": status.HTTP_201_CREATED},
),
)
@pytest.mark.django_db
def test_labels_create_label(
mocked_create_label,
make_organization_and_user_with_plugin_token,
make_user_auth_headers,
make_alert_receive_channel,
):
organization, user, token = make_organization_and_user_with_plugin_token()
client = APIClient()
url = reverse("api-internal:create_label")
data = {"key": {"name": "team"}, "values": [{"name": "yolo"}]}
expected_result = {"key": {"id": "keyid123", "name": "team"}, "values": [{"id": "valueid123", "name": "yolo"}]}
response = client.post(url, format="json", data=data, **make_user_auth_headers(user, token))
assert mocked_create_label.called
assert response.status_code == status.HTTP_201_CREATED
assert response.json() == expected_result
@pytest.mark.django_db
def test_labels_feature_false(
make_organization_and_user_with_plugin_token,
make_user_auth_headers,
make_alert_receive_channel,
settings,
):
setattr(settings, "FEATURE_LABELS_ENABLED", False)
organization, user, token = make_organization_and_user_with_plugin_token()
client = APIClient()
url = reverse("api-internal:get_keys")
response = client.get(url, format="json", **make_user_auth_headers(user, token))
assert response.status_code == status.HTTP_404_NOT_FOUND
url = reverse("api-internal:get_update_key", kwargs={"key_id": "keyid123"})
response = client.get(url, format="json", **make_user_auth_headers(user, token))
assert response.status_code == status.HTTP_404_NOT_FOUND
url = reverse("api-internal:get_update_key", kwargs={"key_id": "keyid123"})
response = client.put(url, format="json", **make_user_auth_headers(user, token), data={})
assert response.status_code == status.HTTP_404_NOT_FOUND
url = reverse("api-internal:add_value", kwargs={"key_id": "keyid123"})
response = client.post(url, format="json", **make_user_auth_headers(user, token), data={})
assert response.status_code == status.HTTP_404_NOT_FOUND
url = reverse("api-internal:get_update_value", kwargs={"key_id": "keyid123", "value_id": "valueid123"})
response = client.get(url, format="json", **make_user_auth_headers(user, token))
assert response.status_code == status.HTTP_404_NOT_FOUND
url = reverse("api-internal:get_update_value", kwargs={"key_id": "keyid123", "value_id": "valueid123"})
response = client.put(url, format="json", **make_user_auth_headers(user, token), data={})
assert response.status_code == status.HTTP_404_NOT_FOUND
url = reverse("api-internal:create_label")
response = client.post(url, format="json", data={}, **make_user_auth_headers(user, token))
assert response.status_code == status.HTTP_404_NOT_FOUND

View file

@ -13,6 +13,7 @@ from .views.escalation_chain import EscalationChainViewSet
from .views.escalation_policy import EscalationPolicyView
from .views.features import FeaturesAPIView
from .views.integration_heartbeat import IntegrationHeartBeatView
from .views.labels import LabelsViewSet
from .views.live_setting import LiveSettingViewSet
from .views.on_call_shifts import OnCallShiftView
from .views.organization import (
@ -110,3 +111,21 @@ urlpatterns += [
path(r"login/<backend>/", auth.overridden_login_slack_auth, name="slack-auth"),
path(r"complete/<backend>/", auth.overridden_complete_slack_auth, name="complete-slack-auth"),
]
urlpatterns += [
re_path(r"^labels/keys/?$", LabelsViewSet.as_view({"get": "get_keys"}), name="get_keys"),
re_path(
r"^labels/id/(?P<key_id>[\w\-]+)/?$",
LabelsViewSet.as_view({"get": "get_key", "put": "rename_key"}),
name="get_update_key",
),
re_path(
r"^labels/id/(?P<key_id>[\w\-]+)/values/?$", LabelsViewSet.as_view({"post": "add_value"}), name="add_value"
),
re_path(
r"^labels/id/(?P<key_id>[\w\-]+)/values/(?P<value_id>[\w\-]+)/?$",
LabelsViewSet.as_view({"put": "rename_value", "get": "get_value"}),
name="get_update_value",
),
re_path(r"^labels/?$", LabelsViewSet.as_view({"post": "create_label"}), name="create_label"),
]

View file

@ -18,8 +18,10 @@ from apps.api.serializers.alert_receive_channel import (
FilterAlertReceiveChannelSerializer,
)
from apps.api.throttlers import DemoAlertThrottler
from apps.api.views.labels import LabelsAssociatingMixin
from apps.auth_token.auth import PluginAuthentication
from apps.integrations.legacy_prefix import has_legacy_prefix, remove_legacy_prefix
from apps.labels.utils import is_labels_feature_enabled
from apps.mobile_app.auth import MobileAppAuthTokenAuthentication
from common.api_helpers.exceptions import BadRequest
from common.api_helpers.filters import ByTeamModelFieldFilterMixin, TeamModelMultipleChoiceFilter
@ -71,6 +73,7 @@ class AlertReceiveChannelView(
PublicPrimaryKeyMixin,
FilterSerializerMixin,
UpdateSerializerMixin,
LabelsAssociatingMixin,
ModelViewSet,
):
authentication_classes = (
@ -153,6 +156,8 @@ class AlertReceiveChannelView(
if not ignore_filtering_by_available_teams:
queryset = queryset.filter(*self.available_teams_lookup_args).distinct()
queryset = self.filter_by_labels(queryset)
return queryset
def paginate_queryset(self, queryset):
@ -259,6 +264,7 @@ class AlertReceiveChannelView(
@action(methods=["get"], detail=False)
def filters(self, request):
organization = self.request.auth.organization
filter_name = request.query_params.get("search", None)
api_root = "/api/internal/v1/"
@ -277,6 +283,15 @@ class AlertReceiveChannelView(
},
]
if is_labels_feature_enabled(organization):
filter_options.append(
{
"name": "label",
"display_name": "Label",
"type": "labels",
}
)
if filter_name is not None:
filter_options = list(filter(lambda f: filter_name in f["name"], filter_options))

View file

@ -6,6 +6,7 @@ from rest_framework.views import APIView
from apps.auth_token.auth import PluginAuthentication
from apps.base.utils import live_settings
from apps.labels.utils import is_labels_feature_enabled
FEATURE_SLACK = "slack"
FEATURE_TELEGRAM = "telegram"
@ -13,6 +14,7 @@ FEATURE_LIVE_SETTINGS = "live_settings"
FEATURE_GRAFANA_CLOUD_NOTIFICATIONS = "grafana_cloud_notifications"
FEATURE_GRAFANA_CLOUD_CONNECTION = "grafana_cloud_connection"
FEATURE_GRAFANA_ALERTING_V2 = "grafana_alerting_v2"
FEATURE_LABELS = "labels"
class FeaturesAPIView(APIView):
@ -57,4 +59,7 @@ class FeaturesAPIView(APIView):
if settings.FEATURE_GRAFANA_ALERTING_V2_ENABLED:
enabled_features.append(FEATURE_GRAFANA_ALERTING_V2)
if is_labels_feature_enabled(self.request.auth.organization):
enabled_features.append(FEATURE_LABELS)
return enabled_features

View file

@ -0,0 +1,155 @@
import logging
from drf_spectacular.utils import extend_schema, inline_serializer
from rest_framework.exceptions import NotFound
from rest_framework.permissions import IsAuthenticated
from rest_framework.response import Response
from rest_framework.viewsets import ViewSet
from apps.alerts.models import AlertReceiveChannel
from apps.api.serializers.labels import (
LabelKeySerializer,
LabelKeyValuesSerializer,
LabelReprSerializer,
LabelValueSerializer,
)
from apps.auth_token.auth import PluginAuthentication
from apps.labels.client import LabelsAPIClient
from apps.labels.tasks import update_instances_labels_cache, update_labels_cache
from apps.labels.utils import is_labels_feature_enabled
from common.api_helpers.exceptions import BadRequest
logger = logging.getLogger(__name__)
class LabelsViewSet(ViewSet):
"""
Proxy requests to labels-app to create/update labels
"""
authentication_classes = (PluginAuthentication,)
permission_classes = (IsAuthenticated,)
# todo: permissions on create/update labels
def initial(self, request, *args, **kwargs):
super().initial(request, *args, **kwargs)
if not is_labels_feature_enabled(self.request.auth.organization):
raise NotFound
@extend_schema(responses=LabelKeySerializer(many=True))
def get_keys(self, request):
"""List of labels keys"""
organization = self.request.auth.organization
result, response_info = LabelsAPIClient(organization.grafana_url, organization.api_token).get_keys()
return Response(result, status=response_info["status_code"])
@extend_schema(responses=LabelKeyValuesSerializer)
def get_key(self, request, key_id):
"""Key with the list of values"""
organization = self.request.auth.organization
result, response_info = LabelsAPIClient(organization.grafana_url, organization.api_token).get_values(key_id)
self._update_labels_cache(result)
return Response(result, status=response_info["status_code"])
@extend_schema(responses=LabelValueSerializer)
def get_value(self, request, key_id, value_id):
"""Value name"""
organization = self.request.auth.organization
result, response_info = LabelsAPIClient(organization.grafana_url, organization.api_token).get_value(
key_id, value_id
)
self._update_labels_cache(result)
return Response(result, status=response_info["status_code"])
@extend_schema(request=LabelReprSerializer, responses=LabelKeyValuesSerializer)
def rename_key(self, request, key_id):
"""Rename the key"""
organization = self.request.auth.organization
label_data = self.request.data
if not label_data:
raise BadRequest(detail="name is required")
result, response_info = LabelsAPIClient(organization.grafana_url, organization.api_token).rename_key(
key_id, label_data
)
self._update_labels_cache(result)
return Response(result, status=response_info["status_code"])
@extend_schema(
request=inline_serializer(
name="LabelCreateSerializer",
fields={"key": LabelReprSerializer(), "values": LabelReprSerializer(many=True)},
many=True,
),
responses={201: LabelKeyValuesSerializer},
)
def create_label(self, request):
"""Create a new label key with values(Optional)"""
organization = self.request.auth.organization
label_data = self.request.data
if not label_data:
raise BadRequest(detail="key data (name, values) is required")
result, response_info = LabelsAPIClient(organization.grafana_url, organization.api_token).create_label(
label_data
)
return Response(result, status=response_info["status_code"])
@extend_schema(request=LabelReprSerializer, responses=LabelKeyValuesSerializer)
def add_value(self, request, key_id):
"""Add a new value to the key"""
organization = self.request.auth.organization
label_data = self.request.data
if not label_data:
raise BadRequest(detail="name is required")
result, response_info = LabelsAPIClient(organization.grafana_url, organization.api_token).add_value(
key_id, label_data
)
return Response(result, status=response_info["status_code"])
@extend_schema(request=LabelReprSerializer, responses=LabelKeyValuesSerializer)
def rename_value(self, request, key_id, value_id):
"""Rename the value"""
organization = self.request.auth.organization
label_data = self.request.data
if not label_data:
raise BadRequest(detail="name is required")
result, response_info = LabelsAPIClient(organization.grafana_url, organization.api_token).rename_value(
key_id, value_id, label_data
)
self._update_labels_cache(result)
return Response(result, status=response_info["status_code"])
def _update_labels_cache(self, label_data):
if not label_data:
return
serializer = LabelKeyValuesSerializer(data=label_data)
if serializer.is_valid():
update_labels_cache.apply_async((label_data,))
class LabelsAssociatingMixin: # use for labelable objects views (ex. AlertReceiveChannelView)
def filter_by_labels(self, queryset):
"""Call this method in `get_queryset()` to add filtering by labels"""
if not is_labels_feature_enabled(self.request.auth.organization):
return queryset
labels = self.request.query_params.getlist("label") # ["key1:value1", "key2:value2"]
if not labels:
return queryset
for label in labels:
label_data = label.split(":")
if len(label_data) != 2: # ["key1", "value1"]
continue
key_id, value_id = label_data
queryset &= AlertReceiveChannel.objects_with_deleted.filter(
labels__key_id=key_id, labels__value_id=value_id
).distinct()
return queryset
def paginate_queryset(self, queryset):
organization = self.request.auth.organization
data = super().paginate_queryset(queryset)
if not is_labels_feature_enabled(self.request.auth.organization):
return data
ids = [d.id for d in data]
logger.info(f"start update_instances_labels_cache for ids: {ids}")
update_instances_labels_cache.apply_async((organization.id, ids, self.model.__name__))
return data

View file

@ -106,6 +106,9 @@ class APIClient:
def api_post(self, endpoint: str, body: typing.Optional[typing.Dict] = None, **kwargs) -> APIClientResponse[_RT]:
return self.call_api(endpoint, requests.post, body, **kwargs)
def api_put(self, endpoint: str, body: typing.Optional[typing.Dict] = None, **kwargs) -> APIClientResponse[_RT]:
return self.call_api(endpoint, requests.put, body, **kwargs)
def call_api(
self, endpoint: str, http_method: HttpMethod, body: typing.Optional[typing.Dict] = None, **kwargs
) -> APIClientResponse[_RT]:

View file

View file

@ -0,0 +1,42 @@
import typing
from urllib.parse import urljoin
from apps.grafana_plugin.helpers.client import APIClient
if typing.TYPE_CHECKING:
from apps.labels.utils import LabelKeyData, LabelsKeysData, LabelUpdateParam
class LabelsAPIClient(APIClient):
LABELS_API_URL = "/api/plugins/grafana-labels/resources/v1/labels/"
def __init__(self, api_url: str, api_token: str) -> None:
super().__init__(api_url, api_token)
self.api_url = urljoin(api_url, self.LABELS_API_URL)
def create_label(self, label_data: "LabelUpdateParam") -> typing.Tuple[typing.Optional["LabelKeyData"], dict]:
return self.api_post("", label_data)
def get_keys(self) -> typing.Tuple[typing.Optional["LabelsKeysData"], dict]:
return self.api_get("keys")
def get_values(self, key_id: str) -> typing.Tuple[typing.Optional["LabelKeyData"], dict]:
return self.api_get(f"id/{key_id}")
def get_value(self, key_id: str, value_id: str) -> typing.Tuple[typing.Optional["LabelKeyData"], dict]:
return self.api_get(f"id/{key_id}/values/{value_id}")
def add_value(
self, key_id: str, label_data: "LabelUpdateParam"
) -> typing.Tuple[typing.Optional["LabelKeyData"], dict]:
return self.api_post(f"id/{key_id}/values", label_data)
def rename_key(
self, key_id: str, label_data: "LabelUpdateParam"
) -> typing.Tuple[typing.Optional["LabelKeyData"], dict]:
return self.api_put(f"id/{key_id}", label_data)
def rename_value(
self, key_id: str, value_id: str, label_data: "LabelUpdateParam"
) -> typing.Tuple[typing.Optional["LabelKeyData"], dict]:
return self.api_put(f"id/{key_id}/values/{value_id}", label_data)

View file

@ -0,0 +1,48 @@
# Generated by Django 3.2.20 on 2023-09-26 09:22
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
initial = True
dependencies = [
('alerts', '0032_remove_alertgroup_slack_message_state'),
('user_management', '0014_auto_20230728_0802'),
]
operations = [
migrations.CreateModel(
name='LabelKeyCache',
fields=[
('id', models.CharField(editable=False, max_length=36, primary_key=True, serialize=False)),
('name', models.CharField(max_length=200)),
('last_synced', models.DateTimeField(auto_now=True)),
('organization', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='user_management.organization')),
],
),
migrations.CreateModel(
name='LabelValueCache',
fields=[
('id', models.CharField(editable=False, max_length=36, primary_key=True, serialize=False)),
('name', models.CharField(max_length=200)),
('last_synced', models.DateTimeField(auto_now=True)),
('key', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='values', to='labels.labelkeycache')),
],
),
migrations.CreateModel(
name='AlertReceiveChannelAssociatedLabel',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('alert_receive_channel', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='labels', to='alerts.alertreceivechannel')),
('key', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='labels.labelkeycache')),
('organization', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='labels', to='user_management.organization')),
('value', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='labels.labelvaluecache')),
],
options={
'unique_together': {('key_id', 'value_id', 'alert_receive_channel_id')},
},
),
]

View file

@ -0,0 +1,114 @@
import typing
from django.db import models
from django.utils import timezone
from apps.labels.tasks import update_labels_cache
from apps.labels.utils import LABEL_OUTDATED_TIMEOUT_MINUTES, LabelsData
if typing.TYPE_CHECKING:
from apps.user_management.models import Organization
class LabelKeyCache(models.Model):
id = models.CharField(primary_key=True, editable=False, max_length=36)
name = models.CharField(max_length=200)
organization = models.ForeignKey("user_management.Organization", on_delete=models.CASCADE)
last_synced = models.DateTimeField(auto_now=True)
@property
def is_outdated(self) -> bool:
return timezone.now() - self.last_synced > timezone.timedelta(minutes=LABEL_OUTDATED_TIMEOUT_MINUTES)
class LabelValueCache(models.Model):
id = models.CharField(primary_key=True, editable=False, max_length=36)
name = models.CharField(max_length=200)
key = models.ForeignKey("labels.LabelKeyCache", on_delete=models.CASCADE, related_name="values")
last_synced = models.DateTimeField(auto_now=True)
@property
def is_outdated(self) -> bool:
return timezone.now() - self.last_synced > timezone.timedelta(minutes=LABEL_OUTDATED_TIMEOUT_MINUTES)
class AssociatedLabel(models.Model):
"""
Abstract model, is used to keep information about label association with other instances
(integrations, schedules, etc.). To add ability to associate labels with a type of instances ,
inhere this model and add a foreign key to the instance model.
Attention: add `AssociatedLabel` to the end of the name of inheritor (example: AlertReceiveChannelAssociatedLabel)
"""
key = models.ForeignKey(LabelKeyCache, on_delete=models.CASCADE)
value = models.ForeignKey(LabelValueCache, on_delete=models.CASCADE)
organization = models.ForeignKey("user_management.Organization", on_delete=models.CASCADE, related_name="labels")
class Meta:
abstract = True
@staticmethod
def update_association(labels_data: "LabelsData", instance: models.Model, organization: "Organization") -> None:
"""
Update label associations for selected instance: delete associations with labels that are not in `labels_data`,
create new associations and labels, if needed.
Then call celery task to update cache for labels from `labels_data`
instance: the model instance that the labels are associated with (e.g. AlertReceiveChannel instance)
"""
labels_data_keys = {label["key"]["id"]: label["key"]["name"] for label in labels_data}
labels_data_values = {label["value"]["id"]: label["value"]["name"] for label in labels_data}
# delete associations with labels that are not presented in labels_data
instance.labels.exclude(key_id__in=labels_data_keys.keys(), value_id__in=labels_data_values.keys()).delete()
labels_keys = []
labels_values = []
labels_associations = []
for label_data in labels_data:
key_id = label_data["key"]["id"]
key_name = label_data["key"]["name"]
value_id = label_data["value"]["id"]
value_name = label_data["value"]["name"]
label_key = LabelKeyCache(id=key_id, name=key_name, organization=organization)
labels_keys.append(label_key)
label_value = LabelValueCache(id=value_id, name=value_name, key_id=key_id)
labels_values.append(label_value)
associated_instance = {instance.labels.field.name: instance}
labels_associations.append(
instance.labels.model(
key_id=key_id, value_id=value_id, organization=organization, **associated_instance
)
)
# create labels cache and associations that don't exist
LabelKeyCache.objects.bulk_create(labels_keys, ignore_conflicts=True, batch_size=5000)
LabelValueCache.objects.bulk_create(labels_values, ignore_conflicts=True, batch_size=5000)
instance.labels.model.objects.bulk_create(labels_associations, ignore_conflicts=True, batch_size=5000)
update_labels_cache.apply_async((labels_data,))
@staticmethod
def get_associating_label_field_name() -> str:
"""Returns ForeignKey field name for the associated model"""
raise NotImplementedError
class AlertReceiveChannelAssociatedLabel(AssociatedLabel):
"""Keeps information about label association with alert receive channel instances"""
alert_receive_channel = models.ForeignKey(
"alerts.AlertReceiveChannel", on_delete=models.CASCADE, related_name="labels"
)
class Meta:
unique_together = ["key_id", "value_id", "alert_receive_channel_id"]
@staticmethod
def get_associating_label_field_name() -> str:
"""Returns ForeignKey field name for the associated model"""
return "alert_receive_channel"

View file

@ -0,0 +1,91 @@
import logging
import typing
from celery.utils.log import get_task_logger
from django.conf import settings
from django.utils import timezone
from apps.labels.client import LabelsAPIClient
from apps.labels.utils import LABEL_OUTDATED_TIMEOUT_MINUTES, LabelKeyData, LabelsData, get_associating_label_model
from apps.user_management.models import Organization
from common.custom_celery_tasks import shared_dedicated_queue_retry_task
logger = get_task_logger(__name__)
logger.setLevel(logging.DEBUG)
class ValueData(typing.TypedDict):
value_name: str
key_name: str
def unify_labels_data(labels_data: LabelsData | LabelKeyData) -> typing.Dict[str, ValueData]:
values_data: typing.Dict[str, ValueData]
if isinstance(labels_data, list): # LabelsData
values_data = {
label["value"]["id"]: {"value_name": label["value"]["name"], "key_name": label["key"]["name"]}
for label in labels_data
}
else: # LabelKeyData
values_data = {
label["id"]: {"value_name": label["name"], "key_name": labels_data["key"]["name"]}
for label in labels_data["values"]
}
return values_data
@shared_dedicated_queue_retry_task(
autoretry_for=(Exception,), retry_backoff=True, max_retries=1 if settings.DEBUG else None
)
def update_labels_cache(labels_data: LabelsData | LabelKeyData):
from apps.labels.models import LabelKeyCache, LabelValueCache
values_data: typing.Dict[str, ValueData] = unify_labels_data(labels_data)
values = LabelValueCache.objects.filter(id__in=values_data).select_related("key")
now = timezone.now()
if not values:
return
keys_to_update = set()
for value in values:
if value.name != values_data[value.id]["value_name"]:
value.name = values_data[value.id]["value_name"]
value.last_synced = now
if value.key.name != values_data[value.id]["key_name"]:
value.key.name = values_data[value.id]["key_name"]
value.key.last_synced = now
keys_to_update.add(value.key)
LabelKeyCache.objects.bulk_update(keys_to_update, fields=["name", "last_synced"])
LabelValueCache.objects.bulk_update(values, fields=["name", "last_synced"])
@shared_dedicated_queue_retry_task(
autoretry_for=(Exception,), retry_backoff=True, max_retries=1 if settings.DEBUG else 10
)
def update_instances_labels_cache(organization_id: int, instance_ids: typing.List[int], instance_model_name: str):
from apps.labels.models import LabelValueCache
now = timezone.now()
organization = Organization.objects.get(id=organization_id)
model = get_associating_label_model(instance_model_name)
field_name = model.get_associating_label_field_name()
associated_instances = {f"{field_name}_id__in": instance_ids}
values_ids = model.objects.filter(**associated_instances).values_list("value_id", flat=True)
outdated_last_synced = now - timezone.timedelta(minutes=LABEL_OUTDATED_TIMEOUT_MINUTES)
values = LabelValueCache.objects.filter(id__in=values_ids, last_synced__lte=outdated_last_synced)
if not values:
return
keys_ids = set(value.key_id for value in values)
client = LabelsAPIClient(organization.grafana_url, organization.api_token)
for key_id in keys_ids:
label_data, _ = client.get_values(key_id)
if label_data:
update_labels_cache.apply_async((label_data,))

View file

View file

@ -0,0 +1,25 @@
import factory
from apps.labels.models import AlertReceiveChannelAssociatedLabel, LabelKeyCache, LabelValueCache
from common.utils import UniqueFaker
class LabelKeyFactory(factory.DjangoModelFactory):
id = UniqueFaker("word")
name = UniqueFaker("word")
class Meta:
model = LabelKeyCache
class LabelValueFactory(factory.DjangoModelFactory):
id = UniqueFaker("word")
name = UniqueFaker("word")
class Meta:
model = LabelValueCache
class AlertReceiveChannelAssociatedLabelFactory(factory.DjangoModelFactory):
class Meta:
model = AlertReceiveChannelAssociatedLabel

View file

@ -0,0 +1,89 @@
import pytest
from apps.alerts.models import AlertReceiveChannel
from apps.labels.models import AlertReceiveChannelAssociatedLabel, AssociatedLabel, LabelValueCache
from apps.labels.utils import get_associating_label_model
@pytest.mark.django_db
def test_label_associate_new_label(make_organization, make_alert_receive_channel):
organization = make_organization()
alert_receive_channel = make_alert_receive_channel(organization)
label_key_id = "testkeyid"
label_value_id = "testvalueid"
labels_data = [
{
"key": {"id": label_key_id, "name": "testkey"},
"value": {"id": label_value_id, "name": "testvalue"},
}
]
assert not alert_receive_channel.labels.exists()
assert not LabelValueCache.objects.filter(key_id=label_key_id, id=label_value_id).exists()
AssociatedLabel.update_association(labels_data, alert_receive_channel, organization)
assert len(alert_receive_channel.labels.all()) == 1
assert alert_receive_channel.labels.get(key_id=label_key_id, value_id=label_value_id)
@pytest.mark.django_db
def test_label_associate_existing_label(make_label_key_and_value, make_organization, make_alert_receive_channel):
organization = make_organization()
alert_receive_channel = make_alert_receive_channel(organization)
label_key, label_value = make_label_key_and_value(organization)
labels_data = [
{
"key": {"id": label_key.id, "name": label_key.name},
"value": {"id": label_value.id, "name": label_value.name},
}
]
assert not alert_receive_channel.labels.exists()
AssociatedLabel.update_association(labels_data, alert_receive_channel, organization)
assert len(alert_receive_channel.labels.all()) == 1
assert alert_receive_channel.labels.filter(key=label_key, value=label_value).exists()
@pytest.mark.django_db
def test_label_update_association_by_removing_label(
make_integration_label_association, make_organization, make_alert_receive_channel
):
organization = make_organization()
alert_receive_channel = make_alert_receive_channel(organization)
label_association_1 = make_integration_label_association(organization, alert_receive_channel)
label_association_2 = make_integration_label_association(organization, alert_receive_channel)
labels_data = [
{
"key": {"id": label_association_1.key_id, "name": label_association_1.key.name},
"value": {"id": label_association_1.value_id, "name": label_association_1.value.name},
}
]
assert len(alert_receive_channel.labels.all()) == 2
assert alert_receive_channel.labels.filter(
key=label_association_1.key_id, value=label_association_1.value_id
).exists()
assert alert_receive_channel.labels.filter(
key=label_association_2.key_id, value=label_association_2.value_id
).exists()
# update labels association by removing label_association_2
AssociatedLabel.update_association(labels_data, alert_receive_channel, organization)
assert len(alert_receive_channel.labels.all()) == 1
assert alert_receive_channel.labels.filter(
key=label_association_1.key_id, value=label_association_1.value_id
).exists()
assert not alert_receive_channel.labels.filter(
key=label_association_2.key_id, value=label_association_2.value_id
).exists()
@pytest.mark.django_db
def test_get_associating_label_model():
model_name = AlertReceiveChannel.__name__
expected_result = AlertReceiveChannelAssociatedLabel
result = get_associating_label_model(model_name)
assert result == expected_result
wrong_model_name = "SomeModel"
with pytest.raises(LookupError):
get_associating_label_model(wrong_model_name)

View file

@ -0,0 +1,136 @@
from unittest.mock import call, patch
import pytest
from django.utils import timezone
from apps.labels.models import LabelKeyCache, LabelValueCache
from apps.labels.tasks import update_instances_labels_cache, update_labels_cache
from apps.labels.utils import LABEL_OUTDATED_TIMEOUT_MINUTES
@pytest.mark.django_db
def test_update_labels_cache_for_key(make_organization, make_label_key_and_value, make_label_value):
organization = make_organization()
label_key, label_value1 = make_label_key_and_value(organization)
label_value2 = make_label_value(label_key)
new_key_name = "updatekeyname"
new_value1_name = "updatevalue1name"
old_value2_name = label_value2.name
last_synced = label_key.last_synced
label_data = {
"key": {"id": label_key.id, "name": new_key_name},
"values": [{"id": label_value1.id, "name": new_value1_name}, {"id": label_value2.id, "name": old_value2_name}],
}
assert label_key.name != new_key_name
assert label_value1.name != new_value1_name
update_labels_cache(label_data)
label_key.refresh_from_db()
label_value1.refresh_from_db()
label_value2.refresh_from_db()
for label_cache in (label_key, label_value1, label_value2):
assert label_cache.last_synced > last_synced
assert label_key.name == new_key_name
assert label_value1.name == new_value1_name
assert label_value2.name == old_value2_name
@pytest.mark.django_db
def test_update_labels_cache(make_organization, make_label_key_and_value, make_label_value):
organization = make_organization()
label_key1, label_value1_1 = make_label_key_and_value(organization)
label_key2, label_value2_1 = make_label_key_and_value(organization)
label_value2_2 = make_label_value(label_key2)
new_key1_name = "updatekey1name"
new_value1_1_name = "updatevalue11name"
old_key2_name = label_key2.name
old_value2_1_name = label_value2_1.name
new_value2_2_name = "updatevalue22name"
last_synced = label_key1.last_synced
labels_data = [
{
"key": {"id": label_key1.id, "name": new_key1_name},
"value": {"id": label_value1_1.id, "name": new_value1_1_name},
},
{
"key": {"id": label_key2.id, "name": old_key2_name},
"value": {"id": label_value2_1.id, "name": old_value2_1_name},
},
{
"key": {"id": label_key2.id, "name": old_key2_name},
"value": {"id": label_value2_2.id, "name": new_value2_2_name},
},
]
assert label_key1.name != new_key1_name
assert label_value1_1.name != new_value1_1_name
assert label_value2_2.name != new_value2_2_name
update_labels_cache(labels_data)
for label_cache in (label_key1, label_key2, label_value1_1, label_value2_1, label_value2_2):
label_cache.refresh_from_db()
assert label_cache.last_synced > last_synced
assert label_key1.name == new_key1_name
assert label_value1_1.name == new_value1_1_name
assert label_key2.name == old_key2_name
assert label_value2_1.name == old_value2_1_name
assert label_value2_2.name == new_value2_2_name
@pytest.mark.django_db
def test_update_instances_labels_cache_recently_synced(
make_organization, make_alert_receive_channel, make_integration_label_association
):
organization = make_organization()
alert_receive_channel = make_alert_receive_channel(organization)
label_association = make_integration_label_association(organization, alert_receive_channel)
assert not label_association.key.is_outdated
assert not label_association.value.is_outdated
with patch("apps.labels.client.LabelsAPIClient.get_values") as mock_get_values:
with patch("apps.labels.tasks.update_labels_cache.apply_async") as mock_update_cache:
update_instances_labels_cache(
organization.id, [alert_receive_channel.id], alert_receive_channel._meta.model.__name__
)
assert not mock_get_values.called
assert not mock_update_cache.called
@pytest.mark.django_db
def test_update_instances_labels_cache_outdated(
make_organization, make_alert_receive_channel, make_integration_label_association
):
organization = make_organization()
alert_receive_channel = make_alert_receive_channel(organization)
label_association = make_integration_label_association(organization, alert_receive_channel)
outdated_last_synced = timezone.now() - timezone.timedelta(minutes=LABEL_OUTDATED_TIMEOUT_MINUTES + 1)
LabelKeyCache.objects.filter(id=label_association.key_id).update(last_synced=outdated_last_synced)
LabelValueCache.objects.filter(id=label_association.value_id).update(last_synced=outdated_last_synced)
label_association.refresh_from_db()
assert label_association.key.is_outdated
assert label_association.value.is_outdated
label_data = {
"key": {"id": label_association.key.id, "name": label_association.key.name},
"values": [{"id": label_association.value.id, "name": label_association.value.name}],
}
with patch("apps.labels.client.LabelsAPIClient.get_values", return_value=(label_data, None)) as mock_get_values:
with patch("apps.labels.tasks.update_labels_cache.apply_async") as mock_update_cache:
update_instances_labels_cache(
organization.id, [alert_receive_channel.id], alert_receive_channel._meta.model.__name__
)
assert mock_get_values.called
assert mock_update_cache.called
assert mock_update_cache.call_args == call((label_data,))

View file

@ -0,0 +1,54 @@
import logging
import typing
from django.apps import apps # noqa: I251
from django.conf import settings
if typing.TYPE_CHECKING:
from apps.labels.models import AssociatedLabel
logger = logging.getLogger(__name__)
LABEL_OUTDATED_TIMEOUT_MINUTES = 30
ASSOCIATED_MODEL_NAME = "AssociatedLabel"
class LabelUpdateParam(typing.TypedDict):
name: str
class LabelParams(typing.TypedDict):
id: str
name: str
class LabelData(typing.TypedDict):
key: LabelParams
value: LabelParams
class LabelKeyData(typing.TypedDict):
key: LabelParams
values: typing.List[LabelParams]
LabelsData = typing.List[LabelData]
LabelsKeysData = typing.List[LabelParams]
def get_associating_label_model(obj_model_name: str) -> typing.Type["AssociatedLabel"]:
associating_label_model_name = obj_model_name + ASSOCIATED_MODEL_NAME
label_model = apps.get_model("labels", associating_label_model_name)
return label_model
def is_labels_feature_enabled(organization) -> bool:
# check FEATURE_LABELS_ENABLED in settings
# checking labels feature flag per organization will be added later
logger.info(
"is_labels_feature_enabled: "
f"FEATURE_LABELS_ENABLED={settings.FEATURE_LABELS_ENABLED} "
f"organization={organization.id}"
)
return settings.FEATURE_LABELS_ENABLED

View file

@ -57,6 +57,7 @@ from apps.base.tests.factories import (
)
from apps.email.tests.factories import EmailMessageFactory
from apps.heartbeat.tests.factories import IntegrationHeartBeatFactory
from apps.labels.tests.factories import AlertReceiveChannelAssociatedLabelFactory, LabelKeyFactory, LabelValueFactory
from apps.mobile_app.models import MobileAppAuthToken, MobileAppVerificationToken
from apps.phone_notifications.phone_backend import PhoneBackend
from apps.phone_notifications.tests.factories import PhoneCallRecordFactory, SMSRecordFactory
@ -128,6 +129,10 @@ register(EmailMessageFactory)
register(IntegrationHeartBeatFactory)
register(LiveSettingFactory)
register(LabelKeyFactory)
register(LabelValueFactory)
register(AlertReceiveChannelAssociatedLabelFactory)
IS_RBAC_ENABLED = os.getenv("ONCALL_TESTING_RBAC_ENABLED", "True") == "True"
@ -173,6 +178,11 @@ def mock_apply_async(monkeypatch):
monkeypatch.setattr(Task, "apply_async", mock_apply_async)
@pytest.fixture(autouse=True)
def mock_is_labels_feature_enabled(settings):
setattr(settings, "FEATURE_LABELS_ENABLED", True)
@pytest.fixture
def make_organization():
def _make_organization(**kwargs):
@ -918,3 +928,40 @@ def webhook_preset_api_setup():
WebhookPresetOptions.WEBHOOK_PRESET_CHOICES = [
preset.metadata for preset in WebhookPresetOptions.WEBHOOK_PRESETS.values()
]
@pytest.fixture
def make_label_key():
def _make_label_key(organization, **kwargs):
return LabelKeyFactory(organization=organization, **kwargs)
return _make_label_key
@pytest.fixture
def make_label_value():
def _make_label_value(key, **kwargs):
return LabelValueFactory(key=key, **kwargs)
return _make_label_value
@pytest.fixture
def make_label_key_and_value(make_label_key, make_label_value):
def _make_label_key_and_value(organization):
key = make_label_key(organization=organization)
value = make_label_value(key=key)
return key, value
return _make_label_key_and_value
@pytest.fixture
def make_integration_label_association(make_label_key_and_value):
def _make_integration_label_association(organization, alert_receive_channel, **kwargs):
key, value = make_label_key_and_value(organization)
return AlertReceiveChannelAssociatedLabelFactory(
alert_receive_channel=alert_receive_channel, organization=organization, key=key, value=value, **kwargs
)
return _make_integration_label_association

View file

@ -66,6 +66,7 @@ FEATURE_MULTIREGION_ENABLED = getenv_boolean("FEATURE_MULTIREGION_ENABLED", defa
FEATURE_INBOUND_EMAIL_ENABLED = getenv_boolean("FEATURE_INBOUND_EMAIL_ENABLED", default=True)
FEATURE_PROMETHEUS_EXPORTER_ENABLED = getenv_boolean("FEATURE_PROMETHEUS_EXPORTER_ENABLED", default=False)
FEATURE_GRAFANA_ALERTING_V2_ENABLED = getenv_boolean("FEATURE_GRAFANA_ALERTING_V2_ENABLED", default=False)
FEATURE_LABELS_ENABLED = getenv_boolean("FEATURE_LABELS_ENABLED", default=False)
GRAFANA_CLOUD_ONCALL_HEARTBEAT_ENABLED = getenv_boolean("GRAFANA_CLOUD_ONCALL_HEARTBEAT_ENABLED", default=True)
GRAFANA_CLOUD_NOTIFICATIONS_ENABLED = getenv_boolean("GRAFANA_CLOUD_NOTIFICATIONS_ENABLED", default=True)
@ -265,6 +266,7 @@ INSTALLED_APPS = [
"apps.grafana_plugin",
"apps.webhooks",
"apps.metrics_exporter",
"apps.labels",
"corsheaders",
"debug_toolbar",
"social_django",
@ -311,6 +313,7 @@ if SWAGGER_UI_SETTINGS_URL:
SPECTACULAR_INCLUDED_PATHS = [
"/features",
"/alertgroups",
"/labels",
]
MIDDLEWARE = [

View file

@ -13,6 +13,8 @@ CELERY_TASK_ROUTES = {
"common.oncall_gateway.tasks.delete_slack_connector_async_v2": {"queue": "default"},
"apps.heartbeat.tasks.integration_heartbeat_checkup": {"queue": "default"},
"apps.heartbeat.tasks.process_heartbeat_task": {"queue": "default"},
"apps.labels.tasks.update_labels_cache": {"queue": "default"},
"apps.labels.tasks.update_instances_labels_cache": {"queue": "default"},
"apps.metrics_exporter.tasks.start_calculate_and_cache_metrics": {"queue": "default"},
"apps.metrics_exporter.tasks.start_recalculation_for_new_metric": {"queue": "default"},
"apps.metrics_exporter.tasks.save_organizations_ids_in_cache": {"queue": "default"},

View file

@ -113,6 +113,7 @@
"@grafana/data": "^9.2.4",
"@grafana/faro-web-sdk": "^1.0.0-beta4",
"@grafana/faro-web-tracing": "^1.0.0-beta4",
"@grafana/labels": "^1.1.0",
"@grafana/runtime": "9.3.0-beta1",
"@grafana/ui": "^9.4.7",
"@opentelemetry/api": "^1.3.0",

View file

@ -1,17 +0,0 @@
.root {
display: block;
}
.integrationsFilters {
display: flex;
position: relative;
}
.searchIntegrationClear {
position: absolute;
right: 0;
}
.searchIntegrationInput {
max-width: 400px;
}

View file

@ -1,55 +0,0 @@
import React, { ChangeEvent, FC, useCallback } from 'react';
import { Icon, Input, Button } from '@grafana/ui';
import cn from 'classnames/bind';
import styles from 'components/IntegrationsFilters/IntegrationsFilters.module.css';
export interface Filters {
searchTerm: string;
}
interface IntegrationsFiltersProps {
value: Filters;
onChange: (filters: Filters) => void;
}
const cx = cn.bind(styles);
const IntegrationsFilters: FC<IntegrationsFiltersProps> = (props) => {
const { value, onChange } = props;
const onSearchTermChangeCallback = useCallback(
(e: ChangeEvent<HTMLInputElement>) => {
const filters = {
...value,
searchTerm: e.currentTarget.value,
};
onChange(filters);
},
[onChange, value]
);
const handleClear = useCallback(() => {
onChange({ searchTerm: '' });
}, [onChange]);
return (
<div className={cx('root', 'integrationsFilters')}>
<Input
autoFocus
prefix={<Icon name="search" />}
className={cx('search', 'control', 'searchIntegrationInput')}
placeholder="Search integrations..."
value={value.searchTerm}
onChange={onSearchTermChangeCallback}
/>
<Button variant="secondary" icon="times" onClick={handleClear} className={cx('searchIntegrationClear')}>
Clear filters
</Button>
</div>
);
};
export default IntegrationsFilters;

View file

@ -0,0 +1,60 @@
import React, { FC, useCallback, useMemo, useState } from 'react';
import { AsyncMultiSelect } from '@grafana/ui';
interface Value {
key: { [labelField: string]: any };
value: { [labelField: string]: any };
}
interface LabelsFilterProps {
autoFocus: boolean;
labelField: string;
onLoadOptions: (search: string) => Promise<any>;
value: Value[];
onChange: (value: Value[]) => void;
}
const LabelsFilter: FC<LabelsFilterProps> = (props) => {
const { autoFocus, value: propsValue, labelField: FieldName = 'name', onLoadOptions, onChange } = props;
const [search, setSearch] = useState('');
const handleChange = useCallback((value) => {
onChange(value.map((v) => v.value));
}, []);
const handleLoadOptions = (search) => {
return onLoadOptions(search).then((options) =>
options.map((v) => ({
label: `${v.key[FieldName]} : ${v.value[FieldName]}`,
value: v,
}))
);
};
const value = useMemo(
() =>
propsValue.map((v) => ({
label: `${v.key[FieldName]} : ${v.value[FieldName]}`,
value: v,
})),
[propsValue]
);
return (
<AsyncMultiSelect
autoFocus={autoFocus}
openMenuOnFocus
loadOptions={handleLoadOptions}
value={value}
onChange={handleChange}
placeholder="Select labels"
inputValue={search}
onInputChange={setSearch}
noOptionsMessage={search ? 'Nothing found' : 'Type to see suggestions'}
/>
);
};
export default LabelsFilter;

View file

@ -3,12 +3,20 @@
line-height: 16px;
padding: 3px 4px;
border-radius: 2px;
display: inline-block;
&--primary {
background: var(--tag-background-primary);
border: 1px solid var(--tag-border-primary);
color: var(--tag-text-primary);
}
&--secondary {
background: var(--background-secondary);
border: var(--border);
color: var(--primary-text-color);
}
&--warning {
background: var(--tag-background-warning);
border: 1px solid var(--tag-border-warning);

View file

@ -67,7 +67,7 @@ const TooltipBadge: FC<TooltipBadgeProps> = (props) => {
>
<HorizontalGroup spacing="xs">
{renderIcon()}
{text && (
{text !== undefined && (
<Text
className={cx('element__text', { [`element__text--${borderType}`]: true })}
{...(testId ? { 'data-testid': `${testId}-text` } : {})}

View file

@ -5,5 +5,6 @@ export function prepareForEdit(item: AlertReceiveChannel) {
verbal_name: item.verbal_name,
description_short: item.description_short,
team: item.team,
labels: item.labels,
};
}

View file

@ -95,3 +95,7 @@
flex-direction: column;
margin-bottom: -15px;
}
.labels {
margin-bottom: 20px;
}

View file

@ -1,4 +1,4 @@
import React, { useState, ChangeEvent, useEffect, useReducer } from 'react';
import React, { useState, ChangeEvent, useEffect, useReducer, useRef } from 'react';
import { SelectableValue } from '@grafana/data';
import {
@ -25,12 +25,14 @@ import GForm from 'components/GForm/GForm';
import { FormItem } from 'components/GForm/GForm.types';
import IntegrationLogo from 'components/IntegrationLogo/IntegrationLogo';
import Text from 'components/Text/Text';
import Labels from 'containers/Labels/Labels';
import { WithPermissionControlTooltip } from 'containers/WithPermissionControl/WithPermissionControlTooltip';
import {
AlertReceiveChannel,
AlertReceiveChannelOption,
} from 'models/alert_receive_channel/alert_receive_channel.types';
import IntegrationHelper from 'pages/integration/Integration.helper';
import { AppFeature } from 'state/features';
import { useStore } from 'state/useStore';
import { openErrorNotification } from 'utils';
import { UserActions } from 'utils/authorization';
@ -53,6 +55,8 @@ const IntegrationForm = observer((props: IntegrationFormProps) => {
const store = useStore();
const history = useHistory();
const labelsRef = useRef(null);
const { id, onHide, onSubmit, isTableView = true } = props;
const {
alertReceiveChannelStore,
@ -64,6 +68,7 @@ const IntegrationForm = observer((props: IntegrationFormProps) => {
const [selectedOption, setSelectedOption] = useState<AlertReceiveChannelOption>(undefined);
const [showIntegrarionsListDrawer, setShowIntegrarionsListDrawer] = useState(id === 'new');
const [allContactPoints, setAllContactPoints] = useState([]);
const [errors, setErrors] = useState<Record<string, any>>();
useEffect(() => {
(async function () {
@ -73,7 +78,7 @@ const IntegrationForm = observer((props: IntegrationFormProps) => {
const data =
id === 'new'
? { integration: selectedOption?.value, team: user?.current_team }
? { integration: selectedOption?.value, team: user?.current_team, labels: [] }
: prepareForEdit(alertReceiveChannelStore.items[id]);
const { alertReceiveChannelOptions } = alertReceiveChannelStore;
@ -128,6 +133,12 @@ const IntegrationForm = observer((props: IntegrationFormProps) => {
<VerticalGroup>
<GForm form={form} data={data} onSubmit={handleSubmit} {...extraGFormProps} />
{store.hasFeature(AppFeature.Labels) && (
<div className={cx('labels')}>
<Labels ref={labelsRef} errors={errors?.labels} value={data.labels} />
</div>
)}
{isTableView && <HowTheIntegrationWorks selectedOption={selectedOption} />}
<HorizontalGroup justify="flex-end">
@ -163,6 +174,10 @@ const IntegrationForm = observer((props: IntegrationFormProps) => {
async function handleSubmit(data): Promise<void> {
const { alert_manager, contact_point, is_existing: isExisting } = data;
const labels = labelsRef.current?.getValue();
data = { ...data, labels };
const matchingAlertManager = allContactPoints.find((cp) => cp.uid === alert_manager);
const hasContactPointInput = alert_manager && contact_point;
@ -175,16 +190,17 @@ const IntegrationForm = observer((props: IntegrationFormProps) => {
return;
}
let apiResponseData: void | AlertReceiveChannel;
const isCreate = id === 'new';
if (isCreate) {
apiResponseData = await createNewIntegration();
} else {
apiResponseData = await alertReceiveChannelStore.update(id, data);
}
try {
if (isCreate) {
await createNewIntegration();
} else {
await alertReceiveChannelStore.update(id, data, undefined, true);
}
} catch (error) {
setErrors(error.response.data);
if (!apiResponseData) {
openErrorNotification(
`There was an issue ${isCreate ? 'creating' : 'updating'} the integration. Please try again.`
);

View file

@ -0,0 +1,2 @@
.root {
}

View file

@ -0,0 +1,107 @@
import React, { forwardRef, useCallback, useImperativeHandle, useState } from 'react';
import '@grafana/labels/dist/theme.css';
import ServiceLabels from '@grafana/labels';
import { Field } from '@grafana/ui';
import cn from 'classnames/bind';
import { observer } from 'mobx-react';
import { LabelKeyValue } from 'models/label/label.types';
import { useStore } from 'state/useStore';
import { openErrorNotification } from 'utils';
import styles from './Labels.module.css';
const cx = cn.bind(styles);
interface LabelsProps {
value: LabelKeyValue[];
errors: any;
}
const Labels = observer(
forwardRef(function Labels2(props: LabelsProps, ref) {
const { value: defaultValue, errors: propsErrors } = props;
// propsErrors are 'external' caused by attaching/detaching labels to oncall entities,
// state errors are errors caused by CRUD operations on labels storage
const [value, setValue] = useState<LabelKeyValue[]>(defaultValue);
const { labelsStore } = useStore();
useImperativeHandle(
ref,
() => {
return {
getValue() {
return value;
},
};
},
[value]
);
const cachedOnLoadKeys = useCallback(() => {
let result = undefined;
return async (search?: string) => {
if (!result) {
try {
result = await labelsStore.loadKeys();
} catch (error) {
openErrorNotification('There was an error processing your request. Please try again');
}
}
return result.filter((k) => k.name.toLowerCase().includes(search.toLowerCase()));
};
}, []);
const cachedOnLoadValuesForKey = useCallback(() => {
let result = undefined;
return async (key: string, search?: string) => {
if (!result) {
try {
const { values } = await labelsStore.loadValuesForKey(key, search);
result = values;
} catch (error) {
openErrorNotification('There was an error processing your request. Please try again');
}
}
return result.filter((k) => k.name.toLowerCase().includes(search.toLowerCase()));
};
}, []);
return (
<div className={cx('root')}>
<Field label="Labels">
<ServiceLabels
loadById
value={value}
onLoadKeys={cachedOnLoadKeys()}
onLoadValuesForKey={cachedOnLoadValuesForKey()}
onCreateKey={labelsStore.createKey.bind(labelsStore)}
onUpdateKey={labelsStore.updateKey.bind(labelsStore)}
onCreateValue={labelsStore.createValue.bind(labelsStore)}
onUpdateValue={labelsStore.updateKeyValue.bind(labelsStore)}
onRowItemRemoval={(_pair, _index) => {}}
onUpdateError={onUpdateError}
errors={{ ...propsErrors }}
onDataUpdate={setValue}
/>
</Field>
</div>
);
})
);
function onUpdateError(res) {
if (res?.response?.status === 409) {
openErrorNotification(`Duplicate values are not allowed`);
} else {
openErrorNotification('An error has occurred. Please try again');
}
}
export default Labels;

View file

@ -0,0 +1,86 @@
import React, { useEffect, useState } from 'react';
import { SelectableValue } from '@grafana/data';
import cn from 'classnames/bind';
import { observer } from 'mobx-react';
import LabelsFilterComponent from 'components/LabelsFilter/LabelsFilter';
import { useStore } from 'state/useStore';
import styles from './Labels.module.css';
const cx = cn.bind(styles);
interface LabelsFilterProps {
autoFocus: boolean;
className: string;
value: string[];
onChange: (value: Array<{ key: SelectableValue<string>; value: SelectableValue<string> }>) => void;
}
const LabelsFilter = observer((props: LabelsFilterProps) => {
const { className, autoFocus, value: propsValue, onChange } = props;
const [value, setValue] = useState([]);
const [keys, setKeys] = useState([]);
const { labelsStore } = useStore();
useEffect(() => {
labelsStore.loadKeys().then(setKeys);
}, []);
useEffect(() => {
const keyValuePairs = (propsValue || []).map((k) => k.split(':'));
const promises = keyValuePairs.map(([keyId]) => labelsStore.loadValuesForKey(keyId));
const fetchKeyValues = async () => await Promise.all(promises);
fetchKeyValues().then((list) => {
const value = list.map(({ key, values }, index) => ({
key,
value: values.find((v) => v.id === keyValuePairs[index][1]) || {},
}));
setValue(value);
});
}, [propsValue, keys]);
const handleLoadOptions = (search) => {
if (!search) {
return Promise.resolve([]);
}
return new Promise((resolve) => {
const keysFiltered = keys.filter((k) => k.name.toLowerCase().includes(search.toLowerCase()));
const promises = keysFiltered.map((key) => labelsStore.loadValuesForKey(key.id));
Promise.all(promises).then((list) => {
const options = list.reduce((memo, { key, values }) => {
const options = values.map((value) => ({ key, value }));
return [...memo, ...options];
}, []);
resolve(options);
});
});
};
return (
<div className={cx('root', className)}>
<LabelsFilterComponent
autoFocus={autoFocus}
labelField="name"
value={value}
onChange={onChange}
onLoadOptions={handleLoadOptions}
/>
</div>
);
});
export default LabelsFilter;

View file

@ -22,7 +22,7 @@ export function parseFilters(
let value: any = rawValue;
if (filterOption.type === 'options' || filterOption.type === 'team_select') {
if (filterOption.type === 'options' || filterOption.type === 'team_select' || filterOption.type === 'labels') {
if (!Array.isArray(rawValue)) {
value = [rawValue];
}

View file

@ -2,7 +2,6 @@ import React, { Component } from 'react';
import { SelectableValue, TimeRange } from '@grafana/data';
import {
IconButton,
InlineSwitch,
MultiSelect,
TimeRangeInput,
@ -11,6 +10,7 @@ import {
Input,
Icon,
Tooltip,
Button,
} from '@grafana/ui';
import { capitalCase } from 'change-case';
import cn from 'classnames/bind';
@ -20,6 +20,7 @@ import moment from 'moment-timezone';
import Emoji from 'react-emoji-render';
import Text from 'components/Text/Text';
import LabelsFilter from 'containers/Labels/LabelsFilter';
import RemoteSelect from 'containers/RemoteSelect/RemoteSelect';
import TeamName from 'containers/TeamName/TeamName';
import { FiltersValues } from 'models/filters/filters.types';
@ -63,6 +64,21 @@ class RemoteFilters extends Component<RemoteFiltersProps, RemoteFiltersState> {
searchRef = React.createRef<HTMLInputElement>();
componentDidUpdate(prevProps: Readonly<RemoteFiltersProps>): void {
const { store, query } = this.props;
const { filtersStore } = store;
if (prevProps.query !== query && filtersStore.needToParseFilters) {
filtersStore.needToParseFilters = false;
const { filterOptions } = this.state;
let { filters, values } = parseFilters(query, filterOptions, query);
this.setState({ filterOptions, filters, values }, () => this.onChange());
}
}
async componentDidMount() {
const { query, page, store, defaultFilters } = this.props;
@ -125,7 +141,13 @@ class RemoteFilters extends Component<RemoteFiltersProps, RemoteFiltersState> {
</Tooltip>
)}
<Text type="secondary">:</Text> {this.renderFilterOption(filterOption)}
<IconButton size="sm" name="times" onClick={this.getDeleteFilterClickHandler(filterOption.name)} />
<Button
size="sm"
icon="times"
tooltip="Remove filter"
variant="secondary"
onClick={this.getDeleteFilterClickHandler(filterOption.name)}
/>
</div>
))}
<Select
@ -299,6 +321,16 @@ class RemoteFilters extends Component<RemoteFiltersProps, RemoteFiltersState> {
/>
);
case 'labels':
return (
<LabelsFilter
autoFocus={autoFocus}
className={cx('filter-select')}
value={values[filter.name]}
onChange={this.getLabelsFilterChangeHandler(filter.name)}
/>
);
default:
console.warn('Unknown type of filter:', filter.type, 'with name', filter.name);
return null;
@ -314,6 +346,15 @@ class RemoteFilters extends Component<RemoteFiltersProps, RemoteFiltersState> {
};
};
getLabelsFilterChangeHandler = (name: FilterOption['name']) => {
return (options: Array<{ key: SelectableValue; value: SelectableValue }>) => {
this.onFiltersValueChange(
name,
options.map((option) => `${option.key.id}:${option.value.id}`)
);
};
};
getRemoteOptionsChangeHandler = (name: FilterOption['name']) => {
return (value: SelectableValue[], _items: any[]) => {
this.onFiltersValueChange(name, value);

View file

@ -5,7 +5,7 @@ export interface RemoteFiltersType {}
export interface FilterOption {
name: string;
display_name?: string;
type: 'search' | 'options' | 'boolean' | 'daterange' | 'team_select';
type: 'search' | 'options' | 'boolean' | 'daterange' | 'team_select' | 'labels';
href?: string;
options?: SelectOption[];
default?: { value: string };

View file

@ -191,12 +191,9 @@ class SchedulePersonal extends Component<SchedulePersonalProps, SchedulePersonal
}
openSchedule = (event: Event) => {
const { store, history } = this.props;
const { history } = this.props;
const shiftId = event.shift?.pk;
const shift = store.scheduleStore.shifts[shiftId];
history.push(`${PLUGIN_ROOT}/schedules/${shift.schedule}`);
history.push(`${PLUGIN_ROOT}/schedules/${event.schedule?.id}`);
};
}

View file

@ -533,4 +533,11 @@ export class AlertReceiveChannelStore extends BaseStore {
makeRequest<null>(`${this.path}${id}/stop_maintenance/`, {
method: 'POST',
});
addLabel = (id: AlertReceiveChannel['id'], data) => {
makeRequest(`${this.path}${id}/associate_label`, {
method: 'POST',
data,
});
};
}

View file

@ -1,5 +1,6 @@
import { GrafanaTeam } from 'models/grafana_team/grafana_team.types';
import { Heartbeat } from 'models/heartbeat/heartbeat.types';
import { LabelKeyValue } from 'models/label/label.types';
import { User } from 'models/user/user.types';
export enum MaintenanceMode {
@ -47,6 +48,7 @@ export interface AlertReceiveChannel {
connected_escalations_chains_count: number;
allow_delete: boolean;
deleted?: boolean;
labels: LabelKeyValue[];
}
export interface AlertReceiveChannelChoice {

View file

@ -57,12 +57,14 @@ export default class BaseStore {
}
@action
async update<RT = any>(id: any, data: any, params: any = null): Promise<RT | void> {
async update<RT = any>(id: any, data: any, params: any = null, skipErrorHandling = false): Promise<RT | void> {
const result = await makeRequest<RT>(`${this.path}${id}/`, {
method: 'PUT',
data,
params: params,
}).catch(this.onApiError);
}).catch((error) => {
this.onApiError(error, skipErrorHandling);
});
// Update env_status field for current team
await this.rootStore.organizationStore.loadCurrentOrganization();

View file

@ -19,6 +19,9 @@ export class FiltersStore extends BaseStore {
private _globalValues: FiltersValues = {};
@observable
public needToParseFilters = false;
constructor(rootStore: RootStore) {
super(rootStore);

View file

@ -0,0 +1,105 @@
import { action, observable } from 'mobx';
import BaseStore from 'models/base_store';
import { makeRequest } from 'network';
import { RootStore } from 'state';
import { openNotification } from 'utils';
import { LabelKey, LabelValue } from './label.types';
export class LabelStore extends BaseStore {
@observable.shallow
public keys: LabelKey[] = [];
@observable.shallow
public values: { [key: string]: LabelValue[] } = {};
constructor(rootStore: RootStore) {
super(rootStore);
this.path = '/labels/';
}
@action
public async loadKeys() {
const result = await makeRequest(`${this.path}keys/`, {});
this.keys = result;
return result;
}
@action
public async loadValuesForKey(key: LabelKey['id'], search = '') {
if (!key) {
return [];
}
const result = await makeRequest(`${this.path}id/${key}`, {
params: { search },
});
const filteredValues = result.values.filter((v) => v.name.toLowerCase().includes(search.toLowerCase())); // TODO remove after backend search implementation
this.values = {
...this.values,
[key]: filteredValues,
};
return { ...result, values: filteredValues };
}
public async createKey(name: string) {
const { key } = await makeRequest(`${this.path}`, {
method: 'POST',
data: { key: { name }, values: [] },
}).then((data) => {
openNotification(`New key has been added`);
return data;
});
return key;
}
public async createValue(keyId: LabelKey['id'], value: string) {
const result = await makeRequest(`${this.path}id/${keyId}/values`, {
method: 'POST',
data: { name: value },
}).then((data) => {
openNotification(`New value has been added`);
return data;
});
return result.values.find((v) => v.name === value); // TODO remove after backend API change
}
@action
public async updateKey(keyId: LabelKey['id'], name: string) {
const result = await makeRequest(`${this.path}id/${keyId}`, {
method: 'PUT',
data: { name },
}).then((data) => {
openNotification(`Key has been renamed`);
return data;
});
return result.key;
}
@action
public async updateKeyValue(keyId: LabelKey['id'], valueId: LabelValue['id'], name: string) {
const result = await makeRequest(`${this.path}id/${keyId}/values/${valueId}`, {
method: 'PUT',
data: { name },
}).then((data) => {
openNotification(`Value has been renamed`);
return data;
});
return result.values.find((v) => v.name === name);
}
}

View file

@ -0,0 +1,12 @@
export interface Label {
id: string;
name: string;
}
export interface LabelKey extends Label {}
export interface LabelValue extends Label {}
export interface LabelKeyValue {
key: LabelKey;
value: LabelValue;
}

View file

@ -11,6 +11,7 @@ import {
ConfirmModal,
Drawer,
Alert,
Tag as GrafanaTag,
} from '@grafana/ui';
import cn from 'classnames/bind';
import { get } from 'lodash-es';
@ -58,6 +59,7 @@ import { ChannelFilter } from 'models/channel_filter';
import { INTEGRATION_TEMPLATES_LIST } from 'pages/integration/Integration.config';
import IntegrationHelper from 'pages/integration/Integration.helper';
import styles from 'pages/integration/Integration.module.scss';
import { AppFeature } from 'state/features';
import { PageProps, SelectOption, WithStoreProps } from 'state/types';
import { useStore } from 'state/useStore';
import { withMobXProviderContext } from 'state/withStore';
@ -126,13 +128,15 @@ class Integration extends React.Component<IntegrationProps, IntegrationState> {
isTemplateSettingsOpen,
} = this.state;
const {
store: { alertReceiveChannelStore },
store,
query,
match: {
params: { id },
},
} = this.props;
const { alertReceiveChannelStore } = store;
const { isNotFoundError, isWrongTeamError } = errorData;
const alertReceiveChannel = alertReceiveChannelStore.items[id];
@ -201,6 +205,7 @@ class Integration extends React.Component<IntegrationProps, IntegrationState> {
alertReceiveChannel={alertReceiveChannel}
alertReceiveChannelCounter={alertReceiveChannelCounter}
integration={integration}
renderLabels={store.hasFeature(AppFeature.Labels)}
/>
</div>
@ -1028,12 +1033,14 @@ interface IntegrationHeaderProps {
alertReceiveChannelCounter: AlertReceiveChannelCounters;
alertReceiveChannel: AlertReceiveChannel;
integration: SelectOption;
renderLabels: boolean;
}
const IntegrationHeader: React.FC<IntegrationHeaderProps> = ({
integration,
alertReceiveChannelCounter,
alertReceiveChannel,
renderLabels,
}) => {
const { grafanaTeamStore, heartbeatStore, alertReceiveChannelStore } = useStore();
@ -1054,6 +1061,25 @@ const IntegrationHeader: React.FC<IntegrationHeaderProps> = ({
</PluginLink>
)}
{renderLabels && (
<TooltipBadge
tooltipTitle=""
borderType="secondary"
icon="tag-alt"
addPadding
text={alertReceiveChannel.labels.length}
tooltipContent={
<VerticalGroup spacing="sm">
{alertReceiveChannel.labels.length
? alertReceiveChannel.labels.map((label) => (
<GrafanaTag name={`${label.key.name}:${label.value.name}`} key={label.key.id} />
))
: 'No labels attached'}
</VerticalGroup>
}
/>
)}
<TooltipBadge
borderType="success"
icon="link"

View file

@ -1,6 +1,6 @@
import React from 'react';
import { HorizontalGroup, Button, VerticalGroup, Icon, ConfirmModal, Tooltip } from '@grafana/ui';
import { HorizontalGroup, Button, VerticalGroup, Icon, ConfirmModal, Tooltip, Tag } from '@grafana/ui';
import cn from 'classnames/bind';
import { debounce } from 'lodash-es';
import { observer } from 'mobx-react';
@ -11,7 +11,6 @@ import { RouteComponentProps, withRouter } from 'react-router-dom';
import GTable from 'components/GTable/GTable';
import HamburgerMenu from 'components/HamburgerMenu/HamburgerMenu';
import IntegrationLogo from 'components/IntegrationLogo/IntegrationLogo';
import { Filters } from 'components/IntegrationsFilters/IntegrationsFilters';
import { PageBaseState } from 'components/PageErrorHandlingWrapper/PageErrorHandlingWrapper';
import {
getWrongTeamResponseInfo,
@ -29,7 +28,9 @@ import { WithPermissionControlTooltip } from 'containers/WithPermissionControl/W
import { HeartIcon, HeartRedIcon } from 'icons';
import { AlertReceiveChannelStore } from 'models/alert_receive_channel/alert_receive_channel';
import { AlertReceiveChannel, MaintenanceMode } from 'models/alert_receive_channel/alert_receive_channel.types';
import { LabelKeyValue } from 'models/label/label.types';
import IntegrationHelper from 'pages/integration/Integration.helper';
import { AppFeature } from 'state/features';
import { PageProps, WithStoreProps } from 'state/types';
import { withMobXProviderContext } from 'state/withStore';
import { openNotification } from 'utils';
@ -44,7 +45,7 @@ const FILTERS_DEBOUNCE_MS = 500;
const ITEMS_PER_PAGE = 15;
interface IntegrationsState extends PageBaseState {
integrationsFilters: Filters;
integrationsFilters: Record<string, any>;
alertReceiveChannelId?: AlertReceiveChannel['id'] | 'new';
confirmationModal: {
isOpen: boolean;
@ -173,7 +174,7 @@ class Integrations extends React.Component<IntegrationsProps, IntegrationsState>
data-testid="integrations-table"
rowKey="id"
data={results}
columns={this.getTableColumns()}
columns={this.getTableColumns(store.hasFeature.bind(store))}
className={cx('integrations-table')}
rowClassName={cx('integrations-table-row')}
pagination={{
@ -357,6 +358,36 @@ class Integrations extends React.Component<IntegrationsProps, IntegrationsState>
return null;
}
renderLabels(item: AlertReceiveChannel) {
return (
<TooltipBadge
tooltipTitle=""
borderType="secondary"
icon="tag-alt"
addPadding
text={item.labels?.length}
tooltipContent={
<VerticalGroup spacing="sm">
{item.labels?.length
? item.labels.map((label) => (
<HorizontalGroup spacing="sm" key={label.key.id}>
<Tag name={`${label.key.name}:${label.value.name}`} />
<Button
size="sm"
icon="filter"
tooltip="Apply filter"
variant="secondary"
onClick={this.getApplyLabelFilterClickHandler(label)}
/>
</HorizontalGroup>
))
: 'No labels attached'}
</VerticalGroup>
}
/>
);
}
renderTeam(item: AlertReceiveChannel, teams: any) {
return (
<TextEllipsisTooltip placement="top" content={teams[item.team]?.name}>
@ -426,12 +457,12 @@ class Integrations extends React.Component<IntegrationsProps, IntegrationsState>
);
};
getTableColumns = () => {
getTableColumns = (hasFeatureFn) => {
const { grafanaTeamStore, alertReceiveChannelStore } = this.props.store;
return [
const columns = [
{
width: '35%',
width: '30%',
title: 'Name',
key: 'name',
render: this.renderName,
@ -444,7 +475,7 @@ class Integrations extends React.Component<IntegrationsProps, IntegrationsState>
render: (item: AlertReceiveChannel) => this.renderIntegrationStatus(item, alertReceiveChannelStore),
},
{
width: '20%',
width: '25%',
title: 'Type',
key: 'datasource',
render: (item: AlertReceiveChannel) => this.renderDatasource(item, alertReceiveChannelStore),
@ -461,6 +492,7 @@ class Integrations extends React.Component<IntegrationsProps, IntegrationsState>
key: 'heartbeat',
render: (item: AlertReceiveChannel) => this.renderHeartbeat(item),
},
{
width: '15%',
title: 'Team',
@ -473,6 +505,17 @@ class Integrations extends React.Component<IntegrationsProps, IntegrationsState>
className: cx('buttons'),
},
];
if (hasFeatureFn(AppFeature.Labels)) {
columns.splice(-2, 0, {
width: '10%',
title: 'Labels',
render: (item: AlertReceiveChannel) => this.renderLabels(item),
});
columns.find((column) => column.key === 'datasource').width = '15%';
}
return columns;
};
invalidateRequestFn = (requestedPage: number) => {
@ -500,10 +543,36 @@ class Integrations extends React.Component<IntegrationsProps, IntegrationsState>
this.setState({ confirmationModal: undefined });
};
handleIntegrationsFiltersChange = (integrationsFilters: Filters, isOnMount: boolean) => {
handleIntegrationsFiltersChange = (
integrationsFilters: IntegrationsState['integrationsFilters'],
isOnMount: boolean
) => {
this.setState({ integrationsFilters }, () => this.debouncedUpdateIntegrations(isOnMount));
};
getApplyLabelFilterClickHandler = (label: LabelKeyValue) => {
const {
store: { filtersStore },
} = this.props;
const {
integrationsFilters: { label: oldLabelFilter = [] },
} = this.state;
return () => {
const labelToAddString = `${label.key.id}:${label.value.id}`;
if (oldLabelFilter.some((label) => label === labelToAddString)) {
return;
}
const newLabelFilter = [...oldLabelFilter, labelToAddString];
LocationHelper.update({ label: newLabelFilter }, 'partial');
filtersStore.needToParseFilters = true;
};
};
applyFilters = async (isOnMount: boolean) => {
const { store } = this.props;
const { alertReceiveChannelStore } = store;

View file

@ -4,4 +4,5 @@ export enum AppFeature {
LiveSettings = 'live_settings',
CloudNotifications = 'grafana_cloud_notifications',
CloudConnection = 'grafana_cloud_connection',
Labels = 'labels',
}

View file

@ -19,6 +19,7 @@ import { FiltersStore } from 'models/filters/filters';
import { GlobalSettingStore } from 'models/global_setting/global_setting';
import { GrafanaTeamStore } from 'models/grafana_team/grafana_team';
import { HeartbeatStore } from 'models/heartbeat/heartbeat';
import { LabelStore } from 'models/label/label';
import { OrganizationStore } from 'models/organization/organization';
import { OutgoingWebhookStore } from 'models/outgoing_webhook/outgoing_webhook';
import { ResolutionNotesStore } from 'models/resolution_note/resolution_note';
@ -108,6 +109,7 @@ export class RootBaseStore {
apiTokenStore = new ApiTokenStore(this);
globalSettingStore = new GlobalSettingStore(this);
filtersStore = new FiltersStore(this);
labelsStore = new LabelStore(this);
// stores

File diff suppressed because it is too large Load diff