remove organization maintenance mode + fix integration maintenance mode (#2511)

This commit is contained in:
Joey Orlando 2023-07-12 22:41:44 +02:00 committed by GitHub
parent 939590fe4c
commit d24dc4b630
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
34 changed files with 371 additions and 764 deletions

View file

@ -15,18 +15,20 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Changed
- Deprecated `/maintenance` web UI page. Maintenance is now handled at the integration level and can be performed
within a single integration's page. by @Ukochka ([2497](https://github.com/grafana/oncall/issues/2497))
within a single integration's page. by @Ukochka ([#2497](https://github.com/grafana/oncall/issues/2497))
### Fixed
- Schedules: Long popup does not fit screen & buttons unreachable & objects outside of the popup [1002](https://github.com/grafana/oncall/issues/1002)
- New schedules white theme issues [2356](https://github.com/grafana/oncall/issues/2356)
- Fixed a bug in the integration maintenance mode workflow where a user could not start/stop an integration's
maintenance mode by @joeyorlando ([#2511](https://github.com/grafana/oncall/issues/2511))
- Schedules: Long popup does not fit screen & buttons unreachable & objects outside of the popup [#1002](https://github.com/grafana/oncall/issues/1002)
- New schedules white theme issues [#2356](https://github.com/grafana/oncall/issues/2356)
## v1.3.9 (2023-07-12)
### Added
- Bring new Jinja editor to webhooks ([2344](https://github.com/grafana/oncall/issues/2344))
- Bring new Jinja editor to webhooks ([#2344](https://github.com/grafana/oncall/issues/2344))
### Fixed
@ -83,7 +85,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Changed
- UI drawer updates for webhooks2 ([#2419](https://github.com/grafana/oncall/pull/2419))
- Removed url from sms notification, changed format ([2317](https://github.com/grafana/oncall/pull/2317))
- Removed url from sms notification, changed format ([#2317](https://github.com/grafana/oncall/pull/2317))
## v1.3.3 (2023-06-29)

View file

@ -231,9 +231,7 @@ class EscalationSnapshotMixin:
"""
AlertGroup = apps.get_model("alerts", "AlertGroup")
is_on_maintenace_or_debug_mode = (
self.channel.maintenance_mode is not None or self.channel.organization.maintenance_mode is not None
)
is_on_maintenace_or_debug_mode = self.channel.maintenance_mode is not None
if (
self.is_restricted

View file

@ -128,10 +128,8 @@ class Alert(models.Model):
if group_created:
# all code below related to maintenance mode
maintenance_uuid = None
if alert_receive_channel.organization.maintenance_mode == AlertReceiveChannel.MAINTENANCE:
maintenance_uuid = alert_receive_channel.organization.maintenance_uuid
elif alert_receive_channel.maintenance_mode == AlertReceiveChannel.MAINTENANCE:
if alert_receive_channel.maintenance_mode == AlertReceiveChannel.MAINTENANCE:
maintenance_uuid = alert_receive_channel.maintenance_uuid
if maintenance_uuid is not None:

View file

@ -426,7 +426,6 @@ class AlertGroup(AlertGroupSlackRenderingMixin, EscalationSnapshotMixin, models.
return self.maintenance_uuid is not None
def stop_maintenance(self, user: User) -> None:
Organization = apps.get_model("user_management", "Organization")
AlertReceiveChannel = apps.get_model("alerts", "AlertReceiveChannel")
try:
@ -436,13 +435,6 @@ class AlertGroup(AlertGroupSlackRenderingMixin, EscalationSnapshotMixin, models.
except AlertReceiveChannel.DoesNotExist:
pass
try:
organization_on_maintenance = Organization.objects.get(maintenance_uuid=self.maintenance_uuid)
organization_on_maintenance.force_disable_maintenance(user)
return
except Organization.DoesNotExist:
pass
self.resolve_by_disable_maintenance()
@property

View file

@ -46,11 +46,8 @@ def send_alert_create_signal(alert_id):
task_logger.debug(f"Started send_alert_create_signal task for alert {alert_id}")
alert = Alert.objects.get(pk=alert_id)
is_on_maintenace_mode = (
alert.group.channel.maintenance_mode == AlertReceiveChannel.MAINTENANCE
or alert.group.channel.organization.maintenance_mode == AlertReceiveChannel.MAINTENANCE
)
if not is_on_maintenace_mode:
if alert.group.channel.maintenance_mode != AlertReceiveChannel.MAINTENANCE:
alert_create_signal.send(
sender=send_alert_create_signal,
alert=alert_id,

View file

@ -16,7 +16,6 @@ from .task_logger import task_logger
def disable_maintenance(*args, **kwargs):
AlertGroup = apps.get_model("alerts", "AlertGroup")
User = apps.get_model("user_management", "User")
Organization = apps.get_model("user_management", "Organization")
user = None
object_under_maintenance = None
user_id = kwargs.get("user_id")
@ -37,12 +36,6 @@ def disable_maintenance(*args, **kwargs):
task_logger.info(
f"AlertReceiveChannel for disable_maintenance does not exists. Id: {alert_receive_channel_id}"
)
elif "organization_id" in kwargs:
organization_id = kwargs["organization_id"]
try:
object_under_maintenance = Organization.objects.select_for_update().get(pk=organization_id)
except Organization.DoesNotExist:
task_logger.info(f"Organization for disable_maintenance does not exists. Id: {organization_id}")
else:
task_logger.info(f"Invalid instance id passed in disable_maintenance. Got: {kwargs}")
@ -90,7 +83,6 @@ def disable_maintenance(*args, **kwargs):
)
def check_maintenance_finished(*args, **kwargs):
AlertReceiveChannel = apps.get_model("alerts", "AlertReceiveChannel")
Organization = apps.get_model("user_management", "Organization")
now = timezone.now()
maintenance_finish_at = ExpressionWrapper(
(F("maintenance_started_at") + F("maintenance_duration")), output_field=fields.DateTimeField()
@ -107,15 +99,3 @@ def check_maintenance_finished(*args, **kwargs):
args=(),
kwargs={"alert_receive_channel_id": id, "force": True},
)
organization_with_expired_maintenance_ids = (
Organization.objects.filter(maintenance_started_at__isnull=False)
.annotate(maintenance_finish_at=maintenance_finish_at)
.filter(maintenance_finish_at__lt=now)
.values_list("pk", flat=True)
)
for id in organization_with_expired_maintenance_ids:
disable_maintenance.apply_async(
args=(),
kwargs={"organization_id": id, "force": True},
)

View file

@ -21,11 +21,7 @@ def send_update_log_report_signal(log_record_pk=None, alert_group_pk=None):
)
return
is_on_maintenace_mode = (
alert_group.channel.maintenance_mode == AlertReceiveChannel.MAINTENANCE
or alert_group.channel.organization.maintenance_mode == AlertReceiveChannel.MAINTENANCE
)
if is_on_maintenace_mode:
if alert_group.channel.maintenance_mode == AlertReceiveChannel.MAINTENANCE:
task_logger.debug(
f'send_update_log_report_signal: alert_group={alert_group_pk} msg="skip alert_group_update_log_report_signal due to maintenace"'
)

View file

@ -86,40 +86,6 @@ def test_maintenance_integration_will_not_start_twice(
assert alert_receive_channel.maintenance_author == user
@pytest.mark.django_db
def test_start_maintenance_team(maintenance_test_setup, mock_start_disable_maintenance_task):
organization, user = maintenance_test_setup
mode = AlertReceiveChannel.MAINTENANCE
duration = AlertReceiveChannel.DURATION_ONE_HOUR.seconds
organization.start_maintenance(mode, duration, user)
assert organization.maintenance_mode == mode
assert organization.maintenance_duration == AlertReceiveChannel.DURATION_ONE_HOUR
assert organization.maintenance_uuid is not None
assert organization.maintenance_started_at is not None
assert organization.maintenance_author == user
@pytest.mark.django_db
def test_maintenance_team_will_not_start_twice(maintenance_test_setup, mock_start_disable_maintenance_task):
organization, user = maintenance_test_setup
mode = AlertReceiveChannel.MAINTENANCE
duration = AlertReceiveChannel.DURATION_ONE_HOUR.seconds
organization.start_maintenance(mode, duration, user)
with pytest.raises(MaintenanceCouldNotBeStartedError):
organization.start_maintenance(mode, duration, user)
assert organization.maintenance_mode == mode
assert organization.maintenance_duration == AlertReceiveChannel.DURATION_ONE_HOUR
assert organization.maintenance_uuid is not None
assert organization.maintenance_started_at is not None
assert organization.maintenance_author == user
@pytest.mark.django_db
def test_alert_attached_to_maintenance_incident_integration(
maintenance_test_setup,
@ -151,38 +117,6 @@ def test_alert_attached_to_maintenance_incident_integration(
assert alert.group.root_alert_group == maintenance_incident
@pytest.mark.django_db
def test_alert_attached_to_maintenance_incident_team(
maintenance_test_setup,
make_alert_receive_channel,
make_alert_with_custom_create_method,
mock_start_disable_maintenance_task,
):
organization, user = maintenance_test_setup
alert_receive_channel = make_alert_receive_channel(
organization, integration=AlertReceiveChannel.INTEGRATION_GRAFANA
)
mode = AlertReceiveChannel.MAINTENANCE
duration = AlertReceiveChannel.DURATION_ONE_HOUR.seconds
organization.start_maintenance(mode, duration, user)
maintenance_incident = AlertGroup.all_objects.get(maintenance_uuid=organization.maintenance_uuid)
alert = make_alert_with_custom_create_method(
title="test_title",
message="test_message",
image_url="test_img_url",
link_to_upstream_details=None,
alert_receive_channel=alert_receive_channel,
raw_request_data={"message": "test"},
integration_unique_data={},
)
assert alert.group.root_alert_group == maintenance_incident
@pytest.mark.django_db(transaction=True)
def test_stop_maintenance(
maintenance_test_setup,
@ -214,9 +148,3 @@ def test_stop_maintenance(
alert.refresh_from_db()
assert maintenance_incident.resolved_by == AlertGroup.DISABLE_MAINTENANCE
assert alert.group.resolved_by == AlertGroup.DISABLE_MAINTENANCE
assert organization.maintenance_mode is None
assert organization.maintenance_duration is None
assert organization.maintenance_uuid is None
assert organization.maintenance_started_at is None
assert organization.maintenance_author is None

View file

@ -21,7 +21,6 @@ class OrganizationSerializer(EagerLoadingMixin, serializers.ModelSerializer):
slack_team_identity = FastSlackTeamIdentitySerializer(read_only=True)
name = serializers.CharField(required=False, allow_null=True, allow_blank=True, source="org_title")
maintenance_till = serializers.ReadOnlyField(source="till_maintenance_timestamp")
slack_channel = serializers.SerializerMethodField()
SELECT_RELATED = ["slack_team_identity"]
@ -32,14 +31,10 @@ class OrganizationSerializer(EagerLoadingMixin, serializers.ModelSerializer):
"pk",
"name",
"slack_team_identity",
"maintenance_mode",
"maintenance_till",
"slack_channel",
]
read_only_fields = [
"slack_team_identity",
"maintenance_mode",
"maintenance_till",
]
def get_slack_channel(self, obj):

View file

@ -96,7 +96,9 @@ def test_update_notify_multiple_users_step(escalation_policy_internal_api_setup,
assert response.status_code == status.HTTP_200_OK
assert response.json()["step"] == EscalationPolicy.STEP_NOTIFY_MULTIPLE_USERS
assert response.json()["notify_to_users_queue"] == [first_user.public_primary_key, second_user.public_primary_key]
assert sorted(response.json()["notify_to_users_queue"]) == sorted(
[first_user.public_primary_key, second_user.public_primary_key]
)
@pytest.mark.django_db

View file

@ -1,272 +0,0 @@
import pytest
from django.urls import reverse
from rest_framework import status
from rest_framework.test import APIClient
from apps.alerts.models import AlertReceiveChannel
from apps.user_management.models import Organization
# TODO: should probably modify these tests to take into account new rbac permissions
@pytest.fixture()
def maintenance_internal_api_setup(
make_organization_and_user_with_plugin_token,
make_escalation_chain,
make_alert_receive_channel,
):
organization, user, token = make_organization_and_user_with_plugin_token()
make_escalation_chain(organization)
alert_receive_channel = make_alert_receive_channel(organization)
return token, organization, user, alert_receive_channel
@pytest.mark.django_db
def test_start_maintenance_integration(
maintenance_internal_api_setup, mock_start_disable_maintenance_task, make_user_auth_headers
):
token, _, user, alert_receive_channel = maintenance_internal_api_setup
client = APIClient()
url = reverse("api-internal:start_maintenance")
data = {
"mode": AlertReceiveChannel.MAINTENANCE,
"duration": AlertReceiveChannel.DURATION_ONE_HOUR.total_seconds(),
"type": "alert_receive_channel",
"alert_receive_channel_id": alert_receive_channel.public_primary_key,
}
response = client.post(url, data=data, format="json", **make_user_auth_headers(user, token))
alert_receive_channel.refresh_from_db()
assert response.status_code == status.HTTP_200_OK
assert alert_receive_channel.maintenance_mode == AlertReceiveChannel.MAINTENANCE
assert alert_receive_channel.maintenance_duration == AlertReceiveChannel.DURATION_ONE_HOUR
assert alert_receive_channel.maintenance_uuid is not None
assert alert_receive_channel.maintenance_started_at is not None
assert alert_receive_channel.maintenance_author is not None
@pytest.mark.django_db
def test_start_maintenance_integration_user_team(
maintenance_internal_api_setup, mock_start_disable_maintenance_task, make_user_auth_headers, make_team
):
token, organization, user, alert_receive_channel = maintenance_internal_api_setup
another_team = make_team(organization)
user.current_team = another_team
user.save()
client = APIClient()
url = reverse("api-internal:start_maintenance")
data = {
"mode": AlertReceiveChannel.MAINTENANCE,
"duration": AlertReceiveChannel.DURATION_ONE_HOUR.total_seconds(),
"type": "alert_receive_channel",
"alert_receive_channel_id": alert_receive_channel.public_primary_key,
}
response = client.post(url, data=data, format="json", **make_user_auth_headers(user, token))
alert_receive_channel.refresh_from_db()
assert response.status_code == status.HTTP_200_OK
assert alert_receive_channel.maintenance_mode == AlertReceiveChannel.MAINTENANCE
assert alert_receive_channel.maintenance_duration == AlertReceiveChannel.DURATION_ONE_HOUR
assert alert_receive_channel.maintenance_uuid is not None
assert alert_receive_channel.maintenance_started_at is not None
assert alert_receive_channel.maintenance_author is not None
@pytest.mark.django_db
def test_start_maintenance_integration_different_team(
maintenance_internal_api_setup, mock_start_disable_maintenance_task, make_user_auth_headers, make_team
):
token, organization, user, alert_receive_channel = maintenance_internal_api_setup
another_team = make_team(organization)
other_team = make_team(organization)
user.current_team = another_team
user.save()
# integration belongs to non-general team, != user current team
alert_receive_channel.team = other_team
alert_receive_channel.save()
client = APIClient()
url = reverse("api-internal:start_maintenance")
data = {
"mode": AlertReceiveChannel.MAINTENANCE,
"duration": AlertReceiveChannel.DURATION_ONE_HOUR.total_seconds(),
"type": "alert_receive_channel",
"alert_receive_channel_id": alert_receive_channel.public_primary_key,
}
response = client.post(url, data=data, format="json", **make_user_auth_headers(user, token))
assert response.status_code == status.HTTP_400_BAD_REQUEST
alert_receive_channel.refresh_from_db()
assert alert_receive_channel.maintenance_mode is None
@pytest.mark.django_db
def test_stop_maintenance_integration(
maintenance_internal_api_setup,
mock_start_disable_maintenance_task,
make_user_auth_headers,
):
token, _, user, alert_receive_channel = maintenance_internal_api_setup
client = APIClient()
mode = AlertReceiveChannel.MAINTENANCE
duration = AlertReceiveChannel.DURATION_ONE_HOUR.seconds
alert_receive_channel.start_maintenance(mode, duration, user)
url = reverse("api-internal:stop_maintenance")
data = {
"type": "alert_receive_channel",
"alert_receive_channel_id": alert_receive_channel.public_primary_key,
}
response = client.post(url, data=data, format="json", **make_user_auth_headers(user, token))
alert_receive_channel.refresh_from_db()
assert response.status_code == status.HTTP_200_OK
assert alert_receive_channel.maintenance_mode is None
assert alert_receive_channel.maintenance_duration is None
assert alert_receive_channel.maintenance_uuid is None
assert alert_receive_channel.maintenance_started_at is None
assert alert_receive_channel.maintenance_author is None
@pytest.mark.django_db
def test_start_maintenance_organization(
maintenance_internal_api_setup,
mock_start_disable_maintenance_task,
make_user_auth_headers,
):
token, organization, user, _ = maintenance_internal_api_setup
client = APIClient()
url = reverse("api-internal:start_maintenance")
data = {
"mode": Organization.MAINTENANCE,
"duration": Organization.DURATION_ONE_HOUR.total_seconds(),
"type": "organization",
}
response = client.post(url, data=data, format="json", **make_user_auth_headers(user, token))
organization.refresh_from_db()
assert response.status_code == status.HTTP_200_OK
assert organization.maintenance_mode == Organization.MAINTENANCE
assert organization.maintenance_duration == Organization.DURATION_ONE_HOUR
assert organization.maintenance_uuid is not None
assert organization.maintenance_started_at is not None
assert organization.maintenance_author is not None
@pytest.mark.django_db
def test_stop_maintenance_team(
maintenance_internal_api_setup,
mock_start_disable_maintenance_task,
make_user_auth_headers,
):
token, organization, user, _ = maintenance_internal_api_setup
client = APIClient()
mode = Organization.MAINTENANCE
duration = AlertReceiveChannel.DURATION_ONE_HOUR.seconds
organization.start_maintenance(mode, duration, user)
url = reverse("api-internal:stop_maintenance")
data = {
"type": "organization",
}
response = client.post(url, data=data, format="json", **make_user_auth_headers(user, token))
organization.refresh_from_db()
assert response.status_code == status.HTTP_200_OK
assert organization.maintenance_mode is None
assert organization.maintenance_duration is None
assert organization.maintenance_uuid is None
assert organization.maintenance_started_at is None
assert organization.maintenance_author is None
@pytest.mark.django_db
def test_maintenances_list(
maintenance_internal_api_setup,
mock_start_disable_maintenance_task,
make_user_auth_headers,
):
token, organization, user, alert_receive_channel = maintenance_internal_api_setup
client = APIClient()
mode = AlertReceiveChannel.MAINTENANCE
duration = AlertReceiveChannel.DURATION_ONE_HOUR.seconds
alert_receive_channel.start_maintenance(mode, duration, user)
organization.start_maintenance(mode, duration, user)
url = reverse("api-internal:maintenance")
response = client.get(url, format="json", **make_user_auth_headers(user, token))
expected_payload = [
{
"organization_id": organization.public_primary_key,
"type": "organization",
"maintenance_mode": 1,
"maintenance_till_timestamp": organization.till_maintenance_timestamp,
"started_at_timestamp": organization.started_at_timestamp,
},
{
"alert_receive_channel_id": alert_receive_channel.public_primary_key,
"type": "alert_receive_channel",
"maintenance_mode": 1,
"maintenance_till_timestamp": alert_receive_channel.till_maintenance_timestamp,
"started_at_timestamp": alert_receive_channel.started_at_timestamp,
},
]
assert response.status_code == status.HTTP_200_OK
assert response.json() == expected_payload
@pytest.mark.django_db
def test_maintenances_list_include_all_user_teams(
maintenance_internal_api_setup,
mock_start_disable_maintenance_task,
make_user_auth_headers,
make_team,
):
token, organization, user, alert_receive_channel = maintenance_internal_api_setup
another_team = make_team(organization)
other_team = make_team(organization)
# setup user teams
user.teams.add(another_team)
user.teams.add(other_team)
user.current_team = other_team
user.save()
# integration belongs to non-general team, != user current team
alert_receive_channel.team = another_team
alert_receive_channel.save()
client = APIClient()
mode = AlertReceiveChannel.MAINTENANCE
duration = AlertReceiveChannel.DURATION_ONE_HOUR.seconds
alert_receive_channel.start_maintenance(mode, duration, user)
url = reverse("api-internal:maintenance")
response = client.get(url, format="json", **make_user_auth_headers(user, token))
expected_payload = [
{
"alert_receive_channel_id": alert_receive_channel.public_primary_key,
"type": "alert_receive_channel",
"maintenance_mode": 1,
"maintenance_till_timestamp": alert_receive_channel.till_maintenance_timestamp,
"started_at_timestamp": alert_receive_channel.started_at_timestamp,
},
]
assert response.status_code == status.HTTP_200_OK
assert response.json() == expected_payload
@pytest.mark.django_db
def test_empty_maintenances_list(
maintenance_internal_api_setup, mock_start_disable_maintenance_task, make_user_auth_headers
):
token, _, user, alert_receive_channel = maintenance_internal_api_setup
client = APIClient()
url = reverse("api-internal:maintenance")
response = client.get(url, format="json", **make_user_auth_headers(user, token))
expected_payload = []
alert_receive_channel.refresh_from_db()
assert response.status_code == status.HTTP_200_OK
assert response.data == expected_payload

View file

@ -15,7 +15,6 @@ from .views.features import FeaturesAPIView
from .views.gitops import TerraformGitOpsView, TerraformStateView
from .views.integration_heartbeat import IntegrationHeartBeatView
from .views.live_setting import LiveSettingViewSet
from .views.maintenance import MaintenanceAPIView, MaintenanceStartAPIView, MaintenanceStopAPIView
from .views.on_call_shifts import OnCallShiftView
from .views.organization import (
CurrentOrganizationView,
@ -84,9 +83,6 @@ urlpatterns = [
),
optional_slash_path("terraform_file", TerraformGitOpsView.as_view(), name="terraform_file"),
optional_slash_path("terraform_imports", TerraformStateView.as_view(), name="terraform_imports"),
optional_slash_path("maintenance", MaintenanceAPIView.as_view(), name="maintenance"),
optional_slash_path("start_maintenance", MaintenanceStartAPIView.as_view(), name="start_maintenance"),
optional_slash_path("stop_maintenance", MaintenanceStopAPIView.as_view(), name="stop_maintenance"),
optional_slash_path("slack_settings", SlackTeamSettingsAPIView.as_view(), name="slack-settings"),
optional_slash_path(
"slack_settings/acknowledge_remind_options",

View file

@ -1,161 +0,0 @@
from rest_framework import status
from rest_framework.decorators import action
from rest_framework.permissions import IsAuthenticated
from rest_framework.response import Response
from rest_framework.views import APIView
from apps.alerts.models import AlertReceiveChannel
from apps.alerts.models.maintainable_object import MaintainableObject
from apps.api.permissions import RBACPermission
from apps.auth_token.auth import PluginAuthentication
from common.api_helpers.exceptions import BadRequest
from common.api_helpers.mixins import TeamFilteringMixin
from common.exceptions import MaintenanceCouldNotBeStartedError
class GetObjectMixin:
def get_object(self, request):
organization = request.auth.organization
type = request.data.get("type", None)
if type == "organization":
instance = organization
elif type == "alert_receive_channel":
pk = request.data.get("alert_receive_channel_id", None)
if pk is not None:
try:
instance = AlertReceiveChannel.objects.get(
public_primary_key=pk,
organization=organization,
)
if instance.team is not None and instance.team not in self.request.user.teams.all():
raise BadRequest(detail={"alert_receive_channel_id": ["unknown id"]})
except AlertReceiveChannel.DoesNotExist:
raise BadRequest(detail={"alert_receive_channel_id": ["unknown id"]})
else:
raise BadRequest(detail={"alert_receive_channel_id": ["id is required"]})
else:
raise BadRequest(detail={"type": ["Unknown type"]})
return instance
class MaintenanceAPIView(APIView, TeamFilteringMixin):
"""Deprecated. Maintenance management is now performed on integrations page (alert_receive_channel/ endpoint))"""
authentication_classes = (PluginAuthentication,)
permission_classes = (IsAuthenticated, RBACPermission)
rbac_permissions = {
"get": [RBACPermission.Permissions.MAINTENANCE_READ],
"filters": [RBACPermission.Permissions.MAINTENANCE_READ],
}
def get(self, request):
organization = self.request.auth.organization
response = []
integrations_under_maintenance = (
AlertReceiveChannel.objects.filter(
maintenance_mode__isnull=False, organization=organization, *self.available_teams_lookup_args
)
.distinct()
.order_by("maintenance_started_at")
)
if organization.maintenance_mode is not None:
response.append(
{
"organization_id": organization.public_primary_key,
"type": "organization",
"maintenance_mode": organization.maintenance_mode,
"maintenance_till_timestamp": organization.till_maintenance_timestamp,
"started_at_timestamp": organization.started_at_timestamp,
}
)
for i in integrations_under_maintenance:
response.append(
{
"alert_receive_channel_id": i.public_primary_key,
"type": "alert_receive_channel",
"maintenance_mode": i.maintenance_mode,
"maintenance_till_timestamp": i.till_maintenance_timestamp,
"started_at_timestamp": i.started_at_timestamp,
}
)
return Response(response, status=200)
@action(methods=["get"], detail=False)
def filters(self, request):
filter_name = request.query_params.get("search", None)
api_root = "/api/internal/v1/"
filter_options = [
{
"name": "team",
"type": "team_select",
"href": api_root + "teams/",
"global": True,
},
]
if filter_name is not None:
filter_options = list(filter(lambda f: filter_name in f["name"], filter_options))
return Response(filter_options)
class MaintenanceStartAPIView(GetObjectMixin, APIView):
"""Deprecated. Maintenance management is now performed on integrations page (alert_receive_channel/ endpoint))"""
authentication_classes = (PluginAuthentication,)
permission_classes = (IsAuthenticated, RBACPermission)
rbac_permissions = {
"post": [RBACPermission.Permissions.MAINTENANCE_WRITE],
}
def post(self, request):
mode = request.data.get("mode", None)
duration = request.data.get("duration", None)
try:
mode = int(mode)
except (ValueError, TypeError):
raise BadRequest(detail={"mode": ["Invalid mode"]})
if mode not in [MaintainableObject.DEBUG_MAINTENANCE, MaintainableObject.MAINTENANCE]:
raise BadRequest(detail={"mode": ["Unknown mode"]})
try:
duration = int(duration)
except (ValueError, TypeError):
raise BadRequest(detail={"duration": ["Invalid duration"]})
if duration not in MaintainableObject.maintenance_duration_options_in_seconds():
raise BadRequest(detail={"mode": ["Unknown duration"]})
instance = self.get_object(request)
try:
instance.start_maintenance(mode, duration, request.user)
except MaintenanceCouldNotBeStartedError as e:
if type(instance) == AlertReceiveChannel:
detail = {"alert_receive_channel_id": ["Already on maintenance"]}
else:
detail = str(e)
raise BadRequest(detail=detail)
return Response(status=status.HTTP_200_OK)
class MaintenanceStopAPIView(GetObjectMixin, APIView):
"""Deprecated. Maintenance management is now performed on integrations page (alert_receive_channel/ endpoint))"""
authentication_classes = (PluginAuthentication,)
permission_classes = (IsAuthenticated, RBACPermission)
rbac_permissions = {
"post": [RBACPermission.Permissions.MAINTENANCE_WRITE],
}
def post(self, request):
instance = self.get_object(request)
user = request.user
instance.force_disable_maintenance(user)
return Response(status=status.HTTP_200_OK)

View file

@ -2,17 +2,11 @@ from rest_framework import serializers
from apps.user_management.models import Organization
from .maintenance import MaintainableObjectSerializerMixin
class OrganizationSerializer(serializers.ModelSerializer, MaintainableObjectSerializerMixin):
class OrganizationSerializer(serializers.ModelSerializer):
id = serializers.ReadOnlyField(read_only=True, source="public_primary_key")
class Meta:
model = Organization
fields = MaintainableObjectSerializerMixin.Meta.fields + [
"id",
]
read_only_fields = MaintainableObjectSerializerMixin.Meta.fields + [
"id",
]
fields = ["id"]
read_only_fields = ["id"]

View file

@ -66,12 +66,7 @@ class AlertShootingStep(scenario_step.ScenarioStep):
AlertGroup.all_objects.filter(pk=alert.group.pk).update(slack_message_sent=False)
raise e
is_on_debug_mode = (
alert.group.channel.maintenance_mode == AlertReceiveChannel.DEBUG_MAINTENANCE
or alert.group.channel.organization.maintenance_mode == AlertReceiveChannel.DEBUG_MAINTENANCE
)
if is_on_debug_mode:
if alert.group.channel.maintenance_mode == AlertReceiveChannel.DEBUG_MAINTENANCE:
self._send_debug_mode_notice(alert.group, channel_id)
if alert.group.is_maintenance_incident:

View file

@ -11,8 +11,6 @@ from django.utils import timezone
from mirage import fields as mirage_fields
from apps.alerts.models import MaintainableObject
from apps.alerts.tasks import disable_maintenance
from apps.slack.utils import post_message_to_channel
from apps.user_management.subscription_strategy import FreePublicBetaSubscriptionStrategy
from common.insight_log import ChatOpsEvent, ChatOpsTypePlug, write_chatops_insight_log
from common.oncall_gateway import create_oncall_connector, delete_oncall_connector, delete_slack_connector
@ -75,6 +73,9 @@ class OrganizationManager(models.Manager):
return OrganizationQuerySet(self.model, using=self._db).filter(deleted_at__isnull=True)
# TODO: in a subsequent PR, remove the inheritance from MaintainableObject (plus generate the database migration file)
# this will remove the maintenance related columns that're no longer used on the organization object
# class Organization(models.Model):
class Organization(MaintainableObject):
auth_tokens: "RelatedManager['ApiAuthToken']"
mobile_app_auth_tokens: "RelatedManager['MobileAppAuthToken']"
@ -255,39 +256,6 @@ class Organization(MaintainableObject):
token_model = apps.get_model("auth_token", "PluginAuthToken")
token_model.objects.filter(organization=self).delete()
"""
Following methods: start_disable_maintenance_task, force_disable_maintenance, get_organization, get_verbal serve for
MaintainableObject.
"""
def start_disable_maintenance_task(self, countdown):
maintenance_uuid = disable_maintenance.apply_async(
args=(),
kwargs={
"organization_id": self.pk,
},
countdown=countdown,
)
return maintenance_uuid
def force_disable_maintenance(self, user):
disable_maintenance(organization_id=self.pk, force=True, user_id=user.pk)
def get_organization(self):
return self
def get_team(self):
return None
def get_verbal(self):
return self.org_title
def notify_about_maintenance_action(self, text, send_to_general_log_channel=True):
# TODO: this method should be refactored.
# It's binded to slack and sending maintenance notification only there.
if send_to_general_log_channel:
post_message_to_channel(self, self.general_log_channel_id, text)
"""
Following methods:
phone_calls_left, sms_left, emails_left

View file

@ -0,0 +1,144 @@
import { test, expect, Page, Locator } from '../fixtures';
import { verifyThatAlertGroupIsRoutedCorrectlyButNotEscalated } from '../utils/alertGroup';
import { EscalationStep, createEscalationChain } from '../utils/escalationChain';
import { clickButton, generateRandomValue, selectDropdownValue } from '../utils/forms';
import {
assignEscalationChainToIntegration,
createIntegration,
filterIntegrationsTableAndGoToDetailPage,
sendDemoAlert,
} from '../utils/integrations';
import { goToOnCallPage } from '../utils/navigation';
type MaintenanceModeType = 'Debug' | 'Maintenance';
test.describe('maintenance mode works', () => {
test.slow(); // this test is doing a good amount of work, give it time
const MAINTENANCE_DURATION = '1 hour';
const REMAINING_TIME_TEXT = '59m left';
const REMAINING_TIME_TOOLTIP_TEST_ID = 'maintenance-mode-remaining-time-tooltip';
const createRoutedText = (escalationChainName: string): string =>
`alert group assigned to route "default" with escalation chain "${escalationChainName}"`;
const _openIntegrationSettingsPopup = async (page: Page): Promise<Locator> => {
const integrationSettingsPopupElement = page.getByTestId('integration-settings-context-menu');
await integrationSettingsPopupElement.click();
return integrationSettingsPopupElement;
};
const getRemainingTimeTooltip = (page: Page): Locator => page.getByTestId(REMAINING_TIME_TOOLTIP_TEST_ID);
const enableMaintenanceMode = async (page: Page, mode: MaintenanceModeType): Promise<void> => {
const integrationSettingsPopupElement = await _openIntegrationSettingsPopup(page);
/**
* we need to click twice here, because adding the escalation chain route
* doesn't unfocus out of the select element after selecting an option
*/
await integrationSettingsPopupElement.click();
// open the maintenance mode settings drawer + fill in the maintenance details
await page.getByTestId('integration-start-maintenance').click();
// fill in the form
const maintenanceModeDrawer = page.getByTestId('maintenance-mode-drawer');
await selectDropdownValue({
page,
startingLocator: maintenanceModeDrawer,
selectType: 'grafanaSelect',
placeholderText: 'Choose mode',
value: mode,
optionExactMatch: false,
});
await selectDropdownValue({
page,
startingLocator: maintenanceModeDrawer,
selectType: 'grafanaSelect',
placeholderText: 'Choose duration',
value: MAINTENANCE_DURATION,
optionExactMatch: false,
});
await maintenanceModeDrawer.getByTestId('create-maintenance-button').click();
const maintenanceModeRemainingTimeTooltip = getRemainingTimeTooltip(page);
await maintenanceModeRemainingTimeTooltip.waitFor({ state: 'visible' });
expect(await page.getByTestId(`${REMAINING_TIME_TOOLTIP_TEST_ID}-text`).innerText()).toContain(REMAINING_TIME_TEXT);
};
const disableMaintenanceMode = async (page: Page, integrationName: string): Promise<void> => {
await goToOnCallPage(page, 'integrations');
await filterIntegrationsTableAndGoToDetailPage(page, integrationName);
await _openIntegrationSettingsPopup(page);
// click the stop maintenance button
await page.getByTestId('integration-stop-maintenance').click();
// in the modal popup, confirm that we want to stop it
await clickButton({
page,
buttonText: 'Stop',
startingLocator: page.getByRole('dialog'),
});
await getRemainingTimeTooltip(page).waitFor({ state: 'hidden' });
};
const createIntegrationAndEscalationChainAndEnableMaintenanceMode = async (
page: Page,
userName: string,
maintenanceModeType: MaintenanceModeType
): Promise<{
escalationChainName: string;
integrationName: string;
}> => {
const escalationChainName = generateRandomValue();
const integrationName = generateRandomValue();
await createEscalationChain(page, escalationChainName, EscalationStep.NotifyUsers, userName);
await createIntegration(page, integrationName);
await assignEscalationChainToIntegration(page, escalationChainName);
await enableMaintenanceMode(page, maintenanceModeType);
return { escalationChainName, integrationName };
};
test('debug mode', async ({ adminRolePage: { page, userName } }) => {
const { escalationChainName, integrationName } = await createIntegrationAndEscalationChainAndEnableMaintenanceMode(
page,
userName,
'Debug'
);
await sendDemoAlert(page);
await verifyThatAlertGroupIsRoutedCorrectlyButNotEscalated(
page,
integrationName,
createRoutedText(escalationChainName)
);
await disableMaintenanceMode(page, integrationName);
});
test('"maintenance" mode', async ({ adminRolePage: { page, userName } }) => {
const { integrationName } = await createIntegrationAndEscalationChainAndEnableMaintenanceMode(
page,
userName,
'Maintenance'
);
await sendDemoAlert(page);
// TODO: there seems to be a bug here where "maintenance" mode alert groups don't show up in the UI
// await verifyThatAlertGroupIsRoutedCorrectlyButNotEscalated(
// page,
// integrationName,
// createRoutedText(escalationChainName)
// );
await disableMaintenanceMode(page, integrationName);
});
});

View file

@ -1,10 +1,15 @@
import { Page, expect } from '@playwright/test';
import { Locator, Page, expect } from '@playwright/test';
import { selectDropdownValue, selectValuePickerValue } from './forms';
import { goToOnCallPage } from './navigation';
const MAX_RETRIES = 5;
const ALERT_GROUP_REGISTERED_TEXT = 'alert group registered';
// const sleep = async (seconds: number) => new Promise((resolve) => setTimeout(resolve, seconds * 1000));
const getIncidentTimelineList = async (page: Page): Promise<Locator> => {
const incidentTimelineList = page.getByTestId('incident-timeline-list');
await incidentTimelineList.waitFor({ state: 'visible' });
return incidentTimelineList;
};
/**
* recursively refreshes the page waiting for the background celery workers to have done their job of
@ -15,18 +20,28 @@ const incidentTimelineContainsStep = async (page: Page, triggeredStepText: strin
return Promise.resolve(false);
}
if (!page.getByTestId('incident-timeline-list').getByText(triggeredStepText)) {
const incidentTimelineList = await getIncidentTimelineList(page);
if (!incidentTimelineList.getByText(triggeredStepText)) {
await page.reload({ waitUntil: 'networkidle' });
return incidentTimelineContainsStep(page, triggeredStepText, (retryNum += 1));
}
return true;
};
export const verifyThatAlertGroupIsTriggered = async (
/**
* recursively refreshes the page waiting for the background celery workers to have done their job of
* creating the alert group
*/
export const filterAlertGroupsTableByIntegrationAndGoToDetailPage = async (
page: Page,
integrationName: string,
triggeredStepText: string
retryNum = 0
): Promise<void> => {
if (retryNum > MAX_RETRIES) {
throw new Error('we were not able to properly filter the alert groups table by integration');
}
await goToOnCallPage(page, 'incidents');
// filter by integration
@ -40,10 +55,48 @@ export const verifyThatAlertGroupIsTriggered = async (
await selectValuePickerValue(page, integrationName, false);
/**
* wait for the alert groups to be filtered then
* click on the alert group and go to the individual alert group page
* wait for the alert groups to be filtered then by this particular integration (toBeVisible assertion),
* then click on the alert group and go to the individual alert group page
*/
await (await page.waitForSelector('table > tbody > tr > td:nth-child(4) a')).click();
const firstTableRow = page.locator('table > tbody > tr:first-child');
try {
/**
* wait for up to 5 seconds for the alert groups to be filtered, if the first row does not correspond
* to `integrationName` assume that the background workers have not created it yet and lets
* recursively retry this function
*/
await firstTableRow.getByText(integrationName).waitFor({ state: 'visible', timeout: 5000 });
await firstTableRow.locator('td:nth-child(4) a').click();
} catch (err) {
return filterAlertGroupsTableByIntegrationAndGoToDetailPage(page, integrationName, (retryNum += 1));
}
};
export const verifyThatAlertGroupIsRoutedCorrectlyButNotEscalated = async (
page: Page,
integrationName: string,
routedText: string
): Promise<void> => {
await filterAlertGroupsTableByIntegrationAndGoToDetailPage(page, integrationName);
/**
* incidentTimelineContainsStep recursively reloads the alert group page until the engine
* background workers have processed/escalated the alert group
*/
expect(await incidentTimelineContainsStep(page, ALERT_GROUP_REGISTERED_TEXT)).toBe(true);
const incidentTimelineList = await getIncidentTimelineList(page);
expect(incidentTimelineList).toContainText(routedText);
expect(incidentTimelineList).not.toContainText('triggered step');
};
export const verifyThatAlertGroupIsTriggered = async (
page: Page,
integrationName: string,
triggeredStepText: string
): Promise<void> => {
await filterAlertGroupsTableByIntegrationAndGoToDetailPage(page, integrationName);
expect(await incidentTimelineContainsStep(page, triggeredStepText)).toBe(true);
};

View file

@ -13,6 +13,10 @@ type SelectDropdownValueArgs = {
startingLocator?: Locator;
// if true, when selecting the dropdown option, use an exact match, otherwise use a substring contains match
optionExactMatch?: boolean;
// if true, will press enter in the select dropdown. Some dropdowns don't show a list of options
// and instead the user must press enter to trigger the search
pressEnterInsteadOfSelectingOption?: boolean;
};
type ClickButtonArgs = {
@ -87,9 +91,16 @@ const chooseDropdownValue = async ({ page, value, optionExactMatch = true }: Sel
page.locator(`div[id^="react-select-"][id$="-listbox"] >> ${textMatchSelector(optionExactMatch, value)}`).click();
export const selectDropdownValue = async (args: SelectDropdownValueArgs): Promise<Locator> => {
const { page, value, pressEnterInsteadOfSelectingOption } = args;
const selectElement = await openSelect(args);
await selectElement.type(args.value);
await chooseDropdownValue(args);
await selectElement.type(value);
if (pressEnterInsteadOfSelectingOption) {
await page.keyboard.press('Enter');
} else {
await chooseDropdownValue(args);
}
return selectElement;
};

View file

@ -1,5 +1,5 @@
import { Page } from '@playwright/test';
import { clickButton } from './forms';
import { clickButton, selectDropdownValue } from './forms';
import { goToOnCallPage } from './navigation';
const CREATE_INTEGRATION_MODAL_TEST_ID_SELECTOR = 'div[data-testid="create-integration-modal"]';
@ -15,11 +15,7 @@ export const openCreateIntegrationModal = async (page: Page): Promise<void> => {
await page.waitForSelector(CREATE_INTEGRATION_MODAL_TEST_ID_SELECTOR);
};
export const createIntegrationAndSendDemoAlert = async (
page: Page,
integrationName: string,
_escalationChainName: string
): Promise<void> => {
export const createIntegration = async (page: Page, integrationName: string): Promise<void> => {
await openCreateIntegrationModal(page);
// create a webhook integration
@ -27,25 +23,56 @@ export const createIntegrationAndSendDemoAlert = async (
// fill in the required inputs
(await page.waitForSelector('input[name="verbal_name"]', { state: 'attached' })).fill(integrationName);
(await page.waitForSelector('textarea[name="description_short"]', { state: 'attached' })).fill("Here goes your integration description");
(await page.waitForSelector('textarea[name="description_short"]', { state: 'attached' })).fill(
'Here goes your integration description'
);
const grafanaUpdateBtn = page.getByTestId("update-integration-button");
const grafanaUpdateBtn = page.getByTestId('update-integration-button');
await grafanaUpdateBtn.click();
/*
* TODO: This is slightly more complicated now, change this in next iteration */
// const integrationSettingsElement = page.getByTestId('integration-settings');
// // assign the escalation chain to the integration
// await selectDropdownValue({
// page,
// selectType: 'grafanaSelect',
// placeholderText: 'Select Escalation Chain',
// value: escalationChainName,
// startingLocator: integrationSettingsElement,
// });
// send demo alert
await clickButton({ page, buttonText: 'Send demo alert', dataTestId: 'send-demo-alert' });
await clickButton({ page, buttonText: 'Send Alert', dataTestId: "submit-send-alert" })
};
export const assignEscalationChainToIntegration = async (page: Page, escalationChainName: string): Promise<void> => {
await page.getByTestId('integration-escalation-chain-not-selected').click();
// assign the escalation chain to the integration
await selectDropdownValue({
page,
selectType: 'grafanaSelect',
placeholderText: 'Select Escalation Chain',
value: escalationChainName,
startingLocator: page.getByTestId('integration-block-item'),
});
};
export const sendDemoAlert = async (page: Page): Promise<void> => {
await clickButton({ page, buttonText: 'Send demo alert', dataTestId: 'send-demo-alert' });
await clickButton({ page, buttonText: 'Send Alert', dataTestId: 'submit-send-alert' });
await page.getByTestId('demo-alert-sent-notification').waitFor({ state: 'visible' });
};
export const createIntegrationAndSendDemoAlert = async (
page: Page,
integrationName: string,
escalationChainName: string
): Promise<void> => {
await createIntegration(page, integrationName);
await assignEscalationChainToIntegration(page, escalationChainName);
await sendDemoAlert(page);
};
export const filterIntegrationsTableAndGoToDetailPage = async (page: Page, integrationName: string): Promise<void> => {
// filter the integrations page by the integration in question, then go to its detail page
await selectDropdownValue({
page,
selectType: 'grafanaSelect',
placeholderText: 'Search or filter results...',
value: integrationName,
pressEnterInsteadOfSelectingOption: true,
});
await (
await page.waitForSelector(
`div[data-testid="integrations-table"] table > tbody > tr > td:first-child a >> text=${integrationName}`
)
).click();
};

View file

@ -18,6 +18,10 @@ export const ADMIN_USER_STORAGE_STATE = path.join(__dirname, 'integration-tests/
*/
const config: PlaywrightTestConfig = {
testDir: './integration-tests',
/* Maximum time all the tests can run for. */
globalTimeout: 20 * 60 * 1000, // 20 minutes
/* Maximum time one test can run for. */
timeout: 60 * 1000,
expect: {

View file

@ -12,7 +12,7 @@ interface IntegrationBlockItemProps {
const IntegrationBlockItem: React.FC<IntegrationBlockItemProps> = (props) => {
return (
<div className={cx('blockItem')}>
<div className={cx('blockItem')} data-testid="integration-block-item">
<div className={cx('blockItem__leftDelimitator')} />
<div className={cx('blockItem__content')}>{props.children}</div>
</div>

View file

@ -24,7 +24,10 @@ interface TooltipBadgeProps {
const cx = cn.bind(styles);
const TooltipBadge: FC<TooltipBadgeProps> = (props) => {
const { borderType, text, tooltipTitle, tooltipContent, onHover, addPadding, icon, customIcon, className } = props;
const { borderType, text, tooltipTitle, tooltipContent, onHover, addPadding, icon, customIcon, className, ...rest } =
props;
const testId = rest['data-testid'];
return (
<Tooltip
@ -48,10 +51,18 @@ const TooltipBadge: FC<TooltipBadgeProps> = (props) => {
className
)}
onMouseEnter={onHover}
{...(testId ? { 'data-testid': testId } : {})}
>
<HorizontalGroup spacing="xs">
{renderIcon()}
{text && <Text className={cx('element__text', { [`element__text--${borderType}`]: true })}>{text}</Text>}
{text && (
<Text
className={cx('element__text', { [`element__text--${borderType}`]: true })}
{...(testId ? { 'data-testid': `${testId}-text` } : {})}
>
{text}
</Text>
)}
</HorizontalGroup>
</div>
</Tooltip>

View file

@ -16,6 +16,7 @@ export const WithContextMenu: React.FC<WithContextMenuProps> = ({
renderMenuItems,
forceIsOpen = false,
focusOnOpen = true,
...rest
}) => {
const [isMenuOpen, setIsMenuOpen] = useState(false || forceIsOpen);
const [menuPosition, setMenuPosition] = useState({ x: 0, y: 0 });
@ -36,7 +37,7 @@ export const WithContextMenu: React.FC<WithContextMenuProps> = ({
}, []);
return (
<>
<div {...rest}>
{children({
openMenu: (e) => {
setIsMenuOpen(true);
@ -56,6 +57,6 @@ export const WithContextMenu: React.FC<WithContextMenuProps> = ({
focusOnOpen={focusOnOpen}
/>
)}
</>
</div>
);
};

View file

@ -145,7 +145,9 @@ const CollapsedIntegrationRouteDisplay: React.FC<CollapsedIntegrationRouteDispla
<div className={cx('icon-exclamation')}>
<Icon name="exclamation-triangle" />
</div>
<Text type="primary">Not selected</Text>
<Text type="primary" data-testid="integration-escalation-chain-not-selected">
Not selected
</Text>
</div>
)}
</div>

View file

@ -4,7 +4,7 @@ import { SelectableValue } from '@grafana/data';
import Emoji from 'react-emoji-render';
import { FormItem, FormItemType } from 'components/GForm/GForm.types';
import { MaintenanceMode } from 'models/maintenance/maintenance.types';
import { MaintenanceMode } from 'models/alert_receive_channel/alert_receive_channel.types';
export const form: { name: string; fields: FormItem[] } = {
name: 'Maintenance',
@ -31,6 +31,7 @@ export const form: { name: string; fields: FormItem[] } = {
validation: { required: true },
normalize: (value) => value,
extra: {
placeholder: 'Choose mode',
options: [
{
value: MaintenanceMode.Debug,
@ -50,6 +51,7 @@ export const form: { name: string; fields: FormItem[] } = {
type: FormItemType.Select,
validation: { required: true },
extra: {
placeholder: 'Choose duration',
options: [
{
value: 3600,

View file

@ -8,7 +8,6 @@ import { observer } from 'mobx-react';
import GForm from 'components/GForm/GForm';
import { WithPermissionControlTooltip } from 'containers/WithPermissionControl/WithPermissionControlTooltip';
import { AlertReceiveChannel } from 'models/alert_receive_channel/alert_receive_channel.types';
import { MaintenanceType } from 'models/maintenance/maintenance.types';
import { useStore } from 'state/useStore';
import { openNotification, showApiError } from 'utils';
import { UserActions } from 'utils/authorization';
@ -21,7 +20,6 @@ const cx = cn.bind(styles);
interface MaintenanceFormProps {
initialData: {
type?: MaintenanceType;
alert_receive_channel_id?: AlertReceiveChannel['id'];
disabled?: boolean;
};
@ -35,23 +33,22 @@ const MaintenanceForm = observer((props: MaintenanceFormProps) => {
const store = useStore();
const { maintenanceStore } = store;
const { alertReceiveChannelStore } = store;
const handleSubmit = useCallback((data) => {
maintenanceStore
.startMaintenanceMode(
MaintenanceType.alert_receive_channel,
const handleSubmit = useCallback(async (data) => {
try {
await alertReceiveChannelStore.startMaintenanceMode(
initialData.alert_receive_channel_id,
data.mode,
data.duration,
data.alert_receive_channel_id
)
.then(() => {
onHide();
onUpdate();
data.duration
);
openNotification('Maintenance has been started');
})
.catch(showApiError);
onHide();
onUpdate();
openNotification('Maintenance has been started');
} catch (err) {
showApiError(err);
}
}, []);
if (initialData.disabled) {
@ -65,7 +62,7 @@ const MaintenanceForm = observer((props: MaintenanceFormProps) => {
return (
<Drawer width="640px" scrollableContent title="Start Maintenance Mode" onClose={onHide} closeOnMaskClick={false}>
<div className={cx('content')}>
<div className={cx('content')} data-testid="maintenance-mode-drawer">
<VerticalGroup>
Start maintenance mode when performing scheduled maintenance or updates on the infrastructure, which may
trigger false alarms.
@ -75,7 +72,7 @@ const MaintenanceForm = observer((props: MaintenanceFormProps) => {
Cancel
</Button>
<WithPermissionControlTooltip userAction={UserActions.MaintenanceWrite}>
<Button form={form.name} type="submit">
<Button form={form.name} type="submit" data-testid="create-maintenance-button">
Start
</Button>
</WithPermissionControlTooltip>

View file

@ -20,6 +20,7 @@ import {
AlertReceiveChannel,
AlertReceiveChannelOption,
AlertReceiveChannelCounters,
MaintenanceMode,
} from './alert_receive_channel.types';
export class AlertReceiveChannelStore extends BaseStore {
@ -456,4 +457,18 @@ export class AlertReceiveChannelStore extends BaseStore {
this.counters = counters;
}
startMaintenanceMode = (id: AlertReceiveChannel['id'], mode: MaintenanceMode, duration: number): Promise<void> =>
makeRequest<null>(`${this.path}${id}/start_maintenance/`, {
method: 'POST',
data: {
mode,
duration,
},
});
stopMaintenanceMode = (id: AlertReceiveChannel['id']) =>
makeRequest<null>(`${this.path}${id}/stop_maintenance/`, {
method: 'POST',
});
}

View file

@ -1,55 +0,0 @@
import { action, observable } from 'mobx';
import { AlertReceiveChannel } from 'models/alert_receive_channel/alert_receive_channel.types';
import BaseStore from 'models/base_store';
import { makeRequest } from 'network';
import { RootStore } from 'state';
import { Maintenance, MaintenanceMode, MaintenanceType } from './maintenance.types';
export class MaintenanceStore extends BaseStore {
@observable.shallow
maintenances?: Maintenance[];
constructor(rootStore: RootStore) {
super(rootStore);
this.path = '/maintenance/';
}
@action
async updateMaintenances() {
this.maintenances = await this.getAll();
}
@action
async startMaintenanceMode(
type: MaintenanceType,
mode: MaintenanceMode,
duration: number,
alertReceiveChannelId?: AlertReceiveChannel['id']
) {
return await makeRequest(`/start_maintenance/`, {
method: 'POST',
data: {
type,
mode,
duration,
alert_receive_channel_id: alertReceiveChannelId,
},
withCredentials: true,
});
}
@action
async stopMaintenanceMode(type: MaintenanceType, alertReceiveChannelId: AlertReceiveChannel['id']) {
return await makeRequest(`/stop_maintenance/`, {
method: 'POST',
data: {
type,
alert_receive_channel_id: alertReceiveChannelId,
},
withCredentials: true,
});
}
}

View file

@ -1,19 +0,0 @@
import { AlertReceiveChannel } from 'models/alert_receive_channel/alert_receive_channel.types';
export enum MaintenanceType {
alert_receive_channel = 'alert_receive_channel',
organization = 'organization',
}
export enum MaintenanceMode {
Debug,
Maintenance,
}
export interface Maintenance {
alert_receive_channel_id: AlertReceiveChannel['id'];
type: MaintenanceType;
maintenance_mode: MaintenanceMode;
maintenance_till_timestamp: number;
started_at_timestamp: number;
}

View file

@ -58,7 +58,6 @@ import {
} from 'models/alert_receive_channel/alert_receive_channel.types';
import { AlertTemplatesDTO } from 'models/alert_templates';
import { ChannelFilter } from 'models/channel_filter';
import { MaintenanceType } from 'models/maintenance/maintenance.types';
import { INTEGRATION_TEMPLATES_LIST } from 'pages/integration/Integration.config';
import IntegrationHelper from 'pages/integration/Integration.helper';
import styles from 'pages/integration/Integration.module.scss';
@ -606,7 +605,7 @@ class Integration extends React.Component<IntegrationProps, IntegrationState> {
const DemoNotification: React.FC = () => {
return (
<div>
<div data-testid="demo-alert-sent-notification">
Demo alert was generated. Find it on the
<PluginLink query={{ page: 'alert-groups' }}> "Alert Groups" </PluginLink>
page and make sure it didn't freak out your colleagues 😉
@ -727,7 +726,7 @@ const IntegrationActions: React.FC<IntegrationActionsProps> = ({
alertReceiveChannel,
changeIsTemplateSettingsOpen,
}) => {
const { maintenanceStore, alertReceiveChannelStore, heartbeatStore } = useStore();
const { alertReceiveChannelStore, heartbeatStore } = useStore();
const history = useHistory();
@ -815,6 +814,7 @@ const IntegrationActions: React.FC<IntegrationActionsProps> = ({
</WithPermissionControlTooltip>
<WithContextMenu
data-testid="integration-settings-context-menu"
renderMenuItems={() => (
<div className={cx('integration__actionsList')} id="integration-menu-options">
<div className={cx('integration__actionItem')} onClick={() => openIntegrationSettings()}>
@ -831,7 +831,11 @@ const IntegrationActions: React.FC<IntegrationActionsProps> = ({
{!alertReceiveChannel.maintenance_till && (
<WithPermissionControlTooltip userAction={UserActions.MaintenanceWrite}>
<div className={cx('integration__actionItem')} onClick={openStartMaintenance}>
<div
className={cx('integration__actionItem')}
onClick={openStartMaintenance}
data-testid="integration-start-maintenance"
>
<Text type="primary">Start Maintenance</Text>
</div>
</WithPermissionControlTooltip>
@ -862,6 +866,7 @@ const IntegrationActions: React.FC<IntegrationActionsProps> = ({
),
});
}}
data-testid="integration-stop-maintenance"
>
<Text type="primary">Stop Maintenance</Text>
</div>
@ -941,14 +946,13 @@ const IntegrationActions: React.FC<IntegrationActionsProps> = ({
setMaintenanceData({ disabled: true, alert_receive_channel_id: alertReceiveChannel.id });
}
function onStopMaintenance() {
async function onStopMaintenance() {
setConfirmModal(undefined);
maintenanceStore
.stopMaintenanceMode(MaintenanceType.alert_receive_channel, id)
.then(() => maintenanceStore.updateMaintenances())
.then(() => openNotification('Maintenance has been stopped'))
.then(() => alertReceiveChannelStore.updateItem(alertReceiveChannel.id));
await alertReceiveChannelStore.stopMaintenanceMode(id);
openNotification('Maintenance has been stopped');
await alertReceiveChannelStore.updateItem(id);
}
};
@ -1063,6 +1067,7 @@ const IntegrationHeader: React.FC<IntegrationHeaderProps> = ({
{alertReceiveChannel.maintenance_till && (
<TooltipBadge
data-testid="maintenance-mode-remaining-time-tooltip"
borderType="primary"
icon="pause"
text={IntegrationHelper.getMaintenanceText(alertReceiveChannel.maintenance_till)}

View file

@ -208,6 +208,7 @@ class Integrations extends React.Component<IntegrationsProps, IntegrationsState>
/>
<GTable
emptyText={this.renderNotFound()}
data-testid="integrations-table"
rowKey="id"
data={results}
columns={columns}

View file

@ -18,7 +18,6 @@ import { FiltersStore } from 'models/filters/filters';
import { GlobalSettingStore } from 'models/global_setting/global_setting';
import { GrafanaTeamStore } from 'models/grafana_team/grafana_team';
import { HeartbeatStore } from 'models/heartbeat/heartbeat';
import { MaintenanceStore } from 'models/maintenance/maintenance';
import { OrganizationLogStore } from 'models/organization_log/organization_log';
import { OutgoingWebhookStore } from 'models/outgoing_webhook/outgoing_webhook';
import { OutgoingWebhook2Store } from 'models/outgoing_webhook_2/outgoing_webhook_2';
@ -98,7 +97,6 @@ export class RootBaseStore {
slackStore: SlackStore = new SlackStore(this);
slackChannelStore: SlackChannelStore = new SlackChannelStore(this);
heartbeatStore: HeartbeatStore = new HeartbeatStore(this);
maintenanceStore: MaintenanceStore = new MaintenanceStore(this);
scheduleStore: ScheduleStore = new ScheduleStore(this);
userGroupStore: UserGroupStore = new UserGroupStore(this);
alertGroupStore: AlertGroupStore = new AlertGroupStore(this);

View file

@ -6,6 +6,8 @@ import appEvents from 'grafana/app/core/app_events';
import { isArray, concat, isPlainObject, flatMap, map, keys } from 'lodash-es';
import qs from 'query-string';
import { isNetworkError } from 'network';
export class KeyValuePair<T = string | number> {
key: T;
value: string;
@ -23,7 +25,7 @@ export const getTzOffsetHours = (): number => {
};
export function showApiError(error: any) {
if (error.response.status >= 400 && error.response.status < 500) {
if (isNetworkError(error) && error.response && error.response.status >= 400 && error.response.status < 500) {
const payload = error.response.data;
const text =
typeof payload === 'string'
@ -38,7 +40,7 @@ export function showApiError(error: any) {
}
export function refreshPageError(error: AxiosError) {
if (error.response?.status === 502) {
if (isNetworkError(error) && error.response?.status === 502) {
const payload = error.response.data;
const text = `Try to refresh the page. ${payload}`;
openErrorNotification(text);
@ -48,7 +50,7 @@ export function refreshPageError(error: AxiosError) {
}
export function throttlingError(error: AxiosError) {
if (error.response?.status === 429) {
if (isNetworkError(error) && error.response?.status === 429) {
const seconds = Number(error.response?.headers['retry-after']);
const minutes = Math.floor(seconds / 60);
const text =