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

<img width="1321" alt="Screenshot 2023-05-10 at 11 28 16"
src="https://github.com/grafana/oncall/assets/9406895/833a77fb-3a90-4f9f-88d6-dae0d98d99d4">


## 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)
This commit is contained in:
Joey Orlando 2023-05-12 11:44:09 -04:00 committed by GitHub
parent e57941e650
commit 9be8080e51
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
14 changed files with 162 additions and 26 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -62,6 +62,24 @@ exports[`PluginSetup app successfully initialized 1`] = `
</div>
`;
exports[`PluginSetup currently undergoing maintenance 1`] = `
<div>
<div
class="spin"
>
<img
alt="Grafana OnCall Logo"
src="[object Object]"
/>
<div
class="spin-text"
>
there is some sort of maintenance
</div>
</div>
</div>
`;
exports[`PluginSetup there is an error message - retry setup 1`] = `
<div>
<div

View file

@ -47,16 +47,18 @@ const PluginSetup: FC<PluginSetupProps> = observer(({ InitializedComponent, ...p
if (store.initializationError) {
return (
<PluginSetupWrapper text={store.initializationError}>
<div className="configure-plugin">
<HorizontalGroup>
<Button variant="primary" onClick={setupPlugin} size="sm">
Retry
</Button>
<LinkButton href={`/plugins/grafana-oncall-app?page=configuration`} variant="primary" size="sm">
Configure Plugin
</LinkButton>
</HorizontalGroup>
</div>
{!store.currentlyUndergoingMaintenance && (
<div className="configure-plugin">
<HorizontalGroup>
<Button variant="primary" onClick={setupPlugin} size="sm">
Retry
</Button>
<LinkButton href={`/plugins/grafana-oncall-app?page=configuration`} variant="primary" size="sm">
Configure Plugin
</LinkButton>
</HorizontalGroup>
</div>
)}
</PluginSetupWrapper>
);
}

View file

@ -25,6 +25,10 @@ type PluginConnectedStatusResponse = PluginStatusResponseBase & {
is_user_anonymous: boolean;
};
type PluginIsInMaintenanceModeResponse = {
currently_undergoing_maintenance_message: string;
};
type CloudProvisioningConfigResponse = null;
type SelfHostedProvisioningConfigResponse = Omit<OnCallPluginMetaJSONData, 'onCallApiUrl'> & {
@ -296,6 +300,13 @@ class PluginState {
return null;
};
static checkIfBackendIsInMaintenanceMode = async (): Promise<string> => {
const response = await makeRequest<PluginIsInMaintenanceModeResponse>('/maintenance-mode-status', {
method: 'GET',
});
return response.currently_undergoing_maintenance_message;
};
static checkIfPluginIsConnected = async (
onCallApiUrl: string,
onCallApiUrlIsConfiguredThroughEnvVar = false

View file

@ -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<ReturnType<typeof makeRequestOriginal>>;
const isNetworkError = isNetworkErrorOriginal as unknown as jest.Mock<ReturnType<typeof isNetworkErrorOriginal>>;
@ -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

View file

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

View file

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