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:
parent
e57941e650
commit
9be8080e51
14 changed files with 162 additions and 26 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
18
engine/engine/tests/test_maintenance_mode.py
Normal file
18
engine/engine/tests/test_maintenance_mode.py
Normal 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
|
||||
|
|
@ -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")),
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.'
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue