From 2c10fa583b4e46d47f5a1eb496be75bd745cbdca Mon Sep 17 00:00:00 2001 From: Ildar Iskhakov Date: Wed, 8 Mar 2023 20:23:09 +0800 Subject: [PATCH] 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 Co-authored-by: Joey Orlando --- CHANGELOG.md | 14 ++ .../configure-routes/index.md | 14 +- docs/sources/get-started/_index.md | 2 +- docs/sources/integrations/_index.md | 2 +- .../configure-alertmanager/index.md | 2 +- .../configure-grafana-alerting/index.md | 6 +- .../configure-webhook/index.md | 2 +- .../configure-zabbix/index.md | 2 +- .../configure-teams/index.md | 2 +- docs/sources/oncall-api-reference/routes.md | 5 +- .../0010_channelfilter_filtering_term_type.py | 26 +++ engine/apps/alerts/models/alert.py | 4 +- engine/apps/alerts/models/channel_filter.py | 46 +++-- .../apps/alerts/tests/test_channel_filter.py | 68 +++++++- engine/apps/api/serializers/channel_filter.py | 42 +++-- engine/apps/api/serializers/on_call_shifts.py | 8 +- engine/apps/api/tests/test_features.py | 2 - engine/apps/api/tests/test_oncall_shift.py | 24 +++ engine/apps/api/views/features.py | 4 - .../apps/mobile_app/tests/test_fcm_relay.py | 3 - engine/apps/public_api/serializers/routes.py | 44 +++-- engine/apps/public_api/tests/test_routes.py | 8 + engine/conftest.py | 6 - engine/engine/urls.py | 9 +- engine/requirements.txt | 1 + engine/settings/base.py | 16 +- grafana-plugin/README.md | 1 - .../integration-tests/utils/integrations.ts | 2 +- .../AlertReceiveChannelCard.tsx | 37 ++-- .../AlertRules/AlertRules.module.css | 23 ++- .../src/containers/AlertRules/AlertRules.tsx | 158 ++++++++++++------ .../ChannelFilterForm/ChannelFilterForm.tsx | 148 +++++++++++----- .../src/containers/GSelect/GSelect.tsx | 6 + .../IncidentMatcher/IncidentMatcher.tsx | 2 +- .../containers/UserSettings/UserSettings.tsx | 3 +- .../parts/connectors/MobileAppConnector.tsx | 14 +- .../channel_filter/channel_filter.types.ts | 6 + .../EscalationChains.module.css | 3 +- .../escalation-chains/EscalationChains.tsx | 2 +- .../src/pages/incident/Incident.tsx | 3 +- .../integrations/Integrations.module.css | 4 +- .../src/pages/integrations/Integrations.tsx | 4 +- .../pages/settings/tabs/Cloud/CloudPage.tsx | 49 +++--- grafana-plugin/src/state/features.ts | 1 - 44 files changed, 563 insertions(+), 265 deletions(-) create mode 100644 engine/apps/alerts/migrations/0010_channelfilter_filtering_term_type.py delete mode 120000 grafana-plugin/README.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 21ca8805..205455c6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/docs/sources/escalation-policies/configure-routes/index.md b/docs/sources/escalation-policies/configure-routes/index.md index 71946e07..b4475fd2 100644 --- a/docs/sources/escalation-policies/configure-routes/index.md +++ b/docs/sources/escalation-policies/configure-routes/index.md @@ -55,12 +55,16 @@ You can set up a single route and specify notification escalation steps, or you its own configuration. Each route added to an escalation policy follows an `IF`, `ELSE IF`, or `ELSE` path and depends on the type of alert you -specify using a regular expression that matches content in the payload body of the alert. You can also specify where -to send the notification for each route. +specify using a Jinja template that matches content in the payload body of the first alert in alert group. You can also +specify where to send the notification for each route. -For example, you can send notifications for alerts with `\"severity\": \"critical\"` in the payload to an escalation -chain called `Bob_OnCall`. You can create a different route for alerts with the payload -`\"namespace\" *: *\"synthetic-monitoring-dev-.*\"` and select a escalation chain called `NotifySecurity`. +For example, you can send notifications for alerts with `{{ payload.severity == "critical" and payload.service == +"database" }}` in the payload to an escalation chain called `Bob_OnCall`. You can create a different route for alerts +with the payload `{{ "synthetic-monitoring-dev-" in payload.namespace }}` and select a escalation chain called +`NotifySecurity`. + +Alternatively you can use regular expressions, e.g. `\"severity\": \"critical\"` or `\"namespace\" *: +*\"synthetic-monitoring-dev-.*\"` You can set up escalation steps for each route in a chain. diff --git a/docs/sources/get-started/_index.md b/docs/sources/get-started/_index.md index 0a4b8033..46a6704f 100644 --- a/docs/sources/get-started/_index.md +++ b/docs/sources/get-started/_index.md @@ -52,7 +52,7 @@ send a demo alert. #### Configure your first integration -1. In Grafana OnCall, navigate to the **Integrations** tab and click **+ New integration for receiving alerts**. +1. In Grafana OnCall, navigate to the **Integrations** tab and click **+ New integration to receive alerts**. 2. Select an integration from the provided options, if the integration you’re looking for isn’t listed, then select Webhook. 3. Follow the configuration steps on the integration settings page. 4. Complete any necessary configurations in your monitoring system to send alerts to Grafana OnCall. diff --git a/docs/sources/integrations/_index.md b/docs/sources/integrations/_index.md index 8d6247ac..e575df09 100644 --- a/docs/sources/integrations/_index.md +++ b/docs/sources/integrations/_index.md @@ -33,7 +33,7 @@ describe how to configure and customize your integrations to ensure alerts are t To configure an integration for Grafana OnCall: -1. In Grafana OnCall, navigate to the **Integrations** tab and click **+ New integration for receiving alerts**. +1. In Grafana OnCall, navigate to the **Integrations** tab and click **+ New integration to receive alerts**. 2. Select an integration from the provided options, if the integration you want isn’t listed, then select **Webhook**. 3. Follow the configuration steps on the integration settings page. 4. Complete any necessary configurations in your tool to send alerts to Grafana OnCall. diff --git a/docs/sources/integrations/available-integrations/configure-alertmanager/index.md b/docs/sources/integrations/available-integrations/configure-alertmanager/index.md index a56cc498..a0e59e06 100644 --- a/docs/sources/integrations/available-integrations/configure-alertmanager/index.md +++ b/docs/sources/integrations/available-integrations/configure-alertmanager/index.md @@ -25,7 +25,7 @@ alerts from Alertmanager, including initial deduplicating, grouping, and routing You must have an Admin role to create integrations in Grafana OnCall. -1. In the **Integrations** tab, click **+ New integration for receiving alerts**. +1. In the **Integrations** tab, click **+ New integration to receive alerts**. 2. Select **Alertmanager** from the list of available integrations. 3. Follow the instructions in the **How to connect** window to get your unique integration URL and identify next steps. diff --git a/docs/sources/integrations/available-integrations/configure-grafana-alerting/index.md b/docs/sources/integrations/available-integrations/configure-grafana-alerting/index.md index 5bd8ae9c..f6beab61 100644 --- a/docs/sources/integrations/available-integrations/configure-grafana-alerting/index.md +++ b/docs/sources/integrations/available-integrations/configure-grafana-alerting/index.md @@ -25,7 +25,7 @@ Grafana Alerting for Grafana OnCall can be set up using two methods: You must have an Admin role to create integrations in Grafana OnCall. -1. In the **Integrations** tab, click **+ New integration for receiving alerts**. +1. In the **Integrations** tab, click **+ New integration to receive alerts**. 2. Select **Grafana Alerting** by clicking the **Quick connect** button or select **Grafana (Other Grafana)** from the integrations list. 3. Follow the configuration steps that display in the **How to connect** window to retrieve your unique integration URL @@ -36,7 +36,7 @@ You must have an Admin role to create integrations in Grafana OnCall. Use the following method if you are connecting Grafana OnCall with alerts coming from the same Grafana instance from which Grafana OnCall is being managed. -1. In Grafana OnCall, navigate to the **Integrations** tab and select **New Integration for receiving alerts**. +1. In Grafana OnCall, navigate to the **Integrations** tab and select **New Integration to receive alerts**. 1. Click **Quick connect** in the **Grafana Alerting** tile. This will automatically create the integration in Grafana OnCall as well as the required contact point in Alerting. @@ -54,7 +54,7 @@ which Grafana OnCall is being managed. Connect Grafana OnCall with alerts coming from a Grafana instance that is different from the instance that Grafana OnCall is being managed: -1. In Grafana OnCall, navigate to the **Integrations** tab and select **New Integration for receiving alerts**. +1. In Grafana OnCall, navigate to the **Integrations** tab and select **New Integration to receive alerts**. 2. Select the **Grafana (Other Grafana)** tile. 3. Follow the configuration steps that display in the **How to connect** window to retrieve your unique integration URL and complete any necessary configurations. diff --git a/docs/sources/integrations/available-integrations/configure-webhook/index.md b/docs/sources/integrations/available-integrations/configure-webhook/index.md index 5319cdc6..d3cf8632 100644 --- a/docs/sources/integrations/available-integrations/configure-webhook/index.md +++ b/docs/sources/integrations/available-integrations/configure-webhook/index.md @@ -38,7 +38,7 @@ There are two available formats, **Webhook** and **Formatted Webhook**. To configure a webhook integration: -1. In the **Integrations** tab, click **+ New integration for receiving alerts**. +1. In the **Integrations** tab, click **+ New integration to receive alerts**. 2. Select either **Webhook** or **Formatted Webhook** integration. 3. Follow the configuration steps in the **How to connect** section of the integration settings. 4. Use the unique webhook URL to complete any configuration in your monitoring service to send POST requests. Use any diff --git a/docs/sources/integrations/available-integrations/configure-zabbix/index.md b/docs/sources/integrations/available-integrations/configure-zabbix/index.md index 86d4f8a7..f237abae 100644 --- a/docs/sources/integrations/available-integrations/configure-zabbix/index.md +++ b/docs/sources/integrations/available-integrations/configure-zabbix/index.md @@ -23,7 +23,7 @@ space consumption. This integration is available for Grafana Cloud OnCall. You must have an Admin role to create integrations in Grafana OnCall. -1. In the **Integrations** tab, click **+ New integration for receiving alerts**. +1. In the **Integrations** tab, click **+ New integration to receive alerts**. 2. Select **Zabbix** from the list of available integrations 3. Follow the instructions in the **How to connect** window to get your unique integration URL and review next steps. diff --git a/docs/sources/integrations/chatops-integrations/configure-teams/index.md b/docs/sources/integrations/chatops-integrations/configure-teams/index.md index 6946ead1..cc765fb1 100644 --- a/docs/sources/integrations/chatops-integrations/configure-teams/index.md +++ b/docs/sources/integrations/chatops-integrations/configure-teams/index.md @@ -75,6 +75,6 @@ send alerts from a specific integration to a channel in MS Teams. To automatically send alerts from an integration to MS Teams channels: 1. Navigate to the **Integrations** tab in Grafana OnCall, select an existing integration or - click **+New integration for receiving alerts**. + click **+New integration to receive alerts**. 1. From the integrations settings, navigate to the escalation chain panel. 1. Enable **Post to Microsoft Teams channel** by selecting a channel to connect from the dropdown. diff --git a/docs/sources/oncall-api-reference/routes.md b/docs/sources/oncall-api-reference/routes.md index a51209ab..3ffa32e9 100644 --- a/docs/sources/oncall-api-reference/routes.md +++ b/docs/sources/oncall-api-reference/routes.md @@ -49,10 +49,11 @@ Routes allow you to direct different alerts to different messenger channels and | Parameter | Unique | Required | Description | -| --------------------- | :----: | :------: | :------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +|-----------------------| :----: |:--------:|:--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| | `integration_id` | No | Yes | Each route is assigned to a specific integration. | | `escalation_chain_id` | No | Yes | Each route is assigned a specific escalation chain. | -| `routing_regex` | Yes | Yes | Python Regex query (use for debugging). OnCall chooses the route for an alert in case there is a match inside the whole alert payload. | +| `routing_type` | Yes | No | Routing type that can be either `jinja2` or `regex`(default value) | +| `routing_regex` | Yes | Yes | Jinja2 template or Python Regex query (use for debugging). OnCall chooses the route for an alert in case there is a match inside the whole alert payload. | | `position` | Yes | Optional | Route matching is performed one after another starting from position=`0`. Position=`-1` will put the route to the end of the list before `is_the_last_route`. A new route created with a position of an existing route will move the old route (and all following routes) down in the list. | | `slack` | Yes | Optional | Dictionary with Slack-specific settings for a route. | diff --git a/engine/apps/alerts/migrations/0010_channelfilter_filtering_term_type.py b/engine/apps/alerts/migrations/0010_channelfilter_filtering_term_type.py new file mode 100644 index 00000000..9741e60f --- /dev/null +++ b/engine/apps/alerts/migrations/0010_channelfilter_filtering_term_type.py @@ -0,0 +1,26 @@ +# Generated by Django 3.2.17 on 2023-03-07 07:27 + +from django.db import migrations, models +from django_add_default_value import AddDefaultValue + + +class Migration(migrations.Migration): + + dependencies = [ + ('alerts', '0009_alertreceivechannel_web_templates_modified_at'), + ] + + operations = [ + migrations.AddField( + model_name='channelfilter', + name='filtering_term_type', + field=models.IntegerField(choices=[(0, 'regex'), (1, 'jinja2')], default=0), + ), + # migrations.AddField enforces the default value on the app level, which leads to the issues during release + # adding same default value on the database level + AddDefaultValue( + model_name='channelfilter', + name='filtering_term_type', + value=0 + ) + ] diff --git a/engine/apps/alerts/models/alert.py b/engine/apps/alerts/models/alert.py index 0e108879..b49773e8 100644 --- a/engine/apps/alerts/models/alert.py +++ b/engine/apps/alerts/models/alert.py @@ -89,9 +89,7 @@ class Alert(models.Model): group_data = Alert.render_group_data(alert_receive_channel, raw_request_data, is_demo) if channel_filter is None: - channel_filter = ChannelFilter.select_filter( - alert_receive_channel, raw_request_data, title, message, force_route_id - ) + channel_filter = ChannelFilter.select_filter(alert_receive_channel, raw_request_data, force_route_id) group, group_created = AlertGroup.all_objects.get_or_create_grouping( channel=alert_receive_channel, diff --git a/engine/apps/alerts/models/channel_filter.py b/engine/apps/alerts/models/channel_filter.py index 63a49c70..9721fb2a 100644 --- a/engine/apps/alerts/models/channel_filter.py +++ b/engine/apps/alerts/models/channel_filter.py @@ -8,6 +8,8 @@ from django.core.validators import MinLengthValidator from django.db import models from ordered_model.models import OrderedModel +from common.jinja_templater import apply_jinja_template +from common.jinja_templater.apply_jinja_template import JinjaTemplateError, JinjaTemplateWarning from common.public_primary_keys import generate_public_primary_key, increase_public_primary_key_length logger = logging.getLogger(__name__) @@ -68,6 +70,15 @@ class ChannelFilter(OrderedModel): created_at = models.DateTimeField(auto_now_add=True) filtering_term = models.CharField(max_length=1024, null=True, default=None) + + FILTERING_TERM_TYPE_REGEX = 0 + FILTERING_TERM_TYPE_JINJA2 = 1 + FILTERING_TERM_TYPE_CHOICES = [ + (FILTERING_TERM_TYPE_REGEX, "regex"), + (FILTERING_TERM_TYPE_JINJA2, "jinja2"), + ] + filtering_term_type = models.IntegerField(choices=FILTERING_TERM_TYPE_CHOICES, default=FILTERING_TERM_TYPE_REGEX) + is_default = models.BooleanField(default=False) class Meta: @@ -81,7 +92,7 @@ class ChannelFilter(OrderedModel): return f"{self.pk}: {self.filtering_term or 'default'}" @classmethod - def select_filter(cls, alert_receive_channel, raw_request_data, title, message=None, force_route_id=None): + def select_filter(cls, alert_receive_channel, raw_request_data, force_route_id=None): # Try to find force route first if force_route_id is given if force_route_id is not None: logger.info( @@ -107,20 +118,29 @@ class ChannelFilter(OrderedModel): satisfied_filter = None for _filter in filters: - if satisfied_filter is None and _filter.is_satisfying(raw_request_data, title, message): + if satisfied_filter is None and _filter.is_satisfying(raw_request_data): satisfied_filter = _filter return satisfied_filter - def is_satisfying(self, raw_request_data, title, message=None): - return self.is_default or self.check_filter(json.dumps(raw_request_data)) or self.check_filter(str(title)) + def is_satisfying(self, raw_request_data): + return self.is_default or self.check_filter(raw_request_data) def check_filter(self, value): - try: - return re.search(self.filtering_term, value) - except re.error: - logger.error(f"channel_filter={self.id} failed to parse regex={self.filtering_term}") - return False + if self.filtering_term_type == ChannelFilter.FILTERING_TERM_TYPE_JINJA2: + try: + is_matching = apply_jinja_template(self.filtering_term, payload=value) + return is_matching.strip().lower() in ["1", "true", "ok"] + except (JinjaTemplateError, JinjaTemplateWarning): + logger.error(f"channel_filter={self.id} failed to parse jinja2={self.filtering_term}") + return False + if self.filtering_term is not None and self.filtering_term_type == ChannelFilter.FILTERING_TERM_TYPE_REGEX: + try: + return re.search(self.filtering_term, json.dumps(value)) + except re.error: + logger.error(f"channel_filter={self.id} failed to parse regex={self.filtering_term}") + return False + return False @property def slack_channel_id_or_general_log_id(self): @@ -135,9 +155,13 @@ class ChannelFilter(OrderedModel): @property def str_for_clients(self): - if self.filtering_term is None: + if self.is_default: return "default" - return str(self.filtering_term).replace("`", "") + if self.filtering_term_type == ChannelFilter.FILTERING_TERM_TYPE_JINJA2: + return str(self.filtering_term) + elif self.filtering_term_type == ChannelFilter.FILTERING_TERM_TYPE_REGEX or self.filtering_term_type is None: + return str(self.filtering_term).replace("`", "") + raise Exception("Unknown filtering term") def send_demo_alert(self): integration = self.alert_receive_channel diff --git a/engine/apps/alerts/tests/test_channel_filter.py b/engine/apps/alerts/tests/test_channel_filter.py index 17ebd237..3c9de7e8 100644 --- a/engine/apps/alerts/tests/test_channel_filter.py +++ b/engine/apps/alerts/tests/test_channel_filter.py @@ -17,18 +17,80 @@ def test_channel_filter_select_filter(make_organization, make_alert_receive_chan # alert with data which includes custom route filtering term, satisfied filter is custom channel filter raw_request_data = {"title": filtering_term} - satisfied_filter = ChannelFilter.select_filter(alert_receive_channel, raw_request_data, title) + satisfied_filter = ChannelFilter.select_filter(alert_receive_channel, raw_request_data) assert satisfied_filter == channel_filter # alert with data which does not include custom route filtering term, satisfied filter is default channel filter raw_request_data = {"title": title} - satisfied_filter = ChannelFilter.select_filter(alert_receive_channel, raw_request_data, title) + satisfied_filter = ChannelFilter.select_filter(alert_receive_channel, raw_request_data) assert satisfied_filter == default_channel_filter # demo alert for custom route raw_request_data = {"title": "i'm not matching this route"} satisfied_filter = ChannelFilter.select_filter( - alert_receive_channel, raw_request_data, title, force_route_id=channel_filter.pk + alert_receive_channel, raw_request_data, force_route_id=channel_filter.pk + ) + assert satisfied_filter == channel_filter + + +@pytest.mark.django_db +def test_channel_filter_select_filter_regex(make_organization, make_alert_receive_channel, make_channel_filter): + organization = make_organization() + alert_receive_channel = make_alert_receive_channel(organization) + default_channel_filter = make_channel_filter(alert_receive_channel, is_default=True) + filtering_term = "test alert" + channel_filter = make_channel_filter( + alert_receive_channel, + filtering_term=filtering_term, + filtering_term_type=ChannelFilter.FILTERING_TERM_TYPE_REGEX, + is_default=False, + ) + + # alert with data which includes custom route filtering term, satisfied filter is custom channel filter + raw_request_data = {"title": filtering_term} + satisfied_filter = ChannelFilter.select_filter(alert_receive_channel, raw_request_data) + assert satisfied_filter == channel_filter + + # alert with data which does not include custom route filtering term, satisfied filter is default channel filter + raw_request_data = {"title": "Test Title"} + satisfied_filter = ChannelFilter.select_filter(alert_receive_channel, raw_request_data) + assert satisfied_filter == default_channel_filter + + # demo alert for custom route + raw_request_data = {"title": "i'm not matching this route"} + satisfied_filter = ChannelFilter.select_filter( + alert_receive_channel, raw_request_data, force_route_id=channel_filter.pk + ) + assert satisfied_filter == channel_filter + + +@pytest.mark.django_db +def test_channel_filter_select_filter_jinja2(make_organization, make_alert_receive_channel, make_channel_filter): + organization = make_organization() + alert_receive_channel = make_alert_receive_channel(organization) + default_channel_filter = make_channel_filter(alert_receive_channel, is_default=True) + filtering_term = '{{ payload.foo == "bar" }}' + channel_filter = make_channel_filter( + alert_receive_channel, + filtering_term=filtering_term, + filtering_term_type=ChannelFilter.FILTERING_TERM_TYPE_JINJA2, + is_default=False, + ) + + # alert with data which includes custom route filtering term, satisfied filter is custom channel filter + raw_request_data = {"foo": "bar"} + satisfied_filter = ChannelFilter.select_filter(alert_receive_channel, raw_request_data) + assert satisfied_filter == channel_filter + + # alert with data which does not include custom route filtering term, satisfied filter is default channel filter + raw_request_data = {"foo": "qaz"} + satisfied_filter = ChannelFilter.select_filter(alert_receive_channel, raw_request_data) + assert satisfied_filter == default_channel_filter + + # demo alert for custom route + raw_request_data = {"title": "i'm not matching this route"} + satisfied_filter = ChannelFilter.select_filter( + alert_receive_channel, raw_request_data, force_route_id=channel_filter.pk ) assert satisfied_filter == channel_filter diff --git a/engine/apps/api/serializers/channel_filter.py b/engine/apps/api/serializers/channel_filter.py index d739ff1f..2347a78b 100644 --- a/engine/apps/api/serializers/channel_filter.py +++ b/engine/apps/api/serializers/channel_filter.py @@ -2,11 +2,13 @@ from django.apps import apps from rest_framework import serializers from apps.alerts.models import AlertReceiveChannel, ChannelFilter, EscalationChain +from apps.api.serializers.alert_receive_channel import valid_jinja_template_for_serializer_method_field from apps.base.messaging import get_messaging_backend_from_id from apps.telegram.models import TelegramToOrganizationConnector from common.api_helpers.custom_fields import OrganizationFilteredPrimaryKeyRelatedField from common.api_helpers.exceptions import BadRequest from common.api_helpers.mixins import EagerLoadingMixin, OrderedModelSerializerMixin +from common.jinja_templater.apply_jinja_template import JinjaTemplateError from common.utils import is_regex_valid @@ -37,6 +39,7 @@ class ChannelFilterSerializer(OrderedModelSerializerMixin, EagerLoadingMixin, se "slack_channel", "created_at", "filtering_term", + "filtering_term_type", "telegram_channel", "is_default", "notify_in_slack", @@ -46,6 +49,22 @@ class ChannelFilterSerializer(OrderedModelSerializerMixin, EagerLoadingMixin, se read_only_fields = ["created_at", "is_default"] extra_kwargs = {"filtering_term": {"required": True, "allow_null": False}} + def validate(self, data): + filtering_term = data.get("filtering_term") + filtering_term_type = data.get("filtering_term_type") + if filtering_term_type == ChannelFilter.FILTERING_TERM_TYPE_JINJA2: + try: + valid_jinja_template_for_serializer_method_field({"route_template": filtering_term}) + except JinjaTemplateError: + raise serializers.ValidationError([f"Jinja template is incorrect"]) + elif filtering_term_type == ChannelFilter.FILTERING_TERM_TYPE_REGEX or filtering_term_type is None: + if filtering_term is not None: + if not is_regex_valid(filtering_term): + raise serializers.ValidationError(["Regular expression is incorrect"]) + else: + raise serializers.ValidationError([f"Expression type is incorrect"]) + return data + def get_slack_channel(self, obj): if obj.slack_channel_id is None: return None @@ -56,22 +75,6 @@ class ChannelFilterSerializer(OrderedModelSerializerMixin, EagerLoadingMixin, se "id": obj.slack_channel_pk, } - def validate(self, attrs): - alert_receive_channel = attrs.get("alert_receive_channel") or self.instance.alert_receive_channel - filtering_term = attrs.get("filtering_term") - if filtering_term is None: - return attrs - try: - obj = ChannelFilter.objects.get(alert_receive_channel=alert_receive_channel, filtering_term=filtering_term) - except ChannelFilter.DoesNotExist: - return attrs - if self.instance and obj.id == self.instance.id: - return attrs - else: - raise serializers.ValidationError( - {"filtering_term": ["Channel filter with this filtering term already exists"]} - ) - def validate_slack_channel(self, slack_channel_id): SlackChannel = apps.get_model("slack", "SlackChannel") @@ -84,12 +87,6 @@ class ChannelFilterSerializer(OrderedModelSerializerMixin, EagerLoadingMixin, se raise serializers.ValidationError(["Slack channel does not exist"]) return slack_channel_id - def validate_filtering_term(self, filtering_term): - if filtering_term is not None: - if not is_regex_valid(filtering_term): - raise serializers.ValidationError(["Filtering term is incorrect"]) - return filtering_term - def validate_notification_backends(self, notification_backends): # NOTE: updates the whole field, handling dict updates per backend if notification_backends is not None: @@ -125,6 +122,7 @@ class ChannelFilterCreateSerializer(ChannelFilterSerializer): "slack_channel", "created_at", "filtering_term", + "filtering_term_type", "telegram_channel", "is_default", "notify_in_slack", diff --git a/engine/apps/api/serializers/on_call_shifts.py b/engine/apps/api/serializers/on_call_shifts.py index 3ad11ef4..607dca30 100644 --- a/engine/apps/api/serializers/on_call_shifts.py +++ b/engine/apps/api/serializers/on_call_shifts.py @@ -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"): diff --git a/engine/apps/api/tests/test_features.py b/engine/apps/api/tests/test_features.py index 60484b6b..6f9192ee 100644 --- a/engine/apps/api/tests/test_features.py +++ b/engine/apps/api/tests/test_features.py @@ -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( diff --git a/engine/apps/api/tests/test_oncall_shift.py b/engine/apps/api/tests/test_oncall_shift.py index ee4a6bea..7351abb0 100644 --- a/engine/apps/api/tests/test_oncall_shift.py +++ b/engine/apps/api/tests/test_oncall_shift.py @@ -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", diff --git a/engine/apps/api/views/features.py b/engine/apps/api/views/features.py index d296a1c8..b09a6160 100644 --- a/engine/apps/api/views/features.py +++ b/engine/apps/api/views/features.py @@ -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) diff --git a/engine/apps/mobile_app/tests/test_fcm_relay.py b/engine/apps/mobile_app/tests/test_fcm_relay.py index c694bd40..7cbfd930 100644 --- a/engine/apps/mobile_app/tests/test_fcm_relay.py +++ b/engine/apps/mobile_app/tests/test_fcm_relay.py @@ -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, diff --git a/engine/apps/public_api/serializers/routes.py b/engine/apps/public_api/serializers/routes.py index c083da02..abbc9f4f 100644 --- a/engine/apps/public_api/serializers/routes.py +++ b/engine/apps/public_api/serializers/routes.py @@ -1,11 +1,14 @@ from django.apps import apps -from rest_framework import serializers +from rest_framework import fields, serializers from apps.alerts.models import AlertReceiveChannel, ChannelFilter, EscalationChain +from apps.api.serializers.alert_receive_channel import valid_jinja_template_for_serializer_method_field from apps.base.messaging import get_messaging_backend_from_id, get_messaging_backends from common.api_helpers.custom_fields import OrganizationFilteredPrimaryKeyRelatedField from common.api_helpers.exceptions import BadRequest from common.api_helpers.mixins import OrderedModelSerializerMixin +from common.jinja_templater.apply_jinja_template import JinjaTemplateError +from common.utils import is_regex_valid class BaseChannelFilterSerializer(OrderedModelSerializerMixin, serializers.ModelSerializer): @@ -128,10 +131,22 @@ class BaseChannelFilterSerializer(OrderedModelSerializerMixin, serializers.Model return escalation_chain +class RoutingTypeField(fields.CharField): + def to_representation(self, value): + return ChannelFilter.FILTERING_TERM_TYPE_CHOICES[value][1] + + def to_internal_value(self, data): + for filtering_term_type_choices in ChannelFilter.FILTERING_TERM_TYPE_CHOICES: + if filtering_term_type_choices[1] == data: + return filtering_term_type_choices[0] + raise BadRequest(detail="Invalid route type") + + class ChannelFilterSerializer(BaseChannelFilterSerializer): id = serializers.CharField(read_only=True, source="public_primary_key") slack = serializers.DictField(required=False) telegram = serializers.DictField(required=False) + routing_type = RoutingTypeField(allow_null=False, required=False, source="filtering_term_type") routing_regex = serializers.CharField(allow_null=False, required=True, source="filtering_term") position = serializers.IntegerField(required=False, source="order") integration_id = OrganizationFilteredPrimaryKeyRelatedField( @@ -151,6 +166,7 @@ class ChannelFilterSerializer(BaseChannelFilterSerializer): "id", "integration_id", "escalation_chain_id", + "routing_type", "routing_regex", "position", "is_the_last_route", @@ -176,19 +192,21 @@ class ChannelFilterSerializer(BaseChannelFilterSerializer): return instance - def validate(self, attrs): - alert_receive_channel = attrs.get("alert_receive_channel") or self.instance.alert_receive_channel - filtering_term = attrs.get("filtering_term") - if filtering_term is None: - return attrs - try: - obj = ChannelFilter.objects.get(alert_receive_channel=alert_receive_channel, filtering_term=filtering_term) - except ChannelFilter.DoesNotExist: - return attrs - if self.instance and obj.id == self.instance.id: - return attrs + def validate(self, data): + filtering_term = data.get("routing_regex") + filtering_term_type = data.get("routing_type") + if filtering_term_type == ChannelFilter.FILTERING_TERM_TYPE_JINJA2: + try: + valid_jinja_template_for_serializer_method_field({"route_template": filtering_term}) + except JinjaTemplateError: + raise serializers.ValidationError([f"Jinja template is incorrect"]) + elif filtering_term_type == ChannelFilter.FILTERING_TERM_TYPE_REGEX or filtering_term_type is None: + if filtering_term is not None: + if not is_regex_valid(filtering_term): + raise serializers.ValidationError(["Regular expression is incorrect"]) else: - raise BadRequest(detail="Route with this regex already exists") + raise serializers.ValidationError([f"Expression type is incorrect"]) + return data class ChannelFilterUpdateSerializer(ChannelFilterSerializer): diff --git a/engine/apps/public_api/tests/test_routes.py b/engine/apps/public_api/tests/test_routes.py index 2cb88d9c..5f0db22d 100644 --- a/engine/apps/public_api/tests/test_routes.py +++ b/engine/apps/public_api/tests/test_routes.py @@ -44,6 +44,7 @@ def test_get_route( "id": channel_filter.public_primary_key, "integration_id": alert_receive_channel.public_primary_key, "escalation_chain_id": escalation_chain.public_primary_key, + "routing_type": "regex", "routing_regex": channel_filter.filtering_term, "position": channel_filter.order, "is_the_last_route": channel_filter.is_default, @@ -76,6 +77,7 @@ def test_get_routes_list( "id": channel_filter.public_primary_key, "integration_id": alert_receive_channel.public_primary_key, "escalation_chain_id": escalation_chain.public_primary_key, + "routing_type": "regex", "routing_regex": channel_filter.filtering_term, "position": channel_filter.order, "is_the_last_route": channel_filter.is_default, @@ -112,6 +114,7 @@ def test_get_routes_filter_by_integration_id( "id": channel_filter.public_primary_key, "integration_id": alert_receive_channel.public_primary_key, "escalation_chain_id": escalation_chain.public_primary_key, + "routing_type": "regex", "routing_regex": channel_filter.filtering_term, "position": channel_filter.order, "is_the_last_route": channel_filter.is_default, @@ -146,6 +149,7 @@ def test_create_route( "id": response.data["id"], "integration_id": alert_receive_channel.public_primary_key, "escalation_chain_id": escalation_chain.public_primary_key, + "routing_type": "regex", "routing_regex": data_for_create["routing_regex"], "position": 0, "is_the_last_route": False, @@ -206,6 +210,7 @@ def test_update_route( "id": new_channel_filter.public_primary_key, "integration_id": alert_receive_channel.public_primary_key, "escalation_chain_id": escalation_chain.public_primary_key, + "routing_type": "regex", "routing_regex": data_to_update["routing_regex"], "position": new_channel_filter.order, "is_the_last_route": new_channel_filter.is_default, @@ -273,6 +278,7 @@ def test_create_route_with_messaging_backend( "id": response.data["id"], "integration_id": alert_receive_channel.public_primary_key, "escalation_chain_id": escalation_chain.public_primary_key, + "routing_type": "regex", "routing_regex": data_for_create["routing_regex"], "position": 0, "is_the_last_route": False, @@ -330,6 +336,7 @@ def test_update_route_with_messaging_backend( "id": response.data["id"], "integration_id": alert_receive_channel.public_primary_key, "escalation_chain_id": escalation_chain.public_primary_key, + "routing_type": "regex", "routing_regex": new_channel_filter.filtering_term, "position": 0, "is_the_last_route": False, @@ -363,6 +370,7 @@ def test_update_route_with_messaging_backend( "id": response.data["id"], "integration_id": alert_receive_channel.public_primary_key, "escalation_chain_id": escalation_chain.public_primary_key, + "routing_type": "regex", "routing_regex": new_channel_filter.filtering_term, "position": 0, "is_the_last_route": False, diff --git a/engine/conftest.py b/engine/conftest.py index 2a34be16..7c58208a 100644 --- a/engine/conftest.py +++ b/engine/conftest.py @@ -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): diff --git a/engine/engine/urls.py b/engine/engine/urls.py index f840d4cc..be50be31 100644 --- a/engine/engine/urls.py +++ b/engine/engine/urls.py @@ -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")), diff --git a/engine/requirements.txt b/engine/requirements.txt index 80833473..5e7d6ed5 100644 --- a/engine/requirements.txt +++ b/engine/requirements.txt @@ -42,6 +42,7 @@ emoji==1.7.0 regex==2021.11.2 psutil==5.9.4 django-migration-linter==4.1.0 +django-add-default-value==0.10.0 opentelemetry-instrumentation-celery==0.36b0 opentelemetry-instrumentation-pymysql==0.36b0 opentelemetry-instrumentation-wsgi==0.36b0 diff --git a/engine/settings/base.py b/engine/settings/base.py index 82440857..d6d7438e 100644 --- a/engine/settings/base.py +++ b/engine/settings/base.py @@ -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 = { diff --git a/grafana-plugin/README.md b/grafana-plugin/README.md deleted file mode 120000 index 32d46ee8..00000000 --- a/grafana-plugin/README.md +++ /dev/null @@ -1 +0,0 @@ -../README.md \ No newline at end of file diff --git a/grafana-plugin/integration-tests/utils/integrations.ts b/grafana-plugin/integration-tests/utils/integrations.ts index 026042d4..eab5a475 100644 --- a/grafana-plugin/integration-tests/utils/integrations.ts +++ b/grafana-plugin/integration-tests/utils/integrations.ts @@ -11,7 +11,7 @@ export const createIntegrationAndSendDemoAlert = async ( await goToOnCallPageByClickingOnTab(page, 'Integrations'); // open the create integration modal - (await page.waitForSelector('text=New integration for receiving alerts')).click(); + (await page.waitForSelector('text=New integration to receive alerts')).click(); // create a webhook integration (await page.waitForSelector('div[data-testid="create-integration-modal"] >> text=Webhook')).click(); diff --git a/grafana-plugin/src/containers/AlertReceiveChannelCard/AlertReceiveChannelCard.tsx b/grafana-plugin/src/containers/AlertReceiveChannelCard/AlertReceiveChannelCard.tsx index 0337ab7f..9e0cfb16 100644 --- a/grafana-plugin/src/containers/AlertReceiveChannelCard/AlertReceiveChannelCard.tsx +++ b/grafana-plugin/src/containers/AlertReceiveChannelCard/AlertReceiveChannelCard.tsx @@ -1,6 +1,6 @@ import React from 'react'; -import { Tooltip, HorizontalGroup, VerticalGroup } from '@grafana/ui'; +import { Tooltip, HorizontalGroup, VerticalGroup, Badge } from '@grafana/ui'; import cn from 'classnames/bind'; import { observer } from 'mobx-react'; import Emoji from 'react-emoji-render'; @@ -58,30 +58,37 @@ const AlertReceiveChannelCard = observer((props: AlertReceiveChannelCardProps) = )} - - - - - - - {integration?.display_name} - - - | + + {alertReceiveChannelCounter && ( - {alertReceiveChannelCounter?.alerts_count} alert - {alertReceiveChannelCounter?.alerts_count === 1 ? '' : 's'} in{' '} - {alertReceiveChannelCounter?.alert_groups_count} Alert Group - {alertReceiveChannelCounter?.alert_groups_count === 1 ? '' : 's'} + )} + + + + {integration?.display_name} + + diff --git a/grafana-plugin/src/containers/AlertRules/AlertRules.module.css b/grafana-plugin/src/containers/AlertRules/AlertRules.module.css index cb6bfe72..3fdad2d6 100644 --- a/grafana-plugin/src/containers/AlertRules/AlertRules.module.css +++ b/grafana-plugin/src/containers/AlertRules/AlertRules.module.css @@ -47,10 +47,18 @@ .channel-filter-header { display: flex; - justify-content: space-between; - align-items: center; - flex-wrap: wrap; - gap: 8px; + flex-wrap: wrap-reverse; +} + +.channel-filter-header-left, +.channel-filter-header-right { + flex-grow: 1; + margin-bottom: 16px; +} + +.channel-filter-header-right { + display: flex; + justify-content: flex-end; } .channel-filter-header-title { @@ -120,16 +128,13 @@ .integration__heading-container { display: flex; - flex-wrap: wrap; -} - -.integration__heading-container-left { - margin-bottom: 12px; + flex-wrap: wrap-reverse; } .integration__heading-container-left, .integration__heading-container-right { flex-grow: 1; + margin-bottom: 12px; } .integration__heading-container-right { diff --git a/grafana-plugin/src/containers/AlertRules/AlertRules.tsx b/grafana-plugin/src/containers/AlertRules/AlertRules.tsx index 67d9e076..d0eaa920 100644 --- a/grafana-plugin/src/containers/AlertRules/AlertRules.tsx +++ b/grafana-plugin/src/containers/AlertRules/AlertRules.tsx @@ -6,11 +6,13 @@ import { ConfirmModal, Field, HorizontalGroup, + Icon, IconButton, Input, LoadingPlaceholder, Modal, Tooltip, + VerticalGroup, } from '@grafana/ui'; import cn from 'classnames/bind'; import { observer } from 'mobx-react'; @@ -19,6 +21,7 @@ import Emoji from 'react-emoji-render'; import Collapse from 'components/Collapse/Collapse'; import Block from 'components/GBlock/Block'; import PluginLink from 'components/PluginLink/PluginLink'; +import SourceCode from 'components/SourceCode/SourceCode'; import Text from 'components/Text/Text'; import WithConfirm from 'components/WithConfirm/WithConfirm'; import { parseEmojis } from 'containers/AlertRules/AlertRules.helpers'; @@ -261,7 +264,7 @@ class AlertRules extends React.Component { onDismiss={() => this.setState({ editIntegrationName: undefined })} >
- + { /> )} - - { - event.stopPropagation(); - this.setState({ - channelFilterToEdit: channelFilter, - }); - }} - tooltip="Edit" - tooltipPlacement="top" - /> - + {!channelFilter.is_default && ( + + { + event.stopPropagation(); + this.setState({ + channelFilterToEdit: channelFilter, + }); + }} + tooltip="Edit" + tooltipPlacement="top" + /> + + )}
); diff --git a/grafana-plugin/src/containers/IncidentMatcher/IncidentMatcher.tsx b/grafana-plugin/src/containers/IncidentMatcher/IncidentMatcher.tsx index 0c034758..aacc3dce 100644 --- a/grafana-plugin/src/containers/IncidentMatcher/IncidentMatcher.tsx +++ b/grafana-plugin/src/containers/IncidentMatcher/IncidentMatcher.tsx @@ -96,7 +96,7 @@ const IncidentMatcher = observer((props: IncidentMatcherProps) => {
- Incident payload + Alert Group payload {selectedAlertItem ? ( {JSON.stringify(selectedAlertItem, null, 2)} diff --git a/grafana-plugin/src/containers/UserSettings/UserSettings.tsx b/grafana-plugin/src/containers/UserSettings/UserSettings.tsx index 02aa9d02..bfdf4184 100644 --- a/grafana-plugin/src/containers/UserSettings/UserSettings.tsx +++ b/grafana-plugin/src/containers/UserSettings/UserSettings.tsx @@ -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 ( diff --git a/grafana-plugin/src/containers/UserSettings/parts/connectors/MobileAppConnector.tsx b/grafana-plugin/src/containers/UserSettings/parts/connectors/MobileAppConnector.tsx index cd50e390..9d036526 100644 --- a/grafana-plugin/src/containers/UserSettings/parts/connectors/MobileAppConnector.tsx +++ b/grafana-plugin/src/containers/UserSettings/parts/connectors/MobileAppConnector.tsx @@ -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 (
@@ -40,4 +32,4 @@ const SlackConnector = (props: SlackConnectorProps) => { ); }; -export default SlackConnector; +export default MobileAppConnector; diff --git a/grafana-plugin/src/models/channel_filter/channel_filter.types.ts b/grafana-plugin/src/models/channel_filter/channel_filter.types.ts index e6be0901..1daa4109 100644 --- a/grafana-plugin/src/models/channel_filter/channel_filter.types.ts +++ b/grafana-plugin/src/models/channel_filter/channel_filter.types.ts @@ -3,6 +3,11 @@ import { EscalationChain } from 'models/escalation_chain/escalation_chain.types' import { SlackChannel } from 'models/slack_channel/slack_channel.types'; import { TelegramChannel } from 'models/telegram_channel/telegram_channel.types'; +export enum FilteringTermType { + regex, + jinja2, +} + export interface ChannelFilter { id: string; order: number; @@ -12,6 +17,7 @@ export interface ChannelFilter { telegram_channel?: TelegramChannel['id']; created_at: string; filtering_term: string; + filtering_term_type: FilteringTermType; is_default: boolean; notify_in_slack: boolean; notify_in_telegram: boolean; diff --git a/grafana-plugin/src/pages/escalation-chains/EscalationChains.module.css b/grafana-plugin/src/pages/escalation-chains/EscalationChains.module.css index 79807cf7..a049fb68 100644 --- a/grafana-plugin/src/pages/escalation-chains/EscalationChains.module.css +++ b/grafana-plugin/src/pages/escalation-chains/EscalationChains.module.css @@ -16,10 +16,11 @@ .new-escalation-chain { margin: 16px; + width: calc(100% - 32px); } .left-column { - width: 400px; + width: 300px; flex-shrink: 0; border-right: var(--border); } diff --git a/grafana-plugin/src/pages/escalation-chains/EscalationChains.tsx b/grafana-plugin/src/pages/escalation-chains/EscalationChains.tsx index 3626a894..729da478 100644 --- a/grafana-plugin/src/pages/escalation-chains/EscalationChains.tsx +++ b/grafana-plugin/src/pages/escalation-chains/EscalationChains.tsx @@ -171,7 +171,7 @@ class EscalationChainsPage extends React.Component - New Escalation Chain + New escalation chain )} diff --git a/grafana-plugin/src/pages/incident/Incident.tsx b/grafana-plugin/src/pages/incident/Incident.tsx index 418ec59a..60867ae9 100644 --- a/grafana-plugin/src/pages/incident/Incident.tsx +++ b/grafana-plugin/src/pages/incident/Incident.tsx @@ -554,8 +554,7 @@ class IncidentPage extends React.Component ); default: - console.warn('Unknown render_after_resolve_report_json entity placeholder'); - return ''; + return '{{' + match + '}}'; } }; }; diff --git a/grafana-plugin/src/pages/integrations/Integrations.module.css b/grafana-plugin/src/pages/integrations/Integrations.module.css index 88233e52..1ce1c988 100644 --- a/grafana-plugin/src/pages/integrations/Integrations.module.css +++ b/grafana-plugin/src/pages/integrations/Integrations.module.css @@ -4,6 +4,7 @@ .integrations { width: 100%; + min-width: 720px; display: flex; align-items: flex-start; border: var(--border); @@ -16,7 +17,7 @@ } .alert-receive-channels-list { - width: 400px; + width: 300px; flex-shrink: 0; overflow: auto; max-height: 70vh; @@ -29,6 +30,7 @@ .newIntegrationButton { margin: 16px; + width: calc(100% - 32px); } .integrationsList { diff --git a/grafana-plugin/src/pages/integrations/Integrations.tsx b/grafana-plugin/src/pages/integrations/Integrations.tsx index c9582f76..5c05fddc 100644 --- a/grafana-plugin/src/pages/integrations/Integrations.tsx +++ b/grafana-plugin/src/pages/integrations/Integrations.tsx @@ -169,7 +169,7 @@ class Integrations extends React.Component icon="plus" className={cx('newIntegrationButton')} > - New integration for receiving alerts + New integration to receive alerts
@@ -224,7 +224,7 @@ class Integrations extends React.Component this.setState({ showCreateIntegrationModal: true }); }} > - New integration for receiving alerts + New integration to receive alerts diff --git a/grafana-plugin/src/pages/settings/tabs/Cloud/CloudPage.tsx b/grafana-plugin/src/pages/settings/tabs/Cloud/CloudPage.tsx index bb5c60c3..7a501420 100644 --- a/grafana-plugin/src/pages/settings/tabs/Cloud/CloudPage.tsx +++ b/grafana-plugin/src/pages/settings/tabs/Cloud/CloudPage.tsx @@ -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) => { )} - {store.hasFeature(AppFeature.MobileApp) && ( - - - - Mobile app push notifications - - - Connecting to Cloud OnCall enables sending push notifications on mobile devices using the Grafana OnCall - mobile app. - - - - )} + + + + Mobile app push notifications + + + Connecting to Cloud OnCall enables sending push notifications on mobile devices using the Grafana OnCall + mobile app. + + + ); @@ -370,19 +367,17 @@ const CloudPage = observer((props: CloudPageProps) => { - {store.hasFeature(AppFeature.MobileApp) && ( - - - - Mobile app push notifications - - - Connecting to Cloud OnCall enables sending push notifications on mobile devices using the Grafana OnCall - mobile app. - - - - )} + + + + Mobile app push notifications + + + Connecting to Cloud OnCall enables sending push notifications on mobile devices using the Grafana OnCall + mobile app. + + + ); diff --git a/grafana-plugin/src/state/features.ts b/grafana-plugin/src/state/features.ts index 856d26d0..7636481d 100644 --- a/grafana-plugin/src/state/features.ts +++ b/grafana-plugin/src/state/features.ts @@ -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',