Merge pull request #883 from grafana/dev

Merge dev to main
This commit is contained in:
Michael Derynck 2022-11-22 18:36:17 +00:00 committed by GitHub
commit bd275848dc
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
96 changed files with 4403 additions and 850 deletions

90
.github/workflows/helm_tests.yml vendored Normal file
View file

@ -0,0 +1,90 @@
name: Helm End to End Testing
on:
- pull_request
jobs:
create-cluster:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v3
- name: Set up Docker Buildx # We need this step for docker caching
uses: docker/setup-buildx-action@v2
- name: Build docker image locally # using github actions docker cache
uses: docker/build-push-action@v2
with:
context: ./engine
file: ./engine/Dockerfile
push: false
load: true
tags: oncall/engine:latest
cache-from: type=gha
cache-to: type=gha,mode=max
- name: Create k8s Kind Cluster
uses: helm/kind-action@v1.3.0
with:
config: ./helm/kind.yml
- name: Load image on the nodes of the cluster
run: kind load docker-image --name=chart-testing oncall/engine:latest
- name: Install helm chart
run: helm install test-release helm/oncall --values helm/simple.yml --values helm/values-local-image.yml
- name: Await k8s pods and other resources up
uses: jupyterhub/action-k8s-await-workloads@v1
with:
workloads: "" # all
namespace: "" # default
timeout: 300
max-restarts: 0
- name: Bootstrap organization and integration
run: |
export POD_NAME=$(kubectl get pods --namespace default -l "app.kubernetes.io/name=oncall,app.kubernetes.io/instance=test-release,app.kubernetes.io/component=engine" -o jsonpath="{.items[0].metadata.name}")
export ONCALL_INTEGRATION_URL=http://localhost:30001$(kubectl exec -it $POD_NAME -- bash -c "python manage.py setup_end_to_end_test --bootstrap_integration")
echo "ONCALL_INTEGRATION_URL=$ONCALL_INTEGRATION_URL" >> $GITHUB_ENV
- name: Send an alert to the integration
run: |
echo $ONCALL_INTEGRATION_URL
export TEST_ID=test-0
echo "TEST_ID=$TEST_ID" >> $GITHUB_ENV
curl -X POST "$ONCALL_INTEGRATION_URL" \
-H 'Content-Type: Application/json' \
-d '{
"alert_uid": "08d6891a-835c-e661-39fa-96b6a9e26552",
"title": "'"$TEST_ID"'",
"image_url": "https://upload.wikimedia.org/wikipedia/commons/e/ee/Grumpy_Cat_by_Gage_Skidmore.jpg",
"state": "alerting",
"link_to_upstream_details": "https://en.wikipedia.org/wiki/Downtime",
"message": "Smth happened. Oh no!"
}'
# GitHub Action reference: https://github.com/jupyterhub/action-k8s-namespace-report
- name: Kubernetes namespace report
uses: jupyterhub/action-k8s-namespace-report@v1
if: always()
- name: Await 1 alert group and 1 alert created during the test (timeout 30 seconds)
run: |
export POD_NAME=$(kubectl get pods --namespace default -l "app.kubernetes.io/name=oncall,app.kubernetes.io/instance=test-release,app.kubernetes.io/component=engine" -o jsonpath="{.items[0].metadata.name}")
tries=30
while [ "$tries" -gt 0 ]; do
if kubectl exec -it $POD_NAME -c oncall -- bash -c "python manage.py setup_end_to_end_test --return_results_for_test_id $TEST_ID" | grep -q '1, 1'
then
break
fi
tries=$(( tries - 1 ))
sleep 1
done
if [ "$tries" -eq 0 ]; then
echo 'Expected "1, 1" (alert groups, alerts). They were not created in 30 seconds during this integration test. Something is broken' >&2
exit 1
fi

View file

@ -1,10 +1,29 @@
# Change Log
# Changelog
## v1.1.2 (2022-16-09)
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).
## v1.1.3 (2022-11-22)
- Bug Fixes
## v1.1.1 (2022-16-09)
### 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-11-18)
- Bug Fixes
## v1.1.1 (2022-11-16)
- Compatibility with Grafana 9.3.0
- Bug Fixes

