From fe2a4db76bfb75b2b68b2085d7fb39d3ca983fe2 Mon Sep 17 00:00:00 2001 From: DarkSky <25152247+darkskygit@users.noreply.github.com> Date: Tue, 7 Jul 2026 12:13:11 +0800 Subject: [PATCH] feat(core): improve dashboard style (#15205) --- .../copilot/__snapshots__/copilot.e2e.ts.snap | Bin 1175 -> 708 bytes .../src/__tests__/copilot/copilot.e2e.ts | 40 +-- .../e2e/workspace/admin-analytics.spec.ts | 14 + .../server/src/__tests__/sync/gateway.spec.ts | 37 +++ .../server/src/__tests__/worker.e2e.ts | 4 + .../core/realtime/__tests__/registry.spec.ts | 72 +++++ .../server/src/core/realtime/gateway.ts | 23 +- .../backend/server/src/core/sync/gateway.ts | 14 +- .../src/core/workspaces/resolvers/admin.ts | 7 + .../server/src/models/workspace-analytics.ts | 20 +- packages/backend/server/src/schema.gql | 2 + .../src/graphql/admin/admin-dashboard.gql | 8 + packages/common/graphql/src/graphql/index.ts | 8 + packages/common/graphql/src/schema.ts | 11 + .../src/modules/dashboard/index.spec.tsx | 93 +++++- .../admin/src/modules/dashboard/index.tsx | 288 +++++++++--------- tests/kit/src/electron.ts | 49 +-- tests/kit/src/utils/page-logic.ts | 29 +- 18 files changed, 502 insertions(+), 217 deletions(-) diff --git a/packages/backend/server/src/__tests__/copilot/__snapshots__/copilot.e2e.ts.snap b/packages/backend/server/src/__tests__/copilot/__snapshots__/copilot.e2e.ts.snap index 59261fcfc6d46f5c334b15d6eb9701cf0490e629..0e73e8fccdddbf471e081e81b884a5826a6a7479 100644 GIT binary patch literal 708 zcmV;#0z3UdRzVQ@b#yX zZx#8q!{vW3<=3t=PJo*%;!iXO7T;<3ga*A)9 z)%=D9fB^t}0)Q_7Fju8`FaY(clVM`Rjw;LRo$SumV7NU9hfhp5*~ax}Hy_DWB_7JB z^4?l^!*n*sS$AyihSk6<$arA-&-FGIv0zo`Mx-<7izL!4!5f*1s*1=9KcHDYBW*Or zs0wo~7o>AX3b;Jd3DT77i;ri;TI&Ho3IMyupQmal13g38R91G>t9FpUl~e|8 ztymx(PNJ@K6rn@oIU$j@hWl}b#D!6OOC(xN7G=(+S^hfmPH5xlR~mN;YOl{qEsy}9 zk0(}5#s2S1fnj}rmt}AHFLD3W(7HZlLRJ>#+@^gO4Sx7zv3Ps>STyo1%(6dic~x2Y qI~c73#^V#inC||YNEqu&jK$iCNhm9i#P9=6EgkEF2><}3#Zd_W literal 1175 zcmV;I1Zev~RzVliYlZcA{r_b z5E7yw4&&W%yz%bLc4nN!;RX>BKh#PHE(i(Q3mg!KaNvZvApQVUaplB`3y1>(F4UV{ zhpf{$QBoDE`r?`0dF%H+@4fHN_-eBetDtSxt_d62SgbJ9Qk^(t4Z(OL7R)N9h1FfQ z9N8A*X%rdBQ`1t`ym2iIx#bt>vWpS)K2#-V62M~sj#1?(`RBCeU7}(%K0XdQ1R~HF z(b>YtYXi6h;9UTp7si$O#-a!;IsM_p#02Ot$ORoC`X0bNy|I6ks);a$F9-t~$1!q> z>ysW-^Y9Wv$3q zrMrwbttz@GXF!0948p2MfR}XR;Bi z##~rTADv52FLjnzJB{UM<$SyE8jGnkY?d7|mfMc$FHE%xgfWp^$g^{Qjyg+8PM0;KSeOm4 zFMqvXuI>VW1iwAswzU|@*RbYx*J3d%zR&!|r9i{)Ov$Y(@ zmHpyy0szr)6tr`sBf2ByW|$A(E-X#2>0)u8{qftJZ2Wwc5<|;{#|@!P`O?b2?YXXe`&Z^qAAN(yFc8imManMI5V1 zCYFTmDk-LR=VpDTUVi?7n@{b=&FM3{a&z*0G|6m>%Qow(4r?mS7FE>iNM>7tB|_M2 ztU}(M%A54Tyh*cy-ML5a2%Yg?yQ?#|RA>D62zZ}>j|upUfc0T|<7Irm>Fdp}x!#Ns zJ>a0wqnCO^H~WRR9pH)seB}Vw9pJCwDpYfUGcNFo3%u?ESBI!jmHgjb;7=Dg;Q>#2 zK;Yqb$z9$FEP+FAf@J8Hufa?c#4Ek{I#~?pD`)nR^vXD=PD%c6e pcn;(Iu3>Cg|DQ-mr4t?xBTKgT! { 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 diff --git a/packages/backend/server/src/__tests__/e2e/workspace/admin-analytics.spec.ts b/packages/backend/server/src/__tests__/e2e/workspace/admin-analytics.spec.ts index 5a286a09d8..37d0fcb5d7 100644 --- a/packages/backend/server/src/__tests__/e2e/workspace/admin-analytics.spec.ts +++ b/packages/backend/server/src/__tests__/e2e/workspace/admin-analytics.spec.ts @@ -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); } diff --git a/packages/backend/server/src/__tests__/sync/gateway.spec.ts b/packages/backend/server/src/__tests__/sync/gateway.spec.ts index 6a644963ae..1e0f523059 100644 --- a/packages/backend/server/src/__tests__/sync/gateway.spec.ts +++ b/packages/backend/server/src/__tests__/sync/gateway.spec.ts @@ -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; diff --git a/packages/backend/server/src/__tests__/worker.e2e.ts b/packages/backend/server/src/__tests__/worker.e2e.ts index 0b20652740..32ee91ab88 100644 --- a/packages/backend/server/src/__tests__/worker.e2e.ts +++ b/packages/backend/server/src/__tests__/worker.e2e.ts @@ -12,6 +12,7 @@ type TestContext = { const test = ava.serial as TestFn; 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(); }); diff --git a/packages/backend/server/src/core/realtime/__tests__/registry.spec.ts b/packages/backend/server/src/core/realtime/__tests__/registry.spec.ts index 8592887384..98d6c34487 100644 --- a/packages/backend/server/src/core/realtime/__tests__/registry.spec.ts +++ b/packages/backend/server/src/core/realtime/__tests__/registry.spec.ts @@ -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({ diff --git a/packages/backend/server/src/core/realtime/gateway.ts b/packages/backend/server/src/core/realtime/gateway.ts index ce399f1d19..1ff94ca8c6 100644 --- a/packages/backend/server/src/core/realtime/gateway.ts +++ b/packages/backend/server/src/core/realtime/gateway.ts @@ -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', diff --git a/packages/backend/server/src/core/sync/gateway.ts b/packages/backend/server/src/core/sync/gateway.ts index 0b2015a45c..3b2443918b 100644 --- a/packages/backend/server/src/core/sync/gateway.ts +++ b/packages/backend/server/src/core/sync/gateway.ts @@ -85,14 +85,16 @@ type SyncProtocolRoomType = Extract; 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 { diff --git a/packages/backend/server/src/core/workspaces/resolvers/admin.ts b/packages/backend/server/src/core/workspaces/resolvers/admin.ts index 658b2f65d4..9de611a15e 100644 --- a/packages/backend/server/src/core/workspaces/resolvers/admin.ts +++ b/packages/backend/server/src/core/workspaces/resolvers/admin.ts @@ -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, }); diff --git a/packages/backend/server/src/models/workspace-analytics.ts b/packages/backend/server/src/models/workspace-analytics.ts index 870f6d2c6f..4966e8f5f4 100644 --- a/packages/backend/server/src/models/workspace-analytics.ts +++ b/packages/backend/server/src/models/workspace-analytics.ts @@ -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 => ({ diff --git a/packages/backend/server/src/schema.gql b/packages/backend/server/src/schema.gql index c825f88533..cd8f8b049c 100644 --- a/packages/backend/server/src/schema.gql +++ b/packages/backend/server/src/schema.gql @@ -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 diff --git a/packages/common/graphql/src/graphql/admin/admin-dashboard.gql b/packages/common/graphql/src/graphql/admin/admin-dashboard.gql index a305360138..0dc2ead2a3 100644 --- a/packages/common/graphql/src/graphql/admin/admin-dashboard.gql +++ b/packages/common/graphql/src/graphql/admin/admin-dashboard.gql @@ -14,6 +14,14 @@ query adminDashboard($input: AdminDashboardInput) { effectiveSize } copilotConversations + copilotWindow { + from + to + timezone + bucket + requestedSize + effectiveSize + } workspaceStorageBytes blobStorageBytes workspaceStorageHistory { diff --git a/packages/common/graphql/src/graphql/index.ts b/packages/common/graphql/src/graphql/index.ts index af43b28723..db28d1347d 100644 --- a/packages/common/graphql/src/graphql/index.ts +++ b/packages/common/graphql/src/graphql/index.ts @@ -149,6 +149,14 @@ export const adminDashboardQuery = { effectiveSize } copilotConversations + copilotWindow { + from + to + timezone + bucket + requestedSize + effectiveSize + } workspaceStorageBytes blobStorageBytes workspaceStorageHistory { diff --git a/packages/common/graphql/src/schema.ts b/packages/common/graphql/src/schema.ts index c1863134af..f4e0b2ea38 100644 --- a/packages/common/graphql/src/schema.ts +++ b/packages/common/graphql/src/schema.ts @@ -102,6 +102,7 @@ export interface AdminDashboard { blobStorageBytes: Scalars['SafeInt']['output']; blobStorageHistory: Array; 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; sharedLinkWindowDays?: InputMaybe; storageHistoryDays?: InputMaybe; syncHistoryHours?: InputMaybe; @@ -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; diff --git a/packages/frontend/admin/src/modules/dashboard/index.spec.tsx b/packages/frontend/admin/src/modules/dashboard/index.spec.tsx index b7f21c8b05..d74b39f610 100644 --- a/packages/frontend/admin/src/modules/dashboard/index.spec.tsx +++ b/packages/frontend/admin/src/modules/dashboard/index.spec.tsx @@ -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(); + const { getAllByText, getByText, queryByText } = render(); 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(); + + 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(); + + expect(getByText('Top Shared Links')).toBeTruthy(); + expect(getByLabelText('Top shared links range')).toBeTruthy(); + expect(getByText('28d')).toBeTruthy(); + }); }); diff --git a/packages/frontend/admin/src/modules/dashboard/index.tsx b/packages/frontend/admin/src/modules/dashboard/index.tsx index 50a7a2e111..f2a3e011fa 100644 --- a/packages/frontend/admin/src/modules/dashboard/index.tsx +++ b/packages/frontend/admin/src/modules/dashboard/index.tsx @@ -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 ( - - - - {title} + + + + {title} + {action ?
{action}
: null}
@@ -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 ( -
- - -
- ); -} - -function MailWindowSelect({ - value, - onChange, -}: { - value: number; - onChange: (value: number) => void; -}) { - return ( -
- - -
+ ); } @@ -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 ( - - Top Shared Links - - Top {topSharedLinks.length} links in the last{' '} - {topSharedLinksWindow.effectiveSize} days - + +
+ Top Shared Links + + Top {topSharedLinks.length} links in the last{' '} + {topSharedLinksWindow.effectiveSize} days + +
+
{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('status'); const { data } = useQuery( { @@ -1122,19 +1119,28 @@ function MailDeliverySection({ hours }: { hours: number }) { {analytics.window.bucket === 'Hour' ? 'hour' : 'day'} bucket in UTC
- +
+ + +
@@ -1219,6 +1225,7 @@ function MailDeliveryCardSkeleton() { function DashboardPageContent() { const [storageHistoryDays, setStorageHistoryDays] = useState(30); const [syncHistoryHours, setSyncHistoryHours] = useState(48); + const [copilotWindowDays, setCopilotWindowDays] = useState(7); const [sharedLinkWindowDays, setSharedLinkWindowDays] = useState(28); const [mailDeliveryHours, setMailDeliveryHours] = useState(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() { />
- - - Window Controls - - Tune dashboard windows. Data is sampled in UTC and refreshes - automatically. - - - - - - - - - -
@@ -1376,13 +1358,22 @@ function DashboardPageContent() {
- - - Sync Active Users Trend - - - {dashboard.syncWindow.effectiveSize}h at minute bucket - + +
+ + Sync Active Users Trend + + + {dashboard.syncWindow.effectiveSize}h at minute bucket + +
+
- - - Storage Trend (Workspace + Blob) - - - {dashboard.storageWindow.effectiveSize}d at day bucket - + +
+ + Storage Trend (Workspace + Blob) + + + {dashboard.storageWindow.effectiveSize}d at day bucket + +
+
}> - + {shouldShowTopSharedLinks ? ( }> ) : null} diff --git a/tests/kit/src/electron.ts b/tests/kit/src/electron.ts index 672b5c9d6e..59bf28550f 100644 --- a/tests/kit/src/electron.ts +++ b/tests/kit/src/electron.ts @@ -73,11 +73,15 @@ const releaseChildProcessHandles = (child: ChildProcess) => { } }; -const withTimeoutFallback = async (promise: Promise, fallback: T) => { +const withTimeoutFallback = async ( + promise: Promise, + 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()); diff --git a/tests/kit/src/utils/page-logic.ts b/tests/kit/src/utils/page-logic.ts index 02ccd2cf54..d1668a9a9a 100644 --- a/tests/kit/src/utils/page-logic.ts +++ b/tests/kit/src/utils/page-logic.ts @@ -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(); };