Webhook labels (#3383)
This PR add labels for webhooks.
1. Make webhook "labelable" with ability to filter by labels.
2. Add labels to the webhook payload. It contain new field webhook with
it's name, id and labels. Field integration and alert_group has a
corresponding label field as well. See example of a new payload below:
```
{
"event": {
"type": "escalation"
},
"user": null,
"alert_group": {
"id": "IRFN6ZD31N31B",
"integration_id": "CTWM7U4A2QG97",
"route_id": "RUE7U7Z46SKGY",
"alerts_count": 1,
"state": "firing",
"created_at": "2023-11-22T08:54:55.178243Z",
"resolved_at": null,
"acknowledged_at": null,
"title": "Incident",
"permalinks": {
"slack": null,
"telegram": null,
"web": "http://grafana:3000/a/grafana-oncall-app/alert-groups/IRFN6ZD31N31B"
},
"labels": {
"severity": "critical"
}
},
"alert_group_id": "IRFN6ZD31N31B",
"alert_payload": {
"message": "This alert was sent by user for demonstration purposes"
},
"integration": {
"id": "CTWM7U4A2QG97",
"type": "webhook",
"name": "hi - Webhook",
"team": null,
"labels": {
"hello": "world",
"severity": "critical"
}
},
"notified_users": [],
"users_to_be_notified": [],
"webhook": {
"id": "WHAXK4BTC7TAEQ",
"name": "test",
"labels": {
"hello": "kesha"
}
}
}
```
I feel that there is an opportunity to make code cleaner - remove all
label logic from serializers, views and utils to models or dedicated
LabelerService and introduce Labelable interface with something like
label_verbal, update_labels methods. However, I don't want to tie
webhook labels with a refactoring.
---------
Co-authored-by: Dominik <dominik.broj@grafana.com>
This commit is contained in:
parent
92ed22645c
commit
9628bdc51f
38 changed files with 653 additions and 279 deletions
2
Tiltfile
2
Tiltfile
|
|
@ -58,7 +58,7 @@ local_resource(
|
|||
allow_parallel=True,
|
||||
)
|
||||
|
||||
yaml = helm("helm/oncall", name=HELM_PREFIX, values=["./dev/helm-local.yml"])
|
||||
yaml = helm("helm/oncall", name=HELM_PREFIX, values=["./dev/helm-local.yml", "./dev/helm-local.dev.yml"])
|
||||
|
||||
k8s_yaml(yaml)
|
||||
|
||||
|
|
|
|||
15
engine/apps/api/label_filtering.py
Normal file
15
engine/apps/api/label_filtering.py
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
from typing import List, Tuple
|
||||
|
||||
|
||||
def parse_label_query(label_query: List[str]) -> List[Tuple[str, str]]:
|
||||
"""
|
||||
parse_label_query returns list of key-value tuples from a list of "raw" labels – key-value pairs separated with ':'.
|
||||
"""
|
||||
kv_pairs = []
|
||||
for label in label_query:
|
||||
label_data = label.split(":")
|
||||
# Check if label_data is a valid key-value label pair]: ["key1", "value1"]
|
||||
if len(label_data) != 2:
|
||||
continue
|
||||
kv_pairs.append((label_data[0], label_data[1]))
|
||||
return kv_pairs
|
||||
|
|
@ -3,6 +3,7 @@ from collections import defaultdict
|
|||
from rest_framework import serializers
|
||||
from rest_framework.validators import UniqueTogetherValidator
|
||||
|
||||
from apps.api.serializers.labels import LabelsSerializerMixin
|
||||
from apps.webhooks.models import Webhook, WebhookResponse
|
||||
from apps.webhooks.models.webhook import PUBLIC_WEBHOOK_HTTP_METHODS, WEBHOOK_FIELD_PLACEHOLDER
|
||||
from apps.webhooks.presets.preset_options import WebhookPresetOptions
|
||||
|
|
@ -27,7 +28,7 @@ class WebhookResponseSerializer(serializers.ModelSerializer):
|
|||
]
|
||||
|
||||
|
||||
class WebhookSerializer(serializers.ModelSerializer):
|
||||
class WebhookSerializer(LabelsSerializerMixin, serializers.ModelSerializer):
|
||||
id = serializers.CharField(read_only=True, source="public_primary_key")
|
||||
organization = serializers.HiddenField(default=CurrentOrganizationDefault())
|
||||
team = TeamPrimaryKeyRelatedField(allow_null=True, default=CurrentTeamDefault())
|
||||
|
|
@ -37,6 +38,8 @@ class WebhookSerializer(serializers.ModelSerializer):
|
|||
trigger_type = serializers.CharField(allow_null=True)
|
||||
trigger_type_name = serializers.SerializerMethodField()
|
||||
|
||||
PREFETCH_RELATED = ["labels", "labels__key", "labels__value"]
|
||||
|
||||
class Meta:
|
||||
model = Webhook
|
||||
fields = [
|
||||
|
|
@ -61,10 +64,25 @@ class WebhookSerializer(serializers.ModelSerializer):
|
|||
"last_response_log",
|
||||
"integration_filter",
|
||||
"preset",
|
||||
"labels",
|
||||
]
|
||||
|
||||
validators = [UniqueTogetherValidator(queryset=Webhook.objects.all(), fields=["name", "organization"])]
|
||||
|
||||
def create(self, validated_data):
|
||||
organization = self.context["request"].auth.organization
|
||||
labels = validated_data.pop("labels", None)
|
||||
|
||||
instance = super().create(validated_data)
|
||||
self.update_labels_association_if_needed(labels, instance, organization)
|
||||
return instance
|
||||
|
||||
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)
|
||||
return super().update(instance, validated_data)
|
||||
|
||||
def to_representation(self, instance):
|
||||
result = super().to_representation(instance)
|
||||
if instance.password:
|
||||
|
|
|
|||
|
|
@ -1310,7 +1310,6 @@ def test_integration_filter_by_labels(
|
|||
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()
|
||||
|
|
@ -1353,7 +1352,6 @@ def test_update_alert_receive_channel_labels(
|
|||
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()
|
||||
|
|
|
|||
|
|
@ -43,7 +43,6 @@ 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()
|
||||
|
|
@ -68,7 +67,6 @@ 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()
|
||||
|
|
@ -94,7 +92,6 @@ 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()
|
||||
|
|
@ -120,7 +117,6 @@ 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()
|
||||
|
|
@ -146,7 +142,6 @@ 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()
|
||||
|
|
@ -171,7 +166,6 @@ 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()
|
||||
|
|
@ -189,7 +183,6 @@ def test_labels_create_label(
|
|||
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_FOR_ALL", False)
|
||||
|
|
@ -239,7 +232,6 @@ def test_labels_feature_false(
|
|||
def test_labels_permissions_get_actions(
|
||||
make_organization_and_user_with_plugin_token,
|
||||
make_user_auth_headers,
|
||||
make_alert_receive_channel,
|
||||
role,
|
||||
expected_status,
|
||||
):
|
||||
|
|
@ -274,7 +266,6 @@ def test_labels_permissions_get_actions(
|
|||
def test_labels_permissions_create_update_actions(
|
||||
make_organization_and_user_with_plugin_token,
|
||||
make_user_auth_headers,
|
||||
make_alert_receive_channel,
|
||||
role,
|
||||
expected_status,
|
||||
):
|
||||
|
|
|
|||
|
|
@ -63,6 +63,7 @@ def test_create_webhook_from_preset(
|
|||
"http_method": "GET",
|
||||
"integration_filter": None,
|
||||
"is_webhook_enabled": True,
|
||||
"labels": [],
|
||||
"is_legacy": False,
|
||||
"last_response_log": {
|
||||
"request_data": "",
|
||||
|
|
|
|||
|
|
@ -52,6 +52,7 @@ def test_get_list_webhooks(webhook_internal_api_setup, make_user_auth_headers):
|
|||
"http_method": "POST",
|
||||
"integration_filter": None,
|
||||
"is_webhook_enabled": True,
|
||||
"labels": [],
|
||||
"is_legacy": False,
|
||||
"last_response_log": {
|
||||
"request_data": "",
|
||||
|
|
@ -95,6 +96,7 @@ def test_get_detail_webhook(webhook_internal_api_setup, make_user_auth_headers):
|
|||
"http_method": "POST",
|
||||
"integration_filter": None,
|
||||
"is_webhook_enabled": True,
|
||||
"labels": [],
|
||||
"is_legacy": False,
|
||||
"last_response_log": {
|
||||
"request_data": "",
|
||||
|
|
@ -143,6 +145,7 @@ def test_create_webhook(webhook_internal_api_setup, make_user_auth_headers):
|
|||
"http_method": "POST",
|
||||
"integration_filter": None,
|
||||
"is_webhook_enabled": True,
|
||||
"labels": [],
|
||||
"is_legacy": False,
|
||||
"last_response_log": {
|
||||
"request_data": "",
|
||||
|
|
@ -203,6 +206,7 @@ def test_create_valid_templated_field(webhook_internal_api_setup, make_user_auth
|
|||
"http_method": "POST",
|
||||
"integration_filter": None,
|
||||
"is_webhook_enabled": True,
|
||||
"labels": [],
|
||||
"is_legacy": False,
|
||||
"last_response_log": {
|
||||
"request_data": "",
|
||||
|
|
@ -583,6 +587,7 @@ def test_webhook_field_masking(webhook_internal_api_setup, make_user_auth_header
|
|||
"http_method": "POST",
|
||||
"integration_filter": None,
|
||||
"is_webhook_enabled": True,
|
||||
"labels": [],
|
||||
"is_legacy": False,
|
||||
"last_response_log": {
|
||||
"request_data": "",
|
||||
|
|
@ -642,6 +647,7 @@ def test_webhook_copy(webhook_internal_api_setup, make_user_auth_headers):
|
|||
"http_method": "POST",
|
||||
"integration_filter": None,
|
||||
"is_webhook_enabled": True,
|
||||
"labels": [],
|
||||
"is_legacy": False,
|
||||
"last_response_log": {
|
||||
"request_data": "",
|
||||
|
|
@ -711,3 +717,184 @@ def test_create_invalid_missing_fields(webhook_internal_api_setup, make_user_aut
|
|||
response = client.post(url, data, format="json", **make_user_auth_headers(user, token))
|
||||
assert response.status_code == status.HTTP_400_BAD_REQUEST
|
||||
assert response.json()["trigger_type"][0] == "This field is required."
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_webhook_filter_by_labels(
|
||||
make_organization_and_user_with_plugin_token,
|
||||
make_custom_webhook,
|
||||
make_webhook_label_association,
|
||||
make_label_key_and_value,
|
||||
make_user_auth_headers,
|
||||
):
|
||||
organization, user, token = make_organization_and_user_with_plugin_token()
|
||||
webhook_with_label = make_custom_webhook(organization)
|
||||
label = make_webhook_label_association(organization, webhook_with_label)
|
||||
|
||||
webhook_with_another_label = make_custom_webhook(organization)
|
||||
another_label = make_webhook_label_association(organization, webhook_with_another_label)
|
||||
|
||||
not_attached_key, not_attached_value = make_label_key_and_value(organization)
|
||||
|
||||
client = APIClient()
|
||||
|
||||
# test filter by label, which is attached to only one webhook
|
||||
url = reverse("api-internal:webhooks-list")
|
||||
response = client.get(
|
||||
f"{url}?label={label.key_id}:{label.value_id}",
|
||||
content_type="application/json",
|
||||
**make_user_auth_headers(user, token),
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
assert len(response.json()) == 1
|
||||
assert response.json()[0]["id"] == webhook_with_label.public_primary_key
|
||||
|
||||
url = reverse("api-internal:webhooks-list")
|
||||
response = client.get(
|
||||
f"{url}?label={another_label.key_id}:{another_label.value_id}",
|
||||
content_type="application/json",
|
||||
**make_user_auth_headers(user, token),
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
assert len(response.json()) == 1
|
||||
assert response.json()[0]["id"] == webhook_with_another_label.public_primary_key
|
||||
|
||||
# test filter by label which is not attached to any webhooks
|
||||
response = client.get(
|
||||
f"{url}?label={not_attached_key.id}:{not_attached_value.id}",
|
||||
content_type="application/json",
|
||||
**make_user_auth_headers(user, token),
|
||||
)
|
||||
assert len(response.json()) == 0
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_update_webhook_labels(
|
||||
webhook_internal_api_setup,
|
||||
make_user_auth_headers,
|
||||
):
|
||||
user, token, webhook = webhook_internal_api_setup
|
||||
client = APIClient()
|
||||
|
||||
url = reverse("api-internal:webhooks-detail", kwargs={"pk": webhook.public_primary_key})
|
||||
key_id = "testkey"
|
||||
value_id = "testvalue"
|
||||
data = {"labels": [{"key": {"id": key_id, "name": "test"}, "value": {"id": value_id, "name": "testv"}}]}
|
||||
response = client.patch(
|
||||
url,
|
||||
data=json.dumps(data),
|
||||
content_type="application/json",
|
||||
**make_user_auth_headers(user, token),
|
||||
)
|
||||
|
||||
webhook.refresh_from_db()
|
||||
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
assert webhook.labels.count() == 1
|
||||
label = webhook.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),
|
||||
)
|
||||
|
||||
webhook.refresh_from_db()
|
||||
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
assert webhook.labels.count() == 0
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_create_webhook_with_labels(
|
||||
make_organization_and_user_with_plugin_token,
|
||||
make_user_auth_headers,
|
||||
):
|
||||
organization, user, token = make_organization_and_user_with_plugin_token()
|
||||
client = APIClient()
|
||||
|
||||
url = reverse("api-internal:webhooks-list")
|
||||
|
||||
key_id = "testkey"
|
||||
value_id = "testvalue"
|
||||
data = {
|
||||
"name": "the_webhook",
|
||||
"url": TEST_URL,
|
||||
"trigger_type": Webhook.TRIGGER_ALERT_GROUP_CREATED,
|
||||
"http_method": "POST",
|
||||
"labels": [{"key": {"id": key_id, "name": "test"}, "value": {"id": value_id, "name": "testv"}}],
|
||||
"team": None,
|
||||
}
|
||||
|
||||
response = client.post(
|
||||
url,
|
||||
data=json.dumps(data),
|
||||
content_type="application/json",
|
||||
**make_user_auth_headers(user, token),
|
||||
)
|
||||
|
||||
assert response.status_code == 201
|
||||
webhook = Webhook.objects.get(public_primary_key=response.json()["id"])
|
||||
expected_response = data | {
|
||||
"id": webhook.public_primary_key,
|
||||
"data": None,
|
||||
"username": None,
|
||||
"password": None,
|
||||
"authorization_header": None,
|
||||
"forward_all": True,
|
||||
"headers": None,
|
||||
"http_method": "POST",
|
||||
"integration_filter": None,
|
||||
"is_webhook_enabled": True,
|
||||
"is_legacy": False,
|
||||
"last_response_log": {
|
||||
"request_data": "",
|
||||
"request_headers": "",
|
||||
"timestamp": None,
|
||||
"content": "",
|
||||
"status_code": None,
|
||||
"request_trigger": "",
|
||||
"url": "",
|
||||
"event_data": "",
|
||||
},
|
||||
"trigger_template": None,
|
||||
"trigger_type": str(data["trigger_type"]),
|
||||
"trigger_type_name": "Alert Group Created",
|
||||
"preset": None,
|
||||
}
|
||||
assert response.status_code == status.HTTP_201_CREATED
|
||||
assert response.json() == expected_response
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_update_webhook_labels_duplicate_key(
|
||||
webhook_internal_api_setup,
|
||||
make_user_auth_headers,
|
||||
):
|
||||
user, token, webhook = webhook_internal_api_setup
|
||||
client = APIClient()
|
||||
|
||||
url = reverse("api-internal:webhooks-detail", kwargs={"pk": webhook.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),
|
||||
)
|
||||
|
||||
webhook.refresh_from_db()
|
||||
|
||||
assert response.status_code == status.HTTP_400_BAD_REQUEST
|
||||
assert webhook.labels.count() == 0
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ from apps.alerts.models import Alert, AlertGroup, AlertReceiveChannel, Escalatio
|
|||
from apps.alerts.paging import unpage_user
|
||||
from apps.alerts.tasks import delete_alert_group, send_update_resolution_note_signal
|
||||
from apps.api.errors import AlertGroupAPIError
|
||||
from apps.api.label_filtering import parse_label_query
|
||||
from apps.api.permissions import RBACPermission
|
||||
from apps.api.serializers.alert_group import AlertGroupListSerializer, AlertGroupSerializer
|
||||
from apps.api.serializers.team import TeamSerializer
|
||||
|
|
@ -339,19 +340,15 @@ class AlertGroupView(
|
|||
alert_receive_channels_ids = list(alert_receive_channels_qs.values_list("id", flat=True))
|
||||
queryset = AlertGroup.objects.filter(channel__in=alert_receive_channels_ids)
|
||||
|
||||
# filter by labels
|
||||
labels = self.request.query_params.getlist("label")
|
||||
for label in labels:
|
||||
label_split = label.split(":")
|
||||
if len(label_split) != 2:
|
||||
continue
|
||||
key_name, value_name = label_split
|
||||
|
||||
# Filter by labels. Since alert group labels are "static" filter by names, not IDs.
|
||||
label_query = self.request.query_params.getlist("label", [])
|
||||
kv_pairs = parse_label_query(label_query)
|
||||
for key, value in kv_pairs:
|
||||
# Utilize (organization, key_name, value_name, alert_group) index on AlertGroupAssociatedLabel
|
||||
queryset = queryset.filter(
|
||||
labels__organization=self.request.auth.organization,
|
||||
labels__key_name=key_name,
|
||||
labels__value_name=value_name,
|
||||
labels__key_name=key,
|
||||
labels__value_name=value,
|
||||
)
|
||||
|
||||
queryset = queryset.only("id")
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ from rest_framework.viewsets import ModelViewSet
|
|||
from apps.alerts.grafana_alerting_sync_manager.grafana_alerting_sync import GrafanaAlertingSyncManager
|
||||
from apps.alerts.models import Alert, AlertGroup, AlertReceiveChannel
|
||||
from apps.alerts.models.maintainable_object import MaintainableObject
|
||||
from apps.api.label_filtering import parse_label_query
|
||||
from apps.api.permissions import RBACPermission
|
||||
from apps.api.serializers.alert_receive_channel import (
|
||||
AlertReceiveChannelSerializer,
|
||||
|
|
@ -18,7 +19,7 @@ from apps.api.serializers.alert_receive_channel import (
|
|||
FilterAlertReceiveChannelSerializer,
|
||||
)
|
||||
from apps.api.throttlers import DemoAlertThrottler
|
||||
from apps.api.views.labels import LabelsAssociatingMixin
|
||||
from apps.api.views.labels import schedule_update_label_cache
|
||||
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
|
||||
|
|
@ -76,7 +77,6 @@ class AlertReceiveChannelView(
|
|||
PublicPrimaryKeyMixin,
|
||||
FilterSerializerMixin,
|
||||
UpdateSerializerMixin,
|
||||
LabelsAssociatingMixin,
|
||||
ModelViewSet,
|
||||
):
|
||||
authentication_classes = (
|
||||
|
|
@ -159,7 +159,17 @@ class AlertReceiveChannelView(
|
|||
if not ignore_filtering_by_available_teams:
|
||||
queryset = queryset.filter(*self.available_teams_lookup_args).distinct()
|
||||
|
||||
queryset = self.filter_by_labels(queryset)
|
||||
# filter labels
|
||||
label_query = self.request.query_params.getlist("label", [])
|
||||
kv_pairs = parse_label_query(label_query)
|
||||
for key, value in kv_pairs:
|
||||
queryset = queryset.filter(
|
||||
labels__key_id=key,
|
||||
labels__value_id=value,
|
||||
)
|
||||
|
||||
# distinct to remove duplicates after alert_receive_channels X labels join
|
||||
queryset = queryset.distinct()
|
||||
|
||||
return queryset
|
||||
|
||||
|
|
@ -170,7 +180,11 @@ class AlertReceiveChannelView(
|
|||
"""
|
||||
if self.request.query_params.get("skip_pagination", "false").lower() == "true":
|
||||
return None
|
||||
return super().paginate_queryset(queryset)
|
||||
page = super().paginate_queryset(queryset)
|
||||
if page is not None:
|
||||
ids = [d.id for d in queryset]
|
||||
schedule_update_label_cache(self.model.__name__, self.request.auth.organization, ids)
|
||||
return page
|
||||
|
||||
@action(detail=True, methods=["post"], throttle_classes=[DemoAlertThrottler])
|
||||
def send_demo_alert(self, request, pk):
|
||||
|
|
|
|||
|
|
@ -6,7 +6,6 @@ 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.permissions import BasicRolePermission, LegacyAccessControlRole
|
||||
from apps.api.serializers.labels import (
|
||||
LabelKeySerializer,
|
||||
|
|
@ -172,30 +171,8 @@ class AlertGroupLabelsViewSet(LabelsFeatureFlagViewSet):
|
|||
)
|
||||
|
||||
|
||||
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
|
||||
def schedule_update_label_cache(model_name, org, ids):
|
||||
if not is_labels_feature_enabled(org):
|
||||
return
|
||||
logger.info(f"start update_instances_labels_cache for ids: {ids}")
|
||||
update_instances_labels_cache.apply_async((org.id, ids, model_name))
|
||||
|
|
|
|||
|
|
@ -11,9 +11,12 @@ from rest_framework.permissions import IsAuthenticated
|
|||
from rest_framework.response import Response
|
||||
from rest_framework.viewsets import ModelViewSet
|
||||
|
||||
from apps.api.label_filtering import parse_label_query
|
||||
from apps.api.permissions import RBACPermission
|
||||
from apps.api.serializers.webhook import WebhookResponseSerializer, WebhookSerializer
|
||||
from apps.api.views.labels import schedule_update_label_cache
|
||||
from apps.auth_token.auth import PluginAuthentication
|
||||
from apps.labels.utils import is_labels_feature_enabled
|
||||
from apps.webhooks.models import Webhook, WebhookResponse
|
||||
from apps.webhooks.presets.preset_options import WebhookPresetOptions
|
||||
from apps.webhooks.utils import apply_jinja_template_for_json
|
||||
|
|
@ -94,6 +97,21 @@ class WebhooksView(TeamFilteringMixin, PublicPrimaryKeyMixin, ModelViewSet):
|
|||
).prefetch_related("responses")
|
||||
if not ignore_filtering_by_available_teams:
|
||||
queryset = queryset.filter(*self.available_teams_lookup_args).distinct()
|
||||
|
||||
# filter by labels
|
||||
label_query = self.request.query_params.getlist("label", [])
|
||||
kv_pairs = parse_label_query(label_query)
|
||||
for key, value in kv_pairs:
|
||||
queryset = queryset.filter(
|
||||
labels__key_id=key,
|
||||
labels__value_id=value,
|
||||
)
|
||||
# distinct to remove duplicates after webhooks X labels join
|
||||
queryset = queryset.distinct()
|
||||
# schedule update of labels cache
|
||||
ids = [d.id for d in queryset]
|
||||
schedule_update_label_cache(self.model.__name__, self.request.auth.organization, ids)
|
||||
|
||||
return queryset
|
||||
|
||||
def get_object(self):
|
||||
|
|
@ -132,6 +150,15 @@ class WebhooksView(TeamFilteringMixin, PublicPrimaryKeyMixin, ModelViewSet):
|
|||
},
|
||||
]
|
||||
|
||||
if is_labels_feature_enabled(self.request.auth.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))
|
||||
|
||||
|
|
|
|||
29
engine/apps/labels/migrations/0004_webhookassociatedlabel.py
Normal file
29
engine/apps/labels/migrations/0004_webhookassociatedlabel.py
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
# Generated by Django 4.2.7 on 2023-11-22 06:10
|
||||
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('webhooks', '0011_auto_20230920_1813'),
|
||||
('user_management', '0017_alter_organization_maintenance_author'),
|
||||
('labels', '0003_alertreceivechannelassociatedlabel_inherit'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='WebhookAssociatedLabel',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('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='webhook_labels', to='user_management.organization')),
|
||||
('value', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='labels.labelvaluecache')),
|
||||
('webhook', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='labels', to='webhooks.webhook')),
|
||||
],
|
||||
options={
|
||||
'unique_together': {('key_id', 'value_id', 'webhook_id')},
|
||||
},
|
||||
),
|
||||
]
|
||||
|
|
@ -139,3 +139,24 @@ class AlertGroupAssociatedLabel(models.Model):
|
|||
name="unique_alert_group_label",
|
||||
)
|
||||
]
|
||||
|
||||
|
||||
class WebhookAssociatedLabel(AssociatedLabel):
|
||||
"""Keeps information about label association with outgoing webhooks instances"""
|
||||
|
||||
webhook = models.ForeignKey(
|
||||
"webhooks.Webhook",
|
||||
on_delete=models.CASCADE,
|
||||
related_name="labels",
|
||||
)
|
||||
organization = models.ForeignKey(
|
||||
"user_management.Organization", on_delete=models.CASCADE, related_name="webhook_labels"
|
||||
)
|
||||
|
||||
class Meta:
|
||||
unique_together = ["key_id", "value_id", "webhook_id"]
|
||||
|
||||
@staticmethod
|
||||
def get_associating_label_field_name() -> str:
|
||||
"""Returns ForeignKey field name for the associated model"""
|
||||
return "webhook"
|
||||
|
|
|
|||
|
|
@ -6,7 +6,13 @@ 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.labels.utils import (
|
||||
LABEL_OUTDATED_TIMEOUT_MINUTES,
|
||||
LabelKeyData,
|
||||
LabelsData,
|
||||
ValueData,
|
||||
get_associating_label_model,
|
||||
)
|
||||
from apps.user_management.models import Organization
|
||||
from common.custom_celery_tasks import shared_dedicated_queue_retry_task
|
||||
|
||||
|
|
@ -14,11 +20,6 @@ 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
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ from apps.labels.models import (
|
|||
AlertReceiveChannelAssociatedLabel,
|
||||
LabelKeyCache,
|
||||
LabelValueCache,
|
||||
WebhookAssociatedLabel,
|
||||
)
|
||||
from common.utils import UniqueFaker
|
||||
|
||||
|
|
@ -33,3 +34,8 @@ class AlertReceiveChannelAssociatedLabelFactory(factory.DjangoModelFactory):
|
|||
class AlertGroupAssociatedLabelFactory(factory.DjangoModelFactory):
|
||||
class Meta:
|
||||
model = AlertGroupAssociatedLabel
|
||||
|
||||
|
||||
class WebhookAssociatedLabelFactory(factory.DjangoModelFactory):
|
||||
class Meta:
|
||||
model = WebhookAssociatedLabel
|
||||
|
|
|
|||
|
|
@ -1,8 +1,14 @@
|
|||
import pytest
|
||||
|
||||
from apps.alerts.models import AlertReceiveChannel
|
||||
from apps.labels.models import AlertReceiveChannelAssociatedLabel, AssociatedLabel, LabelValueCache
|
||||
from apps.labels.models import (
|
||||
AlertReceiveChannelAssociatedLabel,
|
||||
AssociatedLabel,
|
||||
LabelValueCache,
|
||||
WebhookAssociatedLabel,
|
||||
)
|
||||
from apps.labels.utils import get_associating_label_model, is_labels_feature_enabled
|
||||
from apps.webhooks.models import Webhook
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
|
|
@ -104,6 +110,11 @@ def test_get_associating_label_model():
|
|||
result = get_associating_label_model(model_name)
|
||||
assert result == expected_result
|
||||
|
||||
model_name = Webhook.__name__
|
||||
expected_result = WebhookAssociatedLabel
|
||||
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)
|
||||
|
|
|
|||
|
|
@ -27,6 +27,11 @@ class LabelData(typing.TypedDict):
|
|||
value: LabelParams
|
||||
|
||||
|
||||
class ValueData(typing.TypedDict):
|
||||
value_name: str
|
||||
key_name: str
|
||||
|
||||
|
||||
class LabelKeyData(typing.TypedDict):
|
||||
key: LabelParams
|
||||
values: typing.List[LabelParams]
|
||||
|
|
@ -66,3 +71,18 @@ def assign_labels(alert_group: "AlertGroup", alert_receive_channel: "AlertReceiv
|
|||
for label in alert_receive_channel.labels.filter(inheritable=True).select_related("key", "value")
|
||||
]
|
||||
AlertGroupAssociatedLabel.objects.bulk_create(alert_group_labels)
|
||||
|
||||
|
||||
def get_label_verbal(labelable) -> typing.Dict[str, str]:
|
||||
"""
|
||||
label_verbal returns dict of labels' key and values names for the given object
|
||||
"""
|
||||
return {label.key.name: label.value.name for label in labelable.labels.all().select_related("key", "value")}
|
||||
|
||||
|
||||
def get_alert_group_label_verbal(alert_group: "AlertGroup") -> typing.Dict[str, str]:
|
||||
"""
|
||||
get_alert_group_label_verbal returns dict of labels' key and values names for the given alert group.
|
||||
It's different from get_label_verbal, because AlertGroupAssociated labels store key/value_name, not key/value_id
|
||||
"""
|
||||
return {label.key_name: label.value_name for label in alert_group.labels.all()}
|
||||
|
|
|
|||
|
|
@ -91,7 +91,7 @@ def _build_payload(webhook, alert_group, user):
|
|||
response_data = r.content
|
||||
responses_data[r.webhook.public_primary_key] = response_data
|
||||
|
||||
data = serialize_event(event, alert_group, user, responses_data)
|
||||
data = serialize_event(event, alert_group, user, webhook, responses_data)
|
||||
|
||||
return data
|
||||
|
||||
|
|
|
|||
|
|
@ -302,6 +302,7 @@ def test_execute_webhook_ok_forward_all(
|
|||
"type": alert_receive_channel.integration,
|
||||
"name": alert_receive_channel.short_name,
|
||||
"team": None,
|
||||
"labels": {},
|
||||
},
|
||||
"notified_users": [
|
||||
{
|
||||
|
|
@ -310,10 +311,15 @@ def test_execute_webhook_ok_forward_all(
|
|||
"email": notified_user.email,
|
||||
}
|
||||
],
|
||||
"alert_group": IncidentSerializer(alert_group).data,
|
||||
"alert_group": {**IncidentSerializer(alert_group).data, "labels": {}},
|
||||
"alert_group_id": alert_group.public_primary_key,
|
||||
"alert_payload": "",
|
||||
"users_to_be_notified": [],
|
||||
"webhook": {
|
||||
"id": webhook.public_primary_key,
|
||||
"name": webhook.name,
|
||||
"labels": {},
|
||||
},
|
||||
}
|
||||
expected_call = call(
|
||||
"https://something/{}/".format(alert_group.public_primary_key),
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ from urllib.parse import urlparse
|
|||
from django.conf import settings
|
||||
|
||||
from apps.base.utils import live_settings
|
||||
from apps.labels.utils import get_alert_group_label_verbal, get_label_verbal, is_labels_feature_enabled
|
||||
from apps.schedules.ical_utils import list_users_to_notify_from_ical
|
||||
from common.jinja_templater import apply_jinja_template
|
||||
|
||||
|
|
@ -150,7 +151,7 @@ def _extract_users_from_escalation_snapshot(escalation_snapshot):
|
|||
return list({u["id"]: u for u in users if u}.values())
|
||||
|
||||
|
||||
def serialize_event(event, alert_group, user, responses=None):
|
||||
def serialize_event(event, alert_group, user, webhook, responses=None):
|
||||
from apps.public_api.serializers import IncidentSerializer
|
||||
|
||||
alert_payload = alert_group.alerts.first()
|
||||
|
|
@ -179,4 +180,10 @@ def serialize_event(event, alert_group, user, responses=None):
|
|||
if responses:
|
||||
data["responses"] = responses
|
||||
|
||||
# Enrich webhook data with labels payloads if labels feature is enabled
|
||||
# TODO: once feature flag will be removed this code should go to the 'data' dict declaration
|
||||
if is_labels_feature_enabled(alert_group.channel.organization):
|
||||
data["webhook"] = {"id": webhook.public_primary_key, "name": webhook.name, "labels": get_label_verbal(webhook)}
|
||||
data["integration"]["labels"] = get_label_verbal(alert_group.channel)
|
||||
data["alert_group"]["labels"] = get_alert_group_label_verbal(alert_group)
|
||||
return data
|
||||
|
|
|
|||
|
|
@ -62,6 +62,7 @@ from apps.labels.tests.factories import (
|
|||
AlertReceiveChannelAssociatedLabelFactory,
|
||||
LabelKeyFactory,
|
||||
LabelValueFactory,
|
||||
WebhookAssociatedLabelFactory,
|
||||
)
|
||||
from apps.mobile_app.models import MobileAppAuthToken, MobileAppVerificationToken
|
||||
from apps.phone_notifications.phone_backend import PhoneBackend
|
||||
|
|
@ -994,3 +995,12 @@ def make_alert_group_label_association():
|
|||
return AlertGroupAssociatedLabelFactory(alert_group=alert_group, organization=organization, **kwargs)
|
||||
|
||||
return _make_alert_group_label_association
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def make_webhook_label_association(make_label_key_and_value):
|
||||
def _make_integration_label_association(organization, webhook, **kwargs):
|
||||
key, value = make_label_key_and_value(organization)
|
||||
return WebhookAssociatedLabelFactory(webhook=webhook, organization=organization, key=key, value=value, **kwargs)
|
||||
|
||||
return _make_integration_label_association
|
||||
|
|
|
|||
|
|
@ -49,6 +49,8 @@ module.exports = {
|
|||
],
|
||||
'no-duplicate-imports': 'error',
|
||||
'no-restricted-imports': 'warn',
|
||||
// https://eslint.org/docs/latest/rules/no-redeclare#handled_by_typescript
|
||||
'no-redeclare': 0,
|
||||
'react/display-name': 'warn',
|
||||
/**
|
||||
* It appears as though the react/prop-types rule has a bug in it
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@
|
|||
"description": "Grafana OnCall Plugin",
|
||||
"scripts": {
|
||||
"lint": "eslint --cache --ext .js,.jsx,.ts,.tsx --max-warnings=0 ./src ./e2e-tests",
|
||||
"lint:fix": "eslint --fix --cache --ext .js,.jsx,.ts,.tsx --max-warnings=0 --quiet ./src ./e2e-tests",
|
||||
"lint:fix": "eslint --fix --cache --ext .js,.jsx,.ts,.tsx --quiet ./src ./e2e-tests",
|
||||
"stylelint": "stylelint ./src/**/*.{css,scss,module.css,module.scss}",
|
||||
"stylelint:fix": "stylelint --fix ./src/**/*.{css,scss,module.css,module.scss}",
|
||||
"build": "grafana-toolkit plugin:build",
|
||||
|
|
|
|||
|
|
@ -10,13 +10,21 @@ import { FormItem, FormItemType } from 'components/GForm/GForm.types';
|
|||
import MonacoEditor from 'components/MonacoEditor/MonacoEditor';
|
||||
import { MONACO_READONLY_CONFIG } from 'components/MonacoEditor/MonacoEditor.config';
|
||||
import GSelect from 'containers/GSelect/GSelect';
|
||||
import { CustomFieldSectionRendererProps } from 'containers/IntegrationForm/IntegrationForm';
|
||||
import RemoteSelect from 'containers/RemoteSelect/RemoteSelect';
|
||||
|
||||
import styles from './GForm.module.scss';
|
||||
|
||||
const cx = cn.bind(styles);
|
||||
|
||||
export interface CustomFieldSectionRendererProps {
|
||||
control: any;
|
||||
formItem: FormItem;
|
||||
errors: any;
|
||||
register: any;
|
||||
setValue: (fieldName: string, fieldValue: any) => void;
|
||||
getValues: <T = unknown>(fieldName: string | string[]) => T;
|
||||
}
|
||||
|
||||
interface GFormProps {
|
||||
form: { name: string; fields: FormItem[] };
|
||||
data: any;
|
||||
|
|
@ -211,6 +219,7 @@ class GForm extends React.Component<GFormProps, {}> {
|
|||
}}
|
||||
errors={errors}
|
||||
register={register}
|
||||
getValues={getValues}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,40 @@
|
|||
import React, { FC } from 'react';
|
||||
|
||||
import { LabelTag } from '@grafana/labels';
|
||||
import { VerticalGroup, HorizontalGroup, Button } from '@grafana/ui';
|
||||
|
||||
import TooltipBadge from 'components/TooltipBadge/TooltipBadge';
|
||||
import { LabelKeyValue } from 'models/label/label.types';
|
||||
|
||||
interface LabelsTooltipBadgeProps {
|
||||
labels: LabelKeyValue[];
|
||||
onClick: (label: LabelKeyValue) => void;
|
||||
}
|
||||
|
||||
const LabelsTooltipBadge: FC<LabelsTooltipBadgeProps> = ({ labels, onClick }) =>
|
||||
labels.length ? (
|
||||
<TooltipBadge
|
||||
borderType="secondary"
|
||||
icon="tag-alt"
|
||||
addPadding
|
||||
text={labels?.length}
|
||||
tooltipContent={
|
||||
<VerticalGroup spacing="sm">
|
||||
{labels.map((label) => (
|
||||
<HorizontalGroup spacing="sm" key={label.key.id}>
|
||||
<LabelTag label={label.key.name} value={label.value.name} key={label.key.id} />
|
||||
<Button
|
||||
size="sm"
|
||||
icon="filter"
|
||||
tooltip="Apply filter"
|
||||
variant="secondary"
|
||||
onClick={() => onClick(label)}
|
||||
/>
|
||||
</HorizontalGroup>
|
||||
))}
|
||||
</VerticalGroup>
|
||||
}
|
||||
/>
|
||||
) : null;
|
||||
|
||||
export default LabelsTooltipBadge;
|
||||
|
|
@ -21,8 +21,7 @@ import { useHistory } from 'react-router-dom';
|
|||
|
||||
import Collapse from 'components/Collapse/Collapse';
|
||||
import Block from 'components/GBlock/Block';
|
||||
import GForm from 'components/GForm/GForm';
|
||||
import { FormItem } from 'components/GForm/GForm.types';
|
||||
import GForm, { CustomFieldSectionRendererProps } from 'components/GForm/GForm';
|
||||
import IntegrationLogo from 'components/IntegrationLogo/IntegrationLogo';
|
||||
import Text from 'components/Text/Text';
|
||||
import Labels from 'containers/Labels/Labels';
|
||||
|
|
@ -262,14 +261,6 @@ const IntegrationForm = observer((props: IntegrationFormProps) => {
|
|||
}
|
||||
});
|
||||
|
||||
export interface CustomFieldSectionRendererProps {
|
||||
control: any;
|
||||
formItem: FormItem;
|
||||
errors: any;
|
||||
register: any;
|
||||
setValue: (fieldName: string, fieldValue: any) => void;
|
||||
}
|
||||
|
||||
interface CustomFieldSectionRendererState {
|
||||
isExistingContactPoint: boolean;
|
||||
selectedAlertManagerOption: string;
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import React, { forwardRef, useCallback, useImperativeHandle, useState } from 'react';
|
||||
|
||||
import ServiceLabels from '@grafana/labels';
|
||||
import ServiceLabels, { ServiceLabelsProps } from '@grafana/labels';
|
||||
import { Field } from '@grafana/ui';
|
||||
import cn from 'classnames/bind';
|
||||
import { isEmpty } from 'lodash-es';
|
||||
|
|
@ -14,14 +14,15 @@ import styles from './Labels.module.css';
|
|||
|
||||
const cx = cn.bind(styles);
|
||||
|
||||
interface LabelsProps {
|
||||
export interface LabelsProps {
|
||||
value: LabelKeyValue[];
|
||||
errors: any;
|
||||
onDataUpdate?: ServiceLabelsProps['onDataUpdate'];
|
||||
}
|
||||
|
||||
const Labels = observer(
|
||||
forwardRef(function Labels2(props: LabelsProps, ref) {
|
||||
const { value: defaultValue, errors: propsErrors } = props;
|
||||
const { value: defaultValue, errors: propsErrors, onDataUpdate } = props;
|
||||
|
||||
// propsErrors are 'external' caused by attaching/detaching labels to oncall entities,
|
||||
// state errors are errors caused by CRUD operations on labels storage
|
||||
|
|
@ -30,6 +31,13 @@ const Labels = observer(
|
|||
|
||||
const { labelsStore } = useStore();
|
||||
|
||||
const onChange = (value: LabelKeyValue[]) => {
|
||||
if (onDataUpdate) {
|
||||
onDataUpdate(value);
|
||||
}
|
||||
setValue(value);
|
||||
};
|
||||
|
||||
useImperativeHandle(
|
||||
ref,
|
||||
() => {
|
||||
|
|
@ -113,7 +121,7 @@ const Labels = observer(
|
|||
onRowItemRemoval={(_pair, _index) => {}}
|
||||
onUpdateError={onUpdateError}
|
||||
errors={isValid() ? {} : { ...propsErrors }}
|
||||
onDataUpdate={setValue}
|
||||
onDataUpdate={onChange}
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -8,6 +8,8 @@ import { OutgoingWebhookPreset } from 'models/outgoing_webhook/outgoing_webhook.
|
|||
import { KeyValuePair } from 'utils';
|
||||
import { generateAssignToTeamInputDescription } from 'utils/consts';
|
||||
|
||||
import { WebhookFormFieldName } from './OutgoingWebhookForm.types';
|
||||
|
||||
export const WebhookTriggerType = {
|
||||
EscalationStep: new KeyValuePair('0', 'Escalation Step'),
|
||||
AlertGroupCreated: new KeyValuePair('1', 'Alert Group Created'),
|
||||
|
|
@ -19,23 +21,29 @@ export const WebhookTriggerType = {
|
|||
Unacknowledged: new KeyValuePair('7', 'Unacknowledged'),
|
||||
};
|
||||
|
||||
export function createForm(presets: OutgoingWebhookPreset[]): { name: string; fields: FormItem[] } {
|
||||
export function createForm(
|
||||
presets: OutgoingWebhookPreset[],
|
||||
hasLabelsFeature?: boolean
|
||||
): {
|
||||
name: string;
|
||||
fields: FormItem[];
|
||||
} {
|
||||
return {
|
||||
name: 'OutgoingWebhook',
|
||||
fields: [
|
||||
{
|
||||
name: 'name',
|
||||
name: WebhookFormFieldName.Name,
|
||||
type: FormItemType.Input,
|
||||
validation: { required: true },
|
||||
},
|
||||
{
|
||||
name: 'is_webhook_enabled',
|
||||
name: WebhookFormFieldName.IsWebhookEnabled,
|
||||
label: 'Enabled',
|
||||
normalize: (value) => Boolean(value),
|
||||
type: FormItemType.Switch,
|
||||
},
|
||||
{
|
||||
name: 'team',
|
||||
name: WebhookFormFieldName.Team,
|
||||
label: 'Assign to Team',
|
||||
description: `${generateAssignToTeamInputDescription(
|
||||
'Outgoing Webhooks'
|
||||
|
|
@ -51,7 +59,7 @@ export function createForm(presets: OutgoingWebhookPreset[]): { name: string; fi
|
|||
},
|
||||
},
|
||||
{
|
||||
name: 'trigger_type',
|
||||
name: WebhookFormFieldName.TriggerType,
|
||||
label: 'Trigger Type',
|
||||
description: 'The type of event which will cause this webhook to execute.',
|
||||
type: FormItemType.Select,
|
||||
|
|
@ -92,13 +100,11 @@ export function createForm(presets: OutgoingWebhookPreset[]): { name: string; fi
|
|||
},
|
||||
],
|
||||
},
|
||||
isVisible: (data) => {
|
||||
return isPresetFieldVisible(data.preset, presets, 'trigger_type');
|
||||
},
|
||||
isVisible: (data) => isPresetFieldVisible(data.preset, presets, WebhookFormFieldName.TriggerType),
|
||||
normalize: (value) => value,
|
||||
},
|
||||
{
|
||||
name: 'http_method',
|
||||
name: WebhookFormFieldName.HttpMethod,
|
||||
label: 'HTTP Method',
|
||||
type: FormItemType.Select,
|
||||
extra: {
|
||||
|
|
@ -126,19 +132,16 @@ export function createForm(presets: OutgoingWebhookPreset[]): { name: string; fi
|
|||
},
|
||||
],
|
||||
},
|
||||
isVisible: (data) => isPresetFieldVisible(data.preset, presets, 'http_method'),
|
||||
isVisible: (data) => isPresetFieldVisible(data.preset, presets, WebhookFormFieldName.HttpMethod),
|
||||
normalize: (value) => value,
|
||||
},
|
||||
{
|
||||
name: 'integration_filter',
|
||||
name: WebhookFormFieldName.IntegrationFilter,
|
||||
label: 'Integrations',
|
||||
type: FormItemType.MultiSelect,
|
||||
isVisible: (data) => {
|
||||
return (
|
||||
isPresetFieldVisible(data.preset, presets, 'integration_filter') &&
|
||||
data.trigger_type !== WebhookTriggerType.EscalationStep.key
|
||||
);
|
||||
},
|
||||
isVisible: (data) =>
|
||||
isPresetFieldVisible(data.preset, presets, WebhookFormFieldName.IntegrationFilter) &&
|
||||
data.trigger_type !== WebhookTriggerType.EscalationStep.key,
|
||||
extra: {
|
||||
placeholder: 'Choose (Optional)',
|
||||
modelName: 'alertReceiveChannelStore',
|
||||
|
|
@ -151,88 +154,79 @@ export function createForm(presets: OutgoingWebhookPreset[]): { name: string; fi
|
|||
'Integrations that this webhook applies to. If this is empty the webhook will execute for all integrations',
|
||||
},
|
||||
{
|
||||
name: 'url',
|
||||
name: WebhookFormFieldName.Labels,
|
||||
label: 'Labels',
|
||||
type: FormItemType.Other,
|
||||
render: true,
|
||||
},
|
||||
{
|
||||
name: WebhookFormFieldName.Url,
|
||||
label: 'Webhook URL',
|
||||
type: FormItemType.Monaco,
|
||||
extra: {
|
||||
height: 30,
|
||||
},
|
||||
isVisible: (data) => {
|
||||
return isPresetFieldVisible(data.preset, presets, 'url');
|
||||
},
|
||||
isVisible: (data) => isPresetFieldVisible(data.preset, presets, WebhookFormFieldName.Url),
|
||||
},
|
||||
{
|
||||
name: 'headers',
|
||||
name: WebhookFormFieldName.Headers,
|
||||
label: 'Webhook Headers',
|
||||
description: 'Request headers should be in JSON format.',
|
||||
type: FormItemType.Monaco,
|
||||
extra: {
|
||||
rows: 3,
|
||||
},
|
||||
isVisible: (data) => {
|
||||
return isPresetFieldVisible(data.preset, presets, 'headers');
|
||||
},
|
||||
isVisible: (data) => isPresetFieldVisible(data.preset, presets, WebhookFormFieldName.Headers),
|
||||
},
|
||||
{
|
||||
name: 'username',
|
||||
name: WebhookFormFieldName.Username,
|
||||
type: FormItemType.Input,
|
||||
isVisible: (data) => {
|
||||
return isPresetFieldVisible(data.preset, presets, 'username');
|
||||
},
|
||||
isVisible: (data) => isPresetFieldVisible(data.preset, presets, WebhookFormFieldName.Username),
|
||||
},
|
||||
{
|
||||
name: 'password',
|
||||
name: WebhookFormFieldName.Password,
|
||||
type: FormItemType.Password,
|
||||
isVisible: (data) => {
|
||||
return isPresetFieldVisible(data.preset, presets, 'password');
|
||||
},
|
||||
isVisible: (data) => isPresetFieldVisible(data.preset, presets, WebhookFormFieldName.Password),
|
||||
},
|
||||
{
|
||||
name: 'authorization_header',
|
||||
name: WebhookFormFieldName.AuthorizationHeader,
|
||||
description:
|
||||
'Value of the Authorization header, do not need to prefix with "Authorization:". For example: Bearer AbCdEf123456',
|
||||
type: FormItemType.Password,
|
||||
isVisible: (data) => {
|
||||
return isPresetFieldVisible(data.preset, presets, 'authorization_header');
|
||||
},
|
||||
isVisible: (data) => isPresetFieldVisible(data.preset, presets, WebhookFormFieldName.AuthorizationHeader),
|
||||
},
|
||||
{
|
||||
name: 'trigger_template',
|
||||
name: WebhookFormFieldName.TriggerTemplate,
|
||||
type: FormItemType.Monaco,
|
||||
description:
|
||||
'Trigger template is used to conditionally execute the webhook based on incoming data. The trigger template must be empty or evaluate to true or 1 for the webhook to be sent',
|
||||
extra: {
|
||||
rows: 2,
|
||||
},
|
||||
isVisible: (data) => {
|
||||
return isPresetFieldVisible(data.preset, presets, 'trigger_template');
|
||||
},
|
||||
isVisible: (data) => isPresetFieldVisible(data.preset, presets, WebhookFormFieldName.TriggerTemplate),
|
||||
},
|
||||
{
|
||||
name: 'forward_all',
|
||||
name: WebhookFormFieldName.ForwardAll,
|
||||
normalize: (value) => (value ? Boolean(value) : value),
|
||||
type: FormItemType.Switch,
|
||||
description: "Forwards whole payload of the alert group and context data to the webhook's url as POST/PUT data",
|
||||
isVisible: (data) => {
|
||||
return isPresetFieldVisible(data.preset, presets, 'forward_all');
|
||||
},
|
||||
isVisible: (data) => isPresetFieldVisible(data.preset, presets, WebhookFormFieldName.ForwardAll),
|
||||
},
|
||||
{
|
||||
name: 'data',
|
||||
name: WebhookFormFieldName.Data,
|
||||
getDisabled: (data) => Boolean(data?.forward_all),
|
||||
type: FormItemType.Monaco,
|
||||
description:
|
||||
'Available variables: {{ event }}, {{ user }}, {{ alert_group }}, {{ alert_group_id }}, {{ alert_payload }}, {{ integration }}, {{ notified_users }}, {{ users_to_be_notified }}, {{ responses }}',
|
||||
description: `Available variables: {{ event }}, {{ user }}, {{ alert_group }}, {{ alert_group_id }}, {{ alert_payload }}, {{ integration }}, {{ notified_users }}, {{ users_to_be_notified }}, {{ responses }}${
|
||||
hasLabelsFeature ? ' {{ webhook }}' : ''
|
||||
}`,
|
||||
extra: {},
|
||||
isVisible: (data) => {
|
||||
return isPresetFieldVisible(data.preset, presets, 'data');
|
||||
},
|
||||
isVisible: (data) => isPresetFieldVisible(data.preset, presets, WebhookFormFieldName.Data),
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
function isPresetFieldVisible(presetId: string, presets: OutgoingWebhookPreset[], fieldName: string) {
|
||||
function isPresetFieldVisible(presetId: string, presets: OutgoingWebhookPreset[], fieldName: WebhookFormFieldName) {
|
||||
if (presetId == null) {
|
||||
return true;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -17,23 +17,28 @@ import { observer } from 'mobx-react';
|
|||
import { useHistory } from 'react-router-dom';
|
||||
|
||||
import Block from 'components/GBlock/Block';
|
||||
import GForm from 'components/GForm/GForm';
|
||||
import GForm, { CustomFieldSectionRendererProps } from 'components/GForm/GForm';
|
||||
import { FormItem, FormItemType } from 'components/GForm/GForm.types';
|
||||
import IntegrationLogo from 'components/IntegrationLogo/IntegrationLogo';
|
||||
import { logoCoors } from 'components/IntegrationLogo/IntegrationLogo.config';
|
||||
import RenderConditionally from 'components/RenderConditionally/RenderConditionally';
|
||||
import Text from 'components/Text/Text';
|
||||
import Labels, { LabelsProps } from 'containers/Labels/Labels';
|
||||
import { webhookPresetIcons } from 'containers/OutgoingWebhookForm/WebhookPresetIcons.config';
|
||||
import OutgoingWebhookStatus from 'containers/OutgoingWebhookStatus/OutgoingWebhookStatus';
|
||||
import WebhooksTemplateEditor from 'containers/WebhooksTemplateEditor/WebhooksTemplateEditor';
|
||||
import { WithPermissionControlTooltip } from 'containers/WithPermissionControl/WithPermissionControlTooltip';
|
||||
import { LabelKeyValue } from 'models/label/label.types';
|
||||
import { OutgoingWebhook, OutgoingWebhookPreset } from 'models/outgoing_webhook/outgoing_webhook.types';
|
||||
import { WebhookFormActionType } from 'pages/outgoing_webhooks/OutgoingWebhooks.types';
|
||||
import { AppFeature } from 'state/features';
|
||||
import { useStore } from 'state/useStore';
|
||||
import { KeyValuePair } from 'utils';
|
||||
import { UserActions } from 'utils/authorization';
|
||||
import { PLUGIN_ROOT } from 'utils/consts';
|
||||
|
||||
import { createForm } from './OutgoingWebhookForm.config';
|
||||
import { WebhookFormFieldName } from './OutgoingWebhookForm.types';
|
||||
|
||||
import styles from 'containers/OutgoingWebhookForm/OutgoingWebhookForm.module.css';
|
||||
|
||||
|
|
@ -52,6 +57,23 @@ export const WebhookTabs = {
|
|||
LastRun: new KeyValuePair('LastRun', 'Last Run'),
|
||||
};
|
||||
|
||||
const CustomFieldSectionRenderer: React.FC<CustomFieldSectionRendererProps> = observer(
|
||||
({ errors, setValue, getValues }) => {
|
||||
const { hasFeature } = useStore();
|
||||
const onDataUpdate: LabelsProps['onDataUpdate'] = (val) => setValue(WebhookFormFieldName.Labels, val);
|
||||
|
||||
return (
|
||||
<RenderConditionally shouldRender={hasFeature(AppFeature.Labels)}>
|
||||
<Labels
|
||||
value={getValues<LabelKeyValue[]>(WebhookFormFieldName.Labels) || []}
|
||||
errors={errors?.[WebhookFormFieldName.Labels]}
|
||||
onDataUpdate={onDataUpdate}
|
||||
/>
|
||||
</RenderConditionally>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
const OutgoingWebhookForm = observer((props: OutgoingWebhookFormProps) => {
|
||||
const history = useHistory();
|
||||
const { id, action, onUpdate, onHide, onDelete } = props;
|
||||
|
|
@ -65,10 +87,10 @@ const OutgoingWebhookForm = observer((props: OutgoingWebhookFormProps) => {
|
|||
const [selectedPreset, setSelectedPreset] = useState<OutgoingWebhookPreset>(undefined);
|
||||
const [filterValue, setFilterValue] = useState('');
|
||||
|
||||
const { outgoingWebhookStore } = useStore();
|
||||
const { outgoingWebhookStore, hasFeature } = useStore();
|
||||
const isNew = action === WebhookFormActionType.NEW;
|
||||
const isNewOrCopy = isNew || action === WebhookFormActionType.COPY;
|
||||
const form = createForm(outgoingWebhookStore.outgoingWebhookPresets);
|
||||
const form = createForm(outgoingWebhookStore.outgoingWebhookPresets, hasFeature(AppFeature.Labels));
|
||||
|
||||
const handleSubmit = useCallback(
|
||||
(data: Partial<OutgoingWebhook>) => {
|
||||
|
|
@ -149,7 +171,15 @@ const OutgoingWebhookForm = observer((props: OutgoingWebhookFormProps) => {
|
|||
return null;
|
||||
}
|
||||
|
||||
const formElement = <GForm form={form} data={data} onSubmit={handleSubmit} onFieldRender={enrchField} />;
|
||||
const formElement = (
|
||||
<GForm
|
||||
form={form}
|
||||
data={data}
|
||||
onSubmit={handleSubmit}
|
||||
onFieldRender={enrchField}
|
||||
customFieldSectionRenderer={CustomFieldSectionRenderer}
|
||||
/>
|
||||
);
|
||||
const createWebhookParameters = (
|
||||
<>
|
||||
<Drawer scrollableContent title={'New Outgoing Webhook'} onClose={onHide} closeOnMaskClick={false}>
|
||||
|
|
@ -279,7 +309,13 @@ const OutgoingWebhookForm = observer((props: OutgoingWebhookFormProps) => {
|
|||
return (
|
||||
<>
|
||||
<div className={cx('content')}>
|
||||
<GForm form={form} data={data} onSubmit={handleSubmit} onFieldRender={enrchField} />
|
||||
<GForm
|
||||
form={form}
|
||||
data={data}
|
||||
onSubmit={handleSubmit}
|
||||
onFieldRender={enrchField}
|
||||
customFieldSectionRenderer={CustomFieldSectionRenderer}
|
||||
/>
|
||||
<div className={cx('buttons')}>
|
||||
<HorizontalGroup justify={'flex-end'}>
|
||||
{id === 'new' ? (
|
||||
|
|
@ -339,8 +375,8 @@ const WebhookTabsContent: React.FC<WebhookTabsProps> = ({
|
|||
formElement,
|
||||
}) => {
|
||||
const [confirmationModal, setConfirmationModal] = useState<ConfirmModalProps>(undefined);
|
||||
const { outgoingWebhookStore } = useStore();
|
||||
const form = createForm(outgoingWebhookStore.outgoingWebhookPresets);
|
||||
const { outgoingWebhookStore, hasFeature } = useStore();
|
||||
const form = createForm(outgoingWebhookStore.outgoingWebhookPresets, hasFeature(AppFeature.Labels));
|
||||
return (
|
||||
<div className={cx('tabs__content')}>
|
||||
{confirmationModal && (
|
||||
|
|
|
|||
|
|
@ -0,0 +1,18 @@
|
|||
export const WebhookFormFieldName = {
|
||||
Name: 'name',
|
||||
IsWebhookEnabled: 'is_webhook_enabled',
|
||||
Team: 'team',
|
||||
TriggerType: 'trigger_type',
|
||||
HttpMethod: 'http_method',
|
||||
IntegrationFilter: 'integration_filter',
|
||||
Labels: 'labels',
|
||||
Url: 'url',
|
||||
Headers: 'headers',
|
||||
Username: 'username',
|
||||
Password: 'password',
|
||||
AuthorizationHeader: 'authorization_header',
|
||||
TriggerTemplate: 'trigger_template',
|
||||
ForwardAll: 'forward_all',
|
||||
Data: 'data',
|
||||
} as const;
|
||||
export type WebhookFormFieldName = (typeof WebhookFormFieldName)[keyof typeof WebhookFormFieldName];
|
||||
|
|
@ -15,10 +15,11 @@ export function parseFilters(
|
|||
filterOptions: FilterOption[],
|
||||
query: { [key: string]: any }
|
||||
) {
|
||||
const filters = filterOptions.filter((filterOption: FilterOption) => filterOption.name in data);
|
||||
const dataWithPredefinedTeams = { ...data, team: data.team || [] };
|
||||
const filters = filterOptions.filter((filterOption: FilterOption) => filterOption.name in dataWithPredefinedTeams);
|
||||
|
||||
const values = filters.reduce((memo: any, filterOption: FilterOption) => {
|
||||
const rawValue = query[filterOption.name] || data[filterOption.name]; // query takes priority over local storage
|
||||
const rawValue = query[filterOption.name] || dataWithPredefinedTeams[filterOption.name]; // query takes priority over local storage
|
||||
|
||||
let value: any = rawValue;
|
||||
|
||||
|
|
|
|||
|
|
@ -103,7 +103,7 @@ class RemoteFilters extends Component<RemoteFiltersProps, RemoteFiltersState> {
|
|||
let { filters, values } = parseFilters({ ...query, ...filtersStore.globalValues }, filterOptions, query);
|
||||
|
||||
if (isEmpty(values)) {
|
||||
({ filters, values } = parseFilters(defaultFilters || { team: [] }, filterOptions, query));
|
||||
({ filters, values } = parseFilters(defaultFilters, filterOptions, query));
|
||||
}
|
||||
|
||||
this.setState({ filterOptions, filters, values }, () => this.onChange(true));
|
||||
|
|
|
|||
|
|
@ -1,8 +1,10 @@
|
|||
import { action, observable } from 'mobx';
|
||||
|
||||
import BaseStore from 'models/base_store';
|
||||
import { LabelKeyValue } from 'models/label/label.types';
|
||||
import { makeRequest } from 'network';
|
||||
import { RootStore } from 'state';
|
||||
import LocationHelper from 'utils/LocationHelper';
|
||||
import { PAGE } from 'utils/consts';
|
||||
import { getItem, setItem } from 'utils/localStorage';
|
||||
|
||||
|
|
@ -79,4 +81,21 @@ export class FiltersStore extends BaseStore {
|
|||
setCurrentTablePageNum(page: PAGE, currentTablePageNum: number) {
|
||||
this.currentTablePageNum[page] = currentTablePageNum;
|
||||
}
|
||||
|
||||
@action
|
||||
applyLabelFilter = (label: LabelKeyValue, page: PAGE) => {
|
||||
const currentLabelFilterValues = this.values[page]?.label || [];
|
||||
const labelToAddString = `${label.key.id}:${label.value.id}`;
|
||||
const newLabelFilter = [...currentLabelFilterValues, labelToAddString];
|
||||
|
||||
if (currentLabelFilterValues?.some((label) => label === labelToAddString)) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.updateValuesForPage(page, {
|
||||
label: newLabelFilter,
|
||||
});
|
||||
LocationHelper.update({ label: newLabelFilter }, 'partial');
|
||||
this.setNeedToParseFilters(true);
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { GrafanaTeam } from 'models/grafana_team/grafana_team.types';
|
||||
import { LabelKeyValue } from 'models/label/label.types';
|
||||
|
||||
export interface OutgoingWebhook {
|
||||
authorization_header: string;
|
||||
|
|
@ -19,6 +20,7 @@ export interface OutgoingWebhook {
|
|||
is_webhook_enabled: boolean;
|
||||
is_legacy: boolean;
|
||||
preset: string;
|
||||
labels: LabelKeyValue[];
|
||||
}
|
||||
|
||||
export interface OutgoingWebhookResponse {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,5 @@
|
|||
import React, { SyntheticEvent } from 'react';
|
||||
|
||||
import { LabelTag } from '@grafana/labels';
|
||||
import { Button, HorizontalGroup, Icon, VerticalGroup } from '@grafana/ui';
|
||||
import cn from 'classnames/bind';
|
||||
import { observer } from 'mobx-react';
|
||||
|
|
@ -12,11 +11,11 @@ import CardButton from 'components/CardButton/CardButton';
|
|||
import CursorPagination from 'components/CursorPagination/CursorPagination';
|
||||
import GTable from 'components/GTable/GTable';
|
||||
import IntegrationLogo from 'components/IntegrationLogo/IntegrationLogo';
|
||||
import LabelsTooltipBadge from 'components/LabelsTooltipBadge/LabelsTooltipBadge';
|
||||
import ManualAlertGroup from 'components/ManualAlertGroup/ManualAlertGroup';
|
||||
import PluginLink from 'components/PluginLink/PluginLink';
|
||||
import Text from 'components/Text/Text';
|
||||
import TextEllipsisTooltip from 'components/TextEllipsisTooltip/TextEllipsisTooltip';
|
||||
import TooltipBadge from 'components/TooltipBadge/TooltipBadge';
|
||||
import Tutorial from 'components/Tutorial/Tutorial';
|
||||
import { TutorialStep } from 'components/Tutorial/Tutorial.types';
|
||||
import { IncidentsFiltersType } from 'containers/IncidentsFilters/IncidentFilters.types';
|
||||
|
|
@ -24,7 +23,6 @@ import RemoteFilters from 'containers/RemoteFilters/RemoteFilters';
|
|||
import TeamName from 'containers/TeamName/TeamName';
|
||||
import { WithPermissionControlTooltip } from 'containers/WithPermissionControl/WithPermissionControlTooltip';
|
||||
import { Alert, Alert as AlertType, AlertAction, IncidentStatus } from 'models/alertgroup/alertgroup.types';
|
||||
import { LabelKeyValue } from 'models/label/label.types';
|
||||
import { renderRelatedUsers } from 'pages/incident/Incident.helpers';
|
||||
import { AppFeature } from 'state/features';
|
||||
import { PageProps, WithStoreProps } from 'state/types';
|
||||
|
|
@ -587,37 +585,6 @@ class Incidents extends React.Component<IncidentsPageProps, IncidentsPageState>
|
|||
);
|
||||
}
|
||||
|
||||
renderLabels(item: AlertType) {
|
||||
if (!item.labels.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<TooltipBadge
|
||||
borderType="secondary"
|
||||
icon="tag-alt"
|
||||
addPadding
|
||||
text={item.labels?.length}
|
||||
tooltipContent={
|
||||
<VerticalGroup spacing="sm">
|
||||
{item.labels.map((label) => (
|
||||
<HorizontalGroup spacing="sm" key={label.key.id}>
|
||||
<LabelTag label={label.key.name} value={label.value.name} key={label.key.id} />
|
||||
<Button
|
||||
size="sm"
|
||||
icon="filter"
|
||||
tooltip="Apply filter"
|
||||
variant="secondary"
|
||||
onClick={this.getApplyLabelFilterClickHandler(label)}
|
||||
/>
|
||||
</HorizontalGroup>
|
||||
))}
|
||||
</VerticalGroup>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
renderTeam(record: AlertType, teams: any) {
|
||||
return (
|
||||
<TextEllipsisTooltip placement="top" content={teams[record.team]?.name}>
|
||||
|
|
@ -626,29 +593,6 @@ class Incidents extends React.Component<IncidentsPageProps, IncidentsPageState>
|
|||
);
|
||||
}
|
||||
|
||||
getApplyLabelFilterClickHandler = (label: LabelKeyValue) => {
|
||||
const {
|
||||
store: { filtersStore },
|
||||
} = this.props;
|
||||
|
||||
return () => {
|
||||
const {
|
||||
filters: { label: oldLabelFilter = [] },
|
||||
} = this.state;
|
||||
|
||||
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.setNeedToParseFilters(true);
|
||||
};
|
||||
};
|
||||
|
||||
shouldShowPagination() {
|
||||
const { alertGroupStore } = this.props.store;
|
||||
|
||||
|
|
@ -666,7 +610,9 @@ class Incidents extends React.Component<IncidentsPageProps, IncidentsPageState>
|
|||
};
|
||||
|
||||
getTableColumns(): Array<{ width: string; title: string; key: string; render }> {
|
||||
const { store } = this.props;
|
||||
const {
|
||||
store: { filtersStore, grafanaTeamStore, hasFeature },
|
||||
} = this.props;
|
||||
|
||||
const columns = [
|
||||
{
|
||||
|
|
@ -709,7 +655,7 @@ class Incidents extends React.Component<IncidentsPageProps, IncidentsPageState>
|
|||
width: '10%',
|
||||
title: 'Team',
|
||||
key: 'team',
|
||||
render: (item: AlertType) => this.renderTeam(item, store.grafanaTeamStore.items),
|
||||
render: (item: AlertType) => this.renderTeam(item, grafanaTeamStore.items),
|
||||
},
|
||||
{
|
||||
width: '15%',
|
||||
|
|
@ -719,12 +665,17 @@ class Incidents extends React.Component<IncidentsPageProps, IncidentsPageState>
|
|||
},
|
||||
];
|
||||
|
||||
if (store.hasFeature(AppFeature.Labels)) {
|
||||
if (hasFeature(AppFeature.Labels)) {
|
||||
columns.splice(-2, 0, {
|
||||
width: '5%',
|
||||
title: 'Labels',
|
||||
key: 'labels',
|
||||
render: (item: AlertType) => this.renderLabels(item),
|
||||
render: ({ labels }: AlertType) => (
|
||||
<LabelsTooltipBadge
|
||||
labels={labels}
|
||||
onClick={(label) => filtersStore.applyLabelFilter(label, PAGE.Incidents)}
|
||||
/>
|
||||
),
|
||||
});
|
||||
columns.find((column) => column.key === 'title').width = '30%';
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,5 @@
|
|||
import React from 'react';
|
||||
|
||||
import { LabelTag } from '@grafana/labels';
|
||||
import {
|
||||
HorizontalGroup,
|
||||
Button,
|
||||
|
|
@ -23,6 +22,7 @@ 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 LabelsTooltipBadge from 'components/LabelsTooltipBadge/LabelsTooltipBadge';
|
||||
import { PageBaseState } from 'components/PageErrorHandlingWrapper/PageErrorHandlingWrapper';
|
||||
import {
|
||||
getWrongTeamResponseInfo,
|
||||
|
|
@ -46,7 +46,6 @@ import {
|
|||
MaintenanceMode,
|
||||
SupportedIntegrationFilters,
|
||||
} 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';
|
||||
|
|
@ -275,7 +274,7 @@ class Integrations extends React.Component<IntegrationsProps, IntegrationsState>
|
|||
data-testid="integrations-table"
|
||||
rowKey="id"
|
||||
data={results}
|
||||
columns={this.getTableColumns(store.hasFeature.bind(store))}
|
||||
columns={this.getTableColumns(store.hasFeature)}
|
||||
className={cx('integrations-table')}
|
||||
rowClassName={cx('integrations-table-row')}
|
||||
pagination={{
|
||||
|
|
@ -472,37 +471,6 @@ class Integrations extends React.Component<IntegrationsProps, IntegrationsState>
|
|||
return null;
|
||||
}
|
||||
|
||||
renderLabels(item: AlertReceiveChannel) {
|
||||
if (!item.labels.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<TooltipBadge
|
||||
borderType="secondary"
|
||||
icon="tag-alt"
|
||||
addPadding
|
||||
text={item.labels?.length}
|
||||
tooltipContent={
|
||||
<VerticalGroup spacing="sm">
|
||||
{item.labels.map((label) => (
|
||||
<HorizontalGroup spacing="sm" key={label.key.id}>
|
||||
<LabelTag label={label.key.name} value={label.value.name} key={label.key.id} />
|
||||
<Button
|
||||
size="sm"
|
||||
icon="filter"
|
||||
tooltip="Apply filter"
|
||||
variant="secondary"
|
||||
onClick={this.getApplyLabelFilterClickHandler(label)}
|
||||
/>
|
||||
</HorizontalGroup>
|
||||
))}
|
||||
</VerticalGroup>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
renderTeam(item: AlertReceiveChannel, teams: any) {
|
||||
return (
|
||||
<TextEllipsisTooltip placement="top" content={teams[item.team]?.name}>
|
||||
|
|
@ -583,7 +551,11 @@ class Integrations extends React.Component<IntegrationsProps, IntegrationsState>
|
|||
};
|
||||
|
||||
getTableColumns = (hasFeatureFn) => {
|
||||
const { grafanaTeamStore, alertReceiveChannelStore } = this.props.store;
|
||||
const {
|
||||
grafanaTeamStore,
|
||||
alertReceiveChannelStore,
|
||||
filtersStore: { applyLabelFilter },
|
||||
} = this.props.store;
|
||||
const isConnectionsTab = this.state.activeTab === TabType.Connections;
|
||||
|
||||
const columns = [
|
||||
|
|
@ -639,7 +611,9 @@ class Integrations extends React.Component<IntegrationsProps, IntegrationsState>
|
|||
columns.splice(-2, 0, {
|
||||
width: '10%',
|
||||
title: 'Labels',
|
||||
render: (item: AlertReceiveChannel) => this.renderLabels(item),
|
||||
render: ({ labels }: AlertReceiveChannel) => (
|
||||
<LabelsTooltipBadge labels={labels} onClick={(label) => applyLabelFilter(label, PAGE.Integrations)} />
|
||||
),
|
||||
});
|
||||
columns.find((column) => column.key === 'datasource').width = '15%';
|
||||
}
|
||||
|
|
@ -683,29 +657,6 @@ class Integrations extends React.Component<IntegrationsProps, IntegrationsState>
|
|||
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.setNeedToParseFilters(true);
|
||||
};
|
||||
};
|
||||
|
||||
applyFilters = async (isOnMount: boolean) => {
|
||||
const { store } = this.props;
|
||||
const { alertReceiveChannelStore } = store;
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ import { RouteComponentProps, withRouter } from 'react-router-dom';
|
|||
|
||||
import GTable from 'components/GTable/GTable';
|
||||
import HamburgerMenu from 'components/HamburgerMenu/HamburgerMenu';
|
||||
import LabelsTooltipBadge from 'components/LabelsTooltipBadge/LabelsTooltipBadge';
|
||||
import PageErrorHandlingWrapper, { PageBaseState } from 'components/PageErrorHandlingWrapper/PageErrorHandlingWrapper';
|
||||
import {
|
||||
getWrongTeamResponseInfo,
|
||||
|
|
@ -33,6 +34,7 @@ import TeamName from 'containers/TeamName/TeamName';
|
|||
import { WithPermissionControlTooltip } from 'containers/WithPermissionControl/WithPermissionControlTooltip';
|
||||
import { FiltersValues } from 'models/filters/filters.types';
|
||||
import { OutgoingWebhook } from 'models/outgoing_webhook/outgoing_webhook.types';
|
||||
import { AppFeature } from 'state/features';
|
||||
import { PageProps, WithStoreProps } from 'state/types';
|
||||
import { withMobXProviderContext } from 'state/withStore';
|
||||
import { openErrorNotification, openNotification } from 'utils';
|
||||
|
|
@ -98,13 +100,15 @@ class OutgoingWebhooks extends React.Component<OutgoingWebhooksProps, OutgoingWe
|
|||
};
|
||||
|
||||
update = () => {
|
||||
const { store } = this.props;
|
||||
return store.outgoingWebhookStore.updateItems();
|
||||
const {
|
||||
store: { outgoingWebhookStore },
|
||||
} = this.props;
|
||||
return outgoingWebhookStore.updateItems();
|
||||
};
|
||||
|
||||
render() {
|
||||
const {
|
||||
store,
|
||||
store: { outgoingWebhookStore, filtersStore, grafanaTeamStore, hasFeature },
|
||||
history,
|
||||
match: {
|
||||
params: { id },
|
||||
|
|
@ -112,7 +116,7 @@ class OutgoingWebhooks extends React.Component<OutgoingWebhooksProps, OutgoingWe
|
|||
} = this.props;
|
||||
const { outgoingWebhookId, outgoingWebhookAction, errorData, confirmationModal } = this.state;
|
||||
|
||||
const webhooks = store.outgoingWebhookStore.getSearchResult();
|
||||
const webhooks = outgoingWebhookStore.getSearchResult();
|
||||
|
||||
const columns = [
|
||||
{
|
||||
|
|
@ -134,13 +138,27 @@ class OutgoingWebhooks extends React.Component<OutgoingWebhooksProps, OutgoingWe
|
|||
},
|
||||
{
|
||||
width: '10%',
|
||||
title: 'Last run',
|
||||
render: this.renderLastRun,
|
||||
title: 'Last event',
|
||||
render: this.renderLastEvent,
|
||||
},
|
||||
...(hasFeature(AppFeature.Labels)
|
||||
? [
|
||||
{
|
||||
width: '10%',
|
||||
title: 'Labels',
|
||||
render: ({ labels }: OutgoingWebhook) => (
|
||||
<LabelsTooltipBadge
|
||||
labels={labels}
|
||||
onClick={(label) => filtersStore.applyLabelFilter(label, PAGE.Webhooks)}
|
||||
/>
|
||||
),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
{
|
||||
width: '15%',
|
||||
title: 'Team',
|
||||
render: (item: OutgoingWebhook) => this.renderTeam(item, store.grafanaTeamStore.items),
|
||||
render: (item: OutgoingWebhook) => this.renderTeam(item, grafanaTeamStore.items),
|
||||
},
|
||||
{
|
||||
width: '20%',
|
||||
|
|
@ -357,17 +375,17 @@ class OutgoingWebhooks extends React.Component<OutgoingWebhooksProps, OutgoingWe
|
|||
);
|
||||
}
|
||||
|
||||
renderLastRun(record: OutgoingWebhook) {
|
||||
const lastRunMoment = moment(record.last_response_log?.timestamp);
|
||||
renderLastEvent(record: OutgoingWebhook) {
|
||||
const lastEventMoment = moment(record.last_response_log?.timestamp);
|
||||
|
||||
return !record.is_webhook_enabled ? (
|
||||
<Text type="secondary">Disabled</Text>
|
||||
) : (
|
||||
<VerticalGroup spacing="none">
|
||||
<Text type="secondary">{lastRunMoment.isValid() ? lastRunMoment.format('MMM DD, YYYY') : '-'}</Text>
|
||||
<Text type="secondary">{lastRunMoment.isValid() ? lastRunMoment.format('HH:mm') : ''}</Text>
|
||||
<Text type="secondary">{lastEventMoment.isValid() ? lastEventMoment.format('MMM DD, YYYY') : '-'}</Text>
|
||||
<Text type="secondary">{lastEventMoment.isValid() ? lastEventMoment.format('HH:mm') : ''}</Text>
|
||||
<Text type="secondary">
|
||||
{lastRunMoment.isValid()
|
||||
{lastEventMoment.isValid()
|
||||
? record.last_response_log?.status_code
|
||||
? 'Status: ' + record.last_response_log?.status_code
|
||||
: 'Check Status'
|
||||
|
|
|
|||
|
|
@ -263,10 +263,8 @@ export class RootBaseStore {
|
|||
});
|
||||
}
|
||||
|
||||
hasFeature(feature: string | AppFeature) {
|
||||
// todo use AppFeature only
|
||||
return this.features?.[feature];
|
||||
}
|
||||
// todo use AppFeature only
|
||||
hasFeature = (feature: string | AppFeature) => this.features?.[feature];
|
||||
|
||||
get license() {
|
||||
if (this.backendLicense) {
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue