Merge pull request #4837 from grafana/dev

v1.9.1
This commit is contained in:
Michael Derynck 2024-08-16 11:26:51 -06:00 committed by GitHub
commit 43a49fc782
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
135 changed files with 4146 additions and 3553 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

@ -2,7 +2,7 @@
title: Grafana OnCall HTTP API reference
menuTitle: API reference
description: Reference material for Grafana OnCall API.
weight: 900
weight: 0
keywords:
- OnCall
- API

View file

@ -1,7 +1,7 @@
---
canonical: https://grafana.com/docs/oncall/latest/oncall-api-reference/alertgroups/
title: Alert groups HTTP API
weight: 400
weight: 0
refs:
pagination:
- pattern: /docs/oncall/
@ -58,10 +58,13 @@ The above command returns JSON structured in the following way:
These available filter parameters should be provided as `GET` arguments:
- `id`
- `route_id`
- `integration_id`
- `state`
- `id` (Exact match, alert group ID)
- `route_id` (Exact match, route ID)
- `integration_id` (Exact match, integration ID)
- `label` (Matching labels, can be passed multiple times; expected format: `key1:value1`)
- `team_id` (Exact match, team ID)
- `started_at` (A "{start}_{end}" ISO 8601 timestamp range; expected format: `%Y-%m-%dT%H:%M:%S_%Y-%m-%dT%H:%M:%S`)
- `state` (Possible values: `new`, `acknowledged`, `resolved` or `silenced`)
**HTTP request**

View file

@ -1,7 +1,7 @@
---
canonical: https://grafana.com/docs/oncall/latest/oncall-api-reference/alerts/
title: Alerts HTTP API
weight: 100
weight: 0
refs:
pagination:
- pattern: /docs/oncall/

View file

@ -1,7 +1,7 @@
---
canonical: https://grafana.com/docs/oncall/latest/oncall-api-reference/escalation/
title: Escalation HTTP API
weight: 1200
weight: 0
refs:
users:
- pattern: /docs/oncall/

View file

@ -1,7 +1,7 @@
---
canonical: https://grafana.com/docs/oncall/latest/oncall-api-reference/escalation_chains/
title: Escalation chains HTTP API
weight: 200
weight: 0
refs:
pagination:
- pattern: /docs/oncall/

View file

@ -1,7 +1,7 @@
---
canonical: https://grafana.com/docs/oncall/latest/oncall-api-reference/escalation_policies/
title: Escalation policies HTTP API
weight: 300
weight: 0
refs:
pagination:
- pattern: /docs/oncall/

View file

@ -1,7 +1,7 @@
---
canonical: https://grafana.com/docs/oncall/latest/oncall-api-reference/integrations/
title: Integrations HTTP API
weight: 500
weight: 0
refs:
alertmanager:
- pattern: /docs/oncall/

View file

@ -1,7 +1,7 @@
---
canonical: https://grafana.com/docs/oncall/latest/oncall-api-reference/on_call_shifts/
title: OnCall shifts HTTP API
weight: 600
weight: 0
refs:
pagination:
- pattern: /docs/oncall/

View file

@ -1,7 +1,7 @@
---
canonical: https://grafana.com/docs/oncall/latest/oncall-api-reference/organizations/
title: Grafana OnCall organizations HTTP API
weight: 1500
weight: 0
refs:
pagination:
- pattern: /docs/oncall/

View file

@ -1,7 +1,7 @@
---
canonical: https://grafana.com/docs/oncall/latest/oncall-api-reference/outgoing_webhooks/
title: Outgoing webhooks HTTP API
weight: 700
weight: 0
refs:
outgoing-webhooks:
- pattern: /docs/oncall/

View file

@ -1,7 +1,7 @@
---
canonical: https://grafana.com/docs/oncall/latest/oncall-api-reference/personal_notification_rules/
title: Personal notification rules HTTP API
weight: 800
weight: 0
refs:
pagination:
- pattern: /docs/oncall/

View file

@ -1,7 +1,7 @@
---
canonical: https://grafana.com/docs/oncall/latest/oncall-api-reference/resolution_notes/
title: Resolution notes HTTP API
weight: 900
weight: 0
refs:
pagination:
- pattern: /docs/oncall/

