From 2e63a9ff08a546ec58bb38d93c652edaf34262da Mon Sep 17 00:00:00 2001 From: Ildar Iskhakov Date: Wed, 8 Mar 2023 16:42:18 +0800 Subject: [PATCH] Jinja2 based routes (#1319) # What this PR does This PR adds the new way to set up routes using jinja2 templating language Screenshot 2023-03-06 at 22 11 13 Screenshot 2023-03-06 at 22 11 34 ## Which issue(s) this PR fixes ## Checklist - [ ] Tests updated - [ ] Documentation added - [ ] `CHANGELOG.md` updated --- CHANGELOG.md | 4 + .../configure-routes/index.md | 14 +- docs/sources/get-started/_index.md | 2 +- docs/sources/integrations/_index.md | 2 +- .../configure-alertmanager/index.md | 2 +- .../configure-grafana-alerting/index.md | 6 +- .../configure-webhook/index.md | 2 +- .../configure-zabbix/index.md | 2 +- .../configure-teams/index.md | 2 +- docs/sources/oncall-api-reference/routes.md | 5 +- .../0010_channelfilter_filtering_term_type.py | 26 +++ engine/apps/alerts/models/alert.py | 4 +- engine/apps/alerts/models/channel_filter.py | 46 ++++-- .../apps/alerts/tests/test_channel_filter.py | 68 +++++++- engine/apps/api/serializers/channel_filter.py | 42 +++-- engine/apps/public_api/serializers/routes.py | 44 ++++-- engine/apps/public_api/tests/test_routes.py | 8 + engine/requirements.txt | 1 + grafana-plugin/README.md | 1 - .../integration-tests/utils/integrations.ts | 2 +- .../AlertReceiveChannelCard.tsx | 37 +++-- .../AlertRules/AlertRules.module.css | 23 +-- .../src/containers/AlertRules/AlertRules.tsx | 128 +++++++++++---- .../ChannelFilterForm/ChannelFilterForm.tsx | 148 +++++++++++++----- .../src/containers/GSelect/GSelect.tsx | 6 + .../IncidentMatcher/IncidentMatcher.tsx | 2 +- .../channel_filter/channel_filter.types.ts | 6 + .../EscalationChains.module.css | 3 +- .../escalation-chains/EscalationChains.tsx | 2 +- .../src/pages/incident/Incident.tsx | 3 +- .../integrations/Integrations.module.css | 4 +- .../src/pages/integrations/Integrations.tsx | 4 +- 32 files changed, 473 insertions(+), 176 deletions(-) create mode 100644 engine/apps/alerts/migrations/0010_channelfilter_filtering_term_type.py delete mode 120000 grafana-plugin/README.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 2547d879..fcda86cb 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 71946e07..b4475fd2 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 0a4b8033..46a6704f 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 8d6247ac..e575df09 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 a56cc498..a0e59e06 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 5bd8ae9c..f6beab61 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 5319cdc6..d3cf8632 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 86d4f8a7..f237abae 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 6946ead1..cc765fb1 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 a51209ab..3ffa32e9 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 00000000..9741e60f --- /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 0e108879..b49773e8 100644 --- a/engine/apps/alerts/models/alert.py +++ b/engine/apps/alerts/models/alert.py @@ -89,9 +89,7 @@ class Alert(models.Model): 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 63a49c70..9721fb2a 100644 --- a/engine/apps/alerts/models/channel_filter.py +++ b/engine/apps/alerts/models/channel_filter.py @@ -8,6 +8,8 @@ from django.core.validators import MinLengthValidator 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 @@ class ChannelFilter(OrderedModel): 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 @@ class ChannelFilter(OrderedModel): 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 @@ class ChannelFilter(OrderedModel): @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 17ebd237..3c9de7e8 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 d739ff1f..2347a78b 100644 --- a/engine/apps/api/serializers/channel_filter.py +++ b/engine/apps/api/serializers/channel_filter.py @@ -2,11 +2,13 @@ from django.apps import apps 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 ChannelFilterSerializer(OrderedModelSerializerMixin, EagerLoadingMixin, se "slack_channel", "created_at", "filtering_term", + "filtering_term_type", "telegram_channel", "is_default", "notify_in_slack", @@ -46,6 +49,22 @@ class ChannelFilterSerializer(OrderedModelSerializerMixin, EagerLoadingMixin, se 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 @@ class ChannelFilterSerializer(OrderedModelSerializerMixin, EagerLoadingMixin, se "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 @@ class ChannelFilterSerializer(OrderedModelSerializerMixin, EagerLoadingMixin, se 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 ChannelFilterCreateSerializer(ChannelFilterSerializer): "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 c083da02..abbc9f4f 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 @@ class BaseChannelFilterSerializer(OrderedModelSerializerMixin, serializers.Model 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 ChannelFilterSerializer(BaseChannelFilterSerializer): "id", "integration_id", "escalation_chain_id", + "routing_type", "routing_regex", "position", "is_the_last_route", @@ -176,19 +192,21 @@ class ChannelFilterSerializer(BaseChannelFilterSerializer): 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 2cb88d9c..5f0db22d 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 80833473..5e7d6ed5 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 32d46ee8..00000000 --- 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 026042d4..eab5a475 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 0337ab7f..9e0cfb16 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 cb6bfe72..3fdad2d6 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 67d9e076..ac92a069 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 20396667..fd211342 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 0c034758..aacc3dce 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 e6be0901..1daa4109 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 79807cf7..a049fb68 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 3626a894..729da478 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 418ec59a..60867ae9 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 88233e52..1ce1c988 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 c9582f76..5c05fddc 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