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
);
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 => ({
+2
View File
@@ -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 {
+11
View File
@@ -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
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 {
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());
+11 -18
View File
@@ -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();
};