Merge pull request #3437 from grafana/dev

v1.3.62
This commit is contained in:
Ildar Iskhakov 2023-11-28 10:10:48 +08:00 committed by GitHub
commit c68e2a5e73
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
89 changed files with 2189 additions and 498 deletions

View file

@ -7,6 +7,25 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## Unreleased
## v1.3.62 (2023-11-28)
### Added
- Add ability to use Grafana Service Account Tokens for OnCall API (This is only enabled for resolution_notes
endpoint currently) @mderynck ([#3189](https://github.com/grafana/oncall/pull/3189))
- Add ability for webhook presets to mask sensitive headers @mderynck
([#3189](https://github.com/grafana/oncall/pull/3189))
### Fixed
- Fixed issue that blocked saving webhooks with presets if the preset is controlling the URL @mderynck
([#3189](https://github.com/grafana/oncall/pull/3189))
- User filter doesn't display current value on Alert Groups page ([1714](https://github.com/grafana/oncall/issues/1714))
- Remove displaying rotation modal for Terraform/API based schedules
- Filters polishing ([3183](https://github.com/grafana/oncall/issues/3183))
- Fixed permissions so User settings reader role included list users @mderynck ([#3419](https://github.com/grafana/oncall/pull/3419))
- Fixed alert group rendering when some links were broken because of replacing `-` to `_` @Ferril ([#3424](https://github.com/grafana/oncall/pull/3424))
## v1.3.62 (2023-11-21)
### Added

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

@ -16,6 +16,10 @@ weight: 500
Inbound Email integration will consume emails from dedicated email address and make alert groups from them.
## Configure required environment variables
See [Inbound Email Setup]({{< relref "../../open-source/_index.md#inbound-email-setup" >}}) for details.
## Configure Inbound Email integration for Grafana OnCall
You must have an Admin role to create integrations in Grafana OnCall.

View file

@ -265,7 +265,9 @@ To configure Inbound Email integration for Grafana OnCall OSS populate env varia
- `INBOUND_EMAIL_DOMAIN` - Inbound email domain
- `INBOUND_EMAIL_WEBHOOK_SECRET` - Inbound email webhook secret
You will also need to configure your ESP to forward messages to the following URL: `<ONCALL_ENGINE_PUBLIC_URL>/integrations/v1/inbound_email_webhook`.
Required secret syntax: `part1ofsecret:part2ofsecret` (The colon `:` is a mandatory delimiter separating both parts of your secret.)
You will also need to configure your ESP to forward messages to the following URL: `scheme://<INBOUND_EMAIL_WEBHOOK_SECRET>@<ONCALL_ENGINE_PUBLIC_URL>/integrations/v1/inbound_email_webhook`.
## Limits

View file

@ -0,0 +1,23 @@
# Generated by Django 4.2.7 on 2023-11-27 17:45
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('alerts', '0039_remove_alertreceivechannel_unique_integration_name'),
]
operations = [
migrations.AddField(
model_name='alertreceivechannel',
name='alert_group_labels_custom',
field=models.JSONField(default=None, null=True),
),
migrations.AddField(
model_name='alertreceivechannel',
name='alert_group_labels_template',
field=models.TextField(default=None, null=True),
),
]

View file

@ -12,7 +12,7 @@ from django.db.models import JSONField
from apps.alerts import tasks
from apps.alerts.constants import TASK_DELAY_SECONDS
from apps.alerts.incident_appearance.templaters import TemplateLoader
from apps.labels.utils import assign_labels
from apps.labels.alert_group_labels import assign_labels
from common.jinja_templater import apply_jinja_template
from common.jinja_templater.apply_jinja_template import JinjaTemplateError, JinjaTemplateWarning
from common.public_primary_keys import generate_public_primary_key, increase_public_primary_key_length
@ -108,7 +108,7 @@ class Alert(models.Model):
)
if group_created:
assign_labels(group, alert_receive_channel)
assign_labels(group, alert_receive_channel, raw_request_data)
group.log_records.create(type=AlertGroupLogRecord.TYPE_REGISTERED)
group.log_records.create(type=AlertGroupLogRecord.TYPE_ROUTE_ASSIGNED)
@ -144,7 +144,9 @@ class Alert(models.Model):
if settings.DEBUG:
tasks.distribute_alert(alert.pk)
else:
tasks.distribute_alert.apply_async((alert.pk,), countdown=TASK_DELAY_SECONDS)
transaction.on_commit(
partial(tasks.distribute_alert.apply_async, (alert.pk,), countdown=TASK_DELAY_SECONDS)
)
if group_created:
# all code below related to maintenance mode

View file

@ -43,6 +43,7 @@ if typing.TYPE_CHECKING:
ResolutionNoteSlackMessage,
)
from apps.base.models import UserNotificationPolicyLogRecord
from apps.labels.models import AlertGroupAssociatedLabel
from apps.slack.models import SlackMessage
logger = logging.getLogger(__name__)
@ -194,6 +195,7 @@ class AlertGroup(AlertGroupSlackRenderingMixin, EscalationSnapshotMixin, models.
slack_log_message: typing.Optional["SlackMessage"]
slack_messages: "RelatedManager['SlackMessage']"
users: "RelatedManager['User']"
labels: "RelatedManager['AlertGroupAssociatedLabel']"
objects: models.Manager["AlertGroup"] = AlertGroupQuerySet.as_manager()

View file

@ -42,6 +42,7 @@ if typing.TYPE_CHECKING:
from django.db.models.manager import RelatedManager
from apps.alerts.models import AlertGroup, ChannelFilter
from apps.labels.models import AlertReceiveChannelAssociatedLabel
from apps.user_management.models import Organization, Team
logger = logging.getLogger(__name__)
@ -87,10 +88,6 @@ def number_to_smiles_translator(number):
return "".join(reversed(smileset))
class IntegrationAlertGroupLabels(typing.TypedDict):
inheritable: typing.Dict[str, bool]
class AlertReceiveChannelQueryset(models.QuerySet):
def delete(self):
self.update(deleted_at=timezone.now())
@ -123,6 +120,7 @@ class AlertReceiveChannel(IntegrationOptionsMixin, MaintainableObject):
channel_filters: "RelatedManager['ChannelFilter']"
organization: "Organization"
team: typing.Optional["Team"]
labels: "RelatedManager['AlertReceiveChannelAssociatedLabel']"
objects = AlertReceiveChannelManager()
objects_with_maintenance = AlertReceiveChannelManagerWithMaintenance()
@ -206,6 +204,17 @@ class AlertReceiveChannel(IntegrationOptionsMixin, MaintainableObject):
rate_limited_in_slack_at = models.DateTimeField(null=True, default=None)
rate_limit_message_task_id = models.CharField(max_length=100, null=True, default=None)
AlertGroupCustomLabels = list[tuple[str, str | None, str | None]] | None
alert_group_labels_custom: AlertGroupCustomLabels = models.JSONField(null=True, default=None)
"""
Stores "custom labels" for alert group labels. Custom labels can be either "plain" or "templated".
For plain labels, the format is: [<LABEL_KEY_ID>, <LABEL_VALUE_ID>, None]
For templated labels, the format is: [<LABEL_KEY_ID>, None, <JINJA2_TEMPLATE>]
"""
alert_group_labels_template: str | None = models.TextField(null=True, default=None)
"""Stores a Jinja2 template for "advanced label templating" for alert group labels."""
def __str__(self):
short_name_with_emojis = emojize(self.short_name, language="alias")
return f"{self.pk}: {short_name_with_emojis}"
@ -635,21 +644,6 @@ class AlertReceiveChannel(IntegrationOptionsMixin, MaintainableObject):
result["team"] = "General"
return result
@property
def alert_group_labels(self) -> IntegrationAlertGroupLabels:
"""
Alert group labels configuration for the integration used by AlertReceiveChannelSerializer.
See AlertReceiveChannelAssociatedLabel.inheritable for more details.
"""
return {"inheritable": {label.key_id: label.inheritable for label in self.labels.all()}}
@alert_group_labels.setter
def alert_group_labels(self, value: IntegrationAlertGroupLabels) -> None:
"""Setter for alert_group_labels used by AlertReceiveChannelSerializer"""
inheritable_key_ids = [key_id for key_id, inheritable in value["inheritable"].items() if inheritable]
self.labels.filter(key_id__in=inheritable_key_ids).update(inheritable=True)
self.labels.filter(~Q(key_id__in=inheritable_key_ids)).update(inheritable=False)
@receiver(post_save, sender=AlertReceiveChannel)
def listen_for_alertreceivechannel_model_save(

View file

@ -7,22 +7,32 @@ from apps.alerts.tasks import distribute_alert, escalate_alert_group
@pytest.mark.django_db
def test_alert_create_default_channel_filter(make_organization, make_alert_receive_channel, make_channel_filter):
@patch("apps.alerts.tasks.distribute_alert.distribute_alert.apply_async", return_value=None)
def test_alert_create_default_channel_filter(
mocked_distribute_alert_task,
make_organization,
make_alert_receive_channel,
make_channel_filter,
django_capture_on_commit_callbacks,
):
organization = make_organization()
alert_receive_channel = make_alert_receive_channel(organization)
channel_filter = make_channel_filter(alert_receive_channel, is_default=True)
alert = Alert.create(
title="the title",
message="the message",
alert_receive_channel=alert_receive_channel,
raw_request_data={},
integration_unique_data={},
image_url=None,
link_to_upstream_details=None,
)
with django_capture_on_commit_callbacks(execute=True) as callbacks:
alert = Alert.create(
title="the title",
message="the message",
alert_receive_channel=alert_receive_channel,
raw_request_data={},
integration_unique_data={},
image_url=None,
link_to_upstream_details=None,
)
assert alert.group.channel_filter == channel_filter
assert len(callbacks) == 1
mocked_distribute_alert_task.assert_called_once_with((alert.pk,), countdown=1)
@pytest.mark.django_db

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

@ -125,6 +125,7 @@ class AlertGroupListSerializer(
PREFETCH_RELATED = [
"dependent_alert_groups",
"log_records__author",
"labels",
]
SELECT_RELATED = [

View file

@ -4,6 +4,7 @@ from collections import OrderedDict
from django.conf import settings
from django.core.exceptions import ObjectDoesNotExist
from django.core.exceptions import ValidationError as DjangoValidationError
from django.db.models import Q
from jinja2 import TemplateSyntaxError
from rest_framework import serializers
from rest_framework.exceptions import ValidationError
@ -14,30 +15,191 @@ from apps.alerts.models import AlertReceiveChannel
from apps.alerts.models.channel_filter import ChannelFilter
from apps.base.messaging import get_messaging_backends
from apps.integrations.legacy_prefix import has_legacy_prefix
from apps.labels.models import LabelKeyCache, LabelValueCache
from apps.user_management.models import Organization
from common.api_helpers.custom_fields import TeamPrimaryKeyRelatedField
from common.api_helpers.exceptions import BadRequest
from common.api_helpers.mixins import APPEARANCE_TEMPLATE_NAMES, EagerLoadingMixin
from common.api_helpers.utils import CurrentTeamDefault
from common.jinja_templater import apply_jinja_template, jinja_template_env
from common.jinja_templater.apply_jinja_template import JinjaTemplateWarning
from common.jinja_templater import jinja_template_env
from .integration_heartbeat import IntegrationHeartBeatSerializer
from .labels import LabelsSerializerMixin
def valid_jinja_template_for_serializer_method_field(template):
for _, val in template.items():
try:
apply_jinja_template(val, payload={})
except JinjaTemplateWarning:
# Suppress warnings, template may be valid with payload
pass
class AlertGroupCustomLabelKey(typing.TypedDict):
id: str
name: str
class AlertGroupCustomLabelValue(typing.TypedDict):
id: str | None # None for templated labels, label value ID for plain labels
name: str # Jinja template for templated labels, label value name for plain labels
class AlertGroupCustomLabel(typing.TypedDict):
key: AlertGroupCustomLabelKey
value: AlertGroupCustomLabelValue
AlertGroupCustomLabels = list[AlertGroupCustomLabel]
class IntegrationAlertGroupLabels(typing.TypedDict):
inheritable: dict[str, bool]
custom: AlertGroupCustomLabels
template: str | None
class CustomLabelSerializer(serializers.Serializer):
"""This serializer is consistent with apps.api.serializers.labels.LabelSerializer, but allows null for value ID."""
class KeySerializer(serializers.Serializer):
id = serializers.CharField()
name = serializers.CharField()
class ValueSerializer(serializers.Serializer):
# ID is null for templated labels. For such labels, the "name" value is a Jinja2 template.
id = serializers.CharField(allow_null=True)
name = serializers.CharField()
key = KeySerializer()
value = ValueSerializer()
class IntegrationAlertGroupLabelsSerializer(serializers.Serializer):
"""Alert group labels configuration for the integration. See AlertReceiveChannel.alert_group_labels for details."""
inheritable = serializers.DictField(child=serializers.BooleanField())
custom = CustomLabelSerializer(many=True)
template = serializers.CharField(allow_null=True)
@staticmethod
def pop_alert_group_labels(validated_data: dict) -> IntegrationAlertGroupLabels | None:
"""Get alert group labels from validated data."""
# the "alert_group_labels" field is optional, so either all 3 fields are present or none
if "inheritable" not in validated_data:
return None
return {
"inheritable": validated_data.pop("inheritable"),
"custom": validated_data.pop("custom"),
"template": validated_data.pop("template"),
}
@classmethod
def update(
cls, instance: AlertReceiveChannel, alert_group_labels: IntegrationAlertGroupLabels | None
) -> AlertReceiveChannel:
if alert_group_labels is None:
return instance
# update inheritable labels
inheritable_key_ids = [
key_id for key_id, inheritable in alert_group_labels["inheritable"].items() if inheritable
]
instance.labels.filter(key_id__in=inheritable_key_ids).update(inheritable=True)
instance.labels.filter(~Q(key_id__in=inheritable_key_ids)).update(inheritable=False)
# update DB cache for custom labels
cls._create_custom_labels(instance.organization, alert_group_labels["custom"])
# update custom labels
instance.alert_group_labels_custom = cls._custom_labels_to_internal_value(alert_group_labels["custom"])
# update template
instance.alert_group_labels_template = alert_group_labels["template"]
instance.save(update_fields=["alert_group_labels_custom", "alert_group_labels_template"])
return instance
@staticmethod
def _create_custom_labels(organization: Organization, labels: AlertGroupCustomLabels) -> None:
"""Create LabelKeyCache and LabelValueCache objects for custom labels."""
label_keys = [
LabelKeyCache(id=label["key"]["id"], name=label["key"]["name"], organization=organization)
for label in labels
]
label_values = [
LabelValueCache(id=label["value"]["id"], name=label["value"]["name"], key_id=label["key"]["id"])
for label in labels
if label["value"]["id"] # don't create LabelValueCache objects for templated labels
]
LabelKeyCache.objects.bulk_create(label_keys, ignore_conflicts=True, batch_size=5000)
LabelValueCache.objects.bulk_create(label_values, ignore_conflicts=True, batch_size=5000)
@classmethod
def to_representation(cls, instance: AlertReceiveChannel) -> IntegrationAlertGroupLabels:
"""
The API representation of alert group labels is very different from the underlying model.
"inheritable" is based on AlertReceiveChannelAssociatedLabel.inheritable, a property of another model.
"custom" is based on AlertReceiveChannel.alert_group_labels_custom, a JSONField with a different schema.
"template" is based on AlertReceiveChannel.alert_group_labels_template, this one is straightforward.
"""
return {
"inheritable": {label.key_id: label.inheritable for label in instance.labels.all()},
"custom": cls._custom_labels_to_representation(instance.alert_group_labels_custom),
"template": instance.alert_group_labels_template,
}
@staticmethod
def _custom_labels_to_internal_value(
custom_labels: AlertGroupCustomLabels,
) -> AlertReceiveChannel.AlertGroupCustomLabels:
"""Convert custom labels from API representation to the schema used by the JSONField on the model."""
return [
[label["key"]["id"], label["value"]["id"], None if label["value"]["id"] else label["value"]["name"]]
for label in custom_labels
]
@staticmethod
def _custom_labels_to_representation(
custom_labels: AlertReceiveChannel.AlertGroupCustomLabels,
) -> AlertGroupCustomLabels:
"""
Inverse of the _custom_labels_to_internal_value method above.
Fetches label names from DB cache, so the API response schema is consistent with other label endpoints.
"""
from apps.labels.models import LabelKeyCache, LabelValueCache
if custom_labels is None:
return []
# get up-to-date label key names
label_key_names = {
k.id: k.name
for k in LabelKeyCache.objects.filter(id__in=[label[0] for label in custom_labels]).only("id", "name")
}
# get up-to-date label value names
label_value_names = {
v.id: v.name
for v in LabelValueCache.objects.filter(id__in=[label[1] for label in custom_labels if label[1]]).only(
"id", "name"
)
}
return [
{
"key": {
"id": key_id,
"name": label_key_names[key_id],
},
"value": {
"id": value_id if value_id else None,
"name": label_value_names[value_id] if value_id else typing.cast(str, template),
},
}
for key_id, value_id, template in custom_labels
if key_id in label_key_names and (value_id in label_value_names or not value_id)
]
class AlertReceiveChannelSerializer(
@ -64,7 +226,7 @@ class AlertReceiveChannelSerializer(
connected_escalations_chains_count = serializers.SerializerMethodField()
inbound_email = serializers.CharField(required=False)
is_legacy = serializers.SerializerMethodField()
alert_group_labels = IntegrationAlertGroupLabelsSerializer(required=False)
alert_group_labels = IntegrationAlertGroupLabelsSerializer(source="*", required=False)
# integration heartbeat is in PREFETCH_RELATED not by mistake.
# With using of select_related ORM builds strange join
@ -138,8 +300,10 @@ class AlertReceiveChannelSerializer(
if _integration.slug == integration:
is_able_to_autoresolve = _integration.is_able_to_autoresolve
# pop associated labels and alert group labels, so they are not passed to AlertReceiveChannel.create
labels = validated_data.pop("labels", None)
alert_group_labels = validated_data.pop("alert_group_labels", None)
alert_group_labels = IntegrationAlertGroupLabelsSerializer.pop_alert_group_labels(validated_data)
try:
instance = AlertReceiveChannel.create(
**validated_data,
@ -150,17 +314,22 @@ class AlertReceiveChannelSerializer(
except AlertReceiveChannel.DuplicateDirectPagingError:
raise BadRequest(detail=AlertReceiveChannel.DuplicateDirectPagingError.DETAIL)
# Create label associations first, then update inheritable labels
# Create label associations first, then update alert group labels
self.update_labels_association_if_needed(labels, instance, organization)
if alert_group_labels:
instance.alert_group_labels = alert_group_labels
instance = IntegrationAlertGroupLabelsSerializer.update(instance, alert_group_labels)
return instance
def update(self, instance, validated_data):
# update associated labels
labels = validated_data.pop("labels", None)
organization = self.context["request"].auth.organization
self.update_labels_association_if_needed(labels, instance, organization)
self.update_labels_association_if_needed(labels, instance, self.context["request"].auth.organization)
# update alert group labels
instance = IntegrationAlertGroupLabelsSerializer.update(
instance, IntegrationAlertGroupLabelsSerializer.pop_alert_group_labels(validated_data)
)
try:
return super().update(instance, validated_data)
except AlertReceiveChannel.DuplicateDirectPagingError:

View file

@ -3,12 +3,12 @@ import typing
from rest_framework import serializers
from apps.alerts.models import AlertReceiveChannel, ChannelFilter, EscalationChain
from apps.api.serializers.alert_receive_channel import valid_jinja_template_for_serializer_method_field
from apps.base.messaging import get_messaging_backend_from_id
from apps.telegram.models import TelegramToOrganizationConnector
from common.api_helpers.custom_fields import OrganizationFilteredPrimaryKeyRelatedField
from common.api_helpers.exceptions import BadRequest
from common.api_helpers.mixins import EagerLoadingMixin
from common.api_helpers.utils import valid_jinja_template_for_serializer_method_field
from common.jinja_templater.apply_jinja_template import JinjaTemplateError
from common.utils import is_regex_valid

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:
@ -164,7 +182,9 @@ class WebhookSerializer(serializers.ModelSerializer):
for controlled_field in preset_metadata.controlled_fields:
if controlled_field in self.initial_data:
if self.instance:
if self.initial_data[controlled_field] != getattr(self.instance, controlled_field):
if self.initial_data[controlled_field] is not None and self.initial_data[
controlled_field
] != getattr(self.instance, controlled_field):
raise serializers.ValidationError(
detail=f"{controlled_field} is controlled by preset, cannot update"
)
@ -176,7 +196,7 @@ class WebhookSerializer(serializers.ModelSerializer):
return preset
def get_last_response_log(self, obj):
return WebhookResponseSerializer(obj.responses.all().last()).data
return WebhookResponseSerializer(obj.responses.last()).data
def get_trigger_type_name(self, obj):
trigger_type_name = ""

View file

@ -863,6 +863,72 @@ def test_get_filter_escalation_chain(
assert len(response.data["results"]) == 2
@pytest.mark.django_db
def test_get_filter_by_teams(
make_organization_and_user_with_plugin_token,
make_team,
make_alert_receive_channel,
make_alert_group,
make_alert,
make_user_auth_headers,
):
client = APIClient()
organization, user, token = make_organization_and_user_with_plugin_token()
team1 = make_team(organization)
team2 = make_team(organization)
alert_receive_channel_0 = make_alert_receive_channel(organization)
alert_receive_channel_1 = make_alert_receive_channel(organization, team=team1)
alert_receive_channel_2 = make_alert_receive_channel(organization, team=team2)
alert_group_0 = make_alert_group(alert_receive_channel_0)
make_alert(alert_group=alert_group_0, raw_request_data=alert_raw_request_data)
alert_group_1 = make_alert_group(alert_receive_channel_1)
make_alert(alert_group=alert_group_1, raw_request_data=alert_raw_request_data)
alert_group_2 = make_alert_group(alert_receive_channel_2)
make_alert(alert_group=alert_group_2, raw_request_data=alert_raw_request_data)
url = reverse("api-internal:alertgroup-list")
# check no team is given
response = client.get(url, **make_user_auth_headers(user, token))
assert response.status_code == status.HTTP_200_OK
assert len(response.data["results"]) == 3
assert {ag["pk"] for ag in response.data["results"]} == {
alert_group_0.public_primary_key,
alert_group_1.public_primary_key,
alert_group_2.public_primary_key,
}
# check the "No team" case
response = client.get(url + "?team=null", **make_user_auth_headers(user, token))
assert response.status_code == status.HTTP_200_OK
assert len(response.data["results"]) == 1
assert {ag["pk"] for ag in response.data["results"]} == {alert_group_0.public_primary_key}
# check the "No team" + other team case
response = client.get(url + f"?team=null&team={team2.public_primary_key}", **make_user_auth_headers(user, token))
assert response.status_code == status.HTTP_200_OK
assert len(response.data["results"]) == 2
assert {ag["pk"] for ag in response.data["results"]} == {
alert_group_0.public_primary_key,
alert_group_2.public_primary_key,
}
# check the multiple teams case
response = client.get(
url + f"?team={team1.public_primary_key}&team={team2.public_primary_key}", **make_user_auth_headers(user, token)
)
assert response.status_code == status.HTTP_200_OK
assert len(response.data["results"]) == 2
assert {ag["pk"] for ag in response.data["results"]} == {
alert_group_1.public_primary_key,
alert_group_2.public_primary_key,
}
@pytest.mark.django_db
def test_get_filter_labels(
make_organization_and_user_with_plugin_token,

View file

@ -9,6 +9,7 @@ from rest_framework.test import APIClient
from apps.alerts.models import AlertReceiveChannel, EscalationPolicy
from apps.api.permissions import LegacyAccessControlRole
from apps.labels.models import LabelKeyCache, LabelValueCache
@pytest.fixture()
@ -1310,7 +1311,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 +1353,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()
@ -1385,23 +1384,49 @@ def test_update_alert_receive_channel_labels_duplicate_key(
def test_alert_group_labels_get(
make_organization_and_user_with_plugin_token,
make_alert_receive_channel,
make_label_key_and_value,
make_integration_label_association,
make_user_auth_headers,
):
organization, user, token = make_organization_and_user_with_plugin_token()
alert_receive_channel = make_alert_receive_channel(organization)
label_key, label_value = make_label_key_and_value(organization)
label_key_1, _ = make_label_key_and_value(organization)
client = APIClient()
url = reverse("api-internal:alert_receive_channel-detail", kwargs={"pk": alert_receive_channel.public_primary_key})
response = client.get(url, **make_user_auth_headers(user, token))
assert response.status_code == status.HTTP_200_OK
assert response.json()["alert_group_labels"] == {"inheritable": {}}
assert response.json()["alert_group_labels"] == {"inheritable": {}, "custom": [], "template": None}
label = make_integration_label_association(organization, alert_receive_channel)
template = "{{ payload.labels | tojson }}"
alert_receive_channel.alert_group_labels_template = template
alert_receive_channel.alert_group_labels_custom = [
(label_key.id, label_value.id, None),
(label_key_1.id, None, "{{ payload.foo }}"),
]
alert_receive_channel.save(update_fields=["alert_group_labels_custom", "alert_group_labels_template"])
response = client.get(url, **make_user_auth_headers(user, token))
assert response.status_code == status.HTTP_200_OK
assert response.json()["alert_group_labels"] == {"inheritable": {label.key_id: True}}
assert response.json()["alert_group_labels"] == {
"inheritable": {label.key_id: True},
"custom": [
{
"key": {"id": label_key.id, "name": label_key.name},
"value": {"id": label_value.id, "name": label_value.name},
},
{
"key": {"id": label_key_1.id, "name": label_key_1.name},
"value": {"id": None, "name": "{{ payload.foo }}"},
},
],
"template": template,
}
@pytest.mark.django_db
@ -1415,14 +1440,75 @@ def test_alert_group_labels_put(
alert_receive_channel = make_alert_receive_channel(organization)
label_1 = make_integration_label_association(organization, alert_receive_channel)
label_2 = make_integration_label_association(organization, alert_receive_channel, inheritable=False)
label_3 = make_integration_label_association(organization, alert_receive_channel, inheritable=False)
custom = [
# plain label
{
"key": {"id": label_2.key.id, "name": label_2.key.name},
"value": {"id": label_2.value.id, "name": label_2.value.name},
},
# plain label not present in DB cache
{
"key": {"id": "hello", "name": "world"},
"value": {"id": "foo", "name": "bar"},
},
# templated label
{
"key": {"id": label_3.key.id, "name": label_3.key.name},
"value": {"id": None, "name": "{{ payload.foo }}"},
},
]
template = "{{ payload.labels | tojson }}" # advanced template
client = APIClient()
url = reverse("api-internal:alert_receive_channel-detail", kwargs={"pk": alert_receive_channel.public_primary_key})
data = {"alert_group_labels": {"inheritable": {label_1.key_id: False, label_2.key_id: True}}}
data = {
"alert_group_labels": {
"inheritable": {label_1.key_id: False, label_2.key_id: True, label_3.key_id: False},
"custom": custom,
"template": template,
}
}
response = client.put(url, data, format="json", **make_user_auth_headers(user, token))
assert response.status_code == status.HTTP_200_OK
assert response.json()["alert_group_labels"] == {"inheritable": {label_1.key_id: False, label_2.key_id: True}}
assert response.json()["alert_group_labels"] == {
"inheritable": {label_1.key_id: False, label_2.key_id: True, label_3.key_id: False},
"custom": custom,
"template": template,
}
alert_receive_channel.refresh_from_db()
assert alert_receive_channel.alert_group_labels_custom == [
[label_2.key_id, label_2.value_id, None],
["hello", "foo", None],
[label_3.key_id, None, "{{ payload.foo }}"],
]
assert alert_receive_channel.alert_group_labels_template == template
# check label keys & values are created
key = LabelKeyCache.objects.filter(id="hello", name="world", organization=organization).first()
assert key is not None
assert LabelValueCache.objects.filter(key=key, id="foo", name="bar").exists()
@pytest.mark.django_db
def test_alert_group_labels_put_none(
make_organization_and_user_with_plugin_token,
make_alert_receive_channel,
make_user_auth_headers,
):
organization, user, token = make_organization_and_user_with_plugin_token()
alert_receive_channel = make_alert_receive_channel(organization)
client = APIClient()
url = reverse("api-internal:alert_receive_channel-detail", kwargs={"pk": alert_receive_channel.public_primary_key})
response = client.put(url, {"verbal_name": "123"}, format="json", **make_user_auth_headers(user, token))
assert response.status_code == status.HTTP_200_OK
assert response.json()["verbal_name"] == "123"
assert response.json()["alert_group_labels"] == {"inheritable": {}, "custom": [], "template": None}
@pytest.mark.django_db
@ -1430,7 +1516,11 @@ def test_alert_group_labels_post(alert_receive_channel_internal_api_setup, make_
user, token, _ = alert_receive_channel_internal_api_setup
labels = [{"key": {"id": "test", "name": "test"}, "value": {"id": "123", "name": "123"}}]
alert_group_labels = {"inheritable": {"test": False}}
alert_group_labels = {
"inheritable": {"test": False},
"custom": [{"key": {"id": "test", "name": "test"}, "value": {"id": "123", "name": "123"}}],
"template": "{{ payload.labels | tojson }}",
}
data = {
"integration": AlertReceiveChannel.INTEGRATION_GRAFANA,
"team": None,
@ -1445,3 +1535,7 @@ def test_alert_group_labels_post(alert_receive_channel_internal_api_setup, make_
assert response.status_code == status.HTTP_201_CREATED
assert response.json()["labels"] == labels
assert response.json()["alert_group_labels"] == alert_group_labels
alert_receive_channel = AlertReceiveChannel.objects.get(public_primary_key=response.json()["id"])
assert alert_receive_channel.alert_group_labels_custom == [["test", "123", None]]
assert alert_receive_channel.alert_group_labels_template == "{{ payload.labels | tojson }}"

View file

@ -337,6 +337,37 @@ def test_preview_alert_receive_channel_backend_templater(
assert response.json() == {"preview": "title: alert!"}
@pytest.mark.django_db
def test_preview_alert_group_labels(
make_organization_and_user_with_plugin_token,
make_user_auth_headers,
make_alert_receive_channel,
make_channel_filter,
make_alert_group,
make_alert,
):
organization, user, token = make_organization_and_user_with_plugin_token()
alert_receive_channel = make_alert_receive_channel(organization)
default_channel_filter = make_channel_filter(alert_receive_channel, is_default=True)
alert_group = make_alert_group(alert_receive_channel, channel_filter=default_channel_filter)
make_alert(alert_group=alert_group, raw_request_data={"labels": {"1": "2"}})
client = APIClient()
url = reverse(
"api-internal:alert_receive_channel-preview-template",
kwargs={"pk": alert_receive_channel.public_primary_key},
)
data = {
"template_body": "{{ payload.labels | tojson }}",
"template_name": "alert_group_labels",
}
response = client.post(url, format="json", data=data, **make_user_auth_headers(user, token))
assert response.status_code == status.HTTP_200_OK
assert response.json() == {"preview": '{"1": "2"}'}
@pytest.mark.django_db
def test_update_alert_receive_channel_templates(
make_organization_and_user_with_plugin_token,

View file

@ -5,6 +5,8 @@ from django.urls import reverse
from rest_framework import status
from rest_framework.test import APIClient
from common.api_helpers.filters import NO_TEAM_VALUE
@pytest.fixture()
def escalation_chain_internal_api_setup(make_organization_and_user_with_plugin_token, make_escalation_chain):
@ -103,7 +105,7 @@ def test_escalation_chain_copy(
escalation_chain = make_escalation_chain(organization, team=team)
data = {
"name": "escalation_chain_updated",
"team": new_team.public_primary_key if new_team else "null",
"team": new_team.public_primary_key if new_team else NO_TEAM_VALUE,
}
client = APIClient()
@ -125,6 +127,8 @@ def test_escalation_chain_copy_empty_name(
client = APIClient()
url = reverse("api-internal:escalation_chain-copy", kwargs={"pk": escalation_chain.public_primary_key})
response = client.post(url, {"name": "", "team": "null"}, format="json", **make_user_auth_headers(user, token))
response = client.post(
url, {"name": "", "team": NO_TEAM_VALUE}, format="json", **make_user_auth_headers(user, token)
)
assert response.status_code == status.HTTP_400_BAD_REQUEST

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

@ -10,8 +10,9 @@ from apps.alerts.models import AlertReceiveChannel
from apps.api.permissions import LegacyAccessControlRole
from apps.schedules.models import CustomOnCallShift, OnCallScheduleCalendar, OnCallScheduleWeb
from apps.user_management.models import Team
from common.api_helpers.filters import NO_TEAM_VALUE
GENERAL_TEAM = Team(public_primary_key="null", name="No team", email=None, avatar_url=None)
GENERAL_TEAM = Team(public_primary_key=NO_TEAM_VALUE, name="No team", email=None, avatar_url=None)
def get_payload_from_team(team, long=False):
@ -203,7 +204,7 @@ def test_teams_number_of_users_currently_oncall_attribute_works_properly(
team1.public_primary_key: 2,
team2.public_primary_key: 1,
team3.public_primary_key: 0,
"null": 0, # this covers the case of "No team"
NO_TEAM_VALUE: 0, # this covers the case of "No team"
}
for team in response.json():

View file

@ -410,7 +410,7 @@ def test_user_update_other_permissions(
[
(LegacyAccessControlRole.ADMIN, status.HTTP_200_OK),
(LegacyAccessControlRole.EDITOR, status.HTTP_200_OK),
(LegacyAccessControlRole.VIEWER, status.HTTP_403_FORBIDDEN),
(LegacyAccessControlRole.VIEWER, status.HTTP_200_OK),
(LegacyAccessControlRole.NONE, status.HTTP_403_FORBIDDEN),
],
)
@ -1291,14 +1291,14 @@ def test_viewer_cant_update_himself(make_organization_and_user_with_plugin_token
@pytest.mark.django_db
def test_viewer_cant_list_users(make_organization_and_user_with_plugin_token, make_user_auth_headers):
def test_viewer_can_list_users(make_organization_and_user_with_plugin_token, make_user_auth_headers):
_, user, token = make_organization_and_user_with_plugin_token(role=LegacyAccessControlRole.VIEWER)
client = APIClient()
url = reverse("api-internal:user-list")
response = client.get(url, format="json", **make_user_auth_headers(user, token))
assert response.status_code == status.HTTP_403_FORBIDDEN
assert response.status_code == status.HTTP_200_OK
@pytest.mark.django_db

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
@ -27,12 +28,7 @@ from apps.labels.utils import is_labels_feature_enabled
from apps.mobile_app.auth import MobileAppAuthTokenAuthentication
from apps.user_management.models import Team, User
from common.api_helpers.exceptions import BadRequest
from common.api_helpers.filters import (
ByTeamModelFieldFilterMixin,
DateRangeFilterMixin,
ModelFieldFilterMixin,
TeamModelMultipleChoiceFilter,
)
from common.api_helpers.filters import NO_TEAM_VALUE, DateRangeFilterMixin, ModelFieldFilterMixin
from common.api_helpers.mixins import PreviewTemplateMixin, PublicPrimaryKeyMixin, TeamFilteringMixin
from common.api_helpers.paginators import TwentyFiveCursorPaginator
@ -83,7 +79,7 @@ class AlertGroupFilterBackend(filters.DjangoFilterBackend):
return filterset
class AlertGroupFilter(DateRangeFilterMixin, ByTeamModelFieldFilterMixin, ModelFieldFilterMixin, filters.FilterSet):
class AlertGroupFilter(DateRangeFilterMixin, ModelFieldFilterMixin, filters.FilterSet):
"""
Examples of possible date formats here https://docs.djangoproject.com/en/1.9/ref/settings/#datetime-input-formats
"""
@ -140,7 +136,6 @@ class AlertGroupFilter(DateRangeFilterMixin, ByTeamModelFieldFilterMixin, ModelF
)
with_resolution_note = filters.BooleanFilter(method="filter_with_resolution_note")
mine = filters.BooleanFilter(method="filter_mine")
team = TeamModelMultipleChoiceFilter(field_name="channel__team")
class Meta:
model = AlertGroup
@ -336,22 +331,28 @@ class AlertGroupView(
if not ignore_filtering_by_available_teams:
alert_receive_channels_qs = alert_receive_channels_qs.filter(*self.available_teams_lookup_args)
# Filter by team(s). Since we really filter teams from integrations, this is not an AlertGroup model filter.
# This is based on the common.api_helpers.ByTeamModelFieldFilterMixin implementation
team_values = self.request.query_params.getlist("team", [])
if team_values:
null_team_lookup = Q(team__isnull=True) if NO_TEAM_VALUE in team_values else None
teams_lookup = Q(team__public_primary_key__in=[ppk for ppk in team_values if ppk != NO_TEAM_VALUE])
if null_team_lookup:
teams_lookup = teams_lookup | null_team_lookup
alert_receive_channels_qs = alert_receive_channels_qs.filter(teams_lookup)
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,13 +19,13 @@ 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
from apps.mobile_app.auth import MobileAppAuthTokenAuthentication
from common.api_helpers.exceptions import BadRequest
from common.api_helpers.filters import ByTeamModelFieldFilterMixin, TeamModelMultipleChoiceFilter
from common.api_helpers.filters import NO_TEAM_VALUE, ByTeamModelFieldFilterMixin, TeamModelMultipleChoiceFilter
from common.api_helpers.mixins import (
FilterSerializerMixin,
PreviewTemplateException,
@ -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):
@ -217,7 +231,7 @@ class AlertReceiveChannelView(
raise BadRequest(detail="team_id must be specified")
team_id = request.query_params["team_id"]
if team_id == "null":
if team_id == NO_TEAM_VALUE:
team_id = None
try:

View file

@ -18,7 +18,12 @@ from apps.auth_token.auth import PluginAuthentication
from apps.mobile_app.auth import MobileAppAuthTokenAuthentication
from apps.user_management.models import Team
from common.api_helpers.exceptions import BadRequest
from common.api_helpers.filters import ByTeamModelFieldFilterMixin, ModelFieldFilterMixin, TeamModelMultipleChoiceFilter
from common.api_helpers.filters import (
NO_TEAM_VALUE,
ByTeamModelFieldFilterMixin,
ModelFieldFilterMixin,
TeamModelMultipleChoiceFilter,
)
from common.api_helpers.mixins import (
FilterSerializerMixin,
ListSerializerMixin,
@ -128,7 +133,7 @@ class EscalationChainViewSet(
name = request.data.get("name")
team_id = request.data.get("team")
if team_id == "null":
if team_id == NO_TEAM_VALUE:
team_id = None
if not name:

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

@ -10,6 +10,7 @@ from apps.auth_token.auth import PluginAuthentication
from apps.mobile_app.auth import MobileAppAuthTokenAuthentication
from apps.schedules.ical_utils import get_cached_oncall_users_for_multiple_schedules
from apps.user_management.models import Team
from common.api_helpers.filters import NO_TEAM_VALUE
from common.api_helpers.mixins import PublicPrimaryKeyMixin
@ -62,7 +63,7 @@ class TeamViewSet(
return TeamLongSerializer if self._is_long_request() else TeamSerializer
def list(self, request, *args, **kwargs):
general_team = [Team(public_primary_key="null", name="No team", email=None, avatar_url=None)]
general_team = [Team(public_primary_key=NO_TEAM_VALUE, name="No team", email=None, avatar_url=None)]
queryset = self.filter_queryset(self.get_queryset())
if self.request.query_params.get("only_include_notifiable_teams", "false") == "true":

View file

@ -159,7 +159,7 @@ class UserView(
"timezone_options": [RBACPermission.Permissions.USER_SETTINGS_READ],
"check_availability": [RBACPermission.Permissions.USER_SETTINGS_READ],
"metadata": [RBACPermission.Permissions.USER_SETTINGS_WRITE],
"list": [RBACPermission.Permissions.USER_SETTINGS_WRITE],
"list": [RBACPermission.Permissions.USER_SETTINGS_READ],
"update": [RBACPermission.Permissions.USER_SETTINGS_WRITE],
"partial_update": [RBACPermission.Permissions.USER_SETTINGS_WRITE],
"verify_number": [RBACPermission.Permissions.USER_SETTINGS_WRITE],

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
@ -91,9 +94,24 @@ class WebhooksView(TeamFilteringMixin, PublicPrimaryKeyMixin, ModelViewSet):
def get_queryset(self, ignore_filtering_by_available_teams=False):
queryset = Webhook.objects.filter(
organization=self.request.auth.organization,
).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

@ -8,14 +8,16 @@ from rest_framework import exceptions
from rest_framework.authentication import BaseAuthentication, get_authorization_header
from rest_framework.request import Request
from apps.api.permissions import RBACPermission, user_is_authorized
from apps.api.permissions import GrafanaAPIPermission, LegacyAccessControlRole, RBACPermission, user_is_authorized
from apps.grafana_plugin.helpers.gcom import check_token
from apps.user_management.exceptions import OrganizationDeletedException, OrganizationMovedException
from apps.user_management.models import User
from apps.user_management.models.organization import Organization
from settings.base import SELF_HOSTED_SETTINGS
from .constants import SCHEDULE_EXPORT_TOKEN_NAME, SLACK_AUTH_TOKEN_NAME
from .exceptions import InvalidToken
from .grafana.grafana_auth_token import get_service_account_token_permissions
from .models import ApiAuthToken, PluginAuthToken, ScheduleExportAuthToken, SlackAuthToken, UserScheduleExportAuthToken
logger = logging.getLogger(__name__)
@ -262,3 +264,71 @@ class UserScheduleExportAuthentication(BaseAuthentication):
raise exceptions.AuthenticationFailed("Export token is deactivated")
return auth_token.user, auth_token
X_GRAFANA_ORG_SLUG = "X-Grafana-Org-Slug"
X_GRAFANA_INSTANCE_SLUG = "X-Grafana-Instance-Slug"
GRAFANA_SA_PREFIX = "glsa_"
class GrafanaServiceAccountAuthentication(BaseAuthentication):
def authenticate(self, request):
auth = get_authorization_header(request).decode("utf-8")
if not auth:
raise exceptions.AuthenticationFailed("Invalid token.")
if not auth.startswith(GRAFANA_SA_PREFIX):
return None
organization = self.get_organization(request)
if not organization:
raise exceptions.AuthenticationFailed("Invalid organization.")
if organization.is_moved:
raise OrganizationMovedException(organization)
if organization.deleted_at:
raise OrganizationDeletedException(organization)
return self.authenticate_credentials(organization, auth)
def get_organization(self, request):
org_slug = SELF_HOSTED_SETTINGS["ORG_SLUG"]
instance_slug = SELF_HOSTED_SETTINGS["STACK_SLUG"]
if settings.LICENSE == settings.CLOUD_LICENSE_NAME:
org_slug = request.headers.get(X_GRAFANA_ORG_SLUG)
if not org_slug:
raise exceptions.AuthenticationFailed(f"Missing {X_GRAFANA_ORG_SLUG}")
instance_slug = request.headers.get(X_GRAFANA_INSTANCE_SLUG)
if not instance_slug:
raise exceptions.AuthenticationFailed(f"Missing {X_GRAFANA_INSTANCE_SLUG}")
return Organization.objects.filter(org_slug=org_slug, stack_slug=instance_slug).first()
def authenticate_credentials(self, organization, token):
permissions = get_service_account_token_permissions(organization, token)
if not permissions:
raise exceptions.AuthenticationFailed("Invalid token.")
role = LegacyAccessControlRole.NONE
if not organization.is_rbac_permissions_enabled:
role = self.determine_role_from_permissions(permissions)
user = User(
organization_id=organization.pk,
name="Grafana Service Account",
username="grafana_service_account",
role=role,
permissions=[GrafanaAPIPermission(action=key) for key, _ in permissions.items()],
)
auth_token = ApiAuthToken(organization=organization, user=user, name="Grafana Service Account")
return user, auth_token
# Using default permissions as proxies for roles since we cannot explicitly get role from the service account token
def determine_role_from_permissions(self, permissions):
if "plugins:write" in permissions:
return LegacyAccessControlRole.ADMIN
if "dashboards:write" in permissions:
return LegacyAccessControlRole.EDITOR
if "dashboards:read" in permissions:
return LegacyAccessControlRole.VIEWER
return LegacyAccessControlRole.NONE

View file

@ -1,2 +1,6 @@
class InvalidToken(Exception):
pass
class ServiceAccountDoesNotExist(Exception):
pass

View file

@ -0,0 +1,48 @@
import typing
from apps.auth_token.exceptions import ServiceAccountDoesNotExist
from apps.grafana_plugin.helpers import GrafanaAPIClient
from apps.user_management.models import Organization
SA_ONCALL_API_NAME = "sa-autogen-OnCall"
def find_service_account(
organization: Organization, service_account_name=SA_ONCALL_API_NAME
) -> typing.Optional["GrafanaAPIClient.Types.GrafanaServiceAccount"]:
grafana_api_client = GrafanaAPIClient(api_url=organization.grafana_url, api_token=organization.api_token)
response, _ = grafana_api_client.get_service_account(service_account_name)
if response and "serviceAccounts" in response and response["serviceAccounts"]:
return response["serviceAccounts"][0]
return None
def create_service_account(
organization: Organization, name: str, role: str
) -> GrafanaAPIClient.Types.GrafanaServiceAccount:
grafana_api_client = GrafanaAPIClient(api_url=organization.grafana_url, api_token=organization.api_token)
response, _ = grafana_api_client.create_service_account(name, role)
return response
def create_service_account_token(
organization: Organization,
token_name: str,
seconds_to_live=int | None,
service_account_name=SA_ONCALL_API_NAME,
) -> typing.Optional[str]:
grafana_api_client = GrafanaAPIClient(api_url=organization.grafana_url, api_token=organization.api_token)
service_account = find_service_account(organization, service_account_name)
if not service_account:
raise ServiceAccountDoesNotExist
response, _ = grafana_api_client.create_service_account_token(service_account["id"], token_name, seconds_to_live)
if response:
return response["key"]
return None
def get_service_account_token_permissions(organization: Organization, token: str) -> typing.Dict[str, typing.List[str]]:
grafana_api_client = GrafanaAPIClient(api_url=organization.grafana_url, api_token=token)
permissions, _ = grafana_api_client.get_service_account_token_permissions()
return permissions

View file

@ -0,0 +1,80 @@
import typing
from unittest.mock import patch
import pytest
from rest_framework import exceptions
from rest_framework.test import APIRequestFactory
from apps.auth_token.auth import (
GRAFANA_SA_PREFIX,
X_GRAFANA_INSTANCE_SLUG,
X_GRAFANA_ORG_SLUG,
GrafanaServiceAccountAuthentication,
)
from settings.base import CLOUD_LICENSE_NAME, OPEN_SOURCE_LICENSE_NAME, SELF_HOSTED_SETTINGS
def fake_authenticate_credentials(organization, token):
pass
@pytest.mark.django_db
def test_grafana_authentication_oss_inputs(make_organization, settings):
settings.LICENSE = OPEN_SOURCE_LICENSE_NAME
headers, token = check_common_inputs()
organization = make_organization(
stack_slug=SELF_HOSTED_SETTINGS["STACK_SLUG"], org_slug=SELF_HOSTED_SETTINGS["ORG_SLUG"]
)
request = APIRequestFactory().get("/", **headers)
with patch(
"apps.auth_token.auth.GrafanaServiceAccountAuthentication.authenticate_credentials",
wraps=fake_authenticate_credentials,
) as mock:
GrafanaServiceAccountAuthentication().authenticate(request)
mock.assert_called_once_with(organization, token)
@pytest.mark.django_db
def test_grafana_authentication_cloud_inputs(make_organization, settings):
settings.LICENSE = CLOUD_LICENSE_NAME
headers, token = check_common_inputs()
test_org_slug = "test_org_123"
test_stack_slug = "test_stack_123"
headers[f"HTTP_{X_GRAFANA_ORG_SLUG}"] = test_org_slug
headers[f"HTTP_{X_GRAFANA_INSTANCE_SLUG}"] = test_stack_slug
request = APIRequestFactory().get("/", **headers)
with pytest.raises(exceptions.AuthenticationFailed):
GrafanaServiceAccountAuthentication().authenticate(request)
organization = make_organization(stack_slug=test_stack_slug, org_slug=test_org_slug)
with patch(
"apps.auth_token.auth.GrafanaServiceAccountAuthentication.authenticate_credentials",
wraps=fake_authenticate_credentials,
) as mock:
GrafanaServiceAccountAuthentication().authenticate(request)
mock.assert_called_once_with(organization, token)
def check_common_inputs() -> (dict[str, typing.Any], str):
request = APIRequestFactory().get("/")
with pytest.raises(exceptions.AuthenticationFailed):
GrafanaServiceAccountAuthentication().authenticate(request)
headers = {
"HTTP_AUTHORIZATION": "xyz",
}
request = APIRequestFactory().get("/", **headers)
result = GrafanaServiceAccountAuthentication().authenticate(request)
assert result is None
token = f"{GRAFANA_SA_PREFIX}xyz"
headers = {
"HTTP_AUTHORIZATION": token,
}
request = APIRequestFactory().get("/", **headers)
with pytest.raises(exceptions.AuthenticationFailed):
GrafanaServiceAccountAuthentication().authenticate(request)
return headers, token

View file

@ -176,9 +176,27 @@ class GrafanaAPIClient(APIClient):
avatarUrl: str
memberCount: int
class GrafanaServiceAccount(typing.TypedDict):
id: int
name: str
login: str
orgId: int
isDisabled: bool
role: str
tokens: int
avatarUrl: str
class GrafanaServiceAccountToken(typing.TypedDict):
id: int
name: str
key: str
class TeamsResponse(_BaseGrafanaAPIResponse):
teams: typing.List["GrafanaAPIClient.Types.GrafanaTeam"]
class ServiceAccountResponse(_BaseGrafanaAPIResponse):
serviceAccounts: typing.List["GrafanaAPIClient.Types.GrafanaServiceAccount"]
def __init__(self, api_url: str, api_token: str) -> None:
super().__init__(api_url, api_token)
@ -274,6 +292,25 @@ class GrafanaAPIClient(APIClient):
def get_grafana_plugin_settings(self, recipient: str) -> APIClientResponse:
return self.api_get(f"api/plugins/{recipient}/settings")
def get_service_account(self, login: str) -> APIClientResponse["GrafanaAPIClient.Types.ServiceAccountResponse"]:
return self.api_get(f"api/serviceaccounts/search?query={login}")
def create_service_account(
self, name: str, role: str
) -> APIClientResponse["GrafanaAPIClient.Types.GrafanaServiceAccount"]:
return self.api_post("api/serviceaccounts", {"name": name, "role": role})
def create_service_account_token(
self, service_account_id: int, name: str, seconds_to_live=int | None
) -> APIClientResponse["GrafanaAPIClient.Types.GrafanaServiceAccountToken"]:
token_config = {"name": name}
if seconds_to_live:
token_config["secondsToLive"] = seconds_to_live
return self.api_post(f"api/serviceaccounts/{service_account_id}/tokens", token_config)
def get_service_account_token_permissions(self) -> APIClientResponse[typing.Dict[str, typing.List[str]]]:
return self.api_get("api/access-control/user/permissions")
class GcomAPIClient(APIClient):
ACTIVE_INSTANCE_QUERY = "instances?status=active"

View file

@ -0,0 +1,161 @@
import json
import logging
import typing
from apps.labels.utils import is_labels_feature_enabled
from common.jinja_templater import apply_jinja_template
from common.jinja_templater.apply_jinja_template import JinjaTemplateError, JinjaTemplateWarning
if typing.TYPE_CHECKING:
from apps.alerts.models import AlertGroup, AlertReceiveChannel
logger = logging.getLogger(__name__)
# What can be used as a label key/value coming out from the template
LABEL_VALUE_TYPES = (str, int, float, bool)
def assign_labels(
alert_group: "AlertGroup", alert_receive_channel: "AlertReceiveChannel", raw_request_data: typing.Any
) -> None:
from apps.labels.models import AlertGroupAssociatedLabel
if not is_labels_feature_enabled(alert_receive_channel.organization):
return
# inherit labels from the integration
labels = {
label.key.name: label.value.name
for label in alert_receive_channel.labels.filter(inheritable=True).select_related("key", "value")
}
# apply custom labels
labels.update(_custom_labels(alert_receive_channel, raw_request_data))
# apply template labels
labels.update(_template_labels(alert_receive_channel, raw_request_data))
# create associated labels
alert_group_labels = [
AlertGroupAssociatedLabel(
alert_group=alert_group,
organization=alert_receive_channel.organization,
key_name=key,
value_name=value,
)
for key, value in labels.items()
]
# sort associated labels by key and value
alert_group_labels.sort(key=lambda label: (label.key_name, label.value_name))
# bulk create associated labels
AlertGroupAssociatedLabel.objects.bulk_create(alert_group_labels)
def _custom_labels(alert_receive_channel: "AlertReceiveChannel", raw_request_data: typing.Any) -> dict[str, str]:
from apps.labels.models import MAX_VALUE_NAME_LENGTH, LabelKeyCache, LabelValueCache
if alert_receive_channel.alert_group_labels_custom is None:
return {}
# fetch up-to-date label key names
label_key_names = {
k.id: k.name
for k in LabelKeyCache.objects.filter(
id__in=[label[0] for label in alert_receive_channel.alert_group_labels_custom]
).only("id", "name")
}
# fetch up-to-date label value names
label_value_names = {
v.id: v.name
for v in LabelValueCache.objects.filter(
id__in=[label[1] for label in alert_receive_channel.alert_group_labels_custom if label[1]]
).only("id", "name")
}
rendered_labels = {}
for label in alert_receive_channel.alert_group_labels_custom:
key_id, value_id, template = label
if key_id in label_key_names:
key = label_key_names[key_id]
else:
logger.warning("Label key cache not found. %s", key_id)
continue
if value_id:
if value_id in label_value_names:
rendered_labels[key] = label_value_names[value_id]
else:
logger.warning("Label value cache not found. %s", value_id)
continue
else:
try:
rendered_labels[key] = apply_jinja_template(template, raw_request_data)
except (JinjaTemplateError, JinjaTemplateWarning) as e:
logger.warning("Failed to apply template. %s", e.fallback_message)
continue
labels = {}
for key in rendered_labels:
value = rendered_labels[key]
# check value length
if len(value) > MAX_VALUE_NAME_LENGTH:
logger.warning("Template result value is too long. %s", value)
continue
labels[key] = value
return labels
def _template_labels(alert_receive_channel: "AlertReceiveChannel", raw_request_data: typing.Any) -> dict[str, str]:
from apps.labels.models import MAX_KEY_NAME_LENGTH, MAX_VALUE_NAME_LENGTH
if not alert_receive_channel.alert_group_labels_template:
return {}
try:
rendered = apply_jinja_template(alert_receive_channel.alert_group_labels_template, raw_request_data)
except (JinjaTemplateError, JinjaTemplateWarning) as e:
logger.warning("Failed to apply template. %s", e.fallback_message)
return {}
try:
rendered_labels = json.loads(rendered)
except (TypeError, json.JSONDecodeError):
logger.warning("Failed to parse template result. %s", rendered)
return {}
if not isinstance(rendered_labels, dict):
logger.warning("Template result is not a dict. %s", rendered_labels)
return {}
labels = {}
for key in rendered_labels:
value = rendered_labels[key]
# check value type
if not isinstance(value, LABEL_VALUE_TYPES):
logger.warning("Template result value has invalid type. %s", value)
continue
# convert value to string
value = str(value)
# check key length
if len(key) > MAX_KEY_NAME_LENGTH:
logger.warning("Template result key is too long. %s", key)
continue
# check value length
if len(value) > MAX_VALUE_NAME_LENGTH:
logger.warning("Template result value is too long. %s", value)
continue
labels[key] = value
return labels

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

@ -10,9 +10,13 @@ if typing.TYPE_CHECKING:
from apps.user_management.models import Organization
MAX_KEY_NAME_LENGTH = 200
MAX_VALUE_NAME_LENGTH = 200
class LabelKeyCache(models.Model):
id = models.CharField(primary_key=True, editable=False, max_length=36)
name = models.CharField(max_length=200)
name = models.CharField(max_length=MAX_KEY_NAME_LENGTH)
organization = models.ForeignKey("user_management.Organization", on_delete=models.CASCADE)
last_synced = models.DateTimeField(auto_now=True)
@ -23,7 +27,7 @@ class LabelKeyCache(models.Model):
class LabelValueCache(models.Model):
id = models.CharField(primary_key=True, editable=False, max_length=36)
name = models.CharField(max_length=200)
name = models.CharField(max_length=MAX_VALUE_NAME_LENGTH)
key = models.ForeignKey("labels.LabelKeyCache", on_delete=models.CASCADE, related_name="values")
last_synced = models.DateTimeField(auto_now=True)
@ -129,8 +133,8 @@ class AlertGroupAssociatedLabel(models.Model):
"user_management.Organization", on_delete=models.CASCADE, related_name="alert_group_labels"
)
key_name = models.CharField(max_length=200)
value_name = models.CharField(max_length=200)
key_name = models.CharField(max_length=MAX_KEY_NAME_LENGTH)
value_name = models.CharField(max_length=MAX_VALUE_NAME_LENGTH)
class Meta:
constraints = [
@ -139,3 +143,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

@ -3,9 +3,13 @@ from unittest import mock
import pytest
from apps.alerts.models import Alert
from apps.labels.models import MAX_KEY_NAME_LENGTH, MAX_VALUE_NAME_LENGTH
TOO_LONG_KEY_NAME = "k" * (MAX_KEY_NAME_LENGTH + 1)
TOO_LONG_VALUE_NAME = "v" * (MAX_VALUE_NAME_LENGTH + 1)
@mock.patch("apps.labels.utils.is_labels_feature_enabled", return_value=False)
@mock.patch("apps.labels.alert_group_labels.is_labels_feature_enabled", return_value=False)
@pytest.mark.django_db
def test_assign_labels_feature_flag_disabled(
_, make_organization, make_alert_receive_channel, make_integration_label_association
@ -28,11 +32,75 @@ def test_assign_labels_feature_flag_disabled(
@pytest.mark.django_db
def test_assign_labels(make_organization, make_alert_receive_channel, make_integration_label_association):
def test_assign_labels(
make_organization,
make_alert_receive_channel,
make_label_key_and_value,
make_label_key,
make_integration_label_association,
):
organization = make_organization()
alert_receive_channel = make_alert_receive_channel(organization)
label = make_integration_label_association(organization, alert_receive_channel)
make_integration_label_association(organization, alert_receive_channel, inheritable=False)
# create label repo labels
label_key, label_value = make_label_key_and_value(organization, key_name="a", value_name="b")
label_key_1 = make_label_key(organization=organization, key_name="c")
label_key_2 = make_label_key(organization=organization)
label_key_3 = make_label_key(organization=organization)
# create alert receive channel with all 3 types of labels
alert_receive_channel = make_alert_receive_channel(
organization,
alert_group_labels_custom=[
[label_key.id, label_value.id, None], # plain label
["nonexistent", label_value.id, None], # plain label with nonexistent key ID
[label_key_2.id, "nonexistent", None], # plain label with nonexistent value ID
[label_key_1.id, None, "{{ payload.c }}"], # templated label
[label_key_3.id, None, TOO_LONG_VALUE_NAME], # templated label too long
],
alert_group_labels_template="{{ payload.advanced_template | tojson }}",
)
make_integration_label_association(organization, alert_receive_channel, key_name="e", value_name="f")
# create alert group
alert = Alert.create(
title="the title",
message="the message",
alert_receive_channel=alert_receive_channel,
raw_request_data={
"c": "d",
"advanced_template": {
"g": 123,
"too_long": TOO_LONG_VALUE_NAME,
TOO_LONG_KEY_NAME: "too_long",
"invalid_type": {"test": "test"},
},
"extra": "hi",
},
integration_unique_data={},
image_url=None,
link_to_upstream_details=None,
)
# check alert group labels are assigned correctly, in the lexicographical order
assert [(label.key_name, label.value_name) for label in alert.group.labels.all()] == [
("a", "b"),
("c", "d"),
("e", "f"),
("g", "123"),
]
@pytest.mark.django_db
def test_assign_labels_custom_labels_none(
make_organization,
make_alert_receive_channel,
make_label_key_and_value,
make_label_key,
make_integration_label_association,
):
organization = make_organization()
alert_receive_channel = make_alert_receive_channel(organization, alert_group_labels_custom=None)
make_integration_label_association(organization, alert_receive_channel, key_name="a", value_name="b")
alert = Alert.create(
title="the title",
@ -44,6 +112,4 @@ def test_assign_labels(make_organization, make_alert_receive_channel, make_integ
link_to_upstream_details=None,
)
assert alert.group.labels.count() == 1
assert alert.group.labels.first().key_name == label.key.name
assert alert.group.labels.first().value_name == label.value.name
assert [(label.key_name, label.value_name) for label in alert.group.labels.all()] == [("a", "b")]

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

@ -1,13 +1,16 @@
import logging
import typing
from django.apps import apps # noqa: I251
from django.conf import settings
if typing.TYPE_CHECKING:
from apps.alerts.models import AlertGroup, AlertReceiveChannel
from apps.alerts.models import AlertGroup
from apps.labels.models import AssociatedLabel
from apps.user_management.models import Organization
logger = logging.getLogger(__name__)
LABEL_OUTDATED_TIMEOUT_MINUTES = 30
ASSOCIATED_MODEL_NAME = "AssociatedLabel"
@ -27,6 +30,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]
@ -49,20 +57,10 @@ def is_labels_feature_enabled(organization: "Organization") -> bool:
)
def assign_labels(alert_group: "AlertGroup", alert_receive_channel: "AlertReceiveChannel") -> None:
from apps.labels.models import AlertGroupAssociatedLabel
def get_label_verbal(obj: typing.Any) -> dict[str, str]:
return {label.key.name: label.value.name for label in obj.labels.all().select_related("key", "value")}
if not is_labels_feature_enabled(alert_receive_channel.organization):
return
# inherit labels from the integration
alert_group_labels = [
AlertGroupAssociatedLabel(
alert_group=alert_group,
organization=alert_receive_channel.organization,
key_name=label.key.name,
value_name=label.value.name,
)
for label in alert_receive_channel.labels.filter(inheritable=True).select_related("key", "value")
]
AlertGroupAssociatedLabel.objects.bulk_create(alert_group_labels)
def get_alert_group_label_verbal(alert_group: "AlertGroup") -> dict[str, str]:
"""This is different from get_label_verbal because alert group labels store key/value names, not IDs"""
return {label.key_name: label.value_name for label in alert_group.labels.all()}

View file

@ -34,7 +34,8 @@ class ResolutionNoteSerializer(EagerLoadingMixin, serializers.ModelSerializer):
SELECT_RELATED = ["alert_group", "resolution_note_slack_message", "author"]
def create(self, validated_data):
validated_data["author"] = self.context["request"].user
if self.context["request"].user.pk:
validated_data["author"] = self.context["request"].user
validated_data["source"] = ResolutionNote.Source.WEB
return super().create(validated_data)

View file

@ -1,10 +1,10 @@
from rest_framework import fields, serializers
from apps.alerts.models import AlertReceiveChannel, ChannelFilter, EscalationChain
from apps.api.serializers.alert_receive_channel import valid_jinja_template_for_serializer_method_field
from apps.base.messaging import get_messaging_backend_from_id, get_messaging_backends
from common.api_helpers.custom_fields import OrganizationFilteredPrimaryKeyRelatedField
from common.api_helpers.exceptions import BadRequest
from common.api_helpers.utils import valid_jinja_template_for_serializer_method_field
from common.jinja_templater.apply_jinja_template import JinjaTemplateError
from common.ordered_model.serializer import OrderedModelSerializer
from common.utils import is_regex_valid

View file

@ -1,9 +1,13 @@
from unittest.mock import patch
import pytest
from django.urls import reverse
from rest_framework import status
from rest_framework.test import APIClient
from apps.alerts.models import ResolutionNote
from apps.auth_token.auth import GRAFANA_SA_PREFIX, ApiTokenAuthentication, GrafanaServiceAccountAuthentication
from apps.auth_token.models import ApiAuthToken
@pytest.mark.django_db
@ -273,3 +277,75 @@ def test_delete_resolution_note(
assert response.status_code == status.HTTP_404_NOT_FOUND
assert response.data["detail"] == "Not found."
@pytest.mark.django_db
def test_create_resolution_note_grafana_auth(make_organization_and_user, make_alert_receive_channel, make_alert_group):
organization, user = make_organization_and_user()
client = APIClient()
alert_receive_channel = make_alert_receive_channel(organization)
alert_group = make_alert_group(alert_receive_channel)
url = reverse("api-public:resolution_notes-list")
data = {
"alert_group_id": alert_group.public_primary_key,
"text": "Test Resolution Note Message",
}
api_token_auth = ApiTokenAuthentication()
grafana_sa_auth = GrafanaServiceAccountAuthentication()
# GrafanaServiceAccountAuthentication handles empty auth
with patch(
"apps.auth_token.auth.ApiTokenAuthentication.authenticate", wraps=api_token_auth.authenticate
) as mock_api_key_auth, patch(
"apps.auth_token.auth.GrafanaServiceAccountAuthentication.authenticate", wraps=grafana_sa_auth.authenticate
) as mock_grafana_auth:
response = client.post(url, data=data, format="json")
mock_grafana_auth.assert_called_once()
mock_api_key_auth.assert_not_called()
assert response.status_code == status.HTTP_403_FORBIDDEN
token = "abc123"
# GrafanaServiceAccountAuthentication passes through api key auth
with patch(
"apps.auth_token.auth.ApiTokenAuthentication.authenticate", wraps=api_token_auth.authenticate
) as mock_api_key_auth, patch(
"apps.auth_token.auth.GrafanaServiceAccountAuthentication.authenticate", wraps=grafana_sa_auth.authenticate
) as mock_grafana_auth:
response = client.post(url, data=data, format="json", HTTP_AUTHORIZATION=f"{token}")
mock_grafana_auth.assert_called_once()
mock_api_key_auth.assert_called_once()
assert response.status_code == status.HTTP_403_FORBIDDEN
token = f"{GRAFANA_SA_PREFIX}123"
# GrafanaServiceAccountAuthentication handle invalid token
with patch(
"apps.auth_token.auth.ApiTokenAuthentication.authenticate", wraps=api_token_auth.authenticate
) as mock_api_key_auth, patch(
"apps.auth_token.auth.GrafanaServiceAccountAuthentication.authenticate", wraps=grafana_sa_auth.authenticate
) as mock_grafana_auth:
response = client.post(url, data=data, format="json", HTTP_AUTHORIZATION=f"{token}")
mock_grafana_auth.assert_called_once()
mock_api_key_auth.assert_not_called()
assert response.status_code == status.HTTP_403_FORBIDDEN
success_token = ApiAuthToken(organization=organization, user=user, name="Grafana Service Account")
# GrafanaServiceAccountAuthentication handle successful token
with patch(
"apps.auth_token.auth.GrafanaServiceAccountAuthentication.authenticate", return_value=(user, success_token)
):
response = client.post(url, data=data, format="json", HTTP_AUTHORIZATION=f"{token}")
assert response.status_code == status.HTTP_201_CREATED
resolution_note = ResolutionNote.objects.get(public_primary_key=response.data["id"])
result = {
"id": resolution_note.public_primary_key,
"alert_group_id": alert_group.public_primary_key,
"author": user.public_primary_key,
"source": resolution_note.get_source_display(),
"created_at": response.data["created_at"],
"text": data["text"],
}
assert response.data == result

View file

@ -16,7 +16,7 @@ from apps.public_api.helpers import is_valid_group_creation_date, team_has_slack
from apps.public_api.serializers import IncidentSerializer
from apps.public_api.throttlers.user_throttle import UserThrottle
from common.api_helpers.exceptions import BadRequest
from common.api_helpers.filters import ByTeamModelFieldFilterMixin, get_team_queryset
from common.api_helpers.filters import NO_TEAM_VALUE, ByTeamModelFieldFilterMixin, get_team_queryset
from common.api_helpers.mixins import RateLimitHeadersMixin
from common.api_helpers.paginators import FiftyPageSizePaginator
@ -27,7 +27,7 @@ class IncidentByTeamFilter(ByTeamModelFieldFilterMixin, filters.FilterSet):
queryset=get_team_queryset,
to_field_name="public_primary_key",
null_label="noteam",
null_value="null",
null_value=NO_TEAM_VALUE,
method=ByTeamModelFieldFilterMixin.filter_model_field_with_single_value.__name__,
)

View file

@ -5,7 +5,8 @@ from rest_framework.viewsets import ModelViewSet
from apps.alerts.models import ResolutionNote
from apps.alerts.tasks import send_update_resolution_note_signal
from apps.auth_token.auth import ApiTokenAuthentication
from apps.api.permissions import RBACPermission
from apps.auth_token.auth import ApiTokenAuthentication, GrafanaServiceAccountAuthentication
from apps.public_api.serializers.resolution_notes import ResolutionNoteSerializer, ResolutionNoteUpdateSerializer
from apps.public_api.throttlers.user_throttle import UserThrottle
from common.api_helpers.mixins import RateLimitHeadersMixin, UpdateSerializerMixin
@ -13,8 +14,18 @@ from common.api_helpers.paginators import FiftyPageSizePaginator
class ResolutionNoteView(RateLimitHeadersMixin, UpdateSerializerMixin, ModelViewSet):
authentication_classes = (ApiTokenAuthentication,)
permission_classes = (IsAuthenticated,)
authentication_classes = (GrafanaServiceAccountAuthentication, ApiTokenAuthentication)
permission_classes = (IsAuthenticated, RBACPermission)
rbac_permissions = {
"metadata": [RBACPermission.Permissions.ALERT_GROUPS_READ],
"list": [RBACPermission.Permissions.ALERT_GROUPS_READ],
"retrieve": [RBACPermission.Permissions.ALERT_GROUPS_READ],
"create": [RBACPermission.Permissions.ALERT_GROUPS_WRITE],
"update": [RBACPermission.Permissions.ALERT_GROUPS_WRITE],
"partial_update": [RBACPermission.Permissions.ALERT_GROUPS_WRITE],
"destroy": [RBACPermission.Permissions.ALERT_GROUPS_WRITE],
}
throttle_classes = [UserThrottle]

View file

@ -37,6 +37,18 @@ class SlackFormatter(SlackFormatterBase):
return message
def slack_to_accepted_emoji(self, message):
"""Overridden original method to fix regex that replaces dashes in links"""
message = re.sub(
r":([a-zA-Z0-9<>/:])([^ <>/:]+):", # overridden regex
lambda x: ":{}{}:".format(x.group(1), x.group(2).replace("-", "_")),
message,
)
# https://github.com/Ranks/emojione/issues/114
message = message.replace(":simple_smile:", ":slightly_smiling_face:")
return message
def _sub_hyperlink(self, matchobj):
compound = matchobj.group(0)[1:-1]
if len(compound.split("|")) == 2:

View file

@ -0,0 +1,16 @@
from unittest.mock import MagicMock
import pytest
from apps.slack.slack_formatter import SlackFormatter
@pytest.mark.django_db
def test_slack_to_accepted_emoji():
sf = SlackFormatter(MagicMock())
test_message = """[:book: Runbook:link:](https://example-test.com/explore?panes=%7B:%7Bname-with-dash%22:%22FE%22:%5B%7B%22another-one%22:namespace-with-dash)
Test emoji :male-construction-worker:https://another-example.com/test:=%22-dash
:female-construction-worker:"""
expected_result = test_message.replace("-construction-worker", "_construction_worker")
result = sf.slack_to_accepted_emoji(test_message)
assert result == expected_result

View file

@ -1,3 +1,5 @@
import typing
from apps.webhooks.models import Webhook
from apps.webhooks.presets.preset import WebhookPreset, WebhookPresetMetadata
@ -17,3 +19,6 @@ class AdvancedWebhookPreset(WebhookPreset):
def override_parameters_at_runtime(self, webhook: Webhook):
pass
def get_masked_headers(self) -> typing.List[str]:
return []

View file

@ -1,3 +1,4 @@
import typing
from abc import ABC, abstractmethod
from dataclasses import dataclass
from typing import List
@ -34,3 +35,8 @@ class WebhookPreset(ABC):
def override_parameters_at_runtime(self, webhook: Webhook):
"""Implement this to write parameters before the webhook is executed (These will not be persisted)"""
pass
@abstractmethod
def get_masked_headers(self) -> typing.List[str]:
"""Implement this to write sensitive header data as ******** when writing to logs"""
return []

View file

@ -1,3 +1,5 @@
import typing
from apps.webhooks.models import Webhook
from apps.webhooks.presets.preset import WebhookPreset, WebhookPresetMetadata
@ -30,3 +32,6 @@ class SimpleWebhookPreset(WebhookPreset):
def override_parameters_at_runtime(self, webhook: Webhook):
pass
def get_masked_headers(self) -> typing.List[str]:
return []

View file

@ -91,15 +91,17 @@ 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
def mask_authorization_header(headers):
def mask_authorization_header(headers, header_keys_to_mask):
masked_headers = headers.copy()
if "Authorization" in masked_headers:
masked_headers["Authorization"] = WEBHOOK_FIELD_PLACEHOLDER
lower_keys = set(k.lower() for k in header_keys_to_mask)
for k in headers.keys():
if k.lower() in lower_keys:
masked_headers[k] = WEBHOOK_FIELD_PLACEHOLDER
return masked_headers
@ -114,6 +116,7 @@ def make_request(webhook, alert_group, data):
"webhook": webhook,
"event_data": json.dumps(data),
}
masked_header_keys = ["Authorization"]
exception = error = None
try:
@ -121,7 +124,9 @@ def make_request(webhook, alert_group, data):
if webhook.preset not in WebhookPresetOptions.WEBHOOK_PRESETS:
raise Exception(f"Invalid preset {webhook.preset}")
else:
WebhookPresetOptions.WEBHOOK_PRESETS[webhook.preset].override_parameters_at_runtime(webhook)
preset = WebhookPresetOptions.WEBHOOK_PRESETS[webhook.preset]
preset.override_parameters_at_runtime(webhook)
masked_header_keys.extend(preset.get_masked_headers())
if not webhook.check_integration_filter(alert_group):
status["request_trigger"] = NOT_FROM_SELECTED_INTEGRATION
@ -131,7 +136,7 @@ def make_request(webhook, alert_group, data):
if triggered:
status["url"] = webhook.build_url(data)
request_kwargs = webhook.build_request_kwargs(data, raise_data_errors=True)
display_headers = mask_authorization_header(request_kwargs.get("headers", {}))
display_headers = mask_authorization_header(request_kwargs.get("headers", {}), masked_header_keys)
status["request_headers"] = json.dumps(display_headers)
if "json" in request_kwargs:
status["request_data"] = json.dumps(request_kwargs["json"])

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

@ -1,8 +1,11 @@
import json
import typing
from unittest.mock import patch
import pytest
from apps.webhooks.models import Webhook
from apps.webhooks.models.webhook import WEBHOOK_FIELD_PLACEHOLDER
from apps.webhooks.presets.preset import WebhookPreset, WebhookPresetMetadata
from apps.webhooks.tasks.trigger_webhook import make_request
from apps.webhooks.tests.test_trigger_webhook import MockResponse
@ -14,6 +17,8 @@ TEST_WEBHOOK_LOGO = "test_logo"
TEST_WEBHOOK_PRESET_DESCRIPTION = "Description of test webhook preset"
TEST_WEBHOOK_PRESET_CONTROLLED_FIELDS = ["url", "http_method", "data", "authorization_header"]
TEST_WEBHOOK_AUTHORIZATION_HEADER = "Test Auth header 12345"
TEST_WEBHOOK_MASK_HEADER = "X-Secret-Header"
TEST_WEBHOOK_MASK_HEADER_VALUE = "abc123"
INVALID_PRESET_ID = "invalid_preset_id"
@ -34,6 +39,12 @@ class TestWebhookPreset(WebhookPreset):
def override_parameters_at_runtime(self, webhook: Webhook):
webhook.authorization_header = TEST_WEBHOOK_AUTHORIZATION_HEADER
webhook.headers = json.dumps(
{"Content-Type": "application/json", TEST_WEBHOOK_MASK_HEADER: TEST_WEBHOOK_MASK_HEADER_VALUE}
)
def get_masked_headers(self) -> typing.List[str]:
return [TEST_WEBHOOK_MASK_HEADER]
@pytest.mark.django_db
@ -124,11 +135,20 @@ def test_webhook_preset_runtime_override(make_organization, webhook_preset_api_s
with patch.object(webhook, "build_url"):
response = MockResponse()
with patch.object(webhook, "make_request", return_value=response) as mock_make_request:
triggered, webhook_status, error, exception = make_request(webhook, None, None)
triggered, webhook_status, error, exception = make_request(webhook, None, {})
assert mock_make_request.call_args.args[1]["headers"]["Content-Type"] == "application/json"
assert mock_make_request.call_args.args[1]["headers"]["Authorization"] == TEST_WEBHOOK_AUTHORIZATION_HEADER
assert (
mock_make_request.call_args.args[1]["headers"][TEST_WEBHOOK_MASK_HEADER]
== TEST_WEBHOOK_MASK_HEADER_VALUE
)
assert triggered
assert error is None
assert exception is None
webhook_status_headers = json.loads(webhook_status["request_headers"])
assert webhook_status_headers["Content-Type"] == "application/json"
assert webhook_status_headers["Authorization"] == WEBHOOK_FIELD_PLACEHOLDER
assert webhook_status_headers[TEST_WEBHOOK_MASK_HEADER] == WEBHOOK_FIELD_PLACEHOLDER
webhook.refresh_from_db()
assert webhook.authorization_header is None

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

@ -7,6 +7,8 @@ from django_filters.utils import handle_timezone
from apps.user_management.models import Team
from common.api_helpers.exceptions import BadRequest
NO_TEAM_VALUE = "null"
class DateRangeFilterMixin:
DATE_FORMAT = "%Y-%m-%dT%H:%M:%S"
@ -100,7 +102,7 @@ class ByTeamFilter(ByTeamModelFieldFilterMixin, filters.FilterSet):
queryset=get_team_queryset,
to_field_name="public_primary_key",
null_label="noteam",
null_value="null",
null_value=NO_TEAM_VALUE,
method=ByTeamModelFieldFilterMixin.filter_model_field_with_single_value.__name__,
)
@ -112,7 +114,7 @@ class TeamModelMultipleChoiceFilter(filters.ModelMultipleChoiceFilter):
queryset=get_team_queryset,
to_field_name="public_primary_key",
null_label="noteam",
null_value="null",
null_value=NO_TEAM_VALUE,
method=ByTeamModelFieldFilterMixin.filter_model_field_with_multiple_values.__name__,
):
super().__init__(

View file

@ -248,6 +248,7 @@ ACKNOWLEDGE_CONDITION = "acknowledge_condition"
GROUPING_ID = "grouping_id"
SOURCE_LINK = "source_link"
ROUTE = "route"
ALERT_GROUP_LABELS = "alert_group_labels"
NOTIFICATION_CHANNEL_TO_TEMPLATER_MAP = {
SLACK: AlertSlackTemplater,
@ -264,9 +265,15 @@ for backend_id, backend in get_messaging_backends():
NOTIFICATION_CHANNEL_TO_TEMPLATER_MAP[backend.slug] = backend.get_templater_class()
APPEARANCE_TEMPLATE_NAMES = [TITLE, MESSAGE, IMAGE_URL]
BEHAVIOUR_TEMPLATE_NAMES = [RESOLVE_CONDITION, ACKNOWLEDGE_CONDITION, GROUPING_ID, SOURCE_LINK]
ROUTE_TEMPLATE_NAMES = [ROUTE]
ALL_TEMPLATE_NAMES = APPEARANCE_TEMPLATE_NAMES + BEHAVIOUR_TEMPLATE_NAMES + ROUTE_TEMPLATE_NAMES
BEHAVIOUR_TEMPLATE_NAMES = [
RESOLVE_CONDITION,
ACKNOWLEDGE_CONDITION,
GROUPING_ID,
SOURCE_LINK,
ROUTE,
ALERT_GROUP_LABELS,
]
ALL_TEMPLATE_NAMES = APPEARANCE_TEMPLATE_NAMES + BEHAVIOUR_TEMPLATE_NAMES
class PreviewTemplateException(Exception):
@ -326,11 +333,6 @@ class PreviewTemplateMixin:
templated_attr = apply_jinja_template(template_body, payload=alert_to_template.raw_request_data)
except (JinjaTemplateError, JinjaTemplateWarning) as e:
return Response({"preview": e.fallback_message}, status.HTTP_200_OK)
elif attr_name in ROUTE_TEMPLATE_NAMES:
try:
templated_attr = apply_jinja_template(template_body, payload=alert_to_template.raw_request_data)
except (JinjaTemplateError, JinjaTemplateWarning) as e:
return Response({"preview": e.fallback_message}, status.HTTP_200_OK)
else:
templated_attr = None
response = {"preview": templated_attr}
@ -346,8 +348,6 @@ class PreviewTemplateMixin:
destination = None
if template_param.startswith(tuple(BEHAVIOUR_TEMPLATE_NAMES)):
attr_name = template_param
if template_param.startswith(tuple(ROUTE_TEMPLATE_NAMES)):
attr_name = template_param
elif template_param.startswith(tuple(NOTIFICATION_CHANNEL_OPTIONS)):
for notification_channel in NOTIFICATION_CHANNEL_OPTIONS:
if template_param.startswith(notification_channel):

View file

@ -14,6 +14,8 @@ from rest_framework.request import Request
from apps.schedules.ical_utils import fetch_ical_file
from common.api_helpers.exceptions import BadRequest
from common.jinja_templater import apply_jinja_template
from common.jinja_templater.apply_jinja_template import JinjaTemplateWarning
from common.timezones import raise_exception_if_not_valid_timezone
@ -165,3 +167,12 @@ def check_phone_number_is_valid(phone_number):
def serialize_datetime_as_utc_timestamp(dt: datetime.datetime) -> str:
return dt.strftime("%Y-%m-%dT%H:%M:%S.%fZ")
def valid_jinja_template_for_serializer_method_field(template):
for _, val in template.items():
try:
apply_jinja_template(val, payload={})
except JinjaTemplateWarning:
# Suppress warnings, template may be valid with payload
pass

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
@ -953,7 +954,13 @@ def webhook_preset_api_setup():
@pytest.fixture
def make_label_key():
def _make_label_key(organization, **kwargs):
def _make_label_key(organization, key_id=None, key_name=None, **kwargs):
if key_id is not None:
kwargs["id"] = key_id
if key_name is not None:
kwargs["name"] = key_name
return LabelKeyFactory(organization=organization, **kwargs)
return _make_label_key
@ -961,7 +968,13 @@ def make_label_key():
@pytest.fixture
def make_label_value():
def _make_label_value(key, **kwargs):
def _make_label_value(key, value_id=None, value_name=None, **kwargs):
if value_id is not None:
kwargs["id"] = value_id
if value_name is not None:
kwargs["name"] = value_name
return LabelValueFactory(key=key, **kwargs)
return _make_label_value
@ -969,9 +982,9 @@ def make_label_value():
@pytest.fixture
def make_label_key_and_value(make_label_key, make_label_value):
def _make_label_key_and_value(organization):
key = make_label_key(organization=organization)
value = make_label_value(key=key)
def _make_label_key_and_value(organization, key_id=None, key_name=None, value_id=None, value_name=None):
key = make_label_key(organization=organization, key_id=key_id, key_name=key_name)
value = make_label_value(key=key, value_id=value_id, value_name=value_name)
return key, value
return _make_label_key_and_value
@ -979,8 +992,12 @@ def make_label_key_and_value(make_label_key, make_label_value):
@pytest.fixture
def make_integration_label_association(make_label_key_and_value):
def _make_integration_label_association(organization, alert_receive_channel, **kwargs):
key, value = make_label_key_and_value(organization)
def _make_integration_label_association(
organization, alert_receive_channel, key_id=None, key_name=None, value_id=None, value_name=None, **kwargs
):
key, value = make_label_key_and_value(
organization, key_id=key_id, key_name=key_name, value_id=value_id, value_name=value_name
)
return AlertReceiveChannelAssociatedLabelFactory(
alert_receive_channel=alert_receive_channel, organization=organization, key=key, value=value, **kwargs
)
@ -994,3 +1011,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

@ -46,9 +46,6 @@ class StartupProbeView(View):
if cache.get(AlertChannelDefiningMixin.CACHE_KEY_DB_FALLBACK) is None:
AlertChannelDefiningMixin().update_alert_receive_channel_cache()
cache.set("healthcheck", "healthcheck", 30) # Checking cache connectivity
assert cache.get("healthcheck") == "healthcheck"
return HttpResponse("Ok")

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",
@ -115,7 +115,7 @@
"@grafana/data": "^9.2.4",
"@grafana/faro-web-sdk": "^1.0.0-beta4",
"@grafana/faro-web-tracing": "^1.0.0-beta4",
"@grafana/labels": "~1.2.1",
"@grafana/labels": "1.3.4",
"@grafana/runtime": "9.3.0-beta1",
"@grafana/ui": "^9.4.7",
"@opentelemetry/api": "^1.3.0",

View file

@ -1,4 +1,4 @@
import { PlaywrightTestConfig, PlaywrightTestProject, defineConfig, devices } from '@playwright/test';
import { PlaywrightTestProject, defineConfig, devices } from '@playwright/test';
import path from 'path';
/**

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

@ -21,14 +21,15 @@ const LabelsFilter: FC<LabelsFilterProps> = (props) => {
const [search, setSearch] = useState('');
const handleChange = useCallback((value) => {
onChange(value.map((v) => v.value));
onChange(value.map((v) => v.data));
}, []);
const handleLoadOptions = (search) => {
return onLoadOptions(search).then((options) =>
options.map((v) => ({
label: `${v.key[FieldName]} : ${v.value[FieldName]}`,
value: v,
value: `${v.key[FieldName]} : ${v.value[FieldName]}`,
data: v,
}))
);
};
@ -37,7 +38,8 @@ const LabelsFilter: FC<LabelsFilterProps> = (props) => {
() =>
propsValue.map((v) => ({
label: `${v.key[FieldName]} : ${v.value[FieldName]}`,
value: v,
value: `${v.key[FieldName]} : ${v.value[FieldName]}`,
data: v,
})),
[propsValue]
);

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} />
<Button
size="sm"
icon="filter"
tooltip="Apply filter"
variant="secondary"
onClick={() => onClick(label)}
/>
</HorizontalGroup>
))}
</VerticalGroup>
}
/>
) : null;
export default LabelsTooltipBadge;

View file

@ -99,6 +99,7 @@ const MonacoEditor: FC<MonacoEditorProps> = (props) => {
height={height}
onEditorDidMount={handleMount}
getSuggestions={useAutoCompleteList ? autoCompleteList : undefined}
containerStyles="u-width-100"
/>
);
};

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,18 +1,37 @@
import React, { useState } from 'react';
import React, { ChangeEvent, useCallback, useState } from 'react';
import { Button, Drawer, HorizontalGroup, Icon, InlineSwitch, Input, Label, Tooltip, VerticalGroup } from '@grafana/ui';
import { ServiceLabels } from '@grafana/labels';
import {
Button,
Drawer,
Dropdown,
HorizontalGroup,
Icon,
InlineSwitch,
Input,
Label,
Menu,
Tooltip,
VerticalGroup,
} from '@grafana/ui';
import cn from 'classnames/bind';
import { observer } from 'mobx-react';
import Collapse from 'components/Collapse/Collapse';
import MonacoEditor, { MONACO_LANGUAGE } from 'components/MonacoEditor/MonacoEditor';
import Text from 'components/Text/Text';
import IntegrationTemplate from 'containers/IntegrationTemplate/IntegrationTemplate';
import { AlertReceiveChannel } from 'models/alert_receive_channel/alert_receive_channel.types';
import { LabelKey } from 'models/label/label.types';
import { useStore } from 'state/useStore';
import { openErrorNotification } from 'utils';
import styles from './IntegrationLabelsForm.module.css';
const cx = cn.bind(styles);
const INPUT_WIDTH = 280;
interface IntegrationLabelsFormProps {
id: AlertReceiveChannel['id'];
onSubmit: () => void;
@ -25,9 +44,13 @@ const IntegrationLabelsForm = observer((props: IntegrationLabelsFormProps) => {
const store = useStore();
const [showTemplateEditor, setShowTemplateEditor] = useState<boolean>(false);
const [customLabelIndexToShowTemplateEditor, setCustomLabelIndexToShowTemplateEditor] = useState<number>(undefined);
const { alertReceiveChannelStore } = store;
const alertReceiveChannel = alertReceiveChannelStore.items[id];
const templates = alertReceiveChannelStore.templates[id];
const [alertGroupLabels, setAlertGroupLabels] = useState(alertReceiveChannel.alert_group_labels);
@ -55,51 +78,273 @@ const IntegrationLabelsForm = observer((props: IntegrationLabelsFormProps) => {
};
return (
<Drawer scrollableContent title="Alert group labels" onClose={onHide} closeOnMaskClick={false} width="640px">
<VerticalGroup>
<HorizontalGroup spacing="xs" align="flex-start">
<Label>Inherited labels</Label>
<Tooltip content="Labels inherited from integration">
<Icon name="info-circle" className={cx('extra-fields__icon')} />
</Tooltip>
</HorizontalGroup>
<ul className={cx('labels-list')}>
{alertReceiveChannel.labels.length ? (
alertReceiveChannel.labels.map((label) => (
<li key={label.key.id}>
<HorizontalGroup spacing="xs">
<Input width={38} value={label.key.name} disabled />
<Input width={31} value={label.value.name} disabled />
<InlineSwitch
value={alertGroupLabels.inheritable[label.key.id]}
transparent
onChange={getInheritanceChangeHandler(label.key.id)}
/>
</HorizontalGroup>
</li>
))
) : (
<>
<Drawer scrollableContent title="Alert group labels" onClose={onHide} closeOnMaskClick={false} width="640px">
<VerticalGroup spacing="lg">
<VerticalGroup>
<HorizontalGroup spacing="xs" align="flex-start">
<Label>Inherited labels</Label>
<Tooltip content="Labels inherited from integration">
<Icon name="info-circle" className={cx('extra-fields__icon')} />
</Tooltip>
</HorizontalGroup>
{alertReceiveChannel.labels.length ? (
<ul className={cx('labels-list')}>
{alertReceiveChannel.labels.map((label) => (
<li key={label.key.id}>
<HorizontalGroup spacing="xs">
<Input width={INPUT_WIDTH / 8} value={label.key.name} disabled />
<Input width={INPUT_WIDTH / 8} value={label.value.name} disabled />
<InlineSwitch
value={alertGroupLabels.inheritable[label.key.id]}
transparent
onChange={getInheritanceChangeHandler(label.key.id)}
/>
</HorizontalGroup>
</li>
))}
</ul>
) : (
<VerticalGroup>
<Text type="secondary">There are no labels to inherit yet</Text>
<Text type="link" onClick={handleOpenIntegrationSettings} clickable>
Add labels to the integration
</Text>
</VerticalGroup>
)}
</VerticalGroup>
<CustomLabels
alertGroupLabels={alertGroupLabels}
onChange={setAlertGroupLabels}
onShowTemplateEditor={setCustomLabelIndexToShowTemplateEditor}
/>
<Collapse isOpen={false} label="Advanced label templating">
<VerticalGroup>
<Text type="secondary">There are no labels to inherit yet</Text>
<Text type="link" onClick={handleOpenIntegrationSettings} clickable>
Add labels to the integration
</Text>
<HorizontalGroup justify="space-between" style={{ marginBottom: '10px' }}>
<Text type="secondary">Jinja2 template to parse all labels at once</Text>
<Button
variant="secondary"
icon="edit"
onClick={() => {
setShowTemplateEditor(true);
}}
/>
</HorizontalGroup>
<MonacoEditor
value={alertGroupLabels.template}
height="200px"
data={{}}
showLineNumbers={false}
language={MONACO_LANGUAGE.jinja2}
onChange={(value) => {
setAlertGroupLabels({ ...alertGroupLabels, template: value });
}}
/>
</VerticalGroup>
)}
</ul>
<div className={cx('buttons')}>
<HorizontalGroup justify="flex-end">
<Button variant="secondary" onClick={onHide}>
Close
</Button>
<Button variant="primary" onClick={handleSave}>
Save
</Button>
</HorizontalGroup>
</div>
</VerticalGroup>
</Drawer>
</Collapse>
<div className={cx('buttons')}>
<HorizontalGroup justify="flex-end">
<Button variant="secondary" onClick={onHide}>
Close
</Button>
<Button variant="primary" onClick={handleSave}>
Save
</Button>
</HorizontalGroup>
</div>
</VerticalGroup>
</Drawer>
{customLabelIndexToShowTemplateEditor !== undefined && (
<IntegrationTemplate
id={id}
template={{
name: 'alert_group_labels',
displayName: ``,
}}
templates={templates}
templateBody={alertGroupLabels.custom[customLabelIndexToShowTemplateEditor].value.name}
onHide={() => setCustomLabelIndexToShowTemplateEditor(undefined)}
onUpdateTemplates={({ alert_group_labels }) => {
const newCustom = [...alertGroupLabels.custom];
newCustom[customLabelIndexToShowTemplateEditor].value.name = alert_group_labels;
setAlertGroupLabels({
...alertGroupLabels,
custom: newCustom,
});
setCustomLabelIndexToShowTemplateEditor(undefined);
}}
/>
)}
{showTemplateEditor && (
<IntegrationTemplate
id={id}
template={{
name: 'alert_group_labels',
displayName: ``,
}}
templates={templates}
templateBody={alertGroupLabels.template}
onHide={() => setShowTemplateEditor(false)}
onUpdateTemplates={({ alert_group_labels }) => {
setAlertGroupLabels({
...alertGroupLabels,
template: alert_group_labels,
});
setShowTemplateEditor(undefined);
}}
/>
)}
</>
);
});
interface CustomLabelsProps {
alertGroupLabels: AlertReceiveChannel['alert_group_labels'];
onChange: (value: AlertReceiveChannel['alert_group_labels']) => void;
onShowTemplateEditor: (index: number) => void;
}
const CustomLabels = (props: CustomLabelsProps) => {
const { alertGroupLabels, onChange, onShowTemplateEditor } = props;
const { labelsStore } = useStore();
const handlePlainLabelAdd = () => {
onChange({
...alertGroupLabels,
custom: [
...alertGroupLabels.custom,
{
key: { id: undefined, name: undefined },
value: { id: undefined, name: undefined },
},
],
});
};
const handleTemplatedLabelAdd = () => {
onChange({
...alertGroupLabels,
custom: [
...alertGroupLabels.custom,
{
key: { id: undefined, name: undefined },
value: { id: null, name: undefined }, // id = null means it's a templated value
},
],
});
};
const cachedOnLoadKeys = useCallback(() => {
let result = undefined;
return async (search?: string) => {
if (!result) {
try {
result = await labelsStore.loadKeys();
} catch (error) {
openErrorNotification('There was an error processing your request. Please try again');
}
}
return result.filter((k) => k.name.toLowerCase().includes(search.toLowerCase()));
};
}, []);
const cachedOnLoadValuesForKey = useCallback(() => {
let result = undefined;
return async (key: string, search?: string) => {
if (!result) {
try {
const { values } = await labelsStore.loadValuesForKey(key, search);
result = values;
} catch (error) {
openErrorNotification('There was an error processing your request. Please try again');
}
}
return result.filter((k) => k.name.toLowerCase().includes(search.toLowerCase()));
};
}, []);
return (
<VerticalGroup>
<HorizontalGroup spacing="xs" align="flex-start">
<Label>Custom labels</Label>
</HorizontalGroup>
<ServiceLabels
isAddingDisabled
loadById
inputWidth={INPUT_WIDTH}
value={alertGroupLabels.custom}
onLoadKeys={cachedOnLoadKeys()}
onLoadValuesForKey={cachedOnLoadValuesForKey()}
onCreateKey={labelsStore.createKey.bind(labelsStore)}
onUpdateKey={labelsStore.updateKey.bind(labelsStore)}
onCreateValue={labelsStore.createValue.bind(labelsStore)}
onUpdateValue={labelsStore.updateKeyValue.bind(labelsStore)}
onUpdateError={(res) => {
if (res?.response?.status === 409) {
openErrorNotification(`Duplicate values are not allowed`);
} else {
openErrorNotification('An error has occurred. Please try again');
}
}}
renderValue={(option, index, renderValueDefault) => {
if (option.value.id === null) {
return (
<Input
placeholder="Jinja2 template"
autoFocus
disabled={!alertGroupLabels.custom[index].key.id}
width={INPUT_WIDTH / 8}
value={option.value.name}
addonAfter={
<Button
variant="secondary"
icon="edit"
onClick={() => {
onShowTemplateEditor(index);
}}
/>
}
onChange={(e: ChangeEvent<HTMLInputElement>) => {
const newCustom = [...alertGroupLabels.custom];
newCustom[index].value.name = e.currentTarget.value;
onChange({ ...alertGroupLabels, custom: newCustom });
}}
/>
);
} else {
return renderValueDefault(option, index);
}
}}
onDataUpdate={(value) => {
onChange({
...alertGroupLabels,
custom: value,
});
}}
/>
<Dropdown
overlay={
<Menu>
<Menu.Item label="Plain label" onClick={handlePlainLabelAdd} />
<Menu.Item label="Templated label" onClick={handleTemplatedLabelAdd} />
</Menu>
}
>
<Button variant="secondary" icon="plus">
Add
</Button>
</Dropdown>
</VerticalGroup>
);
};
export default IntegrationLabelsForm;

View file

@ -5,6 +5,7 @@ import cn from 'classnames/bind';
import { debounce } from 'lodash-es';
import { observer } from 'mobx-react';
import { templateForEdit } from 'components/AlertTemplates/AlertTemplatesForm.config';
import { TemplateForEdit } from 'components/AlertTemplates/CommonAlertTemplatesForm.config';
import CheatSheet from 'components/CheatSheet/CheatSheet';
import {
@ -38,7 +39,7 @@ interface IntegrationTemplateProps {
templates: AlertTemplatesDTO[];
onHide: () => void;
onUpdateTemplates: (values: any) => void;
onUpdateRoute: (values: any, channelFilterId?: ChannelFilter['id']) => void;
onUpdateRoute?: (values: any, channelFilterId?: ChannelFilter['id']) => void;
}
const IntegrationTemplate = observer((props: IntegrationTemplateProps) => {
@ -53,11 +54,13 @@ const IntegrationTemplate = observer((props: IntegrationTemplateProps) => {
const [isRecentAlertGroupExisting, setIsRecentAlertGroupExisting] = useState<boolean>(false);
useEffect(() => {
const locationParams: any = { template: template.name };
if (template.isRoute) {
locationParams.routeId = channelFilterId;
if (templateForEdit[template.name]) {
const locationParams: any = { template: template.name };
if (template.isRoute) {
locationParams.routeId = channelFilterId;
}
LocationHelper.update(locationParams, 'partial');
}
LocationHelper.update(locationParams, 'partial');
}, []);
useEffect(() => {

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

@ -75,7 +75,7 @@ class RemoteFilters extends Component<RemoteFiltersProps, RemoteFiltersState> {
const { filterOptions } = this.state;
let { filters, values } = parseFilters(query, filterOptions, query);
let { filters, values } = parseFilters({ ...query, ...filtersStore.globalValues }, filterOptions, query);
this.setState({ filterOptions, filters, values }, () => this.onChange());
}
@ -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));
@ -273,6 +273,7 @@ class RemoteFilters extends Component<RemoteFiltersProps, RemoteFiltersState> {
value={values[filter.name]}
onChange={this.getRemoteOptionsChangeHandler(filter.name)}
getOptionLabel={(item: SelectableValue) => <Emoji text={item.label || ''} />}
predefinedOptions={filter.default ? [filter.default] : undefined}
/>
);

View file

@ -27,6 +27,7 @@ interface RemoteSelectProps {
showError?: boolean;
maxMenuHeight?: number;
requiredUserAction?: UserAction;
predefinedOptions?: any[];
}
const RemoteSelect = inject('store')(
@ -49,6 +50,7 @@ const RemoteSelect = inject('store')(
showError,
maxMenuHeight,
requiredUserAction,
predefinedOptions,
} = props;
const [noOptionsMessage, setNoOptionsMessage] = useState<string>('No options found');
@ -66,7 +68,7 @@ const RemoteSelect = inject('store')(
return oldOptions.concat(newOptions.filter(({ value }) => !existingValues.includes(value)));
}
const [options, setOptions] = useReducer(mergeOptions, []);
const [options, setOptions] = useReducer(mergeOptions, getOptions(predefinedOptions || []));
const loadOptionsCallback = useDebouncedCallback(async (query: string, cb) => {
try {

View file

@ -49,7 +49,11 @@ export interface AlertReceiveChannel {
allow_delete: boolean;
deleted?: boolean;
labels: LabelKeyValue[];
alert_group_labels: { inheritable: Record<LabelKeyValue['key']['id'], boolean> };
alert_group_labels: {
inheritable: Record<LabelKeyValue['key']['id'], boolean>;
custom: LabelKeyValue[];
template: string;
};
}
export interface AlertReceiveChannelChoice {

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

@ -291,7 +291,13 @@ class SchedulePage extends React.Component<SchedulePageProps, SchedulePageState>
shiftSwapIdToShowForm
? this.adjustShiftSwapForm
: (event: Event) => {
this.handleShowForm(event.shift.pk);
const shiftId = event.shift.pk;
if (event.shift.type === 2 && !disabledRotationForm) {
this.handleShowRotationForm(shiftId);
} else if (event.shift.type === 3 && !disabledOverrideForm) {
this.handleShowOverridesForm(shiftId);
}
}
}
/>
@ -382,20 +388,6 @@ class SchedulePage extends React.Component<SchedulePageProps, SchedulePageState>
});
};
handleShowForm = async (shiftId: Shift['id'] | 'new') => {
const {
store: { scheduleStore },
} = this.props;
const shift = await scheduleStore.updateOncallShift(shiftId);
if (shift.type === 2) {
this.handleShowRotationForm(shiftId);
} else if (shift.type === 3) {
this.handleShowOverridesForm(shiftId);
}
};
handleShowRotationForm = (shiftId: Shift['id'] | 'new') => {
this.setState({ shiftIdToShowRotationForm: shiftId });
};

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

View file

@ -1966,10 +1966,10 @@
"@opentelemetry/sdk-trace-web" "^1.8.0"
"@opentelemetry/semantic-conventions" "^1.8.0"
"@grafana/labels@~1.2.1":
version "1.2.1"
resolved "https://registry.yarnpkg.com/@grafana/labels/-/labels-1.2.1.tgz#4113d584bf5cd826d011f957cb69c90bd0416ea8"
integrity sha512-Nlqqvjwh0MjWsqnfpYbKdYwByeKSmEpiit5mKd6Mnnbc5Hxb8ORIruMr40lTxxWLEnDfhENcAs6pvlBuIMG7tQ==
"@grafana/labels@1.3.4":
version "1.3.4"
resolved "https://registry.yarnpkg.com/@grafana/labels/-/labels-1.3.4.tgz#8d9cdd215a80a1da1045d402c037be85d7efd6f5"
integrity sha512-YYCuLGvtrMz7KkbMc6qoNJQr6drDLo6mMI27LcqsTDMHCNO3uJWpzC1Q2Y9MIwctIuTFYhbgfLvIunEegCx6PQ==
dependencies:
"@emotion/css" "^11.11.2"
"@grafana/ui" "^10.0.0"