|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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; |
|
|
|