Download web/src/components/table/model-deployments/modals/CreateDeploymentModal.jsx from blue-blue/taa: direct link, hf CLI and curl.
- Browser
- Download file 51 kB
-
https://huggingface.co/spaces/blue-blue/taa/resolve/main/web/src/components/table/model-deployments/modals/CreateDeploymentModal.jsx
- Command line
-
hf download hf://spaces/blue-blue/taa/web/src/components/table/model-deployments/modals/CreateDeploymentModal.jsx
-
curl -L -o CreateDeploymentModal.jsx https://huggingface.co/spaces/blue-blue/taa/resolve/main/web/src/components/table/model-deployments/modals/CreateDeploymentModal.jsx
51 kB
| /* | |
| Copyright (C) 2025 QuantumNous | |
| This program is free software: you can redistribute it and/or modify | |
| it under the terms of the GNU Affero General Public License as | |
| published by the Free Software Foundation, either version 3 of the | |
| License, or (at your option) any later version. | |
| This program is distributed in the hope that it will be useful, | |
| but WITHOUT ANY WARRANTY; without even the implied warranty of | |
| MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the | |
| GNU Affero General Public License for more details. | |
| You should have received a copy of the GNU Affero General Public License | |
| along with this program. If not, see <https://www.gnu.org/licenses/>. | |
| For commercial licensing, please contact support@quantumnous.com | |
| */ | |
| import React, { useState, useEffect, useMemo, useRef } from 'react'; | |
| import { | |
| Modal, | |
| Form, | |
| Input, | |
| Select, | |
| InputNumber, | |
| Switch, | |
| Collapse, | |
| Card, | |
| Divider, | |
| Button, | |
| Typography, | |
| Space, | |
| Spin, | |
| Tag, | |
| Row, | |
| Col, | |
| Tooltip, | |
| Radio, | |
| } from '@douyinfe/semi-ui'; | |
| import { IconPlus, IconMinus, IconHelpCircle, IconCopy } from '@douyinfe/semi-icons'; | |
| import { API } from '../../../../helpers'; | |
| import { showError, showSuccess, copy } from '../../../../helpers'; | |
| const { Text, Title } = Typography; | |
| const { Option } = Select; | |
| const RadioGroup = Radio.Group; | |
| const BUILTIN_IMAGE = 'ollama/ollama:latest'; | |
| const DEFAULT_TRAFFIC_PORT = 11434; | |
| const generateRandomKey = () => { | |
| try { | |
| if (typeof crypto !== 'undefined' && crypto.randomUUID) { | |
| return `ionet-${crypto.randomUUID().replace(/-/g, '')}`; | |
| } | |
| } catch (error) { | |
| // ignore | |
| } | |
| return `ionet-${Math.random().toString(36).slice(2)}${Math.random() | |
| .toString(36) | |
| .slice(2)}`; | |
| }; | |
| const CreateDeploymentModal = ({ visible, onCancel, onSuccess, t }) => { | |
| const [formApi, setFormApi] = useState(null); | |
| const [loading, setLoading] = useState(false); | |
| const [submitting, setSubmitting] = useState(false); | |
| // Resource data states | |
| const [hardwareTypes, setHardwareTypes] = useState([]); | |
| const [hardwareTotalAvailable, setHardwareTotalAvailable] = useState(null); | |
| const [locations, setLocations] = useState([]); | |
| const [locationTotalAvailable, setLocationTotalAvailable] = useState(null); | |
| const [availableReplicas, setAvailableReplicas] = useState([]); | |
| const [priceEstimation, setPriceEstimation] = useState(null); | |
| // UI states | |
| const [loadingHardware, setLoadingHardware] = useState(false); | |
| const [loadingLocations, setLoadingLocations] = useState(false); | |
| const [loadingReplicas, setLoadingReplicas] = useState(false); | |
| const [loadingPrice, setLoadingPrice] = useState(false); | |
| const [showAdvanced, setShowAdvanced] = useState(false); | |
| const [envVariables, setEnvVariables] = useState([{ key: '', value: '' }]); | |
| const [secretEnvVariables, setSecretEnvVariables] = useState([{ key: '', value: '' }]); | |
| const [entrypoint, setEntrypoint] = useState(['']); | |
| const [args, setArgs] = useState(['']); | |
| const [imageMode, setImageMode] = useState('builtin'); | |
| const [autoOllamaKey, setAutoOllamaKey] = useState(''); | |
| const customSecretEnvRef = useRef(null); | |
| const customEnvRef = useRef(null); | |
| const customImageRef = useRef(''); | |
| const customTrafficPortRef = useRef(null); | |
| const prevImageModeRef = useRef('builtin'); | |
| const basicSectionRef = useRef(null); | |
| const priceSectionRef = useRef(null); | |
| const advancedSectionRef = useRef(null); | |
| const locationRequestIdRef = useRef(0); | |
| const replicaRequestIdRef = useRef(0); | |
| const [formDefaults, setFormDefaults] = useState({ | |
| resource_private_name: '', | |
| image_url: BUILTIN_IMAGE, | |
| gpus_per_container: 1, | |
| replica_count: 1, | |
| duration_hours: 1, | |
| traffic_port: DEFAULT_TRAFFIC_PORT, | |
| location_ids: [], | |
| }); | |
| const [formKey, setFormKey] = useState(0); | |
| const [priceCurrency, setPriceCurrency] = useState('usdc'); | |
| const normalizeCurrencyValue = (value) => { | |
| if (typeof value === 'string') return value.toLowerCase(); | |
| if (value && typeof value === 'object') { | |
| if (typeof value.value === 'string') return value.value.toLowerCase(); | |
| if (typeof value.target?.value === 'string') { | |
| return value.target.value.toLowerCase(); | |
| } | |
| } | |
| return 'usdc'; | |
| }; | |
| const handleCurrencyChange = (value) => { | |
| const normalized = normalizeCurrencyValue(value); | |
| setPriceCurrency(normalized); | |
| }; | |
| const hardwareLabelMap = useMemo(() => { | |
| const map = {}; | |
| hardwareTypes.forEach((hardware) => { | |
| const displayName = hardware.brand_name | |
| ? `${hardware.brand_name} ${hardware.name}`.trim() | |
| : hardware.name; | |
| map[hardware.id] = displayName; | |
| }); | |
| return map; | |
| }, [hardwareTypes]); | |
| const locationLabelMap = useMemo(() => { | |
| const map = {}; | |
| locations.forEach((location) => { | |
| map[location.id] = location.name; | |
| }); | |
| return map; | |
| }, [locations]); | |
| // Form values for price calculation | |
| const [selectedHardwareId, setSelectedHardwareId] = useState(null); | |
| const [selectedLocationIds, setSelectedLocationIds] = useState([]); | |
| const [gpusPerContainer, setGpusPerContainer] = useState(1); | |
| const [durationHours, setDurationHours] = useState(1); | |
| const [replicaCount, setReplicaCount] = useState(1); | |
| // Load initial data when modal opens | |
| useEffect(() => { | |
| if (visible) { | |
| loadHardwareTypes(); | |
| resetFormState(); | |
| } | |
| }, [visible]); | |
| // Load available replicas when hardware or locations change | |
| useEffect(() => { | |
| if (!visible) { | |
| return; | |
| } | |
| if (selectedHardwareId && gpusPerContainer > 0) { | |
| loadAvailableReplicas(selectedHardwareId, gpusPerContainer); | |
| } | |
| }, [selectedHardwareId, gpusPerContainer, visible]); | |
| // Calculate price when relevant parameters change | |
| useEffect(() => { | |
| if (!visible) { | |
| return; | |
| } | |
| if ( | |
| selectedHardwareId && | |
| selectedLocationIds.length > 0 && | |
| gpusPerContainer > 0 && | |
| durationHours > 0 && | |
| replicaCount > 0 | |
| ) { | |
| calculatePrice(); | |
| } else { | |
| setPriceEstimation(null); | |
| } | |
| }, [ | |
| selectedHardwareId, | |
| selectedLocationIds, | |
| gpusPerContainer, | |
| durationHours, | |
| replicaCount, | |
| priceCurrency, | |
| visible, | |
| ]); | |
| useEffect(() => { | |
| if (!visible) { | |
| return; | |
| } | |
| const prevMode = prevImageModeRef.current; | |
| if (prevMode === imageMode) { | |
| return; | |
| } | |
| if (imageMode === 'builtin') { | |
| if (prevMode === 'custom') { | |
| if (formApi) { | |
| customImageRef.current = formApi.getValue('image_url') || customImageRef.current; | |
| customTrafficPortRef.current = formApi.getValue('traffic_port') ?? customTrafficPortRef.current; | |
| } | |
| customSecretEnvRef.current = secretEnvVariables.map((item) => ({ ...item })); | |
| customEnvRef.current = envVariables.map((item) => ({ ...item })); | |
| } | |
| const newKey = generateRandomKey(); | |
| setAutoOllamaKey(newKey); | |
| setSecretEnvVariables([{ key: 'OLLAMA_API_KEY', value: newKey }]); | |
| setEnvVariables([{ key: '', value: '' }]); | |
| if (formApi) { | |
| formApi.setValue('image_url', BUILTIN_IMAGE); | |
| formApi.setValue('traffic_port', DEFAULT_TRAFFIC_PORT); | |
| } | |
| } else { | |
| const restoredSecrets = | |
| customSecretEnvRef.current && customSecretEnvRef.current.length > 0 | |
| ? customSecretEnvRef.current.map((item) => ({ ...item })) | |
| : [{ key: '', value: '' }]; | |
| const restoredEnv = | |
| customEnvRef.current && customEnvRef.current.length > 0 | |
| ? customEnvRef.current.map((item) => ({ ...item })) | |
| : [{ key: '', value: '' }]; | |
| setSecretEnvVariables(restoredSecrets); | |
| setEnvVariables(restoredEnv); | |
| if (formApi) { | |
| const restoredImage = customImageRef.current || ''; | |
| formApi.setValue('image_url', restoredImage); | |
| if (customTrafficPortRef.current) { | |
| formApi.setValue('traffic_port', customTrafficPortRef.current); | |
| } | |
| } | |
| } | |
| prevImageModeRef.current = imageMode; | |
| }, [imageMode, visible, secretEnvVariables, envVariables, formApi]); | |
| useEffect(() => { | |
| if (!visible || !formApi) { | |
| return; | |
| } | |
| if (imageMode === 'builtin') { | |
| formApi.setValue('image_url', BUILTIN_IMAGE); | |
| } | |
| }, [formApi, imageMode, visible]); | |
| useEffect(() => { | |
| if (!formApi) { | |
| return; | |
| } | |
| if (selectedHardwareId !== null && selectedHardwareId !== undefined) { | |
| formApi.setValue('hardware_id', selectedHardwareId); | |
| } | |
| }, [formApi, selectedHardwareId]); | |
| useEffect(() => { | |
| if (!formApi) { | |
| return; | |
| } | |
| formApi.setValue('location_ids', selectedLocationIds); | |
| }, [formApi, selectedLocationIds]); | |
| useEffect(() => { | |
| if (!visible) { | |
| return; | |
| } | |
| if (selectedHardwareId) { | |
| loadLocations(selectedHardwareId); | |
| } else { | |
| setLocations([]); | |
| setSelectedLocationIds([]); | |
| setAvailableReplicas([]); | |
| setLocationTotalAvailable(null); | |
| setLoadingLocations(false); | |
| setLoadingReplicas(false); | |
| locationRequestIdRef.current = 0; | |
| replicaRequestIdRef.current = 0; | |
| if (formApi) { | |
| formApi.setValue('location_ids', []); | |
| } | |
| } | |
| }, [selectedHardwareId, visible, formApi]); | |
| const resetFormState = () => { | |
| const randomName = `deployment-${Math.random().toString(36).slice(2, 8)}`; | |
| const generatedKey = generateRandomKey(); | |
| setSelectedHardwareId(null); | |
| setSelectedLocationIds([]); | |
| setGpusPerContainer(1); | |
| setDurationHours(1); | |
| setReplicaCount(1); | |
| setPriceEstimation(null); | |
| setAvailableReplicas([]); | |
| setLocations([]); | |
| setLocationTotalAvailable(null); | |
| setHardwareTotalAvailable(null); | |
| setEnvVariables([{ key: '', value: '' }]); | |
| setSecretEnvVariables([{ key: 'OLLAMA_API_KEY', value: generatedKey }]); | |
| setEntrypoint(['']); | |
| setArgs(['']); | |
| setShowAdvanced(false); | |
| setImageMode('builtin'); | |
| setAutoOllamaKey(generatedKey); | |
| customSecretEnvRef.current = null; | |
| customEnvRef.current = null; | |
| customImageRef.current = ''; | |
| customTrafficPortRef.current = DEFAULT_TRAFFIC_PORT; | |
| prevImageModeRef.current = 'builtin'; | |
| setFormDefaults({ | |
| resource_private_name: randomName, | |
| image_url: BUILTIN_IMAGE, | |
| gpus_per_container: 1, | |
| replica_count: 1, | |
| duration_hours: 1, | |
| traffic_port: DEFAULT_TRAFFIC_PORT, | |
| location_ids: [], | |
| }); | |
| setFormKey((prev) => prev + 1); | |
| setPriceCurrency('usdc'); | |
| }; | |
| const arraysEqual = (a = [], b = []) => | |
| a.length === b.length && a.every((value, index) => value === b[index]); | |
| const loadHardwareTypes = async () => { | |
| try { | |
| setLoadingHardware(true); | |
| const response = await API.get('/api/deployments/hardware-types'); | |
| if (response.data.success) { | |
| const { hardware_types: hardwareList = [], total_available } = response.data.data || {}; | |
| const normalizedHardware = hardwareList.map((hardware) => { | |
| const availableCountValue = Number(hardware.available_count); | |
| const availableCount = Number.isNaN(availableCountValue) ? 0 : availableCountValue; | |
| const availableBool = | |
| typeof hardware.available === 'boolean' | |
| ? hardware.available | |
| : availableCount > 0; | |
| return { | |
| ...hardware, | |
| available: availableBool, | |
| available_count: availableCount, | |
| }; | |
| }); | |
| const providedTotal = Number(total_available); | |
| const fallbackTotal = normalizedHardware.reduce( | |
| (acc, item) => acc + (Number.isNaN(item.available_count) ? 0 : item.available_count), | |
| 0, | |
| ); | |
| const hasProvidedTotal = | |
| total_available !== undefined && | |
| total_available !== null && | |
| total_available !== '' && | |
| !Number.isNaN(providedTotal); | |
| setHardwareTypes(normalizedHardware); | |
| setHardwareTotalAvailable( | |
| hasProvidedTotal ? providedTotal : fallbackTotal, | |
| ); | |
| } else { | |
| showError(t('获取硬件类型失败: ') + response.data.message); | |
| } | |
| } catch (error) { | |
| showError(t('获取硬件类型失败: ') + error.message); | |
| } finally { | |
| setLoadingHardware(false); | |
| } | |
| }; | |
| const loadLocations = async (hardwareId) => { | |
| if (!hardwareId) { | |
| setLocations([]); | |
| setLocationTotalAvailable(null); | |
| return; | |
| } | |
| const requestId = Date.now(); | |
| locationRequestIdRef.current = requestId; | |
| setLoadingLocations(true); | |
| setLocations([]); | |
| setLocationTotalAvailable(null); | |
| try { | |
| const response = await API.get('/api/deployments/locations', { | |
| params: { hardware_id: hardwareId }, | |
| }); | |
| if (locationRequestIdRef.current !== requestId) { | |
| return; | |
| } | |
| if (response.data.success) { | |
| const { locations: locationsList = [], total } = | |
| response.data.data || {}; | |
| const normalizedLocations = locationsList.map((location) => { | |
| const iso2 = (location.iso2 || '').toString().toUpperCase(); | |
| const availableValue = Number(location.available); | |
| const available = Number.isNaN(availableValue) ? 0 : availableValue; | |
| return { | |
| ...location, | |
| iso2, | |
| available, | |
| }; | |
| }); | |
| const providedTotal = Number(total); | |
| const fallbackTotal = normalizedLocations.reduce( | |
| (acc, item) => | |
| acc + (Number.isNaN(item.available) ? 0 : item.available), | |
| 0, | |
| ); | |
| const hasProvidedTotal = | |
| total !== undefined && | |
| total !== null && | |
| total !== '' && | |
| !Number.isNaN(providedTotal); | |
| setLocations(normalizedLocations); | |
| setLocationTotalAvailable( | |
| hasProvidedTotal ? providedTotal : fallbackTotal, | |
| ); | |
| } else { | |
| showError(t('获取部署位置失败: ') + response.data.message); | |
| setLocations([]); | |
| setLocationTotalAvailable(null); | |
| } | |
| } catch (error) { | |
| if (locationRequestIdRef.current === requestId) { | |
| showError(t('获取部署位置失败: ') + error.message); | |
| setLocations([]); | |
| setLocationTotalAvailable(null); | |
| } | |
| } finally { | |
| if (locationRequestIdRef.current === requestId) { | |
| setLoadingLocations(false); | |
| } | |
| } | |
| }; | |
| const loadAvailableReplicas = async (hardwareId, gpuCount) => { | |
| if (!hardwareId || !gpuCount) { | |
| setAvailableReplicas([]); | |
| setLocationTotalAvailable(null); | |
| setLoadingReplicas(false); | |
| return; | |
| } | |
| const requestId = Date.now(); | |
| replicaRequestIdRef.current = requestId; | |
| setLoadingReplicas(true); | |
| setAvailableReplicas([]); | |
| try { | |
| const response = await API.get( | |
| `/api/deployments/available-replicas?hardware_id=${hardwareId}&gpu_count=${gpuCount}`, | |
| ); | |
| if (replicaRequestIdRef.current !== requestId) { | |
| return; | |
| } | |
| if (response.data.success) { | |
| const replicasList = response.data.data?.replicas || []; | |
| const filteredReplicas = replicasList.filter( | |
| (replica) => (replica.available_count || 0) > 0, | |
| ); | |
| setAvailableReplicas(filteredReplicas); | |
| const totalAvailableForHardware = filteredReplicas.reduce( | |
| (total, replica) => total + (replica.available_count || 0), | |
| 0, | |
| ); | |
| setLocationTotalAvailable(totalAvailableForHardware); | |
| } else { | |
| showError(t('获取可用资源失败: ') + response.data.message); | |
| setAvailableReplicas([]); | |
| setLocationTotalAvailable(null); | |
| } | |
| } catch (error) { | |
| if (replicaRequestIdRef.current === requestId) { | |
| console.error('Load available replicas error:', error); | |
| setAvailableReplicas([]); | |
| setLocationTotalAvailable(null); | |
| } | |
| } finally { | |
| if (replicaRequestIdRef.current === requestId) { | |
| setLoadingReplicas(false); | |
| } | |
| } | |
| }; | |
| const calculatePrice = async () => { | |
| try { | |
| setLoadingPrice(true); | |
| const requestData = { | |
| location_ids: selectedLocationIds, | |
| hardware_id: selectedHardwareId, | |
| gpus_per_container: gpusPerContainer, | |
| duration_hours: durationHours, | |
| replica_count: replicaCount, | |
| currency: priceCurrency?.toLowerCase?.() || priceCurrency, | |
| duration_type: 'hour', | |
| duration_qty: durationHours, | |
| hardware_qty: gpusPerContainer, | |
| }; | |
| const response = await API.post('/api/deployments/price-estimation', requestData); | |
| if (response.data.success) { | |
| setPriceEstimation(response.data.data); | |
| } else { | |
| showError(t('价格计算失败: ') + response.data.message); | |
| setPriceEstimation(null); | |
| } | |
| } catch (error) { | |
| console.error('Price calculation error:', error); | |
| setPriceEstimation(null); | |
| } finally { | |
| setLoadingPrice(false); | |
| } | |
| }; | |
| const handleSubmit = async (values) => { | |
| try { | |
| setSubmitting(true); | |
| // Prepare environment variables | |
| const envVars = {}; | |
| envVariables.forEach(env => { | |
| if (env.key && env.value) { | |
| envVars[env.key] = env.value; | |
| } | |
| }); | |
| const secretEnvVars = {}; | |
| secretEnvVariables.forEach(env => { | |
| if (env.key && env.value) { | |
| secretEnvVars[env.key] = env.value; | |
| } | |
| }); | |
| if (imageMode === 'builtin') { | |
| if (!secretEnvVars.OLLAMA_API_KEY) { | |
| const ensuredKey = autoOllamaKey || generateRandomKey(); | |
| secretEnvVars.OLLAMA_API_KEY = ensuredKey; | |
| setAutoOllamaKey(ensuredKey); | |
| } | |
| } | |
| // Prepare entrypoint and args | |
| const cleanEntrypoint = entrypoint.filter(item => item.trim() !== ''); | |
| const cleanArgs = args.filter(item => item.trim() !== ''); | |
| const resolvedImage = imageMode === 'builtin' ? BUILTIN_IMAGE : values.image_url; | |
| const resolvedTrafficPort = | |
| values.traffic_port || (imageMode === 'builtin' ? DEFAULT_TRAFFIC_PORT : undefined); | |
| const requestData = { | |
| resource_private_name: values.resource_private_name, | |
| duration_hours: values.duration_hours, | |
| gpus_per_container: values.gpus_per_container, | |
| hardware_id: values.hardware_id, | |
| location_ids: values.location_ids, | |
| container_config: { | |
| replica_count: values.replica_count, | |
| env_variables: envVars, | |
| secret_env_variables: secretEnvVars, | |
| entrypoint: cleanEntrypoint.length > 0 ? cleanEntrypoint : undefined, | |
| args: cleanArgs.length > 0 ? cleanArgs : undefined, | |
| traffic_port: resolvedTrafficPort, | |
| }, | |
| registry_config: { | |
| image_url: resolvedImage, | |
| registry_username: values.registry_username || undefined, | |
| registry_secret: values.registry_secret || undefined, | |
| }, | |
| }; | |
| const response = await API.post('/api/deployments', requestData); | |
| if (response.data.success) { | |
| showSuccess(t('容器创建成功')); | |
| onSuccess?.(response.data.data); | |
| onCancel(); | |
| } else { | |
| showError(t('容器创建失败: ') + response.data.message); | |
| } | |
| } catch (error) { | |
| showError(t('容器创建失败: ') + error.message); | |
| } finally { | |
| setSubmitting(false); | |
| } | |
| }; | |
| const handleAddEnvVariable = (type) => { | |
| if (type === 'env') { | |
| setEnvVariables([...envVariables, { key: '', value: '' }]); | |
| } else { | |
| setSecretEnvVariables([...secretEnvVariables, { key: '', value: '' }]); | |
| } | |
| }; | |
| const handleRemoveEnvVariable = (index, type) => { | |
| if (type === 'env') { | |
| const newEnvVars = envVariables.filter((_, i) => i !== index); | |
| setEnvVariables(newEnvVars.length > 0 ? newEnvVars : [{ key: '', value: '' }]); | |
| } else { | |
| const newSecretEnvVars = secretEnvVariables.filter((_, i) => i !== index); | |
| setSecretEnvVariables(newSecretEnvVars.length > 0 ? newSecretEnvVars : [{ key: '', value: '' }]); | |
| } | |
| }; | |
| const handleEnvVariableChange = (index, field, value, type) => { | |
| if (type === 'env') { | |
| const newEnvVars = [...envVariables]; | |
| newEnvVars[index][field] = value; | |
| setEnvVariables(newEnvVars); | |
| } else { | |
| const newSecretEnvVars = [...secretEnvVariables]; | |
| newSecretEnvVars[index][field] = value; | |
| setSecretEnvVariables(newSecretEnvVars); | |
| } | |
| }; | |
| const handleArrayFieldChange = (index, value, type) => { | |
| if (type === 'entrypoint') { | |
| const newEntrypoint = [...entrypoint]; | |
| newEntrypoint[index] = value; | |
| setEntrypoint(newEntrypoint); | |
| } else { | |
| const newArgs = [...args]; | |
| newArgs[index] = value; | |
| setArgs(newArgs); | |
| } | |
| }; | |
| const handleAddArrayField = (type) => { | |
| if (type === 'entrypoint') { | |
| setEntrypoint([...entrypoint, '']); | |
| } else { | |
| setArgs([...args, '']); | |
| } | |
| }; | |
| const handleRemoveArrayField = (index, type) => { | |
| if (type === 'entrypoint') { | |
| const newEntrypoint = entrypoint.filter((_, i) => i !== index); | |
| setEntrypoint(newEntrypoint.length > 0 ? newEntrypoint : ['']); | |
| } else { | |
| const newArgs = args.filter((_, i) => i !== index); | |
| setArgs(newArgs.length > 0 ? newArgs : ['']); | |
| } | |
| }; | |
| useEffect(() => { | |
| if (!visible) { | |
| return; | |
| } | |
| if (!selectedHardwareId) { | |
| if (selectedLocationIds.length > 0) { | |
| setSelectedLocationIds([]); | |
| if (formApi) { | |
| formApi.setValue('location_ids', []); | |
| } | |
| } | |
| return; | |
| } | |
| const validLocationIds = | |
| availableReplicas.length > 0 | |
| ? availableReplicas.map((item) => item.location_id) | |
| : locations.map((location) => location.id); | |
| if (validLocationIds.length === 0) { | |
| if (selectedLocationIds.length > 0) { | |
| setSelectedLocationIds([]); | |
| if (formApi) { | |
| formApi.setValue('location_ids', []); | |
| } | |
| } | |
| return; | |
| } | |
| if (selectedLocationIds.length === 0) { | |
| return; | |
| } | |
| const filteredSelection = selectedLocationIds.filter((id) => | |
| validLocationIds.includes(id), | |
| ); | |
| if (!arraysEqual(selectedLocationIds, filteredSelection)) { | |
| setSelectedLocationIds(filteredSelection); | |
| if (formApi) { | |
| formApi.setValue('location_ids', filteredSelection); | |
| } | |
| } | |
| }, [ | |
| availableReplicas, | |
| locations, | |
| selectedHardwareId, | |
| selectedLocationIds, | |
| visible, | |
| formApi, | |
| ]); | |
| const maxAvailableReplicas = useMemo(() => { | |
| if (!selectedLocationIds.length) return 0; | |
| if (availableReplicas.length > 0) { | |
| return availableReplicas | |
| .filter((replica) => selectedLocationIds.includes(replica.location_id)) | |
| .reduce((total, replica) => total + (replica.available_count || 0), 0); | |
| } | |
| return locations | |
| .filter((location) => selectedLocationIds.includes(location.id)) | |
| .reduce((total, location) => { | |
| const availableValue = Number(location.available); | |
| return total + (Number.isNaN(availableValue) ? 0 : availableValue); | |
| }, 0); | |
| }, [availableReplicas, selectedLocationIds, locations]); | |
| const isPriceReady = useMemo( | |
| () => | |
| selectedHardwareId && | |
| selectedLocationIds.length > 0 && | |
| gpusPerContainer > 0 && | |
| durationHours > 0 && | |
| replicaCount > 0, | |
| [ | |
| selectedHardwareId, | |
| selectedLocationIds, | |
| gpusPerContainer, | |
| durationHours, | |
| replicaCount, | |
| ], | |
| ); | |
| const currencyLabel = (priceEstimation?.currency || priceCurrency || '').toUpperCase(); | |
| const selectedHardwareLabel = selectedHardwareId | |
| ? hardwareLabelMap[selectedHardwareId] | |
| : ''; | |
| const selectedLocationNames = selectedLocationIds | |
| .map((id) => locationLabelMap[id]) | |
| .filter(Boolean); | |
| const totalGpuHours = | |
| Number(gpusPerContainer || 0) * | |
| Number(replicaCount || 0) * | |
| Number(durationHours || 0); | |
| const priceSummaryItems = [ | |
| { | |
| key: 'hardware', | |
| label: t('硬件类型'), | |
| value: selectedHardwareLabel || '--', | |
| }, | |
| { | |
| key: 'locations', | |
| label: t('部署位置'), | |
| value: selectedLocationNames.length ? selectedLocationNames.join('、') : '--', | |
| }, | |
| { | |
| key: 'replicas', | |
| label: t('副本数量'), | |
| value: (replicaCount ?? 0).toString(), | |
| }, | |
| { | |
| key: 'gpus', | |
| label: t('每容器GPU数量'), | |
| value: (gpusPerContainer ?? 0).toString(), | |
| }, | |
| { | |
| key: 'duration', | |
| label: t('运行时长(小时)'), | |
| value: durationHours ? durationHours.toString() : '0', | |
| }, | |
| { | |
| key: 'gpu-hours', | |
| label: t('总 GPU 小时'), | |
| value: totalGpuHours > 0 ? totalGpuHours.toLocaleString() : '0', | |
| }, | |
| ]; | |
| const scrollToSection = (ref) => { | |
| if (ref?.current && typeof ref.current.scrollIntoView === 'function') { | |
| ref.current.scrollIntoView({ behavior: 'smooth', block: 'start' }); | |
| } | |
| }; | |
| const priceUnavailableContent = ( | |
| <div style={{ marginTop: 12 }}> | |
| {loadingPrice ? ( | |
| <Space spacing={8} align="center"> | |
| <Spin size="small" /> | |
| <Text size="small" type="tertiary"> | |
| {t('价格计算中...')} | |
| </Text> | |
| </Space> | |
| ) : ( | |
| <Text size="small" type="tertiary"> | |
| {isPriceReady | |
| ? t('价格暂时不可用,请稍后重试') | |
| : t('完成硬件类型、部署位置、副本数量等配置后,将自动计算价格')} | |
| </Text> | |
| )} | |
| </div> | |
| ); | |
| useEffect(() => { | |
| if (!visible || !formApi) { | |
| return; | |
| } | |
| if (maxAvailableReplicas > 0 && replicaCount > maxAvailableReplicas) { | |
| setReplicaCount(maxAvailableReplicas); | |
| formApi.setValue('replica_count', maxAvailableReplicas); | |
| } | |
| }, [maxAvailableReplicas, replicaCount, visible, formApi]); | |
| return ( | |
| <Modal | |
| title={t('新建容器部署')} | |
| visible={visible} | |
| onCancel={onCancel} | |
| onOk={() => formApi?.submitForm()} | |
| okText={t('创建')} | |
| cancelText={t('取消')} | |
| width={800} | |
| confirmLoading={submitting} | |
| style={{ top: 20 }} | |
| > | |
| <Form | |
| key={formKey} | |
| initValues={formDefaults} | |
| getFormApi={setFormApi} | |
| onSubmit={handleSubmit} | |
| style={{ maxHeight: '70vh', overflowY: 'auto' }} | |
| labelPosition="top" | |
| > | |
| <Space | |
| wrap | |
| spacing={8} | |
| style={{ justifyContent: 'flex-end', width: '100%', marginBottom: 8 }} | |
| > | |
| <Button | |
| size="small" | |
| theme="borderless" | |
| type="tertiary" | |
| onClick={() => scrollToSection(basicSectionRef)} | |
| > | |
| {t('部署配置')} | |
| </Button> | |
| <Button | |
| size="small" | |
| theme="borderless" | |
| type="tertiary" | |
| onClick={() => scrollToSection(priceSectionRef)} | |
| > | |
| {t('价格预估')} | |
| </Button> | |
| <Button | |
| size="small" | |
| theme="borderless" | |
| type="tertiary" | |
| onClick={() => scrollToSection(advancedSectionRef)} | |
| > | |
| {t('高级配置')} | |
| </Button> | |
| </Space> | |
| <div ref={basicSectionRef}> | |
| <Card className="mb-4"> | |
| <Title heading={6}>{t('部署配置')}</Title> | |
| <Form.Input | |
| field="resource_private_name" | |
| label={t('容器名称')} | |
| placeholder={t('请输入容器名称')} | |
| rules={[{ required: true, message: t('请输入容器名称') }]} | |
| /> | |
| <div className="mt-2"> | |
| <Text strong>{t('镜像选择')}</Text> | |
| <div style={{ marginTop: 8 }}> | |
| <RadioGroup | |
| type="button" | |
| value={imageMode} | |
| onChange={(value) => setImageMode(value?.target?.value ?? value)} | |
| > | |
| <Radio value="builtin">{t('内置 Ollama 镜像')}</Radio> | |
| <Radio value="custom">{t('自定义镜像')}</Radio> | |
| </RadioGroup> | |
| </div> | |
| </div> | |
| <Form.Input | |
| field="image_url" | |
| label={t('镜像地址')} | |
| placeholder={t('例如:nginx:latest')} | |
| rules={[{ required: true, message: t('请输入镜像地址') }]} | |
| disabled={imageMode === 'builtin'} | |
| onChange={(value) => { | |
| if (imageMode === 'custom') { | |
| customImageRef.current = value; | |
| } | |
| }} | |
| /> | |
| {imageMode === 'builtin' && ( | |
| <Space align="center" spacing={8} className="mt-2"> | |
| <Text size="small" type="tertiary"> | |
| {t('系统已为该部署准备 Ollama 镜像与随机 API Key')} | |
| </Text> | |
| <Input | |
| readOnly | |
| value={autoOllamaKey} | |
| size="small" | |
| style={{ width: 220 }} | |
| /> | |
| <Button | |
| icon={<IconCopy />} | |
| size="small" | |
| theme="borderless" | |
| onClick={async () => { | |
| if (!autoOllamaKey) { | |
| return; | |
| } | |
| const copied = await copy(autoOllamaKey); | |
| if (copied) { | |
| showSuccess(t('已复制自动生成的 API Key')); | |
| } else { | |
| showError(t('复制失败,请手动选择文本复制')); | |
| } | |
| }} | |
| > | |
| {t('复制')} | |
| </Button> | |
| </Space> | |
| )} | |
| <Row gutter={16}> | |
| <Col xs={24} md={12}> | |
| <Form.Select | |
| field="hardware_id" | |
| label={t('硬件类型')} | |
| placeholder={t('选择硬件类型')} | |
| loading={loadingHardware} | |
| rules={[{ required: true, message: t('请选择硬件类型') }]} | |
| onChange={(value) => { | |
| setSelectedHardwareId(value); | |
| setSelectedLocationIds([]); | |
| if (formApi) { | |
| formApi.setValue('location_ids', []); | |
| } | |
| }} | |
| style={{ width: '100%' }} | |
| dropdownStyle={{ maxHeight: 360, overflowY: 'auto' }} | |
| renderSelectedItem={(optionNode) => | |
| optionNode | |
| ? hardwareLabelMap[optionNode?.value] || | |
| optionNode?.label || | |
| optionNode?.value || | |
| '' | |
| : '' | |
| } | |
| > | |
| {hardwareTypes.map((hardware) => { | |
| const displayName = hardware.brand_name | |
| ? `${hardware.brand_name} ${hardware.name}`.trim() | |
| : hardware.name; | |
| const availableCount = | |
| typeof hardware.available_count === 'number' | |
| ? hardware.available_count | |
| : 0; | |
| const hasAvailability = availableCount > 0; | |
| return ( | |
| <Option key={hardware.id} value={hardware.id}> | |
| <div className="flex flex-col gap-1"> | |
| <Text strong>{displayName}</Text> | |
| <div className="flex items-center gap-2 text-xs text-[var(--semi-color-text-2)]"> | |
| <span> | |
| {t('最大GPU数')}: {hardware.max_gpus} | |
| </span> | |
| <Tag color={hasAvailability ? 'green' : 'red'} size="small"> | |
| {t('可用数量')}: {availableCount} | |
| </Tag> | |
| </div> | |
| </div> | |
| </Option> | |
| ); | |
| })} | |
| </Form.Select> | |
| </Col> | |
| <Col xs={24} md={12}> | |
| <Form.InputNumber | |
| field="gpus_per_container" | |
| label={t('每容器GPU数量')} | |
| placeholder={1} | |
| min={1} | |
| max={selectedHardwareId ? hardwareTypes.find((h) => h.id === selectedHardwareId)?.max_gpus : 8} | |
| step={1} | |
| innerButtons | |
| rules={[{ required: true, message: t('请输入GPU数量') }]} | |
| onChange={(value) => setGpusPerContainer(value)} | |
| style={{ width: '100%' }} | |
| /> | |
| </Col> | |
| </Row> | |
| {typeof hardwareTotalAvailable === 'number' && ( | |
| <Text size="small" type="tertiary"> | |
| {t('全部硬件总可用资源')}: {hardwareTotalAvailable} | |
| </Text> | |
| )} | |
| <Form.Select | |
| field="location_ids" | |
| label={ | |
| <Space> | |
| {t('部署位置')} | |
| {loadingReplicas && <Spin size="small" />} | |
| </Space> | |
| } | |
| placeholder={ | |
| !selectedHardwareId | |
| ? t('请先选择硬件类型') | |
| : loadingLocations || loadingReplicas | |
| ? t('正在加载可用部署位置...') | |
| : t('选择部署位置(可多选)') | |
| } | |
| multiple | |
| loading={loadingLocations || loadingReplicas} | |
| disabled={!selectedHardwareId || loadingLocations || loadingReplicas} | |
| rules={[{ required: true, message: t('请选择至少一个部署位置') }]} | |
| onChange={(value) => setSelectedLocationIds(value)} | |
| style={{ width: '100%' }} | |
| dropdownStyle={{ maxHeight: 360, overflowY: 'auto' }} | |
| renderSelectedItem={(optionNode) => ({ | |
| isRenderInTag: true, | |
| content: | |
| !optionNode | |
| ? '' | |
| : loadingLocations || loadingReplicas | |
| ? t('部署位置加载中...') | |
| : locationLabelMap[optionNode?.value] || | |
| optionNode?.label || | |
| optionNode?.value || | |
| '', | |
| })} | |
| > | |
| {locations.map((location) => { | |
| const replicaEntry = availableReplicas.find( | |
| (r) => r.location_id === location.id, | |
| ); | |
| const hasReplicaData = availableReplicas.length > 0; | |
| const availableCount = hasReplicaData | |
| ? replicaEntry?.available_count ?? 0 | |
| : (() => { | |
| const numeric = Number(location.available); | |
| return Number.isNaN(numeric) ? 0 : numeric; | |
| })(); | |
| const locationLabel = | |
| location.region || | |
| location.country || | |
| (location.iso2 ? location.iso2.toUpperCase() : '') || | |
| location.code || | |
| ''; | |
| const disableOption = hasReplicaData | |
| ? availableCount === 0 | |
| : typeof location.available === 'number' | |
| ? location.available === 0 | |
| : false; | |
| return ( | |
| <Option | |
| key={location.id} | |
| value={location.id} | |
| disabled={disableOption} | |
| > | |
| <div className="flex flex-col gap-1"> | |
| <div className="flex items-center gap-2"> | |
| <Text strong>{location.name}</Text> | |
| {locationLabel && ( | |
| <Tag color="blue" size="small"> | |
| {locationLabel} | |
| </Tag> | |
| )} | |
| </div> | |
| <Text | |
| size="small" | |
| type={availableCount > 0 ? 'success' : 'danger'} | |
| > | |
| {t('可用数量')}: {availableCount} | |
| </Text> | |
| </div> | |
| </Option> | |
| ); | |
| })} | |
| </Form.Select> | |
| {typeof locationTotalAvailable === 'number' && ( | |
| <Text size="small" type="tertiary"> | |
| {t('全部地区总可用资源')}: {locationTotalAvailable} | |
| </Text> | |
| )} | |
| <Row gutter={16}> | |
| <Col xs={24} md={8}> | |
| <Form.InputNumber | |
| field="replica_count" | |
| label={t('副本数量')} | |
| placeholder={1} | |
| min={1} | |
| max={maxAvailableReplicas || 100} | |
| rules={[{ required: true, message: t('请输入副本数量') }]} | |
| onChange={(value) => setReplicaCount(value)} | |
| style={{ width: '100%' }} | |
| /> | |
| {maxAvailableReplicas > 0 && ( | |
| <Text size="small" type="tertiary"> | |
| {t('最大可用')}: {maxAvailableReplicas} | |
| </Text> | |
| )} | |
| </Col> | |
| <Col xs={24} md={8}> | |
| <Form.InputNumber | |
| field="duration_hours" | |
| label={t('运行时长(小时)')} | |
| placeholder={1} | |
| min={1} | |
| max={8760} // 1 year | |
| rules={[{ required: true, message: t('请输入运行时长') }]} | |
| onChange={(value) => setDurationHours(value)} | |
| style={{ width: '100%' }} | |
| /> | |
| </Col> | |
| <Col xs={24} md={8}> | |
| <Form.InputNumber | |
| field="traffic_port" | |
| label={ | |
| <Space> | |
| {t('流量端口')} | |
| <Tooltip content={t('容器对外服务的端口号,可选')}> | |
| <IconHelpCircle /> | |
| </Tooltip> | |
| </Space> | |
| } | |
| placeholder={DEFAULT_TRAFFIC_PORT} | |
| min={1} | |
| max={65535} | |
| style={{ width: '100%' }} | |
| disabled={imageMode === 'builtin'} | |
| /> | |
| </Col> | |
| </Row> | |
| <div ref={advancedSectionRef}> | |
| <Collapse className="mt-4"> | |
| <Collapse.Panel header={t('高级配置')} itemKey="advanced"> | |
| <Card> | |
| <Title heading={6}>{t('镜像仓库配置')}</Title> | |
| <Row gutter={16}> | |
| <Col span={12}> | |
| <Form.Input | |
| field="registry_username" | |
| label={t('镜像仓库用户名')} | |
| placeholder={t('私有镜像仓库的用户名')} | |
| /> | |
| </Col> | |
| <Col span={12}> | |
| <Form.Input | |
| field="registry_secret" | |
| label={t('镜像仓库密码')} | |
| type="password" | |
| placeholder={t('私有镜像仓库的密码')} | |
| /> | |
| </Col> | |
| </Row> | |
| </Card> | |
| <Divider /> | |
| <Card> | |
| <Title heading={6}>{t('容器启动配置')}</Title> | |
| <div style={{ marginBottom: 16 }}> | |
| <Text strong>{t('启动命令 (Entrypoint)')}</Text> | |
| {entrypoint.map((cmd, index) => ( | |
| <div key={index} style={{ display: 'flex', marginTop: 8 }}> | |
| <Input | |
| value={cmd} | |
| placeholder={t('例如:/bin/bash')} | |
| onChange={(value) => handleArrayFieldChange(index, value, 'entrypoint')} | |
| style={{ flex: 1, marginRight: 8 }} | |
| /> | |
| <Button | |
| icon={<IconMinus />} | |
| onClick={() => handleRemoveArrayField(index, 'entrypoint')} | |
| disabled={entrypoint.length === 1} | |
| /> | |
| </div> | |
| ))} | |
| <Button | |
| icon={<IconPlus />} | |
| onClick={() => handleAddArrayField('entrypoint')} | |
| style={{ marginTop: 8 }} | |
| > | |
| {t('添加启动命令')} | |
| </Button> | |
| </div> | |
| <div style={{ marginBottom: 16 }}> | |
| <Text strong>{t('启动参数 (Args)')}</Text> | |
| {args.map((arg, index) => ( | |
| <div key={index} style={{ display: 'flex', marginTop: 8 }}> | |
| <Input | |
| value={arg} | |
| placeholder={t('例如:-c')} | |
| onChange={(value) => handleArrayFieldChange(index, value, 'args')} | |
| style={{ flex: 1, marginRight: 8 }} | |
| /> | |
| <Button | |
| icon={<IconMinus />} | |
| onClick={() => handleRemoveArrayField(index, 'args')} | |
| disabled={args.length === 1} | |
| /> | |
| </div> | |
| ))} | |
| <Button | |
| icon={<IconPlus />} | |
| onClick={() => handleAddArrayField('args')} | |
| style={{ marginTop: 8 }} | |
| > | |
| {t('添加启动参数')} | |
| </Button> | |
| </div> | |
| </Card> | |
| <Divider /> | |
| <Card> | |
| <Title heading={6}>{t('环境变量')}</Title> | |
| <div style={{ marginBottom: 16 }}> | |
| <Text strong>{t('普通环境变量')}</Text> | |
| {envVariables.map((env, index) => ( | |
| <Row key={index} gutter={8} style={{ marginTop: 8 }}> | |
| <Col span={10}> | |
| <Input | |
| placeholder={t('变量名')} | |
| value={env.key} | |
| onChange={(value) => handleEnvVariableChange(index, 'key', value, 'env')} | |
| /> | |
| </Col> | |
| <Col span={10}> | |
| <Input | |
| placeholder={t('变量值')} | |
| value={env.value} | |
| onChange={(value) => handleEnvVariableChange(index, 'value', value, 'env')} | |
| /> | |
| </Col> | |
| <Col span={4}> | |
| <Button | |
| icon={<IconMinus />} | |
| onClick={() => handleRemoveEnvVariable(index, 'env')} | |
| disabled={envVariables.length === 1} | |
| /> | |
| </Col> | |
| </Row> | |
| ))} | |
| <Button | |
| icon={<IconPlus />} | |
| onClick={() => handleAddEnvVariable('env')} | |
| style={{ marginTop: 8 }} | |
| > | |
| {t('添加环境变量')} | |
| </Button> | |
| </div> | |
| <div> | |
| <Text strong>{t('密钥环境变量')}</Text> | |
| {secretEnvVariables.map((env, index) => { | |
| const isAutoSecret = | |
| imageMode === 'builtin' && env.key === 'OLLAMA_API_KEY'; | |
| return ( | |
| <Row key={index} gutter={8} style={{ marginTop: 8 }}> | |
| <Col span={10}> | |
| <Input | |
| placeholder={t('变量名')} | |
| value={env.key} | |
| onChange={(value) => handleEnvVariableChange(index, 'key', value, 'secret')} | |
| disabled={isAutoSecret} | |
| /> | |
| </Col> | |
| <Col span={10}> | |
| <Input | |
| placeholder={t('变量值')} | |
| type="password" | |
| value={env.value} | |
| onChange={(value) => handleEnvVariableChange(index, 'value', value, 'secret')} | |
| disabled={isAutoSecret} | |
| /> | |
| </Col> | |
| <Col span={4}> | |
| <Button | |
| icon={<IconMinus />} | |
| onClick={() => handleRemoveEnvVariable(index, 'secret')} | |
| disabled={secretEnvVariables.length === 1 || isAutoSecret} | |
| /> | |
| </Col> | |
| </Row> | |
| ); | |
| })} | |
| <Button | |
| icon={<IconPlus />} | |
| onClick={() => handleAddEnvVariable('secret')} | |
| style={{ marginTop: 8 }} | |
| > | |
| {t('添加密钥环境变量')} | |
| </Button> | |
| </div> | |
| </Card> | |
| </Collapse.Panel> | |
| </Collapse> | |
| </div> | |
| </Card> | |
| </div> | |
| <div ref={priceSectionRef}> | |
| <Card className="mb-4"> | |
| <div className="flex flex-wrap items-center justify-between gap-3"> | |
| <Title heading={6} style={{ margin: 0 }}> | |
| {t('价格预估')} | |
| </Title> | |
| <Space align="center" spacing={12} className="flex flex-wrap"> | |
| <Text type="secondary" size="small"> | |
| {t('计价币种')} | |
| </Text> | |
| <RadioGroup | |
| type="button" | |
| value={priceCurrency} | |
| onChange={handleCurrencyChange} | |
| > | |
| <Radio value="usdc">USDC</Radio> | |
| <Radio value="iocoin">IOCOIN</Radio> | |
| </RadioGroup> | |
| <Tag size="small" color="blue"> | |
| {currencyLabel} | |
| </Tag> | |
| </Space> | |
| </div> | |
| {priceEstimation ? ( | |
| <div className="mt-4 flex w-full flex-col gap-4"> | |
| <div className="grid w-full gap-4 md:grid-cols-2 lg:grid-cols-3"> | |
| <div | |
| className="flex flex-col gap-1 rounded-md px-4 py-3" | |
| style={{ | |
| border: '1px solid var(--semi-color-border)', | |
| backgroundColor: 'var(--semi-color-fill-0)', | |
| }} | |
| > | |
| <Text size="small" type="tertiary"> | |
| {t('预估总费用')} | |
| </Text> | |
| <div | |
| style={{ | |
| fontSize: 24, | |
| fontWeight: 600, | |
| color: 'var(--semi-color-text-0)', | |
| }} | |
| > | |
| {typeof priceEstimation.estimated_cost === 'number' | |
| ? `${priceEstimation.estimated_cost.toFixed(4)} ${currencyLabel}` | |
| : '--'} | |
| </div> | |
| </div> | |
| <div | |
| className="flex flex-col gap-1 rounded-md px-4 py-3" | |
| style={{ | |
| border: '1px solid var(--semi-color-border)', | |
| backgroundColor: 'var(--semi-color-fill-0)', | |
| }} | |
| > | |
| <Text size="small" type="tertiary"> | |
| {t('小时费率')} | |
| </Text> | |
| <Text strong> | |
| {typeof priceEstimation.price_breakdown?.hourly_rate === 'number' | |
| ? `${priceEstimation.price_breakdown.hourly_rate.toFixed(4)} ${currencyLabel}/h` | |
| : '--'} | |
| </Text> | |
| </div> | |
| <div | |
| className="flex flex-col gap-1 rounded-md px-4 py-3" | |
| style={{ | |
| border: '1px solid var(--semi-color-border)', | |
| backgroundColor: 'var(--semi-color-fill-0)', | |
| }} | |
| > | |
| <Text size="small" type="tertiary"> | |
| {t('计算成本')} | |
| </Text> | |
| <Text strong> | |
| {typeof priceEstimation.price_breakdown?.compute_cost === 'number' | |
| ? `${priceEstimation.price_breakdown.compute_cost.toFixed(4)} ${currencyLabel}` | |
| : '--'} | |
| </Text> | |
| </div> | |
| </div> | |
| <div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3"> | |
| {priceSummaryItems.map((item) => ( | |
| <div | |
| key={item.key} | |
| className="flex items-center justify-between gap-3 rounded-md px-3 py-2" | |
| style={{ | |
| border: '1px solid var(--semi-color-border)', | |
| backgroundColor: 'var(--semi-color-fill-0)', | |
| }} | |
| > | |
| <Text size="small" type="tertiary"> | |
| {item.label} | |
| </Text> | |
| <Text strong>{item.value}</Text> | |
| </div> | |
| ))} | |
| </div> | |
| </div> | |
| ) : ( | |
| priceUnavailableContent | |
| )} | |
| {priceEstimation && loadingPrice && ( | |
| <Space align="center" spacing={8} style={{ marginTop: 12 }}> | |
| <Spin size="small" /> | |
| <Text size="small" type="tertiary"> | |
| {t('价格重新计算中...')} | |
| </Text> | |
| </Space> | |
| )} | |
| </Card> | |
| </div> | |
| </Form> | |
| </Modal> | |
| ); | |
| }; | |
| export default CreateDeploymentModal; | |