feat: sandbox provider configuration

This commit is contained in:
Harry
2026-01-08 11:03:47 +08:00
parent 5b01f544d1
commit 15c3d712d3
31 changed files with 1501 additions and 20 deletions

View File

@ -1,6 +1,7 @@
export const ACCOUNT_SETTING_MODAL_ACTION = 'showSettings'
export const ACCOUNT_SETTING_TAB = {
SANDBOX_PROVIDER: 'sandbox-provider',
PROVIDER: 'provider',
MEMBERS: 'members',
BILLING: 'billing',

View File

@ -1,6 +1,8 @@
'use client'
import type { AccountSettingTab } from '@/app/components/header/account-setting/constants'
import {
RiBox3Fill,
RiBox3Line,
RiBrain2Fill,
RiBrain2Line,
RiCloseLine,
@ -36,6 +38,7 @@ import DataSourcePage from './data-source-page-new'
import LanguagePage from './language-page'
import MembersPage from './members-page'
import ModelProviderPage from './model-provider-page'
import SandboxProviderPage from './sandbox-provider-page'
const iconClassName = `
w-5 h-5 mr-2
@ -79,6 +82,12 @@ export default function AccountSetting({
icon: <RiBrain2Line className={iconClassName} />,
activeIcon: <RiBrain2Fill className={iconClassName} />,
},
{
key: ACCOUNT_SETTING_TAB.SANDBOX_PROVIDER,
name: t('settings.sandboxProvider', { ns: 'common' }),
icon: <RiBox3Line className={iconClassName} />,
activeIcon: <RiBox3Fill className={iconClassName} />,
},
{
key: ACCOUNT_SETTING_TAB.MEMBERS,
name: t('settings.members', { ns: 'common' }),
@ -239,6 +248,7 @@ export default function AccountSetting({
</div>
<div className="px-4 pt-2 sm:px-8">
{activeMenu === 'provider' && <ModelProviderPage searchText={searchValue} />}
{activeMenu === 'sandbox-provider' && <SandboxProviderPage />}
{activeMenu === 'members' && <MembersPage />}
{activeMenu === 'billing' && <BillingPage />}
{activeMenu === 'data-source' && <DataSourcePage />}

View File

@ -0,0 +1,147 @@
'use client'
import type { FormRefObject, FormSchema } from '@/app/components/base/form/types'
import type { SandboxProvider } from '@/service/use-sandbox-provider'
import { RiExternalLinkLine } from '@remixicon/react'
import { memo, useCallback, useMemo, useRef } from 'react'
import { useTranslation } from 'react-i18next'
import Button from '@/app/components/base/button'
import { BaseForm } from '@/app/components/base/form/components/base'
import { FormTypeEnum } from '@/app/components/base/form/types'
import Modal from '@/app/components/base/modal'
import { useToastContext } from '@/app/components/base/toast'
import {
useDeleteSandboxProviderConfig,
useInvalidSandboxProviderList,
useSaveSandboxProviderConfig,
} from '@/service/use-sandbox-provider'
import { PROVIDER_DOC_LINKS, SANDBOX_FIELD_CONFIGS } from './constants'
type ConfigModalProps = {
provider: SandboxProvider
onClose: () => void
}
const ConfigModal = ({
provider,
onClose,
}: ConfigModalProps) => {
const { t } = useTranslation()
const { notify } = useToastContext()
const invalidateList = useInvalidSandboxProviderList()
const formRef = useRef<FormRefObject>(null)
const { mutateAsync: saveConfig, isPending: isSaving } = useSaveSandboxProviderConfig()
const { mutateAsync: deleteConfig, isPending: isDeleting } = useDeleteSandboxProviderConfig()
const formSchemas: FormSchema[] = useMemo(() => {
return provider.config_schema.map((schema) => {
const fieldConfig = SANDBOX_FIELD_CONFIGS[schema.name as keyof typeof SANDBOX_FIELD_CONFIGS]
const fallbackType = schema.type === 'secret' ? FormTypeEnum.secretInput : FormTypeEnum.textInput
return {
name: schema.name,
label: fieldConfig ? t(fieldConfig.labelKey, { ns: 'common' }) : schema.name,
placeholder: fieldConfig ? t(fieldConfig.placeholderKey, { ns: 'common' }) : '',
type: fieldConfig?.type ?? fallbackType,
required: schema.name === 'api_key',
default: provider.config[schema.name] || '',
}
})
}, [provider.config_schema, provider.config, t])
const handleSave = useCallback(async () => {
const formValues = formRef.current?.getFormValues({
needTransformWhenSecretFieldIsPristine: true,
})
if (!formValues?.isCheckValidated)
return
try {
await saveConfig({
providerType: provider.provider_type,
config: formValues.values,
})
await invalidateList()
notify({ type: 'success', message: t('api.saved', { ns: 'common' }) })
onClose()
}
catch {
// Error toast is handled by fetch layer
}
}, [saveConfig, provider.provider_type, invalidateList, notify, t, onClose])
const handleRevoke = useCallback(async () => {
try {
await deleteConfig(provider.provider_type)
await invalidateList()
notify({ type: 'success', message: t('api.remove', { ns: 'common' }) })
onClose()
}
catch {
// Error toast is handled by fetch layer
}
}, [deleteConfig, provider.provider_type, invalidateList, notify, t, onClose])
const isConfigured = provider.is_tenant_configured
const docLink = PROVIDER_DOC_LINKS[provider.provider_type]
return (
<Modal
isShow
onClose={onClose}
title={t('sandboxProvider.configModal.title', { ns: 'common', provider: provider.label })}
closable
className="w-[480px]"
>
<div className="mt-4">
<BaseForm
formSchemas={formSchemas}
ref={formRef}
labelClassName="system-sm-semibold mb-1 flex items-center gap-1 text-text-secondary"
formClassName="space-y-4"
/>
{/* Footer Actions */}
<div className="mt-6 flex items-center justify-between">
<div>
{docLink && (
<a
href={docLink}
target="_blank"
rel="noopener noreferrer"
className="system-sm-medium inline-flex items-center gap-1 text-text-accent hover:underline"
>
{t('sandboxProvider.configModal.readDoc', { ns: 'common' })}
<RiExternalLinkLine className="h-3.5 w-3.5" />
</a>
)}
</div>
<div className="flex items-center gap-2">
{isConfigured && (
<Button
variant="warning"
size="medium"
onClick={handleRevoke}
disabled={isDeleting || isSaving}
>
{t('sandboxProvider.configModal.revoke', { ns: 'common' })}
</Button>
)}
<Button
variant="primary"
size="medium"
onClick={handleSave}
disabled={isSaving || isDeleting}
>
{t('sandboxProvider.configModal.confirm', { ns: 'common' })}
</Button>
</div>
</div>
</div>
</Modal>
)
}
export default memo(ConfigModal)

View File

@ -0,0 +1,40 @@
import { FormTypeEnum } from '@/app/components/base/form/types'
export const SANDBOX_FIELD_CONFIGS = {
api_key: {
labelKey: 'sandboxProvider.configModal.apiKey',
placeholderKey: 'sandboxProvider.configModal.apiKeyPlaceholder',
type: FormTypeEnum.secretInput,
},
e2b_api_url: {
labelKey: 'sandboxProvider.configModal.e2bApiUrl',
placeholderKey: 'sandboxProvider.configModal.e2bApiUrlPlaceholder',
type: FormTypeEnum.textInput,
},
e2b_default_template: {
labelKey: 'sandboxProvider.configModal.e2bTemplate',
placeholderKey: 'sandboxProvider.configModal.e2bTemplatePlaceholder',
type: FormTypeEnum.textInput,
},
docker_sock: {
labelKey: 'sandboxProvider.configModal.dockerSock',
placeholderKey: 'sandboxProvider.configModal.dockerSockPlaceholder',
type: FormTypeEnum.textInput,
},
docker_image: {
labelKey: 'sandboxProvider.configModal.dockerImage',
placeholderKey: 'sandboxProvider.configModal.dockerImagePlaceholder',
type: FormTypeEnum.textInput,
},
base_working_path: {
labelKey: 'sandboxProvider.configModal.baseWorkingPath',
placeholderKey: 'sandboxProvider.configModal.baseWorkingPathPlaceholder',
type: FormTypeEnum.textInput,
},
} as const
export const PROVIDER_DOC_LINKS: Record<string, string> = {
e2b: 'https://e2b.dev/docs',
docker: 'https://docs.docker.com/',
local: '',
}

View File

@ -0,0 +1,101 @@
'use client'
import type { SandboxProvider } from '@/service/use-sandbox-provider'
import { memo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useAppContext } from '@/context/app-context'
import { useGetSandboxProviderList } from '@/service/use-sandbox-provider'
import ConfigModal from './config-modal'
import ProviderCard from './provider-card'
import SwitchModal from './switch-modal'
const SandboxProviderPage = () => {
const { t } = useTranslation()
const { isCurrentWorkspaceOwner } = useAppContext()
const { data: providers, isLoading } = useGetSandboxProviderList()
const [configModalProvider, setConfigModalProvider] = useState<SandboxProvider | null>(null)
const [switchModalProvider, setSwitchModalProvider] = useState<SandboxProvider | null>(null)
const currentProvider = providers?.find(p => p.is_active)
const otherProviders = providers?.filter(p => !p.is_active) || []
const handleConfig = (provider: SandboxProvider) => {
setConfigModalProvider(provider)
}
const handleEnable = (provider: SandboxProvider) => {
setSwitchModalProvider(provider)
}
if (isLoading) {
return (
<div className="flex h-40 items-center justify-center">
<div className="system-sm-regular text-text-tertiary">Loading...</div>
</div>
)
}
return (
<div className="space-y-6">
{/* Current Provider Section */}
{currentProvider && (
<div>
<div className="system-sm-semibold mb-2 text-text-secondary">
{t('sandboxProvider.currentProvider', { ns: 'common' })}
</div>
<ProviderCard
provider={currentProvider}
isCurrent
onConfig={() => handleConfig(currentProvider)}
disabled={!isCurrentWorkspaceOwner}
/>
</div>
)}
{/* Other Providers Section */}
{otherProviders.length > 0 && (
<div>
<div className="system-sm-semibold mb-2 text-text-secondary">
{t('sandboxProvider.otherProvider', { ns: 'common' })}
</div>
<div className="space-y-2">
{otherProviders.map(provider => (
<ProviderCard
key={provider.provider_type}
provider={provider}
onConfig={() => handleConfig(provider)}
onEnable={() => handleEnable(provider)}
disabled={!isCurrentWorkspaceOwner}
/>
))}
</div>
</div>
)}
{!isCurrentWorkspaceOwner && (
<div className="system-xs-regular text-text-tertiary">
{t('sandboxProvider.noPermission', { ns: 'common' })}
</div>
)}
{/* Config Modal */}
{configModalProvider && (
<ConfigModal
provider={configModalProvider}
onClose={() => setConfigModalProvider(null)}
/>
)}
{/* Switch Modal */}
{switchModalProvider && (
<SwitchModal
provider={switchModalProvider}
onClose={() => setSwitchModalProvider(null)}
/>
)}
</div>
)
}
export default memo(SandboxProviderPage)

View File

@ -0,0 +1,109 @@
'use client'
import type { SandboxProvider } from '@/service/use-sandbox-provider'
import { memo } from 'react'
import { useTranslation } from 'react-i18next'
import Button from '@/app/components/base/button'
import Indicator from '@/app/components/header/indicator'
import { cn } from '@/utils/classnames'
type ProviderCardProps = {
provider: SandboxProvider
isCurrent?: boolean
onConfig: () => void
onEnable?: () => void
disabled?: boolean
}
const PROVIDER_ICONS: Record<string, string> = {
e2b: '/sandbox-providers/e2b.svg',
docker: '/sandbox-providers/docker.svg',
local: '/sandbox-providers/local.svg',
}
const ProviderIcon = ({ providerType }: { providerType: string }) => {
const iconSrc = PROVIDER_ICONS[providerType] || PROVIDER_ICONS.e2b
return (
<img
src={iconSrc}
alt={`${providerType} icon`}
className="h-5 w-5"
/>
)
}
const ProviderCard = ({
provider,
isCurrent = false,
onConfig,
onEnable,
disabled = false,
}: ProviderCardProps) => {
const { t } = useTranslation()
const isConfigured = provider.is_tenant_configured || provider.is_system_configured
const showEnableButton = !isCurrent && isConfigured && onEnable
return (
<div className={cn(
'flex items-center justify-between rounded-xl p-4',
isCurrent ? 'bg-background-section' : 'bg-background-section-burn',
)}
>
<div className="flex items-center">
{/* Icon */}
<div className="mr-3 flex h-8 w-8 shrink-0 items-center justify-center rounded-lg border border-divider-subtle bg-background-default-subtle">
<ProviderIcon providerType={provider.provider_type} />
</div>
{/* Content */}
<div className="min-w-0">
<div className="flex items-center gap-2">
<span className="system-md-semibold text-text-primary">
{provider.label}
</span>
{provider.is_system_configured && (
<span className="system-2xs-medium-uppercase rounded border border-divider-regular px-1.5 py-0.5 text-text-tertiary">
{t('sandboxProvider.managedBySaas', { ns: 'common' })}
</span>
)}
</div>
<div className="system-xs-regular text-text-tertiary">
{provider.description}
</div>
</div>
</div>
{/* Right side: Connected Badge + Actions */}
<div className="flex shrink-0 items-center gap-2">
{isConfigured && (
<span className="system-xs-medium flex items-center gap-1 rounded-md bg-util-colors-green-green-50 px-1.5 py-0.5 text-util-colors-green-green-600">
<Indicator color="green" />
{t('sandboxProvider.connected', { ns: 'common' })}
</span>
)}
<Button
variant="secondary"
size="small"
onClick={onConfig}
disabled={disabled}
>
{t('sandboxProvider.config', { ns: 'common' })}
</Button>
{showEnableButton && (
<Button
variant="secondary"
size="small"
onClick={onEnable}
disabled={disabled}
>
{t('sandboxProvider.enable', { ns: 'common' })}
</Button>
)}
</div>
</div>
)
}
export default memo(ProviderCard)

View File

@ -0,0 +1,95 @@
'use client'
import type { SandboxProvider } from '@/service/use-sandbox-provider'
import { RiAlertLine } from '@remixicon/react'
import { memo, useCallback } from 'react'
import { useTranslation } from 'react-i18next'
import Button from '@/app/components/base/button'
import Modal from '@/app/components/base/modal'
import { useToastContext } from '@/app/components/base/toast'
import {
useActivateSandboxProvider,
useInvalidSandboxProviderList,
} from '@/service/use-sandbox-provider'
type SwitchModalProps = {
provider: SandboxProvider
onClose: () => void
}
const SwitchModal = ({
provider,
onClose,
}: SwitchModalProps) => {
const { t } = useTranslation()
const { notify } = useToastContext()
const invalidateList = useInvalidSandboxProviderList()
const { mutateAsync: activateProvider, isPending } = useActivateSandboxProvider()
const handleConfirm = useCallback(async () => {
try {
await activateProvider(provider.provider_type)
await invalidateList()
notify({ type: 'success', message: t('api.success', { ns: 'common' }) })
onClose()
}
catch {
// Error toast is handled by fetch layer
}
}, [activateProvider, provider.provider_type, invalidateList, notify, t, onClose])
return (
<Modal
isShow
onClose={onClose}
title={t('sandboxProvider.switchModal.title', { ns: 'common' })}
closable
className="w-[480px]"
>
<div className="mt-4">
{/* Warning Section */}
<div className="flex gap-3 rounded-xl bg-state-warning-hover p-3">
<div className="flex h-5 w-5 shrink-0 items-center justify-center rounded-full bg-state-warning-solid">
<RiAlertLine className="h-3 w-3 text-text-warning-secondary" />
</div>
<div>
<div className="system-sm-semibold text-text-primary">
{t('sandboxProvider.switchModal.warning', { ns: 'common' })}
</div>
<div className="system-xs-regular mt-1 text-text-secondary">
{t('sandboxProvider.switchModal.warningDesc', { ns: 'common' })}
</div>
</div>
</div>
{/* Confirm Text */}
<div className="system-sm-regular mt-4 text-text-secondary">
{t('sandboxProvider.switchModal.confirmText', { ns: 'common', provider: provider.label })}
</div>
{/* Footer Actions */}
<div className="mt-6 flex items-center justify-end gap-2">
<Button
variant="secondary"
size="medium"
onClick={onClose}
disabled={isPending}
>
{t('sandboxProvider.switchModal.cancel', { ns: 'common' })}
</Button>
<Button
variant="primary"
size="medium"
onClick={handleConfirm}
disabled={isPending}
>
{t('sandboxProvider.switchModal.confirm', { ns: 'common' })}
</Button>
</div>
</div>
</Modal>
)
}
export default memo(SwitchModal)