|
|
@@ -0,0 +1,1462 @@
|
|
|
+/*
|
|
|
+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 [email protected]
|
|
|
+*/
|
|
|
+
|
|
|
+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;
|