commit
8bdb124f58
19 changed files with 160 additions and 28 deletions
1
.github/workflows/expensive-e2e-tests.yml
vendored
1
.github/workflows/expensive-e2e-tests.yml
vendored
|
|
@ -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
|
||||
|
|
|
|||
1
.github/workflows/linting-and-tests.yml
vendored
1
.github/workflows/linting-and-tests.yml
vendored
|
|
@ -243,6 +243,7 @@ jobs:
|
|||
matrix:
|
||||
grafana_version:
|
||||
- 10.3.0
|
||||
- 11.2.0
|
||||
- latest
|
||||
fail-fast: false
|
||||
with:
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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 import status
|
|||
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()
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -84,6 +84,8 @@ class UserView(RateLimitHeadersMixin, ShortSerializerMixin, ReadOnlyModelViewSet
|
|||
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)
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -31,7 +31,7 @@ export const CardButton: FC<CardButtonProps> = (props) => {
|
|||
>
|
||||
<div className={styles.icon}>{icon}</div>
|
||||
<div className={styles.meta}>
|
||||
<Stack gap={StackSize.xs}>
|
||||
<Stack gap={StackSize.xs} direction="column">
|
||||
<Text type="secondary">{description}</Text>
|
||||
<Text.Title level={1}>{title}</Text.Title>
|
||||
</Stack>
|
||||
|
|
|
|||
|
|
@ -191,26 +191,24 @@ export function getDraggableModalCoordinatesOnInit(
|
|||
return undefined;
|
||||
}
|
||||
|
||||
const scrollBarReferenceElements = document.querySelectorAll<HTMLElement>('.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,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -809,7 +809,8 @@ export const RotationForm = observer((props: RotationFormProps) => {
|
|||
return;
|
||||
}
|
||||
|
||||
setDraggableBounds(getDraggableModalCoordinatesOnInit(data, offsetTop));
|
||||
const bounds = getDraggableModalCoordinatesOnInit(data, offsetTop);
|
||||
setDraggableBounds(bounds);
|
||||
}
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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) => (
|
||||
<Draggable handle=".drag-handler" defaultClassName={cx('draggable')} positionOffset={{ x: 0, y: 200 }}>
|
||||
<Draggable
|
||||
handle=".drag-handler"
|
||||
defaultClassName={cx('draggable')}
|
||||
positionOffset={{ x: 0, y: offsetTop }}
|
||||
position={draggablePosition}
|
||||
bounds={{ ...bounds } || 'body'}
|
||||
onStart={onDraggableInit}
|
||||
onStop={(_e, data) => setDraggablePosition({ x: data.x, y: data.y })}
|
||||
>
|
||||
<div {...props}>{children}</div>
|
||||
</Draggable>
|
||||
)}
|
||||
|
|
@ -235,4 +261,19 @@ export const ShiftSwapForm = (props: ShiftSwapFormProps) => {
|
|||
</div>
|
||||
</Modal>
|
||||
);
|
||||
|
||||
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);
|
||||
}
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue