mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-07-12 23:56:36 +08:00
feat(core): improve dashboard style (#15205)
This commit is contained in:
Binary file not shown.
@@ -1587,15 +1587,6 @@ test('should be able to manage context', async t => {
|
||||
buffer
|
||||
);
|
||||
|
||||
const { files } =
|
||||
(await listContextDocAndFiles(app, workspaceId, sessionId, contextId)) ||
|
||||
{};
|
||||
t.snapshot(
|
||||
cleanObject(files, ['id', 'error', 'createdAt']),
|
||||
'should list context files'
|
||||
);
|
||||
|
||||
// wait for processing
|
||||
await waitForStatus(
|
||||
async () =>
|
||||
(await listContextDocAndFiles(app, workspaceId, sessionId, contextId))
|
||||
@@ -1605,6 +1596,18 @@ test('should be able to manage context', async t => {
|
||||
60
|
||||
);
|
||||
|
||||
const { files } =
|
||||
(await listContextDocAndFiles(app, workspaceId, sessionId, contextId)) ||
|
||||
{};
|
||||
t.deepEqual(cleanObject(files, ['id', 'error', 'createdAt']), [
|
||||
{
|
||||
blobId: 'Ip3vuwzubwJnOlzeKQ0Gc-daDcMc7EOYnIqypOyn4bs',
|
||||
chunkSize: 1,
|
||||
name: 'sample.pdf',
|
||||
status: 'finished',
|
||||
},
|
||||
]);
|
||||
|
||||
const result = await waitForMatches(
|
||||
() => matchFiles(app, contextId, 'test', 1),
|
||||
1
|
||||
@@ -1641,15 +1644,6 @@ test('should be able to manage context', async t => {
|
||||
|
||||
await addContextDoc(app, contextId, docId);
|
||||
|
||||
const { docs } =
|
||||
(await listContextDocAndFiles(app, workspaceId, sessionId, contextId)) ||
|
||||
{};
|
||||
t.snapshot(
|
||||
cleanObject(docs, ['error', 'createdAt']),
|
||||
'should list context docs'
|
||||
);
|
||||
|
||||
// wait for processing
|
||||
await waitForStatus(
|
||||
async () =>
|
||||
(await listContextDocAndFiles(app, workspaceId, sessionId, contextId))
|
||||
@@ -1659,6 +1653,16 @@ test('should be able to manage context', async t => {
|
||||
60
|
||||
);
|
||||
|
||||
const { docs } =
|
||||
(await listContextDocAndFiles(app, workspaceId, sessionId, contextId)) ||
|
||||
{};
|
||||
t.deepEqual(cleanObject(docs, ['error', 'createdAt']), [
|
||||
{
|
||||
id: docId,
|
||||
status: 'finished',
|
||||
},
|
||||
]);
|
||||
|
||||
const result = await waitForMatches(
|
||||
() => matchWorkspaceDocs(app, contextId, 'test', 1),
|
||||
1
|
||||
|
||||
@@ -358,6 +358,12 @@ e2e(
|
||||
requestedSize
|
||||
effectiveSize
|
||||
}
|
||||
copilotWindow {
|
||||
to
|
||||
bucket
|
||||
requestedSize
|
||||
effectiveSize
|
||||
}
|
||||
syncActiveUsersTimeline {
|
||||
minute
|
||||
activeUsers
|
||||
@@ -366,6 +372,7 @@ e2e(
|
||||
date
|
||||
value
|
||||
}
|
||||
generatedAt
|
||||
}
|
||||
}
|
||||
`;
|
||||
@@ -375,6 +382,7 @@ e2e(
|
||||
storageHistoryDays: -10,
|
||||
syncHistoryHours: -10,
|
||||
sharedLinkWindowDays: -10,
|
||||
copilotWindowDays: 500,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -385,6 +393,12 @@ e2e(
|
||||
t.is(dashboard.storageWindow.bucket, 'Day');
|
||||
t.is(dashboard.storageWindow.effectiveSize, 1);
|
||||
t.is(dashboard.topSharedLinksWindow.effectiveSize, 1);
|
||||
t.is(dashboard.copilotWindow.bucket, 'Day');
|
||||
t.is(dashboard.copilotWindow.effectiveSize, 90);
|
||||
t.is(
|
||||
new Date(dashboard.copilotWindow.to).getTime(),
|
||||
new Date(dashboard.generatedAt).getTime()
|
||||
);
|
||||
t.is(dashboard.syncActiveUsersTimeline.length, 1);
|
||||
t.is(dashboard.workspaceStorageHistory.length, 1);
|
||||
}
|
||||
|
||||
@@ -572,6 +572,43 @@ test('old canary date clientVersion should be rejected and disconnected in canar
|
||||
}
|
||||
});
|
||||
|
||||
test('canary date clientVersion should be rejected outside canary namespace', async t => {
|
||||
const prevNamespace = env.NAMESPACE;
|
||||
// @ts-expect-error test
|
||||
env.NAMESPACE = 'production';
|
||||
|
||||
try {
|
||||
const { user, cookieHeader } = await login(app);
|
||||
const spaceId = user.id;
|
||||
|
||||
const socket = createClient(url, cookieHeader);
|
||||
try {
|
||||
await waitForConnect(socket);
|
||||
|
||||
const res = unwrapResponse(
|
||||
t,
|
||||
await emitWithAck<{ clientId: string; success: boolean }>(
|
||||
socket,
|
||||
'space:join',
|
||||
{
|
||||
spaceType: 'userspace',
|
||||
spaceId,
|
||||
clientVersion: makeCanaryDateVersion(new Date(), '15'),
|
||||
}
|
||||
)
|
||||
);
|
||||
t.false(res.success);
|
||||
|
||||
await waitForDisconnect(socket);
|
||||
} finally {
|
||||
socket.disconnect();
|
||||
}
|
||||
} finally {
|
||||
// @ts-expect-error test
|
||||
env.NAMESPACE = prevNamespace;
|
||||
}
|
||||
});
|
||||
|
||||
test('space:join-awareness should reject clientVersion<0.25.0', async t => {
|
||||
const { user, cookieHeader } = await login(app);
|
||||
const spaceId = user.id;
|
||||
|
||||
@@ -12,6 +12,7 @@ type TestContext = {
|
||||
const test = ava.serial as TestFn<TestContext>;
|
||||
|
||||
let safeFetchStub: Sinon.SinonStub | undefined;
|
||||
let originalDeploymentType: typeof env.DEPLOYMENT_TYPE;
|
||||
let safeFetchHandler:
|
||||
| ((request: { url: string; method?: 'get' | 'head' }) => {
|
||||
status?: number;
|
||||
@@ -38,6 +39,7 @@ const stubSafeFetch = (
|
||||
};
|
||||
|
||||
test.before(async t => {
|
||||
originalDeploymentType = env.DEPLOYMENT_TYPE;
|
||||
// @ts-expect-error test
|
||||
env.DEPLOYMENT_TYPE = 'selfhosted';
|
||||
|
||||
@@ -73,6 +75,8 @@ test.afterEach.always(() => {
|
||||
});
|
||||
|
||||
test.after.always(async t => {
|
||||
// @ts-expect-error test
|
||||
env.DEPLOYMENT_TYPE = originalDeploymentType;
|
||||
safeFetchStub?.restore();
|
||||
await t.context.app.close();
|
||||
});
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
import test from 'ava';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { CANARY_CLIENT_VERSION_MAX_AGE_DAYS } from '../../../base';
|
||||
import { Flavor } from '../../../env';
|
||||
import { PublicDocMode } from '../../../models';
|
||||
import { CopilotEmbeddingRealtimeProvider } from '../../../plugins/copilot/context';
|
||||
@@ -63,6 +64,10 @@ const user: CurrentUser = {
|
||||
emailVerified: true,
|
||||
};
|
||||
|
||||
function makeCanaryDateVersion(date: Date, build = '015') {
|
||||
return `${date.getUTCFullYear()}.${date.getUTCMonth() + 1}.${date.getUTCDate()}-canary.${build}`;
|
||||
}
|
||||
|
||||
function createGateway(registry: RealtimeRegistry) {
|
||||
return new RealtimeGateway(registry, {
|
||||
attachServer() {},
|
||||
@@ -142,6 +147,73 @@ test('gateway handles registered request with version gate', async t => {
|
||||
);
|
||||
});
|
||||
|
||||
test('gateway accepts canary date client version in canary namespace', async t => {
|
||||
const originalNamespace = env.NAMESPACE;
|
||||
// @ts-expect-error test
|
||||
env.NAMESPACE = 'dev';
|
||||
try {
|
||||
const registry = new RealtimeRegistry();
|
||||
registry.registerRequest({
|
||||
name: 'notification.count.get',
|
||||
input: z.object({}).strict(),
|
||||
handle: async () => ({ count: 1 }),
|
||||
});
|
||||
const gateway = createGateway(registry);
|
||||
|
||||
t.deepEqual(
|
||||
await gateway.onRequest(user, {
|
||||
op: 'notification.count.get',
|
||||
input: {},
|
||||
clientVersion: makeCanaryDateVersion(new Date(), '040'),
|
||||
}),
|
||||
{ data: { count: 1 } }
|
||||
);
|
||||
|
||||
const old = new Date(
|
||||
Date.now() -
|
||||
(CANARY_CLIENT_VERSION_MAX_AGE_DAYS + 1) * 24 * 60 * 60 * 1000
|
||||
);
|
||||
t.like(
|
||||
await gateway.onRequest(user, {
|
||||
op: 'notification.count.get',
|
||||
input: {},
|
||||
clientVersion: makeCanaryDateVersion(old, '040'),
|
||||
}),
|
||||
{ error: { code: 'UNSUPPORTED_CLIENT_VERSION' } }
|
||||
);
|
||||
} finally {
|
||||
// @ts-expect-error test
|
||||
env.NAMESPACE = originalNamespace;
|
||||
}
|
||||
});
|
||||
|
||||
test('gateway rejects canary date client version outside canary namespace', async t => {
|
||||
const originalNamespace = env.NAMESPACE;
|
||||
// @ts-expect-error test
|
||||
env.NAMESPACE = 'production';
|
||||
try {
|
||||
const registry = new RealtimeRegistry();
|
||||
registry.registerRequest({
|
||||
name: 'notification.count.get',
|
||||
input: z.object({}).strict(),
|
||||
handle: async () => ({ count: 1 }),
|
||||
});
|
||||
const gateway = createGateway(registry);
|
||||
|
||||
t.like(
|
||||
await gateway.onRequest(user, {
|
||||
op: 'notification.count.get',
|
||||
input: {},
|
||||
clientVersion: makeCanaryDateVersion(new Date(), '40'),
|
||||
}),
|
||||
{ error: { code: 'UNSUPPORTED_CLIENT_VERSION' } }
|
||||
);
|
||||
} finally {
|
||||
// @ts-expect-error test
|
||||
env.NAMESPACE = originalNamespace;
|
||||
}
|
||||
});
|
||||
|
||||
test('gateway authorizes subscription and joins room', async t => {
|
||||
const registry = new RealtimeRegistry();
|
||||
registry.registerTopic({
|
||||
|
||||
@@ -19,6 +19,7 @@ import semver from 'semver';
|
||||
import type { Server, Socket } from 'socket.io';
|
||||
|
||||
import {
|
||||
checkCanaryDateClientVersion,
|
||||
GatewayErrorWrapper,
|
||||
OnEvent,
|
||||
UnsupportedClientVersion,
|
||||
@@ -35,6 +36,19 @@ const MIN_REALTIME_CLIENT_VERSION = new semver.Range('>=0.26.0-0', {
|
||||
includePrerelease: true,
|
||||
});
|
||||
|
||||
function normalizeRealtimeClientVersion(clientVersion: string): string | null {
|
||||
const canaryCheck = checkCanaryDateClientVersion(clientVersion);
|
||||
if (!canaryCheck.matched) {
|
||||
return clientVersion;
|
||||
}
|
||||
|
||||
if (!env.namespaces.canary) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return canaryCheck.allowed ? canaryCheck.normalized : null;
|
||||
}
|
||||
|
||||
@WebSocketGateway()
|
||||
@UseInterceptors(ClsInterceptor)
|
||||
export class RealtimeGateway implements OnGatewayInit, OnGatewayDisconnect {
|
||||
@@ -122,10 +136,13 @@ export class RealtimeGateway implements OnGatewayInit, OnGatewayDisconnect {
|
||||
}
|
||||
|
||||
private assertVersion(clientVersion?: string) {
|
||||
const normalized = clientVersion
|
||||
? normalizeRealtimeClientVersion(clientVersion)
|
||||
: null;
|
||||
if (
|
||||
!clientVersion ||
|
||||
!semver.valid(clientVersion) ||
|
||||
!MIN_REALTIME_CLIENT_VERSION.test(clientVersion)
|
||||
!normalized ||
|
||||
!semver.valid(normalized) ||
|
||||
!MIN_REALTIME_CLIENT_VERSION.test(normalized)
|
||||
) {
|
||||
throw new UnsupportedClientVersion({
|
||||
clientVersion: clientVersion ?? 'unset_or_invalid',
|
||||
|
||||
@@ -85,14 +85,16 @@ type SyncProtocolRoomType = Extract<RoomType, 'sync-025' | 'sync-026'>;
|
||||
const SOCKET_PRESENCE_USER_ID_KEY = 'affinePresenceUserId';
|
||||
|
||||
function normalizeWsClientVersion(clientVersion: string): string | null {
|
||||
if (env.namespaces.canary) {
|
||||
const canaryCheck = checkCanaryDateClientVersion(clientVersion);
|
||||
if (canaryCheck.matched) {
|
||||
return canaryCheck.allowed ? canaryCheck.normalized : null;
|
||||
}
|
||||
const canaryCheck = checkCanaryDateClientVersion(clientVersion);
|
||||
if (!canaryCheck.matched) {
|
||||
return clientVersion;
|
||||
}
|
||||
|
||||
return clientVersion;
|
||||
if (!env.namespaces.canary) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return canaryCheck.allowed ? canaryCheck.normalized : null;
|
||||
}
|
||||
|
||||
function isSupportedWsClientVersion(clientVersion: string): boolean {
|
||||
|
||||
@@ -186,6 +186,9 @@ class AdminDashboardInput {
|
||||
|
||||
@Field(() => Int, { nullable: true, defaultValue: 28 })
|
||||
sharedLinkWindowDays?: number;
|
||||
|
||||
@Field(() => Int, { nullable: true, defaultValue: 7 })
|
||||
copilotWindowDays?: number;
|
||||
}
|
||||
|
||||
@ObjectType()
|
||||
@@ -250,6 +253,9 @@ class AdminDashboard {
|
||||
@Field(() => SafeIntResolver)
|
||||
copilotConversations!: number;
|
||||
|
||||
@Field(() => TimeWindow)
|
||||
copilotWindow!: TimeWindow;
|
||||
|
||||
@Field(() => SafeIntResolver)
|
||||
workspaceStorageBytes!: number;
|
||||
|
||||
@@ -532,6 +538,7 @@ export class AdminWorkspaceResolver {
|
||||
storageHistoryDays: input?.storageHistoryDays,
|
||||
syncHistoryHours: input?.syncHistoryHours,
|
||||
sharedLinkWindowDays: input?.sharedLinkWindowDays,
|
||||
copilotWindowDays: input?.copilotWindowDays,
|
||||
includeTopSharedLinks,
|
||||
});
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@ import { BaseModel } from './base';
|
||||
const DEFAULT_STORAGE_HISTORY_DAYS = 30;
|
||||
const DEFAULT_SYNC_HISTORY_HOURS = 48;
|
||||
const DEFAULT_SHARED_LINK_WINDOW_DAYS = 28;
|
||||
const DEFAULT_COPILOT_WINDOW_DAYS = 7;
|
||||
const DEFAULT_ANALYTICS_WINDOW_DAYS = 28;
|
||||
const NON_TEAM_ANALYTICS_WINDOW_DAYS = 7;
|
||||
const DEFAULT_TIMEZONE = 'UTC';
|
||||
@@ -50,6 +51,7 @@ export type AdminDashboardOptions = {
|
||||
storageHistoryDays?: number;
|
||||
syncHistoryHours?: number;
|
||||
sharedLinkWindowDays?: number;
|
||||
copilotWindowDays?: number;
|
||||
includeTopSharedLinks?: boolean;
|
||||
};
|
||||
|
||||
@@ -99,6 +101,7 @@ export type AdminDashboardDto = {
|
||||
}>;
|
||||
syncWindow: TimeWindowDto;
|
||||
copilotConversations: number;
|
||||
copilotWindow: TimeWindowDto;
|
||||
workspaceStorageBytes: number;
|
||||
blobStorageBytes: number;
|
||||
workspaceStorageHistory: Array<{
|
||||
@@ -262,6 +265,12 @@ export class WorkspaceAnalyticsModel extends BaseModel {
|
||||
90,
|
||||
DEFAULT_SHARED_LINK_WINDOW_DAYS
|
||||
);
|
||||
const copilotWindowDays = clampInt(
|
||||
options.copilotWindowDays,
|
||||
1,
|
||||
90,
|
||||
DEFAULT_COPILOT_WINDOW_DAYS
|
||||
);
|
||||
const includeTopSharedLinks = options.includeTopSharedLinks ?? true;
|
||||
|
||||
const now = new Date();
|
||||
@@ -274,6 +283,7 @@ export class WorkspaceAnalyticsModel extends BaseModel {
|
||||
const currentDay = startOfUtcDay(now);
|
||||
const storageFrom = addUtcDays(currentDay, -(storageHistoryDays - 1));
|
||||
const sharedFrom = addUtcDays(currentDay, -(sharedLinkWindowDays - 1));
|
||||
const copilotFrom = addUtcDays(currentDay, -(copilotWindowDays - 1));
|
||||
|
||||
const topSharedLinksPromise = includeTopSharedLinks
|
||||
? this.db.$queryRaw<
|
||||
@@ -432,7 +442,7 @@ export class WorkspaceAnalyticsModel extends BaseModel {
|
||||
SELECT COUNT(*) AS conversations
|
||||
FROM ai_sessions_messages
|
||||
WHERE role = 'user'
|
||||
AND created_at >= ${sharedFrom}
|
||||
AND created_at >= ${copilotFrom}
|
||||
AND created_at <= ${now}
|
||||
`,
|
||||
topSharedLinksPromise,
|
||||
@@ -497,6 +507,14 @@ export class WorkspaceAnalyticsModel extends BaseModel {
|
||||
effectiveSize: syncHistoryHours,
|
||||
},
|
||||
copilotConversations: Number(copilotCount[0]?.conversations ?? 0),
|
||||
copilotWindow: {
|
||||
from: copilotFrom,
|
||||
to: now,
|
||||
timezone,
|
||||
bucket: 'Day',
|
||||
requestedSize: options.copilotWindowDays ?? DEFAULT_COPILOT_WINDOW_DAYS,
|
||||
effectiveSize: copilotWindowDays,
|
||||
},
|
||||
workspaceStorageBytes: currentWorkspaceStorageBytes,
|
||||
blobStorageBytes: currentBlobStorageBytes,
|
||||
workspaceStorageHistory: storageHistorySeries.map(row => ({
|
||||
|
||||
@@ -63,6 +63,7 @@ type AdminDashboard {
|
||||
blobStorageBytes: SafeInt!
|
||||
blobStorageHistory: [AdminDashboardValueDayPoint!]!
|
||||
copilotConversations: SafeInt!
|
||||
copilotWindow: TimeWindow!
|
||||
generatedAt: DateTime!
|
||||
storageWindow: TimeWindow!
|
||||
syncActiveUsers: Int!
|
||||
@@ -75,6 +76,7 @@ type AdminDashboard {
|
||||
}
|
||||
|
||||
input AdminDashboardInput {
|
||||
copilotWindowDays: Int = 7
|
||||
sharedLinkWindowDays: Int = 28
|
||||
storageHistoryDays: Int = 30
|
||||
syncHistoryHours: Int = 48
|
||||
|
||||
@@ -14,6 +14,14 @@ query adminDashboard($input: AdminDashboardInput) {
|
||||
effectiveSize
|
||||
}
|
||||
copilotConversations
|
||||
copilotWindow {
|
||||
from
|
||||
to
|
||||
timezone
|
||||
bucket
|
||||
requestedSize
|
||||
effectiveSize
|
||||
}
|
||||
workspaceStorageBytes
|
||||
blobStorageBytes
|
||||
workspaceStorageHistory {
|
||||
|
||||
@@ -149,6 +149,14 @@ export const adminDashboardQuery = {
|
||||
effectiveSize
|
||||
}
|
||||
copilotConversations
|
||||
copilotWindow {
|
||||
from
|
||||
to
|
||||
timezone
|
||||
bucket
|
||||
requestedSize
|
||||
effectiveSize
|
||||
}
|
||||
workspaceStorageBytes
|
||||
blobStorageBytes
|
||||
workspaceStorageHistory {
|
||||
|
||||
@@ -102,6 +102,7 @@ export interface AdminDashboard {
|
||||
blobStorageBytes: Scalars['SafeInt']['output'];
|
||||
blobStorageHistory: Array<AdminDashboardValueDayPoint>;
|
||||
copilotConversations: Scalars['SafeInt']['output'];
|
||||
copilotWindow: TimeWindow;
|
||||
generatedAt: Scalars['DateTime']['output'];
|
||||
storageWindow: TimeWindow;
|
||||
syncActiveUsers: Scalars['Int']['output'];
|
||||
@@ -114,6 +115,7 @@ export interface AdminDashboard {
|
||||
}
|
||||
|
||||
export interface AdminDashboardInput {
|
||||
copilotWindowDays?: InputMaybe<Scalars['Int']['input']>;
|
||||
sharedLinkWindowDays?: InputMaybe<Scalars['Int']['input']>;
|
||||
storageHistoryDays?: InputMaybe<Scalars['Int']['input']>;
|
||||
syncHistoryHours?: InputMaybe<Scalars['Int']['input']>;
|
||||
@@ -3911,6 +3913,15 @@ export type AdminDashboardQuery = {
|
||||
requestedSize: number;
|
||||
effectiveSize: number;
|
||||
};
|
||||
copilotWindow: {
|
||||
__typename?: 'TimeWindow';
|
||||
from: string;
|
||||
to: string;
|
||||
timezone: string;
|
||||
bucket: TimeBucket;
|
||||
requestedSize: number;
|
||||
effectiveSize: number;
|
||||
};
|
||||
workspaceStorageHistory: Array<{
|
||||
__typename?: 'AdminDashboardValueDayPoint';
|
||||
date: string;
|
||||
|
||||
@@ -71,6 +71,14 @@ const dashboardData = {
|
||||
effectiveSize: 48,
|
||||
},
|
||||
copilotConversations: 0,
|
||||
copilotWindow: {
|
||||
from: '2026-02-10T00:00:00.000Z',
|
||||
to: '2026-02-16T00:00:00.000Z',
|
||||
timezone: 'UTC',
|
||||
bucket: 'Day',
|
||||
requestedSize: 7,
|
||||
effectiveSize: 7,
|
||||
},
|
||||
workspaceStorageBytes: 375,
|
||||
blobStorageBytes: 0,
|
||||
workspaceStorageHistory: [{ date: '2026-02-16', value: 375 }],
|
||||
@@ -159,19 +167,49 @@ const mailDeliveryData = {
|
||||
},
|
||||
};
|
||||
|
||||
const topSharedLinksData = {
|
||||
adminDashboard: {
|
||||
topSharedLinks: [
|
||||
{
|
||||
workspaceId: 'workspace-1',
|
||||
docId: 'doc-1',
|
||||
title: 'Public doc',
|
||||
shareUrl: 'https://app.affine.pro/workspace/workspace-1/doc-1',
|
||||
publishedAt: '2026-02-16T10:00:00.000Z',
|
||||
views: 12,
|
||||
uniqueViews: 8,
|
||||
guestViews: 6,
|
||||
lastAccessedAt: '2026-02-16T19:00:00.000Z',
|
||||
},
|
||||
],
|
||||
topSharedLinksWindow: {
|
||||
from: '2026-01-20T00:00:00.000Z',
|
||||
to: '2026-02-16T00:00:00.000Z',
|
||||
timezone: 'UTC',
|
||||
bucket: 'Day',
|
||||
requestedSize: 28,
|
||||
effectiveSize: 28,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
describe('DashboardPage', () => {
|
||||
beforeEach(() => {
|
||||
(globalThis as any).environment = {
|
||||
isSelfHosted: true,
|
||||
};
|
||||
useQueryMock.mockReset();
|
||||
useQueryMock.mockImplementation(({ query }: { query: { id: string } }) => ({
|
||||
data:
|
||||
query.id === 'adminMailDeliveriesQuery'
|
||||
? mailDeliveryData
|
||||
: dashboardData,
|
||||
isValidating: false,
|
||||
}));
|
||||
useQueryMock.mockImplementation(
|
||||
({ query }: { query: { id: string; query: string } }) => ({
|
||||
data:
|
||||
query.id === 'adminMailDeliveriesQuery'
|
||||
? mailDeliveryData
|
||||
: query.query.includes('topSharedLinks')
|
||||
? topSharedLinksData
|
||||
: dashboardData,
|
||||
isValidating: false,
|
||||
})
|
||||
);
|
||||
mutateQueryResourceMock.mockReset();
|
||||
});
|
||||
|
||||
@@ -202,12 +240,47 @@ describe('DashboardPage', () => {
|
||||
});
|
||||
|
||||
test('renders mail delivery analytics controls and summary', () => {
|
||||
const { getByText } = render(<DashboardPage />);
|
||||
const { getAllByText, getByText, queryByText } = render(<DashboardPage />);
|
||||
|
||||
expect(getByText('Email Delivery Trend')).toBeTruthy();
|
||||
expect(getByText('Mail Delivery')).toBeTruthy();
|
||||
expect(getByText('24 hours')).toBeTruthy();
|
||||
expect(queryByText('Window Controls')).toBeNull();
|
||||
expect(getByText('Status')).toBeTruthy();
|
||||
expect(getAllByText('24h').length).toBeGreaterThan(0);
|
||||
expect(getByText('Success rate')).toBeTruthy();
|
||||
expect(getByText('66.7%')).toBeTruthy();
|
||||
});
|
||||
|
||||
test('sends independent dashboard windows to the overview query', () => {
|
||||
render(<DashboardPage />);
|
||||
|
||||
const overviewCall = useQueryMock.mock.calls.find(([arg]) => {
|
||||
const query = (arg as { query: { query: string } }).query.query;
|
||||
return query.includes('syncActiveUsersTimeline');
|
||||
});
|
||||
|
||||
expect(overviewCall).toBeTruthy();
|
||||
expect(overviewCall![0]).toMatchObject({
|
||||
variables: {
|
||||
input: {
|
||||
storageHistoryDays: 30,
|
||||
syncHistoryHours: 48,
|
||||
copilotWindowDays: 7,
|
||||
sharedLinkWindowDays: 28,
|
||||
timezone: 'UTC',
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test('renders top shared links range control in cloud mode', () => {
|
||||
(globalThis as any).environment = {
|
||||
isSelfHosted: false,
|
||||
};
|
||||
|
||||
const { getByLabelText, getByText } = render(<DashboardPage />);
|
||||
|
||||
expect(getByText('Top Shared Links')).toBeTruthy();
|
||||
expect(getByLabelText('Top shared links range')).toBeTruthy();
|
||||
expect(getByText('28d')).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -26,7 +26,6 @@ import {
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from '@affine/admin/components/ui/dropdown-menu';
|
||||
import { Label } from '@affine/admin/components/ui/label';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
@@ -96,6 +95,14 @@ const adminDashboardOverviewQuery: typeof adminDashboardQuery = {
|
||||
effectiveSize
|
||||
}
|
||||
copilotConversations
|
||||
copilotWindow {
|
||||
from
|
||||
to
|
||||
timezone
|
||||
bucket
|
||||
requestedSize
|
||||
effectiveSize
|
||||
}
|
||||
workspaceStorageBytes
|
||||
blobStorageBytes
|
||||
workspaceStorageHistory {
|
||||
@@ -171,6 +178,8 @@ const utcDateFormatter = new Intl.DateTimeFormat('en-US', {
|
||||
const STORAGE_DAY_OPTIONS = [7, 14, 30, 60, 90] as const;
|
||||
const SYNC_HOUR_OPTIONS = [1, 6, 12, 24, 48, 72] as const;
|
||||
const SHARED_DAY_OPTIONS = [7, 14, 28, 60, 90] as const;
|
||||
const COPILOT_DAY_OPTIONS = [1, 7, 14, 28, 60, 90] as const;
|
||||
const MAIL_HOUR_OPTIONS = [24, 168] as const;
|
||||
type MailChartMode = 'status' | 'type' | 'outcome';
|
||||
|
||||
type DualNumberPoint = {
|
||||
@@ -514,19 +523,24 @@ function SecondaryMetricCard({
|
||||
value,
|
||||
description,
|
||||
icon,
|
||||
action,
|
||||
}: {
|
||||
title: string;
|
||||
value: string;
|
||||
description: string;
|
||||
icon: ReactNode;
|
||||
action?: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<Card className="h-full border-border/60 bg-card shadow-1">
|
||||
<CardHeader className="pb-2">
|
||||
<CardDescription className="flex items-center gap-2 text-sm">
|
||||
<span aria-hidden="true">{icon}</span>
|
||||
{title}
|
||||
<CardHeader className="flex flex-row items-start justify-between gap-3 pb-2">
|
||||
<CardDescription className="flex min-w-0 items-center gap-2 pt-2 text-sm">
|
||||
<span className="shrink-0" aria-hidden="true">
|
||||
{icon}
|
||||
</span>
|
||||
<span className="min-w-0 truncate">{title}</span>
|
||||
</CardDescription>
|
||||
{action ? <div className="shrink-0">{action}</div> : null}
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-semibold tracking-tight tabular-nums">
|
||||
@@ -538,76 +552,42 @@ function SecondaryMetricCard({
|
||||
);
|
||||
}
|
||||
|
||||
function WindowSelect({
|
||||
id,
|
||||
label,
|
||||
function rangeOptionLabel(option: number, unit: 'hours' | 'days') {
|
||||
if (unit === 'hours') {
|
||||
return option === 168 ? '7d' : `${option}h`;
|
||||
}
|
||||
return `${option}d`;
|
||||
}
|
||||
|
||||
function PanelRangeSelect({
|
||||
ariaLabel,
|
||||
value,
|
||||
options,
|
||||
unit,
|
||||
onChange,
|
||||
}: {
|
||||
id: string;
|
||||
label: string;
|
||||
ariaLabel: string;
|
||||
value: number;
|
||||
options: readonly number[];
|
||||
unit: string;
|
||||
unit: 'hours' | 'days';
|
||||
onChange: (value: number) => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex min-w-0 flex-col gap-2">
|
||||
<Label
|
||||
htmlFor={id}
|
||||
className="text-xs uppercase tracking-wide text-muted-foreground"
|
||||
>
|
||||
{label}
|
||||
</Label>
|
||||
<Select
|
||||
value={String(value)}
|
||||
onValueChange={next => onChange(Number(next))}
|
||||
>
|
||||
<SelectTrigger id={id}>
|
||||
<SelectValue placeholder={`Select ${label.toLowerCase()}…`} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{options.map(option => (
|
||||
<SelectItem key={option} value={String(option)}>
|
||||
{option} {unit}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function MailWindowSelect({
|
||||
value,
|
||||
onChange,
|
||||
}: {
|
||||
value: number;
|
||||
onChange: (value: number) => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex min-w-0 flex-col gap-2">
|
||||
<Label
|
||||
htmlFor="mail-delivery-window"
|
||||
className="text-xs uppercase tracking-wide text-muted-foreground"
|
||||
>
|
||||
Mail Delivery
|
||||
</Label>
|
||||
<Select
|
||||
value={String(value)}
|
||||
onValueChange={next => onChange(Number(next))}
|
||||
>
|
||||
<SelectTrigger id="mail-delivery-window">
|
||||
<SelectValue placeholder="Select mail window…" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="24">24 hours</SelectItem>
|
||||
<SelectItem value="168">7 days</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<Select
|
||||
value={String(value)}
|
||||
onValueChange={next => onChange(Number(next))}
|
||||
>
|
||||
<SelectTrigger className="w-full md:w-28" aria-label={ariaLabel}>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{options.map(option => (
|
||||
<SelectItem key={option} value={String(option)}>
|
||||
{rangeOptionLabel(option, unit)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -870,8 +850,10 @@ function TopSharedLinksCardSkeleton() {
|
||||
|
||||
function TopSharedLinksSection({
|
||||
sharedLinkWindowDays,
|
||||
onWindowChange,
|
||||
}: {
|
||||
sharedLinkWindowDays: number;
|
||||
onWindowChange: (value: number) => void;
|
||||
}) {
|
||||
const variables = useMemo(
|
||||
() => ({
|
||||
@@ -901,12 +883,21 @@ function TopSharedLinksSection({
|
||||
|
||||
return (
|
||||
<Card className="border-border/60 bg-card shadow-1">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">Top Shared Links</CardTitle>
|
||||
<CardDescription>
|
||||
Top {topSharedLinks.length} links in the last{' '}
|
||||
{topSharedLinksWindow.effectiveSize} days
|
||||
</CardDescription>
|
||||
<CardHeader className="flex flex-col gap-3 pb-4 md:flex-row md:items-start md:justify-between">
|
||||
<div className="space-y-1.5">
|
||||
<CardTitle className="text-base">Top Shared Links</CardTitle>
|
||||
<CardDescription>
|
||||
Top {topSharedLinks.length} links in the last{' '}
|
||||
{topSharedLinksWindow.effectiveSize} days
|
||||
</CardDescription>
|
||||
</div>
|
||||
<PanelRangeSelect
|
||||
ariaLabel="Top shared links range"
|
||||
value={sharedLinkWindowDays}
|
||||
options={SHARED_DAY_OPTIONS}
|
||||
unit="days"
|
||||
onChange={onWindowChange}
|
||||
/>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{topSharedLinks.length === 0 ? (
|
||||
@@ -1058,7 +1049,13 @@ function combineMailSeries(
|
||||
};
|
||||
}
|
||||
|
||||
function MailDeliverySection({ hours }: { hours: number }) {
|
||||
function MailDeliverySection({
|
||||
hours,
|
||||
onHoursChange,
|
||||
}: {
|
||||
hours: number;
|
||||
onHoursChange: (value: number) => void;
|
||||
}) {
|
||||
const [mode, setMode] = useState<MailChartMode>('status');
|
||||
const { data } = useQuery(
|
||||
{
|
||||
@@ -1122,19 +1119,28 @@ function MailDeliverySection({ hours }: { hours: number }) {
|
||||
{analytics.window.bucket === 'Hour' ? 'hour' : 'day'} bucket in UTC
|
||||
</CardDescription>
|
||||
</div>
|
||||
<Select
|
||||
value={mode}
|
||||
onValueChange={value => setMode(value as MailChartMode)}
|
||||
>
|
||||
<SelectTrigger className="w-full md:w-44">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="status">Status</SelectItem>
|
||||
<SelectItem value="type">Mail type</SelectItem>
|
||||
<SelectItem value="outcome">Success / failure</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<div className="flex w-full flex-col gap-2 md:w-auto md:flex-row">
|
||||
<PanelRangeSelect
|
||||
ariaLabel="Email delivery range"
|
||||
value={hours}
|
||||
options={MAIL_HOUR_OPTIONS}
|
||||
unit="hours"
|
||||
onChange={onHoursChange}
|
||||
/>
|
||||
<Select
|
||||
value={mode}
|
||||
onValueChange={value => setMode(value as MailChartMode)}
|
||||
>
|
||||
<SelectTrigger className="w-full md:w-44">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="status">Status</SelectItem>
|
||||
<SelectItem value="type">Mail type</SelectItem>
|
||||
<SelectItem value="outcome">Success / failure</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="grid grid-cols-2 gap-3 md:grid-cols-4">
|
||||
@@ -1219,6 +1225,7 @@ function MailDeliveryCardSkeleton() {
|
||||
function DashboardPageContent() {
|
||||
const [storageHistoryDays, setStorageHistoryDays] = useState<number>(30);
|
||||
const [syncHistoryHours, setSyncHistoryHours] = useState<number>(48);
|
||||
const [copilotWindowDays, setCopilotWindowDays] = useState<number>(7);
|
||||
const [sharedLinkWindowDays, setSharedLinkWindowDays] = useState<number>(28);
|
||||
const [mailDeliveryHours, setMailDeliveryHours] = useState<number>(24);
|
||||
const shouldShowTopSharedLinks = !environment.isSelfHosted;
|
||||
@@ -1229,11 +1236,17 @@ function DashboardPageContent() {
|
||||
input: {
|
||||
storageHistoryDays,
|
||||
syncHistoryHours,
|
||||
copilotWindowDays,
|
||||
sharedLinkWindowDays,
|
||||
timezone: 'UTC',
|
||||
},
|
||||
}),
|
||||
[sharedLinkWindowDays, storageHistoryDays, syncHistoryHours]
|
||||
[
|
||||
copilotWindowDays,
|
||||
sharedLinkWindowDays,
|
||||
storageHistoryDays,
|
||||
syncHistoryHours,
|
||||
]
|
||||
);
|
||||
|
||||
const { data, isValidating } = useQuery(
|
||||
@@ -1296,46 +1309,6 @@ function DashboardPageContent() {
|
||||
/>
|
||||
|
||||
<div className="flex-1 overflow-auto p-6 space-y-6">
|
||||
<Card className="border-border/60 bg-card shadow-1">
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-base">Window Controls</CardTitle>
|
||||
<CardDescription>
|
||||
Tune dashboard windows. Data is sampled in UTC and refreshes
|
||||
automatically.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="grid grid-cols-1 items-end gap-3 md:grid-cols-2 lg:grid-cols-4">
|
||||
<WindowSelect
|
||||
id="storage-history-window"
|
||||
label="Storage History"
|
||||
value={storageHistoryDays}
|
||||
options={STORAGE_DAY_OPTIONS}
|
||||
unit="days"
|
||||
onChange={setStorageHistoryDays}
|
||||
/>
|
||||
<WindowSelect
|
||||
id="sync-history-window"
|
||||
label="Sync History"
|
||||
value={syncHistoryHours}
|
||||
options={SYNC_HOUR_OPTIONS}
|
||||
unit="hours"
|
||||
onChange={setSyncHistoryHours}
|
||||
/>
|
||||
<WindowSelect
|
||||
id="shared-link-window"
|
||||
label="Shared Link Window"
|
||||
value={sharedLinkWindowDays}
|
||||
options={SHARED_DAY_OPTIONS}
|
||||
unit="days"
|
||||
onChange={setSharedLinkWindowDays}
|
||||
/>
|
||||
<MailWindowSelect
|
||||
value={mailDeliveryHours}
|
||||
onChange={setMailDeliveryHours}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<div className="grid grid-cols-1 gap-5 lg:grid-cols-12">
|
||||
<div className="h-full min-w-0 lg:col-span-5">
|
||||
<PrimaryMetricCard
|
||||
@@ -1347,10 +1320,19 @@ function DashboardPageContent() {
|
||||
<SecondaryMetricCard
|
||||
title="Copilot Conversations"
|
||||
value={intFormatter.format(dashboard.copilotConversations)}
|
||||
description={`${sharedLinkWindowDays}d aggregation`}
|
||||
description={`${dashboard.copilotWindow.effectiveSize}d aggregation`}
|
||||
icon={
|
||||
<MessageSquareTextIcon className="h-4 w-4" aria-hidden="true" />
|
||||
}
|
||||
action={
|
||||
<PanelRangeSelect
|
||||
ariaLabel="Copilot conversations range"
|
||||
value={copilotWindowDays}
|
||||
options={COPILOT_DAY_OPTIONS}
|
||||
unit="days"
|
||||
onChange={setCopilotWindowDays}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="h-full min-w-0 lg:col-span-4">
|
||||
@@ -1376,13 +1358,22 @@ function DashboardPageContent() {
|
||||
|
||||
<div className="grid grid-cols-1 gap-5 lg:grid-cols-3">
|
||||
<Card className="border-border/60 bg-card shadow-1 lg:col-span-1">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">
|
||||
Sync Active Users Trend
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
{dashboard.syncWindow.effectiveSize}h at minute bucket
|
||||
</CardDescription>
|
||||
<CardHeader className="flex flex-col gap-3 pb-4 md:flex-row md:items-start md:justify-between">
|
||||
<div className="space-y-1.5">
|
||||
<CardTitle className="text-base">
|
||||
Sync Active Users Trend
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
{dashboard.syncWindow.effectiveSize}h at minute bucket
|
||||
</CardDescription>
|
||||
</div>
|
||||
<PanelRangeSelect
|
||||
ariaLabel="Sync active users range"
|
||||
value={syncHistoryHours}
|
||||
options={SYNC_HOUR_OPTIONS}
|
||||
unit="hours"
|
||||
onChange={setSyncHistoryHours}
|
||||
/>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<TrendChart
|
||||
@@ -1395,13 +1386,22 @@ function DashboardPageContent() {
|
||||
</Card>
|
||||
|
||||
<Card className="border-border/60 bg-card shadow-1 lg:col-span-2">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">
|
||||
Storage Trend (Workspace + Blob)
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
{dashboard.storageWindow.effectiveSize}d at day bucket
|
||||
</CardDescription>
|
||||
<CardHeader className="flex flex-col gap-3 pb-4 md:flex-row md:items-start md:justify-between">
|
||||
<div className="space-y-1.5">
|
||||
<CardTitle className="text-base">
|
||||
Storage Trend (Workspace + Blob)
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
{dashboard.storageWindow.effectiveSize}d at day bucket
|
||||
</CardDescription>
|
||||
</div>
|
||||
<PanelRangeSelect
|
||||
ariaLabel="Storage trend range"
|
||||
value={storageHistoryDays}
|
||||
options={STORAGE_DAY_OPTIONS}
|
||||
unit="days"
|
||||
onChange={setStorageHistoryDays}
|
||||
/>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<TrendChart
|
||||
@@ -1428,13 +1428,17 @@ function DashboardPageContent() {
|
||||
</div>
|
||||
|
||||
<Suspense fallback={<MailDeliveryCardSkeleton />}>
|
||||
<MailDeliverySection hours={mailDeliveryHours} />
|
||||
<MailDeliverySection
|
||||
hours={mailDeliveryHours}
|
||||
onHoursChange={setMailDeliveryHours}
|
||||
/>
|
||||
</Suspense>
|
||||
|
||||
{shouldShowTopSharedLinks ? (
|
||||
<Suspense fallback={<TopSharedLinksCardSkeleton />}>
|
||||
<TopSharedLinksSection
|
||||
sharedLinkWindowDays={sharedLinkWindowDays}
|
||||
onWindowChange={setSharedLinkWindowDays}
|
||||
/>
|
||||
</Suspense>
|
||||
) : null}
|
||||
|
||||
+30
-19
@@ -73,11 +73,15 @@ const releaseChildProcessHandles = (child: ChildProcess) => {
|
||||
}
|
||||
};
|
||||
|
||||
const withTimeoutFallback = async <T>(promise: Promise<T>, fallback: T) => {
|
||||
const withTimeoutFallback = async <T>(
|
||||
promise: Promise<T>,
|
||||
fallback: T,
|
||||
timeoutMs = 1_000
|
||||
) => {
|
||||
try {
|
||||
return await Promise.race([
|
||||
promise,
|
||||
setTimeout(1_000).then(() => fallback),
|
||||
setTimeout(timeoutMs).then(() => fallback),
|
||||
]);
|
||||
} catch {
|
||||
return fallback;
|
||||
@@ -89,7 +93,8 @@ const getPageId = async (page: Page) => {
|
||||
page.evaluate(() => {
|
||||
return (window.__appInfo as any)?.viewId as string | undefined;
|
||||
}),
|
||||
undefined
|
||||
undefined,
|
||||
500
|
||||
);
|
||||
};
|
||||
|
||||
@@ -98,7 +103,8 @@ const isActivePage = async (page: Page) => {
|
||||
page.evaluate(async () => {
|
||||
return (await (window as any).__apis?.ui.isActiveTab()) === true;
|
||||
}),
|
||||
false
|
||||
false,
|
||||
500
|
||||
);
|
||||
};
|
||||
|
||||
@@ -113,22 +119,27 @@ const isEditorPage = async (page: Page) => {
|
||||
};
|
||||
|
||||
const getActivePage = async (pages: Page[]) => {
|
||||
for (const page of pages) {
|
||||
if (await isActivePage(page)) {
|
||||
return page;
|
||||
}
|
||||
const activeChecks = await Promise.all(
|
||||
pages.map(async page => ((await isActivePage(page)) ? page : null))
|
||||
);
|
||||
for (const page of activeChecks) {
|
||||
if (page) return page;
|
||||
}
|
||||
|
||||
const contentPages: Page[] = [];
|
||||
for (const page of pages) {
|
||||
const pageId = await getPageId(page);
|
||||
if (pageId === 'shell') {
|
||||
continue;
|
||||
}
|
||||
if (pageId || (await isEditorPage(page))) {
|
||||
contentPages.push(page);
|
||||
}
|
||||
}
|
||||
const contentPages = (
|
||||
await Promise.all(
|
||||
pages.map(async page => {
|
||||
const pageId = await getPageId(page);
|
||||
if (pageId === 'shell') {
|
||||
return null;
|
||||
}
|
||||
if (pageId || (await isEditorPage(page))) {
|
||||
return page;
|
||||
}
|
||||
return null;
|
||||
})
|
||||
)
|
||||
).filter((page): page is Page => !!page);
|
||||
|
||||
if (contentPages.length > 0) {
|
||||
return contentPages.at(-1) ?? null;
|
||||
@@ -153,7 +164,7 @@ const waitForElectronPage = async (
|
||||
) => {
|
||||
const deadline =
|
||||
Date.now() +
|
||||
(process.env.CI && process.platform === 'darwin' ? 20_000 : 10_000);
|
||||
(process.env.CI && process.platform === 'darwin' ? 25_000 : 20_000);
|
||||
|
||||
while (Date.now() < deadline) {
|
||||
const page = await getPage(electronApp.windows());
|
||||
|
||||
@@ -177,30 +177,23 @@ export const dragTo = async (
|
||||
location: DragLocation = 'center',
|
||||
willMoveOnDrag = false
|
||||
) => {
|
||||
await locator.hover();
|
||||
const locatorElement = await locator.boundingBox();
|
||||
if (!locatorElement) {
|
||||
throw new Error('locator element not found');
|
||||
}
|
||||
const locatorCenter = toPosition(locatorElement, 'center');
|
||||
await page.mouse.move(
|
||||
locatorElement.x + locatorCenter.x,
|
||||
locatorElement.y + locatorCenter.y
|
||||
);
|
||||
const start = {
|
||||
x: locatorElement.x + locatorCenter.x,
|
||||
y: locatorElement.y + locatorCenter.y,
|
||||
};
|
||||
await page.mouse.move(start.x, start.y);
|
||||
await page.mouse.down();
|
||||
await page.waitForTimeout(100);
|
||||
await page.mouse.move(
|
||||
locatorElement.x + locatorCenter.x + 1,
|
||||
locatorElement.y + locatorCenter.y + 1
|
||||
);
|
||||
await page.mouse.move(start.x + 8, start.y + 8, { steps: 4 });
|
||||
|
||||
await page.mouse.move(1, 1, {
|
||||
steps: 10,
|
||||
});
|
||||
|
||||
await target.hover();
|
||||
|
||||
if (!willMoveOnDrag) {
|
||||
if (willMoveOnDrag) {
|
||||
await target.hover();
|
||||
} else {
|
||||
const targetElement = await target.boundingBox();
|
||||
if (!targetElement) {
|
||||
throw new Error('target element not found');
|
||||
@@ -210,11 +203,11 @@ export const dragTo = async (
|
||||
targetElement.x + targetPosition.x,
|
||||
targetElement.y + targetPosition.y,
|
||||
{
|
||||
steps: 10,
|
||||
steps: 24,
|
||||
}
|
||||
);
|
||||
}
|
||||
await page.waitForTimeout(100);
|
||||
await page.waitForTimeout(200);
|
||||
await page.mouse.up();
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user