vaibhavg
Add application file
8c7ba7b
/*
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;