/*
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 [priceEstimation, setPriceEstimation] = useState(null);
// UI states
const [loadingHardware, setLoadingHardware] = 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 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 getHardwareMaxGpus = (hardwareId) => {
if (!hardwareId) return 1;
const hardware = hardwareTypes.find((h) => h.id === hardwareId);
const maxGpus = Number(hardware?.max_gpus);
return Number.isFinite(maxGpus) && maxGpus > 0 ? maxGpus : 1;
};
// 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);
useEffect(() => {
if (!selectedHardwareId) {
return;
}
const nextMaxGpus = getHardwareMaxGpus(selectedHardwareId);
if (gpusPerContainer !== nextMaxGpus) {
setGpusPerContainer(nextMaxGpus);
}
if (formApi) {
formApi.setValue('gpus_per_container', nextMaxGpus);
}
}, [selectedHardwareId, hardwareTypes, formApi, gpusPerContainer]);
// 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) {
return;
} else {
setLocations([]);
setSelectedLocationIds([]);
setLocationTotalAvailable(null);
setLoadingReplicas(false);
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);
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 loadAvailableReplicas = async (hardwareId, gpuCount) => {
if (!hardwareId || !gpuCount) {
setLocations([]);
setLocationTotalAvailable(null);
setLoadingReplicas(false);
return;
}
const requestId = Date.now();
replicaRequestIdRef.current = requestId;
setLoadingReplicas(true);
setLocations([]);
setLocationTotalAvailable(null);
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 nextLocationsMap = new Map();
replicasList.forEach((replica) => {
const rawId = replica?.location_id ?? replica?.location?.id;
if (rawId === null || rawId === undefined) {
return;
}
const id = rawId;
const mapKey = String(rawId);
const existing = nextLocationsMap.get(mapKey) || null;
const rawIso2 =
replica?.iso2 ?? replica?.location_iso2 ?? replica?.location?.iso2;
const iso2 = rawIso2 ? String(rawIso2).toUpperCase() : '';
const name =
replica?.location_name ??
replica?.location?.name ??
replica?.name ??
id;
const available = Number(replica?.available_count) || 0;
if (existing) {
existing.available += available;
return;
}
nextLocationsMap.set(mapKey, {
id,
name: String(name),
iso2,
region:
replica?.region ??
replica?.location_region ??
replica?.location?.region,
country:
replica?.country ??
replica?.location_country ??
replica?.location?.country,
code:
replica?.code ??
replica?.location_code ??
replica?.location?.code,
available,
});
});
setLocations(Array.from(nextLocationsMap.values()));
setLocationTotalAvailable(
Array.from(nextLocationsMap.values()).reduce(
(total, location) => total + (location.available || 0),
0,
),
);
} else {
showError(t('获取可用资源失败: ') + response.data.message);
setLocationTotalAvailable(null);
}
} catch (error) {
if (replicaRequestIdRef.current === requestId) {
console.error('Load available replicas error:', error);
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: gpusPerContainer,
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 = locations
.filter((location) => (Number(location.available) || 0) > 0)
.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);
}
}
}, [locations, selectedHardwareId, selectedLocationIds, visible, formApi]);
const maxAvailableReplicas = useMemo(() => {
if (!selectedLocationIds.length) return 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);
}, [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;