| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548 |
- /*
- 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, { 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();
- }
- // eslint-disable-next-line react-hooks/exhaustive-deps
- }, [visible, deployment?.id]);
- useEffect(() => {
- if (!visible) {
- return;
- }
- if (!deploymentDetails) {
- return;
- }
- calculatePrice(durationHours, deploymentDetails);
- // eslint-disable-next-line react-hooks/exhaustive-deps
- }, [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;
|