diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index be6032020c..11e50eaedf 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -1,5 +1,6 @@ * @grafana/grafana-oncall-backend /grafana-plugin @grafana/grafana-oncall-frontend +/grafana-plugin/pkg @grafana/grafana-oncall-backend /docs @grafana/docs-gops @grafana/grafana-oncall # `make docs` procedure is owned by @jdbaldry of @grafana/docs-squad. diff --git a/.github/actions/build-sign-and-package-plugin/action.yml b/.github/actions/build-sign-and-package-plugin/action.yml index 7ce4e42a0a..54f2b7e55f 100644 --- a/.github/actions/build-sign-and-package-plugin/action.yml +++ b/.github/actions/build-sign-and-package-plugin/action.yml @@ -27,6 +27,13 @@ runs: # yamllint disable rule:line-length run: | echo filename="grafana-oncall${{ inputs.is_enterprise == 'true' && '-ee' || '' }}-app-${{ inputs.plugin_version_number }}.zip" >> $GITHUB_OUTPUT + - name: Install Go + uses: actions/setup-go@v4 + with: + go-version: "1.21.5" + - name: Install Mage + shell: bash + run: go install github.com/magefile/mage@v1.15.0 - name: Build, sign, and package plugin shell: bash working-directory: ${{ inputs.working_directory }} @@ -35,6 +42,7 @@ runs: run: | jq --arg v "${{ inputs.plugin_version_number }}" '.version=$v' package.json > package.new && mv package.new package.json && jq '.version' package.json; yarn build + mage buildAll yarn sign if [ ! -f dist/MANIFEST.txt ]; then echo "Sign failed, MANIFEST.txt not created, aborting." && exit 1; fi mv dist grafana-oncall-app diff --git a/.github/workflows/e2e-tests.yml b/.github/workflows/e2e-tests.yml index fcc9e03069..d97120fa45 100644 --- a/.github/workflows/e2e-tests.yml +++ b/.github/workflows/e2e-tests.yml @@ -109,13 +109,11 @@ jobs: # ---------- Expensive e2e tests steps start ----------- - name: Install Go - if: inputs.run-expensive-tests uses: actions/setup-go@v4 with: go-version: "1.21.5" - name: Install Mage - if: inputs.run-expensive-tests run: go install github.com/magefile/mage@v1.15.0 - name: Get Vault secrets diff --git a/.github/workflows/linting-and-tests.yml b/.github/workflows/linting-and-tests.yml index 4c7449b3b3..3173dd0d91 100644 --- a/.github/workflows/linting-and-tests.yml +++ b/.github/workflows/linting-and-tests.yml @@ -12,7 +12,7 @@ env: jobs: lint-entire-project: name: "Lint entire project" - runs-on: ubuntu-latest + runs-on: ubuntu-latest-16-cores steps: - name: Checkout project uses: actions/checkout@v4 @@ -26,7 +26,7 @@ jobs: lint-test-and-build-frontend: name: "Lint, test, and build frontend" - runs-on: ubuntu-latest + runs-on: ubuntu-latest-16-cores steps: - name: Checkout project uses: actions/checkout@v4 @@ -38,7 +38,7 @@ jobs: test-technical-documentation: name: "Test technical documentation" - runs-on: ubuntu-latest + runs-on: ubuntu-latest-16-cores steps: - name: "Check out code" uses: "actions/checkout@v4" @@ -55,7 +55,7 @@ jobs: lint-migrations-backend-mysql-rabbitmq: name: "Lint database migrations" - runs-on: ubuntu-latest + runs-on: ubuntu-latest-16-cores services: rabbit_test: image: rabbitmq:3.12.0 @@ -86,7 +86,7 @@ jobs: unit-test-helm-chart: name: "Helm Chart Unit Tests" - runs-on: ubuntu-latest + runs-on: ubuntu-latest-16-cores steps: - name: Checkout project uses: actions/checkout@v4 @@ -98,6 +98,16 @@ jobs: - name: Run tests run: helm unittest ./helm/oncall + unit-test-backend-plugin: + name: "Backend Tests: Plugin" + runs-on: ubuntu-latest-16-cores + steps: + - uses: actions/checkout@v3 + - uses: actions/setup-go@v4 + with: + go-version: "1.21.5" + - run: cd grafana-plugin && go test ./pkg/... + unit-test-backend-mysql-rabbitmq: name: "Backend Tests: MySQL + RabbitMQ (RBAC enabled: ${{ matrix.rbac_enabled }})" runs-on: ubuntu-latest-16-cores @@ -201,7 +211,7 @@ jobs: unit-test-migrators: name: "Unit tests - Migrators" - runs-on: ubuntu-latest + runs-on: ubuntu-latest-16-cores steps: - name: Checkout project uses: actions/checkout@v4 @@ -215,7 +225,7 @@ jobs: mypy: name: "mypy" - runs-on: ubuntu-latest + runs-on: ubuntu-latest-16-cores steps: - name: Checkout project uses: actions/checkout@v4 @@ -227,10 +237,15 @@ jobs: end-to-end-tests: name: Standard e2e tests + strategy: + matrix: + grafana_version: + - 10.3.3 + - 10.1.7 + fail-fast: false uses: ./.github/workflows/e2e-tests.yml with: - # TODO: fix issues with running e2e tests against Grafana v10.2.x and v10.3.x - grafana_version: 10.1.7 - # grafana_version: 10.3.3 + grafana_version: ${{ matrix.grafana_version }} run-expensive-tests: false browsers: "chromium" + secrets: inherit diff --git a/Tiltfile b/Tiltfile index f6e07226f1..a0662caee6 100644 --- a/Tiltfile +++ b/Tiltfile @@ -20,7 +20,7 @@ if not running_under_parent_tiltfile: # Load the custom Grafana extensions v1alpha1.extension_repo( name="grafana-tilt-extensions", - ref="v1.2.0", + ref="v1.4.2", url="https://github.com/grafana/tilt-extensions", ) v1alpha1.extension( @@ -63,7 +63,7 @@ docker_build_sub( if is_ci: local_resource( "build-ui", - labels=["OnCallUI"], + labels=["OnCallPluginUI"], dir="grafana-plugin", cmd="yarn build", allow_parallel=True, @@ -81,13 +81,30 @@ if not is_ci: allow_parallel=True, ) +local_resource( + 'build-oncall-plugin-backend', + labels=["OnCallPluginBackend"], + dir="./grafana-plugin", + cmd="mage buildAll", + deps=['grafana-plugin/pkg/plugin'] +) + +local_resource( + 'restart-oncall-plugin-backend', + labels=["OnCallPluginBackend"], + dir="./dev/scripts", + cmd="chmod +x ./restart_backend_plugin.sh && ./restart_backend_plugin.sh", + resource_deps=["grafana", "build-oncall-plugin-backend"], + deps=['grafana-plugin/pkg/plugin'] +) + local_resource( "e2e-tests", labels=["allTests"], cmd=e2e_tests_cmd, trigger_mode=TRIGGER_MODE_MANUAL, auto_init=is_ci, - resource_deps=["build-ui", "grafana", "grafana-oncall-app-provisioning-configmap", "engine", "celery"] + resource_deps=["build-ui", "grafana", "grafana-oncall-app-provisioning-configmap", "engine", "celery", "build-oncall-plugin-backend"] ) cmd_button( @@ -183,11 +200,13 @@ if not running_under_parent_tiltfile: context="grafana-plugin", plugin_files=["grafana-plugin/src/plugin.json"], namespace="default", - deps=["grafana-oncall-app-provisioning-configmap", "build-ui"], + deps=["grafana-oncall-app-provisioning-configmap", "build-ui", "build-oncall-plugin-backend"], extra_env={ "GF_SECURITY_ADMIN_PASSWORD": "oncall", "GF_SECURITY_ADMIN_USER": "oncall", "GF_AUTH_ANONYMOUS_ENABLED": "false", + "GF_FEATURE_TOGGLES_ENABLE": "externalServiceAccounts", + "ONCALL_API_URL": "http://oncall-dev-engine:8080" }, ) diff --git a/dev/grafana/provisioning/plugins/grafana-oncall-app-provisioning.yaml b/dev/grafana/provisioning/plugins/grafana-oncall-app-provisioning.yaml index 47b8e1d7ee..b3ad074d87 100644 --- a/dev/grafana/provisioning/plugins/grafana-oncall-app-provisioning.yaml +++ b/dev/grafana/provisioning/plugins/grafana-oncall-app-provisioning.yaml @@ -5,4 +5,6 @@ apps: jsonData: stackId: 5 orgId: 100 - onCallApiUrl: http://oncall-dev-engine:8080 + license: OpenSource + onCallApiUrl: $ONCALL_API_URL + grafanaUrl: http://grafana:3000 diff --git a/dev/helm-local.yml b/dev/helm-local.yml index 02a7052718..b5fd80492d 100644 --- a/dev/helm-local.yml +++ b/dev/helm-local.yml @@ -70,7 +70,7 @@ grafana: - name: DATABASE_PASSWORD value: oncallpassword env: - GF_FEATURE_TOGGLES_ENABLE: topnav + GF_FEATURE_TOGGLES_ENABLE: topnav,externalServiceAccounts GF_SECURITY_ADMIN_PASSWORD: oncall GF_SECURITY_ADMIN_USER: oncall GF_PLUGINS_ALLOW_LOADING_UNSIGNED_PLUGINS: grafana-oncall-app diff --git a/dev/scripts/restart_backend_plugin.sh b/dev/scripts/restart_backend_plugin.sh new file mode 100755 index 0000000000..b80eaeae79 --- /dev/null +++ b/dev/scripts/restart_backend_plugin.sh @@ -0,0 +1,23 @@ +#!/bin/bash + +# Find a grafana pod +pod=$(kubectl get pods -l app.kubernetes.io/name=grafana -o=jsonpath='{.items[0].metadata.name}') + +if [ -z "$pod" ]; then + echo "No pod found with the specified label." + exit 1 +fi + +# Exec into the pod +kubectl exec -it "$pod" -- /bin/bash <<'EOF' + +# Find and kill the process containing "gpx_grafana" (plugin backend process) +process_id=$(ps aux | grep gpx_grafana | grep -v grep | awk '{print $1}') +echo $process_id +if [ -n "$process_id" ]; then + echo "Killing process $process_id" + kill $process_id +else + echo "No process containing 'gpx_grafana' in COMMAND found." +fi +EOF diff --git a/docker-compose-developer.yml b/docker-compose-developer.yml index 67eb2de43b..b751ab1e98 100644 --- a/docker-compose-developer.yml +++ b/docker-compose-developer.yml @@ -52,8 +52,6 @@ services: context: ./grafana-plugin dockerfile: Dockerfile.dev labels: *oncall-labels - environment: - ONCALL_API_URL: http://host.docker.internal:8080 volumes: - ./grafana-plugin:/etc/app - node_modules_dev:/etc/app/node_modules @@ -324,6 +322,8 @@ services: GF_SECURITY_ADMIN_USER: oncall GF_SECURITY_ADMIN_PASSWORD: oncall GF_PLUGINS_ALLOW_LOADING_UNSIGNED_PLUGINS: grafana-oncall-app + GF_FEATURE_TOGGLES_ENABLE: externalServiceAccounts + ONCALL_API_URL: http://host.docker.internal:8080 env_file: - ./dev/.env.${DB}.dev ports: diff --git a/engine/apps/auth_token/auth.py b/engine/apps/auth_token/auth.py index 755947c36f..41d7f80a7f 100644 --- a/engine/apps/auth_token/auth.py +++ b/engine/apps/auth_token/auth.py @@ -11,9 +11,11 @@ from apps.api.permissions import GrafanaAPIPermission, LegacyAccessControlRole, RBACPermission, user_is_authorized from apps.grafana_plugin.helpers.gcom import check_token +from apps.grafana_plugin.sync_data import SyncUser from apps.user_management.exceptions import OrganizationDeletedException, OrganizationMovedException from apps.user_management.models import User from apps.user_management.models.organization import Organization +from apps.user_management.sync import get_or_create_user from settings.base import SELF_HOSTED_SETTINGS from .constants import GOOGLE_OAUTH2_AUTH_TOKEN_NAME, SCHEDULE_EXPORT_TOKEN_NAME, SLACK_AUTH_TOKEN_NAME @@ -149,19 +151,34 @@ def _get_user(request: Request, organization: Organization) -> User: except (ValueError, TypeError): raise exceptions.AuthenticationFailed("Grafana context must be JSON dict.") - if "UserId" not in context and "UserID" not in context: - raise exceptions.AuthenticationFailed("Invalid Grafana context.") - - try: - user_id = context["UserId"] - except KeyError: - user_id = context["UserID"] - try: - return organization.users.get(user_id=user_id) + user_id = context.get("UserId", context.get("UserID")) + if user_id is not None: + return organization.users.get(user_id=user_id) + elif "Login" in context: + return organization.users.get(username=context["Login"]) + else: + raise exceptions.AuthenticationFailed("Grafana context must specify a User or UserID.") except User.DoesNotExist: - logger.debug(f"Could not get user from grafana request. Context {context}") - raise exceptions.AuthenticationFailed("Non-existent or anonymous user.") + try: + user_data = dict(json.loads(request.headers.get("X-Oncall-User-Context"))) + except (ValueError, TypeError): + raise exceptions.AuthenticationFailed("User context must be JSON dict.") + if user_data: + user_sync_data = SyncUser( + id=user_data["id"], + name=user_data["name"], + login=user_data["login"], + email=user_data["email"], + role=user_data["role"], + avatar_url=user_data["avatar_url"], + permissions=user_data["permissions"] or [], + teams=user_data.get("teams", None), + ) + return get_or_create_user(organization, user_sync_data) + else: + logger.debug("Could not get user from grafana request.") + raise exceptions.AuthenticationFailed("Non-existent or anonymous user.") class PluginAuthenticationSchema(OpenApiAuthenticationExtension): diff --git a/engine/apps/auth_token/tests/test_plugin_auth.py b/engine/apps/auth_token/tests/test_plugin_auth.py index 664ff9d361..cf7eaa07e0 100644 --- a/engine/apps/auth_token/tests/test_plugin_auth.py +++ b/engine/apps/auth_token/tests/test_plugin_auth.py @@ -1,3 +1,5 @@ +import json + import pytest from django.utils import timezone from rest_framework.exceptions import AuthenticationFailed @@ -75,3 +77,73 @@ def test_plugin_authentication_fail(authorization, instance_context): with pytest.raises(AuthenticationFailed): PluginAuthentication().authenticate(request) + + +@pytest.mark.django_db +def test_plugin_authentication_gcom_setup_new_user(make_organization): + # Setting gcom_token_org_last_time_synced to now, so it doesn't try to sync with gcom + organization = make_organization( + stack_id=42, org_id=24, gcom_token="123", gcom_token_org_last_time_synced=timezone.now() + ) + assert organization.users.count() == 0 + # user = make_user(organization=organization, user_id=12) + + # logged in user data available through header + user_data = { + "id": 12, + "name": "Test User", + "login": "test_user", + "email": "test@test.com", + "role": "Admin", + "avatar_url": "http://test.com/avatar.png", + "permissions": None, + "teams": None, + } + + headers = { + "HTTP_AUTHORIZATION": "gcom:123", + "HTTP_X-Instance-Context": '{"stack_id": 42, "org_id": 24}', + "HTTP_X-Grafana-Context": '{"UserId": 12}', + "HTTP_X-Oncall-User-Context": json.dumps(user_data), + } + request = APIRequestFactory().get("/", **headers) + + ret_user, ret_token = PluginAuthentication().authenticate(request) + + assert ret_user.user_id == 12 + assert ret_token.organization == organization + assert organization.users.count() == 1 + + +@pytest.mark.django_db +def test_plugin_authentication_self_hosted_setup_new_user(make_organization, make_token_for_organization): + # Setting gcom_token_org_last_time_synced to now, so it doesn't try to sync with gcom + organization = make_organization(stack_id=42, org_id=24) + token, token_string = make_token_for_organization(organization) + assert organization.users.count() == 0 + + # logged in user data available through header + user_data = { + "id": 12, + "name": "Test User", + "login": "test_user", + "email": "test@test.com", + "role": "Admin", + "avatar_url": "http://test.com/avatar.png", + "permissions": None, + "teams": None, + } + + headers = { + "HTTP_AUTHORIZATION": token_string, + "HTTP_X-Instance-Context": '{"stack_id": 42, "org_id": 24}', + "HTTP_X-Grafana-Context": '{"UserId": 12}', + "HTTP_X-Oncall-User-Context": json.dumps(user_data), + } + request = APIRequestFactory().get("/", **headers) + + ret_user, ret_token = PluginAuthentication().authenticate(request) + + assert ret_user.user_id == 12 + assert ret_token.organization == organization + assert organization.users.count() == 1 diff --git a/engine/apps/grafana_plugin/helpers/client.py b/engine/apps/grafana_plugin/helpers/client.py index 6a0cdc895e..d473c7e942 100644 --- a/engine/apps/grafana_plugin/helpers/client.py +++ b/engine/apps/grafana_plugin/helpers/client.py @@ -242,8 +242,8 @@ def get_users_permissions(self) -> typing.Optional[UserPermissionsDict]: return all_users_permissions def is_rbac_enabled_for_organization(self) -> bool: - _, resp_status = self.api_head(self.USER_PERMISSION_ENDPOINT) - return resp_status["connected"] + response, resp_status = self.api_get(self.USER_PERMISSION_ENDPOINT) + return response is not None and len(response) > 0 and resp_status["connected"] def get_users(self, rbac_is_enabled_for_org: bool, **kwargs) -> GrafanaUsersWithPermissions: users_response, _ = self.api_get("api/org/users", **kwargs) diff --git a/engine/apps/grafana_plugin/serializers/sync_data.py b/engine/apps/grafana_plugin/serializers/sync_data.py new file mode 100644 index 0000000000..321fad2f8a --- /dev/null +++ b/engine/apps/grafana_plugin/serializers/sync_data.py @@ -0,0 +1,105 @@ +from dataclasses import asdict +from typing import Dict, List + +from rest_framework import serializers + +from apps.grafana_plugin.sync_data import SyncData, SyncPermission, SyncSettings, SyncTeam, SyncUser + + +class SyncPermissionSerializer(serializers.Serializer): + action = serializers.CharField() + + def create(self, validated_data): + return SyncPermission(**validated_data) + + def to_representation(self, instance): + return asdict(instance) + + +class SyncUserSerializer(serializers.Serializer): + id = serializers.IntegerField() + name = serializers.CharField(allow_blank=True) + login = serializers.CharField() + email = serializers.CharField() + role = serializers.CharField() + avatar_url = serializers.CharField() + permissions = SyncPermissionSerializer(many=True, allow_empty=True, allow_null=True) + teams = serializers.ListField(child=serializers.IntegerField(), allow_empty=True, allow_null=True) + + def create(self, validated_data): + return SyncUser(**validated_data) + + def to_representation(self, instance): + return asdict(instance) + + +class SyncTeamSerializer(serializers.Serializer): + team_id = serializers.IntegerField() + name = serializers.CharField() + email = serializers.EmailField(allow_blank=True) + avatar_url = serializers.CharField() + + def create(self, validated_data): + return SyncTeam(**validated_data) + + def to_representation(self, instance): + return asdict(instance) + + +class TeamMemberMappingField(serializers.Field): + def to_representation(self, value: Dict[int, List[int]]): + return {str(k): v for k, v in value.items()} + + def to_internal_value(self, data): + if not isinstance(data, dict): + raise serializers.ValidationError("Expected a dictionary") + try: + return {int(k): v for k, v in data.items()} + except ValueError: + raise serializers.ValidationError("All keys must be convertible to integers") + + +class SyncOnCallSettingsSerializer(serializers.Serializer): + stack_id = serializers.IntegerField() + org_id = serializers.IntegerField() + license = serializers.CharField() + oncall_api_url = serializers.CharField() + oncall_token = serializers.CharField(allow_blank=True) + grafana_url = serializers.CharField() + grafana_token = serializers.CharField() + rbac_enabled = serializers.BooleanField() + incident_enabled = serializers.BooleanField() + incident_backend_url = serializers.CharField(allow_blank=True) + labels_enabled = serializers.BooleanField() + + def create(self, validated_data): + return SyncSettings(**validated_data) + + def to_representation(self, instance): + return asdict(instance) + + +class SyncDataSerializer(serializers.Serializer): + users = serializers.ListField(child=SyncUserSerializer()) + teams = serializers.ListField(child=SyncTeamSerializer(), allow_null=True, allow_empty=True) + team_members = TeamMemberMappingField() + settings = SyncOnCallSettingsSerializer() + + def create(self, validated_data): + return SyncData(**validated_data) + + def to_representation(self, instance): + return asdict(instance) + + def to_internal_value(self, data): + data = super().to_internal_value(data) + users = data.get("users") + if users: + data["users"] = [SyncUser(**user) for user in users] + teams = data.get("teams") + if teams: + data["teams"] = [SyncTeam(**team) for team in teams] + settings = data.get("settings") + if settings: + data["settings"] = SyncSettings(**settings) + return data diff --git a/engine/apps/grafana_plugin/sync_data.py b/engine/apps/grafana_plugin/sync_data.py new file mode 100644 index 0000000000..b4a868577e --- /dev/null +++ b/engine/apps/grafana_plugin/sync_data.py @@ -0,0 +1,50 @@ +from dataclasses import dataclass +from typing import Dict, List, Optional + + +@dataclass +class SyncPermission: + action: str + + +@dataclass +class SyncUser: + id: int + name: str + login: str + email: str + role: str + avatar_url: str + permissions: List[SyncPermission] + teams: Optional[List[int]] + + +@dataclass +class SyncTeam: + team_id: int + name: str + email: str + avatar_url: str + + +@dataclass +class SyncSettings: + stack_id: int + org_id: int + license: str + oncall_api_url: str + oncall_token: str + grafana_url: str + grafana_token: str + rbac_enabled: bool + incident_enabled: bool + incident_backend_url: str + labels_enabled: bool + + +@dataclass +class SyncData: + users: List[SyncUser] + teams: List[SyncTeam] + team_members: Dict[int, List[int]] + settings: SyncSettings diff --git a/engine/apps/grafana_plugin/tests/test_grafana_api_client.py b/engine/apps/grafana_plugin/tests/test_grafana_api_client.py index 65e83504c4..e2859484e4 100644 --- a/engine/apps/grafana_plugin/tests/test_grafana_api_client.py +++ b/engine/apps/grafana_plugin/tests/test_grafana_api_client.py @@ -115,17 +115,19 @@ def test_it_returns_none_if_permissions_call_returns_none( class TestIsRbacEnabledForOrganization: @pytest.mark.parametrize( - "api_response_connected,expected", + "api_response, api_response_connected, expected", [ - (True, True), - (False, False), + ([], True, False), + ([{"some": "data"}], True, True), + ([{"some": "data"}], False, False), + (None, False, False), ], ) - @patch("apps.grafana_plugin.helpers.client.GrafanaAPIClient.api_head") - def test_it_returns_based_on_status_code_of_head_call( - self, mocked_grafana_api_client_api_head, api_response_connected, expected + @patch("apps.grafana_plugin.helpers.client.GrafanaAPIClient.api_get") + def test_it_returns_based_on_response_and_connected_status( + self, mocked_grafana_api_client_api_get, api_response, api_response_connected, expected ): - mocked_grafana_api_client_api_head.return_value = (None, {"connected": api_response_connected}) + mocked_grafana_api_client_api_get.return_value = (api_response, {"connected": api_response_connected}) api_client = GrafanaAPIClient(API_URL, API_TOKEN) assert api_client.is_rbac_enabled_for_organization() == expected diff --git a/engine/apps/grafana_plugin/tests/test_sync_v2.py b/engine/apps/grafana_plugin/tests/test_sync_v2.py new file mode 100644 index 0000000000..bf7a9a06ea --- /dev/null +++ b/engine/apps/grafana_plugin/tests/test_sync_v2.py @@ -0,0 +1,43 @@ +from unittest.mock import patch + +import pytest +from django.urls import reverse +from rest_framework import status +from rest_framework.test import APIClient + +from apps.api.permissions import LegacyAccessControlRole + + +@pytest.mark.django_db +def test_auth_success(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) + + with patch("apps.grafana_plugin.views.sync_v2.SyncV2View.do_sync", return_value=organization) as mock_sync: + response = client.post(reverse("grafana-plugin:sync-v2"), format="json", **auth_headers) + + assert response.status_code == status.HTTP_200_OK + assert mock_sync.called + + +@pytest.mark.django_db +def test_invalid_auth(make_organization_and_user_with_plugin_token, make_user_auth_headers): + organization, user, token = make_organization_and_user_with_plugin_token(role=LegacyAccessControlRole.EDITOR) + client = APIClient() + + auth_headers = make_user_auth_headers(user, "invalid-token") + + with patch("apps.grafana_plugin.views.sync_v2.SyncV2View.do_sync", return_value=organization) as mock_sync: + response = client.post(reverse("grafana-plugin:sync-v2"), format="json", **auth_headers) + + assert response.status_code == status.HTTP_401_UNAUTHORIZED + assert not mock_sync.called + + auth_headers = make_user_auth_headers(user, token) + with patch("apps.grafana_plugin.views.sync_v2.SyncV2View.do_sync", return_value=organization) as mock_sync: + response = client.post(reverse("grafana-plugin:sync-v2"), format="json", **auth_headers) + + assert response.status_code == status.HTTP_403_FORBIDDEN + assert not mock_sync.called diff --git a/engine/apps/grafana_plugin/urls.py b/engine/apps/grafana_plugin/urls.py index 0b3e76c807..55ad0a0fd6 100644 --- a/engine/apps/grafana_plugin/urls.py +++ b/engine/apps/grafana_plugin/urls.py @@ -1,12 +1,25 @@ from django.urls import re_path -from apps.grafana_plugin.views import InstallView, SelfHostedInstallView, StatusView, SyncOrganizationView +from apps.grafana_plugin.views import ( + InstallV2View, + InstallView, + RecaptchaView, + SelfHostedInstallView, + StatusV2View, + StatusView, + SyncOrganizationView, + SyncV2View, +) app_name = "grafana-plugin" urlpatterns = [ + re_path(r"v2/sync/?", SyncV2View().as_view(), name="sync-v2"), + re_path(r"v2/status/?", StatusV2View().as_view(), name="status-v2"), + re_path(r"v2/install/?", InstallV2View().as_view(), name="install-v2"), 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"recaptcha/?", RecaptchaView().as_view(), name="recaptcha"), ] diff --git a/engine/apps/grafana_plugin/views/__init__.py b/engine/apps/grafana_plugin/views/__init__.py index 23e2437334..0abedcedd6 100644 --- a/engine/apps/grafana_plugin/views/__init__.py +++ b/engine/apps/grafana_plugin/views/__init__.py @@ -1,4 +1,8 @@ from .install import InstallView # noqa: F401 +from .install_v2 import InstallV2View # noqa: F401 +from .recaptcha import RecaptchaView # noqa: F401 from .self_hosted_install import SelfHostedInstallView # noqa: F401 from .status import StatusView # noqa: F401 +from .status_v2 import StatusV2View # noqa: F401 from .sync_organization import SyncOrganizationView # noqa: F401 +from .sync_v2 import SyncV2View # noqa: F401 diff --git a/engine/apps/grafana_plugin/views/install_v2.py b/engine/apps/grafana_plugin/views/install_v2.py new file mode 100644 index 0000000000..4b35c772aa --- /dev/null +++ b/engine/apps/grafana_plugin/views/install_v2.py @@ -0,0 +1,30 @@ +import logging + +from django.conf import settings +from rest_framework import status +from rest_framework.request import Request +from rest_framework.response import Response + +from apps.grafana_plugin.views.sync_v2 import SyncException, SyncV2View +from common.api_helpers.errors import SELF_HOSTED_ONLY_FEATURE_ERROR + +logger = logging.getLogger(__name__) + + +class InstallV2View(SyncV2View): + authentication_classes = () + permission_classes = () + + def post(self, request: Request) -> Response: + if settings.LICENSE != settings.OPEN_SOURCE_LICENSE_NAME: + return Response(data=SELF_HOSTED_ONLY_FEATURE_ERROR, status=status.HTTP_403_FORBIDDEN) + + try: + organization = self.do_sync(request) + except SyncException as e: + return Response(data=e.error_data, status=status.HTTP_400_BAD_REQUEST) + + organization.revoke_plugin() + provisioned_data = organization.provision_plugin() + + return Response(data=provisioned_data, status=status.HTTP_200_OK) diff --git a/engine/apps/grafana_plugin/views/recaptcha.py b/engine/apps/grafana_plugin/views/recaptcha.py new file mode 100644 index 0000000000..0f742b7921 --- /dev/null +++ b/engine/apps/grafana_plugin/views/recaptcha.py @@ -0,0 +1,17 @@ +from django.conf import settings +from rest_framework.request import Request +from rest_framework.response import Response +from rest_framework.views import APIView + +from apps.auth_token.auth import PluginAuthentication + + +class RecaptchaView(APIView): + authentication_classes = (PluginAuthentication,) + + def get(self, request: Request) -> Response: + return Response( + data={ + "recaptcha_site_key": settings.RECAPTCHA_V3_SITE_KEY, + } + ) diff --git a/engine/apps/grafana_plugin/views/status_v2.py b/engine/apps/grafana_plugin/views/status_v2.py new file mode 100644 index 0000000000..ce5a33af5f --- /dev/null +++ b/engine/apps/grafana_plugin/views/status_v2.py @@ -0,0 +1,52 @@ +import logging + +from django.conf import settings +from rest_framework.request import Request +from rest_framework.response import Response +from rest_framework.views import APIView + +from apps.auth_token.auth import BasePluginAuthentication +from apps.grafana_plugin.helpers import GrafanaAPIClient +from apps.mobile_app.auth import MobileAppAuthTokenAuthentication +from common.api_helpers.mixins import GrafanaHeadersMixin +from common.api_helpers.utils import create_engine_url + +logger = logging.getLogger(__name__) + + +class StatusV2View(GrafanaHeadersMixin, APIView): + authentication_classes = ( + MobileAppAuthTokenAuthentication, + BasePluginAuthentication, + ) + + def get(self, request: Request) -> Response: + # Check if the plugin is currently undergoing maintenance, and return response without querying db + if settings.CURRENTLY_UNDERGOING_MAINTENANCE_MESSAGE: + return Response( + data={ + "currently_undergoing_maintenance_message": settings.CURRENTLY_UNDERGOING_MAINTENANCE_MESSAGE, + } + ) + + organization = request.auth.organization + api_url = create_engine_url("") + + # If /status is called frequently this can be skipped with a cache + grafana_api_client = GrafanaAPIClient(api_url=organization.grafana_url, api_token=organization.api_token) + _, call_status = grafana_api_client.check_token() + + return Response( + data={ + "connection_to_grafana": { + "url": call_status["url"], + "connected": call_status["connected"], + "status_code": call_status["status_code"], + "message": call_status["message"], + }, + "license": settings.LICENSE, + "version": settings.VERSION, + "currently_undergoing_maintenance_message": settings.CURRENTLY_UNDERGOING_MAINTENANCE_MESSAGE, + "api_url": api_url, + } + ) diff --git a/engine/apps/grafana_plugin/views/sync_v2.py b/engine/apps/grafana_plugin/views/sync_v2.py new file mode 100644 index 0000000000..b762c7b79d --- /dev/null +++ b/engine/apps/grafana_plugin/views/sync_v2.py @@ -0,0 +1,59 @@ +import logging + +from django.conf import settings +from rest_framework import status +from rest_framework.permissions import IsAuthenticated +from rest_framework.request import Request +from rest_framework.response import Response +from rest_framework.views import APIView + +from apps.api.permissions import RBACPermission +from apps.auth_token.auth import PluginAuthentication +from apps.grafana_plugin.serializers.sync_data import SyncDataSerializer +from apps.user_management.models import Organization +from apps.user_management.sync import apply_sync_data, get_or_create_organization +from common.api_helpers.errors import INVALID_SELF_HOSTED_ID + +logger = logging.getLogger(__name__) + + +class SyncException(Exception): + def __init__(self, error_data): + self.error_data = error_data + + +class SyncV2View(APIView): + authentication_classes = (PluginAuthentication,) + permission_classes = [IsAuthenticated, RBACPermission] + rbac_permissions = { + "post": [RBACPermission.Permissions.USER_SETTINGS_ADMIN], + } + + def do_sync(self, request: Request) -> Organization: + serializer = SyncDataSerializer(data=request.data) + if not serializer.is_valid(): + raise SyncException(serializer.errors) + + sync_data = serializer.save() + + if settings.LICENSE == settings.OPEN_SOURCE_LICENSE_NAME: + stack_id = settings.SELF_HOSTED_SETTINGS["STACK_ID"] + org_id = settings.SELF_HOSTED_SETTINGS["ORG_ID"] + else: + org_id = request.auth.organization + stack_id = request.auth.organization.stack_id + + if sync_data.settings.org_id != org_id or sync_data.settings.stack_id != stack_id: + raise SyncException(INVALID_SELF_HOSTED_ID) + + organization = get_or_create_organization(sync_data.settings.org_id, sync_data.settings.stack_id, sync_data) + apply_sync_data(organization, sync_data) + return organization + + def post(self, request: Request) -> Response: + try: + self.do_sync(request) + except SyncException as e: + return Response(data=e.error_data, status=status.HTTP_400_BAD_REQUEST) + + return Response(status=status.HTTP_200_OK) diff --git a/engine/apps/user_management/models/team.py b/engine/apps/user_management/models/team.py index 50d8dd69a4..32b8af530f 100644 --- a/engine/apps/user_management/models/team.py +++ b/engine/apps/user_management/models/team.py @@ -4,18 +4,14 @@ from django.core.validators import MinLengthValidator from django.db import models -from apps.alerts.models import AlertReceiveChannel -from apps.metrics_exporter.helpers import metrics_bulk_update_team_label_cache -from apps.metrics_exporter.metrics_cache_manager import MetricsCacheManager from common.public_primary_keys import generate_public_primary_key, increase_public_primary_key_length if typing.TYPE_CHECKING: from django.db.models.manager import RelatedManager from apps.alerts.models import AlertGroupLogRecord - from apps.grafana_plugin.helpers.client import GrafanaAPIClient from apps.schedules.models import CustomOnCallShift - from apps.user_management.models import Organization, User + from apps.user_management.models import User def generate_public_primary_key_for_team() -> str: @@ -33,66 +29,7 @@ def generate_public_primary_key_for_team() -> str: class TeamManager(models.Manager["Team"]): - @staticmethod - def sync_for_organization( - organization: "Organization", api_teams: typing.List["GrafanaAPIClient.Types.GrafanaTeam"] - ) -> None: - grafana_teams = {team["id"]: team for team in api_teams} - existing_team_ids: typing.Set[int] = set(organization.teams.all().values_list("team_id", flat=True)) - - # create missing teams - teams_to_create = tuple( - Team( - organization_id=organization.pk, - team_id=team["id"], - name=team["name"], - email=team["email"], - avatar_url=team["avatarUrl"], - ) - for team in grafana_teams.values() - if team["id"] not in existing_team_ids - ) - # create entries, ignore failed insertions if team_id already exists in the organization - organization.teams.bulk_create(teams_to_create, batch_size=5000, ignore_conflicts=True) - - # create missing direct paging integrations - AlertReceiveChannel.objects.create_missing_direct_paging_integrations(organization) - - # delete excess teams and their direct paging integrations - team_ids_to_delete = existing_team_ids - grafana_teams.keys() - organization.alert_receive_channels.filter( - team__team_id__in=team_ids_to_delete, integration=AlertReceiveChannel.INTEGRATION_DIRECT_PAGING - ).delete() - organization.teams.filter(team_id__in=team_ids_to_delete).delete() - - # collect teams diffs to update metrics cache - metrics_teams_to_update: MetricsCacheManager.TeamsDiffMap = {} - for team_id in team_ids_to_delete: - metrics_teams_to_update = MetricsCacheManager.update_team_diff( - metrics_teams_to_update, team_id, deleted=True - ) - - # update existing teams if any fields have changed - teams_to_update = [] - for team in organization.teams.filter(team_id__in=existing_team_ids): - grafana_team = grafana_teams[team.team_id] - if ( - team.name != grafana_team["name"] - or team.email != grafana_team["email"] - or team.avatar_url != grafana_team["avatarUrl"] - ): - if team.name != grafana_team["name"]: - # collect teams diffs to update metrics cache - metrics_teams_to_update = MetricsCacheManager.update_team_diff( - metrics_teams_to_update, team.id, new_name=grafana_team["name"] - ) - team.name = grafana_team["name"] - team.email = grafana_team["email"] - team.avatar_url = grafana_team["avatarUrl"] - teams_to_update.append(team) - organization.teams.bulk_update(teams_to_update, ["name", "email", "avatar_url"], batch_size=5000) - - metrics_bulk_update_team_label_cache(metrics_teams_to_update, organization.id) + pass class Team(models.Model): diff --git a/engine/apps/user_management/models/user.py b/engine/apps/user_management/models/user.py index c6de3813f9..1e4592079e 100644 --- a/engine/apps/user_management/models/user.py +++ b/engine/apps/user_management/models/user.py @@ -1,5 +1,4 @@ import datetime -import json import logging import re import typing @@ -76,69 +75,7 @@ def default_working_hours(): class UserManager(models.Manager["User"]): - @staticmethod - def sync_for_team(team, api_members: list[dict]): - user_ids = tuple(member["userId"] for member in api_members) - users = team.organization.users.filter(user_id__in=user_ids) - team.users.set(users) - - @staticmethod - def sync_for_organization(organization, api_users: list[dict]): - grafana_users = {user["userId"]: user for user in api_users} - existing_user_ids = set(organization.users.all().values_list("user_id", flat=True)) - - # create missing users - users_to_create = tuple( - User( - organization_id=organization.pk, - user_id=user["userId"], - email=user["email"], - name=user["name"], - username=user["login"], - role=getattr(LegacyAccessControlRole, user["role"].upper(), LegacyAccessControlRole.NONE), - avatar_url=user["avatarUrl"], - permissions=user["permissions"], - ) - for user in grafana_users.values() - if user["userId"] not in existing_user_ids - ) - - organization.users.bulk_create(users_to_create, batch_size=5000) - - # delete excess users - user_ids_to_delete = existing_user_ids - grafana_users.keys() - organization.users.filter(user_id__in=user_ids_to_delete).delete() - - # update existing users if any fields have changed - users_to_update = [] - for user in organization.users.filter(user_id__in=existing_user_ids): - grafana_user = grafana_users[user.user_id] - g_user_role = getattr(LegacyAccessControlRole, grafana_user["role"].upper(), LegacyAccessControlRole.NONE) - - if ( - user.email != grafana_user["email"] - or user.name != grafana_user["name"] - or user.username != grafana_user["login"] - or user.role != g_user_role - or user.avatar_url != grafana_user["avatarUrl"] - # instead of looping through the array of permission objects, simply take the hash - # of the string representation of the data structures and compare. - # Need to first convert the lists of objects to strings because lists/dicts are not hashable - # (because lists and dicts are not hashable.. as they are mutable) - # https://stackoverflow.com/a/22003440 - or hash(json.dumps(user.permissions)) != hash(json.dumps(grafana_user["permissions"])) - ): - user.email = grafana_user["email"] - user.name = grafana_user["name"] - user.username = grafana_user["login"] - user.role = g_user_role - user.avatar_url = grafana_user["avatarUrl"] - user.permissions = grafana_user["permissions"] - users_to_update.append(user) - - organization.users.bulk_update( - users_to_update, ["email", "name", "username", "role", "avatar_url", "permissions"], batch_size=5000 - ) + pass class UserQuerySet(models.QuerySet): diff --git a/engine/apps/user_management/sync.py b/engine/apps/user_management/sync.py index 6000a9d1a7..07af904dc4 100644 --- a/engine/apps/user_management/sync.py +++ b/engine/apps/user_management/sync.py @@ -1,14 +1,21 @@ import logging +import typing import uuid from celery.utils.log import get_task_logger from django.conf import settings from django.utils import timezone -from apps.grafana_plugin.helpers.client import GcomAPIClient, GrafanaAPIClient +from apps.alerts.models import AlertReceiveChannel +from apps.api.permissions import LegacyAccessControlRole +from apps.auth_token.exceptions import InvalidToken +from apps.grafana_plugin.helpers.client import GcomAPIClient, GCOMInstanceInfo, GrafanaAPIClient +from apps.grafana_plugin.sync_data import SyncData, SyncPermission, SyncSettings, SyncTeam, SyncUser +from apps.metrics_exporter.helpers import metrics_bulk_update_team_label_cache +from apps.metrics_exporter.metrics_cache_manager import MetricsCacheManager from apps.user_management.models import Organization, Team, User -from apps.user_management.signals import org_sync_signal from common.utils import task_lock +from settings.base import CLOUD_LICENSE_NAME, OPEN_SOURCE_LICENSE_NAME logger = get_task_logger(__name__) logger.setLevel(logging.DEBUG) @@ -28,8 +35,8 @@ def sync_organization(organization: Organization) -> None: def _sync_organization(organization: Organization) -> None: grafana_api_client = GrafanaAPIClient(api_url=organization.grafana_url, api_token=organization.api_token) - rbac_is_enabled = organization.is_rbac_permissions_enabled + rbac_is_enabled = organization.is_rbac_permissions_enabled # NOTE: checking whether or not RBAC is enabled depends on whether we are dealing with an open-source or cloud # stack. For open-source, simply make a HEAD request to the grafana instance's API and consider RBAC enabled if # the list RBAC permissions endpoint returns 200. @@ -51,42 +58,42 @@ def _sync_organization(organization: Organization) -> None: else: rbac_is_enabled = grafana_api_client.is_rbac_enabled_for_organization() - organization.is_rbac_permissions_enabled = rbac_is_enabled - logger.info(f"RBAC status org={organization.pk} rbac_enabled={organization.is_rbac_permissions_enabled}") - - _sync_instance_info(organization) - - _, check_token_call_status = grafana_api_client.check_token() - if check_token_call_status["connected"]: - organization.api_token_status = Organization.API_TOKEN_STATUS_OK - sync_users_and_teams(grafana_api_client, organization) - organization.last_time_synced = timezone.now() - - _sync_grafana_incident_plugin(organization, grafana_api_client) - _sync_grafana_labels_plugin(organization, grafana_api_client) - else: - organization.api_token_status = Organization.API_TOKEN_STATUS_FAILED - logger.warning(f"Sync not successful org={organization.pk} token_status=FAILED") + # get incident plugin settings + grafana_incident_settings, _ = grafana_api_client.get_grafana_incident_plugin_settings() + is_grafana_incident_enabled = False + grafana_incident_backend_url = None + if grafana_incident_settings is not None: + is_grafana_incident_enabled = grafana_incident_settings["enabled"] + grafana_incident_backend_url = (grafana_incident_settings.get("jsonData") or {}).get( + GrafanaAPIClient.GRAFANA_INCIDENT_PLUGIN_BACKEND_URL_KEY + ) - organization.save( - update_fields=[ - "cluster_slug", - "stack_slug", - "org_slug", - "org_title", - "region_slug", - "grafana_url", - "last_time_synced", - "api_token_status", - "gcom_token_org_last_time_synced", - "is_rbac_permissions_enabled", - "is_grafana_incident_enabled", - "is_grafana_labels_enabled", - "grafana_incident_backend_url", - ] + # get labels plugin settings + is_grafana_labels_enabled = False + grafana_labels_plugin_settings, _ = grafana_api_client.get_grafana_labels_plugin_settings() + if grafana_labels_plugin_settings is not None: + is_grafana_labels_enabled = grafana_labels_plugin_settings["enabled"] + + oncall_api_url = settings.BASE_URL + if settings.LICENSE == CLOUD_LICENSE_NAME: + oncall_api_url = settings.GRAFANA_CLOUD_ONCALL_API_URL + + sync_settings = SyncSettings( + stack_id=organization.stack_id, + org_id=organization.org_id, + license=settings.LICENSE, + oncall_api_url=oncall_api_url, + oncall_token=organization.gcom_token, + grafana_url=organization.grafana_url, + grafana_token=organization.api_token, + rbac_enabled=rbac_is_enabled, + incident_enabled=is_grafana_incident_enabled, + incident_backend_url=grafana_incident_backend_url, + labels_enabled=is_grafana_labels_enabled, ) - - org_sync_signal.send(sender=None, organization=organization) + _sync_organization_data(organization, sync_settings) + if organization.api_token_status == Organization.API_TOKEN_STATUS_OK: + sync_users_and_teams(grafana_api_client, organization) def _sync_instance_info(organization: Organization) -> None: @@ -106,32 +113,6 @@ def _sync_instance_info(organization: Organization) -> None: organization.gcom_token_org_last_time_synced = timezone.now() -def _sync_grafana_labels_plugin(organization: Organization, grafana_api_client) -> None: - """ - _sync_grafana_labels_plugin checks if grafana-labels-app plugin is enabled and sets a flag in the organization. - It intended to use only inside _sync_organization. It mutates, but not saves org, it's saved in _sync_organization. - """ - grafana_labels_plugin_settings, _ = grafana_api_client.get_grafana_labels_plugin_settings() - if grafana_labels_plugin_settings is not None: - organization.is_grafana_labels_enabled = grafana_labels_plugin_settings["enabled"] - - -def _sync_grafana_incident_plugin(organization: Organization, grafana_api_client) -> None: - """ - _sync_grafana_incident_plugin check if incident plugin is enabled and sets a flag and its url in the organization. - It intended to use only inside _sync_organization. It mutates, but not saves org, it's saved in _sync_organization. - """ - grafana_incident_settings, _ = grafana_api_client.get_grafana_incident_plugin_settings() - organization.is_grafana_incident_enabled = False - organization.grafana_incident_backend_url = None - - if grafana_incident_settings is not None: - organization.is_grafana_incident_enabled = grafana_incident_settings["enabled"] - organization.grafana_incident_backend_url = (grafana_incident_settings.get("jsonData") or {}).get( - GrafanaAPIClient.GRAFANA_INCIDENT_PLUGIN_BACKEND_URL_KEY - ) - - def sync_users_and_teams(client: GrafanaAPIClient, organization: Organization) -> None: sync_users(client, organization) sync_teams(client, organization) @@ -143,7 +124,21 @@ def sync_users(client: GrafanaAPIClient, organization: Organization, **kwargs) - # check if api_users are shaped correctly. e.g. for paused instance, the response is not a list. if not api_users or not isinstance(api_users, (tuple, list)): return - User.objects.sync_for_organization(organization=organization, api_users=api_users) + + sync_users = [ + SyncUser( + id=user["userId"], + name=user["name"], + login=user["login"], + email=user["email"], + role=user["role"], + avatar_url=user["avatarUrl"], + teams=None, + permissions=[SyncPermission(action=permission["permission"]) for permission in user["permissions"]], + ) + for user in api_users + ] + _sync_users_data(organization, sync_users, delete_extra=True) def sync_teams(client: GrafanaAPIClient, organization: Organization, **kwargs) -> None: @@ -151,23 +146,26 @@ def sync_teams(client: GrafanaAPIClient, organization: Organization, **kwargs) - if not api_teams_result: return api_teams = api_teams_result["teams"] - Team.objects.sync_for_organization(organization=organization, api_teams=api_teams) + sync_teams = [ + SyncTeam( + team_id=team["id"], + name=team["name"], + email=team["email"], + avatar_url=team["avatarUrl"], + ) + for team in api_teams + ] + _sync_teams_data(organization, sync_teams) def sync_team_members(client: GrafanaAPIClient, organization: Organization) -> None: + team_members = {} for team in organization.teams.all(): members, _ = client.get_team_members(team.team_id) if not members: continue - User.objects.sync_for_team(team=team, api_members=members) - - -def sync_users_for_teams(client: GrafanaAPIClient, organization: Organization, **kwargs) -> None: - api_teams_result, _ = client.get_teams(**kwargs) - if not api_teams_result: - return - api_teams = api_teams_result["teams"] - Team.objects.sync_for_organization(organization=organization, api_teams=api_teams) + team_members[team.team_id] = [member["userId"] for member in members] + _sync_teams_members_data(organization, team_members) def delete_organization_if_needed(organization: Organization) -> bool: @@ -214,3 +212,232 @@ def cleanup_organization(organization_pk: int) -> None: except Organization.DoesNotExist: logger.info(f"Organization {organization_pk} was not found") + + +def _create_cloud_organization( + org_id: int, stack_id: int, sync_data: SyncData, instance_info: GCOMInstanceInfo +) -> Organization: + client = GcomAPIClient(sync_data.settings.oncall_token) + if not instance_info: + instance_info = client.get_instance_info(stack_id) + if not instance_info or str(instance_info["orgId"]) != org_id: + raise InvalidToken + + return Organization.objects.create( + stack_id=str(instance_info["id"]), + stack_slug=instance_info["slug"], + grafana_url=instance_info["url"], + org_id=str(instance_info["orgId"]), + org_slug=instance_info["orgSlug"], + org_title=instance_info["orgName"], + region_slug=instance_info["regionSlug"], + cluster_slug=instance_info["clusterSlug"], + api_token=sync_data.settings.grafana_token, + gcom_token=sync_data.settings.oncall_token, + is_rbac_permissions_enabled=sync_data.settings.rbac_enabled, + defaults={"gcom_token_org_last_time_synced": timezone.now()}, + ) + + +def _create_oss_organization(sync_data: SyncData) -> Organization: + return Organization.objects.create( + stack_id=settings.SELF_HOSTED_SETTINGS["STACK_ID"], + stack_slug=settings.SELF_HOSTED_SETTINGS["STACK_SLUG"], + org_id=settings.SELF_HOSTED_SETTINGS["ORG_ID"], + org_slug=settings.SELF_HOSTED_SETTINGS["ORG_SLUG"], + org_title=settings.SELF_HOSTED_SETTINGS["ORG_TITLE"], + region_slug=settings.SELF_HOSTED_SETTINGS["REGION_SLUG"], + cluster_slug=settings.SELF_HOSTED_SETTINGS["CLUSTER_SLUG"], + grafana_url=sync_data.settings.grafana_url, + api_token=sync_data.settings.grafana_token, + is_rbac_permissions_enabled=sync_data.settings.rbac_enabled, + ) + + +def _create_organization( + org_id: int, stack_id: int, sync_data: SyncData, instance_info: GCOMInstanceInfo +) -> typing.Optional[Organization]: + if settings.LICENSE == CLOUD_LICENSE_NAME: + return _create_cloud_organization(org_id, stack_id, sync_data, instance_info) + elif settings.LICENSE == OPEN_SOURCE_LICENSE_NAME: + return _create_oss_organization(sync_data) + return None + + +def get_or_create_organization( + org_id: int, stack_id: int, sync_data: SyncData = None, instance_info: GCOMInstanceInfo = None +) -> Organization: + organization = Organization.objects.filter(org_id=org_id, stack_id=stack_id).first() + if not organization: + organization = _create_organization(org_id, stack_id, sync_data, instance_info) + return organization + + +def get_or_create_user(organization: Organization, sync_user: SyncUser) -> User: + _sync_users_data(organization, [sync_user], delete_extra=False) + user = organization.users.get(user_id=sync_user.id) + + # update team membership if needed + # (not removing user from teams, assuming this is called on user creation/first login only; + # periodic sync will keep teams updated) + membership = sync_user.teams or [] + for team_id in membership: + team = organization.teams.filter(team_id=team_id).first() + if team: + user.teams.add(team) + + return user + + +def _sync_organization_data(organization: Organization, sync_settings: SyncSettings): + organization.is_rbac_permissions_enabled = sync_settings.rbac_enabled + logger.info(f"RBAC status org={organization.pk} rbac_enabled={organization.is_rbac_permissions_enabled}") + + organization.is_grafana_labels_enabled = sync_settings.labels_enabled + organization.is_grafana_incident_enabled = sync_settings.incident_enabled + organization.grafana_incident_backend_url = sync_settings.incident_backend_url + organization.grafana_url = sync_settings.grafana_url + organization.api_token = sync_settings.grafana_token + organization.last_time_synced = timezone.now() + + _sync_instance_info(organization) + + grafana_api_client = GrafanaAPIClient(api_url=organization.grafana_url, api_token=organization.api_token) + _, check_token_call_status = grafana_api_client.check_token() + if check_token_call_status["connected"]: + organization.api_token_status = Organization.API_TOKEN_STATUS_OK + organization.last_time_synced = timezone.now() + else: + organization.api_token_status = Organization.API_TOKEN_STATUS_FAILED + logger.warning(f"Sync not successful org={organization.pk} token_status=FAILED") + + organization.save( + update_fields=[ + "api_token", + "cluster_slug", + "stack_slug", + "org_slug", + "org_title", + "region_slug", + "grafana_url", + "last_time_synced", + "gcom_token_org_last_time_synced", + "is_rbac_permissions_enabled", + "is_grafana_incident_enabled", + "is_grafana_labels_enabled", + "grafana_incident_backend_url", + ] + ) + + +def _sync_users_data(organization: Organization, sync_users: list[SyncUser], delete_extra=False): + users_to_sync = ( + User( + organization_id=organization.pk, + user_id=user.id, + email=user.email, + name=user.name, + username=user.login, + role=getattr(LegacyAccessControlRole, user.role.upper(), LegacyAccessControlRole.NONE), + avatar_url=user.avatar_url, + permissions=user.permissions or [], + ) + for user in sync_users + ) + + existing_user_ids = set(organization.users.all().values_list("user_id", flat=True)) + kwargs = {} + if settings.DATABASE_TYPE in ("sqlite3", "postgresql"): + # unique_fields is required for sqlite and postgresql setups + kwargs["unique_fields"] = ("organization_id", "user_id", "is_active") + organization.users.bulk_create( + users_to_sync, + update_conflicts=True, + update_fields=("email", "name", "username", "role", "avatar_url", "permissions"), + batch_size=5000, + **kwargs, + ) + + # Retrieve primary keys for the newly created users + # + # If the model’s primary key is an AutoField, the primary key attribute can only be retrieved + # on certain databases (currently PostgreSQL, MariaDB 10.5+, and SQLite 3.35+). + # On other databases, it will not be set. + # https://docs.djangoproject.com/en/4.1/ref/models/querysets/#django.db.models.query.QuerySet.bulk_create + created_users = organization.users.exclude(user_id__in=existing_user_ids) + + if delete_extra: + # delete removed users + existing_user_ids |= set(u.user_id for u in created_users) + user_ids_to_delete = existing_user_ids - {user.id for user in sync_users} + organization.users.filter(user_id__in=user_ids_to_delete).delete() + + +def _sync_teams_data(organization: Organization, sync_teams: list[SyncTeam]): + if sync_teams is None: + sync_teams = [] + # keep existing team names mapping to check for possible metrics cache updates + existing_team_names = {team.team_id: team.name for team in organization.teams.all()} + teams_to_sync = tuple( + Team( + organization_id=organization.pk, + team_id=team.team_id, + name=team.name, + email=team.email, + avatar_url=team.avatar_url, + ) + for team in sync_teams + ) + # create entries, update if team_id already exists in the organization + kwargs = {} + if settings.DATABASE_TYPE in ("sqlite3", "postgresql"): + # unique_fields is required for sqlite and postgresql setups + kwargs["unique_fields"] = ("organization_id", "team_id") + organization.teams.bulk_create( + teams_to_sync, + batch_size=5000, + update_conflicts=True, + update_fields=("name", "email", "avatar_url"), + **kwargs, + ) + + # create missing direct paging integrations + AlertReceiveChannel.objects.create_missing_direct_paging_integrations(organization) + + # delete removed teams and their direct paging integrations + existing_team_ids = set(organization.teams.all().values_list("team_id", flat=True)) + team_ids_to_delete = existing_team_ids - set(t.team_id for t in sync_teams) + organization.alert_receive_channels.filter( + team__team_id__in=team_ids_to_delete, integration=AlertReceiveChannel.INTEGRATION_DIRECT_PAGING + ).delete() + organization.teams.filter(team_id__in=team_ids_to_delete).delete() + + # collect teams diffs to update metrics cache + metrics_teams_to_update: MetricsCacheManager.TeamsDiffMap = {} + for team_id in team_ids_to_delete: + metrics_teams_to_update = MetricsCacheManager.update_team_diff(metrics_teams_to_update, team_id, deleted=True) + for team in sync_teams: + previous_name = existing_team_names.get(team.team_id) + if previous_name and previous_name != team.name: + metrics_teams_to_update = MetricsCacheManager.update_team_diff( + metrics_teams_to_update, team.team_id, new_name=team.name + ) + metrics_bulk_update_team_label_cache(metrics_teams_to_update, organization.id) + + +def _sync_teams_members_data(organization: Organization, team_members: dict[int, list[int]]): + # set team members + for team_id, members_ids in team_members.items(): + team = organization.teams.get(team_id=team_id) + team.users.set(organization.users.filter(user_id__in=members_ids)) + + +def apply_sync_data(organization: Organization, sync_data: SyncData): + # update org + settings + _sync_organization_data(organization, sync_data.settings) + # update or create users + _sync_users_data(organization, sync_data.users, delete_extra=True) + # update or create teams + direct paging integrations + _sync_teams_data(organization, sync_data.teams) + # update team members + _sync_teams_members_data(organization, sync_data.team_members) diff --git a/engine/apps/user_management/tests/test_sync.py b/engine/apps/user_management/tests/test_sync.py index 3cfd875a74..b41bb83311 100644 --- a/engine/apps/user_management/tests/test_sync.py +++ b/engine/apps/user_management/tests/test_sync.py @@ -9,13 +9,15 @@ from apps.alerts.models import AlertReceiveChannel from apps.api.permissions import LegacyAccessControlRole -from apps.grafana_plugin.helpers.client import GrafanaAPIClient -from apps.user_management.models import Team, User +from apps.grafana_plugin.sync_data import SyncUser +from apps.user_management.models import User from apps.user_management.sync import ( - _sync_grafana_incident_plugin, - _sync_grafana_labels_plugin, cleanup_organization, + get_or_create_user, sync_organization, + sync_team_members, + sync_teams, + sync_users, ) MOCK_GRAFANA_INCIDENT_BACKEND_URL = "https://grafana-incident.test" @@ -96,8 +98,9 @@ def test_sync_users_for_organization(make_organization, make_user_for_organizati } for user_id in (2, 3) ) - - User.objects.sync_for_organization(organization, api_users=api_users) + with patched_grafana_api_client(organization) as mock_grafana_api_client: + mock_grafana_api_client.get_users.return_value = api_users + sync_users(mock_grafana_api_client, organization) assert organization.users.count() == 2 @@ -137,7 +140,9 @@ def test_sync_users_for_organization_role_none(make_organization, make_user_for_ for user_id in (2, 3) ) - User.objects.sync_for_organization(organization, api_users=api_users) + with patched_grafana_api_client(organization) as mock_grafana_api_client: + mock_grafana_api_client.get_users.return_value = api_users + sync_users(mock_grafana_api_client, organization) assert organization.users.count() == 2 @@ -170,7 +175,9 @@ def test_sync_teams_for_organization(make_organization, make_team, make_alert_re for team_id in (2, 3, 4) ) - Team.objects.sync_for_organization(organization, api_teams=api_teams) + with patched_grafana_api_client(organization) as mock_grafana_api_client: + mock_grafana_api_client.get_teams.return_value = ({"teams": api_teams}, None) + sync_teams(mock_grafana_api_client, organization) assert organization.teams.count() == 3 @@ -224,15 +231,16 @@ def test_sync_users_for_team(make_organization, make_user_for_organization, make }, ) - User.objects.sync_for_team(team, api_members=api_members) + with patched_grafana_api_client(organization) as mock_grafana_api_client: + mock_grafana_api_client.get_team_members.return_value = (api_members, None) + sync_team_members(mock_grafana_api_client, organization) assert team.users.count() == 1 assert team.users.get() == users[0] @pytest.mark.django_db -@patch("apps.user_management.sync.org_sync_signal") -def test_sync_organization(mocked_org_sync_signal, make_organization): +def test_sync_organization(make_organization): organization = make_organization() with patched_grafana_api_client(organization): @@ -259,8 +267,6 @@ def test_sync_organization(mocked_org_sync_signal, make_organization): # check that is_grafana_labels_enabled flag is set assert organization.is_grafana_labels_enabled is True - mocked_org_sync_signal.send.assert_called_once_with(sender=None, organization=organization) - @pytest.mark.parametrize("is_rbac_enabled_for_organization", [False, True]) @override_settings(LICENSE=settings.OPEN_SOURCE_LICENSE_NAME) @@ -322,15 +328,26 @@ def test_duplicate_user_ids(make_organization, make_user_for_organization): organization = make_organization() user = make_user_for_organization(organization, user_id=1) - api_users = [] - - User.objects.sync_for_organization(organization, api_users=api_users) + api_users = [ + { + "userId": 2, + "email": "other@test.test", + "name": "Other", + "login": "other", + "role": "admin", + "avatarUrl": "other.test/test", + "permissions": [], + } + ] + with patched_grafana_api_client(organization) as mock_grafana_api_client: + mock_grafana_api_client.get_users.return_value = api_users + sync_users(mock_grafana_api_client, organization) user.refresh_from_db() assert user.is_active is None - assert organization.users.count() == 0 - assert User.objects.filter_with_deleted().count() == 1 + assert organization.users.count() == 1 + assert User.objects.filter_with_deleted(organization=organization).count() == 2 api_users = [ { @@ -344,11 +361,13 @@ def test_duplicate_user_ids(make_organization, make_user_for_organization): } ] - User.objects.sync_for_organization(organization, api_users=api_users) + with patched_grafana_api_client(organization) as mock_grafana_api_client: + mock_grafana_api_client.get_users.return_value = api_users + sync_users(mock_grafana_api_client, organization) assert organization.users.count() == 1 assert organization.users.get().email == "newtest@test.test" - assert User.objects.filter_with_deleted().count() == 2 + assert User.objects.filter_with_deleted(organization=organization).count() == 3 @pytest.mark.django_db @@ -399,7 +418,9 @@ def test_sync_organization_lock( mock_task_lock.assert_called_once_with(lock_cache_key, "random") if task_lock_acquired: - mock_grafana_api_client.assert_called_once() + # 2 calls: get client to fetch organization data, + # and then another one to check token in the refactored sync function + assert mock_grafana_api_client.call_count == 2 else: # task lock could not be acquired mock_grafana_api_client.assert_not_called() @@ -422,18 +443,14 @@ class TestSyncGrafanaLabelsPluginParams: TestSyncGrafanaLabelsPluginParams(({"enabled": False}, None), False), ], ) -@pytest.mark.django_db def test_sync_grafana_labels_plugin(make_organization, test_params: TestSyncGrafanaLabelsPluginParams): organization = make_organization() organization.is_grafana_labels_enabled = False # by default in tests it's true, so setting to false - with patch.object( - GrafanaAPIClient, - "get_grafana_labels_plugin_settings", - return_value=test_params.response, - ): - grafana_api_client = GrafanaAPIClient(api_url=organization.grafana_url, api_token=organization.api_token) - _sync_grafana_labels_plugin(organization, grafana_api_client) + with patched_grafana_api_client(organization) as mock_grafana_api_client: + mock_grafana_api_client.return_value.get_grafana_labels_plugin_settings.return_value = test_params.response + sync_organization(organization) + organization.refresh_from_db() assert organization.is_grafana_labels_enabled is test_params.expected_result @@ -461,15 +478,52 @@ class TestSyncGrafanaIncidentParams: TestSyncGrafanaIncidentParams(({"enabled": False}, None), False, None), # plugin is disabled for some reason ], ) -@pytest.mark.django_db def test_sync_grafana_incident_plugin(make_organization, test_params: TestSyncGrafanaIncidentParams): organization = make_organization() - with patch.object( - GrafanaAPIClient, - "get_grafana_incident_plugin_settings", - return_value=test_params.response, - ): - grafana_api_client = GrafanaAPIClient(api_url=organization.grafana_url, api_token=organization.api_token) - _sync_grafana_incident_plugin(organization, grafana_api_client) + with patched_grafana_api_client(organization) as mock_grafana_api_client: + mock_grafana_api_client.return_value.get_grafana_incident_plugin_settings.return_value = test_params.response + sync_organization(organization) + organization.refresh_from_db() assert organization.is_grafana_incident_enabled is test_params.expected_flag - assert organization.grafana_incident_backend_url is test_params.expected_url + assert organization.grafana_incident_backend_url == test_params.expected_url + + +@pytest.mark.django_db +def test_get_or_create_user(make_organization, make_team, make_user_for_organization): + organization = make_organization() + team = make_team(organization) + # add an existing_user + existing_user = make_user_for_organization(organization) + team.users.add(existing_user) + + assert organization.users.count() == 1 + assert team.users.count() == 1 + + sync_user = SyncUser( + id=42, + email="test@test.com", + name="Test", + login="test", + avatar_url="https://test.com/test", + role="admin", + permissions=[], + teams=None, + ) + + # create user + user = get_or_create_user(organization, sync_user) + + assert user.user_id == sync_user.id + assert user.name == sync_user.name + assert user.email == sync_user.email + assert user.avatar_full_url == sync_user.avatar_url + assert organization.users.count() == 2 + assert team.users.count() == 1 + + # update user + sync_user.teams = [team.team_id] + user = get_or_create_user(organization, sync_user) + + assert organization.users.count() == 2 + assert team.users.count() == 2 + assert team.users.filter(pk=user.pk).exists() diff --git a/engine/common/api_helpers/errors.py b/engine/common/api_helpers/errors.py new file mode 100644 index 0000000000..55a27fbfa0 --- /dev/null +++ b/engine/common/api_helpers/errors.py @@ -0,0 +1,20 @@ +from dataclasses import dataclass +from typing import Dict, List, Optional + + +@dataclass +class OnCallError: + code: int + message: str + fields: Optional[Dict[str, List[str]]] = None + + +SELF_HOSTED_ONLY_FEATURE_ERROR = OnCallError( + code=1001, message="This feature is not available in Cloud versions of OnCall" +) + +INVALID_SELF_HOSTED_ID = OnCallError(code=1001, message="Invalid stack or org id for self-hosted organization") + +CLOUD_ONLY_FEATURE_ERROR = OnCallError(code=1002, message="This feature is not available in OSS versions of OnCall") + +INSTALL_ERROR = OnCallError(code=1003, message="Install failed check /plugin/status for details") diff --git a/grafana-plugin/Magefile.go b/grafana-plugin/Magefile.go new file mode 100644 index 0000000000..75240cf364 --- /dev/null +++ b/grafana-plugin/Magefile.go @@ -0,0 +1,12 @@ +//go:build mage +// +build mage + +package main + +import ( + // mage:import + build "github.com/grafana/grafana-plugin-sdk-go/build" +) + +// Default configures the default target. +var Default = build.BuildAll \ No newline at end of file diff --git a/grafana-plugin/e2e-tests/alerts/directPaging.test.ts b/grafana-plugin/e2e-tests/alerts/directPaging.test.ts index 7086cdd121..75f86e7db1 100644 --- a/grafana-plugin/e2e-tests/alerts/directPaging.test.ts +++ b/grafana-plugin/e2e-tests/alerts/directPaging.test.ts @@ -1,3 +1,5 @@ +import semver from 'semver'; + import { test, expect } from '../fixtures'; import { clickButton, fillInInput } from '../utils/forms'; import { goToOnCallPage } from '../utils/navigation'; @@ -20,8 +22,10 @@ test('we can directly page a user', async ({ adminRolePage }) => { const addRespondersPopup = page.getByTestId('add-responders-popup'); - await addRespondersPopup.getByText('Users').click(); - await addRespondersPopup.getByText(adminRolePage.userName).click(); + await addRespondersPopup[semver.lt(process.env.CURRENT_GRAFANA_VERSION, '10.3.0') ? 'getByText' : 'getByLabel']( + 'Users' + ).click(); + await addRespondersPopup.getByText(adminRolePage.userName).first().click(); // If user is not on call, confirm invitation await page.waitForTimeout(1000); diff --git a/grafana-plugin/e2e-tests/globalSetup.ts b/grafana-plugin/e2e-tests/globalSetup.ts index 0bba7dc618..e7cfa34ac6 100644 --- a/grafana-plugin/e2e-tests/globalSetup.ts +++ b/grafana-plugin/e2e-tests/globalSetup.ts @@ -1,14 +1,12 @@ import { test as setup, chromium, - expect, - type Page, type BrowserContext, type FullConfig, type APIRequestContext, + Page, } from '@playwright/test'; - -import { getOnCallApiUrl } from 'utils/consts'; +import semver from 'semver'; import { VIEWER_USER_STORAGE_STATE, EDITOR_USER_STORAGE_STATE, ADMIN_USER_STORAGE_STATE } from '../playwright.config'; @@ -22,16 +20,9 @@ import { GRAFANA_VIEWER_USERNAME, IS_CLOUD, IS_OPEN_SOURCE, + OrgRole, } from './utils/constants'; -import { clickButton, getInputByName } from './utils/forms'; -import { goToGrafanaPage } from './utils/navigation'; - -enum OrgRole { - None = 'None', - Viewer = 'Viewer', - Editor = 'Editor', - Admin = 'Admin', -} +import { goToOnCallPage } from './utils/navigation'; type UserCreationSettings = { adminAuthedRequest: APIRequestContext; @@ -64,45 +55,35 @@ const generateLoginStorageStateAndOptionallCreateUser = async ( return browserContext; }; -/** - go to config page and wait for plugin icon to be available on left-hand navigation - */ -const configureOnCallPlugin = async (page: Page): Promise => { - /** - * go to the oncall plugin configuration page and wait for the page to be loaded - */ - await goToGrafanaPage(page, '/plugins/grafana-oncall-app'); - await page.waitForTimeout(3000); - - // if plugin is configured, go to OnCall - const isConfigured = (await page.getByText('Connected to OnCall').count()) >= 1; - if (isConfigured) { - await page.getByRole('link', { name: 'Open Grafana OnCall' }).click(); - return; - } - - // otherwise we may need to reconfigure the plugin - const needToReconfigure = (await page.getByText('try removing your plugin configuration').count()) >= 1; - if (needToReconfigure) { - await clickButton({ page, buttonText: 'Remove current configuration' }); - await clickButton({ page, buttonText: /^Remove$/ }); - } - await page.waitForTimeout(2000); - - const needToEnterOnCallApiUrl = await page.getByText(/Connected to OnCall/).isHidden(); - if (needToEnterOnCallApiUrl) { - await getInputByName(page, 'onCallApiUrl').fill(getOnCallApiUrl() || 'http://oncall-dev-engine:8080'); - await clickButton({ page, buttonText: 'Connect' }); +const idempotentlyInitializePlugin = async (page: Page) => { + await goToOnCallPage(page, 'alert-groups'); + await page.waitForTimeout(1000); + const openPluginConfigurationButton = page.getByRole('button', { name: 'Open configuration' }); + if (await openPluginConfigurationButton.isVisible()) { + await openPluginConfigurationButton.click(); + // Before 10.3 Admin user needs to create service account manually + if (semver.lt(process.env.CURRENT_GRAFANA_VERSION, '10.3.0')) { + await page.getByTestId('recreate-service-account').click(); + } + await page.getByTestId('connect-plugin').click(); + await page.waitForLoadState('networkidle'); + await page.getByText('Plugin is connected').waitFor(); } +}; +const determineGrafanaVersion = async (adminAuthedRequest: APIRequestContext) => { /** - * wait for the "Connected to OnCall" message to know that everything is properly configured + * determine the current Grafana version of the stack in question and set it such that it can be used in the tests + * to conditionally skip certain tests. * - * Regarding increasing the timeout for the "plugin configured" assertion: - * This is because it can sometimes take a bit longer for the backend sync to finish. The default assertion - * timeout is 5s, which is sometimes not enough if the backend is under load + * According to the Playwright docs, the best way to set config like this on the fly, is to set values + * on process.env https://playwright.dev/docs/test-global-setup-teardown#example + * + * TODO: when this bug is fixed in playwright https://github.com/microsoft/playwright/issues/29608 + * move this to the currentGrafanaVersion fixture */ - await expect(page.getByTestId('status-message-block')).toHaveText(/Connected to OnCall.*/, { timeout: 25_000 }); + const currentGrafanaVersion = await grafanaApiClient.getGrafanaVersion(adminAuthedRequest); + process.env.CURRENT_GRAFANA_VERSION = currentGrafanaVersion; }; /** @@ -123,6 +104,10 @@ setup('Configure Grafana OnCall plugin', async ({ request }, { config }) => { const adminPage = await adminBrowserContext.newPage(); const { request: adminAuthedRequest } = adminBrowserContext; + await determineGrafanaVersion(adminAuthedRequest); + + await idempotentlyInitializePlugin(adminPage); + await generateLoginStorageStateAndOptionallCreateUser( config, GRAFANA_EDITOR_USERNAME, @@ -147,23 +132,5 @@ setup('Configure Grafana OnCall plugin', async ({ request }, { config }) => { true ); - if (IS_OPEN_SOURCE) { - // plugin configuration can safely be skipped for cloud environments - await configureOnCallPlugin(adminPage); - } - - /** - * determine the current Grafana version of the stack in question and set it such that it can be used in the tests - * to conditionally skip certain tests. - * - * According to the Playwright docs, the best way to set config like this on the fly, is to set values - * on process.env https://playwright.dev/docs/test-global-setup-teardown#example - * - * TODO: when this bug is fixed in playwright https://github.com/microsoft/playwright/issues/29608 - * move this to the currentGrafanaVersion fixture - */ - const currentGrafanaVersion = await grafanaApiClient.getGrafanaVersion(adminAuthedRequest); - process.env.CURRENT_GRAFANA_VERSION = currentGrafanaVersion; - await adminBrowserContext.close(); }); diff --git a/grafana-plugin/e2e-tests/integrations/integrationsTable.test.ts b/grafana-plugin/e2e-tests/integrations/integrationsTable.test.ts index 45b1f7b773..dfd4db7528 100644 --- a/grafana-plugin/e2e-tests/integrations/integrationsTable.test.ts +++ b/grafana-plugin/e2e-tests/integrations/integrationsTable.test.ts @@ -1,10 +1,12 @@ import { test } from '../fixtures'; import { generateRandomValue } from '../utils/forms'; import { createIntegration, searchIntegrationAndAssertItsPresence } from '../utils/integrations'; +import { goToOnCallPage } from '../utils/navigation'; test('Integrations table shows data in Monitoring Systems and Direct Paging tabs', async ({ adminRolePage: { page }, }) => { + test.slow(); const ID = generateRandomValue(); const WEBHOOK_INTEGRATION_NAME = `Webhook-${ID}`; const ALERTMANAGER_INTEGRATION_NAME = `Alertmanager-${ID}`; @@ -13,14 +15,14 @@ test('Integrations table shows data in Monitoring Systems and Direct Paging tabs // Create 2 integrations that are not Direct Paging await createIntegration({ page, integrationSearchText: 'Webhook', integrationName: WEBHOOK_INTEGRATION_NAME }); await page.waitForTimeout(1000); - await page.getByRole('tab', { name: 'Tab Integrations' }).click(); + await goToOnCallPage(page, 'integrations'); await createIntegration({ page, integrationSearchText: 'Alertmanager', integrationName: ALERTMANAGER_INTEGRATION_NAME, }); await page.waitForTimeout(1000); - await page.getByRole('tab', { name: 'Tab Integrations' }).click(); + await goToOnCallPage(page, 'integrations'); // Create 1 Direct Paging integration if it doesn't exist await page.getByRole('tab', { name: 'Tab Manual Direct Paging' }).click(); @@ -35,7 +37,7 @@ test('Integrations table shows data in Monitoring Systems and Direct Paging tabs }); await page.waitForTimeout(1000); } - await page.getByRole('tab', { name: 'Tab Integrations' }).click(); + await goToOnCallPage(page, 'integrations'); // By default Monitoring Systems tab is opened and newly created integrations are visible except Direct Paging one await searchIntegrationAndAssertItsPresence({ page, integrationName: WEBHOOK_INTEGRATION_NAME }); diff --git a/grafana-plugin/e2e-tests/pluginInitialization/configuration.test.ts b/grafana-plugin/e2e-tests/pluginInitialization/configuration.test.ts new file mode 100644 index 0000000000..4552f982e0 --- /dev/null +++ b/grafana-plugin/e2e-tests/pluginInitialization/configuration.test.ts @@ -0,0 +1,33 @@ +import { PLUGIN_CONFIG } from 'utils/consts'; + +import { test, expect } from '../fixtures'; +import { goToGrafanaPage } from '../utils/navigation'; + +test.describe('Plugin configuration', () => { + test('Admin user can see currently applied URL', async ({ adminRolePage: { page } }) => { + await goToGrafanaPage(page, PLUGIN_CONFIG); + const currentlyAppliedURL = await page.getByTestId('oncall-api-url-input').inputValue(); + + expect(currentlyAppliedURL).toBe('http://oncall-dev-engine:8080'); + }); + + test('Admin user can see error when invalid OnCall API URL is entered and plugin is reconnected', async ({ + adminRolePage: { page }, + }) => { + await goToGrafanaPage(page, PLUGIN_CONFIG); + const correctURLAppliedByDefault = await page.getByTestId('oncall-api-url-input').inputValue(); + + // show client-side validation errors + const urlInput = page.getByTestId('oncall-api-url-input'); + await urlInput.fill(''); + await page.getByText('URL is required').waitFor(); + await urlInput.fill('invalid-url-format:8080'); + await page.getByText('URL is invalid').waitFor(); + + // apply back correct url and verify plugin connected again + await urlInput.fill(correctURLAppliedByDefault); + await page.getByTestId('connect-plugin').click(); + await page.waitForLoadState('networkidle'); + await page.getByText('Plugin is connected').waitFor(); + }); +}); diff --git a/grafana-plugin/e2e-tests/pluginInitialization/initialization.test.ts b/grafana-plugin/e2e-tests/pluginInitialization/initialization.test.ts new file mode 100644 index 0000000000..0befb993ad --- /dev/null +++ b/grafana-plugin/e2e-tests/pluginInitialization/initialization.test.ts @@ -0,0 +1,74 @@ +import semver from 'semver'; + +import { test, expect } from '../fixtures'; +import { OrgRole } from '../utils/constants'; +import { goToGrafanaPage, goToOnCallPage } from '../utils/navigation'; +import { createGrafanaUser, loginAndWaitTillGrafanaIsLoaded } from '../utils/users'; + +test.describe('Plugin initialization', () => { + test('Plugin OnCall pages work for new viewer user right away', async ({ adminRolePage: { page }, browser }) => { + // Create new viewer user and login as new user + const USER_NAME = `viewer-${new Date().getTime()}`; + await createGrafanaUser({ page, username: USER_NAME, role: OrgRole.Viewer }); + + // Create new browser context to act as new user + const viewerUserContext = await browser.newContext(); + const viewerUserPage = await viewerUserContext.newPage(); + + await loginAndWaitTillGrafanaIsLoaded({ page: viewerUserPage, username: USER_NAME }); + + // Start watching for HTTP responses + const networkResponseStatuses: number[] = []; + viewerUserPage.on('requestfinished', async (request) => + networkResponseStatuses.push((await request.response()).status()) + ); + + // Go to OnCall and assert that none of the requests failed + await goToOnCallPage(viewerUserPage, 'alert-groups'); + await viewerUserPage.waitForLoadState('networkidle'); + const numberOfFailedRequests = networkResponseStatuses.filter( + (status) => !(`${status}`.startsWith('2') || `${status}`.startsWith('3')) + ).length; + expect(numberOfFailedRequests).toBeLessThanOrEqual(1); // we allow /status request to fail once so plugin is reinstalled + }); + + test('Extension registered by OnCall plugin works for new editor user right away', async ({ + adminRolePage: { page }, + browser, + }) => { + test.skip( + semver.lt(process.env.CURRENT_GRAFANA_VERSION, '10.3.0'), + 'Extension is only available in Grafana 10.3.0 and above' + ); + + // Create new editor user + const USER_NAME = `editor-${new Date().getTime()}`; + await createGrafanaUser({ page, username: USER_NAME, role: OrgRole.Editor }); + await page.waitForLoadState('networkidle'); + + // Create new browser context to act as new user + const editorUserContext = await browser.newContext(); + const editorUserPage = await editorUserContext.newPage(); + + await loginAndWaitTillGrafanaIsLoaded({ page: editorUserPage, username: USER_NAME }); + + // Start watching for HTTP responses + const networkResponseStatuses: number[] = []; + editorUserPage.on('requestfinished', async (request) => + networkResponseStatuses.push((await request.response()).status()) + ); + + // Go to profile -> IRM tab where OnCall plugin extension is registered and assert that none of the requests failed + await goToGrafanaPage(editorUserPage, '/profile?tab=irm'); + await editorUserPage.waitForLoadState('networkidle'); + const numberOfFailedRequests = networkResponseStatuses.filter( + (status) => !(`${status}`.startsWith('2') || `${status}`.startsWith('3')) + ).length; + expect(numberOfFailedRequests).toBeLessThanOrEqual(1); // we allow /status request to fail once so plugin is reinstalled + + // ...as well as that user sees content of the extension + const extensionContentText = editorUserPage.getByText('Please connect Grafana Cloud OnCall to use the mobile app'); + await extensionContentText.waitFor(); + await expect(extensionContentText).toBeVisible(); + }); +}); diff --git a/grafana-plugin/e2e-tests/users/usersActions.test.ts b/grafana-plugin/e2e-tests/users/usersActions.test.ts index f106abd950..91cf9f09b3 100644 --- a/grafana-plugin/e2e-tests/users/usersActions.test.ts +++ b/grafana-plugin/e2e-tests/users/usersActions.test.ts @@ -1,29 +1,40 @@ +import semver from 'semver'; + import { test, expect } from '../fixtures'; import { goToOnCallPage } from '../utils/navigation'; -import { viewUsers, accessProfileTabs } from '../utils/users'; +import { verifyThatUserCanViewOtherUsers, accessProfileTabs } from '../utils/users'; test.describe('Users screen actions', () => { test("Admin is allowed to edit other users' profile", async ({ adminRolePage: { page } }) => { await goToOnCallPage(page, 'users'); - await expect(page.getByTestId('users-table').getByRole('button', { name: 'Edit', disabled: false })).toHaveCount(3); + const editableUsers = page.getByTestId('users-table').getByRole('button', { name: 'Edit', disabled: false }); + await editableUsers.first().waitFor(); + const editableUsersCount = await editableUsers.count(); + expect(editableUsersCount).toBeGreaterThan(1); }); test('Admin is allowed to view the list of users', async ({ adminRolePage: { page } }) => { - await viewUsers(page); + await verifyThatUserCanViewOtherUsers(page); }); test('Viewer is not allowed to view the list of users', async ({ viewerRolePage: { page } }) => { - await viewUsers(page, false); + await verifyThatUserCanViewOtherUsers(page, false); }); test('Viewer cannot access restricted tabs from View My Profile', async ({ viewerRolePage }) => { const { page } = viewerRolePage; + const tabsToCheck = ['tab-phone-verification', 'tab-slack', 'tab-telegram']; + + // After 10.3 it's been moved to global user profile + if (semver.lt(process.env.CURRENT_GRAFANA_VERSION, '10.3.0')) { + tabsToCheck.unshift('tab-mobile-app'); + } - await accessProfileTabs(page, ['tab-mobile-app', 'tab-phone-verification', 'tab-slack', 'tab-telegram'], false); + await accessProfileTabs(page, tabsToCheck, false); }); test('Editor is allowed to view the list of users', async ({ editorRolePage }) => { - await viewUsers(editorRolePage.page); + await verifyThatUserCanViewOtherUsers(editorRolePage.page); }); test("Editor cannot view other users' data", async ({ editorRolePage }) => { @@ -33,8 +44,10 @@ test.describe('Users screen actions', () => { await page.getByTestId('users-email').and(page.getByText('editor')).waitFor(); await expect(page.getByTestId('users-email').and(page.getByText('editor'))).toHaveCount(1); - await expect(page.getByTestId('users-email').and(page.getByText('******'))).toHaveCount(2); - await expect(page.getByTestId('users-phone-number').and(page.getByText('******'))).toHaveCount(2); + const maskedEmailsCount = await page.getByTestId('users-email').and(page.getByText('******')).count(); + expect(maskedEmailsCount).toBeGreaterThan(1); + const maskedPhoneNumbersCount = await page.getByTestId('users-phone-number').and(page.getByText('******')).count(); + expect(maskedPhoneNumbersCount).toBeGreaterThan(1); }); test('Editor can access tabs from View My Profile', async ({ editorRolePage }) => { @@ -47,7 +60,11 @@ test.describe('Users screen actions', () => { test("Editor is not allowed to edit other users' profile", async ({ editorRolePage: { page } }) => { await goToOnCallPage(page, 'users'); await expect(page.getByTestId('users-table').getByRole('button', { name: 'Edit', disabled: false })).toHaveCount(1); - await expect(page.getByTestId('users-table').getByRole('button', { name: 'Edit', disabled: true })).toHaveCount(2); + const usersCountWithDisabledEdit = await page + .getByTestId('users-table') + .getByRole('button', { name: 'Edit', disabled: true }) + .count(); + expect(usersCountWithDisabledEdit).toBeGreaterThan(1); }); test('Search updates the table view', async ({ adminRolePage }) => { diff --git a/grafana-plugin/e2e-tests/utils/constants.ts b/grafana-plugin/e2e-tests/utils/constants.ts index 5345d1c4d7..9cbee844b9 100644 --- a/grafana-plugin/e2e-tests/utils/constants.ts +++ b/grafana-plugin/e2e-tests/utils/constants.ts @@ -11,4 +11,11 @@ export const GRAFANA_ADMIN_PASSWORD = process.env.GRAFANA_ADMIN_PASSWORD || 'onc export const IS_OPEN_SOURCE = (process.env.IS_OPEN_SOURCE || 'true').toLowerCase() === 'true'; export const IS_CLOUD = !IS_OPEN_SOURCE; +export enum OrgRole { + None = 'None', + Viewer = 'Viewer', + Editor = 'Editor', + Admin = 'Admin', +} + export const MOSCOW_TIMEZONE = 'Europe/Moscow'; diff --git a/grafana-plugin/e2e-tests/utils/forms.ts b/grafana-plugin/e2e-tests/utils/forms.ts index cc53d62b65..6ddefa2d01 100644 --- a/grafana-plugin/e2e-tests/utils/forms.ts +++ b/grafana-plugin/e2e-tests/utils/forms.ts @@ -25,6 +25,7 @@ type ClickButtonArgs = { buttonText: string | RegExp; // if provided, use this Locator as the root of our search for the button startingLocator?: Locator; + exact?: boolean; }; export const fillInInput = (page: Page, selector: string, value: string) => page.fill(selector, value); @@ -34,9 +35,9 @@ export const fillInInputByPlaceholderValue = (page: Page, placeholderValue: stri export const getInputByName = (page: Page, name: string): Locator => page.locator(`input[name="${name}"]`); -export const clickButton = async ({ page, buttonText, startingLocator }: ClickButtonArgs): Promise => { +export const clickButton = async ({ page, buttonText, startingLocator, exact }: ClickButtonArgs): Promise => { const baseLocator = startingLocator || page; - await baseLocator.getByRole('button', { name: buttonText, disabled: false }).click(); + await baseLocator.getByRole('button', { name: buttonText, disabled: false, exact }).click(); }; /** diff --git a/grafana-plugin/e2e-tests/utils/users.ts b/grafana-plugin/e2e-tests/utils/users.ts index e7c5d15dff..03972f8f5c 100644 --- a/grafana-plugin/e2e-tests/utils/users.ts +++ b/grafana-plugin/e2e-tests/utils/users.ts @@ -1,6 +1,8 @@ import { Page, expect } from '@playwright/test'; -import { goToOnCallPage } from './navigation'; +import { OrgRole } from './constants'; +import { clickButton } from './forms'; +import { goToGrafanaPage, goToOnCallPage } from './navigation'; export async function accessProfileTabs(page: Page, tabs: string[], hasAccess: boolean) { await goToOnCallPage(page, 'users'); @@ -30,16 +32,55 @@ export async function accessProfileTabs(page: Page, tabs: string[], hasAccess: b } } -export async function viewUsers(page: Page, isAllowedToView = true): Promise { +export async function verifyThatUserCanViewOtherUsers(page: Page, isAllowedToView = true): Promise { await goToOnCallPage(page, 'users'); if (isAllowedToView) { const usersTable = page.getByTestId('users-table'); await usersTable.getByRole('row').nth(1).waitFor(); - await expect(usersTable.getByRole('row')).toHaveCount(4); + const usersCount = await page.getByTestId('users-table').getByRole('row').count(); + expect(usersCount).toBeGreaterThan(1); } else { await expect(page.getByTestId('view-users-missing-permission-message')).toHaveText( /You are missing the .* to be able to view OnCall users/ ); } } + +export const createGrafanaUser = async ({ + page, + username, + role = OrgRole.Viewer, +}: { + page: Page; + username: string; + role?: OrgRole; +}): Promise => { + await goToGrafanaPage(page, '/admin/users'); + await page.getByRole('link', { name: 'New user' }).click(); + await page.getByLabel('Name *').fill(username); + await page.getByLabel('Username').fill(username); + await page.getByLabel('Password *').fill(username); + await clickButton({ page, buttonText: 'Create user' }); + + if (role !== OrgRole.Viewer) { + await clickButton({ page, buttonText: 'Change role' }); + await page + .locator('div') + .filter({ hasText: /^Viewer$/ }) + .nth(1) + .click(); + await page.getByText(new RegExp(role)).click(); + await clickButton({ page, buttonText: 'Save' }); + } +}; + +export const loginAndWaitTillGrafanaIsLoaded = async ({ page, username }: { page: Page; username: string }) => { + await goToGrafanaPage(page, '/login'); + await page.getByPlaceholder(/Email or username/i).fill(username); + await page.getByPlaceholder(/Password/i).fill(username); + await page.locator('button[type="submit"]').click(); + + await page.getByText('Welcome to Grafana').waitFor(); + await page.waitForLoadState('networkidle'); +}; diff --git a/grafana-plugin/go.mod b/grafana-plugin/go.mod new file mode 100644 index 0000000000..c094111fec --- /dev/null +++ b/grafana-plugin/go.mod @@ -0,0 +1,89 @@ +module github.com/grafana-labs/grafana-oncall-app + +go 1.21 + +require github.com/grafana/grafana-plugin-sdk-go v0.228.0 + +require ( + github.com/BurntSushi/toml v1.3.2 // indirect + github.com/apache/arrow/go/v15 v15.0.2 // indirect + github.com/beorn7/perks v1.0.1 // indirect + github.com/cenkalti/backoff/v4 v4.3.0 // indirect + github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/cheekybits/genny v1.0.0 // indirect + github.com/chromedp/cdproto v0.0.0-20220208224320-6efb837e6bc2 // indirect + github.com/cpuguy83/go-md2man/v2 v2.0.4 // indirect + github.com/elazarl/goproxy v0.0.0-20230731152917-f99041a5c027 // indirect + github.com/fatih/color v1.15.0 // indirect + github.com/getkin/kin-openapi v0.124.0 // indirect + github.com/go-logr/logr v1.4.1 // indirect + github.com/go-logr/stdr v1.2.2 // indirect + github.com/go-openapi/jsonpointer v0.20.2 // indirect + github.com/go-openapi/swag v0.22.8 // indirect + github.com/goccy/go-json v0.10.2 // indirect + github.com/gogo/protobuf v1.3.2 // indirect + github.com/golang/protobuf v1.5.4 // indirect + github.com/google/flatbuffers v23.5.26+incompatible // indirect + github.com/google/go-cmp v0.6.0 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/gorilla/mux v1.8.1 // indirect + github.com/grpc-ecosystem/go-grpc-middleware/providers/prometheus v1.0.1 // indirect + github.com/grpc-ecosystem/go-grpc-middleware/v2 v2.1.0 // indirect + github.com/grpc-ecosystem/grpc-gateway/v2 v2.19.1 // indirect + github.com/hashicorp/go-hclog v1.6.3 // indirect + github.com/hashicorp/go-plugin v1.6.0 // indirect + github.com/hashicorp/yamux v0.1.1 // indirect + github.com/invopop/yaml v0.2.0 // indirect + github.com/josharian/intern v1.0.0 // indirect + github.com/json-iterator/go v1.1.12 // indirect + github.com/klauspost/compress v1.16.7 // indirect + github.com/klauspost/cpuid/v2 v2.2.5 // indirect + github.com/magefile/mage v1.15.0 // indirect + github.com/mailru/easyjson v0.7.7 // indirect + github.com/mattetti/filebuffer v1.0.1 // indirect + github.com/mattn/go-colorable v0.1.13 // indirect + github.com/mattn/go-isatty v0.0.19 // indirect + github.com/mattn/go-runewidth v0.0.9 // indirect + github.com/mitchellh/go-testing-interface v1.14.1 // indirect + github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect + github.com/modern-go/reflect2 v1.0.2 // indirect + github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826 // indirect + github.com/oklog/run v1.1.0 // indirect + github.com/olekukonko/tablewriter v0.0.5 // indirect + github.com/perimeterx/marshmallow v1.1.5 // indirect + github.com/pierrec/lz4/v4 v4.1.18 // indirect + github.com/prometheus/client_golang v1.19.0 // indirect + github.com/prometheus/client_model v0.6.1 // indirect + github.com/prometheus/common v0.53.0 // indirect + github.com/prometheus/procfs v0.14.0 // indirect + github.com/russross/blackfriday/v2 v2.1.0 // indirect + github.com/unknwon/bra v0.0.0-20200517080246-1e3013ecaff8 // indirect + github.com/unknwon/com v1.0.1 // indirect + github.com/unknwon/log v0.0.0-20150304194804-e617c87089d3 // indirect + github.com/urfave/cli v1.22.15 // indirect + github.com/zeebo/xxh3 v1.0.2 // indirect + go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.51.0 // indirect + go.opentelemetry.io/contrib/instrumentation/net/http/httptrace/otelhttptrace v0.51.0 // indirect + go.opentelemetry.io/contrib/propagators/jaeger v1.26.0 // indirect + go.opentelemetry.io/contrib/samplers/jaegerremote v0.20.0 // indirect + go.opentelemetry.io/otel v1.26.0 // indirect + go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.26.0 // indirect + go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.26.0 // indirect + go.opentelemetry.io/otel/metric v1.26.0 // indirect + go.opentelemetry.io/otel/sdk v1.26.0 // indirect + go.opentelemetry.io/otel/trace v1.26.0 // indirect + go.opentelemetry.io/proto/otlp v1.2.0 // indirect + golang.org/x/exp v0.0.0-20231006140011-7918f672742d // indirect + golang.org/x/mod v0.13.0 // indirect + golang.org/x/net v0.24.0 // indirect + golang.org/x/sys v0.19.0 // indirect + golang.org/x/text v0.14.0 // indirect + golang.org/x/tools v0.14.0 // indirect + golang.org/x/xerrors v0.0.0-20220907171357-04be3eba64a2 // indirect + google.golang.org/genproto/googleapis/api v0.0.0-20240415180920-8c6c420018be // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20240415180920-8c6c420018be // indirect + google.golang.org/grpc v1.63.2 // indirect + google.golang.org/protobuf v1.33.0 // indirect + gopkg.in/fsnotify/fsnotify.v1 v1.4.7 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) \ No newline at end of file diff --git a/grafana-plugin/go.sum b/grafana-plugin/go.sum new file mode 100644 index 0000000000..7d161db594 --- /dev/null +++ b/grafana-plugin/go.sum @@ -0,0 +1,282 @@ +github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= +github.com/BurntSushi/toml v1.3.2 h1:o7IhLm0Msx3BaB+n3Ag7L8EVlByGnpq14C4YWiu/gL8= +github.com/BurntSushi/toml v1.3.2/go.mod h1:CxXYINrC8qIiEnFrOxCa7Jy5BFHlXnUU2pbicEuybxQ= +github.com/apache/arrow/go/v15 v15.0.2 h1:60IliRbiyTWCWjERBCkO1W4Qun9svcYoZrSLcyOsMLE= +github.com/apache/arrow/go/v15 v15.0.2/go.mod h1:DGXsR3ajT524njufqf95822i+KTh+yea1jass9YXgjA= +github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= +github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= +github.com/bufbuild/protocompile v0.4.0 h1:LbFKd2XowZvQ/kajzguUp2DC9UEIQhIq77fZZlaQsNA= +github.com/bufbuild/protocompile v0.4.0/go.mod h1:3v93+mbWn/v3xzN+31nwkJfrEpAUwp+BagBSZWx+TP8= +github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8= +github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE= +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/cheekybits/genny v1.0.0 h1:uGGa4nei+j20rOSeDeP5Of12XVm7TGUd4dJA9RDitfE= +github.com/cheekybits/genny v1.0.0/go.mod h1:+tQajlRqAUrPI7DOSpB0XAqZYtQakVtB7wXkRAgjxjQ= +github.com/chromedp/cdproto v0.0.0-20220208224320-6efb837e6bc2 h1:XCdvHbz3LhewBHN7+mQPx0sg/Hxil/1USnBmxkjHcmY= +github.com/chromedp/cdproto v0.0.0-20220208224320-6efb837e6bc2/go.mod h1:At5TxYYdxkbQL0TSefRjhLE3Q0lgvqKKMSFUglJ7i1U= +github.com/chromedp/sysutil v1.0.0/go.mod h1:kgWmDdq8fTzXYcKIBqIYvRRTnYb9aNS9moAV0xufSww= +github.com/cpuguy83/go-md2man/v2 v2.0.0-20190314233015-f79a8a8ca69d/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU= +github.com/cpuguy83/go-md2man/v2 v2.0.4 h1:wfIWP927BUkWJb2NmU/kNDYIBTh/ziUX91+lVfRxZq4= +github.com/cpuguy83/go-md2man/v2 v2.0.4/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/elazarl/goproxy v0.0.0-20230731152917-f99041a5c027 h1:1L0aalTpPz7YlMxETKpmQoWMBkeiuorElZIXoNmgiPE= +github.com/elazarl/goproxy v0.0.0-20230731152917-f99041a5c027/go.mod h1:Ro8st/ElPeALwNFlcTpWmkr6IoMFfkjXAvTHpevnDsM= +github.com/elazarl/goproxy/ext v0.0.0-20190711103511-473e67f1d7d2/go.mod h1:gNh8nYJoAm43RfaxurUnxr+N1PwuFV3ZMl/efxlIlY8= +github.com/elazarl/goproxy/ext v0.0.0-20220115173737-adb46da277ac h1:9yrT5tmn9Zc0ytWPASlaPwQfQMQYnRf0RSDe1XvHw0Q= +github.com/elazarl/goproxy/ext v0.0.0-20220115173737-adb46da277ac/go.mod h1:gNh8nYJoAm43RfaxurUnxr+N1PwuFV3ZMl/efxlIlY8= +github.com/fatih/color v1.13.0/go.mod h1:kLAiJbzzSOZDVNGyDpeOxJ47H46qBXwg5ILebYFFOfk= +github.com/fatih/color v1.15.0 h1:kOqh6YHBtK8aywxGerMG2Eq3H6Qgoqeo13Bk2Mv/nBs= +github.com/fatih/color v1.15.0/go.mod h1:0h5ZqXfHYED7Bhv2ZJamyIOUej9KtShiJESRwBDUSsw= +github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= +github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= +github.com/fsnotify/fsnotify v1.4.7 h1:IXs+QLmnXW2CcXuY+8Mzv/fWEsPGWxqefPtCP5CnV9I= +github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo= +github.com/getkin/kin-openapi v0.124.0 h1:VSFNMB9C9rTKBnQ/fpyDU8ytMTr4dWI9QovSKj9kz/M= +github.com/getkin/kin-openapi v0.124.0/go.mod h1:wb1aSZA/iWmorQP9KTAS/phLj/t17B5jT7+fS8ed9NM= +github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= +github.com/go-logr/logr v1.4.1 h1:pKouT5E8xu9zeFC39JXRDukb6JFQPXM5p5I91188VAQ= +github.com/go-logr/logr v1.4.1/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= +github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= +github.com/go-openapi/jsonpointer v0.20.2 h1:mQc3nmndL8ZBzStEo3JYF8wzmeWffDH4VbXz58sAx6Q= +github.com/go-openapi/jsonpointer v0.20.2/go.mod h1:bHen+N0u1KEO3YlmqOjTT9Adn1RfD91Ar825/PuiRVs= +github.com/go-openapi/swag v0.22.8 h1:/9RjDSQ0vbFR+NyjGMkFTsA1IA0fmhKSThmfGZjicbw= +github.com/go-openapi/swag v0.22.8/go.mod h1:6QT22icPLEqAM/z/TChgb4WAveCHF92+2gF0CNjHpPI= +github.com/go-test/deep v1.0.8 h1:TDsG77qcSprGbC6vTN8OuXp5g+J+b5Pcguhf7Zt61VM= +github.com/go-test/deep v1.0.8/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE= +github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU= +github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I= +github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= +github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= +github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= +github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= +github.com/google/flatbuffers v23.5.26+incompatible h1:M9dgRyhJemaM4Sw8+66GHBu8ioaQmyPLg1b8VwK5WJg= +github.com/google/flatbuffers v23.5.26+incompatible/go.mod h1:1AeVuKshWv4vARoZatz6mlQ0JxURH0Kv5+zNeJKJCa8= +github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= +github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/gopherjs/gopherjs v0.0.0-20181103185306-d547d1d9531e h1:JKmoR8x90Iww1ks85zJ1lfDGgIiMDuIptTOhJq+zKyg= +github.com/gopherjs/gopherjs v0.0.0-20181103185306-d547d1d9531e/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY= +github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY= +github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ= +github.com/grafana/grafana-plugin-sdk-go v0.228.0 h1:LlPqyB+RZTtDy8RVYD7iQVJW5A0gMoGSI/+Ykz8HebQ= +github.com/grafana/grafana-plugin-sdk-go v0.228.0/go.mod h1:u4K9vVN6eU86loO68977eTXGypC4brUCnk4sfDzutZU= +github.com/grpc-ecosystem/go-grpc-middleware/providers/prometheus v1.0.1 h1:qnpSQwGEnkcRpTqNOIR6bJbR0gAorgP9CSALpRcKoAA= +github.com/grpc-ecosystem/go-grpc-middleware/providers/prometheus v1.0.1/go.mod h1:lXGCsh6c22WGtjr+qGHj1otzZpV/1kwTMAqkwZsnWRU= +github.com/grpc-ecosystem/go-grpc-middleware/v2 v2.1.0 h1:pRhl55Yx1eC7BZ1N+BBWwnKaMyD8uC+34TLdndZMAKk= +github.com/grpc-ecosystem/go-grpc-middleware/v2 v2.1.0/go.mod h1:XKMd7iuf/RGPSMJ/U4HP0zS2Z9Fh8Ps9a+6X26m/tmI= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.19.1 h1:/c3QmbOGMGTOumP2iT/rCwB7b0QDGLKzqOmktBjT+Is= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.19.1/go.mod h1:5SN9VR2LTsRFsrEC6FHgRbTWrTHu6tqPeKxEQv15giM= +github.com/hashicorp/go-hclog v1.6.3 h1:Qr2kF+eVWjTiYmU7Y31tYlP1h0q/X3Nl3tPGdaB11/k= +github.com/hashicorp/go-hclog v1.6.3/go.mod h1:W4Qnvbt70Wk/zYJryRzDRU/4r0kIg0PVHBcfoyhpF5M= +github.com/hashicorp/go-plugin v1.6.0 h1:wgd4KxHJTVGGqWBq4QPB1i5BZNEx9BR8+OFmHDmTk8A= +github.com/hashicorp/go-plugin v1.6.0/go.mod h1:lBS5MtSSBZk0SHc66KACcjjlU6WzEVP/8pwz68aMkCI= +github.com/hashicorp/yamux v0.1.1 h1:yrQxtgseBDrq9Y652vSRDvsKCJKOUD+GzTS4Y0Y8pvE= +github.com/hashicorp/yamux v0.1.1/go.mod h1:CtWFDAQgb7dxtzFs4tWbplKIe2jSi3+5vKbgIO0SLnQ= +github.com/invopop/yaml v0.2.0 h1:7zky/qH+O0DwAyoobXUqvVBwgBFRxKoQ/3FjcVpjTMY= +github.com/invopop/yaml v0.2.0/go.mod h1:2XuRLgs/ouIrW3XNzuNj7J3Nvu/Dig5MXvbCEdiBN3Q= +github.com/jhump/protoreflect v1.15.1 h1:HUMERORf3I3ZdX05WaQ6MIpd/NJ434hTp5YiKgfCL6c= +github.com/jhump/protoreflect v1.15.1/go.mod h1:jD/2GMKKE6OqX8qTjhADU1e6DShO+gavG9e0Q693nKo= +github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY= +github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= +github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= +github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= +github.com/jtolds/gls v4.2.1+incompatible h1:fSuqC+Gmlu6l/ZYAoZzx2pyucC8Xza35fpRVWLVmUEE= +github.com/jtolds/gls v4.2.1+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU= +github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8= +github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= +github.com/klauspost/compress v1.16.7 h1:2mk3MPGNzKyxErAw8YaohYh69+pa4sIQSC0fPGCFR9I= +github.com/klauspost/compress v1.16.7/go.mod h1:ntbaceVETuRiXiv4DpjP66DpAtAGkEQskQzEyD//IeE= +github.com/klauspost/cpuid/v2 v2.2.5 h1:0E5MSMDEoAulmXNFquVs//DdoomxaoTY1kUhbc/qbZg= +github.com/klauspost/cpuid/v2 v2.2.5/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/magefile/mage v1.15.0 h1:BvGheCMAsG3bWUDbZ8AyXXpCNwU9u5CB6sM+HNb9HYg= +github.com/magefile/mage v1.15.0/go.mod h1:z5UZb/iS3GoOSn0JgWuiw7dxlurVYTu+/jHXqQg881A= +github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0= +github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= +github.com/mattetti/filebuffer v1.0.1 h1:gG7pyfnSIZCxdoKq+cPa8T0hhYtD9NxCdI4D7PTjRLM= +github.com/mattetti/filebuffer v1.0.1/go.mod h1:YdMURNDOttIiruleeVr6f56OrMc+MydEnTcXwtkxNVs= +github.com/mattn/go-colorable v0.1.9/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc= +github.com/mattn/go-colorable v0.1.12/go.mod h1:u5H1YNBxpqRaxsYJYSkiCWKzEfiAb1Gb520KVy5xxl4= +github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= +github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= +github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU= +github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94= +github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= +github.com/mattn/go-isatty v0.0.19 h1:JITubQf0MOLdlGRuRq+jtsDlekdYPia9ZFsB8h/APPA= +github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-runewidth v0.0.9 h1:Lm995f3rfxdpd6TSmuVCHVb/QhupuXlYr8sCI/QdE+0= +github.com/mattn/go-runewidth v0.0.9/go.mod h1:H031xJmbD/WCDINGzjvQ9THkh0rPKHF+m2gUSrubnMI= +github.com/mitchellh/go-testing-interface v1.14.1 h1:jrgshOhYAUVNMAJiKbEu7EqAwgJJ2JqpQmpLJOu07cU= +github.com/mitchellh/go-testing-interface v1.14.1/go.mod h1:gfgS7OtZj6MA4U1UrDRp04twqAjfvlZyCfX3sDjEym8= +github.com/mitchellh/reflectwalk v1.0.2 h1:G2LzWKi524PWgd3mLHV8Y5k7s6XUvT0Gef6zxSIeXaQ= +github.com/mitchellh/reflectwalk v1.0.2/go.mod h1:mSTlrgnPZtwu0c4WaC2kGObEpuNDbx0jmZXqmk4esnw= +github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= +github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= +github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826 h1:RWengNIwukTxcDr9M+97sNutRR1RKhG96O6jWumTTnw= +github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826/go.mod h1:TaXosZuwdSHYgviHp1DAtfrULt5eUgsSMsZf+YrPgl8= +github.com/oklog/run v1.1.0 h1:GEenZ1cK0+q0+wsJew9qUg/DyD8k3JzYsZAi5gYi2mA= +github.com/oklog/run v1.1.0/go.mod h1:sVPdnTZT1zYwAJeCMu2Th4T21pA3FPOQRfWjQlk7DVU= +github.com/olekukonko/tablewriter v0.0.5 h1:P2Ga83D34wi1o9J6Wh1mRuqd4mF/x/lgBS7N7AbDhec= +github.com/olekukonko/tablewriter v0.0.5/go.mod h1:hPp6KlRPjbx+hW8ykQs1w3UBbZlj6HuIJcUGPhkA7kY= +github.com/perimeterx/marshmallow v1.1.5 h1:a2LALqQ1BlHM8PZblsDdidgv1mWi1DgC2UmX50IvK2s= +github.com/perimeterx/marshmallow v1.1.5/go.mod h1:dsXbUu8CRzfYP5a87xpp0xq9S3u0Vchtcl8we9tYaXw= +github.com/pierrec/lz4/v4 v4.1.18 h1:xaKrnTkyoqfh1YItXl56+6KJNVYWlEEPuAQW9xsplYQ= +github.com/pierrec/lz4/v4 v4.1.18/go.mod h1:gZWDp/Ze/IJXGXf23ltt2EXimqmTUXEy0GFuRQyBid4= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/prometheus/client_golang v1.19.0 h1:ygXvpU1AoN1MhdzckN+PyD9QJOSD4x7kmXYlnfbA6JU= +github.com/prometheus/client_golang v1.19.0/go.mod h1:ZRM9uEAypZakd+q/x7+gmsvXdURP+DABIEIjnmDdp+k= +github.com/prometheus/client_model v0.6.1 h1:ZKSh/rekM+n3CeS952MLRAdFwIKqeY8b62p8ais2e9E= +github.com/prometheus/client_model v0.6.1/go.mod h1:OrxVMOVHjw3lKMa8+x6HeMGkHMQyHDk9E3jmP2AmGiY= +github.com/prometheus/common v0.53.0 h1:U2pL9w9nmJwJDa4qqLQ3ZaePJ6ZTwt7cMD3AG3+aLCE= +github.com/prometheus/common v0.53.0/go.mod h1:BrxBKv3FWBIGXw89Mg1AeBq7FSyRzXWI3l3e7W3RN5U= +github.com/prometheus/procfs v0.14.0 h1:Lw4VdGGoKEZilJsayHf0B+9YgLGREba2C6xr+Fdfq6s= +github.com/prometheus/procfs v0.14.0/go.mod h1:XL+Iwz8k8ZabyZfMFHPiilCniixqQarAy5Mu67pHlNQ= +github.com/rogpeppe/go-charset v0.0.0-20180617210344-2471d30d28b4/go.mod h1:qgYeAmZ5ZIpBWTGllZSQnw97Dj+woV0toclVaRGI8pc= +github.com/rogpeppe/go-internal v1.12.0 h1:exVL4IDcn6na9z1rAb56Vxr+CgyK3nn3O+epU5NdKM8= +github.com/rogpeppe/go-internal v1.12.0/go.mod h1:E+RYuTGaKKdloAfM02xzb0FW3Paa99yedzYV+kq4uf4= +github.com/russross/blackfriday/v2 v2.0.1/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk= +github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/shurcooL/sanitized_anchor_name v1.0.0/go.mod h1:1NzhyTcUVG4SuEtjjoZeVRXNmyL/1OwPU0+IJeTBvfc= +github.com/smartystreets/assertions v0.0.0-20190116191733-b6c0e53d7304 h1:Jpy1PXuP99tXNrhbq2BaPz9B+jNAvH1JPQQpG/9GCXY= +github.com/smartystreets/assertions v0.0.0-20190116191733-b6c0e53d7304/go.mod h1:OnSkiWE9lh6wB0YB77sQom3nweQdgAjqCqsofrRNTgc= +github.com/smartystreets/goconvey v0.0.0-20181108003508-044398e4856c h1:Ho+uVpkel/udgjbwB5Lktg9BtvJSh2DT0Hi6LPSyI2w= +github.com/smartystreets/goconvey v0.0.0-20181108003508-044398e4856c/go.mod h1:XDJAKZRPZ1CvBcN2aX5YOUTYGHki24fSF0Iv48Ibg0s= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.7.2/go.mod h1:R6va5+xMeoiuVRoj+gSkQ7d3FALtqAAGI1FQKckRals= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= +github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= +github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/ugorji/go/codec v1.2.7 h1:YPXUKf7fYbp/y8xloBqZOw2qaVggbfwMlI8WM3wZUJ0= +github.com/ugorji/go/codec v1.2.7/go.mod h1:WGN1fab3R1fzQlVQTkfxVtIBhWDRqOviHU95kRgeqEY= +github.com/unknwon/bra v0.0.0-20200517080246-1e3013ecaff8 h1:aVGB3YnaS/JNfOW3tiHIlmNmTDg618va+eT0mVomgyI= +github.com/unknwon/bra v0.0.0-20200517080246-1e3013ecaff8/go.mod h1:fVle4kNr08ydeohzYafr20oZzbAkhQT39gKK/pFQ5M4= +github.com/unknwon/com v1.0.1 h1:3d1LTxD+Lnf3soQiD4Cp/0BRB+Rsa/+RTvz8GMMzIXs= +github.com/unknwon/com v1.0.1/go.mod h1:tOOxU81rwgoCLoOVVPHb6T/wt8HZygqH5id+GNnlCXM= +github.com/unknwon/log v0.0.0-20150304194804-e617c87089d3 h1:4EYQaWAatQokdji3zqZloVIW/Ke1RQjYw2zHULyrHJg= +github.com/unknwon/log v0.0.0-20150304194804-e617c87089d3/go.mod h1:1xEUf2abjfP92w2GZTV+GgaRxXErwRXcClbUwrNJffU= +github.com/urfave/cli v1.22.1/go.mod h1:Gos4lmkARVdJ6EkW0WaNv/tZAAMe9V7XWyB60NtXRu0= +github.com/urfave/cli v1.22.15 h1:nuqt+pdC/KqswQKhETJjo7pvn/k4xMUxgW6liI7XpnM= +github.com/urfave/cli v1.22.15/go.mod h1:wSan1hmo5zeyLGBjRJbzRTNk8gwoYa2B9n4q9dmRIc0= +github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +github.com/zeebo/assert v1.3.0 h1:g7C04CbJuIDKNPFHmsk4hwZDO5O+kntRxzaUoNXj+IQ= +github.com/zeebo/assert v1.3.0/go.mod h1:Pq9JiuJQpG8JLJdtkwrJESF0Foym2/D9XMU5ciN/wJ0= +github.com/zeebo/xxh3 v1.0.2 h1:xZmwmqxHZA8AI603jOQ0tMqmBr9lPeFwGg6d+xy9DC0= +github.com/zeebo/xxh3 v1.0.2/go.mod h1:5NWz9Sef7zIDm2JHfFlcQvNekmcEl9ekUZQQKCYaDcA= +go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.51.0 h1:A3SayB3rNyt+1S6qpI9mHPkeHTZbD7XILEqWnYZb2l0= +go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.51.0/go.mod h1:27iA5uvhuRNmalO+iEUdVn5ZMj2qy10Mm+XRIpRmyuU= +go.opentelemetry.io/contrib/instrumentation/net/http/httptrace/otelhttptrace v0.51.0 h1:974XTyIwHI4nHa1+uSLxHtUnlJ2DiVtAJjk7fd07p/8= +go.opentelemetry.io/contrib/instrumentation/net/http/httptrace/otelhttptrace v0.51.0/go.mod h1:ZvX/taFlN6TGaOOM6D42wrNwPKUV1nGO2FuUXkityBU= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.51.0 h1:Xs2Ncz0gNihqu9iosIZ5SkBbWo5T8JhhLJFMQL1qmLI= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.51.0/go.mod h1:vy+2G/6NvVMpwGX/NyLqcC41fxepnuKHk16E6IZUcJc= +go.opentelemetry.io/contrib/propagators/jaeger v1.26.0 h1:RH76Cl2pfOLLoCtxAPax9c7oYzuL1tiI7/ZPJEmEmOw= +go.opentelemetry.io/contrib/propagators/jaeger v1.26.0/go.mod h1:W/cylm0ZtJK1uxsuTqoYGYPnqpZ8CeVGgW7TwfXPsGw= +go.opentelemetry.io/contrib/samplers/jaegerremote v0.20.0 h1:ja+d7Aea/9PgGxB63+E0jtRFpma717wubS0KFkZpmYw= +go.opentelemetry.io/contrib/samplers/jaegerremote v0.20.0/go.mod h1:Yc1eg51SJy7xZdOTyg1xyFcwE+ghcWh3/0hKeLo6Wlo= +go.opentelemetry.io/otel v1.26.0 h1:LQwgL5s/1W7YiiRwxf03QGnWLb2HW4pLiAhaA5cZXBs= +go.opentelemetry.io/otel v1.26.0/go.mod h1:UmLkJHUAidDval2EICqBMbnAd0/m2vmpf/dAM+fvFs4= +go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.26.0 h1:1u/AyyOqAWzy+SkPxDpahCNZParHV8Vid1RnI2clyDE= +go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.26.0/go.mod h1:z46paqbJ9l7c9fIPCXTqTGwhQZ5XoTIsfeFYWboizjs= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.26.0 h1:Waw9Wfpo/IXzOI8bCB7DIk+0JZcqqsyn1JFnAc+iam8= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.26.0/go.mod h1:wnJIG4fOqyynOnnQF/eQb4/16VlX2EJAHhHgqIqWfAo= +go.opentelemetry.io/otel/metric v1.26.0 h1:7S39CLuY5Jgg9CrnA9HHiEjGMF/X2VHvoXGgSllRz30= +go.opentelemetry.io/otel/metric v1.26.0/go.mod h1:SY+rHOI4cEawI9a7N1A4nIg/nTQXe1ccCNWYOJUrpX4= +go.opentelemetry.io/otel/sdk v1.26.0 h1:Y7bumHf5tAiDlRYFmGqetNcLaVUZmh4iYfmGxtmz7F8= +go.opentelemetry.io/otel/sdk v1.26.0/go.mod h1:0p8MXpqLeJ0pzcszQQN4F0S5FVjBLgypeGSngLsmirs= +go.opentelemetry.io/otel/trace v1.26.0 h1:1ieeAUb4y0TE26jUFrCIXKpTuVK7uJGN9/Z/2LP5sQA= +go.opentelemetry.io/otel/trace v1.26.0/go.mod h1:4iDxvGDQuUkHve82hJJ8UqrwswHYsZuWCBllGV2U2y0= +go.opentelemetry.io/proto/otlp v1.2.0 h1:pVeZGk7nXDC9O2hncA6nHldxEjm6LByfA2aN8IOkz94= +go.opentelemetry.io/proto/otlp v1.2.0/go.mod h1:gGpR8txAl5M03pDhMC79G6SdqNV26naRm/KDsgaHD8A= +go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= +go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= +golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI= +golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo= +golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/mod v0.13.0 h1:I/DsJXRlw/8l/0c24sM9yb0T4z9liZTduXvdAWYiysY= +golang.org/x/mod v0.13.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= +golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= +golang.org/x/net v0.24.0 h1:1PcaxkF854Fu3+lvBIx5SYn9wRlBzzcnHZSiaFFAb0w= +golang.org/x/net v0.24.0/go.mod h1:2Q7sJY5mzlzWjKtYUEXSlBWCdyaioyXzRB2RtU8KVE8= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.7.0 h1:YsImfSBoP9QPYL0xyKJPq0gcaJdG3rInoqxTWbfQu9M= +golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20191020152052-9984515f0562/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20210927094055-39ccf1dd6fa6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220503163025-988cb79eb6c6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.19.0 h1:q5f1RH2jigJ1MoAWp2KTp3gm5zAGFUTarQZ5U386+4o= +golang.org/x/sys v0.19.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ= +golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= +golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= +golang.org/x/tools v0.14.0 h1:jvNa2pY0M4r62jkRQ6RwEZZyPcymeL9XZMLBbV7U2nc= +golang.org/x/tools v0.14.0/go.mod h1:uYBEerGOWcJyEORxN+Ek8+TT266gXkNlHdJBwexUsBg= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20220907171357-04be3eba64a2 h1:H2TDz8ibqkAF6YGhCdN3jS9O0/s90v0rJh3X/OLHEUk= +golang.org/x/xerrors v0.0.0-20220907171357-04be3eba64a2/go.mod h1:K8+ghG5WaK9qNqU5K3HdILfMLy1f3aNYFI/wnl100a8= +gonum.org/v1/gonum v0.12.0 h1:xKuo6hzt+gMav00meVPUlXwSdoEJP46BR+wdxQEFK2o= +gonum.org/v1/gonum v0.12.0/go.mod h1:73TDxJfAAHeA8Mk9mf8NlIppyhQNo5GLTcYeqgo2lvY= +google.golang.org/genproto/googleapis/api v0.0.0-20240415180920-8c6c420018be h1:Zz7rLWqp0ApfsR/l7+zSHhY3PMiH2xqgxlfYfAfNpoU= +google.golang.org/genproto/googleapis/api v0.0.0-20240415180920-8c6c420018be/go.mod h1:dvdCTIoAGbkWbcIKBniID56/7XHTt6WfxXNMxuziJ+w= +google.golang.org/genproto/googleapis/rpc v0.0.0-20240415180920-8c6c420018be h1:LG9vZxsWGOmUKieR8wPAUR3u3MpnYFQZROPIMaXh7/A= +google.golang.org/genproto/googleapis/rpc v0.0.0-20240415180920-8c6c420018be/go.mod h1:WtryC6hu0hhx87FDGxWCDptyssuo68sk10vYjF+T9fY= +google.golang.org/grpc v1.63.2 h1:MUeiw1B2maTVZthpU5xvASfTh3LDbxHd6IJ6QQVU+xM= +google.golang.org/grpc v1.63.2/go.mod h1:WAX/8DgncnokcFUldAxq7GeB5DXHDbMF+lLvDomNkRA= +google.golang.org/protobuf v1.33.0 h1:uNO2rsAINq/JlFpSdYEKIZ0uKD/R9cpdv0T+yoGwGmI= +google.golang.org/protobuf v1.33.0/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/fsnotify/fsnotify.v1 v1.4.7 h1:XNNYLJHt73EyYiCZi6+xjupS9CpvmiDgjPTAjrBlQbo= +gopkg.in/fsnotify/fsnotify.v1 v1.4.7/go.mod h1:Fyux9zXlo4rWoMSIzpn9fDAYjalPqJ/K1qJ27s+7ltE= +gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.0/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= \ No newline at end of file diff --git a/grafana-plugin/pkg/main.go b/grafana-plugin/pkg/main.go new file mode 100644 index 0000000000..2d956202d4 --- /dev/null +++ b/grafana-plugin/pkg/main.go @@ -0,0 +1,23 @@ +package main + +import ( + "os" + + "github.com/grafana-labs/grafana-oncall-app/pkg/plugin" + "github.com/grafana/grafana-plugin-sdk-go/backend/app" + "github.com/grafana/grafana-plugin-sdk-go/backend/log" +) + +func main() { + // Start listening to requests sent from Grafana. This call is blocking so + // it won't finish until Grafana shuts down the process or the plugin choose + // to exit by itself using os.Exit. Manage automatically manages life cycle + // of app instances. It accepts app instance factory as first + // argument. This factory will be automatically called on incoming request + // from Grafana to create different instances of `App` (per plugin + // ID). + if err := app.Manage("grafana-oncall-app", plugin.NewApp, app.ManageOpts{}); err != nil { + log.DefaultLogger.Error(err.Error()) + os.Exit(1) + } +} diff --git a/grafana-plugin/pkg/plugin/app.go b/grafana-plugin/pkg/plugin/app.go new file mode 100644 index 0000000000..1e799c8f47 --- /dev/null +++ b/grafana-plugin/pkg/plugin/app.go @@ -0,0 +1,107 @@ +package plugin + +import ( + "context" + "fmt" + "net/http" + "net/url" + "time" + + "github.com/grafana/grafana-plugin-sdk-go/backend" + "github.com/grafana/grafana-plugin-sdk-go/backend/httpclient" + "github.com/grafana/grafana-plugin-sdk-go/backend/instancemgmt" + "github.com/grafana/grafana-plugin-sdk-go/backend/log" + "github.com/grafana/grafana-plugin-sdk-go/backend/resource/httpadapter" +) + +// Make sure App implements required interfaces. This is important to do +// since otherwise we will only get a not implemented error response from plugin in +// runtime. Plugin should not implement all these interfaces - only those which are +// required for a particular task. +var ( + _ backend.CallResourceHandler = (*App)(nil) + _ instancemgmt.InstanceDisposer = (*App)(nil) + _ backend.CheckHealthHandler = (*App)(nil) +) + +// App is an example app backend plugin which can respond to data queries. +type App struct { + backend.CallResourceHandler + httpClient *http.Client +} + +// NewApp creates a new example *App instance. +func NewApp(ctx context.Context, settings backend.AppInstanceSettings) (instancemgmt.Instance, error) { + var app App + + // Use a httpadapter (provided by the SDK) for resource calls. This allows us + // to use a *http.ServeMux for resource calls, so we can map multiple routes + // to CallResource without having to implement extra logic. + mux := http.NewServeMux() + app.registerRoutes(mux) + app.CallResourceHandler = httpadapter.New(mux) + + opts, err := settings.HTTPClientOptions(ctx) + if err != nil { + return nil, fmt.Errorf("http client options: %w", err) + } + + cl, err := httpclient.New(opts) + if err != nil { + return nil, fmt.Errorf("httpclient new: %w", err) + } + app.httpClient = cl + + return &app, nil +} + +// Dispose here tells plugin SDK that plugin wants to clean up resources when a new instance +// created. +func (a *App) Dispose() { + // cleanup +} + +// CheckHealth handles health checks sent from Grafana to the plugin. +func (a *App) CheckHealth(_ context.Context, _ *backend.CheckHealthRequest) (*backend.CheckHealthResult, error) { + return &backend.CheckHealthResult{ + Status: backend.HealthStatusOk, + Message: "ok", + }, nil +} + +// Check OnCallApi health +func (a *App) CheckOnCallApiHealthStatus(onCallPluginSettings *OnCallPluginSettings) (int, error) { + healthURL, err := url.JoinPath(onCallPluginSettings.OnCallAPIURL, "/health/") + if err != nil { + log.DefaultLogger.Error("Error joining path: %v", err) + return http.StatusInternalServerError, err + } + + parsedHealthURL, err := url.Parse(healthURL) + if err != nil { + log.DefaultLogger.Error("Error parsing path: %v", err) + return http.StatusInternalServerError, err + } + + healthReq, err := http.NewRequest("GET", parsedHealthURL.String(), nil) + if err != nil { + log.DefaultLogger.Error("Error creating request: ", err) + return http.StatusBadRequest, err + } + + client := &http.Client{ + Timeout: 500 * time.Millisecond, + } + healthRes, err := client.Do(healthReq) + if err != nil { + log.DefaultLogger.Error("Error request to oncall: ", err) + return http.StatusBadRequest, err + } + + if healthRes.StatusCode != http.StatusOK { + log.DefaultLogger.Error("Error request to oncall: ", healthRes.Status) + return healthRes.StatusCode, fmt.Errorf(healthRes.Status) + } + + return http.StatusOK, nil +} diff --git a/grafana-plugin/pkg/plugin/debug.go b/grafana-plugin/pkg/plugin/debug.go new file mode 100644 index 0000000000..c32a9cb73b --- /dev/null +++ b/grafana-plugin/pkg/plugin/debug.go @@ -0,0 +1,66 @@ +package plugin + +import ( + "encoding/json" + "github.com/grafana/grafana-plugin-sdk-go/backend/log" + "github.com/grafana/grafana-plugin-sdk-go/backend/resource/httpadapter" + "net/http" +) + +func (a *App) handleDebugUser(w http.ResponseWriter, req *http.Request) { + onCallPluginSettings, err := a.OnCallSettingsFromContext(req.Context()) + if err != nil { + log.DefaultLogger.Error("Error getting settings from context: ", err) + return + } + + user := httpadapter.UserFromContext(req.Context()) + onCallUser, err := a.GetUserForHeader(onCallPluginSettings, user) + if err != nil { + log.DefaultLogger.Error("Error getting user: ", err) + return + } + + w.Header().Add("Content-Type", "application/json") + if err := json.NewEncoder(w).Encode(onCallUser); err != nil { + http.Error(w, "Failed to encode response", http.StatusInternalServerError) + return + } + w.WriteHeader(http.StatusOK) +} + +func (a *App) handleDebugSync(w http.ResponseWriter, req *http.Request) { + onCallPluginSettings, err := a.OnCallSettingsFromContext(req.Context()) + if err != nil { + log.DefaultLogger.Error("Error getting settings from context: ", err) + return + } + + onCallSync, err := a.GetSyncData(req.Context(), onCallPluginSettings) + if err != nil { + log.DefaultLogger.Error("Error getting sync data: ", err) + return + } + + w.Header().Add("Content-Type", "application/json") + if err := json.NewEncoder(w).Encode(onCallSync); err != nil { + http.Error(w, "Failed to encode response", http.StatusInternalServerError) + return + } + w.WriteHeader(http.StatusOK) +} + +func (a *App) handleDebugSettings(w http.ResponseWriter, req *http.Request) { + onCallPluginSettings, err := a.OnCallSettingsFromContext(req.Context()) + if err != nil { + log.DefaultLogger.Error("Error getting settings from context: ", err) + return + } + + w.Header().Add("Content-Type", "application/json") + if err := json.NewEncoder(w).Encode(onCallPluginSettings); err != nil { + http.Error(w, "Failed to encode response", http.StatusInternalServerError) + return + } + w.WriteHeader(http.StatusOK) +} diff --git a/grafana-plugin/pkg/plugin/errors.go b/grafana-plugin/pkg/plugin/errors.go new file mode 100644 index 0000000000..72d9d74fe6 --- /dev/null +++ b/grafana-plugin/pkg/plugin/errors.go @@ -0,0 +1,11 @@ +package plugin + +const ( + INSTALL_ERROR_CODE = 1000 +) + +type OnCallError struct { + Code int `json:"code"` + Message string `json:"message"` + Fields map[string][]string `json:"fields,omitempty"` +} diff --git a/grafana-plugin/pkg/plugin/install.go b/grafana-plugin/pkg/plugin/install.go new file mode 100644 index 0000000000..c1eca9ca3c --- /dev/null +++ b/grafana-plugin/pkg/plugin/install.go @@ -0,0 +1,111 @@ +package plugin + +import ( + "bytes" + "encoding/json" + "io" + "net/url" + + "github.com/grafana/grafana-plugin-sdk-go/backend/log" + + "net/http" +) + +type OnCallInstall struct { + OnCallError `json:"onCallError,omitempty"` +} + +// TODO: Lock so that multiple installs do not revoke each others tokens +func (a *App) handleInstall(w http.ResponseWriter, req *http.Request) { + onCallPluginSettings, err := a.OnCallSettingsFromContext(req.Context()) + if err != nil { + log.DefaultLogger.Error("Error getting settings from context: ", err) + return + } + + healthStatus, err := a.CheckOnCallApiHealthStatus(onCallPluginSettings) + if err != nil { + log.DefaultLogger.Error("Error checking on-call API health: ", err) + http.Error(w, err.Error(), healthStatus) + return + } + + onCallSync, err := a.GetSyncData(req.Context(), onCallPluginSettings) + if err != nil { + log.DefaultLogger.Error("Error getting sync data: ", err) + return + } + + onCallSyncJsonData, err := json.Marshal(onCallSync) + if err != nil { + log.DefaultLogger.Error("Error marshalling JSON: ", err) + return + } + + installURL, err := url.JoinPath(onCallPluginSettings.OnCallAPIURL, "api/internal/v1/plugin/v2/install") + if err != nil { + log.DefaultLogger.Error("Error joining path: %v", err) + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + parsedInstallURL, err := url.Parse(installURL) + if err != nil { + log.DefaultLogger.Error("Error parsing path: %v", err) + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + installReq, err := http.NewRequest("POST", parsedInstallURL.String(), bytes.NewBuffer(onCallSyncJsonData)) + if err != nil { + log.DefaultLogger.Error("Error creating request: ", err) + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + installReq.Header.Set("Content-Type", "application/json") + + res, err := a.httpClient.Do(installReq) + if err != nil { + log.DefaultLogger.Error("Error request to oncall: ", err) + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + defer res.Body.Close() + + if res.StatusCode != http.StatusOK { + w.Header().Add("Content-Type", "application/json") + installError := OnCallInstall{ + OnCallError: OnCallError{ + Code: INSTALL_ERROR_CODE, + Message: "Install failed check /status for details", + }, + } + if err := json.NewEncoder(w).Encode(installError); err != nil { + http.Error(w, "Failed to encode response", http.StatusInternalServerError) + return + } + w.WriteHeader(http.StatusBadRequest) + } else { + provisionBody, err := io.ReadAll(res.Body) + if err != nil { + log.DefaultLogger.Error("Error reading response body: ", err) + return + } + + var provisioningData OnCallProvisioningJSONData + err = json.Unmarshal(provisionBody, &provisioningData) + if err != nil { + log.DefaultLogger.Error("Error unmarshalling OnCallProvisioningJSONData: ", err) + return + } + + onCallPluginSettings.OnCallToken = provisioningData.OnCallToken + err = a.SaveOnCallSettings(onCallPluginSettings) + if err != nil { + log.DefaultLogger.Error("Error saving settings: ", err) + return + } + w.WriteHeader(http.StatusOK) + } + +} diff --git a/grafana-plugin/pkg/plugin/permissions.go b/grafana-plugin/pkg/plugin/permissions.go new file mode 100644 index 0000000000..dfcc773c43 --- /dev/null +++ b/grafana-plugin/pkg/plugin/permissions.go @@ -0,0 +1,95 @@ +package plugin + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "strings" +) + +type OnCallPermission struct { + Action string `json:"action"` +} + +func (a *App) GetPermissions(settings *OnCallPluginSettings, onCallUser *OnCallUser) ([]OnCallPermission, error) { + reqURL, err := url.JoinPath(settings.GrafanaURL, fmt.Sprintf("api/access-control/users/%d/permissions", onCallUser.ID)) + if err != nil { + return nil, fmt.Errorf("error creating URL: %v", err) + } + + req, err := http.NewRequest("GET", reqURL, nil) + if err != nil { + return nil, fmt.Errorf("error creating creating new request: %v", err) + } + req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", settings.GrafanaToken)) + + res, err := a.httpClient.Do(req) + if err != nil { + return nil, fmt.Errorf("error making request: %v", err) + } + defer res.Body.Close() + + body, err := io.ReadAll(res.Body) + if err != nil { + return nil, fmt.Errorf("error reading response: %v", err) + } + + var permissions []OnCallPermission + err = json.Unmarshal(body, &permissions) + if err != nil { + return nil, fmt.Errorf("failed to parse JSON response: %v", err) + } + + if res.StatusCode == 200 { + var filtered []OnCallPermission + for _, permission := range permissions { + if strings.HasPrefix(permission.Action, "grafana-oncall-app") { + filtered = append(filtered, permission) + } + } + return filtered, nil + } + return nil, fmt.Errorf("no permissions for %s, http status %s", onCallUser.Login, res.Status) +} + +func (a *App) GetAllPermissions(settings *OnCallPluginSettings) (map[string]map[string]interface{}, error) { + reqURL, err := url.Parse(settings.GrafanaURL) + if err != nil { + return nil, fmt.Errorf("error parsing URL: %v", err) + } + + reqURL.Path += "api/access-control/users/permissions/search" + q := reqURL.Query() + q.Set("actionPrefix", "grafana-oncall-app") + reqURL.RawQuery = q.Encode() + + req, err := http.NewRequest("GET", reqURL.String(), nil) + if err != nil { + return nil, fmt.Errorf("error creating creating new request: %v", err) + } + req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", settings.GrafanaToken)) + + res, err := a.httpClient.Do(req) + if err != nil { + return nil, fmt.Errorf("error making request: %v", err) + } + defer res.Body.Close() + + body, err := io.ReadAll(res.Body) + if err != nil { + return nil, fmt.Errorf("error reading response: %v", err) + } + + var permissions map[string]map[string]interface{} + err = json.Unmarshal(body, &permissions) + if err != nil { + return nil, fmt.Errorf("failed to parse JSON response: %v", err) + } + + if res.StatusCode == 200 { + return permissions, nil + } + return nil, fmt.Errorf("no permissions available, http status %s", res.Status) +} diff --git a/grafana-plugin/pkg/plugin/proxy.go b/grafana-plugin/pkg/plugin/proxy.go new file mode 100644 index 0000000000..7d048cbe63 --- /dev/null +++ b/grafana-plugin/pkg/plugin/proxy.go @@ -0,0 +1,194 @@ +package plugin + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "strconv" + + "github.com/grafana/grafana-plugin-sdk-go/backend" + "github.com/grafana/grafana-plugin-sdk-go/backend/log" + "github.com/grafana/grafana-plugin-sdk-go/backend/resource/httpadapter" +) + +type XInstanceContextJSONData struct { + StackId string `json:"stack_id,omitempty"` + OrgId string `json:"org_id,omitempty"` + GrafanaToken string `json:"grafana_token"` +} + +type XGrafanaContextJSONData struct { + ID int `json:"UserID"` + IsAnonymous bool `json:"IsAnonymous"` + Name string `json:"Name"` + Login string `json:"Login"` + Email string `json:"Email"` + Role string `json:"Role"` +} + +type OnCallProvisioningJSONData struct { + Error string `json:"error,omitempty"` + StackId int `json:"stackId,omitempty"` + OrgId int `json:"orgId,omitempty"` + OnCallToken string `json:"onCallToken,omitempty"` + License string `json:"license,omitempty"` +} + +func SetXInstanceContextHeader(settings *OnCallPluginSettings, req *http.Request) error { + xInstanceContext := XInstanceContextJSONData{ + StackId: strconv.Itoa(settings.StackID), + OrgId: strconv.Itoa(settings.OrgID), + GrafanaToken: settings.GrafanaToken, + } + xInstanceContextHeader, err := json.Marshal(xInstanceContext) + if err != nil { + return err + } + req.Header.Set("X-Instance-Context", string(xInstanceContextHeader)) + return nil +} + +func SetXGrafanaContextHeader(user *backend.User, userID int, req *http.Request) error { + var xGrafanaContext XGrafanaContextJSONData + if user == nil { + xGrafanaContext = XGrafanaContextJSONData{ + IsAnonymous: true, + } + } else { + xGrafanaContext = XGrafanaContextJSONData{ + ID: userID, + IsAnonymous: false, + Name: user.Name, + Login: user.Login, + Email: user.Email, + Role: user.Role, + } + } + xGrafanaContextHeader, err := json.Marshal(xGrafanaContext) + if err != nil { + return err + } + req.Header.Set("X-Grafana-Context", string(xGrafanaContextHeader)) + return nil +} + +func SetAuthorizationHeader(settings *OnCallPluginSettings, req *http.Request) { + req.Header.Set("Authorization", settings.OnCallToken) +} + +func SetOnCallUserHeader(onCallUser *OnCallUser, req *http.Request) error { + xOnCallUserHeader, err := json.Marshal(onCallUser) + if err != nil { + return err + } + req.Header.Set("X-OnCall-User-Context", string(xOnCallUserHeader)) + return nil +} + +func (a *App) SetupRequestHeadersForOnCall(ctx context.Context, settings *OnCallPluginSettings, req *http.Request) error { + user := httpadapter.UserFromContext(ctx) + onCallUser, err := a.GetUserForHeader(settings, user) + if err != nil { + log.DefaultLogger.Error("Error getting user: %v", err) + return err + } + + SetAuthorizationHeader(settings, req) + + err = SetXInstanceContextHeader(settings, req) + if err != nil { + log.DefaultLogger.Error("Error setting instance header: %v", err) + return err + } + + err = SetXGrafanaContextHeader(user, onCallUser.ID, req) + if err != nil { + log.DefaultLogger.Error("Error setting context header: %v", err) + return err + } + + err = SetOnCallUserHeader(onCallUser, req) + if err != nil { + log.DefaultLogger.Error("Error setting user header: %v", err) + return err + } + + return nil +} + +func (a *App) ProxyRequestToOnCall(w http.ResponseWriter, req *http.Request, pathPrefix string) { + proxyMethod := req.Method + var bodyReader io.Reader + if req.Body != nil { + proxyBody, err := io.ReadAll(req.Body) + if err != nil { + log.DefaultLogger.Error("Error reading original request: %v", err) + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + if proxyBody != nil { + bodyReader = bytes.NewReader(proxyBody) + } + } + + onCallPluginSettings, err := a.OnCallSettingsFromContext(req.Context()) + if err != nil { + log.DefaultLogger.Error("Error getting plugin settings: %v", err) + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + reqURL, err := url.JoinPath(onCallPluginSettings.OnCallAPIURL, pathPrefix, req.URL.Path) + if err != nil { + log.DefaultLogger.Error("Error joining path: %v", err) + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + parsedReqURL, err := url.Parse(reqURL) + if err != nil { + log.DefaultLogger.Error("Error parsing path: %v", err) + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + parsedReqURL.RawQuery = req.URL.RawQuery + + proxyReq, err := http.NewRequest(proxyMethod, parsedReqURL.String(), bodyReader) + if err != nil { + log.DefaultLogger.Error("Error creating request: %v", err) + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + + proxyReq.Header = req.Header + err = a.SetupRequestHeadersForOnCall(req.Context(), onCallPluginSettings, proxyReq) + if err != nil { + log.DefaultLogger.Error("Error setting up headers: %v", err) + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + + if proxyMethod == "POST" || proxyMethod == "PUT" || proxyMethod == "PATCH" { + proxyReq.Header.Set("Content-Type", "application/json") + } + log.DefaultLogger.Info(fmt.Sprintf("Making request to oncall = %+v", onCallPluginSettings)) + res, err := a.httpClient.Do(proxyReq) + if err != nil { + log.DefaultLogger.Error("Error request to oncall: ", err) + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + defer res.Body.Close() + + for name, values := range res.Header { + for _, value := range values { + w.Header().Add(name, value) + } + } + w.WriteHeader(res.StatusCode) + io.Copy(w, res.Body) +} diff --git a/grafana-plugin/pkg/plugin/resources.go b/grafana-plugin/pkg/plugin/resources.go new file mode 100644 index 0000000000..af2a6af074 --- /dev/null +++ b/grafana-plugin/pkg/plugin/resources.go @@ -0,0 +1,99 @@ +package plugin + +import ( + "bytes" + "encoding/json" + "fmt" + "net/http" + + "github.com/grafana/grafana-plugin-sdk-go/backend/log" +) + +type OnCallSync struct { + Users []OnCallUser `json:"users"` + Teams []OnCallTeam `json:"teams"` + TeamMembers map[int][]int `json:"team_members"` + Settings OnCallPluginSettings `json:"settings"` +} + +type responseWriter struct { + http.ResponseWriter + statusCode int + body bytes.Buffer +} + +func (rw *responseWriter) WriteHeader(statusCode int) { + rw.statusCode = statusCode + rw.ResponseWriter.WriteHeader(statusCode) +} + +func (rw *responseWriter) Write(b []byte) (int, error) { + if rw.statusCode == 0 { + rw.WriteHeader(http.StatusOK) + } + n, err := rw.body.Write(b) + if err != nil { + return n, err + } + return rw.ResponseWriter.Write(b) +} + +func afterRequest(handler http.Handler, afterFunc func(*responseWriter, *http.Request)) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + wrappedWriter := &responseWriter{ResponseWriter: w} + handler.ServeHTTP(wrappedWriter, r) + afterFunc(wrappedWriter, r) + }) +} + +func (a *App) handleInternalApi(w http.ResponseWriter, req *http.Request) { + a.ProxyRequestToOnCall(w, req, "api/internal/v1/") +} + +func (a *App) handleLegacyInstall(w *responseWriter, req *http.Request) { + var provisioningData OnCallProvisioningJSONData + err := json.Unmarshal(w.body.Bytes(), &provisioningData) + if err != nil { + log.DefaultLogger.Error("Error unmarshalling OnCallProvisioningJSONData: ", err) + return + } + + onCallPluginSettings, err := a.OnCallSettingsFromContext(req.Context()) + if err != nil { + log.DefaultLogger.Error("Error getting settings from context: ", err) + return + } + + log.DefaultLogger.Info(fmt.Sprintf("Settings = %+v", onCallPluginSettings)) + log.DefaultLogger.Info(fmt.Sprintf("Provisioning data = %+v", provisioningData)) + + if provisioningData.Error != "" { + log.DefaultLogger.Error(fmt.Sprintf("Error installing OnCall = %s", provisioningData.Error)) + return + } + onCallPluginSettings.License = provisioningData.License + onCallPluginSettings.OrgID = provisioningData.OrgId + onCallPluginSettings.StackID = provisioningData.StackId + onCallPluginSettings.OnCallToken = provisioningData.OnCallToken + + err = a.SaveOnCallSettings(onCallPluginSettings) + if err != nil { + log.DefaultLogger.Error("Error saving settings: ", err) + return + } +} + +// registerRoutes takes a *http.ServeMux and registers some HTTP handlers. +func (a *App) registerRoutes(mux *http.ServeMux) { + mux.HandleFunc("/plugin/install", a.handleInstall) + mux.HandleFunc("/plugin/status", a.handleStatus) + mux.HandleFunc("/plugin/sync", a.handleSync) + + mux.Handle("/plugin/self-hosted/install", afterRequest(http.HandlerFunc(a.handleInternalApi), a.handleLegacyInstall)) + + mux.HandleFunc("/debug/user", a.handleDebugUser) + mux.HandleFunc("/debug/sync", a.handleDebugSync) + mux.HandleFunc("/debug/settings", a.handleDebugSettings) + + mux.HandleFunc("/", a.handleInternalApi) +} diff --git a/grafana-plugin/pkg/plugin/resources_test.go b/grafana-plugin/pkg/plugin/resources_test.go new file mode 100644 index 0000000000..7506326a7e --- /dev/null +++ b/grafana-plugin/pkg/plugin/resources_test.go @@ -0,0 +1,73 @@ +package plugin + +import ( + "bytes" + "context" + "github.com/grafana/grafana-plugin-sdk-go/backend" + "testing" +) + +// mockCallResourceResponseSender implements backend.CallResourceResponseSender +// for use in tests. +type mockCallResourceResponseSender struct { + response *backend.CallResourceResponse +} + +// Send sets the received *backend.CallResourceResponse to s.response +func (s *mockCallResourceResponseSender) Send(response *backend.CallResourceResponse) error { + s.response = response + return nil +} + +// TestCallResource tests CallResource calls, using backend.CallResourceRequest and backend.CallResourceResponse. +// This ensures the httpadapter for CallResource works correctly. +func TestCallResource(t *testing.T) { + // Initialize app + inst, err := NewApp(context.Background(), backend.AppInstanceSettings{}) + if err != nil { + t.Fatalf("new app: %s", err) + } + if inst == nil { + t.Fatal("inst must not be nil") + } + app, ok := inst.(*App) + if !ok { + t.Fatal("inst must be of type *App") + } + + // Set up and run test cases + for _, tc := range []struct { + name string + + method string + path string + body []byte + + expStatus int + expBody []byte + }{} { + t.Run(tc.name, func(t *testing.T) { + // Request by calling CallResource. This tests the httpadapter. + var r mockCallResourceResponseSender + err = app.CallResource(context.Background(), &backend.CallResourceRequest{ + Method: tc.method, + Path: tc.path, + Body: tc.body, + }, &r) + if err != nil { + t.Fatalf("CallResource error: %s", err) + } + if r.response == nil { + t.Fatal("no response received from CallResource") + } + if tc.expStatus > 0 && tc.expStatus != r.response.Status { + t.Errorf("response status should be %d, got %d", tc.expStatus, r.response.Status) + } + if len(tc.expBody) > 0 { + if tb := bytes.TrimSpace(r.response.Body); !bytes.Equal(tb, tc.expBody) { + t.Errorf("response body should be %s, got %s", tc.expBody, tb) + } + } + }) + } +} diff --git a/grafana-plugin/pkg/plugin/settings.go b/grafana-plugin/pkg/plugin/settings.go new file mode 100644 index 0000000000..f09afd9388 --- /dev/null +++ b/grafana-plugin/pkg/plugin/settings.go @@ -0,0 +1,220 @@ +package plugin + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "strings" + + "github.com/grafana/grafana-plugin-sdk-go/backend" + "github.com/grafana/grafana-plugin-sdk-go/backend/log" + "github.com/grafana/grafana-plugin-sdk-go/backend/resource/httpadapter" +) + +type OnCallPluginSettingsJSONData struct { + OnCallAPIURL string `json:"onCallApiUrl"` + StackID int `json:"stackId,omitempty"` + OrgID int `json:"orgId,omitempty"` + License string `json:"license"` + GrafanaURL string `json:"grafanaUrl"` +} + +type OnCallPluginSettingsSecureJSONData struct { + OnCallToken string `json:"onCallApiToken"` + GrafanaToken string `json:"grafanaToken,omitempty"` +} + +type OnCallPluginJSONData struct { + JSONData OnCallPluginSettingsJSONData `json:"jsonData"` + SecureJSONData OnCallPluginSettingsSecureJSONData `json:"secureJsonData"` + Enabled bool `json:"enabled"` + Pinned bool `json:"pinned"` +} + +type OnCallPluginSettings struct { + OnCallAPIURL string `json:"oncall_api_url"` + OnCallToken string `json:"oncall_token"` + StackID int `json:"stack_id"` + OrgID int `json:"org_id"` + License string `json:"license"` + GrafanaURL string `json:"grafana_url"` + GrafanaToken string `json:"grafana_token"` + RBACEnabled bool `json:"rbac_enabled"` + IncidentEnabled bool `json:"incident_enabled"` + IncidentBackendURL string `json:"incident_backend_url"` + LabelsEnabled bool `json:"labels_enabled"` + ExternalServiceAccountEnabled bool `json:"external_service_account_enabled"` +} + +func (a *App) OnCallSettingsFromContext(ctx context.Context) (*OnCallPluginSettings, error) { + pluginContext := httpadapter.PluginConfigFromContext(ctx) + var pluginSettingsJson OnCallPluginSettingsJSONData + err := json.Unmarshal(pluginContext.AppInstanceSettings.JSONData, &pluginSettingsJson) + if err != nil { + err = fmt.Errorf("OnCallSettingsFromContext: json.Unmarshal: %w", err) + log.DefaultLogger.Error(err.Error()) + return nil, err + } + + settings := OnCallPluginSettings{ + StackID: pluginSettingsJson.StackID, + OrgID: pluginSettingsJson.OrgID, + OnCallAPIURL: pluginSettingsJson.OnCallAPIURL, + License: pluginSettingsJson.License, + GrafanaURL: pluginSettingsJson.GrafanaURL, + } + + settings.OnCallToken = strings.TrimSpace(pluginContext.AppInstanceSettings.DecryptedSecureJSONData["onCallApiToken"]) + cfg := backend.GrafanaConfigFromContext(ctx) + if settings.GrafanaURL == "" { + return &settings, fmt.Errorf("get GrafanaURL from provisioning failed (not set in jsonData): %v", settings) + } + log.DefaultLogger.Info(fmt.Sprintf("Using Grafana URL from provisioning: %s", settings.GrafanaURL)) + + settings.RBACEnabled = cfg.FeatureToggles().IsEnabled("accessControlOnCall") + if cfg.FeatureToggles().IsEnabled("externalServiceAccounts") { + settings.GrafanaToken, err = cfg.PluginAppClientSecret() + if err != nil { + return &settings, err + } + settings.ExternalServiceAccountEnabled = true + } else { + settings.GrafanaToken = strings.TrimSpace(pluginContext.AppInstanceSettings.DecryptedSecureJSONData["grafanaToken"]) + settings.ExternalServiceAccountEnabled = false + } + + var jsonData map[string]interface{} + settings.IncidentEnabled, jsonData, err = a.GetOtherPluginSettings(&settings, "grafana-incident-app") + if err != nil { + return &settings, err + } + if jsonData != nil { + if value, ok := jsonData["backendUrl"].(string); ok { + settings.IncidentBackendURL = value + } + } + settings.LabelsEnabled, _, err = a.GetOtherPluginSettings(&settings, "grafana-labels-app") + if err != nil { + return &settings, err + } + + return &settings, nil +} + +func (a *App) SaveOnCallSettings(settings *OnCallPluginSettings) error { + data := OnCallPluginJSONData{ + JSONData: OnCallPluginSettingsJSONData{ + OnCallAPIURL: settings.OnCallAPIURL, + StackID: settings.StackID, + OrgID: settings.OrgID, + License: settings.License, + GrafanaURL: settings.GrafanaURL, + }, + SecureJSONData: OnCallPluginSettingsSecureJSONData{ + OnCallToken: settings.OnCallToken, + GrafanaToken: settings.GrafanaToken, + }, + Enabled: true, + Pinned: true, + } + body, err := json.Marshal(data) + if err != nil { + return fmt.Errorf("Marshal OnCall settings JSON: %w", err) + } + + settingsUrl, err := url.JoinPath(settings.GrafanaURL, fmt.Sprintf("api/plugins/grafana-oncall-app/settings")) + if err != nil { + return err + } + + settingsReq, err := http.NewRequest("POST", settingsUrl, bytes.NewReader(body)) + if err != nil { + return err + } + + settingsReq.Header.Set("Authorization", fmt.Sprintf("Bearer %s", settings.GrafanaToken)) + settingsReq.Header.Set("Content-Type", "application/json") + + res, err := a.httpClient.Do(settingsReq) + if err != nil { + return err + } + defer res.Body.Close() + + return nil +} + +func (a *App) GetOtherPluginSettings(settings *OnCallPluginSettings, pluginID string) (bool, map[string]interface{}, error) { + reqURL, err := url.JoinPath(settings.GrafanaURL, fmt.Sprintf("api/plugins/%s/settings", pluginID)) + if err != nil { + return false, nil, fmt.Errorf("error creating URL: %v", err) + } + + req, err := http.NewRequest("GET", reqURL, nil) + if err != nil { + return false, nil, fmt.Errorf("error creating creating new request: %v", err) + } + req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", settings.GrafanaToken)) + + res, err := a.httpClient.Do(req) + if err != nil { + return false, nil, fmt.Errorf("error making request: %v", err) + } + defer res.Body.Close() + + if res.StatusCode != 200 { + return false, nil, nil + } + + body, err := io.ReadAll(res.Body) + if err != nil { + return false, nil, fmt.Errorf("error reading response: %v", err) + } + + var result map[string]interface{} + err = json.Unmarshal(body, &result) + if err != nil { + return false, nil, fmt.Errorf("failed to parse JSON response: %v", err) + } + + var enabled = false + if value, ok := result["enabled"].(bool); ok { + enabled = value + } + if jsonData, ok := result["jsonData"].(map[string]interface{}); ok { + return enabled, jsonData, nil + } + return enabled, nil, fmt.Errorf("no jsonData for plugin %s", pluginID) +} + +func (a *App) GetSyncData(ctx context.Context, settings *OnCallPluginSettings) (*OnCallSync, error) { + onCallPluginSettings, err := a.OnCallSettingsFromContext(ctx) + if err != nil { + return nil, fmt.Errorf("error getting settings from context = %v", err) + } + + onCallSync := OnCallSync{ + Settings: *settings, + } + onCallSync.Users, err = a.GetAllUsersWithPermissions(onCallPluginSettings) + if err != nil { + return nil, fmt.Errorf("error getting users = %v", err) + } + + onCallSync.Teams, err = a.GetAllTeams(onCallPluginSettings) + if err != nil { + return nil, fmt.Errorf("error getting teams = %v", err) + } + + teamMembers, err := a.GetAllTeamMembers(onCallPluginSettings, onCallSync.Teams) + if err != nil { + return nil, fmt.Errorf("error getting team members = %v", err) + } + onCallSync.TeamMembers = teamMembers + + return &onCallSync, nil +} diff --git a/grafana-plugin/pkg/plugin/status.go b/grafana-plugin/pkg/plugin/status.go new file mode 100644 index 0000000000..def43727f7 --- /dev/null +++ b/grafana-plugin/pkg/plugin/status.go @@ -0,0 +1,260 @@ +package plugin + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + + "github.com/grafana/grafana-plugin-sdk-go/backend/log" +) + +type OnCallPluginConnectionEntry struct { + Ok bool `json:"ok"` + Error string `json:"error,omitempty"` +} + +func (e *OnCallPluginConnectionEntry) SetValid() { + e.Ok = true + e.Error = "" +} + +func (e *OnCallPluginConnectionEntry) SetInvalid(reason string) { + e.Ok = false + e.Error = reason +} + +func DefaultPluginConnectionEntry() OnCallPluginConnectionEntry { + return OnCallPluginConnectionEntry{ + Ok: false, + Error: "Not validated", + } +} + +type OnCallPluginConnection struct { + Settings OnCallPluginConnectionEntry `json:"settings"` + ServiceAccountToken OnCallPluginConnectionEntry `json:"service_account_token"` + GrafanaURLFromPlugin OnCallPluginConnectionEntry `json:"grafana_url_from_plugin"` + GrafanaURLFromEngine OnCallPluginConnectionEntry `json:"grafana_url_from_engine"` + OnCallAPIURL OnCallPluginConnectionEntry `json:"oncall_api_url"` + OnCallToken OnCallPluginConnectionEntry `json:"oncall_token"` +} + +func DefaultPluginConnection() OnCallPluginConnection { + return OnCallPluginConnection{ + Settings: DefaultPluginConnectionEntry(), + GrafanaURLFromPlugin: DefaultPluginConnectionEntry(), + ServiceAccountToken: DefaultPluginConnectionEntry(), + OnCallAPIURL: DefaultPluginConnectionEntry(), + OnCallToken: DefaultPluginConnectionEntry(), + GrafanaURLFromEngine: DefaultPluginConnectionEntry(), + } +} + +type OnCallEngineConnection struct { + GrafanaURL string `json:"url"` + Connected bool `json:"connected"` + StatusCode int `json:"status_code"` + Message string `json:"message"` +} + +type OnCallEngineStatus struct { + ConnectionToGrafana OnCallEngineConnection `json:"connection_to_grafana"` + License string `json:"license"` + Version string `json:"version"` + CurrentlyUndergoingMaintenanceMessage string `json:"currently_undergoing_maintenance_message"` + APIURL string `json:"api_url"` +} + +type OnCallStatus struct { + PluginConnection OnCallPluginConnection `json:"pluginConnection,omitempty"` + License string `json:"license"` + Version string `json:"version"` + CurrentlyUndergoingMaintenanceMessage string `json:"currently_undergoing_maintenance_message"` + APIURL string `json:"api_url"` +} + +func (c *OnCallPluginConnection) ValidateOnCallPluginSettings(settings *OnCallPluginSettings) bool { + // TODO: Return all instead of first? + if settings.StackID == 0 { + c.Settings.SetInvalid("jsonData.stackId is not set") + } else if settings.OrgID == 0 { + c.Settings.SetInvalid("jsonData.orgId is not set") + } else if settings.License == "" { + c.Settings.SetInvalid("jsonData.license is not set") + } else if settings.OnCallAPIURL == "" { + c.Settings.SetInvalid("jsonData.onCallApiUrl is not set") + } else if settings.GrafanaURL == "" { + c.Settings.SetInvalid("jsonData.grafanaUrl is not set") + } else { + c.Settings.SetValid() + } + return c.Settings.Ok +} + +func (a *App) ValidateGrafanaConnectionFromPlugin(status *OnCallStatus, settings *OnCallPluginSettings) (bool, error) { + reqURL, err := url.Parse(settings.GrafanaURL) + if err != nil { + status.PluginConnection.GrafanaURLFromPlugin.SetInvalid(fmt.Sprintf("Failed to parse grafana URL %s, %v", settings.GrafanaURL, err)) + return false, nil + } + + reqURL.Path += "api/org" + req, err := http.NewRequest("GET", reqURL.String(), nil) + if err != nil { + return false, fmt.Errorf("error creating new request: %+v", err) + } + req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", settings.GrafanaToken)) + + res, err := a.httpClient.Do(req) + if err != nil { + return false, fmt.Errorf("error making request: %+v", err) + } + defer res.Body.Close() + + if res.StatusCode == http.StatusOK { + status.PluginConnection.GrafanaURLFromPlugin.SetValid() + status.PluginConnection.ServiceAccountToken.SetValid() + } else if res.StatusCode == http.StatusUnauthorized || res.StatusCode == http.StatusForbidden { + status.PluginConnection.GrafanaURLFromPlugin.SetValid() + status.PluginConnection.ServiceAccountToken.SetInvalid(fmt.Sprintf("Grafana %s, status code %d", reqURL.String(), res.StatusCode)) + } else { + status.PluginConnection.GrafanaURLFromPlugin.SetInvalid(fmt.Sprintf("Grafana %s, status code %d", reqURL.String(), res.StatusCode)) + } + + return status.PluginConnection.ServiceAccountToken.Ok && status.PluginConnection.GrafanaURLFromPlugin.Ok, nil +} + +func (a *App) ValidateOnCallConnection(ctx context.Context, status *OnCallStatus, settings *OnCallPluginSettings) error { + healthStatus, err := a.CheckOnCallApiHealthStatus(settings) + if err != nil { + log.DefaultLogger.Error("Error checking OnCall API health: ", err) + status.PluginConnection.OnCallAPIURL = OnCallPluginConnectionEntry{ + Ok: false, + Error: fmt.Sprintf("Error checking OnCall API health. %v. Status code: %d", err, healthStatus), + } + return nil + } + + statusURL, err := url.JoinPath(settings.OnCallAPIURL, "api/internal/v1/plugin/v2/status") + if err != nil { + return fmt.Errorf("error joining path: %v", err) + } + + parsedStatusURL, err := url.Parse(statusURL) + if err != nil { + return fmt.Errorf("error parsing path: %v", err) + } + + statusReq, err := http.NewRequest("GET", parsedStatusURL.String(), nil) + if err != nil { + return fmt.Errorf("error creating request: %v", err) + } + + statusReq.Header.Set("Content-Type", "application/json") + err = a.SetupRequestHeadersForOnCall(ctx, settings, statusReq) + if err != nil { + return fmt.Errorf("error setting up request headers: %v ", err) + } + + res, err := a.httpClient.Do(statusReq) + if err != nil { + return fmt.Errorf("error request to oncall: %v ", err) + } + defer res.Body.Close() + + if res.StatusCode != http.StatusOK { + if res.StatusCode == http.StatusUnauthorized || res.StatusCode == http.StatusForbidden { + status.PluginConnection.OnCallToken = OnCallPluginConnectionEntry{ + Ok: false, + Error: fmt.Sprintf("Unauthorized/Forbidden while accessing OnCall engine: %s, status code: %d, check token", statusReq.URL.Path, res.StatusCode), + } + } else { + status.PluginConnection.OnCallAPIURL = OnCallPluginConnectionEntry{ + Ok: false, + Error: fmt.Sprintf("Unable to connect to OnCall engine: %s, status code: %d", statusReq.URL.Path, res.StatusCode), + } + } + } else { + status.PluginConnection.OnCallAPIURL.SetValid() + status.PluginConnection.OnCallToken.SetValid() + + statusBody, err := io.ReadAll(res.Body) + if err != nil { + return fmt.Errorf("error reading response body: %v", err) + } + + var engineStatus OnCallEngineStatus + err = json.Unmarshal(statusBody, &engineStatus) + if err != nil { + return fmt.Errorf("error unmarshalling OnCallStatus: %v", err) + } + + if engineStatus.ConnectionToGrafana.Connected { + status.PluginConnection.GrafanaURLFromEngine.SetValid() + } else { + status.PluginConnection.GrafanaURLFromPlugin.SetInvalid(fmt.Sprintf("While contacting Grafana: %s from Engine: %s, received status: %d, additional: %s", + engineStatus.ConnectionToGrafana.GrafanaURL, + settings.OnCallAPIURL, + engineStatus.ConnectionToGrafana.StatusCode, + engineStatus.ConnectionToGrafana.Message)) + } + + status.APIURL = engineStatus.APIURL + status.License = engineStatus.License + status.CurrentlyUndergoingMaintenanceMessage = engineStatus.CurrentlyUndergoingMaintenanceMessage + status.Version = engineStatus.Version + } + + return nil +} + +func (a *App) ValidateOnCallStatus(ctx context.Context, settings *OnCallPluginSettings) (*OnCallStatus, error) { + status := OnCallStatus{ + PluginConnection: DefaultPluginConnection(), + } + + if !status.PluginConnection.ValidateOnCallPluginSettings(settings) { + return &status, nil + } + + err := a.ValidateOnCallConnection(ctx, &status, settings) + if err != nil { + return &status, err + } + + grafanaOK, err := a.ValidateGrafanaConnectionFromPlugin(&status, settings) + if err != nil { + return &status, err + } else if !grafanaOK { + return &status, nil + } + + return &status, nil +} + +func (a *App) handleStatus(w http.ResponseWriter, req *http.Request) { + onCallPluginSettings, err := a.OnCallSettingsFromContext(req.Context()) + if err != nil { + log.DefaultLogger.Error("Error getting settings from context: ", err) + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + status, err := a.ValidateOnCallStatus(req.Context(), onCallPluginSettings) + if err != nil { + log.DefaultLogger.Error("Error validating oncall plugin settings: ", err) + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + w.Header().Add("Content-Type", "application/json") + if err := json.NewEncoder(w).Encode(status); err != nil { + http.Error(w, "Failed to encode response", http.StatusInternalServerError) + return + } + w.WriteHeader(http.StatusOK) + +} diff --git a/grafana-plugin/pkg/plugin/sync.go b/grafana-plugin/pkg/plugin/sync.go new file mode 100644 index 0000000000..55e6dc3e88 --- /dev/null +++ b/grafana-plugin/pkg/plugin/sync.go @@ -0,0 +1,70 @@ +package plugin + +import ( + "bytes" + "encoding/json" + "net/url" + + "github.com/grafana/grafana-plugin-sdk-go/backend/log" + + "net/http" +) + +func (a *App) handleSync(w http.ResponseWriter, req *http.Request) { + onCallPluginSettings, err := a.OnCallSettingsFromContext(req.Context()) + if err != nil { + log.DefaultLogger.Error("Error getting settings from context: ", err) + return + } + + onCallSync, err := a.GetSyncData(req.Context(), onCallPluginSettings) + if err != nil { + log.DefaultLogger.Error("Error getting sync data: ", err) + return + } + + onCallSyncJsonData, err := json.Marshal(onCallSync) + if err != nil { + log.DefaultLogger.Error("Error marshalling JSON: ", err) + return + } + + syncURL, err := url.JoinPath(onCallPluginSettings.OnCallAPIURL, "api/internal/v1/plugin/v2/sync") + if err != nil { + log.DefaultLogger.Error("Error joining path: %v", err) + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + parsedSyncURL, err := url.Parse(syncURL) + if err != nil { + log.DefaultLogger.Error("Error parsing path: %v", err) + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + syncReq, err := http.NewRequest("POST", parsedSyncURL.String(), bytes.NewBuffer(onCallSyncJsonData)) + if err != nil { + log.DefaultLogger.Error("Error creating request: ", err) + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + + err = a.SetupRequestHeadersForOnCall(req.Context(), onCallPluginSettings, syncReq) + if err != nil { + log.DefaultLogger.Error("Error setting up headers: %v", err) + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + syncReq.Header.Set("Content-Type", "application/json") + + res, err := a.httpClient.Do(syncReq) + if err != nil { + log.DefaultLogger.Error("Error request to oncall: ", err) + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + defer res.Body.Close() + + w.WriteHeader(http.StatusOK) +} diff --git a/grafana-plugin/pkg/plugin/teams.go b/grafana-plugin/pkg/plugin/teams.go new file mode 100644 index 0000000000..b587cff7db --- /dev/null +++ b/grafana-plugin/pkg/plugin/teams.go @@ -0,0 +1,167 @@ +package plugin + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" +) + +type Teams struct { + Teams []Team `json:"teams"` +} + +type Team struct { + ID int `json:"id"` + Name string `json:"name"` + Email string `json:"email"` + AvatarURL string `json:"avatarUrl"` +} + +type OnCallTeam struct { + ID int `json:"team_id"` + Name string `json:"name"` + Email string `json:"email"` + AvatarURL string `json:"avatar_url"` +} + +func (a *App) GetTeamsForUser(settings *OnCallPluginSettings, onCallUser *OnCallUser) ([]int, error) { + reqURL, err := url.JoinPath(settings.GrafanaURL, fmt.Sprintf("api/users/%d/teams", onCallUser.ID)) + if err != nil { + return nil, fmt.Errorf("error creating URL: %v", err) + } + + req, err := http.NewRequest("GET", reqURL, nil) + if err != nil { + return nil, fmt.Errorf("error creating creating new request: %v", err) + } + req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", settings.GrafanaToken)) + + res, err := a.httpClient.Do(req) + if err != nil { + return nil, fmt.Errorf("error making request: %v", err) + } + defer res.Body.Close() + + body, err := io.ReadAll(res.Body) + if err != nil { + return nil, fmt.Errorf("error reading response: %v", err) + } + + var result []Team + err = json.Unmarshal(body, &result) + if err != nil { + return nil, fmt.Errorf("failed to parse JSON response: %v", err) + } + + if res.StatusCode == 200 { + var teams []int + for _, team := range result { + teams = append(teams, team.ID) + } + return teams, nil + } + return nil, fmt.Errorf("no teams for %s, http status %s", onCallUser.Login, res.Status) +} + +func (a *App) GetAllTeams(settings *OnCallPluginSettings) ([]OnCallTeam, error) { + reqURL, err := url.Parse(settings.GrafanaURL) + if err != nil { + return nil, fmt.Errorf("error parsing URL: %v", err) + } + + reqURL.Path += "api/teams/search" + q := reqURL.Query() + q.Set("perpage", "100000") + reqURL.RawQuery = q.Encode() + + req, err := http.NewRequest("GET", reqURL.String(), nil) + if err != nil { + return nil, fmt.Errorf("error creating new request: %v", err) + } + req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", settings.GrafanaToken)) + + res, err := a.httpClient.Do(req) + if err != nil { + return nil, fmt.Errorf("error making request: %+v", err) + } + defer res.Body.Close() + + body, err := io.ReadAll(res.Body) + if err != nil { + return nil, fmt.Errorf("error reading response: %+v", err) + } + + var result Teams + err = json.Unmarshal(body, &result) + if err != nil { + return nil, fmt.Errorf("failed to parse JSON response: %v", err) + } + + if res.StatusCode == 200 { + var teams []OnCallTeam + for _, team := range result.Teams { + onCallTeam := OnCallTeam{ + ID: team.ID, + Name: team.Name, + Email: team.Email, + AvatarURL: team.AvatarURL, + } + teams = append(teams, onCallTeam) + } + return teams, nil + } + return nil, fmt.Errorf("http status %s", res.Status) +} + +func (a *App) GetTeamsMembersForTeam(settings *OnCallPluginSettings, onCallTeam *OnCallTeam) ([]int, error) { + reqURL, err := url.JoinPath(settings.GrafanaURL, fmt.Sprintf("api/teams/%d/members", onCallTeam.ID)) + if err != nil { + return nil, fmt.Errorf("error creating URL: %+v", err) + } + + req, err := http.NewRequest("GET", reqURL, nil) + if err != nil { + return nil, fmt.Errorf("error creating creating new request: %+v", err) + } + req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", settings.GrafanaToken)) + + res, err := a.httpClient.Do(req) + if err != nil { + return nil, fmt.Errorf("error making request: %+v", err) + } + defer res.Body.Close() + + body, err := io.ReadAll(res.Body) + if err != nil { + return nil, fmt.Errorf("error reading response: %+v", err) + } + + var result []OrgUser + err = json.Unmarshal(body, &result) + if err != nil { + return nil, fmt.Errorf("failed to parse JSON response: %v", err) + } + + if res.StatusCode == 200 { + var members []int + for _, user := range result { + members = append(members, user.ID) + } + return members, nil + } + return nil, fmt.Errorf("http status %s", res.Status) +} + +func (a *App) GetAllTeamMembers(settings *OnCallPluginSettings, onCallTeams []OnCallTeam) (map[int][]int, error) { + teamMapping := map[int][]int{} + for _, team := range onCallTeams { + teamMembers, err := a.GetTeamsMembersForTeam(settings, &team) + if err != nil { + return nil, err + } + teamMapping[team.ID] = teamMembers + } + return teamMapping, nil +} diff --git a/grafana-plugin/pkg/plugin/users.go b/grafana-plugin/pkg/plugin/users.go new file mode 100644 index 0000000000..29c44f7408 --- /dev/null +++ b/grafana-plugin/pkg/plugin/users.go @@ -0,0 +1,150 @@ +package plugin + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + + "github.com/grafana/grafana-plugin-sdk-go/backend" + "github.com/grafana/grafana-plugin-sdk-go/backend/log" +) + +type LookupUser struct { + ID int `json:"id"` + Name string `json:"name"` + Login string `json:"login"` + Email string `json:"email"` + AvatarURL string `json:"avatarUrl"` +} + +type OrgUser struct { + ID int `json:"userId"` + Name string `json:"name"` + Login string `json:"login"` + Email string `json:"email"` + AvatarURL string `json:"avatarUrl"` + Role string `json:"role"` +} + +type OnCallUser struct { + ID int `json:"id"` + Name string `json:"name"` + Login string `json:"login"` + Email string `json:"email"` + Role string `json:"role"` + AvatarURL string `json:"avatar_url"` + Permissions []OnCallPermission `json:"permissions"` + Teams []int `json:"teams"` +} + +func (a *App) GetUser(settings *OnCallPluginSettings, user *backend.User) (*OnCallUser, error) { + users, err := a.GetAllUsers(settings) + if err != nil { + return nil, err + } + + for _, u := range users { + if u.Login == user.Login { + return &u, nil + } + } + return nil, fmt.Errorf("user %s not found", user.Login) +} + +func (a *App) GetUserForHeader(settings *OnCallPluginSettings, user *backend.User) (*OnCallUser, error) { + onCallUser, err := a.GetUser(settings, user) + if err != nil { + return nil, err + } + + // manually created service account with Admin role doesn't have permission to get user teams + if settings.ExternalServiceAccountEnabled { + onCallUser.Teams, err = a.GetTeamsForUser(settings, onCallUser) + if err != nil { + return nil, err + } + } + if settings.RBACEnabled { + onCallUser.Permissions, err = a.GetPermissions(settings, onCallUser) + if err != nil { + return nil, err + } + } + return onCallUser, nil +} + +func (a *App) GetAllUsers(settings *OnCallPluginSettings) ([]OnCallUser, error) { + reqURL, err := url.Parse(settings.GrafanaURL) + if err != nil { + return nil, fmt.Errorf("error parsing URL: %+v", err) + } + + reqURL.Path += "api/org/users" + + req, err := http.NewRequest("GET", reqURL.String(), nil) + if err != nil { + return nil, fmt.Errorf("error creating new request: %+v", err) + } + req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", settings.GrafanaToken)) + + res, err := a.httpClient.Do(req) + if err != nil { + return nil, fmt.Errorf("error making request: %+v", err) + } + defer res.Body.Close() + + body, err := io.ReadAll(res.Body) + if err != nil { + return nil, fmt.Errorf("error reading response: %+v", err) + } + + var result []OrgUser + err = json.Unmarshal(body, &result) + if err != nil { + return nil, fmt.Errorf("failed to parse JSON response: %v", err) + } + + if res.StatusCode == 200 { + var users []OnCallUser + for _, orgUser := range result { + onCallUser := OnCallUser{ + ID: orgUser.ID, + Name: orgUser.Name, + Login: orgUser.Login, + Email: orgUser.Email, + AvatarURL: orgUser.AvatarURL, + Role: orgUser.Role, + } + users = append(users, onCallUser) + } + return users, nil + } + return nil, fmt.Errorf("http status %s", res.Status) +} + +func (a *App) GetAllUsersWithPermissions(settings *OnCallPluginSettings) ([]OnCallUser, error) { + onCallUsers, err := a.GetAllUsers(settings) + if err != nil { + return nil, err + } + if settings.RBACEnabled { + permissions, err := a.GetAllPermissions(settings) + if err != nil { + return nil, err + } + for i := range onCallUsers { + actions, exists := permissions["1"] + if exists { + onCallUsers[i].Permissions = []OnCallPermission{} + for action, _ := range actions { + onCallUsers[i].Permissions = append(onCallUsers[i].Permissions, OnCallPermission{Action: action}) + } + } else { + log.DefaultLogger.Error(fmt.Sprintf("Did not find permissions for user %s", onCallUsers[i].Login)) + } + } + } + return onCallUsers, nil +} diff --git a/grafana-plugin/src/components/IntegrationCollapsibleTreeView/IntegrationCollapsibleTreeView.styles.ts b/grafana-plugin/src/components/CollapsibleTreeView/CollapsibleTreeView.styles.ts similarity index 96% rename from grafana-plugin/src/components/IntegrationCollapsibleTreeView/IntegrationCollapsibleTreeView.styles.ts rename to grafana-plugin/src/components/CollapsibleTreeView/CollapsibleTreeView.styles.ts index edeed6f3a7..ab94abb6ed 100644 --- a/grafana-plugin/src/components/IntegrationCollapsibleTreeView/IntegrationCollapsibleTreeView.styles.ts +++ b/grafana-plugin/src/components/CollapsibleTreeView/CollapsibleTreeView.styles.ts @@ -2,7 +2,7 @@ import { css } from '@emotion/css'; import { GrafanaTheme2 } from '@grafana/data'; import { COLORS } from 'styles/utils.styles'; -export const getIntegrationCollapsibleTreeStyles = (theme: GrafanaTheme2) => { +export const getCollapsibleTreeStyles = (theme: GrafanaTheme2) => { return { container: css` margin-left: 32px; diff --git a/grafana-plugin/src/components/IntegrationCollapsibleTreeView/IntegrationCollapsibleTreeView.tsx b/grafana-plugin/src/components/CollapsibleTreeView/CollapsibleTreeView.tsx similarity index 81% rename from grafana-plugin/src/components/IntegrationCollapsibleTreeView/IntegrationCollapsibleTreeView.tsx rename to grafana-plugin/src/components/CollapsibleTreeView/CollapsibleTreeView.tsx index 1d50da088c..2ee48c9690 100644 --- a/grafana-plugin/src/components/IntegrationCollapsibleTreeView/IntegrationCollapsibleTreeView.tsx +++ b/grafana-plugin/src/components/CollapsibleTreeView/CollapsibleTreeView.tsx @@ -8,9 +8,9 @@ import { bem } from 'styles/utils.styles'; import { Text } from 'components/Text/Text'; -import { getIntegrationCollapsibleTreeStyles } from './IntegrationCollapsibleTreeView.styles'; +import { getCollapsibleTreeStyles } from './CollapsibleTreeView.styles'; -export interface IntegrationCollapsibleItem { +export interface CollapsibleItem { isHidden?: boolean; customIcon?: IconName; canHoverIcon?: boolean; @@ -23,16 +23,17 @@ export interface IntegrationCollapsibleItem { onStateChange?(isChecked: boolean): void; } -interface IntegrationCollapsibleTreeViewProps { +interface CollapsibleTreeViewProps { startingElemPosition?: string; isRouteView?: boolean; - configElements: Array; + configElements: Array; + className?: string; } -export const IntegrationCollapsibleTreeView: React.FC = observer((props) => { - const { configElements, isRouteView } = props; +export const CollapsibleTreeView: React.FC = observer((props) => { + const { configElements, isRouteView, className } = props; - const styles = useStyles2(getIntegrationCollapsibleTreeStyles); + const styles = useStyles2(getCollapsibleTreeStyles); const [expandedList, setExpandedList] = useState(getStartingExpandedState()); useEffect(() => { @@ -40,13 +41,13 @@ export const IntegrationCollapsibleTreeView: React.FC +
{configElements .filter((config) => config) // filter out falsy values - .map((item: IntegrationCollapsibleItem | IntegrationCollapsibleItem[], idx) => { + .map((item: CollapsibleItem | CollapsibleItem[], idx) => { if (isArray(item)) { return item.map((it, innerIdx) => ( - expandOrCollapseAtPos(!expandedList[idx][innerIdx], idx, innerIdx)} @@ -56,7 +57,7 @@ export const IntegrationCollapsibleTreeView: React.FC void; }> = ({ item, elementPosition, isExpanded, onClick }) => { - const styles = useStyles2(getIntegrationCollapsibleTreeStyles); + const styles = useStyles2(getCollapsibleTreeStyles); const handleIconClick = !item.isCollapsible ? undefined : onClick; return ( diff --git a/grafana-plugin/src/components/ExtensionLinkMenu/ExtensionLinkMenu.tsx b/grafana-plugin/src/components/ExtensionLinkMenu/ExtensionLinkMenu.tsx index ebe8958247..1abbcd5c3e 100644 --- a/grafana-plugin/src/components/ExtensionLinkMenu/ExtensionLinkMenu.tsx +++ b/grafana-plugin/src/components/ExtensionLinkMenu/ExtensionLinkMenu.tsx @@ -4,6 +4,7 @@ import { locationUtil, PluginExtensionLink, PluginExtensionTypes } from '@grafan import { IconName, Menu } from '@grafana/ui'; import { PluginBridge, SupportedPlugin } from 'components/PluginBridge/PluginBridge'; +import { PLUGIN_ID } from 'utils/consts'; import { truncateTitle } from 'utils/string'; type Props = { @@ -68,7 +69,7 @@ function DeclareIncidentMenuItem({ extensions, declareIncidentLink, grafanaIncid icon: 'fire', category: 'Incident', title: 'Declare incident', - pluginId: 'grafana-oncall-app', + pluginId: PLUGIN_ID, } as Partial, ])} diff --git a/grafana-plugin/src/components/FullPageError/FullPageError.tsx b/grafana-plugin/src/components/FullPageError/FullPageError.tsx new file mode 100644 index 0000000000..a77aee5a4f --- /dev/null +++ b/grafana-plugin/src/components/FullPageError/FullPageError.tsx @@ -0,0 +1,40 @@ +import React, { FC } from 'react'; + +import { css } from '@emotion/css'; +import { useStyles2, VerticalGroup } from '@grafana/ui'; + +import errorSVG from 'assets/img/error.svg'; +import { Text } from 'components/Text/Text'; + +interface FullPageErrorProps { + children?: React.ReactNode; + title?: string; + subtitle?: React.ReactNode; +} + +export const FullPageError: FC = ({ + title = 'An unexpected error happened', + subtitle, + children, +}) => { + const styles = useStyles2(getStyles); + + return ( +
+ + + {title} + {subtitle && {subtitle}} + {children} + +
+ ); +}; + +const getStyles = () => ({ + wrapper: css` + margin: 24px auto; + width: 600px; + text-align: center; + `, +}); diff --git a/grafana-plugin/src/components/Text/Text.styles.ts b/grafana-plugin/src/components/Text/Text.styles.ts index b91e87078b..cd08815f3e 100644 --- a/grafana-plugin/src/components/Text/Text.styles.ts +++ b/grafana-plugin/src/components/Text/Text.styles.ts @@ -5,8 +5,6 @@ import { COLORS } from 'styles/utils.styles'; export const getTextStyles = (theme: GrafanaTheme2) => { return { root: css` - display: inline; - &:hover [data-emotion='iconButton'] { display: inline-flex; } @@ -66,6 +64,18 @@ export const getTextStyles = (theme: GrafanaTheme2) => { } `, + display: css` + &--inline { + display: inline; + } + &--block { + display: block; + } + &--inline-block { + display: inline-block; + } + `, + noWrap: css` white-space: nowrap; `, diff --git a/grafana-plugin/src/components/Text/Text.tsx b/grafana-plugin/src/components/Text/Text.tsx index b3bd65a48d..de2857d17e 100644 --- a/grafana-plugin/src/components/Text/Text.tsx +++ b/grafana-plugin/src/components/Text/Text.tsx @@ -16,6 +16,7 @@ interface TextProps extends HTMLAttributes { strong?: boolean; underline?: boolean; size?: 'xs' | 'small' | 'medium' | 'large'; + display?: 'inline' | 'block' | 'inline-block'; className?: string; wrap?: boolean; copyable?: boolean; @@ -40,6 +41,7 @@ export const Text: TextInterface = (props) => { const { type, size = 'medium', + display = 'inline', strong = false, underline = false, children, @@ -93,8 +95,9 @@ export const Text: TextInterface = (props) => { styles.root, styles.text, { [styles.maxWidth]: Boolean(maxWidth) }, - { [bem(styles.text, type)]: true }, - { [bem(styles.text, size)]: true }, + bem(styles.text, type), + bem(styles.text, size), + bem(styles.display, display), { [bem(styles.text, `strong`)]: strong }, { [bem(styles.text, `underline`)]: underline }, { [bem(styles.text, 'clickable')]: clickable }, diff --git a/grafana-plugin/src/components/Unauthorized/__snapshots__/Unauthorized.test.tsx.snap b/grafana-plugin/src/components/Unauthorized/__snapshots__/Unauthorized.test.tsx.snap index 59b30dfbb0..42ff8aa004 100644 --- a/grafana-plugin/src/components/Unauthorized/__snapshots__/Unauthorized.test.tsx.snap +++ b/grafana-plugin/src/components/Unauthorized/__snapshots__/Unauthorized.test.tsx.snap @@ -20,7 +20,7 @@ exports[`Unauthorized renders properly - access control enabled: false 1`] = ` className="css-1fmhfo9" > Participants @@ -72,7 +72,7 @@ exports[`AddResponders should properly display the add responders button when hi class="css-u023fv" > Participants @@ -106,7 +106,7 @@ exports[`AddResponders should render properly in create mode 1`] = ` class="css-u023fv" > Participants @@ -159,7 +159,7 @@ exports[`AddResponders should render properly in update mode 1`] = ` class="css-u023fv" > Participants @@ -212,7 +212,7 @@ exports[`AddResponders should render selected team and users properly 1`] = ` class="css-u023fv" > Participants @@ -270,7 +270,7 @@ exports[`AddResponders should render selected team and users properly 1`] = ` class="css-18qv8yz-layoutChildrenWrapper" > my test team @@ -323,7 +323,7 @@ exports[`AddResponders should render selected team and users properly 1`] = ` class="css-18qv8yz-layoutChildrenWrapper" > my test user3 @@ -438,7 +438,7 @@ exports[`AddResponders should render selected team and users properly 1`] = ` class="css-18qv8yz-layoutChildrenWrapper" > my test user @@ -552,7 +552,7 @@ exports[`AddResponders should render selected team and users properly 1`] = ` class="css-18qv8yz-layoutChildrenWrapper" > my test user2 @@ -664,7 +664,7 @@ exports[`AddResponders should render selected team and users properly 1`] = ` class="css-9om60p" >
my test team diff --git a/grafana-plugin/src/containers/AddResponders/parts/UserResponder/__snapshots__/UserResponder.test.tsx.snap b/grafana-plugin/src/containers/AddResponders/parts/UserResponder/__snapshots__/UserResponder.test.tsx.snap index 9e79722ebc..69e375dd09 100644 --- a/grafana-plugin/src/containers/AddResponders/parts/UserResponder/__snapshots__/UserResponder.test.tsx.snap +++ b/grafana-plugin/src/containers/AddResponders/parts/UserResponder/__snapshots__/UserResponder.test.tsx.snap @@ -31,7 +31,7 @@ exports[`UserResponder it renders data properly 1`] = ` class="css-18qv8yz-layoutChildrenWrapper" > johnsmith diff --git a/grafana-plugin/src/containers/IntegrationContainers/ExpandedIntegrationRouteDisplay/ExpandedIntegrationRouteDisplay.tsx b/grafana-plugin/src/containers/IntegrationContainers/ExpandedIntegrationRouteDisplay/ExpandedIntegrationRouteDisplay.tsx index f4caea5691..1cb24b34ce 100644 --- a/grafana-plugin/src/containers/IntegrationContainers/ExpandedIntegrationRouteDisplay/ExpandedIntegrationRouteDisplay.tsx +++ b/grafana-plugin/src/containers/IntegrationContainers/ExpandedIntegrationRouteDisplay/ExpandedIntegrationRouteDisplay.tsx @@ -16,11 +16,8 @@ import cn from 'classnames/bind'; import { observer } from 'mobx-react'; import CopyToClipboard from 'react-copy-to-clipboard'; +import { CollapsibleTreeView, CollapsibleItem } from 'components/CollapsibleTreeView/CollapsibleTreeView'; import { HamburgerMenuIcon } from 'components/HamburgerMenuIcon/HamburgerMenuIcon'; -import { - IntegrationCollapsibleTreeView, - IntegrationCollapsibleItem, -} from 'components/IntegrationCollapsibleTreeView/IntegrationCollapsibleTreeView'; import { IntegrationBlock } from 'components/Integrations/IntegrationBlock'; import { MonacoEditor } from 'components/MonacoEditor/MonacoEditor'; import { MONACO_READONLY_CONFIG } from 'components/MonacoEditor/MonacoEditor.config'; @@ -123,7 +120,7 @@ export const ExpandedIntegrationRouteDisplay: React.FC { - const configs: IntegrationCollapsibleItem[] = [ + const configs: CollapsibleItem[] = [ { isHidden: false, isCollapsible: false, @@ -316,11 +313,7 @@ export const ExpandedIntegrationRouteDisplay: React.FC } content={ - + } /> {routeIdForDeletion && ( diff --git a/grafana-plugin/src/containers/MobileAppConnection/MobileAppConnection.tsx b/grafana-plugin/src/containers/MobileAppConnection/MobileAppConnection.tsx index 701c96b1ce..9ec78d754e 100644 --- a/grafana-plugin/src/containers/MobileAppConnection/MobileAppConnection.tsx +++ b/grafana-plugin/src/containers/MobileAppConnection/MobileAppConnection.tsx @@ -7,13 +7,16 @@ import { observer } from 'mobx-react'; import qrCodeImage from 'assets/img/qr-code.png'; import { Block } from 'components/GBlock/Block'; import { PluginLink } from 'components/PluginLink/PluginLink'; +import { RenderConditionally } from 'components/RenderConditionally/RenderConditionally'; import { Text } from 'components/Text/Text'; +import { PluginInitializer } from 'containers/PluginInitializer/PluginInitializer'; import { WithPermissionControlDisplay } from 'containers/WithPermissionControl/WithPermissionControlDisplay'; import { UserHelper } from 'models/user/user.helpers'; import { ApiSchemas } from 'network/oncall-api/api.types'; import { AppFeature } from 'state/features'; import { RootStore, rootStore as store } from 'state/rootStore'; import { UserActions } from 'utils/authorization/authorization'; +import { useInitializePlugin } from 'utils/hooks'; import { isMobile, openErrorNotification, openNotification, openWarningNotification } from 'utils/utils'; import styles from './MobileAppConnection.module.scss'; @@ -364,10 +367,13 @@ function QRLoading() { export const MobileAppConnectionWrapper: React.FC<{}> = observer(() => { const { userStore } = store; + const { isConnected } = useInitializePlugin(); useEffect(() => { - loadData(); - }, []); + if (isConnected) { + loadData(); + } + }, [isConnected]); const loadData = async () => { if (!store.isBasicDataLoaded) { @@ -379,9 +385,13 @@ export const MobileAppConnectionWrapper: React.FC<{}> = observer(() => { } }; - if (store.isBasicDataLoaded && userStore.currentUserPk) { - return ; - } - - return ; + return ( + + } + backupChildren={} + /> + + ); }); diff --git a/grafana-plugin/src/containers/MobileAppConnection/__snapshots__/MobileAppConnection.test.tsx.snap b/grafana-plugin/src/containers/MobileAppConnection/__snapshots__/MobileAppConnection.test.tsx.snap index af350393f8..687d3de064 100644 --- a/grafana-plugin/src/containers/MobileAppConnection/__snapshots__/MobileAppConnection.test.tsx.snap +++ b/grafana-plugin/src/containers/MobileAppConnection/__snapshots__/MobileAppConnection.test.tsx.snap @@ -45,7 +45,7 @@ exports[`MobileAppConnection it shows a QR code if the app isn't already connect class="css-12oo3x0-layoutChildrenWrapper" > Download @@ -54,7 +54,7 @@ exports[`MobileAppConnection it shows a QR code if the app isn't already connect class="css-12oo3x0-layoutChildrenWrapper" > The Grafana OnCall app is available on both the App Store and Google Play Store. @@ -84,7 +84,7 @@ exports[`MobileAppConnection it shows a QR code if the app isn't already connect src="[object Object]" /> iOS @@ -109,7 +109,7 @@ exports[`MobileAppConnection it shows a QR code if the app isn't already connect src="[object Object]" /> Android @@ -171,7 +171,7 @@ exports[`MobileAppConnection it shows a loading message if it is currently disco class="css-12oo3x0-layoutChildrenWrapper" > Download @@ -180,7 +180,7 @@ exports[`MobileAppConnection it shows a loading message if it is currently disco class="css-12oo3x0-layoutChildrenWrapper" > The Grafana OnCall app is available on both the App Store and Google Play Store. @@ -210,7 +210,7 @@ exports[`MobileAppConnection it shows a loading message if it is currently disco src="[object Object]" /> iOS @@ -235,7 +235,7 @@ exports[`MobileAppConnection it shows a loading message if it is currently disco src="[object Object]" /> Android @@ -297,7 +297,7 @@ exports[`MobileAppConnection it shows a loading message if it is currently fetch class="css-12oo3x0-layoutChildrenWrapper" > Download @@ -306,7 +306,7 @@ exports[`MobileAppConnection it shows a loading message if it is currently fetch class="css-12oo3x0-layoutChildrenWrapper" > The Grafana OnCall app is available on both the App Store and Google Play Store. @@ -336,7 +336,7 @@ exports[`MobileAppConnection it shows a loading message if it is currently fetch src="[object Object]" /> iOS @@ -361,7 +361,7 @@ exports[`MobileAppConnection it shows a loading message if it is currently fetch src="[object Object]" /> Android @@ -388,7 +388,7 @@ exports[`MobileAppConnection it shows a warning when cloud is not connected 1`] class="css-12oo3x0-layoutChildrenWrapper" > Please connect Grafana Cloud OnCall to use the mobile app @@ -438,7 +438,7 @@ exports[`MobileAppConnection it shows an error message if there was an error dis class="css-1x53p5e css-1x53p5e--bordered css-1x53p5e--shadowed css-1x53p5e--withBackGround container__box" > There was an error disconnecting your mobile app. Please try again. @@ -454,7 +454,7 @@ exports[`MobileAppConnection it shows an error message if there was an error dis class="css-12oo3x0-layoutChildrenWrapper" > Download @@ -463,7 +463,7 @@ exports[`MobileAppConnection it shows an error message if there was an error dis class="css-12oo3x0-layoutChildrenWrapper" > The Grafana OnCall app is available on both the App Store and Google Play Store. @@ -493,7 +493,7 @@ exports[`MobileAppConnection it shows an error message if there was an error dis src="[object Object]" /> iOS @@ -518,7 +518,7 @@ exports[`MobileAppConnection it shows an error message if there was an error dis src="[object Object]" /> Android @@ -554,7 +554,7 @@ exports[`MobileAppConnection it shows an error message if there was an error fet class="css-1x53p5e css-1x53p5e--bordered css-1x53p5e--shadowed css-1x53p5e--withBackGround container__box" > There was an error fetching your QR code. Please try again. @@ -570,7 +570,7 @@ exports[`MobileAppConnection it shows an error message if there was an error fet class="css-12oo3x0-layoutChildrenWrapper" > Download @@ -579,7 +579,7 @@ exports[`MobileAppConnection it shows an error message if there was an error fet class="css-12oo3x0-layoutChildrenWrapper" > The Grafana OnCall app is available on both the App Store and Google Play Store. @@ -609,7 +609,7 @@ exports[`MobileAppConnection it shows an error message if there was an error fet src="[object Object]" /> iOS @@ -634,7 +634,7 @@ exports[`MobileAppConnection it shows an error message if there was an error fet src="[object Object]" /> Android diff --git a/grafana-plugin/src/containers/MobileAppConnection/parts/DownloadIcons/__snapshots__/DownloadIcons.test.tsx.snap b/grafana-plugin/src/containers/MobileAppConnection/parts/DownloadIcons/__snapshots__/DownloadIcons.test.tsx.snap index 2cef0d0768..dc19303527 100644 --- a/grafana-plugin/src/containers/MobileAppConnection/parts/DownloadIcons/__snapshots__/DownloadIcons.test.tsx.snap +++ b/grafana-plugin/src/containers/MobileAppConnection/parts/DownloadIcons/__snapshots__/DownloadIcons.test.tsx.snap @@ -10,7 +10,7 @@ exports[`DownloadIcons it renders properly 1`] = ` class="css-12oo3x0-layoutChildrenWrapper" > Download @@ -19,7 +19,7 @@ exports[`DownloadIcons it renders properly 1`] = ` class="css-12oo3x0-layoutChildrenWrapper" > The Grafana OnCall app is available on both the App Store and Google Play Store. @@ -49,7 +49,7 @@ exports[`DownloadIcons it renders properly 1`] = ` src="[object Object]" /> iOS @@ -74,7 +74,7 @@ exports[`DownloadIcons it renders properly 1`] = ` src="[object Object]" /> Android diff --git a/grafana-plugin/src/containers/MobileAppConnection/parts/LinkLoginButton/__snapshots__/LinkLoginButton.test.tsx.snap b/grafana-plugin/src/containers/MobileAppConnection/parts/LinkLoginButton/__snapshots__/LinkLoginButton.test.tsx.snap index 18408d5c6d..6a1e6f76e0 100644 --- a/grafana-plugin/src/containers/MobileAppConnection/parts/LinkLoginButton/__snapshots__/LinkLoginButton.test.tsx.snap +++ b/grafana-plugin/src/containers/MobileAppConnection/parts/LinkLoginButton/__snapshots__/LinkLoginButton.test.tsx.snap @@ -10,7 +10,7 @@ exports[`LinkLoginButton it renders properly 1`] = ` class="css-12oo3x0-layoutChildrenWrapper" > Sign in via deeplink @@ -19,7 +19,7 @@ exports[`LinkLoginButton it renders properly 1`] = ` class="css-12oo3x0-layoutChildrenWrapper" > Make sure to have the app installed diff --git a/grafana-plugin/src/containers/PluginConfigPage/PluginConfigPage.test.tsx b/grafana-plugin/src/containers/PluginConfigPage/PluginConfigPage.test.tsx deleted file mode 100644 index 196ddc5017..0000000000 --- a/grafana-plugin/src/containers/PluginConfigPage/PluginConfigPage.test.tsx +++ /dev/null @@ -1,284 +0,0 @@ -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/plugin'; - -import { - PluginConfigPage, - reloadPageWithPluginConfiguredQueryParams, - removePluginConfiguredQueryParams, -} from './PluginConfigPage'; - -jest.mock('../../../package.json', () => ({ - version: 'v1.2.3', -})); - -jest.mock('react-router-dom', () => ({ - useLocation: jest.fn(() => ({ - search: '', - })), -})); - -const useLocation = useLocationOriginal as jest.Mock>; - -enum License { - OSS = 'OpenSource', - CLOUD = 'some-other-license', -} - -const CHECK_IF_PLUGIN_IS_CONNECTED_ERROR_MESSAGE = 'ohhh nooo a plugin connection error'; -const UPDATE_PLUGIN_STATUS_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}`; - -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(); -}); - -afterEach(() => { - jest.clearAllMocks(); -}); - -const mockCheckTokenAndIfPluginIsConnected = (license: License = License.OSS) => { - PluginState.checkTokenAndIfPluginIsConnected = jest.fn().mockResolvedValueOnce({ - token_ok: true, - license, - version: 'v1.2.3', - allow_signup: true, - currently_undergoing_maintenance_message: null, - recaptcha_site_key: 'abc', - is_installed: true, - is_user_anonymous: false, - }); -}; - -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'; - const recaptcha_site_key = 'abc'; - const currently_undergoing_maintenance_message = 'false'; - - // test - reloadPageWithPluginConfiguredQueryParams( - { version, license, recaptcha_site_key, currently_undergoing_maintenance_message }, - 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).toHaveBeenCalledWith({ 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.updatePluginStatus = jest.fn(); - mockCheckTokenAndIfPluginIsConnected(); - - // test setup - render(); - await screen.findByTestId(STATUS_MESSAGE_BLOCK_DATA_ID); - - // assertions - expect(window.history.pushState).toHaveBeenCalledWith({ path: MOCK_URL }, '', MOCK_URL); - - expect(PluginState.updatePluginStatus).toHaveBeenCalledTimes(1); - expect(PluginState.updatePluginStatus).toHaveBeenCalledWith(metaJsonDataOnCallApiUrl); - - expect(PluginState.checkTokenAndIfPluginIsConnected).toHaveBeenCalledTimes(1); - expect(PluginState.checkTokenAndIfPluginIsConnected).toHaveBeenCalledWith(metaJsonDataOnCallApiUrl); - }); - - test("It doesn't make any network calls if the plugin configured query params are provided", async () => { - // mocks - const metaJsonDataOnCallApiUrl = 'onCallApiUrlFromMetaJsonData'; - const version = 'v1.2.3'; - const license = 'OpenSource'; - - useLocation.mockReturnValueOnce({ - search: `?pluginConfigured=true&pluginConfiguredLicense=${license}&pluginConfiguredVersion=${version}`, - } as ReturnType); - - PluginState.updatePluginStatus = jest.fn(); - mockCheckTokenAndIfPluginIsConnected(); - - // test setup - const component = render(); - await screen.findByTestId(STATUS_MESSAGE_BLOCK_DATA_ID); - - // assertions - expect(PluginState.updatePluginStatus).not.toHaveBeenCalled(); - expect(PluginState.checkTokenAndIfPluginIsConnected).not.toHaveBeenCalled(); - expect(component.container).toMatchSnapshot(); - }); - - test("If onCallApiUrl is not set in the plugin's meta jsonData, or in process.env, updatePluginStatus is not called, and the configuration form is shown", async () => { - // mocks - delete process.env.ONCALL_API_URL; - - PluginState.updatePluginStatus = jest.fn(); - PluginState.checkTokenAndIfPluginIsConnected = jest.fn(); - - // test setup - const component = render(); - await screen.findByTestId(PLUGIN_CONFIGURATION_FORM_DATA_ID); - - // assertions - expect(PluginState.updatePluginStatus).not.toHaveBeenCalled(); - expect(PluginState.checkTokenAndIfPluginIsConnected).not.toHaveBeenCalled(); - expect(component.container).toMatchSnapshot(); - }); - - test('If onCallApiUrl is set, and updatePluginStatus returns an error, it sets an error message', async () => { - // mocks - const processEnvOnCallApiUrl = 'onCallApiUrlFromProcessEnv'; - const metaJsonDataOnCallApiUrl = 'onCallApiUrlFromMetaJsonData'; - - process.env.ONCALL_API_URL = processEnvOnCallApiUrl; - - PluginState.updatePluginStatus = jest.fn().mockResolvedValueOnce(CHECK_IF_PLUGIN_IS_CONNECTED_ERROR_MESSAGE); - - // test setup - const component = render(); - await screen.findByTestId(STATUS_MESSAGE_BLOCK_DATA_ID); - - // assertions - expect(PluginState.updatePluginStatus).toHaveBeenCalledTimes(1); - expect(PluginState.updatePluginStatus).toHaveBeenCalledWith(metaJsonDataOnCallApiUrl); - expect(component.container).toMatchSnapshot(); - }); - - test('OnCallApiUrl is set, and checkTokenAndIfPluginIsConnected returns an error', async () => { - // mocks - const processEnvOnCallApiUrl = 'onCallApiUrlFromProcessEnv'; - const metaJsonDataOnCallApiUrl = 'onCallApiUrlFromMetaJsonData'; - - process.env.ONCALL_API_URL = processEnvOnCallApiUrl; - - PluginState.updatePluginStatus = jest.fn().mockResolvedValueOnce(null); - PluginState.checkTokenAndIfPluginIsConnected = jest.fn().mockResolvedValueOnce(UPDATE_PLUGIN_STATUS_ERROR_MESSAGE); - - // test setup - const component = render(); - await screen.findByTestId(STATUS_MESSAGE_BLOCK_DATA_ID); - - // assertions - expect(PluginState.updatePluginStatus).toHaveBeenCalledTimes(1); - expect(PluginState.updatePluginStatus).toHaveBeenCalledWith(metaJsonDataOnCallApiUrl); - expect(component.container).toMatchSnapshot(); - }); - - test.each([License.CLOUD, License.OSS])( - 'OnCallApiUrl is set, and checkTokenAndIfPluginIsConnected 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.updatePluginStatus = jest.fn().mockResolvedValueOnce(null); - mockCheckTokenAndIfPluginIsConnected(license); - - // test setup - const component = render(); - await screen.findByTestId(STATUS_MESSAGE_BLOCK_DATA_ID); - - // assertions - expect(PluginState.updatePluginStatus).toHaveBeenCalledTimes(1); - expect(PluginState.updatePluginStatus).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; - window.location.reload = jest.fn(); - - PluginState.updatePluginStatus = jest.fn().mockResolvedValue(null); - mockCheckTokenAndIfPluginIsConnected(License.OSS); - - if (successful) { - PluginState.resetPlugin = jest.fn().mockResolvedValueOnce(null); - } else { - PluginState.resetPlugin = jest.fn().mockRejectedValueOnce('dfdf'); - } - - // test setup - const component = render(); - const button = await screen.findByRole('button'); - - // click the reset button, which opens the modal - await userEvent.click(button); - // click the confirm button within the modal, which actually triggers the callback - await userEvent.click(screen.getByText('Remove')); - - // assertions - expect(PluginState.updatePluginStatus).toHaveBeenCalledTimes(1); - expect(PluginState.updatePluginStatus).toHaveBeenCalledWith(metaJsonDataOnCallApiUrl); - - expect(PluginState.checkTokenAndIfPluginIsConnected).toHaveBeenCalledTimes(1); - expect(PluginState.checkTokenAndIfPluginIsConnected).toHaveBeenCalledWith(metaJsonDataOnCallApiUrl); - - expect(PluginState.resetPlugin).toHaveBeenCalledTimes(1); - expect(PluginState.resetPlugin).toHaveBeenCalledWith(); - - expect(component.container).toMatchSnapshot(); - }); -}); diff --git a/grafana-plugin/src/containers/PluginConfigPage/PluginConfigPage.tsx b/grafana-plugin/src/containers/PluginConfigPage/PluginConfigPage.tsx index 428c10bfc1..dc8be6b8bd 100644 --- a/grafana-plugin/src/containers/PluginConfigPage/PluginConfigPage.tsx +++ b/grafana-plugin/src/containers/PluginConfigPage/PluginConfigPage.tsx @@ -1,250 +1,251 @@ -import React, { FC, useCallback, useEffect, useState } from 'react'; - -import { Button, HorizontalGroup, Label, Legend, LinkButton, LoadingPlaceholder, VerticalGroup } from '@grafana/ui'; -import { useLocation } from 'react-router-dom'; -import { OnCallPluginConfigPageProps } from 'types'; - -import { PluginState, PluginStatusResponseBase } from 'state/plugin/plugin'; -import { FALLBACK_LICENSE, getOnCallApiUrl, GRAFANA_LICENSE_OSS, hasPluginBeenConfigured } from 'utils/consts'; - -import { ConfigurationForm } from './parts/ConfigurationForm/ConfigurationForm'; -import { RemoveCurrentConfigurationButton } from './parts/RemoveCurrentConfigurationButton/RemoveCurrentConfigurationButton'; -import { StatusMessageBlock } from './parts/StatusMessageBlock/StatusMessageBlock'; - -const PLUGIN_CONFIGURED_QUERY_PARAM = 'pluginConfigured'; -const PLUGIN_CONFIGURED_QUERY_PARAM_TRUTHY_VALUE = 'true'; - -const PLUGIN_CONFIGURED_LICENSE_QUERY_PARAM = 'pluginConfiguredLicense'; -const PLUGIN_CONFIGURED_VERSION_QUERY_PARAM = 'pluginConfiguredVersion'; - -/** - * 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}`; - } -}; - -/** - * 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); - } +import React from 'react'; + +import { css } from '@emotion/css'; +import { GrafanaTheme2, PluginConfigPageProps, PluginMeta } from '@grafana/data'; +import { Alert, Field, HorizontalGroup, Input, LoadingPlaceholder, useStyles2, VerticalGroup } from '@grafana/ui'; +import { observer } from 'mobx-react-lite'; +import { Controller, useForm } from 'react-hook-form'; +import { OnCallPluginMetaJSONData } from 'types'; + +import { Button } from 'components/Button/Button'; +import { CollapsibleTreeView } from 'components/CollapsibleTreeView/CollapsibleTreeView'; +import { Text } from 'components/Text/Text'; +import { ActionKey } from 'models/loader/action-keys'; +import { rootStore } from 'state/rootStore'; +import { + DOCS_ONCALL_OSS_INSTALL, + DOCS_SERVICE_ACCOUNTS, + PLUGIN_CONFIG, + PLUGIN_ROOT, + REQUEST_HELP_URL, +} from 'utils/consts'; +import { useOnMount } from 'utils/hooks'; +import { validateURL } from 'utils/string'; +import { getIsExternalServiceAccountFeatureAvailable, getIsRunningOpenSourceVersion } from 'utils/utils'; + +type PluginConfigFormValues = { + onCallApiUrl: string; }; -export const PluginConfigPage: FC = ({ - plugin: { - meta, - meta: { 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 pluginConfiguredRedirect = pluginConfiguredQueryParam === PLUGIN_CONFIGURED_QUERY_PARAM_TRUTHY_VALUE; - - const [checkingIfPluginIsConnected, setCheckingIfPluginIsConnected] = useState(!pluginConfiguredRedirect); - const [pluginConnectionCheckError, setPluginConnectionCheckError] = useState(null); - const [pluginIsConnected, setPluginIsConnected] = useState( - pluginConfiguredRedirect - ? { - version: pluginConfiguredVersionQueryParam, - license: pluginConfiguredLicenseQueryParam, - recaptcha_site_key: 'abc', - currently_undergoing_maintenance_message: 'false', - } - : null - ); - - const [updatingPluginStatus, setUpdatingPluginStatus] = useState(false); - const [updatingPluginStatusError, setUpdatingPluginStatusError] = useState(null); - - const [resettingPlugin, setResettingPlugin] = useState(false); - const [pluginResetError, setPluginResetError] = useState(null); - const licenseType = pluginIsConnected?.license || FALLBACK_LICENSE; - const onCallApiUrl = getOnCallApiUrl(meta); - - const resetQueryParams = useCallback(() => removePluginConfiguredQueryParams(pluginIsEnabled), [pluginIsEnabled]); - - const triggerUpdatePluginStatus = useCallback(async () => { - resetMessages(); - setUpdatingPluginStatus(true); - - const pluginConnectionStatus = await PluginState.checkTokenAndIfPluginIsConnected(onCallApiUrl); - - if (typeof pluginConnectionStatus === 'string') { - setUpdatingPluginStatusError(pluginConnectionStatus); - } else { - const { token_ok, ...versionLicenseInfo } = pluginConnectionStatus; - setPluginIsConnected(versionLicenseInfo); - reloadPageWithPluginConfiguredQueryParams(versionLicenseInfo, pluginIsEnabled); - } - - setUpdatingPluginStatus(false); - }, [onCallApiUrl, pluginIsEnabled]); - - useEffect(resetQueryParams, [resetQueryParams]); - - useEffect(() => { - const configurePluginAndUpdatePluginStatus = 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 (!hasPluginBeenConfigured(meta) && onCallApiUrl) { - /** - * 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(onCallApiUrl, true); - if (errorMsg) { - setPluginConnectionCheckError(errorMsg); - setCheckingIfPluginIsConnected(false); - return; - } - } - - /** - * 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.updatePluginStatus(onCallApiUrl); - - if (typeof pluginConnectionResponse === 'string') { - setPluginConnectionCheckError(pluginConnectionResponse); - } else { - triggerUpdatePluginStatus(); - } - } - 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) { - configurePluginAndUpdatePluginStatus(); - } - }, [onCallApiUrl, pluginConfiguredRedirect]); - - const resetMessages = useCallback(() => { - setPluginResetError(null); - setPluginConnectionCheckError(null); - setPluginIsConnected(null); - setUpdatingPluginStatusError(null); - }, []); - - const resetState = useCallback(() => { - resetMessages(); - resetQueryParams(); - }, [resetQueryParams]); - - const triggerPluginReset = useCallback(async () => { - setResettingPlugin(true); - resetState(); - - try { - await PluginState.resetPlugin(); - window.location.reload(); - } 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.'); - } +export const PluginConfigPage = observer((props: PluginConfigPageProps>) => { + const { + pluginStore: { verifyPluginConnection, refreshAppliedOnCallApiUrl }, + } = rootStore; - setResettingPlugin(false); - }, [resetState]); + useOnMount(() => { + refreshAppliedOnCallApiUrl(); + verifyPluginConnection(); + }); - const RemoveConfigButton = useCallback( - () => , - [resettingPlugin, triggerPluginReset] + return ( + + + Configure Grafana OnCall + + {props.plugin.meta.enabled && } + {getIsRunningOpenSourceVersion() ? : } + ); +}); + +const CloudPluginConfigPage = observer(() => { + const { + pluginStore: { isPluginConnected }, + } = rootStore; + const styles = useStyles2(getStyles); - const ReconfigurePluginButtons = () => ( - - - {licenseType === GRAFANA_LICENSE_OSS ? : null} - + return ( + <> + + This is a cloud-managed configuration. + + {!isPluginConnected && } + ); +}); + +const OSSPluginConfigPage = observer( + ({ plugin: { meta } }: PluginConfigPageProps>) => { + const { + pluginStore: { + updatePluginSettingsAndReinitializePlugin, + connectionStatus, + recreateServiceAccountAndRecheckPluginStatus, + isPluginConnected, + appliedOnCallApiUrl, + }, + loaderStore, + } = rootStore; + const styles = useStyles2(getStyles); + const { handleSubmit, control, formState } = useForm({ + mode: 'onChange', + values: { onCallApiUrl: appliedOnCallApiUrl }, + }); + const isReinitializating = loaderStore.isLoading(ActionKey.PLUGIN_UPDATE_SETTINGS_AND_REINITIALIZE); + const isRecreatingServiceAccount = loaderStore.isLoading(ActionKey.PLUGIN_RECREATE_SERVICE_ACCOUNT); + + const isSubmitButtonDisabled = !formState.isValid || !meta.enabled || isReinitializating; + + const onSubmit = async (values: PluginConfigFormValues) => { + await updatePluginSettingsAndReinitializePlugin({ + currentJsonData: meta.jsonData, + newJsonData: { onCallApiUrl: values.onCallApiUrl }, + }); + }; - let content: React.ReactNode; + const getCheckOrTextIcon = (isOk: boolean) => (isOk ? { customIcon: 'check' as const } : { isTextIcon: true }); - if (checkingIfPluginIsConnected) { - content = ; - } else if (updatingPluginStatus) { - content = ; - } else if (pluginConnectionCheckError || pluginResetError) { - content = ( + const enablePluginExpandedView = () => ( <> - - + Enable OnCall plugin + + Make sure that OnCall plugin has been enabled. + ); - } else if (updatingPluginStatusError) { - content = ( + + const serviceAccountTokenExpandedView = () => ( <> - - + Service account user allows to connect OnCall plugin to Grafana. + + Make sure that OnCall plugin has been enabled.{' '} + + Read more + + + + + {isRecreatingServiceAccount && } + ); - } else if (!pluginIsConnected) { - content = ; - } else { - // plugin is fully connected and synced - const pluginLink = ( - - Open Grafana OnCall - - ); - content = - licenseType === GRAFANA_LICENSE_OSS ? ( -
+ + const onCallApiUrlExpandedView = () => ( + <> + Let us know the backend URL for your OnCall API + + OnCall backend must be reachable from your Grafana Installation.
+ You can run hobby, dev or production backend. See{' '} + + here + {' '} + how to get started. +
+
+ ( + + + + )} + /> - {pluginLink} - + + {isReinitializating && } -
- ) : ( - - - {pluginLink} - - ); + + + ); + + const COMMON_CONFIG_ELEM_PARAMS = { + startingElemPosition: '-6px', + }; + + const configElements = [ + { + ...getCheckOrTextIcon(meta.enabled), + expandedView: enablePluginExpandedView, + }, + ...(getIsExternalServiceAccountFeatureAvailable() + ? [] + : [ + { + ...getCheckOrTextIcon(connectionStatus?.service_account_token?.ok), + expandedView: serviceAccountTokenExpandedView, + }, + ]), + { + ...getCheckOrTextIcon(connectionStatus?.oncall_api_url?.ok), + expandedView: onCallApiUrlExpandedView, + }, + ].map((elem) => ({ ...COMMON_CONFIG_ELEM_PARAMS, ...elem })); + + return ( +
+ + This page will help you to connect OnCall backend and OnCall Grafana plugin. + + +
+ ); } +); + +const PluginConfigAlert = observer(() => { + const { + pluginStore: { connectionStatus, isPluginConnected }, + } = rootStore; + if (!connectionStatus) { + return null; + } + if (isPluginConnected) { + return ( + + Go to{' '} + + Grafana OnCall + + + ); + } return ( - <> - Configure Grafana OnCall - {pluginIsConnected ? ( - <> - - - ) : ( -

This page will help you configure the OnCall plugin πŸ‘‹

- )} - {content} - + +
    + {Object.values(connectionStatus) + .filter(({ ok, error }) => !ok && Boolean(error) && error !== 'Not validated') + .map(({ error }) => ( +
  1. {error}
  2. + ))} +
+ window.location.reload()}> + Reload + +
); -}; +}); + +const getStyles = (theme: GrafanaTheme2) => ({ + secondaryTitle: css` + display: block; + margin-bottom: 12px; + `, + spinner: css` + margin-bottom: 0; + & path { + fill: ${theme.colors.text.primary}; + } + `, + treeView: css` + & path { + fill: ${theme.colors.success.text}; + } + margin-bottom: 100px; + `, +}); diff --git a/grafana-plugin/src/containers/PluginConfigPage/__snapshots__/PluginConfigPage.test.tsx.snap b/grafana-plugin/src/containers/PluginConfigPage/__snapshots__/PluginConfigPage.test.tsx.snap deleted file mode 100644 index 5adfe91afb..0000000000 --- a/grafana-plugin/src/containers/PluginConfigPage/__snapshots__/PluginConfigPage.test.tsx.snap +++ /dev/null @@ -1,536 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`PluginConfigPage If onCallApiUrl is not set in the plugin's meta jsonData, or in process.env, updatePluginStatus is not called, and the configuration form is shown 1`] = ` -
- - Configure Grafana OnCall - -

- This page will help you configure the OnCall plugin πŸ‘‹ -

-
-
-

- 1. Launch the OnCall backend -

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

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

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

- This page will help you configure the OnCall plugin πŸ‘‹ -

-
-    
-      ohhh nooo a plugin connection error
-    
-  
-
-
- -
-
- -
-
-
-`; - -exports[`PluginConfigPage It doesn't make any network calls if the plugin configured query params are provided 1`] = ` -
- - Configure Grafana OnCall - -
-    
-      Connected to OnCall (v1.2.3, OpenSource)
-    
-  
-
-
- -
- -
-
-
-
-`; - -exports[`PluginConfigPage OnCallApiUrl is set, and checkTokenAndIfPluginIsConnected does not return an error. It displays properly the plugin connected items based on the license - License: OpenSource 1`] = ` -
- - Configure Grafana OnCall - -
-    
-      Connected to OnCall (v1.2.3, OpenSource)
-    
-  
-
-
- -
- -
-
-
-
-`; - -exports[`PluginConfigPage OnCallApiUrl is set, and checkTokenAndIfPluginIsConnected does not return an error. It displays properly the plugin connected items based on the license - License: some-other-license 1`] = ` -
- - Configure Grafana OnCall - -
-    
-      Connected to OnCall (v1.2.3, some-other-license)
-    
-  
-
-
-
- -
-
- -
-
-`; - -exports[`PluginConfigPage OnCallApiUrl is set, and checkTokenAndIfPluginIsConnected returns an error 1`] = ` -
- - Configure Grafana OnCall - -

- This page will help you configure the OnCall plugin πŸ‘‹ -

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

- This page will help you configure the OnCall plugin πŸ‘‹ -

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

- This page will help you configure the OnCall plugin πŸ‘‹ -

-
-
-

- 1. Launch the OnCall backend -

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

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

- - The OnCall backend must be reachable from your Grafana installation. Some examples are: -
- - http://host.docker.internal:8080 -
- - http://localhost:8080 -
-
-
-
- -
-
-
-
-
- -
-
-
-
-
- -
-
-`; diff --git a/grafana-plugin/src/containers/PluginConfigPage/parts/ConfigurationForm/ConfigurationForm.module.css b/grafana-plugin/src/containers/PluginConfigPage/parts/ConfigurationForm/ConfigurationForm.module.css deleted file mode 100644 index 5c2d1cfd8d..0000000000 --- a/grafana-plugin/src/containers/PluginConfigPage/parts/ConfigurationForm/ConfigurationForm.module.css +++ /dev/null @@ -1,4 +0,0 @@ -.info-block { - margin-bottom: 24px; - margin-top: 24px; -} diff --git a/grafana-plugin/src/containers/PluginConfigPage/parts/ConfigurationForm/ConfigurationForm.test.tsx b/grafana-plugin/src/containers/PluginConfigPage/parts/ConfigurationForm/ConfigurationForm.test.tsx deleted file mode 100644 index 949ac61fa1..0000000000 --- a/grafana-plugin/src/containers/PluginConfigPage/parts/ConfigurationForm/ConfigurationForm.test.tsx +++ /dev/null @@ -1,75 +0,0 @@ -import React from 'react'; - -import { render, screen } from '@testing-library/react'; -import userEvent from '@testing-library/user-event'; - -import { PluginState } from 'state/plugin/plugin'; - -import { ConfigurationForm } from './ConfigurationForm'; - -jest.mock('state/plugin/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 component = render( - - ); - - // fill out onCallApiUrl input - const input = screen.getByTestId('onCallApiUrl'); - - await userEvent.click(input); - await userEvent.clear(input); // clear the input first before typing to wipe out the placeholder text - await userEvent.keyboard(onCallApiUrl); - - // submit form - await userEvent.click(screen.getByRole('button')); - - return { dom: component.baseElement, mockOnSuccessfulSetup }; -}; - -describe('ConfigurationForm', () => { - afterEach(() => { - jest.resetAllMocks(); - }); - - test('it sets the default input value of onCallApiUrl to the passed in prop value of defaultOnCallApiUrl', () => { - const processEnvOnCallApiUrl = 'http://hello.com'; - render(); - expect(screen.getByDisplayValue(processEnvOnCallApiUrl)).toBeInTheDocument(); - }); - - test('It calls the onSuccessfulSetup callback on successful form submission', async () => { - const { mockOnSuccessfulSetup } = await fillOutFormAndTryToSubmit(VALID_ONCALL_API_URL); - - expect(PluginState.selfHostedInstallPlugin).toHaveBeenCalledTimes(1); - expect(PluginState.selfHostedInstallPlugin).toHaveBeenCalledWith(VALID_ONCALL_API_URL, false); - expect(mockOnSuccessfulSetup).toHaveBeenCalledTimes(1); - }); - - test("It doesn't allow the user to submit if the URL is invalid", async () => { - const { dom, mockOnSuccessfulSetup } = await fillOutFormAndTryToSubmit('potato'); - - expect(PluginState.selfHostedInstallPlugin).toHaveBeenCalledTimes(0); - expect(mockOnSuccessfulSetup).toHaveBeenCalledTimes(0); - expect(screen.getByRole('button')).toBeDisabled(); - expect(dom).toMatchSnapshot(); - }); - - test('It shows an error message if the self hosted plugin API call fails', async () => { - const { dom, mockOnSuccessfulSetup } = await fillOutFormAndTryToSubmit(VALID_ONCALL_API_URL, false); - - expect(PluginState.selfHostedInstallPlugin).toHaveBeenCalledWith(VALID_ONCALL_API_URL, false); - expect(mockOnSuccessfulSetup).toHaveBeenCalledTimes(0); - expect(dom).toMatchSnapshot(); - }); -}); diff --git a/grafana-plugin/src/containers/PluginConfigPage/parts/ConfigurationForm/ConfigurationForm.tsx b/grafana-plugin/src/containers/PluginConfigPage/parts/ConfigurationForm/ConfigurationForm.tsx deleted file mode 100644 index 700515b43c..0000000000 --- a/grafana-plugin/src/containers/PluginConfigPage/parts/ConfigurationForm/ConfigurationForm.tsx +++ /dev/null @@ -1,128 +0,0 @@ -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/plugin'; - -import styles from './ConfigurationForm.module.css'; - -const cx = cn.bind(styles); - -type Props = { - onSuccessfulSetup: () => void; - defaultOnCallApiUrl: string; -}; - -type FormProps = { - onCallApiUrl: string; -}; - -/** - * https://stackoverflow.com/a/43467144 - */ -const isValidUrl = (url: string): boolean => { - try { - new URL(url); - return true; - } catch (_) { - return false; - } -}; - -const FormErrorMessage: FC<{ errorMsg: string }> = ({ errorMsg }) => ( - <> -
-      {errorMsg}
-    
- - - Need help? -
- Reach out to the OnCall team in the{' '} - - #grafana-oncall - {' '} - community Slack channel -
- Ask questions on our GitHub Discussions page{' '} - - here - {' '} -
- Or file bugs on our GitHub Issues page{' '} - - here - -
-
- -); - -export const ConfigurationForm: FC = ({ onSuccessfulSetup, defaultOnCallApiUrl }) => { - const [setupErrorMsg, setSetupErrorMsg] = useState(null); - const [formLoading, setFormLoading] = useState(false); - - const setupPlugin: SubmitHandler = useCallback(async ({ onCallApiUrl }) => { - setFormLoading(true); - - const errorMsg = await PluginState.selfHostedInstallPlugin(onCallApiUrl, false); - - if (!errorMsg) { - onSuccessfulSetup(); - } else { - setSetupErrorMsg(errorMsg); - setFormLoading(false); - } - }, []); - - return ( - - defaultValues={{ onCallApiUrl: defaultOnCallApiUrl }} - onSubmit={setupPlugin} - data-testid="plugin-configuration-form" - > - {({ register, errors }) => ( - <> -
-

1. Launch the OnCall backend

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

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

- - The OnCall backend must be reachable from your Grafana installation. Some examples are: -
- - http://host.docker.internal:8080 -
- http://localhost:8080 -
-
- - - - - - {setupErrorMsg && } - - - - )} - - ); -}; diff --git a/grafana-plugin/src/containers/PluginConfigPage/parts/ConfigurationForm/__snapshots__/ConfigurationForm.test.tsx.snap b/grafana-plugin/src/containers/PluginConfigPage/parts/ConfigurationForm/__snapshots__/ConfigurationForm.test.tsx.snap deleted file mode 100644 index edb160cbeb..0000000000 --- a/grafana-plugin/src/containers/PluginConfigPage/parts/ConfigurationForm/__snapshots__/ConfigurationForm.test.tsx.snap +++ /dev/null @@ -1,270 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`ConfigurationForm It doesn't allow the user to submit if the URL is invalid 1`] = ` - -
-
-
-

- 1. Launch the OnCall backend -

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

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

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

- 1. Launch the OnCall backend -

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

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

- - The OnCall backend must be reachable from your Grafana installation. Some examples are: -
- - http://host.docker.internal:8080 -
- - http://localhost:8080 -
-
-
-
- -
-
-
-
-
- -
-
-
-
-
-
-        
-          ohhh nooo there was an error from the OnCall API
-        
-      
-
- - Need help? -
- - Reach out to the OnCall team in the - - - - #grafana-oncall - - - - community Slack channel -
- - Ask questions on our GitHub Discussions page - - - - here - - - -
- - Or file bugs on our GitHub Issues page - - - - here - - -
-
- -
-
- -`; diff --git a/grafana-plugin/src/containers/PluginConfigPage/parts/RemoveCurrentConfigurationButton/RemoveCurrentConfigurationButton.test.tsx b/grafana-plugin/src/containers/PluginConfigPage/parts/RemoveCurrentConfigurationButton/RemoveCurrentConfigurationButton.test.tsx deleted file mode 100644 index d03cccb15c..0000000000 --- a/grafana-plugin/src/containers/PluginConfigPage/parts/RemoveCurrentConfigurationButton/RemoveCurrentConfigurationButton.test.tsx +++ /dev/null @@ -1,32 +0,0 @@ -import React from 'react'; - -import { render, screen } from '@testing-library/react'; -import userEvent from '@testing-library/user-event'; - -import { RemoveCurrentConfigurationButton } from './RemoveCurrentConfigurationButton'; - -describe('RemoveCurrentConfigurationButton', () => { - test('It renders properly when enabled', () => { - const component = render( {}} disabled={false} />); - expect(component.baseElement).toMatchSnapshot(); - }); - - test('It renders properly when disabled', () => { - const component = render( {}} disabled />); - expect(component.baseElement).toMatchSnapshot(); - }); - - test('It calls the onClick handler when clicked', async () => { - const mockedOnClick = jest.fn(); - - render(); - - // click the button, which opens the modal - await userEvent.click(screen.getByRole('button')); - // click the confirm button within the modal, which actually triggers the callback - await userEvent.click(screen.getByText('Remove')); - - expect(mockedOnClick).toHaveBeenCalledWith(); - expect(mockedOnClick).toHaveBeenCalledTimes(1); - }); -}); diff --git a/grafana-plugin/src/containers/PluginConfigPage/parts/RemoveCurrentConfigurationButton/RemoveCurrentConfigurationButton.tsx b/grafana-plugin/src/containers/PluginConfigPage/parts/RemoveCurrentConfigurationButton/RemoveCurrentConfigurationButton.tsx deleted file mode 100644 index 0571d18568..0000000000 --- a/grafana-plugin/src/containers/PluginConfigPage/parts/RemoveCurrentConfigurationButton/RemoveCurrentConfigurationButton.tsx +++ /dev/null @@ -1,18 +0,0 @@ -import React, { FC } from 'react'; - -import { Button } from '@grafana/ui'; - -import { WithConfirm } from 'components/WithConfirm/WithConfirm'; - -type Props = { - disabled: boolean; - onClick: () => void; -}; - -export const RemoveCurrentConfigurationButton: FC = ({ disabled, onClick }) => ( - - - -); diff --git a/grafana-plugin/src/containers/PluginConfigPage/parts/RemoveCurrentConfigurationButton/__snapshots__/RemoveCurrentConfigurationButton.test.tsx.snap b/grafana-plugin/src/containers/PluginConfigPage/parts/RemoveCurrentConfigurationButton/__snapshots__/RemoveCurrentConfigurationButton.test.tsx.snap deleted file mode 100644 index b31071e71e..0000000000 --- a/grafana-plugin/src/containers/PluginConfigPage/parts/RemoveCurrentConfigurationButton/__snapshots__/RemoveCurrentConfigurationButton.test.tsx.snap +++ /dev/null @@ -1,36 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`RemoveCurrentConfigurationButton It renders properly when disabled 1`] = ` - -
- -
- -`; - -exports[`RemoveCurrentConfigurationButton It renders properly when enabled 1`] = ` - -
- -
- -`; diff --git a/grafana-plugin/src/containers/PluginConfigPage/parts/StatusMessageBlock/StatusMessageBlock.test.tsx b/grafana-plugin/src/containers/PluginConfigPage/parts/StatusMessageBlock/StatusMessageBlock.test.tsx deleted file mode 100644 index 3708991c28..0000000000 --- a/grafana-plugin/src/containers/PluginConfigPage/parts/StatusMessageBlock/StatusMessageBlock.test.tsx +++ /dev/null @@ -1,12 +0,0 @@ -import React from 'react'; - -import { render } from '@testing-library/react'; - -import { StatusMessageBlock } from './StatusMessageBlock'; - -describe('StatusMessageBlock', () => { - test('It renders properly', async () => { - const component = render(); - expect(component.baseElement).toMatchSnapshot(); - }); -}); diff --git a/grafana-plugin/src/containers/PluginConfigPage/parts/StatusMessageBlock/StatusMessageBlock.tsx b/grafana-plugin/src/containers/PluginConfigPage/parts/StatusMessageBlock/StatusMessageBlock.tsx deleted file mode 100644 index 600b3ebec7..0000000000 --- a/grafana-plugin/src/containers/PluginConfigPage/parts/StatusMessageBlock/StatusMessageBlock.tsx +++ /dev/null @@ -1,13 +0,0 @@ -import React, { FC } from 'react'; - -import { Text } from 'components/Text/Text'; - -type Props = { - text: string; -}; - -export const StatusMessageBlock: FC = ({ text }) => ( -
-    {text}
-  
-); diff --git a/grafana-plugin/src/containers/PluginConfigPage/parts/StatusMessageBlock/__snapshots__/StatusMessageBlock.test.tsx.snap b/grafana-plugin/src/containers/PluginConfigPage/parts/StatusMessageBlock/__snapshots__/StatusMessageBlock.test.tsx.snap deleted file mode 100644 index d95332a6e3..0000000000 --- a/grafana-plugin/src/containers/PluginConfigPage/parts/StatusMessageBlock/__snapshots__/StatusMessageBlock.test.tsx.snap +++ /dev/null @@ -1,17 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`StatusMessageBlock It renders properly 1`] = ` - -
-
-      
-        helloooo
-      
-    
-
- -`; diff --git a/grafana-plugin/src/containers/PluginInitializer/PluginInitializer.tsx b/grafana-plugin/src/containers/PluginInitializer/PluginInitializer.tsx new file mode 100644 index 0000000000..0052795ffa --- /dev/null +++ b/grafana-plugin/src/containers/PluginInitializer/PluginInitializer.tsx @@ -0,0 +1,69 @@ +import React, { FC } from 'react'; + +import { Button, HorizontalGroup, LoadingPlaceholder, VerticalGroup } from '@grafana/ui'; +import { observer } from 'mobx-react'; +import { useHistory } from 'react-router-dom'; + +import { FullPageError } from 'components/FullPageError/FullPageError'; +import { RenderConditionally } from 'components/RenderConditionally/RenderConditionally'; +import { REQUEST_HELP_URL, PLUGIN_CONFIG } from 'utils/consts'; +import { useInitializePlugin } from 'utils/hooks'; +import { getIsRunningOpenSourceVersion } from 'utils/utils'; + +interface PluginInitializerProps { + children: React.ReactNode; +} + +export const PluginInitializer: FC = observer(({ children }) => { + const { isConnected, isCheckingConnectionStatus } = useInitializePlugin(); + + if (isCheckingConnectionStatus) { + return ( + + + + ); + } + return ( + } + render={() => <>{children}} + /> + ); +}); + +const PluginNotConnectedFullPageError = observer(() => { + const isOpenSource = getIsRunningOpenSourceVersion(); + const isCurrentUserAdmin = window.grafanaBootData.user.orgRole === 'Admin'; + const { push } = useHistory(); + + const getSubtitleExtension = () => { + if (!isOpenSource) { + return 'request help from our support team.'; + } + return isCurrentUserAdmin + ? 'go to plugin configuration page to establish connection.' + : 'contact your administrator.'; + }; + + return ( + + Looks like OnCall plugin hasn't been connected yet or has been misconfigured.
+ Retry or {getSubtitleExtension()} + + } + > + + + {!isOpenSource && } + {isOpenSource && isCurrentUserAdmin && } + +
+ ); +}); diff --git a/grafana-plugin/src/models/loader/action-keys.ts b/grafana-plugin/src/models/loader/action-keys.ts index ba8ac1760f..f89476f17b 100644 --- a/grafana-plugin/src/models/loader/action-keys.ts +++ b/grafana-plugin/src/models/loader/action-keys.ts @@ -1,4 +1,7 @@ export enum ActionKey { + PLUGIN_VERIFY_CONNECTION = 'PLUGIN_VERIFY_CONNECTION', + PLUGIN_UPDATE_SETTINGS_AND_REINITIALIZE = 'PLUGIN_UPDATE_SETTINGS_AND_REINITIALIZE', + PLUGIN_RECREATE_SERVICE_ACCOUNT = 'PLUGIN_RECREATE_SERVICE_ACCOUNT', UPDATE_INTEGRATION = 'UPDATE_INTEGRATION', ADD_NEW_COLUMN_TO_ALERT_GROUP = 'ADD_NEW_COLUMN_TO_ALERT_GROUP', REMOVE_COLUMN_FROM_ALERT_GROUP = 'REMOVE_COLUMN_FROM_ALERT_GROUP', @@ -9,7 +12,6 @@ export enum ActionKey { FETCH_INCIDENTS_POLLING = 'FETCH_INCIDENTS_POLLING', FETCH_INCIDENTS_AND_STATS = 'FETCH_INCIDENTS_AND_STATS', INCIDENTS_BULK_UPDATE = 'INCIDENTS_BULK_UPDATE', - UPDATE_FILTERS_AND_FETCH_INCIDENTS = 'UPDATE_FILTERS_AND_FETCH_INCIDENTS', UPDATE_SERVICENOW_TOKEN = 'UPDATE_SERVICENOW_TOKEN', FETCH_INTEGRATIONS = 'FETCH_INTEGRATIONS', diff --git a/grafana-plugin/src/models/plugin/plugin.helper.ts b/grafana-plugin/src/models/plugin/plugin.helper.ts new file mode 100644 index 0000000000..8feb154f86 --- /dev/null +++ b/grafana-plugin/src/models/plugin/plugin.helper.ts @@ -0,0 +1,9 @@ +import { makeRequest } from 'network/network'; + +export class PluginHelper { + static async install() { + return makeRequest(`/plugin/install`, { + method: 'POST', + }); + } +} diff --git a/grafana-plugin/src/models/plugin/plugin.ts b/grafana-plugin/src/models/plugin/plugin.ts new file mode 100644 index 0000000000..2120b42cf9 --- /dev/null +++ b/grafana-plugin/src/models/plugin/plugin.ts @@ -0,0 +1,93 @@ +import { isEqual } from 'lodash-es'; +import { makeAutoObservable, runInAction } from 'mobx'; +import { OnCallPluginMetaJSONData } from 'types'; + +import { ActionKey } from 'models/loader/action-keys'; +import { GrafanaApiClient } from 'network/grafana-api/http-client'; +import { makeRequest } from 'network/network'; +import { PluginConnection, PostStatusResponse } from 'network/oncall-api/api.types'; +import { RootBaseStore } from 'state/rootBaseStore/RootBaseStore'; +import { waitInMs } from 'utils/async'; +import { AutoLoadingState } from 'utils/decorators'; + +import { PluginHelper } from './plugin.helper'; + +/* +High-level OnCall initialization process: +On OSS: + - On OnCall page / OnCall extension mount POST /status is called and it has pluginConfiguration object with different flags. + If all of them have `ok: true` , we consider plugin to be successfully configured and application loading is being continued. + Otherwise, we show error page with the option to go to plugin config (for Admin user) or to contact administrator (for nonAdmin user) + - On plugin config page frontend sends another POST /status. If every flag has `ok: true`, it shows that plugin is connected. + Otherwise, it shows more detailed information of what is misconfigured / missing. User can update onCallApiUrl and try to reconnect plugin. + - If Grafana version >= 10.3 AND externalServiceAccount feature flag is `true`, then grafana token is autoprovisioned and there is no need to create it + - Otherwise, user is given the option to manually create service account as Admin and then reconnect the plugin +On Cloud: + - On OnCall page / OnCall extension mount POST /status is called. If plugin is configured correctly, application loads as usual. + If it's not, we show error page with the button to contact support + - On plugin config page we show info if plugin is connected. If it's not we show detailed information of the errors and the button to contact support +*/ + +export class PluginStore { + rootStore: RootBaseStore; + connectionStatus?: PluginConnection; + isPluginConnected = false; + appliedOnCallApiUrl = ''; + + constructor(rootStore: RootBaseStore) { + makeAutoObservable(this, undefined, { autoBind: true }); + this.rootStore = rootStore; + } + + private resetConnectionStatus() { + this.connectionStatus = undefined; + this.isPluginConnected = false; + } + + async refreshAppliedOnCallApiUrl() { + const { jsonData } = await GrafanaApiClient.getGrafanaPluginSettings(); + runInAction(() => { + this.appliedOnCallApiUrl = jsonData.onCallApiUrl; + }); + } + + @AutoLoadingState(ActionKey.PLUGIN_VERIFY_CONNECTION) + async verifyPluginConnection() { + const { pluginConnection } = await makeRequest(`/plugin/status`, { + method: 'POST', + }); + runInAction(() => { + this.connectionStatus = pluginConnection; + this.isPluginConnected = Object.keys(pluginConnection).every( + (key) => pluginConnection[key as keyof PluginConnection]?.ok + ); + }); + } + + @AutoLoadingState(ActionKey.PLUGIN_UPDATE_SETTINGS_AND_REINITIALIZE) + async updatePluginSettingsAndReinitializePlugin({ + currentJsonData, + newJsonData, + }: { + currentJsonData: OnCallPluginMetaJSONData; + newJsonData: Partial; + }) { + this.resetConnectionStatus(); + const saveJsonDataCandidate = { ...currentJsonData, ...newJsonData }; + if (!isEqual(currentJsonData, saveJsonDataCandidate) || !this.connectionStatus?.oncall_api_url?.ok) { + await GrafanaApiClient.updateGrafanaPluginSettings({ jsonData: saveJsonDataCandidate }); + await waitInMs(1000); // It's required for backend proxy to pick up new settings + } + try { + await PluginHelper.install(); + } finally { + await this.verifyPluginConnection(); + } + } + + @AutoLoadingState(ActionKey.PLUGIN_RECREATE_SERVICE_ACCOUNT) + async recreateServiceAccountAndRecheckPluginStatus() { + await GrafanaApiClient.recreateGrafanaTokenAndSaveInPluginSettings(); + await this.verifyPluginConnection(); + } +} diff --git a/grafana-plugin/src/models/user/user.types.ts b/grafana-plugin/src/models/user/user.types.ts deleted file mode 100644 index 7049e407f5..0000000000 --- a/grafana-plugin/src/models/user/user.types.ts +++ /dev/null @@ -1,3 +0,0 @@ -export interface MessagingBackends { - [key: string]: any; -} diff --git a/grafana-plugin/src/module.ts b/grafana-plugin/src/module.ts index fc932f07be..39edee39ab 100644 --- a/grafana-plugin/src/module.ts +++ b/grafana-plugin/src/module.ts @@ -5,8 +5,8 @@ import { AppPlugin, PluginExtensionPoints } from '@grafana/data'; import { MobileAppConnectionWrapper } from 'containers/MobileAppConnection/MobileAppConnection'; import { PluginConfigPage } from 'containers/PluginConfigPage/PluginConfigPage'; import { GrafanaPluginRootPage } from 'plugin/GrafanaPluginRootPage'; -import { getGrafanaVersion } from 'plugin/GrafanaPluginRootPage.helpers'; import { IRM_TAB } from 'utils/consts'; +import { isCurrentGrafanaVersionEqualOrGreaterThan } from 'utils/utils'; import { OnCallPluginConfigPageProps, OnCallPluginMetaJSONData } from './types'; @@ -33,11 +33,8 @@ if (isUseProfileExtensionPointEnabled()) { } function isUseProfileExtensionPointEnabled(): boolean { - const { major, minor } = getGrafanaVersion(); - const isRequiredGrafanaVersion = major > 10 || (major === 10 && minor >= 3); // >= 10.3.0 - return ( - isRequiredGrafanaVersion && + isCurrentGrafanaVersionEqualOrGreaterThan({ minMajor: 10, minMinor: 3 }) && 'configureExtensionComponent' in plugin && PluginExtensionPoints != null && 'UserProfileTab' in PluginExtensionPoints diff --git a/grafana-plugin/src/network/grafana-api/api.types.d.ts b/grafana-plugin/src/network/grafana-api/api.types.d.ts new file mode 100644 index 0000000000..918bef67b6 --- /dev/null +++ b/grafana-plugin/src/network/grafana-api/api.types.d.ts @@ -0,0 +1,52 @@ +import { OnCallPluginMetaJSONData, OnCallPluginMetaSecureJSONData } from 'types'; + +export type ServiceAccountDTO = { + description: string; + accessControl: { [key: string]: boolean }; + avatarUrl: string; + id: number; + isDisabled: boolean; + login: string; + name: string; + orgId: number; + role: string; + tokens: number; +}; + +export type PaginatedServiceAccounts = { + page: number; + perPage: number; + serviceAccounts: ServiceAccountDTO[]; + totalCount: number; +}; + +export type TokenDTO = { + created: string; + expiration: string; + hasExpired: boolean; + id: number; + isRevoked: boolean; + lastUsedAt: string; + name: string; + secondsUntilExpiration: number; +}; + +export type ApiAuthKeyDTO = { + accessControl: { [key: string]: boolean }; + expiration: string; + id: number; + lastUsedAt: string; + name: string; + role: 'None' | 'Viewer' | 'Editor' | 'Admin'; +}; + +export type NewApiKeyResult = { + id: number; + key: string; + name: string; +}; + +export type UpdateGrafanaPluginSettingsProps = { + jsonData?: Partial; + secureJsonData?: Partial; +}; diff --git a/grafana-plugin/src/network/grafana-api/http-client.ts b/grafana-plugin/src/network/grafana-api/http-client.ts new file mode 100644 index 0000000000..05fc0ff58b --- /dev/null +++ b/grafana-plugin/src/network/grafana-api/http-client.ts @@ -0,0 +1,88 @@ +import { getBackendSrv } from '@grafana/runtime'; +import { OnCallPluginMetaJSONData } from 'types'; + +import { + ApiAuthKeyDTO, + NewApiKeyResult, + PaginatedServiceAccounts, + ServiceAccountDTO, + TokenDTO, + UpdateGrafanaPluginSettingsProps, +} from './api.types'; + +const KEYS_BASE_URL = '/api/auth/keys'; +const SERVICE_ACCOUNTS_BASE_URL = '/api/serviceaccounts'; +const ONCALL_KEY_NAME = 'OnCall'; +const ONCALL_SERVICE_ACCOUNT_NAME = 'sa-autogen-OnCall'; +const GRAFANA_PLUGIN_SETTINGS_URL = '/api/plugins/grafana-oncall-app/settings'; + +export class GrafanaApiClient { + static grafanaBackend = getBackendSrv(); + + private static getServiceAccount = async () => { + const serviceAccounts = await this.grafanaBackend.get( + `${SERVICE_ACCOUNTS_BASE_URL}/search?query=${ONCALL_SERVICE_ACCOUNT_NAME}` + ); + return serviceAccounts.serviceAccounts.length > 0 ? serviceAccounts.serviceAccounts[0] : null; + }; + + private static getOrCreateServiceAccount = async () => { + const serviceAccount = await this.getServiceAccount(); + if (serviceAccount) { + return serviceAccount; + } + + return await this.grafanaBackend.post(SERVICE_ACCOUNTS_BASE_URL, { + name: ONCALL_SERVICE_ACCOUNT_NAME, + role: 'Admin', + isDisabled: false, + }); + }; + + private static getTokenFromServiceAccount = async (serviceAccount) => { + const tokens = await this.grafanaBackend.get( + `${SERVICE_ACCOUNTS_BASE_URL}/${serviceAccount.id}/tokens` + ); + return tokens.find(({ name }) => name === ONCALL_KEY_NAME); + }; + + private static getGrafanaToken = async () => { + const serviceAccount = await this.getServiceAccount(); + if (serviceAccount) { + return await this.getTokenFromServiceAccount(serviceAccount); + } + + const keys = await this.grafanaBackend.get(KEYS_BASE_URL); + return keys.find(({ name }) => name === ONCALL_KEY_NAME); + }; + + static updateGrafanaPluginSettings = async (data: UpdateGrafanaPluginSettingsProps, enabled = true) => + this.grafanaBackend.post(GRAFANA_PLUGIN_SETTINGS_URL, { ...data, enabled, pinned: true }); + + static getGrafanaPluginSettings = async () => + this.grafanaBackend.get<{ jsonData: OnCallPluginMetaJSONData }>(GRAFANA_PLUGIN_SETTINGS_URL); + + static recreateGrafanaTokenAndSaveInPluginSettings = async () => { + const serviceAccount = await this.getOrCreateServiceAccount(); + + const existingToken = await this.getTokenFromServiceAccount(serviceAccount); + if (existingToken) { + await this.grafanaBackend.delete(`${SERVICE_ACCOUNTS_BASE_URL}/${serviceAccount.id}/tokens/${existingToken.id}`); + } + + const existingKey = await this.getGrafanaToken(); + if (existingKey) { + await this.grafanaBackend.delete(`${KEYS_BASE_URL}/${existingKey.id}`); + } + + const { key: grafanaToken } = await this.grafanaBackend.post( + `${SERVICE_ACCOUNTS_BASE_URL}/${serviceAccount.id}/tokens`, + { + name: ONCALL_KEY_NAME, + role: 'Admin', + } + ); + + await this.updateGrafanaPluginSettings({ secureJsonData: { grafanaToken } }); + }; +} diff --git a/grafana-plugin/src/network/network.ts b/grafana-plugin/src/network/network.ts index 2604a44939..a5913bdeef 100644 --- a/grafana-plugin/src/network/network.ts +++ b/grafana-plugin/src/network/network.ts @@ -1,12 +1,10 @@ import axios, { AxiosError } from 'axios'; import qs from 'query-string'; +import { getOnCallApiPath } from 'utils/consts'; import { FaroHelper } from 'utils/faro'; import { safeJSONStringify } from 'utils/string'; -export const API_PROXY_PREFIX = 'api/plugin-proxy/grafana-oncall-app'; -export const API_PATH_PREFIX = '/api/internal/v1'; - const instance = axios.create(); instance.interceptors.request.use(function (config) { @@ -42,7 +40,7 @@ export const isNetworkError = axios.isAxiosError; export const makeRequestRaw = async (path: string, config: RequestConfig) => { const { method = 'GET', params, data, validateStatus, headers } = config; - const url = `${API_PROXY_PREFIX}${API_PATH_PREFIX}${path}`; + const url = getOnCallApiPath(path); try { FaroHelper.pushNetworkRequestEvent({ method, url, body: `${safeJSONStringify(data)}` }); diff --git a/grafana-plugin/src/network/oncall-api/api.types.d.ts b/grafana-plugin/src/network/oncall-api/api.types.d.ts index 7d98964289..701866a14e 100644 --- a/grafana-plugin/src/network/oncall-api/api.types.d.ts +++ b/grafana-plugin/src/network/oncall-api/api.types.d.ts @@ -1,3 +1,30 @@ import { components } from './autogenerated-api.types'; export type ApiSchemas = components['schemas']; + +type PluginConnectionCheck = { + ok: boolean; + error?: string; +}; + +type PluginConnection = { + settings: PluginConnectionCheck; + grafana_url_from_plugin: PluginConnectionCheck; + service_account_token: PluginConnectionCheck; + oncall_api_url: PluginConnectionCheck; + oncall_token: PluginConnectionCheck; + grafana_url_from_engine: PluginConnectionCheck; +}; + +export type PostStatusResponse = { + pluginConnection: PluginConnection; + allow_signup: boolean; + api_url: string; + currently_undergoing_maintenance_message: string | null; + is_installed: boolean; + is_user_anonymous: boolean; + license: string; + recaptcha_site_key: string; + token_ok: boolean; + version: string; +}; diff --git a/grafana-plugin/src/network/oncall-api/http-client.ts b/grafana-plugin/src/network/oncall-api/http-client.ts index 3586a355bf..c5976addbc 100644 --- a/grafana-plugin/src/network/oncall-api/http-client.ts +++ b/grafana-plugin/src/network/oncall-api/http-client.ts @@ -1,15 +1,13 @@ import createClient from 'openapi-fetch'; import qs from 'query-string'; +import { getOnCallApiPath } from 'utils/consts'; import { FaroHelper } from 'utils/faro'; import { safeJSONStringify } from 'utils/string'; import { formatBackendError, openErrorNotification } from 'utils/utils'; import { paths } from './autogenerated-api.types'; -export const API_PROXY_PREFIX = 'api/plugin-proxy/grafana-oncall-app'; -export const API_PATH_PREFIX = '/api/internal/v1'; - const showApiError = (status: number, errorData: string | Record) => { if (status >= 400 && status < 500) { const text = formatBackendError(errorData); @@ -56,7 +54,7 @@ export const getCustomFetchFn = }; const clientConfig = { - baseUrl: `${API_PROXY_PREFIX}${API_PATH_PREFIX}`, + baseUrl: getOnCallApiPath(), querySerializer: (params: unknown) => qs.stringify(params, { arrayFormat: 'none' }), }; diff --git a/grafana-plugin/src/pages/incident/Incident.tsx b/grafana-plugin/src/pages/incident/Incident.tsx index 6c4a60c7ce..60d056495d 100644 --- a/grafana-plugin/src/pages/incident/Incident.tsx +++ b/grafana-plugin/src/pages/incident/Incident.tsx @@ -30,9 +30,9 @@ import reactStringReplace from 'react-string-replace'; import { COLORS, getLabelBackgroundTextColorObject } from 'styles/utils.styles'; import { OnCallPluginExtensionPoints } from 'types'; -import errorSVG from 'assets/img/error.svg'; import { Collapse } from 'components/Collapse/Collapse'; import { ExtensionLinkDropdown } from 'components/ExtensionLinkMenu/ExtensionLinkDropdown'; +import { FullPageError } from 'components/FullPageError/FullPageError'; import { Block } from 'components/GBlock/Block'; import { IntegrationLogo } from 'components/IntegrationLogo/IntegrationLogo'; import { PageErrorHandlingWrapper, PageBaseState } from 'components/PageErrorHandlingWrapper/PageErrorHandlingWrapper'; @@ -882,14 +882,11 @@ function AttachedIncidentsList({ const AlertGroupStub = ({ buttons }: { buttons: React.ReactNode }) => { const styles = useStyles2(getStyles); return ( -
- - - An unexpected error happened - - OnCall is not able to receive any information about the current Alert Group. It's unknown if it's firing, - acknowledged, silenced, or resolved. - + + <>
@@ -897,8 +894,8 @@ const AlertGroupStub = ({ buttons }: { buttons: React.ReactNode }) => { {buttons} -
-
+ + ); }; @@ -1010,12 +1007,6 @@ const getStyles = (theme: GrafanaTheme2) => { text-align: center; `, - alertGroupStub: css` - margin: 24px auto; - width: 520px; - text-align: center; - `, - alertGroupStubDivider: css` width: 520px; `, diff --git a/grafana-plugin/src/pages/integration/Integration.tsx b/grafana-plugin/src/pages/integration/Integration.tsx index 6f2ec9b696..06b56305a6 100644 --- a/grafana-plugin/src/pages/integration/Integration.tsx +++ b/grafana-plugin/src/pages/integration/Integration.tsx @@ -22,11 +22,8 @@ import { RouteComponentProps, useHistory, withRouter } from 'react-router-dom'; import { getTemplatesForEdit } from 'components/AlertTemplates/AlertTemplatesForm.config'; import { TemplateForEdit } from 'components/AlertTemplates/CommonAlertTemplatesForm.config'; +import { CollapsibleTreeView, CollapsibleItem } from 'components/CollapsibleTreeView/CollapsibleTreeView'; import { HamburgerContextMenu } from 'components/HamburgerContextMenu/HamburgerContextMenu'; -import { - IntegrationCollapsibleTreeView, - IntegrationCollapsibleItem, -} from 'components/IntegrationCollapsibleTreeView/IntegrationCollapsibleTreeView'; import { IntegrationContactPoint } from 'components/IntegrationContactPoint/IntegrationContactPoint'; import { IntegrationHowToConnect } from 'components/IntegrationHowToConnect/IntegrationHowToConnect'; import { IntegrationLogoWithTitle } from 'components/IntegrationLogo/IntegrationLogoWithTitle'; @@ -171,7 +168,7 @@ class _IntegrationPage extends React.Component - + {isEditTemplateModalOpen && ( = [ + const configs: Array = [ (isAlerting || isLegacyAlerting) && { isHidden: isLegacyAlerting || contactPoints === null || contactPoints === undefined, isCollapsible: false, @@ -568,7 +565,7 @@ class _IntegrationPage extends React.Component ), }, - this.renderRoutesFn() as IntegrationCollapsibleItem[], + this.renderRoutesFn() as CollapsibleItem[], ]; return configs.filter(Boolean); @@ -618,7 +615,7 @@ class _IntegrationPage extends React.Component { + renderRoutesFn = (): CollapsibleItem[] => { const { store: { alertReceiveChannelStore }, match: { @@ -676,8 +673,8 @@ class _IntegrationPage extends React.Component ), - } as IntegrationCollapsibleItem) - ) as IntegrationCollapsibleItem[]; + } as CollapsibleItem) + ) as CollapsibleItem[]; }; handleEditRegexpRouteTemplate = (channelFilterId) => { diff --git a/grafana-plugin/src/pages/integration/OutgoingTab/OutgoingTab.tsx b/grafana-plugin/src/pages/integration/OutgoingTab/OutgoingTab.tsx index f03890a1c4..24e184347a 100644 --- a/grafana-plugin/src/pages/integration/OutgoingTab/OutgoingTab.tsx +++ b/grafana-plugin/src/pages/integration/OutgoingTab/OutgoingTab.tsx @@ -4,8 +4,8 @@ import { useStyles2, Input, IconButton, Drawer, HorizontalGroup } from '@grafana import { observer } from 'mobx-react'; import { Button } from 'components/Button/Button'; +import { CollapsibleTreeView } from 'components/CollapsibleTreeView/CollapsibleTreeView'; import { CopyToClipboardIcon } from 'components/CopyToClipboardIcon/CopyToClipboardIcon'; -import { IntegrationCollapsibleTreeView } from 'components/IntegrationCollapsibleTreeView/IntegrationCollapsibleTreeView'; import { IntegrationBlock } from 'components/Integrations/IntegrationBlock'; import { IntegrationTag } from 'components/Integrations/IntegrationTag'; import { Text } from 'components/Text/Text'; @@ -40,7 +40,7 @@ export const OutgoingTab = ({ openSnowConfigurationDrawer }: { openSnowConfigura )} - =9.2.0", "plugins": [] diff --git a/grafana-plugin/src/plugin/GrafanaPluginRootPage.helpers.test.tsx b/grafana-plugin/src/plugin/GrafanaPluginRootPage.helpers.test.tsx index afccfd510a..3b35235080 100644 --- a/grafana-plugin/src/plugin/GrafanaPluginRootPage.helpers.test.tsx +++ b/grafana-plugin/src/plugin/GrafanaPluginRootPage.helpers.test.tsx @@ -1,6 +1,6 @@ import * as runtime from '@grafana/runtime'; -import { getGrafanaVersion } from './GrafanaPluginRootPage.helpers'; +import { getGrafanaVersion } from 'utils/utils'; jest.mock('@grafana/runtime', () => ({ config: jest.fn(), diff --git a/grafana-plugin/src/plugin/GrafanaPluginRootPage.helpers.tsx b/grafana-plugin/src/plugin/GrafanaPluginRootPage.helpers.tsx index a167a71610..63b461beb8 100644 --- a/grafana-plugin/src/plugin/GrafanaPluginRootPage.helpers.tsx +++ b/grafana-plugin/src/plugin/GrafanaPluginRootPage.helpers.tsx @@ -4,21 +4,6 @@ export function isTopNavbar(): boolean { return !!config.featureToggles.topnav; } -export function getGrafanaVersion(): { major?: number; minor?: number; patch?: number } { - const regex = /^([1-9]?[0-9]*)\.([1-9]?[0-9]*)\.([1-9]?[0-9]*)/; - const match = config.buildInfo.version.match(regex); - - if (match) { - return { - major: Number(match[1]), - minor: Number(match[2]), - patch: Number(match[3]), - }; - } - - return {}; -} - export function getQueryParams(): any { const searchParams = new URLSearchParams(window.location.search); const result = {}; diff --git a/grafana-plugin/src/plugin/GrafanaPluginRootPage.tsx b/grafana-plugin/src/plugin/GrafanaPluginRootPage.tsx index 28ee755656..df20342e18 100644 --- a/grafana-plugin/src/plugin/GrafanaPluginRootPage.tsx +++ b/grafana-plugin/src/plugin/GrafanaPluginRootPage.tsx @@ -11,6 +11,7 @@ import { AppRootProps } from 'types'; import { RenderConditionally } from 'components/RenderConditionally/RenderConditionally'; import { Unauthorized } from 'components/Unauthorized/Unauthorized'; import { DefaultPageLayout } from 'containers/DefaultPageLayout/DefaultPageLayout'; +import { PluginInitializer } from 'containers/PluginInitializer/PluginInitializer'; import { NoMatch } from 'pages/NoMatch'; import { EscalationChainsPage } from 'pages/escalation-chains/EscalationChains'; import { IncidentPage } from 'pages/incident/Incident'; @@ -27,7 +28,6 @@ import { ChatOpsPage } from 'pages/settings/tabs/ChatOps/ChatOps'; import { CloudPage } from 'pages/settings/tabs/Cloud/CloudPage'; import LiveSettings from 'pages/settings/tabs/LiveSettings/LiveSettingsPage'; import { UsersPage } from 'pages/users/Users'; -import { PluginSetup } from 'plugin/PluginSetup/PluginSetup'; import { rootStore } from 'state/rootStore'; import { useStore } from 'state/useStore'; import { isUserActionAllowed } from 'utils/authorization/authorization'; @@ -42,7 +42,7 @@ import { getQueryParams, isTopNavbar } from './GrafanaPluginRootPage.helpers'; import grafanaGlobalStyle from '!raw-loader!assets/style/grafanaGlobalStyles.css'; -export const GrafanaPluginRootPage = (props: AppRootProps) => { +export const GrafanaPluginRootPage = observer((props: AppRootProps) => { useOnMount(() => { FaroHelper.initializeFaro(getOnCallApiUrl(props.meta)); }); @@ -50,13 +50,15 @@ export const GrafanaPluginRootPage = (props: AppRootProps) => { return ( {() => ( - - - + + + + + )} ); -}; +}); export const Root = observer((props: AppRootProps) => { const { isBasicDataLoaded, loadBasicData, loadMasterData, pageTitle } = useStore(); diff --git a/grafana-plugin/src/plugin/PluginSetup/PluginSetup.test.tsx b/grafana-plugin/src/plugin/PluginSetup/PluginSetup.test.tsx deleted file mode 100644 index 543dd6bc72..0000000000 --- a/grafana-plugin/src/plugin/PluginSetup/PluginSetup.test.tsx +++ /dev/null @@ -1,112 +0,0 @@ -import React from 'react'; - -import * as runtime from '@grafana/runtime'; -import { render, screen } from '@testing-library/react'; -import userEvent from '@testing-library/user-event'; - -import { RootBaseStore } from 'state/rootBaseStore/RootBaseStore'; -import { useStore as useStoreOriginal } from 'state/useStore'; - -import { PluginSetup, PluginSetupProps } from './PluginSetup'; - -jest.mock('state/useStore'); - -jest.mock('@grafana/runtime', () => ({ - __esModule: true, - - config: { - featureToggles: { - topnav: undefined, - }, - }, - - getBackendSrv: () => ({ - get: jest.fn(), - post: jest.fn(), - }), - - PluginPage: (props: any) => <>{props.children}, -})); - -jest.mock('grafana/app/core/core', () => ({ - contextSrv: { - user: { - orgRole: null, - }, - hasAccess: (_action, _fallback): boolean => null, - }, -})); - -const createComponentAndMakeAssertions = async (rootBaseStore: RootBaseStore) => { - // mocks - const mockedSetupPlugin = jest.fn(); - rootBaseStore.setupPlugin = mockedSetupPlugin; - (useStoreOriginal as jest.Mock>).mockReturnValue(rootBaseStore); - - // test setup - const MockedInitializedComponent = jest.fn().mockReturnValue(
hello
); - - const props = { - meta: { - jsonData: 'hello', - }, - InitializedComponent: MockedInitializedComponent, - } as unknown as PluginSetupProps; - - const component = render(); - - // assertions - expect(mockedSetupPlugin).toHaveBeenCalledTimes(1); - expect(mockedSetupPlugin).toHaveBeenCalledWith(props.meta); - expect(component.container).toMatchSnapshot(); - - return mockedSetupPlugin; -}; - -describe('PluginSetup', () => { - afterEach(() => { - jest.resetAllMocks(); - }); - - test('app is loading', async () => { - const rootBaseStore = new RootBaseStore(); - await createComponentAndMakeAssertions(rootBaseStore); - }); - - test('there is an error message', async () => { - const rootBaseStore = new RootBaseStore(); - rootBaseStore.initializationError = 'ohhhh noo'; - await createComponentAndMakeAssertions(rootBaseStore); - }); - - test('there is an error message - retry setup', async () => { - const rootBaseStore = new RootBaseStore(); - rootBaseStore.initializationError = 'ohhhh noo'; - - const mockedSetupPlugin = await createComponentAndMakeAssertions(rootBaseStore); - - await userEvent.click(screen.getByText('Retry')); - - expect(mockedSetupPlugin).toHaveBeenCalledTimes(2); - }); - - test('currently undergoing maintenance', async () => { - const rootBaseStore = new RootBaseStore(); - rootBaseStore.currentlyUndergoingMaintenance = true; - rootBaseStore.initializationError = 'there is some sort of maintenance'; - await createComponentAndMakeAssertions(rootBaseStore); - }); - - test('app successfully initialized', async () => { - const rootBaseStore = new RootBaseStore(); - rootBaseStore.initializationError = null; - await createComponentAndMakeAssertions(rootBaseStore); - }); - - test.each([true, false])('app initialized with topnavbar = %s', async (isTopNavBar: boolean) => { - runtime.config.featureToggles.topnav = isTopNavBar; - - const rootBaseStore = new RootBaseStore(); - await createComponentAndMakeAssertions(rootBaseStore); - }); -}); diff --git a/grafana-plugin/src/plugin/PluginSetup/PluginSetup.tsx b/grafana-plugin/src/plugin/PluginSetup/PluginSetup.tsx deleted file mode 100644 index ce2345811f..0000000000 --- a/grafana-plugin/src/plugin/PluginSetup/PluginSetup.tsx +++ /dev/null @@ -1,67 +0,0 @@ -import React, { FC, PropsWithChildren, useCallback, useEffect } from 'react'; - -import { PluginPage as RealPluginPage } from '@grafana/runtime'; // Use the one from @grafana, not our wrapped PluginPage -import { Button, HorizontalGroup, LinkButton } from '@grafana/ui'; -import { PluginPageFallback } from 'PluginPage'; -import { observer } from 'mobx-react'; -import { AppRootProps } from 'types'; - -import logo from 'assets/img/logo.svg'; -import { isTopNavbar } from 'plugin/GrafanaPluginRootPage.helpers'; -import { useStore } from 'state/useStore'; -import { loadJs } from 'utils/loadJs'; - -export type PluginSetupProps = AppRootProps & { - InitializedComponent: (props: AppRootProps) => JSX.Element; -}; - -type PluginSetupWrapperProps = PropsWithChildren<{ - text: string; -}>; - -const PluginSetupWrapper: FC = ({ text, children }) => { - const PluginPage = (isTopNavbar() ? RealPluginPage : PluginPageFallback) as React.ComponentType; - - return ( - -
- Grafana OnCall Logo -
{text}
- {children} -
-
- ); -}; - -export const PluginSetup: FC = observer(({ InitializedComponent, ...props }) => { - const store = useStore(); - const setupPlugin = useCallback(() => store.setupPlugin(props.meta), [props.meta]); - - useEffect(() => { - (async function () { - await setupPlugin(); - store.recaptchaSiteKey && - loadJs(`https://www.google.com/recaptcha/api.js?render=${store.recaptchaSiteKey}`, store.recaptchaSiteKey); - })(); - }, [setupPlugin]); - - if (store.initializationError) { - return ( - - {!store.currentlyUndergoingMaintenance && ( -
- - - - Configure Plugin - - -
- )} -
- ); - } - return ; -}); diff --git a/grafana-plugin/src/plugin/PluginSetup/__snapshots__/PluginSetup.test.tsx.snap b/grafana-plugin/src/plugin/PluginSetup/__snapshots__/PluginSetup.test.tsx.snap deleted file mode 100644 index 0e6e8207db..0000000000 --- a/grafana-plugin/src/plugin/PluginSetup/__snapshots__/PluginSetup.test.tsx.snap +++ /dev/null @@ -1,163 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`PluginSetup app initialized with topnavbar = false 1`] = ` -
-
- hello -
-
-`; - -exports[`PluginSetup app initialized with topnavbar = true 1`] = ` -
-
- hello -
-
-`; - -exports[`PluginSetup app is loading 1`] = ` -
-
- hello -
-
-`; - -exports[`PluginSetup app successfully initialized 1`] = ` -
-
- hello -
-
-`; - -exports[`PluginSetup currently undergoing maintenance 1`] = ` -
-
- Grafana OnCall Logo -
- there is some sort of maintenance -
-
-
-`; - -exports[`PluginSetup there is an error message - retry setup 1`] = ` -
-
- Grafana OnCall Logo -
- ohhhh noo -
-
-
-
- -
- -
-
-
-
-`; - -exports[`PluginSetup there is an error message 1`] = ` -
-
- Grafana OnCall Logo -
- ohhhh noo -
-
-
-
- -
- -
-
-
-
-`; diff --git a/grafana-plugin/src/state/plugin/__snapshots__/plugin.test.ts.snap b/grafana-plugin/src/state/plugin/__snapshots__/plugin.test.ts.snap deleted file mode 100644 index 8152d352b8..0000000000 --- a/grafana-plugin/src/state/plugin/__snapshots__/plugin.test.ts.snap +++ /dev/null @@ -1,57 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`PluginState.generateInvalidOnCallApiURLErrorMsg it returns the proper error message - configured through env var: false 1`] = ` -"Could not communicate with OnCall API at http://hello.com. -Validate that the URL is correct, 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 OnCall API at http://hello.com (NOTE: OnCall API URL is currently being taken from process.env of your UI). -Validate that the URL is correct, 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: 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 occurred when trying to install the plugin. Verify 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 occurred when trying to sync the plugin. Verify 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 occurred when trying to install the plugin. Verify OnCall API URL, http://hello.com, is correct (NOTE: 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 occurred when trying to sync the plugin. Verify OnCall API URL, http://hello.com, is correct (NOTE: 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 network error properly - has custom error message: false 1`] = ` -"An unknown error occurred when trying to install the plugin. Verify OnCall API URL, http://hello.com, is correct (NOTE: 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 network error properly - has custom error message: true 1`] = `"ohhhh nooo an error"`; - -exports[`PluginState.getHumanReadableErrorFromOnCallError it handles a non-400 network error properly - status code: 409 1`] = ` -"An unknown error occurred when trying to install the plugin. Verify OnCall API URL, http://hello.com, is correct (NOTE: 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 network error properly - status code: 502 1`] = ` -"Could not communicate with OnCall API at http://hello.com (NOTE: OnCall API URL is currently being taken from process.env of your UI). -Validate that the URL is correct, 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 occurred when trying to install the plugin. Verify OnCall API URL, http://hello.com, is correct (NOTE: 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." -`; diff --git a/grafana-plugin/src/state/plugin/plugin.test.ts b/grafana-plugin/src/state/plugin/plugin.test.ts deleted file mode 100644 index 7effdff2b1..0000000000 --- a/grafana-plugin/src/state/plugin/plugin.test.ts +++ /dev/null @@ -1,520 +0,0 @@ -import { makeRequest as makeRequestOriginal, isNetworkError as isNetworkErrorOriginal } from 'network/network'; - -import { PluginState, InstallationVerb, UpdateGrafanaPluginSettingsProps } from './plugin'; - -const makeRequest = makeRequestOriginal as jest.Mock>; -const isNetworkError = isNetworkErrorOriginal as unknown as jest.Mock>; - -jest.mock('network/network'); - -afterEach(() => { - jest.resetAllMocks(); -}); - -const ONCALL_BASE_URL = '/plugin'; -const GRAFANA_PLUGIN_SETTINGS_URL = '/api/plugins/grafana-oncall-app/settings'; - -const generateMockNetworkError = (status: number, data = {}) => ({ 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', () => { - beforeEach(() => { - console.warn = () => {}; - }); - - test.each([502, 409])('it handles a non-400 network error properly - status code: %s', (status) => { - isNetworkError.mockReturnValueOnce(true); - - expect( - PluginState.getHumanReadableErrorFromOnCallError( - generateMockNetworkError(status), - 'http://hello.com', - 'install', - true - ) - ).toMatchSnapshot(); - }); - - test.each([true, false])( - 'it handles a 400 network error properly - has custom error message: %s', - (hasCustomErrorMessage) => { - isNetworkError.mockReturnValueOnce(true); - - const networkError = generateMockNetworkError(400) as any; - if (hasCustomErrorMessage) { - networkError.response.data = { error: 'ohhhh nooo an error' }; - } - expect( - PluginState.getHumanReadableErrorFromOnCallError(networkError, 'http://hello.com', 'install', true) - ).toMatchSnapshot(); - } - ); - - test('it handles an unknown error properly', () => { - isNetworkError.mockReturnValueOnce(false); - - expect( - PluginState.getHumanReadableErrorFromOnCallError(new Error('asdfasdf'), 'http://hello.com', 'install', true) - ).toMatchSnapshot(); - }); -}); - -describe('PluginState.getHumanReadableErrorFromGrafanaProvisioningError', () => { - beforeEach(() => { - console.warn = () => {}; - }); - - test.each([true, false])('it handles an error properly - network error: %s', (networkError) => { - const onCallApiUrl = 'http://hello.com'; - const installationVerb = 'install'; - const onCallApiUrlIsConfiguredThroughEnvVar = true; - const error = networkError ? generateMockNetworkError(400) : new Error('oh noooo'); - - const mockGenerateInvalidOnCallApiURLErrorMsgResult = 'asdadslkjfkjlsd'; - const mockGenerateUnknownErrorMsgResult = 'asdadslkjfkjlsd'; - - isNetworkError.mockReturnValueOnce(networkError); - - PluginState.generateInvalidOnCallApiURLErrorMsg = jest - .fn() - .mockReturnValueOnce(mockGenerateInvalidOnCallApiURLErrorMsgResult); - PluginState.generateUnknownErrorMsg = jest.fn().mockReturnValueOnce(mockGenerateUnknownErrorMsgResult); - - const expectedErrorMsg = networkError - ? mockGenerateInvalidOnCallApiURLErrorMsgResult - : mockGenerateUnknownErrorMsgResult; - - expect( - PluginState.getHumanReadableErrorFromGrafanaProvisioningError( - error, - onCallApiUrl, - installationVerb, - onCallApiUrlIsConfiguredThroughEnvVar - ) - ).toEqual(expectedErrorMsg); - - if (networkError) { - 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', () => { - const cases = [ - [true, true, false], - [true, false, false], - [false, true, true], - [false, true, false], - [false, false, false], - ]; - - test.each(cases)( - 'it calls the proper methods - existing key: %s, existing sa: %s, existing token: %s', - async (apiKeyExists, saExists, apiTokenExists) => { - const baseUrl = PluginState.KEYS_BASE_URL; - const serviceAccountBaseUrl = PluginState.SERVICE_ACCOUNTS_BASE_URL; - const apiKeyId = 12345; - const apiKeyName = PluginState.ONCALL_KEY_NAME; - const apiKey = { name: apiKeyName, id: apiKeyId }; - const saId = 33333; - const serviceAccount = { id: saId }; - - PluginState.getGrafanaToken = jest.fn().mockReturnValueOnce(apiKeyExists ? apiKey : null); - PluginState.grafanaBackend.delete = jest.fn(); - PluginState.grafanaBackend.post = jest.fn(); - - PluginState.getServiceAccount = jest.fn().mockReturnValueOnce(saExists ? serviceAccount : null); - PluginState.getOrCreateServiceAccount = jest.fn().mockReturnValueOnce(serviceAccount); - PluginState.getTokenFromServiceAccount = jest.fn().mockReturnValueOnce(apiTokenExists ? apiKey : null); - - await PluginState.createGrafanaToken(); - - expect(PluginState.getGrafanaToken).toHaveBeenCalledTimes(1); - - if (apiKeyExists) { - expect(PluginState.grafanaBackend.delete).toHaveBeenCalledTimes(1); - expect(PluginState.grafanaBackend.delete).toHaveBeenCalledWith(`${baseUrl}/${apiKey.id}`); - } else if (apiTokenExists) { - expect(PluginState.grafanaBackend.delete).toHaveBeenCalledTimes(1); - expect(PluginState.grafanaBackend.delete).toHaveBeenCalledWith( - `${serviceAccountBaseUrl}/${serviceAccount.id}/tokens/${apiKey.id}` - ); - } else { - expect(PluginState.grafanaBackend.delete).not.toHaveBeenCalled(); - } - - expect(PluginState.grafanaBackend.post).toHaveBeenCalledTimes(1); - expect(PluginState.grafanaBackend.post).toHaveBeenCalledWith( - `${serviceAccountBaseUrl}/${serviceAccount.id}/tokens`, - { - name: apiKeyName, - role: 'Admin', - } - ); - } - ); -}); - -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).toHaveBeenCalledTimes(1); - expect(PluginState.createGrafanaToken).toHaveBeenCalledWith(); - - expect(PluginState.updateGrafanaPluginSettings).toHaveBeenCalledTimes(1); - expect(PluginState.updateGrafanaPluginSettings).toHaveBeenCalledWith({ - secureJsonData: { - grafanaToken, - }, - }); - - expect(makeRequest).toHaveBeenCalledTimes(1); - expect(makeRequest).toHaveBeenCalledWith( - `${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.updatePluginStatus', () => { - test('it returns the API response', async () => { - // mocks - const mockedResp = { foo: 'bar' }; - const onCallApiUrl = 'http://hello.com'; - makeRequest.mockResolvedValueOnce(mockedResp); - - // test - const response = await PluginState.updatePluginStatus(onCallApiUrl); - - // assertions - expect(response).toEqual(mockedResp); - - expect(makeRequest).toHaveBeenCalledTimes(1); - expect(makeRequest).toHaveBeenCalledWith(`${ONCALL_BASE_URL}/status`, { method: 'POST' }); - }); - - 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.updatePluginStatus(onCallApiUrl); - - // assertions - expect(response).toEqual(mockedHumanReadableError); - - expect(makeRequest).toHaveBeenCalledTimes(1); - expect(makeRequest).toHaveBeenCalledWith(`${ONCALL_BASE_URL}/status`, { method: 'POST' }); - - expect(PluginState.getHumanReadableErrorFromOnCallError).toHaveBeenCalledTimes(1); - expect(PluginState.getHumanReadableErrorFromOnCallError).toHaveBeenCalledWith( - mockedError, - onCallApiUrl, - 'install', - false - ); - }); -}); - -describe('PluginState.resetPlugin', () => { - test('it calls grafanaBackend.post with the proper settings', async () => { - // mocks - const mockedResponse = 'asdfasdf'; - PluginState.updateGrafanaPluginSettings = jest.fn().mockResolvedValueOnce(mockedResponse); - - // test - const response = await PluginState.resetPlugin(); - - // assertions - expect(response).toEqual(mockedResponse); - expect(PluginState.updateGrafanaPluginSettings).toHaveBeenCalledTimes(1); - expect(PluginState.updateGrafanaPluginSettings).toHaveBeenCalledWith( - { - jsonData: { - stackId: null, - orgId: null, - onCallApiUrl: null, - license: null, - }, - secureJsonData: { - grafanaToken: null, - onCallApiToken: null, - }, - }, - false - ); - }); -}); diff --git a/grafana-plugin/src/state/plugin/plugin.ts b/grafana-plugin/src/state/plugin/plugin.ts deleted file mode 100644 index 3c71c2b5a4..0000000000 --- a/grafana-plugin/src/state/plugin/plugin.ts +++ /dev/null @@ -1,343 +0,0 @@ -import { getBackendSrv } from '@grafana/runtime'; -import { OnCallAppPluginMeta, OnCallPluginMetaJSONData, OnCallPluginMetaSecureJSONData } from 'types'; - -import { makeRequest, isNetworkError } from 'network/network'; - -export type UpdateGrafanaPluginSettingsProps = { - jsonData?: Partial; - secureJsonData?: Partial; -}; - -export type PluginStatusResponseBase = Pick & { - version: string; - recaptcha_site_key: string; - currently_undergoing_maintenance_message: string; -}; - -export type PluginSyncStatusResponse = PluginStatusResponseBase & { - token_ok: boolean; - recaptcha_site_key: string; -}; - -type PluginConnectedStatusResponse = PluginStatusResponseBase & { - is_installed: boolean; - token_ok: boolean; - allow_signup: boolean; - is_user_anonymous: boolean; -}; - -type CloudProvisioningConfigResponse = null; - -type SelfHostedProvisioningConfigResponse = Omit & { - onCallToken: string; -}; - -type InstallPluginResponse = Pick & { - onCallAPIResponse: OnCallAPIResponse; -}; - -export type InstallationVerb = 'install' | 'sync'; - -export class PluginState { - static ONCALL_BASE_URL = '/plugin'; - static GRAFANA_PLUGIN_SETTINGS_URL = '/api/plugins/grafana-oncall-app/settings'; - static grafanaBackend = getBackendSrv(); - - static generateOnCallApiUrlConfiguredThroughEnvVarMsg = (isConfiguredThroughEnvVar: boolean): string => - isConfiguredThroughEnvVar ? ' (NOTE: OnCall API URL is currently being taken from process.env of your UI)' : ''; - - static generateInvalidOnCallApiURLErrorMsg = (onCallApiUrl: string, isConfiguredThroughEnvVar: boolean): string => - `Could not communicate with OnCall API at ${onCallApiUrl}${this.generateOnCallApiUrlConfiguredThroughEnvVarMsg( - isConfiguredThroughEnvVar - )}.\nValidate that the URL is correct, 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 occurred when trying to ${verb} the plugin. Verify 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 = `occurred while trying to ${installationVerb} the plugin w/ the OnCall backend`; - - if (isNetworkError(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-network related error occurred.. 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 (isNetworkError(e)) { - // The user likely put in a bogus URL for the OnCall API URL - console.warn('An HTTP related error occurred while trying to provision the plugin w/ Grafana', e.response); - errorMsg = this.generateInvalidOnCallApiURLErrorMsg(onCallApiUrl, onCallApiUrlIsConfiguredThroughEnvVar); - } else { - // a non-network related error occurred.. this scenario shouldn't occur... - console.warn('An unknown error occurred while trying to provision the plugin w/ Grafana', e); - errorMsg = this.generateUnknownErrorMsg(onCallApiUrl, installationVerb, onCallApiUrlIsConfiguredThroughEnvVar); - } - return errorMsg; - }; - - static getGrafanaPluginSettings = async (): Promise => - this.grafanaBackend.get(this.GRAFANA_PLUGIN_SETTINGS_URL); - - static updateGrafanaPluginSettings = async (data: UpdateGrafanaPluginSettingsProps, enabled = true) => - this.grafanaBackend.post(this.GRAFANA_PLUGIN_SETTINGS_URL, { ...data, enabled, pinned: true }); - - static readonly KEYS_BASE_URL = '/api/auth/keys'; - static readonly ONCALL_KEY_NAME = 'OnCall'; - static readonly SERVICE_ACCOUNTS_BASE_URL = '/api/serviceaccounts'; - static readonly ONCALL_SERVICE_ACCOUNT_NAME = 'sa-autogen-OnCall'; - static readonly SERVICE_ACCOUNTS_SEARCH_URL = `${PluginState.SERVICE_ACCOUNTS_BASE_URL}/search?query=${PluginState.ONCALL_SERVICE_ACCOUNT_NAME}`; - - static getServiceAccount = async () => { - const serviceAccounts = await this.grafanaBackend.get(this.SERVICE_ACCOUNTS_SEARCH_URL); - return serviceAccounts.serviceAccounts.length > 0 ? serviceAccounts.serviceAccounts[0] : null; - }; - - static getOrCreateServiceAccount = async () => { - const serviceAccount = await this.getServiceAccount(); - if (serviceAccount) { - return serviceAccount; - } - - return await this.grafanaBackend.post(this.SERVICE_ACCOUNTS_BASE_URL, { - name: this.ONCALL_SERVICE_ACCOUNT_NAME, - role: 'Admin', - isDisabled: false, - }); - }; - - static getTokenFromServiceAccount = async (serviceAccount) => { - const tokens = await this.grafanaBackend.get(`${this.SERVICE_ACCOUNTS_BASE_URL}/${serviceAccount.id}/tokens`); - return tokens.find((key: { id: number; name: string; role: string }) => key.name === PluginState.ONCALL_KEY_NAME); - }; - - /** - * This will satisfy a check for an existing key regardless of if the key is an older api key or under a - * service account. - */ - static getGrafanaToken = async () => { - const serviceAccount = await this.getServiceAccount(); - if (serviceAccount) { - return await this.getTokenFromServiceAccount(serviceAccount); - } - - const keys = await this.grafanaBackend.get(this.KEYS_BASE_URL); - const oncallApiKeys = keys.find( - (key: { id: number; name: string; role: string }) => key.name === PluginState.ONCALL_KEY_NAME - ); - if (oncallApiKeys) { - return oncallApiKeys; - } - - return null; - }; - - /** - * Create service account and api token belonging to it instead of using api keys - */ - static createGrafanaToken = async () => { - const serviceAccount = await this.getOrCreateServiceAccount(); - const existingToken = await this.getTokenFromServiceAccount(serviceAccount); - if (existingToken) { - await this.grafanaBackend.delete( - `${this.SERVICE_ACCOUNTS_BASE_URL}/${serviceAccount.id}/tokens/${existingToken.id}` - ); - } - - const existingKey = await this.getGrafanaToken(); - if (existingKey) { - await this.grafanaBackend.delete(`${this.KEYS_BASE_URL}/${existingKey.id}`); - } - - return await this.grafanaBackend.post(`${this.SERVICE_ACCOUNTS_BASE_URL}/${serviceAccount.id}/tokens`, { - name: PluginState.ONCALL_KEY_NAME, - role: 'Admin', - }); - }; - - static checkTokenAndIfPluginIsConnected = async ( - onCallApiUrl: string - ): Promise => { - /** - * Allows the plugin config page to repair settings like the app initialization screen if a user deletes - * an API key on accident but leaves the plugin settings intact. - */ - const existingKey = await PluginState.getGrafanaToken(); - if (!existingKey) { - try { - await PluginState.installPlugin(); - } catch (e) { - return PluginState.getHumanReadableErrorFromOnCallError(e, onCallApiUrl, 'install', false); - } - } - - return await PluginState.updatePluginStatus(onCallApiUrl); - }; - - static installPlugin = async ( - selfHosted = false - ): Promise> => { - const { key: grafanaToken } = await this.createGrafanaToken(); - await this.updateGrafanaPluginSettings({ secureJsonData: { grafanaToken } }); - const onCallAPIResponse = await makeRequest( - `${this.ONCALL_BASE_URL}/${selfHosted ? 'self-hosted/' : ''}install`, - { - method: 'POST', - } - ); - return { grafanaToken, onCallAPIResponse }; - }; - - static selfHostedInstallPlugin = async ( - onCallApiUrl: string, - onCallApiUrlIsConfiguredThroughEnvVar: boolean - ): Promise => { - let pluginInstallationOnCallResponse: InstallPluginResponse; - const errorMsgVerb: InstallationVerb = 'install'; - - // Step 1. Try provisioning the plugin w/ the Grafana API - try { - await this.updateGrafanaPluginSettings({ jsonData: { onCallApiUrl: onCallApiUrl } }); - } catch (e) { - return this.getHumanReadableErrorFromGrafanaProvisioningError( - e, - onCallApiUrl, - errorMsgVerb, - onCallApiUrlIsConfiguredThroughEnvVar - ); - } - - /** - * Step 2: - * - Create a grafana token - * - store that token in the Grafana plugin settings - * - configure the plugin in OnCall's backend - */ - try { - pluginInstallationOnCallResponse = await this.installPlugin(true); - } catch (e) { - return this.getHumanReadableErrorFromOnCallError( - e, - onCallApiUrl, - errorMsgVerb, - onCallApiUrlIsConfiguredThroughEnvVar - ); - } - - // Step 3. reprovision the Grafana plugin settings, storing information that we get back from OnCall's backend - try { - const { - grafanaToken, - onCallAPIResponse: { onCallToken: onCallApiToken, ...jsonData }, - } = pluginInstallationOnCallResponse; - - await this.updateGrafanaPluginSettings({ - jsonData: { - ...jsonData, - onCallApiUrl, - }, - secureJsonData: { - grafanaToken, - onCallApiToken, - }, - }); - } catch (e) { - return this.getHumanReadableErrorFromGrafanaProvisioningError( - e, - onCallApiUrl, - errorMsgVerb, - onCallApiUrlIsConfiguredThroughEnvVar - ); - } - - return null; - }; - - static updatePluginStatus = async ( - onCallApiUrl: string, - onCallApiUrlIsConfiguredThroughEnvVar = false - ): Promise => { - try { - return await makeRequest(`${this.ONCALL_BASE_URL}/status`, { - method: 'POST', - }); - } catch (e) { - return this.getHumanReadableErrorFromOnCallError( - e, - onCallApiUrl, - 'install', - onCallApiUrlIsConfiguredThroughEnvVar - ); - } - }; - - static resetPlugin = (): Promise => { - /** - * mark both of these objects as Required.. this will ensure that we are resetting every attribute back to null - * and throw a type error in the event that OnCallPluginMetaJSONData or OnCallPluginMetaSecureJSONData is updated - * but we forget to add the attribute here - */ - const jsonData: Required = { - stackId: null, - orgId: null, - onCallApiUrl: null, - insightsDatasource: undefined, - license: null, - }; - const secureJsonData: Required = { - grafanaToken: null, - onCallApiToken: null, - }; - - return this.updateGrafanaPluginSettings({ jsonData, secureJsonData }, false); - }; -} diff --git a/grafana-plugin/src/state/rootBaseStore/RootBaseStore.test.ts b/grafana-plugin/src/state/rootBaseStore/RootBaseStore.test.ts deleted file mode 100644 index e80a58dc50..0000000000 --- a/grafana-plugin/src/state/rootBaseStore/RootBaseStore.test.ts +++ /dev/null @@ -1,333 +0,0 @@ -import { OrgRole } from '@grafana/data'; -import { contextSrv } from 'grafana/app/core/core'; -import { OnCallAppPluginMeta } from 'types'; - -import { PluginState } from 'state/plugin/plugin'; -import { isUserActionAllowed as isUserActionAllowedOriginal } from 'utils/authorization/authorization'; - -import { RootBaseStore } from './RootBaseStore'; - -jest.mock('state/plugin/plugin'); -jest.mock('utils/authorization/authorization'); -jest.mock('grafana/app/core/core', () => ({ - contextSrv: { - user: { - orgRole: null, - }, - }, -})); -jest.mock('network/network', () => ({ - __esModule: true, - makeRequest: () => ({ pk: '1' }), -})); - -const onCallApiUrl = 'http://oncall-dev-engine:8080'; - -const isUserActionAllowed = isUserActionAllowedOriginal as jest.Mock>; - -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 () => { - const rootBaseStore = new RootBaseStore(); - - // test - await rootBaseStore.setupPlugin(generatePluginData()); - - // assertions - 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 () => { - const errorMsg = 'ohhh noooo error'; - const rootBaseStore = new RootBaseStore(); - - PluginState.updatePluginStatus = jest.fn().mockResolvedValueOnce(errorMsg); - - // test - await rootBaseStore.setupPlugin(generatePluginData(onCallApiUrl)); - - // assertions - expect(PluginState.updatePluginStatus).toHaveBeenCalledTimes(1); - expect(PluginState.updatePluginStatus).toHaveBeenCalledWith(onCallApiUrl); - - expect(rootBaseStore.initializationError).toEqual(errorMsg); - }); - - test('currently undergoing maintenance', async () => { - const rootBaseStore = new RootBaseStore(); - const maintenanceMessage = 'mncvnmvcmnvkjdjkd'; - - PluginState.updatePluginStatus = jest - .fn() - .mockResolvedValueOnce({ currently_undergoing_maintenance_message: maintenanceMessage }); - - // test - await rootBaseStore.setupPlugin(generatePluginData(onCallApiUrl)); - - // assertions - expect(PluginState.updatePluginStatus).toHaveBeenCalledTimes(1); - expect(PluginState.updatePluginStatus).toHaveBeenCalledWith(onCallApiUrl); - - expect(rootBaseStore.initializationError).toEqual(`🚧 ${maintenanceMessage} 🚧`); - expect(rootBaseStore.currentlyUndergoingMaintenance).toBe(true); - }); - - test('anonymous user', async () => { - const rootBaseStore = new RootBaseStore(); - - PluginState.updatePluginStatus = 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.updatePluginStatus).toHaveBeenCalledTimes(1); - expect(PluginState.updatePluginStatus).toHaveBeenCalledWith(onCallApiUrl); - - expect(rootBaseStore.initializationError).toEqual( - '😞 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 () => { - const rootBaseStore = new RootBaseStore(); - - PluginState.updatePluginStatus = 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.updatePluginStatus).toHaveBeenCalledTimes(1); - expect(PluginState.updatePluginStatus).toHaveBeenCalledWith(onCallApiUrl); - - expect(PluginState.installPlugin).toHaveBeenCalledTimes(0); - - 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 () => { - const rootBaseStore = new RootBaseStore(); - - contextSrv.user.orgRole = OrgRole.Viewer; - contextSrv.licensedAccessControlEnabled = jest.fn().mockReturnValue(false); - contextSrv.hasPermission = jest.fn().mockReturnValue(false); - - PluginState.updatePluginStatus = jest.fn().mockResolvedValueOnce({ - is_user_anonymous: false, - is_installed: false, - token_ok: true, - allow_signup: true, - version: 'asdfasdf', - license: 'asdfasdf', - }); - isUserActionAllowed.mockReturnValueOnce(false); - PluginState.installPlugin = jest.fn().mockResolvedValueOnce(null); - - // test - await rootBaseStore.setupPlugin(generatePluginData(onCallApiUrl)); - - // assertions - expect(PluginState.updatePluginStatus).toHaveBeenCalledTimes(1); - expect(PluginState.updatePluginStatus).toHaveBeenCalledWith(onCallApiUrl); - - expect(PluginState.installPlugin).toHaveBeenCalledTimes(0); - - expect(rootBaseStore.initializationError).toEqual( - '🚫 User with Admin permissions in your organization must sign on and setup OnCall before it can be used' - ); - }); - - test.each([ - { is_installed: false, token_ok: true }, - { is_installed: true, token_ok: false }, - ])('signup is allowed, user is an admin, plugin installation is triggered', async (scenario) => { - const rootBaseStore = new RootBaseStore(); - - contextSrv.user.orgRole = OrgRole.Admin; - contextSrv.licensedAccessControlEnabled = jest.fn().mockResolvedValueOnce(false); - contextSrv.hasPermission = jest.fn().mockReturnValue(true); - - PluginState.updatePluginStatus = jest.fn().mockResolvedValueOnce({ - ...scenario, - is_user_anonymous: false, - allow_signup: true, - version: 'asdfasdf', - license: 'asdfasdf', - }); - isUserActionAllowed.mockReturnValueOnce(true); - PluginState.installPlugin = jest.fn().mockResolvedValueOnce(null); - // test - await rootBaseStore.setupPlugin(generatePluginData(onCallApiUrl)); - - // assertions - expect(PluginState.updatePluginStatus).toHaveBeenCalledTimes(1); - expect(PluginState.updatePluginStatus).toHaveBeenCalledWith(onCallApiUrl); - - expect(PluginState.installPlugin).toHaveBeenCalledTimes(1); - expect(PluginState.installPlugin).toHaveBeenCalledWith(); - }); - - test.each([ - { role: OrgRole.Admin, missing_permissions: [], expected_result: true }, - { role: OrgRole.Viewer, missing_permissions: [], expected_result: true }, - { - role: OrgRole.Admin, - missing_permissions: ['plugins:write', 'org.users:read', 'teams:read', 'apikeys:create', 'apikeys:delete'], - expected_result: false, - }, - { - role: OrgRole.Viewer, - missing_permissions: ['plugins:write', 'org.users:read', 'teams:read', 'apikeys:create', 'apikeys:delete'], - expected_result: false, - }, - ])('signup is allowed, licensedAccessControlEnabled, various roles and permissions', async (scenario) => { - const rootBaseStore = new RootBaseStore(); - - contextSrv.user.orgRole = scenario.role; - contextSrv.licensedAccessControlEnabled = jest.fn().mockReturnValue(true); - rootBaseStore.checkMissingSetupPermissions = jest.fn().mockImplementation(() => scenario.missing_permissions); - - PluginState.updatePluginStatus = jest.fn().mockResolvedValueOnce({ - ...scenario, - is_user_anonymous: false, - allow_signup: true, - version: 'asdfasdf', - license: 'asdfasdf', - }); - isUserActionAllowed.mockReturnValueOnce(true); - PluginState.installPlugin = jest.fn().mockResolvedValueOnce(null); - - // test - await rootBaseStore.setupPlugin(generatePluginData(onCallApiUrl)); - - // assertions - expect(PluginState.updatePluginStatus).toHaveBeenCalledTimes(1); - expect(PluginState.updatePluginStatus).toHaveBeenCalledWith(onCallApiUrl); - - if (scenario.expected_result) { - expect(PluginState.installPlugin).toHaveBeenCalledTimes(1); - expect(PluginState.installPlugin).toHaveBeenCalledWith(); - } else { - expect(PluginState.installPlugin).toHaveBeenCalledTimes(0); - - expect(rootBaseStore.initializationError).toEqual( - '🚫 User is missing permission(s) ' + - scenario.missing_permissions.join(', ') + - ' to setup OnCall before it can be used' - ); - } - }); - - test('plugin is not installed, signup is allowed, the user is an admin, and plugin installation throws an error', async () => { - const rootBaseStore = new RootBaseStore(); - const installPluginError = new Error('asdasdfasdfasf'); - const humanReadableErrorMsg = 'asdfasldkfjaksdjflk'; - - contextSrv.user.orgRole = OrgRole.Admin; - contextSrv.licensedAccessControlEnabled = jest.fn().mockReturnValue(false); - contextSrv.hasPermission = jest.fn().mockReturnValue(true); - - PluginState.updatePluginStatus = jest.fn().mockResolvedValueOnce({ - is_user_anonymous: false, - is_installed: false, - token_ok: true, - allow_signup: true, - version: 'asdfasdf', - license: 'asdfasdf', - }); - isUserActionAllowed.mockReturnValueOnce(true); - PluginState.installPlugin = jest.fn().mockRejectedValueOnce(installPluginError); - PluginState.getHumanReadableErrorFromOnCallError = jest.fn().mockReturnValueOnce(humanReadableErrorMsg); - - // test - await rootBaseStore.setupPlugin(generatePluginData(onCallApiUrl)); - - // assertions - expect(PluginState.updatePluginStatus).toHaveBeenCalledTimes(1); - expect(PluginState.updatePluginStatus).toHaveBeenCalledWith(onCallApiUrl); - - expect(PluginState.installPlugin).toHaveBeenCalledTimes(1); - expect(PluginState.installPlugin).toHaveBeenCalledWith(); - - expect(PluginState.getHumanReadableErrorFromOnCallError).toHaveBeenCalledTimes(1); - expect(PluginState.getHumanReadableErrorFromOnCallError).toHaveBeenCalledWith( - installPluginError, - onCallApiUrl, - 'install' - ); - - expect(rootBaseStore.initializationError).toEqual(humanReadableErrorMsg); - }); - - test('when the plugin is installed, a data sync is triggered', async () => { - const rootBaseStore = new RootBaseStore(); - - PluginState.updatePluginStatus = jest.fn().mockResolvedValueOnce({ - is_user_anonymous: false, - is_installed: true, - token_ok: true, - allow_signup: true, - version: 'asdfasdf', - license: 'asdfasdf', - }); - // test - await rootBaseStore.setupPlugin(generatePluginData(onCallApiUrl)); - - // assertions - expect(PluginState.updatePluginStatus).toHaveBeenCalledTimes(1); - expect(PluginState.updatePluginStatus).toHaveBeenCalledWith(onCallApiUrl); - expect(rootBaseStore.initializationError).toBeNull(); - }); - - test('when the plugin is installed, and the data sync returns an error, it is properly handled', async () => { - const rootBaseStore = new RootBaseStore(); - const updatePluginStatusError = 'asdasdfasdfasf'; - - PluginState.updatePluginStatus = jest.fn().mockResolvedValueOnce({ - is_user_anonymous: false, - is_installed: true, - token_ok: true, - allow_signup: true, - version: 'asdfasdf', - license: 'asdfasdf', - }); - PluginState.updatePluginStatus = jest.fn().mockResolvedValueOnce(updatePluginStatusError); - - // test - await rootBaseStore.setupPlugin(generatePluginData(onCallApiUrl)); - - // assertions - expect(PluginState.updatePluginStatus).toHaveBeenCalledTimes(1); - expect(PluginState.updatePluginStatus).toHaveBeenCalledWith(onCallApiUrl); - - expect(rootBaseStore.initializationError).toEqual(updatePluginStatusError); - }); -}); diff --git a/grafana-plugin/src/state/rootBaseStore/RootBaseStore.ts b/grafana-plugin/src/state/rootBaseStore/RootBaseStore.ts index 7481e8d7c9..26dfa5be4e 100644 --- a/grafana-plugin/src/state/rootBaseStore/RootBaseStore.ts +++ b/grafana-plugin/src/state/rootBaseStore/RootBaseStore.ts @@ -1,7 +1,5 @@ -import { contextSrv } from 'grafana/app/core/core'; import { action, computed, makeObservable, observable, runInAction } from 'mobx'; import qs from 'query-string'; -import { OnCallAppPluginMeta } from 'types'; import { AlertReceiveChannelStore } from 'models/alert_receive_channel/alert_receive_channel'; import { AlertReceiveChannelConnectedChannelsStore } from 'models/alert_receive_channel_connected_channels/alert_receive_channel_connected_channels'; @@ -22,6 +20,7 @@ import { LoaderStore } from 'models/loader/loader'; import { MSTeamsChannelStore } from 'models/msteams_channel/msteams_channel'; import { OrganizationStore } from 'models/organization/organization'; import { OutgoingWebhookStore } from 'models/outgoing_webhook/outgoing_webhook'; +import { PluginStore } from 'models/plugin/plugin'; import { ResolutionNotesStore } from 'models/resolution_note/resolution_note'; import { ScheduleStore } from 'models/schedule/schedule'; import { SlackStore } from 'models/slack/slack'; @@ -33,15 +32,8 @@ import { UserGroupStore } from 'models/user_group/user_group'; import { makeRequest } from 'network/network'; import { ApiSchemas } from 'network/oncall-api/api.types'; import { AppFeature } from 'state/features'; -import { PluginState } from 'state/plugin/plugin'; import { retryFailingPromises } from 'utils/async'; -import { - APP_VERSION, - CLOUD_VERSION_REGEX, - getOnCallApiUrl, - GRAFANA_LICENSE_CLOUD, - GRAFANA_LICENSE_OSS, -} from 'utils/consts'; +import { APP_VERSION, CLOUD_VERSION_REGEX, GRAFANA_LICENSE_CLOUD, GRAFANA_LICENSE_OSS } from 'utils/consts'; // ------ Dashboard ------ // @@ -58,9 +50,6 @@ export class RootBaseStore { @observable recaptchaSiteKey = ''; - @observable - initializationError = ''; - @observable currentlyUndergoingMaintenance = false; @@ -85,6 +74,7 @@ export class RootBaseStore { insightsDatasource?: string; // stores + pluginStore = new PluginStore(this); userStore = new UserStore(this); cloudStore = new CloudStore(this); directPagingStore = new DirectPagingStore(this); @@ -116,6 +106,7 @@ export class RootBaseStore { constructor() { makeObservable(this); } + @action.bound loadBasicData = async () => { const updateFeatures = async () => { @@ -148,134 +139,6 @@ export class RootBaseStore { this.isBasicDataLoaded = value; } - @action - setupPluginError(errorMsg: string) { - this.initializationError = errorMsg; - } - - /** - * This function is called in the background when the plugin is loaded. - * It will check the status of the plugin and - * rerender the screen with the appropriate message if the plugin is not setup correctly. - * - * 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: - * - OnCall api should not be under maintenance - * - plugin must be considered installed by the OnCall API - * - token_ok must be true - * - This represents the status of the Grafana API token. It can be false in the event that either the token - * hasn't been created, or if the API token was revoked in Grafana. - * - 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 and necessary permissions - * Finally, try to load the current user from the OnCall backend - */ - async setupPlugin(meta: OnCallAppPluginMeta) { - this.setupPluginError(null); - this.onCallApiUrl = getOnCallApiUrl(meta); - this.insightsDatasource = meta.jsonData?.insightsDatasource || 'grafanacloud-usage'; - - if (!this.onCallApiUrl) { - // plugin is not provisioned - return this.setupPluginError('🚫 Plugin has not been initialized'); - } - - if (this.isOpenSource && !meta.secureJsonFields?.onCallApiToken) { - // Reinstall plugin if onCallApiToken is missing - const errorMsg = await PluginState.selfHostedInstallPlugin(this.onCallApiUrl, true); - if (errorMsg) { - return this.setupPluginError(errorMsg); - } - location.reload(); - } - - // at this point we know the plugin is provisioned - const pluginConnectionStatus = await PluginState.updatePluginStatus(this.onCallApiUrl); - if (typeof pluginConnectionStatus === 'string') { - return this.setupPluginError(pluginConnectionStatus); - } - - // Check if the plugin is currently undergoing maintenance - if (pluginConnectionStatus.currently_undergoing_maintenance_message) { - this.currentlyUndergoingMaintenance = true; - return this.setupPluginError(`🚧 ${pluginConnectionStatus.currently_undergoing_maintenance_message} 🚧`); - } - - const { allow_signup, is_installed, is_user_anonymous, token_ok } = pluginConnectionStatus; - - // Anonymous users are not allowed to use the plugin - if (is_user_anonymous) { - return this.setupPluginError( - '😞 Grafana OnCall is available for authorized users only, please sign in to proceed.' - ); - } - - // If the plugin is not installed in the OnCall backend, or token is not valid, then we need to install it - if (!is_installed || !token_ok) { - if (!allow_signup) { - return this.setupPluginError('🚫 OnCall has temporarily disabled signup of new users. Please try again later.'); - } - - const missingPermissions = this.checkMissingSetupPermissions(); - if (missingPermissions.length === 0) { - 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 { - if (contextSrv.licensedAccessControlEnabled()) { - return this.setupPluginError( - '🚫 User is missing permission(s) ' + - missingPermissions.join(', ') + - ' to setup OnCall before it can be used' - ); - } else { - return this.setupPluginError( - '🚫 User with Admin permissions in your organization must sign on and setup OnCall before it can be used' - ); - } - } - } else { - // everything is all synced successfully at this point.. - runInAction(() => { - this.backendVersion = pluginConnectionStatus.version; - this.backendLicense = pluginConnectionStatus.license; - this.recaptchaSiteKey = pluginConnectionStatus.recaptcha_site_key; - }); - } - - if (!this.userStore.currentUser) { - try { - await this.userStore.loadCurrentUser(); - } catch (e) { - return this.setupPluginError('OnCall was not able to load the current user. Try refreshing the page'); - } - } - } - - checkMissingSetupPermissions() { - const setupRequiredPermissions = [ - 'plugins:write', - 'org.users:read', - 'teams:read', - 'apikeys:create', - 'apikeys:delete', - ]; - return setupRequiredPermissions.filter(function (permission) { - return !contextSrv.hasPermission(permission); - }); - } - // todo use AppFeature only hasFeature = (feature: string | AppFeature) => this.features?.[feature]; @@ -314,16 +177,6 @@ export class RootBaseStore { this.pageTitle = title; } - @action - async removeSlackIntegration() { - await this.slackStore.removeSlackIntegration(); - } - - @action - async installSlackIntegration() { - await this.slackStore.installSlackIntegration(); - } - @action.bound async getApiUrlForSettings() { return this.onCallApiUrl; diff --git a/grafana-plugin/src/types.ts b/grafana-plugin/src/types.ts index 7bf6c041c0..05d82fef92 100644 --- a/grafana-plugin/src/types.ts +++ b/grafana-plugin/src/types.ts @@ -5,7 +5,6 @@ export type OnCallPluginMetaJSONData = { orgId: number; onCallApiUrl: string; insightsDatasource?: string; - license: string; }; export type OnCallPluginMetaSecureJSONData = { diff --git a/grafana-plugin/src/utils/async.ts b/grafana-plugin/src/utils/async.ts index ad6a3c1037..257d4ce880 100644 --- a/grafana-plugin/src/utils/async.ts +++ b/grafana-plugin/src/utils/async.ts @@ -7,3 +7,5 @@ export const retryFailingPromises = async ( maxAttempts === 0 ? Promise.allSettled(asyncActions) : Promise.allSettled(asyncActions.map((asyncAction) => retry(asyncAction, { maxAttempts, delay: delayInMs }))); + +export const waitInMs = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); diff --git a/grafana-plugin/src/utils/authorization/authorization.ts b/grafana-plugin/src/utils/authorization/authorization.ts index d04afb775e..d6989d5713 100644 --- a/grafana-plugin/src/utils/authorization/authorization.ts +++ b/grafana-plugin/src/utils/authorization/authorization.ts @@ -2,7 +2,7 @@ import { OrgRole } from '@grafana/data'; import { config } from '@grafana/runtime'; import { contextSrv } from 'grafana/app/core/core'; -const ONCALL_PERMISSION_PREFIX = 'grafana-oncall-app'; +import { PLUGIN_ID } from 'utils/consts'; export type UserAction = { permission: string; @@ -110,7 +110,7 @@ export const generateMissingPermissionMessage = (permission: UserAction): string `You are missing the ${determineRequiredAuthString(permission)}`; export const generatePermissionString = (resource: Resource, action: Action, includePrefix: boolean): string => - `${includePrefix ? `${ONCALL_PERMISSION_PREFIX}.` : ''}${resource}:${action}`; + `${includePrefix ? `${PLUGIN_ID}.` : ''}${resource}:${action}`; const constructAction = ( resource: Resource, diff --git a/grafana-plugin/src/utils/consts.ts b/grafana-plugin/src/utils/consts.ts index a091378e28..f9ef7963d0 100644 --- a/grafana-plugin/src/utils/consts.ts +++ b/grafana-plugin/src/utils/consts.ts @@ -28,37 +28,31 @@ export const BREAKPOINT_TABS = 1024; // Default redirect page export const DEFAULT_PAGE = 'alert-groups'; -export const PLUGIN_ROOT = '/a/grafana-oncall-app'; +export const PLUGIN_ID = 'grafana-oncall-app'; +export const PLUGIN_ROOT = `/a/${PLUGIN_ID}`; +export const PLUGIN_CONFIG = `/plugins/${PLUGIN_ID}`; + +export const REQUEST_HELP_URL = 'https://grafana.com/profile/org/tickets/new'; // Environment options list for onCallApiUrl export const ONCALL_PROD = 'https://oncall-prod-us-central-0.grafana.net/oncall'; export const ONCALL_OPS = 'https://oncall-ops-us-east-0.grafana.net/oncall'; export const ONCALL_DEV = 'https://oncall-dev-us-central-0.grafana.net/oncall'; -export const getIsDevelopmentEnv = () => { +export const getOnCallApiUrl = (meta?: OnCallAppPluginMeta) => meta?.jsonData?.onCallApiUrl; + +export const getProcessEnvVarSafely = (name: string) => { try { - return process.env.NODE_ENV === 'development'; + return process.env[name]; } catch (error) { - return false; + console.error(error); + return undefined; } }; -// Single source of truth on the frontend for OnCall API URL -export const getOnCallApiUrl = (meta?: OnCallAppPluginMeta) => { - if (meta?.jsonData?.onCallApiUrl) { - return meta?.jsonData?.onCallApiUrl; - } else if (typeof window === 'undefined') { - try { - return process.env.ONCALL_API_URL; - } catch (error) { - return undefined; - } - } - return undefined; -}; +export const getIsDevelopmentEnv = () => getProcessEnvVarSafely['NODE_ENV'] === 'development'; -// If the plugin has never been configured, onCallApiUrl will be undefined in the plugin's jsonData -export const hasPluginBeenConfigured = (meta?: OnCallAppPluginMeta) => Boolean(meta?.jsonData?.onCallApiUrl); +export const getOnCallApiPath = (subpath = '') => `/api/plugins/${PLUGIN_ID}/resources${subpath}`; // Faro export const FARO_ENDPOINT_DEV = @@ -71,6 +65,9 @@ export const FARO_ENDPOINT_PROD = export const DOCS_ROOT = 'https://grafana.com/docs/oncall/latest'; export const DOCS_SLACK_SETUP = 'https://grafana.com/docs/oncall/latest/open-source/#slack-setup'; export const DOCS_TELEGRAM_SETUP = 'https://grafana.com/docs/oncall/latest/notify/telegram/'; +export const DOCS_SERVICE_ACCOUNTS = 'https://grafana.com/docs/grafana/latest/administration/service-accounts/'; +export const DOCS_ONCALL_OSS_INSTALL = + 'https://grafana.com/docs/oncall/latest/set-up/open-source/#install-grafana-oncall-oss'; export const generateAssignToTeamInputDescription = (objectName: string): string => `Assigning to a team allows you to filter ${objectName} and configure their visibility. Go to OnCall -> Settings -> Team and Access Settings for more details.`; diff --git a/grafana-plugin/src/utils/faro.ts b/grafana-plugin/src/utils/faro.ts index 3fc2707213..eeb0179c5b 100644 --- a/grafana-plugin/src/utils/faro.ts +++ b/grafana-plugin/src/utils/faro.ts @@ -10,6 +10,7 @@ import { ONCALL_OPS, ONCALL_PROD, getIsDevelopmentEnv, + PLUGIN_ID, } from './consts'; import { safeJSONStringify } from './string'; @@ -55,7 +56,7 @@ class BaseFaroHelper { persistent: true, }, beforeSend: (event) => { - if ((event.meta.page?.url ?? '').includes('grafana-oncall-app')) { + if ((event.meta.page?.url ?? '').includes(PLUGIN_ID)) { return event; } diff --git a/grafana-plugin/src/utils/hooks.tsx b/grafana-plugin/src/utils/hooks.tsx index e107c40237..a3cff392fd 100644 --- a/grafana-plugin/src/utils/hooks.tsx +++ b/grafana-plugin/src/utils/hooks.tsx @@ -5,6 +5,7 @@ import { useLocation } from 'react-router-dom'; import { ActionKey } from 'models/loader/action-keys'; import { LoaderHelper } from 'models/loader/loader.helpers'; +import { rootStore } from 'state/rootStore'; import { useStore } from 'state/useStore'; import { LocationHelper } from './LocationHelper'; @@ -148,3 +149,20 @@ export const useOnMount = (callback: () => void) => { callback(); }, []); }; + +export const useInitializePlugin = () => { + /* + We need to rely on rootStore imported directly (not provided via context) + because this hook is invoked out of plugin root (in plugin extension) + */ + const isConnected = rootStore.pluginStore.isPluginConnected; + const isCheckingConnectionStatus = rootStore.loaderStore.isLoading(ActionKey.PLUGIN_VERIFY_CONNECTION); + + useOnMount(() => { + if (!isConnected && !isCheckingConnectionStatus) { + rootStore.pluginStore.verifyPluginConnection(); + } + }); + + return { isConnected, isCheckingConnectionStatus }; +}; diff --git a/grafana-plugin/src/utils/utils.test.ts b/grafana-plugin/src/utils/utils.test.ts new file mode 100644 index 0000000000..0accc9164d --- /dev/null +++ b/grafana-plugin/src/utils/utils.test.ts @@ -0,0 +1,57 @@ +import * as runtime from '@grafana/runtime'; + +import { getGrafanaVersion, isCurrentGrafanaVersionEqualOrGreaterThan } from 'utils/utils'; + +jest.mock('@grafana/runtime', () => ({ + config: jest.fn(), +})); + +function setGrafanaVersion(version: string) { + runtime.config.buildInfo = { + version, + } as any; +} + +describe('getGrafanaVersion', () => { + it('figures out grafana version from string', () => { + setGrafanaVersion('10.13.95-9.0.1.1test'); + + const { major, minor, patch } = getGrafanaVersion(); + + expect(major).toBe(10); + expect(minor).toBe(13); + expect(patch).toBe(95); + }); + + it('figures out grafana version for v9', () => { + setGrafanaVersion('9.04.3105-rctest100'); + + const { major, minor, patch } = getGrafanaVersion(); + + expect(major).toBe(9); + expect(minor).toBe(4); + expect(patch).toBe(3105); + }); + + it('figures out grafana version for 1.0.0', () => { + setGrafanaVersion('1.0.0-any-asd-value'); + + const { major, minor, patch } = getGrafanaVersion(); + + expect(major).toBe(1); + expect(minor).toBe(0); + expect(patch).toBe(0); + }); +}); + +describe('isCurrentGrafanaVersionEqualOrGreaterThan()', () => { + it('returns true if grafana version is equal or greater than specified version', () => { + setGrafanaVersion('11.0.0'); + expect(isCurrentGrafanaVersionEqualOrGreaterThan({ minMajor: 11, minMinor: 0, minPatch: 0 })).toBe(true); + expect(isCurrentGrafanaVersionEqualOrGreaterThan({ minMajor: 10, minMinor: 0, minPatch: 1 })).toBe(true); + expect(isCurrentGrafanaVersionEqualOrGreaterThan({ minMajor: 10, minMinor: 1, minPatch: 0 })).toBe(true); + expect(isCurrentGrafanaVersionEqualOrGreaterThan({ minMajor: 10, minMinor: 1, minPatch: 1 })).toBe(true); + expect(isCurrentGrafanaVersionEqualOrGreaterThan({ minMajor: 11, minMinor: 0, minPatch: 1 })).toBe(false); + expect(isCurrentGrafanaVersionEqualOrGreaterThan({ minMajor: 12, minMinor: 0, minPatch: 0 })).toBe(false); + }); +}); diff --git a/grafana-plugin/src/utils/utils.ts b/grafana-plugin/src/utils/utils.ts index 99846408d6..9574cf4e85 100644 --- a/grafana-plugin/src/utils/utils.ts +++ b/grafana-plugin/src/utils/utils.ts @@ -1,4 +1,5 @@ import { AppEvents } from '@grafana/data'; +import { config } from '@grafana/runtime'; import { AxiosError } from 'axios'; import { sentenceCase } from 'change-case'; // @ts-ignore @@ -6,7 +7,8 @@ import appEvents from 'grafana/app/core/app_events'; import { isArray, concat, every, isEmpty, isObject, isPlainObject, flatMap, map, keys } from 'lodash-es'; import { isNetworkError } from 'network/network'; -import { getGrafanaVersion } from 'plugin/GrafanaPluginRootPage.helpers'; + +import { CLOUD_VERSION_REGEX, PLUGIN_ID } from './consts'; export class KeyValuePair { key: T; @@ -118,3 +120,41 @@ function isFieldEmpty(value: any): boolean { export const allFieldsEmpty = (obj: any) => every(obj, isFieldEmpty); export const isMobile = window.matchMedia('(max-width: 768px)').matches; + +export function getGrafanaVersion(): { major?: number; minor?: number; patch?: number } { + const regex = /^([1-9]?[0-9]*)\.([1-9]?[0-9]*)\.([1-9]?[0-9]*)/; + const match = config.buildInfo.version.match(regex); + + if (match) { + return { + major: Number(match[1]), + minor: Number(match[2]), + patch: Number(match[3]), + }; + } + + return {}; +} + +export const isCurrentGrafanaVersionEqualOrGreaterThan = ({ + minMajor, + minMinor = 0, + minPatch = 0, +}: { + minMajor: number; + minMinor?: number; + minPatch?: number; +}) => { + const { major, minor, patch } = getGrafanaVersion(); + return ( + major > minMajor || + (major === minMajor && minor > minMinor) || + (major === minMajor && minor === minMinor && patch >= minPatch) + ); +}; + +export const getIsRunningOpenSourceVersion = () => !CLOUD_VERSION_REGEX.test(config.apps[PLUGIN_ID]?.version); + +export const getIsExternalServiceAccountFeatureAvailable = () => + isCurrentGrafanaVersionEqualOrGreaterThan({ minMajor: 10, minMinor: 3 }) && + config.featureToggles.externalServiceAccounts; diff --git a/grafana-plugin/webpack.config.ts b/grafana-plugin/webpack.config.ts index cccf4a11c2..3e520ce74a 100644 --- a/grafana-plugin/webpack.config.ts +++ b/grafana-plugin/webpack.config.ts @@ -1,11 +1,9 @@ -import { Configuration, DefinePlugin, EnvironmentPlugin } from 'webpack'; +import { Configuration, EnvironmentPlugin } from 'webpack'; import LiveReloadPlugin from 'webpack-livereload-plugin'; import { mergeWithRules, CustomizeRule } from 'webpack-merge'; import grafanaConfig from './.config/webpack/webpack.config'; -const dotenv = require('dotenv'); - const config = async (env): Promise => { const baseConfig = await grafanaConfig(env); const customConfig = { @@ -63,12 +61,8 @@ const config = async (env): Promise => { ...(baseConfig.plugins?.filter((plugin) => !(plugin instanceof LiveReloadPlugin)) || []), ...(env.development ? [new LiveReloadPlugin({ appendScriptTag: true, useSourceHash: true })] : []), new EnvironmentPlugin({ - ONCALL_API_URL: null, NODE_ENV: 'development', }), - new DefinePlugin({ - 'process.env': JSON.stringify(dotenv.config().parsed), - }), ], };