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:
Innokentii Konstantinov 2023-11-22 19:17:41 +08:00 committed by GitHub
parent 92ed22645c
commit 9628bdc51f
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
38 changed files with 653 additions and 279 deletions

View file

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

View 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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View 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')},
},
),
]

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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}
/>
);
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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%';
}

View file

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

View file

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

View file

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