From f0b2c103c11a559bccfd034ed8c8f1b4f6a12919 Mon Sep 17 00:00:00 2001 From: Hyu Date: Mon, 3 Aug 2026 19:08:47 +0800 Subject: [PATCH] fix(web): disable quota-reached create actions (#2389) * fix(web): disable quota-reached create actions * fix(web): close quota review gaps --------- Co-authored-by: dadachann <185672915+dadachann@users.noreply.github.com> --- web/src/app/home/add-extension/page.tsx | 146 +++-- .../components/home-sidebar/HomeSidebar.tsx | 498 ++++++++++-------- .../home-sidebar/SidebarDataContext.tsx | 84 ++- .../workspace-quota/WorkspaceQuotaTooltip.tsx | 47 ++ .../useWorkspaceQuotaStatus.ts | 82 +++ .../components/PluginLocalPreviewPanel.tsx | 35 +- .../plugin-market/PluginMarketComponent.tsx | 14 + .../plugin-market/RecommendationLists.tsx | 12 + .../PluginMarketCardComponent.tsx | 29 +- .../components/SkillZipPreviewPanel.tsx | 79 ++- web/src/app/infra/entities/api/index.ts | 1 + web/src/i18n/locales/en-US.ts | 6 + web/src/i18n/locales/es-ES.ts | 6 + web/src/i18n/locales/ja-JP.ts | 6 + web/src/i18n/locales/ru-RU.ts | 6 + web/src/i18n/locales/th-TH.ts | 6 + web/src/i18n/locales/vi-VN.ts | 6 + web/src/i18n/locales/zh-Hans.ts | 4 + web/src/i18n/locales/zh-Hant.ts | 4 + web/tests/e2e/quota-create-actions.spec.ts | 148 ++++++ web/tests/unit/quota-create-actions.test.mjs | 111 ++++ 21 files changed, 1037 insertions(+), 293 deletions(-) create mode 100644 web/src/app/home/components/workspace-quota/WorkspaceQuotaTooltip.tsx create mode 100644 web/src/app/home/components/workspace-quota/useWorkspaceQuotaStatus.ts create mode 100644 web/tests/e2e/quota-create-actions.spec.ts create mode 100644 web/tests/unit/quota-create-actions.test.mjs diff --git a/web/src/app/home/add-extension/page.tsx b/web/src/app/home/add-extension/page.tsx index 1dfcb74e0..c3dd8b9a0 100644 --- a/web/src/app/home/add-extension/page.tsx +++ b/web/src/app/home/add-extension/page.tsx @@ -49,6 +49,8 @@ import type { } from '@/app/home/mcp/components/mcp-form/MCPForm'; import SkillZipPreviewPanel from '@/app/home/skills/components/SkillZipPreviewPanel'; import PluginLocalPreviewPanel from '@/app/home/plugins/components/PluginLocalPreviewPanel'; +import { useWorkspaceQuotaStatus } from '@/app/home/components/workspace-quota/useWorkspaceQuotaStatus'; +import { WorkspaceQuotaTooltip } from '@/app/home/components/workspace-quota/WorkspaceQuotaTooltip'; type PopoverView = 'menu' | 'mcp' | 'github'; @@ -154,6 +156,12 @@ function AddExtensionContent() { const navigate = useNavigate(); const [searchParams, setSearchParams] = useSearchParams(); const { refreshPlugins, refreshMCPServers, refreshSkills } = useSidebarData(); + const { extensions: extensionQuota, extensionsReached } = + useWorkspaceQuotaStatus(); + const extensionQuotaTooltip = t('limitation.createDisabledTooltip', { + resource: t('sidebar.extensions'), + max: extensionQuota.max, + }); // Localized label for an extension type, used in the install dialog. const extensionTypeLabel = (type: string) => @@ -344,23 +352,28 @@ function AddExtensionContent() { t, ]); - const handleInstallPlugin = useCallback(async (plugin: PluginV4) => { - setInstallInfo({ - plugin_author: plugin.author, - plugin_name: plugin.name, - plugin_version: plugin.latest_version, - plugin_label: extractI18nObject(plugin.label) || plugin.name, - plugin_description: extractI18nObject(plugin.description) || '', - plugin_icon: plugin.icon || '', - }); - setInstallExtensionType(plugin.type || 'plugin'); - setPluginInstallStatus(PluginInstallStatus.ASK_CONFIRM); - setInstallError(null); - setInstallIconFailed(false); - setModalOpen(true); - }, []); + const handleInstallPlugin = useCallback( + async (plugin: PluginV4) => { + if (extensionsReached) return; + setInstallInfo({ + plugin_author: plugin.author, + plugin_name: plugin.name, + plugin_version: plugin.latest_version, + plugin_label: extractI18nObject(plugin.label) || plugin.name, + plugin_description: extractI18nObject(plugin.description) || '', + plugin_icon: plugin.icon || '', + }); + setInstallExtensionType(plugin.type || 'plugin'); + setPluginInstallStatus(PluginInstallStatus.ASK_CONFIRM); + setInstallError(null); + setInstallIconFailed(false); + setModalOpen(true); + }, + [extensionsReached], + ); function handleModalConfirm() { + if (extensionsReached) return; setPluginInstallStatus(PluginInstallStatus.INSTALLING); const pluginDisplayName = `${installInfo.plugin_author}/${installInfo.plugin_name}`; httpClient @@ -402,6 +415,7 @@ function AddExtensionContent() { const uploadFile = useCallback( async (file: File) => { + if (extensionsReached) return; if (!validateFileType(file)) { toast.error(t('addExtension.unsupportedFileType')); return; @@ -421,14 +435,15 @@ function AddExtensionContent() { setSkillUploadPreviewOpen(true); } }, - [t, setSelectedTaskId], + [extensionsReached, t, setSelectedTaskId], ); const handleFileSelect = useCallback(() => { + if (extensionsReached) return; if (fileInputRef.current) { fileInputRef.current.click(); } - }, []); + }, [extensionsReached]); const handleFileChange = useCallback( (event: React.ChangeEvent) => { @@ -455,12 +470,13 @@ function AddExtensionContent() { (event: React.DragEvent) => { event.preventDefault(); setIsDragOver(false); + if (extensionsReached) return; const files = Array.from(event.dataTransfer.files); if (files.length > 0) { uploadFile(files[0]); } }, - [uploadFile], + [extensionsReached, uploadFile], ); function handleMCPCreated(_serverName: string) { @@ -490,7 +506,8 @@ function AddExtensionContent() { return false; } } catch { - // If we can't check, let backend handle it + toast.error(t('limitation.quotaCheckFailed')); + return false; } return true; } @@ -630,9 +647,11 @@ function AddExtensionContent() { async function handleGithubConfirm() { if (!selectedAsset || !selectedRelease) return; - if (!(await checkExtensionsLimit())) return; - setGithubInstallStatus(GithubInstallStatus.INSTALLING); + if (!(await checkExtensionsLimit())) { + setGithubInstallStatus(GithubInstallStatus.ASK_CONFIRM); + return; + } const pluginDisplayName = `${githubOwner}/${githubRepo}`; httpClient .installPluginFromGithub( @@ -664,9 +683,11 @@ function AddExtensionContent() { async function handleGithubSkillConfirm() { if (!githubSkillInfo) return; - if (!(await checkExtensionsLimit())) return; - setGithubInstallStatus(GithubInstallStatus.SKILL_INSTALLING); + if (!(await checkExtensionsLimit())) { + setGithubInstallStatus(GithubInstallStatus.SKILL_PREVIEW); + return; + } try { await httpClient.installSkillFromGithub( githubURL.trim(), @@ -726,17 +747,24 @@ function AddExtensionContent() { setPopoverOpen(open); }} > - - - + + + + + + {extensionsReached && ( +
+ {extensionQuotaTooltip} +
+ )} {/* File upload area */}
)} - @@ -1184,6 +1231,7 @@ function AddExtensionContent() { @@ -1240,6 +1288,8 @@ function AddExtensionContent() { @@ -1325,9 +1375,17 @@ function AddExtensionContent() { - + + + )} {pluginInstallStatus === PluginInstallStatus.ERROR && ( @@ -1359,6 +1417,8 @@ function AddExtensionContent() { {pluginUploadPreviewFile && ( { setPluginUploadPreviewOpen(false); setPluginUploadPreviewFile(null); @@ -1392,6 +1452,8 @@ function AddExtensionContent() { {skillUploadPreviewFile && ( { setSkillUploadPreviewOpen(false); setSkillUploadPreviewFile(null); diff --git a/web/src/app/home/components/home-sidebar/HomeSidebar.tsx b/web/src/app/home/components/home-sidebar/HomeSidebar.tsx index 3ba151cca..32be9b1f6 100644 --- a/web/src/app/home/components/home-sidebar/HomeSidebar.tsx +++ b/web/src/app/home/components/home-sidebar/HomeSidebar.tsx @@ -109,6 +109,11 @@ import { import { cn } from '@/lib/utils'; import { useSidebarData, SidebarEntityItem } from './SidebarDataContext'; import { FeedbackPopoverContent } from './FeedbackPopover'; +import { + type WorkspaceQuotaItem, + useWorkspaceQuotaStatus, +} from '@/app/home/components/workspace-quota/useWorkspaceQuotaStatus'; +import { WorkspaceQuotaTooltip } from '@/app/home/components/workspace-quota/WorkspaceQuotaTooltip'; // Compare two version strings, returns true if v1 > v2 function compareVersions(v1: string, v2: string): boolean { @@ -279,6 +284,14 @@ function sleep(ms: number) { return new Promise((resolve) => setTimeout(resolve, ms)); } +const UNLIMITED_QUOTA: WorkspaceQuotaItem = { + count: 0, + max: -1, + reached: false, + loading: false, + disabled: false, +}; + async function waitForMCPRefreshTask(taskId: number) { const deadline = Date.now() + MCP_REFRESH_TIMEOUT_MS; @@ -386,6 +399,7 @@ function NavItems({ const pathname = location.pathname; const [searchParams] = useSearchParams(); const sidebarData = useSidebarData(); + const quotaStatus = useWorkspaceQuotaStatus(); const { state: sidebarState, isMobile } = useSidebar(); const { t } = useTranslation(); const currentWorkspace = useCurrentWorkspace(); @@ -529,19 +543,33 @@ function NavItems({ if (config.id === 'add-extension' && !canManageResources) { return null; } - // Non-entity entries (e.g. monitoring, market, mcp) render as plain links + const quota = + config.id === 'add-extension' + ? quotaStatus.extensions + : UNLIMITED_QUOTA; + // Non-entity entries (e.g. monitoring and the extension market) render as plain links. return ( - onChildClick(config)} - tooltip={config.name} + - {config.icon} - - {config.name} - - + { + if (!quota.disabled) onChildClick(config); + }} + disabled={quota.disabled} + aria-disabled={quota.disabled} + tooltip={quota.disabled ? undefined : config.name} + > + {config.icon} + + {config.name} + + + ); } @@ -575,6 +603,18 @@ function NavItems({ const isSkill = categoryId === 'skills'; const isBot = categoryId === 'bots'; const isMCP = categoryId === 'mcp'; + const quota = + categoryId === 'bots' + ? quotaStatus.bots + : categoryId === 'pipelines' + ? quotaStatus.pipelines + : categoryId === 'knowledge' + ? quotaStatus.knowledgeBases + : categoryId === 'plugins' || + categoryId === 'mcp' || + categoryId === 'skills' + ? quotaStatus.extensions + : UNLIMITED_QUOTA; const resolveItemRoute = (item: SidebarEntityItem): string => { if (item.extensionType === 'mcp') { @@ -907,128 +947,144 @@ function NavItems({ >
{config.name} - {canCreate && - (isPlugin ? ( - - - - - - {systemInfo.enable_marketplace && ( + {canCreate && ( + + {isPlugin ? ( + + + + + + {systemInfo.enable_marketplace && ( + { + e.stopPropagation(); + navigate('/home/add-extension'); + setPopoverOpen((prev) => ({ + ...prev, + [config.id]: false, + })); + }} + > + + {t('plugins.goToMarketplace')} + + )} { e.stopPropagation(); - navigate('/home/add-extension'); + navigate('/home/add-extension?manual=1'); setPopoverOpen((prev) => ({ ...prev, [config.id]: false, })); }} > - - {t('plugins.goToMarketplace')} + + {t('plugins.uploadLocal')} - )} - { - e.stopPropagation(); - navigate('/home/add-extension?manual=1'); - setPopoverOpen((prev) => ({ - ...prev, - [config.id]: false, - })); - }} - > - - {t('plugins.uploadLocal')} - - { - e.stopPropagation(); - navigate('/home/add-extension?manual=1'); - setPopoverOpen((prev) => ({ - ...prev, - [config.id]: false, - })); - }} - > - - {t('plugins.installFromGithub')} - - - - ) : isSkill ? ( - - - - - - { - e.stopPropagation(); - navigate('/home/skills?action=create'); - setPopoverOpen((prev) => ({ - ...prev, - [config.id]: false, - })); - }} - > - - {t('skills.createManually')} - - { - e.stopPropagation(); - navigate('/home/add-extension?manual=1'); - setPopoverOpen((prev) => ({ - ...prev, - [config.id]: false, - })); - }} - > - - {t('skills.uploadZip')} - - { - e.stopPropagation(); - navigate('/home/add-extension?manual=1'); - setPopoverOpen((prev) => ({ - ...prev, - [config.id]: false, - })); - }} - > - - {t('skills.importFromGithub')} - - - - ) : ( - - ))} + { + e.stopPropagation(); + navigate('/home/add-extension?manual=1'); + setPopoverOpen((prev) => ({ + ...prev, + [config.id]: false, + })); + }} + > + + {t('plugins.installFromGithub')} + + + + ) : isSkill ? ( + + + + + + { + e.stopPropagation(); + navigate('/home/skills?action=create'); + setPopoverOpen((prev) => ({ + ...prev, + [config.id]: false, + })); + }} + > + + {t('skills.createManually')} + + { + e.stopPropagation(); + navigate('/home/add-extension?manual=1'); + setPopoverOpen((prev) => ({ + ...prev, + [config.id]: false, + })); + }} + > + + {t('skills.uploadZip')} + + { + e.stopPropagation(); + navigate('/home/add-extension?manual=1'); + setPopoverOpen((prev) => ({ + ...prev, + [config.id]: false, + })); + }} + > + + {t('skills.importFromGithub')} + + + + ) : ( + + )} + + )}
{renderEntityList(true)} @@ -1096,103 +1152,119 @@ function NavItems({ /> )} - {canCreate && - (isPlugin ? ( - - - - - - {systemInfo.enable_marketplace && ( + {canCreate && ( + + {isPlugin ? ( + + + + + + {systemInfo.enable_marketplace && ( + { + e.stopPropagation(); + navigate('/home/add-extension'); + }} + > + + {t('plugins.goToMarketplace')} + + )} { e.stopPropagation(); - navigate('/home/add-extension'); + navigate('/home/add-extension?manual=1'); }} > - - {t('plugins.goToMarketplace')} + + {t('plugins.uploadLocal')} - )} - { - e.stopPropagation(); - navigate('/home/add-extension?manual=1'); - }} - > - - {t('plugins.uploadLocal')} - - { - e.stopPropagation(); - navigate('/home/add-extension?manual=1'); - }} - > - - {t('plugins.installFromGithub')} - - - - ) : isSkill ? ( - - - - - - { - e.stopPropagation(); - navigate('/home/skills?action=create'); - }} - > - - {t('skills.createManually')} - - { - e.stopPropagation(); - navigate('/home/add-extension?manual=1'); - }} - > - - {t('skills.uploadZip')} - - { - e.stopPropagation(); - navigate('/home/add-extension?manual=1'); - }} - > - - {t('skills.importFromGithub')} - - - - ) : ( - - ))} + { + e.stopPropagation(); + navigate('/home/add-extension?manual=1'); + }} + > + + {t('plugins.installFromGithub')} + + + + ) : isSkill ? ( + + + + + + { + e.stopPropagation(); + navigate('/home/skills?action=create'); + }} + > + + {t('skills.createManually')} + + { + e.stopPropagation(); + navigate('/home/add-extension?manual=1'); + }} + > + + {t('skills.uploadZip')} + + { + e.stopPropagation(); + navigate('/home/add-extension?manual=1'); + }} + > + + {t('skills.importFromGithub')} + + + + ) : ( + + )} + + )} )} - + {quota ? ( + + + + ) : ( + + )}
); diff --git a/web/src/app/home/plugins/components/plugin-market/PluginMarketComponent.tsx b/web/src/app/home/plugins/components/plugin-market/PluginMarketComponent.tsx index d9ab23a6b..b13e13ff3 100644 --- a/web/src/app/home/plugins/components/plugin-market/PluginMarketComponent.tsx +++ b/web/src/app/home/plugins/components/plugin-market/PluginMarketComponent.tsx @@ -80,9 +80,13 @@ function loadMarketFilters(): MarketFilters { function MarketPageContent({ installPlugin, headerActions, + installDisabled, + installDisabledTooltip, }: { installPlugin: (plugin: PluginV4) => void; headerActions?: React.ReactNode; + installDisabled?: boolean; + installDisabledTooltip?: string; }) { const { t } = useTranslation(); const [searchParams] = useSearchParams(); @@ -847,6 +851,8 @@ function MarketPageContent({ lists={recommendationLists} tagNames={tagNames} onInstall={handleInstallPlugin} + installDisabled={installDisabled} + installDisabledTooltip={installDisabledTooltip} /> )} @@ -876,6 +882,8 @@ function MarketPageContent({ cardVO={plugin} onInstall={handleInstallPlugin} tagNames={tagNames} + installDisabled={installDisabled} + installDisabledTooltip={installDisabledTooltip} /> ))} @@ -915,9 +923,13 @@ function MarketPageContent({ export default function MarketPage({ installPlugin, headerActions, + installDisabled, + installDisabledTooltip, }: { installPlugin: (plugin: PluginV4) => void; headerActions?: React.ReactNode; + installDisabled?: boolean; + installDisabledTooltip?: string; }) { return ( ); diff --git a/web/src/app/home/plugins/components/plugin-market/RecommendationLists.tsx b/web/src/app/home/plugins/components/plugin-market/RecommendationLists.tsx index 8a6554e0e..2eb5a22eb 100644 --- a/web/src/app/home/plugins/components/plugin-market/RecommendationLists.tsx +++ b/web/src/app/home/plugins/components/plugin-market/RecommendationLists.tsx @@ -54,11 +54,15 @@ function RecommendationListRow({ list, tagNames, onInstall, + installDisabled, + installDisabledTooltip, isLast, }: { list: RecommendationList; tagNames: Record; onInstall: (cardVO: PluginMarketCardVO) => void; + installDisabled?: boolean; + installDisabledTooltip?: string; isLast: boolean; }) { const { t } = useTranslation(); @@ -263,6 +267,8 @@ function RecommendationListRow({ cardVO={pluginToVO(plugin, t)} tagNames={tagNames} onInstall={onInstall} + installDisabled={installDisabled} + installDisabledTooltip={installDisabledTooltip} /> ))} @@ -277,10 +283,14 @@ export function RecommendationLists({ lists, tagNames, onInstall, + installDisabled, + installDisabledTooltip, }: { lists: RecommendationList[]; tagNames: Record; onInstall: (cardVO: PluginMarketCardVO) => void; + installDisabled?: boolean; + installDisabledTooltip?: string; }) { if (!lists || lists.length === 0) return null; @@ -292,6 +302,8 @@ export function RecommendationLists({ list={list} tagNames={tagNames} onInstall={onInstall} + installDisabled={installDisabled} + installDisabledTooltip={installDisabledTooltip} isLast={index === lists.length - 1} /> ))} diff --git a/web/src/app/home/plugins/components/plugin-market/plugin-market-card/PluginMarketCardComponent.tsx b/web/src/app/home/plugins/components/plugin-market/plugin-market-card/PluginMarketCardComponent.tsx index c980b9cd1..062ffb0b8 100644 --- a/web/src/app/home/plugins/components/plugin-market/plugin-market-card/PluginMarketCardComponent.tsx +++ b/web/src/app/home/plugins/components/plugin-market/plugin-market-card/PluginMarketCardComponent.tsx @@ -23,10 +23,14 @@ export default function PluginMarketCardComponent({ cardVO, onInstall, tagNames = {}, + installDisabled = false, + installDisabledTooltip, }: { cardVO: PluginMarketCardVO; onInstall?: (cardVO: PluginMarketCardVO) => void; tagNames?: Record; + installDisabled?: boolean; + installDisabledTooltip?: string; }) { const { t } = useTranslation(); const bottomRef = useRef(null); @@ -127,6 +131,7 @@ export default function PluginMarketCardComponent({ const remainingTags = cardVO.tags ? cardVO.tags.length - visibleTags : 0; const handleInstallClick = () => { + if (installDisabled) return; onInstall?.(cardVO); }; @@ -153,12 +158,17 @@ export default function PluginMarketCardComponent({ } }; - return ( + const cardContent = (
{ if ( @@ -382,4 +392,17 @@ export default function PluginMarketCardComponent({
); + + if (!installDisabled || !installDisabledTooltip) return cardContent; + + return ( + + + {cardContent} + + {installDisabledTooltip} + + + + ); } diff --git a/web/src/app/home/skills/components/SkillZipPreviewPanel.tsx b/web/src/app/home/skills/components/SkillZipPreviewPanel.tsx index 1970f8e53..5b00ade99 100644 --- a/web/src/app/home/skills/components/SkillZipPreviewPanel.tsx +++ b/web/src/app/home/skills/components/SkillZipPreviewPanel.tsx @@ -7,6 +7,8 @@ import { Checkbox } from '@/components/ui/checkbox'; import { httpClient } from '@/app/infra/http/HttpClient'; import type { Skill } from '@/app/infra/entities/api'; import { cn } from '@/lib/utils'; +import { WorkspaceQuotaTooltip } from '@/app/home/components/workspace-quota/WorkspaceQuotaTooltip'; +import type { WorkspaceQuotaItem } from '@/app/home/components/workspace-quota/useWorkspaceQuotaStatus'; interface PreviewSkill extends Skill { source_path?: string; @@ -16,6 +18,8 @@ interface SkillZipPreviewPanelProps { file: File; onImported: (skillNames: string[]) => void; onCancel?: () => void; + quota?: WorkspaceQuotaItem; + quotaResource?: string; } function formatFileSize(bytes: number): string { @@ -45,6 +49,8 @@ export default function SkillZipPreviewPanel({ file, onImported, onCancel, + quota, + quotaResource = '', }: SkillZipPreviewPanelProps) { const { t } = useTranslation(); const [previewSkills, setPreviewSkills] = useState([]); @@ -117,6 +123,7 @@ export default function SkillZipPreviewPanel({ } async function handleInstall() { + if (quota?.disabled) return; if (selectedPaths.length === 0) return; setInstalling(true); @@ -249,28 +256,56 @@ export default function SkillZipPreviewPanel({ {t('common.cancel')} )} - + {quota ? ( + + + + ) : ( + + )} ); diff --git a/web/src/app/infra/entities/api/index.ts b/web/src/app/infra/entities/api/index.ts index 4c4504707..d5639f608 100644 --- a/web/src/app/infra/entities/api/index.ts +++ b/web/src/app/infra/entities/api/index.ts @@ -328,6 +328,7 @@ export interface SystemLimitation { max_bots: number; max_pipelines: number; max_extensions: number; + max_knowledge_bases?: number; /** When non-empty, every pipeline is forced to this Box sandbox-scope * template (e.g. ``{global}``) and the per-pipeline "Sandbox Scope" * selector is locked. Used by SaaS deployments. Empty = no restriction. */ diff --git a/web/src/i18n/locales/en-US.ts b/web/src/i18n/locales/en-US.ts index 668e17405..d875f523b 100644 --- a/web/src/i18n/locales/en-US.ts +++ b/web/src/i18n/locales/en-US.ts @@ -1678,6 +1678,12 @@ const enUS = { 'Maximum number of pipelines ({{max}}) reached. Please remove an existing pipeline before creating a new one.', maxExtensionsReached: 'Maximum number of extensions ({{max}}) reached. Please remove an existing extension before adding a new one.', + quotaLoadingTooltip: + 'Workspace usage is still loading. Please wait before creating a resource.', + quotaCheckFailed: + 'Unable to verify the current workspace quota. Please try again.', + createDisabledTooltip: + 'The {{resource}} limit ({{max}}) for this workspace has been reached. Delete one existing item before creating another.', }, skills: { title: 'Skills', diff --git a/web/src/i18n/locales/es-ES.ts b/web/src/i18n/locales/es-ES.ts index ebe3be98a..51ca0fb35 100644 --- a/web/src/i18n/locales/es-ES.ts +++ b/web/src/i18n/locales/es-ES.ts @@ -1634,6 +1634,12 @@ const esES = { 'Se ha alcanzado el número máximo de Pipelines ({{max}}). Por favor, elimina un Pipeline existente antes de crear uno nuevo.', maxExtensionsReached: 'Se ha alcanzado el número máximo de extensiones ({{max}}). Por favor, elimina un servidor MCP o plugin existente antes de añadir uno nuevo.', + quotaLoadingTooltip: + 'El uso del espacio de trabajo aún se está cargando. Espera antes de crear un recurso.', + quotaCheckFailed: + 'No se pudo verificar la cuota actual del espacio de trabajo. Inténtalo de nuevo.', + createDisabledTooltip: + 'Se alcanzó el límite de {{resource}} ({{max}}) de este espacio de trabajo. Elimina uno existente antes de crear otro.', }, wizard: { sidebarDescription: 'Crea un Bot con pasos guiados', diff --git a/web/src/i18n/locales/ja-JP.ts b/web/src/i18n/locales/ja-JP.ts index 734cf7859..6123c730f 100644 --- a/web/src/i18n/locales/ja-JP.ts +++ b/web/src/i18n/locales/ja-JP.ts @@ -1685,6 +1685,12 @@ const jaJP = { 'パイプライン数が上限({{max}}個)に達しました。新しいパイプラインを作成するには、既存のパイプラインを削除してください。', maxExtensionsReached: '拡張機能数が上限({{max}}個)に達しました。新しい MCP サーバーやプラグインを追加するには、既存のものを削除してください。', + quotaLoadingTooltip: + 'ワークスペースの使用状況を読み込んでいます。リソースを作成する前にお待ちください。', + quotaCheckFailed: + '現在のワークスペース上限を確認できません。もう一度お試しください。', + createDisabledTooltip: + 'このワークスペースの{{resource}}数が上限({{max}}個)に達しました。新しく作成する前に既存の{{resource}}を削除してください。', }, wizard: { sidebarDescription: 'ガイド付きステップでボットを作成', diff --git a/web/src/i18n/locales/ru-RU.ts b/web/src/i18n/locales/ru-RU.ts index 5719b83cc..8788c5c7b 100644 --- a/web/src/i18n/locales/ru-RU.ts +++ b/web/src/i18n/locales/ru-RU.ts @@ -1609,6 +1609,12 @@ const ruRU = { 'Достигнуто максимальное количество конвейеров ({{max}}). Удалите существующий конвейер перед созданием нового.', maxExtensionsReached: 'Достигнуто максимальное количество расширений ({{max}}). Удалите существующий MCP-сервер или плагин перед добавлением нового.', + quotaLoadingTooltip: + 'Данные об использовании рабочего пространства загружаются. Подождите перед созданием ресурса.', + quotaCheckFailed: + 'Не удалось проверить текущую квоту рабочего пространства. Повторите попытку.', + createDisabledTooltip: + 'Достигнут лимит {{resource}} ({{max}}) для этого рабочего пространства. Удалите существующий ресурс перед созданием нового.', }, wizard: { sidebarDescription: 'Создать бота с пошаговым руководством', diff --git a/web/src/i18n/locales/th-TH.ts b/web/src/i18n/locales/th-TH.ts index 6c26b9877..dd85d06ba 100644 --- a/web/src/i18n/locales/th-TH.ts +++ b/web/src/i18n/locales/th-TH.ts @@ -1576,6 +1576,12 @@ const thTH = { 'จำนวน Pipeline สูงสุด ({{max}}) ถึงขีดจำกัดแล้ว กรุณาลบ Pipeline ที่มีอยู่ก่อนสร้างใหม่', maxExtensionsReached: 'จำนวนส่วนขยายสูงสุด ({{max}}) ถึงขีดจำกัดแล้ว กรุณาลบเซิร์ฟเวอร์ MCP หรือปลั๊กอินที่มีอยู่ก่อนเพิ่มใหม่', + quotaLoadingTooltip: + 'กำลังโหลดการใช้งานพื้นที่ทำงาน โปรดรอก่อนสร้างทรัพยากร', + quotaCheckFailed: + 'ไม่สามารถตรวจสอบโควตาปัจจุบันของพื้นที่ทำงานได้ โปรดลองอีกครั้ง', + createDisabledTooltip: + 'ถึงขีดจำกัด {{resource}} ({{max}}) ของเวิร์กสเปซนี้แล้ว โปรดลบรายการเดิมก่อนสร้างรายการใหม่', }, wizard: { sidebarDescription: 'สร้าง Bot ด้วยขั้นตอนที่แนะนำ', diff --git a/web/src/i18n/locales/vi-VN.ts b/web/src/i18n/locales/vi-VN.ts index 0b058ba7f..a07297060 100644 --- a/web/src/i18n/locales/vi-VN.ts +++ b/web/src/i18n/locales/vi-VN.ts @@ -1602,6 +1602,12 @@ const viVN = { 'Đã đạt số lượng Pipeline tối đa ({{max}}). Vui lòng xóa một Pipeline hiện có trước khi tạo mới.', maxExtensionsReached: 'Đã đạt số lượng tiện ích mở rộng tối đa ({{max}}). Vui lòng xóa một máy chủ MCP hoặc plugin hiện có trước khi thêm mới.', + quotaLoadingTooltip: + 'Dữ liệu sử dụng không gian làm việc đang tải. Vui lòng chờ trước khi tạo tài nguyên.', + quotaCheckFailed: + 'Không thể kiểm tra hạn mức hiện tại của không gian làm việc. Vui lòng thử lại.', + createDisabledTooltip: + 'Đã đạt giới hạn {{resource}} ({{max}}) của workspace này. Hãy xóa một mục hiện có trước khi tạo mới.', }, wizard: { sidebarDescription: 'Tạo Bot với các bước hướng dẫn', diff --git a/web/src/i18n/locales/zh-Hans.ts b/web/src/i18n/locales/zh-Hans.ts index fa4d56911..88415733b 100644 --- a/web/src/i18n/locales/zh-Hans.ts +++ b/web/src/i18n/locales/zh-Hans.ts @@ -1606,6 +1606,10 @@ const zhHans = { '已达到流水线数量上限({{max}}个)。请先删除已有流水线后再创建新的。', maxExtensionsReached: '已达到扩展数量上限({{max}}个)。请先删除已有扩展后再添加新的。', + quotaLoadingTooltip: '正在加载工作空间用量,请稍后再创建资源。', + quotaCheckFailed: '无法确认当前工作空间额度,请重试。', + createDisabledTooltip: + '当前工作区的{{resource}}数量已达到上限({{max}}个)。请先删除一个已有{{resource}}后再创建。', }, skills: { title: '技能', diff --git a/web/src/i18n/locales/zh-Hant.ts b/web/src/i18n/locales/zh-Hant.ts index 1f7023678..3650175d2 100644 --- a/web/src/i18n/locales/zh-Hant.ts +++ b/web/src/i18n/locales/zh-Hant.ts @@ -1531,6 +1531,10 @@ const zhHant = { '已達到流水線數量上限({{max}}個)。請先刪除已有流水線後再建立新的。', maxExtensionsReached: '已達到擴充功能數量上限({{max}}個)。請先刪除已有擴充功能後再新增。', + quotaLoadingTooltip: '正在載入工作空間用量,請稍後再建立資源。', + quotaCheckFailed: '無法確認目前工作空間額度,請重試。', + createDisabledTooltip: + '目前工作區的{{resource}}數量已達上限({{max}}個)。請先刪除一個現有{{resource}}後再建立。', }, wizard: { sidebarDescription: '透過引導步驟建立機器人', diff --git a/web/tests/e2e/quota-create-actions.spec.ts b/web/tests/e2e/quota-create-actions.spec.ts new file mode 100644 index 000000000..eb2771820 --- /dev/null +++ b/web/tests/e2e/quota-create-actions.spec.ts @@ -0,0 +1,148 @@ +import { expect, test } from '@playwright/test'; +import { installLangBotApiMocks } from './fixtures/langbot-api'; + +function wrapped(data: unknown) { + return JSON.stringify({ + code: 0, + message: 'ok', + data, + timestamp: Date.now(), + }); +} + +async function fulfill( + route: Parameters[1]>[0], + data: unknown, +) { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: wrapped(data), + }); +} + +test('quota-reached create actions are disabled and explain the current limit', async ({ + page, +}) => { + await installLangBotApiMocks(page, { authenticated: true }); + + await page.route('**/api/v1/system/info', (route) => + fulfill(route, { + debug: false, + version: 'quota-e2e', + edition: 'community', + cloud_service_url: 'https://space.langbot.app', + enable_marketplace: true, + allow_modify_login_info: true, + disable_models_service: false, + limitation: { + max_bots: 2, + max_pipelines: 3, + max_extensions: 3, + max_knowledge_bases: 2, + }, + outbound_ips: [], + wizard_status: 'completed', + wizard_progress: null, + }), + ); + await page.route('**/api/v1/platform/bots**', (route) => + fulfill(route, { + bots: Array.from({ length: 2 }, (_, index) => ({ + uuid: `bot-${index}`, + name: `Bot ${index + 1}`, + description: '', + adapter: 'aiocqhttp', + enable: true, + updated_at: new Date().toISOString(), + })), + }), + ); + await page.route('**/api/v1/pipelines**', (route) => + fulfill(route, { + pipelines: Array.from({ length: 3 }, (_, index) => ({ + uuid: `pipeline-${index}`, + name: `Pipeline ${index + 1}`, + description: '', + emoji: '⚙️', + updated_at: new Date().toISOString(), + })), + }), + ); + await page.route('**/api/v1/knowledge/bases**', (route) => + fulfill(route, { + bases: Array.from({ length: 2 }, (_, index) => ({ + uuid: `kb-${index}`, + name: `Knowledge ${index + 1}`, + description: '', + emoji: '📚', + updated_at: new Date().toISOString(), + })), + }), + ); + await page.route('**/api/v1/plugins**', (route) => + fulfill(route, { plugins: [] }), + ); + await page.route('**/api/v1/mcp/servers**', (route) => + fulfill(route, { + servers: Array.from({ length: 3 }, (_, index) => ({ + name: `mcp-${index}`, + mode: 'http', + enable: true, + runtime_info: { status: 'connected' }, + })), + }), + ); + await page.route('**/api/v1/skills**', (route) => + fulfill(route, { skills: [] }), + ); + + await page.goto('/home/bots'); + + const botCreate = page.getByRole('button', { + name: 'Create Bots', + exact: true, + }); + const pipelineCreate = page.getByRole('button', { + name: 'Create Pipelines', + exact: true, + }); + const knowledgeCreate = page.getByRole('button', { + name: 'Create Knowledge', + exact: true, + }); + const addExtension = page.getByRole('button', { + name: 'Add Extension', + exact: true, + }); + + await expect(botCreate).toBeDisabled(); + await expect(pipelineCreate).toBeDisabled(); + await expect(knowledgeCreate).toBeDisabled(); + await expect(addExtension).toBeDisabled(); + + const botQuotaTrigger = botCreate.locator('..'); + await botQuotaTrigger.hover(); + await expect( + page.getByText( + 'The Bots limit (2) for this workspace has been reached. Delete one existing item before creating another.', + ), + ).toBeVisible(); + await botQuotaTrigger.focus(); + await expect(botQuotaTrigger).toBeFocused(); + await expect( + page.getByText( + 'The Bots limit (2) for this workspace has been reached. Delete one existing item before creating another.', + ), + ).toBeVisible(); + + await page.goto('/home/add-extension'); + const manualAdd = page.getByRole('button', { name: 'Manual Add' }); + await expect(manualAdd).toBeDisabled(); + await manualAdd.locator('..').hover(); + await expect( + page.getByText( + 'The Extensions limit (3) for this workspace has been reached. Delete one existing item before creating another.', + ), + ).toBeVisible(); +}); diff --git a/web/tests/unit/quota-create-actions.test.mjs b/web/tests/unit/quota-create-actions.test.mjs new file mode 100644 index 000000000..628b43ad7 --- /dev/null +++ b/web/tests/unit/quota-create-actions.test.mjs @@ -0,0 +1,111 @@ +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import path from 'node:path'; +import test from 'node:test'; + +const root = process.cwd(); +const quotaPath = path.join( + root, + 'src/app/home/components/workspace-quota/useWorkspaceQuotaStatus.ts', +); +const sidebarPath = path.join( + root, + 'src/app/home/components/home-sidebar/HomeSidebar.tsx', +); +const tooltipPath = path.join( + root, + 'src/app/home/components/workspace-quota/WorkspaceQuotaTooltip.tsx', +); +const addExtensionPath = path.join(root, 'src/app/home/add-extension/page.tsx'); +const marketPath = path.join( + root, + 'src/app/home/plugins/components/plugin-market/PluginMarketComponent.tsx', +); +const marketCardPath = path.join( + root, + 'src/app/home/plugins/components/plugin-market/plugin-market-card/PluginMarketCardComponent.tsx', +); +const recommendationPath = path.join( + root, + 'src/app/home/plugins/components/plugin-market/RecommendationLists.tsx', +); +const zhPath = path.join(root, 'src/i18n/locales/zh-Hans.ts'); + +test('workspace quota hook exposes reached states for every creatable resource', () => { + assert.equal( + fs.existsSync(quotaPath), + true, + 'workspace quota hook is missing', + ); + const source = fs.readFileSync(quotaPath, 'utf8'); + for (const token of [ + 'botsReached', + 'pipelinesReached', + 'knowledgeBasesReached', + 'extensionsReached', + 'max_bots', + 'max_pipelines', + 'max_knowledge_bases', + 'max_extensions', + ]) { + assert.match(source, new RegExp(token)); + } +}); + +test('sidebar quota-disables create controls and renders a tooltip', () => { + const source = fs.readFileSync(sidebarPath, 'utf8'); + const tooltip = fs.readFileSync(tooltipPath, 'utf8'); + assert.match(source, /useWorkspaceQuotaStatus/); + assert.match(source, /quota\.disabled/); + assert.match(source, /disabled=\{quota\.disabled\}/); + assert.match(source, /WorkspaceQuotaTooltip/); + assert.match(tooltip, /TooltipContent/); + assert.match(tooltip, /limitation\.createDisabledTooltip/); + assert.match(tooltip, /limitation\.quotaLoadingTooltip/); + assert.match(tooltip, /tabIndex=\{0\}/); + assert.match(source, /config\.id === 'add-extension'/); +}); + +test('add-extension page disables all install entry points at the quota', () => { + const page = fs.readFileSync(addExtensionPath, 'utf8'); + const market = fs.readFileSync(marketPath, 'utf8'); + const card = fs.readFileSync(marketCardPath, 'utf8'); + const recommendations = fs.readFileSync(recommendationPath, 'utf8'); + + assert.match(page, /extensionsReached/); + assert.match(page, /installDisabled=\{extensionsReached\}/); + assert.match(page, /disabled=\{extensionsReached/); + assert.match(page, /limitation\.createDisabledTooltip/); + assert.match(market, /installDisabled/); + assert.match(card, /installDisabled/); + assert.match(card, /disabled=\{installDisabled\}/); + assert.match(card, /TooltipContent/); + assert.match(recommendations, /installDisabled=\{installDisabled\}/); + assert.match( + recommendations, + /installDisabledTooltip=\{installDisabledTooltip\}/, + ); + assert.match(page, /quota=\{extensionQuota\}/); +}); + +test('extension confirmation checks fail closed and enter an in-flight state first', () => { + const page = fs.readFileSync(addExtensionPath, 'utf8'); + + assert.match(page, /limitation\.quotaCheckFailed/); + assert.doesNotMatch(page, /If we can't check, let backend handle it/); + assert.match( + page, + /setGithubInstallStatus\(GithubInstallStatus\.INSTALLING\);\s+if \(!\(await checkExtensionsLimit\(\)\)\)/, + ); + assert.match( + page, + /setGithubInstallStatus\(GithubInstallStatus\.SKILL_INSTALLING\);\s+if \(!\(await checkExtensionsLimit\(\)\)\)/, + ); +}); + +test('quota tooltip copy is localized in Simplified Chinese', () => { + const source = fs.readFileSync(zhPath, 'utf8'); + assert.match(source, /createDisabledTooltip/); + assert.match(source, /已达到.*上限/); + assert.match(source, /删除.*后再/); +});