View file

@ -1,7 +1,7 @@
---
canonical: https://grafana.com/docs/oncall/latest/oncall-api-reference/routes/
title: Routes HTTP API
weight: 1100
weight: 0
refs:
pagination:
- pattern: /docs/oncall/

View file

@ -1,7 +1,7 @@
---
canonical: https://grafana.com/docs/oncall/latest/oncall-api-reference/schedules/
title: Schedules HTTP API
weight: 1200
weight: 0
refs:
pagination:
- pattern: /docs/oncall/

View file

@ -1,7 +1,7 @@
---
canonical: https://grafana.com/docs/oncall/latest/oncall-api-reference/shift_swaps/
title: Shift swap requests HTTP API
weight: 1200
weight: 0
refs:
pagination:
- pattern: /docs/oncall/

View file

@ -1,7 +1,7 @@
---
canonical: https://grafana.com/docs/oncall/latest/oncall-api-reference/slack_channels/
title: Slack channels HTTP API
weight: 1300
weight: 0
refs:
pagination:
- pattern: /docs/oncall/

View file

@ -1,7 +1,7 @@
---
canonical: https://grafana.com/docs/oncall/latest/oncall-api-reference/teams/
title: Grafana OnCall teams HTTP API
weight: 1500
weight: 0
refs:
pagination:
- pattern: /docs/oncall/

View file

@ -1,7 +1,7 @@
---
canonical: https://grafana.com/docs/oncall/latest/oncall-api-reference/user_groups/
title: OnCall user groups HTTP API
weight: 1400
weight: 0
refs:
pagination:
- pattern: /docs/oncall/

View file

@ -1,7 +1,7 @@
---
canonical: https://grafana.com/docs/oncall/latest/oncall-api-reference/users/
title: Grafana OnCall users HTTP API
weight: 1500
weight: 0
refs:
pagination:
- pattern: /docs/oncall/

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

@ -1,8 +1,8 @@
from .alert_groups import AlertGroupSerializer # noqa: F401
from .alerts import AlertSerializer # noqa: F401
from .escalation import EscalationSerializer # noqa: F401
from .escalation_chains import EscalationChainSerializer # noqa: F401
from .escalation_policies import EscalationPolicySerializer, EscalationPolicyUpdateSerializer # noqa: F401
from .incidents import IncidentSerializer # noqa: F401
from .integrations import IntegrationSerializer, IntegrationUpdateSerializer # noqa: F401
from .maintenance import MaintainableObjectSerializerMixin # noqa: F401
from .on_call_shifts import CustomOnCallShiftSerializer, CustomOnCallShiftUpdateSerializer # noqa: F401

View file

