diff --git a/CHANGELOG.md b/CHANGELOG.md index 2547d8791e..fcda86cb0c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## Unreleased +### Added + +- Jinja2 based routes ([1319](https://github.com/grafana/oncall/pull/1319)) + ### Fixed - Prohibit creating & updating past overrides ([1474](https://github.com/grafana/oncall/pull/1474)) diff --git a/docs/sources/escalation-policies/configure-routes/index.md b/docs/sources/escalation-policies/configure-routes/index.md index 71946e073b..b4475fd21e 100644 --- a/docs/sources/escalation-policies/configure-routes/index.md +++ b/docs/sources/escalation-policies/configure-routes/index.md @@ -55,12 +55,16 @@ You can set up a single route and specify notification escalation steps, or you its own configuration. Each route added to an escalation policy follows an `IF`, `ELSE IF`, or `ELSE` path and depends on the type of alert you -specify using a regular expression that matches content in the payload body of the alert. You can also specify where -to send the notification for each route. +specify using a Jinja template that matches content in the payload body of the first alert in alert group. You can also +specify where to send the notification for each route. -For example, you can send notifications for alerts with `\"severity\": \"critical\"` in the payload to an escalation -chain called `Bob_OnCall`. You can create a different route for alerts with the payload -`\"namespace\" *: *\"synthetic-monitoring-dev-.*\"` and select a escalation chain called `NotifySecurity`. +For example, you can send notifications for alerts with `{{ payload.severity == "critical" and payload.service == +"database" }}` in the payload to an escalation chain called `Bob_OnCall`. You can create a different route for alerts +with the payload `{{ "synthetic-monitoring-dev-" in payload.namespace }}` and select a escalation chain called +`NotifySecurity`. + +Alternatively you can use regular expressions, e.g. `\"severity\": \"critical\"` or `\"namespace\" *: +*\"synthetic-monitoring-dev-.*\"` You can set up escalation steps for each route in a chain. diff --git a/docs/sources/get-started/_index.md b/docs/sources/get-started/_index.md index 0a4b803338..46a6704f79 100644 --- a/docs/sources/get-started/_index.md +++ b/docs/sources/get-started/_index.md @@ -52,7 +52,7 @@ send a demo alert. #### Configure your first integration -1. In Grafana OnCall, navigate to the **Integrations** tab and click **+ New integration for receiving alerts**. +1. In Grafana OnCall, navigate to the **Integrations** tab and click **+ New integration to receive alerts**. 2. Select an integration from the provided options, if the integration you’re looking for isn’t listed, then select Webhook. 3. Follow the configuration steps on the integration settings page. 4. Complete any necessary configurations in your monitoring system to send alerts to Grafana OnCall. diff --git a/docs/sources/integrations/_index.md b/docs/sources/integrations/_index.md index 8d6247ac80..e575df093e 100644 --- a/docs/sources/integrations/_index.md +++ b/docs/sources/integrations/_index.md @@ -33,7 +33,7 @@ describe how to configure and customize your integrations to ensure alerts are t To configure an integration for Grafana OnCall: -1. In Grafana OnCall, navigate to the **Integrations** tab and click **+ New integration for receiving alerts**. +1. In Grafana OnCall, navigate to the **Integrations** tab and click **+ New integration to receive alerts**. 2. Select an integration from the provided options, if the integration you want isn’t listed, then select **Webhook**. 3. Follow the configuration steps on the integration settings page. 4. Complete any necessary configurations in your tool to send alerts to Grafana OnCall. diff --git a/docs/sources/integrations/available-integrations/configure-alertmanager/index.md b/docs/sources/integrations/available-integrations/configure-alertmanager/index.md index a56cc498d4..a0e59e0637 100644 --- a/docs/sources/integrations/available-integrations/configure-alertmanager/index.md +++ b/docs/sources/integrations/available-integrations/configure-alertmanager/index.md @@ -25,7 +25,7 @@ alerts from Alertmanager, including initial deduplicating, grouping, and routing You must have an Admin role to create integrations in Grafana OnCall. -1. In the **Integrations** tab, click **+ New integration for receiving alerts**. +1. In the **Integrations** tab, click **+ New integration to receive alerts**. 2. Select **Alertmanager** from the list of available integrations. 3. Follow the instructions in the **How to connect** window to get your unique integration URL and identify next steps. diff --git a/docs/sources/integrations/available-integrations/configure-grafana-alerting/index.md b/docs/sources/integrations/available-integrations/configure-grafana-alerting/index.md index 5bd8ae9c13..f6beab61f1 100644 --- a/docs/sources/integrations/available-integrations/configure-grafana-alerting/index.md +++ b/docs/sources/integrations/available-integrations/configure-grafana-alerting/index.md @@ -25,7 +25,7 @@ Grafana Alerting for Grafana OnCall can be set up using two methods: You must have an Admin role to create integrations in Grafana OnCall. -1. In the **Integrations** tab, click **+ New integration for receiving alerts**. +1. In the **Integrations** tab, click **+ New integration to receive alerts**. 2. Select **Grafana Alerting** by clicking the **Quick connect** button or select **Grafana (Other Grafana)** from the integrations list. 3. Follow the configuration steps that display in the **How to connect** window to retrieve your unique integration URL @@ -36,7 +36,7 @@ You must have an Admin role to create integrations in Grafana OnCall. Use the following method if you are connecting Grafana OnCall with alerts coming from the same Grafana instance from which Grafana OnCall is being managed. -1. In Grafana OnCall, navigate to the **Integrations** tab and select **New Integration for receiving alerts**. +1. In Grafana OnCall, navigate to the **Integrations** tab and select **New Integration to receive alerts**. 1. Click **Quick connect** in the **Grafana Alerting** tile. This will automatically create the integration in Grafana OnCall as well as the required contact point in Alerting. @@ -54,7 +54,7 @@ which Grafana OnCall is being managed. Connect Grafana OnCall with alerts coming from a Grafana instance that is different from the instance that Grafana OnCall is being managed: -1. In Grafana OnCall, navigate to the **Integrations** tab and select **New Integration for receiving alerts**. +1. In Grafana OnCall, navigate to the **Integrations** tab and select **New Integration to receive alerts**. 2. Select the **Grafana (Other Grafana)** tile. 3. Follow the configuration steps that display in the **How to connect** window to retrieve your unique integration URL and complete any necessary configurations. diff --git a/docs/sources/integrations/available-integrations/configure-webhook/index.md b/docs/sources/integrations/available-integrations/configure-webhook/index.md index 5319cdc692..d3cf863216 100644 --- a/docs/sources/integrations/available-integrations/configure-webhook/index.md +++ b/docs/sources/integrations/available-integrations/configure-webhook/index.md @@ -38,7 +38,7 @@ There are two available formats, **Webhook** and **Formatted Webhook**. To configure a webhook integration: -1. In the **Integrations** tab, click **+ New integration for receiving alerts**. +1. In the **Integrations** tab, click **+ New integration to receive alerts**. 2. Select either **Webhook** or **Formatted Webhook** integration. 3. Follow the configuration steps in the **How to connect** section of the integration settings. 4. Use the unique webhook URL to complete any configuration in your monitoring service to send POST requests. Use any diff --git a/docs/sources/integrations/available-integrations/configure-zabbix/index.md b/docs/sources/integrations/available-integrations/configure-zabbix/index.md index 86d4f8a72f..f237abaea2 100644 --- a/docs/sources/integrations/available-integrations/configure-zabbix/index.md +++ b/docs/sources/integrations/available-integrations/configure-zabbix/index.md @@ -23,7 +23,7 @@ space consumption. This integration is available for Grafana Cloud OnCall. You must have an Admin role to create integrations in Grafana OnCall. -1. In the **Integrations** tab, click **+ New integration for receiving alerts**. +1. In the **Integrations** tab, click **+ New integration to receive alerts**. 2. Select **Zabbix** from the list of available integrations 3. Follow the instructions in the **How to connect** window to get your unique integration URL and review next steps. diff --git a/docs/sources/integrations/chatops-integrations/configure-teams/index.md b/docs/sources/integrations/chatops-integrations/configure-teams/index.md index 6946ead1fc..cc765fb185 100644 --- a/docs/sources/integrations/chatops-integrations/configure-teams/index.md +++ b/docs/sources/integrations/chatops-integrations/configure-teams/index.md @@ -75,6 +75,6 @@ send alerts from a specific integration to a channel in MS Teams. To automatically send alerts from an integration to MS Teams channels: 1. Navigate to the **Integrations** tab in Grafana OnCall, select an existing integration or - click **+New integration for receiving alerts**. + click **+New integration to receive alerts**. 1. From the integrations settings, navigate to the escalation chain panel. 1. Enable **Post to Microsoft Teams channel** by selecting a channel to connect from the dropdown. diff --git a/docs/sources/oncall-api-reference/routes.md b/docs/sources/oncall-api-reference/routes.md index a51209abaf..3ffa32e93c 100644 --- a/docs/sources/oncall-api-reference/routes.md +++ b/docs/sources/oncall-api-reference/routes.md @@ -49,10 +49,11 @@ Routes allow you to direct different alerts to different messenger channels and | Parameter | Unique | Required | Description | -| --------------------- | :----: | :------: | :------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +|-----------------------| :----: |:--------:|:--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| | `integration_id` | No | Yes | Each route is assigned to a specific integration. | | `escalation_chain_id` | No | Yes | Each route is assigned a specific escalation chain. | -| `routing_regex` | Yes | Yes | Python Regex query (use for debugging). OnCall chooses the route for an alert in case there is a match inside the whole alert payload. | +| `routing_type` | Yes | No | Routing type that can be either `jinja2` or `regex`(default value) | +| `routing_regex` | Yes | Yes | Jinja2 template or Python Regex query (use for debugging). OnCall chooses the route for an alert in case there is a match inside the whole alert payload. | | `position` | Yes | Optional | Route matching is performed one after another starting from position=`0`. Position=`-1` will put the route to the end of the list before `is_the_last_route`. A new route created with a position of an existing route will move the old route (and all following routes) down in the list. | | `slack` | Yes | Optional | Dictionary with Slack-specific settings for a route. | diff --git a/engine/apps/alerts/migrations/0010_channelfilter_filtering_term_type.py b/engine/apps/alerts/migrations/0010_channelfilter_filtering_term_type.py new file mode 100644 index 0000000000..9741e60f80 --- /dev/null +++ b/engine/apps/alerts/migrations/0010_channelfilter_filtering_term_type.py @@ -0,0 +1,26 @@ +# Generated by Django 3.2.17 on 2023-03-07 07:27 + +from django.db import migrations, models +from django_add_default_value import AddDefaultValue + + +class Migration(migrations.Migration): + + dependencies = [ + ('alerts', '0009_alertreceivechannel_web_templates_modified_at'), + ] + + operations = [ + migrations.AddField( + model_name='channelfilter', + name='filtering_term_type', + field=models.IntegerField(choices=[(0, 'regex'), (1, 'jinja2')], default=0), + ), + # migrations.AddField enforces the default value on the app level, which leads to the issues during release + # adding same default value on the database level + AddDefaultValue( + model_name='channelfilter', + name='filtering_term_type', + value=0 + ) + ] diff --git a/engine/apps/alerts/models/alert.py b/engine/apps/alerts/models/alert.py index 0e10887977..b49773e849 100644 --- a/engine/apps/alerts/models/alert.py +++ b/engine/apps/alerts/models/alert.py @@ -89,9 +89,7 @@ def create( group_data = Alert.render_group_data(alert_receive_channel, raw_request_data, is_demo) if channel_filter is None: - channel_filter = ChannelFilter.select_filter( - alert_receive_channel, raw_request_data, title, message, force_route_id - ) + channel_filter = ChannelFilter.select_filter(alert_receive_channel, raw_request_data, force_route_id) group, group_created = AlertGroup.all_objects.get_or_create_grouping( channel=alert_receive_channel, diff --git a/engine/apps/alerts/models/channel_filter.py b/engine/apps/alerts/models/channel_filter.py index 63a49c70f7..9721fb2a70 100644 --- a/engine/apps/alerts/models/channel_filter.py +++ b/engine/apps/alerts/models/channel_filter.py @@ -8,6 +8,8 @@ from django.db import models from ordered_model.models import OrderedModel +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 logger = logging.getLogger(__name__) @@ -68,6 +70,15 @@ class ChannelFilter(OrderedModel): created_at = models.DateTimeField(auto_now_add=True) filtering_term = models.CharField(max_length=1024, null=True, default=None) + + FILTERING_TERM_TYPE_REGEX = 0 + FILTERING_TERM_TYPE_JINJA2 = 1 + FILTERING_TERM_TYPE_CHOICES = [ + (FILTERING_TERM_TYPE_REGEX, "regex"), + (FILTERING_TERM_TYPE_JINJA2, "jinja2"), + ] + filtering_term_type = models.IntegerField(choices=FILTERING_TERM_TYPE_CHOICES, default=FILTERING_TERM_TYPE_REGEX) + is_default = models.BooleanField(default=False) class Meta: @@ -81,7 +92,7 @@ def __str__(self): return f"{self.pk}: {self.filtering_term or 'default'}" @classmethod - def select_filter(cls, alert_receive_channel, raw_request_data, title, message=None, force_route_id=None): + def select_filter(cls, alert_receive_channel, raw_request_data, force_route_id=None): # Try to find force route first if force_route_id is given if force_route_id is not None: logger.info( @@ -107,20 +118,29 @@ def select_filter(cls, alert_receive_channel, raw_request_data, title, message=N satisfied_filter = None for _filter in filters: - if satisfied_filter is None and _filter.is_satisfying(raw_request_data, title, message): + if satisfied_filter is None and _filter.is_satisfying(raw_request_data): satisfied_filter = _filter return satisfied_filter - def is_satisfying(self, raw_request_data, title, message=None): - return self.is_default or self.check_filter(json.dumps(raw_request_data)) or self.check_filter(str(title)) + def is_satisfying(self, raw_request_data): + return self.is_default or self.check_filter(raw_request_data) def check_filter(self, value): - try: - return re.search(self.filtering_term, value) - except re.error: - logger.error(f"channel_filter={self.id} failed to parse regex={self.filtering_term}") - return False + if self.filtering_term_type == ChannelFilter.FILTERING_TERM_TYPE_JINJA2: + try: + is_matching = apply_jinja_template(self.filtering_term, payload=value) + return is_matching.strip().lower() in ["1", "true", "ok"] + except (JinjaTemplateError, JinjaTemplateWarning): + logger.error(f"channel_filter={self.id} failed to parse jinja2={self.filtering_term}") + return False + if self.filtering_term is not None and self.filtering_term_type == ChannelFilter.FILTERING_TERM_TYPE_REGEX: + try: + return re.search(self.filtering_term, json.dumps(value)) + except re.error: + logger.error(f"channel_filter={self.id} failed to parse regex={self.filtering_term}") + return False + return False @property def slack_channel_id_or_general_log_id(self): @@ -135,9 +155,13 @@ def slack_channel_id_or_general_log_id(self): @property def str_for_clients(self): - if self.filtering_term is None: + if self.is_default: return "default" - return str(self.filtering_term).replace("`", "") + if self.filtering_term_type == ChannelFilter.FILTERING_TERM_TYPE_JINJA2: + return str(self.filtering_term) + elif self.filtering_term_type == ChannelFilter.FILTERING_TERM_TYPE_REGEX or self.filtering_term_type is None: + return str(self.filtering_term).replace("`", "") + raise Exception("Unknown filtering term") def send_demo_alert(self): integration = self.alert_receive_channel diff --git a/engine/apps/alerts/tests/test_channel_filter.py b/engine/apps/alerts/tests/test_channel_filter.py index 17ebd23716..3c9de7e81e 100644 --- a/engine/apps/alerts/tests/test_channel_filter.py +++ b/engine/apps/alerts/tests/test_channel_filter.py @@ -17,18 +17,80 @@ def test_channel_filter_select_filter(make_organization, make_alert_receive_chan # alert with data which includes custom route filtering term, satisfied filter is custom channel filter raw_request_data = {"title": filtering_term} - satisfied_filter = ChannelFilter.select_filter(alert_receive_channel, raw_request_data, title) + satisfied_filter = ChannelFilter.select_filter(alert_receive_channel, raw_request_data) assert satisfied_filter == channel_filter # alert with data which does not include custom route filtering term, satisfied filter is default channel filter raw_request_data = {"title": title} - satisfied_filter = ChannelFilter.select_filter(alert_receive_channel, raw_request_data, title) + satisfied_filter = ChannelFilter.select_filter(alert_receive_channel, raw_request_data) assert satisfied_filter == default_channel_filter # demo alert for custom route raw_request_data = {"title": "i'm not matching this route"} satisfied_filter = ChannelFilter.select_filter( - alert_receive_channel, raw_request_data, title, force_route_id=channel_filter.pk + alert_receive_channel, raw_request_data, force_route_id=channel_filter.pk + ) + assert satisfied_filter == channel_filter + + +@pytest.mark.django_db +def test_channel_filter_select_filter_regex(make_organization, make_alert_receive_channel, make_channel_filter): + organization = make_organization() + alert_receive_channel = make_alert_receive_channel(organization) + default_channel_filter = make_channel_filter(alert_receive_channel, is_default=True) + filtering_term = "test alert" + channel_filter = make_channel_filter( + alert_receive_channel, + filtering_term=filtering_term, + filtering_term_type=ChannelFilter.FILTERING_TERM_TYPE_REGEX, + is_default=False, + ) + + # alert with data which includes custom route filtering term, satisfied filter is custom channel filter + raw_request_data = {"title": filtering_term} + satisfied_filter = ChannelFilter.select_filter(alert_receive_channel, raw_request_data) + assert satisfied_filter == channel_filter + + # alert with data which does not include custom route filtering term, satisfied filter is default channel filter + raw_request_data = {"title": "Test Title"} + satisfied_filter = ChannelFilter.select_filter(alert_receive_channel, raw_request_data) + assert satisfied_filter == default_channel_filter + + # demo alert for custom route + raw_request_data = {"title": "i'm not matching this route"} + satisfied_filter = ChannelFilter.select_filter( + alert_receive_channel, raw_request_data, force_route_id=channel_filter.pk + ) + assert satisfied_filter == channel_filter + + +@pytest.mark.django_db +def test_channel_filter_select_filter_jinja2(make_organization, make_alert_receive_channel, make_channel_filter): + organization = make_organization() + alert_receive_channel = make_alert_receive_channel(organization) + default_channel_filter = make_channel_filter(alert_receive_channel, is_default=True) + filtering_term = '{{ payload.foo == "bar" }}' + channel_filter = make_channel_filter( + alert_receive_channel, + filtering_term=filtering_term, + filtering_term_type=ChannelFilter.FILTERING_TERM_TYPE_JINJA2, + is_default=False, + ) + + # alert with data which includes custom route filtering term, satisfied filter is custom channel filter + raw_request_data = {"foo": "bar"} + satisfied_filter = ChannelFilter.select_filter(alert_receive_channel, raw_request_data) + assert satisfied_filter == channel_filter + + # alert with data which does not include custom route filtering term, satisfied filter is default channel filter + raw_request_data = {"foo": "qaz"} + satisfied_filter = ChannelFilter.select_filter(alert_receive_channel, raw_request_data) + assert satisfied_filter == default_channel_filter + + # demo alert for custom route + raw_request_data = {"title": "i'm not matching this route"} + satisfied_filter = ChannelFilter.select_filter( + alert_receive_channel, raw_request_data, force_route_id=channel_filter.pk ) assert satisfied_filter == channel_filter diff --git a/engine/apps/api/serializers/channel_filter.py b/engine/apps/api/serializers/channel_filter.py index d739ff1f2c..2347a78bce 100644 --- a/engine/apps/api/serializers/channel_filter.py +++ b/engine/apps/api/serializers/channel_filter.py @@ -2,11 +2,13 @@ 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, OrderedModelSerializerMixin +from common.jinja_templater.apply_jinja_template import JinjaTemplateError from common.utils import is_regex_valid @@ -37,6 +39,7 @@ class Meta: "slack_channel", "created_at", "filtering_term", + "filtering_term_type", "telegram_channel", "is_default", "notify_in_slack", @@ -46,6 +49,22 @@ class Meta: read_only_fields = ["created_at", "is_default"] extra_kwargs = {"filtering_term": {"required": True, "allow_null": False}} + def validate(self, data): + filtering_term = data.get("filtering_term") + filtering_term_type = data.get("filtering_term_type") + if filtering_term_type == ChannelFilter.FILTERING_TERM_TYPE_JINJA2: + try: + valid_jinja_template_for_serializer_method_field({"route_template": filtering_term}) + except JinjaTemplateError: + raise serializers.ValidationError([f"Jinja template is incorrect"]) + elif filtering_term_type == ChannelFilter.FILTERING_TERM_TYPE_REGEX or filtering_term_type is None: + if filtering_term is not None: + if not is_regex_valid(filtering_term): + raise serializers.ValidationError(["Regular expression is incorrect"]) + else: + raise serializers.ValidationError([f"Expression type is incorrect"]) + return data + def get_slack_channel(self, obj): if obj.slack_channel_id is None: return None @@ -56,22 +75,6 @@ def get_slack_channel(self, obj): "id": obj.slack_channel_pk, } - def validate(self, attrs): - alert_receive_channel = attrs.get("alert_receive_channel") or self.instance.alert_receive_channel - filtering_term = attrs.get("filtering_term") - if filtering_term is None: - return attrs - try: - obj = ChannelFilter.objects.get(alert_receive_channel=alert_receive_channel, filtering_term=filtering_term) - except ChannelFilter.DoesNotExist: - return attrs - if self.instance and obj.id == self.instance.id: - return attrs - else: - raise serializers.ValidationError( - {"filtering_term": ["Channel filter with this filtering term already exists"]} - ) - def validate_slack_channel(self, slack_channel_id): SlackChannel = apps.get_model("slack", "SlackChannel") @@ -84,12 +87,6 @@ def validate_slack_channel(self, slack_channel_id): raise serializers.ValidationError(["Slack channel does not exist"]) return slack_channel_id - def validate_filtering_term(self, filtering_term): - if filtering_term is not None: - if not is_regex_valid(filtering_term): - raise serializers.ValidationError(["Filtering term is incorrect"]) - return filtering_term - def validate_notification_backends(self, notification_backends): # NOTE: updates the whole field, handling dict updates per backend if notification_backends is not None: @@ -125,6 +122,7 @@ class Meta: "slack_channel", "created_at", "filtering_term", + "filtering_term_type", "telegram_channel", "is_default", "notify_in_slack", diff --git a/engine/apps/public_api/serializers/routes.py b/engine/apps/public_api/serializers/routes.py index c083da029e..abbc9f4fb1 100644 --- a/engine/apps/public_api/serializers/routes.py +++ b/engine/apps/public_api/serializers/routes.py @@ -1,11 +1,14 @@ from django.apps import apps -from rest_framework import serializers +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.mixins import OrderedModelSerializerMixin +from common.jinja_templater.apply_jinja_template import JinjaTemplateError +from common.utils import is_regex_valid class BaseChannelFilterSerializer(OrderedModelSerializerMixin, serializers.ModelSerializer): @@ -128,10 +131,22 @@ def validate_escalation_chain_id(self, escalation_chain): return escalation_chain +class RoutingTypeField(fields.CharField): + def to_representation(self, value): + return ChannelFilter.FILTERING_TERM_TYPE_CHOICES[value][1] + + def to_internal_value(self, data): + for filtering_term_type_choices in ChannelFilter.FILTERING_TERM_TYPE_CHOICES: + if filtering_term_type_choices[1] == data: + return filtering_term_type_choices[0] + raise BadRequest(detail="Invalid route type") + + class ChannelFilterSerializer(BaseChannelFilterSerializer): id = serializers.CharField(read_only=True, source="public_primary_key") slack = serializers.DictField(required=False) telegram = serializers.DictField(required=False) + routing_type = RoutingTypeField(allow_null=False, required=False, source="filtering_term_type") routing_regex = serializers.CharField(allow_null=False, required=True, source="filtering_term") position = serializers.IntegerField(required=False, source="order") integration_id = OrganizationFilteredPrimaryKeyRelatedField( @@ -151,6 +166,7 @@ class Meta: "id", "integration_id", "escalation_chain_id", + "routing_type", "routing_regex", "position", "is_the_last_route", @@ -176,19 +192,21 @@ def create(self, validated_data): return instance - def validate(self, attrs): - alert_receive_channel = attrs.get("alert_receive_channel") or self.instance.alert_receive_channel - filtering_term = attrs.get("filtering_term") - if filtering_term is None: - return attrs - try: - obj = ChannelFilter.objects.get(alert_receive_channel=alert_receive_channel, filtering_term=filtering_term) - except ChannelFilter.DoesNotExist: - return attrs - if self.instance and obj.id == self.instance.id: - return attrs + def validate(self, data): + filtering_term = data.get("routing_regex") + filtering_term_type = data.get("routing_type") + if filtering_term_type == ChannelFilter.FILTERING_TERM_TYPE_JINJA2: + try: + valid_jinja_template_for_serializer_method_field({"route_template": filtering_term}) + except JinjaTemplateError: + raise serializers.ValidationError([f"Jinja template is incorrect"]) + elif filtering_term_type == ChannelFilter.FILTERING_TERM_TYPE_REGEX or filtering_term_type is None: + if filtering_term is not None: + if not is_regex_valid(filtering_term): + raise serializers.ValidationError(["Regular expression is incorrect"]) else: - raise BadRequest(detail="Route with this regex already exists") + raise serializers.ValidationError([f"Expression type is incorrect"]) + return data class ChannelFilterUpdateSerializer(ChannelFilterSerializer): diff --git a/engine/apps/public_api/tests/test_routes.py b/engine/apps/public_api/tests/test_routes.py index 2cb88d9cf1..5f0db22d3c 100644 --- a/engine/apps/public_api/tests/test_routes.py +++ b/engine/apps/public_api/tests/test_routes.py @@ -44,6 +44,7 @@ def test_get_route( "id": channel_filter.public_primary_key, "integration_id": alert_receive_channel.public_primary_key, "escalation_chain_id": escalation_chain.public_primary_key, + "routing_type": "regex", "routing_regex": channel_filter.filtering_term, "position": channel_filter.order, "is_the_last_route": channel_filter.is_default, @@ -76,6 +77,7 @@ def test_get_routes_list( "id": channel_filter.public_primary_key, "integration_id": alert_receive_channel.public_primary_key, "escalation_chain_id": escalation_chain.public_primary_key, + "routing_type": "regex", "routing_regex": channel_filter.filtering_term, "position": channel_filter.order, "is_the_last_route": channel_filter.is_default, @@ -112,6 +114,7 @@ def test_get_routes_filter_by_integration_id( "id": channel_filter.public_primary_key, "integration_id": alert_receive_channel.public_primary_key, "escalation_chain_id": escalation_chain.public_primary_key, + "routing_type": "regex", "routing_regex": channel_filter.filtering_term, "position": channel_filter.order, "is_the_last_route": channel_filter.is_default, @@ -146,6 +149,7 @@ def test_create_route( "id": response.data["id"], "integration_id": alert_receive_channel.public_primary_key, "escalation_chain_id": escalation_chain.public_primary_key, + "routing_type": "regex", "routing_regex": data_for_create["routing_regex"], "position": 0, "is_the_last_route": False, @@ -206,6 +210,7 @@ def test_update_route( "id": new_channel_filter.public_primary_key, "integration_id": alert_receive_channel.public_primary_key, "escalation_chain_id": escalation_chain.public_primary_key, + "routing_type": "regex", "routing_regex": data_to_update["routing_regex"], "position": new_channel_filter.order, "is_the_last_route": new_channel_filter.is_default, @@ -273,6 +278,7 @@ def test_create_route_with_messaging_backend( "id": response.data["id"], "integration_id": alert_receive_channel.public_primary_key, "escalation_chain_id": escalation_chain.public_primary_key, + "routing_type": "regex", "routing_regex": data_for_create["routing_regex"], "position": 0, "is_the_last_route": False, @@ -330,6 +336,7 @@ def test_update_route_with_messaging_backend( "id": response.data["id"], "integration_id": alert_receive_channel.public_primary_key, "escalation_chain_id": escalation_chain.public_primary_key, + "routing_type": "regex", "routing_regex": new_channel_filter.filtering_term, "position": 0, "is_the_last_route": False, @@ -363,6 +370,7 @@ def test_update_route_with_messaging_backend( "id": response.data["id"], "integration_id": alert_receive_channel.public_primary_key, "escalation_chain_id": escalation_chain.public_primary_key, + "routing_type": "regex", "routing_regex": new_channel_filter.filtering_term, "position": 0, "is_the_last_route": False, diff --git a/engine/requirements.txt b/engine/requirements.txt index 808334738e..5e7d6ed50d 100644 --- a/engine/requirements.txt +++ b/engine/requirements.txt @@ -42,6 +42,7 @@ emoji==1.7.0 regex==2021.11.2 psutil==5.9.4 django-migration-linter==4.1.0 +django-add-default-value==0.10.0 opentelemetry-instrumentation-celery==0.36b0 opentelemetry-instrumentation-pymysql==0.36b0 opentelemetry-instrumentation-wsgi==0.36b0 diff --git a/grafana-plugin/README.md b/grafana-plugin/README.md deleted file mode 120000 index 32d46ee883..0000000000 --- a/grafana-plugin/README.md +++ /dev/null @@ -1 +0,0 @@ -../README.md \ No newline at end of file diff --git a/grafana-plugin/integration-tests/utils/integrations.ts b/grafana-plugin/integration-tests/utils/integrations.ts index 026042d4a6..eab5a475a9 100644 --- a/grafana-plugin/integration-tests/utils/integrations.ts +++ b/grafana-plugin/integration-tests/utils/integrations.ts @@ -11,7 +11,7 @@ export const createIntegrationAndSendDemoAlert = async ( await goToOnCallPageByClickingOnTab(page, 'Integrations'); // open the create integration modal - (await page.waitForSelector('text=New integration for receiving alerts')).click(); + (await page.waitForSelector('text=New integration to receive alerts')).click(); // create a webhook integration (await page.waitForSelector('div[data-testid="create-integration-modal"] >> text=Webhook')).click(); diff --git a/grafana-plugin/src/containers/AlertReceiveChannelCard/AlertReceiveChannelCard.tsx b/grafana-plugin/src/containers/AlertReceiveChannelCard/AlertReceiveChannelCard.tsx index 0337ab7fc6..9e0cfb1655 100644 --- a/grafana-plugin/src/containers/AlertReceiveChannelCard/AlertReceiveChannelCard.tsx +++ b/grafana-plugin/src/containers/AlertReceiveChannelCard/AlertReceiveChannelCard.tsx @@ -1,6 +1,6 @@ import React from 'react'; -import { Tooltip, HorizontalGroup, VerticalGroup } from '@grafana/ui'; +import { Tooltip, HorizontalGroup, VerticalGroup, Badge } from '@grafana/ui'; import cn from 'classnames/bind'; import { observer } from 'mobx-react'; import Emoji from 'react-emoji-render'; @@ -58,30 +58,37 @@ const AlertReceiveChannelCard = observer((props: AlertReceiveChannelCardProps) = )} - - - - - - - {integration?.display_name} - - - | + + {alertReceiveChannelCounter && ( - {alertReceiveChannelCounter?.alerts_count} alert - {alertReceiveChannelCounter?.alerts_count === 1 ? '' : 's'} in{' '} - {alertReceiveChannelCounter?.alert_groups_count} Alert Group - {alertReceiveChannelCounter?.alert_groups_count === 1 ? '' : 's'} + )} + + + + {integration?.display_name} + + diff --git a/grafana-plugin/src/containers/AlertRules/AlertRules.module.css b/grafana-plugin/src/containers/AlertRules/AlertRules.module.css index cb6bfe724e..3fdad2d681 100644 --- a/grafana-plugin/src/containers/AlertRules/AlertRules.module.css +++ b/grafana-plugin/src/containers/AlertRules/AlertRules.module.css @@ -47,10 +47,18 @@ .channel-filter-header { display: flex; - justify-content: space-between; - align-items: center; - flex-wrap: wrap; - gap: 8px; + flex-wrap: wrap-reverse; +} + +.channel-filter-header-left, +.channel-filter-header-right { + flex-grow: 1; + margin-bottom: 16px; +} + +.channel-filter-header-right { + display: flex; + justify-content: flex-end; } .channel-filter-header-title { @@ -120,16 +128,13 @@ .integration__heading-container { display: flex; - flex-wrap: wrap; -} - -.integration__heading-container-left { - margin-bottom: 12px; + flex-wrap: wrap-reverse; } .integration__heading-container-left, .integration__heading-container-right { flex-grow: 1; + margin-bottom: 12px; } .integration__heading-container-right { diff --git a/grafana-plugin/src/containers/AlertRules/AlertRules.tsx b/grafana-plugin/src/containers/AlertRules/AlertRules.tsx index 67d9e076ae..ac92a06972 100644 --- a/grafana-plugin/src/containers/AlertRules/AlertRules.tsx +++ b/grafana-plugin/src/containers/AlertRules/AlertRules.tsx @@ -6,11 +6,13 @@ import { ConfirmModal, Field, HorizontalGroup, + Icon, IconButton, Input, LoadingPlaceholder, Modal, Tooltip, + VerticalGroup, } from '@grafana/ui'; import cn from 'classnames/bind'; import { observer } from 'mobx-react'; @@ -19,6 +21,7 @@ import Emoji from 'react-emoji-render'; import Collapse from 'components/Collapse/Collapse'; import Block from 'components/GBlock/Block'; import PluginLink from 'components/PluginLink/PluginLink'; +import SourceCode from 'components/SourceCode/SourceCode'; import Text from 'components/Text/Text'; import WithConfirm from 'components/WithConfirm/WithConfirm'; import { parseEmojis } from 'containers/AlertRules/AlertRules.helpers'; @@ -261,7 +264,7 @@ class AlertRules extends React.Component { onDismiss={() => this.setState({ editIntegrationName: undefined })} >
- + { const index = channelFilterIds.indexOf(channelFilterId); return ( -
-
- {channelFilter.is_default ? ( - {channelFilterIds.length > 1 ? 'ELSE ' : ''} - ) : ( - <> - {index === 0 ? 'IF ' : 'ELSE IF '}alert payload matches regex - - {channelFilter.filtering_term} - - - )} - escalate to{' '} - -
e.stopPropagation()}> - + <> +
+
+
+ {channelFilter.is_default ? ( + <> + {channelFilterIds.length > 1 && ELSE} + route to escalation chain: + +
e.stopPropagation()}> + +
+
+ + ) : ( + <> + {index === 0 ? 'IF' : 'ELSE IF'} + {channelFilter.filtering_term_type === 0 ? ( + <> + + regular expression + + + + + + ) : ( + jinja2 expression + )} + is + {'True'} + {'for new Alert Group:'} + + )}
- +
+
+
e.stopPropagation()}>{this.renderChannelFilterButtons(channelFilterId, index)}
+
-
e.stopPropagation()}>{this.renderChannelFilterButtons(channelFilterId, index)}
-
+ {!channelFilter.is_default && ( + + + {!channelFilter.is_default && ( + <> + {channelFilter.filtering_term_type === 0 ? ( + + {'payload =~ "' + channelFilter.filtering_term + '"'} + + ) : ( + {channelFilter.filtering_term} + )} + + )} + + + {'route to escalation chain: '} + +
e.stopPropagation()}> + +
+
+
+
+ )} + ); }; diff --git a/grafana-plugin/src/containers/ChannelFilterForm/ChannelFilterForm.tsx b/grafana-plugin/src/containers/ChannelFilterForm/ChannelFilterForm.tsx index 20396667cd..fd211342f4 100644 --- a/grafana-plugin/src/containers/ChannelFilterForm/ChannelFilterForm.tsx +++ b/grafana-plugin/src/containers/ChannelFilterForm/ChannelFilterForm.tsx @@ -1,15 +1,16 @@ -import React, { useCallback, useState, ChangeEvent } from 'react'; +import React, { useCallback, useState } from 'react'; -import { Button, Field, HorizontalGroup, Input } from '@grafana/ui'; +import { Button, Field, HorizontalGroup, RadioButtonGroup } from '@grafana/ui'; import cn from 'classnames/bind'; import { get } from 'lodash-es'; import { observer } from 'mobx-react'; import Block from 'components/GBlock/Block'; +import MonacoJinja2Editor from 'components/MonacoJinja2Editor/MonacoJinja2Editor'; import Text from 'components/Text/Text'; import IncidentMatcher from 'containers/IncidentMatcher/IncidentMatcher'; import { AlertReceiveChannel } from 'models/alert_receive_channel'; -import { ChannelFilter } from 'models/channel_filter/channel_filter.types'; +import { ChannelFilter, FilteringTermType } from 'models/channel_filter/channel_filter.types'; import { useStore } from 'state/useStore'; import { openErrorNotification } from 'utils'; @@ -29,16 +30,35 @@ interface ChannelFilterFormProps { const ChannelFilterForm = observer((props: ChannelFilterFormProps) => { const { id, alertReceiveChannelId, onHide, onUpdate, data, className } = props; - const [filteringTerm, setFilteringTerm] = useState(data ? data.filtering_term : '.*'); + // TODO: use FilteringTermType.jinja2 instead of 1 + const [filteringTermType, setFilteringTermType] = useState(data ? data.filtering_term_type : 1); + + function renderFilteringTermValue(type) { + if (data && type === data?.filtering_term_type) { + return data.filtering_term; + } + switch (type) { + // TODO: use FilteringTermType.regex and jinja2 instead of 0 and 1 + case 0: + return '.*'; + case 1: + return '{{ (payload.severity == "foo" and "bar" in payload.region) or True }}'; + default: + return null; + } + } + + const [filteringTerm, setFilteringTerm] = useState(renderFilteringTermValue(filteringTermType)); + const [errors, setErrors] = useState<{ filtering_term?: string }>({}); const store = useStore(); const { alertReceiveChannelStore } = store; - const handleFilteringTermChange = useCallback((event: ChangeEvent) => { + const handleFilteringTermChange = useCallback((value: string) => { setErrors({}); - setFilteringTerm(event.target.value); + setFilteringTerm(value); }, []); const onUpdateClickCallback = useCallback(() => { @@ -47,8 +67,12 @@ const ChannelFilterForm = observer((props: ChannelFilterFormProps) => { order: 0, alert_receive_channel: alertReceiveChannelId, filtering_term: filteringTerm, + filtering_term_type: filteringTermType, + }) + : alertReceiveChannelStore.saveChannelFilter(id, { + filtering_term: filteringTerm, + filtering_term_type: filteringTermType, }) - : alertReceiveChannelStore.saveChannelFilter(id, { filtering_term: filteringTerm }) ) .then((channelFilter: ChannelFilter) => { onUpdate(channelFilter.id); @@ -61,7 +85,7 @@ const ChannelFilterForm = observer((props: ChannelFilterFormProps) => { openErrorNotification(errors.non_field_errors); } }); - }, [filteringTerm]); + }, [filteringTerm, filteringTermType]); return ( @@ -69,44 +93,86 @@ const ChannelFilterForm = observer((props: ChannelFilterFormProps) => { {id === 'new' ? 'New' : 'Update'} Route - Sends alert to a different escalation chain (slack channel, different users, different urgency) based on the - alert content, using regular expressions. + Route sends alert group to a different escalation chain (slack channel, different users, different urgency) + based on the alert group content.
- - Use{' '} - - python style - {' '} - regex to filter incidents based on a expression - - } - > - + { + setErrors({}); + setFilteringTermType(value); + setFilteringTerm(renderFilteringTermValue(value)); + }} /> + + {filteringTermType === 0 ? ( + <> + + Use{' '} + + python style + {' '} + regex to filter incidents based on a expression + + } + > + + + {!data?.is_default && ( + { + setErrors({ filtering_term: message }); + }} + /> + )} + + ) : ( + <> + + If the result of the{' '} + + Jinja2-based template + {' '} + is True + alert group will be matched with this route + + + + + + )}
- {!data?.is_default && ( - { - setErrors({ filtering_term: message }); - }} - /> - )}
); diff --git a/grafana-plugin/src/containers/IncidentMatcher/IncidentMatcher.tsx b/grafana-plugin/src/containers/IncidentMatcher/IncidentMatcher.tsx index 0c03475887..aacc3dce8c 100644 --- a/grafana-plugin/src/containers/IncidentMatcher/IncidentMatcher.tsx +++ b/grafana-plugin/src/containers/IncidentMatcher/IncidentMatcher.tsx @@ -96,7 +96,7 @@ const IncidentMatcher = observer((props: IncidentMatcherProps) => {
- Incident payload + Alert Group payload {selectedAlertItem ? ( {JSON.stringify(selectedAlertItem, null, 2)} diff --git a/grafana-plugin/src/models/channel_filter/channel_filter.types.ts b/grafana-plugin/src/models/channel_filter/channel_filter.types.ts index e6be090118..1daa4109d4 100644 --- a/grafana-plugin/src/models/channel_filter/channel_filter.types.ts +++ b/grafana-plugin/src/models/channel_filter/channel_filter.types.ts @@ -3,6 +3,11 @@ import { EscalationChain } from 'models/escalation_chain/escalation_chain.types' import { SlackChannel } from 'models/slack_channel/slack_channel.types'; import { TelegramChannel } from 'models/telegram_channel/telegram_channel.types'; +export enum FilteringTermType { + regex, + jinja2, +} + export interface ChannelFilter { id: string; order: number; @@ -12,6 +17,7 @@ export interface ChannelFilter { telegram_channel?: TelegramChannel['id']; created_at: string; filtering_term: string; + filtering_term_type: FilteringTermType; is_default: boolean; notify_in_slack: boolean; notify_in_telegram: boolean; diff --git a/grafana-plugin/src/pages/escalation-chains/EscalationChains.module.css b/grafana-plugin/src/pages/escalation-chains/EscalationChains.module.css index 79807cf70c..a049fb6829 100644 --- a/grafana-plugin/src/pages/escalation-chains/EscalationChains.module.css +++ b/grafana-plugin/src/pages/escalation-chains/EscalationChains.module.css @@ -16,10 +16,11 @@ .new-escalation-chain { margin: 16px; + width: calc(100% - 32px); } .left-column { - width: 400px; + width: 300px; flex-shrink: 0; border-right: var(--border); } diff --git a/grafana-plugin/src/pages/escalation-chains/EscalationChains.tsx b/grafana-plugin/src/pages/escalation-chains/EscalationChains.tsx index 3626a894bb..729da47865 100644 --- a/grafana-plugin/src/pages/escalation-chains/EscalationChains.tsx +++ b/grafana-plugin/src/pages/escalation-chains/EscalationChains.tsx @@ -171,7 +171,7 @@ class EscalationChainsPage extends React.Component - New Escalation Chain + New escalation chain )} diff --git a/grafana-plugin/src/pages/incident/Incident.tsx b/grafana-plugin/src/pages/incident/Incident.tsx index 418ec59a27..60867ae9ad 100644 --- a/grafana-plugin/src/pages/incident/Incident.tsx +++ b/grafana-plugin/src/pages/incident/Incident.tsx @@ -554,8 +554,7 @@ class IncidentPage extends React.Component ); default: - console.warn('Unknown render_after_resolve_report_json entity placeholder'); - return ''; + return '{{' + match + '}}'; } }; }; diff --git a/grafana-plugin/src/pages/integrations/Integrations.module.css b/grafana-plugin/src/pages/integrations/Integrations.module.css index 88233e52ae..1ce1c988da 100644 --- a/grafana-plugin/src/pages/integrations/Integrations.module.css +++ b/grafana-plugin/src/pages/integrations/Integrations.module.css @@ -4,6 +4,7 @@ .integrations { width: 100%; + min-width: 720px; display: flex; align-items: flex-start; border: var(--border); @@ -16,7 +17,7 @@ } .alert-receive-channels-list { - width: 400px; + width: 300px; flex-shrink: 0; overflow: auto; max-height: 70vh; @@ -29,6 +30,7 @@ .newIntegrationButton { margin: 16px; + width: calc(100% - 32px); } .integrationsList { diff --git a/grafana-plugin/src/pages/integrations/Integrations.tsx b/grafana-plugin/src/pages/integrations/Integrations.tsx index c9582f7638..5c05fddc3b 100644 --- a/grafana-plugin/src/pages/integrations/Integrations.tsx +++ b/grafana-plugin/src/pages/integrations/Integrations.tsx @@ -169,7 +169,7 @@ class Integrations extends React.Component icon="plus" className={cx('newIntegrationButton')} > - New integration for receiving alerts + New integration to receive alerts
@@ -224,7 +224,7 @@ class Integrations extends React.Component this.setState({ showCreateIntegrationModal: true }); }} > - New integration for receiving alerts + New integration to receive alerts