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 <rares.mardare@grafana.com>
This commit is contained in:
parent
3544ab14ec
commit
3b274f45f4
40 changed files with 567 additions and 178 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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}")
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
23
engine/apps/alerts/migrations/0012_auto_20230406_1010.py
Normal file
23
engine/apps/alerts/migrations/0012_auto_20230406_1010.py
Normal file
|
|
@ -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')]),
|
||||
),
|
||||
]
|
||||
|
|
@ -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():
|
||||
"""
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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=[
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -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])
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
),
|
||||
]
|
||||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
3
engine/apps/user_management/signals.py
Normal file
3
engine/apps/user_management/signals.py
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
import django.dispatch
|
||||
|
||||
org_sync_signal = django.dispatch.Signal()
|
||||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
2
engine/common/constants/alert_group_restrictions.py
Normal file
2
engine/common/constants/alert_group_restrictions.py
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
IS_RESTRICTED_TITLE = "UPGRADE TO SEE MORE"
|
||||
IS_RESTRICTED_MESSAGE = "UPGRADE TO SEE MORE"
|
||||
|
|
@ -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 (
|
||||
<RealPluginPage {...props}>
|
||||
{/* Render alerts at the top */}
|
||||
<Alerts />
|
||||
<Header backendLicense={store.backendLicense} />
|
||||
<Header />
|
||||
{pages[page]?.text && !pages[page]?.hideTitle && (
|
||||
<h3 className="page-title" data-testid="page-title">
|
||||
{pages[page].text}
|
||||
|
|
|
|||
|
|
@ -1,16 +0,0 @@
|
|||
.root {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.heartbeat {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
|
||||
.heartbeat-icon {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.alertsInfoText {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
.alerts-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
margin-bottom: 24px;
|
||||
margin-bottom: 10px;
|
||||
gap: 10px;
|
||||
|
||||
&--legacy {
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<div className={cx('alerts-container', { 'alerts-container--legacy': !isTopNavbar() })}>
|
||||
{showSlackInstallAlert && (
|
||||
|
|
@ -79,7 +82,7 @@ export default function Alerts() {
|
|||
)}
|
||||
</Alert>
|
||||
)}
|
||||
{currentTeam?.banner.title != null && !getItem(currentTeam?.banner.title) && (
|
||||
{showBannerTeam() && (
|
||||
<Alert
|
||||
className={cx('alert')}
|
||||
severity="success"
|
||||
|
|
@ -93,41 +96,31 @@ export default function Alerts() {
|
|||
/>
|
||||
</Alert>
|
||||
)}
|
||||
{store.backendLicense === GRAFANA_LICENSE_OSS &&
|
||||
store.backendVersion &&
|
||||
plugin?.version &&
|
||||
store.backendVersion !== plugin?.version &&
|
||||
!getItem(`version_mismatch_${store.backendVersion}_${plugin?.version}`) && (
|
||||
<Alert
|
||||
className={cx('alert')}
|
||||
severity="warning"
|
||||
title={'Version mismatch!'}
|
||||
onRemove={getRemoveAlertHandler(`version_mismatch_${store.backendVersion}_${plugin?.version}`)}
|
||||
{showMismatchWarning() && (
|
||||
<Alert
|
||||
className={cx('alert')}
|
||||
severity="warning"
|
||||
title={'Version mismatch!'}
|
||||
onRemove={getRemoveAlertHandler(`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!
|
||||
<br />
|
||||
{`Current plugin version: ${plugin.version}, current engine version: ${store.backendVersion}`}
|
||||
<br />
|
||||
Please see{' '}
|
||||
<a
|
||||
href={'https://grafana.com/docs/oncall/latest/open-source/#update-grafana-oncall-oss'}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className={cx('instructions-link')}
|
||||
>
|
||||
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!
|
||||
<br />
|
||||
{`Current plugin version: ${plugin.version}, current engine version: ${store.backendVersion}`}
|
||||
<br />
|
||||
Please see{' '}
|
||||
<a
|
||||
href={'https://grafana.com/docs/oncall/latest/open-source/#update-grafana-oncall-oss'}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className={cx('instructions-link')}
|
||||
>
|
||||
the update instructions
|
||||
</a>
|
||||
.
|
||||
</Alert>
|
||||
)}
|
||||
{Boolean(
|
||||
currentTeam &&
|
||||
currentUser &&
|
||||
isUserActionAllowed(UserActions.UserSettingsWrite) &&
|
||||
(!isPhoneVerified || !isChatOpsConnected) &&
|
||||
!getItem(AlertID.CONNECTIVITY_WARNING)
|
||||
) && (
|
||||
the update instructions
|
||||
</a>
|
||||
.
|
||||
</Alert>
|
||||
)}
|
||||
{showChannelWarnings() && (
|
||||
<Alert
|
||||
onRemove={getRemoveAlertHandler(AlertID.CONNECTIVITY_WARNING)}
|
||||
className={cx('alert')}
|
||||
|
|
@ -151,4 +144,28 @@ export default function Alerts() {
|
|||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
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)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(
|
|||
<Button
|
||||
icon="users-alt"
|
||||
variant={variant}
|
||||
disabled={disabled}
|
||||
onClick={() => {
|
||||
setShowEscalationVariants(true);
|
||||
}}
|
||||
|
|
@ -273,7 +276,11 @@ const ScheduleResponder = ({ important, data, onImportantChange, handleDelete })
|
|||
isSearchable={false}
|
||||
value={Number(important)}
|
||||
options={[
|
||||
{ value: 0, label: 'Default', description: 'Use "Default notifications" from users personal settings' },
|
||||
{
|
||||
value: 0,
|
||||
label: 'Default',
|
||||
description: 'Use "Default notifications" from users personal settings',
|
||||
},
|
||||
{
|
||||
value: 1,
|
||||
label: 'Important',
|
||||
|
|
|
|||
56
grafana-plugin/src/containers/IRMBanner/IRMBanner.tsx
Normal file
56
grafana-plugin/src/containers/IRMBanner/IRMBanner.tsx
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
import React, { useEffect } from 'react';
|
||||
|
||||
import { Alert, AlertVariant, Button, HorizontalGroup } from '@grafana/ui';
|
||||
import { observer } from 'mobx-react';
|
||||
|
||||
import Text from 'components/Text/Text';
|
||||
import { IRMPlanStatus } from 'models/alertgroup/alertgroup.types';
|
||||
import { useStore } from 'state/useStore';
|
||||
|
||||
const IRMBanner: React.FC = observer(() => {
|
||||
const store = useStore();
|
||||
const {
|
||||
alertGroupStore,
|
||||
alertGroupStore: { irmPlan },
|
||||
} = store;
|
||||
|
||||
useEffect(() => {
|
||||
if (store.isOpenSource()) {
|
||||
alertGroupStore.fetchIRMPlan();
|
||||
}
|
||||
}, []);
|
||||
|
||||
if (store.isOpenSource() || !irmPlan?.limits) {
|
||||
return null;
|
||||
}
|
||||
if (irmPlan.limits.isIrmPro || irmPlan.limits.status === IRMPlanStatus.WithinLimits) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const statusSeverity: { [key: string]: AlertVariant } = {
|
||||
[IRMPlanStatus.WithinLimits]: 'success',
|
||||
[IRMPlanStatus.NearLimit]: 'warning',
|
||||
[IRMPlanStatus.AtLimit]: 'error',
|
||||
};
|
||||
|
||||
return (
|
||||
<Alert
|
||||
title={
|
||||
(
|
||||
<HorizontalGroup justify={'space-between'}>
|
||||
<Text type={'secondary'}>
|
||||
<div dangerouslySetInnerHTML={{ __html: irmPlan.limits.reasonHTML }} />
|
||||
</Text>
|
||||
<Button variant={'secondary'} onClick={() => window.open(irmPlan.limits.upgradeURL, '_blank')}>
|
||||
Upgrade
|
||||
</Button>
|
||||
</HorizontalGroup>
|
||||
) as any
|
||||
}
|
||||
severity={statusSeverity[irmPlan.limits.status]}
|
||||
buttonContent={undefined}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
export default IRMBanner;
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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) => {
|
|||
<QRCode className={cx({ 'qr-code': true, blurry: isQRBlurry })} value={QRCodeValue} />
|
||||
{isQRBlurry && <QRLoading />}
|
||||
</div>
|
||||
{store.backendLicense === GRAFANA_LICENSE_OSS && QRCodeDataParsed && (
|
||||
{store.isOpenSource() && QRCodeDataParsed && (
|
||||
<Text type="secondary">
|
||||
Server URL embedded in this QR:
|
||||
<br />
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<div className={cx('root')}>
|
||||
<div className={cx('page-header__inner', { 'header-topnavbar': isTopNavbar() })}>
|
||||
<div className={cx('navbar-left')}>
|
||||
<span className="page-header__logo">
|
||||
<img className="page-header__img" src={logo} alt="Grafana OnCall" />
|
||||
</span>
|
||||
<div className="page-header__info-block">{renderHeading()}</div>
|
||||
<>
|
||||
<div className={cx('root')}>
|
||||
<div className={cx('page-header__inner', { 'header-topnavbar': isTopNavbar() })}>
|
||||
<div className={cx('navbar-left')}>
|
||||
<span className="page-header__logo">
|
||||
<img className="page-header__img" src={logo} alt="Grafana OnCall" />
|
||||
</span>
|
||||
<div className="page-header__info-block">{renderHeading()}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Banners />
|
||||
</>
|
||||
);
|
||||
|
||||
function renderHeading() {
|
||||
if (backendLicense === GRAFANA_LICENSE_OSS) {
|
||||
if (store.isOpenSource()) {
|
||||
return (
|
||||
<div className={cx('heading')}>
|
||||
<h1 className={cx('page-header__title')}>Grafana OnCall</h1>
|
||||
|
|
@ -48,11 +58,27 @@ export default function Header({ backendLicense }: { backendLicense: string }) {
|
|||
);
|
||||
}
|
||||
|
||||
const { irmPlan } = store.alertGroupStore;
|
||||
|
||||
return (
|
||||
<>
|
||||
<h1 className={cx('page-header__title')}>Grafana OnCall</h1>
|
||||
<HorizontalGroup>
|
||||
<h1 className={cx('page-header__title')}>Grafana OnCall</h1>
|
||||
{irmPlan?.limits && <Tag className={cx('irm-icon')}>{irmPlan.limits.isIrmPro ? 'IRM Pro' : 'IRM Lite'}</Tag>}
|
||||
</HorizontalGroup>
|
||||
<div className={cx('page-header__sub-title')}>{APP_SUBTITLE}</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const Banners: React.FC = () => {
|
||||
return (
|
||||
<div className={cx('banners')}>
|
||||
<Alerts />
|
||||
<IRMBanner />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Header;
|
||||
|
|
|
|||
|
|
@ -156,7 +156,7 @@ export function getActionButtons(incident: AlertType, cx: any, callbacks: { [key
|
|||
|
||||
const resolveButton = (
|
||||
<WithPermissionControlTooltip key="resolve" userAction={UserActions.AlertGroupsWrite}>
|
||||
<Button disabled={incident.loading} onClick={onResolve} variant="primary">
|
||||
<Button disabled={incident.loading || incident.is_restricted} onClick={onResolve} variant="primary">
|
||||
Resolve
|
||||
</Button>
|
||||
</WithPermissionControlTooltip>
|
||||
|
|
@ -164,7 +164,7 @@ export function getActionButtons(incident: AlertType, cx: any, callbacks: { [key
|
|||
|
||||
const unacknowledgeButton = (
|
||||
<WithPermissionControlTooltip key="unacknowledge" userAction={UserActions.AlertGroupsWrite}>
|
||||
<Button disabled={incident.loading} onClick={onUnacknowledge} variant="secondary">
|
||||
<Button disabled={incident.loading || incident.is_restricted} onClick={onUnacknowledge} variant="secondary">
|
||||
Unacknowledge
|
||||
</Button>
|
||||
</WithPermissionControlTooltip>
|
||||
|
|
@ -172,7 +172,7 @@ export function getActionButtons(incident: AlertType, cx: any, callbacks: { [key
|
|||
|
||||
const unresolveButton = (
|
||||
<WithPermissionControlTooltip key="unacknowledge" userAction={UserActions.AlertGroupsWrite}>
|
||||
<Button disabled={incident.loading} onClick={onUnresolve} variant="primary">
|
||||
<Button disabled={incident.loading || incident.is_restricted} onClick={onUnresolve} variant="primary">
|
||||
Unresolve
|
||||
</Button>
|
||||
</WithPermissionControlTooltip>
|
||||
|
|
@ -180,7 +180,7 @@ export function getActionButtons(incident: AlertType, cx: any, callbacks: { [key
|
|||
|
||||
const acknowledgeButton = (
|
||||
<WithPermissionControlTooltip key="acknowledge" userAction={UserActions.AlertGroupsWrite}>
|
||||
<Button disabled={incident.loading} onClick={onAcknowledge} variant="secondary">
|
||||
<Button disabled={incident.loading || incident.is_restricted} onClick={onAcknowledge} variant="secondary">
|
||||
Acknowledge
|
||||
</Button>
|
||||
</WithPermissionControlTooltip>
|
||||
|
|
@ -194,7 +194,7 @@ export function getActionButtons(incident: AlertType, cx: any, callbacks: { [key
|
|||
<SilenceButtonCascader
|
||||
className={cx('silence-button-inline')}
|
||||
key="silence"
|
||||
disabled={incident.loading}
|
||||
disabled={incident.loading || incident.is_restricted}
|
||||
onSelect={onSilence}
|
||||
/>
|
||||
);
|
||||
|
|
@ -203,7 +203,7 @@ export function getActionButtons(incident: AlertType, cx: any, callbacks: { [key
|
|||
if (incident.status === IncidentStatus.Silenced) {
|
||||
buttons.push(
|
||||
<WithPermissionControlTooltip key="silence" userAction={UserActions.AlertGroupsWrite}>
|
||||
<Button disabled={incident.loading} variant="secondary" onClick={onUnsilence}>
|
||||
<Button disabled={incident.loading || incident.is_restricted} variant="secondary" onClick={onUnsilence}>
|
||||
Unsilence
|
||||
</Button>
|
||||
</WithPermissionControlTooltip>
|
||||
|
|
|
|||
|
|
@ -159,13 +159,18 @@ class IncidentPage extends React.Component<IncidentPageProps, IncidentPageState>
|
|||
<Incident incident={incident} datetimeReference={this.getIncidentDatetimeReference(incident)} />
|
||||
<GroupedIncidentsList
|
||||
id={incident.pk}
|
||||
disabled={incident.is_restricted}
|
||||
getIncidentDatetimeReference={this.getIncidentDatetimeReference}
|
||||
/>
|
||||
<AttachedIncidentsList id={incident.pk} getUnattachClickHandler={this.getUnattachClickHandler} />
|
||||
</div>
|
||||
<div className={cx('column')}>
|
||||
<VerticalGroup>
|
||||
<PagedUsers pagedUsers={incident.paged_users} onRemove={this.handlePagedUserRemove} />
|
||||
<PagedUsers
|
||||
pagedUsers={incident.paged_users}
|
||||
onRemove={this.handlePagedUserRemove}
|
||||
disabled={incident.is_restricted}
|
||||
/>
|
||||
{this.renderTimeline()}
|
||||
</VerticalGroup>
|
||||
</div>
|
||||
|
|
@ -261,7 +266,12 @@ class IncidentPage extends React.Component<IncidentPageProps, IncidentPageState>
|
|||
{incident.root_alert_group.render_for_web.title}
|
||||
</PluginLink>{' '}
|
||||
<WithPermissionControlTooltip userAction={UserActions.AlertGroupsWrite}>
|
||||
<Button variant="secondary" onClick={() => this.getUnattachClickHandler(incident.pk)} size="sm">
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => this.getUnattachClickHandler(incident.pk)}
|
||||
size="sm"
|
||||
disabled={incident.is_restricted}
|
||||
>
|
||||
Unattach
|
||||
</Button>
|
||||
</WithPermissionControlTooltip>
|
||||
|
|
@ -277,10 +287,16 @@ class IncidentPage extends React.Component<IncidentPageProps, IncidentPageState>
|
|||
onClick={this.showAttachIncidentForm}
|
||||
tooltip="Attach to another Alert Group"
|
||||
className={cx('title-icon')}
|
||||
disabled={incident.is_restricted}
|
||||
/>
|
||||
)}
|
||||
<a href={incident.slack_permalink} target="_blank" rel="noreferrer">
|
||||
<IconButton name="slack" tooltip="View in Slack" className={cx('title-icon')} />
|
||||
<IconButton
|
||||
name="slack"
|
||||
tooltip="View in Slack"
|
||||
className={cx('title-icon')}
|
||||
disabled={incident.is_restricted}
|
||||
/>
|
||||
</a>
|
||||
<CopyToClipboard
|
||||
text={window.location.href}
|
||||
|
|
@ -288,7 +304,12 @@ class IncidentPage extends React.Component<IncidentPageProps, IncidentPageState>
|
|||
openNotification('Link copied');
|
||||
}}
|
||||
>
|
||||
<IconButton name="copy" tooltip="Copy link" className={cx('title-icon')} />
|
||||
<IconButton
|
||||
name="copy"
|
||||
tooltip="Copy link"
|
||||
className={cx('title-icon')}
|
||||
disabled={incident.is_restricted}
|
||||
/>
|
||||
</CopyToClipboard>
|
||||
</Text>
|
||||
</HorizontalGroup>
|
||||
|
|
@ -303,7 +324,7 @@ class IncidentPage extends React.Component<IncidentPageProps, IncidentPageState>
|
|||
query={{ page: 'integrations', id: incident.alert_receive_channel.id }}
|
||||
>
|
||||
<Button
|
||||
disabled={incident.alert_receive_channel.deleted}
|
||||
disabled={incident.alert_receive_channel.deleted || incident.is_restricted}
|
||||
variant="secondary"
|
||||
fill="outline"
|
||||
size="sm"
|
||||
|
|
@ -340,7 +361,7 @@ class IncidentPage extends React.Component<IncidentPageProps, IncidentPageState>
|
|||
variant="secondary"
|
||||
fill="outline"
|
||||
size="sm"
|
||||
disabled={incident.render_for_web.source_link === null}
|
||||
disabled={incident.render_for_web.source_link === null || incident.is_restricted}
|
||||
className={cx('label-button')}
|
||||
icon="external-link-alt"
|
||||
>
|
||||
|
|
@ -364,7 +385,7 @@ class IncidentPage extends React.Component<IncidentPageProps, IncidentPageState>
|
|||
})}
|
||||
<PluginBridge plugin={SupportedPlugin.Incident}>
|
||||
<a href={incident.declare_incident_link} target="_blank" rel="noreferrer">
|
||||
<Button variant="secondary" size="md" icon="fire">
|
||||
<Button variant="secondary" size="md" icon="fire" disabled={incident.is_restricted}>
|
||||
Declare incident
|
||||
</Button>
|
||||
</a>
|
||||
|
|
@ -376,11 +397,12 @@ class IncidentPage extends React.Component<IncidentPageProps, IncidentPageState>
|
|||
variant="secondary"
|
||||
hideSelected
|
||||
value={prepareForEdit(incident.paged_users)}
|
||||
disabled={incident.is_restricted}
|
||||
onUpdateEscalationVariants={this.handleAddResponders}
|
||||
/>
|
||||
|
||||
<Button
|
||||
disabled={incident.alert_receive_channel.deleted}
|
||||
disabled={incident.alert_receive_channel.deleted || incident.is_restricted}
|
||||
variant="secondary"
|
||||
icon="edit"
|
||||
onClick={this.showIntegrationSettings}
|
||||
|
|
@ -488,6 +510,7 @@ class IncidentPage extends React.Component<IncidentPageProps, IncidentPageState>
|
|||
>
|
||||
<TextArea
|
||||
value={resolutionNoteText}
|
||||
disabled={incident.is_restricted}
|
||||
onChange={(e: any) => this.setState({ resolutionNoteText: e.target.value })}
|
||||
/>
|
||||
</Field>
|
||||
|
|
@ -496,7 +519,7 @@ class IncidentPage extends React.Component<IncidentPageProps, IncidentPageState>
|
|||
icon="plus"
|
||||
variant="primary"
|
||||
onClick={this.handleCreateResolutionNote}
|
||||
disabled={isResolutionNoteTextEmpty}
|
||||
disabled={isResolutionNoteTextEmpty || incident.is_restricted}
|
||||
>
|
||||
Add resolution note
|
||||
</ToolbarButton>
|
||||
|
|
@ -625,8 +648,10 @@ function Incident({ incident, datetimeReference }: { incident: Alert; datetimeRe
|
|||
function GroupedIncidentsList({
|
||||
id,
|
||||
getIncidentDatetimeReference,
|
||||
disabled,
|
||||
}: {
|
||||
id: string;
|
||||
disabled: boolean;
|
||||
getIncidentDatetimeReference: (incident: GroupedAlert) => string;
|
||||
}) {
|
||||
const store = useStore();
|
||||
|
|
@ -656,13 +681,26 @@ function GroupedIncidentsList({
|
|||
contentClassName={cx('incidents-content')}
|
||||
>
|
||||
{alerts.map((alert) => (
|
||||
<GroupedIncident key={alert.id} incident={alert} datetimeReference={getIncidentDatetimeReference(alert)} />
|
||||
<GroupedIncident
|
||||
key={alert.id}
|
||||
incident={alert}
|
||||
disabled={disabled}
|
||||
datetimeReference={getIncidentDatetimeReference(alert)}
|
||||
/>
|
||||
))}
|
||||
</Collapse>
|
||||
);
|
||||
}
|
||||
|
||||
function GroupedIncident({ incident, datetimeReference }: { incident: GroupedAlert; datetimeReference: string }) {
|
||||
function GroupedIncident({
|
||||
incident,
|
||||
datetimeReference,
|
||||
disabled,
|
||||
}: {
|
||||
incident: GroupedAlert;
|
||||
datetimeReference: string;
|
||||
disabled: boolean;
|
||||
}) {
|
||||
const store = useStore();
|
||||
const [incidentRawResponse, setIncidentRawResponse] = useState<{ id: string; raw_request_data: any }>(undefined);
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
|
|
@ -710,7 +748,7 @@ function GroupedIncident({ incident, datetimeReference }: { incident: GroupedAle
|
|||
<div className={cx('incident-row-right')}>
|
||||
<HorizontalGroup wrap={false} justify={'flex-end'}>
|
||||
<Tooltip placement="top" content="Alert Payload">
|
||||
<IconButton name="arrow" onClick={() => openIncidentResponse(incident)} />
|
||||
<IconButton name="arrow" onClick={() => openIncidentResponse(incident)} disabled={disabled} />
|
||||
</Tooltip>
|
||||
</HorizontalGroup>
|
||||
</div>
|
||||
|
|
@ -764,7 +802,12 @@ function AttachedIncidentsList({
|
|||
#{incident.inside_organization_number} {incident.render_for_web.title}
|
||||
</PluginLink>
|
||||
<WithPermissionControlTooltip userAction={UserActions.AlertGroupsWrite}>
|
||||
<Button size="sm" onClick={() => getUnattachClickHandler(incident.pk)} variant="secondary">
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => getUnattachClickHandler(incident.pk)}
|
||||
variant="secondary"
|
||||
disabled={incident.is_restricted}
|
||||
>
|
||||
Unattach
|
||||
</Button>
|
||||
</WithPermissionControlTooltip>
|
||||
|
|
|
|||
|
|
@ -20,11 +20,13 @@ const cx = cn.bind(styles);
|
|||
|
||||
interface PagedUsersProps {
|
||||
pagedUsers: Alert['paged_users'];
|
||||
disabled: boolean;
|
||||
|
||||
onRemove: (id: User['pk']) => void;
|
||||
}
|
||||
|
||||
const PagedUsers = observer((props: PagedUsersProps) => {
|
||||
const { pagedUsers, onRemove } = props;
|
||||
const { pagedUsers, disabled, onRemove } = props;
|
||||
|
||||
const getPagedUserRemoveHandler = useCallback((id: User['pk']) => {
|
||||
return () => {
|
||||
|
|
@ -94,6 +96,7 @@ const PagedUsers = observer((props: PagedUsersProps) => {
|
|||
onClick={getPagedUserRemoveHandler(pagedUser.pk)}
|
||||
tooltip="Remove from responders"
|
||||
name="trash-alt"
|
||||
disabled={disabled}
|
||||
/>
|
||||
</WithConfirm>
|
||||
</WithPermissionControlTooltip>
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import React, { ReactElement, SyntheticEvent } from 'react';
|
||||
|
||||
import { Button, VerticalGroup, LoadingPlaceholder, HorizontalGroup, Tooltip, Icon } from '@grafana/ui';
|
||||
import { Button, HorizontalGroup, Icon, LoadingPlaceholder, Tooltip, VerticalGroup } from '@grafana/ui';
|
||||
import cn from 'classnames/bind';
|
||||
import { get } from 'lodash-es';
|
||||
import { observer } from 'mobx-react';
|
||||
|
|
@ -98,6 +98,14 @@ class Incidents extends React.Component<IncidentsPageProps, IncidentsPageState>
|
|||
|
||||
private pollingIntervalId: NodeJS.Timer = undefined;
|
||||
|
||||
async componentDidMount() {
|
||||
const { store } = this.props;
|
||||
|
||||
if (!store.isOpenSource()) {
|
||||
await store.alertGroupStore.fetchIRMPlan();
|
||||
}
|
||||
}
|
||||
|
||||
componentWillUnmount(): void {
|
||||
this.clearPollingInterval();
|
||||
}
|
||||
|
|
@ -105,6 +113,15 @@ class Incidents extends React.Component<IncidentsPageProps, IncidentsPageState>
|
|||
render() {
|
||||
const { history } = this.props;
|
||||
const { showAddAlertGroupForm } = this.state;
|
||||
const {
|
||||
store,
|
||||
store: { alertGroupStore },
|
||||
} = this.props;
|
||||
|
||||
if (!alertGroupStore.irmPlan && !store.isOpenSource()) {
|
||||
return <LoadingPlaceholder text={'Loading...'} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className={cx('root')}>
|
||||
|
|
@ -511,7 +528,10 @@ class Incidents extends React.Component<IncidentsPageProps, IncidentsPageState>
|
|||
emptyText={alertGroupsLoading ? 'Loading...' : 'No alert groups found'}
|
||||
loading={alertGroupsLoading}
|
||||
className={cx('incidents-table')}
|
||||
rowSelection={{ selectedRowKeys: selectedIncidentIds, onChange: this.handleSelectedIncidentIdsChange }}
|
||||
rowSelection={{
|
||||
selectedRowKeys: selectedIncidentIds,
|
||||
onChange: this.handleSelectedIncidentIdsChange,
|
||||
}}
|
||||
rowKey="pk"
|
||||
data={results}
|
||||
columns={columns}
|
||||
|
|
|
|||
|
|
@ -105,6 +105,7 @@ export const Root = observer((props: AppRootProps) => {
|
|||
|
||||
const updateBasicData = async () => {
|
||||
await store.updateBasicData();
|
||||
await store.alertGroupStore.fetchIRMPlan();
|
||||
setDidFinishLoading(true);
|
||||
};
|
||||
|
||||
|
|
@ -125,7 +126,7 @@ export const Root = observer((props: AppRootProps) => {
|
|||
<DefaultPageLayout {...props} page={page}>
|
||||
{!isTopNavbar() && (
|
||||
<>
|
||||
<Header backendLicense={store.backendLicense} />
|
||||
<Header />
|
||||
<LegacyNavTabsBar currentPage={page} />
|
||||
</>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -33,6 +33,7 @@ import { makeRequest } from 'network';
|
|||
import { AppFeature } from 'state/features';
|
||||
import PluginState from 'state/plugin';
|
||||
import { isUserActionAllowed, UserActions } from 'utils/authorization';
|
||||
import { GRAFANA_LICENSE_OSS } from 'utils/consts';
|
||||
|
||||
// ------ Dashboard ------ //
|
||||
|
||||
|
|
@ -63,9 +64,6 @@ export class RootBaseStore {
|
|||
@observable
|
||||
selectedAlertReceiveChannel?: AlertReceiveChannel['id'];
|
||||
|
||||
@observable
|
||||
isLess1280: boolean;
|
||||
|
||||
@observable
|
||||
features?: { [key: string]: boolean };
|
||||
|
||||
|
|
@ -220,6 +218,10 @@ export class RootBaseStore {
|
|||
return this.features?.[feature];
|
||||
}
|
||||
|
||||
isOpenSource(): boolean {
|
||||
return this.backendLicense === GRAFANA_LICENSE_OSS;
|
||||
}
|
||||
|
||||
@observable
|
||||
async updateFeatures() {
|
||||
const response = await makeRequest('/features/', {});
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue