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 <joseph.t.orlando@gmail.com>
This commit is contained in:
parent
d26f76fea0
commit
381520ee13
77 changed files with 4010 additions and 797 deletions
19
CHANGELOG.md
19
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)
|
||||
|
||||
|
|
|
|||
20
Makefile
20
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)
|
||||
|
||||
|
|
|
|||
14
README.md
14
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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
37
engine/apps/grafana_plugin/apps.py
Normal file
37
engine/apps/grafana_plugin/apps.py
Normal file
|
|
@ -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()
|
||||
|
|
@ -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]:
|
||||
"""
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
56
engine/apps/grafana_plugin/tests/test_app_config.py
Normal file
56
engine/apps/grafana_plugin/tests/test_app_config.py
Normal file
|
|
@ -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()
|
||||
27
engine/apps/grafana_plugin/tests/test_install.py
Normal file
27
engine/apps/grafana_plugin/tests/test_install.py
Normal file
|
|
@ -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
|
||||
162
engine/apps/grafana_plugin/tests/test_self_hosted_install.py
Normal file
162
engine/apps/grafana_plugin/tests/test_self_hosted_install.py
Normal file
|
|
@ -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
|
||||
66
engine/apps/grafana_plugin/tests/test_status.py
Normal file
66
engine/apps/grafana_plugin/tests/test_status.py
Normal file
|
|
@ -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
|
||||
|
|
@ -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"),
|
||||
]
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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"],
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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}",
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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.")
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -14,7 +14,10 @@ module.exports = {
|
|||
'jest/outgoingWebhooksStub': '<rootDir>/src/jest/outgoingWebhooksStub.ts',
|
||||
'^jest$': '<rootDir>/src/jest',
|
||||
'^.+\\.(css|scss)$': '<rootDir>/src/jest/styleMock.ts',
|
||||
// '^.+\\.(ts|tsx)$': 'ts-jest',
|
||||
'^lodash-es$': 'lodash',
|
||||
"^.+\\.svg$": "<rootDir>/src/jest/svgTransform.ts"
|
||||
'^.+\\.svg$': '<rootDir>/src/jest/svgTransform.ts',
|
||||
},
|
||||
};
|
||||
|
||||
setupFilesAfterEnv: ['<rootDir>/jest.setup.ts'],
|
||||
};
|
||||
|
|
|
|||
21
grafana-plugin/jest.setup.ts
Normal file
21
grafana-plugin/jest.setup.ts
Normal file
|
|
@ -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(),
|
||||
})),
|
||||
});
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
4
grafana-plugin/src/__mocks__/@grafana/runtime.ts
Normal file
4
grafana-plugin/src/__mocks__/@grafana/runtime.ts
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
export const getBackendSrv = () => ({
|
||||
get: jest.fn(),
|
||||
post: jest.fn(),
|
||||
});
|
||||
3
grafana-plugin/src/__mocks__/grafana/app/core/core.ts
Normal file
3
grafana-plugin/src/__mocks__/grafana/app/core/core.ts
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
export const contextSrv = {
|
||||
hasRole: jest.fn(),
|
||||
};
|
||||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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<ReturnType<typeof useLocationOriginal>>;
|
||||
|
||||
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(<PluginConfigPage {...generateComponentProps(metaJsonDataOnCallApiUrl, true)} />);
|
||||
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<typeof useLocationOriginal>);
|
||||
|
||||
PluginState.checkIfPluginIsConnected = jest.fn();
|
||||
mockSyncDataWithOnCall();
|
||||
|
||||
// test setup
|
||||
const component = render(<PluginConfigPage {...generateComponentProps(metaJsonDataOnCallApiUrl)} />);
|
||||
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(<PluginConfigPage {...generateComponentProps()} />);
|
||||
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(<PluginConfigPage {...generateComponentProps()} />);
|
||||
|
||||
// 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(<PluginConfigPage {...generateComponentProps()} />);
|
||||
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(<PluginConfigPage {...generateComponentProps(metaJsonDataOnCallApiUrl)} />);
|
||||
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(<PluginConfigPage {...generateComponentProps(metaJsonDataOnCallApiUrl)} />);
|
||||
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(<PluginConfigPage {...generateComponentProps(metaJsonDataOnCallApiUrl)} />);
|
||||
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(<PluginConfigPage {...generateComponentProps(metaJsonDataOnCallApiUrl)} />);
|
||||
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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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<AppPluginMeta<OnCallAppSettings>> {}
|
||||
/**
|
||||
* 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<string>(getItem('onCallApiUrl'));
|
||||
const [onCallInvitationToken, setOnCallInvitationToken] = useState<string>();
|
||||
const [grafanaUrl, setGrafanaUrl] = useState<string>(getItem('grafanaUrl'));
|
||||
const [pluginConfigLoading, setPluginConfigLoading] = useState<boolean>(true);
|
||||
const [pluginStatusOk, setPluginStatusOk] = useState<boolean>();
|
||||
const [pluginStatusMessage, setPluginStatusMessage] = useState<string>();
|
||||
const [isSelfHostedInstall, setIsSelfHostedInstall] = useState<boolean>(true);
|
||||
const [retrySync, setRetrySync] = useState<boolean>(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<OnCallPluginConfigPageProps> = ({
|
||||
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<boolean>(!pluginConfiguredRedirect);
|
||||
const [pluginConnectionCheckError, setPluginConnectionCheckError] = useState<string>(null);
|
||||
const [pluginIsConnected, setPluginIsConnected] = useState<PluginStatusResponseBase>(
|
||||
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<boolean>(false);
|
||||
const [syncError, setSyncError] = useState<string>(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<boolean>(false);
|
||||
const [pluginResetError, setPluginResetError] = useState<string>(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(
|
||||
() => <RemoveCurrentConfigurationButton disabled={resettingPlugin} onClick={triggerPluginReset} />,
|
||||
[resettingPlugin, triggerPluginReset]
|
||||
);
|
||||
|
||||
let content: React.ReactNode;
|
||||
|
||||
if (checkingIfPluginIsConnected) {
|
||||
content = <LoadingPlaceholder text="Validating your plugin connection..." />;
|
||||
} else if (syncingPlugin) {
|
||||
content = <LoadingPlaceholder text="Syncing data required for your plugin..." />;
|
||||
} else if (pluginConnectionCheckError || pluginResetError) {
|
||||
content = (
|
||||
<>
|
||||
<StatusMessageBlock text={pluginConnectionCheckError || pluginResetError} />
|
||||
<RemoveConfigButton />
|
||||
</>
|
||||
);
|
||||
} else if (syncError) {
|
||||
content = (
|
||||
<>
|
||||
<StatusMessageBlock text={syncError} />
|
||||
<Button variant="primary" onClick={triggerDataSyncWithOnCall} size="md">
|
||||
Retry Sync
|
||||
</Button>
|
||||
</>
|
||||
);
|
||||
} else if (!pluginIsConnected) {
|
||||
content = (
|
||||
<ConfigurationForm onSuccessfulSetup={triggerDataSyncWithOnCall} defaultOnCallApiUrl={processEnvOnCallApiUrl} />
|
||||
);
|
||||
} else {
|
||||
// plugin is fully connected and synced
|
||||
content =
|
||||
licenseType === GRAFANA_LICENSE_OSS ? (
|
||||
<RemoveConfigButton />
|
||||
) : (
|
||||
<Label>This is a cloud managed configuration.</Label>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
{pluginConfigLoading ? (
|
||||
<LoadingPlaceholder text="Loading..." />
|
||||
) : pluginStatusOk || retrySync ? (
|
||||
<>
|
||||
<Legend>Configure Grafana OnCall</Legend>
|
||||
{pluginIsConnected ? (
|
||||
<>
|
||||
<Legend>Configure Grafana OnCall</Legend>
|
||||
{pluginStatusOk && (
|
||||
<p>
|
||||
Plugin and the backend are connected! Check Grafana OnCall 👈👈👈{' '}
|
||||
<img alt="Grafana OnCall Logo" src={logo} width={18} />
|
||||
</p>
|
||||
)}
|
||||
<p>{'Plugin <-> backend connection status'}</p>
|
||||
<pre>
|
||||
<Text>{pluginStatusMessage}</Text>
|
||||
</pre>
|
||||
|
||||
<HorizontalGroup>
|
||||
{retrySync && (
|
||||
<Button variant="primary" onClick={startSync} size="md">
|
||||
Retry
|
||||
</Button>
|
||||
)}
|
||||
{isSelfHostedInstall ? (
|
||||
<WithConfirm title="Are you sure to delete OnCall plugin configuration?">
|
||||
<Button variant="destructive" onClick={resetPlugin} size="md">
|
||||
Remove current configuration
|
||||
</Button>
|
||||
</WithConfirm>
|
||||
) : (
|
||||
<Label>This is a cloud managed configuration.</Label>
|
||||
)}{' '}
|
||||
</HorizontalGroup>
|
||||
<p>
|
||||
Plugin is connected! Continue to Grafana OnCall by clicking the{' '}
|
||||
<img alt="Grafana OnCall Logo" src={logo} width={18} /> icon over there 👈
|
||||
</p>
|
||||
<StatusMessageBlock
|
||||
text={`Connected to OnCall (${pluginIsConnected.version}, ${pluginIsConnected.license})`}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<React.Fragment>
|
||||
<Legend>Configure Grafana OnCall</Legend>
|
||||
<p>This page will help you to connect OnCall backend and OnCall Grafana plugin 👋</p>
|
||||
|
||||
<p>1. Launch backend</p>
|
||||
<VerticalGroup>
|
||||
<Text type="secondary">
|
||||
Run hobby, dev or production backend:{' '}
|
||||
<a href="https://github.com/grafana/oncall#getting-started" target="_blank" rel="noreferrer">
|
||||
<Text type="link">getting started.</Text>
|
||||
</a>
|
||||
</Text>
|
||||
</VerticalGroup>
|
||||
<Block withBackground className={cx('info-block')}>
|
||||
<Text type="secondary">
|
||||
Need help?
|
||||
<br />- Talk to the OnCall team in the #grafana-oncall channel at{' '}
|
||||
<a href="https://slack.grafana.com/" target="_blank" rel="noreferrer">
|
||||
<Text type="link">Slack</Text>
|
||||
</a>
|
||||
<br />- Ask questions at{' '}
|
||||
<a href="https://github.com/grafana/oncall/discussions/categories/q-a" target="_blank" rel="noreferrer">
|
||||
<Text type="link">GitHub Discussions</Text>
|
||||
</a>{' '}
|
||||
or file bugs at{' '}
|
||||
<a href="https://github.com/grafana/oncall/issues" target="_blank" rel="noreferrer">
|
||||
<Text type="link">GitHub Issues</Text>
|
||||
</a>
|
||||
</Text>
|
||||
</Block>
|
||||
|
||||
<p>2. Conect the backend and the plugin </p>
|
||||
<p>{'Plugin <-> backend connection status:'}</p>
|
||||
<pre>
|
||||
<Text type="link">{pluginStatusMessage}</Text>
|
||||
</pre>
|
||||
<Field
|
||||
label="Invite token"
|
||||
description="Invite token is a 1-time secret used to make sure the backend is talking with the proper frontend. Find it at the end of the backend docker container logs.
|
||||
Seek for such a line: “Your invite token: <<LONG TOKEN>> , use it in the Grafana OnCall plugin.”"
|
||||
>
|
||||
<>
|
||||
<Input id="onCallInvitationToken" onChange={handleInvitationTokenChange} />
|
||||
<a
|
||||
href="https://github.com/grafana/oncall/blob/dev/dev/README.md#frontend-setup"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
<Text size="small" type="link">
|
||||
How to re-issue the invite token?
|
||||
</Text>
|
||||
</a>
|
||||
</>
|
||||
</Field>
|
||||
|
||||
<Field
|
||||
label="OnCall backend URL"
|
||||
description={
|
||||
<Text>
|
||||
It should be reachable from Grafana. Possible options: <br />
|
||||
http://host.docker.internal:8080 (if you run backend in the docker locally)
|
||||
<br />
|
||||
http://localhost:8080 <br />
|
||||
...
|
||||
</Text>
|
||||
}
|
||||
>
|
||||
<Input id="onCallApiUrl" onChange={handleApiUrlChange} defaultValue={onCallApiUrl} />
|
||||
</Field>
|
||||
<Field label="Grafana URL" description="URL of the current Grafana instance. ">
|
||||
<Input id="grafanaUrl" onChange={handleGrafanaUrlChange} defaultValue={grafanaUrl} />
|
||||
</Field>
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={setupPlugin}
|
||||
disabled={!onCallApiUrl || !onCallInvitationToken || !grafanaUrl}
|
||||
size="md"
|
||||
>
|
||||
Connect
|
||||
</Button>
|
||||
</React.Fragment>
|
||||
<p>This page will help you configure the OnCall plugin 👋</p>
|
||||
)}
|
||||
</div>
|
||||
{content}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default PluginConfigPage;
|
||||
|
|
|
|||
|
|
@ -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`] = `
|
||||
<div>
|
||||
<legend
|
||||
class="css-xim8hk"
|
||||
>
|
||||
Configure Grafana OnCall
|
||||
</legend>
|
||||
<p>
|
||||
This page will help you configure the OnCall plugin 👋
|
||||
</p>
|
||||
<pre
|
||||
data-testid="status-message-block"
|
||||
>
|
||||
<span
|
||||
class="root text text--undefined text--medium"
|
||||
>
|
||||
ohhh nooo an error msg from self hosted install plugin
|
||||
</span>
|
||||
</pre>
|
||||
<button
|
||||
class="css-mk7eo3-button"
|
||||
type="button"
|
||||
>
|
||||
<span
|
||||
class="css-1mhnkuh"
|
||||
>
|
||||
Remove current configuration
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
`;
|
||||
|
||||
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`] = `
|
||||
<div>
|
||||
<legend
|
||||
class="css-xim8hk"
|
||||
>
|
||||
Configure Grafana OnCall
|
||||
</legend>
|
||||
<p>
|
||||
This page will help you configure the OnCall plugin 👋
|
||||
</p>
|
||||
<form
|
||||
class="css-xs0vux"
|
||||
data-testid="plugin-configuration-form"
|
||||
>
|
||||
<div
|
||||
class="info-block"
|
||||
>
|
||||
<p>
|
||||
1. Launch the OnCall backend
|
||||
</p>
|
||||
<span
|
||||
class="root text text--secondary text--medium"
|
||||
>
|
||||
Run hobby, dev or production backend. See
|
||||
|
||||
<a
|
||||
href="https://github.com/grafana/oncall#getting-started"
|
||||
rel="noreferrer"
|
||||
target="_blank"
|
||||
>
|
||||
<span
|
||||
class="root text text--link text--medium"
|
||||
>
|
||||
here
|
||||
</span>
|
||||
</a>
|
||||
|
||||
on how to get started.
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
class="info-block"
|
||||
>
|
||||
<p>
|
||||
2. Let us know the base URL of your OnCall API
|
||||
</p>
|
||||
<span
|
||||
class="root text text--secondary text--medium"
|
||||
>
|
||||
The OnCall backend must be reachable from your Grafana installation. Some examples are:
|
||||
<br />
|
||||
- http://host.docker.internal:8080
|
||||
<br />
|
||||
- http://localhost:8080
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
class="css-8e5b3"
|
||||
>
|
||||
<div
|
||||
class="css-jt4xma-Label"
|
||||
>
|
||||
<label>
|
||||
<div
|
||||
class="css-xhqy0o"
|
||||
>
|
||||
OnCall backend URL
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
<div>
|
||||
<div
|
||||
class="css-xcstkt-input-wrapper"
|
||||
data-testid="input-wrapper"
|
||||
>
|
||||
<div
|
||||
class="css-1w5c5dq-input-inputWrapper"
|
||||
>
|
||||
<input
|
||||
class="css-1wdli31-input-input"
|
||||
data-testid="onCallApiUrl"
|
||||
name="onCallApiUrl"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
class="css-1sara2j-button"
|
||||
type="submit"
|
||||
>
|
||||
<span
|
||||
class="css-1mhnkuh"
|
||||
>
|
||||
Connect
|
||||
</span>
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
`;
|
||||
|
||||
exports[`PluginConfigPage If onCallApiUrl is set, and checkIfPluginIsConnected returns an error, it sets an error message 1`] = `
|
||||
<div>
|
||||
<legend
|
||||
class="css-xim8hk"
|
||||
>
|
||||
Configure Grafana OnCall
|
||||
</legend>
|
||||
<p>
|
||||
This page will help you configure the OnCall plugin 👋
|
||||
</p>
|
||||
<pre
|
||||
data-testid="status-message-block"
|
||||
>
|
||||
<span
|
||||
class="root text text--undefined text--medium"
|
||||
>
|
||||
ohhh nooo a plugin connection error
|
||||
</span>
|
||||
</pre>
|
||||
<button
|
||||
class="css-mk7eo3-button"
|
||||
type="button"
|
||||
>
|
||||
<span
|
||||
class="css-1mhnkuh"
|
||||
>
|
||||
Remove current configuration
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
`;
|
||||
|
||||
exports[`PluginConfigPage It doesn't make any network calls if the plugin configured query params are provided 1`] = `
|
||||
<div>
|
||||
<legend
|
||||
class="css-xim8hk"
|
||||
>
|
||||
Configure Grafana OnCall
|
||||
</legend>
|
||||
<p>
|
||||
Plugin is connected! Continue to Grafana OnCall by clicking the
|
||||
|
||||
<img
|
||||
alt="Grafana OnCall Logo"
|
||||
src="[object Object]"
|
||||
width="18"
|
||||
/>
|
||||
icon over there 👈
|
||||
</p>
|
||||
<pre
|
||||
data-testid="status-message-block"
|
||||
>
|
||||
<span
|
||||
class="root text text--undefined text--medium"
|
||||
>
|
||||
Connected to OnCall (v1.2.3, OpenSource)
|
||||
</span>
|
||||
</pre>
|
||||
<button
|
||||
class="css-mk7eo3-button"
|
||||
type="button"
|
||||
>
|
||||
<span
|
||||
class="css-1mhnkuh"
|
||||
>
|
||||
Remove current configuration
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
`;
|
||||
|
||||
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`] = `
|
||||
<div>
|
||||
<legend
|
||||
class="css-xim8hk"
|
||||
>
|
||||
Configure Grafana OnCall
|
||||
</legend>
|
||||
<p>
|
||||
Plugin is connected! Continue to Grafana OnCall by clicking the
|
||||
|
||||
<img
|
||||
alt="Grafana OnCall Logo"
|
||||
src="[object Object]"
|
||||
width="18"
|
||||
/>
|
||||
icon over there 👈
|
||||
</p>
|
||||
<pre
|
||||
data-testid="status-message-block"
|
||||
>
|
||||
<span
|
||||
class="root text text--undefined text--medium"
|
||||
>
|
||||
Connected to OnCall (v1.2.3, OpenSource)
|
||||
</span>
|
||||
</pre>
|
||||
<button
|
||||
class="css-mk7eo3-button"
|
||||
type="button"
|
||||
>
|
||||
<span
|
||||
class="css-1mhnkuh"
|
||||
>
|
||||
Remove current configuration
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
`;
|
||||
|
||||
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`] = `
|
||||
<div>
|
||||
<legend
|
||||
class="css-xim8hk"
|
||||
>
|
||||
Configure Grafana OnCall
|
||||
</legend>
|
||||
<p>
|
||||
Plugin is connected! Continue to Grafana OnCall by clicking the
|
||||
|
||||
<img
|
||||
alt="Grafana OnCall Logo"
|
||||
src="[object Object]"
|
||||
width="18"
|
||||
/>
|
||||
icon over there 👈
|
||||
</p>
|
||||
<pre
|
||||
data-testid="status-message-block"
|
||||
>
|
||||
<span
|
||||
class="root text text--undefined text--medium"
|
||||
>
|
||||
Connected to OnCall (v1.2.3, some-other-license)
|
||||
</span>
|
||||
</pre>
|
||||
<div
|
||||
class="css-jt4xma-Label"
|
||||
>
|
||||
<label>
|
||||
<div
|
||||
class="css-xhqy0o"
|
||||
>
|
||||
This is a cloud managed configuration.
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
exports[`PluginConfigPage OnCallApiUrl is set, and syncDataWithOnCall returns an error 1`] = `
|
||||
<div>
|
||||
<legend
|
||||
class="css-xim8hk"
|
||||
>
|
||||
Configure Grafana OnCall
|
||||
</legend>
|
||||
<p>
|
||||
This page will help you configure the OnCall plugin 👋
|
||||
</p>
|
||||
<pre
|
||||
data-testid="status-message-block"
|
||||
>
|
||||
<span
|
||||
class="root text text--undefined text--medium"
|
||||
>
|
||||
ohhh noooo a sync issue
|
||||
</span>
|
||||
</pre>
|
||||
<button
|
||||
class="css-1sara2j-button"
|
||||
type="button"
|
||||
>
|
||||
<span
|
||||
class="css-1mhnkuh"
|
||||
>
|
||||
Retry Sync
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
`;
|
||||
|
||||
exports[`PluginConfigPage Plugin reset: successful - false 1`] = `
|
||||
<div>
|
||||
<legend
|
||||
class="css-xim8hk"
|
||||
>
|
||||
Configure Grafana OnCall
|
||||
</legend>
|
||||
<p>
|
||||
This page will help you configure the OnCall plugin 👋
|
||||
</p>
|
||||
<pre
|
||||
data-testid="status-message-block"
|
||||
>
|
||||
<span
|
||||
class="root text text--undefined text--medium"
|
||||
>
|
||||
There was an error resetting your plugin, try again.
|
||||
</span>
|
||||
</pre>
|
||||
<button
|
||||
class="css-mk7eo3-button"
|
||||
type="button"
|
||||
>
|
||||
<span
|
||||
class="css-1mhnkuh"
|
||||
>
|
||||
Remove current configuration
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
`;
|
||||
|
||||
exports[`PluginConfigPage Plugin reset: successful - true 1`] = `
|
||||
<div>
|
||||
<legend
|
||||
class="css-xim8hk"
|
||||
>
|
||||
Configure Grafana OnCall
|
||||
</legend>
|
||||
<p>
|
||||
This page will help you configure the OnCall plugin 👋
|
||||
</p>
|
||||
<form
|
||||
class="css-xs0vux"
|
||||
data-testid="plugin-configuration-form"
|
||||
>
|
||||
<div
|
||||
class="info-block"
|
||||
>
|
||||
<p>
|
||||
1. Launch the OnCall backend
|
||||
</p>
|
||||
<span
|
||||
class="root text text--secondary text--medium"
|
||||
>
|
||||
Run hobby, dev or production backend. See
|
||||
|
||||
<a
|
||||
href="https://github.com/grafana/oncall#getting-started"
|
||||
rel="noreferrer"
|
||||
target="_blank"
|
||||
>
|
||||
<span
|
||||
class="root text text--link text--medium"
|
||||
>
|
||||
here
|
||||
</span>
|
||||
</a>
|
||||
|
||||
on how to get started.
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
class="info-block"
|
||||
>
|
||||
<p>
|
||||
2. Let us know the base URL of your OnCall API
|
||||
</p>
|
||||
<span
|
||||
class="root text text--secondary text--medium"
|
||||
>
|
||||
The OnCall backend must be reachable from your Grafana installation. Some examples are:
|
||||
<br />
|
||||
- http://host.docker.internal:8080
|
||||
<br />
|
||||
- http://localhost:8080
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
class="css-8e5b3"
|
||||
>
|
||||
<div
|
||||
class="css-jt4xma-Label"
|
||||
>
|
||||
<label>
|
||||
<div
|
||||
class="css-xhqy0o"
|
||||
>
|
||||
OnCall backend URL
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
<div>
|
||||
<div
|
||||
class="css-xcstkt-input-wrapper"
|
||||
data-testid="input-wrapper"
|
||||
>
|
||||
<div
|
||||
class="css-1w5c5dq-input-inputWrapper"
|
||||
>
|
||||
<input
|
||||
class="css-1wdli31-input-input"
|
||||
data-testid="onCallApiUrl"
|
||||
name="onCallApiUrl"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
class="css-1sara2j-button"
|
||||
type="submit"
|
||||
>
|
||||
<span
|
||||
class="css-1mhnkuh"
|
||||
>
|
||||
Connect
|
||||
</span>
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
`;
|
||||
|
|
@ -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.`;
|
||||
|
|
@ -1,7 +1,3 @@
|
|||
.command-line {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.info-block {
|
||||
margin-bottom: 24px;
|
||||
margin-top: 24px;
|
||||
|
|
@ -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(
|
||||
<ConfigurationForm onSuccessfulSetup={mockOnSuccessfulSetup} defaultOnCallApiUrl="http://potato.com" />
|
||||
);
|
||||
|
||||
// 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(<ConfigurationForm onSuccessfulSetup={jest.fn()} defaultOnCallApiUrl={processEnvOnCallApiUrl} />);
|
||||
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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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`] = `
|
||||
<body>
|
||||
<div>
|
||||
<form
|
||||
class="css-xs0vux"
|
||||
data-testid="plugin-configuration-form"
|
||||
>
|
||||
<div
|
||||
class="info-block"
|
||||
>
|
||||
<p>
|
||||
1. Launch the OnCall backend
|
||||
</p>
|
||||
<span
|
||||
class="root text text--secondary text--medium"
|
||||
>
|
||||
Run hobby, dev or production backend. See
|
||||
|
||||
<a
|
||||
href="https://github.com/grafana/oncall#getting-started"
|
||||
rel="noreferrer"
|
||||
target="_blank"
|
||||
>
|
||||
<span
|
||||
class="root text text--link text--medium"
|
||||
>
|
||||
here
|
||||
</span>
|
||||
</a>
|
||||
|
||||
on how to get started.
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
class="info-block"
|
||||
>
|
||||
<p>
|
||||
2. Let us know the base URL of your OnCall API
|
||||
</p>
|
||||
<span
|
||||
class="root text text--secondary text--medium"
|
||||
>
|
||||
The OnCall backend must be reachable from your Grafana installation. Some examples are:
|
||||
<br />
|
||||
- http://host.docker.internal:8080
|
||||
<br />
|
||||
- http://localhost:8080
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
class="css-8e5b3"
|
||||
>
|
||||
<div
|
||||
class="css-jt4xma-Label"
|
||||
>
|
||||
<label>
|
||||
<div
|
||||
class="css-xhqy0o"
|
||||
>
|
||||
OnCall backend URL
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
<div>
|
||||
<div
|
||||
class="css-1wz1ggz-input-wrapper"
|
||||
data-testid="input-wrapper"
|
||||
>
|
||||
<div
|
||||
class="css-1w5c5dq-input-inputWrapper"
|
||||
>
|
||||
<input
|
||||
class="css-uzu9xn-input-input"
|
||||
data-testid="onCallApiUrl"
|
||||
name="onCallApiUrl"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="css-1gd2lua"
|
||||
>
|
||||
<div
|
||||
class="css-mw0th3"
|
||||
role="alert"
|
||||
>
|
||||
<div
|
||||
class="css-wf08df-Icon"
|
||||
>
|
||||
<svg
|
||||
class="css-1ah41zt"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
width="16"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M12,16a1,1,0,1,0,1,1A1,1,0,0,0,12,16Zm10.67,1.47-8.05-14a3,3,0,0,0-5.24,0l-8,14A3,3,0,0,0,3.94,22H20.06a3,3,0,0,0,2.61-4.53Zm-1.73,2a1,1,0,0,1-.88.51H3.94a1,1,0,0,1-.88-.51,1,1,0,0,1,0-1l8-14a1,1,0,0,1,1.78,0l8.05,14A1,1,0,0,1,20.94,19.49ZM12,8a1,1,0,0,0-1,1v4a1,1,0,0,0,2,0V9A1,1,0,0,0,12,8Z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
Must be a valid URL
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
class="css-1sara2j-button"
|
||||
disabled=""
|
||||
type="submit"
|
||||
>
|
||||
<span
|
||||
class="css-1mhnkuh"
|
||||
>
|
||||
Connect
|
||||
</span>
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</body>
|
||||
`;
|
||||
|
||||
exports[`ConfigurationForm It shows an error message if the self hosted plugin API call fails 1`] = `
|
||||
<body>
|
||||
<div>
|
||||
<form
|
||||
class="css-xs0vux"
|
||||
data-testid="plugin-configuration-form"
|
||||
>
|
||||
<div
|
||||
class="info-block"
|
||||
>
|
||||
<p>
|
||||
1. Launch the OnCall backend
|
||||
</p>
|
||||
<span
|
||||
class="root text text--secondary text--medium"
|
||||
>
|
||||
Run hobby, dev or production backend. See
|
||||
|
||||
<a
|
||||
href="https://github.com/grafana/oncall#getting-started"
|
||||
rel="noreferrer"
|
||||
target="_blank"
|
||||
>
|
||||
<span
|
||||
class="root text text--link text--medium"
|
||||
>
|
||||
here
|
||||
</span>
|
||||
</a>
|
||||
|
||||
on how to get started.
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
class="info-block"
|
||||
>
|
||||
<p>
|
||||
2. Let us know the base URL of your OnCall API
|
||||
</p>
|
||||
<span
|
||||
class="root text text--secondary text--medium"
|
||||
>
|
||||
The OnCall backend must be reachable from your Grafana installation. Some examples are:
|
||||
<br />
|
||||
- http://host.docker.internal:8080
|
||||
<br />
|
||||
- http://localhost:8080
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
class="css-8e5b3"
|
||||
>
|
||||
<div
|
||||
class="css-jt4xma-Label"
|
||||
>
|
||||
<label>
|
||||
<div
|
||||
class="css-xhqy0o"
|
||||
>
|
||||
OnCall backend URL
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
<div>
|
||||
<div
|
||||
class="css-xcstkt-input-wrapper"
|
||||
data-testid="input-wrapper"
|
||||
>
|
||||
<div
|
||||
class="css-1w5c5dq-input-inputWrapper"
|
||||
>
|
||||
<input
|
||||
class="css-1wdli31-input-input"
|
||||
data-testid="onCallApiUrl"
|
||||
name="onCallApiUrl"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<pre>
|
||||
<span
|
||||
class="root text text--link text--medium"
|
||||
>
|
||||
ohhh nooo there was an error from the OnCall API
|
||||
</span>
|
||||
</pre>
|
||||
<div
|
||||
class="root info-block root_with-background"
|
||||
>
|
||||
<span
|
||||
class="root text text--secondary text--medium"
|
||||
>
|
||||
Need help?
|
||||
<br />
|
||||
- Reach out to the OnCall team in the
|
||||
|
||||
<a
|
||||
href="https://grafana.slack.com/archives/C02LSUUSE2G"
|
||||
rel="noreferrer"
|
||||
target="_blank"
|
||||
>
|
||||
<span
|
||||
class="root text text--link text--medium"
|
||||
>
|
||||
#grafana-oncall
|
||||
</span>
|
||||
</a>
|
||||
|
||||
community Slack channel
|
||||
<br />
|
||||
- Ask questions on our GitHub Discussions page
|
||||
|
||||
<a
|
||||
href="https://github.com/grafana/oncall/discussions/categories/q-a"
|
||||
rel="noreferrer"
|
||||
target="_blank"
|
||||
>
|
||||
<span
|
||||
class="root text text--link text--medium"
|
||||
>
|
||||
here
|
||||
</span>
|
||||
</a>
|
||||
|
||||
<br />
|
||||
- Or file bugs on our GitHub Issues page
|
||||
|
||||
<a
|
||||
href="https://github.com/grafana/oncall/issues"
|
||||
rel="noreferrer"
|
||||
target="_blank"
|
||||
>
|
||||
<span
|
||||
class="root text text--link text--medium"
|
||||
>
|
||||
here
|
||||
</span>
|
||||
</a>
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
class="css-1sara2j-button"
|
||||
type="submit"
|
||||
>
|
||||
<span
|
||||
class="css-1mhnkuh"
|
||||
>
|
||||
Connect
|
||||
</span>
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</body>
|
||||
`;
|
||||
|
|
@ -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 }) => (
|
||||
<>
|
||||
<pre>
|
||||
<Text type="link">{errorMsg}</Text>
|
||||
</pre>
|
||||
<Block withBackground className={cx('info-block')}>
|
||||
<Text type="secondary">
|
||||
Need help?
|
||||
<br />- Reach out to the OnCall team in the{' '}
|
||||
<a href="https://grafana.slack.com/archives/C02LSUUSE2G" target="_blank" rel="noreferrer">
|
||||
<Text type="link">#grafana-oncall</Text>
|
||||
</a>{' '}
|
||||
community Slack channel
|
||||
<br />- Ask questions on our GitHub Discussions page{' '}
|
||||
<a href="https://github.com/grafana/oncall/discussions/categories/q-a" target="_blank" rel="noreferrer">
|
||||
<Text type="link">here</Text>
|
||||
</a>{' '}
|
||||
<br />- Or file bugs on our GitHub Issues page{' '}
|
||||
<a href="https://github.com/grafana/oncall/issues" target="_blank" rel="noreferrer">
|
||||
<Text type="link">here</Text>
|
||||
</a>
|
||||
</Text>
|
||||
</Block>
|
||||
</>
|
||||
);
|
||||
|
||||
const ConfigurationForm: FC<Props> = ({ onSuccessfulSetup, defaultOnCallApiUrl }) => {
|
||||
const [setupErrorMsg, setSetupErrorMsg] = useState<string>(null);
|
||||
const [formLoading, setFormLoading] = useState<boolean>(false);
|
||||
|
||||
const setupPlugin: SubmitHandler<FormProps> = useCallback(async ({ onCallApiUrl }) => {
|
||||
setFormLoading(true);
|
||||
|
||||
const errorMsg = await PluginState.selfHostedInstallPlugin(onCallApiUrl, false);
|
||||
|
||||
if (!errorMsg) {
|
||||
onSuccessfulSetup();
|
||||
} else {
|
||||
setSetupErrorMsg(errorMsg);
|
||||
setFormLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<Form<FormProps>
|
||||
defaultValues={{ onCallApiUrl: defaultOnCallApiUrl }}
|
||||
onSubmit={setupPlugin}
|
||||
data-testid="plugin-configuration-form"
|
||||
>
|
||||
{({ register, errors }) => (
|
||||
<>
|
||||
<div className={cx('info-block')}>
|
||||
<p>1. Launch the OnCall backend</p>
|
||||
<Text type="secondary">
|
||||
Run hobby, dev or production backend. See{' '}
|
||||
<a href="https://github.com/grafana/oncall#getting-started" target="_blank" rel="noreferrer">
|
||||
<Text type="link">here</Text>
|
||||
</a>{' '}
|
||||
on how to get started.
|
||||
</Text>
|
||||
</div>
|
||||
|
||||
<div className={cx('info-block')}>
|
||||
<p>2. Let us know the base URL of your OnCall API</p>
|
||||
<Text type="secondary">
|
||||
The OnCall backend must be reachable from your Grafana installation. Some examples are:
|
||||
<br />
|
||||
- http://host.docker.internal:8080
|
||||
<br />- http://localhost:8080
|
||||
</Text>
|
||||
</div>
|
||||
|
||||
<Field label="OnCall backend URL" invalid={!!errors.onCallApiUrl} error="Must be a valid URL">
|
||||
<Input
|
||||
data-testid="onCallApiUrl"
|
||||
{...register('onCallApiUrl', {
|
||||
required: true,
|
||||
validate: isValidUrl,
|
||||
})}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
{setupErrorMsg && <FormErrorMessage errorMsg={setupErrorMsg} />}
|
||||
|
||||
<Button type="submit" size="md" disabled={formLoading || !isEmpty(errors)}>
|
||||
Connect
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</Form>
|
||||
);
|
||||
};
|
||||
|
||||
export default ConfigurationForm;
|
||||
|
|
@ -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(<RemoveCurrentConfigurationButton onClick={() => {}} disabled={false} />);
|
||||
expect(component.baseElement).toMatchSnapshot();
|
||||
});
|
||||
|
||||
test('It renders properly when disabled', () => {
|
||||
const component = render(<RemoveCurrentConfigurationButton onClick={() => {}} disabled />);
|
||||
expect(component.baseElement).toMatchSnapshot();
|
||||
});
|
||||
|
||||
test('It calls the onClick handler when clicked', async () => {
|
||||
const mockedOnClick = jest.fn();
|
||||
|
||||
const user = userEvent.setup();
|
||||
render(<RemoveCurrentConfigurationButton onClick={mockedOnClick} disabled={false} />);
|
||||
|
||||
// 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);
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,36 @@
|
|||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`RemoveCurrentConfigurationButton It renders properly when disabled 1`] = `
|
||||
<body>
|
||||
<div>
|
||||
<button
|
||||
class="css-mk7eo3-button"
|
||||
disabled=""
|
||||
type="button"
|
||||
>
|
||||
<span
|
||||
class="css-1mhnkuh"
|
||||
>
|
||||
Remove current configuration
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</body>
|
||||
`;
|
||||
|
||||
exports[`RemoveCurrentConfigurationButton It renders properly when enabled 1`] = `
|
||||
<body>
|
||||
<div>
|
||||
<button
|
||||
class="css-mk7eo3-button"
|
||||
type="button"
|
||||
>
|
||||
<span
|
||||
class="css-1mhnkuh"
|
||||
>
|
||||
Remove current configuration
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</body>
|
||||
`;
|
||||
|
|
@ -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<Props> = ({ disabled, onClick }) => (
|
||||
<WithConfirm title="Are you sure to delete the plugin configuration?" confirmText="Remove">
|
||||
<Button variant="destructive" onClick={onClick} size="md" disabled={disabled}>
|
||||
Remove current configuration
|
||||
</Button>
|
||||
</WithConfirm>
|
||||
);
|
||||
|
||||
export default RemoveCurrentConfigurationButton;
|
||||
|
|
@ -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(<StatusMessageBlock text="helloooo" />);
|
||||
expect(component.baseElement).toMatchSnapshot();
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,17 @@
|
|||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`StatusMessageBlock It renders properly 1`] = `
|
||||
<body>
|
||||
<div>
|
||||
<pre
|
||||
data-testid="status-message-block"
|
||||
>
|
||||
<span
|
||||
class="root text text--undefined text--medium"
|
||||
>
|
||||
helloooo
|
||||
</span>
|
||||
</pre>
|
||||
</div>
|
||||
</body>
|
||||
`;
|
||||
|
|
@ -0,0 +1,15 @@
|
|||
import React, { FC } from 'react';
|
||||
|
||||
import Text from 'components/Text/Text';
|
||||
|
||||
type Props = {
|
||||
text: string;
|
||||
};
|
||||
|
||||
const StatusMessageBlock: FC<Props> = ({ text }) => (
|
||||
<pre data-testid="status-message-block">
|
||||
<Text>{text}</Text>
|
||||
</pre>
|
||||
);
|
||||
|
||||
export default StatusMessageBlock;
|
||||
9
grafana-plugin/src/index.d.ts
vendored
9
grafana-plugin/src/index.d.ts
vendored
|
|
@ -16,3 +16,12 @@ declare module '*.scss' {
|
|||
const content: Record<string, string>;
|
||||
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;
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<OnCallAppSettings>()
|
||||
.setRootPage(GrafanaPluginRootPage as unknown as ComponentClass<AppRootProps<OnCallAppSettings>>)
|
||||
.addConfigPage({
|
||||
title: 'Configuration',
|
||||
icon: 'cog',
|
||||
body: PluginConfigPage as unknown as ComponentClass<
|
||||
PluginConfigPageProps<AppPluginMeta<OnCallAppSettings>>,
|
||||
unknown
|
||||
>,
|
||||
id: 'configuration',
|
||||
});
|
||||
export const plugin = new AppPlugin<OnCallPluginMetaJSONData>().setRootPage(GrafanaPluginRootPage).addConfigPage({
|
||||
title: 'Configuration',
|
||||
icon: 'cog',
|
||||
body: PluginConfigPage as unknown as ComponentClass<OnCallPluginConfigPageProps, unknown>,
|
||||
id: 'configuration',
|
||||
});
|
||||
|
|
|
|||
|
|
@ -30,7 +30,7 @@ interface RequestConfig {
|
|||
validateStatus?: (status: number) => boolean;
|
||||
}
|
||||
|
||||
export const makeRequest = async (path: string, config: RequestConfig) => {
|
||||
export const makeRequest = async <RT = any>(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;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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 }}\" }"
|
||||
}
|
||||
]
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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) => (
|
||||
<Provider store={rootStore}>
|
||||
<RootWithLoader {...props} />
|
||||
<PluginSetup InitializedComponent={Root} {...props} />
|
||||
</Provider>
|
||||
);
|
||||
|
||||
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 (
|
||||
<div className="spin">
|
||||
<img alt="Grafana OnCall Logo" src={logo} />
|
||||
<div className="spin-text">{text}</div>
|
||||
{!store.pluginIsInitialized ||
|
||||
!store.correctProvisioningForInstallation ||
|
||||
store.initializationError ||
|
||||
store.retrySync ? (
|
||||
<div className="configure-plugin">
|
||||
<HorizontalGroup>
|
||||
<Button variant="primary" onClick={() => store.setupPlugin(props.meta)} size="sm">
|
||||
Retry
|
||||
</Button>
|
||||
<LinkButton href={`/plugins/grafana-oncall-app?page=configuration`} variant="primary" size="sm">
|
||||
Configure Plugin
|
||||
</LinkButton>
|
||||
</HorizontalGroup>
|
||||
</div>
|
||||
) : (
|
||||
<></>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return <Root {...props} />;
|
||||
});
|
||||
|
||||
export const Root = observer((props: AppRootProps) => {
|
||||
const [didFinishLoading, setDidFinishLoading] = useState(false);
|
||||
const queryParams = useQueryParams();
|
||||
|
|
|
|||
76
grafana-plugin/src/plugin/PluginSetup/PluginSetup.test.tsx
Normal file
76
grafana-plugin/src/plugin/PluginSetup/PluginSetup.test.tsx
Normal file
|
|
@ -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<ReturnType<typeof useStoreOriginal>>).mockReturnValue(rootBaseStore);
|
||||
|
||||
// test setup
|
||||
const MockedInitializedComponent = jest.fn().mockReturnValue(<div>hello</div>);
|
||||
|
||||
const props = {
|
||||
meta: {
|
||||
jsonData: 'hello',
|
||||
},
|
||||
InitializedComponent: MockedInitializedComponent,
|
||||
} as unknown as PluginSetupProps;
|
||||
|
||||
const component = render(<PluginSetup {...props} />);
|
||||
|
||||
// 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);
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,139 @@
|
|||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`PluginSetup app is loading 1`] = `
|
||||
<div>
|
||||
<div
|
||||
class="spin"
|
||||
>
|
||||
<img
|
||||
alt="Grafana OnCall Logo"
|
||||
src="[object Object]"
|
||||
/>
|
||||
<div
|
||||
class="spin-text"
|
||||
>
|
||||
Initializing plugin...
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
exports[`PluginSetup app successfully initialized 1`] = `
|
||||
<div>
|
||||
<div>
|
||||
hello
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
exports[`PluginSetup there is an error message - retry setup 1`] = `
|
||||
<div>
|
||||
<div
|
||||
class="spin"
|
||||
>
|
||||
<img
|
||||
alt="Grafana OnCall Logo"
|
||||
src="[object Object]"
|
||||
/>
|
||||
<div
|
||||
class="spin-text"
|
||||
>
|
||||
ohhhh noo
|
||||
</div>
|
||||
<div
|
||||
class="configure-plugin"
|
||||
>
|
||||
<div
|
||||
class="css-ve64a7-horizontal-group"
|
||||
style="width: 100%; height: 100%;"
|
||||
>
|
||||
<div
|
||||
class="css-cvef6c-layoutChildrenWrapper"
|
||||
>
|
||||
<button
|
||||
class="css-zy62io-button"
|
||||
type="button"
|
||||
>
|
||||
<span
|
||||
class="css-1mhnkuh"
|
||||
>
|
||||
Retry
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
<div
|
||||
class="css-cvef6c-layoutChildrenWrapper"
|
||||
>
|
||||
<a
|
||||
class="css-zy62io-button"
|
||||
href="/plugins/grafana-oncall-app?page=configuration"
|
||||
tabindex="0"
|
||||
>
|
||||
<span
|
||||
class="css-1mhnkuh"
|
||||
>
|
||||
Configure Plugin
|
||||
</span>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
exports[`PluginSetup there is an error message 1`] = `
|
||||
<div>
|
||||
<div
|
||||
class="spin"
|
||||
>
|
||||
<img
|
||||
alt="Grafana OnCall Logo"
|
||||
src="[object Object]"
|
||||
/>
|
||||
<div
|
||||
class="spin-text"
|
||||
>
|
||||
ohhhh noo
|
||||
</div>
|
||||
<div
|
||||
class="configure-plugin"
|
||||
>
|
||||
<div
|
||||
class="css-ve64a7-horizontal-group"
|
||||
style="width: 100%; height: 100%;"
|
||||
>
|
||||
<div
|
||||
class="css-cvef6c-layoutChildrenWrapper"
|
||||
>
|
||||
<button
|
||||
class="css-zy62io-button"
|
||||
type="button"
|
||||
>
|
||||
<span
|
||||
class="css-1mhnkuh"
|
||||
>
|
||||
Retry
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
<div
|
||||
class="css-cvef6c-layoutChildrenWrapper"
|
||||
>
|
||||
<a
|
||||
class="css-zy62io-button"
|
||||
href="/plugins/grafana-oncall-app?page=configuration"
|
||||
tabindex="0"
|
||||
>
|
||||
<span
|
||||
class="css-1mhnkuh"
|
||||
>
|
||||
Configure Plugin
|
||||
</span>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
58
grafana-plugin/src/plugin/PluginSetup/index.tsx
Normal file
58
grafana-plugin/src/plugin/PluginSetup/index.tsx
Normal file
|
|
@ -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<PluginSetupWrapperProps> = ({ text, children }) => (
|
||||
<div className="spin">
|
||||
<img alt="Grafana OnCall Logo" src={logo} />
|
||||
<div className="spin-text">{text}</div>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
|
||||
const PluginSetup: FC<PluginSetupProps> = observer(({ InitializedComponent, ...props }) => {
|
||||
const store = useStore();
|
||||
const setupPlugin = useCallback(() => store.setupPlugin(props.meta), [props.meta]);
|
||||
|
||||
useEffect(() => {
|
||||
setupPlugin();
|
||||
}, [setupPlugin]);
|
||||
|
||||
if (store.appLoading) {
|
||||
return <PluginSetupWrapper text="Initializing plugin..." />;
|
||||
}
|
||||
|
||||
if (store.initializationError) {
|
||||
return (
|
||||
<PluginSetupWrapper text={store.initializationError}>
|
||||
<div className="configure-plugin">
|
||||
<HorizontalGroup>
|
||||
<Button variant="primary" onClick={setupPlugin} size="sm">
|
||||
Retry
|
||||
</Button>
|
||||
<LinkButton href={`/plugins/grafana-oncall-app?page=configuration`} variant="primary" size="sm">
|
||||
Configure Plugin
|
||||
</LinkButton>
|
||||
</HorizontalGroup>
|
||||
</div>
|
||||
</PluginSetupWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
return <InitializedComponent {...props} />;
|
||||
});
|
||||
|
||||
export default PluginSetup;
|
||||
|
|
@ -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' });
|
||||
}
|
||||
|
|
@ -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)"
|
||||
`;
|
||||
339
grafana-plugin/src/state/plugin/index.ts
Normal file
339
grafana-plugin/src/state/plugin/index.ts
Normal file
|
|
@ -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<OnCallPluginMetaJSONData>;
|
||||
secureJsonData?: Partial<OnCallPluginMetaSecureJSONData>;
|
||||
};
|
||||
|
||||
export type PluginStatusResponseBase = Pick<OnCallPluginMetaJSONData, 'license'> & {
|
||||
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<OnCallPluginMetaJSONData, 'onCallApiUrl'> & {
|
||||
onCallToken: string;
|
||||
};
|
||||
|
||||
type InstallPluginResponse<OnCallAPIResponse = any> = Pick<OnCallPluginMetaSecureJSONData, 'grafanaToken'> & {
|
||||
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<OnCallAppPluginMeta> =>
|
||||
this.grafanaBackend.get<OnCallAppPluginMeta>(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<PluginSyncStatusResponse> =>
|
||||
makeRequest<PluginSyncStatusResponse>(`${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<PluginSyncStatusResponse | string> => {
|
||||
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<PluginSyncStatusResponse | string> => {
|
||||
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 <RT = CloudProvisioningConfigResponse>(
|
||||
selfHosted = false
|
||||
): Promise<InstallPluginResponse<RT>> => {
|
||||
const { key: grafanaToken } = await this.createGrafanaToken();
|
||||
await this.updateGrafanaPluginSettings({ secureJsonData: { grafanaToken } });
|
||||
const onCallAPIResponse = await makeRequest<RT>(
|
||||
`${this.ONCALL_BASE_URL}/${selfHosted ? 'self-hosted/' : ''}install`,
|
||||
{
|
||||
method: 'POST',
|
||||
}
|
||||
);
|
||||
return { grafanaToken, onCallAPIResponse };
|
||||
};
|
||||
|
||||
static selfHostedInstallPlugin = async (
|
||||
onCallApiUrl: string,
|
||||
onCallApiUrlIsConfiguredThroughEnvVar: boolean
|
||||
): Promise<string | null> => {
|
||||
let pluginInstallationOnCallResponse: InstallPluginResponse<SelfHostedProvisioningConfigResponse>;
|
||||
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<SelfHostedProvisioningConfigResponse>(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<PluginConnectedStatusResponse | string> => {
|
||||
try {
|
||||
const resp = await makeRequest<PluginConnectedStatusResponse>(`${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<void> => {
|
||||
/**
|
||||
* 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<OnCallPluginMetaJSONData> = {
|
||||
stackId: null,
|
||||
orgId: null,
|
||||
onCallApiUrl: null,
|
||||
license: null,
|
||||
};
|
||||
const secureJsonData: Required<OnCallPluginMetaSecureJSONData> = {
|
||||
grafanaToken: null,
|
||||
onCallApiToken: null,
|
||||
};
|
||||
|
||||
return this.updateGrafanaPluginSettings({ jsonData, secureJsonData }, false);
|
||||
};
|
||||
}
|
||||
|
||||
export default PluginState;
|
||||
718
grafana-plugin/src/state/plugin/plugin.test.ts
Normal file
718
grafana-plugin/src/state/plugin/plugin.test.ts
Normal file
|
|
@ -0,0 +1,718 @@
|
|||
import { makeRequest as makeRequestOriginal } from 'network';
|
||||
|
||||
import PluginState, { InstallationVerb, PluginSyncStatusResponse, UpdateGrafanaPluginSettingsProps } from './';
|
||||
|
||||
const makeRequest = makeRequestOriginal as jest.Mock<ReturnType<typeof makeRequestOriginal>>;
|
||||
|
||||
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
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
@ -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<OnCallAppSettings>) {
|
||||
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;
|
||||
}
|
||||
}
|
||||
295
grafana-plugin/src/state/rootBaseStore/rootBaseStore.test.ts
Normal file
295
grafana-plugin/src/state/rootBaseStore/rootBaseStore.test.ts
Normal file
|
|
@ -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<ReturnType<typeof contextSrvOriginal['hasRole']>> };
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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<OnCallPluginMetaJSONData>;
|
||||
|
||||
// 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<null | OnCallPluginMetaJSONData>;
|
||||
export type OnCallPluginConfigPageProps = PluginConfigPageProps<OnCallAppPluginMeta>;
|
||||
|
||||
declare global {
|
||||
export interface Window {
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
```
|
||||
```
|
||||
kind delete cluster
|
||||
```
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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://<external-grafana>{{- end }}
|
||||
|
||||
|
||||
|
||||
🎉🎉🎉 Done! 🎉🎉🎉
|
||||
|
|
|
|||
|
|
@ -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" . }}
|
||||
|
|
|
|||
|
|
@ -293,6 +293,9 @@ grafana:
|
|||
plugins:
|
||||
- grafana-oncall-app
|
||||
|
||||
externalGrafana:
|
||||
url:
|
||||
|
||||
nameOverride: ""
|
||||
fullnameOverride: ""
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue