From 3b274f45f40cd0410bbb1eb493b33a783c9cae07 Mon Sep 17 00:00:00 2001 From: Joey Orlando Date: Fri, 14 Apr 2023 09:15:57 +0200 Subject: [PATCH] add several new database columns + emit two new Django signals (#1522) # What this PR does - add new columns `gcom_org_contract_type`, `gcom_org_irm_sku_subscription_start_date`, and `gcom_org_oldest_admin_with_billing_privileges_user_id` to `user_management_organization` table + `is_restricted` column to `alerts_alertgroup` table - emit two new Django signals - `org_sync_signal` at the end of the `engine/apps/user_management/sync.py::sync_organization` method - `alert_group_created_signal` when a new Alert Group is created ## Checklist - [ ] Tests updated (N/A) - [ ] Documentation added (N/A) - [x] `CHANGELOG.md` updated --------- Co-authored-by: Rares Mardare --- CHANGELOG.md | 9 ++ .../escalation_snapshot_mixin.py | 18 ++-- .../renderers/classic_markdown_renderer.py | 14 +-- .../renderers/web_renderer.py | 14 +-- .../migrations/0012_auto_20230406_1010.py | 23 +++++ engine/apps/alerts/models/alert_group.py | 11 ++- .../alerts/models/alert_group_log_record.py | 6 +- engine/apps/alerts/signals.py | 6 ++ engine/apps/api/serializers/alert.py | 15 +++- engine/apps/api/serializers/alert_group.py | 14 +-- .../alerts_field_cache_buster_mixin.py | 17 ++++ engine/apps/public_api/serializers/alerts.py | 5 +- .../apps/public_api/serializers/incidents.py | 6 +- .../migrations/0011_auto_20230411_1358.py | 28 ++++++ .../user_management/models/organization.py | 3 + engine/apps/user_management/signals.py | 3 + engine/apps/user_management/sync.py | 3 + .../apps/user_management/tests/test_sync.py | 85 ++++++++++-------- .../constants/alert_group_restrictions.py | 2 + grafana-plugin/src/PluginPage.tsx | 7 +- .../AlertReceiveChannelCard.module.css | 16 ---- .../AlertReceiveChannelCard.module.scss | 48 ++++++++++ .../AlertReceiveChannelCard.tsx | 4 +- .../src/containers/Alerts/Alerts.module.scss | 2 +- .../src/containers/Alerts/Alerts.tsx | 89 +++++++++++-------- .../EscalationVariants/EscalationVariants.tsx | 9 +- .../src/containers/IRMBanner/IRMBanner.tsx | 56 ++++++++++++ .../MobileAppConnection.test.tsx | 1 + .../MobileAppConnection.tsx | 3 +- .../alert_receive_channel.types.ts | 7 +- .../src/models/alertgroup/alertgroup.ts | 11 ++- .../src/models/alertgroup/alertgroup.types.ts | 18 ++++ .../src/navbar/Header/Header.module.scss | 17 ++++ grafana-plugin/src/navbar/Header/Header.tsx | 54 ++++++++--- .../src/pages/incident/Incident.helpers.tsx | 12 +-- .../src/pages/incident/Incident.tsx | 69 +++++++++++--- .../src/pages/incident/parts/PagedUsers.tsx | 5 +- .../src/pages/incidents/Incidents.tsx | 24 ++++- .../src/plugin/GrafanaPluginRootPage.tsx | 3 +- .../src/state/rootBaseStore/index.ts | 8 +- 40 files changed, 567 insertions(+), 178 deletions(-) create mode 100644 engine/apps/alerts/migrations/0012_auto_20230406_1010.py create mode 100644 engine/apps/api/serializers/alerts_field_cache_buster_mixin.py create mode 100644 engine/apps/user_management/migrations/0011_auto_20230411_1358.py create mode 100644 engine/apps/user_management/signals.py create mode 100644 engine/common/constants/alert_group_restrictions.py delete mode 100644 grafana-plugin/src/containers/AlertReceiveChannelCard/AlertReceiveChannelCard.module.css create mode 100644 grafana-plugin/src/containers/AlertReceiveChannelCard/AlertReceiveChannelCard.module.scss create mode 100644 grafana-plugin/src/containers/IRMBanner/IRMBanner.tsx diff --git a/CHANGELOG.md b/CHANGELOG.md index 3a992aa9..5345aac0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## v1.2.10 (2023-04-13) +### Added + +- add new columns `gcom_org_contract_type`, `gcom_org_irm_sku_subscription_start_date`, + and `gcom_org_oldest_admin_with_billing_privileges_user_id` to `user_management_organization` table, + plus `is_restricted` column to `alerts_alertgroup` table by @joeyorlando and @teodosii ([1522](https://github.com/grafana/oncall/pull/1522)) +- emit two new Django signals by @joeyorlando and @teodosii ([1522](https://github.com/grafana/oncall/pull/1522)) + - `org_sync_signal` at the end of the `engine/apps/user_management/sync.py::sync_organization` method + - `alert_group_created_signal` when a new Alert Group is created + ### Fixed - Fixed a bug in GForm's RemoteSelect where the value for Dropdown could not change diff --git a/engine/apps/alerts/escalation_snapshot/escalation_snapshot_mixin.py b/engine/apps/alerts/escalation_snapshot/escalation_snapshot_mixin.py index 04c1cb8a..c64eed76 100644 --- a/engine/apps/alerts/escalation_snapshot/escalation_snapshot_mixin.py +++ b/engine/apps/alerts/escalation_snapshot/escalation_snapshot_mixin.py @@ -230,12 +230,20 @@ class EscalationSnapshotMixin: is_on_maintenace_or_debug_mode = ( self.channel.maintenance_mode is not None or self.channel.organization.maintenance_mode is not None ) - if is_on_maintenace_or_debug_mode: - return - if self.pause_escalation: - return - if not self.escalation_chain_exists: + if ( + self.is_restricted + or is_on_maintenace_or_debug_mode + or self.pause_escalation + or not self.escalation_chain_exists + ): + logger.debug( + f"Not escalating alert group w/ pk: {self.pk}\n" + f"is_restricted: {self.is_restricted}\n" + f"is_on_maintenace_or_debug_mode: {is_on_maintenace_or_debug_mode}\n" + f"pause_escalation: {self.pause_escalation}\n" + f"escalation_chain_exists: {self.escalation_chain_exists}" + ) return logger.debug(f"Start escalation for alert group with pk: {self.pk}") diff --git a/engine/apps/alerts/incident_appearance/renderers/classic_markdown_renderer.py b/engine/apps/alerts/incident_appearance/renderers/classic_markdown_renderer.py index aa7a059e..44c58fac 100644 --- a/engine/apps/alerts/incident_appearance/renderers/classic_markdown_renderer.py +++ b/engine/apps/alerts/incident_appearance/renderers/classic_markdown_renderer.py @@ -1,5 +1,6 @@ from apps.alerts.incident_appearance.renderers.base_renderer import AlertBaseRenderer, AlertGroupBaseRenderer from apps.alerts.incident_appearance.templaters import AlertClassicMarkdownTemplater +from common.constants.alert_group_restrictions import IS_RESTRICTED_MESSAGE, IS_RESTRICTED_TITLE from common.utils import str_or_backup @@ -10,13 +11,14 @@ class AlertClassicMarkdownRenderer(AlertBaseRenderer): def render(self): templated_alert = self.templated_alert - rendered_alert = { - "title": str_or_backup(templated_alert.title, "Alert"), - "message": str_or_backup(templated_alert.message, ""), - "image_url": str_or_backup(templated_alert.image_url, None), - "source_link": str_or_backup(templated_alert.source_link, None), + is_restricted = self.alert.group.is_restricted + + return { + "title": IS_RESTRICTED_TITLE if is_restricted else str_or_backup(templated_alert.title, "Alert"), + "message": IS_RESTRICTED_MESSAGE if is_restricted else str_or_backup(templated_alert.message, ""), + "image_url": None if is_restricted else str_or_backup(templated_alert.image_url, None), + "source_link": None if is_restricted else str_or_backup(templated_alert.source_link, None), } - return rendered_alert class AlertGroupClassicMarkdownRenderer(AlertGroupBaseRenderer): diff --git a/engine/apps/alerts/incident_appearance/renderers/web_renderer.py b/engine/apps/alerts/incident_appearance/renderers/web_renderer.py index 681f94f5..616a5f52 100644 --- a/engine/apps/alerts/incident_appearance/renderers/web_renderer.py +++ b/engine/apps/alerts/incident_appearance/renderers/web_renderer.py @@ -1,5 +1,6 @@ from apps.alerts.incident_appearance.renderers.base_renderer import AlertBaseRenderer, AlertGroupBaseRenderer from apps.alerts.incident_appearance.templaters import AlertWebTemplater +from common.constants.alert_group_restrictions import IS_RESTRICTED_MESSAGE, IS_RESTRICTED_TITLE from common.utils import str_or_backup @@ -10,13 +11,14 @@ class AlertWebRenderer(AlertBaseRenderer): def render(self): templated_alert = self.templated_alert - rendered_alert = { - "title": str_or_backup(templated_alert.title, "Alert"), - "message": str_or_backup(templated_alert.message, ""), - "image_url": str_or_backup(templated_alert.image_url, None), - "source_link": str_or_backup(templated_alert.source_link, None), + is_restricted = self.alert.group.is_restricted + + return { + "title": IS_RESTRICTED_TITLE if is_restricted else str_or_backup(templated_alert.title, "Alert"), + "message": IS_RESTRICTED_MESSAGE if is_restricted else str_or_backup(templated_alert.message, ""), + "image_url": None if is_restricted else str_or_backup(templated_alert.image_url, None), + "source_link": None if is_restricted else str_or_backup(templated_alert.source_link, None), } - return rendered_alert class AlertGroupWebRenderer(AlertGroupBaseRenderer): diff --git a/engine/apps/alerts/migrations/0012_auto_20230406_1010.py b/engine/apps/alerts/migrations/0012_auto_20230406_1010.py new file mode 100644 index 00000000..ad6ab411 --- /dev/null +++ b/engine/apps/alerts/migrations/0012_auto_20230406_1010.py @@ -0,0 +1,23 @@ +# Generated by Django 3.2.18 on 2023-04-06 10:10 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('alerts', '0011_auto_20230329_1617'), + ] + + operations = [ + migrations.AddField( + model_name='alertgroup', + name='is_restricted', + field=models.BooleanField(default=False, null=True), + ), + migrations.AlterField( + model_name='alertgrouplogrecord', + name='type', + field=models.IntegerField(choices=[(0, 'Acknowledged'), (1, 'Unacknowledged'), (2, 'Invite'), (3, 'Stop invitation'), (4, 'Re-invite'), (5, 'Escalation triggered'), (6, 'Invitation triggered'), (16, 'Escalation finished'), (7, 'Silenced'), (15, 'Unsilenced'), (8, 'Attached'), (9, 'Unattached'), (10, 'Custom button triggered'), (11, 'Unacknowledged by timeout'), (12, 'Failed attachment'), (13, 'Incident resolved'), (14, 'Incident unresolved'), (17, 'Escalation failed'), (18, 'Acknowledge reminder triggered'), (19, 'Wiped'), (20, 'Deleted'), (21, 'Incident registered'), (22, 'A route is assigned to the incident'), (23, 'Trigger direct paging escalation'), (24, 'Unpage a user'), (25, 'Restricted')]), + ), + ] diff --git a/engine/apps/alerts/models/alert_group.py b/engine/apps/alerts/models/alert_group.py index cf776ab6..cdcba846 100644 --- a/engine/apps/alerts/models/alert_group.py +++ b/engine/apps/alerts/models/alert_group.py @@ -19,7 +19,7 @@ from apps.alerts.escalation_snapshot import EscalationSnapshotMixin from apps.alerts.incident_appearance.renderers.constants import DEFAULT_BACKUP_TITLE from apps.alerts.incident_appearance.renderers.slack_renderer import AlertGroupSlackRenderer from apps.alerts.incident_log_builder import IncidentLogBuilder -from apps.alerts.signals import alert_group_action_triggered_signal +from apps.alerts.signals import alert_group_action_triggered_signal, alert_group_created_signal from apps.alerts.tasks import acknowledge_reminder_task, call_ack_url, send_alert_group_signal, unsilence_task from apps.slack.slack_formatter import SlackFormatter from apps.user_management.models import User @@ -88,10 +88,11 @@ class AlertGroupQuerySet(models.QuerySet): # Create a new group if we couldn't group it to any existing ones try: - return ( - self.create(**search_params, is_open_for_grouping=True, web_title_cache=group_data.web_title_cache), - True, + alert_group = self.create( + **search_params, is_open_for_grouping=True, web_title_cache=group_data.web_title_cache ) + alert_group_created_signal.send(sender=self.__class__, alert_group=alert_group) + return (alert_group, True) except IntegrityError: try: return self.get(**search_params, is_open_for_grouping__isnull=False), False @@ -351,6 +352,8 @@ class AlertGroup(AlertGroupSlackRenderingMixin, EscalationSnapshotMixin, models. # https://code.djangoproject.com/ticket/28545 is_open_for_grouping = models.BooleanField(default=None, null=True, blank=True) + is_restricted = models.BooleanField(default=False, null=True) + @staticmethod def get_silenced_state_filter(): """ diff --git a/engine/apps/alerts/models/alert_group_log_record.py b/engine/apps/alerts/models/alert_group_log_record.py index bc54d671..a7db0206 100644 --- a/engine/apps/alerts/models/alert_group_log_record.py +++ b/engine/apps/alerts/models/alert_group_log_record.py @@ -45,7 +45,8 @@ class AlertGroupLogRecord(models.Model): TYPE_ROUTE_ASSIGNED, TYPE_DIRECT_PAGING, TYPE_UNPAGE_USER, - ) = range(25) + TYPE_RESTRICTED, + ) = range(26) TYPES_FOR_LICENCE_CALCULATION = ( TYPE_ACK, @@ -89,6 +90,7 @@ class AlertGroupLogRecord(models.Model): (TYPE_ROUTE_ASSIGNED, "A route is assigned to the incident"), (TYPE_DIRECT_PAGING, "Trigger direct paging escalation"), (TYPE_UNPAGE_USER, "Unpage a user"), + (TYPE_RESTRICTED, "Restricted"), ) # Handlers should be named like functions. @@ -258,6 +260,8 @@ class AlertGroupLogRecord(models.Model): if self.type == AlertGroupLogRecord.TYPE_REGISTERED: result += "alert group registered" + elif self.type == AlertGroupLogRecord.TYPE_RESTRICTED: + result += self.reason elif self.type == AlertGroupLogRecord.TYPE_ROUTE_ASSIGNED: channel_filter = self.alert_group.channel_filter_with_respect_to_escalation_snapshot escalation_chain = self.alert_group.escalation_chain_with_respect_to_escalation_snapshot diff --git a/engine/apps/alerts/signals.py b/engine/apps/alerts/signals.py index c3934d05..1b4652aa 100644 --- a/engine/apps/alerts/signals.py +++ b/engine/apps/alerts/signals.py @@ -14,6 +14,12 @@ alert_create_signal = django.dispatch.Signal( ] ) +alert_group_created_signal = django.dispatch.Signal( + providing_args=[ + "alert_group", + ] +) + # Signal to rerender alert group in all connected integrations (Slack, Telegram) when its state is changed alert_group_action_triggered_signal = django.dispatch.Signal( providing_args=[ diff --git a/engine/apps/api/serializers/alert.py b/engine/apps/api/serializers/alert.py index 9216a281..626408dd 100644 --- a/engine/apps/api/serializers/alert.py +++ b/engine/apps/api/serializers/alert.py @@ -5,8 +5,12 @@ from rest_framework import serializers from apps.alerts.incident_appearance.renderers.web_renderer import AlertWebRenderer from apps.alerts.models import Alert +from .alerts_field_cache_buster_mixin import AlertsFieldCacheBusterMixin + + +class AlertFieldsCacheSerializerMixin(AlertsFieldCacheBusterMixin): + CACHE_KEY_FORMAT_TEMPLATE = "{field_name}_alert_{object_id}" -class AlertFieldsCacheSerializerMixin: @classmethod def get_or_set_web_template_field( cls, @@ -15,7 +19,7 @@ class AlertFieldsCacheSerializerMixin: renderer_class, cache_lifetime=60 * 60 * 24, ): - CACHE_KEY = f"{field_name}_alert_{obj.id}" + CACHE_KEY = cls.calculate_cache_key(field_name, obj) cached_field = cache.get(CACHE_KEY, None) web_templates_modified_at = obj.group.channel.web_templates_modified_at @@ -50,13 +54,14 @@ class AlertSerializer(AlertFieldsCacheSerializerMixin, serializers.ModelSerializ def get_render_for_web(self, obj): return AlertFieldsCacheSerializerMixin.get_or_set_web_template_field( obj, - "render_for_web", + AlertFieldsCacheSerializerMixin.RENDER_FOR_WEB_FIELD_NAME, AlertWebRenderer, ) class AlertRawSerializer(serializers.ModelSerializer): id = serializers.CharField(read_only=True, source="public_primary_key") + raw_request_data = serializers.SerializerMethodField() class Meta: model = Alert @@ -64,3 +69,7 @@ class AlertRawSerializer(serializers.ModelSerializer): "id", "raw_request_data", ] + + def get_raw_request_data(self, obj): + # TODO: + return {} if obj.group.is_restricted else obj.raw_request_data diff --git a/engine/apps/api/serializers/alert_group.py b/engine/apps/api/serializers/alert_group.py index ab0f5e9d..91247463 100644 --- a/engine/apps/api/serializers/alert_group.py +++ b/engine/apps/api/serializers/alert_group.py @@ -13,13 +13,16 @@ from common.api_helpers.mixins import EagerLoadingMixin from .alert import AlertSerializer from .alert_receive_channel import FastAlertReceiveChannelSerializer +from .alerts_field_cache_buster_mixin import AlertsFieldCacheBusterMixin from .user import FastUserSerializer logger = logging.getLogger(__name__) logger.setLevel(logging.DEBUG) -class AlertGroupFieldsCacheSerializerMixin: +class AlertGroupFieldsCacheSerializerMixin(AlertsFieldCacheBusterMixin): + CACHE_KEY_FORMAT_TEMPLATE = "{field_name}_alert_group_{object_id}" + @classmethod def get_or_set_web_template_field( cls, @@ -29,7 +32,7 @@ class AlertGroupFieldsCacheSerializerMixin: renderer_class, cache_lifetime=60 * 60 * 24, ): - CACHE_KEY = f"{field_name}_alert_group_{obj.id}" + CACHE_KEY = cls.calculate_cache_key(field_name, obj) cached_field = cache.get(CACHE_KEY, None) web_templates_modified_at = obj.channel.web_templates_modified_at @@ -68,7 +71,7 @@ class ShortAlertGroupSerializer(AlertGroupFieldsCacheSerializerMixin, serializer return AlertGroupFieldsCacheSerializerMixin.get_or_set_web_template_field( obj, last_alert, - "render_for_web", + AlertGroupFieldsCacheSerializerMixin.RENDER_FOR_WEB_FIELD_NAME, AlertGroupWebRenderer, ) @@ -132,6 +135,7 @@ class AlertGroupListSerializer(EagerLoadingMixin, AlertGroupFieldsCacheSerialize "status", "declare_incident_link", "team", + "is_restricted", ] def get_render_for_web(self, obj): @@ -140,7 +144,7 @@ class AlertGroupListSerializer(EagerLoadingMixin, AlertGroupFieldsCacheSerialize return AlertGroupFieldsCacheSerializerMixin.get_or_set_web_template_field( obj, obj.last_alert, - "render_for_web", + AlertGroupFieldsCacheSerializerMixin.RENDER_FOR_WEB_FIELD_NAME, AlertGroupWebRenderer, ) @@ -150,7 +154,7 @@ class AlertGroupListSerializer(EagerLoadingMixin, AlertGroupFieldsCacheSerialize return AlertGroupFieldsCacheSerializerMixin.get_or_set_web_template_field( obj, obj.last_alert, - "render_for_classic_markdown", + AlertGroupFieldsCacheSerializerMixin.RENDER_FOR_CLASSIC_MARKDOWN_FIELD_NAME, AlertGroupClassicMarkdownRenderer, ) diff --git a/engine/apps/api/serializers/alerts_field_cache_buster_mixin.py b/engine/apps/api/serializers/alerts_field_cache_buster_mixin.py new file mode 100644 index 00000000..f9d3b7f4 --- /dev/null +++ b/engine/apps/api/serializers/alerts_field_cache_buster_mixin.py @@ -0,0 +1,17 @@ +import typing + +from django.core.cache import cache + + +class AlertsFieldCacheBusterMixin: + RENDER_FOR_WEB_FIELD_NAME = "render_for_web" + RENDER_FOR_CLASSIC_MARKDOWN_FIELD_NAME = "render_for_classic_markdown" + ALL_FIELD_NAMES = [RENDER_FOR_WEB_FIELD_NAME, RENDER_FOR_CLASSIC_MARKDOWN_FIELD_NAME] + + @classmethod + def calculate_cache_key(cls, field_name: str, obj: typing.Any) -> str: + return cls.CACHE_KEY_FORMAT_TEMPLATE.format(field_name=field_name, object_id=obj.id) + + @classmethod + def bust_object_caches(cls, obj: typing.Any) -> None: + cache.delete_many([cls.calculate_cache_key(field_name, obj) for field_name in cls.ALL_FIELD_NAMES]) diff --git a/engine/apps/public_api/serializers/alerts.py b/engine/apps/public_api/serializers/alerts.py index 3d725cc8..755d38fd 100644 --- a/engine/apps/public_api/serializers/alerts.py +++ b/engine/apps/public_api/serializers/alerts.py @@ -7,7 +7,7 @@ from common.api_helpers.mixins import EagerLoadingMixin class AlertSerializer(EagerLoadingMixin, serializers.ModelSerializer): id = serializers.CharField(read_only=True, source="public_primary_key") alert_group_id = serializers.CharField(read_only=True, source="group.public_primary_key") - payload = serializers.JSONField(read_only=True, source="raw_request_data") + payload = serializers.SerializerMethodField(read_only=True) SELECT_RELATED = ["group"] @@ -19,3 +19,6 @@ class AlertSerializer(EagerLoadingMixin, serializers.ModelSerializer): "created_at", "payload", ] + + def get_payload(self, obj): + return {} if obj.group.is_restricted else obj.raw_request_data diff --git a/engine/apps/public_api/serializers/incidents.py b/engine/apps/public_api/serializers/incidents.py index e6e7e490..dbd9e828 100644 --- a/engine/apps/public_api/serializers/incidents.py +++ b/engine/apps/public_api/serializers/incidents.py @@ -4,6 +4,7 @@ from rest_framework import serializers from apps.alerts.models import AlertGroup from apps.telegram.models.message import TelegramMessage from common.api_helpers.mixins import EagerLoadingMixin +from common.constants.alert_group_restrictions import IS_RESTRICTED_TITLE class IncidentSerializer(EagerLoadingMixin, serializers.ModelSerializer): @@ -13,7 +14,7 @@ class IncidentSerializer(EagerLoadingMixin, serializers.ModelSerializer): route_id = serializers.SerializerMethodField() created_at = serializers.DateTimeField(source="started_at") alerts_count = serializers.SerializerMethodField() - title = serializers.CharField(source="web_title_cache") + title = serializers.SerializerMethodField() state = serializers.SerializerMethodField() SELECT_RELATED = ["channel", "channel_filter", "slack_message", "channel__organization"] @@ -41,6 +42,9 @@ class IncidentSerializer(EagerLoadingMixin, serializers.ModelSerializer): "permalinks", ] + def get_title(self, obj): + return IS_RESTRICTED_TITLE if obj.is_restricted else obj.web_title_cache + def get_alerts_count(self, obj): return len(obj.alerts.all()) diff --git a/engine/apps/user_management/migrations/0011_auto_20230411_1358.py b/engine/apps/user_management/migrations/0011_auto_20230411_1358.py new file mode 100644 index 00000000..4dc1de3d --- /dev/null +++ b/engine/apps/user_management/migrations/0011_auto_20230411_1358.py @@ -0,0 +1,28 @@ +# Generated by Django 3.2.18 on 2023-04-11 13:58 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('user_management', '0010_team_is_sharing_resources_to_all'), + ] + + operations = [ + migrations.AddField( + model_name='organization', + name='gcom_org_contract_type', + field=models.CharField(default=None, max_length=300, null=True), + ), + migrations.AddField( + model_name='organization', + name='gcom_org_irm_sku_subscription_start_date', + field=models.DateTimeField(default=None, null=True), + ), + migrations.AddField( + model_name='organization', + name='gcom_org_oldest_admin_with_billing_privileges_user_id', + field=models.PositiveIntegerField(null=True), + ), + ] diff --git a/engine/apps/user_management/models/organization.py b/engine/apps/user_management/models/organization.py index 75ae6420..73e3f9c3 100644 --- a/engine/apps/user_management/models/organization.py +++ b/engine/apps/user_management/models/organization.py @@ -133,6 +133,9 @@ class Organization(MaintainableObject): gcom_token = mirage_fields.EncryptedCharField(max_length=300, null=True, default=None) gcom_token_org_last_time_synced = models.DateTimeField(null=True, default=None) + gcom_org_contract_type = models.CharField(max_length=300, null=True, default=None) + gcom_org_irm_sku_subscription_start_date = models.DateTimeField(null=True, default=None) + gcom_org_oldest_admin_with_billing_privileges_user_id = models.PositiveIntegerField(null=True) last_time_synced = models.DateTimeField(null=True, default=None) diff --git a/engine/apps/user_management/signals.py b/engine/apps/user_management/signals.py new file mode 100644 index 00000000..bee6e1c3 --- /dev/null +++ b/engine/apps/user_management/signals.py @@ -0,0 +1,3 @@ +import django.dispatch + +org_sync_signal = django.dispatch.Signal() diff --git a/engine/apps/user_management/sync.py b/engine/apps/user_management/sync.py index f2abac9b..18e694cd 100644 --- a/engine/apps/user_management/sync.py +++ b/engine/apps/user_management/sync.py @@ -6,6 +6,7 @@ from django.utils import timezone from apps.grafana_plugin.helpers.client import GcomAPIClient, GrafanaAPIClient from apps.user_management.models import Organization, Team, User +from apps.user_management.signals import org_sync_signal logger = get_task_logger(__name__) logger.setLevel(logging.DEBUG) @@ -55,6 +56,8 @@ def sync_organization(organization): ] ) + org_sync_signal.send(sender=None, organization=organization) + def _sync_instance_info(organization): if organization.gcom_token: diff --git a/engine/apps/user_management/tests/test_sync.py b/engine/apps/user_management/tests/test_sync.py index 4b6cfc78..1157cc74 100644 --- a/engine/apps/user_management/tests/test_sync.py +++ b/engine/apps/user_management/tests/test_sync.py @@ -99,10 +99,11 @@ def test_sync_users_for_team(make_organization, make_user_for_organization, make @pytest.mark.django_db -def test_sync_organization(make_organization, make_team, make_user_for_organization): - organization = make_organization() - - api_users_response = ( +@patch.object(GrafanaAPIClient, "is_rbac_enabled_for_organization", return_value=False) +@patch.object( + GrafanaAPIClient, + "get_users", + return_value=[ { "userId": 1, "email": "test@test.test", @@ -111,42 +112,54 @@ def test_sync_organization(make_organization, make_team, make_user_for_organizat "role": "admin", "avatarUrl": "test.test/test", "permissions": [], + } + ], +) +@patch.object( + GrafanaAPIClient, + "get_teams", + return_value=( + { + "totalCount": 1, + "teams": ( + { + "id": 1, + "name": "Test", + "email": "test@test.test", + "avatarUrl": "test.test/test", + }, + ), }, - ) - - api_teams_response = { - "totalCount": 1, - "teams": ( - { - "id": 1, - "name": "Test", - "email": "test@test.test", - "avatarUrl": "test.test/test", - }, - ), - } + None, + ), +) +@patch.object(GrafanaAPIClient, "check_token", return_value=(None, {"connected": True})) +@patch.object(GrafanaAPIClient, "get_grafana_plugin_settings", return_value=({"enabled": True}, None)) +@patch("apps.user_management.sync.org_sync_signal") +def test_sync_organization( + mocked_org_sync_signal, + _mock_get_grafana_plugin_settings, + _mock_check_token, + _mock_get_teams, + _mock_get_users, + _mock_is_rbac_enabled_for_organization, + make_organization, +): + organization = make_organization() api_members_response = ( - { - "orgId": organization.org_id, - "teamId": 1, - "userId": 1, - }, + [ + { + "orgId": organization.org_id, + "teamId": 1, + "userId": 1, + } + ], + None, ) - api_check_token_call_status = {"connected": True} - - with patch.object(GrafanaAPIClient, "is_rbac_enabled_for_organization", return_value=False): - with patch.object(GrafanaAPIClient, "get_users", return_value=api_users_response): - with patch.object(GrafanaAPIClient, "get_teams", return_value=(api_teams_response, None)): - with patch.object(GrafanaAPIClient, "get_team_members", return_value=(api_members_response, None)): - with patch.object( - GrafanaAPIClient, "check_token", return_value=(None, api_check_token_call_status) - ): - with patch.object( - GrafanaAPIClient, "get_grafana_plugin_settings", return_value=({"enabled": True}, None) - ): - sync_organization(organization) + with patch.object(GrafanaAPIClient, "get_team_members", return_value=api_members_response): + sync_organization(organization) # check that users are populated assert organization.users.count() == 1 @@ -165,6 +178,8 @@ def test_sync_organization(make_organization, make_team, make_user_for_organizat # check that is_grafana_incident_enabled flag is set assert organization.is_grafana_incident_enabled is True + mocked_org_sync_signal.send.assert_called_once_with(sender=None, organization=organization) + @pytest.mark.parametrize("grafana_api_response", [False, True]) @override_settings(LICENSE=settings.OPEN_SOURCE_LICENSE_NAME) diff --git a/engine/common/constants/alert_group_restrictions.py b/engine/common/constants/alert_group_restrictions.py new file mode 100644 index 00000000..b30f2da6 --- /dev/null +++ b/engine/common/constants/alert_group_restrictions.py @@ -0,0 +1,2 @@ +IS_RESTRICTED_TITLE = "UPGRADE TO SEE MORE" +IS_RESTRICTED_MESSAGE = "UPGRADE TO SEE MORE" diff --git a/grafana-plugin/src/PluginPage.tsx b/grafana-plugin/src/PluginPage.tsx index a80120f2..4497af92 100644 --- a/grafana-plugin/src/PluginPage.tsx +++ b/grafana-plugin/src/PluginPage.tsx @@ -3,10 +3,8 @@ import React from 'react'; import { PluginPageProps, PluginPage as RealPluginPage } from '@grafana/runtime'; import Header from 'navbar/Header/Header'; -import Alerts from 'containers/Alerts/Alerts'; import { pages } from 'pages'; import { isTopNavbar } from 'plugin/GrafanaPluginRootPage.helpers'; -import { useStore } from 'state/useStore'; interface AppPluginPageProps extends PluginPageProps { page?: string; @@ -16,13 +14,10 @@ export const PluginPage = (isTopNavbar() ? RealPlugin : PluginPageFallback) as R function RealPlugin(props: AppPluginPageProps): React.ReactNode { const { page } = props; - const store = useStore(); return ( - {/* Render alerts at the top */} - -
+
{pages[page]?.text && !pages[page]?.hideTitle && (

{pages[page].text} diff --git a/grafana-plugin/src/containers/AlertReceiveChannelCard/AlertReceiveChannelCard.module.css b/grafana-plugin/src/containers/AlertReceiveChannelCard/AlertReceiveChannelCard.module.css deleted file mode 100644 index 957743c4..00000000 --- a/grafana-plugin/src/containers/AlertReceiveChannelCard/AlertReceiveChannelCard.module.css +++ /dev/null @@ -1,16 +0,0 @@ -.root { - display: block; -} - -.heartbeat { - width: 16px; - height: 16px; -} - -.heartbeat-icon { - cursor: pointer; -} - -.alertsInfoText { - font-size: 12px; -} diff --git a/grafana-plugin/src/containers/AlertReceiveChannelCard/AlertReceiveChannelCard.module.scss b/grafana-plugin/src/containers/AlertReceiveChannelCard/AlertReceiveChannelCard.module.scss new file mode 100644 index 00000000..db66b721 --- /dev/null +++ b/grafana-plugin/src/containers/AlertReceiveChannelCard/AlertReceiveChannelCard.module.scss @@ -0,0 +1,48 @@ +// TODO: Refactor to reuse these tag styles across multiple pages +$score-primary: rgba(27, 133, 94, 0.15); +$score-warning: rgba(245, 183, 61, 0.18); +$score-danger: rgba(209, 14, 92, 0.15); + +.root { + display: block; +} + +.heartbeat { + width: 16px; + height: 16px; +} + +.heartbeat-icon { + cursor: pointer; +} + +.alertsInfoText { + font-size: 12px; +} + +.tag { + font-size: 12px; + padding: 4px 10px 3px 10px; + + &--danger { + background-color: $score-danger; + color: var(--tag-text-danger); + border: 1px solid var(--tag-border-danger); + } + + &--warning { + background-color: $score-warning; + color: var(--tag-text-warning); + border: 1px solid var(--tag-border-warning); + } +} + +.tag__icon { + &--danger { + color: var(--error-text-color); + } + + &--warning { + color: var(--warning-text-color); + } +} \ No newline at end of file diff --git a/grafana-plugin/src/containers/AlertReceiveChannelCard/AlertReceiveChannelCard.tsx b/grafana-plugin/src/containers/AlertReceiveChannelCard/AlertReceiveChannelCard.tsx index cc4699d5..729498f2 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, Badge } from '@grafana/ui'; +import { Badge, HorizontalGroup, Tooltip, VerticalGroup } from '@grafana/ui'; import cn from 'classnames/bind'; import { observer } from 'mobx-react'; import Emoji from 'react-emoji-render'; @@ -13,7 +13,7 @@ import { HeartGreenIcon, HeartRedIcon } from 'icons'; import { AlertReceiveChannel } from 'models/alert_receive_channel/alert_receive_channel.types'; import { useStore } from 'state/useStore'; -import styles from './AlertReceiveChannelCard.module.css'; +import styles from './AlertReceiveChannelCard.module.scss'; const cx = cn.bind(styles); diff --git a/grafana-plugin/src/containers/Alerts/Alerts.module.scss b/grafana-plugin/src/containers/Alerts/Alerts.module.scss index 5f838a6f..495b561b 100644 --- a/grafana-plugin/src/containers/Alerts/Alerts.module.scss +++ b/grafana-plugin/src/containers/Alerts/Alerts.module.scss @@ -1,7 +1,7 @@ .alerts-container { display: flex; flex-direction: column; - margin-bottom: 24px; + margin-bottom: 10px; gap: 10px; &--legacy { diff --git a/grafana-plugin/src/containers/Alerts/Alerts.tsx b/grafana-plugin/src/containers/Alerts/Alerts.tsx index eb4b7158..db30904c 100644 --- a/grafana-plugin/src/containers/Alerts/Alerts.tsx +++ b/grafana-plugin/src/containers/Alerts/Alerts.tsx @@ -13,7 +13,6 @@ import { AppFeature } from 'state/features'; import { useStore } from 'state/useStore'; import LocationHelper from 'utils/LocationHelper'; import { isUserActionAllowed, UserActions } from 'utils/authorization'; -import { GRAFANA_LICENSE_OSS } from 'utils/consts'; import { useForceUpdate, useQueryParams } from 'utils/hooks'; import { getItem, setItem } from 'utils/localStorage'; @@ -63,6 +62,10 @@ export default function Alerts() { const isChatOpsConnected = getIfChatOpsConnected(currentUser); const isPhoneVerified = currentUser?.cloud_connection_status === 3 || currentUser?.verified_phone_number; + if (!showSlackInstallAlert && !showBannerTeam() && !showMismatchWarning() && !showChannelWarnings()) { + return null; + } + return (
{showSlackInstallAlert && ( @@ -79,7 +82,7 @@ export default function Alerts() { )} )} - {currentTeam?.banner.title != null && !getItem(currentTeam?.banner.title) && ( + {showBannerTeam() && ( )} - {store.backendLicense === GRAFANA_LICENSE_OSS && - store.backendVersion && - plugin?.version && - store.backendVersion !== plugin?.version && - !getItem(`version_mismatch_${store.backendVersion}_${plugin?.version}`) && ( - + Please make sure you have the same versions of the Grafana OnCall plugin and the Grafana OnCall engine, + otherwise there could be issues with your Grafana OnCall installation! +
+ {`Current plugin version: ${plugin.version}, current engine version: ${store.backendVersion}`} +
+ Please see{' '} + - Please make sure you have the same versions of the Grafana OnCall plugin and the Grafana OnCall engine, - otherwise there could be issues with your Grafana OnCall installation! -
- {`Current plugin version: ${plugin.version}, current engine version: ${store.backendVersion}`} -
- Please see{' '} -
- the update instructions - - . -
- )} - {Boolean( - currentTeam && - currentUser && - isUserActionAllowed(UserActions.UserSettingsWrite) && - (!isPhoneVerified || !isChatOpsConnected) && - !getItem(AlertID.CONNECTIVITY_WARNING) - ) && ( + the update instructions + + . + + )} + {showChannelWarnings() && ( ); + + function showBannerTeam(): boolean { + return currentTeam?.banner.title != null && !getItem(currentTeam?.banner.title); + } + + function showMismatchWarning(): boolean { + return ( + store.isOpenSource() && + store.backendVersion && + plugin?.version && + store.backendVersion !== plugin?.version && + !getItem(`version_mismatch_${store.backendVersion}_${plugin?.version}`) + ); + } + + function showChannelWarnings(): boolean { + return Boolean( + currentTeam && + currentUser && + isUserActionAllowed(UserActions.UserSettingsWrite) && + (!isPhoneVerified || !isChatOpsConnected) && + !getItem(AlertID.CONNECTIVITY_WARNING) + ); + } } diff --git a/grafana-plugin/src/containers/EscalationVariants/EscalationVariants.tsx b/grafana-plugin/src/containers/EscalationVariants/EscalationVariants.tsx index c3bc585d..07ca0b8e 100644 --- a/grafana-plugin/src/containers/EscalationVariants/EscalationVariants.tsx +++ b/grafana-plugin/src/containers/EscalationVariants/EscalationVariants.tsx @@ -25,6 +25,7 @@ export interface EscalationVariantsProps { value: { scheduleResponders; userResponders }; variant?: 'secondary' | 'primary'; hideSelected?: boolean; + disabled?: boolean; } const EscalationVariants = observer( @@ -33,6 +34,7 @@ const EscalationVariants = observer( value, variant = 'primary', hideSelected = false, + disabled, }: EscalationVariantsProps) => { const [showEscalationVariants, setShowEscalationVariants] = useState(false); @@ -127,6 +129,7 @@ const EscalationVariants = observer( + + ) as any + } + severity={statusSeverity[irmPlan.limits.status]} + buttonContent={undefined} + /> + ); +}); + +export default IRMBanner; diff --git a/grafana-plugin/src/containers/MobileAppConnection/MobileAppConnection.test.tsx b/grafana-plugin/src/containers/MobileAppConnection/MobileAppConnection.test.tsx index 5e8e3749..321358e6 100644 --- a/grafana-plugin/src/containers/MobileAppConnection/MobileAppConnection.test.tsx +++ b/grafana-plugin/src/containers/MobileAppConnection/MobileAppConnection.test.tsx @@ -54,6 +54,7 @@ const mockUseStore = (rest?: any, connected = false, cloud_connected = true) => cloudConnectionStatus: { cloud_connection_status: cloud_connected }, } as unknown as CloudStore, hasFeature: jest.fn().mockReturnValue(true), + isOpenSource: jest.fn().mockReturnValue(true), } as unknown as RootStore; useStore.mockReturnValue(store); diff --git a/grafana-plugin/src/containers/MobileAppConnection/MobileAppConnection.tsx b/grafana-plugin/src/containers/MobileAppConnection/MobileAppConnection.tsx index 2ad82347..eee2203e 100644 --- a/grafana-plugin/src/containers/MobileAppConnection/MobileAppConnection.tsx +++ b/grafana-plugin/src/containers/MobileAppConnection/MobileAppConnection.tsx @@ -13,7 +13,6 @@ import { User } from 'models/user/user.types'; import { AppFeature } from 'state/features'; import { useStore } from 'state/useStore'; import { UserActions } from 'utils/authorization'; -import { GRAFANA_LICENSE_OSS } from 'utils/consts'; import styles from './MobileAppConnection.module.scss'; import DisconnectButton from './parts/DisconnectButton/DisconnectButton'; @@ -171,7 +170,7 @@ const MobileAppConnection = observer(({ userPk }: Props) => { {isQRBlurry && }
- {store.backendLicense === GRAFANA_LICENSE_OSS && QRCodeDataParsed && ( + {store.isOpenSource() && QRCodeDataParsed && ( Server URL embedded in this QR:
diff --git a/grafana-plugin/src/models/alert_receive_channel/alert_receive_channel.types.ts b/grafana-plugin/src/models/alert_receive_channel/alert_receive_channel.types.ts index a2dc3216..6178e3b2 100644 --- a/grafana-plugin/src/models/alert_receive_channel/alert_receive_channel.types.ts +++ b/grafana-plugin/src/models/alert_receive_channel/alert_receive_channel.types.ts @@ -1,3 +1,4 @@ +import { IRMPlanStatus } from 'models/alertgroup/alertgroup.types'; import { GrafanaTeam } from 'models/grafana_team/grafana_team.types'; import { Heartbeat } from 'models/heartbeat/heartbeat.types'; import { UserDTO as User } from 'models/user'; @@ -16,6 +17,7 @@ export interface AlertReceiveChannel { author: User['pk']; team: GrafanaTeam['id']; created_at: string; + status: IRMPlanStatus; integration_url: string; allow_source_based_resolving: boolean; is_able_to_autoresolve: boolean; @@ -29,11 +31,6 @@ export interface AlertReceiveChannel { deleted?: boolean; } -export interface AlertReceiveChannelChoice { - display_name: string; - value: number; -} - export interface AlertReceiveChannelOption { display_name: string; value: number; diff --git a/grafana-plugin/src/models/alertgroup/alertgroup.ts b/grafana-plugin/src/models/alertgroup/alertgroup.ts index 593e3106..4e35488f 100644 --- a/grafana-plugin/src/models/alertgroup/alertgroup.ts +++ b/grafana-plugin/src/models/alertgroup/alertgroup.ts @@ -7,10 +7,10 @@ import { makeRequest } from 'network'; import { Mixpanel } from 'services/mixpanel'; import { RootStore } from 'state'; import { SelectOption } from 'state/types'; -import { showApiError, refreshPageError, openErrorNotification } from 'utils'; +import { openErrorNotification, refreshPageError, showApiError } from 'utils'; import LocationHelper from 'utils/LocationHelper'; -import { Alert, AlertAction, IncidentStatus } from './alertgroup.types'; +import { Alert, AlertAction, IncidentStatus, ResponseIRMPlan } from './alertgroup.types'; export class AlertGroupStore extends BaseStore { @observable.shallow @@ -69,6 +69,9 @@ export class AlertGroupStore extends BaseStore { @observable liveUpdatesPaused = false; + @observable + irmPlan: ResponseIRMPlan = undefined; + constructor(rootStore: RootStore) { super(rootStore); @@ -204,6 +207,10 @@ export class AlertGroupStore extends BaseStore { }); } + async fetchIRMPlan() { + this.irmPlan = await makeRequest(`/usage-limits`, { method: 'GET' }); + } + // methods were moved from rootBaseStore. // TODO check if methods are dublicating existing ones @action diff --git a/grafana-plugin/src/models/alertgroup/alertgroup.types.ts b/grafana-plugin/src/models/alertgroup/alertgroup.types.ts index 03967a78..12e7c981 100644 --- a/grafana-plugin/src/models/alertgroup/alertgroup.types.ts +++ b/grafana-plugin/src/models/alertgroup/alertgroup.types.ts @@ -51,6 +51,7 @@ export interface Alert { acknowledged_at: string; acknowledged_by_user: User; acknowledged_on_source: boolean; + is_restricted: boolean; channel: Channel; slack_permalink?: string; declare_incident_link?: string; @@ -85,6 +86,23 @@ export interface Alert { has_pormortem?: boolean; // not implemented yet } +export enum IRMPlanStatus { + WithinLimits = 'within-limits', + NearLimit = 'near-limit', + AtLimit = 'at-limit', +} + +export interface ResponseIRMPlan { + limits: { + id: string; + irmProductStartDate: null; + isIrmPro: boolean; + status: IRMPlanStatus; + reasonHTML: string; + upgradeURL: string; + }; +} + interface RenderForWeb { message: any; title: any; diff --git a/grafana-plugin/src/navbar/Header/Header.module.scss b/grafana-plugin/src/navbar/Header/Header.module.scss index 67d2db95..78318cca 100644 --- a/grafana-plugin/src/navbar/Header/Header.module.scss +++ b/grafana-plugin/src/navbar/Header/Header.module.scss @@ -35,3 +35,20 @@ column-gap: 8px; row-gap: 8px; } + +.irm-icon { + font-size: 12px; + padding: 2px 4px; + border: 1px solid #ffb375; + color: #ffb375; +} + +.banners { + padding-top: 12px; + margin-bottom: 24px; + + &:empty { + padding-top: 0; + margin-bottom: 0; + } +} diff --git a/grafana-plugin/src/navbar/Header/Header.tsx b/grafana-plugin/src/navbar/Header/Header.tsx index 825ccefc..44442357 100644 --- a/grafana-plugin/src/navbar/Header/Header.tsx +++ b/grafana-plugin/src/navbar/Header/Header.tsx @@ -1,33 +1,43 @@ import React from 'react'; -import { Card } from '@grafana/ui'; +import { Card, HorizontalGroup } from '@grafana/ui'; import cn from 'classnames/bind'; +import { observer } from 'mobx-react'; import gitHubStarSVG from 'assets/img/github_star.svg'; +import Tag from 'components/Tag/Tag'; +import Alerts from 'containers/Alerts/Alerts'; +import IRMBanner from 'containers/IRMBanner/IRMBanner'; import logo from 'img/logo.svg'; import { isTopNavbar } from 'plugin/GrafanaPluginRootPage.helpers'; -import { APP_SUBTITLE, GRAFANA_LICENSE_OSS } from 'utils/consts'; +import { useStore } from 'state/useStore'; +import { APP_SUBTITLE } from 'utils/consts'; import styles from './Header.module.scss'; const cx = cn.bind(styles); -export default function Header({ backendLicense }: { backendLicense: string }) { +const Header = observer(() => { + const store = useStore(); + return ( -
-
-
- - Grafana OnCall - -
{renderHeading()}
+ <> +
+
+
+ + Grafana OnCall + +
{renderHeading()}
+
-
+ + ); function renderHeading() { - if (backendLicense === GRAFANA_LICENSE_OSS) { + if (store.isOpenSource()) { return (

Grafana OnCall

@@ -48,11 +58,27 @@ export default function Header({ backendLicense }: { backendLicense: string }) { ); } + const { irmPlan } = store.alertGroupStore; + return ( <> -

Grafana OnCall

+ +

Grafana OnCall

+ {irmPlan?.limits && {irmPlan.limits.isIrmPro ? 'IRM Pro' : 'IRM Lite'}} +
{APP_SUBTITLE}
); } -} +}); + +const Banners: React.FC = () => { + return ( +
+ + +
+ ); +}; + +export default Header; diff --git a/grafana-plugin/src/pages/incident/Incident.helpers.tsx b/grafana-plugin/src/pages/incident/Incident.helpers.tsx index 0fe1eb91..581618ef 100644 --- a/grafana-plugin/src/pages/incident/Incident.helpers.tsx +++ b/grafana-plugin/src/pages/incident/Incident.helpers.tsx @@ -156,7 +156,7 @@ export function getActionButtons(incident: AlertType, cx: any, callbacks: { [key const resolveButton = ( - @@ -164,7 +164,7 @@ export function getActionButtons(incident: AlertType, cx: any, callbacks: { [key const unacknowledgeButton = ( - @@ -172,7 +172,7 @@ export function getActionButtons(incident: AlertType, cx: any, callbacks: { [key const unresolveButton = ( - @@ -180,7 +180,7 @@ export function getActionButtons(incident: AlertType, cx: any, callbacks: { [key const acknowledgeButton = ( - @@ -194,7 +194,7 @@ export function getActionButtons(incident: AlertType, cx: any, callbacks: { [key ); @@ -203,7 +203,7 @@ export function getActionButtons(incident: AlertType, cx: any, callbacks: { [key if (incident.status === IncidentStatus.Silenced) { buttons.push( - diff --git a/grafana-plugin/src/pages/incident/Incident.tsx b/grafana-plugin/src/pages/incident/Incident.tsx index fe012fbc..6ce7de3e 100644 --- a/grafana-plugin/src/pages/incident/Incident.tsx +++ b/grafana-plugin/src/pages/incident/Incident.tsx @@ -159,13 +159,18 @@ class IncidentPage extends React.Component
- + {this.renderTimeline()}
@@ -261,7 +266,12 @@ class IncidentPage extends React.Component {incident.root_alert_group.render_for_web.title} {' '} - @@ -277,10 +287,16 @@ class IncidentPage extends React.Component onClick={this.showAttachIncidentForm} tooltip="Attach to another Alert Group" className={cx('title-icon')} + disabled={incident.is_restricted} /> )} - + openNotification('Link copied'); }} > - + @@ -303,7 +324,7 @@ class IncidentPage extends React.Component query={{ page: 'integrations', id: incident.alert_receive_channel.id }} > @@ -376,11 +397,12 @@ class IncidentPage extends React.Component variant="secondary" hideSelected value={prepareForEdit(incident.paged_users)} + disabled={incident.is_restricted} onUpdateEscalationVariants={this.handleAddResponders} />