View file

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

View file

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

View file

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

View file

@ -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
@ -215,6 +213,22 @@ $ CDPATH="." make init
$ CDPATH="" make init
```
**Problem:**
When running `make init start`:
```
Error response from daemon: open /var/lib/docker/overlay2/ac57b871108ee1b98ff4455e36d2175eae90cbc7d4c9a54608c0b45cfb7c6da5/committed: is a directory
make: *** [start] Error 1
```
**Solution:**
clear everything in docker by resetting or:
```
make cleanup
```
## IDE Specific Instructions
### PyCharm

View file

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

View file

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

View file

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

View 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()

View file

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

View file

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

View 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()

View 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

View 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

View 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

View file

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

View file

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

View file

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

View file

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

View file

@ -270,6 +270,11 @@ class SlackEventApiEndpointView(APIView):
# Open pop-up to inform user why OnCall bot doesn't work if any action was triggered
self._open_warning_window_if_needed(payload, slack_team_identity, warning_text)
return Response(status=200)
elif not slack_user_identity.users.exists():
# Means that slack_user_identity doesn't have any connected user
# Open pop-up to inform user why OnCall bot doesn't work if any action was triggered
self._open_warning_for_unconnected_user(sc, payload)
return Response(status=200)
action_record = SlackActionRecord(user=user, organization=organization, payload=payload)

View file

@ -1,5 +1,4 @@
import logging
import re
import requests
from django.http import HttpResponse
@ -22,16 +21,15 @@ class OrganizationMovedMiddleware(MiddlewareMixin):
)
url = create_engine_url(request.path, override_base=region.oncall_backend_url)
if request.META["QUERY_STRING"]:
url = f"{url}?{request.META['QUERY_STRING']}"
if (v := request.META.get("QUERY_STRING", None)) is not None:
url = f"{url}?{v}"
regex = re.compile("^HTTP_")
headers = dict(
(regex.sub("", header), value) for (header, value) in request.META.items() if header.startswith("HTTP_")
)
headers.pop("HOST", None)
if request.META["CONTENT_TYPE"]:
headers["CONTENT_TYPE"] = request.META["CONTENT_TYPE"]
headers = {}
if (v := request.META.get("CONTENT_TYPE", None)) is not None:
headers["Content-type"] = v
if (v := request.META.get("HTTP_AUTHORIZATION", None)) is not None:
headers["Authorization"] = v
response = self.make_request(request.method, url, headers, request.body)
return HttpResponse(response.content, status=response.status_code)

View file

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

View file

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

View file

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

View file

@ -0,0 +1,52 @@
from django.core.management import BaseCommand
from django.db.models.signals import post_save
from django.urls import reverse
from apps.alerts.models import Alert, AlertGroup, AlertReceiveChannel, listen_for_alertreceivechannel_model_save
from apps.alerts.tests.factories import AlertReceiveChannelFactory
from apps.user_management.tests.factories import OrganizationFactory
class Command(BaseCommand):
def add_arguments(self, parser):
group = parser.add_mutually_exclusive_group(required=True)
group.add_argument(
"--bootstrap_integration",
action="store_true",
help="Create random formatted webhook integration",
)
group.add_argument(
"--return_results_for_test_id",
type=str,
help="Count alert groups with specific text in the title and their alerts",
)
def handle(self, *args, **options):
if options["bootstrap_integration"]:
organization = OrganizationFactory()
def _make_alert_receive_channel(organization, **kwargs):
if "integration" not in kwargs:
kwargs["integration"] = "formatted_webhook"
post_save.disconnect(listen_for_alertreceivechannel_model_save, sender=AlertReceiveChannel)
alert_receive_channel = AlertReceiveChannelFactory(organization=organization, **kwargs)
post_save.connect(listen_for_alertreceivechannel_model_save, sender=AlertReceiveChannel)
return alert_receive_channel
integration = _make_alert_receive_channel(
organization, integration=AlertReceiveChannel.INTEGRATION_FORMATTED_WEBHOOK
)
url = reverse(
"integrations:universal",
kwargs={
"integration_type": AlertReceiveChannel.INTEGRATION_FORMATTED_WEBHOOK,
"alert_channel_key": integration.token,
},
)
return url
elif test_id := options["return_results_for_test_id"]:
alert_groups_pks = list(AlertGroup.all_objects.filter(web_title_cache=test_id).values_list("id", flat=True))
alert_groups_count = len(alert_groups_pks)
alerts_count = Alert.objects.filter(group_id__in=alert_groups_pks).count()
return f"{alert_groups_count}, {alerts_count}"

View file

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

View file

@ -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'],
};

View 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(),
})),
});

View file

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

View file

@ -0,0 +1,4 @@
export const getBackendSrv = () => ({
get: jest.fn(),
post: jest.fn(),
});

View file

@ -0,0 +1,3 @@
export const contextSrv = {
hasRole: jest.fn(),
};

View file

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

View file

@ -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', () => {

View file

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

View file

@ -42,7 +42,7 @@ const SchedulesFilters = (props: SchedulesFiltersProps) => {
return (
<div className={cx('root')}>
<HorizontalGroup spacing="lg">
<Field label="Search by name, user or object ID">
<Field label="Search by name">
<Input
autoFocus
className={cx('search')}

View file

@ -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', () => {

View file

@ -1,3 +1,3 @@
.root {
width: 300px;
width: auto;
}

View file

@ -89,7 +89,7 @@ const UserTimezoneSelect: FC<UserTimezoneSelectProps> = (props) => {
return (
<div className={cx('root')}>
<Select value={value} onChange={handleChange} width={100} placeholder={propValue} options={options} />
<Select value={value} onChange={handleChange} width={30} placeholder={propValue} options={options} />
</div>
);
};

View file

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

View file

@ -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();
});
});

View file

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

View file

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

View file

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

View file

@ -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();
});
});

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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();
});
});

View file

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

View file

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

View file

@ -22,6 +22,9 @@
padding: 6px 10px;
z-index: 1;
color: #fff;
width: 330px;
overflow: hidden;
white-space: nowrap;
}
.working-hours {

View file

@ -1,7 +1,7 @@
import React, { Component } from 'react';
import { SelectableValue } from '@grafana/data';
import { ValuePicker, HorizontalGroup, Button } from '@grafana/ui';
import { ValuePicker, HorizontalGroup, Button, Tooltip } from '@grafana/ui';
import cn from 'classnames/bind';
import dayjs from 'dayjs';
import { observer } from 'mobx-react';
@ -13,7 +13,7 @@ import Rotation from 'containers/Rotation/Rotation';
import RotationForm from 'containers/RotationForm/RotationForm';
import { WithPermissionControl } from 'containers/WithPermissionControl/WithPermissionControl';
import { getColor, getFromString } from 'models/schedule/schedule.helpers';
import { Layer, Schedule, Shift } from 'models/schedule/schedule.types';
import { Layer, Schedule, ScheduleType, Shift } from 'models/schedule/schedule.types';
import { Timezone } from 'models/timezone/timezone.types';
import { WithStoreProps } from 'state/types';
import { UserAction } from 'state/userAction';
@ -87,6 +87,11 @@ class Rotations extends Component<RotationsProps, RotationsState> {
options.push({ label: 'New Layer', value: nextPriority });
const schedule = store.scheduleStore.items[scheduleId];
const isTypeReadOnly =
schedule && (schedule?.type === ScheduleType.Ical || schedule?.type === ScheduleType.Calendar);
return (
<>
<div className={cx('root')}>
@ -98,11 +103,21 @@ class Rotations extends Component<RotationsProps, RotationsState> {
</Text.Title>
</div>
{disabled ? (
<WithPermissionControl userAction={UserAction.UpdateSchedules}>
<Button variant="primary" icon="plus" disabled>
Add rotation
</Button>
</WithPermissionControl>
isTypeReadOnly ? (
<Tooltip content="Ical and API/Terraform schedules are read-only" placement="top">
<div>
<Button variant="primary" icon="plus" disabled>
Add rotation
</Button>
</div>
</Tooltip>
) : (
<WithPermissionControl userAction={UserAction.UpdateSchedules}>
<Button variant="primary" icon="plus" disabled>
Add rotation
</Button>
</WithPermissionControl>
)
) : (
<ValuePicker
label="Add rotation"

View file

@ -1,6 +1,6 @@
import React, { Component } from 'react';
import { Button, HorizontalGroup } from '@grafana/ui';
import { Button, HorizontalGroup, Tooltip } from '@grafana/ui';
import cn from 'classnames/bind';
import dayjs from 'dayjs';
import { observer } from 'mobx-react';
@ -12,7 +12,7 @@ import Rotation from 'containers/Rotation/Rotation';
import ScheduleOverrideForm from 'containers/RotationForm/ScheduleOverrideForm';
import { WithPermissionControl } from 'containers/WithPermissionControl/WithPermissionControl';
import { getOverrideColor, getOverridesFromStore } from 'models/schedule/schedule.helpers';
import { Schedule, Shift, ShiftEvents } from 'models/schedule/schedule.types';
import { Schedule, ScheduleType, Shift, ShiftEvents } from 'models/schedule/schedule.types';
import { Timezone } from 'models/timezone/timezone.types';
import { WithStoreProps } from 'state/types';
import { UserAction } from 'state/userAction';
@ -70,6 +70,11 @@ class ScheduleOverrides extends Component<ScheduleOverridesProps, ScheduleOverri
const currentTimeHidden = currentTimeX < 0 || currentTimeX > 1;
const schedule = store.scheduleStore.items[scheduleId];
const isTypeReadOnly =
schedule && (schedule?.type === ScheduleType.Ical || schedule?.type === ScheduleType.Calendar);
return (
<>
<div id="overrides-list" className={cx('root')}>
@ -80,11 +85,21 @@ class ScheduleOverrides extends Component<ScheduleOverridesProps, ScheduleOverri
Overrides
</Text.Title>
</div>
<WithPermissionControl userAction={UserAction.UpdateSchedules}>
<Button disabled={disabled} icon="plus" onClick={this.handleAddOverride} variant="secondary">
Add override
</Button>
</WithPermissionControl>
{isTypeReadOnly ? (
<Tooltip content="Ical and API/Terraform schedules are read-only" placement="top">
<div>
<Button variant="primary" icon="plus" disabled>
Add override
</Button>
</div>
</Tooltip>
) : (
<WithPermissionControl userAction={UserAction.UpdateSchedules}>
<Button disabled={disabled} icon="plus" onClick={this.handleAddOverride} variant="secondary">
Add override
</Button>
</WithPermissionControl>
)}
</HorizontalGroup>
</div>
<div className={cx('header-plus-content')}>

View file

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

View file

@ -19,6 +19,8 @@ export class UserStore extends BaseStore {
@observable.shallow
items: { [pk: string]: User } = {};
itemsCurrentlyUpdating = {};
@observable
notificationPolicies: any = {};
@ -87,12 +89,20 @@ export class UserStore extends BaseStore {
@action
async updateItem(userPk: User['pk']) {
if (this.itemsCurrentlyUpdating[userPk]) {
return;
}
this.itemsCurrentlyUpdating[userPk] = true;
const user = await this.getById(userPk);
this.items = {
...this.items,
[user.pk]: { ...user, timezone: getTimezone(user) },
};
delete this.itemsCurrentlyUpdating[userPk];
}
@action

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,13 +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 { omit } from 'lodash-es';
import { observer } from 'mobx-react';
import { AppRootProps } from 'types';
import PageErrorHandlingWrapper from 'components/PageErrorHandlingWrapper/PageErrorHandlingWrapper';
import PluginLink from 'components/PluginLink/PluginLink';
@ -138,16 +137,16 @@ class SchedulePage extends React.Component<SchedulePageProps, SchedulePageState>
</HorizontalGroup>
)}
<HorizontalGroup>
{schedule?.type === ScheduleType.Ical && (
<HorizontalGroup>
<Button variant="secondary" onClick={this.handleExportClick()}>
Export
</Button>
<HorizontalGroup>
<Button variant="secondary" onClick={this.handleExportClick()}>
Export
</Button>
{(schedule?.type === ScheduleType.Ical || schedule?.type === ScheduleType.Calendar) && (
<Button variant="secondary" onClick={this.handleReloadClick(scheduleId)}>
Reload
</Button>
</HorizontalGroup>
)}
)}
</HorizontalGroup>
<ToolbarButton
icon="cog"
tooltip="Settings"
@ -162,11 +161,6 @@ class SchedulePage extends React.Component<SchedulePageProps, SchedulePageState>
</HorizontalGroup>
</HorizontalGroup>
</div>
{schedule?.type !== ScheduleType.API && (
<Text className={cx('desc')} type="secondary">
Ical and API/Terraform schedules are read-only
</Text>
)}
<div className={cx('users-timezones')}>
<UsersTimezones
scheduleId={scheduleId}
@ -418,31 +412,11 @@ class SchedulePage extends React.Component<SchedulePageProps, SchedulePageState>
return async () => {
await scheduleStore.reloadIcal(scheduleId);
scheduleStore.updateItem(scheduleId);
this.updateEventsFor(scheduleId);
store.scheduleStore.updateOncallShifts(scheduleId);
this.updateEvents();
};
};
updateEventsFor = async (scheduleId: Schedule['id'], withEmpty = true, with_gap = true) => {
const { store } = this.props;
const { id } = getQueryParams();
const { scheduleStore } = store;
store.scheduleStore.scheduleToScheduleEvents = omit(store.scheduleStore.scheduleToScheduleEvents, [scheduleId]);
await scheduleStore.updateScheduleEvents(
scheduleId,
withEmpty,
with_gap,
dayjs().format('YYYY-MM-DD').toString(),
dayjs.tz.guess()
);
await store.scheduleStore.updateOncallShifts(id);
await this.updateEvents();
};
handleDelete = () => {
const { store } = this.props;
const { id: scheduleId } = getQueryParams();

View file

@ -1,6 +1,7 @@
.schedule {
position: relative;
margin: 20px 0;
max-width: calc(100vw - 104px);
}
.title {

View file

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

View file

@ -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 }}\" }"
}
]
},

View file

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

View 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);
});
});

View file

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

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

View file

@ -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' });
}

View file

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

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

View 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
);
});
});

View file

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

View 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);
});
});

View file

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

View file

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

View file

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

32
helm/README.md Normal file
View file

@ -0,0 +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
```
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
```
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}")
```
4. Clean up
```
kind delete cluster
```

