feat(core): improve dashboard style (#15205)

This commit is contained in:
DarkSky
2026-07-07 12:13:11 +08:00
committed by GitHub
parent 631e4af9b9
commit fe2a4db76b
18 changed files with 502 additions and 217 deletions
@@ -1587,15 +1587,6 @@ test('should be able to manage context', async t => {
buffer 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( await waitForStatus(
async () => async () =>
(await listContextDocAndFiles(app, workspaceId, sessionId, contextId)) (await listContextDocAndFiles(app, workspaceId, sessionId, contextId))
@@ -1605,6 +1596,18 @@ test('should be able to manage context', async t => {
60 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( const result = await waitForMatches(
() => matchFiles(app, contextId, 'test', 1), () => matchFiles(app, contextId, 'test', 1),
1 1
@@ -1641,15 +1644,6 @@ test('should be able to manage context', async t => {
await addContextDoc(app, contextId, docId); 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( await waitForStatus(
async () => async () =>
(await listContextDocAndFiles(app, workspaceId, sessionId, contextId)) (await listContextDocAndFiles(app, workspaceId, sessionId, contextId))
@@ -1659,6 +1653,16 @@ test('should be able to manage context', async t => {
60 60
); );
const { docs } =
(await listContextDocAndFiles(app, workspaceId, sessionId, contextId)) ||
{};
t.deepEqual(cleanObject(docs, ['error', 'createdAt']), [
{
id: docId,
status: 'finished',
},
]);
const result = await waitForMatches( const result = await waitForMatches(
() => matchWorkspaceDocs(app, contextId, 'test', 1), () => matchWorkspaceDocs(app, contextId, 'test', 1),
1 1
@@ -358,6 +358,12 @@ e2e(
requestedSize requestedSize
effectiveSize effectiveSize
} }
copilotWindow {
to
bucket
requestedSize
effectiveSize
}
syncActiveUsersTimeline { syncActiveUsersTimeline {
minute minute
activeUsers activeUsers
@@ -366,6 +372,7 @@ e2e(
date date
value value
} }
generatedAt
} }
} }
`; `;
@@ -375,6 +382,7 @@ e2e(
storageHistoryDays: -10, storageHistoryDays: -10,
syncHistoryHours: -10, syncHistoryHours: -10,
sharedLinkWindowDays: -10, sharedLinkWindowDays: -10,
copilotWindowDays: 500,
}, },
}); });
@@ -385,6 +393,12 @@ e2e(
t.is(dashboard.storageWindow.bucket, 'Day'); t.is(dashboard.storageWindow.bucket, 'Day');
t.is(dashboard.storageWindow.effectiveSize, 1); t.is(dashboard.storageWindow.effectiveSize, 1);
t.is(dashboard.topSharedLinksWindow.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.syncActiveUsersTimeline.length, 1);
t.is(dashboard.workspaceStorageHistory.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 => { test('space:join-awareness should reject clientVersion<0.25.0', async t => {
const { user, cookieHeader } = await login(app); const { user, cookieHeader } = await login(app);
const spaceId = user.id; const spaceId = user.id;
@@ -12,6 +12,7 @@ type TestContext = {
const test = ava.serial as TestFn<TestContext>; const test = ava.serial as TestFn<TestContext>;
let safeFetchStub: Sinon.SinonStub | undefined; let safeFetchStub: Sinon.SinonStub | undefined;
let originalDeploymentType: typeof env.DEPLOYMENT_TYPE;
let safeFetchHandler: let safeFetchHandler:
| ((request: { url: string; method?: 'get' | 'head' }) => { | ((request: { url: string; method?: 'get' | 'head' }) => {
status?: number; status?: number;
@@ -38,6 +39,7 @@ const stubSafeFetch = (
}; };
test.before(async t => { test.before(async t => {
originalDeploymentType = env.DEPLOYMENT_TYPE;
// @ts-expect-error test // @ts-expect-error test
env.DEPLOYMENT_TYPE = 'selfhosted'; env.DEPLOYMENT_TYPE = 'selfhosted';
@@ -73,6 +75,8 @@ test.afterEach.always(() => {
}); });
test.after.always(async t => { test.after.always(async t => {
// @ts-expect-error test
env.DEPLOYMENT_TYPE = originalDeploymentType;
safeFetchStub?.restore(); safeFetchStub?.restore();
await t.context.app.close(); await t.context.app.close();
}); });
@@ -5,6 +5,7 @@ import {
import test from 'ava'; import test from 'ava';
import { z } from 'zod'; import { z } from 'zod';
import { CANARY_CLIENT_VERSION_MAX_AGE_DAYS } from '../../../base';
import { Flavor } from '../../../env'; import { Flavor } from '../../../env';
import { PublicDocMode } from '../../../models'; import { PublicDocMode } from '../../../models';
import { CopilotEmbeddingRealtimeProvider } from '../../../plugins/copilot/context'; import { CopilotEmbeddingRealtimeProvider } from '../../../plugins/copilot/context';
@@ -63,6 +64,10 @@ const user: CurrentUser = {
emailVerified: true, emailVerified: true,
}; };
function makeCanaryDateVersion(date: Date, build = '015') {
return `${date.getUTCFullYear()}.${date.getUTCMonth() + 1}.${date.getUTCDate()}-canary.${build}`;
}
function createGateway(registry: RealtimeRegistry) { function createGateway(registry: RealtimeRegistry) {
return new RealtimeGateway(registry, { return new RealtimeGateway(registry, {
attachServer() {}, 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 => { test('gateway authorizes subscription and joins room', async t => {
const registry = new RealtimeRegistry(); const registry = new RealtimeRegistry();
registry.registerTopic({ registry.registerTopic({
@@ -19,6 +19,7 @@ import semver from 'semver';
import type { Server, Socket } from 'socket.io'; import type { Server, Socket } from 'socket.io';
import { import {
checkCanaryDateClientVersion,
GatewayErrorWrapper, GatewayErrorWrapper,
OnEvent, OnEvent,
UnsupportedClientVersion, UnsupportedClientVersion,
@@ -35,6 +36,19 @@ const MIN_REALTIME_CLIENT_VERSION = new semver.Range('>=0.26.0-0', {
includePrerelease: true, 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() @WebSocketGateway()
@UseInterceptors(ClsInterceptor) @UseInterceptors(ClsInterceptor)
export class RealtimeGateway implements OnGatewayInit, OnGatewayDisconnect { export class RealtimeGateway implements OnGatewayInit, OnGatewayDisconnect {
@@ -122,10 +136,13 @@ export class RealtimeGateway implements OnGatewayInit, OnGatewayDisconnect {
} }
private assertVersion(clientVersion?: string) { private assertVersion(clientVersion?: string) {
const normalized = clientVersion
? normalizeRealtimeClientVersion(clientVersion)
: null;
if ( if (
!clientVersion || !normalized ||
!semver.valid(clientVersion) || !semver.valid(normalized) ||
!MIN_REALTIME_CLIENT_VERSION.test(clientVersion) !MIN_REALTIME_CLIENT_VERSION.test(normalized)
) { ) {
throw new UnsupportedClientVersion({ throw new UnsupportedClientVersion({
clientVersion: clientVersion ?? 'unset_or_invalid', clientVersion: clientVersion ?? 'unset_or_invalid',
@@ -85,14 +85,16 @@ type SyncProtocolRoomType = Extract<RoomType, 'sync-025' | 'sync-026'>;
const SOCKET_PRESENCE_USER_ID_KEY = 'affinePresenceUserId'; const SOCKET_PRESENCE_USER_ID_KEY = 'affinePresenceUserId';
function normalizeWsClientVersion(clientVersion: string): string | null { function normalizeWsClientVersion(clientVersion: string): string | null {
if (env.namespaces.canary) { const canaryCheck = checkCanaryDateClientVersion(clientVersion);
const canaryCheck = checkCanaryDateClientVersion(clientVersion); if (!canaryCheck.matched) {
if (canaryCheck.matched) { return clientVersion;
return canaryCheck.allowed ? canaryCheck.normalized : null;
}
} }
return clientVersion; if (!env.namespaces.canary) {
return null;
}
return canaryCheck.allowed ? canaryCheck.normalized : null;
} }
function isSupportedWsClientVersion(clientVersion: string): boolean { function isSupportedWsClientVersion(clientVersion: string): boolean {
@@ -186,6 +186,9 @@ class AdminDashboardInput {
@Field(() => Int, { nullable: true, defaultValue: 28 }) @Field(() => Int, { nullable: true, defaultValue: 28 })
sharedLinkWindowDays?: number; sharedLinkWindowDays?: number;
@Field(() => Int, { nullable: true, defaultValue: 7 })
copilotWindowDays?: number;
} }
@ObjectType() @ObjectType()
@@ -250,6 +253,9 @@ class AdminDashboard {
@Field(() => SafeIntResolver) @Field(() => SafeIntResolver)
copilotConversations!: number; copilotConversations!: number;
@Field(() => TimeWindow)
copilotWindow!: TimeWindow;
@Field(() => SafeIntResolver) @Field(() => SafeIntResolver)
workspaceStorageBytes!: number; workspaceStorageBytes!: number;
@@ -532,6 +538,7 @@ export class AdminWorkspaceResolver {
storageHistoryDays: input?.storageHistoryDays, storageHistoryDays: input?.storageHistoryDays,
syncHistoryHours: input?.syncHistoryHours, syncHistoryHours: input?.syncHistoryHours,
sharedLinkWindowDays: input?.sharedLinkWindowDays, sharedLinkWindowDays: input?.sharedLinkWindowDays,
copilotWindowDays: input?.copilotWindowDays,
includeTopSharedLinks, includeTopSharedLinks,
}); });
@@ -13,6 +13,7 @@ import { BaseModel } from './base';
const DEFAULT_STORAGE_HISTORY_DAYS = 30; const DEFAULT_STORAGE_HISTORY_DAYS = 30;
const DEFAULT_SYNC_HISTORY_HOURS = 48; const DEFAULT_SYNC_HISTORY_HOURS = 48;
const DEFAULT_SHARED_LINK_WINDOW_DAYS = 28; const DEFAULT_SHARED_LINK_WINDOW_DAYS = 28;
const DEFAULT_COPILOT_WINDOW_DAYS = 7;
const DEFAULT_ANALYTICS_WINDOW_DAYS = 28; const DEFAULT_ANALYTICS_WINDOW_DAYS = 28;
const NON_TEAM_ANALYTICS_WINDOW_DAYS = 7; const NON_TEAM_ANALYTICS_WINDOW_DAYS = 7;
const DEFAULT_TIMEZONE = 'UTC'; const DEFAULT_TIMEZONE = 'UTC';
@@ -50,6 +51,7 @@ export type AdminDashboardOptions = {
storageHistoryDays?: number; storageHistoryDays?: number;
syncHistoryHours?: number; syncHistoryHours?: number;
sharedLinkWindowDays?: number; sharedLinkWindowDays?: number;
copilotWindowDays?: number;
includeTopSharedLinks?: boolean; includeTopSharedLinks?: boolean;
}; };
@@ -99,6 +101,7 @@ export type AdminDashboardDto = {
}>; }>;
syncWindow: TimeWindowDto; syncWindow: TimeWindowDto;
copilotConversations: number; copilotConversations: number;
copilotWindow: TimeWindowDto;
workspaceStorageBytes: number; workspaceStorageBytes: number;
blobStorageBytes: number; blobStorageBytes: number;
workspaceStorageHistory: Array<{ workspaceStorageHistory: Array<{
@@ -262,6 +265,12 @@ export class WorkspaceAnalyticsModel extends BaseModel {
90, 90,
DEFAULT_SHARED_LINK_WINDOW_DAYS DEFAULT_SHARED_LINK_WINDOW_DAYS
); );
const copilotWindowDays = clampInt(
options.copilotWindowDays,
1,
90,
DEFAULT_COPILOT_WINDOW_DAYS
);
const includeTopSharedLinks = options.includeTopSharedLinks ?? true; const includeTopSharedLinks = options.includeTopSharedLinks ?? true;
const now = new Date(); const now = new Date();
@@ -274,6 +283,7 @@ export class WorkspaceAnalyticsModel extends BaseModel {
const currentDay = startOfUtcDay(now); const currentDay = startOfUtcDay(now);
const storageFrom = addUtcDays(currentDay, -(storageHistoryDays - 1)); const storageFrom = addUtcDays(currentDay, -(storageHistoryDays - 1));
const sharedFrom = addUtcDays(currentDay, -(sharedLinkWindowDays - 1)); const sharedFrom = addUtcDays(currentDay, -(sharedLinkWindowDays - 1));
const copilotFrom = addUtcDays(currentDay, -(copilotWindowDays - 1));
const topSharedLinksPromise = includeTopSharedLinks const topSharedLinksPromise = includeTopSharedLinks
? this.db.$queryRaw< ? this.db.$queryRaw<
@@ -432,7 +442,7 @@ export class WorkspaceAnalyticsModel extends BaseModel {
SELECT COUNT(*) AS conversations SELECT COUNT(*) AS conversations
FROM ai_sessions_messages FROM ai_sessions_messages
WHERE role = 'user' WHERE role = 'user'
AND created_at >= ${sharedFrom} AND created_at >= ${copilotFrom}
AND created_at <= ${now} AND created_at <= ${now}
`, `,
topSharedLinksPromise, topSharedLinksPromise,
@@ -497,6 +507,14 @@ export class WorkspaceAnalyticsModel extends BaseModel {
effectiveSize: syncHistoryHours, effectiveSize: syncHistoryHours,
}, },
copilotConversations: Number(copilotCount[0]?.conversations ?? 0), 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, workspaceStorageBytes: currentWorkspaceStorageBytes,
blobStorageBytes: currentBlobStorageBytes, blobStorageBytes: currentBlobStorageBytes,
workspaceStorageHistory: storageHistorySeries.map(row => ({ workspaceStorageHistory: storageHistorySeries.map(row => ({
+2
View File
@@ -63,6 +63,7 @@ type AdminDashboard {
blobStorageBytes: SafeInt! blobStorageBytes: SafeInt!
blobStorageHistory: [AdminDashboardValueDayPoint!]! blobStorageHistory: [AdminDashboardValueDayPoint!]!
copilotConversations: SafeInt! copilotConversations: SafeInt!
copilotWindow: TimeWindow!
generatedAt: DateTime! generatedAt: DateTime!
storageWindow: TimeWindow! storageWindow: TimeWindow!
syncActiveUsers: Int! syncActiveUsers: Int!
@@ -75,6 +76,7 @@ type AdminDashboard {
} }
input AdminDashboardInput { input AdminDashboardInput {
copilotWindowDays: Int = 7
sharedLinkWindowDays: Int = 28 sharedLinkWindowDays: Int = 28
storageHistoryDays: Int = 30 storageHistoryDays: Int = 30
syncHistoryHours: Int = 48 syncHistoryHours: Int = 48
@@ -14,6 +14,14 @@ query adminDashboard($input: AdminDashboardInput) {
effectiveSize effectiveSize
} }
copilotConversations copilotConversations
copilotWindow {
from
to
timezone
bucket
requestedSize
effectiveSize
}
workspaceStorageBytes workspaceStorageBytes
blobStorageBytes blobStorageBytes
workspaceStorageHistory { workspaceStorageHistory {
@@ -149,6 +149,14 @@ export const adminDashboardQuery = {
effectiveSize effectiveSize
} }
copilotConversations copilotConversations
copilotWindow {
from
to
timezone
bucket
requestedSize
effectiveSize
}
workspaceStorageBytes workspaceStorageBytes
blobStorageBytes blobStorageBytes
workspaceStorageHistory { workspaceStorageHistory {
+11
View File
@@ -102,6 +102,7 @@ export interface AdminDashboard {
blobStorageBytes: Scalars['SafeInt']['output']; blobStorageBytes: Scalars['SafeInt']['output'];
blobStorageHistory: Array<AdminDashboardValueDayPoint>; blobStorageHistory: Array<AdminDashboardValueDayPoint>;
copilotConversations: Scalars['SafeInt']['output']; copilotConversations: Scalars['SafeInt']['output'];
copilotWindow: TimeWindow;
generatedAt: Scalars['DateTime']['output']; generatedAt: Scalars['DateTime']['output'];
storageWindow: TimeWindow; storageWindow: TimeWindow;
syncActiveUsers: Scalars['Int']['output']; syncActiveUsers: Scalars['Int']['output'];
@@ -114,6 +115,7 @@ export interface AdminDashboard {
} }
export interface AdminDashboardInput { export interface AdminDashboardInput {
copilotWindowDays?: InputMaybe<Scalars['Int']['input']>;
sharedLinkWindowDays?: InputMaybe<Scalars['Int']['input']>; sharedLinkWindowDays?: InputMaybe<Scalars['Int']['input']>;
storageHistoryDays?: InputMaybe<Scalars['Int']['input']>; storageHistoryDays?: InputMaybe<Scalars['Int']['input']>;
syncHistoryHours?: InputMaybe<Scalars['Int']['input']>; syncHistoryHours?: InputMaybe<Scalars['Int']['input']>;
@@ -3911,6 +3913,15 @@ export type AdminDashboardQuery = {
requestedSize: number; requestedSize: number;
effectiveSize: number; effectiveSize: number;
}; };
copilotWindow: {
__typename?: 'TimeWindow';
from: string;
to: string;
timezone: string;
bucket: TimeBucket;
requestedSize: number;
effectiveSize: number;
};
workspaceStorageHistory: Array<{ workspaceStorageHistory: Array<{
__typename?: 'AdminDashboardValueDayPoint'; __typename?: 'AdminDashboardValueDayPoint';
date: string; date: string;
@@ -71,6 +71,14 @@ const dashboardData = {
effectiveSize: 48, effectiveSize: 48,
}, },
copilotConversations: 0, 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, workspaceStorageBytes: 375,
blobStorageBytes: 0, blobStorageBytes: 0,
workspaceStorageHistory: [{ date: '2026-02-16', value: 375 }], 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', () => { describe('DashboardPage', () => {
beforeEach(() => { beforeEach(() => {
(globalThis as any).environment = { (globalThis as any).environment = {
isSelfHosted: true, isSelfHosted: true,
}; };
useQueryMock.mockReset(); useQueryMock.mockReset();
useQueryMock.mockImplementation(({ query }: { query: { id: string } }) => ({ useQueryMock.mockImplementation(
data: ({ query }: { query: { id: string; query: string } }) => ({
query.id === 'adminMailDeliveriesQuery' data:
? mailDeliveryData query.id === 'adminMailDeliveriesQuery'
: dashboardData, ? mailDeliveryData
isValidating: false, : query.query.includes('topSharedLinks')
})); ? topSharedLinksData
: dashboardData,
isValidating: false,
})
);
mutateQueryResourceMock.mockReset(); mutateQueryResourceMock.mockReset();
}); });
@@ -202,12 +240,47 @@ describe('DashboardPage', () => {
}); });
test('renders mail delivery analytics controls and summary', () => { 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('Email Delivery Trend')).toBeTruthy();
expect(getByText('Mail Delivery')).toBeTruthy(); expect(queryByText('Window Controls')).toBeNull();
expect(getByText('24 hours')).toBeTruthy(); expect(getByText('Status')).toBeTruthy();
expect(getAllByText('24h').length).toBeGreaterThan(0);
expect(getByText('Success rate')).toBeTruthy(); expect(getByText('Success rate')).toBeTruthy();
expect(getByText('66.7%')).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, DropdownMenuItem,
DropdownMenuTrigger, DropdownMenuTrigger,
} from '@affine/admin/components/ui/dropdown-menu'; } from '@affine/admin/components/ui/dropdown-menu';
import { Label } from '@affine/admin/components/ui/label';
import { import {
Select, Select,
SelectContent, SelectContent,
@@ -96,6 +95,14 @@ const adminDashboardOverviewQuery: typeof adminDashboardQuery = {
effectiveSize effectiveSize
} }
copilotConversations copilotConversations
copilotWindow {
from
to
timezone
bucket
requestedSize
effectiveSize
}
workspaceStorageBytes workspaceStorageBytes
blobStorageBytes blobStorageBytes
workspaceStorageHistory { workspaceStorageHistory {
@@ -171,6 +178,8 @@ const utcDateFormatter = new Intl.DateTimeFormat('en-US', {
const STORAGE_DAY_OPTIONS = [7, 14, 30, 60, 90] as const; const STORAGE_DAY_OPTIONS = [7, 14, 30, 60, 90] as const;
const SYNC_HOUR_OPTIONS = [1, 6, 12, 24, 48, 72] 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 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 MailChartMode = 'status' | 'type' | 'outcome';
type DualNumberPoint = { type DualNumberPoint = {
@@ -514,19 +523,24 @@ function SecondaryMetricCard({
value, value,
description, description,
icon, icon,
action,
}: { }: {
title: string; title: string;
value: string; value: string;
description: string; description: string;
icon: ReactNode; icon: ReactNode;
action?: ReactNode;
}) { }) {
return ( return (
<Card className="h-full border-border/60 bg-card shadow-1"> <Card className="h-full border-border/60 bg-card shadow-1">
<CardHeader className="pb-2"> <CardHeader className="flex flex-row items-start justify-between gap-3 pb-2">
<CardDescription className="flex items-center gap-2 text-sm"> <CardDescription className="flex min-w-0 items-center gap-2 pt-2 text-sm">
<span aria-hidden="true">{icon}</span> <span className="shrink-0" aria-hidden="true">
{title} {icon}
</span>
<span className="min-w-0 truncate">{title}</span>
</CardDescription> </CardDescription>
{action ? <div className="shrink-0">{action}</div> : null}
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<div className="text-2xl font-semibold tracking-tight tabular-nums"> <div className="text-2xl font-semibold tracking-tight tabular-nums">
@@ -538,76 +552,42 @@ function SecondaryMetricCard({
); );
} }
function WindowSelect({ function rangeOptionLabel(option: number, unit: 'hours' | 'days') {
id, if (unit === 'hours') {
label, return option === 168 ? '7d' : `${option}h`;
}
return `${option}d`;
}
function PanelRangeSelect({
ariaLabel,
value, value,
options, options,
unit, unit,
onChange, onChange,
}: { }: {
id: string; ariaLabel: string;
label: string;
value: number; value: number;
options: readonly number[]; options: readonly number[];
unit: string; unit: 'hours' | 'days';
onChange: (value: number) => void; onChange: (value: number) => void;
}) { }) {
return ( return (
<div className="flex min-w-0 flex-col gap-2"> <Select
<Label value={String(value)}
htmlFor={id} onValueChange={next => onChange(Number(next))}
className="text-xs uppercase tracking-wide text-muted-foreground" >
> <SelectTrigger className="w-full md:w-28" aria-label={ariaLabel}>
{label} <SelectValue />
</Label> </SelectTrigger>
<Select <SelectContent>
value={String(value)} {options.map(option => (
onValueChange={next => onChange(Number(next))} <SelectItem key={option} value={String(option)}>
> {rangeOptionLabel(option, unit)}
<SelectTrigger id={id}> </SelectItem>
<SelectValue placeholder={`Select ${label.toLowerCase()}`} /> ))}
</SelectTrigger> </SelectContent>
<SelectContent> </Select>
{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>
); );
} }
@@ -870,8 +850,10 @@ function TopSharedLinksCardSkeleton() {
function TopSharedLinksSection({ function TopSharedLinksSection({
sharedLinkWindowDays, sharedLinkWindowDays,
onWindowChange,
}: { }: {
sharedLinkWindowDays: number; sharedLinkWindowDays: number;
onWindowChange: (value: number) => void;
}) { }) {
const variables = useMemo( const variables = useMemo(
() => ({ () => ({
@@ -901,12 +883,21 @@ function TopSharedLinksSection({
return ( return (
<Card className="border-border/60 bg-card shadow-1"> <Card className="border-border/60 bg-card shadow-1">
<CardHeader> <CardHeader className="flex flex-col gap-3 pb-4 md:flex-row md:items-start md:justify-between">
<CardTitle className="text-base">Top Shared Links</CardTitle> <div className="space-y-1.5">
<CardDescription> <CardTitle className="text-base">Top Shared Links</CardTitle>
Top {topSharedLinks.length} links in the last{' '} <CardDescription>
{topSharedLinksWindow.effectiveSize} days Top {topSharedLinks.length} links in the last{' '}
</CardDescription> {topSharedLinksWindow.effectiveSize} days
</CardDescription>
</div>
<PanelRangeSelect
ariaLabel="Top shared links range"
value={sharedLinkWindowDays}
options={SHARED_DAY_OPTIONS}
unit="days"
onChange={onWindowChange}
/>
</CardHeader> </CardHeader>
<CardContent className="space-y-4"> <CardContent className="space-y-4">
{topSharedLinks.length === 0 ? ( {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 [mode, setMode] = useState<MailChartMode>('status');
const { data } = useQuery( const { data } = useQuery(
{ {
@@ -1122,19 +1119,28 @@ function MailDeliverySection({ hours }: { hours: number }) {
{analytics.window.bucket === 'Hour' ? 'hour' : 'day'} bucket in UTC {analytics.window.bucket === 'Hour' ? 'hour' : 'day'} bucket in UTC
</CardDescription> </CardDescription>
</div> </div>
<Select <div className="flex w-full flex-col gap-2 md:w-auto md:flex-row">
value={mode} <PanelRangeSelect
onValueChange={value => setMode(value as MailChartMode)} ariaLabel="Email delivery range"
> value={hours}
<SelectTrigger className="w-full md:w-44"> options={MAIL_HOUR_OPTIONS}
<SelectValue /> unit="hours"
</SelectTrigger> onChange={onHoursChange}
<SelectContent> />
<SelectItem value="status">Status</SelectItem> <Select
<SelectItem value="type">Mail type</SelectItem> value={mode}
<SelectItem value="outcome">Success / failure</SelectItem> onValueChange={value => setMode(value as MailChartMode)}
</SelectContent> >
</Select> <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> </CardHeader>
<CardContent className="space-y-4"> <CardContent className="space-y-4">
<div className="grid grid-cols-2 gap-3 md:grid-cols-4"> <div className="grid grid-cols-2 gap-3 md:grid-cols-4">
@@ -1219,6 +1225,7 @@ function MailDeliveryCardSkeleton() {
function DashboardPageContent() { function DashboardPageContent() {
const [storageHistoryDays, setStorageHistoryDays] = useState<number>(30); const [storageHistoryDays, setStorageHistoryDays] = useState<number>(30);
const [syncHistoryHours, setSyncHistoryHours] = useState<number>(48); const [syncHistoryHours, setSyncHistoryHours] = useState<number>(48);
const [copilotWindowDays, setCopilotWindowDays] = useState<number>(7);
const [sharedLinkWindowDays, setSharedLinkWindowDays] = useState<number>(28); const [sharedLinkWindowDays, setSharedLinkWindowDays] = useState<number>(28);
const [mailDeliveryHours, setMailDeliveryHours] = useState<number>(24); const [mailDeliveryHours, setMailDeliveryHours] = useState<number>(24);
const shouldShowTopSharedLinks = !environment.isSelfHosted; const shouldShowTopSharedLinks = !environment.isSelfHosted;
@@ -1229,11 +1236,17 @@ function DashboardPageContent() {
input: { input: {
storageHistoryDays, storageHistoryDays,
syncHistoryHours, syncHistoryHours,
copilotWindowDays,
sharedLinkWindowDays, sharedLinkWindowDays,
timezone: 'UTC', timezone: 'UTC',
}, },
}), }),
[sharedLinkWindowDays, storageHistoryDays, syncHistoryHours] [
copilotWindowDays,
sharedLinkWindowDays,
storageHistoryDays,
syncHistoryHours,
]
); );
const { data, isValidating } = useQuery( const { data, isValidating } = useQuery(
@@ -1296,46 +1309,6 @@ function DashboardPageContent() {
/> />
<div className="flex-1 overflow-auto p-6 space-y-6"> <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="grid grid-cols-1 gap-5 lg:grid-cols-12">
<div className="h-full min-w-0 lg:col-span-5"> <div className="h-full min-w-0 lg:col-span-5">
<PrimaryMetricCard <PrimaryMetricCard
@@ -1347,10 +1320,19 @@ function DashboardPageContent() {
<SecondaryMetricCard <SecondaryMetricCard
title="Copilot Conversations" title="Copilot Conversations"
value={intFormatter.format(dashboard.copilotConversations)} value={intFormatter.format(dashboard.copilotConversations)}
description={`${sharedLinkWindowDays}d aggregation`} description={`${dashboard.copilotWindow.effectiveSize}d aggregation`}
icon={ icon={
<MessageSquareTextIcon className="h-4 w-4" aria-hidden="true" /> <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>
<div className="h-full min-w-0 lg:col-span-4"> <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"> <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"> <Card className="border-border/60 bg-card shadow-1 lg:col-span-1">
<CardHeader> <CardHeader className="flex flex-col gap-3 pb-4 md:flex-row md:items-start md:justify-between">
<CardTitle className="text-base"> <div className="space-y-1.5">
Sync Active Users Trend <CardTitle className="text-base">
</CardTitle> Sync Active Users Trend
<CardDescription> </CardTitle>
{dashboard.syncWindow.effectiveSize}h at minute bucket <CardDescription>
</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> </CardHeader>
<CardContent className="space-y-3"> <CardContent className="space-y-3">
<TrendChart <TrendChart
@@ -1395,13 +1386,22 @@ function DashboardPageContent() {
</Card> </Card>
<Card className="border-border/60 bg-card shadow-1 lg:col-span-2"> <Card className="border-border/60 bg-card shadow-1 lg:col-span-2">
<CardHeader> <CardHeader className="flex flex-col gap-3 pb-4 md:flex-row md:items-start md:justify-between">
<CardTitle className="text-base"> <div className="space-y-1.5">
Storage Trend (Workspace + Blob) <CardTitle className="text-base">
</CardTitle> Storage Trend (Workspace + Blob)
<CardDescription> </CardTitle>
{dashboard.storageWindow.effectiveSize}d at day bucket <CardDescription>
</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> </CardHeader>
<CardContent className="space-y-4"> <CardContent className="space-y-4">
<TrendChart <TrendChart
@@ -1428,13 +1428,17 @@ function DashboardPageContent() {
</div> </div>
<Suspense fallback={<MailDeliveryCardSkeleton />}> <Suspense fallback={<MailDeliveryCardSkeleton />}>
<MailDeliverySection hours={mailDeliveryHours} /> <MailDeliverySection
hours={mailDeliveryHours}
onHoursChange={setMailDeliveryHours}
/>
</Suspense> </Suspense>
{shouldShowTopSharedLinks ? ( {shouldShowTopSharedLinks ? (
<Suspense fallback={<TopSharedLinksCardSkeleton />}> <Suspense fallback={<TopSharedLinksCardSkeleton />}>
<TopSharedLinksSection <TopSharedLinksSection
sharedLinkWindowDays={sharedLinkWindowDays} sharedLinkWindowDays={sharedLinkWindowDays}
onWindowChange={setSharedLinkWindowDays}
/> />
</Suspense> </Suspense>
) : null} ) : null}
+30 -19
View File
@@ -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 { try {
return await Promise.race([ return await Promise.race([
promise, promise,
setTimeout(1_000).then(() => fallback), setTimeout(timeoutMs).then(() => fallback),
]); ]);
} catch { } catch {
return fallback; return fallback;
@@ -89,7 +93,8 @@ const getPageId = async (page: Page) => {
page.evaluate(() => { page.evaluate(() => {
return (window.__appInfo as any)?.viewId as string | undefined; return (window.__appInfo as any)?.viewId as string | undefined;
}), }),
undefined undefined,
500
); );
}; };
@@ -98,7 +103,8 @@ const isActivePage = async (page: Page) => {
page.evaluate(async () => { page.evaluate(async () => {
return (await (window as any).__apis?.ui.isActiveTab()) === true; 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[]) => { const getActivePage = async (pages: Page[]) => {
for (const page of pages) { const activeChecks = await Promise.all(
if (await isActivePage(page)) { pages.map(async page => ((await isActivePage(page)) ? page : null))
return page; );
} for (const page of activeChecks) {
if (page) return page;
} }
const contentPages: Page[] = []; const contentPages = (
for (const page of pages) { await Promise.all(
const pageId = await getPageId(page); pages.map(async page => {
if (pageId === 'shell') { const pageId = await getPageId(page);
continue; if (pageId === 'shell') {
} return null;
if (pageId || (await isEditorPage(page))) { }
contentPages.push(page); if (pageId || (await isEditorPage(page))) {
} return page;
} }
return null;
})
)
).filter((page): page is Page => !!page);
if (contentPages.length > 0) { if (contentPages.length > 0) {
return contentPages.at(-1) ?? null; return contentPages.at(-1) ?? null;
@@ -153,7 +164,7 @@ const waitForElectronPage = async (
) => { ) => {
const deadline = const deadline =
Date.now() + 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) { while (Date.now() < deadline) {
const page = await getPage(electronApp.windows()); const page = await getPage(electronApp.windows());
+11 -18
View File
@@ -177,30 +177,23 @@ export const dragTo = async (
location: DragLocation = 'center', location: DragLocation = 'center',
willMoveOnDrag = false willMoveOnDrag = false
) => { ) => {
await locator.hover();
const locatorElement = await locator.boundingBox(); const locatorElement = await locator.boundingBox();
if (!locatorElement) { if (!locatorElement) {
throw new Error('locator element not found'); throw new Error('locator element not found');
} }
const locatorCenter = toPosition(locatorElement, 'center'); const locatorCenter = toPosition(locatorElement, 'center');
await page.mouse.move( const start = {
locatorElement.x + locatorCenter.x, x: locatorElement.x + locatorCenter.x,
locatorElement.y + locatorCenter.y y: locatorElement.y + locatorCenter.y,
); };
await page.mouse.move(start.x, start.y);
await page.mouse.down(); await page.mouse.down();
await page.waitForTimeout(100); await page.waitForTimeout(100);
await page.mouse.move( await page.mouse.move(start.x + 8, start.y + 8, { steps: 4 });
locatorElement.x + locatorCenter.x + 1,
locatorElement.y + locatorCenter.y + 1
);
await page.mouse.move(1, 1, { if (willMoveOnDrag) {
steps: 10, await target.hover();
}); } else {
await target.hover();
if (!willMoveOnDrag) {
const targetElement = await target.boundingBox(); const targetElement = await target.boundingBox();
if (!targetElement) { if (!targetElement) {
throw new Error('target element not found'); throw new Error('target element not found');
@@ -210,11 +203,11 @@ export const dragTo = async (
targetElement.x + targetPosition.x, targetElement.x + targetPosition.x,
targetElement.y + targetPosition.y, targetElement.y + targetPosition.y,
{ {
steps: 10, steps: 24,
} }
); );
} }
await page.waitForTimeout(100); await page.waitForTimeout(200);
await page.mouse.up(); await page.mouse.up();
}; };