From 8dee2503e6d2ccaf848f6892619416c1ae718624 Mon Sep 17 00:00:00 2001 From: Joey Orlando Date: Thu, 15 Aug 2024 15:29:56 -0400 Subject: [PATCH 1/3] update public API docs subpage ordering (#4833) # What this PR does **Before** ![Screenshot 2024-08-15 at 13 55 40](https://github.com/user-attachments/assets/847afb34-d8d1-46f8-b6b4-fba2b6a469e0) **After** Screenshot 2024-08-15 at 15 20 41 --- docs/sources/oncall-api-reference/_index.md | 2 +- docs/sources/oncall-api-reference/alertgroups.md | 2 +- docs/sources/oncall-api-reference/alerts.md | 2 +- docs/sources/oncall-api-reference/escalation.md | 2 +- docs/sources/oncall-api-reference/escalation_chains.md | 2 +- docs/sources/oncall-api-reference/escalation_policies.md | 2 +- docs/sources/oncall-api-reference/integrations.md | 2 +- docs/sources/oncall-api-reference/on_call_shifts.md | 2 +- docs/sources/oncall-api-reference/organizations.md | 2 +- docs/sources/oncall-api-reference/outgoing_webhooks.md | 2 +- .../sources/oncall-api-reference/personal_notification_rules.md | 2 +- docs/sources/oncall-api-reference/resolution_notes.md | 2 +- docs/sources/oncall-api-reference/routes.md | 2 +- docs/sources/oncall-api-reference/schedules.md | 2 +- docs/sources/oncall-api-reference/shift_swaps.md | 2 +- docs/sources/oncall-api-reference/slack_channels.md | 2 +- docs/sources/oncall-api-reference/teams.md | 2 +- docs/sources/oncall-api-reference/user_groups.md | 2 +- docs/sources/oncall-api-reference/users.md | 2 +- 19 files changed, 19 insertions(+), 19 deletions(-) diff --git a/docs/sources/oncall-api-reference/_index.md b/docs/sources/oncall-api-reference/_index.md index 13852be13e..59b747b681 100644 --- a/docs/sources/oncall-api-reference/_index.md +++ b/docs/sources/oncall-api-reference/_index.md @@ -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 diff --git a/docs/sources/oncall-api-reference/alertgroups.md b/docs/sources/oncall-api-reference/alertgroups.md index e8a892b0c3..ad3b58b0f7 100644 --- a/docs/sources/oncall-api-reference/alertgroups.md +++ b/docs/sources/oncall-api-reference/alertgroups.md @@ -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/ diff --git a/docs/sources/oncall-api-reference/alerts.md b/docs/sources/oncall-api-reference/alerts.md index ca2ba1b416..0cbe85e38f 100644 --- a/docs/sources/oncall-api-reference/alerts.md +++ b/docs/sources/oncall-api-reference/alerts.md @@ -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/ diff --git a/docs/sources/oncall-api-reference/escalation.md b/docs/sources/oncall-api-reference/escalation.md index a37e84fbcb..7a5b381dcc 100644 --- a/docs/sources/oncall-api-reference/escalation.md +++ b/docs/sources/oncall-api-reference/escalation.md @@ -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/ diff --git a/docs/sources/oncall-api-reference/escalation_chains.md b/docs/sources/oncall-api-reference/escalation_chains.md index c79f21cf6c..a596acad56 100644 --- a/docs/sources/oncall-api-reference/escalation_chains.md +++ b/docs/sources/oncall-api-reference/escalation_chains.md @@ -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/ diff --git a/docs/sources/oncall-api-reference/escalation_policies.md b/docs/sources/oncall-api-reference/escalation_policies.md index 1b123895a0..584435e5b9 100644 --- a/docs/sources/oncall-api-reference/escalation_policies.md +++ b/docs/sources/oncall-api-reference/escalation_policies.md @@ -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/ diff --git a/docs/sources/oncall-api-reference/integrations.md b/docs/sources/oncall-api-reference/integrations.md index d1f2ed164c..5ae6d29214 100644 --- a/docs/sources/oncall-api-reference/integrations.md +++ b/docs/sources/oncall-api-reference/integrations.md @@ -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/ diff --git a/docs/sources/oncall-api-reference/on_call_shifts.md b/docs/sources/oncall-api-reference/on_call_shifts.md index 0ea61b3346..203622303b 100644 --- a/docs/sources/oncall-api-reference/on_call_shifts.md +++ b/docs/sources/oncall-api-reference/on_call_shifts.md @@ -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/ diff --git a/docs/sources/oncall-api-reference/organizations.md b/docs/sources/oncall-api-reference/organizations.md index 7c7abcae47..4ad35c07a1 100644 --- a/docs/sources/oncall-api-reference/organizations.md +++ b/docs/sources/oncall-api-reference/organizations.md @@ -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/ diff --git a/docs/sources/oncall-api-reference/outgoing_webhooks.md b/docs/sources/oncall-api-reference/outgoing_webhooks.md index 4c00b47842..70a37d0a03 100644 --- a/docs/sources/oncall-api-reference/outgoing_webhooks.md +++ b/docs/sources/oncall-api-reference/outgoing_webhooks.md @@ -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/ diff --git a/docs/sources/oncall-api-reference/personal_notification_rules.md b/docs/sources/oncall-api-reference/personal_notification_rules.md index b568895703..d647f9d92a 100644 --- a/docs/sources/oncall-api-reference/personal_notification_rules.md +++ b/docs/sources/oncall-api-reference/personal_notification_rules.md @@ -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/ diff --git a/docs/sources/oncall-api-reference/resolution_notes.md b/docs/sources/oncall-api-reference/resolution_notes.md index d00c1d779b..9cf181d0c2 100644 --- a/docs/sources/oncall-api-reference/resolution_notes.md +++ b/docs/sources/oncall-api-reference/resolution_notes.md @@ -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/ diff --git a/docs/sources/oncall-api-reference/routes.md b/docs/sources/oncall-api-reference/routes.md index 87c3d01c60..4386492d2e 100644 --- a/docs/sources/oncall-api-reference/routes.md +++ b/docs/sources/oncall-api-reference/routes.md @@ -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/ diff --git a/docs/sources/oncall-api-reference/schedules.md b/docs/sources/oncall-api-reference/schedules.md index 008a9809da..4193c26b1b 100644 --- a/docs/sources/oncall-api-reference/schedules.md +++ b/docs/sources/oncall-api-reference/schedules.md @@ -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/ diff --git a/docs/sources/oncall-api-reference/shift_swaps.md b/docs/sources/oncall-api-reference/shift_swaps.md index 97a143e18c..d2b9caef48 100644 --- a/docs/sources/oncall-api-reference/shift_swaps.md +++ b/docs/sources/oncall-api-reference/shift_swaps.md @@ -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/ diff --git a/docs/sources/oncall-api-reference/slack_channels.md b/docs/sources/oncall-api-reference/slack_channels.md index dad890dc52..e8e0d89cbd 100644 --- a/docs/sources/oncall-api-reference/slack_channels.md +++ b/docs/sources/oncall-api-reference/slack_channels.md @@ -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/ diff --git a/docs/sources/oncall-api-reference/teams.md b/docs/sources/oncall-api-reference/teams.md index 15c61e39ce..802e39d9ae 100644 --- a/docs/sources/oncall-api-reference/teams.md +++ b/docs/sources/oncall-api-reference/teams.md @@ -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/ diff --git a/docs/sources/oncall-api-reference/user_groups.md b/docs/sources/oncall-api-reference/user_groups.md index da39320831..c1d561eee2 100644 --- a/docs/sources/oncall-api-reference/user_groups.md +++ b/docs/sources/oncall-api-reference/user_groups.md @@ -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/ diff --git a/docs/sources/oncall-api-reference/users.md b/docs/sources/oncall-api-reference/users.md index 1af09784d8..6d15b3dbef 100644 --- a/docs/sources/oncall-api-reference/users.md +++ b/docs/sources/oncall-api-reference/users.md @@ -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/ From a416863a282f00a8c22300c6b68e73e7ea04c459 Mon Sep 17 00:00:00 2001 From: Matias Bordese Date: Thu, 15 Aug 2024 16:58:25 -0300 Subject: [PATCH 2/3] Update alert groups public API filters support (#4832) Related to https://github.com/grafana/oncall/issues/4747 - include labels in response - allow filtering by labels - allow filtering by started_at - update docs --- .../oncall-api-reference/alertgroups.md | 11 +- .../apps/public_api/serializers/__init__.py | 2 +- .../{incidents.py => alert_groups.py} | 12 +- .../public_api/tests/test_alert_groups.py | 107 +++++++++++++++++- .../apps/public_api/tests/test_escalation.py | 2 + engine/apps/public_api/urls.py | 2 +- engine/apps/public_api/views/__init__.py | 2 +- .../views/{incidents.py => alert_groups.py} | 40 +++++-- engine/apps/public_api/views/escalation.py | 4 +- .../webhooks/tests/test_trigger_webhook.py | 6 +- engine/apps/webhooks/utils.py | 4 +- engine/common/api_helpers/filters.py | 6 +- 12 files changed, 168 insertions(+), 30 deletions(-) rename engine/apps/public_api/serializers/{incidents.py => alert_groups.py} (75%) rename engine/apps/public_api/views/{incidents.py => alert_groups.py} (87%) diff --git a/docs/sources/oncall-api-reference/alertgroups.md b/docs/sources/oncall-api-reference/alertgroups.md index ad3b58b0f7..bb8d6f660d 100644 --- a/docs/sources/oncall-api-reference/alertgroups.md +++ b/docs/sources/oncall-api-reference/alertgroups.md @@ -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** diff --git a/engine/apps/public_api/serializers/__init__.py b/engine/apps/public_api/serializers/__init__.py index 21f9b08f84..baf4d4c392 100644 --- a/engine/apps/public_api/serializers/__init__.py +++ b/engine/apps/public_api/serializers/__init__.py @@ -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 diff --git a/engine/apps/public_api/serializers/incidents.py b/engine/apps/public_api/serializers/alert_groups.py similarity index 75% rename from engine/apps/public_api/serializers/incidents.py rename to engine/apps/public_api/serializers/alert_groups.py index 1633b98430..07f1d0fbc6 100644 --- a/engine/apps/public_api/serializers/incidents.py +++ b/engine/apps/public_api/serializers/alert_groups.py @@ -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 Meta: "resolved_by", "acknowledged_at", "acknowledged_by", + "labels", "title", "permalinks", "silenced_at", diff --git a/engine/apps/public_api/tests/test_alert_groups.py b/engine/apps/public_api/tests/test_alert_groups.py index 2b128a49d9..51d074211d 100644 --- a/engine/apps/public_api/tests/test_alert_groups.py +++ b/engine/apps/public_api/tests/test_alert_groups.py @@ -2,6 +2,7 @@ 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 user_pk_or_none(alert_group, user_field): 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 user_pk_or_none(alert_group, user_field): "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 user_pk_or_none(alert_group, user_field): } ) return { - "count": alert_groups.count(), + "count": len(alert_groups), "next": None, "previous": None, "results": results, @@ -75,15 +87,17 @@ def user_pk_or_none(alert_group, user_field): @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", [ diff --git a/engine/apps/public_api/tests/test_escalation.py b/engine/apps/public_api/tests/test_escalation.py index ebf8c4a0ce..0fad2f71ee 100644 --- a/engine/apps/public_api/tests/test_escalation.py +++ b/engine/apps/public_api/tests/test_escalation.py @@ -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, diff --git a/engine/apps/public_api/urls.py b/engine/apps/public_api/urls.py index 7f36170bcd..b1fc491ae4 100644 --- a/engine/apps/public_api/urls.py +++ b/engine/apps/public_api/urls.py @@ -17,7 +17,7 @@ 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") diff --git a/engine/apps/public_api/views/__init__.py b/engine/apps/public_api/views/__init__.py index 49a5f7281c..3bd42729aa 100644 --- a/engine/apps/public_api/views/__init__.py +++ b/engine/apps/public_api/views/__init__.py @@ -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 diff --git a/engine/apps/public_api/views/incidents.py b/engine/apps/public_api/views/alert_groups.py similarity index 87% rename from engine/apps/public_api/views/incidents.py rename to engine/apps/public_api/views/alert_groups.py index 76f25dc8b4..0db6f34932 100644 --- a/engine/apps/public_api/views/incidents.py +++ b/engine/apps/public_api/views/alert_groups.py @@ -10,19 +10,30 @@ 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 @@ def get_queryset(self): ) 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): diff --git a/engine/apps/public_api/views/escalation.py b/engine/apps/public_api/views/escalation.py index c7da1fe19a..ae3b5717df 100644 --- a/engine/apps/public_api/views/escalation.py +++ b/engine/apps/public_api/views/escalation.py @@ -5,7 +5,7 @@ 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 @@ def post(self, request): 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) diff --git a/engine/apps/webhooks/tests/test_trigger_webhook.py b/engine/apps/webhooks/tests/test_trigger_webhook.py index 38445a8a73..85b3966ae1 100644 --- a/engine/apps/webhooks/tests/test_trigger_webhook.py +++ b/engine/apps/webhooks/tests/test_trigger_webhook.py @@ -9,7 +9,7 @@ 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": [], diff --git a/engine/apps/webhooks/utils.py b/engine/apps/webhooks/utils.py index 93ee42f265..f3b90e5593 100644 --- a/engine/apps/webhooks/utils.py +++ b/engine/apps/webhooks/utils.py @@ -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": { diff --git a/engine/common/api_helpers/filters.py b/engine/common/api_helpers/filters.py index f3423e0cb0..851ff6f6a8 100644 --- a/engine/common/api_helpers/filters.py +++ b/engine/common/api_helpers/filters.py @@ -76,13 +76,13 @@ def filter_model_field(self, queryset, name, value): 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 @@ def filter_model_field_with_single_value(self, queryset, name, value): 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}) From 06d19bf6e985d2ffa2fc36752b6643a45a032450 Mon Sep 17 00:00:00 2001 From: Dominik Broj Date: Fri, 16 Aug 2024 18:43:52 +0200 Subject: [PATCH 3/3] New OnCall plugin initialization process (#4657) # What this PR does New OnCall plugin initialization process ## Checklist - [x] Unit, integration, and e2e (if applicable) tests updated - [x] Documentation added (or `pr:no public docs` PR label added if not required) - [x] Added the relevant release notes label (see labels prefixed w/ `release:`). These labels dictate how your PR will show up in the autogenerated release notes. --------- Co-authored-by: Michael Derynck Co-authored-by: Matias Bordese --- .github/CODEOWNERS | 1 + .github/workflows/e2e-tests.yml | 2 - .github/workflows/linting-and-tests.yml | 24 +- .tilt/plugin/Tiltfile | 19 +- .tilt/tests/Tiltfile | 2 +- Tiltfile | 9 +- .../grafana-oncall-app-provisioning.yaml | 2 +- dev/helm-local.yml | 2 +- dev/scripts/restart_backend_plugin.sh | 23 + docker-compose-developer.yml | 4 +- .../grafana_alerting_sync.py | 24 +- .../tests/test_grafana_alerting_sync.py | 2 +- engine/apps/grafana_plugin/helpers/gcom.py | 4 +- .../grafana_plugin/serializers/sync_data.py | 8 +- .../apps/grafana_plugin/tests/test_sync_v2.py | 7 +- .../apps/grafana_plugin/views/install_v2.py | 3 +- engine/apps/grafana_plugin/views/sync_v2.py | 15 +- engine/apps/user_management/sync.py | 5 +- engine/conftest.py | 6 +- grafana-plugin/Magefile.go | 12 + .../e2e-tests/alerts/directPaging.test.ts | 8 +- grafana-plugin/e2e-tests/globalSetup.ts | 97 +--- .../integrations/integrationsTable.test.ts | 8 +- .../configuration.test.ts | 34 ++ .../initialization.test.ts | 78 +++ .../e2e-tests/users/usersActions.test.ts | 35 +- grafana-plugin/e2e-tests/utils/constants.ts | 7 + grafana-plugin/e2e-tests/utils/forms.ts | 5 +- grafana-plugin/e2e-tests/utils/users.ts | 47 +- grafana-plugin/go.mod | 92 +++ grafana-plugin/go.sum | 293 ++++++++++ grafana-plugin/pkg/main.go | 23 + grafana-plugin/pkg/plugin/app.go | 120 ++++ grafana-plugin/pkg/plugin/debug.go | 97 ++++ grafana-plugin/pkg/plugin/errors.go | 11 + grafana-plugin/pkg/plugin/install.go | 136 +++++ grafana-plugin/pkg/plugin/permissions.go | 98 ++++ grafana-plugin/pkg/plugin/proxy.go | 209 +++++++ grafana-plugin/pkg/plugin/resources.go | 137 +++++ grafana-plugin/pkg/plugin/resources_test.go | 73 +++ grafana-plugin/pkg/plugin/settings.go | 343 +++++++++++ grafana-plugin/pkg/plugin/status.go | 265 +++++++++ grafana-plugin/pkg/plugin/sync.go | 138 +++++ grafana-plugin/pkg/plugin/teams.go | 187 ++++++ grafana-plugin/pkg/plugin/users.go | 279 +++++++++ .../CollapsibleTreeView.styles.ts} | 2 +- .../CollapsibleTreeView.tsx} | 33 +- .../FullPageError/FullPageError.tsx | 40 ++ .../src/components/Text/Text.styles.ts | 14 +- grafana-plugin/src/components/Text/Text.tsx | 7 +- .../__snapshots__/Unauthorized.test.tsx.snap | 20 +- .../__snapshots__/AddResponders.test.tsx.snap | 22 +- .../__snapshots__/TeamResponder.test.tsx.snap | 2 +- .../__snapshots__/UserResponder.test.tsx.snap | 2 +- .../ExpandedIntegrationRouteDisplay.tsx | 13 +- .../MobileAppConnection.tsx | 28 +- .../MobileAppConnection.test.tsx.snap | 46 +- .../__snapshots__/DownloadIcons.test.tsx.snap | 8 +- .../LinkLoginButton.test.tsx.snap | 4 +- .../PluginConfigPage.test.tsx | 284 --------- .../PluginConfigPage/PluginConfigPage.tsx | 508 ++++++++-------- .../PluginConfigPage.test.tsx.snap | 546 ------------------ .../ConfigurationForm.module.css | 4 - .../ConfigurationForm.test.tsx | 75 --- .../ConfigurationForm/ConfigurationForm.tsx | 128 ---- .../ConfigurationForm.test.tsx.snap | 269 --------- .../RemoveCurrentConfigurationButton.test.tsx | 32 - .../RemoveCurrentConfigurationButton.tsx | 18 - ...veCurrentConfigurationButton.test.tsx.snap | 38 -- .../StatusMessageBlock.test.tsx | 12 - .../StatusMessageBlock/StatusMessageBlock.tsx | 13 - .../StatusMessageBlock.test.tsx.snap | 17 - .../PluginInitializer/PluginInitializer.tsx | 69 +++ .../src/models/loader/action-keys.ts | 4 +- .../src/models/plugin/plugin.helper.ts | 9 + grafana-plugin/src/models/plugin/plugin.ts | 96 +++ grafana-plugin/src/models/user/user.types.ts | 3 - grafana-plugin/src/module.ts | 7 +- .../src/network/grafana-api/api.types.d.ts | 52 ++ .../src/network/grafana-api/http-client.ts | 88 +++ grafana-plugin/src/network/network.ts | 11 +- .../src/network/oncall-api/api.types.d.ts | 27 + .../src/network/oncall-api/http-client.ts | 7 +- .../src/pages/incident/Incident.tsx | 19 +- .../src/pages/incidents/Incidents.tsx | 2 +- .../src/pages/integration/Integration.tsx | 17 +- .../integration/OutgoingTab/OutgoingTab.tsx | 4 +- grafana-plugin/src/plugin.json | 140 ++++- .../GrafanaPluginRootPage.helpers.test.tsx | 2 +- .../plugin/GrafanaPluginRootPage.helpers.tsx | 15 - .../src/plugin/GrafanaPluginRootPage.tsx | 19 +- .../src/plugin/PluginSetup/PluginSetup.tsx | 68 --- .../plugin/__snapshots__/plugin.test.ts.snap | 57 -- .../src/state/plugin/plugin.test.ts | 521 ----------------- grafana-plugin/src/state/plugin/plugin.ts | 344 ----------- .../state/rootBaseStore/RootBaseStore.test.ts | 333 ----------- .../src/state/rootBaseStore/RootBaseStore.ts | 177 +----- grafana-plugin/src/types.ts | 1 - grafana-plugin/src/utils/async.ts | 2 + .../src/utils/authorization/authorization.ts | 4 +- grafana-plugin/src/utils/consts.ts | 31 +- grafana-plugin/src/utils/hooks.tsx | 18 + grafana-plugin/src/utils/utils.test.ts | 57 ++ grafana-plugin/src/utils/utils.ts | 42 +- grafana-plugin/webpack.config.ts | 8 +- 105 files changed, 3944 insertions(+), 3489 deletions(-) create mode 100755 dev/scripts/restart_backend_plugin.sh create mode 100644 grafana-plugin/Magefile.go create mode 100644 grafana-plugin/e2e-tests/pluginInitialization/configuration.test.ts create mode 100644 grafana-plugin/e2e-tests/pluginInitialization/initialization.test.ts create mode 100644 grafana-plugin/go.mod create mode 100644 grafana-plugin/go.sum create mode 100644 grafana-plugin/pkg/main.go create mode 100644 grafana-plugin/pkg/plugin/app.go create mode 100644 grafana-plugin/pkg/plugin/debug.go create mode 100644 grafana-plugin/pkg/plugin/errors.go create mode 100644 grafana-plugin/pkg/plugin/install.go create mode 100644 grafana-plugin/pkg/plugin/permissions.go create mode 100644 grafana-plugin/pkg/plugin/proxy.go create mode 100644 grafana-plugin/pkg/plugin/resources.go create mode 100644 grafana-plugin/pkg/plugin/resources_test.go create mode 100644 grafana-plugin/pkg/plugin/settings.go create mode 100644 grafana-plugin/pkg/plugin/status.go create mode 100644 grafana-plugin/pkg/plugin/sync.go create mode 100644 grafana-plugin/pkg/plugin/teams.go create mode 100644 grafana-plugin/pkg/plugin/users.go rename grafana-plugin/src/components/{IntegrationCollapsibleTreeView/IntegrationCollapsibleTreeView.styles.ts => CollapsibleTreeView/CollapsibleTreeView.styles.ts} (96%) rename grafana-plugin/src/components/{IntegrationCollapsibleTreeView/IntegrationCollapsibleTreeView.tsx => CollapsibleTreeView/CollapsibleTreeView.tsx} (81%) create mode 100644 grafana-plugin/src/components/FullPageError/FullPageError.tsx delete mode 100644 grafana-plugin/src/containers/PluginConfigPage/PluginConfigPage.test.tsx delete mode 100644 grafana-plugin/src/containers/PluginConfigPage/__snapshots__/PluginConfigPage.test.tsx.snap delete mode 100644 grafana-plugin/src/containers/PluginConfigPage/parts/ConfigurationForm/ConfigurationForm.module.css delete mode 100644 grafana-plugin/src/containers/PluginConfigPage/parts/ConfigurationForm/ConfigurationForm.test.tsx delete mode 100644 grafana-plugin/src/containers/PluginConfigPage/parts/ConfigurationForm/ConfigurationForm.tsx delete mode 100644 grafana-plugin/src/containers/PluginConfigPage/parts/ConfigurationForm/__snapshots__/ConfigurationForm.test.tsx.snap delete mode 100644 grafana-plugin/src/containers/PluginConfigPage/parts/RemoveCurrentConfigurationButton/RemoveCurrentConfigurationButton.test.tsx delete mode 100644 grafana-plugin/src/containers/PluginConfigPage/parts/RemoveCurrentConfigurationButton/RemoveCurrentConfigurationButton.tsx delete mode 100644 grafana-plugin/src/containers/PluginConfigPage/parts/RemoveCurrentConfigurationButton/__snapshots__/RemoveCurrentConfigurationButton.test.tsx.snap delete mode 100644 grafana-plugin/src/containers/PluginConfigPage/parts/StatusMessageBlock/StatusMessageBlock.test.tsx delete mode 100644 grafana-plugin/src/containers/PluginConfigPage/parts/StatusMessageBlock/StatusMessageBlock.tsx delete mode 100644 grafana-plugin/src/containers/PluginConfigPage/parts/StatusMessageBlock/__snapshots__/StatusMessageBlock.test.tsx.snap create mode 100644 grafana-plugin/src/containers/PluginInitializer/PluginInitializer.tsx create mode 100644 grafana-plugin/src/models/plugin/plugin.helper.ts create mode 100644 grafana-plugin/src/models/plugin/plugin.ts delete mode 100644 grafana-plugin/src/models/user/user.types.ts create mode 100644 grafana-plugin/src/network/grafana-api/api.types.d.ts create mode 100644 grafana-plugin/src/network/grafana-api/http-client.ts delete mode 100644 grafana-plugin/src/plugin/PluginSetup/PluginSetup.tsx delete mode 100644 grafana-plugin/src/state/plugin/__snapshots__/plugin.test.ts.snap delete mode 100644 grafana-plugin/src/state/plugin/plugin.test.ts delete mode 100644 grafana-plugin/src/state/plugin/plugin.ts delete mode 100644 grafana-plugin/src/state/rootBaseStore/RootBaseStore.test.ts create mode 100644 grafana-plugin/src/utils/utils.test.ts diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index be6032020c..11e50eaedf 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -1,5 +1,6 @@ * @grafana/grafana-oncall-backend /grafana-plugin @grafana/grafana-oncall-frontend +/grafana-plugin/pkg @grafana/grafana-oncall-backend /docs @grafana/docs-gops @grafana/grafana-oncall # `make docs` procedure is owned by @jdbaldry of @grafana/docs-squad. diff --git a/.github/workflows/e2e-tests.yml b/.github/workflows/e2e-tests.yml index 675d11ec5d..fc6d4a6423 100644 --- a/.github/workflows/e2e-tests.yml +++ b/.github/workflows/e2e-tests.yml @@ -109,13 +109,11 @@ jobs: # ---------- Expensive e2e tests steps start ----------- - name: Install Go - if: inputs.run-expensive-tests uses: actions/setup-go@v4 with: go-version: "1.21.5" - name: Install Mage - if: inputs.run-expensive-tests run: go install github.com/magefile/mage@v1.15.0 - name: Get Vault secrets diff --git a/.github/workflows/linting-and-tests.yml b/.github/workflows/linting-and-tests.yml index 0063e8baba..83f1ff61e2 100644 --- a/.github/workflows/linting-and-tests.yml +++ b/.github/workflows/linting-and-tests.yml @@ -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 diff --git a/.tilt/plugin/Tiltfile b/.tilt/plugin/Tiltfile index 858ecffd74..9ea3ee9336 100644 --- a/.tilt/plugin/Tiltfile +++ b/.tilt/plugin/Tiltfile @@ -23,4 +23,21 @@ if not is_ci: serve_dir=grafana_plugin_dir, serve_cmd="yarn watch", allow_parallel=True, - ) \ No newline at end of file + ) + +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'] +) \ No newline at end of file diff --git a/.tilt/tests/Tiltfile b/.tilt/tests/Tiltfile index 7fec693baf..bf47ca53ea 100644 --- a/.tilt/tests/Tiltfile +++ b/.tilt/tests/Tiltfile @@ -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( diff --git a/Tiltfile b/Tiltfile index fc65ce0590..1e00bad31d 100644 --- a/Tiltfile +++ b/Tiltfile @@ -71,7 +71,7 @@ if not running_under_parent_tiltfile: # Load the custom Grafana extensions v1alpha1.extension_repo( name="grafana-tilt-extensions", - ref="v1.2.0", + ref="v1.4.2", url="https://github.com/grafana/tilt-extensions", ) v1alpha1.extension( @@ -83,6 +83,7 @@ def load_grafana(): # The user/pass that you will login to Grafana with grafana_admin_user_pass = os.getenv("GRAFANA_ADMIN_USER_PASS", "oncall") grafana_version = os.getenv("GRAFANA_VERSION", "latest") + grafana_url = os.getenv("GRAFANA_URL", "http://grafana:3000") if 'plugin' in profiles: @@ -100,11 +101,15 @@ def load_grafana(): context="grafana-plugin", plugin_files=["grafana-plugin/src/plugin.json"], namespace="default", - deps=["grafana-oncall-app-provisioning-configmap", "build-ui"], + deps=["grafana-oncall-app-provisioning-configmap", "build-ui", "build-oncall-plugin-backend"], extra_env={ "GF_SECURITY_ADMIN_PASSWORD": "oncall", "GF_SECURITY_ADMIN_USER": "oncall", "GF_AUTH_ANONYMOUS_ENABLED": "false", + "GF_APP_URL": grafana_url, # older versions of grafana need this + "GF_SERVER_ROOT_URL": grafana_url, + "GF_FEATURE_TOGGLES_ENABLE": "externalServiceAccounts", + "ONCALL_API_URL": "http://oncall-dev-engine:8080" }, ) # --- GRAFANA END ---- diff --git a/dev/grafana/provisioning/plugins/grafana-oncall-app-provisioning.yaml b/dev/grafana/provisioning/plugins/grafana-oncall-app-provisioning.yaml index 47b8e1d7ee..bb0c5a7c41 100644 --- a/dev/grafana/provisioning/plugins/grafana-oncall-app-provisioning.yaml +++ b/dev/grafana/provisioning/plugins/grafana-oncall-app-provisioning.yaml @@ -5,4 +5,4 @@ apps: jsonData: stackId: 5 orgId: 100 - onCallApiUrl: http://oncall-dev-engine:8080 + onCallApiUrl: $ONCALL_API_URL diff --git a/dev/helm-local.yml b/dev/helm-local.yml index 02a7052718..b5fd80492d 100644 --- a/dev/helm-local.yml +++ b/dev/helm-local.yml @@ -70,7 +70,7 @@ grafana: - name: DATABASE_PASSWORD value: oncallpassword env: - GF_FEATURE_TOGGLES_ENABLE: topnav + GF_FEATURE_TOGGLES_ENABLE: topnav,externalServiceAccounts GF_SECURITY_ADMIN_PASSWORD: oncall GF_SECURITY_ADMIN_USER: oncall GF_PLUGINS_ALLOW_LOADING_UNSIGNED_PLUGINS: grafana-oncall-app diff --git a/dev/scripts/restart_backend_plugin.sh b/dev/scripts/restart_backend_plugin.sh new file mode 100755 index 0000000000..b80eaeae79 --- /dev/null +++ b/dev/scripts/restart_backend_plugin.sh @@ -0,0 +1,23 @@ +#!/bin/bash + +# Find a grafana pod +pod=$(kubectl get pods -l app.kubernetes.io/name=grafana -o=jsonpath='{.items[0].metadata.name}') + +if [ -z "$pod" ]; then + echo "No pod found with the specified label." + exit 1 +fi + +# Exec into the pod +kubectl exec -it "$pod" -- /bin/bash <<'EOF' + +# Find and kill the process containing "gpx_grafana" (plugin backend process) +process_id=$(ps aux | grep gpx_grafana | grep -v grep | awk '{print $1}') +echo $process_id +if [ -n "$process_id" ]; then + echo "Killing process $process_id" + kill $process_id +else + echo "No process containing 'gpx_grafana' in COMMAND found." +fi +EOF diff --git a/docker-compose-developer.yml b/docker-compose-developer.yml index 67eb2de43b..b751ab1e98 100644 --- a/docker-compose-developer.yml +++ b/docker-compose-developer.yml @@ -52,8 +52,6 @@ services: context: ./grafana-plugin dockerfile: Dockerfile.dev labels: *oncall-labels - environment: - ONCALL_API_URL: http://host.docker.internal:8080 volumes: - ./grafana-plugin:/etc/app - node_modules_dev:/etc/app/node_modules @@ -324,6 +322,8 @@ services: GF_SECURITY_ADMIN_USER: oncall GF_SECURITY_ADMIN_PASSWORD: oncall GF_PLUGINS_ALLOW_LOADING_UNSIGNED_PLUGINS: grafana-oncall-app + GF_FEATURE_TOGGLES_ENABLE: externalServiceAccounts + ONCALL_API_URL: http://host.docker.internal:8080 env_file: - ./dev/.env.${DB}.dev ports: diff --git a/engine/apps/alerts/grafana_alerting_sync_manager/grafana_alerting_sync.py b/engine/apps/alerts/grafana_alerting_sync_manager/grafana_alerting_sync.py index e60e33e936..e377b0fea4 100644 --- a/engine/apps/alerts/grafana_alerting_sync_manager/grafana_alerting_sync.py +++ b/engine/apps/alerts/grafana_alerting_sync_manager/grafana_alerting_sync.py @@ -370,16 +370,20 @@ def _get_connected_contact_points_from_config(self, alertmanager_config: dict, i 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 diff --git a/engine/apps/alerts/tests/test_grafana_alerting_sync.py b/engine/apps/alerts/tests/test_grafana_alerting_sync.py index 8a5dd462c3..d585f60207 100644 --- a/engine/apps/alerts/tests/test_grafana_alerting_sync.py +++ b/engine/apps/alerts/tests/test_grafana_alerting_sync.py @@ -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 diff --git a/engine/apps/grafana_plugin/helpers/gcom.py b/engine/apps/grafana_plugin/helpers/gcom.py index c2bae3d1cb..185538abb7 100644 --- a/engine/apps/grafana_plugin/helpers/gcom.py +++ b/engine/apps/grafana_plugin/helpers/gcom.py @@ -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"], diff --git a/engine/apps/grafana_plugin/serializers/sync_data.py b/engine/apps/grafana_plugin/serializers/sync_data.py index 321fad2f8a..bedefea1cd 100644 --- a/engine/apps/grafana_plugin/serializers/sync_data.py +++ b/engine/apps/grafana_plugin/serializers/sync_data.py @@ -95,7 +95,13 @@ def to_internal_value(self, data): data = super().to_internal_value(data) users = data.get("users") if users: - data["users"] = [SyncUser(**user) for user in users] + + 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] diff --git a/engine/apps/grafana_plugin/tests/test_sync_v2.py b/engine/apps/grafana_plugin/tests/test_sync_v2.py index bf7a9a06ea..d84de38a94 100644 --- a/engine/apps/grafana_plugin/tests/test_sync_v2.py +++ b/engine/apps/grafana_plugin/tests/test_sync_v2.py @@ -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 diff --git a/engine/apps/grafana_plugin/views/install_v2.py b/engine/apps/grafana_plugin/views/install_v2.py index 4b35c772aa..7223adb038 100644 --- a/engine/apps/grafana_plugin/views/install_v2.py +++ b/engine/apps/grafana_plugin/views/install_v2.py @@ -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) diff --git a/engine/apps/grafana_plugin/views/sync_v2.py b/engine/apps/grafana_plugin/views/sync_v2.py index b762c7b79d..7df24cfe5c 100644 --- a/engine/apps/grafana_plugin/views/sync_v2.py +++ b/engine/apps/grafana_plugin/views/sync_v2.py @@ -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 @@ def __init__(self, error_data): 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 @@ def do_sync(self, request: Request) -> Organization: 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 @@ def post(self, request: Request) -> Response: try: self.do_sync(request) except SyncException as e: - return Response(data=e.error_data, status=status.HTTP_400_BAD_REQUEST) + return Response(data=asdict(e.error_data), status=status.HTTP_400_BAD_REQUEST) return Response(status=status.HTTP_200_OK) diff --git a/engine/apps/user_management/sync.py b/engine/apps/user_management/sync.py index b8ac758e9b..35616c153f 100644 --- a/engine/apps/user_management/sync.py +++ b/engine/apps/user_management/sync.py @@ -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): diff --git a/engine/conftest.py b/engine/conftest.py index df5fcb0153..9cb362ada6 100644 --- a/engine/conftest.py +++ b/engine/conftest.py @@ -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: diff --git a/grafana-plugin/Magefile.go b/grafana-plugin/Magefile.go new file mode 100644 index 0000000000..75240cf364 --- /dev/null +++ b/grafana-plugin/Magefile.go @@ -0,0 +1,12 @@ +//go:build mage +// +build mage + +package main + +import ( + // mage:import + build "github.com/grafana/grafana-plugin-sdk-go/build" +) + +// Default configures the default target. +var Default = build.BuildAll \ No newline at end of file diff --git a/grafana-plugin/e2e-tests/alerts/directPaging.test.ts b/grafana-plugin/e2e-tests/alerts/directPaging.test.ts index 7086cdd121..75f86e7db1 100644 --- a/grafana-plugin/e2e-tests/alerts/directPaging.test.ts +++ b/grafana-plugin/e2e-tests/alerts/directPaging.test.ts @@ -1,3 +1,5 @@ +import semver from 'semver'; + import { test, expect } from '../fixtures'; import { clickButton, fillInInput } from '../utils/forms'; import { goToOnCallPage } from '../utils/navigation'; @@ -20,8 +22,10 @@ test('we can directly page a user', async ({ adminRolePage }) => { const addRespondersPopup = page.getByTestId('add-responders-popup'); - await addRespondersPopup.getByText('Users').click(); - await addRespondersPopup.getByText(adminRolePage.userName).click(); + await addRespondersPopup[semver.lt(process.env.CURRENT_GRAFANA_VERSION, '10.3.0') ? 'getByText' : 'getByLabel']( + 'Users' + ).click(); + await addRespondersPopup.getByText(adminRolePage.userName).first().click(); // If user is not on call, confirm invitation await page.waitForTimeout(1000); diff --git a/grafana-plugin/e2e-tests/globalSetup.ts b/grafana-plugin/e2e-tests/globalSetup.ts index 0bba7dc618..e7cfa34ac6 100644 --- a/grafana-plugin/e2e-tests/globalSetup.ts +++ b/grafana-plugin/e2e-tests/globalSetup.ts @@ -1,14 +1,12 @@ import { test as setup, chromium, - expect, - type Page, type BrowserContext, type FullConfig, type APIRequestContext, + Page, } from '@playwright/test'; - -import { getOnCallApiUrl } from 'utils/consts'; +import semver from 'semver'; import { VIEWER_USER_STORAGE_STATE, EDITOR_USER_STORAGE_STATE, ADMIN_USER_STORAGE_STATE } from '../playwright.config'; @@ -22,16 +20,9 @@ import { GRAFANA_VIEWER_USERNAME, IS_CLOUD, IS_OPEN_SOURCE, + OrgRole, } from './utils/constants'; -import { clickButton, getInputByName } from './utils/forms'; -import { goToGrafanaPage } from './utils/navigation'; - -enum OrgRole { - None = 'None', - Viewer = 'Viewer', - Editor = 'Editor', - Admin = 'Admin', -} +import { goToOnCallPage } from './utils/navigation'; type UserCreationSettings = { adminAuthedRequest: APIRequestContext; @@ -64,45 +55,35 @@ const generateLoginStorageStateAndOptionallCreateUser = async ( return browserContext; }; -/** - go to config page and wait for plugin icon to be available on left-hand navigation - */ -const configureOnCallPlugin = async (page: Page): Promise => { - /** - * go to the oncall plugin configuration page and wait for the page to be loaded - */ - await goToGrafanaPage(page, '/plugins/grafana-oncall-app'); - await page.waitForTimeout(3000); - - // if plugin is configured, go to OnCall - const isConfigured = (await page.getByText('Connected to OnCall').count()) >= 1; - if (isConfigured) { - await page.getByRole('link', { name: 'Open Grafana OnCall' }).click(); - return; - } - - // otherwise we may need to reconfigure the plugin - const needToReconfigure = (await page.getByText('try removing your plugin configuration').count()) >= 1; - if (needToReconfigure) { - await clickButton({ page, buttonText: 'Remove current configuration' }); - await clickButton({ page, buttonText: /^Remove$/ }); - } - await page.waitForTimeout(2000); - - const needToEnterOnCallApiUrl = await page.getByText(/Connected to OnCall/).isHidden(); - if (needToEnterOnCallApiUrl) { - await getInputByName(page, 'onCallApiUrl').fill(getOnCallApiUrl() || 'http://oncall-dev-engine:8080'); - await clickButton({ page, buttonText: 'Connect' }); +const idempotentlyInitializePlugin = async (page: Page) => { + await goToOnCallPage(page, 'alert-groups'); + await page.waitForTimeout(1000); + const openPluginConfigurationButton = page.getByRole('button', { name: 'Open configuration' }); + if (await openPluginConfigurationButton.isVisible()) { + await openPluginConfigurationButton.click(); + // Before 10.3 Admin user needs to create service account manually + if (semver.lt(process.env.CURRENT_GRAFANA_VERSION, '10.3.0')) { + await page.getByTestId('recreate-service-account').click(); + } + await page.getByTestId('connect-plugin').click(); + await page.waitForLoadState('networkidle'); + await page.getByText('Plugin is connected').waitFor(); } +}; +const determineGrafanaVersion = async (adminAuthedRequest: APIRequestContext) => { /** - * wait for the "Connected to OnCall" message to know that everything is properly configured + * determine the current Grafana version of the stack in question and set it such that it can be used in the tests + * to conditionally skip certain tests. * - * Regarding increasing the timeout for the "plugin configured" assertion: - * This is because it can sometimes take a bit longer for the backend sync to finish. The default assertion - * timeout is 5s, which is sometimes not enough if the backend is under load + * According to the Playwright docs, the best way to set config like this on the fly, is to set values + * on process.env https://playwright.dev/docs/test-global-setup-teardown#example + * + * TODO: when this bug is fixed in playwright https://github.com/microsoft/playwright/issues/29608 + * move this to the currentGrafanaVersion fixture */ - await expect(page.getByTestId('status-message-block')).toHaveText(/Connected to OnCall.*/, { timeout: 25_000 }); + const currentGrafanaVersion = await grafanaApiClient.getGrafanaVersion(adminAuthedRequest); + process.env.CURRENT_GRAFANA_VERSION = currentGrafanaVersion; }; /** @@ -123,6 +104,10 @@ setup('Configure Grafana OnCall plugin', async ({ request }, { config }) => { const adminPage = await adminBrowserContext.newPage(); const { request: adminAuthedRequest } = adminBrowserContext; + await determineGrafanaVersion(adminAuthedRequest); + + await idempotentlyInitializePlugin(adminPage); + await generateLoginStorageStateAndOptionallCreateUser( config, GRAFANA_EDITOR_USERNAME, @@ -147,23 +132,5 @@ setup('Configure Grafana OnCall plugin', async ({ request }, { config }) => { true ); - if (IS_OPEN_SOURCE) { - // plugin configuration can safely be skipped for cloud environments - await configureOnCallPlugin(adminPage); - } - - /** - * determine the current Grafana version of the stack in question and set it such that it can be used in the tests - * to conditionally skip certain tests. - * - * According to the Playwright docs, the best way to set config like this on the fly, is to set values - * on process.env https://playwright.dev/docs/test-global-setup-teardown#example - * - * TODO: when this bug is fixed in playwright https://github.com/microsoft/playwright/issues/29608 - * move this to the currentGrafanaVersion fixture - */ - const currentGrafanaVersion = await grafanaApiClient.getGrafanaVersion(adminAuthedRequest); - process.env.CURRENT_GRAFANA_VERSION = currentGrafanaVersion; - await adminBrowserContext.close(); }); diff --git a/grafana-plugin/e2e-tests/integrations/integrationsTable.test.ts b/grafana-plugin/e2e-tests/integrations/integrationsTable.test.ts index 45b1f7b773..dfd4db7528 100644 --- a/grafana-plugin/e2e-tests/integrations/integrationsTable.test.ts +++ b/grafana-plugin/e2e-tests/integrations/integrationsTable.test.ts @@ -1,10 +1,12 @@ import { test } from '../fixtures'; import { generateRandomValue } from '../utils/forms'; import { createIntegration, searchIntegrationAndAssertItsPresence } from '../utils/integrations'; +import { goToOnCallPage } from '../utils/navigation'; test('Integrations table shows data in Monitoring Systems and Direct Paging tabs', async ({ adminRolePage: { page }, }) => { + test.slow(); const ID = generateRandomValue(); const WEBHOOK_INTEGRATION_NAME = `Webhook-${ID}`; const ALERTMANAGER_INTEGRATION_NAME = `Alertmanager-${ID}`; @@ -13,14 +15,14 @@ test('Integrations table shows data in Monitoring Systems and Direct Paging tabs // Create 2 integrations that are not Direct Paging await createIntegration({ page, integrationSearchText: 'Webhook', integrationName: WEBHOOK_INTEGRATION_NAME }); await page.waitForTimeout(1000); - await page.getByRole('tab', { name: 'Tab Integrations' }).click(); + await goToOnCallPage(page, 'integrations'); await createIntegration({ page, integrationSearchText: 'Alertmanager', integrationName: ALERTMANAGER_INTEGRATION_NAME, }); await page.waitForTimeout(1000); - await page.getByRole('tab', { name: 'Tab Integrations' }).click(); + await goToOnCallPage(page, 'integrations'); // Create 1 Direct Paging integration if it doesn't exist await page.getByRole('tab', { name: 'Tab Manual Direct Paging' }).click(); @@ -35,7 +37,7 @@ test('Integrations table shows data in Monitoring Systems and Direct Paging tabs }); await page.waitForTimeout(1000); } - await page.getByRole('tab', { name: 'Tab Integrations' }).click(); + await goToOnCallPage(page, 'integrations'); // By default Monitoring Systems tab is opened and newly created integrations are visible except Direct Paging one await searchIntegrationAndAssertItsPresence({ page, integrationName: WEBHOOK_INTEGRATION_NAME }); diff --git a/grafana-plugin/e2e-tests/pluginInitialization/configuration.test.ts b/grafana-plugin/e2e-tests/pluginInitialization/configuration.test.ts new file mode 100644 index 0000000000..8be9d75bfd --- /dev/null +++ b/grafana-plugin/e2e-tests/pluginInitialization/configuration.test.ts @@ -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(); + }); +}); diff --git a/grafana-plugin/e2e-tests/pluginInitialization/initialization.test.ts b/grafana-plugin/e2e-tests/pluginInitialization/initialization.test.ts new file mode 100644 index 0000000000..005d03d2de --- /dev/null +++ b/grafana-plugin/e2e-tests/pluginInitialization/initialization.test.ts @@ -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'); + }); +}); diff --git a/grafana-plugin/e2e-tests/users/usersActions.test.ts b/grafana-plugin/e2e-tests/users/usersActions.test.ts index f106abd950..91cf9f09b3 100644 --- a/grafana-plugin/e2e-tests/users/usersActions.test.ts +++ b/grafana-plugin/e2e-tests/users/usersActions.test.ts @@ -1,29 +1,40 @@ +import semver from 'semver'; + import { test, expect } from '../fixtures'; import { goToOnCallPage } from '../utils/navigation'; -import { viewUsers, accessProfileTabs } from '../utils/users'; +import { verifyThatUserCanViewOtherUsers, accessProfileTabs } from '../utils/users'; test.describe('Users screen actions', () => { test("Admin is allowed to edit other users' profile", async ({ adminRolePage: { page } }) => { await goToOnCallPage(page, 'users'); - await expect(page.getByTestId('users-table').getByRole('button', { name: 'Edit', disabled: false })).toHaveCount(3); + const editableUsers = page.getByTestId('users-table').getByRole('button', { name: 'Edit', disabled: false }); + await editableUsers.first().waitFor(); + const editableUsersCount = await editableUsers.count(); + expect(editableUsersCount).toBeGreaterThan(1); }); test('Admin is allowed to view the list of users', async ({ adminRolePage: { page } }) => { - await viewUsers(page); + await verifyThatUserCanViewOtherUsers(page); }); test('Viewer is not allowed to view the list of users', async ({ viewerRolePage: { page } }) => { - await viewUsers(page, false); + await verifyThatUserCanViewOtherUsers(page, false); }); test('Viewer cannot access restricted tabs from View My Profile', async ({ viewerRolePage }) => { const { page } = viewerRolePage; + const tabsToCheck = ['tab-phone-verification', 'tab-slack', 'tab-telegram']; + + // After 10.3 it's been moved to global user profile + if (semver.lt(process.env.CURRENT_GRAFANA_VERSION, '10.3.0')) { + tabsToCheck.unshift('tab-mobile-app'); + } - await accessProfileTabs(page, ['tab-mobile-app', 'tab-phone-verification', 'tab-slack', 'tab-telegram'], false); + await accessProfileTabs(page, tabsToCheck, false); }); test('Editor is allowed to view the list of users', async ({ editorRolePage }) => { - await viewUsers(editorRolePage.page); + await verifyThatUserCanViewOtherUsers(editorRolePage.page); }); test("Editor cannot view other users' data", async ({ editorRolePage }) => { @@ -33,8 +44,10 @@ test.describe('Users screen actions', () => { await page.getByTestId('users-email').and(page.getByText('editor')).waitFor(); await expect(page.getByTestId('users-email').and(page.getByText('editor'))).toHaveCount(1); - await expect(page.getByTestId('users-email').and(page.getByText('******'))).toHaveCount(2); - await expect(page.getByTestId('users-phone-number').and(page.getByText('******'))).toHaveCount(2); + const maskedEmailsCount = await page.getByTestId('users-email').and(page.getByText('******')).count(); + expect(maskedEmailsCount).toBeGreaterThan(1); + const maskedPhoneNumbersCount = await page.getByTestId('users-phone-number').and(page.getByText('******')).count(); + expect(maskedPhoneNumbersCount).toBeGreaterThan(1); }); test('Editor can access tabs from View My Profile', async ({ editorRolePage }) => { @@ -47,7 +60,11 @@ test.describe('Users screen actions', () => { test("Editor is not allowed to edit other users' profile", async ({ editorRolePage: { page } }) => { await goToOnCallPage(page, 'users'); await expect(page.getByTestId('users-table').getByRole('button', { name: 'Edit', disabled: false })).toHaveCount(1); - await expect(page.getByTestId('users-table').getByRole('button', { name: 'Edit', disabled: true })).toHaveCount(2); + const usersCountWithDisabledEdit = await page + .getByTestId('users-table') + .getByRole('button', { name: 'Edit', disabled: true }) + .count(); + expect(usersCountWithDisabledEdit).toBeGreaterThan(1); }); test('Search updates the table view', async ({ adminRolePage }) => { diff --git a/grafana-plugin/e2e-tests/utils/constants.ts b/grafana-plugin/e2e-tests/utils/constants.ts index 5345d1c4d7..9cbee844b9 100644 --- a/grafana-plugin/e2e-tests/utils/constants.ts +++ b/grafana-plugin/e2e-tests/utils/constants.ts @@ -11,4 +11,11 @@ export const GRAFANA_ADMIN_PASSWORD = process.env.GRAFANA_ADMIN_PASSWORD || 'onc export const IS_OPEN_SOURCE = (process.env.IS_OPEN_SOURCE || 'true').toLowerCase() === 'true'; export const IS_CLOUD = !IS_OPEN_SOURCE; +export enum OrgRole { + None = 'None', + Viewer = 'Viewer', + Editor = 'Editor', + Admin = 'Admin', +} + export const MOSCOW_TIMEZONE = 'Europe/Moscow'; diff --git a/grafana-plugin/e2e-tests/utils/forms.ts b/grafana-plugin/e2e-tests/utils/forms.ts index cc53d62b65..6ddefa2d01 100644 --- a/grafana-plugin/e2e-tests/utils/forms.ts +++ b/grafana-plugin/e2e-tests/utils/forms.ts @@ -25,6 +25,7 @@ type ClickButtonArgs = { buttonText: string | RegExp; // if provided, use this Locator as the root of our search for the button startingLocator?: Locator; + exact?: boolean; }; export const fillInInput = (page: Page, selector: string, value: string) => page.fill(selector, value); @@ -34,9 +35,9 @@ export const fillInInputByPlaceholderValue = (page: Page, placeholderValue: stri export const getInputByName = (page: Page, name: string): Locator => page.locator(`input[name="${name}"]`); -export const clickButton = async ({ page, buttonText, startingLocator }: ClickButtonArgs): Promise => { +export const clickButton = async ({ page, buttonText, startingLocator, exact }: ClickButtonArgs): Promise => { const baseLocator = startingLocator || page; - await baseLocator.getByRole('button', { name: buttonText, disabled: false }).click(); + await baseLocator.getByRole('button', { name: buttonText, disabled: false, exact }).click(); }; /** diff --git a/grafana-plugin/e2e-tests/utils/users.ts b/grafana-plugin/e2e-tests/utils/users.ts index e7c5d15dff..03972f8f5c 100644 --- a/grafana-plugin/e2e-tests/utils/users.ts +++ b/grafana-plugin/e2e-tests/utils/users.ts @@ -1,6 +1,8 @@ import { Page, expect } from '@playwright/test'; -import { goToOnCallPage } from './navigation'; +import { OrgRole } from './constants'; +import { clickButton } from './forms'; +import { goToGrafanaPage, goToOnCallPage } from './navigation'; export async function accessProfileTabs(page: Page, tabs: string[], hasAccess: boolean) { await goToOnCallPage(page, 'users'); @@ -30,16 +32,55 @@ export async function accessProfileTabs(page: Page, tabs: string[], hasAccess: b } } -export async function viewUsers(page: Page, isAllowedToView = true): Promise { +export async function verifyThatUserCanViewOtherUsers(page: Page, isAllowedToView = true): Promise { await goToOnCallPage(page, 'users'); if (isAllowedToView) { const usersTable = page.getByTestId('users-table'); await usersTable.getByRole('row').nth(1).waitFor(); - await expect(usersTable.getByRole('row')).toHaveCount(4); + const usersCount = await page.getByTestId('users-table').getByRole('row').count(); + expect(usersCount).toBeGreaterThan(1); } else { await expect(page.getByTestId('view-users-missing-permission-message')).toHaveText( /You are missing the .* to be able to view OnCall users/ ); } } + +export const createGrafanaUser = async ({ + page, + username, + role = OrgRole.Viewer, +}: { + page: Page; + username: string; + role?: OrgRole; +}): Promise => { + await goToGrafanaPage(page, '/admin/users'); + await page.getByRole('link', { name: 'New user' }).click(); + await page.getByLabel('Name *').fill(username); + await page.getByLabel('Username').fill(username); + await page.getByLabel('Password *').fill(username); + await clickButton({ page, buttonText: 'Create user' }); + + if (role !== OrgRole.Viewer) { + await clickButton({ page, buttonText: 'Change role' }); + await page + .locator('div') + .filter({ hasText: /^Viewer$/ }) + .nth(1) + .click(); + await page.getByText(new RegExp(role)).click(); + await clickButton({ page, buttonText: 'Save' }); + } +}; + +export const loginAndWaitTillGrafanaIsLoaded = async ({ page, username }: { page: Page; username: string }) => { + await goToGrafanaPage(page, '/login'); + await page.getByPlaceholder(/Email or username/i).fill(username); + await page.getByPlaceholder(/Password/i).fill(username); + await page.locator('button[type="submit"]').click(); + + await page.getByText('Welcome to Grafana').waitFor(); + await page.waitForLoadState('networkidle'); +}; diff --git a/grafana-plugin/go.mod b/grafana-plugin/go.mod new file mode 100644 index 0000000000..a905ffd3eb --- /dev/null +++ b/grafana-plugin/go.mod @@ -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 +) diff --git a/grafana-plugin/go.sum b/grafana-plugin/go.sum new file mode 100644 index 0000000000..760d8efeba --- /dev/null +++ b/grafana-plugin/go.sum @@ -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= diff --git a/grafana-plugin/pkg/main.go b/grafana-plugin/pkg/main.go new file mode 100644 index 0000000000..2d956202d4 --- /dev/null +++ b/grafana-plugin/pkg/main.go @@ -0,0 +1,23 @@ +package main + +import ( + "os" + + "github.com/grafana-labs/grafana-oncall-app/pkg/plugin" + "github.com/grafana/grafana-plugin-sdk-go/backend/app" + "github.com/grafana/grafana-plugin-sdk-go/backend/log" +) + +func main() { + // Start listening to requests sent from Grafana. This call is blocking so + // it won't finish until Grafana shuts down the process or the plugin choose + // to exit by itself using os.Exit. Manage automatically manages life cycle + // of app instances. It accepts app instance factory as first + // argument. This factory will be automatically called on incoming request + // from Grafana to create different instances of `App` (per plugin + // ID). + if err := app.Manage("grafana-oncall-app", plugin.NewApp, app.ManageOpts{}); err != nil { + log.DefaultLogger.Error(err.Error()) + os.Exit(1) + } +} diff --git a/grafana-plugin/pkg/plugin/app.go b/grafana-plugin/pkg/plugin/app.go new file mode 100644 index 0000000000..cd51cd4a10 --- /dev/null +++ b/grafana-plugin/pkg/plugin/app.go @@ -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 +} diff --git a/grafana-plugin/pkg/plugin/debug.go b/grafana-plugin/pkg/plugin/debug.go new file mode 100644 index 0000000000..eb10cd5b3e --- /dev/null +++ b/grafana-plugin/pkg/plugin/debug.go @@ -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) +} diff --git a/grafana-plugin/pkg/plugin/errors.go b/grafana-plugin/pkg/plugin/errors.go new file mode 100644 index 0000000000..72d9d74fe6 --- /dev/null +++ b/grafana-plugin/pkg/plugin/errors.go @@ -0,0 +1,11 @@ +package plugin + +const ( + INSTALL_ERROR_CODE = 1000 +) + +type OnCallError struct { + Code int `json:"code"` + Message string `json:"message"` + Fields map[string][]string `json:"fields,omitempty"` +} diff --git a/grafana-plugin/pkg/plugin/install.go b/grafana-plugin/pkg/plugin/install.go new file mode 100644 index 0000000000..da6f94b2cb --- /dev/null +++ b/grafana-plugin/pkg/plugin/install.go @@ -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) + } + +} diff --git a/grafana-plugin/pkg/plugin/permissions.go b/grafana-plugin/pkg/plugin/permissions.go new file mode 100644 index 0000000000..2b5813af2e --- /dev/null +++ b/grafana-plugin/pkg/plugin/permissions.go @@ -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) +} diff --git a/grafana-plugin/pkg/plugin/proxy.go b/grafana-plugin/pkg/plugin/proxy.go new file mode 100644 index 0000000000..a80fcc9058 --- /dev/null +++ b/grafana-plugin/pkg/plugin/proxy.go @@ -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) +} diff --git a/grafana-plugin/pkg/plugin/resources.go b/grafana-plugin/pkg/plugin/resources.go new file mode 100644 index 0000000000..3fa8a1a0b0 --- /dev/null +++ b/grafana-plugin/pkg/plugin/resources.go @@ -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) +} diff --git a/grafana-plugin/pkg/plugin/resources_test.go b/grafana-plugin/pkg/plugin/resources_test.go new file mode 100644 index 0000000000..7506326a7e --- /dev/null +++ b/grafana-plugin/pkg/plugin/resources_test.go @@ -0,0 +1,73 @@ +package plugin + +import ( + "bytes" + "context" + "github.com/grafana/grafana-plugin-sdk-go/backend" + "testing" +) + +// mockCallResourceResponseSender implements backend.CallResourceResponseSender +// for use in tests. +type mockCallResourceResponseSender struct { + response *backend.CallResourceResponse +} + +// Send sets the received *backend.CallResourceResponse to s.response +func (s *mockCallResourceResponseSender) Send(response *backend.CallResourceResponse) error { + s.response = response + return nil +} + +// TestCallResource tests CallResource calls, using backend.CallResourceRequest and backend.CallResourceResponse. +// This ensures the httpadapter for CallResource works correctly. +func TestCallResource(t *testing.T) { + // Initialize app + inst, err := NewApp(context.Background(), backend.AppInstanceSettings{}) + if err != nil { + t.Fatalf("new app: %s", err) + } + if inst == nil { + t.Fatal("inst must not be nil") + } + app, ok := inst.(*App) + if !ok { + t.Fatal("inst must be of type *App") + } + + // Set up and run test cases + for _, tc := range []struct { + name string + + method string + path string + body []byte + + expStatus int + expBody []byte + }{} { + t.Run(tc.name, func(t *testing.T) { + // Request by calling CallResource. This tests the httpadapter. + var r mockCallResourceResponseSender + err = app.CallResource(context.Background(), &backend.CallResourceRequest{ + Method: tc.method, + Path: tc.path, + Body: tc.body, + }, &r) + if err != nil { + t.Fatalf("CallResource error: %s", err) + } + if r.response == nil { + t.Fatal("no response received from CallResource") + } + if tc.expStatus > 0 && tc.expStatus != r.response.Status { + t.Errorf("response status should be %d, got %d", tc.expStatus, r.response.Status) + } + if len(tc.expBody) > 0 { + if tb := bytes.TrimSpace(r.response.Body); !bytes.Equal(tb, tc.expBody) { + t.Errorf("response body should be %s, got %s", tc.expBody, tb) + } + } + }) + } +} diff --git a/grafana-plugin/pkg/plugin/settings.go b/grafana-plugin/pkg/plugin/settings.go new file mode 100644 index 0000000000..54af3b12f6 --- /dev/null +++ b/grafana-plugin/pkg/plugin/settings.go @@ -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 +} diff --git a/grafana-plugin/pkg/plugin/status.go b/grafana-plugin/pkg/plugin/status.go new file mode 100644 index 0000000000..9192887e95 --- /dev/null +++ b/grafana-plugin/pkg/plugin/status.go @@ -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) + +} diff --git a/grafana-plugin/pkg/plugin/sync.go b/grafana-plugin/pkg/plugin/sync.go new file mode 100644 index 0000000000..457963237d --- /dev/null +++ b/grafana-plugin/pkg/plugin/sync.go @@ -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 +} diff --git a/grafana-plugin/pkg/plugin/teams.go b/grafana-plugin/pkg/plugin/teams.go new file mode 100644 index 0000000000..e9226ba329 --- /dev/null +++ b/grafana-plugin/pkg/plugin/teams.go @@ -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 +} diff --git a/grafana-plugin/pkg/plugin/users.go b/grafana-plugin/pkg/plugin/users.go new file mode 100644 index 0000000000..e4bec0f2f5 --- /dev/null +++ b/grafana-plugin/pkg/plugin/users.go @@ -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 +} diff --git a/grafana-plugin/src/components/IntegrationCollapsibleTreeView/IntegrationCollapsibleTreeView.styles.ts b/grafana-plugin/src/components/CollapsibleTreeView/CollapsibleTreeView.styles.ts similarity index 96% rename from grafana-plugin/src/components/IntegrationCollapsibleTreeView/IntegrationCollapsibleTreeView.styles.ts rename to grafana-plugin/src/components/CollapsibleTreeView/CollapsibleTreeView.styles.ts index d1986cd750..085f566700 100644 --- a/grafana-plugin/src/components/IntegrationCollapsibleTreeView/IntegrationCollapsibleTreeView.styles.ts +++ b/grafana-plugin/src/components/CollapsibleTreeView/CollapsibleTreeView.styles.ts @@ -2,7 +2,7 @@ import { css } from '@emotion/css'; import { GrafanaTheme2 } from '@grafana/data'; import { Colors } from 'styles/utils.styles'; -export const getIntegrationCollapsibleTreeStyles = (theme: GrafanaTheme2) => { +export const getCollapsibleTreeStyles = (theme: GrafanaTheme2) => { return { container: css` margin-left: 32px; diff --git a/grafana-plugin/src/components/IntegrationCollapsibleTreeView/IntegrationCollapsibleTreeView.tsx b/grafana-plugin/src/components/CollapsibleTreeView/CollapsibleTreeView.tsx similarity index 81% rename from grafana-plugin/src/components/IntegrationCollapsibleTreeView/IntegrationCollapsibleTreeView.tsx rename to grafana-plugin/src/components/CollapsibleTreeView/CollapsibleTreeView.tsx index 1d50da088c..2ee48c9690 100644 --- a/grafana-plugin/src/components/IntegrationCollapsibleTreeView/IntegrationCollapsibleTreeView.tsx +++ b/grafana-plugin/src/components/CollapsibleTreeView/CollapsibleTreeView.tsx @@ -8,9 +8,9 @@ import { bem } from 'styles/utils.styles'; import { Text } from 'components/Text/Text'; -import { getIntegrationCollapsibleTreeStyles } from './IntegrationCollapsibleTreeView.styles'; +import { getCollapsibleTreeStyles } from './CollapsibleTreeView.styles'; -export interface IntegrationCollapsibleItem { +export interface CollapsibleItem { isHidden?: boolean; customIcon?: IconName; canHoverIcon?: boolean; @@ -23,16 +23,17 @@ export interface IntegrationCollapsibleItem { onStateChange?(isChecked: boolean): void; } -interface IntegrationCollapsibleTreeViewProps { +interface CollapsibleTreeViewProps { startingElemPosition?: string; isRouteView?: boolean; - configElements: Array; + configElements: Array; + className?: string; } -export const IntegrationCollapsibleTreeView: React.FC = observer((props) => { - const { configElements, isRouteView } = props; +export const CollapsibleTreeView: React.FC = observer((props) => { + const { configElements, isRouteView, className } = props; - const styles = useStyles2(getIntegrationCollapsibleTreeStyles); + const styles = useStyles2(getCollapsibleTreeStyles); const [expandedList, setExpandedList] = useState(getStartingExpandedState()); useEffect(() => { @@ -40,13 +41,13 @@ export const IntegrationCollapsibleTreeView: React.FC +
{configElements .filter((config) => config) // filter out falsy values - .map((item: IntegrationCollapsibleItem | IntegrationCollapsibleItem[], idx) => { + .map((item: CollapsibleItem | CollapsibleItem[], idx) => { if (isArray(item)) { return item.map((it, innerIdx) => ( - expandOrCollapseAtPos(!expandedList[idx][innerIdx], idx, innerIdx)} @@ -56,7 +57,7 @@ export const IntegrationCollapsibleTreeView: React.FC void; }> = ({ item, elementPosition, isExpanded, onClick }) => { - const styles = useStyles2(getIntegrationCollapsibleTreeStyles); + const styles = useStyles2(getCollapsibleTreeStyles); const handleIconClick = !item.isCollapsible ? undefined : onClick; return ( diff --git a/grafana-plugin/src/components/FullPageError/FullPageError.tsx b/grafana-plugin/src/components/FullPageError/FullPageError.tsx new file mode 100644 index 0000000000..a77aee5a4f --- /dev/null +++ b/grafana-plugin/src/components/FullPageError/FullPageError.tsx @@ -0,0 +1,40 @@ +import React, { FC } from 'react'; + +import { css } from '@emotion/css'; +import { useStyles2, VerticalGroup } from '@grafana/ui'; + +import errorSVG from 'assets/img/error.svg'; +import { Text } from 'components/Text/Text'; + +interface FullPageErrorProps { + children?: React.ReactNode; + title?: string; + subtitle?: React.ReactNode; +} + +export const FullPageError: FC = ({ + title = 'An unexpected error happened', + subtitle, + children, +}) => { + const styles = useStyles2(getStyles); + + return ( +
+ + + {title} + {subtitle && {subtitle}} + {children} + +
+ ); +}; + +const getStyles = () => ({ + wrapper: css` + margin: 24px auto; + width: 600px; + text-align: center; + `, +}); diff --git a/grafana-plugin/src/components/Text/Text.styles.ts b/grafana-plugin/src/components/Text/Text.styles.ts index 220c4303be..41a61b6314 100644 --- a/grafana-plugin/src/components/Text/Text.styles.ts +++ b/grafana-plugin/src/components/Text/Text.styles.ts @@ -5,8 +5,6 @@ import { Colors } from 'styles/utils.styles'; export const getTextStyles = (theme: GrafanaTheme2) => { return { root: css` - display: inline; - &:hover [data-emotion='iconButton'] { display: inline-flex; } @@ -66,6 +64,18 @@ export const getTextStyles = (theme: GrafanaTheme2) => { } `, + display: css` + &--inline { + display: inline; + } + &--block { + display: block; + } + &--inline-block { + display: inline-block; + } + `, + noWrap: css` white-space: nowrap; `, diff --git a/grafana-plugin/src/components/Text/Text.tsx b/grafana-plugin/src/components/Text/Text.tsx index b3bd65a48d..de2857d17e 100644 --- a/grafana-plugin/src/components/Text/Text.tsx +++ b/grafana-plugin/src/components/Text/Text.tsx @@ -16,6 +16,7 @@ interface TextProps extends HTMLAttributes { strong?: boolean; underline?: boolean; size?: 'xs' | 'small' | 'medium' | 'large'; + display?: 'inline' | 'block' | 'inline-block'; className?: string; wrap?: boolean; copyable?: boolean; @@ -40,6 +41,7 @@ export const Text: TextInterface = (props) => { const { type, size = 'medium', + display = 'inline', strong = false, underline = false, children, @@ -93,8 +95,9 @@ export const Text: TextInterface = (props) => { styles.root, styles.text, { [styles.maxWidth]: Boolean(maxWidth) }, - { [bem(styles.text, type)]: true }, - { [bem(styles.text, size)]: true }, + bem(styles.text, type), + bem(styles.text, size), + bem(styles.display, display), { [bem(styles.text, `strong`)]: strong }, { [bem(styles.text, `underline`)]: underline }, { [bem(styles.text, 'clickable')]: clickable }, diff --git a/grafana-plugin/src/components/Unauthorized/__snapshots__/Unauthorized.test.tsx.snap b/grafana-plugin/src/components/Unauthorized/__snapshots__/Unauthorized.test.tsx.snap index 59b30dfbb0..42ff8aa004 100644 --- a/grafana-plugin/src/components/Unauthorized/__snapshots__/Unauthorized.test.tsx.snap +++ b/grafana-plugin/src/components/Unauthorized/__snapshots__/Unauthorized.test.tsx.snap @@ -20,7 +20,7 @@ exports[`Unauthorized renders properly - access control enabled: false 1`] = ` className="css-1fmhfo9" > Participants @@ -70,7 +70,7 @@ exports[`AddResponders should properly display the add responders button when hi class="css-u023fv" > Participants @@ -104,7 +104,7 @@ exports[`AddResponders should render properly in create mode 1`] = ` class="css-u023fv" > Participants @@ -155,7 +155,7 @@ exports[`AddResponders should render properly in update mode 1`] = ` class="css-u023fv" > Participants @@ -206,7 +206,7 @@ exports[`AddResponders should render selected team and users properly 1`] = ` class="css-u023fv" > Participants @@ -262,7 +262,7 @@ exports[`AddResponders should render selected team and users properly 1`] = ` class="css-18qv8yz-layoutChildrenWrapper" > my test team @@ -311,7 +311,7 @@ exports[`AddResponders should render selected team and users properly 1`] = ` class="css-18qv8yz-layoutChildrenWrapper" > my test user3 @@ -420,7 +420,7 @@ exports[`AddResponders should render selected team and users properly 1`] = ` class="css-18qv8yz-layoutChildrenWrapper" > my test user @@ -528,7 +528,7 @@ exports[`AddResponders should render selected team and users properly 1`] = ` class="css-18qv8yz-layoutChildrenWrapper" > my test user2 @@ -630,7 +630,7 @@ exports[`AddResponders should render selected team and users properly 1`] = ` class="css-b9x8ok" >
my test team diff --git a/grafana-plugin/src/containers/AddResponders/parts/UserResponder/__snapshots__/UserResponder.test.tsx.snap b/grafana-plugin/src/containers/AddResponders/parts/UserResponder/__snapshots__/UserResponder.test.tsx.snap index 2878310a4c..bc9fdd7334 100644 --- a/grafana-plugin/src/containers/AddResponders/parts/UserResponder/__snapshots__/UserResponder.test.tsx.snap +++ b/grafana-plugin/src/containers/AddResponders/parts/UserResponder/__snapshots__/UserResponder.test.tsx.snap @@ -31,7 +31,7 @@ exports[`UserResponder it renders data properly 1`] = ` class="css-18qv8yz-layoutChildrenWrapper" > johnsmith diff --git a/grafana-plugin/src/containers/IntegrationContainers/ExpandedIntegrationRouteDisplay/ExpandedIntegrationRouteDisplay.tsx b/grafana-plugin/src/containers/IntegrationContainers/ExpandedIntegrationRouteDisplay/ExpandedIntegrationRouteDisplay.tsx index a239eacd7d..504b00a76d 100644 --- a/grafana-plugin/src/containers/IntegrationContainers/ExpandedIntegrationRouteDisplay/ExpandedIntegrationRouteDisplay.tsx +++ b/grafana-plugin/src/containers/IntegrationContainers/ExpandedIntegrationRouteDisplay/ExpandedIntegrationRouteDisplay.tsx @@ -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 { - const configs: IntegrationCollapsibleItem[] = [ + const configs: CollapsibleItem[] = [ { isHidden: false, isCollapsible: false, @@ -390,11 +387,7 @@ export const ExpandedIntegrationRouteDisplay: React.FC } content={ - + } /> {routeIdForDeletion && ( diff --git a/grafana-plugin/src/containers/MobileAppConnection/MobileAppConnection.tsx b/grafana-plugin/src/containers/MobileAppConnection/MobileAppConnection.tsx index 701c96b1ce..27b9c704a5 100644 --- a/grafana-plugin/src/containers/MobileAppConnection/MobileAppConnection.tsx +++ b/grafana-plugin/src/containers/MobileAppConnection/MobileAppConnection.tsx @@ -7,13 +7,16 @@ import { observer } from 'mobx-react'; import qrCodeImage from 'assets/img/qr-code.png'; import { Block } from 'components/GBlock/Block'; import { PluginLink } from 'components/PluginLink/PluginLink'; +import { RenderConditionally } from 'components/RenderConditionally/RenderConditionally'; import { Text } from 'components/Text/Text'; +import { PluginInitializer } from 'containers/PluginInitializer/PluginInitializer'; import { WithPermissionControlDisplay } from 'containers/WithPermissionControl/WithPermissionControlDisplay'; import { UserHelper } from 'models/user/user.helpers'; import { ApiSchemas } from 'network/oncall-api/api.types'; import { AppFeature } from 'state/features'; import { RootStore, rootStore as store } from 'state/rootStore'; import { UserActions } from 'utils/authorization/authorization'; +import { useInitializePlugin } from 'utils/hooks'; import { isMobile, openErrorNotification, openNotification, openWarningNotification } from 'utils/utils'; import styles from './MobileAppConnection.module.scss'; @@ -364,10 +367,13 @@ function QRLoading() { export const MobileAppConnectionWrapper: React.FC<{}> = observer(() => { const { userStore } = store; + const { isConnected } = useInitializePlugin(); useEffect(() => { - loadData(); - }, []); + if (isConnected) { + loadData(); + } + }, [isConnected]); const loadData = async () => { if (!store.isBasicDataLoaded) { @@ -379,9 +385,17 @@ export const MobileAppConnectionWrapper: React.FC<{}> = observer(() => { } }; - if (store.isBasicDataLoaded && userStore.currentUserPk) { - return ; - } - - return ; + return ( + + ( +
+ +
+ )} + backupChildren={} + /> +
+ ); }); diff --git a/grafana-plugin/src/containers/MobileAppConnection/__snapshots__/MobileAppConnection.test.tsx.snap b/grafana-plugin/src/containers/MobileAppConnection/__snapshots__/MobileAppConnection.test.tsx.snap index 0951e49630..1e21c7f738 100644 --- a/grafana-plugin/src/containers/MobileAppConnection/__snapshots__/MobileAppConnection.test.tsx.snap +++ b/grafana-plugin/src/containers/MobileAppConnection/__snapshots__/MobileAppConnection.test.tsx.snap @@ -40,7 +40,7 @@ exports[`MobileAppConnection it shows a QR code if the app isn't already connect class="css-12oo3x0-layoutChildrenWrapper" > Download @@ -49,7 +49,7 @@ exports[`MobileAppConnection it shows a QR code if the app isn't already connect class="css-12oo3x0-layoutChildrenWrapper" > The Grafana OnCall app is available on both the App Store and Google Play Store. @@ -79,7 +79,7 @@ exports[`MobileAppConnection it shows a QR code if the app isn't already connect src="[object Object]" /> iOS @@ -104,7 +104,7 @@ exports[`MobileAppConnection it shows a QR code if the app isn't already connect src="[object Object]" /> Android @@ -161,7 +161,7 @@ exports[`MobileAppConnection it shows a loading message if it is currently disco class="css-12oo3x0-layoutChildrenWrapper" > Download @@ -170,7 +170,7 @@ exports[`MobileAppConnection it shows a loading message if it is currently disco class="css-12oo3x0-layoutChildrenWrapper" > The Grafana OnCall app is available on both the App Store and Google Play Store. @@ -200,7 +200,7 @@ exports[`MobileAppConnection it shows a loading message if it is currently disco src="[object Object]" /> iOS @@ -225,7 +225,7 @@ exports[`MobileAppConnection it shows a loading message if it is currently disco src="[object Object]" /> Android @@ -282,7 +282,7 @@ exports[`MobileAppConnection it shows a loading message if it is currently fetch class="css-12oo3x0-layoutChildrenWrapper" > Download @@ -291,7 +291,7 @@ exports[`MobileAppConnection it shows a loading message if it is currently fetch class="css-12oo3x0-layoutChildrenWrapper" > The Grafana OnCall app is available on both the App Store and Google Play Store. @@ -321,7 +321,7 @@ exports[`MobileAppConnection it shows a loading message if it is currently fetch src="[object Object]" /> iOS @@ -346,7 +346,7 @@ exports[`MobileAppConnection it shows a loading message if it is currently fetch src="[object Object]" /> Android @@ -373,7 +373,7 @@ exports[`MobileAppConnection it shows a warning when cloud is not connected 1`] class="css-12oo3x0-layoutChildrenWrapper" > Please connect Grafana Cloud OnCall to use the mobile app @@ -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" > There was an error disconnecting your mobile app. Please try again. @@ -437,7 +437,7 @@ exports[`MobileAppConnection it shows an error message if there was an error dis class="css-12oo3x0-layoutChildrenWrapper" > Download @@ -446,7 +446,7 @@ exports[`MobileAppConnection it shows an error message if there was an error dis class="css-12oo3x0-layoutChildrenWrapper" > The Grafana OnCall app is available on both the App Store and Google Play Store. @@ -476,7 +476,7 @@ exports[`MobileAppConnection it shows an error message if there was an error dis src="[object Object]" /> iOS @@ -501,7 +501,7 @@ exports[`MobileAppConnection it shows an error message if there was an error dis src="[object Object]" /> Android @@ -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" > There was an error fetching your QR code. Please try again. @@ -553,7 +553,7 @@ exports[`MobileAppConnection it shows an error message if there was an error fet class="css-12oo3x0-layoutChildrenWrapper" > Download @@ -562,7 +562,7 @@ exports[`MobileAppConnection it shows an error message if there was an error fet class="css-12oo3x0-layoutChildrenWrapper" > The Grafana OnCall app is available on both the App Store and Google Play Store. @@ -592,7 +592,7 @@ exports[`MobileAppConnection it shows an error message if there was an error fet src="[object Object]" /> iOS @@ -617,7 +617,7 @@ exports[`MobileAppConnection it shows an error message if there was an error fet src="[object Object]" /> Android diff --git a/grafana-plugin/src/containers/MobileAppConnection/parts/DownloadIcons/__snapshots__/DownloadIcons.test.tsx.snap b/grafana-plugin/src/containers/MobileAppConnection/parts/DownloadIcons/__snapshots__/DownloadIcons.test.tsx.snap index 2cef0d0768..dc19303527 100644 --- a/grafana-plugin/src/containers/MobileAppConnection/parts/DownloadIcons/__snapshots__/DownloadIcons.test.tsx.snap +++ b/grafana-plugin/src/containers/MobileAppConnection/parts/DownloadIcons/__snapshots__/DownloadIcons.test.tsx.snap @@ -10,7 +10,7 @@ exports[`DownloadIcons it renders properly 1`] = ` class="css-12oo3x0-layoutChildrenWrapper" > Download @@ -19,7 +19,7 @@ exports[`DownloadIcons it renders properly 1`] = ` class="css-12oo3x0-layoutChildrenWrapper" > The Grafana OnCall app is available on both the App Store and Google Play Store. @@ -49,7 +49,7 @@ exports[`DownloadIcons it renders properly 1`] = ` src="[object Object]" /> iOS @@ -74,7 +74,7 @@ exports[`DownloadIcons it renders properly 1`] = ` src="[object Object]" /> Android diff --git a/grafana-plugin/src/containers/MobileAppConnection/parts/LinkLoginButton/__snapshots__/LinkLoginButton.test.tsx.snap b/grafana-plugin/src/containers/MobileAppConnection/parts/LinkLoginButton/__snapshots__/LinkLoginButton.test.tsx.snap index f1aef1bc9e..da0987239f 100644 --- a/grafana-plugin/src/containers/MobileAppConnection/parts/LinkLoginButton/__snapshots__/LinkLoginButton.test.tsx.snap +++ b/grafana-plugin/src/containers/MobileAppConnection/parts/LinkLoginButton/__snapshots__/LinkLoginButton.test.tsx.snap @@ -10,7 +10,7 @@ exports[`LinkLoginButton it renders properly 1`] = ` class="css-12oo3x0-layoutChildrenWrapper" > Sign in via deeplink @@ -19,7 +19,7 @@ exports[`LinkLoginButton it renders properly 1`] = ` class="css-12oo3x0-layoutChildrenWrapper" > Make sure to have the app installed diff --git a/grafana-plugin/src/containers/PluginConfigPage/PluginConfigPage.test.tsx b/grafana-plugin/src/containers/PluginConfigPage/PluginConfigPage.test.tsx deleted file mode 100644 index 219cb1a222..0000000000 --- a/grafana-plugin/src/containers/PluginConfigPage/PluginConfigPage.test.tsx +++ /dev/null @@ -1,284 +0,0 @@ -import React from 'react'; - -import { render, screen } from '@testing-library/react'; -import userEvent from '@testing-library/user-event'; -import { useLocation as useLocationOriginal } from 'react-router-dom-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>; - -enum License { - OSS = 'OpenSource', - CLOUD = 'some-other-license', -} - -const CHECK_IF_PLUGIN_IS_CONNECTED_ERROR_MESSAGE = 'ohhh nooo a plugin connection error'; -const UPDATE_PLUGIN_STATUS_ERROR_MESSAGE = 'ohhh noooo a sync issue'; -const PLUGIN_CONFIGURATION_FORM_DATA_ID = 'plugin-configuration-form'; -const STATUS_MESSAGE_BLOCK_DATA_ID = 'status-message-block'; - -const MOCK_PROTOCOL = 'https:'; -const MOCK_HOST = 'localhost:3000'; -const MOCK_PATHNAME = '/dkjdfjkfd'; -const MOCK_URL = `${MOCK_PROTOCOL}//${MOCK_HOST}${MOCK_PATHNAME}`; - -beforeEach(() => { - delete global.window.location; - global.window ??= Object.create(window); - global.window.location = { - protocol: MOCK_PROTOCOL, - host: MOCK_HOST, - pathname: MOCK_PATHNAME, - href: MOCK_URL, - } as Location; - global.window.history.pushState = jest.fn(); -}); - -afterEach(() => { - jest.clearAllMocks(); -}); - -const mockCheckTokenAndIfPluginIsConnected = (license: License = License.OSS) => { - PluginState.checkTokenAndIfPluginIsConnected = jest.fn().mockResolvedValueOnce({ - token_ok: true, - license, - version: 'v1.2.3', - allow_signup: true, - currently_undergoing_maintenance_message: null, - recaptcha_site_key: 'abc', - is_installed: true, - is_user_anonymous: false, - }); -}; - -const generateComponentProps = ( - onCallApiUrl: OnCallPluginConfigPageProps['plugin']['meta']['jsonData']['onCallApiUrl'] = null, - enabled = false -): OnCallPluginConfigPageProps => - ({ - plugin: { - meta: { - jsonData: onCallApiUrl === null ? null : { onCallApiUrl }, - enabled, - }, - }, - } as OnCallPluginConfigPageProps); - -describe('reloadPageWithPluginConfiguredQueryParams', () => { - test.each([true, false])( - 'it modifies the query params depending on whether or not the plugin is already enabled: enabled - %s', - (pluginEnabled) => { - // mocks - const version = 'v1.2.3'; - const license = 'OpenSource'; - const recaptcha_site_key = 'abc'; - const currently_undergoing_maintenance_message = 'false'; - - // test - reloadPageWithPluginConfiguredQueryParams( - { version, license, recaptcha_site_key, currently_undergoing_maintenance_message }, - pluginEnabled - ); - - // assertions - expect(window.location.href).toEqual( - pluginEnabled - ? MOCK_URL - : `${MOCK_URL}?pluginConfigured=true&pluginConfiguredLicense=${license}&pluginConfiguredVersion=${version}` - ); - } - ); -}); - -describe('removePluginConfiguredQueryParams', () => { - test('it removes all the query params if history.pushState is available, and plugin is enabled', () => { - removePluginConfiguredQueryParams(true); - expect(window.history.pushState).toHaveBeenCalledWith({ path: MOCK_URL }, '', MOCK_URL); - }); - - test('it does not remove all the query params if history.pushState is available, and plugin is disabled', () => { - removePluginConfiguredQueryParams(false); - expect(window.history.pushState).not.toHaveBeenCalled(); - }); -}); - -describe('PluginConfigPage', () => { - test('It removes the plugin configured query params if the plugin is enabled', async () => { - // mocks - const metaJsonDataOnCallApiUrl = 'onCallApiUrlFromMetaJsonData'; - PluginState.updatePluginStatus = jest.fn(); - mockCheckTokenAndIfPluginIsConnected(); - - // test setup - render(); - await screen.findByTestId(STATUS_MESSAGE_BLOCK_DATA_ID); - - // assertions - expect(window.history.pushState).toHaveBeenCalledWith({ path: MOCK_URL }, '', MOCK_URL); - - expect(PluginState.updatePluginStatus).toHaveBeenCalledTimes(1); - expect(PluginState.updatePluginStatus).toHaveBeenCalledWith(metaJsonDataOnCallApiUrl); - - expect(PluginState.checkTokenAndIfPluginIsConnected).toHaveBeenCalledTimes(1); - expect(PluginState.checkTokenAndIfPluginIsConnected).toHaveBeenCalledWith(metaJsonDataOnCallApiUrl); - }); - - test("It doesn't make any network calls if the plugin configured query params are provided", async () => { - // mocks - const metaJsonDataOnCallApiUrl = 'onCallApiUrlFromMetaJsonData'; - const version = 'v1.2.3'; - const license = 'OpenSource'; - - useLocation.mockReturnValueOnce({ - search: `?pluginConfigured=true&pluginConfiguredLicense=${license}&pluginConfiguredVersion=${version}`, - } as ReturnType); - - PluginState.updatePluginStatus = jest.fn(); - mockCheckTokenAndIfPluginIsConnected(); - - // test setup - const component = render(); - await screen.findByTestId(STATUS_MESSAGE_BLOCK_DATA_ID); - - // assertions - expect(PluginState.updatePluginStatus).not.toHaveBeenCalled(); - expect(PluginState.checkTokenAndIfPluginIsConnected).not.toHaveBeenCalled(); - expect(component.container).toMatchSnapshot(); - }); - - test("If onCallApiUrl is not set in the plugin's meta jsonData, or in process.env, updatePluginStatus is not called, and the configuration form is shown", async () => { - // mocks - delete process.env.ONCALL_API_URL; - - PluginState.updatePluginStatus = jest.fn(); - PluginState.checkTokenAndIfPluginIsConnected = jest.fn(); - - // test setup - const component = render(); - await screen.findByTestId(PLUGIN_CONFIGURATION_FORM_DATA_ID); - - // assertions - expect(PluginState.updatePluginStatus).not.toHaveBeenCalled(); - expect(PluginState.checkTokenAndIfPluginIsConnected).not.toHaveBeenCalled(); - expect(component.container).toMatchSnapshot(); - }); - - test('If onCallApiUrl is set, and updatePluginStatus returns an error, it sets an error message', async () => { - // mocks - const processEnvOnCallApiUrl = 'onCallApiUrlFromProcessEnv'; - const metaJsonDataOnCallApiUrl = 'onCallApiUrlFromMetaJsonData'; - - process.env.ONCALL_API_URL = processEnvOnCallApiUrl; - - PluginState.updatePluginStatus = jest.fn().mockResolvedValueOnce(CHECK_IF_PLUGIN_IS_CONNECTED_ERROR_MESSAGE); - - // test setup - const component = render(); - await screen.findByTestId(STATUS_MESSAGE_BLOCK_DATA_ID); - - // assertions - expect(PluginState.updatePluginStatus).toHaveBeenCalledTimes(1); - expect(PluginState.updatePluginStatus).toHaveBeenCalledWith(metaJsonDataOnCallApiUrl); - expect(component.container).toMatchSnapshot(); - }); - - test('OnCallApiUrl is set, and checkTokenAndIfPluginIsConnected returns an error', async () => { - // mocks - const processEnvOnCallApiUrl = 'onCallApiUrlFromProcessEnv'; - const metaJsonDataOnCallApiUrl = 'onCallApiUrlFromMetaJsonData'; - - process.env.ONCALL_API_URL = processEnvOnCallApiUrl; - - PluginState.updatePluginStatus = jest.fn().mockResolvedValueOnce(null); - PluginState.checkTokenAndIfPluginIsConnected = jest.fn().mockResolvedValueOnce(UPDATE_PLUGIN_STATUS_ERROR_MESSAGE); - - // test setup - const component = render(); - await screen.findByTestId(STATUS_MESSAGE_BLOCK_DATA_ID); - - // assertions - expect(PluginState.updatePluginStatus).toHaveBeenCalledTimes(1); - expect(PluginState.updatePluginStatus).toHaveBeenCalledWith(metaJsonDataOnCallApiUrl); - expect(component.container).toMatchSnapshot(); - }); - - test.each([License.CLOUD, License.OSS])( - 'OnCallApiUrl is set, and checkTokenAndIfPluginIsConnected does not return an error. It displays properly the plugin connected items based on the license - License: %s', - async (license) => { - // mocks - const processEnvOnCallApiUrl = 'onCallApiUrlFromProcessEnv'; - const metaJsonDataOnCallApiUrl = 'onCallApiUrlFromMetaJsonData'; - - process.env.ONCALL_API_URL = processEnvOnCallApiUrl; - - PluginState.updatePluginStatus = jest.fn().mockResolvedValueOnce(null); - mockCheckTokenAndIfPluginIsConnected(license); - - // test setup - const component = render(); - await screen.findByTestId(STATUS_MESSAGE_BLOCK_DATA_ID); - - // assertions - expect(PluginState.updatePluginStatus).toHaveBeenCalledTimes(1); - expect(PluginState.updatePluginStatus).toHaveBeenCalledWith(metaJsonDataOnCallApiUrl); - expect(component.container).toMatchSnapshot(); - } - ); - - test.each([true, false])('Plugin reset: successful - %s', async (successful) => { - // mocks - const processEnvOnCallApiUrl = 'onCallApiUrlFromProcessEnv'; - const metaJsonDataOnCallApiUrl = 'onCallApiUrlFromMetaJsonData'; - - process.env.ONCALL_API_URL = processEnvOnCallApiUrl; - window.location.reload = jest.fn(); - - PluginState.updatePluginStatus = jest.fn().mockResolvedValue(null); - mockCheckTokenAndIfPluginIsConnected(License.OSS); - - if (successful) { - PluginState.resetPlugin = jest.fn().mockResolvedValueOnce(null); - } else { - PluginState.resetPlugin = jest.fn().mockRejectedValueOnce('dfdf'); - } - - // test setup - const component = render(); - const button = await screen.findByRole('button'); - - // click the reset button, which opens the modal - await userEvent.click(button); - // click the confirm button within the modal, which actually triggers the callback - await userEvent.click(screen.getByText('Remove')); - - // assertions - expect(PluginState.updatePluginStatus).toHaveBeenCalledTimes(1); - expect(PluginState.updatePluginStatus).toHaveBeenCalledWith(metaJsonDataOnCallApiUrl); - - expect(PluginState.checkTokenAndIfPluginIsConnected).toHaveBeenCalledTimes(1); - expect(PluginState.checkTokenAndIfPluginIsConnected).toHaveBeenCalledWith(metaJsonDataOnCallApiUrl); - - expect(PluginState.resetPlugin).toHaveBeenCalledTimes(1); - expect(PluginState.resetPlugin).toHaveBeenCalledWith(); - - expect(component.container).toMatchSnapshot(); - }); -}); diff --git a/grafana-plugin/src/containers/PluginConfigPage/PluginConfigPage.tsx b/grafana-plugin/src/containers/PluginConfigPage/PluginConfigPage.tsx index 71c10c836f..947eac23ed 100644 --- a/grafana-plugin/src/containers/PluginConfigPage/PluginConfigPage.tsx +++ b/grafana-plugin/src/containers/PluginConfigPage/PluginConfigPage.tsx @@ -1,256 +1,300 @@ -import React, { FC, useCallback, useEffect, useState } from 'react'; - -import { Button, HorizontalGroup, Label, Legend, LinkButton, LoadingPlaceholder, VerticalGroup } from '@grafana/ui'; -import { useLocation } from 'react-router-dom-v5-compat'; -import { OnCallPluginConfigPageProps } from 'types'; - -import { PluginState, PluginStatusResponseBase } from 'state/plugin/plugin'; +import React, { useEffect, useState } from 'react'; + +import { css } from '@emotion/css'; +import { GrafanaTheme2, PluginConfigPageProps, PluginMeta } from '@grafana/data'; +import { Alert, Field, HorizontalGroup, Input, LoadingPlaceholder, useStyles2, VerticalGroup } from '@grafana/ui'; +import { observer } from 'mobx-react-lite'; +import { Controller, useForm } from 'react-hook-form'; +import { useNavigate } from 'react-router-dom-v5-compat'; +import { OnCallPluginMetaJSONData } from 'types'; + +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>) => { + const { + pluginStore: { verifyPluginConnection, refreshAppliedOnCallApiUrl }, + } = rootStore; -export const PluginConfigPage: FC = ({ - plugin: { - meta, - meta: { enabled: pluginIsEnabled }, - }, -}) => { - const { search } = useLocation(); - const queryParams = new URLSearchParams(search); - const pluginConfiguredQueryParam = queryParams.get(PLUGIN_CONFIGURED_QUERY_PARAM); - const pluginConfiguredLicenseQueryParam = queryParams.get(PLUGIN_CONFIGURED_LICENSE_QUERY_PARAM); - const pluginConfiguredVersionQueryParam = queryParams.get(PLUGIN_CONFIGURED_VERSION_QUERY_PARAM); + useOnMount(() => { + refreshAppliedOnCallApiUrl(); + verifyPluginConnection(); + }); - const pluginConfiguredRedirect = pluginConfiguredQueryParam === PLUGIN_CONFIGURED_QUERY_PARAM_TRUTHY_VALUE; - - const [checkingIfPluginIsConnected, setCheckingIfPluginIsConnected] = useState(!pluginConfiguredRedirect); - const [pluginConnectionCheckError, setPluginConnectionCheckError] = useState(null); - const [pluginIsConnected, setPluginIsConnected] = useState( - pluginConfiguredRedirect - ? { - version: pluginConfiguredVersionQueryParam, - license: pluginConfiguredLicenseQueryParam, - recaptcha_site_key: 'abc', - currently_undergoing_maintenance_message: 'false', - } - : null + return ( + + + Configure Grafana OnCall + + {getIsRunningOpenSourceVersion() ? : } + ); - - const [updatingPluginStatus, setUpdatingPluginStatus] = useState(false); - const [updatingPluginStatusError, setUpdatingPluginStatusError] = useState(null); - - const [resettingPlugin, setResettingPlugin] = useState(false); - const [pluginResetError, setPluginResetError] = useState(null); - const licenseType = pluginIsConnected?.license || FALLBACK_LICENSE; - const onCallApiUrl = getOnCallApiUrl(meta); - - const resetQueryParams = useCallback(() => removePluginConfiguredQueryParams(pluginIsEnabled), [pluginIsEnabled]); - - const triggerUpdatePluginStatus = useCallback(async () => { - resetMessages(); - setUpdatingPluginStatus(true); - - const pluginConnectionStatus = await PluginState.checkTokenAndIfPluginIsConnected(onCallApiUrl); - - if (typeof pluginConnectionStatus === 'string') { - setUpdatingPluginStatusError(pluginConnectionStatus); - } else { - const { token_ok, ...versionLicenseInfo } = pluginConnectionStatus; - setPluginIsConnected(versionLicenseInfo); - reloadPageWithPluginConfiguredQueryParams(versionLicenseInfo, pluginIsEnabled); - } - - setUpdatingPluginStatus(false); - }, [onCallApiUrl, pluginIsEnabled]); - - useEffect(resetQueryParams, [resetQueryParams]); - - useEffect(() => { - const configurePluginAndUpdatePluginStatus = async () => { - /** - * If the plugin has never been configured, onCallApiUrl will be undefined in the plugin's jsonData - * In that case, check to see if ONCALL_API_URL has been supplied as an env var. - * Supplying the env var basically allows to skip the configuration form - * (check webpack.config.js to see how this is set) - */ - if (!hasPluginBeenConfigured(meta) && onCallApiUrl) { - /** - * onCallApiUrl is not yet saved in the grafana plugin settings, but has been supplied as an env var - * lets auto-trigger a self-hosted plugin install w/ the onCallApiUrl passed in as an env var - */ - const errorMsg = await PluginState.selfHostedInstallPlugin(onCallApiUrl, true); - if (errorMsg) { - setPluginConnectionCheckError(errorMsg); - setCheckingIfPluginIsConnected(false); - return; - } - } - - /** - * If the onCallApiUrl is not set in the plugin settings, and not supplied via an env var - * there's no reason to check if the plugin is connected, we know it can't be - */ - if (onCallApiUrl) { - const pluginConnectionResponse = await PluginState.updatePluginStatus(onCallApiUrl); - - if (typeof pluginConnectionResponse === 'string') { - setPluginConnectionCheckError(pluginConnectionResponse); - } else { - triggerUpdatePluginStatus(); - } - } - setCheckingIfPluginIsConnected(false); +}); + +const CloudPluginConfigPage = observer( + ({ plugin: { meta } }: PluginConfigPageProps>) => { + const { + pluginStore: { isPluginConnected }, + } = rootStore; + const styles = useStyles2(getStyles); + + return ( + + + This is a cloud-managed configuration. + + } /> + } + /> + + ); + } +); + +const OSSPluginConfigPage = observer( + ({ plugin: { meta } }: PluginConfigPageProps>) => { + 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({ + 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); }; - /** - * 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( - () => , - [resettingPlugin, triggerPluginReset] - ); + const getCheckOrTextIcon = (isOk: boolean) => (isOk ? { customIcon: 'check' as const } : { isTextIcon: true }); - const ReconfigurePluginButtons = () => ( - - - {licenseType === GRAFANA_LICENSE_OSS ? : null} - - ); - - let content: React.ReactNode; - - if (checkingIfPluginIsConnected) { - content = ; - } else if (updatingPluginStatus) { - content = ; - } else if (pluginConnectionCheckError || pluginResetError) { - content = ( + const enablePluginExpandedView = () => ( <> - - + Enable OnCall plugin + + Make sure that OnCall plugin has been enabled. + + ( + + )} + /> ); - } else if (updatingPluginStatusError) { - content = ( + + const serviceAccountTokenExpandedView = () => ( <> - - + Service account user allows to connect OnCall plugin to Grafana. + + Make sure that OnCall plugin has been enabled.{' '} +
+ Read more + + + + + } + /> + ); - } else if (!pluginIsConnected) { - content = ; - } else { - // plugin is fully connected and synced - const pluginLink = ( - - Open Grafana OnCall - - ); - content = - licenseType === GRAFANA_LICENSE_OSS ? ( -
+ + const onCallApiUrlExpandedView = () => ( + <> + Let us know the backend URL for your OnCall API + + OnCall backend must be reachable from your Grafana Installation.
+ You can run hobby, dev or production backend. See{' '} + + here + {' '} + how to get started. +
+
+ ( + + + + )} + /> - {pluginLink} - + {isPluginConnected && ( + + )} + + } + /> -
- ) : ( - - - {pluginLink} - - ); + + + ); + + const COMMON_CONFIG_ELEM_PARAMS = { + startingElemPosition: '-6px', + }; + + const configElements = [ + { + ...getCheckOrTextIcon(meta.enabled), + expandedView: enablePluginExpandedView, + }, + ...(getIsExternalServiceAccountFeatureAvailable() + ? [] + : [ + { + ...getCheckOrTextIcon(connectionStatus?.service_account_token?.ok), + expandedView: serviceAccountTokenExpandedView, + }, + ]), + { + ...getCheckOrTextIcon(connectionStatus?.oncall_api_url?.ok), + expandedView: onCallApiUrlExpandedView, + }, + ].map((elem) => ({ ...COMMON_CONFIG_ELEM_PARAMS, ...elem })); + + return ( +
+ + This page will help you to connect OnCall backend and OnCall Grafana plugin. + + {showAlert && } + +
+ ); } +); +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 }) =>
  • {error}
  • ); + + if (isPluginConnected) { + return ( + + Go to{' '} + + Grafana OnCall + + + ); + } return ( - <> - Configure Grafana OnCall - {pluginIsConnected ? ( - <> - - - ) : ( -

    This page will help you configure the OnCall plugin 👋

    + ( + setShowAlert(false)}> +
      {errors}
    + window.location.reload()}> + Reload + +
    )} - {content} - + /> ); -}; +}); + +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; + `, +}); diff --git a/grafana-plugin/src/containers/PluginConfigPage/__snapshots__/PluginConfigPage.test.tsx.snap b/grafana-plugin/src/containers/PluginConfigPage/__snapshots__/PluginConfigPage.test.tsx.snap deleted file mode 100644 index 5ea4416c16..0000000000 --- a/grafana-plugin/src/containers/PluginConfigPage/__snapshots__/PluginConfigPage.test.tsx.snap +++ /dev/null @@ -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`] = ` -
    - - Configure Grafana OnCall - -

    - This page will help you configure the OnCall plugin 👋 -

    -
    -
    -

    - 1. Launch the OnCall backend -

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

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

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

    - This page will help you configure the OnCall plugin 👋 -

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

    - This page will help you configure the OnCall plugin 👋 -

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

    - This page will help you configure the OnCall plugin 👋 -

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

    - This page will help you configure the OnCall plugin 👋 -

    -
    -
    -

    - 1. Launch the OnCall backend -

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

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

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

    1. Launch the OnCall backend

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

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

    - - The OnCall backend must be reachable from your Grafana installation. Some examples are: -
    - - http://host.docker.internal:8080 -
    - http://localhost:8080 -
    -
    - - - - - - {setupErrorMsg && } - - - - )} - - ); -}; diff --git a/grafana-plugin/src/containers/PluginConfigPage/parts/ConfigurationForm/__snapshots__/ConfigurationForm.test.tsx.snap b/grafana-plugin/src/containers/PluginConfigPage/parts/ConfigurationForm/__snapshots__/ConfigurationForm.test.tsx.snap deleted file mode 100644 index eb0721db24..0000000000 --- a/grafana-plugin/src/containers/PluginConfigPage/parts/ConfigurationForm/__snapshots__/ConfigurationForm.test.tsx.snap +++ /dev/null @@ -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`] = ` - -
    -
    -
    -

    - 1. Launch the OnCall backend -

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

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

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

    - 1. Launch the OnCall backend -

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

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

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