|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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) { |
|
|
|
|
|
} |
|
|
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); |
|
|
|
|
|
|
|
|
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); |
|
|
|
|
|
|
|
|
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]); |
|
|
|
|
|
|
|
|
const [selectedHardwareId, setSelectedHardwareId] = useState(null); |
|
|
const [selectedLocationIds, setSelectedLocationIds] = useState([]); |
|
|
const [gpusPerContainer, setGpusPerContainer] = useState(1); |
|
|
const [durationHours, setDurationHours] = useState(1); |
|
|
const [replicaCount, setReplicaCount] = useState(1); |
|
|
|
|
|
|
|
|
useEffect(() => { |
|
|
if (visible) { |
|
|
loadHardwareTypes(); |
|
|
resetFormState(); |
|
|
} |
|
|
}, [visible]); |
|
|
|
|
|
|
|
|
useEffect(() => { |
|
|
if (!visible) { |
|
|
return; |
|
|
} |
|
|
if (selectedHardwareId && gpusPerContainer > 0) { |
|
|
loadAvailableReplicas(selectedHardwareId, gpusPerContainer); |
|
|
} |
|
|
}, [selectedHardwareId, gpusPerContainer, visible]); |
|
|
|
|
|
|
|
|
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); |
|
|
|
|
|
|
|
|
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); |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
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; |
|
|
|