Jinja2 based routes (#1319)
# What this PR does This PR adds the new way to set up routes using jinja2 templating language <img width="1174" alt="Screenshot 2023-03-06 at 22 11 13" src="https://user-images.githubusercontent.com/2262529/223134053-69d43c47-bb2a-4790-a16d-767425017a76.png"> <img width="1175" alt="Screenshot 2023-03-06 at 22 11 34" src="https://user-images.githubusercontent.com/2262529/223134070-1e5ef82f-021c-4d5d-b255-b19bb3445641.png"> ## Which issue(s) this PR fixes ## Checklist - [ ] Tests updated - [ ] Documentation added - [ ] `CHANGELOG.md` updated
This commit is contained in:
parent
98ccd3eca5
commit
2e63a9ff08
32 changed files with 473 additions and 176 deletions
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -49,10 +49,11 @@ Routes allow you to direct different alerts to different messenger channels and
|
|||
<!-- markdownlint-disable MD013 -->
|
||||
|
||||
| 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 <https://regex101.com/> 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 <https://regex101.com/> 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. |
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
)
|
||||
]
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -1 +0,0 @@
|
|||
../README.md
|
||||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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) =
|
|||
)}
|
||||
</div>
|
||||
<VerticalGroup spacing="xs">
|
||||
<Text type="primary" size="medium">
|
||||
<Emoji className={cx('title')} text={alertReceiveChannel.verbal_name} />
|
||||
</Text>
|
||||
|
||||
<HorizontalGroup>
|
||||
<IntegrationLogo scale={0.08} integration={integration} />
|
||||
<Text type="secondary" size="small">
|
||||
{integration?.display_name}
|
||||
</Text>
|
||||
<Text type="secondary" size="small">
|
||||
|
|
||||
<Text type="primary" size="medium">
|
||||
<Emoji className={cx('title')} text={alertReceiveChannel.verbal_name} />
|
||||
</Text>
|
||||
{alertReceiveChannelCounter && (
|
||||
<PluginLink
|
||||
query={{ page: 'incidents', integration: alertReceiveChannel.id }}
|
||||
className={cx('alertsInfoText')}
|
||||
>
|
||||
<b>{alertReceiveChannelCounter?.alerts_count}</b> alert
|
||||
{alertReceiveChannelCounter?.alerts_count === 1 ? '' : 's'} in{' '}
|
||||
<b>{alertReceiveChannelCounter?.alert_groups_count}</b> Alert Group
|
||||
{alertReceiveChannelCounter?.alert_groups_count === 1 ? '' : 's'}
|
||||
<Badge
|
||||
text={alertReceiveChannelCounter?.alerts_count + '/' + alertReceiveChannelCounter?.alert_groups_count}
|
||||
color={'blue'}
|
||||
tooltip={
|
||||
alertReceiveChannelCounter?.alerts_count +
|
||||
' alert' +
|
||||
(alertReceiveChannelCounter?.alerts_count === 1 ? '' : 's') +
|
||||
' in ' +
|
||||
alertReceiveChannelCounter?.alert_groups_count +
|
||||
' alert group' +
|
||||
(alertReceiveChannelCounter?.alert_groups_count === 1 ? '' : 's')
|
||||
}
|
||||
/>
|
||||
</PluginLink>
|
||||
)}
|
||||
</HorizontalGroup>
|
||||
<HorizontalGroup>
|
||||
<IntegrationLogo scale={0.08} integration={integration} />
|
||||
<Text type="secondary" size="small">
|
||||
{integration?.display_name}
|
||||
</Text>
|
||||
</HorizontalGroup>
|
||||
</VerticalGroup>
|
||||
</HorizontalGroup>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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<AlertRulesProps, AlertRulesState> {
|
|||
onDismiss={() => this.setState({ editIntegrationName: undefined })}
|
||||
>
|
||||
<div className={cx('root')} data-testid="edit-integration-name-modal">
|
||||
<Field invalid={isIntegrationNameempty} label="Integration name">
|
||||
<Field invalid={isIntegrationNameempty}>
|
||||
<Input
|
||||
autoFocus
|
||||
value={editIntegrationName}
|
||||
|
|
@ -694,40 +697,99 @@ class AlertRules extends React.Component<AlertRulesProps, AlertRulesState> {
|
|||
|
||||
const index = channelFilterIds.indexOf(channelFilterId);
|
||||
return (
|
||||
<div className={cx('channel-filter-header')}>
|
||||
<div className={cx('channel-filter-header-title')}>
|
||||
{channelFilter.is_default ? (
|
||||
<Text type="success">{channelFilterIds.length > 1 ? 'ELSE ' : ''}</Text>
|
||||
) : (
|
||||
<>
|
||||
<Text type="success">{index === 0 ? 'IF ' : 'ELSE IF '}</Text>alert payload matches regex
|
||||
<Text
|
||||
keyboard
|
||||
//@ts-ignore
|
||||
onClick={this.getEditChannelFilterClickHandler(channelFilter)}
|
||||
>
|
||||
{channelFilter.filtering_term}
|
||||
</Text>
|
||||
</>
|
||||
)}
|
||||
escalate to{' '}
|
||||
<WithPermissionControlTooltip userAction={UserActions.IntegrationsWrite}>
|
||||
<div onClick={(e) => e.stopPropagation()}>
|
||||
<GSelect
|
||||
showSearch
|
||||
modelName="escalationChainStore"
|
||||
displayField="name"
|
||||
placeholder="Select Escalation Chain"
|
||||
className={cx('select', 'control', 'no-trigger-collapse-please')}
|
||||
value={channelFilter.escalation_chain}
|
||||
onChange={this.getEscalationChainChangeHandler(channelFilterId)}
|
||||
showWarningIfEmptyValue={true}
|
||||
/>
|
||||
<>
|
||||
<div className={cx('channel-filter-header')}>
|
||||
<div className={cx('channel-filter-header-left')}>
|
||||
<div className={cx('channel-filter-header-title')}>
|
||||
{channelFilter.is_default ? (
|
||||
<>
|
||||
{channelFilterIds.length > 1 && <Text keyboard>ELSE</Text>}
|
||||
<Text>route to escalation chain:</Text>
|
||||
<WithPermissionControlTooltip userAction={UserActions.IntegrationsWrite}>
|
||||
<div onClick={(e) => e.stopPropagation()}>
|
||||
<GSelect
|
||||
showSearch
|
||||
modelName="escalationChainStore"
|
||||
displayField="name"
|
||||
placeholder="Select Escalation Chain"
|
||||
className={cx('select', 'control', 'no-trigger-collapse-please')}
|
||||
value={channelFilter.escalation_chain}
|
||||
onChange={this.getEscalationChainChangeHandler(channelFilterId)}
|
||||
showWarningIfEmptyValue={true}
|
||||
width={'auto'}
|
||||
icon={'list-ul'}
|
||||
/>
|
||||
</div>
|
||||
</WithPermissionControlTooltip>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Text keyboard>{index === 0 ? 'IF' : 'ELSE IF'}</Text>
|
||||
{channelFilter.filtering_term_type === 0 ? (
|
||||
<>
|
||||
<Tooltip content={'Recommend you to switch from regular expressions to jinja2 templates'}>
|
||||
<Text>regular expression</Text>
|
||||
</Tooltip>
|
||||
<Tooltip content={'We recommend to switch to jinja2 based routes'}>
|
||||
<Icon
|
||||
name="exclamation-circle"
|
||||
style={{
|
||||
color: '#FF5286',
|
||||
}}
|
||||
/>
|
||||
</Tooltip>
|
||||
</>
|
||||
) : (
|
||||
<Text>jinja2 expression</Text>
|
||||
)}
|
||||
<Text>is</Text>
|
||||
<Text keyboard>{'True'}</Text>
|
||||
<Text>{'for new Alert Group:'}</Text>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</WithPermissionControlTooltip>
|
||||
</div>
|
||||
<div className={cx('channel-filter-header-right')}>
|
||||
<div onClick={(e) => e.stopPropagation()}>{this.renderChannelFilterButtons(channelFilterId, index)}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div onClick={(e) => e.stopPropagation()}>{this.renderChannelFilterButtons(channelFilterId, index)}</div>
|
||||
</div>
|
||||
{!channelFilter.is_default && (
|
||||
<VerticalGroup>
|
||||
<HorizontalGroup>
|
||||
{!channelFilter.is_default && (
|
||||
<>
|
||||
{channelFilter.filtering_term_type === 0 ? (
|
||||
<SourceCode showCopyToClipboard={false}>
|
||||
{'payload =~ "' + channelFilter.filtering_term + '"'}
|
||||
</SourceCode>
|
||||
) : (
|
||||
<SourceCode showCopyToClipboard={false}>{channelFilter.filtering_term}</SourceCode>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</HorizontalGroup>
|
||||
<HorizontalGroup>
|
||||
<Text>{'route to escalation chain: '}</Text>
|
||||
<WithPermissionControlTooltip userAction={UserActions.IntegrationsWrite}>
|
||||
<div onClick={(e) => e.stopPropagation()}>
|
||||
<GSelect
|
||||
showSearch
|
||||
modelName="escalationChainStore"
|
||||
displayField="name"
|
||||
placeholder="Select Escalation Chain"
|
||||
className={cx('select', 'control', 'no-trigger-collapse-please')}
|
||||
value={channelFilter.escalation_chain}
|
||||
onChange={this.getEscalationChainChangeHandler(channelFilterId)}
|
||||
showWarningIfEmptyValue={true}
|
||||
width={'auto'}
|
||||
icon={'list-ul'}
|
||||
/>
|
||||
</div>
|
||||
</WithPermissionControlTooltip>
|
||||
</HorizontalGroup>
|
||||
</VerticalGroup>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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<string>(data ? data.filtering_term : '.*');
|
||||
// TODO: use FilteringTermType.jinja2 instead of 1
|
||||
const [filteringTermType, setFilteringTermType] = useState<FilteringTermType>(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<string>(renderFilteringTermValue(filteringTermType));
|
||||
|
||||
const [errors, setErrors] = useState<{ filtering_term?: string }>({});
|
||||
|
||||
const store = useStore();
|
||||
|
||||
const { alertReceiveChannelStore } = store;
|
||||
|
||||
const handleFilteringTermChange = useCallback((event: ChangeEvent<HTMLInputElement>) => {
|
||||
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 (
|
||||
<Block bordered className={cx('root', className)}>
|
||||
|
|
@ -69,44 +93,86 @@ const ChannelFilterForm = observer((props: ChannelFilterFormProps) => {
|
|||
{id === 'new' ? 'New' : 'Update'} Route
|
||||
</Text.Title>
|
||||
<Text type="secondary">
|
||||
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.
|
||||
</Text>
|
||||
<div className={styles.form}>
|
||||
<Field
|
||||
invalid={Boolean(errors['filtering_term'])}
|
||||
disabled={data?.is_default}
|
||||
error={errors['filtering_term']}
|
||||
label="Regex to route incidents"
|
||||
description={
|
||||
<>
|
||||
Use{' '}
|
||||
<a href="https://regex101.com/" target="_blank" rel="noreferrer">
|
||||
python style
|
||||
</a>{' '}
|
||||
regex to filter incidents based on a expression
|
||||
</>
|
||||
}
|
||||
>
|
||||
<Input
|
||||
placeholder={
|
||||
data?.is_default ? "Default routes can't have a filtering term" : 'Insert your regular expression here'
|
||||
}
|
||||
autoFocus
|
||||
value={filteringTerm}
|
||||
onChange={handleFilteringTermChange}
|
||||
<Field>
|
||||
<RadioButtonGroup
|
||||
options={[
|
||||
{ label: 'Jinja2 (recommended)', value: 1 },
|
||||
{ label: 'Regular Expression', value: 0 },
|
||||
]}
|
||||
value={filteringTermType}
|
||||
onChange={(value) => {
|
||||
setErrors({});
|
||||
setFilteringTermType(value);
|
||||
setFilteringTerm(renderFilteringTermValue(value));
|
||||
}}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
{filteringTermType === 0 ? (
|
||||
<>
|
||||
<Field
|
||||
invalid={Boolean(errors['filtering_term'])}
|
||||
disabled={data?.is_default}
|
||||
error={errors['filtering_term']}
|
||||
label="Regex to route alert groups"
|
||||
description={
|
||||
<>
|
||||
Use{' '}
|
||||
<a href="https://regex101.com/" target="_blank" rel="noreferrer">
|
||||
python style
|
||||
</a>{' '}
|
||||
regex to filter incidents based on a expression
|
||||
</>
|
||||
}
|
||||
>
|
||||
<MonacoJinja2Editor
|
||||
value={filteringTerm}
|
||||
disabled={false}
|
||||
onChange={handleFilteringTermChange}
|
||||
data={{}}
|
||||
loading={null}
|
||||
/>
|
||||
</Field>
|
||||
{!data?.is_default && (
|
||||
<IncidentMatcher
|
||||
regexp={filteringTerm}
|
||||
className={cx('incident-matcher')}
|
||||
onError={(message: string) => {
|
||||
setErrors({ filtering_term: message });
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Text type="secondary">
|
||||
If the result of the{' '}
|
||||
<a href="https://jinja.palletsprojects.com/en/3.0.x/" target="_blank" rel="noreferrer">
|
||||
Jinja2-based template
|
||||
</a>{' '}
|
||||
is <Text keyboard>True</Text>
|
||||
alert group will be matched with this route
|
||||
</Text>
|
||||
<Field
|
||||
invalid={Boolean(errors['filtering_term'])}
|
||||
disabled={data?.is_default}
|
||||
error={errors['filtering_term']}
|
||||
>
|
||||
<MonacoJinja2Editor
|
||||
value={filteringTerm}
|
||||
disabled={false}
|
||||
onChange={handleFilteringTermChange}
|
||||
data={{}}
|
||||
loading={null}
|
||||
/>
|
||||
</Field>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{!data?.is_default && (
|
||||
<IncidentMatcher
|
||||
regexp={filteringTerm}
|
||||
className={cx('incident-matcher')}
|
||||
onError={(message: string) => {
|
||||
setErrors({ filtering_term: message });
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<HorizontalGroup>
|
||||
<Button variant="primary" onClick={onUpdateClickCallback}>
|
||||
{id === 'new' ? 'Create' : 'Update'} route
|
||||
|
|
|
|||
|
|
@ -36,6 +36,8 @@ interface GSelectProps {
|
|||
getOptionLabel?: <T>(item: SelectableValue<T>) => React.ReactNode;
|
||||
getDescription?: (item: any) => React.ReactNode;
|
||||
openMenuOnFocus?: boolean;
|
||||
width?: number | 'auto';
|
||||
icon?: string;
|
||||
}
|
||||
|
||||
const GSelect = observer((props: GSelectProps) => {
|
||||
|
|
@ -60,6 +62,8 @@ const GSelect = observer((props: GSelectProps) => {
|
|||
getDescription,
|
||||
filterOptions,
|
||||
fromOrganization,
|
||||
width = null,
|
||||
icon = null,
|
||||
} = props;
|
||||
|
||||
const store = useStore();
|
||||
|
|
@ -152,6 +156,8 @@ const GSelect = observer((props: GSelectProps) => {
|
|||
noOptionsMessage={`Not found`}
|
||||
getOptionLabel={getOptionLabel}
|
||||
invalid={showError || (showWarningIfEmptyValue && !value)}
|
||||
width={width}
|
||||
icon={icon}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -96,7 +96,7 @@ const IncidentMatcher = observer((props: IncidentMatcherProps) => {
|
|||
</div>
|
||||
<div className={cx('incident-payload')}>
|
||||
<Text.Title className={cx('title')} level={5}>
|
||||
Incident payload
|
||||
Alert Group payload
|
||||
</Text.Title>
|
||||
{selectedAlertItem ? (
|
||||
<SourceCode noMaxHeight>{JSON.stringify(selectedAlertItem, null, 2)}</SourceCode>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -171,7 +171,7 @@ class EscalationChainsPage extends React.Component<EscalationChainsPageProps, Es
|
|||
icon="plus"
|
||||
className={cx('new-escalation-chain')}
|
||||
>
|
||||
New Escalation Chain
|
||||
New escalation chain
|
||||
</Button>
|
||||
</WithPermissionControlTooltip>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -554,8 +554,7 @@ class IncidentPage extends React.Component<IncidentPageProps, IncidentPageState>
|
|||
</span>
|
||||
);
|
||||
default:
|
||||
console.warn('Unknown render_after_resolve_report_json entity placeholder');
|
||||
return '';
|
||||
return '{{' + match + '}}';
|
||||
}
|
||||
};
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -169,7 +169,7 @@ class Integrations extends React.Component<IntegrationsProps, IntegrationsState>
|
|||
icon="plus"
|
||||
className={cx('newIntegrationButton')}
|
||||
>
|
||||
New integration for receiving alerts
|
||||
New integration to receive alerts
|
||||
</Button>
|
||||
</WithPermissionControlTooltip>
|
||||
<div className={cx('alert-receive-channels-list')}>
|
||||
|
|
@ -224,7 +224,7 @@ class Integrations extends React.Component<IntegrationsProps, IntegrationsState>
|
|||
this.setState({ showCreateIntegrationModal: true });
|
||||
}}
|
||||
>
|
||||
New integration for receiving alerts
|
||||
New integration to receive alerts
|
||||
</Button>
|
||||
</WithPermissionControlTooltip>
|
||||
</VerticalGroup>
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue