Merge pull request #4958 from grafana/dev

v1.9.18
This commit is contained in:
Michael Derynck 2024-08-29 14:04:00 -06:00 committed by GitHub
commit 8bdb124f58
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
19 changed files with 160 additions and 28 deletions

View file

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

View file

@ -243,6 +243,7 @@ jobs:
matrix:
grafana_version:
- 10.3.0
- 11.2.0
- latest
fail-fast: false
with:

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -809,7 +809,8 @@ export const RotationForm = observer((props: RotationFormProps) => {
return;
}
setDraggableBounds(getDraggableModalCoordinatesOnInit(data, offsetTop));
const bounds = getDraggableModalCoordinatesOnInit(data, offsetTop);
setDraggableBounds(bounds);
}
});

View file

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

View file

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