From 9be8080e512aa925a8f142e0af611c625b1844a1 Mon Sep 17 00:00:00 2001 From: Joey Orlando Date: Fri, 12 May 2023 11:44:09 -0400 Subject: [PATCH] add the ability to set/display "currently undergoing maintenance message" in the UI (#1917) # What this PR does add a new endpoint, `GET /maintenance-mode/`, which returns either a string message pulled from the `CURRENTLY_UNDERGOING_MAINTENANCE_MESSAGE` env var, or `None` + update the UI to conditionally show this message if it is set Screenshot 2023-05-10 at 11 28 16 ## Checklist - [x] Unit, integration, and e2e (if applicable) tests updated - [x] Documentation added (or `pr:no public docs` PR label added if not required) (N/A) - [x] `CHANGELOG.md` updated (or `pr:no changelog` PR label added if not required) --- CHANGELOG.md | 6 ++++ engine/conftest.py | 20 +++++++------ engine/engine/celery.py | 3 -- engine/engine/tests/test_maintenance_mode.py | 18 ++++++++++++ engine/engine/urls.py | 11 ++++++-- engine/engine/views.py | 12 +++++++- engine/settings/base.py | 2 ++ .../plugin/PluginSetup/PluginSetup.test.tsx | 8 ++++++ .../__snapshots__/PluginSetup.test.tsx.snap | 18 ++++++++++++ .../src/plugin/PluginSetup/index.tsx | 22 ++++++++------- grafana-plugin/src/state/plugin/index.ts | 11 ++++++++ .../src/state/plugin/plugin.test.ts | 19 ++++++++++++- .../src/state/rootBaseStore/index.ts | 10 +++++++ .../state/rootBaseStore/rootBaseStore.test.ts | 28 +++++++++++++++++++ 14 files changed, 162 insertions(+), 26 deletions(-) create mode 100644 engine/engine/tests/test_maintenance_mode.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 4a08d17e..c68a1bde 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,12 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## Unreleased + +### Added + +- Add a way to set a maintenance mode message and display this in the web plugin UI by @joeyorlando ([#1917](https://github.com/grafana/oncall/pull/#1917)) + ## v1.2.22 (2023-05-12) ### Added diff --git a/engine/conftest.py b/engine/conftest.py index 09046897..af9559c4 100644 --- a/engine/conftest.py +++ b/engine/conftest.py @@ -801,23 +801,27 @@ def make_integration_heartbeat(): return _make_integration_heartbeat +@pytest.fixture def reload_urls(settings): """ Reloads Django URLs, especially useful when testing conditionally registered URLs """ - clear_url_caches() - urlconf = settings.ROOT_URLCONF - if urlconf in sys.modules: - reload(sys.modules[urlconf]) - else: - import_module(urlconf) + def _reload_urls(): + clear_url_caches() + urlconf = settings.ROOT_URLCONF + if urlconf in sys.modules: + reload(sys.modules[urlconf]) + else: + import_module(urlconf) + + return _reload_urls @pytest.fixture() -def load_slack_urls(settings): +def load_slack_urls(settings, reload_urls): settings.FEATURE_SLACK_INTEGRATION_ENABLED = True - reload_urls(settings) + reload_urls() @pytest.fixture diff --git a/engine/engine/celery.py b/engine/engine/celery.py index c5b8b224..c78459d5 100644 --- a/engine/engine/celery.py +++ b/engine/engine/celery.py @@ -16,12 +16,9 @@ from opentelemetry.sdk.trace.export import BatchSpanProcessor os.environ.setdefault("DJANGO_SETTINGS_MODULE", "settings.prod") -from django.db import connection # noqa: E402 - logger = get_task_logger(__name__) logger.setLevel(logging.DEBUG) -connection.cursor() from celery import Celery # noqa: E402 app = Celery("proj") diff --git a/engine/engine/tests/test_maintenance_mode.py b/engine/engine/tests/test_maintenance_mode.py new file mode 100644 index 00000000..c38f2f68 --- /dev/null +++ b/engine/engine/tests/test_maintenance_mode.py @@ -0,0 +1,18 @@ +import pytest +from django.test import override_settings +from rest_framework import status +from rest_framework.test import APIClient + +MAINTENANCE_MODE_MSG = "asdfasdfasdf" + + +@pytest.mark.parametrize("setting_value", [MAINTENANCE_MODE_MSG, None]) +@override_settings() +def test_get_maintenance_mode_status(setting_value): + client = APIClient() + + with override_settings(CURRENTLY_UNDERGOING_MAINTENANCE_MESSAGE=setting_value): + response = client.get("/api/internal/v1/maintenance-mode-status", format="json") + + assert response.status_code == status.HTTP_200_OK + assert response.json()["currently_undergoing_maintenance_message"] == setting_value diff --git a/engine/engine/urls.py b/engine/engine/urls.py index be50be31..3cb8073a 100644 --- a/engine/engine/urls.py +++ b/engine/engine/urls.py @@ -18,13 +18,19 @@ from django.conf.urls.static import static from django.contrib import admin from django.urls import include, path -from .views import HealthCheckView, ReadinessCheckView, StartupProbeView +from .views import HealthCheckView, MaintenanceModeStatusView, ReadinessCheckView, StartupProbeView -urlpatterns = [ +paths_to_work_even_when_maintenance_mode_is_active = [ path("", HealthCheckView.as_view()), path("health/", HealthCheckView.as_view()), path("ready/", ReadinessCheckView.as_view()), path("startupprobe/", StartupProbeView.as_view()), + path("integrations/v1/", include("apps.integrations.urls", namespace="integrations")), + path("api/internal/v1/maintenance-mode-status", MaintenanceModeStatusView.as_view()), +] + +urlpatterns = [ + *paths_to_work_even_when_maintenance_mode_is_active, # path('slow/', SlowView.as_view()), # path('exception/', ExceptionView.as_view()), path(settings.ONCALL_DJANGO_ADMIN_PATH, admin.site.urls), @@ -32,7 +38,6 @@ urlpatterns = [ path("api/internal/v1/", include("apps.api.urls", namespace="api-internal")), path("api/internal/v1/", include("social_django.urls", namespace="social")), path("api/internal/v1/plugin/", include("apps.grafana_plugin.urls", namespace="grafana-plugin")), - path("integrations/v1/", include("apps.integrations.urls", namespace="integrations")), path("twilioapp/", include("apps.twilioapp.urls")), path("api/v1/", include("apps.public_api.urls", namespace="api-public")), path("mobile_app/v1/", include("apps.mobile_app.urls", namespace="mobile_app")), diff --git a/engine/engine/views.py b/engine/engine/views.py index 045c245e..410bf048 100644 --- a/engine/engine/views.py +++ b/engine/engine/views.py @@ -1,7 +1,8 @@ import time +from django.conf import settings from django.core.cache import cache -from django.http import HttpResponse +from django.http import HttpResponse, JsonResponse from django.views.generic import View from apps.integrations.mixins import AlertChannelDefiningMixin @@ -68,3 +69,12 @@ class SlowView(View): class ExceptionView(View): def get(self, request): raise Exception("Trying exception!") + + +class MaintenanceModeStatusView(View): + def get(self, _request): + return JsonResponse( + { + "currently_undergoing_maintenance_message": settings.CURRENTLY_UNDERGOING_MAINTENANCE_MESSAGE, + } + ) diff --git a/engine/settings/base.py b/engine/settings/base.py index 0ab59af2..c7c00f39 100644 --- a/engine/settings/base.py +++ b/engine/settings/base.py @@ -14,6 +14,8 @@ OPEN_SOURCE_LICENSE_NAME = "OpenSource" CLOUD_LICENSE_NAME = "Cloud" LICENSE = os.environ.get("ONCALL_LICENSE", default=OPEN_SOURCE_LICENSE_NAME) IS_OPEN_SOURCE = LICENSE == OPEN_SOURCE_LICENSE_NAME +CURRENTLY_UNDERGOING_MAINTENANCE_MESSAGE = os.environ.get("CURRENTLY_UNDERGOING_MAINTENANCE_MESSAGE", None) +IS_IN_MAINTENANCE_MODE = CURRENTLY_UNDERGOING_MAINTENANCE_MESSAGE is not None DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField" diff --git a/grafana-plugin/src/plugin/PluginSetup/PluginSetup.test.tsx b/grafana-plugin/src/plugin/PluginSetup/PluginSetup.test.tsx index a51b1e79..7e75a5b9 100644 --- a/grafana-plugin/src/plugin/PluginSetup/PluginSetup.test.tsx +++ b/grafana-plugin/src/plugin/PluginSetup/PluginSetup.test.tsx @@ -93,6 +93,14 @@ describe('PluginSetup', () => { expect(mockedSetupPlugin).toHaveBeenCalledTimes(2); }); + test('currently undergoing maintenance', async () => { + const rootBaseStore = new RootBaseStore(); + rootBaseStore.appLoading = false; + rootBaseStore.currentlyUndergoingMaintenance = true; + rootBaseStore.initializationError = 'there is some sort of maintenance'; + await createComponentAndMakeAssertions(rootBaseStore); + }); + test('app successfully initialized', async () => { const rootBaseStore = new RootBaseStore(); rootBaseStore.appLoading = false; diff --git a/grafana-plugin/src/plugin/PluginSetup/__snapshots__/PluginSetup.test.tsx.snap b/grafana-plugin/src/plugin/PluginSetup/__snapshots__/PluginSetup.test.tsx.snap index 05b875fa..04eb5aa2 100644 --- a/grafana-plugin/src/plugin/PluginSetup/__snapshots__/PluginSetup.test.tsx.snap +++ b/grafana-plugin/src/plugin/PluginSetup/__snapshots__/PluginSetup.test.tsx.snap @@ -62,6 +62,24 @@ exports[`PluginSetup app successfully initialized 1`] = ` `; +exports[`PluginSetup currently undergoing maintenance 1`] = ` +
+
+ Grafana OnCall Logo +
+ there is some sort of maintenance +
+
+
+`; + exports[`PluginSetup there is an error message - retry setup 1`] = `
= observer(({ InitializedComponent, ...p if (store.initializationError) { return ( -
- - - - Configure Plugin - - -
+ {!store.currentlyUndergoingMaintenance && ( +
+ + + + Configure Plugin + + +
+ )}
); } diff --git a/grafana-plugin/src/state/plugin/index.ts b/grafana-plugin/src/state/plugin/index.ts index bc2fd180..26d0f740 100644 --- a/grafana-plugin/src/state/plugin/index.ts +++ b/grafana-plugin/src/state/plugin/index.ts @@ -25,6 +25,10 @@ type PluginConnectedStatusResponse = PluginStatusResponseBase & { is_user_anonymous: boolean; }; +type PluginIsInMaintenanceModeResponse = { + currently_undergoing_maintenance_message: string; +}; + type CloudProvisioningConfigResponse = null; type SelfHostedProvisioningConfigResponse = Omit & { @@ -296,6 +300,13 @@ class PluginState { return null; }; + static checkIfBackendIsInMaintenanceMode = async (): Promise => { + const response = await makeRequest('/maintenance-mode-status', { + method: 'GET', + }); + return response.currently_undergoing_maintenance_message; + }; + static checkIfPluginIsConnected = async ( onCallApiUrl: string, onCallApiUrlIsConfiguredThroughEnvVar = false diff --git a/grafana-plugin/src/state/plugin/plugin.test.ts b/grafana-plugin/src/state/plugin/plugin.test.ts index 1c215b75..7b67fad0 100644 --- a/grafana-plugin/src/state/plugin/plugin.test.ts +++ b/grafana-plugin/src/state/plugin/plugin.test.ts @@ -1,6 +1,6 @@ import { makeRequest as makeRequestOriginal, isNetworkError as isNetworkErrorOriginal } from 'network'; -import PluginState, { InstallationVerb, PluginSyncStatusResponse, UpdateGrafanaPluginSettingsProps } from './'; +import PluginState, { InstallationVerb, PluginSyncStatusResponse, UpdateGrafanaPluginSettingsProps } from '.'; const makeRequest = makeRequestOriginal as jest.Mock>; const isNetworkError = isNetworkErrorOriginal as unknown as jest.Mock>; @@ -658,6 +658,23 @@ describe('PluginState.selfHostedInstallPlugin', () => { }); }); +describe('PluginState.checkIfBackendIsInMaintenanceMode', () => { + test('it returns the API response', async () => { + // mocks + const maintenanceModeMsg = 'asdfljkadsjlfkajsdf'; + const mockedResp = { currently_undergoing_maintenance_message: maintenanceModeMsg }; + makeRequest.mockResolvedValueOnce(mockedResp); + + // test + const response = await PluginState.checkIfBackendIsInMaintenanceMode(); + + // assertions + expect(response).toEqual(maintenanceModeMsg); + expect(makeRequest).toHaveBeenCalledTimes(1); + expect(makeRequest).toHaveBeenCalledWith('/maintenance-mode-status', { method: 'GET' }); + }); +}); + describe('PluginState.checkIfPluginIsConnected', () => { test('it returns the API response', async () => { // mocks diff --git a/grafana-plugin/src/state/rootBaseStore/index.ts b/grafana-plugin/src/state/rootBaseStore/index.ts index 6a4ed648..b8019992 100644 --- a/grafana-plugin/src/state/rootBaseStore/index.ts +++ b/grafana-plugin/src/state/rootBaseStore/index.ts @@ -56,6 +56,9 @@ export class RootBaseStore { @observable initializationError = null; + @observable + currentlyUndergoingMaintenance = false; + @observable isMobile = false; @@ -159,6 +162,12 @@ export class RootBaseStore { return this.setupPluginError('🚫 Plugin has not been initialized'); } + const isInMaintenanceMode = await PluginState.checkIfBackendIsInMaintenanceMode(); + if (isInMaintenanceMode !== null) { + this.currentlyUndergoingMaintenance = true; + return this.setupPluginError(`🚧 ${isInMaintenanceMode} 🚧`); + } + // at this point we know the plugin is provionsed const pluginConnectionStatus = await PluginState.checkIfPluginIsConnected(this.onCallApiUrl); if (typeof pluginConnectionStatus === 'string') { @@ -166,6 +175,7 @@ export class RootBaseStore { } const { allow_signup, is_installed, is_user_anonymous, token_ok } = pluginConnectionStatus; + if (is_user_anonymous) { return this.setupPluginError( '😞 Unfortunately Grafana OnCall is available for authorized users only, please sign in to proceed.' diff --git a/grafana-plugin/src/state/rootBaseStore/rootBaseStore.test.ts b/grafana-plugin/src/state/rootBaseStore/rootBaseStore.test.ts index 2c726d54..ee8a4351 100644 --- a/grafana-plugin/src/state/rootBaseStore/rootBaseStore.test.ts +++ b/grafana-plugin/src/state/rootBaseStore/rootBaseStore.test.ts @@ -42,6 +42,7 @@ describe('rootBaseStore', () => { const onCallApiUrl = 'http://asdfasdf.com'; const rootBaseStore = new RootBaseStore(); + PluginState.checkIfBackendIsInMaintenanceMode = jest.fn().mockResolvedValueOnce(null); PluginState.checkIfPluginIsConnected = jest.fn().mockResolvedValueOnce(errorMsg); // test @@ -55,11 +56,32 @@ describe('rootBaseStore', () => { expect(rootBaseStore.initializationError).toEqual(errorMsg); }); + test('currently undergoing maintenance', async () => { + // mocks/setup + const onCallApiUrl = 'http://asdfasdf.com'; + const rootBaseStore = new RootBaseStore(); + const maintenanceMessage = 'mncvnmvcmnvkjdjkd'; + + PluginState.checkIfBackendIsInMaintenanceMode = jest.fn().mockResolvedValueOnce(maintenanceMessage); + + // test + await rootBaseStore.setupPlugin(generatePluginData(onCallApiUrl)); + + // assertions + expect(PluginState.checkIfBackendIsInMaintenanceMode).toHaveBeenCalledTimes(1); + expect(PluginState.checkIfBackendIsInMaintenanceMode).toHaveBeenCalledWith(); + + expect(rootBaseStore.appLoading).toBe(false); + expect(rootBaseStore.initializationError).toEqual(`🚧 ${maintenanceMessage} 🚧`); + expect(rootBaseStore.currentlyUndergoingMaintenance).toBe(true); + }); + test('anonymous user', async () => { // mocks/setup const onCallApiUrl = 'http://asdfasdf.com'; const rootBaseStore = new RootBaseStore(); + PluginState.checkIfBackendIsInMaintenanceMode = jest.fn().mockResolvedValueOnce(null); PluginState.checkIfPluginIsConnected = jest.fn().mockResolvedValueOnce({ is_user_anonymous: true, is_installed: true, @@ -87,6 +109,7 @@ describe('rootBaseStore', () => { const onCallApiUrl = 'http://asdfasdf.com'; const rootBaseStore = new RootBaseStore(); + PluginState.checkIfBackendIsInMaintenanceMode = jest.fn().mockResolvedValueOnce(null); PluginState.checkIfPluginIsConnected = jest.fn().mockResolvedValueOnce({ is_user_anonymous: false, is_installed: false, @@ -117,6 +140,7 @@ describe('rootBaseStore', () => { const onCallApiUrl = 'http://asdfasdf.com'; const rootBaseStore = new RootBaseStore(); + PluginState.checkIfBackendIsInMaintenanceMode = jest.fn().mockResolvedValueOnce(null); PluginState.checkIfPluginIsConnected = jest.fn().mockResolvedValueOnce({ is_user_anonymous: false, is_installed: false, @@ -155,6 +179,7 @@ describe('rootBaseStore', () => { const rootBaseStore = new RootBaseStore(); const mockedLoadCurrentUser = jest.fn(); + PluginState.checkIfBackendIsInMaintenanceMode = jest.fn().mockResolvedValueOnce(null); PluginState.checkIfPluginIsConnected = jest.fn().mockResolvedValueOnce({ ...scenario, is_user_anonymous: false, @@ -193,6 +218,7 @@ describe('rootBaseStore', () => { const installPluginError = new Error('asdasdfasdfasf'); const humanReadableErrorMsg = 'asdfasldkfjaksdjflk'; + PluginState.checkIfBackendIsInMaintenanceMode = jest.fn().mockResolvedValueOnce(null); PluginState.checkIfPluginIsConnected = jest.fn().mockResolvedValueOnce({ is_user_anonymous: false, is_installed: false, @@ -237,6 +263,7 @@ describe('rootBaseStore', () => { const version = 'asdfalkjslkjdf'; const license = 'lkjdkjfdkjfdjkfd'; + PluginState.checkIfBackendIsInMaintenanceMode = jest.fn().mockResolvedValueOnce(null); PluginState.checkIfPluginIsConnected = jest.fn().mockResolvedValueOnce({ is_user_anonymous: false, is_installed: true, @@ -272,6 +299,7 @@ describe('rootBaseStore', () => { const mockedLoadCurrentUser = jest.fn(); const syncDataWithOnCallError = 'asdasdfasdfasf'; + PluginState.checkIfBackendIsInMaintenanceMode = jest.fn().mockResolvedValueOnce(null); PluginState.checkIfPluginIsConnected = jest.fn().mockResolvedValueOnce({ is_user_anonymous: false, is_installed: true,