New OnCall plugin initialization process (#4657)
# What this PR does
New OnCall plugin initialization process
## Checklist
- [x] Unit, integration, and e2e (if applicable) tests updated
- [x] Documentation added (or `pr:no public docs` PR label added if not
required)
- [x] Added the relevant release notes label (see labels prefixed w/
`release:`). These labels dictate how your PR will
show up in the autogenerated release notes.
---------
Co-authored-by: Michael Derynck <michael.derynck@grafana.com>
Co-authored-by: Matias Bordese <mbordese@gmail.com>
This commit is contained in:
parent
a416863a28
commit
06d19bf6e9
105 changed files with 3959 additions and 3504 deletions
1
.github/CODEOWNERS
vendored
1
.github/CODEOWNERS
vendored
|
|
@ -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.
|
||||
|
|
|
|||
2
.github/workflows/e2e-tests.yml
vendored
2
.github/workflows/e2e-tests.yml
vendored
|
|
@ -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
|
||||
|
|
|
|||
24
.github/workflows/linting-and-tests.yml
vendored
24
.github/workflows/linting-and-tests.yml
vendored
|
|
@ -13,7 +13,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
|
||||
|
|
@ -27,7 +27,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
|
||||
|
|
@ -39,7 +39,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"
|
||||
|
|
@ -56,7 +56,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
|
||||
|
|
@ -87,7 +87,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
|
||||
|
|
@ -99,6 +99,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
|
||||
|
|
@ -202,7 +212,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
|
||||
|
|
@ -216,7 +226,7 @@ jobs:
|
|||
|
||||
mypy:
|
||||
name: "mypy"
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: ubuntu-latest-16-cores
|
||||
steps:
|
||||
- name: Checkout project
|
||||
uses: actions/checkout@v4
|
||||
|
|
|
|||
|
|
@ -23,4 +23,21 @@ if not is_ci:
|
|||
serve_dir=grafana_plugin_dir,
|
||||
serve_cmd="yarn watch",
|
||||
allow_parallel=True,
|
||||
)
|
||||
)
|
||||
|
||||
local_resource(
|
||||
'build-oncall-plugin-backend',
|
||||
labels=[label],
|
||||
dir="../../grafana-plugin",
|
||||
cmd="mage buildAll",
|
||||
deps=['../../grafana-plugin/pkg/plugin']
|
||||
)
|
||||
|
||||
local_resource(
|
||||
'restart-oncall-plugin-backend',
|
||||
labels=[label],
|
||||
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']
|
||||
)
|
||||
|
|
@ -11,7 +11,7 @@ local_resource(
|
|||
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(
|
||||
|
|
|
|||
9
Tiltfile
9
Tiltfile
|
|
@ -71,7 +71,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(
|
||||
|
|
@ -83,6 +83,7 @@ def load_grafana():
|
|||
# The user/pass that you will login to Grafana with
|
||||
grafana_admin_user_pass = os.getenv("GRAFANA_ADMIN_USER_PASS", "oncall")
|
||||
grafana_version = os.getenv("GRAFANA_VERSION", "latest")
|
||||
grafana_url = os.getenv("GRAFANA_URL", "http://grafana:3000")
|
||||
|
||||
|
||||
if 'plugin' in profiles:
|
||||
|
|
@ -100,11 +101,15 @@ def load_grafana():
|
|||
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_APP_URL": grafana_url, # older versions of grafana need this
|
||||
"GF_SERVER_ROOT_URL": grafana_url,
|
||||
"GF_FEATURE_TOGGLES_ENABLE": "externalServiceAccounts",
|
||||
"ONCALL_API_URL": "http://oncall-dev-engine:8080"
|
||||
},
|
||||
)
|
||||
# --- GRAFANA END ----
|
||||
|
|
|
|||
|
|
@ -5,4 +5,4 @@ apps:
|
|||
jsonData:
|
||||
stackId: 5
|
||||
orgId: 100
|
||||
onCallApiUrl: http://oncall-dev-engine:8080
|
||||
onCallApiUrl: $ONCALL_API_URL
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
23
dev/scripts/restart_backend_plugin.sh
Executable file
23
dev/scripts/restart_backend_plugin.sh
Executable file
|
|
@ -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
|
||||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -370,16 +370,20 @@ class GrafanaAlertingSyncManager:
|
|||
return contact_points
|
||||
|
||||
def _recursive_check_contact_point_is_in_routes(self, route_config: dict, receiver_name: str) -> bool:
|
||||
if route_config.get("receiver") == receiver_name:
|
||||
return True
|
||||
routes = route_config.get("routes", [])
|
||||
for route in routes:
|
||||
if route.get("receiver") == receiver_name:
|
||||
return True
|
||||
if route.get("routes"):
|
||||
if self._recursive_check_contact_point_is_in_routes(route, receiver_name):
|
||||
return True
|
||||
return False
|
||||
# TODO: Relaxing this condition due to API limitations when requesting config with external service account
|
||||
# instead of Admin response does not contain child routes. We are currently considering the integration
|
||||
# connected as long as the contact point exists.
|
||||
return True
|
||||
# if route_config.get("receiver") == receiver_name:
|
||||
# return True
|
||||
# routes = route_config.get("routes", [])
|
||||
# for route in routes:
|
||||
# if route.get("receiver") == receiver_name:
|
||||
# return True
|
||||
# if route.get("routes"):
|
||||
# if self._recursive_check_contact_point_is_in_routes(route, receiver_name):
|
||||
# return True
|
||||
# return False
|
||||
|
||||
def _get_oncall_config_and_config_field_for_datasource_type(
|
||||
self, contact_point_name: str, is_grafana_datasource: bool, is_oncall_type_available: bool
|
||||
|
|
|
|||
|
|
@ -334,7 +334,7 @@ def test_get_connected_contact_points_from_config(
|
|||
},
|
||||
{
|
||||
"name": ALERTMANAGER_INACTIVE_RECEIVER_CONNECTED,
|
||||
"notification_connected": False,
|
||||
"notification_connected": True,
|
||||
},
|
||||
]
|
||||
if alertmanager_config
|
||||
|
|
|
|||
|
|
@ -54,10 +54,10 @@ def check_gcom_permission(token_string: str, context) -> GcomToken:
|
|||
if allow_signup:
|
||||
# Get org from db or create a new one
|
||||
organization, _ = Organization.objects.get_or_create(
|
||||
stack_id=str(instance_info["id"]),
|
||||
stack_id=instance_info["id"],
|
||||
stack_slug=instance_info["slug"],
|
||||
grafana_url=instance_info["url"],
|
||||
org_id=str(instance_info["orgId"]),
|
||||
org_id=instance_info["orgId"],
|
||||
org_slug=instance_info["orgSlug"],
|
||||
org_title=instance_info["orgName"],
|
||||
region_slug=instance_info["regionSlug"],
|
||||
|
|
|
|||
|
|
@ -95,7 +95,13 @@ class SyncDataSerializer(serializers.Serializer):
|
|||
data = super().to_internal_value(data)
|
||||
users = data.get("users")
|
||||
if users:
|
||||
data["users"] = [SyncUser(**user) for user in users]
|
||||
|
||||
def create_user(user):
|
||||
permissions_data = user.pop("permissions", [])
|
||||
permissions = [SyncPermission(**perm) for perm in permissions_data] if permissions_data else []
|
||||
return SyncUser(permissions=permissions, **user)
|
||||
|
||||
data["users"] = [create_user(user) for user in users]
|
||||
teams = data.get("teams")
|
||||
if teams:
|
||||
data["teams"] = [SyncTeam(**team) for team in teams]
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ def test_auth_success(make_organization_and_user_with_plugin_token, make_user_au
|
|||
client = APIClient()
|
||||
|
||||
auth_headers = make_user_auth_headers(user, token)
|
||||
del auth_headers["HTTP_X-Grafana-Context"]
|
||||
|
||||
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)
|
||||
|
|
@ -35,9 +36,11 @@ def test_invalid_auth(make_organization_and_user_with_plugin_token, make_user_au
|
|||
assert response.status_code == status.HTTP_401_UNAUTHORIZED
|
||||
assert not mock_sync.called
|
||||
|
||||
auth_headers = make_user_auth_headers(user, token)
|
||||
auth_headers = make_user_auth_headers(None, token, organization=organization)
|
||||
del auth_headers["HTTP_X-Instance-Context"]
|
||||
|
||||
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 response.status_code == status.HTTP_401_UNAUTHORIZED
|
||||
assert not mock_sync.called
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import logging
|
||||
from dataclasses import asdict
|
||||
|
||||
from django.conf import settings
|
||||
from rest_framework import status
|
||||
|
|
@ -17,7 +18,7 @@ class InstallV2View(SyncV2View):
|
|||
|
||||
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)
|
||||
return Response(data=asdict(SELF_HOSTED_ONLY_FEATURE_ERROR), status=status.HTTP_403_FORBIDDEN)
|
||||
|
||||
try:
|
||||
organization = self.do_sync(request)
|
||||
|
|
|
|||
|
|
@ -1,14 +1,13 @@
|
|||
import logging
|
||||
from dataclasses import asdict
|
||||
|
||||
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.auth_token.auth import BasePluginAuthentication
|
||||
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
|
||||
|
|
@ -23,11 +22,7 @@ class SyncException(Exception):
|
|||
|
||||
|
||||
class SyncV2View(APIView):
|
||||
authentication_classes = (PluginAuthentication,)
|
||||
permission_classes = [IsAuthenticated, RBACPermission]
|
||||
rbac_permissions = {
|
||||
"post": [RBACPermission.Permissions.USER_SETTINGS_ADMIN],
|
||||
}
|
||||
authentication_classes = (BasePluginAuthentication,)
|
||||
|
||||
def do_sync(self, request: Request) -> Organization:
|
||||
serializer = SyncDataSerializer(data=request.data)
|
||||
|
|
@ -40,7 +35,7 @@ class SyncV2View(APIView):
|
|||
stack_id = settings.SELF_HOSTED_SETTINGS["STACK_ID"]
|
||||
org_id = settings.SELF_HOSTED_SETTINGS["ORG_ID"]
|
||||
else:
|
||||
org_id = request.auth.organization
|
||||
org_id = request.auth.organization.org_id
|
||||
stack_id = request.auth.organization.stack_id
|
||||
|
||||
if sync_data.settings.org_id != org_id or sync_data.settings.stack_id != stack_id:
|
||||
|
|
@ -54,6 +49,6 @@ class SyncV2View(APIView):
|
|||
try:
|
||||
self.do_sync(request)
|
||||
except SyncException as e:
|
||||
return Response(data=e.error_data, status=status.HTTP_400_BAD_REQUEST)
|
||||
return Response(data=asdict(e.error_data), status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
return Response(status=status.HTTP_200_OK)
|
||||
|
|
|
|||
|
|
@ -419,7 +419,10 @@ def _sync_teams_members_data(organization: Organization, team_members: dict[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))
|
||||
if members_ids:
|
||||
team.users.set(organization.users.filter(user_id__in=members_ids))
|
||||
else:
|
||||
team.users.clear()
|
||||
|
||||
|
||||
def apply_sync_data(organization: Organization, sync_data: SyncData):
|
||||
|
|
|
|||
|
|
@ -314,9 +314,11 @@ def make_user_auth_headers():
|
|||
token,
|
||||
grafana_token: typing.Optional[str] = None,
|
||||
grafana_context_data: typing.Optional[typing.Dict] = None,
|
||||
organization=None,
|
||||
):
|
||||
instance_context_headers = {"stack_id": user.organization.stack_id, "org_id": user.organization.org_id}
|
||||
grafana_context_headers = {"UserId": user.user_id}
|
||||
org = organization or user.organization
|
||||
instance_context_headers = {"stack_id": org.stack_id, "org_id": org.org_id}
|
||||
grafana_context_headers = {"UserId": user.user_id if user else None}
|
||||
if grafana_token is not None:
|
||||
instance_context_headers["grafana_token"] = grafana_token
|
||||
if grafana_context_data is not None:
|
||||
|
|
|
|||
12
grafana-plugin/Magefile.go
Normal file
12
grafana-plugin/Magefile.go
Normal file
|
|
@ -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
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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<void> => {
|
||||
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) => {
|
||||
/**
|
||||
* 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' });
|
||||
}
|
||||
|
||||
/**
|
||||
* 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();
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
|
|
|
|||
|
|
@ -0,0 +1,34 @@
|
|||
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);
|
||||
await page.waitForLoadState('networkidle');
|
||||
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();
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,78 @@
|
|||
import semver from 'semver';
|
||||
|
||||
import { waitInMs } from 'utils/async';
|
||||
|
||||
import { test, expect, Page } from '../fixtures';
|
||||
import { OrgRole } from '../utils/constants';
|
||||
import { goToGrafanaPage, goToOnCallPage } from '../utils/navigation';
|
||||
import { createGrafanaUser, loginAndWaitTillGrafanaIsLoaded } from '../utils/users';
|
||||
|
||||
const assertThatUserCanAccessOnCallWithinMinute = async (page: Page, testIdOfConnectedElem: string) => {
|
||||
let isConnected = false;
|
||||
let retries = 0;
|
||||
while (!isConnected && retries < 12) {
|
||||
await waitInMs(5_000);
|
||||
await page.reload();
|
||||
await page.waitForLoadState('networkidle');
|
||||
isConnected = await page.getByTestId(testIdOfConnectedElem).isVisible();
|
||||
}
|
||||
expect(isConnected).toBe(true);
|
||||
};
|
||||
|
||||
test.describe('Plugin initialization', () => {
|
||||
test('Plugin OnCall pages work for new viewer user within 1 minute after creation', async ({
|
||||
adminRolePage: { page },
|
||||
browser,
|
||||
}) => {
|
||||
test.slow();
|
||||
|
||||
// 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 });
|
||||
|
||||
// Go to OnCall and assert that plugin is connected
|
||||
await goToOnCallPage(viewerUserPage, 'alert-groups');
|
||||
|
||||
await assertThatUserCanAccessOnCallWithinMinute(viewerUserPage, 'add-escalation-button');
|
||||
});
|
||||
|
||||
test('Extension registered by OnCall plugin works for new editor user within 1 minute after creation', async ({
|
||||
adminRolePage: { page },
|
||||
browser,
|
||||
}) => {
|
||||
test.slow();
|
||||
|
||||
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 assertThatUserCanAccessOnCallWithinMinute(editorUserPage, 'mobile-app-connection');
|
||||
});
|
||||
});
|
||||
|
|
@ -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'];
|
||||
|
||||
await accessProfileTabs(page, ['tab-mobile-app', 'tab-phone-verification', 'tab-slack', 'tab-telegram'], false);
|
||||
// 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, 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 }) => {
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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<void> => {
|
||||
export const clickButton = async ({ page, buttonText, startingLocator, exact }: ClickButtonArgs): Promise<void> => {
|
||||
const baseLocator = startingLocator || page;
|
||||
await baseLocator.getByRole('button', { name: buttonText, disabled: false }).click();
|
||||
await baseLocator.getByRole('button', { name: buttonText, disabled: false, exact }).click();
|
||||
};
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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<void> {
|
||||
export async function verifyThatUserCanViewOtherUsers(page: Page, isAllowedToView = true): Promise<void> {
|
||||
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<void> => {
|
||||
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');
|
||||
};
|
||||
|
|
|
|||
92
grafana-plugin/go.mod
Normal file
92
grafana-plugin/go.mod
Normal file
|
|
@ -0,0 +1,92 @@
|
|||
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/sergi/go-diff v1.3.1 // 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/yudai/gojsondiff v1.0.0 // indirect
|
||||
github.com/yudai/golcs v0.0.0-20170316035057-ecda9a501e82 // 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
|
||||
)
|
||||
293
grafana-plugin/go.sum
Normal file
293
grafana-plugin/go.sum
Normal file
|
|
@ -0,0 +1,293 @@
|
|||
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.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
|
||||
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
||||
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
|
||||
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
|
||||
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/sergi/go-diff v1.3.1 h1:xkr+Oxo4BOQKmkn/B9eMK0g5Kg/983T9DqqPHwYqD+8=
|
||||
github.com/sergi/go-diff v1.3.1/go.mod h1:aMJSSKb2lpPvRNec0+w3fl7LP9IOFzdc9Pa4NFbPK1I=
|
||||
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.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
|
||||
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/yudai/gojsondiff v1.0.0 h1:27cbfqXLVEJ1o8I6v3y9lg8Ydm53EKqHXAOMxEGlCOA=
|
||||
github.com/yudai/gojsondiff v1.0.0/go.mod h1:AY32+k2cwILAkW1fbgxQ5mUmMiZFgLIV+FBNExI05xg=
|
||||
github.com/yudai/golcs v0.0.0-20170316035057-ecda9a501e82 h1:BHyfKlQyqbsFN5p3IfnEUduWvb9is428/nNb5L3U01M=
|
||||
github.com/yudai/golcs v0.0.0-20170316035057-ecda9a501e82/go.mod h1:lgjkn3NuSvDfVJdfcVVdX+jpBxNmX4rDAzaS45IcYoM=
|
||||
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-20190902080502-41f04d3bba15/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=
|
||||
23
grafana-plugin/pkg/main.go
Normal file
23
grafana-plugin/pkg/main.go
Normal file
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
120
grafana-plugin/pkg/plugin/app.go
Normal file
120
grafana-plugin/pkg/plugin/app.go
Normal file
|
|
@ -0,0 +1,120 @@
|
|||
package plugin
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"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
|
||||
installMutex sync.Mutex
|
||||
*OnCallSyncCache
|
||||
*OnCallSettingsCache
|
||||
*OnCallUserCache
|
||||
*OnCallDebugStats
|
||||
}
|
||||
|
||||
// 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)
|
||||
app.OnCallSyncCache = &OnCallSyncCache{}
|
||||
app.OnCallSettingsCache = &OnCallSettingsCache{}
|
||||
app.OnCallUserCache = NewOnCallUserCache()
|
||||
app.OnCallDebugStats = &OnCallDebugStats{}
|
||||
|
||||
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) {
|
||||
log.DefaultLogger.Info("CheckHealth")
|
||||
return &backend.CheckHealthResult{
|
||||
Status: backend.HealthStatusOk,
|
||||
Message: "ok",
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Check OnCallApi health
|
||||
func (a *App) CheckOnCallApiHealthStatus(onCallPluginSettings *OnCallPluginSettings) (int, error) {
|
||||
atomic.AddInt32(&a.CheckHealthCallCount, 1)
|
||||
healthURL, err := url.JoinPath(onCallPluginSettings.OnCallAPIURL, "/api/internal/v1/health/")
|
||||
if err != nil {
|
||||
log.DefaultLogger.Error("Error joining path", "error", err)
|
||||
return http.StatusInternalServerError, err
|
||||
}
|
||||
|
||||
parsedHealthURL, err := url.Parse(healthURL)
|
||||
if err != nil {
|
||||
log.DefaultLogger.Error("Error parsing path", "error", err)
|
||||
return http.StatusInternalServerError, err
|
||||
}
|
||||
|
||||
healthReq, err := http.NewRequest("GET", parsedHealthURL.String(), nil)
|
||||
if err != nil {
|
||||
log.DefaultLogger.Error("Error creating request", "error", 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", "error", err)
|
||||
return http.StatusBadRequest, err
|
||||
}
|
||||
|
||||
if healthRes.StatusCode != http.StatusOK {
|
||||
log.DefaultLogger.Error("Error request to oncall", "error", healthRes.Status)
|
||||
return healthRes.StatusCode, fmt.Errorf(healthRes.Status)
|
||||
}
|
||||
|
||||
return http.StatusOK, nil
|
||||
}
|
||||
97
grafana-plugin/pkg/plugin/debug.go
Normal file
97
grafana-plugin/pkg/plugin/debug.go
Normal file
|
|
@ -0,0 +1,97 @@
|
|||
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"
|
||||
)
|
||||
|
||||
type OnCallDebugStats struct {
|
||||
SettingsCallCount int32 `json:"settingsCallCount"`
|
||||
AllUsersCallCount int32 `json:"allUsersCallCount"`
|
||||
PermissionsCallCount int32 `json:"permissionsCallCount"`
|
||||
AllPermissionsCallCount int32 `json:"allPermissionsCallCount"`
|
||||
TeamForUserCallCount int32 `json:"teamForUserCallCount"`
|
||||
AllTeamsCallCount int32 `json:"allTeamsCallCount"`
|
||||
TeamMembersForTeamCallCount int32 `json:"teamMembersForTeamCallCount"`
|
||||
CheckHealthCallCount int32 `json:"checkHealthCallCount"`
|
||||
}
|
||||
|
||||
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", "error", err)
|
||||
return
|
||||
}
|
||||
|
||||
user := httpadapter.UserFromContext(req.Context())
|
||||
onCallUser, err := a.GetUserForHeader(onCallPluginSettings, user)
|
||||
if err != nil {
|
||||
log.DefaultLogger.Error("Error getting user", "error", 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", "error", err)
|
||||
return
|
||||
}
|
||||
|
||||
onCallSync, err := a.GetSyncData(req.Context(), onCallPluginSettings)
|
||||
if err != nil {
|
||||
log.DefaultLogger.Error("Error getting sync data", "error", 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", "error", 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)
|
||||
}
|
||||
|
||||
func (a *App) handleDebugPermissions(w http.ResponseWriter, req *http.Request) {
|
||||
pluginContext := httpadapter.PluginConfigFromContext(req.Context())
|
||||
|
||||
w.Header().Add("Content-Type", "application/json")
|
||||
if err := json.NewEncoder(w).Encode(pluginContext); err != nil {
|
||||
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}
|
||||
|
||||
func (a *App) handleDebugStats(w http.ResponseWriter, req *http.Request) {
|
||||
w.Header().Add("Content-Type", "application/json")
|
||||
if err := json.NewEncoder(w).Encode(a.OnCallDebugStats); err != nil {
|
||||
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}
|
||||
11
grafana-plugin/pkg/plugin/errors.go
Normal file
11
grafana-plugin/pkg/plugin/errors.go
Normal file
|
|
@ -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"`
|
||||
}
|
||||
136
grafana-plugin/pkg/plugin/install.go
Normal file
136
grafana-plugin/pkg/plugin/install.go
Normal file
|
|
@ -0,0 +1,136 @@
|
|||
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"`
|
||||
}
|
||||
|
||||
func (a *App) handleInstall(w http.ResponseWriter, req *http.Request) {
|
||||
if req.Method != http.MethodPost {
|
||||
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
|
||||
locked := a.installMutex.TryLock()
|
||||
if !locked {
|
||||
http.Error(w, "Install is already in progress", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
defer a.installMutex.Unlock()
|
||||
|
||||
onCallPluginSettings, err := a.OnCallSettingsFromContext(req.Context())
|
||||
if err != nil {
|
||||
log.DefaultLogger.Error("Error getting settings from context", "error", err)
|
||||
return
|
||||
}
|
||||
|
||||
healthStatus, err := a.CheckOnCallApiHealthStatus(onCallPluginSettings)
|
||||
if err != nil {
|
||||
log.DefaultLogger.Error("Error checking on-call API health", "error", 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", "error", err)
|
||||
return
|
||||
}
|
||||
|
||||
onCallSyncJsonData, err := json.Marshal(onCallSync)
|
||||
if err != nil {
|
||||
log.DefaultLogger.Error("Error marshalling JSON", "error", err)
|
||||
return
|
||||
}
|
||||
|
||||
installURL, err := url.JoinPath(onCallPluginSettings.OnCallAPIURL, "api/internal/v1/plugin/v2/install")
|
||||
if err != nil {
|
||||
log.DefaultLogger.Error("Error joining path", "error", err)
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
parsedInstallURL, err := url.Parse(installURL)
|
||||
if err != nil {
|
||||
log.DefaultLogger.Error("Error parsing path", "error", 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", "error", 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", "error", err)
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
defer res.Body.Close()
|
||||
|
||||
if res.StatusCode != http.StatusOK {
|
||||
errorBody, err := io.ReadAll(res.Body)
|
||||
var installError = OnCallInstall{
|
||||
OnCallError: OnCallError{
|
||||
Code: INSTALL_ERROR_CODE,
|
||||
Message: "Install failed check /status for details",
|
||||
},
|
||||
}
|
||||
if errorBody != nil {
|
||||
var tempError OnCallError
|
||||
err = json.Unmarshal(errorBody, &tempError)
|
||||
if err != nil {
|
||||
log.DefaultLogger.Error("Error unmarshalling OnCallError", "error", err)
|
||||
}
|
||||
if tempError.Message == "" {
|
||||
installError.OnCallError.Message = string(errorBody)
|
||||
} else {
|
||||
installError.OnCallError = tempError
|
||||
}
|
||||
}
|
||||
|
||||
w.Header().Add("Content-Type", "application/json")
|
||||
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", "error", err)
|
||||
return
|
||||
}
|
||||
|
||||
var provisioningData OnCallProvisioningJSONData
|
||||
err = json.Unmarshal(provisionBody, &provisioningData)
|
||||
if err != nil {
|
||||
log.DefaultLogger.Error("Error unmarshalling OnCallProvisioningJSONData", "error", err)
|
||||
return
|
||||
}
|
||||
|
||||
onCallPluginSettings.OnCallToken = provisioningData.OnCallToken
|
||||
err = a.SaveOnCallSettings(onCallPluginSettings)
|
||||
if err != nil {
|
||||
log.DefaultLogger.Error("Error saving settings", "error", err)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}
|
||||
|
||||
}
|
||||
98
grafana-plugin/pkg/plugin/permissions.go
Normal file
98
grafana-plugin/pkg/plugin/permissions.go
Normal file
|
|
@ -0,0 +1,98 @@
|
|||
package plugin
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
)
|
||||
|
||||
type OnCallPermission struct {
|
||||
Action string `json:"action"`
|
||||
}
|
||||
|
||||
func (a *App) GetPermissions(settings *OnCallPluginSettings, onCallUser *OnCallUser) ([]OnCallPermission, error) {
|
||||
atomic.AddInt32(&a.PermissionsCallCount, 1)
|
||||
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) {
|
||||
atomic.AddInt32(&a.AllPermissionsCallCount, 1)
|
||||
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)
|
||||
}
|
||||
209
grafana-plugin/pkg/plugin/proxy.go
Normal file
209
grafana-plugin/pkg/plugin/proxy.go
Normal file
|
|
@ -0,0 +1,209 @@
|
|||
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 {
|
||||
req.Header.Del("Cookie")
|
||||
req.Header.Del("Set-Cookie")
|
||||
|
||||
SetAuthorizationHeader(settings, req)
|
||||
|
||||
err := SetXInstanceContextHeader(settings, req)
|
||||
if err != nil {
|
||||
log.DefaultLogger.Error("Error setting instance header", "error", err)
|
||||
return err
|
||||
}
|
||||
|
||||
pluginContext := httpadapter.PluginConfigFromContext(ctx)
|
||||
req.Header.Set("User-Agent", fmt.Sprintf("GrafanaOnCall/%s", pluginContext.PluginVersion))
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *App) SetupRequestHeadersForOnCallWithUser(ctx context.Context, settings *OnCallPluginSettings, req *http.Request) error {
|
||||
err := a.SetupRequestHeadersForOnCall(ctx, settings, req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
user := httpadapter.UserFromContext(ctx)
|
||||
onCallUser, err := a.GetUserForHeader(settings, user)
|
||||
if err != nil {
|
||||
log.DefaultLogger.Error("Error getting user", "error", err)
|
||||
return err
|
||||
}
|
||||
|
||||
err = SetXGrafanaContextHeader(user, onCallUser.ID, req)
|
||||
if err != nil {
|
||||
log.DefaultLogger.Error("Error setting context header", "error", err)
|
||||
return err
|
||||
}
|
||||
|
||||
err = SetOnCallUserHeader(onCallUser, req)
|
||||
if err != nil {
|
||||
log.DefaultLogger.Error("Error setting user header", "error", 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", "error", 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", "error", 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", "error", err)
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
parsedReqURL, err := url.Parse(reqURL)
|
||||
if err != nil {
|
||||
log.DefaultLogger.Error("Error parsing path", "error", 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", "error", err)
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
proxyReq.Header = req.Header
|
||||
err = a.SetupRequestHeadersForOnCallWithUser(req.Context(), onCallPluginSettings, proxyReq)
|
||||
if err != nil {
|
||||
log.DefaultLogger.Error("Error setting up headers", "error", err)
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if proxyMethod == "POST" || proxyMethod == "PUT" || proxyMethod == "PATCH" {
|
||||
proxyReq.Header.Set("Content-Type", "application/json")
|
||||
}
|
||||
|
||||
res, err := a.httpClient.Do(proxyReq)
|
||||
if err != nil {
|
||||
log.DefaultLogger.Error("Error request to oncall", "error", 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)
|
||||
}
|
||||
137
grafana-plugin/pkg/plugin/resources.go
Normal file
137
grafana-plugin/pkg/plugin/resources.go
Normal file
|
|
@ -0,0 +1,137 @@
|
|||
package plugin
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"sort"
|
||||
|
||||
"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"`
|
||||
}
|
||||
|
||||
func (a *OnCallSync) Equal(b *OnCallSync) bool {
|
||||
if len(a.Users) != len(b.Users) || len(a.Teams) != len(b.Teams) || len(a.TeamMembers) != len(b.TeamMembers) {
|
||||
return false
|
||||
}
|
||||
for i := range a.Users {
|
||||
if !a.Users[i].Equal(&b.Users[i]) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
for i := range a.Teams {
|
||||
if !a.Teams[i].Equal(&b.Teams[i]) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
for key, teamMembersA := range a.TeamMembers {
|
||||
if teamMembersB, exists := b.TeamMembers[key]; !exists {
|
||||
if len(teamMembersA) != len(teamMembersB) {
|
||||
return false
|
||||
}
|
||||
sort.Slice(teamMembersA, func(i, j int) bool {
|
||||
return teamMembersA[i] < teamMembersA[j]
|
||||
})
|
||||
sort.Slice(teamMembersB, func(i, j int) bool {
|
||||
return teamMembersB[i] < teamMembersB[j]
|
||||
})
|
||||
for i := range teamMembersA {
|
||||
if teamMembersA[i] != teamMembersB[i] {
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if !a.Settings.Equal(&b.Settings) {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
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", "error", err)
|
||||
return
|
||||
}
|
||||
|
||||
onCallPluginSettings, err := a.OnCallSettingsFromContext(req.Context())
|
||||
if err != nil {
|
||||
log.DefaultLogger.Error("Error getting settings from context", "error", err)
|
||||
return
|
||||
}
|
||||
|
||||
if provisioningData.Error != "" {
|
||||
log.DefaultLogger.Error("Error installing OnCall", "error", 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", "error", 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))
|
||||
|
||||
// Disable debug endpoints
|
||||
//mux.HandleFunc("/debug/user", a.handleDebugUser)
|
||||
//mux.HandleFunc("/debug/sync", a.handleDebugSync)
|
||||
//mux.HandleFunc("/debug/settings", a.handleDebugSettings)
|
||||
//mux.HandleFunc("/debug/permissions", a.handleDebugPermissions)
|
||||
//mux.HandleFunc("/debug/stats", a.handleDebugStats)
|
||||
|
||||
mux.HandleFunc("/", a.handleInternalApi)
|
||||
}
|
||||
73
grafana-plugin/pkg/plugin/resources_test.go
Normal file
73
grafana-plugin/pkg/plugin/resources_test.go
Normal file
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
343
grafana-plugin/pkg/plugin/settings.go
Normal file
343
grafana-plugin/pkg/plugin/settings.go
Normal file
|
|
@ -0,0 +1,343 @@
|
|||
package plugin
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"regexp"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"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"
|
||||
grafana_plugin_build "github.com/grafana/grafana-plugin-sdk-go/build"
|
||||
)
|
||||
|
||||
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 *OnCallPluginSettings) Equal(b *OnCallPluginSettings) bool {
|
||||
if a.OnCallAPIURL != b.OnCallAPIURL {
|
||||
return false
|
||||
}
|
||||
if a.OnCallToken != b.OnCallToken {
|
||||
return false
|
||||
}
|
||||
if a.StackID != b.StackID {
|
||||
return false
|
||||
}
|
||||
if a.OrgID != b.OrgID {
|
||||
return false
|
||||
}
|
||||
if a.License != b.License {
|
||||
return false
|
||||
}
|
||||
if a.GrafanaURL != b.GrafanaURL {
|
||||
return false
|
||||
}
|
||||
if a.GrafanaToken != b.GrafanaToken {
|
||||
return false
|
||||
}
|
||||
if a.RBACEnabled != b.RBACEnabled {
|
||||
return false
|
||||
}
|
||||
if a.IncidentEnabled != b.IncidentEnabled {
|
||||
return false
|
||||
}
|
||||
if a.IncidentBackendURL != b.IncidentBackendURL {
|
||||
return false
|
||||
}
|
||||
if a.LabelsEnabled != b.LabelsEnabled {
|
||||
return false
|
||||
}
|
||||
if a.ExternalServiceAccountEnabled != b.ExternalServiceAccountEnabled {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
type OnCallSettingsCache struct {
|
||||
otherPluginSettingsLock sync.Mutex
|
||||
otherPluginSettingsCache map[string]map[string]interface{}
|
||||
otherPluginSettingsExpiry time.Time
|
||||
}
|
||||
|
||||
const CLOUD_VERSION_PATTERN = `^(r\d+-v?\d+\.\d+\.\d+|^github-actions-\d+)$`
|
||||
const OSS_VERSION_PATTERN = `^(v?\d+\.\d+\.\d+|dev-oss)$`
|
||||
const CLOUD_LICENSE_NAME = "Cloud"
|
||||
const OPEN_SOURCE_LICENSE_NAME = "OpenSource"
|
||||
const INCIDENT_PLUGIN_ID = "grafana-incident-app"
|
||||
const LABELS_PLUGIN_ID = "grafana-labels-app"
|
||||
const OTHER_PLUGIN_EXPIRY_SECONDS = 60
|
||||
|
||||
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,
|
||||
}
|
||||
|
||||
version := pluginContext.PluginVersion
|
||||
if version == "" {
|
||||
// older Grafana versions do not have the plugin version in the context
|
||||
buildInfo, err := grafana_plugin_build.GetBuildInfo()
|
||||
if err != nil {
|
||||
err = fmt.Errorf("OnCallSettingsFromContext: couldn't get plugin version: %w", err)
|
||||
log.DefaultLogger.Error(err.Error())
|
||||
return nil, err
|
||||
}
|
||||
version = buildInfo.Version
|
||||
}
|
||||
|
||||
if settings.License == "" {
|
||||
cloudRe := regexp.MustCompile(CLOUD_VERSION_PATTERN)
|
||||
ossRe := regexp.MustCompile(OSS_VERSION_PATTERN)
|
||||
if ossRe.MatchString(version) {
|
||||
settings.License = OPEN_SOURCE_LICENSE_NAME
|
||||
} else if cloudRe.MatchString(version) {
|
||||
settings.License = CLOUD_LICENSE_NAME
|
||||
} else {
|
||||
return &settings, fmt.Errorf("jsonData.license is not set and version %s did not match a known pattern", version)
|
||||
}
|
||||
}
|
||||
|
||||
settings.OnCallToken = strings.TrimSpace(pluginContext.AppInstanceSettings.DecryptedSecureJSONData["onCallApiToken"])
|
||||
cfg := backend.GrafanaConfigFromContext(ctx)
|
||||
if settings.GrafanaURL == "" {
|
||||
appUrl, err := cfg.AppURL()
|
||||
if err != nil {
|
||||
return &settings, fmt.Errorf("get GrafanaURL from provisioning failed (not set in jsonData), unable to fallback to grafana cfg")
|
||||
}
|
||||
settings.GrafanaURL = appUrl
|
||||
log.DefaultLogger.Info(fmt.Sprintf("Using Grafana URL from grafana cfg app url: %s", settings.GrafanaURL))
|
||||
} else {
|
||||
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
|
||||
}
|
||||
|
||||
otherPluginSettings := a.GetAllOtherPluginSettings(&settings)
|
||||
pluginSettings, exists := otherPluginSettings[INCIDENT_PLUGIN_ID]
|
||||
if exists {
|
||||
if value, ok := pluginSettings["enabled"].(bool); ok {
|
||||
settings.IncidentEnabled = value
|
||||
}
|
||||
if jsonData, ok := pluginSettings["jsonData"].(map[string]interface{}); ok {
|
||||
if value, ok := jsonData["backendUrl"].(string); ok {
|
||||
settings.IncidentBackendURL = value
|
||||
}
|
||||
}
|
||||
}
|
||||
pluginSettings, exists = otherPluginSettings[LABELS_PLUGIN_ID]
|
||||
if exists {
|
||||
if value, ok := pluginSettings["enabled"].(bool); ok {
|
||||
settings.LabelsEnabled = value
|
||||
}
|
||||
}
|
||||
return &settings, nil
|
||||
}
|
||||
|
||||
func (a *App) GetAllOtherPluginSettings(settings *OnCallPluginSettings) map[string]map[string]interface{} {
|
||||
a.otherPluginSettingsLock.Lock()
|
||||
defer a.otherPluginSettingsLock.Unlock()
|
||||
|
||||
if time.Now().Before(a.otherPluginSettingsExpiry) {
|
||||
return a.otherPluginSettingsCache
|
||||
}
|
||||
|
||||
incidentPluginSettings, err := a.GetOtherPluginSettings(settings, INCIDENT_PLUGIN_ID)
|
||||
if err != nil {
|
||||
log.DefaultLogger.Error("getting incident plugin settings", "error", err)
|
||||
}
|
||||
labelsPluginSettings, err := a.GetOtherPluginSettings(settings, LABELS_PLUGIN_ID)
|
||||
if err != nil {
|
||||
log.DefaultLogger.Error("getting labels plugin settings", "error", err)
|
||||
}
|
||||
|
||||
otherPluginSettings := make(map[string]map[string]interface{})
|
||||
if incidentPluginSettings != nil {
|
||||
otherPluginSettings[INCIDENT_PLUGIN_ID] = incidentPluginSettings
|
||||
}
|
||||
if labelsPluginSettings != nil {
|
||||
otherPluginSettings[LABELS_PLUGIN_ID] = labelsPluginSettings
|
||||
}
|
||||
|
||||
a.otherPluginSettingsCache = otherPluginSettings
|
||||
a.otherPluginSettingsExpiry = time.Now().Add(OTHER_PLUGIN_EXPIRY_SECONDS * time.Second)
|
||||
|
||||
return a.otherPluginSettingsCache
|
||||
}
|
||||
|
||||
func (a *App) GetOtherPluginSettings(settings *OnCallPluginSettings, pluginID string) (map[string]interface{}, error) {
|
||||
atomic.AddInt32(&a.SettingsCallCount, 1)
|
||||
reqURL, err := url.JoinPath(settings.GrafanaURL, fmt.Sprintf("api/plugins/%s/settings", pluginID))
|
||||
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, %v", err, reqURL)
|
||||
}
|
||||
defer res.Body.Close()
|
||||
|
||||
if res.StatusCode != 200 {
|
||||
return nil, fmt.Errorf("request did not return 200: %d", res.StatusCode)
|
||||
}
|
||||
|
||||
body, err := io.ReadAll(res.Body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error reading response: %v", err)
|
||||
}
|
||||
|
||||
var result map[string]interface{}
|
||||
err = json.Unmarshal(body, &result)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to parse JSON response: %v", err)
|
||||
}
|
||||
|
||||
return result, 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) GetSyncData(ctx context.Context, settings *OnCallPluginSettings) (*OnCallSync, error) {
|
||||
startGetSyncData := time.Now()
|
||||
defer func() {
|
||||
elapsed := time.Since(startGetSyncData)
|
||||
log.DefaultLogger.Info("GetSyncData", "time", elapsed.Milliseconds())
|
||||
}()
|
||||
|
||||
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
|
||||
}
|
||||
265
grafana-plugin/pkg/plugin/status.go
Normal file
265
grafana-plugin/pkg/plugin/status.go
Normal file
|
|
@ -0,0 +1,265 @@
|
|||
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", "error", 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.SetupRequestHeadersForOnCallWithUser(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) {
|
||||
if req.Method != http.MethodGet {
|
||||
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
|
||||
onCallPluginSettings, err := a.OnCallSettingsFromContext(req.Context())
|
||||
if err != nil {
|
||||
log.DefaultLogger.Error("Error getting settings from context", "error", 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", "error", 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)
|
||||
|
||||
}
|
||||
138
grafana-plugin/pkg/plugin/sync.go
Normal file
138
grafana-plugin/pkg/plugin/sync.go
Normal file
|
|
@ -0,0 +1,138 @@
|
|||
package plugin
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"github.com/grafana/grafana-plugin-sdk-go/backend/log"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
type OnCallSyncCache struct {
|
||||
syncMutex sync.Mutex
|
||||
lastOnCallSync *OnCallSync
|
||||
}
|
||||
|
||||
func (a *App) handleSync(w http.ResponseWriter, req *http.Request) {
|
||||
if req.Method != http.MethodPost {
|
||||
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
|
||||
waitToCompleteParameter := req.URL.Query().Get("wait")
|
||||
var waitToComplete = false
|
||||
var err error
|
||||
if waitToCompleteParameter != "" {
|
||||
waitToComplete, err = strconv.ParseBool(waitToCompleteParameter)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
forceSendParameter := req.URL.Query().Get("force")
|
||||
var forceSend = false
|
||||
if forceSendParameter != "" {
|
||||
forceSend, err = strconv.ParseBool(forceSendParameter)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if waitToComplete {
|
||||
err := a.makeSyncRequest(req.Context(), forceSend)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
} else {
|
||||
go func() {
|
||||
err := a.makeSyncRequest(req.Context(), forceSend)
|
||||
if err != nil {
|
||||
log.DefaultLogger.Error("Error making sync request", "error", err)
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}
|
||||
|
||||
func (a *App) compareSyncData(newOnCallSync *OnCallSync) bool {
|
||||
if a.lastOnCallSync == nil {
|
||||
log.DefaultLogger.Info("No saved OnCallSync to compare")
|
||||
return false
|
||||
}
|
||||
return newOnCallSync.Equal(a.lastOnCallSync)
|
||||
}
|
||||
|
||||
func (a *App) makeSyncRequest(ctx context.Context, forceSend bool) error {
|
||||
startMakeSyncRequest := time.Now()
|
||||
defer func() {
|
||||
elapsed := time.Since(startMakeSyncRequest)
|
||||
log.DefaultLogger.Info("makeSyncRequest", "time", elapsed.Milliseconds())
|
||||
}()
|
||||
|
||||
locked := a.syncMutex.TryLock()
|
||||
if !locked {
|
||||
return errors.New("sync already in progress")
|
||||
}
|
||||
defer a.syncMutex.Unlock()
|
||||
|
||||
onCallPluginSettings, err := a.OnCallSettingsFromContext(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("error getting settings from context: %v ", err)
|
||||
}
|
||||
|
||||
onCallSync, err := a.GetSyncData(ctx, onCallPluginSettings)
|
||||
if err != nil {
|
||||
return fmt.Errorf("error getting sync data: %v", err)
|
||||
}
|
||||
|
||||
same := a.compareSyncData(onCallSync)
|
||||
if same && !forceSend {
|
||||
log.DefaultLogger.Info("No changes detected to sync")
|
||||
return nil
|
||||
}
|
||||
|
||||
onCallSyncJsonData, err := json.Marshal(onCallSync)
|
||||
if err != nil {
|
||||
return fmt.Errorf("error marshalling JSON: %v", err)
|
||||
}
|
||||
|
||||
syncURL, err := url.JoinPath(onCallPluginSettings.OnCallAPIURL, "api/internal/v1/plugin/v2/sync")
|
||||
if err != nil {
|
||||
return fmt.Errorf("error joining path: %v", err)
|
||||
}
|
||||
|
||||
parsedSyncURL, err := url.Parse(syncURL)
|
||||
if err != nil {
|
||||
return fmt.Errorf("error parsing path: %v", err)
|
||||
}
|
||||
|
||||
syncReq, err := http.NewRequest("POST", parsedSyncURL.String(), bytes.NewBuffer(onCallSyncJsonData))
|
||||
if err != nil {
|
||||
return fmt.Errorf("error creating request: %v", err)
|
||||
}
|
||||
|
||||
err = a.SetupRequestHeadersForOnCall(ctx, onCallPluginSettings, syncReq)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
syncReq.Header.Set("Content-Type", "application/json")
|
||||
|
||||
res, err := a.httpClient.Do(syncReq)
|
||||
if err != nil {
|
||||
return fmt.Errorf("error request to oncall: %v", err)
|
||||
}
|
||||
defer res.Body.Close()
|
||||
|
||||
a.lastOnCallSync = onCallSync
|
||||
return nil
|
||||
}
|
||||
187
grafana-plugin/pkg/plugin/teams.go
Normal file
187
grafana-plugin/pkg/plugin/teams.go
Normal file
|
|
@ -0,0 +1,187 @@
|
|||
package plugin
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"sync/atomic"
|
||||
)
|
||||
|
||||
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 *OnCallTeam) Equal(b *OnCallTeam) bool {
|
||||
if a.ID != b.ID {
|
||||
return false
|
||||
}
|
||||
if a.Name != b.Name {
|
||||
return false
|
||||
}
|
||||
if a.Email != b.Email {
|
||||
return false
|
||||
}
|
||||
if a.AvatarURL != b.AvatarURL {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func (a *App) GetTeamsForUser(settings *OnCallPluginSettings, onCallUser *OnCallUser) ([]int, error) {
|
||||
atomic.AddInt32(&a.TeamForUserCallCount, 1)
|
||||
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) {
|
||||
atomic.AddInt32(&a.AllTeamsCallCount, 1)
|
||||
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) {
|
||||
atomic.AddInt32(&a.TeamMembersForTeamCallCount, 1)
|
||||
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
|
||||
}
|
||||
279
grafana-plugin/pkg/plugin/users.go
Normal file
279
grafana-plugin/pkg/plugin/users.go
Normal file
|
|
@ -0,0 +1,279 @@
|
|||
package plugin
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"sort"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"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 *OnCallUser) Equal(b *OnCallUser) bool {
|
||||
if a.ID != b.ID {
|
||||
return false
|
||||
}
|
||||
if a.Name != b.Name {
|
||||
return false
|
||||
}
|
||||
if a.Login != b.Login {
|
||||
return false
|
||||
}
|
||||
if a.Email != b.Email {
|
||||
return false
|
||||
}
|
||||
if a.Role != b.Role {
|
||||
return false
|
||||
}
|
||||
if a.AvatarURL != b.AvatarURL {
|
||||
return false
|
||||
}
|
||||
|
||||
if len(a.Permissions) != len(b.Permissions) {
|
||||
return false
|
||||
}
|
||||
sort.Slice(a.Permissions, func(i, j int) bool {
|
||||
return a.Permissions[i].Action < a.Permissions[j].Action
|
||||
})
|
||||
sort.Slice(b.Permissions, func(i, j int) bool {
|
||||
return b.Permissions[i].Action < b.Permissions[j].Action
|
||||
})
|
||||
for i := range a.Permissions {
|
||||
if a.Permissions[i].Action != b.Permissions[i].Action {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
if len(a.Teams) != len(b.Teams) {
|
||||
return false
|
||||
}
|
||||
sort.Slice(a.Teams, func(i, j int) bool {
|
||||
return a.Teams[i] < a.Teams[j]
|
||||
})
|
||||
sort.Slice(b.Teams, func(i, j int) bool {
|
||||
return b.Teams[i] < b.Teams[j]
|
||||
})
|
||||
for i := range a.Teams {
|
||||
if a.Teams[i] != b.Teams[i] {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
type OnCallUserCache struct {
|
||||
allUsersLock sync.Mutex
|
||||
allUsersCache map[string]*OnCallUser
|
||||
allUsersExpiry time.Time
|
||||
|
||||
lockInitLock sync.Mutex
|
||||
userLocks map[string]*sync.Mutex
|
||||
userCache map[string]*OnCallUser
|
||||
userExpiry map[string]time.Time
|
||||
}
|
||||
|
||||
const USER_EXPIRY_SECONDS = 60
|
||||
|
||||
func NewOnCallUserCache() *OnCallUserCache {
|
||||
return &OnCallUserCache{
|
||||
allUsersCache: make(map[string]*OnCallUser),
|
||||
userLocks: make(map[string]*sync.Mutex),
|
||||
userCache: make(map[string]*OnCallUser),
|
||||
userExpiry: make(map[string]time.Time),
|
||||
}
|
||||
}
|
||||
|
||||
func (c *OnCallUserCache) GetUserLock(user string) *sync.Mutex {
|
||||
c.lockInitLock.Lock()
|
||||
defer c.lockInitLock.Unlock()
|
||||
lock, exists := c.userLocks[user]
|
||||
if !exists {
|
||||
lock = &sync.Mutex{}
|
||||
c.userLocks[user] = lock
|
||||
}
|
||||
return lock
|
||||
}
|
||||
|
||||
func (a *App) GetUser(settings *OnCallPluginSettings, user *backend.User) (*OnCallUser, error) {
|
||||
log.DefaultLogger.Info("GetUser", "user", user)
|
||||
a.allUsersLock.Lock()
|
||||
defer a.allUsersLock.Unlock()
|
||||
|
||||
if time.Now().Before(a.allUsersExpiry) {
|
||||
ocu, exists := a.allUsersCache[user.Login]
|
||||
if !exists {
|
||||
return nil, fmt.Errorf("user %s not found", user.Login)
|
||||
}
|
||||
return ocu, nil
|
||||
}
|
||||
|
||||
users, err := a.GetAllUsers(settings)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var oncallUser *OnCallUser
|
||||
allUsersCache := make(map[string]*OnCallUser)
|
||||
for i := range users {
|
||||
u := &users[i]
|
||||
allUsersCache[u.Login] = u
|
||||
if u.Login == user.Login {
|
||||
oncallUser = u
|
||||
}
|
||||
}
|
||||
|
||||
a.allUsersCache = allUsersCache
|
||||
a.allUsersExpiry = time.Now().Add(USER_EXPIRY_SECONDS * time.Second)
|
||||
|
||||
if oncallUser == nil {
|
||||
return nil, fmt.Errorf("user %s not found", user.Login)
|
||||
}
|
||||
return oncallUser, nil
|
||||
}
|
||||
|
||||
func (a *App) GetUserForHeader(settings *OnCallPluginSettings, user *backend.User) (*OnCallUser, error) {
|
||||
userLock := a.GetUserLock(user.Login)
|
||||
userLock.Lock()
|
||||
defer userLock.Unlock()
|
||||
|
||||
ue, expiryExists := a.userExpiry[user.Login]
|
||||
if expiryExists && time.Now().Before(ue) {
|
||||
ocu, userExists := a.userCache[user.Login]
|
||||
if !userExists {
|
||||
return nil, fmt.Errorf("user %s not found", user.Login)
|
||||
}
|
||||
return ocu, nil
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
a.userCache[user.Login] = onCallUser
|
||||
a.userExpiry[user.Login] = time.Now().Add(USER_EXPIRY_SECONDS * time.Second)
|
||||
return onCallUser, nil
|
||||
}
|
||||
|
||||
func (a *App) GetAllUsers(settings *OnCallPluginSettings) ([]OnCallUser, error) {
|
||||
atomic.AddInt32(&a.AllUsersCallCount, 1)
|
||||
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("Did not find permissions for user", "user", onCallUsers[i].Login)
|
||||
}
|
||||
}
|
||||
}
|
||||
return onCallUsers, nil
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
@ -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<IntegrationCollapsibleItem | IntegrationCollapsibleItem[]>;
|
||||
configElements: Array<CollapsibleItem | CollapsibleItem[]>;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export const IntegrationCollapsibleTreeView: React.FC<IntegrationCollapsibleTreeViewProps> = observer((props) => {
|
||||
const { configElements, isRouteView } = props;
|
||||
export const CollapsibleTreeView: React.FC<CollapsibleTreeViewProps> = 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<IntegrationCollapsibleTree
|
|||
}, [configElements]);
|
||||
|
||||
return (
|
||||
<div className={cx(styles.container, isRouteView ? styles.timeline : '')}>
|
||||
<div className={cx(styles.container, isRouteView ? styles.timeline : '', className)}>
|
||||
{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) => (
|
||||
<IntegrationCollapsibleTreeItem
|
||||
<CollapsibleTreeItem
|
||||
item={it}
|
||||
key={`${idx}-${innerIdx}`}
|
||||
onClick={() => expandOrCollapseAtPos(!expandedList[idx][innerIdx], idx, innerIdx)}
|
||||
|
|
@ -56,7 +57,7 @@ export const IntegrationCollapsibleTreeView: React.FC<IntegrationCollapsibleTree
|
|||
}
|
||||
|
||||
return (
|
||||
<IntegrationCollapsibleTreeItem
|
||||
<CollapsibleTreeItem
|
||||
item={item}
|
||||
key={idx}
|
||||
elementPosition={idx + 1} // start from 1 instead of 0
|
||||
|
|
@ -83,12 +84,12 @@ export const IntegrationCollapsibleTreeView: React.FC<IntegrationCollapsibleTree
|
|||
|
||||
function expandOrCollapseAtPos(isChecked: boolean, i: number, j: number = undefined) {
|
||||
if (j !== undefined) {
|
||||
let elem = configElements[i] as IntegrationCollapsibleItem[];
|
||||
let elem = configElements[i] as CollapsibleItem[];
|
||||
if (elem[j].onStateChange) {
|
||||
elem[j].onStateChange(isChecked);
|
||||
}
|
||||
} else {
|
||||
let elem = configElements[i] as IntegrationCollapsibleItem;
|
||||
let elem = configElements[i] as CollapsibleItem;
|
||||
if (elem.onStateChange) {
|
||||
elem.onStateChange(isChecked);
|
||||
}
|
||||
|
|
@ -108,13 +109,13 @@ export const IntegrationCollapsibleTreeView: React.FC<IntegrationCollapsibleTree
|
|||
}
|
||||
});
|
||||
|
||||
const IntegrationCollapsibleTreeItem: React.FC<{
|
||||
item: IntegrationCollapsibleItem;
|
||||
const CollapsibleTreeItem: React.FC<{
|
||||
item: CollapsibleItem;
|
||||
elementPosition?: number;
|
||||
isExpanded: boolean;
|
||||
onClick: () => void;
|
||||
}> = ({ item, elementPosition, isExpanded, onClick }) => {
|
||||
const styles = useStyles2(getIntegrationCollapsibleTreeStyles);
|
||||
const styles = useStyles2(getCollapsibleTreeStyles);
|
||||
const handleIconClick = !item.isCollapsible ? undefined : onClick;
|
||||
|
||||
return (
|
||||
|
|
@ -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<FullPageErrorProps> = ({
|
||||
title = 'An unexpected error happened',
|
||||
subtitle,
|
||||
children,
|
||||
}) => {
|
||||
const styles = useStyles2(getStyles);
|
||||
|
||||
return (
|
||||
<div className={styles.wrapper}>
|
||||
<VerticalGroup align="center" spacing="md">
|
||||
<img src={errorSVG} alt="" />
|
||||
<Text.Title level={3}>{title}</Text.Title>
|
||||
{subtitle && <Text type="secondary">{subtitle}</Text>}
|
||||
{children}
|
||||
</VerticalGroup>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const getStyles = () => ({
|
||||
wrapper: css`
|
||||
margin: 24px auto;
|
||||
width: 600px;
|
||||
text-align: center;
|
||||
`,
|
||||
});
|
||||
|
|
@ -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;
|
||||
`,
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ interface TextProps extends HTMLAttributes<HTMLElement> {
|
|||
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 },
|
||||
|
|
|
|||
|
|
@ -20,7 +20,7 @@ exports[`Unauthorized renders properly - access control enabled: false 1`] = `
|
|||
className="css-1fmhfo9"
|
||||
>
|
||||
<span
|
||||
className="css-77ouhj--undefined css-77ouhj--medium css-1287p17"
|
||||
className="css-77ouhj--undefined css-77ouhj--medium css-1rchs6o--inline css-66w5es"
|
||||
style={
|
||||
{
|
||||
"maxWidth": undefined,
|
||||
|
|
@ -38,7 +38,7 @@ exports[`Unauthorized renders properly - access control enabled: false 1`] = `
|
|||
className="css-u023fv"
|
||||
>
|
||||
<span
|
||||
className="css-77ouhj--undefined css-77ouhj--medium css-1287p17"
|
||||
className="css-77ouhj--undefined css-77ouhj--medium css-1rchs6o--inline css-66w5es"
|
||||
style={
|
||||
{
|
||||
"maxWidth": undefined,
|
||||
|
|
@ -78,7 +78,7 @@ exports[`Unauthorized renders properly - access control enabled: true 1`] = `
|
|||
className="css-1fmhfo9"
|
||||
>
|
||||
<span
|
||||
className="css-77ouhj--undefined css-77ouhj--medium css-1287p17"
|
||||
className="css-77ouhj--undefined css-77ouhj--medium css-1rchs6o--inline css-66w5es"
|
||||
style={
|
||||
{
|
||||
"maxWidth": undefined,
|
||||
|
|
@ -96,7 +96,7 @@ exports[`Unauthorized renders properly - access control enabled: true 1`] = `
|
|||
className="css-u023fv"
|
||||
>
|
||||
<span
|
||||
className="css-77ouhj--undefined css-77ouhj--medium css-1287p17"
|
||||
className="css-77ouhj--undefined css-77ouhj--medium css-1rchs6o--inline css-66w5es"
|
||||
style={
|
||||
{
|
||||
"maxWidth": undefined,
|
||||
|
|
@ -136,7 +136,7 @@ exports[`Unauthorized renders properly the grammar for different roles - Admin 1
|
|||
className="css-1fmhfo9"
|
||||
>
|
||||
<span
|
||||
className="css-77ouhj--undefined css-77ouhj--medium css-1287p17"
|
||||
className="css-77ouhj--undefined css-77ouhj--medium css-1rchs6o--inline css-66w5es"
|
||||
style={
|
||||
{
|
||||
"maxWidth": undefined,
|
||||
|
|
@ -154,7 +154,7 @@ exports[`Unauthorized renders properly the grammar for different roles - Admin 1
|
|||
className="css-u023fv"
|
||||
>
|
||||
<span
|
||||
className="css-77ouhj--undefined css-77ouhj--medium css-1287p17"
|
||||
className="css-77ouhj--undefined css-77ouhj--medium css-1rchs6o--inline css-66w5es"
|
||||
style={
|
||||
{
|
||||
"maxWidth": undefined,
|
||||
|
|
@ -194,7 +194,7 @@ exports[`Unauthorized renders properly the grammar for different roles - Editor
|
|||
className="css-1fmhfo9"
|
||||
>
|
||||
<span
|
||||
className="css-77ouhj--undefined css-77ouhj--medium css-1287p17"
|
||||
className="css-77ouhj--undefined css-77ouhj--medium css-1rchs6o--inline css-66w5es"
|
||||
style={
|
||||
{
|
||||
"maxWidth": undefined,
|
||||
|
|
@ -212,7 +212,7 @@ exports[`Unauthorized renders properly the grammar for different roles - Editor
|
|||
className="css-u023fv"
|
||||
>
|
||||
<span
|
||||
className="css-77ouhj--undefined css-77ouhj--medium css-1287p17"
|
||||
className="css-77ouhj--undefined css-77ouhj--medium css-1rchs6o--inline css-66w5es"
|
||||
style={
|
||||
{
|
||||
"maxWidth": undefined,
|
||||
|
|
@ -252,7 +252,7 @@ exports[`Unauthorized renders properly the grammar for different roles - Viewer
|
|||
className="css-1fmhfo9"
|
||||
>
|
||||
<span
|
||||
className="css-77ouhj--undefined css-77ouhj--medium css-1287p17"
|
||||
className="css-77ouhj--undefined css-77ouhj--medium css-1rchs6o--inline css-66w5es"
|
||||
style={
|
||||
{
|
||||
"maxWidth": undefined,
|
||||
|
|
@ -270,7 +270,7 @@ exports[`Unauthorized renders properly the grammar for different roles - Viewer
|
|||
className="css-u023fv"
|
||||
>
|
||||
<span
|
||||
className="css-77ouhj--undefined css-77ouhj--medium css-1287p17"
|
||||
className="css-77ouhj--undefined css-77ouhj--medium css-1rchs6o--inline css-66w5es"
|
||||
style={
|
||||
{
|
||||
"maxWidth": undefined,
|
||||
|
|
|
|||
|
|
@ -19,7 +19,7 @@ exports[`AddResponders should properly display the add responders button when hi
|
|||
class="css-u023fv"
|
||||
>
|
||||
<span
|
||||
class="css-77ouhj--primary css-77ouhj--medium css-1287p17"
|
||||
class="css-77ouhj--primary css-77ouhj--medium css-1rchs6o--inline css-66w5es"
|
||||
>
|
||||
Participants
|
||||
</span>
|
||||
|
|
@ -70,7 +70,7 @@ exports[`AddResponders should properly display the add responders button when hi
|
|||
class="css-u023fv"
|
||||
>
|
||||
<span
|
||||
class="css-77ouhj--primary css-77ouhj--medium css-1287p17"
|
||||
class="css-77ouhj--primary css-77ouhj--medium css-1rchs6o--inline css-66w5es"
|
||||
>
|
||||
Participants
|
||||
</span>
|
||||
|
|
@ -104,7 +104,7 @@ exports[`AddResponders should render properly in create mode 1`] = `
|
|||
class="css-u023fv"
|
||||
>
|
||||
<span
|
||||
class="css-77ouhj--primary css-77ouhj--medium css-1287p17"
|
||||
class="css-77ouhj--primary css-77ouhj--medium css-1rchs6o--inline css-66w5es"
|
||||
>
|
||||
Participants
|
||||
</span>
|
||||
|
|
@ -155,7 +155,7 @@ exports[`AddResponders should render properly in update mode 1`] = `
|
|||
class="css-u023fv"
|
||||
>
|
||||
<span
|
||||
class="css-77ouhj--primary css-77ouhj--medium css-1287p17"
|
||||
class="css-77ouhj--primary css-77ouhj--medium css-1rchs6o--inline css-66w5es"
|
||||
>
|
||||
Participants
|
||||
</span>
|
||||
|
|
@ -206,7 +206,7 @@ exports[`AddResponders should render selected team and users properly 1`] = `
|
|||
class="css-u023fv"
|
||||
>
|
||||
<span
|
||||
class="css-77ouhj--primary css-77ouhj--medium css-1287p17"
|
||||
class="css-77ouhj--primary css-77ouhj--medium css-1rchs6o--inline css-66w5es"
|
||||
>
|
||||
Participants
|
||||
</span>
|
||||
|
|
@ -262,7 +262,7 @@ exports[`AddResponders should render selected team and users properly 1`] = `
|
|||
class="css-18qv8yz-layoutChildrenWrapper"
|
||||
>
|
||||
<span
|
||||
class="css-77ouhj--undefined css-77ouhj--medium css-r15ga2"
|
||||
class="css-77ouhj--undefined css-77ouhj--medium css-1rchs6o--inline css-1dl45yk"
|
||||
>
|
||||
my test team
|
||||
</span>
|
||||
|
|
@ -311,7 +311,7 @@ exports[`AddResponders should render selected team and users properly 1`] = `
|
|||
class="css-18qv8yz-layoutChildrenWrapper"
|
||||
>
|
||||
<span
|
||||
class="css-77ouhj--undefined css-77ouhj--medium css-r15ga2"
|
||||
class="css-77ouhj--undefined css-77ouhj--medium css-1rchs6o--inline css-1dl45yk"
|
||||
>
|
||||
my test user3
|
||||
</span>
|
||||
|
|
@ -420,7 +420,7 @@ exports[`AddResponders should render selected team and users properly 1`] = `
|
|||
class="css-18qv8yz-layoutChildrenWrapper"
|
||||
>
|
||||
<span
|
||||
class="css-77ouhj--undefined css-77ouhj--medium css-r15ga2"
|
||||
class="css-77ouhj--undefined css-77ouhj--medium css-1rchs6o--inline css-1dl45yk"
|
||||
>
|
||||
my test user
|
||||
</span>
|
||||
|
|
@ -528,7 +528,7 @@ exports[`AddResponders should render selected team and users properly 1`] = `
|
|||
class="css-18qv8yz-layoutChildrenWrapper"
|
||||
>
|
||||
<span
|
||||
class="css-77ouhj--undefined css-77ouhj--medium css-r15ga2"
|
||||
class="css-77ouhj--undefined css-77ouhj--medium css-1rchs6o--inline css-1dl45yk"
|
||||
>
|
||||
my test user2
|
||||
</span>
|
||||
|
|
@ -630,7 +630,7 @@ exports[`AddResponders should render selected team and users properly 1`] = `
|
|||
class="css-b9x8ok"
|
||||
>
|
||||
<span
|
||||
class="css-77ouhj--primary css-77ouhj--medium css-1287p17"
|
||||
class="css-77ouhj--primary css-77ouhj--medium css-1rchs6o--inline css-66w5es"
|
||||
>
|
||||
<a
|
||||
class="css-1cvxpvr"
|
||||
|
|
@ -639,7 +639,7 @@ exports[`AddResponders should render selected team and users properly 1`] = `
|
|||
target="_blank"
|
||||
>
|
||||
<span
|
||||
class="css-77ouhj--link css-77ouhj--medium css-1287p17"
|
||||
class="css-77ouhj--link css-77ouhj--medium css-1rchs6o--inline css-66w5es"
|
||||
>
|
||||
<div
|
||||
class="css-ffyaiw-horizontal-group"
|
||||
|
|
|
|||
|
|
@ -31,7 +31,7 @@ exports[`TeamResponder it renders data properly 1`] = `
|
|||
class="css-18qv8yz-layoutChildrenWrapper"
|
||||
>
|
||||
<span
|
||||
class="css-77ouhj--undefined css-77ouhj--medium css-r15ga2"
|
||||
class="css-77ouhj--undefined css-77ouhj--medium css-1rchs6o--inline css-1dl45yk"
|
||||
>
|
||||
my test team
|
||||
</span>
|
||||
|
|
|
|||
|
|
@ -31,7 +31,7 @@ exports[`UserResponder it renders data properly 1`] = `
|
|||
class="css-18qv8yz-layoutChildrenWrapper"
|
||||
>
|
||||
<span
|
||||
class="css-77ouhj--undefined css-77ouhj--medium css-r15ga2"
|
||||
class="css-77ouhj--undefined css-77ouhj--medium css-1rchs6o--inline css-1dl45yk"
|
||||
>
|
||||
johnsmith
|
||||
</span>
|
||||
|
|
|
|||
|
|
@ -17,11 +17,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';
|
||||
|
|
@ -164,7 +161,7 @@ export const ExpandedIntegrationRouteDisplay: React.FC<ExpandedIntegrationRouteD
|
|||
const hasLabels = store.hasFeature(AppFeature.Labels);
|
||||
const escChainDisplayName = escalationChainStore.items[channelFilter.escalation_chain]?.name;
|
||||
const getTreeViewElements = () => {
|
||||
const configs: IntegrationCollapsibleItem[] = [
|
||||
const configs: CollapsibleItem[] = [
|
||||
{
|
||||
isHidden: false,
|
||||
isCollapsible: false,
|
||||
|
|
@ -390,11 +387,7 @@ export const ExpandedIntegrationRouteDisplay: React.FC<ExpandedIntegrationRouteD
|
|||
</div>
|
||||
}
|
||||
content={
|
||||
<IntegrationCollapsibleTreeView
|
||||
configElements={getTreeViewElements()}
|
||||
isRouteView
|
||||
startingElemPosition="0%"
|
||||
/>
|
||||
<CollapsibleTreeView configElements={getTreeViewElements() as any} isRouteView startingElemPosition="0%" />
|
||||
}
|
||||
/>
|
||||
{routeIdForDeletion && (
|
||||
|
|
|
|||
|
|
@ -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,17 @@ export const MobileAppConnectionWrapper: React.FC<{}> = observer(() => {
|
|||
}
|
||||
};
|
||||
|
||||
if (store.isBasicDataLoaded && userStore.currentUserPk) {
|
||||
return <MobileAppConnection userPk={userStore.currentUserPk} />;
|
||||
}
|
||||
|
||||
return <LoadingPlaceholder text="Loading..." />;
|
||||
return (
|
||||
<PluginInitializer>
|
||||
<RenderConditionally
|
||||
shouldRender={Boolean(store.isBasicDataLoaded && userStore.currentUserPk)}
|
||||
render={() => (
|
||||
<div data-testid="mobile-app-connection">
|
||||
<MobileAppConnection userPk={userStore.currentUserPk} />
|
||||
</div>
|
||||
)}
|
||||
backupChildren={<LoadingPlaceholder text="Loading..." />}
|
||||
/>
|
||||
</PluginInitializer>
|
||||
);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -40,7 +40,7 @@ exports[`MobileAppConnection it shows a QR code if the app isn't already connect
|
|||
class="css-12oo3x0-layoutChildrenWrapper"
|
||||
>
|
||||
<span
|
||||
class="css-77ouhj--primary css-77ouhj--medium css-77ouhj--strong css-1287p17"
|
||||
class="css-77ouhj--primary css-77ouhj--medium css-1rchs6o--inline css-77ouhj--strong css-66w5es"
|
||||
>
|
||||
Download
|
||||
</span>
|
||||
|
|
@ -49,7 +49,7 @@ exports[`MobileAppConnection it shows a QR code if the app isn't already connect
|
|||
class="css-12oo3x0-layoutChildrenWrapper"
|
||||
>
|
||||
<span
|
||||
class="css-77ouhj--primary css-77ouhj--medium css-1287p17"
|
||||
class="css-77ouhj--primary css-77ouhj--medium css-1rchs6o--inline css-66w5es"
|
||||
>
|
||||
The Grafana OnCall app is available on both the App Store and Google Play Store.
|
||||
</span>
|
||||
|
|
@ -79,7 +79,7 @@ exports[`MobileAppConnection it shows a QR code if the app isn't already connect
|
|||
src="[object Object]"
|
||||
/>
|
||||
<span
|
||||
class="css-77ouhj--primary css-77ouhj--medium icon-text css-1287p17"
|
||||
class="css-77ouhj--primary css-77ouhj--medium css-1rchs6o--inline icon-text css-66w5es"
|
||||
>
|
||||
iOS
|
||||
</span>
|
||||
|
|
@ -104,7 +104,7 @@ exports[`MobileAppConnection it shows a QR code if the app isn't already connect
|
|||
src="[object Object]"
|
||||
/>
|
||||
<span
|
||||
class="css-77ouhj--primary css-77ouhj--medium icon-text css-1287p17"
|
||||
class="css-77ouhj--primary css-77ouhj--medium css-1rchs6o--inline icon-text css-66w5es"
|
||||
>
|
||||
Android
|
||||
</span>
|
||||
|
|
@ -161,7 +161,7 @@ exports[`MobileAppConnection it shows a loading message if it is currently disco
|
|||
class="css-12oo3x0-layoutChildrenWrapper"
|
||||
>
|
||||
<span
|
||||
class="css-77ouhj--primary css-77ouhj--medium css-77ouhj--strong css-1287p17"
|
||||
class="css-77ouhj--primary css-77ouhj--medium css-1rchs6o--inline css-77ouhj--strong css-66w5es"
|
||||
>
|
||||
Download
|
||||
</span>
|
||||
|
|
@ -170,7 +170,7 @@ exports[`MobileAppConnection it shows a loading message if it is currently disco
|
|||
class="css-12oo3x0-layoutChildrenWrapper"
|
||||
>
|
||||
<span
|
||||
class="css-77ouhj--primary css-77ouhj--medium css-1287p17"
|
||||
class="css-77ouhj--primary css-77ouhj--medium css-1rchs6o--inline css-66w5es"
|
||||
>
|
||||
The Grafana OnCall app is available on both the App Store and Google Play Store.
|
||||
</span>
|
||||
|
|
@ -200,7 +200,7 @@ exports[`MobileAppConnection it shows a loading message if it is currently disco
|
|||
src="[object Object]"
|
||||
/>
|
||||
<span
|
||||
class="css-77ouhj--primary css-77ouhj--medium icon-text css-1287p17"
|
||||
class="css-77ouhj--primary css-77ouhj--medium css-1rchs6o--inline icon-text css-66w5es"
|
||||
>
|
||||
iOS
|
||||
</span>
|
||||
|
|
@ -225,7 +225,7 @@ exports[`MobileAppConnection it shows a loading message if it is currently disco
|
|||
src="[object Object]"
|
||||
/>
|
||||
<span
|
||||
class="css-77ouhj--primary css-77ouhj--medium icon-text css-1287p17"
|
||||
class="css-77ouhj--primary css-77ouhj--medium css-1rchs6o--inline icon-text css-66w5es"
|
||||
>
|
||||
Android
|
||||
</span>
|
||||
|
|
@ -282,7 +282,7 @@ exports[`MobileAppConnection it shows a loading message if it is currently fetch
|
|||
class="css-12oo3x0-layoutChildrenWrapper"
|
||||
>
|
||||
<span
|
||||
class="css-77ouhj--primary css-77ouhj--medium css-77ouhj--strong css-1287p17"
|
||||
class="css-77ouhj--primary css-77ouhj--medium css-1rchs6o--inline css-77ouhj--strong css-66w5es"
|
||||
>
|
||||
Download
|
||||
</span>
|
||||
|
|
@ -291,7 +291,7 @@ exports[`MobileAppConnection it shows a loading message if it is currently fetch
|
|||
class="css-12oo3x0-layoutChildrenWrapper"
|
||||
>
|
||||
<span
|
||||
class="css-77ouhj--primary css-77ouhj--medium css-1287p17"
|
||||
class="css-77ouhj--primary css-77ouhj--medium css-1rchs6o--inline css-66w5es"
|
||||
>
|
||||
The Grafana OnCall app is available on both the App Store and Google Play Store.
|
||||
</span>
|
||||
|
|
@ -321,7 +321,7 @@ exports[`MobileAppConnection it shows a loading message if it is currently fetch
|
|||
src="[object Object]"
|
||||
/>
|
||||
<span
|
||||
class="css-77ouhj--primary css-77ouhj--medium icon-text css-1287p17"
|
||||
class="css-77ouhj--primary css-77ouhj--medium css-1rchs6o--inline icon-text css-66w5es"
|
||||
>
|
||||
iOS
|
||||
</span>
|
||||
|
|
@ -346,7 +346,7 @@ exports[`MobileAppConnection it shows a loading message if it is currently fetch
|
|||
src="[object Object]"
|
||||
/>
|
||||
<span
|
||||
class="css-77ouhj--primary css-77ouhj--medium icon-text css-1287p17"
|
||||
class="css-77ouhj--primary css-77ouhj--medium css-1rchs6o--inline icon-text css-66w5es"
|
||||
>
|
||||
Android
|
||||
</span>
|
||||
|
|
@ -373,7 +373,7 @@ exports[`MobileAppConnection it shows a warning when cloud is not connected 1`]
|
|||
class="css-12oo3x0-layoutChildrenWrapper"
|
||||
>
|
||||
<span
|
||||
class="css-77ouhj--secondary css-77ouhj--medium css-1287p17"
|
||||
class="css-77ouhj--secondary css-77ouhj--medium css-1rchs6o--inline css-66w5es"
|
||||
>
|
||||
Please connect Grafana Cloud OnCall to use the mobile app
|
||||
</span>
|
||||
|
|
@ -421,7 +421,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"
|
||||
>
|
||||
<span
|
||||
class="css-77ouhj--primary css-77ouhj--medium css-1287p17"
|
||||
class="css-77ouhj--primary css-77ouhj--medium css-1rchs6o--inline css-66w5es"
|
||||
>
|
||||
There was an error disconnecting your mobile app. Please try again.
|
||||
</span>
|
||||
|
|
@ -437,7 +437,7 @@ exports[`MobileAppConnection it shows an error message if there was an error dis
|
|||
class="css-12oo3x0-layoutChildrenWrapper"
|
||||
>
|
||||
<span
|
||||
class="css-77ouhj--primary css-77ouhj--medium css-77ouhj--strong css-1287p17"
|
||||
class="css-77ouhj--primary css-77ouhj--medium css-1rchs6o--inline css-77ouhj--strong css-66w5es"
|
||||
>
|
||||
Download
|
||||
</span>
|
||||
|
|
@ -446,7 +446,7 @@ exports[`MobileAppConnection it shows an error message if there was an error dis
|
|||
class="css-12oo3x0-layoutChildrenWrapper"
|
||||
>
|
||||
<span
|
||||
class="css-77ouhj--primary css-77ouhj--medium css-1287p17"
|
||||
class="css-77ouhj--primary css-77ouhj--medium css-1rchs6o--inline css-66w5es"
|
||||
>
|
||||
The Grafana OnCall app is available on both the App Store and Google Play Store.
|
||||
</span>
|
||||
|
|
@ -476,7 +476,7 @@ exports[`MobileAppConnection it shows an error message if there was an error dis
|
|||
src="[object Object]"
|
||||
/>
|
||||
<span
|
||||
class="css-77ouhj--primary css-77ouhj--medium icon-text css-1287p17"
|
||||
class="css-77ouhj--primary css-77ouhj--medium css-1rchs6o--inline icon-text css-66w5es"
|
||||
>
|
||||
iOS
|
||||
</span>
|
||||
|
|
@ -501,7 +501,7 @@ exports[`MobileAppConnection it shows an error message if there was an error dis
|
|||
src="[object Object]"
|
||||
/>
|
||||
<span
|
||||
class="css-77ouhj--primary css-77ouhj--medium icon-text css-1287p17"
|
||||
class="css-77ouhj--primary css-77ouhj--medium css-1rchs6o--inline icon-text css-66w5es"
|
||||
>
|
||||
Android
|
||||
</span>
|
||||
|
|
@ -537,7 +537,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"
|
||||
>
|
||||
<span
|
||||
class="css-77ouhj--primary css-77ouhj--medium css-1287p17"
|
||||
class="css-77ouhj--primary css-77ouhj--medium css-1rchs6o--inline css-66w5es"
|
||||
>
|
||||
There was an error fetching your QR code. Please try again.
|
||||
</span>
|
||||
|
|
@ -553,7 +553,7 @@ exports[`MobileAppConnection it shows an error message if there was an error fet
|
|||
class="css-12oo3x0-layoutChildrenWrapper"
|
||||
>
|
||||
<span
|
||||
class="css-77ouhj--primary css-77ouhj--medium css-77ouhj--strong css-1287p17"
|
||||
class="css-77ouhj--primary css-77ouhj--medium css-1rchs6o--inline css-77ouhj--strong css-66w5es"
|
||||
>
|
||||
Download
|
||||
</span>
|
||||
|
|
@ -562,7 +562,7 @@ exports[`MobileAppConnection it shows an error message if there was an error fet
|
|||
class="css-12oo3x0-layoutChildrenWrapper"
|
||||
>
|
||||
<span
|
||||
class="css-77ouhj--primary css-77ouhj--medium css-1287p17"
|
||||
class="css-77ouhj--primary css-77ouhj--medium css-1rchs6o--inline css-66w5es"
|
||||
>
|
||||
The Grafana OnCall app is available on both the App Store and Google Play Store.
|
||||
</span>
|
||||
|
|
@ -592,7 +592,7 @@ exports[`MobileAppConnection it shows an error message if there was an error fet
|
|||
src="[object Object]"
|
||||
/>
|
||||
<span
|
||||
class="css-77ouhj--primary css-77ouhj--medium icon-text css-1287p17"
|
||||
class="css-77ouhj--primary css-77ouhj--medium css-1rchs6o--inline icon-text css-66w5es"
|
||||
>
|
||||
iOS
|
||||
</span>
|
||||
|
|
@ -617,7 +617,7 @@ exports[`MobileAppConnection it shows an error message if there was an error fet
|
|||
src="[object Object]"
|
||||
/>
|
||||
<span
|
||||
class="css-77ouhj--primary css-77ouhj--medium icon-text css-1287p17"
|
||||
class="css-77ouhj--primary css-77ouhj--medium css-1rchs6o--inline icon-text css-66w5es"
|
||||
>
|
||||
Android
|
||||
</span>
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ exports[`DownloadIcons it renders properly 1`] = `
|
|||
class="css-12oo3x0-layoutChildrenWrapper"
|
||||
>
|
||||
<span
|
||||
class="css-77ouhj--primary css-77ouhj--medium css-77ouhj--strong css-1287p17"
|
||||
class="css-77ouhj--primary css-77ouhj--medium css-1rchs6o--inline css-77ouhj--strong css-66w5es"
|
||||
>
|
||||
Download
|
||||
</span>
|
||||
|
|
@ -19,7 +19,7 @@ exports[`DownloadIcons it renders properly 1`] = `
|
|||
class="css-12oo3x0-layoutChildrenWrapper"
|
||||
>
|
||||
<span
|
||||
class="css-77ouhj--primary css-77ouhj--medium css-1287p17"
|
||||
class="css-77ouhj--primary css-77ouhj--medium css-1rchs6o--inline css-66w5es"
|
||||
>
|
||||
The Grafana OnCall app is available on both the App Store and Google Play Store.
|
||||
</span>
|
||||
|
|
@ -49,7 +49,7 @@ exports[`DownloadIcons it renders properly 1`] = `
|
|||
src="[object Object]"
|
||||
/>
|
||||
<span
|
||||
class="css-77ouhj--primary css-77ouhj--medium icon-text css-1287p17"
|
||||
class="css-77ouhj--primary css-77ouhj--medium css-1rchs6o--inline icon-text css-66w5es"
|
||||
>
|
||||
iOS
|
||||
</span>
|
||||
|
|
@ -74,7 +74,7 @@ exports[`DownloadIcons it renders properly 1`] = `
|
|||
src="[object Object]"
|
||||
/>
|
||||
<span
|
||||
class="css-77ouhj--primary css-77ouhj--medium icon-text css-1287p17"
|
||||
class="css-77ouhj--primary css-77ouhj--medium css-1rchs6o--inline icon-text css-66w5es"
|
||||
>
|
||||
Android
|
||||
</span>
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ exports[`LinkLoginButton it renders properly 1`] = `
|
|||
class="css-12oo3x0-layoutChildrenWrapper"
|
||||
>
|
||||
<span
|
||||
class="css-77ouhj--primary css-77ouhj--medium css-77ouhj--strong css-1287p17"
|
||||
class="css-77ouhj--primary css-77ouhj--medium css-1rchs6o--inline css-77ouhj--strong css-66w5es"
|
||||
>
|
||||
Sign in via deeplink
|
||||
</span>
|
||||
|
|
@ -19,7 +19,7 @@ exports[`LinkLoginButton it renders properly 1`] = `
|
|||
class="css-12oo3x0-layoutChildrenWrapper"
|
||||
>
|
||||
<span
|
||||
class="css-77ouhj--primary css-77ouhj--medium css-1287p17"
|
||||
class="css-77ouhj--primary css-77ouhj--medium css-1rchs6o--inline css-66w5es"
|
||||
>
|
||||
Make sure to have the app installed
|
||||
</span>
|
||||
|
|
|
|||
|
|
@ -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-v5-compat';
|
||||
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-v5-compat', () => ({
|
||||
useLocation: jest.fn(() => ({
|
||||
search: '',
|
||||
})),
|
||||
}));
|
||||
|
||||
const useLocation = useLocationOriginal as jest.Mock<ReturnType<typeof useLocationOriginal>>;
|
||||
|
||||
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(<PluginConfigPage {...generateComponentProps(metaJsonDataOnCallApiUrl, true)} />);
|
||||
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<typeof useLocationOriginal>);
|
||||
|
||||
PluginState.updatePluginStatus = jest.fn();
|
||||
mockCheckTokenAndIfPluginIsConnected();
|
||||
|
||||
// test setup
|
||||
const component = render(<PluginConfigPage {...generateComponentProps(metaJsonDataOnCallApiUrl)} />);
|
||||
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(<PluginConfigPage {...generateComponentProps()} />);
|
||||
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(<PluginConfigPage {...generateComponentProps(metaJsonDataOnCallApiUrl)} />);
|
||||
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(<PluginConfigPage {...generateComponentProps(metaJsonDataOnCallApiUrl)} />);
|
||||
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(<PluginConfigPage {...generateComponentProps(metaJsonDataOnCallApiUrl)} />);
|
||||
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(<PluginConfigPage {...generateComponentProps(metaJsonDataOnCallApiUrl)} />);
|
||||
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();
|
||||
});
|
||||
});
|
||||
|
|
@ -1,256 +1,300 @@
|
|||
import React, { FC, useCallback, useEffect, useState } from 'react';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
|
||||
import { Button, HorizontalGroup, Label, Legend, LinkButton, LoadingPlaceholder, VerticalGroup } from '@grafana/ui';
|
||||
import { useLocation } from 'react-router-dom-v5-compat';
|
||||
import { OnCallPluginConfigPageProps } from 'types';
|
||||
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 { useNavigate } from 'react-router-dom-v5-compat';
|
||||
import { OnCallPluginMetaJSONData } from 'types';
|
||||
|
||||
import { PluginState, PluginStatusResponseBase } from 'state/plugin/plugin';
|
||||
import { Button } from 'components/Button/Button';
|
||||
import { CollapsibleTreeView } from 'components/CollapsibleTreeView/CollapsibleTreeView';
|
||||
import { RenderConditionally } from 'components/RenderConditionally/RenderConditionally';
|
||||
import { Text } from 'components/Text/Text';
|
||||
import { ActionKey } from 'models/loader/action-keys';
|
||||
import { rootStore } from 'state/rootStore';
|
||||
import {
|
||||
FALLBACK_LICENSE,
|
||||
getOnCallApiUrl,
|
||||
getPluginId,
|
||||
GRAFANA_LICENSE_OSS,
|
||||
hasPluginBeenConfigured,
|
||||
DEFAULT_PAGE,
|
||||
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';
|
||||
|
||||
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}`;
|
||||
}
|
||||
type PluginConfigFormValues = {
|
||||
onCallApiUrl: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* 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);
|
||||
}
|
||||
};
|
||||
export const PluginConfigPage = observer((props: PluginConfigPageProps<PluginMeta<OnCallPluginMetaJSONData>>) => {
|
||||
const {
|
||||
pluginStore: { verifyPluginConnection, refreshAppliedOnCallApiUrl },
|
||||
} = rootStore;
|
||||
|
||||
export const PluginConfigPage: FC<OnCallPluginConfigPageProps> = ({
|
||||
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<boolean>(!pluginConfiguredRedirect);
|
||||
const [pluginConnectionCheckError, setPluginConnectionCheckError] = useState<string>(null);
|
||||
const [pluginIsConnected, setPluginIsConnected] = useState<PluginStatusResponseBase>(
|
||||
pluginConfiguredRedirect
|
||||
? {
|
||||
version: pluginConfiguredVersionQueryParam,
|
||||
license: pluginConfiguredLicenseQueryParam,
|
||||
recaptcha_site_key: 'abc',
|
||||
currently_undergoing_maintenance_message: 'false',
|
||||
}
|
||||
: null
|
||||
);
|
||||
|
||||
const [updatingPluginStatus, setUpdatingPluginStatus] = useState<boolean>(false);
|
||||
const [updatingPluginStatusError, setUpdatingPluginStatusError] = useState<string>(null);
|
||||
|
||||
const [resettingPlugin, setResettingPlugin] = useState<boolean>(false);
|
||||
const [pluginResetError, setPluginResetError] = useState<string>(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.');
|
||||
}
|
||||
|
||||
setResettingPlugin(false);
|
||||
}, [resetState]);
|
||||
|
||||
const RemoveConfigButton = useCallback(
|
||||
() => <RemoveCurrentConfigurationButton disabled={resettingPlugin} onClick={triggerPluginReset} />,
|
||||
[resettingPlugin, triggerPluginReset]
|
||||
);
|
||||
|
||||
const ReconfigurePluginButtons = () => (
|
||||
<HorizontalGroup>
|
||||
<Button variant="primary" onClick={triggerUpdatePluginStatus} size="md">
|
||||
Retry Sync
|
||||
</Button>
|
||||
{licenseType === GRAFANA_LICENSE_OSS ? <RemoveConfigButton /> : null}
|
||||
</HorizontalGroup>
|
||||
);
|
||||
|
||||
let content: React.ReactNode;
|
||||
|
||||
if (checkingIfPluginIsConnected) {
|
||||
content = <LoadingPlaceholder text="Validating your plugin connection..." />;
|
||||
} else if (updatingPluginStatus) {
|
||||
content = <LoadingPlaceholder text="Syncing data required for your plugin..." />;
|
||||
} else if (pluginConnectionCheckError || pluginResetError) {
|
||||
content = (
|
||||
<>
|
||||
<StatusMessageBlock text={pluginConnectionCheckError || pluginResetError} />
|
||||
<ReconfigurePluginButtons />
|
||||
</>
|
||||
);
|
||||
} else if (updatingPluginStatusError) {
|
||||
content = (
|
||||
<>
|
||||
<StatusMessageBlock text={updatingPluginStatusError} />
|
||||
<ReconfigurePluginButtons />
|
||||
</>
|
||||
);
|
||||
} else if (!pluginIsConnected) {
|
||||
content = <ConfigurationForm onSuccessfulSetup={triggerUpdatePluginStatus} defaultOnCallApiUrl={onCallApiUrl} />;
|
||||
} else {
|
||||
// plugin is fully connected and synced
|
||||
const pluginLink = (
|
||||
<LinkButton href={`/a/${getPluginId()}/`} variant="primary">
|
||||
Open Grafana OnCall
|
||||
</LinkButton>
|
||||
);
|
||||
content =
|
||||
licenseType === GRAFANA_LICENSE_OSS ? (
|
||||
<div>
|
||||
<HorizontalGroup>
|
||||
{pluginLink}
|
||||
<RemoveConfigButton />
|
||||
</HorizontalGroup>
|
||||
</div>
|
||||
) : (
|
||||
<VerticalGroup>
|
||||
<Label>This is a cloud managed configuration.</Label>
|
||||
{pluginLink}
|
||||
</VerticalGroup>
|
||||
);
|
||||
}
|
||||
useOnMount(() => {
|
||||
refreshAppliedOnCallApiUrl();
|
||||
verifyPluginConnection();
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<Legend>Configure Grafana OnCall</Legend>
|
||||
{pluginIsConnected ? (
|
||||
<>
|
||||
<StatusMessageBlock
|
||||
text={`Connected to OnCall (${pluginIsConnected.version}, ${pluginIsConnected.license})`}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<p>This page will help you configure the OnCall plugin 👋</p>
|
||||
)}
|
||||
{content}
|
||||
</>
|
||||
<VerticalGroup>
|
||||
<Text.Title level={3} className="u-margin-bottom-md">
|
||||
Configure Grafana OnCall
|
||||
</Text.Title>
|
||||
{getIsRunningOpenSourceVersion() ? <OSSPluginConfigPage {...props} /> : <CloudPluginConfigPage {...props} />}
|
||||
</VerticalGroup>
|
||||
);
|
||||
};
|
||||
});
|
||||
|
||||
const CloudPluginConfigPage = observer(
|
||||
({ plugin: { meta } }: PluginConfigPageProps<PluginMeta<OnCallPluginMetaJSONData>>) => {
|
||||
const {
|
||||
pluginStore: { isPluginConnected },
|
||||
} = rootStore;
|
||||
const styles = useStyles2(getStyles);
|
||||
|
||||
return (
|
||||
<VerticalGroup>
|
||||
<Text type="secondary" className={styles.secondaryTitle}>
|
||||
This is a cloud-managed configuration.
|
||||
</Text>
|
||||
<RenderConditionally shouldRender={meta.enabled} render={() => <PluginConfigAlert />} />
|
||||
<RenderConditionally
|
||||
shouldRender={!isPluginConnected}
|
||||
render={() => <Button onClick={() => window.open(REQUEST_HELP_URL, '_blank')}>Request help</Button>}
|
||||
/>
|
||||
</VerticalGroup>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
const OSSPluginConfigPage = observer(
|
||||
({ plugin: { meta } }: PluginConfigPageProps<PluginMeta<OnCallPluginMetaJSONData>>) => {
|
||||
const {
|
||||
pluginStore: {
|
||||
updatePluginSettingsAndReinitializePlugin,
|
||||
connectionStatus,
|
||||
recreateServiceAccountAndRecheckPluginStatus,
|
||||
isPluginConnected,
|
||||
appliedOnCallApiUrl,
|
||||
enablePlugin,
|
||||
},
|
||||
loaderStore,
|
||||
} = rootStore;
|
||||
const [hasBeenReconnected, setHasBeenReconnected] = useState(false);
|
||||
const navigate = useNavigate();
|
||||
const styles = useStyles2(getStyles);
|
||||
const { handleSubmit, control, formState } = useForm<PluginConfigFormValues>({
|
||||
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 showAlert = meta.enabled && (!isPluginConnected || hasBeenReconnected);
|
||||
|
||||
const onSubmit = async (values: PluginConfigFormValues) => {
|
||||
await updatePluginSettingsAndReinitializePlugin({
|
||||
currentJsonData: meta.jsonData,
|
||||
newJsonData: { onCallApiUrl: values.onCallApiUrl },
|
||||
});
|
||||
setHasBeenReconnected(true);
|
||||
};
|
||||
|
||||
const getCheckOrTextIcon = (isOk: boolean) => (isOk ? { customIcon: 'check' as const } : { isTextIcon: true });
|
||||
|
||||
const enablePluginExpandedView = () => (
|
||||
<>
|
||||
<Text strong>Enable OnCall plugin</Text>
|
||||
<Text type="secondary" className={styles.secondaryTitle}>
|
||||
Make sure that OnCall plugin has been enabled.
|
||||
</Text>
|
||||
<RenderConditionally
|
||||
shouldRender={!meta.enabled}
|
||||
render={() => (
|
||||
<Button variant="secondary" onClick={enablePlugin}>
|
||||
Enable
|
||||
</Button>
|
||||
)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
||||
const serviceAccountTokenExpandedView = () => (
|
||||
<>
|
||||
<Text strong>Service account user allows to connect OnCall plugin to Grafana. </Text>
|
||||
<Text type="secondary" className={styles.secondaryTitle}>
|
||||
Make sure that OnCall plugin has been enabled.{' '}
|
||||
<a href={DOCS_SERVICE_ACCOUNTS} target="_blank" rel="noreferrer">
|
||||
<Text type="link">Read more</Text>
|
||||
</a>
|
||||
</Text>
|
||||
<HorizontalGroup>
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={recreateServiceAccountAndRecheckPluginStatus}
|
||||
data-testid="recreate-service-account"
|
||||
>
|
||||
Re-create
|
||||
</Button>
|
||||
<RenderConditionally
|
||||
shouldRender={isRecreatingServiceAccount}
|
||||
render={() => <LoadingPlaceholder text="" className={styles.spinner} />}
|
||||
/>
|
||||
</HorizontalGroup>
|
||||
</>
|
||||
);
|
||||
|
||||
const onCallApiUrlExpandedView = () => (
|
||||
<>
|
||||
<Text strong>Let us know the backend URL for your OnCall API</Text>
|
||||
<Text type="secondary" className={styles.secondaryTitle}>
|
||||
OnCall backend must be reachable from your Grafana Installation. <br />
|
||||
You can run hobby, dev or production backend. See{' '}
|
||||
<a href={DOCS_ONCALL_OSS_INSTALL} target="_blank" rel="noreferrer">
|
||||
<Text type="link">here</Text>
|
||||
</a>{' '}
|
||||
how to get started.
|
||||
</Text>
|
||||
<form onSubmit={handleSubmit(onSubmit)}>
|
||||
<Controller
|
||||
name={'onCallApiUrl'}
|
||||
control={control}
|
||||
rules={{ required: 'URL is required', validate: validateURL }}
|
||||
render={({ field }) => (
|
||||
<Field
|
||||
key={'Name'}
|
||||
label={'OnCall API URL'}
|
||||
invalid={Boolean(formState.errors.onCallApiUrl)}
|
||||
error={formState.errors.onCallApiUrl?.message}
|
||||
>
|
||||
<Input {...field} placeholder={'OnCall API URL'} data-testid="oncall-api-url-input" />
|
||||
</Field>
|
||||
)}
|
||||
/>
|
||||
<HorizontalGroup>
|
||||
{isPluginConnected && (
|
||||
<Button onClick={() => navigate(`${PLUGIN_ROOT}/${DEFAULT_PAGE}`)}>Open Grafana OnCall</Button>
|
||||
)}
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={isSubmitButtonDisabled}
|
||||
data-testid="connect-plugin"
|
||||
variant={isPluginConnected ? 'secondary' : 'primary'}
|
||||
>
|
||||
{isPluginConnected ? 'Reconnect' : 'Connect'}
|
||||
</Button>
|
||||
<RenderConditionally
|
||||
shouldRender={isReinitializating}
|
||||
render={() => <LoadingPlaceholder text="" className={styles.spinner} />}
|
||||
/>
|
||||
</HorizontalGroup>
|
||||
</form>
|
||||
</>
|
||||
);
|
||||
|
||||
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 (
|
||||
<div className={styles.configurationWrapper}>
|
||||
<Text type="secondary" className={styles.secondaryTitle}>
|
||||
This page will help you to connect OnCall backend and OnCall Grafana plugin.
|
||||
</Text>
|
||||
{showAlert && <PluginConfigAlert />}
|
||||
<CollapsibleTreeView className={styles.treeView} configElements={configElements} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
const PluginConfigAlert = observer(() => {
|
||||
const {
|
||||
pluginStore: { connectionStatus, isPluginConnected },
|
||||
} = rootStore;
|
||||
const [showAlert, setShowAlert] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
setShowAlert(true);
|
||||
}, [connectionStatus]);
|
||||
|
||||
if (!connectionStatus) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const errors = Object.values(connectionStatus)
|
||||
.filter(({ ok, error }) => !ok && Boolean(error) && error !== 'Not validated')
|
||||
.map(({ error }) => <li key={error}>{error}</li>);
|
||||
|
||||
if (isPluginConnected) {
|
||||
return (
|
||||
<Alert severity="success" title="Plugin is connected">
|
||||
Go to{' '}
|
||||
<a href={PLUGIN_ROOT} rel="noreferrer">
|
||||
<Text type="link">Grafana OnCall</Text>
|
||||
</a>
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<RenderConditionally
|
||||
shouldRender={showAlert}
|
||||
render={() => (
|
||||
<Alert severity="error" title="Plugin is not connected" onRemove={() => setShowAlert(false)}>
|
||||
<ol className="u-margin-bottom-md">{errors}</ol>
|
||||
<a href={PLUGIN_CONFIG} rel="noreferrer" onClick={() => window.location.reload()}>
|
||||
<Text type="link">Reload</Text>
|
||||
</a>
|
||||
</Alert>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
const getStyles = (theme: GrafanaTheme2) => ({
|
||||
configurationWrapper: css`
|
||||
width: 50vw;
|
||||
`,
|
||||
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;
|
||||
`,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,546 +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`] = `
|
||||
<div>
|
||||
<legend
|
||||
class="css-1w9pvsj"
|
||||
>
|
||||
Configure Grafana OnCall
|
||||
</legend>
|
||||
<p>
|
||||
This page will help you configure the OnCall plugin 👋
|
||||
</p>
|
||||
<form
|
||||
class="css-1srg48i"
|
||||
data-testid="plugin-configuration-form"
|
||||
>
|
||||
<div
|
||||
class="info-block"
|
||||
>
|
||||
<p>
|
||||
1. Launch the OnCall backend
|
||||
</p>
|
||||
<span
|
||||
class="css-77ouhj--secondary css-77ouhj--medium css-1287p17"
|
||||
>
|
||||
Run hobby, dev or production backend. See
|
||||
|
||||
<a
|
||||
href="https://github.com/grafana/oncall#getting-started"
|
||||
rel="noreferrer"
|
||||
target="_blank"
|
||||
>
|
||||
<span
|
||||
class="css-77ouhj--link css-77ouhj--medium css-1287p17"
|
||||
>
|
||||
here
|
||||
</span>
|
||||
</a>
|
||||
|
||||
on how to get started.
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
class="info-block"
|
||||
>
|
||||
<p>
|
||||
2. Let us know the base URL of your OnCall API
|
||||
</p>
|
||||
<span
|
||||
class="css-77ouhj--secondary css-77ouhj--medium css-1287p17"
|
||||
>
|
||||
The OnCall backend must be reachable from your Grafana installation. Some examples are:
|
||||
<br />
|
||||
- http://host.docker.internal:8080
|
||||
<br />
|
||||
- http://localhost:8080
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
class="css-1rplq84"
|
||||
>
|
||||
<div
|
||||
class="css-l4ykjo-Label"
|
||||
>
|
||||
<label>
|
||||
<div
|
||||
class="css-70qvj9"
|
||||
>
|
||||
OnCall backend URL
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
<div>
|
||||
<div>
|
||||
<div
|
||||
class="css-1cvb0sk-input-wrapper"
|
||||
data-testid="input-wrapper"
|
||||
>
|
||||
<div
|
||||
class="css-10lnb82-input-inputWrapper"
|
||||
>
|
||||
<input
|
||||
class="css-8tk2dk-input-input"
|
||||
data-testid="onCallApiUrl"
|
||||
name="onCallApiUrl"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
aria-disabled="false"
|
||||
class="css-td06pi-button"
|
||||
type="submit"
|
||||
>
|
||||
<span
|
||||
class="css-1riaxdn"
|
||||
>
|
||||
Connect
|
||||
</span>
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
`;
|
||||
|
||||
exports[`PluginConfigPage If onCallApiUrl is set, and updatePluginStatus returns an error, it sets an error message 1`] = `
|
||||
<div>
|
||||
<legend
|
||||
class="css-1w9pvsj"
|
||||
>
|
||||
Configure Grafana OnCall
|
||||
</legend>
|
||||
<p>
|
||||
This page will help you configure the OnCall plugin 👋
|
||||
</p>
|
||||
<pre
|
||||
data-testid="status-message-block"
|
||||
>
|
||||
<span
|
||||
class="css-77ouhj--undefined css-77ouhj--medium css-1287p17"
|
||||
>
|
||||
ohhh nooo a plugin connection error
|
||||
</span>
|
||||
</pre>
|
||||
<div
|
||||
class="css-ffyaiw-horizontal-group"
|
||||
style="width: 100%; height: 100%;"
|
||||
>
|
||||
<div
|
||||
class="css-18qv8yz-layoutChildrenWrapper"
|
||||
>
|
||||
<button
|
||||
aria-disabled="false"
|
||||
class="css-td06pi-button"
|
||||
type="button"
|
||||
>
|
||||
<span
|
||||
class="css-1riaxdn"
|
||||
>
|
||||
Retry Sync
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
<div
|
||||
class="css-18qv8yz-layoutChildrenWrapper"
|
||||
>
|
||||
<button
|
||||
aria-disabled="false"
|
||||
class="css-ttl745-button"
|
||||
type="button"
|
||||
>
|
||||
<span
|
||||
class="css-1riaxdn"
|
||||
>
|
||||
Remove current configuration
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
exports[`PluginConfigPage It doesn't make any network calls if the plugin configured query params are provided 1`] = `
|
||||
<div>
|
||||
<legend
|
||||
class="css-1w9pvsj"
|
||||
>
|
||||
Configure Grafana OnCall
|
||||
</legend>
|
||||
<pre
|
||||
data-testid="status-message-block"
|
||||
>
|
||||
<span
|
||||
class="css-77ouhj--undefined css-77ouhj--medium css-1287p17"
|
||||
>
|
||||
Connected to OnCall (v1.2.3, OpenSource)
|
||||
</span>
|
||||
</pre>
|
||||
<div>
|
||||
<div
|
||||
class="css-ffyaiw-horizontal-group"
|
||||
style="width: 100%; height: 100%;"
|
||||
>
|
||||
<div
|
||||
class="css-18qv8yz-layoutChildrenWrapper"
|
||||
>
|
||||
<a
|
||||
class="css-td06pi-button"
|
||||
href="/a/grafana-oncall-app/"
|
||||
tabindex="0"
|
||||
>
|
||||
<span
|
||||
class="css-1riaxdn"
|
||||
>
|
||||
Open Grafana OnCall
|
||||
</span>
|
||||
</a>
|
||||
</div>
|
||||
<div
|
||||
class="css-18qv8yz-layoutChildrenWrapper"
|
||||
>
|
||||
<button
|
||||
aria-disabled="false"
|
||||
class="css-ttl745-button"
|
||||
type="button"
|
||||
>
|
||||
<span
|
||||
class="css-1riaxdn"
|
||||
>
|
||||
Remove current configuration
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
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`] = `
|
||||
<div>
|
||||
<legend
|
||||
class="css-1w9pvsj"
|
||||
>
|
||||
Configure Grafana OnCall
|
||||
</legend>
|
||||
<pre
|
||||
data-testid="status-message-block"
|
||||
>
|
||||
<span
|
||||
class="css-77ouhj--undefined css-77ouhj--medium css-1287p17"
|
||||
>
|
||||
Connected to OnCall (v1.2.3, OpenSource)
|
||||
</span>
|
||||
</pre>
|
||||
<div>
|
||||
<div
|
||||
class="css-ffyaiw-horizontal-group"
|
||||
style="width: 100%; height: 100%;"
|
||||
>
|
||||
<div
|
||||
class="css-18qv8yz-layoutChildrenWrapper"
|
||||
>
|
||||
<a
|
||||
class="css-td06pi-button"
|
||||
href="/a/grafana-oncall-app/"
|
||||
tabindex="0"
|
||||
>
|
||||
<span
|
||||
class="css-1riaxdn"
|
||||
>
|
||||
Open Grafana OnCall
|
||||
</span>
|
||||
</a>
|
||||
</div>
|
||||
<div
|
||||
class="css-18qv8yz-layoutChildrenWrapper"
|
||||
>
|
||||
<button
|
||||
aria-disabled="false"
|
||||
class="css-ttl745-button"
|
||||
type="button"
|
||||
>
|
||||
<span
|
||||
class="css-1riaxdn"
|
||||
>
|
||||
Remove current configuration
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
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`] = `
|
||||
<div>
|
||||
<legend
|
||||
class="css-1w9pvsj"
|
||||
>
|
||||
Configure Grafana OnCall
|
||||
</legend>
|
||||
<pre
|
||||
data-testid="status-message-block"
|
||||
>
|
||||
<span
|
||||
class="css-77ouhj--undefined css-77ouhj--medium css-1287p17"
|
||||
>
|
||||
Connected to OnCall (v1.2.3, some-other-license)
|
||||
</span>
|
||||
</pre>
|
||||
<div
|
||||
class="css-gjl87o-vertical-group"
|
||||
style="width: 100%; height: 100%;"
|
||||
>
|
||||
<div
|
||||
class="css-gxt817-layoutChildrenWrapper"
|
||||
>
|
||||
<div
|
||||
class="css-l4ykjo-Label"
|
||||
>
|
||||
<label>
|
||||
<div
|
||||
class="css-70qvj9"
|
||||
>
|
||||
This is a cloud managed configuration.
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="css-gxt817-layoutChildrenWrapper"
|
||||
>
|
||||
<a
|
||||
class="css-td06pi-button"
|
||||
href="/a/grafana-oncall-app/"
|
||||
tabindex="0"
|
||||
>
|
||||
<span
|
||||
class="css-1riaxdn"
|
||||
>
|
||||
Open Grafana OnCall
|
||||
</span>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
exports[`PluginConfigPage OnCallApiUrl is set, and checkTokenAndIfPluginIsConnected returns an error 1`] = `
|
||||
<div>
|
||||
<legend
|
||||
class="css-1w9pvsj"
|
||||
>
|
||||
Configure Grafana OnCall
|
||||
</legend>
|
||||
<p>
|
||||
This page will help you configure the OnCall plugin 👋
|
||||
</p>
|
||||
<pre
|
||||
data-testid="status-message-block"
|
||||
>
|
||||
<span
|
||||
class="css-77ouhj--undefined css-77ouhj--medium css-1287p17"
|
||||
>
|
||||
ohhh noooo a sync issue
|
||||
</span>
|
||||
</pre>
|
||||
<div
|
||||
class="css-ffyaiw-horizontal-group"
|
||||
style="width: 100%; height: 100%;"
|
||||
>
|
||||
<div
|
||||
class="css-18qv8yz-layoutChildrenWrapper"
|
||||
>
|
||||
<button
|
||||
aria-disabled="false"
|
||||
class="css-td06pi-button"
|
||||
type="button"
|
||||
>
|
||||
<span
|
||||
class="css-1riaxdn"
|
||||
>
|
||||
Retry Sync
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
<div
|
||||
class="css-18qv8yz-layoutChildrenWrapper"
|
||||
>
|
||||
<button
|
||||
aria-disabled="false"
|
||||
class="css-ttl745-button"
|
||||
type="button"
|
||||
>
|
||||
<span
|
||||
class="css-1riaxdn"
|
||||
>
|
||||
Remove current configuration
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
exports[`PluginConfigPage Plugin reset: successful - false 1`] = `
|
||||
<div>
|
||||
<legend
|
||||
class="css-1w9pvsj"
|
||||
>
|
||||
Configure Grafana OnCall
|
||||
</legend>
|
||||
<p>
|
||||
This page will help you configure the OnCall plugin 👋
|
||||
</p>
|
||||
<pre
|
||||
data-testid="status-message-block"
|
||||
>
|
||||
<span
|
||||
class="css-77ouhj--undefined css-77ouhj--medium css-1287p17"
|
||||
>
|
||||
There was an error resetting your plugin, try again.
|
||||
</span>
|
||||
</pre>
|
||||
<div
|
||||
class="css-ffyaiw-horizontal-group"
|
||||
style="width: 100%; height: 100%;"
|
||||
>
|
||||
<div
|
||||
class="css-18qv8yz-layoutChildrenWrapper"
|
||||
>
|
||||
<button
|
||||
aria-disabled="false"
|
||||
class="css-td06pi-button"
|
||||
type="button"
|
||||
>
|
||||
<span
|
||||
class="css-1riaxdn"
|
||||
>
|
||||
Retry Sync
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
<div
|
||||
class="css-18qv8yz-layoutChildrenWrapper"
|
||||
>
|
||||
<button
|
||||
aria-disabled="false"
|
||||
class="css-ttl745-button"
|
||||
type="button"
|
||||
>
|
||||
<span
|
||||
class="css-1riaxdn"
|
||||
>
|
||||
Remove current configuration
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
exports[`PluginConfigPage Plugin reset: successful - true 1`] = `
|
||||
<div>
|
||||
<legend
|
||||
class="css-1w9pvsj"
|
||||
>
|
||||
Configure Grafana OnCall
|
||||
</legend>
|
||||
<p>
|
||||
This page will help you configure the OnCall plugin 👋
|
||||
</p>
|
||||
<form
|
||||
class="css-1srg48i"
|
||||
data-testid="plugin-configuration-form"
|
||||
>
|
||||
<div
|
||||
class="info-block"
|
||||
>
|
||||
<p>
|
||||
1. Launch the OnCall backend
|
||||
</p>
|
||||
<span
|
||||
class="css-77ouhj--secondary css-77ouhj--medium css-1287p17"
|
||||
>
|
||||
Run hobby, dev or production backend. See
|
||||
|
||||
<a
|
||||
href="https://github.com/grafana/oncall#getting-started"
|
||||
rel="noreferrer"
|
||||
target="_blank"
|
||||
>
|
||||
<span
|
||||
class="css-77ouhj--link css-77ouhj--medium css-1287p17"
|
||||
>
|
||||
here
|
||||
</span>
|
||||
</a>
|
||||
|
||||
on how to get started.
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
class="info-block"
|
||||
>
|
||||
<p>
|
||||
2. Let us know the base URL of your OnCall API
|
||||
</p>
|
||||
<span
|
||||
class="css-77ouhj--secondary css-77ouhj--medium css-1287p17"
|
||||
>
|
||||
The OnCall backend must be reachable from your Grafana installation. Some examples are:
|
||||
<br />
|
||||
- http://host.docker.internal:8080
|
||||
<br />
|
||||
- http://localhost:8080
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
class="css-1rplq84"
|
||||
>
|
||||
<div
|
||||
class="css-l4ykjo-Label"
|
||||
>
|
||||
<label>
|
||||
<div
|
||||
class="css-70qvj9"
|
||||
>
|
||||
OnCall backend URL
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
<div>
|
||||
<div>
|
||||
<div
|
||||
class="css-1cvb0sk-input-wrapper"
|
||||
data-testid="input-wrapper"
|
||||
>
|
||||
<div
|
||||
class="css-10lnb82-input-inputWrapper"
|
||||
>
|
||||
<input
|
||||
class="css-8tk2dk-input-input"
|
||||
data-testid="onCallApiUrl"
|
||||
name="onCallApiUrl"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
aria-disabled="false"
|
||||
class="css-td06pi-button"
|
||||
type="submit"
|
||||
>
|
||||
<span
|
||||
class="css-1riaxdn"
|
||||
>
|
||||
Connect
|
||||
</span>
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
`;
|
||||
|
|
@ -1,4 +0,0 @@
|
|||
.info-block {
|
||||
margin-bottom: 24px;
|
||||
margin-top: 24px;
|
||||
}
|
||||
|
|
@ -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(
|
||||
<ConfigurationForm onSuccessfulSetup={mockOnSuccessfulSetup} defaultOnCallApiUrl="http://potato.com" />
|
||||
);
|
||||
|
||||
// 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(<ConfigurationForm onSuccessfulSetup={jest.fn()} defaultOnCallApiUrl={processEnvOnCallApiUrl} />);
|
||||
expect(screen.getByDisplayValue(processEnvOnCallApiUrl)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('It calls the onSuccessfulSetup callback on successful form submission', async () => {
|
||||
const { mockOnSuccessfulSetup } = await fillOutFormAndTryToSubmit(VALID_ONCALL_API_URL);
|
||||
|
||||
expect(PluginState.selfHostedInstallPlugin).toHaveBeenCalledTimes(1);
|
||||
expect(PluginState.selfHostedInstallPlugin).toHaveBeenCalledWith(VALID_ONCALL_API_URL, false);
|
||||
expect(mockOnSuccessfulSetup).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test("It doesn't allow the user to submit if the URL is invalid", async () => {
|
||||
const { dom, mockOnSuccessfulSetup } = await fillOutFormAndTryToSubmit('potato');
|
||||
|
||||
expect(PluginState.selfHostedInstallPlugin).toHaveBeenCalledTimes(0);
|
||||
expect(mockOnSuccessfulSetup).toHaveBeenCalledTimes(0);
|
||||
expect(screen.getByRole('button')).toBeDisabled();
|
||||
expect(dom).toMatchSnapshot();
|
||||
});
|
||||
|
||||
test('It shows an error message if the self hosted plugin API call fails', async () => {
|
||||
const { dom, mockOnSuccessfulSetup } = await fillOutFormAndTryToSubmit(VALID_ONCALL_API_URL, false);
|
||||
|
||||
expect(PluginState.selfHostedInstallPlugin).toHaveBeenCalledWith(VALID_ONCALL_API_URL, false);
|
||||
expect(mockOnSuccessfulSetup).toHaveBeenCalledTimes(0);
|
||||
expect(dom).toMatchSnapshot();
|
||||
});
|
||||
});
|
||||
|
|
@ -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 }) => (
|
||||
<>
|
||||
<pre>
|
||||
<Text type="link">{errorMsg}</Text>
|
||||
</pre>
|
||||
<Block withBackground className={cx('info-block')}>
|
||||
<Text type="secondary">
|
||||
Need help?
|
||||
<br />- Reach out to the OnCall team in the{' '}
|
||||
<a href="https://grafana.slack.com/archives/C02LSUUSE2G" target="_blank" rel="noreferrer">
|
||||
<Text type="link">#grafana-oncall</Text>
|
||||
</a>{' '}
|
||||
community Slack channel
|
||||
<br />- Ask questions on our GitHub Discussions page{' '}
|
||||
<a href="https://github.com/grafana/oncall/discussions/categories/q-a" target="_blank" rel="noreferrer">
|
||||
<Text type="link">here</Text>
|
||||
</a>{' '}
|
||||
<br />- Or file bugs on our GitHub Issues page{' '}
|
||||
<a href="https://github.com/grafana/oncall/issues" target="_blank" rel="noreferrer">
|
||||
<Text type="link">here</Text>
|
||||
</a>
|
||||
</Text>
|
||||
</Block>
|
||||
</>
|
||||
);
|
||||
|
||||
export const ConfigurationForm: FC<Props> = ({ onSuccessfulSetup, defaultOnCallApiUrl }) => {
|
||||
const [setupErrorMsg, setSetupErrorMsg] = useState<string>(null);
|
||||
const [formLoading, setFormLoading] = useState<boolean>(false);
|
||||
|
||||
const setupPlugin: SubmitHandler<FormProps> = useCallback(async ({ onCallApiUrl }) => {
|
||||
setFormLoading(true);
|
||||
|
||||
const errorMsg = await PluginState.selfHostedInstallPlugin(onCallApiUrl, false);
|
||||
|
||||
if (!errorMsg) {
|
||||
onSuccessfulSetup();
|
||||
} else {
|
||||
setSetupErrorMsg(errorMsg);
|
||||
setFormLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<Form<FormProps>
|
||||
defaultValues={{ onCallApiUrl: defaultOnCallApiUrl }}
|
||||
onSubmit={setupPlugin}
|
||||
data-testid="plugin-configuration-form"
|
||||
>
|
||||
{({ register, errors }) => (
|
||||
<>
|
||||
<div className={cx('info-block')}>
|
||||
<p>1. Launch the OnCall backend</p>
|
||||
<Text type="secondary">
|
||||
Run hobby, dev or production backend. See{' '}
|
||||
<a href="https://github.com/grafana/oncall#getting-started" target="_blank" rel="noreferrer">
|
||||
<Text type="link">here</Text>
|
||||
</a>{' '}
|
||||
on how to get started.
|
||||
</Text>
|
||||
</div>
|
||||
|
||||
<div className={cx('info-block')}>
|
||||
<p>2. Let us know the base URL of your OnCall API</p>
|
||||
<Text type="secondary">
|
||||
The OnCall backend must be reachable from your Grafana installation. Some examples are:
|
||||
<br />
|
||||
- http://host.docker.internal:8080
|
||||
<br />- http://localhost:8080
|
||||
</Text>
|
||||
</div>
|
||||
|
||||
<Field label="OnCall backend URL" invalid={!!errors.onCallApiUrl} error="Must be a valid URL">
|
||||
<Input
|
||||
data-testid="onCallApiUrl"
|
||||
{...register('onCallApiUrl', {
|
||||
required: true,
|
||||
validate: isValidUrl,
|
||||
})}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
{setupErrorMsg && <FormErrorMessage errorMsg={setupErrorMsg} />}
|
||||
|
||||
<Button type="submit" size="md" disabled={formLoading || !isEmpty(errors)}>
|
||||
Connect
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</Form>
|
||||
);
|
||||
};
|
||||
|
|
@ -1,269 +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`] = `
|
||||
<body>
|
||||
<div>
|
||||
<form
|
||||
class="css-1srg48i"
|
||||
data-testid="plugin-configuration-form"
|
||||
>
|
||||
<div
|
||||
class="info-block"
|
||||
>
|
||||
<p>
|
||||
1. Launch the OnCall backend
|
||||
</p>
|
||||
<span
|
||||
class="css-77ouhj--secondary css-77ouhj--medium css-1287p17"
|
||||
>
|
||||
Run hobby, dev or production backend. See
|
||||
|
||||
<a
|
||||
href="https://github.com/grafana/oncall#getting-started"
|
||||
rel="noreferrer"
|
||||
target="_blank"
|
||||
>
|
||||
<span
|
||||
class="css-77ouhj--link css-77ouhj--medium css-1287p17"
|
||||
>
|
||||
here
|
||||
</span>
|
||||
</a>
|
||||
|
||||
on how to get started.
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
class="info-block"
|
||||
>
|
||||
<p>
|
||||
2. Let us know the base URL of your OnCall API
|
||||
</p>
|
||||
<span
|
||||
class="css-77ouhj--secondary css-77ouhj--medium css-1287p17"
|
||||
>
|
||||
The OnCall backend must be reachable from your Grafana installation. Some examples are:
|
||||
<br />
|
||||
- http://host.docker.internal:8080
|
||||
<br />
|
||||
- http://localhost:8080
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
class="css-1rplq84"
|
||||
>
|
||||
<div
|
||||
class="css-l4ykjo-Label"
|
||||
>
|
||||
<label>
|
||||
<div
|
||||
class="css-70qvj9"
|
||||
>
|
||||
OnCall backend URL
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
<div>
|
||||
<div>
|
||||
<div
|
||||
class="css-1skhtb9-input-wrapper"
|
||||
data-testid="input-wrapper"
|
||||
>
|
||||
<div
|
||||
class="css-10lnb82-input-inputWrapper"
|
||||
>
|
||||
<input
|
||||
class="css-11mwy6h-input-input"
|
||||
data-testid="onCallApiUrl"
|
||||
name="onCallApiUrl"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="css-1fhgjcy"
|
||||
>
|
||||
<div
|
||||
class="css-9z7wq3"
|
||||
role="alert"
|
||||
>
|
||||
Must be a valid URL
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
aria-disabled="false"
|
||||
class="css-9hybrt-button"
|
||||
disabled=""
|
||||
type="submit"
|
||||
>
|
||||
<span
|
||||
class="css-1riaxdn"
|
||||
>
|
||||
Connect
|
||||
</span>
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</body>
|
||||
`;
|
||||
|
||||
exports[`ConfigurationForm It shows an error message if the self hosted plugin API call fails 1`] = `
|
||||
<body>
|
||||
<div>
|
||||
<form
|
||||
class="css-1srg48i"
|
||||
data-testid="plugin-configuration-form"
|
||||
>
|
||||
<div
|
||||
class="info-block"
|
||||
>
|
||||
<p>
|
||||
1. Launch the OnCall backend
|
||||
</p>
|
||||
<span
|
||||
class="css-77ouhj--secondary css-77ouhj--medium css-1287p17"
|
||||
>
|
||||
Run hobby, dev or production backend. See
|
||||
|
||||
<a
|
||||
href="https://github.com/grafana/oncall#getting-started"
|
||||
rel="noreferrer"
|
||||
target="_blank"
|
||||
>
|
||||
<span
|
||||
class="css-77ouhj--link css-77ouhj--medium css-1287p17"
|
||||
>
|
||||
here
|
||||
</span>
|
||||
</a>
|
||||
|
||||
on how to get started.
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
class="info-block"
|
||||
>
|
||||
<p>
|
||||
2. Let us know the base URL of your OnCall API
|
||||
</p>
|
||||
<span
|
||||
class="css-77ouhj--secondary css-77ouhj--medium css-1287p17"
|
||||
>
|
||||
The OnCall backend must be reachable from your Grafana installation. Some examples are:
|
||||
<br />
|
||||
- http://host.docker.internal:8080
|
||||
<br />
|
||||
- http://localhost:8080
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
class="css-1rplq84"
|
||||
>
|
||||
<div
|
||||
class="css-l4ykjo-Label"
|
||||
>
|
||||
<label>
|
||||
<div
|
||||
class="css-70qvj9"
|
||||
>
|
||||
OnCall backend URL
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
<div>
|
||||
<div>
|
||||
<div
|
||||
class="css-1cvb0sk-input-wrapper"
|
||||
data-testid="input-wrapper"
|
||||
>
|
||||
<div
|
||||
class="css-10lnb82-input-inputWrapper"
|
||||
>
|
||||
<input
|
||||
class="css-8tk2dk-input-input"
|
||||
data-testid="onCallApiUrl"
|
||||
name="onCallApiUrl"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<pre>
|
||||
<span
|
||||
class="css-77ouhj--link css-77ouhj--medium css-1287p17"
|
||||
>
|
||||
ohhh nooo there was an error from the OnCall API
|
||||
</span>
|
||||
</pre>
|
||||
<div
|
||||
class="css-1x53p5e css-1x53p5e--withBackGround info-block"
|
||||
>
|
||||
<span
|
||||
class="css-77ouhj--secondary css-77ouhj--medium css-1287p17"
|
||||
>
|
||||
Need help?
|
||||
<br />
|
||||
- Reach out to the OnCall team in the
|
||||
|
||||
<a
|
||||
href="https://grafana.slack.com/archives/C02LSUUSE2G"
|
||||
rel="noreferrer"
|
||||
target="_blank"
|
||||
>
|
||||
<span
|
||||
class="css-77ouhj--link css-77ouhj--medium css-1287p17"
|
||||
>
|
||||
#grafana-oncall
|
||||
</span>
|
||||
</a>
|
||||
|
||||
community Slack channel
|
||||
<br />
|
||||
- Ask questions on our GitHub Discussions page
|
||||
|
||||
<a
|
||||
href="https://github.com/grafana/oncall/discussions/categories/q-a"
|
||||
rel="noreferrer"
|
||||
target="_blank"
|
||||
>
|
||||
<span
|
||||
class="css-77ouhj--link css-77ouhj--medium css-1287p17"
|
||||
>
|
||||
here
|
||||
</span>
|
||||
</a>
|
||||
|
||||
<br />
|
||||
- Or file bugs on our GitHub Issues page
|
||||
|
||||
<a
|
||||
href="https://github.com/grafana/oncall/issues"
|
||||
rel="noreferrer"
|
||||
target="_blank"
|
||||
>
|
||||
<span
|
||||
class="css-77ouhj--link css-77ouhj--medium css-1287p17"
|
||||
>
|
||||
here
|
||||
</span>
|
||||
</a>
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
aria-disabled="false"
|
||||
class="css-td06pi-button"
|
||||
type="submit"
|
||||
>
|
||||
<span
|
||||
class="css-1riaxdn"
|
||||
>
|
||||
Connect
|
||||
</span>
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</body>
|
||||
`;
|
||||
|
|
@ -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(<RemoveCurrentConfigurationButton onClick={() => {}} disabled={false} />);
|
||||
expect(component.baseElement).toMatchSnapshot();
|
||||
});
|
||||
|
||||
test('It renders properly when disabled', () => {
|
||||
const component = render(<RemoveCurrentConfigurationButton onClick={() => {}} disabled />);
|
||||
expect(component.baseElement).toMatchSnapshot();
|
||||
});
|
||||
|
||||
test('It calls the onClick handler when clicked', async () => {
|
||||
const mockedOnClick = jest.fn();
|
||||
|
||||
render(<RemoveCurrentConfigurationButton onClick={mockedOnClick} disabled={false} />);
|
||||
|
||||
// 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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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<Props> = ({ disabled, onClick }) => (
|
||||
<WithConfirm title="Are you sure to delete the plugin configuration?" confirmText="Remove">
|
||||
<Button variant="destructive" onClick={onClick} size="md" disabled={disabled}>
|
||||
Remove current configuration
|
||||
</Button>
|
||||
</WithConfirm>
|
||||
);
|
||||
|
|
@ -1,38 +0,0 @@
|
|||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`RemoveCurrentConfigurationButton It renders properly when disabled 1`] = `
|
||||
<body>
|
||||
<div>
|
||||
<button
|
||||
aria-disabled="false"
|
||||
class="css-mgdi0l-button"
|
||||
disabled=""
|
||||
type="button"
|
||||
>
|
||||
<span
|
||||
class="css-1riaxdn"
|
||||
>
|
||||
Remove current configuration
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</body>
|
||||
`;
|
||||
|
||||
exports[`RemoveCurrentConfigurationButton It renders properly when enabled 1`] = `
|
||||
<body>
|
||||
<div>
|
||||
<button
|
||||
aria-disabled="false"
|
||||
class="css-ttl745-button"
|
||||
type="button"
|
||||
>
|
||||
<span
|
||||
class="css-1riaxdn"
|
||||
>
|
||||
Remove current configuration
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</body>
|
||||
`;
|
||||
|
|
@ -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(<StatusMessageBlock text="helloooo" />);
|
||||
expect(component.baseElement).toMatchSnapshot();
|
||||
});
|
||||
});
|
||||
|
|
@ -1,13 +0,0 @@
|
|||
import React, { FC } from 'react';
|
||||
|
||||
import { Text } from 'components/Text/Text';
|
||||
|
||||
type Props = {
|
||||
text: string;
|
||||
};
|
||||
|
||||
export const StatusMessageBlock: FC<Props> = ({ text }) => (
|
||||
<pre data-testid="status-message-block">
|
||||
<Text>{text}</Text>
|
||||
</pre>
|
||||
);
|
||||
|
|
@ -1,17 +0,0 @@
|
|||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`StatusMessageBlock It renders properly 1`] = `
|
||||
<body>
|
||||
<div>
|
||||
<pre
|
||||
data-testid="status-message-block"
|
||||
>
|
||||
<span
|
||||
class="css-77ouhj--undefined css-77ouhj--medium css-1287p17"
|
||||
>
|
||||
helloooo
|
||||
</span>
|
||||
</pre>
|
||||
</div>
|
||||
</body>
|
||||
`;
|
||||
|
|
@ -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<PluginInitializerProps> = observer(({ children }) => {
|
||||
const { isConnected, isCheckingConnectionStatus } = useInitializePlugin();
|
||||
|
||||
if (isCheckingConnectionStatus) {
|
||||
return (
|
||||
<VerticalGroup justify="center" height="100%" align="center">
|
||||
<LoadingPlaceholder text="Loading..." />
|
||||
</VerticalGroup>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<RenderConditionally
|
||||
shouldRender={isConnected}
|
||||
backupChildren={<PluginNotConnectedFullPageError />}
|
||||
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 (
|
||||
<FullPageError
|
||||
title="Plugin not connected"
|
||||
subtitle={
|
||||
<>
|
||||
Looks like OnCall plugin hasn't been connected yet or has been misconfigured. <br />
|
||||
Retry or {getSubtitleExtension()}
|
||||
</>
|
||||
}
|
||||
>
|
||||
<HorizontalGroup>
|
||||
<Button variant="secondary" onClick={() => window.location.reload()}>
|
||||
Retry
|
||||
</Button>
|
||||
{!isOpenSource && <Button onClick={() => window.open(REQUEST_HELP_URL, '_blank')}>Request help</Button>}
|
||||
{isOpenSource && isCurrentUserAdmin && <Button onClick={() => push(PLUGIN_CONFIG)}>Open configuration</Button>}
|
||||
</HorizontalGroup>
|
||||
</FullPageError>
|
||||
);
|
||||
});
|
||||
|
|
@ -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',
|
||||
|
|
|
|||
9
grafana-plugin/src/models/plugin/plugin.helper.ts
Normal file
9
grafana-plugin/src/models/plugin/plugin.helper.ts
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
import { makeRequest } from 'network/network';
|
||||
|
||||
export class PluginHelper {
|
||||
static async install() {
|
||||
return makeRequest(`/plugin/install`, {
|
||||
method: 'POST',
|
||||
});
|
||||
}
|
||||
}
|
||||
96
grafana-plugin/src/models/plugin/plugin.ts
Normal file
96
grafana-plugin/src/models/plugin/plugin.ts
Normal file
|
|
@ -0,0 +1,96 @@
|
|||
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<PostStatusResponse>(`/plugin/status`, {});
|
||||
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<OnCallPluginMetaJSONData>;
|
||||
}) {
|
||||
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();
|
||||
}
|
||||
|
||||
async enablePlugin() {
|
||||
await GrafanaApiClient.updateGrafanaPluginSettings({}, true);
|
||||
location.reload();
|
||||
}
|
||||
}
|
||||
|
|
@ -1,3 +0,0 @@
|
|||
export interface MessagingBackends {
|
||||
[key: string]: any;
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
52
grafana-plugin/src/network/grafana-api/api.types.d.ts
vendored
Normal file
52
grafana-plugin/src/network/grafana-api/api.types.d.ts
vendored
Normal file
|
|
@ -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<OnCallPluginMetaJSONData>;
|
||||
secureJsonData?: Partial<OnCallPluginMetaSecureJSONData>;
|
||||
};
|
||||
88
grafana-plugin/src/network/grafana-api/http-client.ts
Normal file
88
grafana-plugin/src/network/grafana-api/http-client.ts
Normal file
|
|
@ -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<PaginatedServiceAccounts>(
|
||||
`${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<ServiceAccountDTO>(SERVICE_ACCOUNTS_BASE_URL, {
|
||||
name: ONCALL_SERVICE_ACCOUNT_NAME,
|
||||
role: 'Admin',
|
||||
isDisabled: false,
|
||||
});
|
||||
};
|
||||
|
||||
private static getTokenFromServiceAccount = async (serviceAccount) => {
|
||||
const tokens = await this.grafanaBackend.get<TokenDTO[]>(
|
||||
`${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<ApiAuthKeyDTO[]>(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<NewApiKeyResult>(
|
||||
`${SERVICE_ACCOUNTS_BASE_URL}/${serviceAccount.id}/tokens`,
|
||||
{
|
||||
name: ONCALL_KEY_NAME,
|
||||
role: 'Admin',
|
||||
}
|
||||
);
|
||||
|
||||
await this.updateGrafanaPluginSettings({ secureJsonData: { grafanaToken } });
|
||||
};
|
||||
}
|
||||
|
|
@ -1,13 +1,10 @@
|
|||
import axios, { AxiosError } from 'axios';
|
||||
import qs from 'query-string';
|
||||
|
||||
import { getPluginId } from 'utils/consts';
|
||||
import { getOnCallApiPath } from 'utils/consts';
|
||||
import { FaroHelper } from 'utils/faro';
|
||||
import { safeJSONStringify } from 'utils/string';
|
||||
|
||||
export const API_PROXY_PREFIX = `api/plugin-proxy/${getPluginId()}`;
|
||||
export const API_PATH_PREFIX = '/api/internal/v1';
|
||||
|
||||
const instance = axios.create();
|
||||
|
||||
instance.interceptors.request.use(function (config) {
|
||||
|
|
@ -40,10 +37,10 @@ interface RequestConfig {
|
|||
|
||||
export const isNetworkError = axios.isAxiosError;
|
||||
|
||||
export const makeRequestRaw = async (path: string, config: RequestConfig) => {
|
||||
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)}` });
|
||||
|
|
@ -66,7 +63,7 @@ export const makeRequestRaw = async (path: string, config: RequestConfig) => {
|
|||
}
|
||||
};
|
||||
|
||||
export const makeRequest = async <RT = any>(path: string, config: RequestConfig) => {
|
||||
export const makeRequest = async <RT = any>(path: string, config: RequestConfig = {}) => {
|
||||
try {
|
||||
const result = await makeRequestRaw(path, config);
|
||||
return result.data as RT;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,16 +1,13 @@
|
|||
import createClient from 'openapi-fetch';
|
||||
import qs from 'query-string';
|
||||
|
||||
import { getPluginId } from 'utils/consts';
|
||||
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/${getPluginId()}`;
|
||||
export const API_PATH_PREFIX = '/api/internal/v1';
|
||||
|
||||
const showApiError = (status: number, errorData: string | Record<string, unknown>) => {
|
||||
if (status >= 400 && status < 500) {
|
||||
const text = formatBackendError(errorData);
|
||||
|
|
@ -57,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' }),
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -28,9 +28,9 @@ import Emoji from 'react-emoji-render';
|
|||
import reactStringReplace from 'react-string-replace';
|
||||
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';
|
||||
|
|
@ -889,14 +889,11 @@ function AttachedIncidentsList({
|
|||
const AlertGroupStub = ({ buttons }: { buttons: React.ReactNode }) => {
|
||||
const styles = useStyles2(getIncidentStyles);
|
||||
return (
|
||||
<div className={styles.alertGroupStub}>
|
||||
<VerticalGroup align="center" spacing="md">
|
||||
<img src={errorSVG} alt="" />
|
||||
<Text.Title level={3}>An unexpected error happened</Text.Title>
|
||||
<Text type="secondary">
|
||||
OnCall is not able to receive any information about the current Alert Group. It's unknown if it's firing,
|
||||
acknowledged, silenced, or resolved.
|
||||
</Text>
|
||||
<FullPageError
|
||||
subtitle="OnCall is not able to receive any information about the current Alert Group. It's unknown if it's firing,
|
||||
acknowledged, silenced, or resolved."
|
||||
>
|
||||
<>
|
||||
<div className={styles.alertGroupStubDivider}>
|
||||
<Divider />
|
||||
</div>
|
||||
|
|
@ -904,8 +901,8 @@ const AlertGroupStub = ({ buttons }: { buttons: React.ReactNode }) => {
|
|||
<HorizontalGroup wrap justify="center">
|
||||
{buttons}
|
||||
</HorizontalGroup>
|
||||
</VerticalGroup>
|
||||
</div>
|
||||
</>
|
||||
</FullPageError>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -204,7 +204,7 @@ class _IncidentsPage extends React.Component<IncidentsPageProps, IncidentsPageSt
|
|||
showAutoInterval={false}
|
||||
/>
|
||||
<WithPermissionControlTooltip userAction={UserActions.AlertGroupsDirectPaging}>
|
||||
<Button icon="plus" onClick={this.handleOnClickEscalateTo}>
|
||||
<Button icon="plus" onClick={this.handleOnClickEscalateTo} data-testid="add-escalation-button">
|
||||
Escalation
|
||||
</Button>
|
||||
</WithPermissionControlTooltip>
|
||||
|
|
|
|||
|
|
@ -11,10 +11,7 @@ import Emoji from 'react-emoji-render';
|
|||
|
||||
import { getTemplatesForEdit } from 'components/AlertTemplates/AlertTemplatesForm.config';
|
||||
import { TemplateForEdit } from 'components/AlertTemplates/CommonAlertTemplatesForm.config';
|
||||
import {
|
||||
IntegrationCollapsibleTreeView,
|
||||
IntegrationCollapsibleItem,
|
||||
} from 'components/IntegrationCollapsibleTreeView/IntegrationCollapsibleTreeView';
|
||||
import { CollapsibleTreeView, CollapsibleItem } from 'components/CollapsibleTreeView/CollapsibleTreeView';
|
||||
import { IntegrationContactPoint } from 'components/IntegrationContactPoint/IntegrationContactPoint';
|
||||
import { IntegrationHowToConnect } from 'components/IntegrationHowToConnect/IntegrationHowToConnect';
|
||||
import { IntegrationLogoWithTitle } from 'components/IntegrationLogo/IntegrationLogoWithTitle';
|
||||
|
|
@ -161,7 +158,7 @@ class _IntegrationPage extends React.Component<IntegrationProps, IntegrationStat
|
|||
|
||||
const incomingPart = (
|
||||
<>
|
||||
<IntegrationCollapsibleTreeView configElements={this.getConfigForTreeComponent(id, templates) as any} />
|
||||
<CollapsibleTreeView configElements={this.getConfigForTreeComponent(id, templates) as any} />
|
||||
{isEditTemplateModalOpen && (
|
||||
<IntegrationTemplate
|
||||
id={id}
|
||||
|
|
@ -447,7 +444,7 @@ class _IntegrationPage extends React.Component<IntegrationProps, IntegrationStat
|
|||
const isAlerting = IntegrationHelper.isSpecificIntegration(alertReceiveChannel, 'grafana_alerting');
|
||||
const isLegacyAlerting = IntegrationHelper.isSpecificIntegration(alertReceiveChannel, 'legacy_grafana_alerting');
|
||||
|
||||
const configs: Array<IntegrationCollapsibleItem | IntegrationCollapsibleItem[]> = [
|
||||
const configs: Array<CollapsibleItem | CollapsibleItem[]> = [
|
||||
(isAlerting || isLegacyAlerting) && {
|
||||
isHidden: isLegacyAlerting || contactPoints === null || contactPoints === undefined,
|
||||
isCollapsible: false,
|
||||
|
|
@ -558,7 +555,7 @@ class _IntegrationPage extends React.Component<IntegrationProps, IntegrationStat
|
|||
</div>
|
||||
),
|
||||
},
|
||||
this.renderRoutesFn() as IntegrationCollapsibleItem[],
|
||||
this.renderRoutesFn() as CollapsibleItem[],
|
||||
];
|
||||
|
||||
return configs.filter(Boolean);
|
||||
|
|
@ -610,7 +607,7 @@ class _IntegrationPage extends React.Component<IntegrationProps, IntegrationStat
|
|||
);
|
||||
};
|
||||
|
||||
renderRoutesFn = (): IntegrationCollapsibleItem[] => {
|
||||
renderRoutesFn = (): CollapsibleItem[] => {
|
||||
const {
|
||||
store: { alertReceiveChannelStore },
|
||||
router: {
|
||||
|
|
@ -670,8 +667,8 @@ class _IntegrationPage extends React.Component<IntegrationProps, IntegrationStat
|
|||
onRouteDelete={onRouteDelete}
|
||||
/>
|
||||
),
|
||||
} as IntegrationCollapsibleItem)
|
||||
) as IntegrationCollapsibleItem[];
|
||||
} as CollapsibleItem)
|
||||
) as CollapsibleItem[];
|
||||
};
|
||||
|
||||
handleEditRegexpRouteTemplate = (channelFilterId) => {
|
||||
|
|
|
|||
|
|
@ -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
|
|||
<NewOutgoingWebhookDrawerContent closeDrawer={closeDrawer} />
|
||||
</Drawer>
|
||||
)}
|
||||
<IntegrationCollapsibleTreeView
|
||||
<CollapsibleTreeView
|
||||
configElements={[
|
||||
{
|
||||
customIcon: 'plug',
|
||||
|
|
|
|||
|
|
@ -4,6 +4,8 @@
|
|||
"name": "Grafana OnCall",
|
||||
"id": "grafana-oncall-app",
|
||||
"preload": true,
|
||||
"backend": true,
|
||||
"executable": "gpx_grafana_oncall_app",
|
||||
"info": {
|
||||
"description": "Collect and analyze alerts, escalate based on schedules and deliver them to Slack, Phone Calls, SMS and others",
|
||||
"author": {
|
||||
|
|
@ -110,32 +112,6 @@
|
|||
}
|
||||
],
|
||||
"routes": [
|
||||
{
|
||||
"path": "api/internal/v1/plugin/install",
|
||||
"method": "*",
|
||||
"url": "{{ .JsonData.onCallApiUrl }}/api/internal/v1/plugin/install",
|
||||
"headers": [
|
||||
{
|
||||
"name": "X-Instance-Context",
|
||||
"content": "{ \"stack_id\": \"{{ printf \"%.0f\" .JsonData.stackId }}\", \"org_id\": \"{{ printf \"%.0f\" .JsonData.orgId }}\", \"grafana_token\": \"{{ .SecureJsonData.grafanaToken }}\" }"
|
||||
},
|
||||
{
|
||||
"name": "Authorization",
|
||||
"content": "{{ .SecureJsonData.onCallApiToken }}"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"path": "api/internal/v1/plugin/self-hosted/install",
|
||||
"method": "*",
|
||||
"url": "{{ .JsonData.onCallApiUrl }}/api/internal/v1/plugin/self-hosted/install",
|
||||
"headers": [
|
||||
{
|
||||
"name": "X-Instance-Context",
|
||||
"content": "{ \"grafana_token\": \"{{ .SecureJsonData.grafanaToken }}\" }"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"path": "api/internal/v1/*",
|
||||
"method": "*",
|
||||
|
|
@ -589,6 +565,118 @@
|
|||
"grants": []
|
||||
}
|
||||
],
|
||||
"iam": {
|
||||
"permissions": [
|
||||
{
|
||||
"action": "alert.notifications:read"
|
||||
},
|
||||
{
|
||||
"action": "alert.notifications:write"
|
||||
},
|
||||
{
|
||||
"action": "dashboards:create",
|
||||
"scope": "folders:*"
|
||||
},
|
||||
{
|
||||
"action": "dashboards:read",
|
||||
"scope": "dashboards:*"
|
||||
},
|
||||
{
|
||||
"action": "dashboards:write",
|
||||
"scope": "dashboards:*"
|
||||
},
|
||||
{
|
||||
"action": "datasources:read",
|
||||
"scope": "datasources:*"
|
||||
},
|
||||
{
|
||||
"action": "folders:read",
|
||||
"scope": "folders:*"
|
||||
},
|
||||
{
|
||||
"action": "folders:write",
|
||||
"scope": "folders:*"
|
||||
},
|
||||
{
|
||||
"action": "org.users:read",
|
||||
"scope": "users:*"
|
||||
},
|
||||
{
|
||||
"action": "orgs:read"
|
||||
},
|
||||
{
|
||||
"action": "plugins.app:access",
|
||||
"scope": "plugins:id:grafana-oncall-app"
|
||||
},
|
||||
{
|
||||
"action": "plugins.app:access",
|
||||
"scope": "plugins:id:grafana-incident-app"
|
||||
},
|
||||
{
|
||||
"action": "plugins.app:access",
|
||||
"scope": "plugins:id:grafana-labels-app"
|
||||
},
|
||||
{
|
||||
"action": "plugins:write",
|
||||
"scope": "plugins:id:grafana-oncall-app"
|
||||
},
|
||||
{
|
||||
"action": "roles:read",
|
||||
"scope": "roles:*"
|
||||
},
|
||||
{
|
||||
"action": "status:accesscontrol",
|
||||
"scope": "services:accesscontrol"
|
||||
},
|
||||
{
|
||||
"action": "serviceaccounts:create"
|
||||
},
|
||||
{
|
||||
"action": "serviceaccounts:delete",
|
||||
"scope": "serviceaccounts:*"
|
||||
},
|
||||
{
|
||||
"action": "serviceaccounts:permissions:read",
|
||||
"scope": "serviceaccounts:*"
|
||||
},
|
||||
{
|
||||
"action": "serviceaccounts:permissions:write",
|
||||
"scope": "serviceaccounts:*"
|
||||
},
|
||||
{
|
||||
"action": "serviceaccounts:read",
|
||||
"scope": "serviceaccounts:*"
|
||||
},
|
||||
{
|
||||
"action": "serviceaccounts:write",
|
||||
"scope": "serviceaccounts:*"
|
||||
},
|
||||
{
|
||||
"action": "teams:read",
|
||||
"scope": "teams:*"
|
||||
},
|
||||
{
|
||||
"action": "teams.permissions:read",
|
||||
"scope": "teams:*"
|
||||
},
|
||||
{
|
||||
"action": "teams.roles:read",
|
||||
"scope": "teams:*"
|
||||
},
|
||||
{
|
||||
"action": "users:read",
|
||||
"scope": "global.users:*"
|
||||
},
|
||||
{
|
||||
"action": "users.permissions:read",
|
||||
"scope": "users:id:*"
|
||||
},
|
||||
{
|
||||
"action": "users.roles:read",
|
||||
"scope": "users:*"
|
||||
}
|
||||
]
|
||||
},
|
||||
"dependencies": {
|
||||
"grafanaDependency": ">=9.2.0",
|
||||
"plugins": []
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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 = {};
|
||||
|
|
|
|||
|
|
@ -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,20 +50,25 @@ export const GrafanaPluginRootPage = (props: AppRootProps) => {
|
|||
return (
|
||||
<ErrorBoundary onError={FaroHelper.pushReactError}>
|
||||
{() => (
|
||||
<Provider store={rootStore}>
|
||||
<PluginSetup InitializedComponent={Root} {...props} />
|
||||
</Provider>
|
||||
<PluginInitializer>
|
||||
<Provider store={rootStore}>
|
||||
<Root {...props} />
|
||||
</Provider>
|
||||
</PluginInitializer>
|
||||
)}
|
||||
</ErrorBoundary>
|
||||
);
|
||||
};
|
||||
});
|
||||
|
||||
export const Root = observer((props: AppRootProps) => {
|
||||
const { isBasicDataLoaded, loadBasicData, loadMasterData, pageTitle } = useStore();
|
||||
const { isBasicDataLoaded, loadBasicData, loadMasterData, pageTitle, setupInsightsDatasource, loadRecaptcha } =
|
||||
useStore();
|
||||
|
||||
const location = useLocation();
|
||||
|
||||
useEffect(() => {
|
||||
setupInsightsDatasource(props.meta);
|
||||
loadRecaptcha();
|
||||
loadBasicData();
|
||||
// defer loading master data as it's not used in first sec by user in order to prioritize fetching base data
|
||||
const timeout = setTimeout(() => {
|
||||
|
|
|
|||
|
|
@ -1,68 +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 { getPluginId } from 'utils/consts';
|
||||
import { loadJs } from 'utils/loadJs';
|
||||
|
||||
export type PluginSetupProps = AppRootProps & {
|
||||
InitializedComponent: (props: AppRootProps) => JSX.Element;
|
||||
};
|
||||
|
||||
type PluginSetupWrapperProps = PropsWithChildren<{
|
||||
text: string;
|
||||
}>;
|
||||
|
||||
const PluginSetupWrapper: FC<PluginSetupWrapperProps> = ({ text, children }) => {
|
||||
const PluginPage = (isTopNavbar() ? RealPluginPage : PluginPageFallback) as React.ComponentType<any>;
|
||||
|
||||
return (
|
||||
<PluginPage>
|
||||
<div className="spin">
|
||||
<img alt="Grafana OnCall Logo" src={logo} />
|
||||
<div className="spin-text">{text}</div>
|
||||
{children}
|
||||
</div>
|
||||
</PluginPage>
|
||||
);
|
||||
};
|
||||
|
||||
export const PluginSetup: FC<PluginSetupProps> = 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 (
|
||||
<PluginSetupWrapper text={store.initializationError}>
|
||||
{!store.currentlyUndergoingMaintenance && (
|
||||
<div className="configure-plugin">
|
||||
<HorizontalGroup>
|
||||
<Button variant="primary" onClick={setupPlugin} size="sm">
|
||||
Retry
|
||||
</Button>
|
||||
<LinkButton href={`/plugins/${getPluginId()}?page=configuration`} variant="primary" size="sm">
|
||||
Configure Plugin
|
||||
</LinkButton>
|
||||
</HorizontalGroup>
|
||||
</div>
|
||||
)}
|
||||
</PluginSetupWrapper>
|
||||
);
|
||||
}
|
||||
return <InitializedComponent {...props} />;
|
||||
});
|
||||
|
|
@ -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."
|
||||
`;
|
||||
|
|
@ -1,521 +0,0 @@
|
|||
import { makeRequest as makeRequestOriginal, isNetworkError as isNetworkErrorOriginal } from 'network/network';
|
||||
import { getPluginId } from 'utils/consts';
|
||||
|
||||
import { PluginState, InstallationVerb, UpdateGrafanaPluginSettingsProps } from './plugin';
|
||||
|
||||
const makeRequest = makeRequestOriginal as jest.Mock<ReturnType<typeof makeRequestOriginal>>;
|
||||
const isNetworkError = isNetworkErrorOriginal as unknown as jest.Mock<ReturnType<typeof isNetworkErrorOriginal>>;
|
||||
|
||||
jest.mock('network/network');
|
||||
|
||||
afterEach(() => {
|
||||
jest.resetAllMocks();
|
||||
});
|
||||
|
||||
const ONCALL_BASE_URL = '/plugin';
|
||||
const GRAFANA_PLUGIN_SETTINGS_URL = `/api/plugins/${getPluginId()}/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
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
@ -1,344 +0,0 @@
|
|||
import { getBackendSrv } from '@grafana/runtime';
|
||||
import { OnCallAppPluginMeta, OnCallPluginMetaJSONData, OnCallPluginMetaSecureJSONData } from 'types';
|
||||
|
||||
import { makeRequest, isNetworkError } from 'network/network';
|
||||
import { getPluginId } from 'utils/consts';
|
||||
|
||||
export type UpdateGrafanaPluginSettingsProps = {
|
||||
jsonData?: Partial<OnCallPluginMetaJSONData>;
|
||||
secureJsonData?: Partial<OnCallPluginMetaSecureJSONData>;
|
||||
};
|
||||
|
||||
export type PluginStatusResponseBase = Pick<OnCallPluginMetaJSONData, 'license'> & {
|
||||
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<OnCallPluginMetaJSONData, 'onCallApiUrl'> & {
|
||||
onCallToken: string;
|
||||
};
|
||||
|
||||
type InstallPluginResponse<OnCallAPIResponse = any> = Pick<OnCallPluginMetaSecureJSONData, 'grafanaToken'> & {
|
||||
onCallAPIResponse: OnCallAPIResponse;
|
||||
};
|
||||
|
||||
export type InstallationVerb = 'install' | 'sync';
|
||||
|
||||
export class PluginState {
|
||||
static ONCALL_BASE_URL = '/plugin';
|
||||
static GRAFANA_PLUGIN_SETTINGS_URL = `/api/plugins/${getPluginId()}/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<OnCallAppPluginMeta> =>
|
||||
this.grafanaBackend.get<OnCallAppPluginMeta>(this.GRAFANA_PLUGIN_SETTINGS_URL);
|
||||
|
||||
static updateGrafanaPluginSettings = async (data: UpdateGrafanaPluginSettingsProps, enabled = true) =>
|
||||
this.grafanaBackend.post(this.GRAFANA_PLUGIN_SETTINGS_URL, { ...data, enabled, pinned: true });
|
||||
|
||||
static 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<PluginSyncStatusResponse | string> => {
|
||||
/**
|
||||
* 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 <RT = CloudProvisioningConfigResponse>(
|
||||
selfHosted = false
|
||||
): Promise<InstallPluginResponse<RT>> => {
|
||||
const { key: grafanaToken } = await this.createGrafanaToken();
|
||||
await this.updateGrafanaPluginSettings({ secureJsonData: { grafanaToken } });
|
||||
const onCallAPIResponse = await makeRequest<RT>(
|
||||
`${this.ONCALL_BASE_URL}/${selfHosted ? 'self-hosted/' : ''}install`,
|
||||
{
|
||||
method: 'POST',
|
||||
}
|
||||
);
|
||||
return { grafanaToken, onCallAPIResponse };
|
||||
};
|
||||
|
||||
static selfHostedInstallPlugin = async (
|
||||
onCallApiUrl: string,
|
||||
onCallApiUrlIsConfiguredThroughEnvVar: boolean
|
||||
): Promise<string | null> => {
|
||||
let pluginInstallationOnCallResponse: InstallPluginResponse<SelfHostedProvisioningConfigResponse>;
|
||||
const errorMsgVerb: InstallationVerb = 'install';
|
||||
|
||||
// Step 1. Try provisioning the plugin w/ the Grafana API
|
||||
try {
|
||||
await this.updateGrafanaPluginSettings({ jsonData: { onCallApiUrl: onCallApiUrl } });
|
||||
} catch (e) {
|
||||
return this.getHumanReadableErrorFromGrafanaProvisioningError(
|
||||
e,
|
||||
onCallApiUrl,
|
||||
errorMsgVerb,
|
||||
onCallApiUrlIsConfiguredThroughEnvVar
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Step 2:
|
||||
* - Create a grafana token
|
||||
* - store that token in the Grafana plugin settings
|
||||
* - configure the plugin in OnCall's backend
|
||||
*/
|
||||
try {
|
||||
pluginInstallationOnCallResponse = await this.installPlugin<SelfHostedProvisioningConfigResponse>(true);
|
||||
} catch (e) {
|
||||
return this.getHumanReadableErrorFromOnCallError(
|
||||
e,
|
||||
onCallApiUrl,
|
||||
errorMsgVerb,
|
||||
onCallApiUrlIsConfiguredThroughEnvVar
|
||||
);
|
||||
}
|
||||
|
||||
// Step 3. reprovision the Grafana plugin settings, storing information that we get back from OnCall's backend
|
||||
try {
|
||||
const {
|
||||
grafanaToken,
|
||||
onCallAPIResponse: { onCallToken: onCallApiToken, ...jsonData },
|
||||
} = pluginInstallationOnCallResponse;
|
||||
|
||||
await this.updateGrafanaPluginSettings({
|
||||
jsonData: {
|
||||
...jsonData,
|
||||
onCallApiUrl,
|
||||
},
|
||||
secureJsonData: {
|
||||
grafanaToken,
|
||||
onCallApiToken,
|
||||
},
|
||||
});
|
||||
} catch (e) {
|
||||
return this.getHumanReadableErrorFromGrafanaProvisioningError(
|
||||
e,
|
||||
onCallApiUrl,
|
||||
errorMsgVerb,
|
||||
onCallApiUrlIsConfiguredThroughEnvVar
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
static updatePluginStatus = async (
|
||||
onCallApiUrl: string,
|
||||
onCallApiUrlIsConfiguredThroughEnvVar = false
|
||||
): Promise<PluginConnectedStatusResponse | string> => {
|
||||
try {
|
||||
return await makeRequest<PluginConnectedStatusResponse>(`${this.ONCALL_BASE_URL}/status`, {
|
||||
method: 'POST',
|
||||
});
|
||||
} catch (e) {
|
||||
return this.getHumanReadableErrorFromOnCallError(
|
||||
e,
|
||||
onCallApiUrl,
|
||||
'install',
|
||||
onCallApiUrlIsConfiguredThroughEnvVar
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
static resetPlugin = (): Promise<void> => {
|
||||
/**
|
||||
* mark both of these objects as Required.. this will ensure that we are resetting every attribute back to null
|
||||
* and throw a type error in the event that OnCallPluginMetaJSONData or OnCallPluginMetaSecureJSONData is updated
|
||||
* but we forget to add the attribute here
|
||||
*/
|
||||
const jsonData: Required<OnCallPluginMetaJSONData> = {
|
||||
stackId: null,
|
||||
orgId: null,
|
||||
onCallApiUrl: null,
|
||||
insightsDatasource: undefined,
|
||||
license: null,
|
||||
};
|
||||
const secureJsonData: Required<OnCallPluginMetaSecureJSONData> = {
|
||||
grafanaToken: null,
|
||||
onCallApiToken: null,
|
||||
};
|
||||
|
||||
return this.updateGrafanaPluginSettings({ jsonData, secureJsonData }, false);
|
||||
};
|
||||
}
|
||||
|
|
@ -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<ReturnType<typeof isUserActionAllowedOriginal>>;
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
|
@ -1,4 +1,3 @@
|
|||
import { contextSrv } from 'grafana/app/core/core';
|
||||
import { action, computed, makeObservable, observable, runInAction } from 'mobx';
|
||||
import qs from 'query-string';
|
||||
import { OnCallAppPluginMeta } from 'types';
|
||||
|
|
@ -22,6 +21,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,17 +33,9 @@ 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,
|
||||
getPluginId,
|
||||
GRAFANA_LICENSE_CLOUD,
|
||||
GRAFANA_LICENSE_OSS,
|
||||
PluginId,
|
||||
} from 'utils/consts';
|
||||
import { APP_VERSION, CLOUD_VERSION_REGEX, GRAFANA_LICENSE_CLOUD, GRAFANA_LICENSE_OSS } from 'utils/consts';
|
||||
import { loadJs } from 'utils/loadJs';
|
||||
|
||||
// ------ Dashboard ------ //
|
||||
|
||||
|
|
@ -60,9 +52,6 @@ export class RootBaseStore {
|
|||
@observable
|
||||
recaptchaSiteKey = '';
|
||||
|
||||
@observable
|
||||
initializationError = '';
|
||||
|
||||
@observable
|
||||
currentlyUndergoingMaintenance = false;
|
||||
|
||||
|
|
@ -84,9 +73,10 @@ export class RootBaseStore {
|
|||
onCallApiUrl: string;
|
||||
|
||||
@observable
|
||||
insightsDatasource?: string;
|
||||
insightsDatasource = 'grafanacloud-usage';
|
||||
|
||||
// stores
|
||||
pluginStore = new PluginStore(this);
|
||||
userStore = new UserStore(this);
|
||||
cloudStore = new CloudStore(this);
|
||||
directPagingStore = new DirectPagingStore(this);
|
||||
|
|
@ -118,6 +108,7 @@ export class RootBaseStore {
|
|||
constructor() {
|
||||
makeObservable(this);
|
||||
}
|
||||
|
||||
@action.bound
|
||||
loadBasicData = async () => {
|
||||
const updateFeatures = async () => {
|
||||
|
|
@ -140,6 +131,13 @@ export class RootBaseStore {
|
|||
this.setIsBasicDataLoaded(true);
|
||||
};
|
||||
|
||||
@action
|
||||
setupInsightsDatasource = ({ jsonData: { insightsDatasource } }: OnCallAppPluginMeta) => {
|
||||
if (insightsDatasource) {
|
||||
this.insightsDatasource = insightsDatasource;
|
||||
}
|
||||
};
|
||||
|
||||
@action
|
||||
loadMasterData = async () => {
|
||||
Promise.all([this.userStore.updateNotificationPolicyOptions(), this.userStore.updateNotifyByOptions()]);
|
||||
|
|
@ -150,138 +148,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
|
||||
*/
|
||||
@action.bound
|
||||
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);
|
||||
}
|
||||
// will be removed as part of new OnCall init process
|
||||
if (getPluginId() === PluginId.OnCall) {
|
||||
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];
|
||||
|
||||
|
|
@ -320,18 +186,15 @@ 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;
|
||||
}
|
||||
|
||||
@action.bound
|
||||
async loadRecaptcha() {
|
||||
const { recaptcha_site_key } = await makeRequest<{ recaptcha_site_key: string }>('/plugin/recaptcha');
|
||||
this.recaptchaSiteKey = recaptcha_site_key;
|
||||
loadJs(`https://www.google.com/recaptcha/api.js?render=${recaptcha_site_key}`, recaptcha_site_key);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,7 +7,6 @@ export type OnCallPluginMetaJSONData = {
|
|||
orgId: number;
|
||||
onCallApiUrl: string;
|
||||
insightsDatasource?: string;
|
||||
license: string;
|
||||
};
|
||||
|
||||
export type OnCallPluginMetaSecureJSONData = {
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
|
|
|
|||
|
|
@ -4,8 +4,6 @@ import { contextSrv } from 'grafana/app/core/core';
|
|||
|
||||
import { getPluginId } from 'utils/consts';
|
||||
|
||||
const ONCALL_PERMISSION_PREFIX = getPluginId();
|
||||
|
||||
export type UserAction = {
|
||||
permission: string;
|
||||
fallbackMinimumRoleRequired: OrgRole;
|
||||
|
|
@ -112,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 ? `${getPluginId()}.` : ''}${resource}:${action}`;
|
||||
|
||||
const constructAction = (
|
||||
resource: Resource,
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Reference in a new issue