/*
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 .
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 = (
{loadingPrice ? (
{t('价格计算中...')}
) : (
{isPriceReady
? t('价格暂时不可用,请稍后重试')
: t('完成硬件类型、部署位置、副本数量等配置后,将自动计算价格')}
)}
);
useEffect(() => {
if (!visible || !formApi) {
return;
}
if (maxAvailableReplicas > 0 && replicaCount > maxAvailableReplicas) {
setReplicaCount(maxAvailableReplicas);
formApi.setValue('replica_count', maxAvailableReplicas);
}
}, [maxAvailableReplicas, replicaCount, visible, formApi]);
return (
formApi?.submitForm()}
okText={t('创建')}
cancelText={t('取消')}
width={800}
confirmLoading={submitting}
style={{ top: 20 }}
>
);
};
export default CreateDeploymentModal;