From 381520ee13b9298857385e736c057f4e91feac2f Mon Sep 17 00:00:00 2001 From: Yulya Artyukhina Date: Mon, 21 Nov 2022 16:26:00 +0100 Subject: [PATCH] Get rid of installation token + add a bunch of tests (#624) * Get rid of installation token (for OSS installations) This is done by being required to supply the grafana API URL as an environment variable on the backend. Additionally, optionally an OnCall API URL environment variable can be passed in to the frontend (this basically allows completely skipping the need to configure anything). - deduplicated a lot of the sync logic on the frontend + made error message more useful and consistent - Split PluginConfigPage component into several subcomponents (making it easier to test each individual component) - Moved RootWithLoader (from plugin/GrafanaPluginRootPage) into its own subcomponent (making it easier to test) - Added tests for pre-existing components that were touched: - PluginConfigPage component (and its new subcomponents) - state/plugin and state/rootBaseStore functions - apps.grafana_plugin django app Helm changes: - add GRAFANA_API_URL to oncall.env - some yaml autoformatting changes - remove reference to python manage.py issue_invite_for_the_frontend --override Co-authored-by: Joey Orlando --- CHANGELOG.md | 19 +- Makefile | 20 +- README.md | 14 +- dev/.env.dev.example | 2 + dev/README.md | 2 - docker-compose-developer.yml | 3 + docker-compose-mysql-rabbitmq.yml | 1 + docker-compose.yml | 1 + engine/apps/grafana_plugin/apps.py | 37 + engine/apps/grafana_plugin/helpers/client.py | 5 +- engine/apps/grafana_plugin/permissions.py | 21 - .../grafana_plugin/tests/test_app_config.py | 56 ++ .../apps/grafana_plugin/tests/test_install.py | 27 + .../tests/test_self_hosted_install.py | 162 ++++ .../apps/grafana_plugin/tests/test_status.py | 66 ++ engine/apps/grafana_plugin/urls.py | 10 +- .../views/self_hosted_install.py | 64 +- engine/apps/grafana_plugin/views/status.py | 20 +- engine/apps/grafana_plugin/views/sync.py | 8 +- .../user_management/models/organization.py | 23 +- engine/conftest.py | 22 +- .../commands/issue_invite_for_the_frontend.py | 45 -- engine/settings/base.py | 1 + grafana-plugin/jest.config.js | 7 +- grafana-plugin/jest.setup.ts | 21 + grafana-plugin/package.json | 5 +- .../src/__mocks__/@grafana/runtime.ts | 4 + .../src/__mocks__/grafana/app/core/core.ts | 3 + .../src/components/Avatar/Avatar.test.tsx | 3 - .../components/CardButton/CardButton.test.tsx | 2 - .../src/components/Collapse/Collapse.test.tsx | 3 - .../components/SourceCode/SourceCode.test.tsx | 2 - .../DefaultPageLayout/DefaultPageLayout.tsx | 2 +- .../PluginConfigPage.test.tsx | 321 ++++++++ .../PluginConfigPage/PluginConfigPage.tsx | 522 ++++++------- .../PluginConfigPage.test.tsx.snap | 449 +++++++++++ .../containers/PluginConfigPage/helpers.tsx | 6 - .../ConfigurationForm.module.css} | 4 - .../ConfigurationForm.test.tsx | 76 ++ .../ConfigurationForm.test.tsx.snap | 278 +++++++ .../parts/ConfigurationForm/index.tsx | 130 ++++ .../RemoveCurrentConfigurationButton.test.tsx | 33 + ...veCurrentConfigurationButton.test.tsx.snap | 36 + .../index.tsx | 20 + .../StatusMessageBlock.test.tsx | 12 + .../StatusMessageBlock.test.tsx.snap | 17 + .../parts/StatusMessageBlock/index.tsx | 15 + grafana-plugin/src/index.d.ts | 9 + grafana-plugin/src/module.ts | 23 +- grafana-plugin/src/network/index.ts | 4 +- .../escalation-chains/EscalationChains.tsx | 2 +- .../src/pages/incident/Incident.tsx | 2 +- .../src/pages/incidents/Incidents.tsx | 2 +- .../src/pages/integrations/Integrations.tsx | 2 +- .../src/pages/maintenance/Maintenance.tsx | 2 +- .../outgoing_webhooks/OutgoingWebhooks.tsx | 2 +- .../src/pages/schedule/Schedule.tsx | 2 +- grafana-plugin/src/pages/users/Users.tsx | 2 +- grafana-plugin/src/plugin.json | 6 +- .../src/plugin/GrafanaPluginRootPage.tsx | 63 +- .../plugin/PluginSetup/PluginSetup.test.tsx | 76 ++ .../__snapshots__/PluginSetup.test.tsx.snap | 139 ++++ .../src/plugin/PluginSetup/index.tsx | 58 ++ grafana-plugin/src/state/plugin.ts | 46 -- .../plugin/__snapshots__/plugin.test.ts.snap | 73 ++ grafana-plugin/src/state/plugin/index.ts | 339 +++++++++ .../src/state/plugin/plugin.test.ts | 718 ++++++++++++++++++ .../index.ts} | 200 ++--- .../state/rootBaseStore/rootBaseStore.test.ts | 295 +++++++ grafana-plugin/src/types.ts | 25 +- grafana-plugin/webpack.config.js | 10 + grafana-plugin/yarn.lock | 5 + helm/README.md | 43 +- helm/oncall/README.md | 33 +- helm/oncall/templates/NOTES.txt | 11 - helm/oncall/templates/_env.tpl | 12 + helm/oncall/values.yaml | 3 + 77 files changed, 4010 insertions(+), 797 deletions(-) create mode 100644 engine/apps/grafana_plugin/apps.py create mode 100644 engine/apps/grafana_plugin/tests/test_app_config.py create mode 100644 engine/apps/grafana_plugin/tests/test_install.py create mode 100644 engine/apps/grafana_plugin/tests/test_self_hosted_install.py create mode 100644 engine/apps/grafana_plugin/tests/test_status.py delete mode 100644 engine/engine/management/commands/issue_invite_for_the_frontend.py create mode 100644 grafana-plugin/jest.setup.ts create mode 100644 grafana-plugin/src/__mocks__/@grafana/runtime.ts create mode 100644 grafana-plugin/src/__mocks__/grafana/app/core/core.ts create mode 100644 grafana-plugin/src/containers/PluginConfigPage/PluginConfigPage.test.tsx create mode 100644 grafana-plugin/src/containers/PluginConfigPage/__snapshots__/PluginConfigPage.test.tsx.snap delete mode 100644 grafana-plugin/src/containers/PluginConfigPage/helpers.tsx rename grafana-plugin/src/containers/PluginConfigPage/{PluginConfigPage.module.css => parts/ConfigurationForm/ConfigurationForm.module.css} (63%) create mode 100644 grafana-plugin/src/containers/PluginConfigPage/parts/ConfigurationForm/ConfigurationForm.test.tsx create mode 100644 grafana-plugin/src/containers/PluginConfigPage/parts/ConfigurationForm/__snapshots__/ConfigurationForm.test.tsx.snap create mode 100644 grafana-plugin/src/containers/PluginConfigPage/parts/ConfigurationForm/index.tsx create mode 100644 grafana-plugin/src/containers/PluginConfigPage/parts/RemoveCurrentConfigurationButton/RemoveCurrentConfigurationButton.test.tsx create mode 100644 grafana-plugin/src/containers/PluginConfigPage/parts/RemoveCurrentConfigurationButton/__snapshots__/RemoveCurrentConfigurationButton.test.tsx.snap create mode 100644 grafana-plugin/src/containers/PluginConfigPage/parts/RemoveCurrentConfigurationButton/index.tsx create mode 100644 grafana-plugin/src/containers/PluginConfigPage/parts/StatusMessageBlock/StatusMessageBlock.test.tsx create mode 100644 grafana-plugin/src/containers/PluginConfigPage/parts/StatusMessageBlock/__snapshots__/StatusMessageBlock.test.tsx.snap create mode 100644 grafana-plugin/src/containers/PluginConfigPage/parts/StatusMessageBlock/index.tsx create mode 100644 grafana-plugin/src/plugin/PluginSetup/PluginSetup.test.tsx create mode 100644 grafana-plugin/src/plugin/PluginSetup/__snapshots__/PluginSetup.test.tsx.snap create mode 100644 grafana-plugin/src/plugin/PluginSetup/index.tsx delete mode 100644 grafana-plugin/src/state/plugin.ts create mode 100644 grafana-plugin/src/state/plugin/__snapshots__/plugin.test.ts.snap create mode 100644 grafana-plugin/src/state/plugin/index.ts create mode 100644 grafana-plugin/src/state/plugin/plugin.test.ts rename grafana-plugin/src/state/{rootBaseStore.ts => rootBaseStore/index.ts} (62%) create mode 100644 grafana-plugin/src/state/rootBaseStore/rootBaseStore.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 0ddd856f..0cf018a1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,4 +1,21 @@ -# Change Log +# Changelog + +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] + +### Changed + +- For OSS installations of OnCall, initial configuration is now simplified. When running for local development, you no longer need to configure the plugin via the UI. This is achieved through passing one environment variable to both the backend & frontend containers, both of which have been preconfigured for you in `docker-compose-developer.yml`. + - The Grafana API URL **must be** passed as an environment variable, `GRAFANA_API_URL`, to the OnCall backend (and can be configured by updating this env var in your `./dev/.env.dev` file) + - The OnCall API URL can optionally be passed as an environment variable, `ONCALL_API_URL`, to the OnCall UI. If the environment variable is found, the plugin will "auto-configure", otherwise you will be shown a simple configuration form to provide this info. +- For Helm installations, if you are running Grafana externally (eg. `grafana.enabled` is set to `false` in your `values.yaml`), you will now be required to specify `externalGrafana.url` in `values.yaml`. +- `make start` will now idempotently check to see if a "127.0.0.1 grafana" record exists in `/etc/hosts` (using a tool called [`hostess`](https://github.com/cbednarski/hostess)). This is to support using `http://grafana:3000` as the `Organization.grafana_url` in two scenarios: + - `oncall_engine`/`oncall_celery` -> `grafana` Docker container communication + - public URL generation. There are some instances where `Organization.grafana_url` is referenced to generate public URLs to a Grafana plugin page. Without the `/etc/hosts` record, navigating to `http://grafana:3000/some_page` in your browser, you would obviously get an error from your browser. ## v1.1.2 (2022-16-09) diff --git a/Makefile b/Makefile index d894fe18..b60a4be8 100644 --- a/Makefile +++ b/Makefile @@ -62,6 +62,12 @@ define run_engine_docker_command endef # touch SQLITE_DB_FILE if it does not exist and DB is eqaul to SQLITE_PROFILE +# +# hostess installation (crossplatform/idempotent modification of /etc/hosts file) +# see here (https://github.com/cbednarski/hostess#installation) for docs +# basically this is needed because oncall api has been configured locally to communicate w/ grafana @ +# http://grafana:3000. This becomes a problem in certain parts of OnCall where we generate "public" URLs +# and the user tries to access them via their browser. start: ifeq ($(DB),$(SQLITE_PROFILE)) @if [ ! -f $(SQLITE_DB_FILE) ]; then \ @@ -69,6 +75,17 @@ ifeq ($(DB),$(SQLITE_PROFILE)) fi endif + @if [ ! -x "$$(command -v hostess)" ]; then \ + echo "installing hostess"; \ + git clone https://github.com/cbednarski/hostess "${HOME}/hostess"; \ + cd "${HOME}/hostess"; \ + make install; \ + fi + + @if ! hostess has grafana; then \ + sudo hostess add grafana 127.0.0.1; \ + fi + $(call run_docker_compose_command,up --remove-orphans -d) init: @@ -105,9 +122,6 @@ lint: install-pre-commit install-precommit-hook: install-pre-commit pre-commit install -get-invite-token: - $(call run_engine_docker_command,python manage.py issue_invite_for_the_frontend --override) - test: $(call run_engine_docker_command,pytest) diff --git a/README.md b/README.md index 23a8176e..b0ce2702 100644 --- a/README.md +++ b/README.md @@ -44,23 +44,13 @@ SECRET_KEY=my_random_secret_must_be_more_than_32_characters_long" > .env docker-compose up -d ``` -4. Issue one-time invite token: - -```bash -docker-compose run engine python manage.py issue_invite_for_the_frontend --override -``` - -**Note**: if you remove the plugin configuration and reconfigure it, you will need to generate a new one-time invite token for your new configuration. - -5. Go to [OnCall Plugin Configuration](http://localhost:3000/plugins/grafana-oncall-app), using log in credentials as defined above: `admin`/`admin` (or find OnCall plugin in configuration->plugins) and connect OnCall _plugin_ with OnCall _backend_: +4. Go to [OnCall Plugin Configuration](http://localhost:3000/plugins/grafana-oncall-app), using log in credentials as defined above: `admin`/`admin` (or find OnCall plugin in configuration->plugins) and connect OnCall _plugin_ with OnCall _backend_: ``` -Invite token: ^^^ from the previous step. OnCall backend URL: http://engine:8080 -Grafana Url: http://grafana:3000 ``` -6. Enjoy! Check our [OSS docs](https://grafana.com/docs/grafana-cloud/oncall/open-source/) if you want to set up Slack, Telegram, Twilio or SMS/calls through Grafana Cloud. +5. Enjoy! Check our [OSS docs](https://grafana.com/docs/grafana-cloud/oncall/open-source/) if you want to set up Slack, Telegram, Twilio or SMS/calls through Grafana Cloud. ## Update version diff --git a/dev/.env.dev.example b/dev/.env.dev.example index a33fabce..2566f4fd 100644 --- a/dev/.env.dev.example +++ b/dev/.env.dev.example @@ -26,6 +26,8 @@ SLACK_INSTALL_RETURN_REDIRECT_HOST=http://localhost:8080 SOCIAL_AUTH_REDIRECT_IS_HTTPS=False GRAFANA_INCIDENT_STATIC_API_KEY= +GRAFANA_ONCALL_OSS_INSTALLATION=True +GRAFANA_API_URL=http://localhost:3000 CELERY_WORKER_QUEUE="default,critical,long,slack,telegram,webhook,retry,celery" CELERY_WORKER_CONCURRENCY=1 diff --git a/dev/README.md b/dev/README.md index d433d489..7e063dc1 100644 --- a/dev/README.md +++ b/dev/README.md @@ -27,7 +27,6 @@ By default everything runs inside Docker. These options can be modified via the 3. Open Grafana in a browser [here](http://localhost:3000/plugins/grafana-oncall-app) (login: `oncall`, password: `oncall`). 4. You should now see the OnCall plugin configuration page. Fill out the configuration options as follows: -- Invite token: run `make get-invite-token` and copy/paste the token that gets printed out - OnCall backend URL: http://host.docker.internal:8080 (this is the URL that is running the OnCall API; it should be accessible from Grafana) - Grafana URL: http://grafana:3000 (this is the URL OnCall will use to talk to the Grafana Instance) @@ -98,7 +97,6 @@ make build # rebuild images (e.g. when changing requirements.txt) # associated with your local OnCall developer setup make cleanup -make get-invite-token # generate an invitation token make start-celery-beat # start celery beat make purge-queues # purge celery queues make shell # starts an OnCall engine Django shell diff --git a/docker-compose-developer.yml b/docker-compose-developer.yml index a784459e..b76d5734 100644 --- a/docker-compose-developer.yml +++ b/docker-compose-developer.yml @@ -20,6 +20,7 @@ x-env-files: &oncall-env-files x-env-vars: &oncall-env-vars BROKER_TYPE: ${BROKER_TYPE} + GRAFANA_API_URL: http://grafana:3000 services: oncall_ui: @@ -29,6 +30,8 @@ services: context: ./grafana-plugin dockerfile: Dockerfile.dev labels: *oncall-labels + environment: + ONCALL_API_URL: http://host.docker.internal:8080 volumes: - ./grafana-plugin:/etc/app - /etc/app/node_modules diff --git a/docker-compose-mysql-rabbitmq.yml b/docker-compose-mysql-rabbitmq.yml index 9537c2f2..2468fa39 100644 --- a/docker-compose-mysql-rabbitmq.yml +++ b/docker-compose-mysql-rabbitmq.yml @@ -20,6 +20,7 @@ x-environment: &oncall-environment CELERY_WORKER_MAX_TASKS_PER_CHILD: "100" CELERY_WORKER_SHUTDOWN_INTERVAL: "65m" CELERY_WORKER_BEAT_ENABLED: "True" + GRAFANA_API_URL: http://grafana:3000 services: engine: diff --git a/docker-compose.yml b/docker-compose.yml index 1557dafc..6fa7cd67 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -12,6 +12,7 @@ x-environment: &oncall-environment CELERY_WORKER_MAX_TASKS_PER_CHILD: "100" CELERY_WORKER_SHUTDOWN_INTERVAL: "65m" CELERY_WORKER_BEAT_ENABLED: "True" + GRAFANA_API_URL: http://grafana:3000 services: engine: diff --git a/engine/apps/grafana_plugin/apps.py b/engine/apps/grafana_plugin/apps.py new file mode 100644 index 00000000..71b4738a --- /dev/null +++ b/engine/apps/grafana_plugin/apps.py @@ -0,0 +1,37 @@ +import logging +import sys + +from django.apps import AppConfig, apps +from django.conf import settings + +logger = logging.getLogger(__name__) +logger.setLevel(logging.DEBUG) + +STARTUP_COMMANDS = ["runserver", "uwsgi"] + + +class GrafanaPluginConfig(AppConfig): + name = "apps.grafana_plugin" + + def ready(self): + """ + For OSS installations, validate that GRAFANA_API_URL environment variable is specified, otherwise + abort app startup. + + We only care to run this for OSS_INSTALLATIONS. The STARTUP_COMMANDS check is to avoid running this check + for the django migrate command. For a fresh installation this would crash because user_management table would + [not exist](https://stackoverflow.com/a/63326719). + """ + # TODO: this logic should probably be moved out to a common utility + is_not_migration_script = any(startup_command in sys.argv for startup_command in STARTUP_COMMANDS) + if is_not_migration_script and settings.OSS_INSTALLATION is True: + Organization = apps.get_model("user_management", "Organization") + has_existing_org = Organization.objects.first() is not None + + # only enforce the following for new setups - if no organization exists in the database + # and the GRAFANA_API_URL env var is not specified, exit the application + if has_existing_org is False and settings.SELF_HOSTED_SETTINGS["GRAFANA_API_URL"] is None: + logger.error( + f"For OSS installations, GRAFANA_API_URL is a required environment variable. Please set it and restart the application." + ) + sys.exit() diff --git a/engine/apps/grafana_plugin/helpers/client.py b/engine/apps/grafana_plugin/helpers/client.py index f232719e..c23c5f90 100644 --- a/engine/apps/grafana_plugin/helpers/client.py +++ b/engine/apps/grafana_plugin/helpers/client.py @@ -23,6 +23,9 @@ class APIClient: def api_post(self, endpoint: str, body: dict = None) -> Tuple[Optional[Response], dict]: return self.call_api(endpoint, requests.post, body) + def api_head(self, endpoint: str, body: dict = None) -> Tuple[Optional[Response], dict]: + return self.call_api(endpoint, requests.head, body) + def call_api(self, endpoint: str, http_method, body: dict = None) -> Tuple[Optional[Response], dict]: request_start = time.perf_counter() call_status = { @@ -73,7 +76,7 @@ class GrafanaAPIClient(APIClient): super().__init__(api_url, api_token) def check_token(self) -> Tuple[Optional[Response], dict]: - return self.api_get("api/org") + return self.api_head("api/org") def get_users(self) -> Tuple[Optional[Response], dict]: """ diff --git a/engine/apps/grafana_plugin/permissions.py b/engine/apps/grafana_plugin/permissions.py index b4ea8b5e..59a956f5 100644 --- a/engine/apps/grafana_plugin/permissions.py +++ b/engine/apps/grafana_plugin/permissions.py @@ -1,7 +1,6 @@ import json import logging -from django.apps import apps from django.views import View from rest_framework import permissions from rest_framework.authentication import get_authorization_header @@ -28,23 +27,3 @@ class PluginTokenVerified(permissions.BasePermission): logger.warning(f"Invalid token used: {context}") return False - - -class SelfHostedInvitationTokenVerified(permissions.BasePermission): - def has_permission(self, request: Request, view: View) -> bool: - DynamicSetting = apps.get_model("base", "DynamicSetting") - self_hosted_settings = DynamicSetting.objects.get_or_create( - name="self_hosted_invitations", - defaults={ - "json_value": { - "keys": [], - } - }, - )[0] - token_string = get_authorization_header(request).decode() - try: - return token_string in self_hosted_settings.json_value["keys"] - except InvalidToken: - logger.warning(f"Invalid token used") - - return False diff --git a/engine/apps/grafana_plugin/tests/test_app_config.py b/engine/apps/grafana_plugin/tests/test_app_config.py new file mode 100644 index 00000000..f14b7e5e --- /dev/null +++ b/engine/apps/grafana_plugin/tests/test_app_config.py @@ -0,0 +1,56 @@ +import sys +from unittest.mock import patch + +import pytest +from django.apps import apps +from django.test import override_settings + +app_name = "grafana_plugin" + + +@pytest.mark.parametrize( + "startup_command,app_crashed", + [ + (["python", "manage.py", "runserver"], True), + (["uwsgi", "blah", "blah", "blah"], True), + (["python", "manage.py", "migration"], False), + ], +) +@patch.object(sys, "exit") +@override_settings(OSS_INSTALLATION=True) +@override_settings(SELF_HOSTED_SETTINGS={"GRAFANA_API_URL": None}) +@pytest.mark.django_db +def test_it_crashes_the_app_if_the_env_var_is_not_present_for_oss_installations_and_an_org_does_not_exist( + mocked_sys_exit, + startup_command, + app_crashed, +) -> None: + with patch.object(sys, "argv", startup_command): + apps.get_app_config(app_name).ready() + + if app_crashed: + mocked_sys_exit.assert_called_once() + else: + mocked_sys_exit.assert_not_called() + + +@patch.object(sys, "argv", ["runserver"]) +@patch.object(sys, "exit") +@override_settings(OSS_INSTALLATION=True) +@override_settings(SELF_HOSTED_SETTINGS={"GRAFANA_API_URL": None}) +@pytest.mark.django_db +def test_it_doesnt_crash_the_app_if_the_env_var_is_not_present_for_oss_installations_and_an_org_does_exist( + mocked_sys_exit, make_organization +) -> None: + make_organization() + + apps.get_app_config(app_name).ready() + mocked_sys_exit.assert_not_called() + + +@patch.object(sys, "argv", ["runserver"]) +@patch.object(sys, "exit") +@override_settings(OSS_INSTALLATION=False) +def test_it_ignores_non_oss_installations(mocked_sys_exit) -> None: + apps.get_app_config(app_name).ready() + mocked_sys_exit.assert_not_called() diff --git a/engine/apps/grafana_plugin/tests/test_install.py b/engine/apps/grafana_plugin/tests/test_install.py new file mode 100644 index 00000000..054f0156 --- /dev/null +++ b/engine/apps/grafana_plugin/tests/test_install.py @@ -0,0 +1,27 @@ +from unittest.mock import patch + +import pytest +from django.urls import reverse +from rest_framework import status +from rest_framework.test import APIClient + +GRAFANA_TOKEN = "TESTTOKEN" + + +@patch("apps.grafana_plugin.views.install.sync_organization", return_value=None) +@pytest.mark.django_db +def test_it_triggers_an_organization_sync_and_saves_the_grafana_token( + mocked_sync_organization, make_organization_and_user_with_plugin_token, make_user_auth_headers +): + organization, user, token = make_organization_and_user_with_plugin_token() + client = APIClient() + + auth_headers = make_user_auth_headers(user, token, grafana_token=GRAFANA_TOKEN) + response = client.post(reverse("grafana-plugin:install"), format="json", **auth_headers) + + assert response.status_code == status.HTTP_204_NO_CONTENT + assert mocked_sync_organization.called_once_with(organization) + + # make sure api token is saved on the org + organization.refresh_from_db() + assert organization.api_token == GRAFANA_TOKEN diff --git a/engine/apps/grafana_plugin/tests/test_self_hosted_install.py b/engine/apps/grafana_plugin/tests/test_self_hosted_install.py new file mode 100644 index 00000000..62d835d5 --- /dev/null +++ b/engine/apps/grafana_plugin/tests/test_self_hosted_install.py @@ -0,0 +1,162 @@ +import json +from unittest.mock import patch + +import pytest +from django.apps import apps +from django.conf import settings +from django.test import override_settings +from django.urls import reverse +from rest_framework import status +from rest_framework.test import APIClient + +GRAFANA_TOKEN = "TEST_TOKEN" +STACK_ID = 1 +ORG_ID = 5 +GRAFANA_API_URL = "hello.com" +LICENSE = "OpenSource" +STACK_SLUG = "asdfasdf" +ORG_SLUG = "hellooo" +ORG_TITLE = "nmvcnmvnmvc" +REGION_SLUG = "nmcvnmcvnmcvnmcv" +SELF_HOSTED_SETTINGS = { + "GRAFANA_API_URL": GRAFANA_API_URL, + "STACK_ID": STACK_ID, + "ORG_ID": ORG_ID, + "LICENSE": LICENSE, + "STACK_SLUG": STACK_SLUG, + "ORG_SLUG": ORG_SLUG, + "ORG_TITLE": ORG_TITLE, + "REGION_SLUG": REGION_SLUG, +} + +UNABLE_TO_FIND_GRAFANA_ERROR_MSG = f"Unable to connect to the specified Grafana API - {GRAFANA_API_URL}" +UNAUTHED_GRAFANA_API_ERROR_MSG = ( + f"You are not authorized to communicate with the specified Grafana API - {GRAFANA_API_URL}" +) + + +@pytest.fixture +def make_self_hosted_install_header(): + def _make_instance_context_header(token): + return { + "HTTP_X-Instance-Context": json.dumps({"grafana_token": token}), + } + + return _make_instance_context_header + + +@override_settings(LICENSE=settings.CLOUD_LICENSE_NAME) +def test_a_cloud_license_gets_an_unauthorized_error(make_self_hosted_install_header): + client = APIClient() + url = reverse("grafana-plugin:self-hosted-install") + response = client.post(url, format="json", **make_self_hosted_install_header(GRAFANA_TOKEN)) + + assert response.status_code == status.HTTP_403_FORBIDDEN + + +@pytest.mark.parametrize( + "grafana_api_status_code,expected_error_msg", + [ + (status.HTTP_404_NOT_FOUND, UNABLE_TO_FIND_GRAFANA_ERROR_MSG), + (status.HTTP_401_UNAUTHORIZED, UNAUTHED_GRAFANA_API_ERROR_MSG), + (status.HTTP_401_UNAUTHORIZED, UNAUTHED_GRAFANA_API_ERROR_MSG), + ], +) +@override_settings(SELF_HOSTED_SETTINGS=SELF_HOSTED_SETTINGS) +@patch("apps.grafana_plugin.views.self_hosted_install.GrafanaAPIClient") +def test_it_properly_handles_errors_from_the_grafana_api( + mocked_grafana_api_client, make_self_hosted_install_header, grafana_api_status_code, expected_error_msg +): + mocked_grafana_api_client.return_value.check_token.return_value = (None, {"status_code": grafana_api_status_code}) + + client = APIClient() + url = reverse("grafana-plugin:self-hosted-install") + response = client.post(url, format="json", **make_self_hosted_install_header(GRAFANA_TOKEN)) + + assert mocked_grafana_api_client.called_once_with(api_url=GRAFANA_API_URL, api_token=GRAFANA_TOKEN) + assert mocked_grafana_api_client.return_value.check_token.called_once_with() + + assert response.status_code == status.HTTP_400_BAD_REQUEST + assert response.data["error"] == expected_error_msg + + +@override_settings(SELF_HOSTED_SETTINGS=SELF_HOSTED_SETTINGS) +@pytest.mark.django_db +@patch("apps.grafana_plugin.views.self_hosted_install.GrafanaAPIClient") +@patch("apps.grafana_plugin.views.self_hosted_install.sync_organization") +@patch("apps.grafana_plugin.views.self_hosted_install.Organization.provision_plugin") +@patch("apps.grafana_plugin.views.self_hosted_install.Organization.revoke_plugin") +def test_if_organization_exists_it_is_updated( + mocked_revoke_plugin, + mocked_provision_plugin, + mocked_sync_organization, + mocked_grafana_api_client, + make_self_hosted_install_header, + make_organization, +): + organization = make_organization(stack_id=STACK_ID, org_id=ORG_ID) + provision_plugin_response = {"stackId": STACK_ID, "orgId": ORG_ID, "onCallToken": "HELLOOO", "license": LICENSE} + + mocked_provision_plugin.return_value = provision_plugin_response + mocked_grafana_api_client.return_value.check_token.return_value = (None, {"status_code": status.HTTP_200_OK}) + + client = APIClient() + url = reverse("grafana-plugin:self-hosted-install") + response = client.post(url, format="json", **make_self_hosted_install_header(GRAFANA_TOKEN)) + + assert mocked_grafana_api_client.called_once_with(api_url=GRAFANA_API_URL, api_token=GRAFANA_TOKEN) + assert mocked_grafana_api_client.return_value.check_token.called_once_with() + assert mocked_sync_organization.called_once_with(organization) + assert mocked_provision_plugin.called_once_with() + assert mocked_revoke_plugin.called_once_with() + + assert response.status_code == status.HTTP_201_CREATED + assert response.data == {"error": None, **provision_plugin_response} + + organization.refresh_from_db() + + assert organization.grafana_url == GRAFANA_API_URL + assert organization.api_token == GRAFANA_TOKEN + + +@override_settings(SELF_HOSTED_SETTINGS=SELF_HOSTED_SETTINGS) +@pytest.mark.django_db +@patch("apps.grafana_plugin.views.self_hosted_install.GrafanaAPIClient") +@patch("apps.grafana_plugin.views.self_hosted_install.sync_organization") +@patch("apps.grafana_plugin.views.self_hosted_install.Organization.provision_plugin") +@patch("apps.grafana_plugin.views.self_hosted_install.Organization.revoke_plugin") +def test_if_organization_does_not_exist_it_is_created( + mocked_revoke_plugin, + mocked_provision_plugin, + mocked_sync_organization, + mocked_grafana_api_client, + make_self_hosted_install_header, +): + provision_plugin_response = {"stackId": STACK_ID, "orgId": ORG_ID, "onCallToken": "HELLOOO", "license": LICENSE} + + mocked_provision_plugin.return_value = provision_plugin_response + mocked_grafana_api_client.return_value.check_token.return_value = (None, {"status_code": status.HTTP_200_OK}) + + client = APIClient() + url = reverse("grafana-plugin:self-hosted-install") + response = client.post(url, format="json", **make_self_hosted_install_header(GRAFANA_TOKEN)) + + Organization = apps.get_model("user_management", "Organization") + organization = Organization.objects.filter(stack_id=STACK_ID, org_id=ORG_ID).first() + + assert mocked_grafana_api_client.called_once_with(api_url=GRAFANA_API_URL, api_token=GRAFANA_TOKEN) + assert mocked_grafana_api_client.return_value.check_token.called_once_with() + assert mocked_sync_organization.called_once_with(organization) + assert mocked_provision_plugin.called_once_with() + assert not mocked_revoke_plugin.called + + assert response.status_code == status.HTTP_201_CREATED + assert response.data == {"error": None, **provision_plugin_response} + + assert organization.stack_id == STACK_ID + assert organization.stack_slug == STACK_SLUG + assert organization.org_slug == ORG_SLUG + assert organization.org_title == ORG_TITLE + assert organization.region_slug == REGION_SLUG + assert organization.grafana_url == GRAFANA_API_URL + assert organization.api_token == GRAFANA_TOKEN diff --git a/engine/apps/grafana_plugin/tests/test_status.py b/engine/apps/grafana_plugin/tests/test_status.py new file mode 100644 index 00000000..74e74a11 --- /dev/null +++ b/engine/apps/grafana_plugin/tests/test_status.py @@ -0,0 +1,66 @@ +from unittest.mock import patch + +import pytest +from django.test import override_settings +from django.urls import reverse +from rest_framework import status +from rest_framework.test import APIClient + +GRAFANA_TOKEN = "TESTTOKEN" +GRAFANA_URL = "hello.com" +LICENSE = "asdfasdf" +VERSION = "asdfasdfasdf" +GRAFANA_CONTEXT_DATA = {"IsAnonymous": False} +SETTINGS = {"LICENSE": LICENSE, "VERSION": VERSION} + + +@pytest.mark.django_db +@override_settings(**SETTINGS) +@patch("apps.grafana_plugin.views.status.GrafanaAPIClient") +def test_token_ok_is_based_on_grafana_api_check_token_response( + mocked_grafana_api_client, make_organization_and_user_with_plugin_token, make_user_auth_headers +): + mocked_grafana_api_client.return_value.check_token.return_value = (None, {"connected": True}) + + organization, user, token = make_organization_and_user_with_plugin_token() + organization.grafana_url = GRAFANA_URL + organization.save(update_fields=["grafana_url"]) + + client = APIClient() + auth_headers = make_user_auth_headers( + user, token, grafana_token=GRAFANA_TOKEN, grafana_context_data=GRAFANA_CONTEXT_DATA + ) + response = client.get(reverse("grafana-plugin:status"), format="json", **auth_headers) + response_data = response.data + + assert response.status_code == status.HTTP_200_OK + assert response_data["token_ok"] is True + assert response_data["is_installed"] is True + assert response_data["allow_signup"] is True + assert response_data["is_user_anonymous"] is False + assert response_data["license"] == LICENSE + assert response_data["version"] == VERSION + + assert mocked_grafana_api_client.called_once_with(api_url=GRAFANA_URL, api_token=GRAFANA_TOKEN) + assert mocked_grafana_api_client.return_value.check_token.called_once_with() + + +@pytest.mark.django_db +@override_settings(**SETTINGS) +def test_allow_signup(make_organization_and_user_with_plugin_token, make_user_auth_headers): + organization, user, token = make_organization_and_user_with_plugin_token() + # change the stack id so that this org isn't found + organization.stack_id = 494509 + organization.save(update_fields=["stack_id"]) + + client = APIClient() + auth_headers = make_user_auth_headers( + user, token, grafana_token=GRAFANA_TOKEN, grafana_context_data=GRAFANA_CONTEXT_DATA + ) + response = client.get(reverse("grafana-plugin:status"), format="json", **auth_headers) + + # if the org doesn't exist this will never return 200 due to + # the PluginTokenVerified permission class.. + # should consider removing the DynamicSetting logic because technically this + # condition will never be reached in the code... + assert response.status_code == status.HTTP_403_FORBIDDEN diff --git a/engine/apps/grafana_plugin/urls.py b/engine/apps/grafana_plugin/urls.py index 347f8e9d..8cdd4ca5 100644 --- a/engine/apps/grafana_plugin/urls.py +++ b/engine/apps/grafana_plugin/urls.py @@ -11,9 +11,9 @@ from apps.grafana_plugin.views import ( app_name = "grafana-plugin" urlpatterns = [ - re_path(r"self-hosted/install/?", SelfHostedInstallView().as_view()), - re_path(r"status/?", StatusView().as_view()), - re_path(r"install/?", InstallView().as_view()), - re_path(r"sync_organization/?", SyncOrganizationView().as_view()), - re_path(r"sync/?", PluginSyncView().as_view()), + re_path(r"self-hosted/install/?", SelfHostedInstallView().as_view(), name="self-hosted-install"), + re_path(r"status/?", StatusView().as_view(), name="status"), + re_path(r"install/?", InstallView().as_view(), name="install"), + re_path(r"sync_organization/?", SyncOrganizationView().as_view(), name="sync-organization"), + re_path(r"sync/?", PluginSyncView().as_view(), name="sync"), ] diff --git a/engine/apps/grafana_plugin/views/self_hosted_install.py b/engine/apps/grafana_plugin/views/self_hosted_install.py index f4159ea6..7117cf63 100644 --- a/engine/apps/grafana_plugin/views/self_hosted_install.py +++ b/engine/apps/grafana_plugin/views/self_hosted_install.py @@ -1,43 +1,52 @@ -from django.apps import apps from django.conf import settings from rest_framework import status -from rest_framework.authentication import get_authorization_header from rest_framework.request import Request from rest_framework.response import Response from rest_framework.views import APIView -from apps.grafana_plugin.permissions import SelfHostedInvitationTokenVerified -from apps.user_management.models import Organization +from apps.grafana_plugin.helpers import GrafanaAPIClient +from apps.user_management.models.organization import Organization, ProvisionedPlugin from apps.user_management.sync import sync_organization from common.api_helpers.mixins import GrafanaHeadersMixin class SelfHostedInstallView(GrafanaHeadersMixin, APIView): - permission_classes = (SelfHostedInvitationTokenVerified,) - - def remove_invitation_token(self, token): - DynamicSetting = apps.get_model("base", "DynamicSetting") - self_hosted_settings = DynamicSetting.objects.get_or_create( - name="self_hosted_invitations", - defaults={ - "json_value": { - "keys": [], - } - }, - )[0] - self_hosted_settings.json_value["keys"].remove(token) - self_hosted_settings.save(update_fields=["json_value"]) - - def post(self, request: Request) -> Response: - token_string = get_authorization_header(request).decode() + def post(self, _request: Request) -> Response: + """ + We've already validated that settings.GRAFANA_API_URL is set (in apps.grafana_plugin.GrafanaPluginConfig) + The user is now trying to finish plugin installation. We'll take the Grafana API url that they specified + + the token that we are provided and first verify them. If all is good, upsert the organization in the database, + and provision the plugin. + """ stack_id = settings.SELF_HOSTED_SETTINGS["STACK_ID"] org_id = settings.SELF_HOSTED_SETTINGS["ORG_ID"] + grafana_url = settings.SELF_HOSTED_SETTINGS["GRAFANA_API_URL"] + grafana_api_token = self.instance_context["grafana_token"] + + provisioning_info: ProvisionedPlugin = {"error": None} + + if settings.LICENSE != settings.OPEN_SOURCE_LICENSE_NAME: + provisioning_info["error"] = f"License type not authorized" + return Response(status=status.HTTP_403_FORBIDDEN) + + grafana_api_client = GrafanaAPIClient(api_url=grafana_url, api_token=grafana_api_token) + _, client_status = grafana_api_client.check_token() + status_code = client_status["status_code"] + + if status_code == status.HTTP_404_NOT_FOUND: + provisioning_info["error"] = f"Unable to connect to the specified Grafana API - {grafana_url}" + return Response(data=provisioning_info, status=status.HTTP_400_BAD_REQUEST) + elif status_code in [status.HTTP_401_UNAUTHORIZED, status.HTTP_403_FORBIDDEN]: + provisioning_info[ + "error" + ] = f"You are not authorized to communicate with the specified Grafana API - {grafana_url}" + return Response(data=provisioning_info, status=status.HTTP_400_BAD_REQUEST) organization = Organization.objects.filter(stack_id=stack_id, org_id=org_id).first() if organization: organization.revoke_plugin() - organization.grafana_url = self.instance_context["grafana_url"] - organization.api_token = self.instance_context["grafana_token"] + organization.grafana_url = grafana_url + organization.api_token = grafana_api_token organization.save(update_fields=["grafana_url", "api_token"]) else: organization = Organization.objects.create( @@ -47,10 +56,11 @@ class SelfHostedInstallView(GrafanaHeadersMixin, APIView): org_slug=settings.SELF_HOSTED_SETTINGS["ORG_SLUG"], org_title=settings.SELF_HOSTED_SETTINGS["ORG_TITLE"], region_slug=settings.SELF_HOSTED_SETTINGS["REGION_SLUG"], - grafana_url=self.instance_context["grafana_url"], - api_token=self.instance_context["grafana_token"], + grafana_url=grafana_url, + api_token=grafana_api_token, ) + sync_organization(organization) - provisioning_info = organization.provision_plugin() - self.remove_invitation_token(token_string) + provisioning_info.update(organization.provision_plugin()) + return Response(data=provisioning_info, status=status.HTTP_201_CREATED) diff --git a/engine/apps/grafana_plugin/views/status.py b/engine/apps/grafana_plugin/views/status.py index e202e337..d7dbac46 100644 --- a/engine/apps/grafana_plugin/views/status.py +++ b/engine/apps/grafana_plugin/views/status.py @@ -1,6 +1,5 @@ from django.apps import apps from django.conf import settings -from rest_framework import status from rest_framework.request import Request from rest_framework.response import Response from rest_framework.views import APIView @@ -14,25 +13,17 @@ from common.api_helpers.mixins import GrafanaHeadersMixin class StatusView(GrafanaHeadersMixin, APIView): permission_classes = (PluginTokenVerified,) - def get(self, request: Request) -> Response: + def get(self, _request: Request) -> Response: stack_id = self.instance_context["stack_id"] org_id = self.instance_context["org_id"] is_installed = False - connected_to_grafana = False token_ok = False allow_signup = True - organization = Organization.objects.filter(stack_id=stack_id, org_id=org_id).first() - if organization: + + if organization := Organization.objects.filter(stack_id=stack_id, org_id=org_id).first(): is_installed = True - client = GrafanaAPIClient(api_url=organization.grafana_url, api_token=organization.api_token) - token_info, client_status = client.check_token() - connected_to_grafana = ( - client_status["connected"] - or client_status["status_code"] == status.HTTP_401_UNAUTHORIZED - or client_status["status_code"] == status.HTTP_403_FORBIDDEN - ) - if token_info: - token_ok = True + _, resp = GrafanaAPIClient(api_url=organization.grafana_url, api_token=organization.api_token).check_token() + token_ok = resp["connected"] else: DynamicSetting = apps.get_model("base", "DynamicSetting") allow_signup = DynamicSetting.objects.get_or_create( @@ -42,7 +33,6 @@ class StatusView(GrafanaHeadersMixin, APIView): return Response( data={ "is_installed": is_installed, - "grafana_connection_ok": connected_to_grafana, "token_ok": token_ok, "allow_signup": allow_signup, "is_user_anonymous": self.grafana_context["IsAnonymous"], diff --git a/engine/apps/grafana_plugin/views/sync.py b/engine/apps/grafana_plugin/views/sync.py index a0deeec3..98d3c23e 100644 --- a/engine/apps/grafana_plugin/views/sync.py +++ b/engine/apps/grafana_plugin/views/sync.py @@ -21,13 +21,15 @@ class PluginSyncView(GrafanaHeadersMixin, APIView): def post(self, request: Request) -> Response: stack_id = self.instance_context["stack_id"] org_id = self.instance_context["org_id"] - is_installed = False + try: organization = Organization.objects.get(stack_id=stack_id, org_id=org_id) + if organization.api_token_status == Organization.API_TOKEN_STATUS_OK: is_installed = True organization.api_token_status = Organization.API_TOKEN_STATUS_PENDING + organization.save(update_fields=["api_token_status"]) plugin_sync_organization_async.apply_async((organization.pk,)) except Organization.DoesNotExist: @@ -49,11 +51,11 @@ class PluginSyncView(GrafanaHeadersMixin, APIView): }, ) - def get(self, request: Request) -> Response: + def get(self, _request: Request) -> Response: stack_id = self.instance_context["stack_id"] org_id = self.instance_context["org_id"] - token_ok = False + try: organization = Organization.objects.get(stack_id=stack_id, org_id=org_id) if organization.api_token_status == Organization.API_TOKEN_STATUS_PENDING: diff --git a/engine/apps/user_management/models/organization.py b/engine/apps/user_management/models/organization.py index e2cd7f2f..0be08493 100644 --- a/engine/apps/user_management/models/organization.py +++ b/engine/apps/user_management/models/organization.py @@ -1,4 +1,5 @@ import logging +import typing from urllib.parse import urljoin from django.apps import apps @@ -32,6 +33,14 @@ def generate_public_primary_key_for_organization(): return new_public_primary_key +class ProvisionedPlugin(typing.TypedDict): + error: typing.Union[str, None] + stackId: int + orgId: int + onCallToken: str + license: str + + class OrganizationQuerySet(models.QuerySet): def create(self, **kwargs): instance = super().create(**kwargs) @@ -187,18 +196,14 @@ class Organization(MaintainableObject): class Meta: unique_together = ("stack_id", "org_id") - def provision_plugin(self) -> dict: + def provision_plugin(self) -> ProvisionedPlugin: PluginAuthToken = apps.get_model("auth_token", "PluginAuthToken") _, token = PluginAuthToken.create_auth_token(organization=self) return { - "pk": self.public_primary_key, - "jsonData": { - "stackId": self.stack_id, - "orgId": self.org_id, - "onCallApiUrl": settings.BASE_URL, - "license": settings.LICENSE, - }, - "secureJsonData": {"onCallToken": token}, + "stackId": self.stack_id, + "orgId": self.org_id, + "onCallToken": token, + "license": settings.LICENSE, } def revoke_plugin(self): diff --git a/engine/conftest.py b/engine/conftest.py index 4e88b798..7ac895b7 100644 --- a/engine/conftest.py +++ b/engine/conftest.py @@ -1,5 +1,6 @@ import json import sys +import typing import uuid from importlib import import_module, reload @@ -109,7 +110,6 @@ register(SMSFactory) register(EmailMessageFactory) register(IntegrationHeartBeatFactory) - register(LiveSettingFactory) @@ -178,12 +178,22 @@ def make_public_api_token(): @pytest.fixture def make_user_auth_headers(): - def _make_user_auth_headers(user, token): + def _make_user_auth_headers( + user, + token, + grafana_token: typing.Optional[str] = None, + grafana_context_data: typing.Optional[typing.Dict] = None, + ): + instance_context_headers = {"stack_id": user.organization.stack_id, "org_id": user.organization.org_id} + grafana_context_headers = {"UserId": user.user_id} + if grafana_token is not None: + instance_context_headers["grafana_token"] = grafana_token + if grafana_context_data is not None: + grafana_context_headers.update(grafana_context_data) + return { - "HTTP_X-Instance-Context": json.dumps( - {"stack_id": user.organization.stack_id, "org_id": user.organization.org_id} - ), - "HTTP_X-Grafana-Context": json.dumps({"UserId": user.user_id}), + "HTTP_X-Instance-Context": json.dumps(instance_context_headers), + "HTTP_X-Grafana-Context": json.dumps(grafana_context_headers), "HTTP_AUTHORIZATION": f"{token}", } diff --git a/engine/engine/management/commands/issue_invite_for_the_frontend.py b/engine/engine/management/commands/issue_invite_for_the_frontend.py deleted file mode 100644 index 927a2a9f..00000000 --- a/engine/engine/management/commands/issue_invite_for_the_frontend.py +++ /dev/null @@ -1,45 +0,0 @@ -from django.apps import apps -from django.core.management.base import BaseCommand - -from apps.auth_token import crypto - - -class Command(BaseCommand): - def add_arguments(self, parser): - parser.add_argument( - "--override", - action="store_true", - help="Allow overriding of existing invites.", - ) - - def handle(self, *args, **options): - self.stdout.write("-------------------------") - self.stdout.write("👋 This script will issue an invite token to securely connect the frontend.") - self.stdout.write( - f"Maintainers will be happy to help in the slack channel #grafana-oncall: https://slack.grafana.com/" - ) - - DynamicSetting = apps.get_model("base", "DynamicSetting") - self_hosted_settings = DynamicSetting.objects.get_or_create( - name="self_hosted_invitations", - defaults={ - "json_value": { - "keys": [], - } - }, - )[0] - - if options["override"]: - self_hosted_settings.json_value["keys"] = [] - else: - if len(self_hosted_settings.json_value["keys"]) > 0: - self.stdout.write( - f"Whoops, there is already an active invite in the DB. Override it with --override argument." - ) - return 0 - - invite_token = crypto.generate_token_string() - self_hosted_settings.json_value["keys"].append(invite_token) - self_hosted_settings.save(update_fields=["json_value"]) - - self.stdout.write(f"Your invite token: \033[31m{invite_token}\033[39m , use it in the Grafana OnCall plugin.") diff --git a/engine/settings/base.py b/engine/settings/base.py index 0dbdf92b..b50b19b6 100644 --- a/engine/settings/base.py +++ b/engine/settings/base.py @@ -559,6 +559,7 @@ SELF_HOSTED_SETTINGS = { "ORG_SLUG": "self_hosted_org", "ORG_TITLE": "Self-Hosted Organization", "REGION_SLUG": "self_hosted_region", + "GRAFANA_API_URL": os.environ.get("GRAFANA_API_URL", default=None), } GRAFANA_INCIDENT_STATIC_API_KEY = os.environ.get("GRAFANA_INCIDENT_STATIC_API_KEY", None) diff --git a/grafana-plugin/jest.config.js b/grafana-plugin/jest.config.js index 305a51f4..305169f5 100644 --- a/grafana-plugin/jest.config.js +++ b/grafana-plugin/jest.config.js @@ -14,7 +14,10 @@ module.exports = { 'jest/outgoingWebhooksStub': '/src/jest/outgoingWebhooksStub.ts', '^jest$': '/src/jest', '^.+\\.(css|scss)$': '/src/jest/styleMock.ts', + // '^.+\\.(ts|tsx)$': 'ts-jest', '^lodash-es$': 'lodash', - "^.+\\.svg$": "/src/jest/svgTransform.ts" + '^.+\\.svg$': '/src/jest/svgTransform.ts', }, -}; \ No newline at end of file + + setupFilesAfterEnv: ['/jest.setup.ts'], +}; diff --git a/grafana-plugin/jest.setup.ts b/grafana-plugin/jest.setup.ts new file mode 100644 index 00000000..0dc74191 --- /dev/null +++ b/grafana-plugin/jest.setup.ts @@ -0,0 +1,21 @@ +/** + * globally import this, avoids needing to import it in each file + * https://stackoverflow.com/a/65871118 + */ +import '@testing-library/jest-dom'; + +// https://stackoverflow.com/a/66055672 +// https://jestjs.io/docs/manual-mocks#mocking-methods-which-are-not-implemented-in-jsdom +Object.defineProperty(window, 'matchMedia', { + writable: true, + value: jest.fn().mockImplementation((query) => ({ + matches: false, + media: query, + onchange: null, + addListener: jest.fn(), // Deprecated + removeListener: jest.fn(), // Deprecated + addEventListener: jest.fn(), + removeEventListener: jest.fn(), + dispatchEvent: jest.fn(), + })), +}); diff --git a/grafana-plugin/package.json b/grafana-plugin/package.json index 2c6b910c..46d3cc39 100644 --- a/grafana-plugin/package.json +++ b/grafana-plugin/package.json @@ -62,14 +62,17 @@ "@jest/globals": "^27.5.1", "@testing-library/jest-dom": "^5.16.5", "@testing-library/react": "12", + "@testing-library/user-event": "^14.4.3", "@types/dompurify": "^2.3.4", "@types/jest": "27.5.1", "@types/lodash-es": "^4.17.6", + "@types/query-string": "^6.3.0", "@types/react-copy-to-clipboard": "^5.0.4", "@types/react-dom": "^18.0.6", "@types/react-responsive": "^8.0.5", "@types/react-router-dom": "^5.3.3", "@types/react-test-renderer": "^17.0.2", + "@types/react-transition-group": "^4.4.5", "@types/throttle-debounce": "^5.0.0", "@typescript-eslint/eslint-plugin": "^5.40.1", "babel-plugin-dynamic-import-node": "^2.3.3", @@ -101,8 +104,6 @@ "node": ">=14" }, "dependencies": { - "@types/query-string": "^6.3.0", - "@types/react-transition-group": "^4.4.5", "array-move": "^4.0.0", "change-case": "^4.1.1", "circular-dependency-plugin": "^5.2.2", diff --git a/grafana-plugin/src/__mocks__/@grafana/runtime.ts b/grafana-plugin/src/__mocks__/@grafana/runtime.ts new file mode 100644 index 00000000..84b17260 --- /dev/null +++ b/grafana-plugin/src/__mocks__/@grafana/runtime.ts @@ -0,0 +1,4 @@ +export const getBackendSrv = () => ({ + get: jest.fn(), + post: jest.fn(), +}); diff --git a/grafana-plugin/src/__mocks__/grafana/app/core/core.ts b/grafana-plugin/src/__mocks__/grafana/app/core/core.ts new file mode 100644 index 00000000..abe4971e --- /dev/null +++ b/grafana-plugin/src/__mocks__/grafana/app/core/core.ts @@ -0,0 +1,3 @@ +export const contextSrv = { + hasRole: jest.fn(), +}; diff --git a/grafana-plugin/src/components/Avatar/Avatar.test.tsx b/grafana-plugin/src/components/Avatar/Avatar.test.tsx index 504ab3c2..254b8d17 100644 --- a/grafana-plugin/src/components/Avatar/Avatar.test.tsx +++ b/grafana-plugin/src/components/Avatar/Avatar.test.tsx @@ -1,12 +1,9 @@ import React from 'react'; -import { describe, expect, test } from '@jest/globals'; import { render, screen } from '@testing-library/react'; import Avatar from 'components/Avatar/Avatar'; -import '@testing-library/jest-dom'; - describe('Avatar', () => { const avatarSrc = 'http://avatar.com/'; const avatarSizeLarge = 'large'; diff --git a/grafana-plugin/src/components/CardButton/CardButton.test.tsx b/grafana-plugin/src/components/CardButton/CardButton.test.tsx index d24adbdc..5d37eb69 100644 --- a/grafana-plugin/src/components/CardButton/CardButton.test.tsx +++ b/grafana-plugin/src/components/CardButton/CardButton.test.tsx @@ -1,10 +1,8 @@ import 'jest/matchMedia.ts'; import React from 'react'; -import { describe, expect, test } from '@jest/globals'; import { fireEvent, render, screen } from '@testing-library/react'; -import '@testing-library/jest-dom'; import CardButton from 'components/CardButton/CardButton'; describe('CardButton', () => { diff --git a/grafana-plugin/src/components/Collapse/Collapse.test.tsx b/grafana-plugin/src/components/Collapse/Collapse.test.tsx index d9e94395..1303dc41 100644 --- a/grafana-plugin/src/components/Collapse/Collapse.test.tsx +++ b/grafana-plugin/src/components/Collapse/Collapse.test.tsx @@ -1,13 +1,10 @@ import 'jest/matchMedia.ts'; import React from 'react'; -import { describe, expect, test } from '@jest/globals'; import { render, fireEvent, screen } from '@testing-library/react'; import Collapse, { CollapseProps } from 'components/Collapse/Collapse'; -import '@testing-library/jest-dom'; - describe('Collapse', () => { function getProps(isOpen: boolean, onClick: jest.Mock = jest.fn()) { return { diff --git a/grafana-plugin/src/components/SourceCode/SourceCode.test.tsx b/grafana-plugin/src/components/SourceCode/SourceCode.test.tsx index 592e65e8..cecef3ad 100644 --- a/grafana-plugin/src/components/SourceCode/SourceCode.test.tsx +++ b/grafana-plugin/src/components/SourceCode/SourceCode.test.tsx @@ -1,10 +1,8 @@ import 'jest/matchMedia.ts'; import React from 'react'; -import { describe, expect, test } from '@jest/globals'; import { render, screen } from '@testing-library/react'; -import '@testing-library/jest-dom'; import SourceCode from './SourceCode'; describe('SourceCode', () => { diff --git a/grafana-plugin/src/containers/DefaultPageLayout/DefaultPageLayout.tsx b/grafana-plugin/src/containers/DefaultPageLayout/DefaultPageLayout.tsx index 75d796fb..9f18e251 100644 --- a/grafana-plugin/src/containers/DefaultPageLayout/DefaultPageLayout.tsx +++ b/grafana-plugin/src/containers/DefaultPageLayout/DefaultPageLayout.tsx @@ -1,11 +1,11 @@ import plugin from '../../../package.json'; // eslint-disable-line import React, { FC, useEffect, useState, useCallback } from 'react'; -import { AppRootProps } from '@grafana/data'; import { getLocationSrv } from '@grafana/runtime'; import { Alert } from '@grafana/ui'; import cn from 'classnames/bind'; import { observer } from 'mobx-react'; +import { AppRootProps } from 'types'; import PluginLink from 'components/PluginLink/PluginLink'; import { getIfChatOpsConnected } from 'containers/DefaultPageLayout/helper'; diff --git a/grafana-plugin/src/containers/PluginConfigPage/PluginConfigPage.test.tsx b/grafana-plugin/src/containers/PluginConfigPage/PluginConfigPage.test.tsx new file mode 100644 index 00000000..14c1ad52 --- /dev/null +++ b/grafana-plugin/src/containers/PluginConfigPage/PluginConfigPage.test.tsx @@ -0,0 +1,321 @@ +import React from 'react'; + +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { useLocation as useLocationOriginal } from 'react-router-dom'; +import { OnCallPluginConfigPageProps } from 'types'; + +import PluginState from 'state/plugin'; + +import PluginConfigPage, { + reloadPageWithPluginConfiguredQueryParams, + removePluginConfiguredQueryParams, +} from './PluginConfigPage'; + +jest.mock('react-router-dom', () => ({ + useLocation: jest.fn(() => ({ + search: '', + })), +})); + +const useLocation = useLocationOriginal as jest.Mock>; + +enum License { + OSS = 'OpenSource', + CLOUD = 'some-other-license', +} + +const SELF_HOSTED_INSTALL_PLUGIN_ERROR_MESSAGE = 'ohhh nooo an error msg from self hosted install plugin'; +const CHECK_IF_PLUGIN_IS_CONNECTED_ERROR_MESSAGE = 'ohhh nooo a plugin connection error'; +const SNYC_DATA_WITH_ONCALL_ERROR_MESSAGE = 'ohhh noooo a sync issue'; +const PLUGIN_CONFIGURATION_FORM_DATA_ID = 'plugin-configuration-form'; +const STATUS_MESSAGE_BLOCK_DATA_ID = 'status-message-block'; + +const MOCK_PROTOCOL = 'https:'; +const MOCK_HOST = 'localhost:3000'; +const MOCK_PATHNAME = '/dkjdfjkfd'; +const MOCK_URL = `${MOCK_PROTOCOL}//${MOCK_HOST}${MOCK_PATHNAME}`; + +/** + * this is just a little hack to silence a warning that we'll get until we + * upgrade to 16.9. See also: https://github.com/facebook/react/pull/14853 + * https://github.com/testing-library/react-testing-library#suppressing-unnecessary-warnings-on-react-dom-168 + */ +const originalError = console.error; + +beforeEach(() => { + delete global.window.location; + global.window = Object.create(window); + global.window.location = { + protocol: MOCK_PROTOCOL, + host: MOCK_HOST, + pathname: MOCK_PATHNAME, + href: MOCK_URL, + } as Location; + global.window.history.pushState = jest.fn(); + + console.error = (...args) => { + if (/Warning.*not wrapped in act/.test(args[0])) { + return; + } + originalError.call(console, ...args); + }; +}); + +afterEach(() => { + jest.clearAllMocks(); + console.error = originalError; +}); + +const mockSyncDataWithOnCall = (license: License = License.OSS) => { + PluginState.syncDataWithOnCall = jest.fn().mockResolvedValueOnce({ + token_ok: true, + license, + version: 'v1.2.3', + }); +}; + +const generateComponentProps = ( + onCallApiUrl: OnCallPluginConfigPageProps['plugin']['meta']['jsonData']['onCallApiUrl'] = null, + enabled = false +): OnCallPluginConfigPageProps => + ({ + plugin: { + meta: { + jsonData: onCallApiUrl === null ? null : { onCallApiUrl }, + enabled, + }, + }, + } as OnCallPluginConfigPageProps); + +describe('reloadPageWithPluginConfiguredQueryParams', () => { + test.each([true, false])( + 'it modifies the query params depending on whether or not the plugin is already enabled: enabled - %s', + (pluginEnabled) => { + // mocks + const version = 'v1.2.3'; + const license = 'OpenSource'; + + // test + reloadPageWithPluginConfiguredQueryParams({ version, license }, pluginEnabled); + + // assertions + expect(window.location.href).toEqual( + pluginEnabled + ? MOCK_URL + : `${MOCK_URL}?pluginConfigured=true&pluginConfiguredLicense=${license}&pluginConfiguredVersion=${version}` + ); + } + ); +}); + +describe('removePluginConfiguredQueryParams', () => { + test('it removes all the query params if history.pushState is available, and plugin is enabled', () => { + removePluginConfiguredQueryParams(true); + expect(window.history.pushState).toBeCalledWith({ path: MOCK_URL }, '', MOCK_URL); + }); + + test('it does not remove all the query params if history.pushState is available, and plugin is disabled', () => { + removePluginConfiguredQueryParams(false); + expect(window.history.pushState).not.toHaveBeenCalled(); + }); +}); + +describe('PluginConfigPage', () => { + test('It removes the plugin configured query params if the plugin is enabled', async () => { + // mocks + const metaJsonDataOnCallApiUrl = 'onCallApiUrlFromMetaJsonData'; + PluginState.checkIfPluginIsConnected = jest.fn(); + mockSyncDataWithOnCall(); + + // test setup + render(); + await screen.findByTestId(STATUS_MESSAGE_BLOCK_DATA_ID); + + // assertions + expect(window.history.pushState).toBeCalledWith({ path: MOCK_URL }, '', MOCK_URL); + + expect(PluginState.checkIfPluginIsConnected).toHaveBeenCalledTimes(1); + expect(PluginState.checkIfPluginIsConnected).toHaveBeenCalledWith(metaJsonDataOnCallApiUrl); + + expect(PluginState.syncDataWithOnCall).toHaveBeenCalledTimes(1); + expect(PluginState.syncDataWithOnCall).toHaveBeenCalledWith(metaJsonDataOnCallApiUrl); + }); + + test("It doesn't make any network calls if the plugin configured query params are provided", async () => { + // mocks + const metaJsonDataOnCallApiUrl = 'onCallApiUrlFromMetaJsonData'; + const version = 'v1.2.3'; + const license = 'OpenSource'; + + useLocation.mockReturnValueOnce({ + search: `?pluginConfigured=true&pluginConfiguredLicense=${license}&pluginConfiguredVersion=${version}`, + } as ReturnType); + + PluginState.checkIfPluginIsConnected = jest.fn(); + mockSyncDataWithOnCall(); + + // test setup + const component = render(); + await screen.findByTestId(STATUS_MESSAGE_BLOCK_DATA_ID); + + // assertions + expect(PluginState.checkIfPluginIsConnected).not.toHaveBeenCalled(); + expect(PluginState.syncDataWithOnCall).not.toHaveBeenCalled(); + expect(component.container).toMatchSnapshot(); + }); + + test("If onCallApiUrl is not set in the plugin's meta jsonData, or in process.env, checkIfPluginIsConnected is not called, and the configuration form is shown", async () => { + // mocks + delete process.env.ONCALL_API_URL; + + PluginState.checkIfPluginIsConnected = jest.fn(); + PluginState.syncDataWithOnCall = jest.fn(); + + // test setup + const component = render(); + await screen.findByTestId(PLUGIN_CONFIGURATION_FORM_DATA_ID); + + // assertions + expect(PluginState.checkIfPluginIsConnected).not.toHaveBeenCalled(); + expect(PluginState.syncDataWithOnCall).not.toHaveBeenCalled(); + expect(component.container).toMatchSnapshot(); + }); + + test("If onCallApiUrl is not set in the plugin's meta jsonData, and ONCALL_API_URL is passed in process.env, it calls selfHostedInstallPlugin", async () => { + // mocks + const processEnvOnCallApiUrl = 'onCallApiUrlFromProcessEnv'; + process.env.ONCALL_API_URL = processEnvOnCallApiUrl; + + PluginState.selfHostedInstallPlugin = jest.fn(); + mockSyncDataWithOnCall(); + + // test setup + render(); + + // assertions + expect(PluginState.selfHostedInstallPlugin).toHaveBeenCalledTimes(1); + expect(PluginState.selfHostedInstallPlugin).toHaveBeenCalledWith(processEnvOnCallApiUrl, true); + }); + + test("If onCallApiUrl is not set in the plugin's meta jsonData, and ONCALL_API_URL is passed in process.env, and there is an error calling selfHostedInstallPlugin, it sets an error message", async () => { + // mocks + const processEnvOnCallApiUrl = 'onCallApiUrlFromProcessEnv'; + process.env.ONCALL_API_URL = processEnvOnCallApiUrl; + + PluginState.selfHostedInstallPlugin = jest.fn().mockResolvedValueOnce(SELF_HOSTED_INSTALL_PLUGIN_ERROR_MESSAGE); + + // test setup + const component = render(); + await screen.findByTestId(STATUS_MESSAGE_BLOCK_DATA_ID); + + // assertions + expect(PluginState.selfHostedInstallPlugin).toHaveBeenCalledTimes(1); + expect(PluginState.selfHostedInstallPlugin).toHaveBeenCalledWith(processEnvOnCallApiUrl, true); + expect(component.container).toMatchSnapshot(); + }); + + test('If onCallApiUrl is set, and checkIfPluginIsConnected returns an error, it sets an error message', async () => { + // mocks + const processEnvOnCallApiUrl = 'onCallApiUrlFromProcessEnv'; + const metaJsonDataOnCallApiUrl = 'onCallApiUrlFromMetaJsonData'; + + process.env.ONCALL_API_URL = processEnvOnCallApiUrl; + + PluginState.checkIfPluginIsConnected = jest.fn().mockResolvedValueOnce(CHECK_IF_PLUGIN_IS_CONNECTED_ERROR_MESSAGE); + + // test setup + const component = render(); + await screen.findByTestId(STATUS_MESSAGE_BLOCK_DATA_ID); + + // assertions + expect(PluginState.checkIfPluginIsConnected).toHaveBeenCalledTimes(1); + expect(PluginState.checkIfPluginIsConnected).toHaveBeenCalledWith(metaJsonDataOnCallApiUrl); + expect(component.container).toMatchSnapshot(); + }); + + test('OnCallApiUrl is set, and syncDataWithOnCall returns an error', async () => { + // mocks + const processEnvOnCallApiUrl = 'onCallApiUrlFromProcessEnv'; + const metaJsonDataOnCallApiUrl = 'onCallApiUrlFromMetaJsonData'; + + process.env.ONCALL_API_URL = processEnvOnCallApiUrl; + + PluginState.checkIfPluginIsConnected = jest.fn().mockResolvedValueOnce(null); + PluginState.syncDataWithOnCall = jest.fn().mockResolvedValueOnce(SNYC_DATA_WITH_ONCALL_ERROR_MESSAGE); + + // test setup + const component = render(); + await screen.findByTestId(STATUS_MESSAGE_BLOCK_DATA_ID); + + // assertions + expect(PluginState.checkIfPluginIsConnected).toHaveBeenCalledTimes(1); + expect(PluginState.checkIfPluginIsConnected).toHaveBeenCalledWith(metaJsonDataOnCallApiUrl); + expect(component.container).toMatchSnapshot(); + }); + + test.each([License.CLOUD, License.OSS])( + 'OnCallApiUrl is set, and syncDataWithOnCall does not return an error. It displays properly the plugin connected items based on the license - License: %s', + async (license) => { + // mocks + const processEnvOnCallApiUrl = 'onCallApiUrlFromProcessEnv'; + const metaJsonDataOnCallApiUrl = 'onCallApiUrlFromMetaJsonData'; + + process.env.ONCALL_API_URL = processEnvOnCallApiUrl; + + PluginState.checkIfPluginIsConnected = jest.fn().mockResolvedValueOnce(null); + mockSyncDataWithOnCall(license); + + // test setup + const component = render(); + await screen.findByTestId(STATUS_MESSAGE_BLOCK_DATA_ID); + + // assertions + expect(PluginState.checkIfPluginIsConnected).toHaveBeenCalledTimes(1); + expect(PluginState.checkIfPluginIsConnected).toHaveBeenCalledWith(metaJsonDataOnCallApiUrl); + expect(component.container).toMatchSnapshot(); + } + ); + + test.each([true, false])('Plugin reset: successful - %s', async (successful) => { + // mocks + const processEnvOnCallApiUrl = 'onCallApiUrlFromProcessEnv'; + const metaJsonDataOnCallApiUrl = 'onCallApiUrlFromMetaJsonData'; + + process.env.ONCALL_API_URL = processEnvOnCallApiUrl; + + PluginState.checkIfPluginIsConnected = jest.fn().mockResolvedValueOnce(null); + mockSyncDataWithOnCall(License.OSS); + + if (successful) { + PluginState.resetPlugin = jest.fn().mockResolvedValueOnce(null); + } else { + PluginState.resetPlugin = jest.fn().mockRejectedValueOnce('dfdf'); + } + + // test setup + const component = render(); + const user = userEvent.setup(); + + const button = await screen.findByRole('button'); + + // click the reset button, which opens the modal + await user.click(button); + // click the confirm button within the modal, which actually triggers the callback + await user.click(screen.getByText('Remove')); + + await screen.findByTestId(successful ? PLUGIN_CONFIGURATION_FORM_DATA_ID : STATUS_MESSAGE_BLOCK_DATA_ID); + + // assertions + expect(PluginState.checkIfPluginIsConnected).toHaveBeenCalledTimes(1); + expect(PluginState.checkIfPluginIsConnected).toHaveBeenCalledWith(metaJsonDataOnCallApiUrl); + + expect(PluginState.syncDataWithOnCall).toHaveBeenCalledTimes(1); + expect(PluginState.syncDataWithOnCall).toHaveBeenCalledWith(metaJsonDataOnCallApiUrl); + + expect(PluginState.resetPlugin).toHaveBeenCalledTimes(1); + expect(PluginState.resetPlugin).toHaveBeenCalledWith(); + + expect(component.container).toMatchSnapshot(); + }); +}); diff --git a/grafana-plugin/src/containers/PluginConfigPage/PluginConfigPage.tsx b/grafana-plugin/src/containers/PluginConfigPage/PluginConfigPage.tsx index 7ff7353a..9b44999e 100644 --- a/grafana-plugin/src/containers/PluginConfigPage/PluginConfigPage.tsx +++ b/grafana-plugin/src/containers/PluginConfigPage/PluginConfigPage.tsx @@ -1,342 +1,246 @@ -import React, { useCallback, useEffect, useState } from 'react'; +import React, { FC, useCallback, useEffect, useState } from 'react'; -import { AppPluginMeta, PluginConfigPageProps } from '@grafana/data'; -import { getBackendSrv } from '@grafana/runtime'; -import { Button, Field, HorizontalGroup, VerticalGroup, Input, Label, Legend, LoadingPlaceholder } from '@grafana/ui'; -import { AxiosError } from 'axios'; -import cn from 'classnames/bind'; -import { OnCallAppSettings } from 'types'; +import { Button, Label, Legend, LoadingPlaceholder } from '@grafana/ui'; +import { useLocation } from 'react-router-dom'; +import { OnCallPluginConfigPageProps } from 'types'; -import Block from 'components/GBlock/Block'; -import Text from 'components/Text/Text'; -import WithConfirm from 'components/WithConfirm/WithConfirm'; import logo from 'img/logo.svg'; -import { makeRequest } from 'network'; -import { - createGrafanaToken, - getPluginSyncStatus, - startPluginSync, - SYNC_STATUS_RETRY_LIMIT, - syncStatusDelay, - updateGrafanaToken, -} from 'state/plugin'; +import PluginState, { PluginStatusResponseBase } from 'state/plugin'; import { GRAFANA_LICENSE_OSS } from 'utils/consts'; -import { getItem, setItem } from 'utils/localStorage'; -import { constructSyncErrorMessage, constructErrorActionMessage } from './helpers'; +import ConfigurationForm from './parts/ConfigurationForm'; +import RemoveCurrentConfigurationButton from './parts/RemoveCurrentConfigurationButton'; +import StatusMessageBlock from './parts/StatusMessageBlock'; -import styles from './PluginConfigPage.module.css'; +const PLUGIN_CONFIGURED_QUERY_PARAM = 'pluginConfigured'; +const PLUGIN_CONFIGURED_QUERY_PARAM_TRUTHY_VALUE = 'true'; -const cx = cn.bind(styles); +const PLUGIN_CONFIGURED_LICENSE_QUERY_PARAM = 'pluginConfiguredLicense'; +const PLUGIN_CONFIGURED_VERSION_QUERY_PARAM = 'pluginConfiguredVersion'; -interface Props extends PluginConfigPageProps> {} +/** + * When everything is successfully configured, reload the page, and pass along a few query parameters + * so that we avoid an infinite configuration-check/data-sync loop + * + * Don't refresh the page if the plugin is already enabled.. + */ +export const reloadPageWithPluginConfiguredQueryParams = ( + { license, version }: PluginStatusResponseBase, + pluginEnabled: boolean +): void => { + if (!pluginEnabled) { + window.location.href = `${window.location.href}?${PLUGIN_CONFIGURED_QUERY_PARAM}=${PLUGIN_CONFIGURED_QUERY_PARAM_TRUTHY_VALUE}&${PLUGIN_CONFIGURED_LICENSE_QUERY_PARAM}=${license}&${PLUGIN_CONFIGURED_VERSION_QUERY_PARAM}=${version}`; + } +}; -export const PluginConfigPage = (props: Props) => { - const { plugin } = props; - const [onCallApiUrl, setOnCallApiUrl] = useState(getItem('onCallApiUrl')); - const [onCallInvitationToken, setOnCallInvitationToken] = useState(); - const [grafanaUrl, setGrafanaUrl] = useState(getItem('grafanaUrl')); - const [pluginConfigLoading, setPluginConfigLoading] = useState(true); - const [pluginStatusOk, setPluginStatusOk] = useState(); - const [pluginStatusMessage, setPluginStatusMessage] = useState(); - const [isSelfHostedInstall, setIsSelfHostedInstall] = useState(true); - const [retrySync, setRetrySync] = useState(false); +/** + * remove the query params used to track state for a page reload after successful configuration, without triggering + * a page reload + * https://stackoverflow.com/a/19279428 + */ +export const removePluginConfiguredQueryParams = (pluginIsEnabled: boolean): void => { + if (history.pushState && pluginIsEnabled) { + const newurl = `${window.location.protocol}//${window.location.host}${window.location.pathname}`; + window.history.pushState({ path: newurl }, '', newurl); + } +}; - const INVALID_INVITE_TOKEN_ERROR_MSG = `It seems like your invite token may be invalid. ${constructErrorActionMessage( - 'generating a new invite token' - )}`; +const PluginConfigPage: FC = ({ + plugin: { + meta: { jsonData, enabled: pluginIsEnabled }, + }, +}) => { + const { search } = useLocation(); + const queryParams = new URLSearchParams(search); + const pluginConfiguredQueryParam = queryParams.get(PLUGIN_CONFIGURED_QUERY_PARAM); + const pluginConfiguredLicenseQueryParam = queryParams.get(PLUGIN_CONFIGURED_LICENSE_QUERY_PARAM); + const pluginConfiguredVersionQueryParam = queryParams.get(PLUGIN_CONFIGURED_VERSION_QUERY_PARAM); - const setupPlugin = useCallback(async () => { - setItem('onCallApiUrl', onCallApiUrl); - setItem('grafanaUrl', grafanaUrl); - await getBackendSrv().post(`/api/plugins/grafana-oncall-app/settings`, { - enabled: true, - pinned: true, - jsonData: { - onCallApiUrl: onCallApiUrl, - grafanaUrl: grafanaUrl, - }, - secureJsonData: { - onCallInvitationToken: onCallInvitationToken, - }, - }); + const pluginConfiguredRedirect = pluginConfiguredQueryParam === PLUGIN_CONFIGURED_QUERY_PARAM_TRUTHY_VALUE; - const grafanaToken = await createGrafanaToken(); - await updateGrafanaToken(grafanaToken.key); + const [checkingIfPluginIsConnected, setCheckingIfPluginIsConnected] = useState(!pluginConfiguredRedirect); + const [pluginConnectionCheckError, setPluginConnectionCheckError] = useState(null); + const [pluginIsConnected, setPluginIsConnected] = useState( + pluginConfiguredRedirect + ? { version: pluginConfiguredVersionQueryParam, license: pluginConfiguredLicenseQueryParam } + : null + ); - let provisioningConfig; - try { - provisioningConfig = await makeRequest('/plugin/self-hosted/install', { method: 'POST' }); - } catch (e) { - if (e.response.status === 502) { - console.warn('Could not connect to OnCall: ' + onCallApiUrl); - } else if (e.response.status === 403) { - console.warn('Invitation token is invalid or expired.'); - } else { - console.warn('Expected error: ' + e.response.status); - } - } + const [syncingPlugin, setSyncingPlugin] = useState(false); + const [syncError, setSyncError] = useState(null); - if (provisioningConfig) { - await getBackendSrv().post(`/api/plugins/grafana-oncall-app/settings`, { - enabled: true, - pinned: true, - jsonData: { - stackId: provisioningConfig.jsonData.stackId, - orgId: provisioningConfig.jsonData.orgId, - onCallApiUrl: onCallApiUrl, - grafanaUrl: grafanaUrl, - license: provisioningConfig.jsonData.license, - }, - secureJsonData: { - grafanaToken: grafanaToken.key, - onCallApiToken: provisioningConfig.secureJsonData.onCallToken, - }, - }); - } + const [resettingPlugin, setResettingPlugin] = useState(false); + const [pluginResetError, setPluginResetError] = useState(null); - window.location.reload(); - }, [onCallApiUrl, onCallInvitationToken, grafanaUrl]); + const pluginMetaOnCallApiUrl = jsonData?.onCallApiUrl; + const processEnvOnCallApiUrl = process.env.ONCALL_API_URL; // don't destructure this, will break how webpack supplies this + const onCallApiUrl = pluginMetaOnCallApiUrl || processEnvOnCallApiUrl; + const licenseType = pluginIsConnected?.license; - const resetPlugin = useCallback(async () => { - await getBackendSrv().post(`/api/plugins/grafana-oncall-app/settings`, { - enabled: false, - pinned: true, - jsonData: { - stackId: null, - orgId: null, - onCallApiUrl: null, - grafanaUrl: null, - }, - secureJsonData: { - grafanaToken: null, - onCallApiToken: null, - }, - }); + const resetQueryParams = useCallback(() => removePluginConfiguredQueryParams(pluginIsEnabled), [pluginIsEnabled]); - window.location.reload(); - }, []); + const triggerDataSyncWithOnCall = useCallback(async () => { + setSyncingPlugin(true); + setSyncError(null); - const handleApiUrlChange = useCallback((e) => { - setOnCallApiUrl(e.target.value); - }, []); + const syncDataResponse = await PluginState.syncDataWithOnCall(onCallApiUrl); - const handleInvitationTokenChange = useCallback((e) => { - setOnCallInvitationToken(e.target.value); - }, []); - - const handleGrafanaUrlChange = useCallback((e) => { - setGrafanaUrl(e.target.value); - }, []); - - const handleSyncException = useCallback((e: AxiosError) => { - const buildErrMsg = (msg: string): string => constructSyncErrorMessage(msg, plugin.meta.jsonData?.onCallApiUrl); - - if (plugin.meta.jsonData?.onCallApiUrl) { - const { status: statusCode } = e.response; - - let statusMessage: string; - - if (statusCode === 403) { - statusMessage = buildErrMsg(INVALID_INVITE_TOKEN_ERROR_MSG); - } else if (statusCode === 404) { - statusMessage = buildErrMsg( - 'If Grafana OnCall was just installed, restart Grafana for OnCall routes to be available.' - ); - } else if (statusCode === 502) { - statusMessage = buildErrMsg( - `Unable to communicate with either the Grafana API, or Grafana OnCall engine API. ${constructErrorActionMessage( - 'verify that the API URLs that you entered are correct' - )}` - ); - } else { - statusMessage = buildErrMsg( - `An unknown error occured. ${constructErrorActionMessage()}. If the error still occurs please reach out to support.` - ); - } - setPluginStatusMessage(statusMessage); - setRetrySync(true); + if (typeof syncDataResponse === 'string') { + setSyncError(syncDataResponse); } else { - setPluginStatusMessage(buildErrMsg('OnCall has not been setup, configure & initialize below.')); - } - setPluginStatusOk(false); - setPluginConfigLoading(false); - }, []); - - const finishSync = useCallback((getSyncResponse) => { - if (getSyncResponse.token_ok) { - const versionInfo = - getSyncResponse.version && getSyncResponse.license - ? ` (${getSyncResponse.license}, ${getSyncResponse.version})` - : ''; - - let pluginStatusMessage = `Connected to OnCall${versionInfo}\n - OnCall URL: ${plugin.meta.jsonData.onCallApiUrl}\n`; - if (plugin.meta.jsonData.grafanaUrl) { - pluginStatusMessage = `${pluginStatusMessage} - Grafana URL: ${plugin.meta.jsonData.grafanaUrl}`; - } - - setPluginStatusMessage(pluginStatusMessage); - setIsSelfHostedInstall(plugin.meta.jsonData?.license === GRAFANA_LICENSE_OSS); - setPluginStatusOk(true); - } else { - setPluginStatusMessage( - constructSyncErrorMessage(INVALID_INVITE_TOKEN_ERROR_MSG, plugin.meta.jsonData.grafanaUrl) - ); - setRetrySync(true); - } - setPluginConfigLoading(false); - }, []); - - const waitForSyncStatus = (retryCount = 0) => { - if (retryCount > SYNC_STATUS_RETRY_LIMIT) { - setPluginStatusMessage( - `OnCall took too many tries to synchronize. Did you launch Celery workers? Background workers should perform synchronization, not web server.` - ); - setRetrySync(true); - setPluginStatusOk(false); - setPluginConfigLoading(false); - return; + const { token_ok, ...versionLicenseInfo } = syncDataResponse; + setPluginIsConnected(versionLicenseInfo); + reloadPageWithPluginConfiguredQueryParams(versionLicenseInfo, pluginIsEnabled); } - getPluginSyncStatus() - .then((get_sync_response) => { - if (get_sync_response.hasOwnProperty('token_ok')) { - finishSync(get_sync_response); - } else { - syncStatusDelay(retryCount + 1).then(() => waitForSyncStatus(retryCount + 1)); + setSyncingPlugin(false); + }, [onCallApiUrl, pluginIsEnabled]); + + useEffect(resetQueryParams, [resetQueryParams]); + + useEffect(() => { + const configurePluginAndSyncData = async () => { + /** + * If the plugin has never been configured, onCallApiUrl will be undefined in the plugin's jsonData + * In that case, check to see if ONCALL_API_URL has been supplied as an env var. + * Supplying the env var basically allows to skip the configuration form + * (check webpack.config.js to see how this is set) + */ + if (!pluginMetaOnCallApiUrl && processEnvOnCallApiUrl) { + /** + * onCallApiUrl is not yet saved in the grafana plugin settings, but has been supplied as an env var + * lets auto-trigger a self-hosted plugin install w/ the onCallApiUrl passed in as an env var + */ + const errorMsg = await PluginState.selfHostedInstallPlugin(processEnvOnCallApiUrl, true); + if (errorMsg) { + setPluginConnectionCheckError(errorMsg); + setCheckingIfPluginIsConnected(false); + return; } - }) - .catch(handleSyncException); - }; + } - const startSync = useCallback(() => { - setRetrySync(false); - setPluginConfigLoading(true); - startPluginSync() - .then(() => waitForSyncStatus()) - .catch(handleSyncException); - }, []); + /** + * If the onCallApiUrl is not set in the plugin settings, and not supplied via an env var + * there's no reason to check if the plugin is connected, we know it can't be + */ + if (onCallApiUrl) { + const pluginConnectionResponse = await PluginState.checkIfPluginIsConnected(onCallApiUrl); - useEffect(startSync, []); + if (typeof pluginConnectionResponse === 'string') { + setPluginConnectionCheckError(pluginConnectionResponse); + } else { + triggerDataSyncWithOnCall(); + } + } + setCheckingIfPluginIsConnected(false); + }; + + /** + * don't check the plugin status (or trigger a data sync) if the user was just redirected after a successful + * plugin setup + */ + if (!pluginConfiguredRedirect) { + configurePluginAndSyncData(); + } + }, [pluginMetaOnCallApiUrl, processEnvOnCallApiUrl, onCallApiUrl, pluginConfiguredRedirect]); + + const resetState = useCallback(() => { + setPluginResetError(null); + setPluginConnectionCheckError(null); + setPluginIsConnected(null); + setSyncError(null); + resetQueryParams(); + }, [resetQueryParams]); + + /** + * NOTE: there is a possible edge case when resetting the plugin, that would lead to an error message being shown + * (which could be fixed by just reloading the page) + * This would happen if the user removes the plugin configuration, leaves the page, then comes back to the plugin + * configuration. + * + * This is because the props being passed into this component wouldn't reflect the actual plugin + * provisioning state. The props would still have onCallApiUrl set in the plugin jsonData, so when we make the API + * call to check the plugin state w/ OnCall API the plugin-proxy would return a 502 Bad Gateway because the actual + * provisioned plugin doesn't know about the onCallApiUrl. + * + * This could be fixed by instead of passing in the plugin provisioning information as props always fetching it + * when this component renders (via a useEffect). We probably don't need to worry about this because it should happen + * very rarely, if ever + */ + const triggerPluginReset = useCallback(async () => { + setResettingPlugin(true); + resetState(); + + try { + await PluginState.resetPlugin(); + } catch (e) { + // this should rarely, if ever happen, but we should handle the case nevertheless + setPluginResetError('There was an error resetting your plugin, try again.'); + } + + setResettingPlugin(false); + }, [resetState]); + + const RemoveConfigButton = useCallback( + () => , + [resettingPlugin, triggerPluginReset] + ); + + let content: React.ReactNode; + + if (checkingIfPluginIsConnected) { + content = ; + } else if (syncingPlugin) { + content = ; + } else if (pluginConnectionCheckError || pluginResetError) { + content = ( + <> + + + + ); + } else if (syncError) { + content = ( + <> + + + + ); + } else if (!pluginIsConnected) { + content = ( + + ); + } else { + // plugin is fully connected and synced + content = + licenseType === GRAFANA_LICENSE_OSS ? ( + + ) : ( + + ); + } return ( -
- {pluginConfigLoading ? ( - - ) : pluginStatusOk || retrySync ? ( + <> + Configure Grafana OnCall + {pluginIsConnected ? ( <> - Configure Grafana OnCall - {pluginStatusOk && ( -

- Plugin and the backend are connected! Check Grafana OnCall 👈👈👈{' '} - Grafana OnCall Logo -

- )} -

{'Plugin <-> backend connection status'}

-
-            {pluginStatusMessage}
-          
- - - {retrySync && ( - - )} - {isSelfHostedInstall ? ( - - - - ) : ( - - )}{' '} - +

+ Plugin is connected! Continue to Grafana OnCall by clicking the{' '} + Grafana OnCall Logo icon over there 👈 +

+ ) : ( - - Configure Grafana OnCall -

This page will help you to connect OnCall backend and OnCall Grafana plugin 👋

- -

1. Launch backend

- - - Run hobby, dev or production backend:{' '} - - getting started. - - - - - - Need help? -
- Talk to the OnCall team in the #grafana-oncall channel at{' '} - - Slack - -
- Ask questions at{' '} - - GitHub Discussions - {' '} - or file bugs at{' '} - - GitHub Issues - -
-
- -

2. Conect the backend and the plugin

-

{'Plugin <-> backend connection status:'}

-
-            {pluginStatusMessage}
-          
- - <> - - - - How to re-issue the invite token? - - - - - - - It should be reachable from Grafana. Possible options:
- http://host.docker.internal:8080 (if you run backend in the docker locally) -
- http://localhost:8080
- ... - - } - > - -
- - - - -
+

This page will help you configure the OnCall plugin 👋

)} -
+ {content} + ); }; + +export default PluginConfigPage; diff --git a/grafana-plugin/src/containers/PluginConfigPage/__snapshots__/PluginConfigPage.test.tsx.snap b/grafana-plugin/src/containers/PluginConfigPage/__snapshots__/PluginConfigPage.test.tsx.snap new file mode 100644 index 00000000..b8601b10 --- /dev/null +++ b/grafana-plugin/src/containers/PluginConfigPage/__snapshots__/PluginConfigPage.test.tsx.snap @@ -0,0 +1,449 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`PluginConfigPage If onCallApiUrl is not set in the plugin's meta jsonData, and ONCALL_API_URL is passed in process.env, and there is an error calling selfHostedInstallPlugin, it sets an error message 1`] = ` +
+ + Configure Grafana OnCall + +

+ This page will help you configure the OnCall plugin 👋 +

+
+    
+      ohhh nooo an error msg from self hosted install plugin
+    
+  
+ +
+`; + +exports[`PluginConfigPage If onCallApiUrl is not set in the plugin's meta jsonData, or in process.env, checkIfPluginIsConnected is not called, and the configuration form is shown 1`] = ` +
+ + Configure Grafana OnCall + +

+ This page will help you configure the OnCall plugin 👋 +

+
+
+

+ 1. Launch the OnCall backend +

+ + Run hobby, dev or production backend. See + + + + here + + + + on how to get started. + +
+
+

+ 2. Let us know the base URL of your OnCall API +

+ + The OnCall backend must be reachable from your Grafana installation. Some examples are: +
+ - http://host.docker.internal:8080 +
+ - http://localhost:8080 +
+
+
+
+ +
+
+
+
+ +
+
+
+
+ +
+
+`; + +exports[`PluginConfigPage If onCallApiUrl is set, and checkIfPluginIsConnected returns an error, it sets an error message 1`] = ` +
+ + Configure Grafana OnCall + +

+ This page will help you configure the OnCall plugin 👋 +

+
+    
+      ohhh nooo a plugin connection error
+    
+  
+ +
+`; + +exports[`PluginConfigPage It doesn't make any network calls if the plugin configured query params are provided 1`] = ` +
+ + Configure Grafana OnCall + +

+ Plugin is connected! Continue to Grafana OnCall by clicking the + + Grafana OnCall Logo + icon over there 👈 +

+
+    
+      Connected to OnCall (v1.2.3, OpenSource)
+    
+  
+ +
+`; + +exports[`PluginConfigPage OnCallApiUrl is set, and syncDataWithOnCall does not return an error. It displays properly the plugin connected items based on the license - License: OpenSource 1`] = ` +
+ + Configure Grafana OnCall + +

+ Plugin is connected! Continue to Grafana OnCall by clicking the + + Grafana OnCall Logo + icon over there 👈 +

+
+    
+      Connected to OnCall (v1.2.3, OpenSource)
+    
+  
+ +
+`; + +exports[`PluginConfigPage OnCallApiUrl is set, and syncDataWithOnCall does not return an error. It displays properly the plugin connected items based on the license - License: some-other-license 1`] = ` +
+ + Configure Grafana OnCall + +

+ Plugin is connected! Continue to Grafana OnCall by clicking the + + Grafana OnCall Logo + icon over there 👈 +

+
+    
+      Connected to OnCall (v1.2.3, some-other-license)
+    
+  
+
+ +
+
+`; + +exports[`PluginConfigPage OnCallApiUrl is set, and syncDataWithOnCall returns an error 1`] = ` +
+ + Configure Grafana OnCall + +

+ This page will help you configure the OnCall plugin 👋 +

+
+    
+      ohhh noooo a sync issue
+    
+  
+ +
+`; + +exports[`PluginConfigPage Plugin reset: successful - false 1`] = ` +
+ + Configure Grafana OnCall + +

+ This page will help you configure the OnCall plugin 👋 +

+
+    
+      There was an error resetting your plugin, try again.
+    
+  
+ +
+`; + +exports[`PluginConfigPage Plugin reset: successful - true 1`] = ` +
+ + Configure Grafana OnCall + +

+ This page will help you configure the OnCall plugin 👋 +

+
+
+

+ 1. Launch the OnCall backend +

+ + Run hobby, dev or production backend. See + + + + here + + + + on how to get started. + +
+
+

+ 2. Let us know the base URL of your OnCall API +

+ + The OnCall backend must be reachable from your Grafana installation. Some examples are: +
+ - http://host.docker.internal:8080 +
+ - http://localhost:8080 +
+
+
+
+ +
+
+
+
+ +
+
+
+
+ +
+
+`; diff --git a/grafana-plugin/src/containers/PluginConfigPage/helpers.tsx b/grafana-plugin/src/containers/PluginConfigPage/helpers.tsx deleted file mode 100644 index e6d4da0c..00000000 --- a/grafana-plugin/src/containers/PluginConfigPage/helpers.tsx +++ /dev/null @@ -1,6 +0,0 @@ -export const constructSyncErrorMessage = (errMsg: string, url?: string): string => `${url ? `${url}\n` : ''}${errMsg}`; - -export const constructErrorActionMessage = (msg?: string): string => - `Try removing your current configuration, ${ - msg ? msg : 'double checking your settings' - }, and re-initializing the plugin.\nBy removing your current configuration, you will need to ensure that you regenerate a new invite token, and input this in your new configuration.`; diff --git a/grafana-plugin/src/containers/PluginConfigPage/PluginConfigPage.module.css b/grafana-plugin/src/containers/PluginConfigPage/parts/ConfigurationForm/ConfigurationForm.module.css similarity index 63% rename from grafana-plugin/src/containers/PluginConfigPage/PluginConfigPage.module.css rename to grafana-plugin/src/containers/PluginConfigPage/parts/ConfigurationForm/ConfigurationForm.module.css index d0eafdf8..5c2d1cfd 100644 --- a/grafana-plugin/src/containers/PluginConfigPage/PluginConfigPage.module.css +++ b/grafana-plugin/src/containers/PluginConfigPage/parts/ConfigurationForm/ConfigurationForm.module.css @@ -1,7 +1,3 @@ -.command-line { - width: 100%; -} - .info-block { margin-bottom: 24px; margin-top: 24px; diff --git a/grafana-plugin/src/containers/PluginConfigPage/parts/ConfigurationForm/ConfigurationForm.test.tsx b/grafana-plugin/src/containers/PluginConfigPage/parts/ConfigurationForm/ConfigurationForm.test.tsx new file mode 100644 index 00000000..ab5ba96c --- /dev/null +++ b/grafana-plugin/src/containers/PluginConfigPage/parts/ConfigurationForm/ConfigurationForm.test.tsx @@ -0,0 +1,76 @@ +import React from 'react'; + +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; + +import PluginState from 'state/plugin'; + +import ConfigurationForm from '.'; + +jest.mock('state/plugin'); + +const VALID_ONCALL_API_URL = 'http://host.docker.internal:8080'; +const SELF_HOSTED_PLUGIN_API_ERROR_MSG = 'ohhh nooo there was an error from the OnCall API'; + +const fillOutFormAndTryToSubmit = async (onCallApiUrl: string, selfHostedInstallPluginSuccess = true) => { + // mocks + const mockOnSuccessfulSetup = jest.fn(); + PluginState.selfHostedInstallPlugin = jest + .fn() + .mockResolvedValueOnce(selfHostedInstallPluginSuccess ? null : SELF_HOSTED_PLUGIN_API_ERROR_MSG); + + // setup + const user = userEvent.setup(); + const component = render( + + ); + + // fill out onCallApiUrl input + const input = screen.getByTestId('onCallApiUrl'); + + await user.click(input); + await user.clear(input); // clear the input first before typing to wipe out the placeholder text + await user.keyboard(onCallApiUrl); + + // submit form + await user.click(screen.getByRole('button')); + + return { dom: component.baseElement, mockOnSuccessfulSetup }; +}; + +describe('ConfigurationForm', () => { + afterEach(() => { + jest.resetAllMocks(); + }); + + test('it sets the default input value of onCallApiUrl to the passed in prop value of defaultOnCallApiUrl', () => { + const processEnvOnCallApiUrl = 'http://hello.com'; + render(); + expect(screen.getByDisplayValue(processEnvOnCallApiUrl)).toBeInTheDocument(); + }); + + test('It calls the onSuccessfulSetup callback on successful form submission', async () => { + const { mockOnSuccessfulSetup } = await fillOutFormAndTryToSubmit(VALID_ONCALL_API_URL); + + expect(PluginState.selfHostedInstallPlugin).toHaveBeenCalledTimes(1); + expect(PluginState.selfHostedInstallPlugin).toHaveBeenCalledWith(VALID_ONCALL_API_URL, false); + expect(mockOnSuccessfulSetup).toHaveBeenCalledTimes(1); + }); + + test("It doesn't allow the user to submit if the URL is invalid", async () => { + const { dom, mockOnSuccessfulSetup } = await fillOutFormAndTryToSubmit('potato'); + + expect(PluginState.selfHostedInstallPlugin).toHaveBeenCalledTimes(0); + expect(mockOnSuccessfulSetup).toHaveBeenCalledTimes(0); + expect(screen.getByRole('button')).toBeDisabled(); + expect(dom).toMatchSnapshot(); + }); + + test('It shows an error message if the self hosted plugin API call fails', async () => { + const { dom, mockOnSuccessfulSetup } = await fillOutFormAndTryToSubmit(VALID_ONCALL_API_URL, false); + + expect(PluginState.selfHostedInstallPlugin).toHaveBeenCalledWith(VALID_ONCALL_API_URL, false); + expect(mockOnSuccessfulSetup).toHaveBeenCalledTimes(0); + expect(dom).toMatchSnapshot(); + }); +}); diff --git a/grafana-plugin/src/containers/PluginConfigPage/parts/ConfigurationForm/__snapshots__/ConfigurationForm.test.tsx.snap b/grafana-plugin/src/containers/PluginConfigPage/parts/ConfigurationForm/__snapshots__/ConfigurationForm.test.tsx.snap new file mode 100644 index 00000000..08fcbc9e --- /dev/null +++ b/grafana-plugin/src/containers/PluginConfigPage/parts/ConfigurationForm/__snapshots__/ConfigurationForm.test.tsx.snap @@ -0,0 +1,278 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`ConfigurationForm It doesn't allow the user to submit if the URL is invalid 1`] = ` + +
+
+
+

+ 1. Launch the OnCall backend +

+ + Run hobby, dev or production backend. See + + + + here + + + + on how to get started. + +
+
+

+ 2. Let us know the base URL of your OnCall API +

+ + The OnCall backend must be reachable from your Grafana installation. Some examples are: +
+ - http://host.docker.internal:8080 +
+ - http://localhost:8080 +
+
+
+
+ +
+
+
+
+ +
+
+
+ +
+
+
+ +
+
+ +`; + +exports[`ConfigurationForm It shows an error message if the self hosted plugin API call fails 1`] = ` + +
+
+
+

+ 1. Launch the OnCall backend +

+ + Run hobby, dev or production backend. See + + + + here + + + + on how to get started. + +
+
+

+ 2. Let us know the base URL of your OnCall API +

+ + The OnCall backend must be reachable from your Grafana installation. Some examples are: +
+ - http://host.docker.internal:8080 +
+ - http://localhost:8080 +
+
+
+
+ +
+
+
+
+ +
+
+
+
+
+        
+          ohhh nooo there was an error from the OnCall API
+        
+      
+
+ + Need help? +
+ - Reach out to the OnCall team in the + + + + #grafana-oncall + + + + community Slack channel +
+ - Ask questions on our GitHub Discussions page + + + + here + + + +
+ - Or file bugs on our GitHub Issues page + + + + here + + +
+
+ +
+
+ +`; diff --git a/grafana-plugin/src/containers/PluginConfigPage/parts/ConfigurationForm/index.tsx b/grafana-plugin/src/containers/PluginConfigPage/parts/ConfigurationForm/index.tsx new file mode 100644 index 00000000..26ce5f73 --- /dev/null +++ b/grafana-plugin/src/containers/PluginConfigPage/parts/ConfigurationForm/index.tsx @@ -0,0 +1,130 @@ +import React, { FC, useCallback, useState } from 'react'; + +import { Button, Field, Form, Input } from '@grafana/ui'; +import cn from 'classnames/bind'; +import { isEmpty } from 'lodash-es'; +import { SubmitHandler } from 'react-hook-form'; + +import Block from 'components/GBlock/Block'; +import Text from 'components/Text/Text'; +import PluginState from 'state/plugin'; + +import styles from './ConfigurationForm.module.css'; + +const cx = cn.bind(styles); + +type Props = { + onSuccessfulSetup: () => void; + defaultOnCallApiUrl: string; +}; + +type FormProps = { + onCallApiUrl: string; +}; + +/** + * https://stackoverflow.com/a/43467144 + */ +const isValidUrl = (url: string): boolean => { + try { + new URL(url); + return true; + } catch (_) { + return false; + } +}; + +const FormErrorMessage: FC<{ errorMsg: string }> = ({ errorMsg }) => ( + <> +
+      {errorMsg}
+    
+ + + Need help? +
- Reach out to the OnCall team in the{' '} + + #grafana-oncall + {' '} + community Slack channel +
- Ask questions on our GitHub Discussions page{' '} + + here + {' '} +
- Or file bugs on our GitHub Issues page{' '} + + here + +
+
+ +); + +const ConfigurationForm: FC = ({ onSuccessfulSetup, defaultOnCallApiUrl }) => { + const [setupErrorMsg, setSetupErrorMsg] = useState(null); + const [formLoading, setFormLoading] = useState(false); + + const setupPlugin: SubmitHandler = useCallback(async ({ onCallApiUrl }) => { + setFormLoading(true); + + const errorMsg = await PluginState.selfHostedInstallPlugin(onCallApiUrl, false); + + if (!errorMsg) { + onSuccessfulSetup(); + } else { + setSetupErrorMsg(errorMsg); + setFormLoading(false); + } + }, []); + + return ( + + defaultValues={{ onCallApiUrl: defaultOnCallApiUrl }} + onSubmit={setupPlugin} + data-testid="plugin-configuration-form" + > + {({ register, errors }) => ( + <> +
+

1. Launch the OnCall backend

+ + Run hobby, dev or production backend. See{' '} + + here + {' '} + on how to get started. + +
+ +
+

2. Let us know the base URL of your OnCall API

+ + The OnCall backend must be reachable from your Grafana installation. Some examples are: +
+ - http://host.docker.internal:8080 +
- http://localhost:8080 +
+
+ + + + + + {setupErrorMsg && } + + + + )} + + ); +}; + +export default ConfigurationForm; diff --git a/grafana-plugin/src/containers/PluginConfigPage/parts/RemoveCurrentConfigurationButton/RemoveCurrentConfigurationButton.test.tsx b/grafana-plugin/src/containers/PluginConfigPage/parts/RemoveCurrentConfigurationButton/RemoveCurrentConfigurationButton.test.tsx new file mode 100644 index 00000000..934df214 --- /dev/null +++ b/grafana-plugin/src/containers/PluginConfigPage/parts/RemoveCurrentConfigurationButton/RemoveCurrentConfigurationButton.test.tsx @@ -0,0 +1,33 @@ +import React from 'react'; + +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; + +import RemoveCurrentConfigurationButton from '.'; + +describe('RemoveCurrentConfigurationButton', () => { + test('It renders properly when enabled', () => { + const component = render( {}} disabled={false} />); + expect(component.baseElement).toMatchSnapshot(); + }); + + test('It renders properly when disabled', () => { + const component = render( {}} disabled />); + expect(component.baseElement).toMatchSnapshot(); + }); + + test('It calls the onClick handler when clicked', async () => { + const mockedOnClick = jest.fn(); + + const user = userEvent.setup(); + render(); + + // click the button, which opens the modal + await user.click(screen.getByRole('button')); + // click the confirm button within the modal, which actually triggers the callback + await user.click(screen.getByText('Remove')); + + expect(mockedOnClick).toHaveBeenCalledWith(); + expect(mockedOnClick).toHaveBeenCalledTimes(1); + }); +}); diff --git a/grafana-plugin/src/containers/PluginConfigPage/parts/RemoveCurrentConfigurationButton/__snapshots__/RemoveCurrentConfigurationButton.test.tsx.snap b/grafana-plugin/src/containers/PluginConfigPage/parts/RemoveCurrentConfigurationButton/__snapshots__/RemoveCurrentConfigurationButton.test.tsx.snap new file mode 100644 index 00000000..3b04735c --- /dev/null +++ b/grafana-plugin/src/containers/PluginConfigPage/parts/RemoveCurrentConfigurationButton/__snapshots__/RemoveCurrentConfigurationButton.test.tsx.snap @@ -0,0 +1,36 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`RemoveCurrentConfigurationButton It renders properly when disabled 1`] = ` + +
+ +
+ +`; + +exports[`RemoveCurrentConfigurationButton It renders properly when enabled 1`] = ` + +
+ +
+ +`; diff --git a/grafana-plugin/src/containers/PluginConfigPage/parts/RemoveCurrentConfigurationButton/index.tsx b/grafana-plugin/src/containers/PluginConfigPage/parts/RemoveCurrentConfigurationButton/index.tsx new file mode 100644 index 00000000..e4fab3cb --- /dev/null +++ b/grafana-plugin/src/containers/PluginConfigPage/parts/RemoveCurrentConfigurationButton/index.tsx @@ -0,0 +1,20 @@ +import React, { FC } from 'react'; + +import { Button } from '@grafana/ui'; + +import WithConfirm from 'components/WithConfirm/WithConfirm'; + +type Props = { + disabled: boolean; + onClick: () => void; +}; + +const RemoveCurrentConfigurationButton: FC = ({ disabled, onClick }) => ( + + + +); + +export default RemoveCurrentConfigurationButton; diff --git a/grafana-plugin/src/containers/PluginConfigPage/parts/StatusMessageBlock/StatusMessageBlock.test.tsx b/grafana-plugin/src/containers/PluginConfigPage/parts/StatusMessageBlock/StatusMessageBlock.test.tsx new file mode 100644 index 00000000..15c05f36 --- /dev/null +++ b/grafana-plugin/src/containers/PluginConfigPage/parts/StatusMessageBlock/StatusMessageBlock.test.tsx @@ -0,0 +1,12 @@ +import React from 'react'; + +import { render } from '@testing-library/react'; + +import StatusMessageBlock from '.'; + +describe('StatusMessageBlock', () => { + test('It renders properly', async () => { + const component = render(); + expect(component.baseElement).toMatchSnapshot(); + }); +}); diff --git a/grafana-plugin/src/containers/PluginConfigPage/parts/StatusMessageBlock/__snapshots__/StatusMessageBlock.test.tsx.snap b/grafana-plugin/src/containers/PluginConfigPage/parts/StatusMessageBlock/__snapshots__/StatusMessageBlock.test.tsx.snap new file mode 100644 index 00000000..4671a76e --- /dev/null +++ b/grafana-plugin/src/containers/PluginConfigPage/parts/StatusMessageBlock/__snapshots__/StatusMessageBlock.test.tsx.snap @@ -0,0 +1,17 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`StatusMessageBlock It renders properly 1`] = ` + +
+
+      
+        helloooo
+      
+    
+
+ +`; diff --git a/grafana-plugin/src/containers/PluginConfigPage/parts/StatusMessageBlock/index.tsx b/grafana-plugin/src/containers/PluginConfigPage/parts/StatusMessageBlock/index.tsx new file mode 100644 index 00000000..931b1a7a --- /dev/null +++ b/grafana-plugin/src/containers/PluginConfigPage/parts/StatusMessageBlock/index.tsx @@ -0,0 +1,15 @@ +import React, { FC } from 'react'; + +import Text from 'components/Text/Text'; + +type Props = { + text: string; +}; + +const StatusMessageBlock: FC = ({ text }) => ( +
+    {text}
+  
+); + +export default StatusMessageBlock; diff --git a/grafana-plugin/src/index.d.ts b/grafana-plugin/src/index.d.ts index e5b19cf2..98c6c4b4 100644 --- a/grafana-plugin/src/index.d.ts +++ b/grafana-plugin/src/index.d.ts @@ -16,3 +16,12 @@ declare module '*.scss' { const content: Record; export default content; } + +declare module 'grafana/app/core/core' { + import { OrgRole } from '@grafana/data'; + + // https://github.com/grafana/grafana/blob/main/public/app/core/services/context_srv.ts#L59 + export const contextSrv: { + hasRole(role: OrgRole): boolean; + }; +} diff --git a/grafana-plugin/src/module.ts b/grafana-plugin/src/module.ts index 358af703..068b4df2 100644 --- a/grafana-plugin/src/module.ts +++ b/grafana-plugin/src/module.ts @@ -1,20 +1,15 @@ import { ComponentClass } from 'react'; -import { AppPlugin, AppPluginMeta, AppRootProps, PluginConfigPageProps } from '@grafana/data'; +import { AppPlugin } from '@grafana/data'; -import { PluginConfigPage } from 'containers/PluginConfigPage/PluginConfigPage'; +import PluginConfigPage from 'containers/PluginConfigPage/PluginConfigPage'; import { GrafanaPluginRootPage } from 'plugin/GrafanaPluginRootPage'; -import { OnCallAppSettings } from './types'; +import { OnCallPluginConfigPageProps, OnCallPluginMetaJSONData } from './types'; -export const plugin = new AppPlugin() - .setRootPage(GrafanaPluginRootPage as unknown as ComponentClass>) - .addConfigPage({ - title: 'Configuration', - icon: 'cog', - body: PluginConfigPage as unknown as ComponentClass< - PluginConfigPageProps>, - unknown - >, - id: 'configuration', - }); +export const plugin = new AppPlugin().setRootPage(GrafanaPluginRootPage).addConfigPage({ + title: 'Configuration', + icon: 'cog', + body: PluginConfigPage as unknown as ComponentClass, + id: 'configuration', +}); diff --git a/grafana-plugin/src/network/index.ts b/grafana-plugin/src/network/index.ts index aa35fb3b..bfb7068e 100644 --- a/grafana-plugin/src/network/index.ts +++ b/grafana-plugin/src/network/index.ts @@ -30,7 +30,7 @@ interface RequestConfig { validateStatus?: (status: number) => boolean; } -export const makeRequest = async (path: string, config: RequestConfig) => { +export const makeRequest = async (path: string, config: RequestConfig) => { const { method = 'GET', params, data, validateStatus } = config; const url = `${API_PROXY_PREFIX}${API_PATH_PREFIX}${path}`; @@ -43,5 +43,5 @@ export const makeRequest = async (path: string, config: RequestConfig) => { validateStatus, }); - return response.data; + return response.data as RT; }; diff --git a/grafana-plugin/src/pages/escalation-chains/EscalationChains.tsx b/grafana-plugin/src/pages/escalation-chains/EscalationChains.tsx index 081daf14..ddcda1d4 100644 --- a/grafana-plugin/src/pages/escalation-chains/EscalationChains.tsx +++ b/grafana-plugin/src/pages/escalation-chains/EscalationChains.tsx @@ -1,12 +1,12 @@ import React from 'react'; -import { AppRootProps } from '@grafana/data'; import { getLocationSrv } from '@grafana/runtime'; import { Button, HorizontalGroup, Icon, IconButton, LoadingPlaceholder, Tooltip, VerticalGroup } from '@grafana/ui'; import { PluginPage } from 'PluginPage'; import cn from 'classnames/bind'; import { debounce } from 'lodash-es'; import { observer } from 'mobx-react'; +import { AppRootProps } from 'types'; import Collapse from 'components/Collapse/Collapse'; import EscalationsFilters from 'components/EscalationsFilters/EscalationsFilters'; diff --git a/grafana-plugin/src/pages/incident/Incident.tsx b/grafana-plugin/src/pages/incident/Incident.tsx index c6377277..2125e374 100644 --- a/grafana-plugin/src/pages/incident/Incident.tsx +++ b/grafana-plugin/src/pages/incident/Incident.tsx @@ -1,6 +1,5 @@ import React, { useState, SyntheticEvent } from 'react'; -import { AppRootProps } from '@grafana/data'; import { getLocationSrv } from '@grafana/runtime'; import { Button, @@ -23,6 +22,7 @@ import moment from 'moment-timezone'; import CopyToClipboard from 'react-copy-to-clipboard'; import Emoji from 'react-emoji-render'; import reactStringReplace from 'react-string-replace'; +import { AppRootProps } from 'types'; import Collapse from 'components/Collapse/Collapse'; import Block from 'components/GBlock/Block'; diff --git a/grafana-plugin/src/pages/incidents/Incidents.tsx b/grafana-plugin/src/pages/incidents/Incidents.tsx index 718251b5..3ef595a6 100644 --- a/grafana-plugin/src/pages/incidents/Incidents.tsx +++ b/grafana-plugin/src/pages/incidents/Incidents.tsx @@ -1,6 +1,5 @@ import React, { ReactElement, SyntheticEvent } from 'react'; -import { AppRootProps } from '@grafana/data'; import { getLocationSrv } from '@grafana/runtime'; import { Button, Icon, Tooltip, VerticalGroup, LoadingPlaceholder, HorizontalGroup } from '@grafana/ui'; import { PluginPage } from 'PluginPage'; @@ -9,6 +8,7 @@ import { get } from 'lodash-es'; import { observer } from 'mobx-react'; import moment from 'moment-timezone'; import Emoji from 'react-emoji-render'; +import { AppRootProps } from 'types'; import CursorPagination from 'components/CursorPagination/CursorPagination'; import GTable from 'components/GTable/GTable'; diff --git a/grafana-plugin/src/pages/integrations/Integrations.tsx b/grafana-plugin/src/pages/integrations/Integrations.tsx index 21d6d7a9..e3a759b4 100644 --- a/grafana-plugin/src/pages/integrations/Integrations.tsx +++ b/grafana-plugin/src/pages/integrations/Integrations.tsx @@ -1,12 +1,12 @@ import React from 'react'; -import { AppRootProps } from '@grafana/data'; import { getLocationSrv } from '@grafana/runtime'; import { Button, LoadingPlaceholder, VerticalGroup } from '@grafana/ui'; import { PluginPage } from 'PluginPage'; import cn from 'classnames/bind'; import { debounce } from 'lodash-es'; import { observer } from 'mobx-react'; +import { AppRootProps } from 'types'; import GList from 'components/GList/GList'; import IntegrationsFilters, { Filters } from 'components/IntegrationsFilters/IntegrationsFilters'; diff --git a/grafana-plugin/src/pages/maintenance/Maintenance.tsx b/grafana-plugin/src/pages/maintenance/Maintenance.tsx index 721add7f..4d329475 100644 --- a/grafana-plugin/src/pages/maintenance/Maintenance.tsx +++ b/grafana-plugin/src/pages/maintenance/Maintenance.tsx @@ -1,6 +1,5 @@ import React from 'react'; -import { AppRootProps } from '@grafana/data'; import { Button, VerticalGroup } from '@grafana/ui'; import { PluginPage } from 'PluginPage'; import cn from 'classnames/bind'; @@ -8,6 +7,7 @@ import { observer } from 'mobx-react'; import moment from 'moment-timezone'; import LegacyNavHeading from 'navbar/LegacyNavHeading'; import Emoji from 'react-emoji-render'; +import { AppRootProps } from 'types'; import GTable from 'components/GTable/GTable'; import Text from 'components/Text/Text'; diff --git a/grafana-plugin/src/pages/outgoing_webhooks/OutgoingWebhooks.tsx b/grafana-plugin/src/pages/outgoing_webhooks/OutgoingWebhooks.tsx index 6435f10e..797fc6d6 100644 --- a/grafana-plugin/src/pages/outgoing_webhooks/OutgoingWebhooks.tsx +++ b/grafana-plugin/src/pages/outgoing_webhooks/OutgoingWebhooks.tsx @@ -1,12 +1,12 @@ import React from 'react'; -import { AppRootProps } from '@grafana/data'; import { getLocationSrv } from '@grafana/runtime'; import { Button, HorizontalGroup } from '@grafana/ui'; import { PluginPage } from 'PluginPage'; import cn from 'classnames/bind'; import { observer } from 'mobx-react'; import LegacyNavHeading from 'navbar/LegacyNavHeading'; +import { AppRootProps } from 'types'; import GTable from 'components/GTable/GTable'; import PageErrorHandlingWrapper, { PageBaseState } from 'components/PageErrorHandlingWrapper/PageErrorHandlingWrapper'; diff --git a/grafana-plugin/src/pages/schedule/Schedule.tsx b/grafana-plugin/src/pages/schedule/Schedule.tsx index 1f155226..7f99d9fe 100644 --- a/grafana-plugin/src/pages/schedule/Schedule.tsx +++ b/grafana-plugin/src/pages/schedule/Schedule.tsx @@ -1,12 +1,12 @@ import React from 'react'; -import { AppRootProps } from '@grafana/data'; import { getLocationSrv } from '@grafana/runtime'; import { Button, HorizontalGroup, VerticalGroup, IconButton, ToolbarButton, Icon, Modal } from '@grafana/ui'; import { PluginPage } from 'PluginPage'; import cn from 'classnames/bind'; import dayjs from 'dayjs'; import { observer } from 'mobx-react'; +import { AppRootProps } from 'types'; import PageErrorHandlingWrapper from 'components/PageErrorHandlingWrapper/PageErrorHandlingWrapper'; import PluginLink from 'components/PluginLink/PluginLink'; diff --git a/grafana-plugin/src/pages/users/Users.tsx b/grafana-plugin/src/pages/users/Users.tsx index c4a6458d..623b379b 100644 --- a/grafana-plugin/src/pages/users/Users.tsx +++ b/grafana-plugin/src/pages/users/Users.tsx @@ -1,6 +1,5 @@ import React from 'react'; -import { AppRootProps } from '@grafana/data'; import { getLocationSrv } from '@grafana/runtime'; import { Alert, Button, HorizontalGroup, Icon, VerticalGroup } from '@grafana/ui'; import { PluginPage } from 'PluginPage'; @@ -8,6 +7,7 @@ import cn from 'classnames/bind'; import { debounce } from 'lodash-es'; import { observer } from 'mobx-react'; import LegacyNavHeading from 'navbar/LegacyNavHeading'; +import { AppRootProps } from 'types'; import Avatar from 'components/Avatar/Avatar'; import GTable from 'components/GTable/GTable'; diff --git a/grafana-plugin/src/plugin.json b/grafana-plugin/src/plugin.json index 50415d19..d753a8e6 100644 --- a/grafana-plugin/src/plugin.json +++ b/grafana-plugin/src/plugin.json @@ -109,11 +109,7 @@ "headers": [ { "name": "X-Instance-Context", - "content": "{ \"grafana_token\": \"{{ .SecureJsonData.grafanaToken }}\", \"grafana_url\": \"{{ .JsonData.grafanaUrl }}\" }" - }, - { - "name": "Authorization", - "content": "{{ .SecureJsonData.onCallInvitationToken }}" + "content": "{ \"grafana_token\": \"{{ .SecureJsonData.grafanaToken }}\" }" } ] }, diff --git a/grafana-plugin/src/plugin/GrafanaPluginRootPage.tsx b/grafana-plugin/src/plugin/GrafanaPluginRootPage.tsx index 416b0767..d2b4cd59 100644 --- a/grafana-plugin/src/plugin/GrafanaPluginRootPage.tsx +++ b/grafana-plugin/src/plugin/GrafanaPluginRootPage.tsx @@ -1,8 +1,6 @@ import React, { useEffect, useMemo, useState } from 'react'; -import { AppRootProps } from '@grafana/data'; import { locationService } from '@grafana/runtime'; -import { Button, HorizontalGroup, LinkButton } from '@grafana/ui'; import classnames from 'classnames'; import dayjs from 'dayjs'; import isSameOrAfter from 'dayjs/plugin/isSameOrAfter'; @@ -12,13 +10,13 @@ import localeData from 'dayjs/plugin/localeData'; import timezone from 'dayjs/plugin/timezone'; import utc from 'dayjs/plugin/utc'; import weekday from 'dayjs/plugin/weekday'; -import 'interceptors'; import { observer, Provider } from 'mobx-react'; import Header from 'navbar/Header/Header'; import LegacyNavTabsBar from 'navbar/LegacyNavTabsBar'; +import { AppRootProps } from 'types'; import DefaultPageLayout from 'containers/DefaultPageLayout/DefaultPageLayout'; -import logo from 'img/logo.svg'; +import 'interceptors'; import { pages } from 'pages'; import { routes } from 'pages/routes'; import { rootStore } from 'state'; @@ -38,67 +36,14 @@ import 'style/global.css'; import 'style/utils.css'; import { isTopNavbar } from './GrafanaPluginRootPage.helpers'; +import PluginSetup from './PluginSetup'; export const GrafanaPluginRootPage = (props: AppRootProps) => ( - + ); -const RootWithLoader = observer((props: AppRootProps) => { - const store = useStore(); - - useEffect(() => { - store.setupPlugin(props.meta); - }, []); - - if (store.appLoading) { - let text = 'Initializing plugin...'; - - if (!store.pluginIsInitialized) { - text = '🚫 Plugin has not been initialized'; - } else if (!store.correctProvisioningForInstallation) { - text = '🚫 Plugin could not be initialized due to provisioning error'; - } else if (!store.correctRoleForInstallation) { - text = '🚫 Admin must sign on to setup OnCall before a Viewer can use it'; - } else if (!store.signupAllowedForPlugin) { - text = '🚫 OnCall has temporarily disabled signup of new users. Please try again later.'; - } else if (store.initializationError) { - text = `🚫 Error during initialization: ${store.initializationError}`; - } else if (store.isUserAnonymous) { - text = '😞 Unfortunately Grafana OnCall is available for authorized users only, please sign in to proceed.'; - } else if (store.retrySync) { - text = `🚫 OnCall took too many tries to synchronize... Are background workers up and running?`; - } - - return ( -
- Grafana OnCall Logo -
{text}
- {!store.pluginIsInitialized || - !store.correctProvisioningForInstallation || - store.initializationError || - store.retrySync ? ( -
- - - - Configure Plugin - - -
- ) : ( - <> - )} -
- ); - } - - return ; -}); - export const Root = observer((props: AppRootProps) => { const [didFinishLoading, setDidFinishLoading] = useState(false); const queryParams = useQueryParams(); diff --git a/grafana-plugin/src/plugin/PluginSetup/PluginSetup.test.tsx b/grafana-plugin/src/plugin/PluginSetup/PluginSetup.test.tsx new file mode 100644 index 00000000..30aa83ed --- /dev/null +++ b/grafana-plugin/src/plugin/PluginSetup/PluginSetup.test.tsx @@ -0,0 +1,76 @@ +import React from 'react'; + +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; + +import { RootBaseStore } from 'state/rootBaseStore'; +import { useStore as useStoreOriginal } from 'state/useStore'; + +import PluginSetup, { PluginSetupProps } from '.'; + +jest.mock('state/useStore'); + +const createComponentAndMakeAssertions = async (rootBaseStore: RootBaseStore) => { + // mocks + const mockedSetupPlugin = jest.fn(); + rootBaseStore.setupPlugin = mockedSetupPlugin; + (useStoreOriginal as jest.Mock>).mockReturnValue(rootBaseStore); + + // test setup + const MockedInitializedComponent = jest.fn().mockReturnValue(
hello
); + + const props = { + meta: { + jsonData: 'hello', + }, + InitializedComponent: MockedInitializedComponent, + } as unknown as PluginSetupProps; + + const component = render(); + + // assertions + expect(mockedSetupPlugin).toHaveBeenCalledTimes(1); + expect(mockedSetupPlugin).toHaveBeenCalledWith(props.meta); + expect(component.container).toMatchSnapshot(); + + return mockedSetupPlugin; +}; + +describe('PluginSetup', () => { + afterEach(() => { + jest.resetAllMocks(); + }); + + test('app is loading', async () => { + const rootBaseStore = new RootBaseStore(); + rootBaseStore.appLoading = true; + await createComponentAndMakeAssertions(rootBaseStore); + }); + + test('there is an error message', async () => { + const rootBaseStore = new RootBaseStore(); + rootBaseStore.appLoading = false; + rootBaseStore.initializationError = 'ohhhh noo'; + await createComponentAndMakeAssertions(rootBaseStore); + }); + + test('there is an error message - retry setup', async () => { + const rootBaseStore = new RootBaseStore(); + rootBaseStore.appLoading = false; + rootBaseStore.initializationError = 'ohhhh noo'; + + const mockedSetupPlugin = await createComponentAndMakeAssertions(rootBaseStore); + + const user = userEvent.setup(); + await user.click(screen.getByText('Retry')); + + expect(mockedSetupPlugin).toHaveBeenCalledTimes(2); + }); + + test('app successfully initialized', async () => { + const rootBaseStore = new RootBaseStore(); + rootBaseStore.appLoading = false; + rootBaseStore.initializationError = null; + await createComponentAndMakeAssertions(rootBaseStore); + }); +}); diff --git a/grafana-plugin/src/plugin/PluginSetup/__snapshots__/PluginSetup.test.tsx.snap b/grafana-plugin/src/plugin/PluginSetup/__snapshots__/PluginSetup.test.tsx.snap new file mode 100644 index 00000000..fb5ca479 --- /dev/null +++ b/grafana-plugin/src/plugin/PluginSetup/__snapshots__/PluginSetup.test.tsx.snap @@ -0,0 +1,139 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`PluginSetup app is loading 1`] = ` +
+
+ Grafana OnCall Logo +
+ Initializing plugin... +
+
+
+`; + +exports[`PluginSetup app successfully initialized 1`] = ` +
+
+ hello +
+
+`; + +exports[`PluginSetup there is an error message - retry setup 1`] = ` +
+
+ Grafana OnCall Logo +
+ ohhhh noo +
+
+
+
+ +
+ +
+
+
+
+`; + +exports[`PluginSetup there is an error message 1`] = ` +
+
+ Grafana OnCall Logo +
+ ohhhh noo +
+
+
+
+ +
+ +
+
+
+
+`; diff --git a/grafana-plugin/src/plugin/PluginSetup/index.tsx b/grafana-plugin/src/plugin/PluginSetup/index.tsx new file mode 100644 index 00000000..dba2a290 --- /dev/null +++ b/grafana-plugin/src/plugin/PluginSetup/index.tsx @@ -0,0 +1,58 @@ +import React, { FC, PropsWithChildren, useCallback, useEffect } from 'react'; + +import { Button, HorizontalGroup, LinkButton } from '@grafana/ui'; +import { observer } from 'mobx-react'; +import { AppRootProps } from 'types'; + +import logo from 'img/logo.svg'; +import { useStore } from 'state/useStore'; + +export type PluginSetupProps = AppRootProps & { + InitializedComponent: (props: AppRootProps) => JSX.Element; +}; + +type PluginSetupWrapperProps = PropsWithChildren<{ + text: string; +}>; + +const PluginSetupWrapper: FC = ({ text, children }) => ( +
+ Grafana OnCall Logo +
{text}
+ {children} +
+); + +const PluginSetup: FC = observer(({ InitializedComponent, ...props }) => { + const store = useStore(); + const setupPlugin = useCallback(() => store.setupPlugin(props.meta), [props.meta]); + + useEffect(() => { + setupPlugin(); + }, [setupPlugin]); + + if (store.appLoading) { + return ; + } + + if (store.initializationError) { + return ( + +
+ + + + Configure Plugin + + +
+
+ ); + } + + return ; +}); + +export default PluginSetup; diff --git a/grafana-plugin/src/state/plugin.ts b/grafana-plugin/src/state/plugin.ts deleted file mode 100644 index 80adbbd2..00000000 --- a/grafana-plugin/src/state/plugin.ts +++ /dev/null @@ -1,46 +0,0 @@ -import { getBackendSrv } from '@grafana/runtime'; - -import { makeRequest } from 'network'; - -export async function createGrafanaToken() { - const keys = await getBackendSrv().get('/api/auth/keys'); - const existingKey = keys.find((key: { id: number; name: string; role: string }) => key.name === 'OnCall'); - - if (existingKey) { - await getBackendSrv().delete(`/api/auth/keys/${existingKey.id}`); - } - - return await getBackendSrv().post('/api/auth/keys', { - name: 'OnCall', - role: 'Admin', - secondsToLive: null, - }); -} - -export async function updateGrafanaToken(key: string) { - await getBackendSrv().post(`/api/plugins/grafana-oncall-app/settings`, { - enabled: true, - pinned: true, - secureJsonData: { - grafanaToken: key, - }, - }); -} - -export async function startPluginSync() { - return await makeRequest('/plugin/sync', { method: 'POST' }); -} - -export const SYNC_STATUS_RETRY_LIMIT = 10; - -export const syncStatusDelay = (retryCount) => new Promise((resolve) => setTimeout(resolve, 10 * 2 ** retryCount)); - -export async function getPluginSyncStatus() { - return await makeRequest(`/plugin/sync`, { method: 'GET' }); -} - -export async function installPlugin() { - const grafanaToken = await createGrafanaToken(); - await updateGrafanaToken(grafanaToken.key); - return await makeRequest('/plugin/install', { method: 'POST' }); -} diff --git a/grafana-plugin/src/state/plugin/__snapshots__/plugin.test.ts.snap b/grafana-plugin/src/state/plugin/__snapshots__/plugin.test.ts.snap new file mode 100644 index 00000000..24cc2f4c --- /dev/null +++ b/grafana-plugin/src/state/plugin/__snapshots__/plugin.test.ts.snap @@ -0,0 +1,73 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`PluginState.checkIfPluginIsConnected token_ok: false 1`] = ` +"There was an issue with the communication between your OnCall API and your Grafana instance. +Please ensure that your OnCall API is properly configured to communicate with your Grafana instance." +`; + +exports[`PluginState.checkIfPluginIsConnected token_ok: true 1`] = ` +Object { + "token_ok": true, +} +`; + +exports[`PluginState.generateInvalidOnCallApiURLErrorMsg it returns the proper error message - configured through env var: false 1`] = ` +"Could not communicate with your OnCall API at http://hello.com. +Validate that the URL is correct, your OnCall API is running, and that it is accessible from your Grafana instance." +`; + +exports[`PluginState.generateInvalidOnCallApiURLErrorMsg it returns the proper error message - configured through env var: true 1`] = ` +"Could not communicate with your OnCall API at http://hello.com (NOTE: your OnCall API URL is currently being taken from process.env of your UI). +Validate that the URL is correct, your OnCall API is running, and that it is accessible from your Grafana instance." +`; + +exports[`PluginState.generateOnCallApiUrlConfiguredThroughEnvVarMsg it returns the proper error message - configured through env var: false 1`] = `""`; + +exports[`PluginState.generateOnCallApiUrlConfiguredThroughEnvVarMsg it returns the proper error message - configured through env var: true 1`] = `" (NOTE: your OnCall API URL is currently being taken from process.env of your UI)"`; + +exports[`PluginState.generateUnknownErrorMsg it returns the proper error message - configured through env var: false 1`] = ` +"An unknown error occured when trying to install the plugin. Are you sure that your OnCall API URL, http://hello.com, is correct? +Refresh your page and try again, or try removing your plugin configuration and reconfiguring." +`; + +exports[`PluginState.generateUnknownErrorMsg it returns the proper error message - configured through env var: false 2`] = ` +"An unknown error occured when trying to sync the plugin. Are you sure that your OnCall API URL, http://hello.com, is correct? +Refresh your page and try again, or try removing your plugin configuration and reconfiguring." +`; + +exports[`PluginState.generateUnknownErrorMsg it returns the proper error message - configured through env var: true 1`] = ` +"An unknown error occured when trying to install the plugin. Are you sure that your OnCall API URL, http://hello.com, is correct (NOTE: your OnCall API URL is currently being taken from process.env of your UI)? +Refresh your page and try again, or try removing your plugin configuration and reconfiguring." +`; + +exports[`PluginState.generateUnknownErrorMsg it returns the proper error message - configured through env var: true 2`] = ` +"An unknown error occured when trying to sync the plugin. Are you sure that your OnCall API URL, http://hello.com, is correct (NOTE: your OnCall API URL is currently being taken from process.env of your UI)? +Refresh your page and try again, or try removing your plugin configuration and reconfiguring." +`; + +exports[`PluginState.getHumanReadableErrorFromOnCallError it handles a 400 AxiosError properly - has custom error message: false 1`] = ` +"An unknown error occured when trying to install the plugin. Are you sure that your OnCall API URL, http://hello.com, is correct (NOTE: your OnCall API URL is currently being taken from process.env of your UI)? +Refresh your page and try again, or try removing your plugin configuration and reconfiguring." +`; + +exports[`PluginState.getHumanReadableErrorFromOnCallError it handles a 400 AxiosError properly - has custom error message: true 1`] = `"ohhhh nooo an error"`; + +exports[`PluginState.getHumanReadableErrorFromOnCallError it handles a non-400 AxiosError properly - status code: 409 1`] = ` +"An unknown error occured when trying to install the plugin. Are you sure that your OnCall API URL, http://hello.com, is correct (NOTE: your OnCall API URL is currently being taken from process.env of your UI)? +Refresh your page and try again, or try removing your plugin configuration and reconfiguring." +`; + +exports[`PluginState.getHumanReadableErrorFromOnCallError it handles a non-400 AxiosError properly - status code: 502 1`] = ` +"Could not communicate with your OnCall API at http://hello.com (NOTE: your OnCall API URL is currently being taken from process.env of your UI). +Validate that the URL is correct, your OnCall API is running, and that it is accessible from your Grafana instance." +`; + +exports[`PluginState.getHumanReadableErrorFromOnCallError it handles an unknown error properly 1`] = ` +"An unknown error occured when trying to install the plugin. Are you sure that your OnCall API URL, http://hello.com, is correct (NOTE: your OnCall API URL is currently being taken from process.env of your UI)? +Refresh your page and try again, or try removing your plugin configuration and reconfiguring." +`; + +exports[`PluginState.pollOnCallDataSyncStatus it returns an error message if the pollCount is greater than 10 1`] = ` +"There was an issue while synchronizing data required for the plugin. +Verify your OnCall backend setup (ie. that Celery workers are launched and properly configured)" +`; diff --git a/grafana-plugin/src/state/plugin/index.ts b/grafana-plugin/src/state/plugin/index.ts new file mode 100644 index 00000000..f4506b5b --- /dev/null +++ b/grafana-plugin/src/state/plugin/index.ts @@ -0,0 +1,339 @@ +import { getBackendSrv } from '@grafana/runtime'; +import axios from 'axios'; +import { OnCallAppPluginMeta, OnCallPluginMetaJSONData, OnCallPluginMetaSecureJSONData } from 'types'; + +import { makeRequest } from 'network'; + +export type UpdateGrafanaPluginSettingsProps = { + jsonData?: Partial; + secureJsonData?: Partial; +}; + +export type PluginStatusResponseBase = Pick & { + version: string; +}; + +export type PluginSyncStatusResponse = PluginStatusResponseBase & { + token_ok: boolean; +}; + +type PluginConnectedStatusResponse = PluginStatusResponseBase & { + is_installed: boolean; + token_ok: boolean; + allow_signup: boolean; + is_user_anonymous: boolean; +}; + +type CloudProvisioningConfigResponse = null; + +type SelfHostedProvisioningConfigResponse = Omit & { + onCallToken: string; +}; + +type InstallPluginResponse = Pick & { + onCallAPIResponse: OnCallAPIResponse; +}; + +export type InstallationVerb = 'install' | 'sync'; + +class PluginState { + static ONCALL_BASE_URL = '/plugin'; + static GRAFANA_PLUGIN_SETTINGS_URL = '/api/plugins/grafana-oncall-app/settings'; + static SYNC_STATUS_POLLING_RETRY_LIMIT = 10; + static grafanaBackend = getBackendSrv(); + + static generateOnCallApiUrlConfiguredThroughEnvVarMsg = (isConfiguredThroughEnvVar: boolean): string => + isConfiguredThroughEnvVar + ? ' (NOTE: your OnCall API URL is currently being taken from process.env of your UI)' + : ''; + + static generateInvalidOnCallApiURLErrorMsg = (onCallApiUrl: string, isConfiguredThroughEnvVar: boolean): string => + `Could not communicate with your OnCall API at ${onCallApiUrl}${this.generateOnCallApiUrlConfiguredThroughEnvVarMsg( + isConfiguredThroughEnvVar + )}.\nValidate that the URL is correct, your OnCall API is running, and that it is accessible from your Grafana instance.`; + + static generateUnknownErrorMsg = ( + onCallApiUrl: string, + verb: InstallationVerb, + isConfiguredThroughEnvVar: boolean + ): string => + `An unknown error occured when trying to ${verb} the plugin. Are you sure that your OnCall API URL, ${onCallApiUrl}, is correct${this.generateOnCallApiUrlConfiguredThroughEnvVarMsg( + isConfiguredThroughEnvVar + )}?\nRefresh your page and try again, or try removing your plugin configuration and reconfiguring.`; + + static getHumanReadableErrorFromOnCallError = ( + e: any, + onCallApiUrl: string, + installationVerb: InstallationVerb, + onCallApiUrlIsConfiguredThroughEnvVar = false + ): string => { + let errorMsg: string; + const unknownErrorMsg = this.generateUnknownErrorMsg( + onCallApiUrl, + installationVerb, + onCallApiUrlIsConfiguredThroughEnvVar + ); + const consoleMsg = `occured while trying to ${installationVerb} the plugin w/ the OnCall backend`; + + if (axios.isAxiosError(e)) { + const { status: statusCode } = e.response; + + console.warn(`An HTTP related error ${consoleMsg}`, e.response); + + if (statusCode === 502) { + // 502 occurs when the plugin-proxy cannot communicate w/ the OnCall API using the provided URL + errorMsg = this.generateInvalidOnCallApiURLErrorMsg(onCallApiUrl, onCallApiUrlIsConfiguredThroughEnvVar); + } else if (statusCode === 400) { + /** + * A 400 is 'bubbled-up' from the OnCall API. It indicates one of three cases: + * 1. there is a communication error when OnCall API tries to contact Grafana's API + * 2. there is an auth error when OnCall API tries to contact Grafana's API + * 3. (likely rare) user inputs an onCallApiUrl that is not RFC 1034/1035 compliant + * + * Check if the response body has an 'error' JSON attribute, if it does, assume scenario 1 or 2 + * Use the error message provided to give the user more context/helpful debugging information + */ + errorMsg = e.response.data?.error || unknownErrorMsg; + } else { + // this scenario shouldn't occur.. + errorMsg = unknownErrorMsg; + } + } else { + // a non-axios related error occured.. this scenario shouldn't occur... + console.warn(`An unknown error ${consoleMsg}`, e); + errorMsg = unknownErrorMsg; + } + return errorMsg; + }; + + static getHumanReadableErrorFromGrafanaProvisioningError = ( + e: any, + onCallApiUrl: string, + installationVerb: InstallationVerb, + onCallApiUrlIsConfiguredThroughEnvVar: boolean + ): string => { + let errorMsg: string; + + if (axios.isAxiosError(e)) { + // The user likely put in a bogus URL for the OnCall API URL + console.warn('An HTTP related error occured while trying to provision the plugin w/ Grafana', e.response); + errorMsg = this.generateInvalidOnCallApiURLErrorMsg(onCallApiUrl, onCallApiUrlIsConfiguredThroughEnvVar); + } else { + // a non-axios related error occured.. this scenario shouldn't occur... + console.warn('An unknown error occured while trying to provision the plugin w/ Grafana', e); + errorMsg = this.generateUnknownErrorMsg(onCallApiUrl, installationVerb, onCallApiUrlIsConfiguredThroughEnvVar); + } + return errorMsg; + }; + + static getGrafanaPluginSettings = async (): Promise => + this.grafanaBackend.get(this.GRAFANA_PLUGIN_SETTINGS_URL); + + static updateGrafanaPluginSettings = async (data: UpdateGrafanaPluginSettingsProps, enabled = true) => + this.grafanaBackend.post(this.GRAFANA_PLUGIN_SETTINGS_URL, { ...data, enabled, pinned: true }); + + static createGrafanaToken = async () => { + const baseUrl = '/api/auth/keys'; + const keys = await this.grafanaBackend.get(baseUrl); + const existingKey = keys.find((key: { id: number; name: string; role: string }) => key.name === 'OnCall'); + + if (existingKey) { + await this.grafanaBackend.delete(`${baseUrl}/${existingKey.id}`); + } + + return await this.grafanaBackend.post(baseUrl, { + name: 'OnCall', + role: 'Admin', + secondsToLive: null, + }); + }; + + static getPluginSyncStatus = (): Promise => + makeRequest(`${this.ONCALL_BASE_URL}/sync`, { method: 'GET' }); + + static timeout = (pollCount: number) => new Promise((resolve) => setTimeout(resolve, 10 * 2 ** pollCount)); + + /** + * DON'T CALL THIS METHOD DIRECTLY + * This really only exists to properly test the recursive nature of pollOnCallDataSyncStatus + * Without this it is impossible (or very hacky) to mock the recursive calls + */ + static _pollOnCallDataSyncStatus = ( + onCallApiUrl: string, + onCallApiUrlIsConfiguredThroughEnvVar: boolean, + pollCount: number + ) => this.pollOnCallDataSyncStatus(onCallApiUrl, onCallApiUrlIsConfiguredThroughEnvVar, pollCount); + + /** + * Poll, for a configured amount of time, the status of the OnCall backend data sync + * Returns a PluginSyncStatusResponse if the sync was successful (ie. token_ok is true), otherwise null + */ + static pollOnCallDataSyncStatus = async ( + onCallApiUrl: string, + onCallApiUrlIsConfiguredThroughEnvVar: boolean, + pollCount = 0 + ): Promise => { + if (pollCount > this.SYNC_STATUS_POLLING_RETRY_LIMIT) { + return `There was an issue while synchronizing data required for the plugin.\nVerify your OnCall backend setup (ie. that Celery workers are launched and properly configured)`; + } + + try { + const syncResponse = await this.getPluginSyncStatus(); + if (syncResponse?.token_ok) { + return syncResponse; + } + + await this.timeout(pollCount); + return await this._pollOnCallDataSyncStatus(onCallApiUrl, onCallApiUrlIsConfiguredThroughEnvVar, pollCount + 1); + } catch (e) { + return this.getHumanReadableErrorFromOnCallError(e, onCallApiUrl, 'sync', onCallApiUrlIsConfiguredThroughEnvVar); + } + }; + + /** + * Trigger a data sync with the OnCall backend AND then poll, for a configured amount of time, the status of that sync + * If the + * Returns a PluginSyncStatusResponse if the sync was succesful, otherwise null + */ + static syncDataWithOnCall = async ( + onCallApiUrl: string, + onCallApiUrlIsConfiguredThroughEnvVar = false + ): Promise => { + try { + const startSyncResponse = await makeRequest(`${this.ONCALL_BASE_URL}/sync`, { method: 'POST' }); + + if (typeof startSyncResponse === 'string') { + // an error occured trying to initiate the sync + return startSyncResponse; + } + + return await this.pollOnCallDataSyncStatus(onCallApiUrl, onCallApiUrlIsConfiguredThroughEnvVar); + } catch (e) { + return this.getHumanReadableErrorFromOnCallError(e, onCallApiUrl, 'sync', onCallApiUrlIsConfiguredThroughEnvVar); + } + }; + + static installPlugin = async ( + selfHosted = false + ): Promise> => { + const { key: grafanaToken } = await this.createGrafanaToken(); + await this.updateGrafanaPluginSettings({ secureJsonData: { grafanaToken } }); + const onCallAPIResponse = await makeRequest( + `${this.ONCALL_BASE_URL}/${selfHosted ? 'self-hosted/' : ''}install`, + { + method: 'POST', + } + ); + return { grafanaToken, onCallAPIResponse }; + }; + + static selfHostedInstallPlugin = async ( + onCallApiUrl: string, + onCallApiUrlIsConfiguredThroughEnvVar: boolean + ): Promise => { + let pluginInstallationOnCallResponse: InstallPluginResponse; + const errorMsgVerb: InstallationVerb = 'install'; + + // Step 1. Try provisioning the plugin w/ the Grafana API + try { + await this.updateGrafanaPluginSettings({ jsonData: { onCallApiUrl: onCallApiUrl } }); + } catch (e) { + return this.getHumanReadableErrorFromGrafanaProvisioningError( + e, + onCallApiUrl, + errorMsgVerb, + onCallApiUrlIsConfiguredThroughEnvVar + ); + } + + /** + * Step 2: + * - Create a grafana token + * - store that token in the Grafana plugin settings + * - configure the plugin in OnCall's backend + */ + try { + pluginInstallationOnCallResponse = await this.installPlugin(true); + } catch (e) { + return this.getHumanReadableErrorFromOnCallError( + e, + onCallApiUrl, + errorMsgVerb, + onCallApiUrlIsConfiguredThroughEnvVar + ); + } + + // Step 3. reprovision the Grafana plugin settings, storing information that we get back from OnCall's backend + try { + const { + grafanaToken, + onCallAPIResponse: { onCallToken: onCallApiToken, ...jsonData }, + } = pluginInstallationOnCallResponse; + + await this.updateGrafanaPluginSettings({ + jsonData: { + ...jsonData, + onCallApiUrl, + }, + secureJsonData: { + grafanaToken, + onCallApiToken, + }, + }); + } catch (e) { + return this.getHumanReadableErrorFromGrafanaProvisioningError( + e, + onCallApiUrl, + errorMsgVerb, + onCallApiUrlIsConfiguredThroughEnvVar + ); + } + + return null; + }; + + static checkIfPluginIsConnected = async ( + onCallApiUrl: string, + onCallApiUrlIsConfiguredThroughEnvVar = false + ): Promise => { + try { + const resp = await makeRequest(`${this.ONCALL_BASE_URL}/status`, { + method: 'GET', + }); + + if (!resp.token_ok) { + return `There was an issue with the communication between your OnCall API and your Grafana instance.\nPlease ensure that your OnCall API is properly configured to communicate with your Grafana instance.`; + } + return resp; + } catch (e) { + return this.getHumanReadableErrorFromOnCallError( + e, + onCallApiUrl, + 'install', + onCallApiUrlIsConfiguredThroughEnvVar + ); + } + }; + + static resetPlugin = (): Promise => { + /** + * mark both of these objects as Required.. this will ensure that we are resetting every attribute back to null + * and throw a type error in the event that OnCallPluginMetaJSONData or OnCallPluginMetaSecureJSONData is updated + * but we forget to add the attribute here + */ + const jsonData: Required = { + stackId: null, + orgId: null, + onCallApiUrl: null, + license: null, + }; + const secureJsonData: Required = { + grafanaToken: null, + onCallApiToken: null, + }; + + return this.updateGrafanaPluginSettings({ jsonData, secureJsonData }, false); + }; +} + +export default PluginState; diff --git a/grafana-plugin/src/state/plugin/plugin.test.ts b/grafana-plugin/src/state/plugin/plugin.test.ts new file mode 100644 index 00000000..32b72b66 --- /dev/null +++ b/grafana-plugin/src/state/plugin/plugin.test.ts @@ -0,0 +1,718 @@ +import { makeRequest as makeRequestOriginal } from 'network'; + +import PluginState, { InstallationVerb, PluginSyncStatusResponse, UpdateGrafanaPluginSettingsProps } from './'; + +const makeRequest = makeRequestOriginal as jest.Mock>; + +jest.mock('network'); + +afterEach(() => { + jest.resetAllMocks(); +}); + +const ONCALL_BASE_URL = '/plugin'; +const GRAFANA_PLUGIN_SETTINGS_URL = '/api/plugins/grafana-oncall-app/settings'; + +const generateMockAxiosError = (status: number, data = {}) => ({ isAxiosError: true, response: { status, ...data } }); + +describe('PluginState.generateOnCallApiUrlConfiguredThroughEnvVarMsg', () => { + test.each([true, false])( + 'it returns the proper error message - configured through env var: %s', + (configuredThroughEnvVar) => { + expect(PluginState.generateOnCallApiUrlConfiguredThroughEnvVarMsg(configuredThroughEnvVar)).toMatchSnapshot(); + } + ); +}); + +describe('PluginState.generateInvalidOnCallApiURLErrorMsg', () => { + test.each([true, false])( + 'it returns the proper error message - configured through env var: %s', + (configuredThroughEnvVar) => { + expect( + PluginState.generateInvalidOnCallApiURLErrorMsg('http://hello.com', configuredThroughEnvVar) + ).toMatchSnapshot(); + } + ); +}); + +describe('PluginState.generateUnknownErrorMsg', () => { + test.each([ + [true, 'install'], + [true, 'sync'], + [false, 'install'], + [false, 'sync'], + ])( + 'it returns the proper error message - configured through env var: %s', + (configuredThroughEnvVar, verb: InstallationVerb) => { + expect(PluginState.generateUnknownErrorMsg('http://hello.com', verb, configuredThroughEnvVar)).toMatchSnapshot(); + } + ); +}); + +describe('PluginState.getHumanReadableErrorFromOnCallError', () => { + test.each([502, 409])('it handles a non-400 AxiosError properly - status code: %s', (status) => { + expect( + PluginState.getHumanReadableErrorFromOnCallError( + generateMockAxiosError(status), + 'http://hello.com', + 'install', + true + ) + ).toMatchSnapshot(); + }); + + test.each([true, false])( + 'it handles a 400 AxiosError properly - has custom error message: %s', + (hasCustomErrorMessage) => { + const axiosError = generateMockAxiosError(400) as any; + if (hasCustomErrorMessage) { + axiosError.response.data = { error: 'ohhhh nooo an error' }; + } + expect( + PluginState.getHumanReadableErrorFromOnCallError(axiosError, 'http://hello.com', 'install', true) + ).toMatchSnapshot(); + } + ); + + test('it handles an unknown error properly', () => { + expect( + PluginState.getHumanReadableErrorFromOnCallError(new Error('asdfasdf'), 'http://hello.com', 'install', true) + ).toMatchSnapshot(); + }); +}); + +describe('PluginState.getHumanReadableErrorFromGrafanaProvisioningError', () => { + test.each([true, false])('it handles an error properly', (isAxiosError) => { + const onCallApiUrl = 'http://hello.com'; + const installationVerb = 'install'; + const onCallApiUrlIsConfiguredThroughEnvVar = true; + const axiosError = generateMockAxiosError(400); + const nonAxiosError = new Error('oh noooo'); + const error = isAxiosError ? axiosError : nonAxiosError; + + const mockGenerateInvalidOnCallApiURLErrorMsgResult = 'asdadslkjfkjlsd'; + const mockGenerateUnknownErrorMsgResult = 'asdadslkjfkjlsd'; + + PluginState.generateInvalidOnCallApiURLErrorMsg = jest + .fn() + .mockReturnValueOnce(mockGenerateInvalidOnCallApiURLErrorMsgResult); + PluginState.generateUnknownErrorMsg = jest.fn().mockReturnValueOnce(mockGenerateUnknownErrorMsgResult); + + const expectedErrorMsg = isAxiosError + ? mockGenerateInvalidOnCallApiURLErrorMsgResult + : mockGenerateUnknownErrorMsgResult; + + expect( + PluginState.getHumanReadableErrorFromGrafanaProvisioningError( + error, + onCallApiUrl, + installationVerb, + onCallApiUrlIsConfiguredThroughEnvVar + ) + ).toEqual(expectedErrorMsg); + + if (isAxiosError) { + expect(PluginState.generateInvalidOnCallApiURLErrorMsg).toHaveBeenCalledTimes(1); + expect(PluginState.generateInvalidOnCallApiURLErrorMsg).toHaveBeenCalledWith( + onCallApiUrl, + onCallApiUrlIsConfiguredThroughEnvVar + ); + } else { + expect(PluginState.generateUnknownErrorMsg).toHaveBeenCalledTimes(1); + expect(PluginState.generateUnknownErrorMsg).toHaveBeenCalledWith( + onCallApiUrl, + installationVerb, + onCallApiUrlIsConfiguredThroughEnvVar + ); + } + }); +}); + +describe('PluginState.getGrafanaPluginSettings', () => { + test('it calls the proper method', async () => { + PluginState.grafanaBackend.get = jest.fn(); + + await PluginState.getGrafanaPluginSettings(); + + expect(PluginState.grafanaBackend.get).toHaveBeenCalledTimes(1); + expect(PluginState.grafanaBackend.get).toHaveBeenCalledWith(GRAFANA_PLUGIN_SETTINGS_URL); + }); +}); + +describe('PluginState.updateGrafanaPluginSettings', () => { + test.each([true, false])('it calls the proper method - enabled: %s', async (enabled) => { + const data: UpdateGrafanaPluginSettingsProps = { + jsonData: { + onCallApiUrl: 'asdfasdf', + }, + secureJsonData: { + grafanaToken: 'kjdfkfdjkffd', + }, + }; + + PluginState.grafanaBackend.post = jest.fn(); + + await PluginState.updateGrafanaPluginSettings(data, enabled); + + expect(PluginState.grafanaBackend.post).toHaveBeenCalledTimes(1); + expect(PluginState.grafanaBackend.post).toHaveBeenCalledWith(GRAFANA_PLUGIN_SETTINGS_URL, { + ...data, + enabled, + pinned: true, + }); + }); +}); + +describe('PluginState.createGrafanaToken', () => { + test.each([true, false])('it calls the proper methods - existing key: %s', async (onCallKeyExists) => { + const baseUrl = '/api/auth/keys'; + const onCallKeyId = 12345; + const onCallKeyName = 'OnCall'; + const onCallKey = { name: onCallKeyName, id: onCallKeyId }; + const existingKeys = [{ name: 'foo', id: 9595 }]; + + PluginState.grafanaBackend.get = jest + .fn() + .mockResolvedValueOnce(onCallKeyExists ? [...existingKeys, onCallKey] : existingKeys); + PluginState.grafanaBackend.delete = jest.fn(); + PluginState.grafanaBackend.post = jest.fn(); + + await PluginState.createGrafanaToken(); + + expect(PluginState.grafanaBackend.get).toHaveBeenCalledTimes(1); + expect(PluginState.grafanaBackend.get).toHaveBeenCalledWith(baseUrl); + + if (onCallKeyExists) { + expect(PluginState.grafanaBackend.delete).toHaveBeenCalledTimes(1); + expect(PluginState.grafanaBackend.delete).toHaveBeenCalledWith(`${baseUrl}/${onCallKeyId}`); + } else { + expect(PluginState.grafanaBackend.delete).not.toHaveBeenCalled(); + } + + expect(PluginState.grafanaBackend.post).toHaveBeenCalledTimes(1); + expect(PluginState.grafanaBackend.post).toHaveBeenCalledWith(baseUrl, { + name: onCallKeyName, + role: 'Admin', + secondsToLive: null, + }); + }); +}); + +describe('PluginState.getPluginSyncStatus', () => { + test('it returns the plugin sync response', async () => { + // mocks + const mockedResp: PluginSyncStatusResponse = { + license: 'asdasdf', + version: 'asdasf', + token_ok: true, + }; + makeRequest.mockResolvedValueOnce(mockedResp); + + // test + const response = await PluginState.getPluginSyncStatus(); + + // assertions + expect(response).toEqual(mockedResp); + expect(makeRequest).toHaveBeenCalledTimes(1); + expect(makeRequest).toHaveBeenCalledWith(`${ONCALL_BASE_URL}/sync`, { method: 'GET' }); + }); +}); + +describe('PluginState.pollOnCallDataSyncStatus', () => { + const onCallApiUrl = 'http://hello.com'; + const onCallApiUrlIsConfiguredThroughEnvVar = true; + + test('it returns an error message if the pollCount is greater than 10', async () => { + // mocks + const mockSyncResponse = { token_ok: false }; + + PluginState.getPluginSyncStatus = jest.fn().mockResolvedValue(mockSyncResponse); + PluginState.timeout = jest.fn(); + + // test + const response = await PluginState.pollOnCallDataSyncStatus(onCallApiUrl, onCallApiUrlIsConfiguredThroughEnvVar); + + // assertions + expect(response).toMatchSnapshot(); + + expect(PluginState.getPluginSyncStatus).toHaveBeenCalledTimes(11); + expect(PluginState.getPluginSyncStatus).toHaveBeenCalledWith(); + + expect(PluginState.timeout).toHaveBeenCalledTimes(11); + expect(PluginState.timeout).toHaveBeenLastCalledWith(10); + }); + + test('it returns successfully if the getPluginSyncStatus response token_ok is true', async () => { + // mocks + const mockSyncResponse = { token_ok: true, foo: 'bar' }; + + PluginState.getPluginSyncStatus = jest.fn().mockResolvedValueOnce(mockSyncResponse); + + // test + const response = await PluginState.pollOnCallDataSyncStatus(onCallApiUrl, onCallApiUrlIsConfiguredThroughEnvVar); + + // assertions + expect(response).toEqual(mockSyncResponse); + + expect(PluginState.getPluginSyncStatus).toHaveBeenCalledTimes(1); + expect(PluginState.getPluginSyncStatus).toHaveBeenCalledWith(); + }); + + test('it recursively calls itself if the getPluginSyncStatus response token_ok is not true', async () => { + // mocks + const mockSyncResponse = { token_ok: false }; + const mock_pollOnCallDataSyncStatusResponse = { foo: 'bar' }; + + PluginState.getPluginSyncStatus = jest.fn().mockResolvedValueOnce(mockSyncResponse); + PluginState.timeout = jest.fn(); + PluginState._pollOnCallDataSyncStatus = jest.fn().mockResolvedValueOnce(mock_pollOnCallDataSyncStatusResponse); + + // test + const response = await PluginState.pollOnCallDataSyncStatus(onCallApiUrl, onCallApiUrlIsConfiguredThroughEnvVar, 8); + + // assertions + expect(response).toEqual(mock_pollOnCallDataSyncStatusResponse); + + expect(PluginState.getPluginSyncStatus).toHaveBeenCalledTimes(1); + expect(PluginState.getPluginSyncStatus).toHaveBeenCalledWith(); + + expect(PluginState.timeout).toHaveBeenCalledTimes(1); + expect(PluginState.timeout).toHaveBeenCalledWith(8); + + expect(PluginState._pollOnCallDataSyncStatus).toHaveBeenCalledTimes(1); + expect(PluginState._pollOnCallDataSyncStatus).toHaveBeenCalledWith( + onCallApiUrl, + onCallApiUrlIsConfiguredThroughEnvVar, + 9 + ); + }); + + test('it returns the result of getHumanReadableErrorFromOnCallError in the event of an error from getPluginSyncStatus', async () => { + // mocks + const mockError = { foo: 'bar' }; + const mockedHumanReadableError = 'kjdfkjfdjkfdkjfd'; + + PluginState.getPluginSyncStatus = jest.fn().mockRejectedValueOnce(mockError); + PluginState._pollOnCallDataSyncStatus = jest.fn(); + PluginState.getHumanReadableErrorFromOnCallError = jest.fn().mockReturnValueOnce(mockedHumanReadableError); + + // test + const response = await PluginState.pollOnCallDataSyncStatus(onCallApiUrl, onCallApiUrlIsConfiguredThroughEnvVar); + + // assertions + expect(response).toEqual(mockedHumanReadableError); + + expect(PluginState.getPluginSyncStatus).toHaveBeenCalledTimes(1); + expect(PluginState.getPluginSyncStatus).toHaveBeenCalledWith(); + + expect(PluginState.getHumanReadableErrorFromOnCallError).toHaveBeenCalledTimes(1); + expect(PluginState.getHumanReadableErrorFromOnCallError).toHaveBeenCalledWith( + mockError, + onCallApiUrl, + 'sync', + onCallApiUrlIsConfiguredThroughEnvVar + ); + + expect(PluginState._pollOnCallDataSyncStatus).not.toHaveBeenCalled(); + }); + + test('it returns the result of getHumanReadableErrorFromOnCallError in the event of an error from a recursive call to pollOnCallDataSyncStatus', async () => { + // mocks + const mockSyncResponse = { token_ok: false }; + const mockError = { foo: 'bar' }; + const mockedHumanReadableError = 'kjdfkjfdjkfdkjfd'; + + PluginState.getPluginSyncStatus = jest.fn().mockResolvedValueOnce(mockSyncResponse); + PluginState._pollOnCallDataSyncStatus = jest.fn().mockRejectedValueOnce(mockError); + PluginState.timeout = jest.fn(); + PluginState.getHumanReadableErrorFromOnCallError = jest.fn().mockReturnValueOnce(mockedHumanReadableError); + + // test + const response = await PluginState.pollOnCallDataSyncStatus(onCallApiUrl, onCallApiUrlIsConfiguredThroughEnvVar, 5); + + // assertions + expect(response).toEqual(mockedHumanReadableError); + + expect(PluginState.getPluginSyncStatus).toHaveBeenCalledTimes(1); + expect(PluginState.getPluginSyncStatus).toHaveBeenCalledWith(); + + expect(PluginState.timeout).toHaveBeenCalledTimes(1); + expect(PluginState.timeout).toHaveBeenCalledWith(5); + + expect(PluginState._pollOnCallDataSyncStatus).toHaveBeenCalledTimes(1); + expect(PluginState._pollOnCallDataSyncStatus).toHaveBeenCalledWith( + onCallApiUrl, + onCallApiUrlIsConfiguredThroughEnvVar, + 6 + ); + + expect(PluginState.getHumanReadableErrorFromOnCallError).toHaveBeenCalledTimes(1); + expect(PluginState.getHumanReadableErrorFromOnCallError).toHaveBeenCalledWith( + mockError, + onCallApiUrl, + 'sync', + onCallApiUrlIsConfiguredThroughEnvVar + ); + }); +}); + +describe('PluginState.syncDataWithOnCall', () => { + const onCallApiUrl = 'http://hello.com'; + const onCallApiUrlIsConfiguredThroughEnvVar = true; + const requestUrl = `${ONCALL_BASE_URL}/sync`; + const requestArgs = { method: 'POST' }; + + test('it returns the error mesage if the start sync returns an error', async () => { + // mocks + const errorMsg = 'asdfasdf'; + + makeRequest.mockResolvedValueOnce(errorMsg); + PluginState.pollOnCallDataSyncStatus = jest.fn(); + + // test + const response = await PluginState.syncDataWithOnCall(onCallApiUrl, onCallApiUrlIsConfiguredThroughEnvVar); + + // assertions + expect(response).toEqual(errorMsg); + + expect(makeRequest).toHaveBeenCalledTimes(1); + expect(makeRequest).toHaveBeenCalledWith(requestUrl, requestArgs); + + expect(PluginState.pollOnCallDataSyncStatus).not.toHaveBeenCalled(); + }); + + test('it calls pollOnCallDataSyncStatus if the start sync does not return an error', async () => { + // mocks + const mockedResponse = { foo: 'bar' }; + const mockedPollOnCallDataSyncStatusResponse = 'dfjkdfjdf'; + + makeRequest.mockResolvedValueOnce(mockedResponse); + PluginState.pollOnCallDataSyncStatus = jest.fn().mockResolvedValueOnce(mockedPollOnCallDataSyncStatusResponse); + + // test + const response = await PluginState.syncDataWithOnCall(onCallApiUrl, onCallApiUrlIsConfiguredThroughEnvVar); + + // assertions + expect(response).toEqual(mockedPollOnCallDataSyncStatusResponse); + + expect(makeRequest).toHaveBeenCalledTimes(1); + expect(makeRequest).toHaveBeenCalledWith(requestUrl, requestArgs); + + expect(PluginState.pollOnCallDataSyncStatus).toHaveBeenCalledTimes(1); + expect(PluginState.pollOnCallDataSyncStatus).toHaveBeenCalledWith( + onCallApiUrl, + onCallApiUrlIsConfiguredThroughEnvVar + ); + }); + + test('it calls getHumanReadableErrorFromOnCallError if an unknown error pops up', async () => { + // mocks + const mockedError = { foo: 'bar' }; + const mockedHumanReadableError = 'asdfjkdfjkdfjk'; + + makeRequest.mockRejectedValueOnce(mockedError); + PluginState.pollOnCallDataSyncStatus = jest.fn(); + PluginState.getHumanReadableErrorFromOnCallError = jest.fn().mockReturnValueOnce(mockedHumanReadableError); + + // test + const response = await PluginState.syncDataWithOnCall(onCallApiUrl, onCallApiUrlIsConfiguredThroughEnvVar); + + // assertions + expect(response).toEqual(mockedHumanReadableError); + + expect(makeRequest).toHaveBeenCalledTimes(1); + expect(makeRequest).toHaveBeenCalledWith(requestUrl, requestArgs); + + expect(PluginState.getHumanReadableErrorFromOnCallError).toHaveBeenCalledTimes(1); + expect(PluginState.getHumanReadableErrorFromOnCallError).toHaveBeenCalledWith( + mockedError, + onCallApiUrl, + 'sync', + onCallApiUrlIsConfiguredThroughEnvVar + ); + + expect(PluginState.pollOnCallDataSyncStatus).not.toHaveBeenCalled(); + }); +}); + +describe('PluginState.installPlugin', () => { + it.each([true, false])('returns the proper response - self hosted: %s', async (selfHosted) => { + // mocks + const mockedResponse = 'asdfasdf'; + const grafanaToken = 'asdfasdf'; + const mockedCreateGrafanaTokenResponse = { key: grafanaToken }; + + makeRequest.mockResolvedValueOnce(mockedResponse); + PluginState.createGrafanaToken = jest.fn().mockResolvedValueOnce(mockedCreateGrafanaTokenResponse); + PluginState.updateGrafanaPluginSettings = jest.fn(); + + // test + const response = await PluginState.installPlugin(selfHosted); + + // assertions + expect(response).toEqual({ + grafanaToken, + onCallAPIResponse: mockedResponse, + }); + + expect(PluginState.createGrafanaToken).toBeCalledTimes(1); + expect(PluginState.createGrafanaToken).toBeCalledWith(); + + expect(PluginState.updateGrafanaPluginSettings).toBeCalledTimes(1); + expect(PluginState.updateGrafanaPluginSettings).toBeCalledWith({ + secureJsonData: { + grafanaToken, + }, + }); + + expect(makeRequest).toBeCalledTimes(1); + expect(makeRequest).toBeCalledWith(`${PluginState.ONCALL_BASE_URL}/${selfHosted ? 'self-hosted/' : ''}install`, { + method: 'POST', + }); + }); +}); + +describe('PluginState.selfHostedInstallPlugin', () => { + test('it returns null if everything is successful', async () => { + // mocks + const onCallApiUrl = 'http://hello.com'; + const installPluginResponse = { + grafanaToken: 'asldkaljkasdfjklfdasklj', + onCallAPIResponse: { + stackId: 5, + orgId: 5, + license: 'asdfasdf', + onCallToken: 'asdfasdf', + }, + }; + const { + grafanaToken, + onCallAPIResponse: { onCallToken: onCallApiToken, ...jsonData }, + } = installPluginResponse; + + PluginState.updateGrafanaPluginSettings = jest.fn(); + PluginState.installPlugin = jest.fn().mockResolvedValueOnce(installPluginResponse); + + // test + const response = await PluginState.selfHostedInstallPlugin(onCallApiUrl, false); + + // assertions + expect(response).toBeNull(); + + expect(PluginState.updateGrafanaPluginSettings).toHaveBeenNthCalledWith(1, { + jsonData: { + onCallApiUrl, + }, + }); + + expect(PluginState.installPlugin).toHaveBeenCalledTimes(1); + expect(PluginState.installPlugin).toHaveBeenCalledWith(true); + + expect(PluginState.updateGrafanaPluginSettings).toHaveBeenNthCalledWith(2, { + jsonData: { + ...jsonData, + onCallApiUrl, + }, + secureJsonData: { + grafanaToken, + onCallApiToken, + }, + }); + }); + + test('it returns an error msg if it cannot update the provisioning settings the first time around', async () => { + // mocks + const onCallApiUrl = 'http://hello.com'; + const mockedError = new Error('ohhh nooo'); + const mockedHumanReadableError = 'asdflkajsdflkajsdf'; + + PluginState.updateGrafanaPluginSettings = jest.fn().mockRejectedValueOnce(mockedError); + PluginState.getHumanReadableErrorFromGrafanaProvisioningError = jest + .fn() + .mockReturnValueOnce(mockedHumanReadableError); + + // test + const response = await PluginState.selfHostedInstallPlugin(onCallApiUrl, false); + + // assertions + expect(response).toEqual(mockedHumanReadableError); + + expect(PluginState.updateGrafanaPluginSettings).toHaveBeenCalledTimes(1); + expect(PluginState.updateGrafanaPluginSettings).toHaveBeenCalledWith({ + jsonData: { + onCallApiUrl, + }, + }); + + expect(PluginState.getHumanReadableErrorFromGrafanaProvisioningError).toHaveBeenCalledTimes(1); + expect(PluginState.getHumanReadableErrorFromGrafanaProvisioningError).toHaveBeenCalledWith( + mockedError, + onCallApiUrl, + 'install', + false + ); + }); + + test('it returns an error msg if it fails when installing the plugin,', async () => { + // mocks + const onCallApiUrl = 'http://hello.com'; + const mockedError = new Error('ohhh nooo'); + const mockedHumanReadableError = 'asdflkajsdflkajsdf'; + + PluginState.updateGrafanaPluginSettings = jest.fn(); + PluginState.installPlugin = jest.fn().mockRejectedValueOnce(mockedError); + PluginState.getHumanReadableErrorFromOnCallError = jest.fn().mockReturnValueOnce(mockedHumanReadableError); + + // test + const response = await PluginState.selfHostedInstallPlugin(onCallApiUrl, false); + + // assertions + expect(response).toEqual(mockedHumanReadableError); + + expect(PluginState.installPlugin).toHaveBeenCalledTimes(1); + expect(PluginState.installPlugin).toHaveBeenCalledWith(true); + + expect(PluginState.getHumanReadableErrorFromOnCallError).toHaveBeenCalledTimes(1); + expect(PluginState.getHumanReadableErrorFromOnCallError).toHaveBeenCalledWith( + mockedError, + onCallApiUrl, + 'install', + false + ); + }); + + test('it returns an error msg if it cannot update the provisioning settings the second time around', async () => { + // mocks + const onCallApiUrl = 'http://hello.com'; + const mockedError = new Error('ohhh nooo'); + const mockedHumanReadableError = 'asdflkajsdflkajsdf'; + const installPluginResponse = { + grafanaToken: 'asldkaljkasdfjklfdasklj', + onCallAPIResponse: { + stackId: 5, + orgId: 5, + license: 'asdfasdf', + onCallToken: 'asdfasdf', + }, + }; + const { + grafanaToken, + onCallAPIResponse: { onCallToken: onCallApiToken, ...jsonData }, + } = installPluginResponse; + + PluginState.updateGrafanaPluginSettings = jest.fn().mockResolvedValueOnce(null).mockRejectedValueOnce(mockedError); + PluginState.installPlugin = jest.fn().mockResolvedValueOnce(installPluginResponse); + PluginState.getHumanReadableErrorFromGrafanaProvisioningError = jest + .fn() + .mockReturnValueOnce(mockedHumanReadableError); + + // test + const response = await PluginState.selfHostedInstallPlugin(onCallApiUrl, false); + + // assertions + expect(response).toEqual(mockedHumanReadableError); + + expect(PluginState.updateGrafanaPluginSettings).toHaveBeenNthCalledWith(1, { + jsonData: { + onCallApiUrl, + }, + }); + + expect(PluginState.installPlugin).toHaveBeenCalledTimes(1); + expect(PluginState.installPlugin).toHaveBeenCalledWith(true); + + expect(PluginState.updateGrafanaPluginSettings).toHaveBeenNthCalledWith(2, { + jsonData: { + ...jsonData, + onCallApiUrl, + }, + secureJsonData: { + grafanaToken, + onCallApiToken, + }, + }); + + expect(PluginState.getHumanReadableErrorFromGrafanaProvisioningError).toHaveBeenCalledTimes(1); + expect(PluginState.getHumanReadableErrorFromGrafanaProvisioningError).toHaveBeenCalledWith( + mockedError, + onCallApiUrl, + 'install', + false + ); + }); +}); + +describe('PluginState.checkIfPluginIsConnected', () => { + test.each([true, false])('token_ok: %s', async (tokenOk) => { + // mocks + const mockedResp = { token_ok: tokenOk }; + const onCallApiUrl = 'http://hello.com'; + makeRequest.mockResolvedValueOnce(mockedResp); + + // test + const response = await PluginState.checkIfPluginIsConnected(onCallApiUrl); + + // assertions + expect(response).toMatchSnapshot(); + + expect(makeRequest).toHaveBeenCalledTimes(1); + expect(makeRequest).toHaveBeenCalledWith(`${ONCALL_BASE_URL}/status`, { method: 'GET' }); + }); + + test('it returns a human readable error in the event of an unsuccessful api call', async () => { + // mocks + const mockedError = new Error('hello'); + const mockedHumanReadableError = 'asdflkajsdflkajsdf'; + const onCallApiUrl = 'http://hello.com'; + makeRequest.mockRejectedValueOnce(mockedError); + + PluginState.getHumanReadableErrorFromOnCallError = jest.fn().mockReturnValueOnce(mockedHumanReadableError); + + // test + const response = await PluginState.checkIfPluginIsConnected(onCallApiUrl); + + // assertions + expect(response).toEqual(mockedHumanReadableError); + + expect(makeRequest).toHaveBeenCalledTimes(1); + expect(makeRequest).toHaveBeenCalledWith(`${ONCALL_BASE_URL}/status`, { method: 'GET' }); + + expect(PluginState.getHumanReadableErrorFromOnCallError).toHaveBeenCalledTimes(1); + expect(PluginState.getHumanReadableErrorFromOnCallError).toHaveBeenCalledWith( + mockedError, + onCallApiUrl, + 'install', + false + ); + }); +}); + +describe('PluginState.resetPlugin', () => { + test('it calls grafanaBackend.post with the proper settings', async () => { + // mocks + const mockedResponse = 'asdfasdf'; + PluginState.updateGrafanaPluginSettings = jest.fn().mockResolvedValueOnce(mockedResponse); + + // test + const response = await PluginState.resetPlugin(); + + // assertions + expect(response).toEqual(mockedResponse); + expect(PluginState.updateGrafanaPluginSettings).toHaveBeenCalledTimes(1); + expect(PluginState.updateGrafanaPluginSettings).toHaveBeenCalledWith( + { + jsonData: { + stackId: null, + orgId: null, + onCallApiUrl: null, + license: null, + }, + secureJsonData: { + grafanaToken: null, + onCallApiToken: null, + }, + }, + false + ); + }); +}); diff --git a/grafana-plugin/src/state/rootBaseStore.ts b/grafana-plugin/src/state/rootBaseStore/index.ts similarity index 62% rename from grafana-plugin/src/state/rootBaseStore.ts rename to grafana-plugin/src/state/rootBaseStore/index.ts index 8dfd0e59..92b20d5a 100644 --- a/grafana-plugin/src/state/rootBaseStore.ts +++ b/grafana-plugin/src/state/rootBaseStore/index.ts @@ -1,9 +1,9 @@ -import { AppPluginMeta } from '@grafana/data'; -import { getBackendSrv } from '@grafana/runtime'; +import { OrgRole } from '@grafana/data'; +import { contextSrv } from 'grafana/app/core/core'; import { action, observable } from 'mobx'; import moment from 'moment-timezone'; import qs from 'query-string'; -import { OnCallAppSettings } from 'types'; +import { OnCallAppPluginMeta } from 'types'; import { AlertReceiveChannelStore } from 'models/alert_receive_channel/alert_receive_channel'; import { AlertReceiveChannel } from 'models/alert_receive_channel/alert_receive_channel.types'; @@ -30,16 +30,9 @@ import { UserStore } from 'models/user/user'; import { UserGroupStore } from 'models/user_group/user_group'; import { makeRequest } from 'network'; import { NavMenuItem } from 'pages/routes'; - -import { AppFeature } from './features'; -import { - getPluginSyncStatus, - installPlugin, - startPluginSync, - SYNC_STATUS_RETRY_LIMIT, - syncStatusDelay, -} from './plugin'; -import { UserAction } from './userAction'; +import { AppFeature } from 'state/features'; +import PluginState from 'state/plugin'; +import { UserAction } from 'state/userAction'; // ------ Dashboard ------ // @@ -57,25 +50,7 @@ export class RootBaseStore { backendLicense = ''; @observable - pluginIsInitialized = true; - - @observable - correctProvisioningForInstallation = true; - - @observable - correctRoleForInstallation = true; - - @observable - signupAllowedForPlugin = true; - - @observable - initializationError = ''; - - @observable - retrySync = false; - - @observable - isUserAnonymous = false; + initializationError = null; @observable isMobile = false; @@ -143,107 +118,82 @@ export class RootBaseStore { ]); } - async getUserRole() { - const user = await getBackendSrv().get('/api/user'); - const userRoles = await getBackendSrv().get('/api/user/orgs'); - const userRole = userRoles.find( - (userRole: { name: string; orgId: number; role: string }) => userRole.orgId === user.orgId - ); - return userRole.role; + setupPluginError(errorMsg: string) { + this.appLoading = false; + this.initializationError = errorMsg; } - async finishSync(get_sync_response: any) { - if (!get_sync_response.token_ok) { - this.initializationError = 'OnCall was not able to connect back to this Grafana'; - return; + /** + * First check to see if the plugin has been provisioned (plugin's meta jsonData has an onCallApiUrl saved) + * If not, tell the user they first need to configure/provision the plugin. + * + * Otherwise, get the plugin connection status from the OnCall API and check a few pre-conditions: + * - plugin must be considered installed by the OnCall API + * - user must be not "anonymous" (this is determined by the plugin-proxy) + * - the OnCall API must be currently allowing signup + * - the user must have an Admin role + * If these conditions are all met then trigger a data sync w/ the OnCall backend and poll its response + * Finally, try to load the current user from the OnCall backend + */ + async setupPlugin(meta: OnCallAppPluginMeta) { + this.appLoading = true; + this.initializationError = null; + this.onCallApiUrl = meta.jsonData?.onCallApiUrl; + + if (!this.onCallApiUrl) { + // plugin is not provisioned + return this.setupPluginError('🚫 Plugin has not been initialized'); + } + + // at this point we know the plugin is provionsed + const pluginConnectionStatus = await PluginState.checkIfPluginIsConnected(this.onCallApiUrl); + if (typeof pluginConnectionStatus === 'string') { + return this.setupPluginError(pluginConnectionStatus); + } + + const { allow_signup, is_installed, is_user_anonymous } = pluginConnectionStatus; + if (is_user_anonymous) { + return this.setupPluginError( + '😞 Unfortunately Grafana OnCall is available for authorized users only, please sign in to proceed.' + ); + } else if (!is_installed) { + if (!allow_signup) { + return this.setupPluginError('🚫 OnCall has temporarily disabled signup of new users. Please try again later.'); + } + + if (!contextSrv.hasRole(OrgRole.Admin)) { + return this.setupPluginError('🚫 Admin must sign on to setup OnCall before a Viewer can use it'); + } + + try { + /** + * this will install AND sync the necessary data + * the sync is done automatically by the /plugin/install OnCall API endpoint + * therefore there is no need to trigger an additional/separate sync, nor poll a status + */ + await PluginState.installPlugin(); + } catch (e) { + return this.setupPluginError(PluginState.getHumanReadableErrorFromOnCallError(e, this.onCallApiUrl, 'install')); + } + } else { + const syncDataResponse = await PluginState.syncDataWithOnCall(this.onCallApiUrl); + + if (typeof syncDataResponse === 'string') { + return this.setupPluginError(syncDataResponse); + } + + // everything is all synced successfully at this point.. + this.backendVersion = syncDataResponse.version; + this.backendLicense = syncDataResponse.license; } - this.backendVersion = get_sync_response.version; - this.backendLicense = get_sync_response.license; try { await this.userStore.loadCurrentUser(); - - this.appLoading = false; } catch (e) { - this.initializationError = 'OnCall was not able to initialize current user'; - } - } - - handleSyncException(e: any) { - this.initializationError = e.response.status; - } - - async startSync() { - try { - return await startPluginSync(); - } catch (e) { - if (e.response.status === 403) { - this.correctProvisioningForInstallation = false; - return; - } else { - this.initializationError = e.response.status; - return; - } - } - } - - resetStatusToDefault() { - this.appLoading = true; - this.pluginIsInitialized = true; - this.correctProvisioningForInstallation = true; - this.correctRoleForInstallation = true; - this.signupAllowedForPlugin = true; - this.initializationError = ''; - this.retrySync = false; - this.isUserAnonymous = false; - } - - async waitForSyncStatus(retryCount = 0) { - if (retryCount > SYNC_STATUS_RETRY_LIMIT) { - this.retrySync = true; - return; + return this.setupPluginError('OnCall was not able to load the current user. Try refreshing the page'); } - getPluginSyncStatus() - .then((get_sync_response) => { - if (get_sync_response.hasOwnProperty('token_ok')) { - this.finishSync(get_sync_response); - } else { - syncStatusDelay(retryCount + 1).then(() => this.waitForSyncStatus(retryCount + 1)); - } - }) - .catch((e) => { - this.handleSyncException(e); - }); - } - - async setupPlugin(meta: AppPluginMeta) { - this.resetStatusToDefault(); - - if (!meta.jsonData?.onCallApiUrl) { - this.pluginIsInitialized = false; - return; - } - - this.onCallApiUrl = meta.jsonData.onCallApiUrl; - - let syncStartStatus = await this.startSync(); - if (syncStartStatus.is_user_anonymous) { - this.isUserAnonymous = true; - return; - } else if (!syncStartStatus.is_installed) { - if (!syncStartStatus.allow_signup) { - this.signupAllowedForPlugin = false; - return; - } - const userRole = await this.getUserRole(); - if (userRole !== 'Admin') { - this.correctRoleForInstallation = false; - return; - } - await installPlugin(); - } - await this.waitForSyncStatus(); + this.appLoading = false; } isUserActionAllowed(action: UserAction) { @@ -278,7 +228,7 @@ export class RootBaseStore { } async getApiUrlForSettings() { - const settings = await getBackendSrv().get('/api/plugins/grafana-oncall-app/settings'); + const settings = await PluginState.getGrafanaPluginSettings(); return settings.jsonData?.onCallApiUrl; } } diff --git a/grafana-plugin/src/state/rootBaseStore/rootBaseStore.test.ts b/grafana-plugin/src/state/rootBaseStore/rootBaseStore.test.ts new file mode 100644 index 00000000..ec9999af --- /dev/null +++ b/grafana-plugin/src/state/rootBaseStore/rootBaseStore.test.ts @@ -0,0 +1,295 @@ +import { OrgRole } from '@grafana/data'; +import { contextSrv as contextSrvOriginal } from 'grafana/app/core/core'; +import { OnCallAppPluginMeta } from 'types'; + +import PluginState from 'state/plugin'; + +import { RootBaseStore } from './'; + +const contextSrv = contextSrvOriginal as { hasRole: jest.Mock> }; + +jest.mock('state/plugin'); + +const generatePluginData = ( + onCallApiUrl: OnCallAppPluginMeta['jsonData']['onCallApiUrl'] = null +): OnCallAppPluginMeta => + ({ + jsonData: onCallApiUrl === null ? null : { onCallApiUrl }, + } as OnCallAppPluginMeta); + +describe('rootBaseStore', () => { + afterEach(() => { + jest.resetAllMocks(); + }); + + test("onCallApiUrl is not set in the plugin's meta jsonData", async () => { + // mocks/setup + const rootBaseStore = new RootBaseStore(); + + // test + await rootBaseStore.setupPlugin(generatePluginData()); + + // assertions + expect(rootBaseStore.appLoading).toBe(false); + expect(rootBaseStore.initializationError).toEqual('🚫 Plugin has not been initialized'); + }); + + test('when there is an issue checking the plugin connection, the error is properly handled', async () => { + // mocks/setup + const errorMsg = 'ohhh noooo error'; + const onCallApiUrl = 'http://asdfasdf.com'; + const rootBaseStore = new RootBaseStore(); + + PluginState.checkIfPluginIsConnected = jest.fn().mockResolvedValueOnce(errorMsg); + + // test + await rootBaseStore.setupPlugin(generatePluginData(onCallApiUrl)); + + // assertions + expect(PluginState.checkIfPluginIsConnected).toHaveBeenCalledTimes(1); + expect(PluginState.checkIfPluginIsConnected).toHaveBeenCalledWith(onCallApiUrl); + + expect(rootBaseStore.appLoading).toBe(false); + expect(rootBaseStore.initializationError).toEqual(errorMsg); + }); + + test('anonymous user', async () => { + // mocks/setup + const onCallApiUrl = 'http://asdfasdf.com'; + const rootBaseStore = new RootBaseStore(); + + PluginState.checkIfPluginIsConnected = jest.fn().mockResolvedValueOnce({ + is_user_anonymous: true, + is_installed: true, + token_ok: true, + allow_signup: true, + version: 'asdfasdf', + license: 'asdfasdf', + }); + + // test + await rootBaseStore.setupPlugin(generatePluginData(onCallApiUrl)); + + // assertions + expect(PluginState.checkIfPluginIsConnected).toHaveBeenCalledTimes(1); + expect(PluginState.checkIfPluginIsConnected).toHaveBeenCalledWith(onCallApiUrl); + + expect(rootBaseStore.appLoading).toBe(false); + expect(rootBaseStore.initializationError).toEqual( + '😞 Unfortunately Grafana OnCall is available for authorized users only, please sign in to proceed.' + ); + }); + + test('the plugin is not installed, and allow_signup is false', async () => { + // mocks/setup + const onCallApiUrl = 'http://asdfasdf.com'; + const rootBaseStore = new RootBaseStore(); + + PluginState.checkIfPluginIsConnected = jest.fn().mockResolvedValueOnce({ + is_user_anonymous: false, + is_installed: false, + token_ok: true, + allow_signup: false, + version: 'asdfasdf', + license: 'asdfasdf', + }); + PluginState.installPlugin = jest.fn().mockResolvedValueOnce(null); + + // test + await rootBaseStore.setupPlugin(generatePluginData(onCallApiUrl)); + + // assertions + expect(PluginState.checkIfPluginIsConnected).toHaveBeenCalledTimes(1); + expect(PluginState.checkIfPluginIsConnected).toHaveBeenCalledWith(onCallApiUrl); + + expect(PluginState.installPlugin).toHaveBeenCalledTimes(0); + + expect(rootBaseStore.appLoading).toBe(false); + expect(rootBaseStore.initializationError).toEqual( + '🚫 OnCall has temporarily disabled signup of new users. Please try again later.' + ); + }); + + test('plugin is not installed, user is not an Admin', async () => { + // mocks/setup + const onCallApiUrl = 'http://asdfasdf.com'; + const rootBaseStore = new RootBaseStore(); + + PluginState.checkIfPluginIsConnected = jest.fn().mockResolvedValueOnce({ + is_user_anonymous: false, + is_installed: false, + token_ok: true, + allow_signup: true, + version: 'asdfasdf', + license: 'asdfasdf', + }); + contextSrv.hasRole.mockReturnValueOnce(false); + PluginState.installPlugin = jest.fn().mockResolvedValueOnce(null); + + // test + await rootBaseStore.setupPlugin(generatePluginData(onCallApiUrl)); + + // assertions + expect(PluginState.checkIfPluginIsConnected).toHaveBeenCalledTimes(1); + expect(PluginState.checkIfPluginIsConnected).toHaveBeenCalledWith(onCallApiUrl); + + expect(contextSrv.hasRole).toHaveBeenCalledTimes(1); + expect(contextSrv.hasRole).toHaveBeenCalledWith(OrgRole.Admin); + + expect(PluginState.installPlugin).toHaveBeenCalledTimes(0); + + expect(rootBaseStore.appLoading).toBe(false); + expect(rootBaseStore.initializationError).toEqual( + '🚫 Admin must sign on to setup OnCall before a Viewer can use it' + ); + }); + + test('plugin is not installed, signup is allowed, user is an admin, plugin installation is triggered', async () => { + // mocks/setup + const onCallApiUrl = 'http://asdfasdf.com'; + const rootBaseStore = new RootBaseStore(); + const mockedLoadCurrentUser = jest.fn(); + + PluginState.checkIfPluginIsConnected = jest.fn().mockResolvedValueOnce({ + is_user_anonymous: false, + is_installed: false, + token_ok: true, + allow_signup: true, + version: 'asdfasdf', + license: 'asdfasdf', + }); + contextSrv.hasRole.mockReturnValueOnce(true); + PluginState.installPlugin = jest.fn().mockResolvedValueOnce(null); + rootBaseStore.userStore.loadCurrentUser = mockedLoadCurrentUser; + + // test + await rootBaseStore.setupPlugin(generatePluginData(onCallApiUrl)); + + // assertions + expect(PluginState.checkIfPluginIsConnected).toHaveBeenCalledTimes(1); + expect(PluginState.checkIfPluginIsConnected).toHaveBeenCalledWith(onCallApiUrl); + + expect(contextSrv.hasRole).toHaveBeenCalledTimes(1); + expect(contextSrv.hasRole).toHaveBeenCalledWith(OrgRole.Admin); + + expect(PluginState.installPlugin).toHaveBeenCalledTimes(1); + expect(PluginState.installPlugin).toHaveBeenCalledWith(); + + expect(mockedLoadCurrentUser).toHaveBeenCalledTimes(1); + expect(mockedLoadCurrentUser).toHaveBeenCalledWith(); + + expect(rootBaseStore.appLoading).toBe(false); + expect(rootBaseStore.initializationError).toBeNull(); + }); + + test('plugin is not installed, signup is allowed, the user is an admin, and plugin installation throws an error', async () => { + // mocks/setup + const onCallApiUrl = 'http://asdfasdf.com'; + const rootBaseStore = new RootBaseStore(); + const installPluginError = new Error('asdasdfasdfasf'); + const humanReadableErrorMsg = 'asdfasldkfjaksdjflk'; + + PluginState.checkIfPluginIsConnected = jest.fn().mockResolvedValueOnce({ + is_user_anonymous: false, + is_installed: false, + token_ok: true, + allow_signup: true, + version: 'asdfasdf', + license: 'asdfasdf', + }); + contextSrv.hasRole.mockReturnValueOnce(true); + PluginState.installPlugin = jest.fn().mockRejectedValueOnce(installPluginError); + PluginState.getHumanReadableErrorFromOnCallError = jest.fn().mockReturnValueOnce(humanReadableErrorMsg); + + // test + await rootBaseStore.setupPlugin(generatePluginData(onCallApiUrl)); + + // assertions + expect(PluginState.checkIfPluginIsConnected).toHaveBeenCalledTimes(1); + expect(PluginState.checkIfPluginIsConnected).toHaveBeenCalledWith(onCallApiUrl); + + expect(contextSrv.hasRole).toHaveBeenCalledTimes(1); + expect(contextSrv.hasRole).toHaveBeenCalledWith(OrgRole.Admin); + + expect(PluginState.installPlugin).toHaveBeenCalledTimes(1); + expect(PluginState.installPlugin).toHaveBeenCalledWith(); + + expect(PluginState.getHumanReadableErrorFromOnCallError).toHaveBeenCalledTimes(1); + expect(PluginState.getHumanReadableErrorFromOnCallError).toHaveBeenCalledWith( + installPluginError, + onCallApiUrl, + 'install' + ); + + expect(rootBaseStore.appLoading).toBe(false); + expect(rootBaseStore.initializationError).toEqual(humanReadableErrorMsg); + }); + + test('when the plugin is installed, a data sync is triggered', async () => { + // mocks/setup + const onCallApiUrl = 'http://asdfasdf.com'; + const rootBaseStore = new RootBaseStore(); + const mockedLoadCurrentUser = jest.fn(); + const version = 'asdfalkjslkjdf'; + const license = 'lkjdkjfdkjfdjkfd'; + + PluginState.checkIfPluginIsConnected = jest.fn().mockResolvedValueOnce({ + is_user_anonymous: false, + is_installed: true, + token_ok: true, + allow_signup: true, + version: 'asdfasdf', + license: 'asdfasdf', + }); + PluginState.syncDataWithOnCall = jest.fn().mockResolvedValueOnce({ version, license, token_ok: true }); + rootBaseStore.userStore.loadCurrentUser = mockedLoadCurrentUser; + + // test + await rootBaseStore.setupPlugin(generatePluginData(onCallApiUrl)); + + // assertions + expect(PluginState.checkIfPluginIsConnected).toHaveBeenCalledTimes(1); + expect(PluginState.checkIfPluginIsConnected).toHaveBeenCalledWith(onCallApiUrl); + + expect(PluginState.syncDataWithOnCall).toHaveBeenCalledTimes(1); + expect(PluginState.syncDataWithOnCall).toHaveBeenCalledWith(onCallApiUrl); + + expect(mockedLoadCurrentUser).toHaveBeenCalledTimes(1); + expect(mockedLoadCurrentUser).toHaveBeenCalledWith(); + + expect(rootBaseStore.appLoading).toBe(false); + expect(rootBaseStore.initializationError).toBeNull(); + }); + + test('when the plugin is installed, and the data sync returns an error, it is properly handled', async () => { + // mocks/setup + const onCallApiUrl = 'http://asdfasdf.com'; + const rootBaseStore = new RootBaseStore(); + const mockedLoadCurrentUser = jest.fn(); + const syncDataWithOnCallError = 'asdasdfasdfasf'; + + PluginState.checkIfPluginIsConnected = jest.fn().mockResolvedValueOnce({ + is_user_anonymous: false, + is_installed: true, + token_ok: true, + allow_signup: true, + version: 'asdfasdf', + license: 'asdfasdf', + }); + PluginState.syncDataWithOnCall = jest.fn().mockResolvedValueOnce(syncDataWithOnCallError); + rootBaseStore.userStore.loadCurrentUser = mockedLoadCurrentUser; + + // test + await rootBaseStore.setupPlugin(generatePluginData(onCallApiUrl)); + + // assertions + expect(PluginState.checkIfPluginIsConnected).toHaveBeenCalledTimes(1); + expect(PluginState.checkIfPluginIsConnected).toHaveBeenCalledWith(onCallApiUrl); + + expect(PluginState.syncDataWithOnCall).toHaveBeenCalledTimes(1); + expect(PluginState.syncDataWithOnCall).toHaveBeenCalledWith(onCallApiUrl); + + expect(rootBaseStore.appLoading).toBe(false); + expect(rootBaseStore.initializationError).toEqual(syncDataWithOnCallError); + }); +}); diff --git a/grafana-plugin/src/types.ts b/grafana-plugin/src/types.ts index 8411077e..33e14637 100644 --- a/grafana-plugin/src/types.ts +++ b/grafana-plugin/src/types.ts @@ -1,8 +1,23 @@ -export interface OnCallAppSettings { - onCallApiUrl?: string; - grafanaUrl?: string; - license?: string; -} +import { AppRootProps as BaseAppRootProps, AppPluginMeta, PluginConfigPageProps } from '@grafana/data'; + +export type OnCallPluginMetaJSONData = { + stackId: number; + orgId: number; + onCallApiUrl: string; + license: string; +}; + +export type OnCallPluginMetaSecureJSONData = { + grafanaToken: string; + onCallApiToken: string; +}; + +export type AppRootProps = BaseAppRootProps; + +// NOTE: it is possible that plugin.meta.jsonData is null (ex. on first-ever setup) +// the typing on AppPluginMeta does not seem correct atm.. +export type OnCallAppPluginMeta = AppPluginMeta; +export type OnCallPluginConfigPageProps = PluginConfigPageProps; declare global { export interface Window { diff --git a/grafana-plugin/webpack.config.js b/grafana-plugin/webpack.config.js index 9e6f58e9..b733cbb3 100644 --- a/grafana-plugin/webpack.config.js +++ b/grafana-plugin/webpack.config.js @@ -1,3 +1,4 @@ +const webpack = require('webpack'); const path = require('path'); const CircularDependencyPlugin = require('circular-dependency-plugin'); @@ -133,6 +134,15 @@ module.exports.getWebpackConfig = (config, options) => { // set the current working directory for displaying module paths cwd: process.cwd(), }), + + /** + * From docs (https://webpack.js.org/plugins/environment-plugin/): + * Default values of null and undefined behave differently. + * Use undefined for variables that must be provided during bundling, or null if they are optional. + */ + new webpack.EnvironmentPlugin({ + ONCALL_API_URL: null, + }), ], resolve: { diff --git a/grafana-plugin/yarn.lock b/grafana-plugin/yarn.lock index 6902ad29..6c6098e2 100644 --- a/grafana-plugin/yarn.lock +++ b/grafana-plugin/yarn.lock @@ -2592,6 +2592,11 @@ "@testing-library/dom" "^8.0.0" "@types/react-dom" "<18.0.0" +"@testing-library/user-event@^14.4.3": + version "14.4.3" + resolved "https://registry.yarnpkg.com/@testing-library/user-event/-/user-event-14.4.3.tgz#af975e367743fa91989cd666666aec31a8f50591" + integrity sha512-kCUc5MEwaEMakkO5x7aoD+DLi02ehmEM2QCGWvNqAS1dV/fAvORWEjnjsEIvml59M7Y5kCkWN6fCCyPOe8OL6Q== + "@tootallnate/once@1": version "1.1.2" resolved "https://registry.yarnpkg.com/@tootallnate/once/-/once-1.1.2.tgz#ccb91445360179a04e7fe6aff78c00ffc1eeaf82" diff --git a/helm/README.md b/helm/README.md index 96d31394..05e8f471 100644 --- a/helm/README.md +++ b/helm/README.md @@ -1,29 +1,32 @@ # How to run the chart locally 1. Create the cluster with [kind](https://kind.sigs.k8s.io/docs/user/quick-start/#installation) - > Make sure ports 30001 and 30002 are free on your machine - ``` - kind create cluster --image kindest/node:v1.24.7 --config kind.yml - ``` + + > Make sure ports 30001 and 30002 are free on your machine + + ``` + kind create cluster --image kindest/node:v1.24.7 --config kind.yml + ``` 2. Install the helm chart - ``` - helm install helm-testing \ - ../oncall --wait --timeout 30m \ - --wait-for-jobs \ - --values ci/simple.yml \ - --values ci/values-arm64.yml - ``` + + ``` + helm install helm-testing \ + ../oncall --wait --timeout 30m \ + --wait-for-jobs \ + --values ci/simple.yml \ + --values ci/values-arm64.yml + ``` 3. Get credentials - ``` - echo "\n\nOpen Grafana on localhost:30002 with credentials - user: admin, password: $(kubectl get secret --namespace default helm-testing-grafana -o jsonpath="{.data.admin-password}" | base64 --decode ; echo)" - echo "Open Plugins -> Grafana OnCall -> fill form: backend url: localhost:30001, grafana url: localhost: 30001, token below" - export POD_NAME=$(kubectl get pods --namespace default -l "app.kubernetes.io/name=oncall,app.kubernetes.io/instance=helm-testing,app.kubernetes.io/component=engine" -o jsonpath="{.items[0].metadata.name}") - kubectl exec -it $POD_NAME -- bash -c "python manage.py issue_invite_for_the_frontend --override" - ``` + + ``` + echo "\n\nOpen Grafana on localhost:30002 with credentials - user: admin, password: $(kubectl get secret --namespace default helm-testing-grafana -o jsonpath="{.data.admin-password}" | base64 --decode ; echo)" + echo "Open Plugins -> Grafana OnCall -> fill form: backend url: localhost:30001, grafana url: localhost: 30001, token below" + export POD_NAME=$(kubectl get pods --namespace default -l "app.kubernetes.io/name=oncall,app.kubernetes.io/instance=helm-testing,app.kubernetes.io/component=engine" -o jsonpath="{.items[0].metadata.name}") + ``` 4. Clean up - ``` - kind delete cluster - ``` \ No newline at end of file + ``` + kind delete cluster + ``` diff --git a/helm/oncall/README.md b/helm/oncall/README.md index 92fcd911..29a3da6b 100644 --- a/helm/oncall/README.md +++ b/helm/oncall/README.md @@ -12,8 +12,8 @@ Architecture diagram can be found [here](https://raw.githubusercontent.com/grafa ### Cluster requirements -* ensure you can run x86-64/amd64 workloads. arm64 architecture is currently not supported -* kubernetes version 1.25+ is not supported, if cert-manager is enabled +- ensure you can run x86-64/amd64 workloads. arm64 architecture is currently not supported +- kubernetes version 1.25+ is not supported, if cert-manager is enabled ## Install @@ -59,19 +59,10 @@ Follow the `helm install` output to finish setting up Grafana OnCall backend and 🔗 Connect Grafana OnCall Plugin to Grafana OnCall backend: - Issue the one-time token to connect Grafana OnCall backend and Grafana OnCall plugin by running these commands: - - export POD_NAME=$(kubectl get pods --namespace default -l "app.kubernetes.io/name=oncall,app.kubernetes.io/instance=release-oncall,app.kubernetes.io/component=engine" -o jsonpath="{.items[0].metadata.name}") - kubectl exec -it $POD_NAME -- bash -c "python manage.py issue_invite_for_the_frontend --override" - Fill the Grafana OnCall Backend URL: http://release-oncall-engine:8080 - Fill the Grafana URL: - - http://release-oncall-grafana - 🎉🎉🎉 Done! 🎉🎉🎉 ``` @@ -131,7 +122,7 @@ ingress-nginx: cert-manager: enabled: false - + ingress: enabled: true annotations: @@ -178,7 +169,7 @@ externalMysql: db_name: user: password: - ``` +``` ### Connect external PostgreSQL @@ -206,7 +197,7 @@ externalPostgresql: password: existingSecret: "" passwordKey: password - ``` +``` ### Connect external RabbitMQ @@ -217,8 +208,8 @@ To use an external RabbitMQ instance set rabbitmq.enabled to `false` and configu ```yaml rabbitmq: - enabled: false # Disable the RabbitMQ dependency from the release - + enabled: false # Disable the RabbitMQ dependency from the release + externalRabbitmq: host: port: @@ -237,8 +228,8 @@ To use an external Redis instance set redis.enabled to `false` and configure the ```yaml redis: - enabled: false # Disable the Redis dependency from the release - + enabled: false # Disable the Redis dependency from the release + externalRedis: host: password: @@ -284,3 +275,9 @@ redis-data-release-oncall-redis-replicas-1 redis-data-release-oncall-redis-repli ```bash kubectl delete secrets certificate-tls release-oncall-cert-manager-webhook-ca release-oncall-ingress-nginx-admission ``` + +## Troubleshooting + +### Issues during initial configuration + +In the event that you run into issues during initial configuration, it is possible that mismatching versions between your OnCall backend and UI is the culprit. Ensure that the versions match, and if not consider updating your `helm` deployment. diff --git a/helm/oncall/templates/NOTES.txt b/helm/oncall/templates/NOTES.txt index d2a3290c..4eee164c 100644 --- a/helm/oncall/templates/NOTES.txt +++ b/helm/oncall/templates/NOTES.txt @@ -34,19 +34,8 @@ 🔗 Connect Grafana OnCall Plugin to Grafana OnCall backend: - Issue the one-time token to connect Grafana OnCall backend and Grafana OnCall plugin by running these commands: - - export POD_NAME=$(kubectl get pods --namespace {{ .Release.Namespace }} -l "app.kubernetes.io/name={{ include "oncall.name" . }},app.kubernetes.io/instance={{ .Release.Name }},app.kubernetes.io/component=engine" -o jsonpath="{.items[0].metadata.name}") - kubectl exec -it $POD_NAME --namespace {{ .Release.Namespace }} -- bash -c "python manage.py issue_invite_for_the_frontend --override" - Fill the Grafana OnCall Backend URL: http://{{ include "oncall.engine.fullname" . }}:8080 - Fill the Grafana URL: - - {{ if .Values.grafana.enabled }}http://{{ include "oncall.grafana.fullname" . }}{{ else }}https://{{- end }} - - - 🎉🎉🎉 Done! 🎉🎉🎉 diff --git a/helm/oncall/templates/_env.tpl b/helm/oncall/templates/_env.tpl index 733d2d35..b0b98302 100644 --- a/helm/oncall/templates/_env.tpl +++ b/helm/oncall/templates/_env.tpl @@ -23,6 +23,8 @@ value: "1024" - name: BROKER_TYPE value: {{ .Values.broker.type | default "rabbitmq" }} +- name: GRAFANA_API_URL + value: {{ include "snippet.grafana.url" . }} {{- end -}} {{- define "snippet.oncall.secret.name" -}} @@ -161,6 +163,16 @@ MIRAGE_SECRET_KEY {{- end -}} {{- end -}} +{{- define "snippet.grafana.url" -}} +{{- if .Values.externalGrafana.url -}} +{{- .Values.externalGrafana.url | quote }} +{{- else if .Values.grafana.enabled -}} +http://{{ include "oncall.grafana.fullname" . }} +{{- else -}} +{{- required "externalGrafana.url is required when not grafana.enabled" .Values.externalGrafana.url | quote }} +{{- end -}} +{{- end -}} + {{- define "snippet.mysql.env" -}} - name: MYSQL_HOST value: {{ include "snippet.mysql.host" . }} diff --git a/helm/oncall/values.yaml b/helm/oncall/values.yaml index c4511216..a80dcd92 100644 --- a/helm/oncall/values.yaml +++ b/helm/oncall/values.yaml @@ -293,6 +293,9 @@ grafana: plugins: - grafana-oncall-app +externalGrafana: + url: + nameOverride: "" fullnameOverride: ""