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