ExtendDurationModal.jsx 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548
  1. /*
  2. Copyright (C) 2025 QuantumNous
  3. This program is free software: you can redistribute it and/or modify
  4. it under the terms of the GNU Affero General Public License as
  5. published by the Free Software Foundation, either version 3 of the
  6. License, or (at your option) any later version.
  7. This program is distributed in the hope that it will be useful,
  8. but WITHOUT ANY WARRANTY; without even the implied warranty of
  9. MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
  10. GNU Affero General Public License for more details.
  11. You should have received a copy of the GNU Affero General Public License
  12. along with this program. If not, see <https://www.gnu.org/licenses/>.
  13. For commercial licensing, please contact support@quantumnous.com
  14. */
  15. import React, { useEffect, useRef, useState } from 'react';
  16. import {
  17. Modal,
  18. Form,
  19. InputNumber,
  20. Typography,
  21. Card,
  22. Space,
  23. Divider,
  24. Button,
  25. Tag,
  26. Banner,
  27. Spin,
  28. } from '@douyinfe/semi-ui';
  29. import {
  30. FaClock,
  31. FaCalculator,
  32. FaInfoCircle,
  33. FaExclamationTriangle,
  34. } from 'react-icons/fa';
  35. import { API, showError, showSuccess } from '../../../../helpers';
  36. const { Text } = Typography;
  37. const ExtendDurationModal = ({
  38. visible,
  39. onCancel,
  40. deployment,
  41. onSuccess,
  42. t,
  43. }) => {
  44. const formRef = useRef(null);
  45. const [loading, setLoading] = useState(false);
  46. const [durationHours, setDurationHours] = useState(1);
  47. const [costLoading, setCostLoading] = useState(false);
  48. const [priceEstimation, setPriceEstimation] = useState(null);
  49. const [priceError, setPriceError] = useState(null);
  50. const [detailsLoading, setDetailsLoading] = useState(false);
  51. const [deploymentDetails, setDeploymentDetails] = useState(null);
  52. const costRequestIdRef = useRef(0);
  53. const resetState = () => {
  54. costRequestIdRef.current += 1;
  55. setDurationHours(1);
  56. setPriceEstimation(null);
  57. setPriceError(null);
  58. setDeploymentDetails(null);
  59. setCostLoading(false);
  60. };
  61. const fetchDeploymentDetails = async (deploymentId) => {
  62. setDetailsLoading(true);
  63. try {
  64. const response = await API.get(`/api/deployments/${deploymentId}`);
  65. if (response.data.success) {
  66. const details = response.data.data;
  67. setDeploymentDetails(details);
  68. setPriceError(null);
  69. return details;
  70. }
  71. const message = response.data.message || '';
  72. const errorMessage = t('获取详情失败') + (message ? `: ${message}` : '');
  73. showError(errorMessage);
  74. setDeploymentDetails(null);
  75. setPriceEstimation(null);
  76. setPriceError(errorMessage);
  77. return null;
  78. } catch (error) {
  79. const message = error?.response?.data?.message || error.message || '';
  80. const errorMessage = t('获取详情失败') + (message ? `: ${message}` : '');
  81. showError(errorMessage);
  82. setDeploymentDetails(null);
  83. setPriceEstimation(null);
  84. setPriceError(errorMessage);
  85. return null;
  86. } finally {
  87. setDetailsLoading(false);
  88. }
  89. };
  90. const calculatePrice = async (hours, details) => {
  91. if (!visible || !details) {
  92. return;
  93. }
  94. const sanitizedHours = Number.isFinite(hours) ? Math.round(hours) : 0;
  95. if (sanitizedHours <= 0) {
  96. setPriceEstimation(null);
  97. setPriceError(null);
  98. return;
  99. }
  100. const hardwareId = Number(details?.hardware_id) || 0;
  101. const totalGPUs = Number(details?.total_gpus) || 0;
  102. const totalContainers = Number(details?.total_containers) || 0;
  103. const baseGpusPerContainer = Number(details?.gpus_per_container) || 0;
  104. const resolvedGpusPerContainer =
  105. baseGpusPerContainer > 0
  106. ? baseGpusPerContainer
  107. : totalContainers > 0 && totalGPUs > 0
  108. ? Math.max(1, Math.round(totalGPUs / totalContainers))
  109. : 0;
  110. const resolvedReplicaCount =
  111. totalContainers > 0
  112. ? totalContainers
  113. : resolvedGpusPerContainer > 0 && totalGPUs > 0
  114. ? Math.max(1, Math.round(totalGPUs / resolvedGpusPerContainer))
  115. : 0;
  116. const locationIds = Array.isArray(details?.locations)
  117. ? details.locations
  118. .map((location) =>
  119. Number(
  120. location?.id ??
  121. location?.location_id ??
  122. location?.locationId,
  123. ),
  124. )
  125. .filter((id) => Number.isInteger(id) && id > 0)
  126. : [];
  127. if (
  128. hardwareId <= 0 ||
  129. resolvedGpusPerContainer <= 0 ||
  130. resolvedReplicaCount <= 0 ||
  131. locationIds.length === 0
  132. ) {
  133. setPriceEstimation(null);
  134. setPriceError(t('价格计算失败'));
  135. return;
  136. }
  137. const requestId = Date.now();
  138. costRequestIdRef.current = requestId;
  139. setCostLoading(true);
  140. setPriceError(null);
  141. const payload = {
  142. location_ids: locationIds,
  143. hardware_id: hardwareId,
  144. gpus_per_container: resolvedGpusPerContainer,
  145. duration_hours: sanitizedHours,
  146. replica_count: resolvedReplicaCount,
  147. currency: 'usdc',
  148. duration_type: 'hour',
  149. duration_qty: sanitizedHours,
  150. hardware_qty: resolvedGpusPerContainer,
  151. };
  152. try {
  153. const response = await API.post(
  154. '/api/deployments/price-estimation',
  155. payload,
  156. );
  157. if (costRequestIdRef.current !== requestId) {
  158. return;
  159. }
  160. if (response.data.success) {
  161. setPriceEstimation(response.data.data);
  162. } else {
  163. const message = response.data.message || '';
  164. setPriceEstimation(null);
  165. setPriceError(
  166. t('价格计算失败') + (message ? `: ${message}` : ''),
  167. );
  168. }
  169. } catch (error) {
  170. if (costRequestIdRef.current !== requestId) {
  171. return;
  172. }
  173. const message = error?.response?.data?.message || error.message || '';
  174. setPriceEstimation(null);
  175. setPriceError(
  176. t('价格计算失败') + (message ? `: ${message}` : ''),
  177. );
  178. } finally {
  179. if (costRequestIdRef.current === requestId) {
  180. setCostLoading(false);
  181. }
  182. }
  183. };
  184. useEffect(() => {
  185. if (visible && deployment?.id) {
  186. resetState();
  187. if (formRef.current) {
  188. formRef.current.setValue('duration_hours', 1);
  189. }
  190. fetchDeploymentDetails(deployment.id);
  191. }
  192. if (!visible) {
  193. resetState();
  194. }
  195. // eslint-disable-next-line react-hooks/exhaustive-deps
  196. }, [visible, deployment?.id]);
  197. useEffect(() => {
  198. if (!visible) {
  199. return;
  200. }
  201. if (!deploymentDetails) {
  202. return;
  203. }
  204. calculatePrice(durationHours, deploymentDetails);
  205. // eslint-disable-next-line react-hooks/exhaustive-deps
  206. }, [durationHours, deploymentDetails, visible]);
  207. const handleExtend = async () => {
  208. try {
  209. if (formRef.current) {
  210. await formRef.current.validate();
  211. }
  212. setLoading(true);
  213. const response = await API.post(
  214. `/api/deployments/${deployment.id}/extend`,
  215. {
  216. duration_hours: Math.round(durationHours),
  217. },
  218. );
  219. if (response.data.success) {
  220. showSuccess(t('容器时长延长成功'));
  221. onSuccess?.(response.data.data);
  222. handleCancel();
  223. }
  224. } catch (error) {
  225. showError(
  226. t('延长时长失败') +
  227. ': ' +
  228. (error?.response?.data?.message || error.message),
  229. );
  230. } finally {
  231. setLoading(false);
  232. }
  233. };
  234. const handleCancel = () => {
  235. if (formRef.current) {
  236. formRef.current.reset();
  237. }
  238. resetState();
  239. onCancel();
  240. };
  241. const currentRemainingTime = deployment?.time_remaining || '0分钟';
  242. const newTotalTime = `${currentRemainingTime} + ${durationHours}${t('小时')}`;
  243. const priceData = priceEstimation || {};
  244. const breakdown =
  245. priceData.price_breakdown || priceData.PriceBreakdown || {};
  246. const currencyLabel = (
  247. priceData.currency || priceData.Currency || 'USDC'
  248. )
  249. .toString()
  250. .toUpperCase();
  251. const estimatedTotalCost =
  252. typeof priceData.estimated_cost === 'number'
  253. ? priceData.estimated_cost
  254. : typeof priceData.EstimatedCost === 'number'
  255. ? priceData.EstimatedCost
  256. : typeof breakdown.total_cost === 'number'
  257. ? breakdown.total_cost
  258. : breakdown.TotalCost;
  259. const hourlyRate =
  260. typeof breakdown.hourly_rate === 'number'
  261. ? breakdown.hourly_rate
  262. : breakdown.HourlyRate;
  263. const computeCost =
  264. typeof breakdown.compute_cost === 'number'
  265. ? breakdown.compute_cost
  266. : breakdown.ComputeCost;
  267. const resolvedHardwareName =
  268. deploymentDetails?.hardware_name || deployment?.hardware_name || '--';
  269. const gpuCount =
  270. deploymentDetails?.total_gpus || deployment?.hardware_quantity || 0;
  271. const containers = deploymentDetails?.total_containers || 0;
  272. return (
  273. <Modal
  274. title={
  275. <div className='flex items-center gap-2'>
  276. <FaClock className='text-blue-500' />
  277. <span>{t('延长容器时长')}</span>
  278. </div>
  279. }
  280. visible={visible}
  281. onCancel={handleCancel}
  282. onOk={handleExtend}
  283. okText={t('确认延长')}
  284. cancelText={t('取消')}
  285. confirmLoading={loading}
  286. okButtonProps={{
  287. disabled:
  288. !deployment?.id || detailsLoading || !durationHours || durationHours < 1,
  289. }}
  290. width={600}
  291. className='extend-duration-modal'
  292. >
  293. <div className='space-y-4'>
  294. <Card className='border-0 bg-gray-50'>
  295. <div className='flex items-center justify-between'>
  296. <div>
  297. <Text strong className='text-base'>
  298. {deployment?.container_name || deployment?.deployment_name}
  299. </Text>
  300. <div className='mt-1'>
  301. <Text type='secondary' size='small'>
  302. ID: {deployment?.id}
  303. </Text>
  304. </div>
  305. </div>
  306. <div className='text-right'>
  307. <div className='flex items-center gap-2 mb-1'>
  308. <Tag color='blue' size='small'>
  309. {resolvedHardwareName}
  310. {gpuCount ? ` x${gpuCount}` : ''}
  311. </Tag>
  312. </div>
  313. <Text size='small' type='secondary'>
  314. {t('当前剩余')}: <Text strong>{currentRemainingTime}</Text>
  315. </Text>
  316. </div>
  317. </div>
  318. </Card>
  319. <Banner
  320. type='warning'
  321. icon={<FaExclamationTriangle />}
  322. title={t('重要提醒')}
  323. description={
  324. <div className='space-y-2'>
  325. <p>
  326. {t('延长容器时长将会产生额外费用,请确认您有足够的账户余额。')}
  327. </p>
  328. <p>
  329. {t('延长操作一旦确认无法撤销,费用将立即扣除。')}
  330. </p>
  331. </div>
  332. }
  333. />
  334. <Form
  335. getFormApi={(api) => (formRef.current = api)}
  336. layout='vertical'
  337. onValueChange={(values) => {
  338. if (values.duration_hours !== undefined) {
  339. const numericValue = Number(values.duration_hours);
  340. setDurationHours(Number.isFinite(numericValue) ? numericValue : 0);
  341. }
  342. }}
  343. >
  344. <Form.InputNumber
  345. field='duration_hours'
  346. label={t('延长时长(小时)')}
  347. placeholder={t('请输入要延长的小时数')}
  348. min={1}
  349. max={720}
  350. step={1}
  351. initValue={1}
  352. style={{ width: '100%' }}
  353. suffix={t('小时')}
  354. rules={[
  355. { required: true, message: t('请输入延长时长') },
  356. {
  357. type: 'number',
  358. min: 1,
  359. message: t('延长时长至少为1小时'),
  360. },
  361. {
  362. type: 'number',
  363. max: 720,
  364. message: t('延长时长不能超过720小时(30天)'),
  365. },
  366. ]}
  367. />
  368. </Form>
  369. <div className='space-y-2'>
  370. <Text size='small' type='secondary'>
  371. {t('快速选择')}:
  372. </Text>
  373. <Space wrap>
  374. {[1, 2, 6, 12, 24, 48, 72, 168].map((hours) => (
  375. <Button
  376. key={hours}
  377. size='small'
  378. theme={durationHours === hours ? 'solid' : 'borderless'}
  379. type={durationHours === hours ? 'primary' : 'secondary'}
  380. onClick={() => {
  381. setDurationHours(hours);
  382. if (formRef.current) {
  383. formRef.current.setValue('duration_hours', hours);
  384. }
  385. }}
  386. >
  387. {hours < 24
  388. ? `${hours}${t('小时')}`
  389. : `${hours / 24}${t('天')}`}
  390. </Button>
  391. ))}
  392. </Space>
  393. </div>
  394. <Divider />
  395. <Card
  396. title={
  397. <div className='flex items-center gap-2'>
  398. <FaCalculator className='text-green-500' />
  399. <span>{t('费用预估')}</span>
  400. </div>
  401. }
  402. className='border border-green-200'
  403. >
  404. {priceEstimation ? (
  405. <div className='space-y-3'>
  406. <div className='flex items-center justify-between'>
  407. <Text>{t('延长时长')}:</Text>
  408. <Text strong>
  409. {Math.round(durationHours)} {t('小时')}
  410. </Text>
  411. </div>
  412. <div className='flex items-center justify-between'>
  413. <Text>{t('硬件配置')}:</Text>
  414. <Text strong>
  415. {resolvedHardwareName}
  416. {gpuCount ? ` x${gpuCount}` : ''}
  417. </Text>
  418. </div>
  419. {containers ? (
  420. <div className='flex items-center justify-between'>
  421. <Text>{t('容器数量')}:</Text>
  422. <Text strong>{containers}</Text>
  423. </div>
  424. ) : null}
  425. <div className='flex items-center justify-between'>
  426. <Text>{t('单GPU小时费率')}:</Text>
  427. <Text strong>
  428. {typeof hourlyRate === 'number'
  429. ? `${hourlyRate.toFixed(4)} ${currencyLabel}`
  430. : '--'}
  431. </Text>
  432. </div>
  433. {typeof computeCost === 'number' && (
  434. <div className='flex items-center justify-between'>
  435. <Text>{t('计算成本')}:</Text>
  436. <Text strong>
  437. {computeCost.toFixed(4)} {currencyLabel}
  438. </Text>
  439. </div>
  440. )}
  441. <Divider margin='12px' />
  442. <div className='flex items-center justify-between'>
  443. <Text strong className='text-lg'>
  444. {t('预估总费用')}:
  445. </Text>
  446. <Text strong className='text-lg text-green-600'>
  447. {typeof estimatedTotalCost === 'number'
  448. ? `${estimatedTotalCost.toFixed(4)} ${currencyLabel}`
  449. : '--'}
  450. </Text>
  451. </div>
  452. <div className='bg-blue-50 p-3 rounded-lg'>
  453. <div className='flex items-start gap-2'>
  454. <FaInfoCircle className='text-blue-500 mt-0.5' />
  455. <div>
  456. <Text size='small' type='secondary'>
  457. {t('延长后总时长')}: <Text strong>{newTotalTime}</Text>
  458. </Text>
  459. <br />
  460. <Text size='small' type='secondary'>
  461. {t('预估费用仅供参考,实际费用可能略有差异')}
  462. </Text>
  463. </div>
  464. </div>
  465. </div>
  466. </div>
  467. ) : (
  468. <div className='text-center text-gray-500 py-4'>
  469. {costLoading ? (
  470. <Space align='center' className='justify-center'>
  471. <Spin size='small' />
  472. <Text type='secondary'>{t('计算费用中...')}</Text>
  473. </Space>
  474. ) : priceError ? (
  475. <Text type='danger'>{priceError}</Text>
  476. ) : deploymentDetails ? (
  477. <Text type='secondary'>{t('请输入延长时长')}</Text>
  478. ) : (
  479. <Text type='secondary'>{t('加载详情中...')}</Text>
  480. )}
  481. </div>
  482. )}
  483. </Card>
  484. <div className='bg-red-50 border border-red-200 rounded-lg p-3'>
  485. <div className='flex items-start gap-2'>
  486. <FaExclamationTriangle className='text-red-500 mt-0.5' />
  487. <div>
  488. <Text strong className='text-red-700'>
  489. {t('确认延长容器时长')}
  490. </Text>
  491. <div className='mt-1'>
  492. <Text size='small' className='text-red-600'>
  493. {t('点击"确认延长"后将立即扣除费用并延长容器运行时间')}
  494. </Text>
  495. </div>
  496. </div>
  497. </div>
  498. </div>
  499. </div>
  500. </Modal>
  501. );
  502. };
  503. export default ExtendDurationModal;