diff --git a/.github/workflows/expensive-e2e-tests.yml b/.github/workflows/expensive-e2e-tests.yml index 49aa605c04..8ea7a1c058 100644 --- a/.github/workflows/expensive-e2e-tests.yml +++ b/.github/workflows/expensive-e2e-tests.yml @@ -15,6 +15,7 @@ jobs: matrix: grafana_version: - 10.3.0 + - 11.2.0 - latest fail-fast: false # Run one version at a time to avoid the issue when SMS notification are bundled together for multiple versions diff --git a/.github/workflows/linting-and-tests.yml b/.github/workflows/linting-and-tests.yml index 5dc78d565f..7d64549248 100644 --- a/.github/workflows/linting-and-tests.yml +++ b/.github/workflows/linting-and-tests.yml @@ -243,6 +243,7 @@ jobs: matrix: grafana_version: - 10.3.0 + - 11.2.0 - latest fail-fast: false with: diff --git a/engine/apps/grafana_plugin/serializers/sync_data.py b/engine/apps/grafana_plugin/serializers/sync_data.py index bedefea1cd..71c10d1aa5 100644 --- a/engine/apps/grafana_plugin/serializers/sync_data.py +++ b/engine/apps/grafana_plugin/serializers/sync_data.py @@ -22,7 +22,7 @@ class SyncUserSerializer(serializers.Serializer): login = serializers.CharField() email = serializers.CharField() role = serializers.CharField() - avatar_url = serializers.CharField() + avatar_url = serializers.CharField(allow_blank=True) permissions = SyncPermissionSerializer(many=True, allow_empty=True, allow_null=True) teams = serializers.ListField(child=serializers.IntegerField(), allow_empty=True, allow_null=True) diff --git a/engine/apps/grafana_plugin/tests/test_sync_v2.py b/engine/apps/grafana_plugin/tests/test_sync_v2.py index 11cd09387a..2aed4c3d10 100644 --- a/engine/apps/grafana_plugin/tests/test_sync_v2.py +++ b/engine/apps/grafana_plugin/tests/test_sync_v2.py @@ -1,3 +1,6 @@ +import gzip +import json +from dataclasses import asdict from unittest.mock import patch import pytest @@ -6,6 +9,7 @@ from rest_framework.test import APIClient from apps.api.permissions import LegacyAccessControlRole +from apps.grafana_plugin.sync_data import SyncData, SyncSettings, SyncUser from apps.grafana_plugin.tasks.sync_v2 import start_sync_organizations_v2 @@ -76,3 +80,59 @@ def test_skip_org_without_api_token(make_organization, api_token, sync_called): ) as mock_sync: start_sync_organizations_v2() assert mock_sync.called == sync_called + + +@pytest.mark.parametrize("format", [("json"), ("gzip")]) +@pytest.mark.django_db +def test_sync_v2_content_encoding( + make_organization_and_user_with_plugin_token, make_user_auth_headers, settings, format +): + organization, user, token = make_organization_and_user_with_plugin_token() + settings.LICENSE = settings.CLOUD_LICENSE_NAME + client = APIClient() + headers = make_user_auth_headers(None, token, organization=organization) + + data = SyncData( + users=[ + SyncUser( + id=user.user_id, + name=user.username, + login=user.username, + email=user.email, + role="Admin", + avatar_url="", + permissions=[], + teams=[], + ) + ], + teams=[], + team_members={}, + settings=SyncSettings( + stack_id=organization.stack_id, + org_id=organization.org_id, + license=settings.CLOUD_LICENSE_NAME, + oncall_api_url="http://localhost", + oncall_token="", + grafana_url="http://localhost", + grafana_token="fake_token", + rbac_enabled=False, + incident_enabled=False, + incident_backend_url="", + labels_enabled=False, + ), + ) + + payload = asdict(data) + headers["HTTP_Content-Type"] = "application/json" + url = reverse("grafana-plugin:sync-v2") + with patch("apps.grafana_plugin.views.sync_v2.apply_sync_data") as mock_sync: + if format == "gzip": + headers["HTTP_Content-Encoding"] = "gzip" + json_data = json.dumps(payload) + payload = gzip.compress(json_data.encode("utf-8")) + response = client.generic("POST", url, data=payload, **headers) + else: + response = client.post(url, format=format, data=payload, **headers) + + assert response.status_code == status.HTTP_200_OK + mock_sync.assert_called() diff --git a/engine/apps/grafana_plugin/views/sync_v2.py b/engine/apps/grafana_plugin/views/sync_v2.py index 1c17cef8c3..2316620789 100644 --- a/engine/apps/grafana_plugin/views/sync_v2.py +++ b/engine/apps/grafana_plugin/views/sync_v2.py @@ -1,3 +1,5 @@ +import gzip +import json import logging from dataclasses import asdict, is_dataclass @@ -25,7 +27,14 @@ class SyncV2View(APIView): authentication_classes = (BasePluginAuthentication,) def do_sync(self, request: Request) -> Organization: - serializer = SyncDataSerializer(data=request.data) + if request.headers.get("Content-Encoding") == "gzip": + gzip_data = gzip.GzipFile(fileobj=request).read() + decoded_data = gzip_data.decode("utf-8") + data = json.loads(decoded_data) + else: + data = request.data + + serializer = SyncDataSerializer(data=data) if not serializer.is_valid(): raise SyncException(serializer.errors) diff --git a/engine/apps/public_api/views/users.py b/engine/apps/public_api/views/users.py index 930d1c85ca..524b5ee008 100644 --- a/engine/apps/public_api/views/users.py +++ b/engine/apps/public_api/views/users.py @@ -84,6 +84,8 @@ def get_object(self): permission_classes=(IsAuthenticated,), ) def schedule_export(self, request, pk): - schedules = OnCallSchedule.objects.filter(organization=self.request.auth.organization) + schedules = OnCallSchedule.objects.filter(organization=self.request.auth.organization).related_to_user( + self.request.user + ) export = user_ical_export(self.request.user, schedules) return Response(export) diff --git a/grafana-plugin/e2e-tests/labels/createNewLabelKeysAndValues.test.ts b/grafana-plugin/e2e-tests/labels/createNewLabelKeysAndValues.test.ts index 5787e1dbc1..006c55f132 100644 --- a/grafana-plugin/e2e-tests/labels/createNewLabelKeysAndValues.test.ts +++ b/grafana-plugin/e2e-tests/labels/createNewLabelKeysAndValues.test.ts @@ -9,7 +9,8 @@ test.skip( 'Above 10.3 labels need enterprise version to validate permissions' ); -test('New label keys and labels can be created @expensive', async ({ adminRolePage }) => { +// TODO: This test is flaky on CI. Undo skipping once we can test labels locally +test.skip('New label keys and labels can be created @expensive', async ({ adminRolePage }) => { const { page } = adminRolePage; await goToOnCallPage(page, 'integrations'); await openCreateIntegrationModal(page); diff --git a/grafana-plugin/pkg/plugin/debug.go b/grafana-plugin/pkg/plugin/debug.go index eb10cd5b3e..e362c9ccd1 100644 --- a/grafana-plugin/pkg/plugin/debug.go +++ b/grafana-plugin/pkg/plugin/debug.go @@ -95,3 +95,8 @@ func (a *App) handleDebugStats(w http.ResponseWriter, req *http.Request) { } w.WriteHeader(http.StatusOK) } + +func (a *App) handleDebugUnlock(w http.ResponseWriter, req *http.Request) { + a.OnCallSyncCache.syncMutex.Unlock() + w.WriteHeader(http.StatusOK) +} diff --git a/grafana-plugin/pkg/plugin/permissions.go b/grafana-plugin/pkg/plugin/permissions.go index efeca5382f..6bb0309592 100644 --- a/grafana-plugin/pkg/plugin/permissions.go +++ b/grafana-plugin/pkg/plugin/permissions.go @@ -41,7 +41,7 @@ func (a *App) GetPermissions(settings *OnCallPluginSettings, onCallUser *OnCallU var permissions []OnCallPermission err = json.Unmarshal(body, &permissions) if err != nil { - return nil, fmt.Errorf("failed to parse JSON response: %v body=%v", err, body) + return nil, fmt.Errorf("failed to parse JSON response: %v body=%v", err, string(body)) } if res.StatusCode == 200 { @@ -88,7 +88,7 @@ func (a *App) GetAllPermissions(settings *OnCallPluginSettings) (map[string]map[ 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 body=%v", err, body) + return nil, fmt.Errorf("failed to parse JSON response: %v body=%v", err, string(body)) } if res.StatusCode == 200 { diff --git a/grafana-plugin/pkg/plugin/resources.go b/grafana-plugin/pkg/plugin/resources.go index 3fa8a1a0b0..7dad9564e0 100644 --- a/grafana-plugin/pkg/plugin/resources.go +++ b/grafana-plugin/pkg/plugin/resources.go @@ -132,6 +132,7 @@ func (a *App) registerRoutes(mux *http.ServeMux) { //mux.HandleFunc("/debug/settings", a.handleDebugSettings) //mux.HandleFunc("/debug/permissions", a.handleDebugPermissions) //mux.HandleFunc("/debug/stats", a.handleDebugStats) + //mux.HandleFunc("/debug/unlock", a.handleDebugUnlock) mux.HandleFunc("/", a.handleInternalApi) } diff --git a/grafana-plugin/pkg/plugin/settings.go b/grafana-plugin/pkg/plugin/settings.go index 70c50e2760..700bc42383 100644 --- a/grafana-plugin/pkg/plugin/settings.go +++ b/grafana-plugin/pkg/plugin/settings.go @@ -268,7 +268,7 @@ func (a *App) GetOtherPluginSettings(settings *OnCallPluginSettings, pluginID st var result map[string]interface{} err = json.Unmarshal(body, &result) if err != nil { - return nil, fmt.Errorf("failed to parse JSON response: %v body=%v", err, body) + return nil, fmt.Errorf("failed to parse JSON response: %v body=%v", err, string(body)) } return result, nil diff --git a/grafana-plugin/pkg/plugin/sync.go b/grafana-plugin/pkg/plugin/sync.go index ebe1b7d712..249a10f532 100644 --- a/grafana-plugin/pkg/plugin/sync.go +++ b/grafana-plugin/pkg/plugin/sync.go @@ -2,6 +2,7 @@ package plugin import ( "bytes" + "compress/gzip" "context" "encoding/json" "errors" @@ -136,6 +137,16 @@ func (a *App) makeSyncRequest(ctx context.Context, forceSend bool) error { return fmt.Errorf("error marshalling JSON: %v", err) } + var syncDataBuffer bytes.Buffer + gzipWriter := gzip.NewWriter(&syncDataBuffer) + _, err = gzipWriter.Write(onCallSyncJsonData) + if err != nil { + return fmt.Errorf("error writing sync data to gzip writer: %v", err) + } + if err := gzipWriter.Close(); err != nil { + return fmt.Errorf("error closing gzip writer: %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) @@ -146,7 +157,7 @@ func (a *App) makeSyncRequest(ctx context.Context, forceSend bool) error { return fmt.Errorf("error parsing path: %v", err) } - syncReq, err := http.NewRequest("POST", parsedSyncURL.String(), bytes.NewBuffer(onCallSyncJsonData)) + syncReq, err := http.NewRequest("POST", parsedSyncURL.String(), &syncDataBuffer) if err != nil { return fmt.Errorf("error creating request: %v", err) } @@ -156,6 +167,7 @@ func (a *App) makeSyncRequest(ctx context.Context, forceSend bool) error { return err } syncReq.Header.Set("Content-Type", "application/json") + syncReq.Header.Set("Content-Encoding", "gzip") res, err := a.httpClient.Do(syncReq) if err != nil { diff --git a/grafana-plugin/pkg/plugin/teams.go b/grafana-plugin/pkg/plugin/teams.go index 4b58781ada..c6d6ef679d 100644 --- a/grafana-plugin/pkg/plugin/teams.go +++ b/grafana-plugin/pkg/plugin/teams.go @@ -70,7 +70,7 @@ func (a *App) GetTeamsForUser(settings *OnCallPluginSettings, onCallUser *OnCall var result []Team err = json.Unmarshal(body, &result) if err != nil { - return nil, fmt.Errorf("failed to parse JSON response: %v body=%v", err, body) + return nil, fmt.Errorf("failed to parse JSON response: %v body=%v", err, string(body)) } if res.StatusCode == 200 { @@ -115,7 +115,7 @@ func (a *App) GetAllTeams(settings *OnCallPluginSettings) ([]OnCallTeam, error) var result Teams err = json.Unmarshal(body, &result) if err != nil { - return nil, fmt.Errorf("failed to parse JSON response: %v body=%v", err, body) + return nil, fmt.Errorf("failed to parse JSON response: %v body=%v", err, string(body)) } if res.StatusCode == 200 { @@ -161,7 +161,7 @@ func (a *App) GetTeamsMembersForTeam(settings *OnCallPluginSettings, onCallTeam var result []OrgUser err = json.Unmarshal(body, &result) if err != nil { - return nil, fmt.Errorf("failed to parse JSON response: %v body=%v", err, body) + return nil, fmt.Errorf("failed to parse JSON response: %v body=%v", err, string(body)) } if res.StatusCode == 200 { diff --git a/grafana-plugin/pkg/plugin/users.go b/grafana-plugin/pkg/plugin/users.go index 8fdd916632..d5a28299aa 100644 --- a/grafana-plugin/pkg/plugin/users.go +++ b/grafana-plugin/pkg/plugin/users.go @@ -233,7 +233,7 @@ func (a *App) GetAllUsers(settings *OnCallPluginSettings) ([]OnCallUser, error) var result []OrgUser err = json.Unmarshal(body, &result) if err != nil { - return nil, fmt.Errorf("failed to parse JSON response: %v body=%v", err, body) + return nil, fmt.Errorf("failed to parse JSON response: %v body=%v", err, string(body)) } if res.StatusCode == 200 { diff --git a/grafana-plugin/src/components/CardButton/CardButton.tsx b/grafana-plugin/src/components/CardButton/CardButton.tsx index e0b0355770..43d0da6177 100644 --- a/grafana-plugin/src/components/CardButton/CardButton.tsx +++ b/grafana-plugin/src/components/CardButton/CardButton.tsx @@ -31,7 +31,7 @@ export const CardButton: FC = (props) => { >
{icon}
- + {description} {title} diff --git a/grafana-plugin/src/containers/RotationForm/RotationForm.helpers.ts b/grafana-plugin/src/containers/RotationForm/RotationForm.helpers.ts index 98597fcb79..37671a36fe 100644 --- a/grafana-plugin/src/containers/RotationForm/RotationForm.helpers.ts +++ b/grafana-plugin/src/containers/RotationForm/RotationForm.helpers.ts @@ -191,26 +191,24 @@ export function getDraggableModalCoordinatesOnInit( return undefined; } - const scrollBarReferenceElements = document.querySelectorAll('.scrollbar-view'); - // top navbar display has 2 scrollbar-view elements (navbar & content) - const baseReferenceElRect = ( - scrollBarReferenceElements.length === 1 ? scrollBarReferenceElements[0] : scrollBarReferenceElements[1] - ).getBoundingClientRect(); + const body = document.body; + const baseReferenceElRect = body.getBoundingClientRect(); + const { innerHeight } = window; const { right, bottom } = baseReferenceElRect; return isTopNavbar() ? { // values are adjusted by any padding/margin differences - left: -data.node.offsetLeft + 4, + left: -data.node.offsetLeft + 12, right: right - (data.node.offsetLeft + data.node.offsetWidth) - 12, - top: -offsetTop + GRAFANA_HEADER_HEIGHT + 4, - bottom: bottom - data.node.offsetHeight - offsetTop - 12, + top: -offsetTop + GRAFANA_HEADER_HEIGHT + 12, + bottom: innerHeight - data.node.offsetHeight - offsetTop - 12, } : { - left: -data.node.offsetLeft + 4 + GRAFANA_LEGACY_SIDEBAR_WIDTH, + left: -data.node.offsetLeft + 12 + GRAFANA_LEGACY_SIDEBAR_WIDTH, right: right - (data.node.offsetLeft + data.node.offsetWidth) - 12, - top: -offsetTop + 4, + top: -offsetTop + 12, bottom: bottom - data.node.offsetHeight - offsetTop - 12, }; } diff --git a/grafana-plugin/src/containers/RotationForm/RotationForm.tsx b/grafana-plugin/src/containers/RotationForm/RotationForm.tsx index 086d474363..8e5d38c6f0 100644 --- a/grafana-plugin/src/containers/RotationForm/RotationForm.tsx +++ b/grafana-plugin/src/containers/RotationForm/RotationForm.tsx @@ -809,7 +809,8 @@ export const RotationForm = observer((props: RotationFormProps) => { return; } - setDraggableBounds(getDraggableModalCoordinatesOnInit(data, offsetTop)); + const bounds = getDraggableModalCoordinatesOnInit(data, offsetTop); + setDraggableBounds(bounds); } }); diff --git a/grafana-plugin/src/containers/RotationForm/ShiftSwapForm.tsx b/grafana-plugin/src/containers/RotationForm/ShiftSwapForm.tsx index b8ab15f3be..12176718c0 100644 --- a/grafana-plugin/src/containers/RotationForm/ShiftSwapForm.tsx +++ b/grafana-plugin/src/containers/RotationForm/ShiftSwapForm.tsx @@ -3,20 +3,23 @@ import React, { useCallback, useEffect, useMemo, useState } from 'react'; import { Button, Field, IconButton, Input, TextArea, Stack } from '@grafana/ui'; import cn from 'classnames/bind'; import dayjs from 'dayjs'; -import Draggable from 'react-draggable'; +import Draggable, { DraggableData, DraggableEvent } from 'react-draggable'; import { Modal } from 'components/Modal/Modal'; import { Tag } from 'components/Tag/Tag'; import { Text } from 'components/Text/Text'; import { WithConfirm } from 'components/WithConfirm/WithConfirm'; +import { calculateScheduleFormOffset } from 'containers/Rotations/Rotations.helpers'; import { WithPermissionControlTooltip } from 'containers/WithPermissionControl/WithPermissionControlTooltip'; import { SHIFT_SWAP_COLOR } from 'models/schedule/schedule.helpers'; import { Schedule, ShiftSwap } from 'models/schedule/schedule.types'; import { getUTCString } from 'pages/schedule/Schedule.helpers'; import { useStore } from 'state/useStore'; import { UserActions } from 'utils/authorization/authorization'; -import { StackSize } from 'utils/consts'; +import { GRAFANA_HEADER_HEIGHT, StackSize } from 'utils/consts'; +import { useDebouncedCallback, useResize } from 'utils/hooks'; +import { getDraggableModalCoordinatesOnInit } from './RotationForm.helpers'; import { DateTimePicker } from './parts/DateTimePicker'; import { UserItem } from './parts/UserItem'; @@ -36,6 +39,15 @@ export const ShiftSwapForm = (props: ShiftSwapFormProps) => { const { onUpdate, onHide, id, scheduleId, params: defaultParams } = props; const [shiftSwap, setShiftSwap] = useState({ ...defaultParams }); + const [offsetTop, setOffsetTop] = useState(GRAFANA_HEADER_HEIGHT + 10); + const [draggablePosition, setDraggablePosition] = useState<{ x: number; y: number }>(undefined); + const [bounds, setDraggableBounds] = useState<{ left: number; right: number; top: number; bottom: number }>( + undefined + ); + + const debouncedOnResize = useDebouncedCallback(onResize, 250); + + useResize(debouncedOnResize); const store = useStore(); const { @@ -44,6 +56,12 @@ export const ShiftSwapForm = (props: ShiftSwapFormProps) => { timezoneStore: { selectedTimezoneOffset }, } = store; + useEffect(() => { + (async () => { + setOffsetTop(await calculateScheduleFormOffset(`.${cx('draggable')}`)); + })(); + }, []); + useEffect(() => { (async () => { if (id !== 'new') { @@ -131,7 +149,15 @@ export const ShiftSwapForm = (props: ShiftSwapFormProps) => { width="430px" onDismiss={handleHide} contentElement={(props, children) => ( - + setDraggablePosition({ x: data.x, y: data.y })} + >
{children}
)} @@ -235,4 +261,19 @@ export const ShiftSwapForm = (props: ShiftSwapFormProps) => {
); + + async function onResize() { + setOffsetTop(await calculateScheduleFormOffset(`.${cx('draggable')}`)); + + setDraggablePosition({ x: 0, y: 0 }); + } + + function onDraggableInit(_e: DraggableEvent, data: DraggableData) { + if (!data) { + return; + } + + const bounds = getDraggableModalCoordinatesOnInit(data, offsetTop); + setDraggableBounds(bounds); + } }; diff --git a/grafana-plugin/src/containers/Rotations/Rotations.helpers.ts b/grafana-plugin/src/containers/Rotations/Rotations.helpers.ts index 6bb9e39876..0c6dfb18c8 100644 --- a/grafana-plugin/src/containers/Rotations/Rotations.helpers.ts +++ b/grafana-plugin/src/containers/Rotations/Rotations.helpers.ts @@ -10,7 +10,7 @@ export const calculateScheduleFormOffset = async (queryClassName: string) => { const modal = await waitForElement(queryClassName); const modalHeight = modal.clientHeight; - return document.documentElement.scrollHeight / 2 - modalHeight / 2; + return window.innerHeight / 2 - modalHeight / 2; }; // DatePickers will convert the date passed to local timezone, instead we want to use the date in the given timezone