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:
Joey Orlando 2023-04-14 09:15:57 +02:00 committed by GitHub
parent 3544ab14ec
commit 3b274f45f4
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
40 changed files with 567 additions and 178 deletions

View file

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

View file

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

View file

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

View file

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

View 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')]),
),
]

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -0,0 +1,3 @@
import django.dispatch
org_sync_signal = django.dispatch.Signal()

View file

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

View file

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

View file

@ -0,0 +1,2 @@
IS_RESTRICTED_TITLE = "UPGRADE TO SEE MORE"
IS_RESTRICTED_MESSAGE = "UPGRADE TO SEE MORE"

View file

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

View file

@ -1,16 +0,0 @@
.root {
display: block;
}
.heartbeat {
width: 16px;
height: 16px;
}
.heartbeat-icon {
cursor: pointer;
}
.alertsInfoText {
font-size: 12px;
}

View file

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

View file

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

View file

@ -1,7 +1,7 @@
.alerts-container {
display: flex;
flex-direction: column;
margin-bottom: 24px;
margin-bottom: 10px;
gap: 10px;
&--legacy {

View file

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

View file

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

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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/', {});