mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-02-04 08:38:34 +00:00
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:
@@ -25,6 +25,7 @@ export class QuotaManagementService {
|
||||
storageQuota: quota.feature.storageQuota,
|
||||
historyPeriod: quota.feature.historyPeriod,
|
||||
memberLimit: quota.feature.memberLimit,
|
||||
humanReadableName: quota.feature.humanReadable.name,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -45,11 +46,12 @@ export class QuotaManagementService {
|
||||
const { user: owner } =
|
||||
await this.permissions.getWorkspaceOwner(workspaceId);
|
||||
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
|
||||
const usedSize = await this.getUserUsage(owner.id);
|
||||
|
||||
return { storageQuota, usedSize, blobLimit };
|
||||
return { humanReadableName, storageQuota, usedSize, blobLimit };
|
||||
}
|
||||
|
||||
async checkBlobQuota(workspaceId: string, size: number) {
|
||||
|
||||
@@ -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 { commonFeatureSchema, FeatureKind } from '../features';
|
||||
@@ -42,13 +43,16 @@ export type Quota = z.infer<typeof QuotaSchema>;
|
||||
|
||||
@ObjectType()
|
||||
export class QuotaQueryType {
|
||||
@Field(() => Int)
|
||||
@Field(() => String)
|
||||
humanReadableName!: string;
|
||||
|
||||
@Field(() => SafeIntResolver)
|
||||
storageQuota!: number;
|
||||
|
||||
@Field(() => Int)
|
||||
@Field(() => SafeIntResolver)
|
||||
usedSize!: number;
|
||||
|
||||
@Field(() => Int)
|
||||
@Field(() => SafeIntResolver)
|
||||
blobLimit!: number;
|
||||
}
|
||||
|
||||
|
||||
@@ -190,9 +190,10 @@ type Query {
|
||||
}
|
||||
|
||||
type QuotaQueryType {
|
||||
blobLimit: Int!
|
||||
storageQuota: Int!
|
||||
usedSize: Int!
|
||||
blobLimit: SafeInt!
|
||||
humanReadableName: String!
|
||||
storageQuota: SafeInt!
|
||||
usedSize: SafeInt!
|
||||
}
|
||||
|
||||
type RemoveAvatar {
|
||||
|
||||
@@ -1,8 +1,15 @@
|
||||
import { DebugLogger } from '@affine/debug';
|
||||
import { Slot } from '@blocksuite/global/utils';
|
||||
import { difference } from 'lodash-es';
|
||||
|
||||
import { BlobStorageOverCapacity } from './error';
|
||||
|
||||
const logger = new DebugLogger('affine:blob-engine');
|
||||
|
||||
export interface BlobStatus {
|
||||
isStorageOverCapacity: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* # BlobEngine
|
||||
*
|
||||
@@ -12,6 +19,19 @@ const logger = new DebugLogger('affine:blob-engine');
|
||||
*/
|
||||
export class BlobEngine {
|
||||
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(
|
||||
private readonly local: BlobStorage,
|
||||
@@ -53,7 +73,7 @@ export class BlobEngine {
|
||||
}
|
||||
|
||||
async sync() {
|
||||
if (this.local.readonly) {
|
||||
if (this.local.readonly || this._status.isStorageOverCapacity) {
|
||||
return;
|
||||
}
|
||||
logger.debug('start syncing blob...');
|
||||
@@ -78,6 +98,11 @@ export class BlobEngine {
|
||||
await remote.set(key, data);
|
||||
}
|
||||
} catch (err) {
|
||||
if (err instanceof BlobStorageOverCapacity) {
|
||||
this.status = {
|
||||
isStorageOverCapacity: true,
|
||||
};
|
||||
}
|
||||
logger.error(
|
||||
`error when sync ${key} from [${this.local.name}] to [${remote.name}]`,
|
||||
err
|
||||
@@ -122,6 +147,12 @@ export class BlobEngine {
|
||||
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 this.local.set(key, value);
|
||||
|
||||
@@ -131,7 +162,7 @@ export class BlobEngine {
|
||||
.filter(r => !r.readonly)
|
||||
.map(peer =>
|
||||
peer.set(key, value).catch(err => {
|
||||
logger.error('error when upload to peer', err);
|
||||
logger.error('Error when uploading to peer', err);
|
||||
})
|
||||
)
|
||||
)
|
||||
|
||||
5
packages/common/workspace/src/engine/error.ts
Normal file
5
packages/common/workspace/src/engine/error.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export class BlobStorageOverCapacity extends Error {
|
||||
constructor(public originError?: any) {
|
||||
super('Blob storage over capacity.');
|
||||
}
|
||||
}
|
||||
@@ -2,11 +2,12 @@ import { Slot } from '@blocksuite/global/utils';
|
||||
|
||||
import { throwIfAborted } from '../utils/throw-if-aborted';
|
||||
import type { AwarenessProvider } from './awareness';
|
||||
import type { BlobEngine } from './blob';
|
||||
import type { BlobEngine, BlobStatus } from './blob';
|
||||
import type { SyncEngine, SyncEngineStatus } from './sync';
|
||||
|
||||
export interface WorkspaceEngineStatus {
|
||||
sync: SyncEngineStatus;
|
||||
blob: BlobStatus;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -34,10 +35,18 @@ export class WorkspaceEngine {
|
||||
) {
|
||||
this._status = {
|
||||
sync: sync.status,
|
||||
blob: blob.status,
|
||||
};
|
||||
sync.onStatusChange.on(status => {
|
||||
this.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 './blob';
|
||||
export * from './error';
|
||||
export * from './sync';
|
||||
|
||||
@@ -11,7 +11,8 @@ export type Notification = {
|
||||
progressingBar?: boolean;
|
||||
multimedia?: React.ReactNode | JSX.Element;
|
||||
// actions
|
||||
undo?: () => Promise<void>;
|
||||
action?: () => Promise<void>;
|
||||
actionLabel?: string;
|
||||
};
|
||||
|
||||
const notificationsBaseAtom = atom<Notification[]>([]);
|
||||
@@ -48,19 +49,19 @@ export const pushNotificationAtom = atom<null, [Notification], void>(
|
||||
set(notificationsBaseAtom, notifications =>
|
||||
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;
|
||||
return async function undoNotificationWrapper() {
|
||||
const action: () => Promise<void> = newNotification.action;
|
||||
return async function actionNotificationWrapper() {
|
||||
removeNotification();
|
||||
return undo();
|
||||
return action();
|
||||
};
|
||||
})()
|
||||
: undefined;
|
||||
|
||||
set(notificationsBaseAtom, notifications => [
|
||||
// push to the top
|
||||
{ ...newNotification, undo },
|
||||
{ ...newNotification, action },
|
||||
...notifications,
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
// License on the MIT
|
||||
// https://github.com/emilkowalski/sonner/blob/5cb703edc108a23fd74979235c2f3c4005edd2a7/src/index.tsx
|
||||
|
||||
import { useAFFiNEI18N } from '@affine/i18n/hooks';
|
||||
import { CloseIcon, InformationFillDuotoneIcon } from '@blocksuite/icons';
|
||||
import * as Toast from '@radix-ui/react-toast';
|
||||
import clsx from 'clsx';
|
||||
@@ -71,6 +72,7 @@ const typeColorMap = {
|
||||
};
|
||||
|
||||
function NotificationCard(props: NotificationCardProps): ReactNode {
|
||||
const t = useAFFiNEI18N();
|
||||
const removeNotification = useSetAtom(removeNotificationAtom);
|
||||
const { notification, notifications, setHeights, heights, index } = props;
|
||||
|
||||
@@ -134,7 +136,6 @@ function NotificationCard(props: NotificationCardProps): ReactNode {
|
||||
notificationNode.style.height = 'auto';
|
||||
const newHeight = notificationNode.getBoundingClientRect().height;
|
||||
notificationNode.style.height = originalHeight;
|
||||
|
||||
setInitialHeight(newHeight);
|
||||
|
||||
setHeights(heights => {
|
||||
@@ -190,9 +191,9 @@ function NotificationCard(props: NotificationCardProps): ReactNode {
|
||||
};
|
||||
}, [duration, expand, onClickRemove]);
|
||||
|
||||
const onClickUndo = useCallback(() => {
|
||||
if (notification.undo) {
|
||||
notification.undo().catch(err => {
|
||||
const onClickAction = useCallback(() => {
|
||||
if (notification.action) {
|
||||
notification.action().catch(err => {
|
||||
console.error(err);
|
||||
});
|
||||
}
|
||||
@@ -298,7 +299,7 @@ function NotificationCard(props: NotificationCardProps): ReactNode {
|
||||
<div className={styles.notificationTitleContactStyle}>
|
||||
{notification.title}
|
||||
</div>
|
||||
{notification.undo && (
|
||||
{notification.action && (
|
||||
<div
|
||||
className={clsx(styles.undoButtonStyle, {
|
||||
[styles.darkColorStyle]:
|
||||
@@ -306,15 +307,16 @@ function NotificationCard(props: NotificationCardProps): ReactNode {
|
||||
notification.theme !== 'default',
|
||||
[styles.undoButtonWithMediaStyle]: notification.multimedia,
|
||||
})}
|
||||
onClick={onClickUndo}
|
||||
onClick={onClickAction}
|
||||
>
|
||||
UNDO
|
||||
{notification.actionLabel ??
|
||||
t['com.affine.keyboardShortcuts.undo']()}
|
||||
</div>
|
||||
)}
|
||||
{notification.multimedia ? null : (
|
||||
<IconButton
|
||||
className={clsx(styles.closeButtonStyle, {
|
||||
[styles.closeButtonWithoutUndoStyle]: !notification.undo,
|
||||
[styles.closeButtonWithoutUndoStyle]: !notification.action,
|
||||
})}
|
||||
style={{
|
||||
color:
|
||||
|
||||
@@ -13,6 +13,7 @@ export const openQuickSearchModalAtom = atom(false);
|
||||
export const openOnboardingModalAtom = atom(false);
|
||||
export const openSignOutModalAtom = atom(false);
|
||||
export const openPaymentDisableAtom = atom(false);
|
||||
export const openQuotaModalAtom = atom(false);
|
||||
|
||||
export type SettingAtom = Pick<
|
||||
SettingProps,
|
||||
|
||||
@@ -6,10 +6,9 @@ import { openSettingModalAtom, type PageMode } from '@affine/core/atoms';
|
||||
import { useIsWorkspaceOwner } from '@affine/core/hooks/affine/use-is-workspace-owner';
|
||||
import { useAsyncCallback } from '@affine/core/hooks/affine-async-hooks';
|
||||
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 { timestampToLocalTime } from '@affine/core/utils';
|
||||
import { SubscriptionPlan } from '@affine/graphql';
|
||||
import { Trans } from '@affine/i18n';
|
||||
import { useAFFiNEI18N } from '@affine/i18n/hooks';
|
||||
import { CloseIcon, ToggleCollapseIcon } from '@blocksuite/icons';
|
||||
@@ -154,10 +153,13 @@ const HistoryEditorPreview = ({
|
||||
const planPromptClosedAtom = atom(false);
|
||||
|
||||
const PlanPrompt = () => {
|
||||
const [subscription] = useUserSubscription();
|
||||
const currentWorkspace = useAtomValue(waitForCurrentWorkspaceAtom);
|
||||
|
||||
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 [planPromptClosed, setPlanPromptClosed] = useAtom(planPromptClosedAtom);
|
||||
@@ -178,7 +180,7 @@ const PlanPrompt = () => {
|
||||
const planTitle = useMemo(() => {
|
||||
return (
|
||||
<div className={styles.planPromptTitle}>
|
||||
{freePlan
|
||||
{!isProWorkspace
|
||||
? t[
|
||||
'com.affine.history.confirm-restore-modal.plan-prompt.limited-title'
|
||||
]()
|
||||
@@ -191,14 +193,15 @@ const PlanPrompt = () => {
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}, [closeFreePlanPrompt, freePlan, t]);
|
||||
}, [closeFreePlanPrompt, isProWorkspace, t]);
|
||||
|
||||
const planDescription = useMemo(() => {
|
||||
if (freePlan) {
|
||||
if (!isProWorkspace) {
|
||||
return (
|
||||
<>
|
||||
<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's Free account, every member can
|
||||
access up to <b>7 days</b> of version history.
|
||||
</Trans>
|
||||
{isOwner ? (
|
||||
<span
|
||||
@@ -215,11 +218,12 @@ const PlanPrompt = () => {
|
||||
} else {
|
||||
return (
|
||||
<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's Pro account, every member enjoys the
|
||||
privilege of accessing up to <b>30 days</b> of version history.
|
||||
</Trans>
|
||||
);
|
||||
}
|
||||
}, [freePlan, isOwner, onClickUpgrade, t]);
|
||||
}, [isOwner, isProWorkspace, onClickUpgrade, t]);
|
||||
|
||||
return !planPromptClosed ? (
|
||||
<div className={styles.planPromptWrapper}>
|
||||
|
||||
@@ -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'](),
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,2 @@
|
||||
export * from './cloud-quota-modal';
|
||||
export * from './local-quota-modal';
|
||||
@@ -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'](),
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -79,6 +79,7 @@ export function getPlanDetail(t: ReturnType<typeof useAFFiNEI18N>) {
|
||||
t['com.affine.payment.benefit-4']({ capacity: '10GB' }),
|
||||
t['com.affine.payment.benefit-5']({ capacity: '10M' }),
|
||||
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-5']({ capacity: '100M' }),
|
||||
t['com.affine.payment.benefit-6']({ capacity: '10' }),
|
||||
t['com.affine.payment.benefit-7']({ capacity: '30' }),
|
||||
],
|
||||
},
|
||||
],
|
||||
|
||||
@@ -11,13 +11,9 @@ import ReactDOMServer from 'react-dom/server';
|
||||
|
||||
class CustomAttachmentService extends AttachmentService {
|
||||
override mounted(): void {
|
||||
//TODO: get user type from store
|
||||
const userType = 'pro';
|
||||
if (userType === 'pro') {
|
||||
this.maxFileSize = bytes.parse('100MB');
|
||||
} else {
|
||||
this.maxFileSize = bytes.parse('10MB');
|
||||
}
|
||||
// blocksuite default max file size is 10MB, we override it to 2GB
|
||||
// but the real place to limit blob size is CloudQuotaModal / LocalQuotaModal
|
||||
this.maxFileSize = bytes.parse('2GB');
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,11 +1,15 @@
|
||||
import { pushNotificationAtom } from '@affine/component/notification-center';
|
||||
import { Avatar } from '@affine/component/ui/avatar';
|
||||
import { Loading } from '@affine/component/ui/loading';
|
||||
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 { useWorkspaceInfo } from '@affine/core/hooks/use-workspace-info';
|
||||
import { waitForCurrentWorkspaceAtom } from '@affine/core/modules/workspace';
|
||||
import { UNTITLED_WORKSPACE_NAME } from '@affine/env/constant';
|
||||
import { WorkspaceFlavour } from '@affine/env/workspace';
|
||||
import { useAFFiNEI18N } from '@affine/i18n/hooks';
|
||||
import { type SyncEngineStatus, SyncEngineStep } from '@affine/workspace';
|
||||
import {
|
||||
CloudWorkspaceIcon,
|
||||
@@ -14,7 +18,7 @@ import {
|
||||
NoNetworkIcon,
|
||||
UnsyncIcon,
|
||||
} from '@blocksuite/icons';
|
||||
import { useAtomValue } from 'jotai';
|
||||
import { useAtomValue, useSetAtom } from 'jotai';
|
||||
import { debounce, mean } from 'lodash-es';
|
||||
import {
|
||||
forwardRef,
|
||||
@@ -86,12 +90,23 @@ const OfflineStatus = () => {
|
||||
};
|
||||
|
||||
const useSyncEngineSyncProgress = () => {
|
||||
const t = useAFFiNEI18N();
|
||||
const isOnline = useSystemOnline();
|
||||
|
||||
const pushNotification = useSetAtom(pushNotificationAtom);
|
||||
const [syncEngineStatus, setSyncEngineStatus] =
|
||||
useState<SyncEngineStatus | null>(null);
|
||||
const [isOverCapacity, setIsOverCapacity] = useState(false);
|
||||
|
||||
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
|
||||
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 () => {
|
||||
disposable?.dispose();
|
||||
disposableOverCapacity?.dispose();
|
||||
};
|
||||
}, [currentWorkspace]);
|
||||
}, [currentWorkspace, isOwner, jumpToPricePlan, pushNotification, t]);
|
||||
|
||||
const progress = useMemo(() => {
|
||||
if (!syncEngineStatus?.remotes || syncEngineStatus?.remotes.length === 0) {
|
||||
@@ -151,20 +195,29 @@ const useSyncEngineSyncProgress = () => {
|
||||
if (syncEngineStatus.retrying) {
|
||||
return 'Sync disconnected due to unexpected issues, reconnecting.';
|
||||
}
|
||||
if (isOverCapacity) {
|
||||
return 'Sync failed due to insufficient cloud storage space.';
|
||||
}
|
||||
return 'Synced with AFFiNE Cloud';
|
||||
}, [currentWorkspace.flavour, isOnline, progress, syncEngineStatus]);
|
||||
}, [
|
||||
currentWorkspace.flavour,
|
||||
isOnline,
|
||||
isOverCapacity,
|
||||
progress,
|
||||
syncEngineStatus,
|
||||
]);
|
||||
|
||||
const CloudWorkspaceSyncStatus = useCallback(() => {
|
||||
if (!syncEngineStatus || syncEngineStatus.step === SyncEngineStep.Syncing) {
|
||||
return SyncingWorkspaceStatus({
|
||||
progress: progress ? Math.max(progress, 0.2) : undefined,
|
||||
});
|
||||
} else if (syncEngineStatus.retrying) {
|
||||
} else if (syncEngineStatus.retrying || isOverCapacity) {
|
||||
return UnSyncWorkspaceStatus();
|
||||
} else {
|
||||
return CloudWorkspaceStatus();
|
||||
}
|
||||
}, [progress, syncEngineStatus]);
|
||||
}, [isOverCapacity, progress, syncEngineStatus]);
|
||||
|
||||
return {
|
||||
message: content,
|
||||
|
||||
41
packages/frontend/core/src/hooks/use-workspace-quota.ts
Normal file
41
packages/frontend/core/src/hooks/use-workspace-quota.ts
Normal 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,
|
||||
},
|
||||
};
|
||||
};
|
||||
@@ -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 = () => {
|
||||
const currentWorkspace = useAtomValue(waitForCurrentWorkspaceAtom);
|
||||
const [{ open, workspaceMetadata, activeTab }, setOpenSettingModalAtom] =
|
||||
@@ -169,7 +181,13 @@ export function CurrentWorkspaceModals() {
|
||||
</Suspense>
|
||||
)}
|
||||
<WorkspaceGuideModal />
|
||||
{currentWorkspace && <Setting />}
|
||||
{currentWorkspace ? <Setting /> : null}
|
||||
{currentWorkspace?.flavour === WorkspaceFlavour.LOCAL && (
|
||||
<LocalQuotaModal />
|
||||
)}
|
||||
{currentWorkspace?.flavour === WorkspaceFlavour.AFFINE_CLOUD && (
|
||||
<CloudQuotaModal />
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -905,6 +905,7 @@ export const workspaceQuotaQuery = {
|
||||
query workspaceQuota($id: String!) {
|
||||
workspace(id: $id) {
|
||||
quota {
|
||||
humanReadableName
|
||||
storageQuota
|
||||
usedSize
|
||||
blobLimit
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
query workspaceQuota($id: String!) {
|
||||
workspace(id: $id) {
|
||||
quota {
|
||||
humanReadableName
|
||||
storageQuota
|
||||
usedSize
|
||||
blobLimit
|
||||
|
||||
@@ -18,3 +18,5 @@ export function getBaseUrl(): string {
|
||||
}
|
||||
|
||||
export const fetcher = gqlFetcherFactory(getBaseUrl() + '/graphql');
|
||||
|
||||
export { GraphQLError } from 'graphql';
|
||||
|
||||
@@ -854,6 +854,7 @@ export type WorkspaceQuotaQuery = {
|
||||
__typename?: 'WorkspaceType';
|
||||
quota: {
|
||||
__typename?: 'QuotaQueryType';
|
||||
humanReadableName: string;
|
||||
storageQuota: number;
|
||||
usedSize: number;
|
||||
blobLimit: number;
|
||||
|
||||
@@ -354,7 +354,7 @@
|
||||
"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.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.error": "Unable to connect to the update server.",
|
||||
"com.affine.aboutAFFiNE.community.title": "Communities",
|
||||
@@ -724,6 +724,7 @@
|
||||
"com.affine.payment.benefit-4": "{{capacity}} of Cloud Storage",
|
||||
"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-7": "{{capacity}}-days version history",
|
||||
"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.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.free.confirm": "Upgrade",
|
||||
"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.disable": "Disable",
|
||||
"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.plan-prompt.title": "HELP INFO",
|
||||
"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.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.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": "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.share-page.header.present": "Present",
|
||||
"com.affine.page-operation.add-linked-page": "Add linked page",
|
||||
|
||||
@@ -2,11 +2,14 @@ import {
|
||||
deleteBlobMutation,
|
||||
fetchWithTraceReport,
|
||||
getBaseUrl,
|
||||
GraphQLError,
|
||||
listBlobsQuery,
|
||||
setBlobMutation,
|
||||
} from '@affine/graphql';
|
||||
import { fetcher } from '@affine/graphql';
|
||||
import type { BlobStorage } from '@affine/workspace';
|
||||
import { BlobStorageOverCapacity } from '@affine/workspace';
|
||||
import { isArray } from 'lodash-es';
|
||||
|
||||
import { bufferToBlob } from '../utils/buffer-to-blob';
|
||||
|
||||
@@ -31,14 +34,24 @@ export const createAffineCloudBlobStorage = (
|
||||
},
|
||||
set: async (key, value) => {
|
||||
// set blob will check blob size & quota
|
||||
const result = await fetcher({
|
||||
return await fetcher({
|
||||
query: setBlobMutation,
|
||||
variables: {
|
||||
workspaceId,
|
||||
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 () => {
|
||||
const result = await fetcher({
|
||||
|
||||
@@ -39,7 +39,7 @@ export const Basic = () => {
|
||||
message: `${key} message`,
|
||||
timeout: 3000,
|
||||
progressingBar: true,
|
||||
undo: async () => {
|
||||
action: async () => {
|
||||
console.log('undo');
|
||||
},
|
||||
type: 'info',
|
||||
@@ -218,7 +218,7 @@ export const Basic = () => {
|
||||
type: 'info',
|
||||
multimedia: image,
|
||||
timeout: 3000,
|
||||
undo: async () => {
|
||||
action: async () => {
|
||||
console.log('undo');
|
||||
},
|
||||
progressingBar: true,
|
||||
|
||||
Reference in New Issue
Block a user