feat(workspace): add blob and storage limit (#5535)

close TOV-343 AFF-508 TOV-461 TOV-460 TOV-419

Add `isOverCapacity ` status to detect if blob usage exceeds limits.
Add `onCapacityChange` and `onBlobSet` to monitor if the storage or blob exceeds the capacity limit.
Global modals `LocalQuotaModal` and `CloudQuotaModal` have been added, with the upload size of the blob being limited within the modal components.
The notification component has been adjusted, now you can pass in `action` click events and `actionLabel` .
This commit is contained in:
JimmFly
2024-01-24 07:34:51 +00:00
parent c566952e09
commit 25897dc404
25 changed files with 394 additions and 58 deletions

View File

@@ -25,6 +25,7 @@ export class QuotaManagementService {
storageQuota: quota.feature.storageQuota, storageQuota: quota.feature.storageQuota,
historyPeriod: quota.feature.historyPeriod, historyPeriod: quota.feature.historyPeriod,
memberLimit: quota.feature.memberLimit, memberLimit: quota.feature.memberLimit,
humanReadableName: quota.feature.humanReadable.name,
}; };
} }
@@ -45,11 +46,12 @@ export class QuotaManagementService {
const { user: owner } = const { user: owner } =
await this.permissions.getWorkspaceOwner(workspaceId); await this.permissions.getWorkspaceOwner(workspaceId);
if (!owner) throw new NotFoundException('Workspace owner not found'); if (!owner) throw new NotFoundException('Workspace owner not found');
const { storageQuota, blobLimit } = await this.getUserQuota(owner.id); const { humanReadableName, storageQuota, blobLimit } =
await this.getUserQuota(owner.id);
// get all workspaces size of owner used // get all workspaces size of owner used
const usedSize = await this.getUserUsage(owner.id); const usedSize = await this.getUserUsage(owner.id);
return { storageQuota, usedSize, blobLimit }; return { humanReadableName, storageQuota, usedSize, blobLimit };
} }
async checkBlobQuota(workspaceId: string, size: number) { async checkBlobQuota(workspaceId: string, size: number) {

View File

@@ -1,4 +1,5 @@
import { Field, Int, ObjectType } from '@nestjs/graphql'; import { Field, ObjectType } from '@nestjs/graphql';
import { SafeIntResolver } from 'graphql-scalars';
import { z } from 'zod'; import { z } from 'zod';
import { commonFeatureSchema, FeatureKind } from '../features'; import { commonFeatureSchema, FeatureKind } from '../features';
@@ -42,13 +43,16 @@ export type Quota = z.infer<typeof QuotaSchema>;
@ObjectType() @ObjectType()
export class QuotaQueryType { export class QuotaQueryType {
@Field(() => Int) @Field(() => String)
humanReadableName!: string;
@Field(() => SafeIntResolver)
storageQuota!: number; storageQuota!: number;
@Field(() => Int) @Field(() => SafeIntResolver)
usedSize!: number; usedSize!: number;
@Field(() => Int) @Field(() => SafeIntResolver)
blobLimit!: number; blobLimit!: number;
} }

View File

@@ -190,9 +190,10 @@ type Query {
} }
type QuotaQueryType { type QuotaQueryType {
blobLimit: Int! blobLimit: SafeInt!
storageQuota: Int! humanReadableName: String!
usedSize: Int! storageQuota: SafeInt!
usedSize: SafeInt!
} }
type RemoveAvatar { type RemoveAvatar {

View File

@@ -1,8 +1,15 @@
import { DebugLogger } from '@affine/debug'; import { DebugLogger } from '@affine/debug';
import { Slot } from '@blocksuite/global/utils';
import { difference } from 'lodash-es'; import { difference } from 'lodash-es';
import { BlobStorageOverCapacity } from './error';
const logger = new DebugLogger('affine:blob-engine'); const logger = new DebugLogger('affine:blob-engine');
export interface BlobStatus {
isStorageOverCapacity: boolean;
}
/** /**
* # BlobEngine * # BlobEngine
* *
@@ -12,6 +19,19 @@ const logger = new DebugLogger('affine:blob-engine');
*/ */
export class BlobEngine { export class BlobEngine {
private abort: AbortController | null = null; private abort: AbortController | null = null;
private _status: BlobStatus = { isStorageOverCapacity: false };
onStatusChange = new Slot<BlobStatus>();
singleBlobSizeLimit: number = 100 * 1024 * 1024;
onAbortLargeBlob = new Slot<Blob>();
private set status(s: BlobStatus) {
logger.debug('status change', s);
this._status = s;
this.onStatusChange.emit(s);
}
get status() {
return this._status;
}
constructor( constructor(
private readonly local: BlobStorage, private readonly local: BlobStorage,
@@ -53,7 +73,7 @@ export class BlobEngine {
} }
async sync() { async sync() {
if (this.local.readonly) { if (this.local.readonly || this._status.isStorageOverCapacity) {
return; return;
} }
logger.debug('start syncing blob...'); logger.debug('start syncing blob...');
@@ -78,6 +98,11 @@ export class BlobEngine {
await remote.set(key, data); await remote.set(key, data);
} }
} catch (err) { } catch (err) {
if (err instanceof BlobStorageOverCapacity) {
this.status = {
isStorageOverCapacity: true,
};
}
logger.error( logger.error(
`error when sync ${key} from [${this.local.name}] to [${remote.name}]`, `error when sync ${key} from [${this.local.name}] to [${remote.name}]`,
err err
@@ -122,6 +147,12 @@ export class BlobEngine {
throw new Error('local peer is readonly'); throw new Error('local peer is readonly');
} }
if (value.size > this.singleBlobSizeLimit) {
this.onAbortLargeBlob.emit(value);
logger.error('blob over limit, abort set');
return key;
}
// await upload to the local peer // await upload to the local peer
await this.local.set(key, value); await this.local.set(key, value);
@@ -131,7 +162,7 @@ export class BlobEngine {
.filter(r => !r.readonly) .filter(r => !r.readonly)
.map(peer => .map(peer =>
peer.set(key, value).catch(err => { peer.set(key, value).catch(err => {
logger.error('error when upload to peer', err); logger.error('Error when uploading to peer', err);
}) })
) )
) )

View File

@@ -0,0 +1,5 @@
export class BlobStorageOverCapacity extends Error {
constructor(public originError?: any) {
super('Blob storage over capacity.');
}
}

View File

@@ -2,11 +2,12 @@ import { Slot } from '@blocksuite/global/utils';
import { throwIfAborted } from '../utils/throw-if-aborted'; import { throwIfAborted } from '../utils/throw-if-aborted';
import type { AwarenessProvider } from './awareness'; import type { AwarenessProvider } from './awareness';
import type { BlobEngine } from './blob'; import type { BlobEngine, BlobStatus } from './blob';
import type { SyncEngine, SyncEngineStatus } from './sync'; import type { SyncEngine, SyncEngineStatus } from './sync';
export interface WorkspaceEngineStatus { export interface WorkspaceEngineStatus {
sync: SyncEngineStatus; sync: SyncEngineStatus;
blob: BlobStatus;
} }
/** /**
@@ -34,10 +35,18 @@ export class WorkspaceEngine {
) { ) {
this._status = { this._status = {
sync: sync.status, sync: sync.status,
blob: blob.status,
}; };
sync.onStatusChange.on(status => { sync.onStatusChange.on(status => {
this.status = { this.status = {
sync: status, sync: status,
blob: blob.status,
};
});
blob.onStatusChange.on(status => {
this.status = {
sync: sync.status,
blob: status,
}; };
}); });
} }
@@ -71,4 +80,5 @@ export class WorkspaceEngine {
export * from './awareness'; export * from './awareness';
export * from './blob'; export * from './blob';
export * from './error';
export * from './sync'; export * from './sync';

View File

@@ -11,7 +11,8 @@ export type Notification = {
progressingBar?: boolean; progressingBar?: boolean;
multimedia?: React.ReactNode | JSX.Element; multimedia?: React.ReactNode | JSX.Element;
// actions // actions
undo?: () => Promise<void>; action?: () => Promise<void>;
actionLabel?: string;
}; };
const notificationsBaseAtom = atom<Notification[]>([]); const notificationsBaseAtom = atom<Notification[]>([]);
@@ -48,19 +49,19 @@ export const pushNotificationAtom = atom<null, [Notification], void>(
set(notificationsBaseAtom, notifications => set(notificationsBaseAtom, notifications =>
notifications.filter(notification => notification.key !== key) notifications.filter(notification => notification.key !== key)
); );
const undo: (() => Promise<void>) | undefined = newNotification.undo const action: (() => Promise<void>) | undefined = newNotification.action
? (() => { ? (() => {
const undo: () => Promise<void> = newNotification.undo; const action: () => Promise<void> = newNotification.action;
return async function undoNotificationWrapper() { return async function actionNotificationWrapper() {
removeNotification(); removeNotification();
return undo(); return action();
}; };
})() })()
: undefined; : undefined;
set(notificationsBaseAtom, notifications => [ set(notificationsBaseAtom, notifications => [
// push to the top // push to the top
{ ...newNotification, undo }, { ...newNotification, action },
...notifications, ...notifications,
]); ]);
} }

View File

@@ -2,6 +2,7 @@
// License on the MIT // License on the MIT
// https://github.com/emilkowalski/sonner/blob/5cb703edc108a23fd74979235c2f3c4005edd2a7/src/index.tsx // https://github.com/emilkowalski/sonner/blob/5cb703edc108a23fd74979235c2f3c4005edd2a7/src/index.tsx
import { useAFFiNEI18N } from '@affine/i18n/hooks';
import { CloseIcon, InformationFillDuotoneIcon } from '@blocksuite/icons'; import { CloseIcon, InformationFillDuotoneIcon } from '@blocksuite/icons';
import * as Toast from '@radix-ui/react-toast'; import * as Toast from '@radix-ui/react-toast';
import clsx from 'clsx'; import clsx from 'clsx';
@@ -71,6 +72,7 @@ const typeColorMap = {
}; };
function NotificationCard(props: NotificationCardProps): ReactNode { function NotificationCard(props: NotificationCardProps): ReactNode {
const t = useAFFiNEI18N();
const removeNotification = useSetAtom(removeNotificationAtom); const removeNotification = useSetAtom(removeNotificationAtom);
const { notification, notifications, setHeights, heights, index } = props; const { notification, notifications, setHeights, heights, index } = props;
@@ -134,7 +136,6 @@ function NotificationCard(props: NotificationCardProps): ReactNode {
notificationNode.style.height = 'auto'; notificationNode.style.height = 'auto';
const newHeight = notificationNode.getBoundingClientRect().height; const newHeight = notificationNode.getBoundingClientRect().height;
notificationNode.style.height = originalHeight; notificationNode.style.height = originalHeight;
setInitialHeight(newHeight); setInitialHeight(newHeight);
setHeights(heights => { setHeights(heights => {
@@ -190,9 +191,9 @@ function NotificationCard(props: NotificationCardProps): ReactNode {
}; };
}, [duration, expand, onClickRemove]); }, [duration, expand, onClickRemove]);
const onClickUndo = useCallback(() => { const onClickAction = useCallback(() => {
if (notification.undo) { if (notification.action) {
notification.undo().catch(err => { notification.action().catch(err => {
console.error(err); console.error(err);
}); });
} }
@@ -298,7 +299,7 @@ function NotificationCard(props: NotificationCardProps): ReactNode {
<div className={styles.notificationTitleContactStyle}> <div className={styles.notificationTitleContactStyle}>
{notification.title} {notification.title}
</div> </div>
{notification.undo && ( {notification.action && (
<div <div
className={clsx(styles.undoButtonStyle, { className={clsx(styles.undoButtonStyle, {
[styles.darkColorStyle]: [styles.darkColorStyle]:
@@ -306,15 +307,16 @@ function NotificationCard(props: NotificationCardProps): ReactNode {
notification.theme !== 'default', notification.theme !== 'default',
[styles.undoButtonWithMediaStyle]: notification.multimedia, [styles.undoButtonWithMediaStyle]: notification.multimedia,
})} })}
onClick={onClickUndo} onClick={onClickAction}
> >
UNDO {notification.actionLabel ??
t['com.affine.keyboardShortcuts.undo']()}
</div> </div>
)} )}
{notification.multimedia ? null : ( {notification.multimedia ? null : (
<IconButton <IconButton
className={clsx(styles.closeButtonStyle, { className={clsx(styles.closeButtonStyle, {
[styles.closeButtonWithoutUndoStyle]: !notification.undo, [styles.closeButtonWithoutUndoStyle]: !notification.action,
})} })}
style={{ style={{
color: color:

View File

@@ -13,6 +13,7 @@ export const openQuickSearchModalAtom = atom(false);
export const openOnboardingModalAtom = atom(false); export const openOnboardingModalAtom = atom(false);
export const openSignOutModalAtom = atom(false); export const openSignOutModalAtom = atom(false);
export const openPaymentDisableAtom = atom(false); export const openPaymentDisableAtom = atom(false);
export const openQuotaModalAtom = atom(false);
export type SettingAtom = Pick< export type SettingAtom = Pick<
SettingProps, SettingProps,

View File

@@ -6,10 +6,9 @@ import { openSettingModalAtom, type PageMode } from '@affine/core/atoms';
import { useIsWorkspaceOwner } from '@affine/core/hooks/affine/use-is-workspace-owner'; import { useIsWorkspaceOwner } from '@affine/core/hooks/affine/use-is-workspace-owner';
import { useAsyncCallback } from '@affine/core/hooks/affine-async-hooks'; import { useAsyncCallback } from '@affine/core/hooks/affine-async-hooks';
import { useBlockSuiteWorkspacePageTitle } from '@affine/core/hooks/use-block-suite-workspace-page-title'; import { useBlockSuiteWorkspacePageTitle } from '@affine/core/hooks/use-block-suite-workspace-page-title';
import { useUserSubscription } from '@affine/core/hooks/use-subscription'; import { useWorkspaceQuota } from '@affine/core/hooks/use-workspace-quota';
import { waitForCurrentWorkspaceAtom } from '@affine/core/modules/workspace'; import { waitForCurrentWorkspaceAtom } from '@affine/core/modules/workspace';
import { timestampToLocalTime } from '@affine/core/utils'; import { timestampToLocalTime } from '@affine/core/utils';
import { SubscriptionPlan } from '@affine/graphql';
import { Trans } from '@affine/i18n'; import { Trans } from '@affine/i18n';
import { useAFFiNEI18N } from '@affine/i18n/hooks'; import { useAFFiNEI18N } from '@affine/i18n/hooks';
import { CloseIcon, ToggleCollapseIcon } from '@blocksuite/icons'; import { CloseIcon, ToggleCollapseIcon } from '@blocksuite/icons';
@@ -154,10 +153,13 @@ const HistoryEditorPreview = ({
const planPromptClosedAtom = atom(false); const planPromptClosedAtom = atom(false);
const PlanPrompt = () => { const PlanPrompt = () => {
const [subscription] = useUserSubscription();
const currentWorkspace = useAtomValue(waitForCurrentWorkspaceAtom); const currentWorkspace = useAtomValue(waitForCurrentWorkspaceAtom);
const isOwner = useIsWorkspaceOwner(currentWorkspace.meta); const isOwner = useIsWorkspaceOwner(currentWorkspace.meta);
const freePlan = subscription?.plan === SubscriptionPlan.Free; const workspaceQuota = useWorkspaceQuota(currentWorkspace.id);
const isProWorkspace = useMemo(() => {
return workspaceQuota?.humanReadable.name.toLowerCase() !== 'free';
}, [workspaceQuota]);
const setSettingModalAtom = useSetAtom(openSettingModalAtom); const setSettingModalAtom = useSetAtom(openSettingModalAtom);
const [planPromptClosed, setPlanPromptClosed] = useAtom(planPromptClosedAtom); const [planPromptClosed, setPlanPromptClosed] = useAtom(planPromptClosedAtom);
@@ -178,7 +180,7 @@ const PlanPrompt = () => {
const planTitle = useMemo(() => { const planTitle = useMemo(() => {
return ( return (
<div className={styles.planPromptTitle}> <div className={styles.planPromptTitle}>
{freePlan {!isProWorkspace
? t[ ? t[
'com.affine.history.confirm-restore-modal.plan-prompt.limited-title' 'com.affine.history.confirm-restore-modal.plan-prompt.limited-title'
]() ]()
@@ -191,14 +193,15 @@ const PlanPrompt = () => {
/> />
</div> </div>
); );
}, [closeFreePlanPrompt, freePlan, t]); }, [closeFreePlanPrompt, isProWorkspace, t]);
const planDescription = useMemo(() => { const planDescription = useMemo(() => {
if (freePlan) { if (!isProWorkspace) {
return ( return (
<> <>
<Trans i18nKey="com.affine.history.confirm-restore-modal.free-plan-prompt.description"> <Trans i18nKey="com.affine.history.confirm-restore-modal.free-plan-prompt.description">
Free users can view up to the <b>last 7 days</b> of page history. With the workspace creator&apos;s Free account, every member can
access up to <b>7 days</b> of version history.
</Trans> </Trans>
{isOwner ? ( {isOwner ? (
<span <span
@@ -215,11 +218,12 @@ const PlanPrompt = () => {
} else { } else {
return ( return (
<Trans i18nKey="com.affine.history.confirm-restore-modal.pro-plan-prompt.description"> <Trans i18nKey="com.affine.history.confirm-restore-modal.pro-plan-prompt.description">
Pro users can view up to the <b>last 30 days</b> of page history. With the workspace creator&apos;s Pro account, every member enjoys the
privilege of accessing up to <b>30 days</b> of version history.
</Trans> </Trans>
); );
} }
}, [freePlan, isOwner, onClickUpgrade, t]); }, [isOwner, isProWorkspace, onClickUpgrade, t]);
return !planPromptClosed ? ( return !planPromptClosed ? (
<div className={styles.planPromptWrapper}> <div className={styles.planPromptWrapper}>

View File

@@ -0,0 +1,91 @@
import { ConfirmModal } from '@affine/component/ui/modal';
import { openQuotaModalAtom, openSettingModalAtom } from '@affine/core/atoms';
import { useIsWorkspaceOwner } from '@affine/core/hooks/affine/use-is-workspace-owner';
import { useUserQuota } from '@affine/core/hooks/use-quota';
import { useWorkspaceQuota } from '@affine/core/hooks/use-workspace-quota';
import { waitForCurrentWorkspaceAtom } from '@affine/core/modules/workspace';
import { useAFFiNEI18N } from '@affine/i18n/hooks';
import bytes from 'bytes';
import { useAtom, useAtomValue, useSetAtom } from 'jotai';
import { useCallback, useEffect, useMemo } from 'react';
export const CloudQuotaModal = () => {
const t = useAFFiNEI18N();
const currentWorkspace = useAtomValue(waitForCurrentWorkspaceAtom);
const [open, setOpen] = useAtom(openQuotaModalAtom);
const workspaceQuota = useWorkspaceQuota(currentWorkspace.id);
const isOwner = useIsWorkspaceOwner(currentWorkspace.meta);
const userQuota = useUserQuota();
const isFreePlanOwner = useMemo(() => {
return isOwner && userQuota?.humanReadable.name.toLowerCase() === 'free';
}, [isOwner, userQuota?.humanReadable.name]);
const setSettingModalAtom = useSetAtom(openSettingModalAtom);
const handleUpgradeConfirm = useCallback(() => {
if (isFreePlanOwner) {
setSettingModalAtom({
open: true,
activeTab: 'plans',
});
}
setOpen(false);
}, [isFreePlanOwner, setOpen, setSettingModalAtom]);
const description = useMemo(() => {
if (userQuota && isFreePlanOwner) {
return t['com.affine.payment.blob-limit.description.owner.free']({
planName: userQuota.humanReadable.name,
currentQuota: userQuota.humanReadable.blobLimit,
upgradeQuota: '100MB',
});
}
if (isOwner && userQuota?.humanReadable.name.toLowerCase() === 'pro') {
return t['com.affine.payment.blob-limit.description.owner.pro']({
planName: userQuota.humanReadable.name,
quota: userQuota.humanReadable.blobLimit,
});
}
return t['com.affine.payment.blob-limit.description.member']({
quota: workspaceQuota.humanReadable.blobLimit,
});
}, [
isFreePlanOwner,
isOwner,
t,
userQuota,
workspaceQuota.humanReadable.blobLimit,
]);
useEffect(() => {
currentWorkspace.engine.blob.singleBlobSizeLimit = bytes.parse(
workspaceQuota.blobLimit.toString()
);
const disposable = currentWorkspace.engine.blob.onAbortLargeBlob.on(() => {
setOpen(true);
});
return () => {
disposable?.dispose();
};
}, [currentWorkspace.engine.blob, setOpen, workspaceQuota.blobLimit]);
return (
<ConfirmModal
open={open}
title={t['com.affine.payment.blob-limit.title']()}
onOpenChange={setOpen}
description={description}
cancelButtonOptions={{
hidden: !isFreePlanOwner,
}}
onConfirm={handleUpgradeConfirm}
confirmButtonOptions={{
type: 'primary',
children: isFreePlanOwner
? t['com.affine.payment.upgrade']()
: t['Got it'](),
}}
/>
);
};

View File

@@ -0,0 +1,2 @@
export * from './cloud-quota-modal';
export * from './local-quota-modal';

View File

@@ -0,0 +1,44 @@
import { ConfirmModal } from '@affine/component/ui/modal';
import { openQuotaModalAtom } from '@affine/core/atoms';
import { waitForCurrentWorkspaceAtom } from '@affine/core/modules/workspace';
import { useAFFiNEI18N } from '@affine/i18n/hooks';
import { useAtom, useAtomValue } from 'jotai';
import { useCallback, useEffect } from 'react';
export const LocalQuotaModal = () => {
const t = useAFFiNEI18N();
const currentWorkspace = useAtomValue(waitForCurrentWorkspaceAtom);
const [open, setOpen] = useAtom(openQuotaModalAtom);
const onConfirm = useCallback(() => {
setOpen(false);
}, [setOpen]);
useEffect(() => {
const disposable = currentWorkspace.engine.blob.onAbortLargeBlob.on(() => {
setOpen(true);
});
return () => {
disposable?.dispose();
};
}, [currentWorkspace.engine.blob.onAbortLargeBlob, setOpen]);
return (
<ConfirmModal
open={open}
title={t['com.affine.payment.blob-limit.title']()}
description={t['com.affine.payment.blob-limit.description.local']({
quota: '100MB',
})}
onOpenChange={setOpen}
cancelButtonOptions={{
hidden: true,
}}
onConfirm={onConfirm}
confirmButtonOptions={{
type: 'primary',
children: t['Got it'](),
}}
/>
);
};

View File

@@ -79,6 +79,7 @@ export function getPlanDetail(t: ReturnType<typeof useAFFiNEI18N>) {
t['com.affine.payment.benefit-4']({ capacity: '10GB' }), t['com.affine.payment.benefit-4']({ capacity: '10GB' }),
t['com.affine.payment.benefit-5']({ capacity: '10M' }), t['com.affine.payment.benefit-5']({ capacity: '10M' }),
t['com.affine.payment.benefit-6']({ capacity: '3' }), t['com.affine.payment.benefit-6']({ capacity: '3' }),
t['com.affine.payment.benefit-7']({ capacity: '7' }),
], ],
}, },
], ],
@@ -96,6 +97,7 @@ export function getPlanDetail(t: ReturnType<typeof useAFFiNEI18N>) {
t['com.affine.payment.benefit-4']({ capacity: '100GB' }), t['com.affine.payment.benefit-4']({ capacity: '100GB' }),
t['com.affine.payment.benefit-5']({ capacity: '100M' }), t['com.affine.payment.benefit-5']({ capacity: '100M' }),
t['com.affine.payment.benefit-6']({ capacity: '10' }), t['com.affine.payment.benefit-6']({ capacity: '10' }),
t['com.affine.payment.benefit-7']({ capacity: '30' }),
], ],
}, },
], ],

View File

@@ -11,13 +11,9 @@ import ReactDOMServer from 'react-dom/server';
class CustomAttachmentService extends AttachmentService { class CustomAttachmentService extends AttachmentService {
override mounted(): void { override mounted(): void {
//TODO: get user type from store // blocksuite default max file size is 10MB, we override it to 2GB
const userType = 'pro'; // but the real place to limit blob size is CloudQuotaModal / LocalQuotaModal
if (userType === 'pro') { this.maxFileSize = bytes.parse('2GB');
this.maxFileSize = bytes.parse('100MB');
} else {
this.maxFileSize = bytes.parse('10MB');
}
} }
} }

View File

@@ -1,11 +1,15 @@
import { pushNotificationAtom } from '@affine/component/notification-center';
import { Avatar } from '@affine/component/ui/avatar'; import { Avatar } from '@affine/component/ui/avatar';
import { Loading } from '@affine/component/ui/loading'; import { Loading } from '@affine/component/ui/loading';
import { Tooltip } from '@affine/component/ui/tooltip'; import { Tooltip } from '@affine/component/ui/tooltip';
import { openSettingModalAtom } from '@affine/core/atoms';
import { useIsWorkspaceOwner } from '@affine/core/hooks/affine/use-is-workspace-owner';
import { useWorkspaceBlobObjectUrl } from '@affine/core/hooks/use-workspace-blob'; import { useWorkspaceBlobObjectUrl } from '@affine/core/hooks/use-workspace-blob';
import { useWorkspaceInfo } from '@affine/core/hooks/use-workspace-info'; import { useWorkspaceInfo } from '@affine/core/hooks/use-workspace-info';
import { waitForCurrentWorkspaceAtom } from '@affine/core/modules/workspace'; import { waitForCurrentWorkspaceAtom } from '@affine/core/modules/workspace';
import { UNTITLED_WORKSPACE_NAME } from '@affine/env/constant'; import { UNTITLED_WORKSPACE_NAME } from '@affine/env/constant';
import { WorkspaceFlavour } from '@affine/env/workspace'; import { WorkspaceFlavour } from '@affine/env/workspace';
import { useAFFiNEI18N } from '@affine/i18n/hooks';
import { type SyncEngineStatus, SyncEngineStep } from '@affine/workspace'; import { type SyncEngineStatus, SyncEngineStep } from '@affine/workspace';
import { import {
CloudWorkspaceIcon, CloudWorkspaceIcon,
@@ -14,7 +18,7 @@ import {
NoNetworkIcon, NoNetworkIcon,
UnsyncIcon, UnsyncIcon,
} from '@blocksuite/icons'; } from '@blocksuite/icons';
import { useAtomValue } from 'jotai'; import { useAtomValue, useSetAtom } from 'jotai';
import { debounce, mean } from 'lodash-es'; import { debounce, mean } from 'lodash-es';
import { import {
forwardRef, forwardRef,
@@ -86,12 +90,23 @@ const OfflineStatus = () => {
}; };
const useSyncEngineSyncProgress = () => { const useSyncEngineSyncProgress = () => {
const t = useAFFiNEI18N();
const isOnline = useSystemOnline(); const isOnline = useSystemOnline();
const pushNotification = useSetAtom(pushNotificationAtom);
const [syncEngineStatus, setSyncEngineStatus] = const [syncEngineStatus, setSyncEngineStatus] =
useState<SyncEngineStatus | null>(null); useState<SyncEngineStatus | null>(null);
const [isOverCapacity, setIsOverCapacity] = useState(false);
const currentWorkspace = useAtomValue(waitForCurrentWorkspaceAtom); const currentWorkspace = useAtomValue(waitForCurrentWorkspaceAtom);
const isOwner = useIsWorkspaceOwner(currentWorkspace.meta);
const setSettingModalAtom = useSetAtom(openSettingModalAtom);
const jumpToPricePlan = useCallback(async () => {
setSettingModalAtom({
open: true,
activeTab: 'plans',
});
}, [setSettingModalAtom]);
// debounce sync engine status // debounce sync engine status
useEffect(() => { useEffect(() => {
@@ -108,10 +123,39 @@ const useSyncEngineSyncProgress = () => {
} }
) )
); );
const disposableOverCapacity =
currentWorkspace.engine.blob.onStatusChange.on(
debounce(status => {
const isOver = status?.isStorageOverCapacity;
if (!isOver) {
setIsOverCapacity(false);
return;
}
setIsOverCapacity(true);
if (isOwner) {
pushNotification({
type: 'warning',
title: t['com.affine.payment.storage-limit.title'](),
message:
t['com.affine.payment.storage-limit.description.owner'](),
actionLabel: t['com.affine.payment.storage-limit.view'](),
action: jumpToPricePlan,
});
} else {
pushNotification({
type: 'warning',
title: t['com.affine.payment.storage-limit.title'](),
message:
t['com.affine.payment.storage-limit.description.member'](),
});
}
})
);
return () => { return () => {
disposable?.dispose(); disposable?.dispose();
disposableOverCapacity?.dispose();
}; };
}, [currentWorkspace]); }, [currentWorkspace, isOwner, jumpToPricePlan, pushNotification, t]);
const progress = useMemo(() => { const progress = useMemo(() => {
if (!syncEngineStatus?.remotes || syncEngineStatus?.remotes.length === 0) { if (!syncEngineStatus?.remotes || syncEngineStatus?.remotes.length === 0) {
@@ -151,20 +195,29 @@ const useSyncEngineSyncProgress = () => {
if (syncEngineStatus.retrying) { if (syncEngineStatus.retrying) {
return 'Sync disconnected due to unexpected issues, reconnecting.'; return 'Sync disconnected due to unexpected issues, reconnecting.';
} }
if (isOverCapacity) {
return 'Sync failed due to insufficient cloud storage space.';
}
return 'Synced with AFFiNE Cloud'; return 'Synced with AFFiNE Cloud';
}, [currentWorkspace.flavour, isOnline, progress, syncEngineStatus]); }, [
currentWorkspace.flavour,
isOnline,
isOverCapacity,
progress,
syncEngineStatus,
]);
const CloudWorkspaceSyncStatus = useCallback(() => { const CloudWorkspaceSyncStatus = useCallback(() => {
if (!syncEngineStatus || syncEngineStatus.step === SyncEngineStep.Syncing) { if (!syncEngineStatus || syncEngineStatus.step === SyncEngineStep.Syncing) {
return SyncingWorkspaceStatus({ return SyncingWorkspaceStatus({
progress: progress ? Math.max(progress, 0.2) : undefined, progress: progress ? Math.max(progress, 0.2) : undefined,
}); });
} else if (syncEngineStatus.retrying) { } else if (syncEngineStatus.retrying || isOverCapacity) {
return UnSyncWorkspaceStatus(); return UnSyncWorkspaceStatus();
} else { } else {
return CloudWorkspaceStatus(); return CloudWorkspaceStatus();
} }
}, [progress, syncEngineStatus]); }, [isOverCapacity, progress, syncEngineStatus]);
return { return {
message: content, message: content,

View File

@@ -0,0 +1,41 @@
import { workspaceQuotaQuery } from '@affine/graphql';
import bytes from 'bytes';
import { useCallback } from 'react';
import { useQuery } from './use-query';
export const useWorkspaceQuota = (workspaceId: string) => {
const { data } = useQuery({
query: workspaceQuotaQuery,
variables: {
id: workspaceId,
},
});
const changeToHumanReadable = useCallback((value: string | number) => {
return bytes.format(bytes.parse(value));
}, []);
const quotaData = data.workspace.quota;
const blobLimit = BigInt(quotaData.blobLimit);
const storageQuota = BigInt(quotaData.storageQuota);
const usedSize = BigInt(quotaData.usedSize);
const humanReadableBlobLimit = changeToHumanReadable(blobLimit.toString());
const humanReadableStorageQuota = changeToHumanReadable(
storageQuota.toString()
);
const humanReadableUsedSize = changeToHumanReadable(usedSize.toString());
return {
blobLimit,
storageQuota,
usedSize,
humanReadable: {
name: quotaData.humanReadableName,
blobLimit: humanReadableBlobLimit,
storageQuota: humanReadableStorageQuota,
usedSize: humanReadableUsedSize,
},
};
};

View File

@@ -67,6 +67,18 @@ const SignOutModal = lazy(() =>
})) }))
); );
const LocalQuotaModal = lazy(() =>
import('../components/affine/quota-reached-modal').then(module => ({
default: module.LocalQuotaModal,
}))
);
const CloudQuotaModal = lazy(() =>
import('../components/affine/quota-reached-modal').then(module => ({
default: module.CloudQuotaModal,
}))
);
export const Setting = () => { export const Setting = () => {
const currentWorkspace = useAtomValue(waitForCurrentWorkspaceAtom); const currentWorkspace = useAtomValue(waitForCurrentWorkspaceAtom);
const [{ open, workspaceMetadata, activeTab }, setOpenSettingModalAtom] = const [{ open, workspaceMetadata, activeTab }, setOpenSettingModalAtom] =
@@ -169,7 +181,13 @@ export function CurrentWorkspaceModals() {
</Suspense> </Suspense>
)} )}
<WorkspaceGuideModal /> <WorkspaceGuideModal />
{currentWorkspace && <Setting />} {currentWorkspace ? <Setting /> : null}
{currentWorkspace?.flavour === WorkspaceFlavour.LOCAL && (
<LocalQuotaModal />
)}
{currentWorkspace?.flavour === WorkspaceFlavour.AFFINE_CLOUD && (
<CloudQuotaModal />
)}
</> </>
); );
} }

View File

@@ -905,6 +905,7 @@ export const workspaceQuotaQuery = {
query workspaceQuota($id: String!) { query workspaceQuota($id: String!) {
workspace(id: $id) { workspace(id: $id) {
quota { quota {
humanReadableName
storageQuota storageQuota
usedSize usedSize
blobLimit blobLimit

View File

@@ -1,6 +1,7 @@
query workspaceQuota($id: String!) { query workspaceQuota($id: String!) {
workspace(id: $id) { workspace(id: $id) {
quota { quota {
humanReadableName
storageQuota storageQuota
usedSize usedSize
blobLimit blobLimit

View File

@@ -18,3 +18,5 @@ export function getBaseUrl(): string {
} }
export const fetcher = gqlFetcherFactory(getBaseUrl() + '/graphql'); export const fetcher = gqlFetcherFactory(getBaseUrl() + '/graphql');
export { GraphQLError } from 'graphql';

View File

@@ -854,6 +854,7 @@ export type WorkspaceQuotaQuery = {
__typename?: 'WorkspaceType'; __typename?: 'WorkspaceType';
quota: { quota: {
__typename?: 'QuotaQueryType'; __typename?: 'QuotaQueryType';
humanReadableName: string;
storageQuota: number; storageQuota: number;
usedSize: number; usedSize: number;
blobLimit: number; blobLimit: number;

View File

@@ -354,7 +354,7 @@
"com.affine.aboutAFFiNE.checkUpdate.subtitle.checking": "Checking for updates...", "com.affine.aboutAFFiNE.checkUpdate.subtitle.checking": "Checking for updates...",
"com.affine.aboutAFFiNE.checkUpdate.subtitle.update-available": "New update available ({{version}})", "com.affine.aboutAFFiNE.checkUpdate.subtitle.update-available": "New update available ({{version}})",
"com.affine.aboutAFFiNE.checkUpdate.subtitle.downloading": "Downloading the latest version...", "com.affine.aboutAFFiNE.checkUpdate.subtitle.downloading": "Downloading the latest version...",
"com.affine.aboutAFFiNE.checkUpdate.subtitle.restart": "Restart tot apply update.", "com.affine.aboutAFFiNE.checkUpdate.subtitle.restart": "Restart ot apply update.",
"com.affine.aboutAFFiNE.checkUpdate.subtitle.latest": "You’ve got the latest version of AFFiNE.", "com.affine.aboutAFFiNE.checkUpdate.subtitle.latest": "You’ve got the latest version of AFFiNE.",
"com.affine.aboutAFFiNE.checkUpdate.subtitle.error": "Unable to connect to the update server.", "com.affine.aboutAFFiNE.checkUpdate.subtitle.error": "Unable to connect to the update server.",
"com.affine.aboutAFFiNE.community.title": "Communities", "com.affine.aboutAFFiNE.community.title": "Communities",
@@ -724,6 +724,7 @@
"com.affine.payment.benefit-4": "{{capacity}} of Cloud Storage", "com.affine.payment.benefit-4": "{{capacity}} of Cloud Storage",
"com.affine.payment.benefit-5": "{{capacity}} of maximum file size", "com.affine.payment.benefit-5": "{{capacity}} of maximum file size",
"com.affine.payment.benefit-6": "Number of members per Workspace ≤ {{capacity}}", "com.affine.payment.benefit-6": "Number of members per Workspace ≤ {{capacity}}",
"com.affine.payment.benefit-7": "{{capacity}}-days version history",
"com.affine.payment.billing-setting.cancel-subscription": "Cancel Subscription", "com.affine.payment.billing-setting.cancel-subscription": "Cancel Subscription",
"com.affine.payment.billing-setting.cancel-subscription.description": "Subscription cancelled, your pro account will expire on {{cancelDate}}", "com.affine.payment.billing-setting.cancel-subscription.description": "Subscription cancelled, your pro account will expire on {{cancelDate}}",
"com.affine.payment.billing-setting.change-plan": "Change Plan", "com.affine.payment.billing-setting.change-plan": "Change Plan",
@@ -805,6 +806,15 @@
"com.affine.payment.member-limit.pro.description": "Each {{planName}} user can invite up to {{quota}} members to join their workspace. If you want to continue adding collaboration members, you can create a new workspace.", "com.affine.payment.member-limit.pro.description": "Each {{planName}} user can invite up to {{quota}} members to join their workspace. If you want to continue adding collaboration members, you can create a new workspace.",
"com.affine.payment.member-limit.free.confirm": "Upgrade", "com.affine.payment.member-limit.free.confirm": "Upgrade",
"com.affine.payment.member-limit.pro.confirm": "Got it", "com.affine.payment.member-limit.pro.confirm": "Got it",
"com.affine.payment.blob-limit.title": "You have reached the limit",
"com.affine.payment.blob-limit.description.local": "The maximum file upload size for local workspaces is {{quota}}.",
"com.affine.payment.blob-limit.description.owner.free": "{{planName}} users can upload files with a maximum size of {{currentQuota}}. You can upgrade your account to unlock a maximum file size of {{upgradeQuota}}.",
"com.affine.payment.blob-limit.description.owner.pro": "{{planName}} users can upload files with a maximum size of {{quota}}.",
"com.affine.payment.blob-limit.description.member": "The maximum file upload size for this joined workspace is {{quota}}. You can contact the owner of this workspace.",
"com.affine.payment.storage-limit.title": "Sync failed",
"com.affine.payment.storage-limit.description.owner": "Cloud storage is insufficient. You can upgrade your account to unlock more cloud storage.",
"com.affine.payment.storage-limit.description.member": "Cloud storage is insufficient. Please contact the owner of that workspace.",
"com.affine.payment.storage-limit.view": "View",
"com.affine.publicLinkDisableModal.button.cancel": "Cancel", "com.affine.publicLinkDisableModal.button.cancel": "Cancel",
"com.affine.publicLinkDisableModal.button.disable": "Disable", "com.affine.publicLinkDisableModal.button.disable": "Disable",
"com.affine.publicLinkDisableModal.description": "Disabling this public link will prevent anyone with the link from accessing this page.", "com.affine.publicLinkDisableModal.description": "Disabling this public link will prevent anyone with the link from accessing this page.",
@@ -1029,8 +1039,8 @@
"com.affine.history.confirm-restore-modal.load-more": "Load More", "com.affine.history.confirm-restore-modal.load-more": "Load More",
"com.affine.history.confirm-restore-modal.plan-prompt.title": "HELP INFO", "com.affine.history.confirm-restore-modal.plan-prompt.title": "HELP INFO",
"com.affine.history.confirm-restore-modal.plan-prompt.limited-title": "LIMITED PAGE HISTORY", "com.affine.history.confirm-restore-modal.plan-prompt.limited-title": "LIMITED PAGE HISTORY",
"com.affine.history.confirm-restore-modal.free-plan-prompt.description": "Free users can view up to the <1>last 7 days<1> of page history.", "com.affine.history.confirm-restore-modal.free-plan-prompt.description": "With the workspace creator's Free account, every member can access up to <1>7 days<1> of version history.",
"com.affine.history.confirm-restore-modal.pro-plan-prompt.description": "Pro users can view up to the <1>last 30 days<1> of page history.", "com.affine.history.confirm-restore-modal.pro-plan-prompt.description": "With the workspace creator's Pro account, every member enjoys the privilege of accessing up to <1>30 days<1> of version history.",
"com.affine.history.confirm-restore-modal.pro-plan-prompt.upgrade": "Upgrade", "com.affine.history.confirm-restore-modal.pro-plan-prompt.upgrade": "Upgrade",
"com.affine.share-page.header.present": "Present", "com.affine.share-page.header.present": "Present",
"com.affine.page-operation.add-linked-page": "Add linked page", "com.affine.page-operation.add-linked-page": "Add linked page",

View File

@@ -2,11 +2,14 @@ import {
deleteBlobMutation, deleteBlobMutation,
fetchWithTraceReport, fetchWithTraceReport,
getBaseUrl, getBaseUrl,
GraphQLError,
listBlobsQuery, listBlobsQuery,
setBlobMutation, setBlobMutation,
} from '@affine/graphql'; } from '@affine/graphql';
import { fetcher } from '@affine/graphql'; import { fetcher } from '@affine/graphql';
import type { BlobStorage } from '@affine/workspace'; import type { BlobStorage } from '@affine/workspace';
import { BlobStorageOverCapacity } from '@affine/workspace';
import { isArray } from 'lodash-es';
import { bufferToBlob } from '../utils/buffer-to-blob'; import { bufferToBlob } from '../utils/buffer-to-blob';
@@ -31,14 +34,24 @@ export const createAffineCloudBlobStorage = (
}, },
set: async (key, value) => { set: async (key, value) => {
// set blob will check blob size & quota // set blob will check blob size & quota
const result = await fetcher({ return await fetcher({
query: setBlobMutation, query: setBlobMutation,
variables: { variables: {
workspaceId, workspaceId,
blob: new File([value], key), blob: new File([value], key),
}, },
}); })
return result.setBlob; .then(res => res.setBlob)
.catch(err => {
if (isArray(err)) {
err.map(e => {
if (e instanceof GraphQLError && e.extensions.code === 413) {
throw new BlobStorageOverCapacity(e);
} else throw e;
});
}
throw err;
});
}, },
list: async () => { list: async () => {
const result = await fetcher({ const result = await fetcher({

View File

@@ -39,7 +39,7 @@ export const Basic = () => {
message: `${key} message`, message: `${key} message`,
timeout: 3000, timeout: 3000,
progressingBar: true, progressingBar: true,
undo: async () => { action: async () => {
console.log('undo'); console.log('undo');
}, },
type: 'info', type: 'info',
@@ -218,7 +218,7 @@ export const Basic = () => {
type: 'info', type: 'info',
multimedia: image, multimedia: image,
timeout: 3000, timeout: 3000,
undo: async () => { action: async () => {
console.log('undo'); console.log('undo');
}, },
progressingBar: true, progressingBar: true,