| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
|
|
| import React, { useEffect, useRef, useState } from 'react'; |
| import { |
| Modal, |
| Form, |
| InputNumber, |
| Typography, |
| Card, |
| Space, |
| Divider, |
| Button, |
| Tag, |
| Banner, |
| Spin, |
| } from '@douyinfe/semi-ui'; |
| import { |
| FaClock, |
| FaCalculator, |
| FaInfoCircle, |
| FaExclamationTriangle, |
| } from 'react-icons/fa'; |
| import { API, showError, showSuccess } from '../../../../helpers'; |
|
|
| const { Text } = Typography; |
|
|
| const ExtendDurationModal = ({ |
| visible, |
| onCancel, |
| deployment, |
| onSuccess, |
| t, |
| }) => { |
| const formRef = useRef(null); |
| const [loading, setLoading] = useState(false); |
| const [durationHours, setDurationHours] = useState(1); |
| const [costLoading, setCostLoading] = useState(false); |
| const [priceEstimation, setPriceEstimation] = useState(null); |
| const [priceError, setPriceError] = useState(null); |
| const [detailsLoading, setDetailsLoading] = useState(false); |
| const [deploymentDetails, setDeploymentDetails] = useState(null); |
| const costRequestIdRef = useRef(0); |
|
|
| const resetState = () => { |
| costRequestIdRef.current += 1; |
| setDurationHours(1); |
| setPriceEstimation(null); |
| setPriceError(null); |
| setDeploymentDetails(null); |
| setCostLoading(false); |
| }; |
|
|
| const fetchDeploymentDetails = async (deploymentId) => { |
| setDetailsLoading(true); |
| try { |
| const response = await API.get(`/api/deployments/${deploymentId}`); |
| if (response.data.success) { |
| const details = response.data.data; |
| setDeploymentDetails(details); |
| setPriceError(null); |
| return details; |
| } |
|
|
| const message = response.data.message || ''; |
| const errorMessage = t('获取详情失败') + (message ? `: ${message}` : ''); |
| showError(errorMessage); |
| setDeploymentDetails(null); |
| setPriceEstimation(null); |
| setPriceError(errorMessage); |
| return null; |
| } catch (error) { |
| const message = error?.response?.data?.message || error.message || ''; |
| const errorMessage = t('获取详情失败') + (message ? `: ${message}` : ''); |
| showError(errorMessage); |
| setDeploymentDetails(null); |
| setPriceEstimation(null); |
| setPriceError(errorMessage); |
| return null; |
| } finally { |
| setDetailsLoading(false); |
| } |
| }; |
|
|
| const calculatePrice = async (hours, details) => { |
| if (!visible || !details) { |
| return; |
| } |
|
|
| const sanitizedHours = Number.isFinite(hours) ? Math.round(hours) : 0; |
| if (sanitizedHours <= 0) { |
| setPriceEstimation(null); |
| setPriceError(null); |
| return; |
| } |
|
|
| const hardwareId = Number(details?.hardware_id) || 0; |
| const totalGPUs = Number(details?.total_gpus) || 0; |
| const totalContainers = Number(details?.total_containers) || 0; |
| const baseGpusPerContainer = Number(details?.gpus_per_container) || 0; |
| const resolvedGpusPerContainer = |
| baseGpusPerContainer > 0 |
| ? baseGpusPerContainer |
| : totalContainers > 0 && totalGPUs > 0 |
| ? Math.max(1, Math.round(totalGPUs / totalContainers)) |
| : 0; |
| const resolvedReplicaCount = |
| totalContainers > 0 |
| ? totalContainers |
| : resolvedGpusPerContainer > 0 && totalGPUs > 0 |
| ? Math.max(1, Math.round(totalGPUs / resolvedGpusPerContainer)) |
| : 0; |
| const locationIds = Array.isArray(details?.locations) |
| ? details.locations |
| .map((location) => |
| Number( |
| location?.id ?? location?.location_id ?? location?.locationId, |
| ), |
| ) |
| .filter((id) => Number.isInteger(id) && id > 0) |
| : []; |
|
|
| if ( |
| hardwareId <= 0 || |
| resolvedGpusPerContainer <= 0 || |
| resolvedReplicaCount <= 0 || |
| locationIds.length === 0 |
| ) { |
| setPriceEstimation(null); |
| setPriceError(t('价格计算失败')); |
| return; |
| } |
|
|
| const requestId = Date.now(); |
| costRequestIdRef.current = requestId; |
| setCostLoading(true); |
| setPriceError(null); |
|
|
| const payload = { |
| location_ids: locationIds, |
| hardware_id: hardwareId, |
| gpus_per_container: resolvedGpusPerContainer, |
| duration_hours: sanitizedHours, |
| replica_count: resolvedReplicaCount, |
| currency: 'usdc', |
| duration_type: 'hour', |
| duration_qty: sanitizedHours, |
| hardware_qty: resolvedGpusPerContainer, |
| }; |
|
|
| try { |
| const response = await API.post( |
| '/api/deployments/price-estimation', |
| payload, |
| ); |
|
|
| if (costRequestIdRef.current !== requestId) { |
| return; |
| } |
|
|
| if (response.data.success) { |
| setPriceEstimation(response.data.data); |
| } else { |
| const message = response.data.message || ''; |
| setPriceEstimation(null); |
| setPriceError(t('价格计算失败') + (message ? `: ${message}` : '')); |
| } |
| } catch (error) { |
| if (costRequestIdRef.current !== requestId) { |
| return; |
| } |
|
|
| const message = error?.response?.data?.message || error.message || ''; |
| setPriceEstimation(null); |
| setPriceError(t('价格计算失败') + (message ? `: ${message}` : '')); |
| } finally { |
| if (costRequestIdRef.current === requestId) { |
| setCostLoading(false); |
| } |
| } |
| }; |
|
|
| useEffect(() => { |
| if (visible && deployment?.id) { |
| resetState(); |
| if (formRef.current) { |
| formRef.current.setValue('duration_hours', 1); |
| } |
| fetchDeploymentDetails(deployment.id); |
| } |
| if (!visible) { |
| resetState(); |
| } |
| |
| }, [visible, deployment?.id]); |
|
|
| useEffect(() => { |
| if (!visible) { |
| return; |
| } |
| if (!deploymentDetails) { |
| return; |
| } |
| calculatePrice(durationHours, deploymentDetails); |
| |
| }, [durationHours, deploymentDetails, visible]); |
|
|
| const handleExtend = async () => { |
| try { |
| if (formRef.current) { |
| await formRef.current.validate(); |
| } |
| setLoading(true); |
|
|
| const response = await API.post( |
| `/api/deployments/${deployment.id}/extend`, |
| { |
| duration_hours: Math.round(durationHours), |
| }, |
| ); |
|
|
| if (response.data.success) { |
| showSuccess(t('容器时长延长成功')); |
| onSuccess?.(response.data.data); |
| handleCancel(); |
| } |
| } catch (error) { |
| showError( |
| t('延长时长失败') + |
| ': ' + |
| (error?.response?.data?.message || error.message), |
| ); |
| } finally { |
| setLoading(false); |
| } |
| }; |
|
|
| const handleCancel = () => { |
| if (formRef.current) { |
| formRef.current.reset(); |
| } |
| resetState(); |
| onCancel(); |
| }; |
|
|
| const currentRemainingTime = deployment?.time_remaining || '0分钟'; |
| const newTotalTime = `${currentRemainingTime} + ${durationHours}${t('小时')}`; |
|
|
| const priceData = priceEstimation || {}; |
| const breakdown = priceData.price_breakdown || priceData.PriceBreakdown || {}; |
| const currencyLabel = (priceData.currency || priceData.Currency || 'USDC') |
| .toString() |
| .toUpperCase(); |
|
|
| const estimatedTotalCost = |
| typeof priceData.estimated_cost === 'number' |
| ? priceData.estimated_cost |
| : typeof priceData.EstimatedCost === 'number' |
| ? priceData.EstimatedCost |
| : typeof breakdown.total_cost === 'number' |
| ? breakdown.total_cost |
| : breakdown.TotalCost; |
| const hourlyRate = |
| typeof breakdown.hourly_rate === 'number' |
| ? breakdown.hourly_rate |
| : breakdown.HourlyRate; |
| const computeCost = |
| typeof breakdown.compute_cost === 'number' |
| ? breakdown.compute_cost |
| : breakdown.ComputeCost; |
|
|
| const resolvedHardwareName = |
| deploymentDetails?.hardware_name || deployment?.hardware_name || '--'; |
| const gpuCount = |
| deploymentDetails?.total_gpus || deployment?.hardware_quantity || 0; |
| const containers = deploymentDetails?.total_containers || 0; |
|
|
| return ( |
| <Modal |
| title={ |
| <div className='flex items-center gap-2'> |
| <FaClock className='text-blue-500' /> |
| <span>{t('延长容器时长')}</span> |
| </div> |
| } |
| visible={visible} |
| onCancel={handleCancel} |
| onOk={handleExtend} |
| okText={t('确认延长')} |
| cancelText={t('取消')} |
| confirmLoading={loading} |
| okButtonProps={{ |
| disabled: |
| !deployment?.id || |
| detailsLoading || |
| !durationHours || |
| durationHours < 1, |
| }} |
| width={600} |
| className='extend-duration-modal' |
| > |
| <div className='space-y-4'> |
| <Card className='border-0 bg-gray-50'> |
| <div className='flex items-center justify-between'> |
| <div> |
| <Text strong className='text-base'> |
| {deployment?.container_name || deployment?.deployment_name} |
| </Text> |
| <div className='mt-1'> |
| <Text type='secondary' size='small'> |
| ID: {deployment?.id} |
| </Text> |
| </div> |
| </div> |
| <div className='text-right'> |
| <div className='flex items-center gap-2 mb-1'> |
| <Tag color='blue' size='small'> |
| {resolvedHardwareName} |
| {gpuCount ? ` x${gpuCount}` : ''} |
| </Tag> |
| </div> |
| <Text size='small' type='secondary'> |
| {t('当前剩余')}: <Text strong>{currentRemainingTime}</Text> |
| </Text> |
| </div> |
| </div> |
| </Card> |
| |
| <Banner |
| type='warning' |
| icon={<FaExclamationTriangle />} |
| title={t('重要提醒')} |
| description={ |
| <div className='space-y-2'> |
| <p> |
| {t('延长容器时长将会产生额外费用,请确认您有足够的账户余额。')} |
| </p> |
| <p>{t('延长操作一旦确认无法撤销,费用将立即扣除。')}</p> |
| </div> |
| } |
| /> |
| |
| <Form |
| getFormApi={(api) => (formRef.current = api)} |
| layout='vertical' |
| onValueChange={(values) => { |
| if (values.duration_hours !== undefined) { |
| const numericValue = Number(values.duration_hours); |
| setDurationHours( |
| Number.isFinite(numericValue) ? numericValue : 0, |
| ); |
| } |
| }} |
| > |
| <Form.InputNumber |
| field='duration_hours' |
| label={t('延长时长(小时)')} |
| placeholder={t('请输入要延长的小时数')} |
| min={1} |
| max={720} |
| step={1} |
| initValue={1} |
| style={{ width: '100%' }} |
| suffix={t('小时')} |
| rules={[ |
| { required: true, message: t('请输入延长时长') }, |
| { |
| type: 'number', |
| min: 1, |
| message: t('延长时长至少为1小时'), |
| }, |
| { |
| type: 'number', |
| max: 720, |
| message: t('延长时长不能超过720小时(30天)'), |
| }, |
| ]} |
| /> |
| </Form> |
| |
| <div className='space-y-2'> |
| <Text size='small' type='secondary'> |
| {t('快速选择')}: |
| </Text> |
| <Space wrap> |
| {[1, 2, 6, 12, 24, 48, 72, 168].map((hours) => ( |
| <Button |
| key={hours} |
| size='small' |
| theme={durationHours === hours ? 'solid' : 'borderless'} |
| type={durationHours === hours ? 'primary' : 'secondary'} |
| onClick={() => { |
| setDurationHours(hours); |
| if (formRef.current) { |
| formRef.current.setValue('duration_hours', hours); |
| } |
| }} |
| > |
| {hours < 24 |
| ? `${hours}${t('小时')}` |
| : `${hours / 24}${t('天')}`} |
| </Button> |
| ))} |
| </Space> |
| </div> |
| |
| <Divider /> |
| |
| <Card |
| title={ |
| <div className='flex items-center gap-2'> |
| <FaCalculator className='text-green-500' /> |
| <span>{t('费用预估')}</span> |
| </div> |
| } |
| className='border border-green-200' |
| > |
| {priceEstimation ? ( |
| <div className='space-y-3'> |
| <div className='flex items-center justify-between'> |
| <Text>{t('延长时长')}:</Text> |
| <Text strong> |
| {Math.round(durationHours)} {t('小时')} |
| </Text> |
| </div> |
| |
| <div className='flex items-center justify-between'> |
| <Text>{t('硬件配置')}:</Text> |
| <Text strong> |
| {resolvedHardwareName} |
| {gpuCount ? ` x${gpuCount}` : ''} |
| </Text> |
| </div> |
| |
| {containers ? ( |
| <div className='flex items-center justify-between'> |
| <Text>{t('容器数量')}:</Text> |
| <Text strong>{containers}</Text> |
| </div> |
| ) : null} |
| |
| <div className='flex items-center justify-between'> |
| <Text>{t('单GPU小时费率')}:</Text> |
| <Text strong> |
| {typeof hourlyRate === 'number' |
| ? `${hourlyRate.toFixed(4)} ${currencyLabel}` |
| : '--'} |
| </Text> |
| </div> |
| |
| {typeof computeCost === 'number' && ( |
| <div className='flex items-center justify-between'> |
| <Text>{t('计算成本')}:</Text> |
| <Text strong> |
| {computeCost.toFixed(4)} {currencyLabel} |
| </Text> |
| </div> |
| )} |
| |
| <Divider margin='12px' /> |
| |
| <div className='flex items-center justify-between'> |
| <Text strong className='text-lg'> |
| {t('预估总费用')}: |
| </Text> |
| <Text strong className='text-lg text-green-600'> |
| {typeof estimatedTotalCost === 'number' |
| ? `${estimatedTotalCost.toFixed(4)} ${currencyLabel}` |
| : '--'} |
| </Text> |
| </div> |
| |
| <div className='bg-blue-50 p-3 rounded-lg'> |
| <div className='flex items-start gap-2'> |
| <FaInfoCircle className='text-blue-500 mt-0.5' /> |
| <div> |
| <Text size='small' type='secondary'> |
| {t('延长后总时长')}: <Text strong>{newTotalTime}</Text> |
| </Text> |
| <br /> |
| <Text size='small' type='secondary'> |
| {t('预估费用仅供参考,实际费用可能略有差异')} |
| </Text> |
| </div> |
| </div> |
| </div> |
| </div> |
| ) : ( |
| <div className='text-center text-gray-500 py-4'> |
| {costLoading ? ( |
| <Space align='center' className='justify-center'> |
| <Spin size='small' /> |
| <Text type='secondary'>{t('计算费用中...')}</Text> |
| </Space> |
| ) : priceError ? ( |
| <Text type='danger'>{priceError}</Text> |
| ) : deploymentDetails ? ( |
| <Text type='secondary'>{t('请输入延长时长')}</Text> |
| ) : ( |
| <Text type='secondary'>{t('加载详情中...')}</Text> |
| )} |
| </div> |
| )} |
| </Card> |
| |
| <div className='bg-red-50 border border-red-200 rounded-lg p-3'> |
| <div className='flex items-start gap-2'> |
| <FaExclamationTriangle className='text-red-500 mt-0.5' /> |
| <div> |
| <Text strong className='text-red-700'> |
| {t('确认延长容器时长')} |
| </Text> |
| <div className='mt-1'> |
| <Text size='small' className='text-red-600'> |
| {t('点击"确认延长"后将立即扣除费用并延长容器运行时间')} |
| </Text> |
| </div> |
| </div> |
| </div> |
| </div> |
| </div> |
| </Modal> |
| ); |
| }; |
|
|
| export default ExtendDurationModal; |
|
|