@ -1,13 +1,15 @@
from rest_framework import serializers
from apps.alerts.models import AlertGroup
from common.api_helpers.custom_fields import UserIdField
from apps.api.serializers.alert_group import AlertGroupLabelSerializer
from common.api_helpers.custom_fields import TeamPrimaryKeyRelatedField, UserIdField
from common.api_helpers.mixins import EagerLoadingMixin
class IncidentSerializer(EagerLoadingMixin, serializers.ModelSerializer):
class AlertGroupSerializer(EagerLoadingMixin, serializers.ModelSerializer):
id = serializers.CharField(read_only=True, source="public_primary_key")
integration_id = serializers.CharField(source="channel.public_primary_key")
team_id = TeamPrimaryKeyRelatedField(source="channel.team", allow_null=True)
route_id = serializers.SerializerMethodField()
created_at = serializers.DateTimeField(source="started_at")
alerts_count = serializers.SerializerMethodField()
@ -15,14 +17,17 @@ class IncidentSerializer(EagerLoadingMixin, serializers.ModelSerializer):
state = serializers.SerializerMethodField()
acknowledged_by = UserIdField(read_only=True, source="acknowledged_by_user")
resolved_by = UserIdField(read_only=True, source="resolved_by_user")
labels = AlertGroupLabelSerializer(many=True, read_only=True)
SELECT_RELATED = ["channel", "channel_filter", "slack_message", "channel__organization"]
SELECT_RELATED = ["channel", "channel_filter", "slack_message", "channel__organization", "channel__team"]
PREFETCH_RELATED = ["labels"]
class Meta:
model = AlertGroup
fields = [
"id",
"integration_id",
"team_id",
"route_id",
"alerts_count",
"state",
@ -31,6 +36,7 @@ class IncidentSerializer(EagerLoadingMixin, serializers.ModelSerializer):
"resolved_by",
"acknowledged_at",
"acknowledged_by",
"labels",
"title",
"permalinks",
"silenced_at",

View file

@ -2,6 +2,7 @@ from unittest.mock import patch
import pytest
from django.urls import reverse
from django.utils import timezone
from rest_framework import status
from rest_framework.test import APIClient
@ -39,10 +40,20 @@ def construct_expected_response_from_alert_groups(alert_groups):
if u is not None:
return u.public_primary_key
labels = []
for label in alert_group.labels.all():
labels.append(
{
"key": {"id": label.key_name, "name": label.key_name},
"value": {"id": label.value_name, "name": label.value_name},
}
)
results.append(
{
"id": alert_group.public_primary_key,
"integration_id": alert_group.channel.public_primary_key,
"team_id": alert_group.channel.team.public_primary_key if alert_group.channel.team else None,
"route_id": alert_group.channel_filter.public_primary_key,
"alerts_count": alert_group.alerts.count(),
"state": alert_group.state,
@ -52,6 +63,7 @@ def construct_expected_response_from_alert_groups(alert_groups):
"acknowledged_by": user_pk_or_none(alert_group, "acknowledged_by_user"),
"resolved_by": user_pk_or_none(alert_group, "resolved_by_user"),
"title": None,
"labels": labels,
"permalinks": {
"slack": None,
"slack_app": None,
@ -62,7 +74,7 @@ def construct_expected_response_from_alert_groups(alert_groups):
}
)
return {
"count": alert_groups.count(),
"count": len(alert_groups),
"next": None,
"previous": None,
"results": results,
@ -75,15 +87,17 @@ def construct_expected_response_from_alert_groups(alert_groups):
@pytest.fixture()
def alert_group_public_api_setup(
make_organization_and_user_with_token,
make_team,
make_alert_receive_channel,
make_channel_filter,
make_alert_group,
make_alert,
):
organization, user, token = make_organization_and_user_with_token()
team = make_team(organization)
grafana = make_alert_receive_channel(organization, integration=AlertReceiveChannel.INTEGRATION_GRAFANA)
formatted_webhook = make_alert_receive_channel(
organization, integration=AlertReceiveChannel.INTEGRATION_FORMATTED_WEBHOOK
organization, integration=AlertReceiveChannel.INTEGRATION_FORMATTED_WEBHOOK, team=team
)
grafana_default_route = make_channel_filter(grafana, is_default=True)
@ -166,6 +180,25 @@ def test_get_alert_groups(alert_group_public_api_setup):
assert response.json() == expected_response
@pytest.mark.django_db
def test_get_alert_groups_include_labels(alert_group_public_api_setup, make_alert_group_label_association):
token, _, _, _ = alert_group_public_api_setup
alert_groups = AlertGroup.objects.all().order_by("-started_at")
alert_group_0 = alert_groups[0]
organization = alert_group_0.channel.organization
# set labels for the first alert group
make_alert_group_label_association(organization, alert_group_0, key_name="a", value_name="b")
client = APIClient()
expected_response = construct_expected_response_from_alert_groups(alert_groups)
url = reverse("api-public:alert_groups-list")
response = client.get(url, format="json", HTTP_AUTHORIZATION=token)
assert response.status_code == status.HTTP_200_OK
assert response.json() == expected_response
@pytest.mark.django_db
def test_get_alert_groups_filter_by_integration(
alert_group_public_api_setup,
@ -185,6 +218,54 @@ def test_get_alert_groups_filter_by_integration(
assert response.json() == expected_response
@pytest.mark.django_db
def test_get_alert_groups_filter_by_team(alert_group_public_api_setup):
token, alert_groups, integrations, _ = alert_group_public_api_setup
for integration in integrations:
team_id = integration.team.public_primary_key if integration.team else "null"
alert_groups = AlertGroup.objects.filter(channel=integration).order_by("-started_at")
expected_response = construct_expected_response_from_alert_groups(alert_groups)
client = APIClient()
url = reverse("api-public:alert_groups-list")
response = client.get(url + f"?team_id={team_id}", format="json", HTTP_AUTHORIZATION=token)
assert response.status_code == status.HTTP_200_OK
assert response.json() == expected_response
@pytest.mark.django_db
def test_get_alert_groups_filter_by_started_at(alert_group_public_api_setup):
token, alert_groups, _, _ = alert_group_public_api_setup
now = timezone.now()
# set custom started_at dates
for i, alert_group in enumerate(alert_groups):
# alert groups starting every 10 days going back
alert_group.started_at = now - timezone.timedelta(days=10 * i + 1)
alert_group.save(update_fields=["started_at"])
client = APIClient()
url = reverse("api-public:alert_groups-list")
ranges = (
# start, end, expected
(now - timezone.timedelta(days=1), now, [alert_groups[0]]),
(now - timezone.timedelta(days=12), now, [alert_groups[0], alert_groups[1]]),
(now - timezone.timedelta(days=12), now - timezone.timedelta(days=5), [alert_groups[1]]),
(now - timezone.timedelta(days=32), now, alert_groups),
)
for range_start, range_end, expected_alert_groups in ranges:
started_at_q = "?started_at={}_{}".format(
range_start.strftime("%Y-%m-%dT%H:%M:%S"), range_end.strftime("%Y-%m-%dT%H:%M:%S")
)
response = client.get(url + started_at_q, format="json", HTTP_AUTHORIZATION=token)
expected_response = construct_expected_response_from_alert_groups(expected_alert_groups)
assert response.status_code == status.HTTP_200_OK
assert response.json() == expected_response
@pytest.mark.django_db
def test_get_alert_groups_filter_by_state_new(
alert_group_public_api_setup,
@ -309,6 +390,28 @@ def test_get_alert_groups_filter_by_route_no_result(
assert response.json()["results"] == []
@pytest.mark.django_db
def test_get_alert_groups_filter_by_labels(
alert_group_public_api_setup,
make_alert_group_label_association,
):
token, alert_groups, _, _ = alert_group_public_api_setup
organization = alert_groups[0].channel.organization
make_alert_group_label_association(organization, alert_groups[0], key_name="a", value_name="b")
make_alert_group_label_association(organization, alert_groups[0], key_name="c", value_name="d")
make_alert_group_label_association(organization, alert_groups[1], key_name="a", value_name="b")
make_alert_group_label_association(organization, alert_groups[2], key_name="c", value_name="d")
expected_response = construct_expected_response_from_alert_groups([alert_groups[0]])
client = APIClient()
url = reverse("api-public:alert_groups-list")
response = client.get(url + "?label=a:b&label=c:d", format="json", HTTP_AUTHORIZATION=token)
assert response.status_code == status.HTTP_200_OK
assert response.json() == expected_response
@pytest.mark.parametrize(
"data,task,status_code",
[

View file

@ -56,6 +56,8 @@ def test_escalation_new_alert_group(
"id": ag.public_primary_key,
"integration_id": ag.channel.public_primary_key,
"route_id": ag.channel_filter.public_primary_key,
"team_id": None,
"labels": [],
"alerts_count": 1,
"state": "firing",
"created_at": mock.ANY,

View file

@ -17,7 +17,7 @@ router.register(r"schedules", views.OnCallScheduleChannelView, basename="schedul
router.register(r"escalation_chains", views.EscalationChainView, basename="escalation_chains")
router.register(r"escalation_policies", views.EscalationPolicyView, basename="escalation_policies")
router.register(r"alerts", views.AlertView, basename="alerts")
router.register(r"alert_groups", views.IncidentView, basename="alert_groups")
router.register(r"alert_groups", views.AlertGroupView, basename="alert_groups")
router.register(r"slack_channels", views.SlackChannelView, basename="slack_channels")
router.register(r"personal_notification_rules", views.PersonalNotificationView, basename="personal_notification_rules")
router.register(r"resolution_notes", views.ResolutionNoteView, basename="resolution_notes")

View file

@ -1,9 +1,9 @@
from .action import ActionView # noqa: F401
from .alert_groups import AlertGroupView # noqa: F401
from .alerts import AlertView # noqa: F401
from .escalation import EscalationView # noqa: F401
from .escalation_chains import EscalationChainView # noqa: F401
from .escalation_policies import EscalationPolicyView # noqa: F401
from .incidents import IncidentView # noqa: F401
from .info import InfoView # noqa: F401
from .integrations import IntegrationView # noqa: F401
from .on_call_shifts import CustomOnCallShiftView # noqa: F401

View file

@ -10,19 +10,30 @@ from rest_framework.viewsets import GenericViewSet
from apps.alerts.constants import ActionSource
from apps.alerts.models import AlertGroup
from apps.alerts.tasks import delete_alert_group, wipe
from apps.api.label_filtering import parse_label_query
from apps.auth_token.auth import ApiTokenAuthentication
from apps.public_api.constants import VALID_DATE_FOR_DELETE_INCIDENT
from apps.public_api.helpers import is_valid_group_creation_date, team_has_slack_token_for_deleting
from apps.public_api.serializers import IncidentSerializer
from apps.public_api.serializers import AlertGroupSerializer
from apps.public_api.throttlers.user_throttle import UserThrottle
from common.api_helpers.exceptions import BadRequest
from common.api_helpers.filters import NO_TEAM_VALUE, ByTeamModelFieldFilterMixin, get_team_queryset
from common.api_helpers.filters import (
NO_TEAM_VALUE,
ByTeamModelFieldFilterMixin,
DateRangeFilterMixin,
get_team_queryset,
)
from common.api_helpers.mixins import RateLimitHeadersMixin
from common.api_helpers.paginators import FiftyPageSizePaginator
class IncidentByTeamFilter(ByTeamModelFieldFilterMixin, filters.FilterSet):
team = filters.ModelChoiceFilter(
class AlertGroupFilters(ByTeamModelFieldFilterMixin, DateRangeFilterMixin, filters.FilterSet):
# query field param name to filter by team
TEAM_FILTER_FIELD_NAME = "team_id"
id = filters.CharFilter(field_name="public_primary_key")
team_id = filters.ModelChoiceFilter(
field_name="channel__team",
queryset=get_team_queryset,
to_field_name="public_primary_key",
@ -31,10 +42,13 @@ class IncidentByTeamFilter(ByTeamModelFieldFilterMixin, filters.FilterSet):
method=ByTeamModelFieldFilterMixin.filter_model_field_with_single_value.__name__,
)
id = filters.CharFilter(field_name="public_primary_key")
started_at = filters.CharFilter(
field_name="started_at",
method=DateRangeFilterMixin.filter_date_range.__name__,
)
class IncidentView(
class AlertGroupView(
RateLimitHeadersMixin, mixins.ListModelMixin, mixins.RetrieveModelMixin, mixins.DestroyModelMixin, GenericViewSet
):
authentication_classes = (ApiTokenAuthentication,)
@ -43,11 +57,11 @@ class IncidentView(
throttle_classes = [UserThrottle]
model = AlertGroup
serializer_class = IncidentSerializer
serializer_class = AlertGroupSerializer
pagination_class = FiftyPageSizePaginator
filter_backends = (filters.DjangoFilterBackend,)
filterset_class = IncidentByTeamFilter
filterset_class = AlertGroupFilters
def get_queryset(self):
route_id = self.request.query_params.get("route_id", None)
@ -82,6 +96,16 @@ class IncidentView(
)
raise BadRequest(detail={"state": f"Must be one of the following: {valid_choices_text}"})
# filter by alert group (static, applied) labels
label_query = self.request.query_params.getlist("label", [])
kv_pairs = parse_label_query(label_query)
for key, value in kv_pairs:
queryset = queryset.filter(
labels__organization=self.request.auth.organization,
labels__key_name=key,
labels__value_name=value,
)
return queryset
def get_object(self):

View file

@ -5,7 +5,7 @@ from rest_framework.views import APIView
from apps.alerts.paging import DirectPagingAlertGroupResolvedError, DirectPagingUserTeamValidationError, direct_paging
from apps.auth_token.auth import ApiTokenAuthentication
from apps.public_api.serializers import EscalationSerializer, IncidentSerializer
from apps.public_api.serializers import AlertGroupSerializer, EscalationSerializer
from apps.public_api.throttlers import UserThrottle
from common.api_helpers.exceptions import BadRequest
@ -43,4 +43,4 @@ class EscalationView(APIView):
raise BadRequest(detail=DirectPagingAlertGroupResolvedError.DETAIL)
except DirectPagingUserTeamValidationError:
raise BadRequest(detail=DirectPagingUserTeamValidationError.DETAIL)
return Response(IncidentSerializer(alert_group).data, status=status.HTTP_200_OK)
return Response(AlertGroupSerializer(alert_group).data, 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

@ -9,7 +9,7 @@ from django.utils import timezone
from apps.alerts.models import AlertGroupExternalID, AlertGroupLogRecord, EscalationPolicy
from apps.base.models import UserNotificationPolicyLogRecord
from apps.public_api.serializers import IncidentSerializer
from apps.public_api.serializers import AlertGroupSerializer
from apps.webhooks.models import Webhook
from apps.webhooks.models.webhook import WebhookSession
from apps.webhooks.tasks import execute_webhook, send_webhook_event
@ -408,7 +408,7 @@ def test_execute_webhook_ok_forward_all(
"email": notified_user.email,
}
],
"alert_group": {**IncidentSerializer(alert_group).data, "labels": {}},
"alert_group": {**AlertGroupSerializer(alert_group).data, "labels": {}},
"alert_group_id": alert_group.public_primary_key,
"alert_payload": "",
"users_to_be_notified": [],
@ -516,7 +516,7 @@ def test_execute_webhook_ok_forward_all_resolved(
"email": notified_user.email,
}
],
"alert_group": {**IncidentSerializer(alert_group).data, "labels": {}},
"alert_group": {**AlertGroupSerializer(alert_group).data, "labels": {}},
"alert_group_id": alert_group.public_primary_key,
"alert_payload": "",
"users_to_be_notified": [],

View file

@ -151,7 +151,7 @@ def _extract_users_from_escalation_snapshot(escalation_snapshot):
def serialize_event(event, alert_group, user, webhook, responses=None):
from apps.alerts.models import AlertGroupExternalID
from apps.public_api.serializers import IncidentSerializer
from apps.public_api.serializers import AlertGroupSerializer
alert_payload = alert_group.alerts.first()
alert_payload_raw = ""
@ -161,7 +161,7 @@ def serialize_event(event, alert_group, user, webhook, responses=None):
data = {
"event": event,
"user": _serialize_event_user(user),
"alert_group": IncidentSerializer(alert_group).data,
"alert_group": AlertGroupSerializer(alert_group).data,
"alert_group_id": alert_group.public_primary_key,
"alert_payload": alert_payload_raw,
"integration": {

View file

@ -76,13 +76,13 @@ class ModelFieldFilterMixin:
class ByTeamModelFieldFilterMixin:
FILTER_FIELD_NAME = "team"
TEAM_FILTER_FIELD_NAME = "team"
def filter_model_field_with_single_value(self, queryset, name, value):
if not value:
return queryset
# ModelChoiceFilter
filter = self.filters[ByTeamModelFieldFilterMixin.FILTER_FIELD_NAME]
filter = self.filters[self.TEAM_FILTER_FIELD_NAME]
if filter.null_value == value:
lookup_kwargs = {f"{name}__isnull": True}
else:
@ -93,7 +93,7 @@ class ByTeamModelFieldFilterMixin:
def filter_model_field_with_multiple_values(self, queryset, name, values):
if not values:
return queryset
filter = self.filters[ByTeamModelFieldFilterMixin.FILTER_FIELD_NAME]
filter = self.filters[self.TEAM_FILTER_FIELD_NAME]
null_team_lookup = None
if filter.null_value in values:
null_team_lookup = Q(**{f"{name}__isnull": True})

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

Some files were not shown because too many files have changed in this diff Show more