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:
parent
bb0bee421e
commit
24f4969f61
51 changed files with 2759 additions and 110 deletions
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
55
engine/apps/api/serializers/labels.py
Normal file
55
engine/apps/api/serializers/labels.py
Normal 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)
|
||||
|
|
@ -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
|
||||
|
|
|
|||
223
engine/apps/api/tests/test_labels.py
Normal file
223
engine/apps/api/tests/test_labels.py
Normal 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
|
||||
|
|
@ -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"),
|
||||
]
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
155
engine/apps/api/views/labels.py
Normal file
155
engine/apps/api/views/labels.py
Normal 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
|
||||
|
|
@ -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]:
|
||||
|
|
|
|||
0
engine/apps/labels/__init__.py
Normal file
0
engine/apps/labels/__init__.py
Normal file
42
engine/apps/labels/client.py
Normal file
42
engine/apps/labels/client.py
Normal 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)
|
||||
48
engine/apps/labels/migrations/0001_initial.py
Normal file
48
engine/apps/labels/migrations/0001_initial.py
Normal 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')},
|
||||
},
|
||||
),
|
||||
]
|
||||
0
engine/apps/labels/migrations/__init__.py
Normal file
0
engine/apps/labels/migrations/__init__.py
Normal file
114
engine/apps/labels/models.py
Normal file
114
engine/apps/labels/models.py
Normal 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"
|
||||
91
engine/apps/labels/tasks.py
Normal file
91
engine/apps/labels/tasks.py
Normal 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,))
|
||||
0
engine/apps/labels/tests/__init__.py
Normal file
0
engine/apps/labels/tests/__init__.py
Normal file
25
engine/apps/labels/tests/factories.py
Normal file
25
engine/apps/labels/tests/factories.py
Normal 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
|
||||
89
engine/apps/labels/tests/test_labels.py
Normal file
89
engine/apps/labels/tests/test_labels.py
Normal 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)
|
||||
136
engine/apps/labels/tests/test_labels_cache.py
Normal file
136
engine/apps/labels/tests/test_labels_cache.py
Normal 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,))
|
||||
54
engine/apps/labels/utils.py
Normal file
54
engine/apps/labels/utils.py
Normal 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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 = [
|
||||
|
|
|
|||
|
|
@ -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"},
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -1,17 +0,0 @@
|
|||
.root {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.integrationsFilters {
|
||||
display: flex;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.searchIntegrationClear {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
}
|
||||
|
||||
.searchIntegrationInput {
|
||||
max-width: 400px;
|
||||
}
|
||||
|
|
@ -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;
|
||||
60
grafana-plugin/src/components/LabelsFilter/LabelsFilter.tsx
Normal file
60
grafana-plugin/src/components/LabelsFilter/LabelsFilter.tsx
Normal 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;
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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` } : {})}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -95,3 +95,7 @@
|
|||
flex-direction: column;
|
||||
margin-bottom: -15px;
|
||||
}
|
||||
|
||||
.labels {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.`
|
||||
);
|
||||
|
|
|
|||
2
grafana-plugin/src/containers/Labels/Labels.module.css
Normal file
2
grafana-plugin/src/containers/Labels/Labels.module.css
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
.root {
|
||||
}
|
||||
107
grafana-plugin/src/containers/Labels/Labels.tsx
Normal file
107
grafana-plugin/src/containers/Labels/Labels.tsx
Normal 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;
|
||||
86
grafana-plugin/src/containers/Labels/LabelsFilter.tsx
Normal file
86
grafana-plugin/src/containers/Labels/LabelsFilter.tsx
Normal 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;
|
||||
|
|
@ -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];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
|
|
|
|||
|
|
@ -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}`);
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -19,6 +19,9 @@ export class FiltersStore extends BaseStore {
|
|||
|
||||
private _globalValues: FiltersValues = {};
|
||||
|
||||
@observable
|
||||
public needToParseFilters = false;
|
||||
|
||||
constructor(rootStore: RootStore) {
|
||||
super(rootStore);
|
||||
|
||||
|
|
|
|||
105
grafana-plugin/src/models/label/label.ts
Normal file
105
grafana-plugin/src/models/label/label.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
12
grafana-plugin/src/models/label/label.types.ts
Normal file
12
grafana-plugin/src/models/label/label.types.ts
Normal 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;
|
||||
}
|
||||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -4,4 +4,5 @@ export enum AppFeature {
|
|||
LiveSettings = 'live_settings',
|
||||
CloudNotifications = 'grafana_cloud_notifications',
|
||||
CloudConnection = 'grafana_cloud_connection',
|
||||
Labels = 'labels',
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
Loading…
Add table
Reference in a new issue