9
helm/kind.yml Normal file
View file

@ -0,0 +1,9 @@
kind: Cluster
apiVersion: kind.x-k8s.io/v1alpha4
nodes:
- role: control-plane
extraPortMappings:
- containerPort: 30001
hostPort: 30001
- containerPort: 30002
hostPort: 30002

View file

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

View file

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

View file

@ -4,15 +4,15 @@
- name: SECRET_KEY
valueFrom:
secretKeyRef:
name: {{ template "oncall.fullname" . }}
key: SECRET_KEY
name: {{ template "snippet.oncall.secret.name" . }}
key: {{ template "snippet.oncall.secret.secretKey" . }}
- name: MIRAGE_SECRET_KEY
valueFrom:
secretKeyRef:
name: {{ template "oncall.fullname" . }}
key: MIRAGE_SECRET_KEY
name: {{ template "snippet.oncall.secret.name" . }}
key: {{ template "snippet.oncall.secret.mirageSecretKey" . }}
- name: MIRAGE_CIPHER_IV
value: "1234567890abcdef"
value: "{{ .Values.oncall.mirageCipherIV | default "1234567890abcdef" }}"
- name: DJANGO_SETTINGS_MODULE
value: "settings.helm"
- name: AMIXR_DJANGO_ADMIN_PATH
@ -23,6 +23,32 @@
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" -}}
{{- if .Values.oncall.secrets.existingSecret -}}
{{ .Values.oncall.secrets.existingSecret }}
{{- else -}}
{{ template "oncall.fullname" . }}
{{- end -}}
{{- end -}}
{{- define "snippet.oncall.secret.secretKey" -}}
{{- if .Values.oncall.secrets.existingSecret -}}
{{ required "oncall.secrets.secretKey is required if oncall.secret.existingSecret is not empty" .Values.oncall.secrets.secretKey }}
{{- else -}}
SECRET_KEY
{{- end -}}
{{- end -}}
{{- define "snippet.oncall.secret.mirageSecretKey" -}}
{{- if .Values.oncall.secrets.existingSecret -}}
{{ required "oncall.secrets.mirageSecretKey is required if oncall.secret.existingSecret is not empty" .Values.oncall.secrets.mirageSecretKey }}
{{- else -}}
MIRAGE_SECRET_KEY
{{- end -}}
{{- end -}}
{{- define "snippet.oncall.slack.env" -}}
@ -31,12 +57,30 @@
value: {{ .Values.oncall.slack.enabled | toString | title | quote }}
- name: SLACK_SLASH_COMMAND_NAME
value: "/{{ .Values.oncall.slack.commandName | default "oncall" }}"
{{- if .Values.oncall.slack.existingSecret }}
- name: SLACK_CLIENT_OAUTH_ID
valueFrom:
secretKeyRef:
name: {{ .Values.oncall.slack.existingSecret }}
key: {{ required "oncall.slack.clientIdKey is required if oncall.slack.existingSecret is not empty" .Values.oncall.slack.clientIdKey }}
- name: SLACK_CLIENT_OAUTH_SECRET
valueFrom:
secretKeyRef:
name: {{ .Values.oncall.slack.existingSecret }}
key: {{ required "oncall.slack.clientSecretKey is required if oncall.slack.existingSecret is not empty" .Values.oncall.slack.clientSecretKey }}
- name: SLACK_SIGNING_SECRET
valueFrom:
secretKeyRef:
name: {{ .Values.oncall.slack.existingSecret }}
key: {{ required "oncall.slack.signingSecretKey is required if oncall.slack.existingSecret is not empty" .Values.oncall.slack.signingSecretKey }}
{{- else }}
- name: SLACK_CLIENT_OAUTH_ID
value: {{ .Values.oncall.slack.clientId | default "" | quote }}
- name: SLACK_CLIENT_OAUTH_SECRET
value: {{ .Values.oncall.slack.clientSecret | default "" | quote }}
- name: SLACK_SIGNING_SECRET
value: {{ .Values.oncall.slack.signingSecret | default "" | quote }}
{{- end }}
- name: SLACK_INSTALL_RETURN_REDIRECT_HOST
value: {{ .Values.oncall.slack.redirectHost | default (printf "https://%s" .Values.base_url) | quote }}
{{- else -}}
@ -51,8 +95,16 @@
value: {{ .Values.oncall.telegram.enabled | toString | title | quote }}
- name: TELEGRAM_WEBHOOK_HOST
value: {{ .Values.oncall.telegram.webhookUrl | default "" | quote }}
{{- if .Values.oncall.telegram.existingSecret }}
- name: TELEGRAM_TOKEN
valueFrom:
secretKeyRef:
name: {{ .Values.oncall.telegram.existingSecret }}
key: {{ required "oncall.telegram.tokenKey is required if oncall.telegram.existingSecret is not empty" .Values.oncall.telegram.tokenKey }}
{{- else }}
- name: TELEGRAM_TOKEN
value: {{ .Values.oncall.telegram.token | default "" | quote }}
{{- end }}
{{- else -}}
- name: FEATURE_TELEGRAM_INTEGRATION_ENABLED
value: {{ .Values.oncall.telegram.enabled | toString | title | quote }}
@ -111,6 +163,16 @@
{{- 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" . }}

View file

@ -6,6 +6,7 @@ metadata:
labels:
{{- include "oncall.engine.labels" . | nindent 4 }}
spec:
backoffLimit: 15
ttlSecondsAfterFinished: 20
template:
metadata:

View file

@ -16,6 +16,9 @@ spec:
targetPort: http
protocol: TCP
name: http
{{- if and (eq .Values.service.type "NodePort") (.Values.service.nodePort) }}
nodePort: {{ .Values.service.nodePort }}
{{- end }}
selector:
{{- include "oncall.engine.selectorLabels" . | nindent 4 }}
{{- end }}

View file

@ -1,3 +1,4 @@
{{- if not .Values.oncall.secrets.existingSecret }}
apiVersion: v1
kind: Secret
metadata:
@ -6,10 +7,9 @@ metadata:
{{- include "oncall.labels" . | nindent 4 }}
type: Opaque
data:
SECRET_KEY: {{ randAlphaNum 40 | b64enc | quote }}
MIRAGE_SECRET_KEY: {{ randAlphaNum 40 | b64enc | quote }}
MIRAGE_CIPHER_IV: {{ randAlphaNum 40 | b64enc | quote }}
{{ template "snippet.oncall.secret.secretKey" . }}: {{ randAlphaNum 40 | b64enc | quote }}
{{ template "snippet.oncall.secret.mirageSecretKey" . }}: {{ randAlphaNum 40 | b64enc | quote }}
{{- end }}
---
{{ if and (not .Values.mariadb.enabled) (eq .Values.database.type "mysql") -}}
apiVersion: v1

View file

@ -68,6 +68,17 @@ celery:
# memory: 128Mi
oncall:
# Override default MIRAGE_CIPHER_IV (must be 16 bytes long)
# For existing installation, this should not be changed.
# mirageCipherIV: 1234567890abcdef
# oncall secrets
secrets:
# Use existing secret. (secretKey and mirageSecretKey is required)
existingSecret: ""
# the key in the secret containing secret key
secretKey: ""
# the key in the secret containing mirage secret key
mirageSecretKey: ""
# slack configures the Grafana Oncall Slack ChatOps integration.
slack:
# enabled enable the Slack ChatOps integration for the Oncall Engine.
@ -84,12 +95,25 @@ oncall:
# requests comming from Slack.
# api.slack.com/apps/<yourApp> -> Basic Information -> App Credentials -> Signing Secret
signingSecret: ~
# Use existing secret for clientId, clientSecret and signingSecret.
# clientIdKey, clientSecretKey and signingSecretKey are required
existingSecret: ""
# the key in the secret containing OAuth2 client ID
clientIdKey: ""
# the key in the secret containing OAuth2 client secret
clientSecretKey: ""
# the key in the secret containing the Slack app signature secret
signingSecretKey: ""
# OnCall external URL
redirectHost: ~
telegram:
enabled: false
token: ~
webhookUrl: ~
# Use exsting secret. (tokenKey is required)
existingSecret: ""
# the key in the secret containing Telegram token
tokenKey: ""
smtp:
enabled: false
host: ~
@ -269,6 +293,9 @@ grafana:
plugins:
- grafana-oncall-app
externalGrafana:
url:
nameOverride: ""
fullnameOverride: ""

23
helm/simple.yml Normal file
View file

@ -0,0 +1,23 @@
base_url: localhost:30001
ingress:
enabled: false
ingress-nginx:
enabled: false
cert-manager:
enabled: false
service:
enabled: true
type: NodePort
port: 8080
nodePort: 30001
grafana:
service:
type: NodePort
nodePort: 30002
database:
# can be either mysql or postgresql
type: postgresql
mariadb:
enabled: false
postgresql:
enabled: true

16
helm/values-arm64.yml Normal file
View file

@ -0,0 +1,16 @@
# Substituting bitnami image with official image
# to be able to run Rabbitmq on arm64 (Mac M1)
# Optional for amd64 systems
rabbitmq:
enabled: true
image:
repository: rabbitmq
tag: 3.10.10
auth:
username: user
password: user
extraEnvVars:
- name: RABBITMQ_DEFAULT_USER
value: user
- name: RABBITMQ_DEFAULT_PASS
value: user

View file

@ -0,0 +1,4 @@
image:
repository: oncall/engine
tag: latest
pullPolicy: IfNotPresent