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`] = `
+
+
+

+
+ 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,