merge dev to main (#1496)

# What this PR does

## Which issue(s) this PR fixes

## Checklist

- [ ] Tests updated
- [ ] Documentation added
- [ ] `CHANGELOG.md` updated

---------

Co-authored-by: Vadim Stepanov <vadimkerr@gmail.com>
Co-authored-by: Joey Orlando <joey.orlando@grafana.com>
This commit is contained in:
Ildar Iskhakov 2023-03-08 20:23:09 +08:00 committed by GitHub
parent 4e2076be97
commit 2c10fa583b
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
44 changed files with 563 additions and 265 deletions

View file

@ -5,6 +5,20 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## v1.1.34 (2023-03-08)
### Added
- Jinja2 based routes ([1319](https://github.com/grafana/oncall/pull/1319))
### Changed
- Remove mobile app feature flag ([1484](https://github.com/grafana/oncall/pull/1484))
### Fixed
- Prohibit creating & updating past overrides ([1474](https://github.com/grafana/oncall/pull/1474))
## v1.1.33 (2023-03-07)
### Fixed

View file

@ -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.

View file

@ -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 youre looking for isnt 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.

View file

@ -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 isnt 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.

View file

@ -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.

View file

@ -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.

View file

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

View file

@ -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.

View file

@ -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.

View file

@ -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. |

View file

@ -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
)
]

View file

@ -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,

View file

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

View file

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

View file

@ -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",

View file

@ -1,3 +1,4 @@
from django.utils import timezone
from rest_framework import serializers
from apps.schedules.models import CustomOnCallShift, OnCallScheduleWeb
@ -93,10 +94,13 @@ class OnCallShiftSerializer(EagerLoadingMixin, serializers.ModelSerializer):
result.append(users_dict)
return result
def _validate_shift_end(self, start, end):
def _validate_shift_end(self, start, end, event_type):
if end <= start:
raise serializers.ValidationError({"shift_end": ["Incorrect shift end date"]})
if event_type == CustomOnCallShift.TYPE_OVERRIDE and timezone.now() > end:
raise serializers.ValidationError({"shift_end": ["Cannot create or update an override in the past"]})
def _validate_frequency(self, frequency, event_type, rolling_users, interval, by_day, until):
if frequency is None:
if rolling_users and len(rolling_users) > 1:
@ -157,7 +161,7 @@ class OnCallShiftSerializer(EagerLoadingMixin, serializers.ModelSerializer):
# convert shift_end into internal value and validate
raw_shift_end = self.initial_data["shift_end"]
shift_end = serializers.DateTimeField().to_internal_value(raw_shift_end)
self._validate_shift_end(validated_data["start"], shift_end)
self._validate_shift_end(validated_data["start"], shift_end, event_type)
validated_data["duration"] = shift_end - validated_data["start"]
if validated_data.get("schedule"):

View file

@ -7,7 +7,6 @@ from apps.api.views.features import (
FEATURE_GRAFANA_CLOUD_CONNECTION,
FEATURE_GRAFANA_CLOUD_NOTIFICATIONS,
FEATURE_LIVE_SETTINGS,
FEATURE_MOBILE_APP,
FEATURE_SLACK,
FEATURE_TELEGRAM,
FEATURE_WEB_SCHEDULES,
@ -39,7 +38,6 @@ def test_features_view(
("FEATURE_TELEGRAM_INTEGRATION_ENABLED", FEATURE_TELEGRAM),
("FEATURE_LIVE_SETTINGS_ENABLED", FEATURE_LIVE_SETTINGS),
("FEATURE_WEB_SCHEDULES_ENABLED", FEATURE_WEB_SCHEDULES),
("FEATURE_MOBILE_APP_INTEGRATION_ENABLED", FEATURE_MOBILE_APP),
],
)
def test_core_features_switch(

View file

@ -921,6 +921,30 @@ def test_create_on_call_shift_override_invalid_data(on_call_shift_internal_api_s
assert response.data["frequency"][0] == "Cannot set 'frequency' for shifts with type 'override'"
@pytest.mark.django_db
def test_create_on_call_shift_override_in_past(on_call_shift_internal_api_setup, make_user_auth_headers):
token, user1, _, _, schedule = on_call_shift_internal_api_setup
client = APIClient()
url = reverse("api-internal:oncall_shifts-list")
start_date = timezone.now().replace(microsecond=0, tzinfo=None) - timezone.timedelta(hours=2)
data = {
"title": "Test Shift Override",
"type": CustomOnCallShift.TYPE_OVERRIDE,
"schedule": schedule.public_primary_key,
"priority_level": 0,
"shift_start": start_date.strftime("%Y-%m-%dT%H:%M:%SZ"),
"shift_end": (start_date + timezone.timedelta(hours=1)).strftime("%Y-%m-%dT%H:%M:%SZ"),
"rotation_start": start_date.strftime("%Y-%m-%dT%H:%M:%SZ"),
"rolling_users": [[user1.public_primary_key]],
}
response = client.post(url, data, format="json", **make_user_auth_headers(user1, token))
assert response.status_code == status.HTTP_400_BAD_REQUEST
assert response.data["shift_end"][0] == "Cannot create or update an override in the past"
@pytest.mark.django_db
@pytest.mark.parametrize(
"role,expected_status",

View file

@ -9,7 +9,6 @@ from apps.base.utils import live_settings
FEATURE_SLACK = "slack"
FEATURE_TELEGRAM = "telegram"
FEATURE_LIVE_SETTINGS = "live_settings"
FEATURE_MOBILE_APP = "mobile_app"
FEATURE_GRAFANA_CLOUD_NOTIFICATIONS = "grafana_cloud_notifications"
FEATURE_GRAFANA_CLOUD_CONNECTION = "grafana_cloud_connection"
FEATURE_WEB_SCHEDULES = "web_schedules"
@ -36,9 +35,6 @@ class FeaturesAPIView(APIView):
if settings.FEATURE_TELEGRAM_INTEGRATION_ENABLED:
enabled_features.append(FEATURE_TELEGRAM)
if settings.FEATURE_MOBILE_APP_INTEGRATION_ENABLED:
enabled_features.append(FEATURE_MOBILE_APP)
if settings.IS_OPEN_SOURCE:
# Features below should be enabled only in OSS
enabled_features.append(FEATURE_GRAFANA_CLOUD_CONNECTION)

View file

@ -13,7 +13,6 @@ from apps.mobile_app.fcm_relay import FCMRelayThrottler, fcm_relay_async
@pytest.mark.django_db
def test_fcm_relay_disabled(
settings,
load_mobile_app_urls,
make_organization_and_user_with_plugin_token,
make_user_auth_headers,
make_public_api_token,
@ -33,7 +32,6 @@ def test_fcm_relay_disabled(
@pytest.mark.django_db
def test_fcm_relay_post(
settings,
load_mobile_app_urls,
make_organization_and_user_with_plugin_token,
make_user_auth_headers,
make_public_api_token,
@ -59,7 +57,6 @@ def test_fcm_relay_post(
@pytest.mark.django_db
def test_fcm_relay_ratelimit(
settings,
load_mobile_app_urls,
make_organization_and_user_with_plugin_token,
make_user_auth_headers,
make_public_api_token,

View file

@ -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):

View file

@ -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,

View file

@ -755,12 +755,6 @@ def load_slack_urls(settings):
reload_urls(settings)
@pytest.fixture()
def load_mobile_app_urls(settings):
settings.FEATURE_MOBILE_APP_INTEGRATION_ENABLED = True
reload_urls(settings)
@pytest.fixture
def make_region():
def _make_region(**kwargs):

View file

@ -35,6 +35,8 @@ urlpatterns = [
path("integrations/v1/", include("apps.integrations.urls", namespace="integrations")),
path("twilioapp/", include("apps.twilioapp.urls")),
path("api/v1/", include("apps.public_api.urls", namespace="api-public")),
path("mobile_app/v1/", include("apps.mobile_app.urls", namespace="mobile_app")),
path("api/internal/v1/mobile_app/", include("apps.mobile_app.urls", namespace="mobile_app_tmp")),
] + static(settings.STATIC_URL, document_root=settings.STATIC_ROOT)
if settings.FEATURE_SLACK_INTEGRATION_ENABLED:
@ -50,13 +52,6 @@ if settings.FEATURE_SLACK_INTEGRATION_ENABLED:
path("slack/", include("apps.slack.urls")),
]
if settings.FEATURE_MOBILE_APP_INTEGRATION_ENABLED:
urlpatterns += [
path("mobile_app/v1/", include("apps.mobile_app.urls", namespace="mobile_app")),
path("api/internal/v1/mobile_app/", include("apps.mobile_app.urls", namespace="mobile_app_tmp")),
]
if settings.IS_OPEN_SOURCE:
urlpatterns += [
path("api/internal/v1/", include("apps.oss_installation.urls", namespace="oss_installation")),

View file

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

View file

@ -2,6 +2,7 @@ import os
from random import randrange
from celery.schedules import crontab
from firebase_admin import initialize_app
from common.utils import getenv_boolean, getenv_integer
@ -55,7 +56,6 @@ FEATURE_LIVE_SETTINGS_ENABLED = getenv_boolean("FEATURE_LIVE_SETTINGS_ENABLED",
FEATURE_TELEGRAM_INTEGRATION_ENABLED = getenv_boolean("FEATURE_TELEGRAM_INTEGRATION_ENABLED", default=True)
FEATURE_EMAIL_INTEGRATION_ENABLED = getenv_boolean("FEATURE_EMAIL_INTEGRATION_ENABLED", default=True)
FEATURE_SLACK_INTEGRATION_ENABLED = getenv_boolean("FEATURE_SLACK_INTEGRATION_ENABLED", default=True)
FEATURE_MOBILE_APP_INTEGRATION_ENABLED = getenv_boolean("FEATURE_MOBILE_APP_INTEGRATION_ENABLED", default=True)
FEATURE_WEB_SCHEDULES_ENABLED = getenv_boolean("FEATURE_WEB_SCHEDULES_ENABLED", default=False)
FEATURE_MULTIREGION_ENABLED = getenv_boolean("FEATURE_MULTIREGION_ENABLED", default=False)
GRAFANA_CLOUD_ONCALL_HEARTBEAT_ENABLED = getenv_boolean("GRAFANA_CLOUD_ONCALL_HEARTBEAT_ENABLED", default=True)
@ -556,16 +556,12 @@ GRAFANA_COM_ADMIN_API_TOKEN = os.environ.get("GRAFANA_COM_ADMIN_API_TOKEN", None
GRAFANA_API_KEY_NAME = "Grafana OnCall"
EXTRA_MESSAGING_BACKENDS = []
if FEATURE_MOBILE_APP_INTEGRATION_ENABLED:
from firebase_admin import initialize_app
EXTRA_MESSAGING_BACKENDS = [
("apps.mobile_app.backend.MobileAppBackend", 5),
("apps.mobile_app.backend.MobileAppCriticalBackend", 6),
]
EXTRA_MESSAGING_BACKENDS += [
("apps.mobile_app.backend.MobileAppBackend", 5),
("apps.mobile_app.backend.MobileAppCriticalBackend", 6),
]
FIREBASE_APP = initialize_app(options={"projectId": os.environ.get("FCM_PROJECT_ID", None)})
FIREBASE_APP = initialize_app(options={"projectId": os.environ.get("FCM_PROJECT_ID", None)})
FCM_RELAY_ENABLED = getenv_boolean("FCM_RELAY_ENABLED", default=False)
FCM_DJANGO_SETTINGS = {

View file

@ -1 +0,0 @@
../README.md

View file

@ -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();

View file

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

View file

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

View file

@ -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}
@ -661,20 +664,22 @@ class AlertRules extends React.Component<AlertRulesProps, AlertRulesState> {
/>
</WithPermissionControlTooltip>
)}
<WithPermissionControlTooltip userAction={UserActions.IntegrationsWrite}>
<IconButton
size="md"
name="pen"
onClick={(event) => {
event.stopPropagation();
this.setState({
channelFilterToEdit: channelFilter,
});
}}
tooltip="Edit"
tooltipPlacement="top"
/>
</WithPermissionControlTooltip>
{!channelFilter.is_default && (
<WithPermissionControlTooltip userAction={UserActions.IntegrationsWrite}>
<IconButton
size="md"
name="pen"
onClick={(event) => {
event.stopPropagation();
this.setState({
channelFilterToEdit: channelFilter,
});
}}
tooltip="Edit"
tooltipPlacement="top"
/>
</WithPermissionControlTooltip>
)}
<WithPermissionControlTooltip userAction={UserActions.IntegrationsTest}>
<Button variant="secondary" size="sm" onClick={this.getSendDemoAlertToParticularRoute(channelFilterId)}>
Send demo alert
@ -694,40 +699,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>
)}
</>
);
};

View file

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

View file

@ -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>
);

View file

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

View file

@ -7,7 +7,6 @@ import { useMediaQuery } from 'react-responsive';
import { Tabs, TabsContent } from 'containers/UserSettings/parts';
import { User as UserType } from 'models/user/user.types';
import { AppFeature } from 'state/features';
import { useStore } from 'state/useStore';
import { isUserActionAllowed, UserActions } from 'utils/authorization';
import { BREAKPOINT_TABS } from 'utils/consts';
@ -53,7 +52,7 @@ const UserSettings = observer(({ id, onHide, tab = UserSettingsTab.UserInfo }: U
!isDesktopOrLaptop,
isCurrent && teamStore.currentTeam?.slack_team_identity && !storeUser.slack_user_identity,
isCurrent && !storeUser.telegram_configuration,
isCurrent && store.hasFeature(AppFeature.MobileApp) && isUserActionAllowed(UserActions.UserSettingsWrite),
isCurrent && isUserActionAllowed(UserActions.UserSettingsWrite),
];
return (

View file

@ -4,30 +4,22 @@ import { Button, Label } from '@grafana/ui';
import cn from 'classnames/bind';
import { UserSettingsTab } from 'containers/UserSettings/UserSettings.types';
import { AppFeature } from 'state/features';
import { useStore } from 'state/useStore';
import styles from './index.module.css';
const cx = cn.bind(styles);
interface SlackConnectorProps {
interface MobileAppConnectorProps {
onTabChange: (tab: UserSettingsTab) => void;
}
const SlackConnector = (props: SlackConnectorProps) => {
const MobileAppConnector = (props: MobileAppConnectorProps) => {
const { onTabChange } = props;
const store = useStore();
const handleClickConfirmMobileAppButton = useCallback(() => {
onTabChange(UserSettingsTab.MobileAppConnection);
}, [onTabChange]);
if (!store.hasFeature(AppFeature.MobileApp)) {
return null;
}
return (
<div className={cx('user-item')}>
<Label>Mobile App:</Label>
@ -40,4 +32,4 @@ const SlackConnector = (props: SlackConnectorProps) => {
);
};
export default SlackConnector;
export default MobileAppConnector;

View file

@ -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;

View file

@ -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);
}

View file

@ -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>
)}

View file

@ -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 + '}}';
}
};
};

View file

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

View file

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

View file

@ -11,7 +11,6 @@ import Text from 'components/Text/Text';
import WithConfirm from 'components/WithConfirm/WithConfirm';
import { CrossCircleIcon, HeartIcon } from 'icons';
import { Cloud } from 'models/cloud/cloud.types';
import { AppFeature } from 'state/features';
import { WithStoreProps } from 'state/types';
import { useStore } from 'state/useStore';
import { withMobXProviderContext } from 'state/withStore';
@ -309,19 +308,17 @@ const CloudPage = observer((props: CloudPageProps) => {
</VerticalGroup>
)}
</Block>
{store.hasFeature(AppFeature.MobileApp) && (
<Block bordered withBackground className={cx('info-block')}>
<VerticalGroup>
<Text.Title level={4}>
<Icon name="mobile-android" className={cx('block-icon')} size="lg" /> Mobile app push notifications
</Text.Title>
<Text type="secondary">
Connecting to Cloud OnCall enables sending push notifications on mobile devices using the Grafana OnCall
mobile app.
</Text>
</VerticalGroup>
</Block>
)}
<Block bordered withBackground className={cx('info-block')}>
<VerticalGroup>
<Text.Title level={4}>
<Icon name="mobile-android" className={cx('block-icon')} size="lg" /> Mobile app push notifications
</Text.Title>
<Text type="secondary">
Connecting to Cloud OnCall enables sending push notifications on mobile devices using the Grafana OnCall
mobile app.
</Text>
</VerticalGroup>
</Block>
</VerticalGroup>
);
@ -370,19 +367,17 @@ const CloudPage = observer((props: CloudPageProps) => {
</Text>
</VerticalGroup>
</Block>
{store.hasFeature(AppFeature.MobileApp) && (
<Block bordered withBackground className={cx('info-block')}>
<VerticalGroup>
<Text.Title level={4}>
<Icon name="mobile-android" className={cx('block-icon')} size="lg" /> Mobile app push notifications
</Text.Title>
<Text type="secondary">
Connecting to Cloud OnCall enables sending push notifications on mobile devices using the Grafana OnCall
mobile app.
</Text>
</VerticalGroup>
</Block>
)}
<Block bordered withBackground className={cx('info-block')}>
<VerticalGroup>
<Text.Title level={4}>
<Icon name="mobile-android" className={cx('block-icon')} size="lg" /> Mobile app push notifications
</Text.Title>
<Text type="secondary">
Connecting to Cloud OnCall enables sending push notifications on mobile devices using the Grafana OnCall
mobile app.
</Text>
</VerticalGroup>
</Block>
</VerticalGroup>
);

View file

@ -2,7 +2,6 @@ export enum AppFeature {
Slack = 'slack',
Telegram = 'telegram',
LiveSettings = 'live_settings',
MobileApp = 'mobile_app',
CloudNotifications = 'grafana_cloud_notifications',
CloudConnection = 'grafana_cloud_connection',
WebSchedules = 'web_schedules',