mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-08-09 12:40:59 +00:00
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>
This commit is contained in:
@@ -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 (
|
||||
<SidebarMenuItem key={config.id}>
|
||||
<SidebarMenuButton
|
||||
isActive={selectedChild?.id === config.id}
|
||||
onClick={() => onChildClick(config)}
|
||||
tooltip={config.name}
|
||||
<WorkspaceQuotaTooltip
|
||||
quota={quota}
|
||||
resource={config.name}
|
||||
side="right"
|
||||
>
|
||||
{config.icon}
|
||||
<span className="cursor-pointer select-none">
|
||||
{config.name}
|
||||
</span>
|
||||
</SidebarMenuButton>
|
||||
<SidebarMenuButton
|
||||
isActive={selectedChild?.id === config.id}
|
||||
onClick={() => {
|
||||
if (!quota.disabled) onChildClick(config);
|
||||
}}
|
||||
disabled={quota.disabled}
|
||||
aria-disabled={quota.disabled}
|
||||
tooltip={quota.disabled ? undefined : config.name}
|
||||
>
|
||||
{config.icon}
|
||||
<span className="cursor-pointer select-none">
|
||||
{config.name}
|
||||
</span>
|
||||
</SidebarMenuButton>
|
||||
</WorkspaceQuotaTooltip>
|
||||
</SidebarMenuItem>
|
||||
);
|
||||
}
|
||||
@@ -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({
|
||||
>
|
||||
<div className="flex items-center justify-between mb-1 px-2">
|
||||
<span className="text-sm font-medium">{config.name}</span>
|
||||
{canCreate &&
|
||||
(isPlugin ? (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="p-1 rounded-sm text-muted-foreground hover:bg-accent hover:text-accent-foreground transition-colors"
|
||||
>
|
||||
<Plus className="size-3.5" />
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
{systemInfo.enable_marketplace && (
|
||||
{canCreate && (
|
||||
<WorkspaceQuotaTooltip
|
||||
quota={quota}
|
||||
resource={config.name}
|
||||
side="right"
|
||||
>
|
||||
{isPlugin ? (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
disabled={quota.disabled}
|
||||
aria-disabled={quota.disabled}
|
||||
aria-label={`${t('common.create')} ${config.name}`}
|
||||
className="p-1 rounded-sm text-muted-foreground hover:bg-accent hover:text-accent-foreground transition-colors disabled:pointer-events-none disabled:opacity-40"
|
||||
>
|
||||
<Plus className="size-3.5" />
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
{systemInfo.enable_marketplace && (
|
||||
<DropdownMenuItem
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
navigate('/home/add-extension');
|
||||
setPopoverOpen((prev) => ({
|
||||
...prev,
|
||||
[config.id]: false,
|
||||
}));
|
||||
}}
|
||||
>
|
||||
<Store className="size-4" />
|
||||
{t('plugins.goToMarketplace')}
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
<DropdownMenuItem
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
navigate('/home/add-extension');
|
||||
navigate('/home/add-extension?manual=1');
|
||||
setPopoverOpen((prev) => ({
|
||||
...prev,
|
||||
[config.id]: false,
|
||||
}));
|
||||
}}
|
||||
>
|
||||
<Store className="size-4" />
|
||||
{t('plugins.goToMarketplace')}
|
||||
<Upload className="size-4" />
|
||||
{t('plugins.uploadLocal')}
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
<DropdownMenuItem
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
navigate('/home/add-extension?manual=1');
|
||||
setPopoverOpen((prev) => ({
|
||||
...prev,
|
||||
[config.id]: false,
|
||||
}));
|
||||
}}
|
||||
>
|
||||
<Upload className="size-4" />
|
||||
{t('plugins.uploadLocal')}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
navigate('/home/add-extension?manual=1');
|
||||
setPopoverOpen((prev) => ({
|
||||
...prev,
|
||||
[config.id]: false,
|
||||
}));
|
||||
}}
|
||||
>
|
||||
<Github className="size-4" />
|
||||
{t('plugins.installFromGithub')}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
) : isSkill ? (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="p-1 rounded-sm text-muted-foreground hover:bg-accent hover:text-accent-foreground transition-colors"
|
||||
>
|
||||
<Plus className="size-3.5" />
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
navigate('/home/skills?action=create');
|
||||
setPopoverOpen((prev) => ({
|
||||
...prev,
|
||||
[config.id]: false,
|
||||
}));
|
||||
}}
|
||||
>
|
||||
<FilePlus2 className="size-4" />
|
||||
{t('skills.createManually')}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
navigate('/home/add-extension?manual=1');
|
||||
setPopoverOpen((prev) => ({
|
||||
...prev,
|
||||
[config.id]: false,
|
||||
}));
|
||||
}}
|
||||
>
|
||||
<Upload className="size-4" />
|
||||
{t('skills.uploadZip')}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
navigate('/home/add-extension?manual=1');
|
||||
setPopoverOpen((prev) => ({
|
||||
...prev,
|
||||
[config.id]: false,
|
||||
}));
|
||||
}}
|
||||
>
|
||||
<Github className="size-4" />
|
||||
{t('skills.importFromGithub')}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
className="p-1 rounded-sm text-muted-foreground hover:bg-accent hover:text-accent-foreground transition-colors"
|
||||
onClick={() => {
|
||||
navigate(`${routePrefix}?id=new`);
|
||||
setPopoverOpen((prev) => ({
|
||||
...prev,
|
||||
[config.id]: false,
|
||||
}));
|
||||
}}
|
||||
>
|
||||
<Plus className="size-3.5" />
|
||||
</button>
|
||||
))}
|
||||
<DropdownMenuItem
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
navigate('/home/add-extension?manual=1');
|
||||
setPopoverOpen((prev) => ({
|
||||
...prev,
|
||||
[config.id]: false,
|
||||
}));
|
||||
}}
|
||||
>
|
||||
<Github className="size-4" />
|
||||
{t('plugins.installFromGithub')}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
) : isSkill ? (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
disabled={quota.disabled}
|
||||
aria-disabled={quota.disabled}
|
||||
aria-label={`${t('common.create')} ${config.name}`}
|
||||
className="p-1 rounded-sm text-muted-foreground hover:bg-accent hover:text-accent-foreground transition-colors disabled:pointer-events-none disabled:opacity-40"
|
||||
>
|
||||
<Plus className="size-3.5" />
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
navigate('/home/skills?action=create');
|
||||
setPopoverOpen((prev) => ({
|
||||
...prev,
|
||||
[config.id]: false,
|
||||
}));
|
||||
}}
|
||||
>
|
||||
<FilePlus2 className="size-4" />
|
||||
{t('skills.createManually')}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
navigate('/home/add-extension?manual=1');
|
||||
setPopoverOpen((prev) => ({
|
||||
...prev,
|
||||
[config.id]: false,
|
||||
}));
|
||||
}}
|
||||
>
|
||||
<Upload className="size-4" />
|
||||
{t('skills.uploadZip')}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
navigate('/home/add-extension?manual=1');
|
||||
setPopoverOpen((prev) => ({
|
||||
...prev,
|
||||
[config.id]: false,
|
||||
}));
|
||||
}}
|
||||
>
|
||||
<Github className="size-4" />
|
||||
{t('skills.importFromGithub')}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
disabled={quota.disabled}
|
||||
aria-disabled={quota.disabled}
|
||||
aria-label={`${t('common.create')} ${config.name}`}
|
||||
className="p-1 rounded-sm text-muted-foreground hover:bg-accent hover:text-accent-foreground transition-colors disabled:pointer-events-none disabled:opacity-40"
|
||||
onClick={() => {
|
||||
navigate(`${routePrefix}?id=new`);
|
||||
setPopoverOpen((prev) => ({
|
||||
...prev,
|
||||
[config.id]: false,
|
||||
}));
|
||||
}}
|
||||
>
|
||||
<Plus className="size-3.5" />
|
||||
</button>
|
||||
)}
|
||||
</WorkspaceQuotaTooltip>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-col gap-0.5 max-h-80 overflow-y-auto">
|
||||
{renderEntityList(true)}
|
||||
@@ -1096,103 +1152,119 @@ function NavItems({
|
||||
/>
|
||||
</button>
|
||||
)}
|
||||
{canCreate &&
|
||||
(isPlugin ? (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="p-1 rounded-sm text-sidebar-foreground/70 hover:bg-sidebar-accent hover:text-sidebar-accent-foreground [@media(hover:hover)]:opacity-0 group-hover/category-header:opacity-100 transition-all"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<Plus className="size-3.5" />
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
{systemInfo.enable_marketplace && (
|
||||
{canCreate && (
|
||||
<WorkspaceQuotaTooltip
|
||||
quota={quota}
|
||||
resource={config.name}
|
||||
side="right"
|
||||
>
|
||||
{isPlugin ? (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
disabled={quota.disabled}
|
||||
aria-disabled={quota.disabled}
|
||||
aria-label={`${t('common.create')} ${config.name}`}
|
||||
className="p-1 rounded-sm text-sidebar-foreground/70 hover:bg-sidebar-accent hover:text-sidebar-accent-foreground [@media(hover:hover)]:opacity-0 group-hover/category-header:opacity-100 transition-all disabled:pointer-events-none disabled:opacity-40"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<Plus className="size-3.5" />
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
{systemInfo.enable_marketplace && (
|
||||
<DropdownMenuItem
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
navigate('/home/add-extension');
|
||||
}}
|
||||
>
|
||||
<Store className="size-4" />
|
||||
{t('plugins.goToMarketplace')}
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
<DropdownMenuItem
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
navigate('/home/add-extension');
|
||||
navigate('/home/add-extension?manual=1');
|
||||
}}
|
||||
>
|
||||
<Store className="size-4" />
|
||||
{t('plugins.goToMarketplace')}
|
||||
<Upload className="size-4" />
|
||||
{t('plugins.uploadLocal')}
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
<DropdownMenuItem
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
navigate('/home/add-extension?manual=1');
|
||||
}}
|
||||
>
|
||||
<Upload className="size-4" />
|
||||
{t('plugins.uploadLocal')}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
navigate('/home/add-extension?manual=1');
|
||||
}}
|
||||
>
|
||||
<Github className="size-4" />
|
||||
{t('plugins.installFromGithub')}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
) : isSkill ? (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="p-1 rounded-sm text-sidebar-foreground/70 hover:bg-sidebar-accent hover:text-sidebar-accent-foreground [@media(hover:hover)]:opacity-0 group-hover/category-header:opacity-100 transition-all"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<Plus className="size-3.5" />
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
navigate('/home/skills?action=create');
|
||||
}}
|
||||
>
|
||||
<FilePlus2 className="size-4" />
|
||||
{t('skills.createManually')}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
navigate('/home/add-extension?manual=1');
|
||||
}}
|
||||
>
|
||||
<Upload className="size-4" />
|
||||
{t('skills.uploadZip')}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
navigate('/home/add-extension?manual=1');
|
||||
}}
|
||||
>
|
||||
<Github className="size-4" />
|
||||
{t('skills.importFromGithub')}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
className="p-1 rounded-sm text-sidebar-foreground/70 hover:bg-sidebar-accent hover:text-sidebar-accent-foreground [@media(hover:hover)]:opacity-0 group-hover/category-header:opacity-100 transition-all"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
navigate(`${routePrefix}?id=new`);
|
||||
}}
|
||||
>
|
||||
<Plus className="size-3.5" />
|
||||
</button>
|
||||
))}
|
||||
<DropdownMenuItem
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
navigate('/home/add-extension?manual=1');
|
||||
}}
|
||||
>
|
||||
<Github className="size-4" />
|
||||
{t('plugins.installFromGithub')}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
) : isSkill ? (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
disabled={quota.disabled}
|
||||
aria-disabled={quota.disabled}
|
||||
aria-label={`${t('common.create')} ${config.name}`}
|
||||
className="p-1 rounded-sm text-sidebar-foreground/70 hover:bg-sidebar-accent hover:text-sidebar-accent-foreground [@media(hover:hover)]:opacity-0 group-hover/category-header:opacity-100 transition-all disabled:pointer-events-none disabled:opacity-40"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<Plus className="size-3.5" />
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
navigate('/home/skills?action=create');
|
||||
}}
|
||||
>
|
||||
<FilePlus2 className="size-4" />
|
||||
{t('skills.createManually')}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
navigate('/home/add-extension?manual=1');
|
||||
}}
|
||||
>
|
||||
<Upload className="size-4" />
|
||||
{t('skills.uploadZip')}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
navigate('/home/add-extension?manual=1');
|
||||
}}
|
||||
>
|
||||
<Github className="size-4" />
|
||||
{t('skills.importFromGithub')}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
disabled={quota.disabled}
|
||||
aria-disabled={quota.disabled}
|
||||
aria-label={`${t('common.create')} ${config.name}`}
|
||||
className="p-1 rounded-sm text-sidebar-foreground/70 hover:bg-sidebar-accent hover:text-sidebar-accent-foreground [@media(hover:hover)]:opacity-0 group-hover/category-header:opacity-100 transition-all disabled:pointer-events-none disabled:opacity-40"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
navigate(`${routePrefix}?id=new`);
|
||||
}}
|
||||
>
|
||||
<Plus className="size-3.5" />
|
||||
</button>
|
||||
)}
|
||||
</WorkspaceQuotaTooltip>
|
||||
)}
|
||||
<CollapsibleTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -4,6 +4,7 @@ import React, {
|
||||
useState,
|
||||
useEffect,
|
||||
useCallback,
|
||||
useRef,
|
||||
} from 'react';
|
||||
import { httpClient, getCloudServiceClientSync } from '@/app/infra/http';
|
||||
import { extractI18nObject } from '@/i18n/I18nProvider';
|
||||
@@ -48,9 +49,11 @@ export interface SidebarDataContextValue {
|
||||
pipelines: SidebarEntityItem[];
|
||||
knowledgeBases: SidebarEntityItem[];
|
||||
plugins: SidebarEntityItem[];
|
||||
pluginCount: number;
|
||||
mcpServers: SidebarEntityItem[];
|
||||
skills: SidebarEntityItem[];
|
||||
pluginPages: PluginPageItem[];
|
||||
quotaDataLoaded: boolean;
|
||||
refreshBots: () => Promise<void>;
|
||||
refreshPipelines: () => Promise<void>;
|
||||
refreshKnowledgeBases: () => Promise<void>;
|
||||
@@ -77,9 +80,36 @@ export function SidebarDataProvider({
|
||||
const [pipelines, setPipelines] = useState<SidebarEntityItem[]>([]);
|
||||
const [knowledgeBases, setKnowledgeBases] = useState<SidebarEntityItem[]>([]);
|
||||
const [plugins, setPlugins] = useState<SidebarEntityItem[]>([]);
|
||||
const [pluginCount, setPluginCount] = useState(0);
|
||||
const [mcpServers, setMCPServers] = useState<SidebarEntityItem[]>([]);
|
||||
const [skills, setSkills] = useState<SidebarEntityItem[]>([]);
|
||||
const [pluginPages, setPluginPages] = useState<PluginPageItem[]>([]);
|
||||
const [quotaDataLoaded, setQuotaDataLoaded] = useState(false);
|
||||
const refreshRequestIds = useRef({
|
||||
bots: 0,
|
||||
pipelines: 0,
|
||||
knowledgeBases: 0,
|
||||
plugins: 0,
|
||||
mcpServers: 0,
|
||||
skills: 0,
|
||||
});
|
||||
const quotaResourceLoaded = useRef({
|
||||
bots: false,
|
||||
pipelines: false,
|
||||
knowledgeBases: false,
|
||||
plugins: false,
|
||||
mcpServers: false,
|
||||
skills: false,
|
||||
});
|
||||
const setQuotaResourceLoaded = useCallback(
|
||||
(resource: keyof typeof quotaResourceLoaded.current, loaded: boolean) => {
|
||||
quotaResourceLoaded.current[resource] = loaded;
|
||||
setQuotaDataLoaded(
|
||||
Object.values(quotaResourceLoaded.current).every(Boolean),
|
||||
);
|
||||
},
|
||||
[],
|
||||
);
|
||||
const [detailEntityName, setDetailEntityName] = useState<string | null>(null);
|
||||
const [extensionsGroupByType, setExtensionsGroupByTypeState] =
|
||||
useState<boolean>(() => {
|
||||
@@ -96,8 +126,11 @@ export function SidebarDataProvider({
|
||||
}, []);
|
||||
|
||||
const refreshBots = useCallback(async () => {
|
||||
const requestId = ++refreshRequestIds.current.bots;
|
||||
try {
|
||||
const resp = await httpClient.getBots();
|
||||
if (requestId !== refreshRequestIds.current.bots) return;
|
||||
setQuotaResourceLoaded('bots', true);
|
||||
setBots(
|
||||
resp.bots.map((bot) => ({
|
||||
id: bot.uuid || '',
|
||||
@@ -109,13 +142,18 @@ export function SidebarDataProvider({
|
||||
})),
|
||||
);
|
||||
} catch (error) {
|
||||
if (requestId !== refreshRequestIds.current.bots) return;
|
||||
setQuotaResourceLoaded('bots', false);
|
||||
console.error('Failed to fetch bots for sidebar:', error);
|
||||
}
|
||||
}, []);
|
||||
}, [setQuotaResourceLoaded]);
|
||||
|
||||
const refreshPipelines = useCallback(async () => {
|
||||
const requestId = ++refreshRequestIds.current.pipelines;
|
||||
try {
|
||||
const resp = await httpClient.getPipelines();
|
||||
if (requestId !== refreshRequestIds.current.pipelines) return;
|
||||
setQuotaResourceLoaded('pipelines', true);
|
||||
setPipelines(
|
||||
resp.pipelines.map((p) => ({
|
||||
id: p.uuid || '',
|
||||
@@ -126,13 +164,18 @@ export function SidebarDataProvider({
|
||||
})),
|
||||
);
|
||||
} catch (error) {
|
||||
if (requestId !== refreshRequestIds.current.pipelines) return;
|
||||
setQuotaResourceLoaded('pipelines', false);
|
||||
console.error('Failed to fetch pipelines for sidebar:', error);
|
||||
}
|
||||
}, []);
|
||||
}, [setQuotaResourceLoaded]);
|
||||
|
||||
const refreshKnowledgeBases = useCallback(async () => {
|
||||
const requestId = ++refreshRequestIds.current.knowledgeBases;
|
||||
try {
|
||||
const resp = await httpClient.getKnowledgeBases();
|
||||
if (requestId !== refreshRequestIds.current.knowledgeBases) return;
|
||||
setQuotaResourceLoaded('knowledgeBases', true);
|
||||
setKnowledgeBases(
|
||||
resp.bases.map((kb) => ({
|
||||
id: kb.uuid || '',
|
||||
@@ -143,11 +186,14 @@ export function SidebarDataProvider({
|
||||
})),
|
||||
);
|
||||
} catch (error) {
|
||||
if (requestId !== refreshRequestIds.current.knowledgeBases) return;
|
||||
setQuotaResourceLoaded('knowledgeBases', false);
|
||||
console.error('Failed to fetch knowledge bases for sidebar:', error);
|
||||
}
|
||||
}, []);
|
||||
}, [setQuotaResourceLoaded]);
|
||||
|
||||
const refreshPlugins = useCallback(async () => {
|
||||
const requestId = ++refreshRequestIds.current.plugins;
|
||||
try {
|
||||
const [pluginsResp, marketplaceResp] = await Promise.all([
|
||||
httpClient.getPlugins(),
|
||||
@@ -155,6 +201,9 @@ export function SidebarDataProvider({
|
||||
.getMarketplacePlugins(1, 100)
|
||||
.catch(() => ({ plugins: [] })),
|
||||
]);
|
||||
if (requestId !== refreshRequestIds.current.plugins) return;
|
||||
setQuotaResourceLoaded('plugins', true);
|
||||
setPluginCount(pluginsResp.plugins?.length ?? 0);
|
||||
|
||||
// Build marketplace version lookup: "author/name" -> latest_version
|
||||
const marketplaceVersions = new Map<string, string>();
|
||||
@@ -241,13 +290,18 @@ export function SidebarDataProvider({
|
||||
}
|
||||
setPluginPages(pages);
|
||||
} catch (error) {
|
||||
if (requestId !== refreshRequestIds.current.plugins) return;
|
||||
setQuotaResourceLoaded('plugins', false);
|
||||
console.error('Failed to fetch plugins for sidebar:', error);
|
||||
}
|
||||
}, []);
|
||||
}, [setQuotaResourceLoaded]);
|
||||
|
||||
const refreshMCPServers = useCallback(async () => {
|
||||
const requestId = ++refreshRequestIds.current.mcpServers;
|
||||
try {
|
||||
const resp = await httpClient.getMCPServers();
|
||||
if (requestId !== refreshRequestIds.current.mcpServers) return;
|
||||
setQuotaResourceLoaded('mcpServers', true);
|
||||
setMCPServers(
|
||||
resp.servers.map((server) => ({
|
||||
id: server.name, // Keep __ for API calls
|
||||
@@ -257,13 +311,18 @@ export function SidebarDataProvider({
|
||||
})),
|
||||
);
|
||||
} catch (error) {
|
||||
if (requestId !== refreshRequestIds.current.mcpServers) return;
|
||||
setQuotaResourceLoaded('mcpServers', false);
|
||||
console.error('Failed to fetch MCP servers for sidebar:', error);
|
||||
}
|
||||
}, []);
|
||||
}, [setQuotaResourceLoaded]);
|
||||
|
||||
const refreshSkills = useCallback(async () => {
|
||||
const requestId = ++refreshRequestIds.current.skills;
|
||||
try {
|
||||
const resp = await httpClient.getSkills();
|
||||
if (requestId !== refreshRequestIds.current.skills) return;
|
||||
setQuotaResourceLoaded('skills', true);
|
||||
setSkills(
|
||||
resp.skills.map((skill) => ({
|
||||
id: skill.name,
|
||||
@@ -273,11 +332,22 @@ export function SidebarDataProvider({
|
||||
})),
|
||||
);
|
||||
} catch (error) {
|
||||
if (requestId !== refreshRequestIds.current.skills) return;
|
||||
setQuotaResourceLoaded('skills', false);
|
||||
console.error('Failed to fetch skills for sidebar:', error);
|
||||
}
|
||||
}, []);
|
||||
}, [setQuotaResourceLoaded]);
|
||||
|
||||
const refreshAll = useCallback(async () => {
|
||||
quotaResourceLoaded.current = {
|
||||
bots: false,
|
||||
pipelines: false,
|
||||
knowledgeBases: false,
|
||||
plugins: false,
|
||||
mcpServers: false,
|
||||
skills: false,
|
||||
};
|
||||
setQuotaDataLoaded(false);
|
||||
await Promise.all([
|
||||
refreshBots(),
|
||||
refreshPipelines(),
|
||||
@@ -307,9 +377,11 @@ export function SidebarDataProvider({
|
||||
pipelines,
|
||||
knowledgeBases,
|
||||
plugins,
|
||||
pluginCount,
|
||||
mcpServers,
|
||||
skills,
|
||||
pluginPages,
|
||||
quotaDataLoaded,
|
||||
refreshBots,
|
||||
refreshPipelines,
|
||||
refreshKnowledgeBases,
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
import type { ReactNode } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from '@/components/ui/tooltip';
|
||||
import type { WorkspaceQuotaItem } from './useWorkspaceQuotaStatus';
|
||||
|
||||
export function WorkspaceQuotaTooltip({
|
||||
quota,
|
||||
resource,
|
||||
children,
|
||||
side = 'top',
|
||||
}: {
|
||||
quota: WorkspaceQuotaItem;
|
||||
resource: string;
|
||||
children: ReactNode;
|
||||
side?: 'top' | 'right' | 'bottom' | 'left';
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
if (!quota.disabled) return children;
|
||||
const message = quota.loading
|
||||
? t('limitation.quotaLoadingTooltip')
|
||||
: t('limitation.createDisabledTooltip', {
|
||||
resource,
|
||||
max: quota.max,
|
||||
});
|
||||
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span
|
||||
tabIndex={0}
|
||||
aria-disabled="true"
|
||||
aria-label={message}
|
||||
className="inline-flex cursor-not-allowed rounded-sm focus-visible:outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50"
|
||||
>
|
||||
{children}
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side={side} className="max-w-72 text-center">
|
||||
{message}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
import { systemInfo } from '@/app/infra/http/HttpClient';
|
||||
import { useSidebarData } from '@/app/home/components/home-sidebar/SidebarDataContext';
|
||||
|
||||
export interface WorkspaceQuotaItem {
|
||||
count: number;
|
||||
max: number;
|
||||
reached: boolean;
|
||||
loading: boolean;
|
||||
disabled: boolean;
|
||||
}
|
||||
|
||||
export interface WorkspaceQuotaStatus {
|
||||
bots: WorkspaceQuotaItem;
|
||||
pipelines: WorkspaceQuotaItem;
|
||||
knowledgeBases: WorkspaceQuotaItem;
|
||||
extensions: WorkspaceQuotaItem;
|
||||
botsReached: boolean;
|
||||
pipelinesReached: boolean;
|
||||
knowledgeBasesReached: boolean;
|
||||
extensionsReached: boolean;
|
||||
}
|
||||
|
||||
function quotaItem(
|
||||
count: number,
|
||||
max: number | undefined,
|
||||
loaded: boolean,
|
||||
): WorkspaceQuotaItem {
|
||||
const normalizedMax = typeof max === 'number' ? max : -1;
|
||||
const reached = loaded && normalizedMax >= 0 && count >= normalizedMax;
|
||||
return {
|
||||
count,
|
||||
max: normalizedMax,
|
||||
reached,
|
||||
loading: !loaded,
|
||||
disabled: !loaded || reached,
|
||||
};
|
||||
}
|
||||
|
||||
export function useWorkspaceQuotaStatus(): WorkspaceQuotaStatus {
|
||||
const {
|
||||
bots,
|
||||
pipelines,
|
||||
knowledgeBases,
|
||||
pluginCount,
|
||||
mcpServers,
|
||||
skills,
|
||||
quotaDataLoaded,
|
||||
} = useSidebarData();
|
||||
const limitation = systemInfo.limitation;
|
||||
|
||||
const botQuota = quotaItem(
|
||||
bots.length,
|
||||
limitation?.max_bots,
|
||||
quotaDataLoaded,
|
||||
);
|
||||
const pipelineQuota = quotaItem(
|
||||
pipelines.length,
|
||||
limitation?.max_pipelines,
|
||||
quotaDataLoaded,
|
||||
);
|
||||
const knowledgeBaseQuota = quotaItem(
|
||||
knowledgeBases.length,
|
||||
limitation?.max_knowledge_bases,
|
||||
quotaDataLoaded,
|
||||
);
|
||||
const extensionQuota = quotaItem(
|
||||
pluginCount + mcpServers.length + skills.length,
|
||||
limitation?.max_extensions,
|
||||
quotaDataLoaded,
|
||||
);
|
||||
|
||||
return {
|
||||
bots: botQuota,
|
||||
pipelines: pipelineQuota,
|
||||
knowledgeBases: knowledgeBaseQuota,
|
||||
extensions: extensionQuota,
|
||||
botsReached: botQuota.disabled,
|
||||
pipelinesReached: pipelineQuota.disabled,
|
||||
knowledgeBasesReached: knowledgeBaseQuota.disabled,
|
||||
extensionsReached: extensionQuota.disabled,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user