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:
Dominik Broj 2024-08-16 18:43:52 +02:00 committed by GitHub
parent a416863a28
commit 06d19bf6e9
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
105 changed files with 3959 additions and 3504 deletions

1
.github/CODEOWNERS vendored
View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -5,4 +5,4 @@ apps:
jsonData:
stackId: 5
orgId: 100
onCallApiUrl: http://oncall-dev-engine:8080
onCallApiUrl: $ONCALL_API_URL

View file

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

View 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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View 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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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 }) => {

View file

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

View file

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

View file

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

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

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

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

View 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"`
}

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

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

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

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

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

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

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

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

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

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,4 +0,0 @@
.info-block {
margin-bottom: 24px;
margin-top: 24px;
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -0,0 +1,9 @@
import { makeRequest } from 'network/network';
export class PluginHelper {
static async install() {
return makeRequest(`/plugin/install`, {
method: 'POST',
});
}
}

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

View file

@ -1,3 +0,0 @@
export interface MessagingBackends {
[key: string]: any;
}

View file

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

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

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -7,7 +7,6 @@ export type OnCallPluginMetaJSONData = {
orgId: number;
onCallApiUrl: string;
insightsDatasource?: string;
license: string;
};
export type OnCallPluginMetaSecureJSONData = {

View file

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

View file

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