| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
|
|
| import React, { useState, useEffect } from 'react'; |
| import { |
| Modal, |
| Typography, |
| Card, |
| Tag, |
| Progress, |
| Descriptions, |
| Spin, |
| Empty, |
| Button, |
| Badge, |
| Tooltip, |
| } from '@douyinfe/semi-ui'; |
| import { |
| FaInfoCircle, |
| FaServer, |
| FaClock, |
| FaMapMarkerAlt, |
| FaDocker, |
| FaMoneyBillWave, |
| FaChartLine, |
| FaCopy, |
| FaLink, |
| } from 'react-icons/fa'; |
| import { IconRefresh } from '@douyinfe/semi-icons'; |
| import { |
| API, |
| showError, |
| showSuccess, |
| timestamp2string, |
| } from '../../../../helpers'; |
|
|
| const { Text, Title } = Typography; |
|
|
| const ViewDetailsModal = ({ visible, onCancel, deployment, t }) => { |
| const [details, setDetails] = useState(null); |
| const [loading, setLoading] = useState(false); |
| const [containers, setContainers] = useState([]); |
| const [containersLoading, setContainersLoading] = useState(false); |
|
|
| const fetchDetails = async () => { |
| if (!deployment?.id) return; |
|
|
| setLoading(true); |
| try { |
| const response = await API.get(`/api/deployments/${deployment.id}`); |
| if (response.data.success) { |
| setDetails(response.data.data); |
| } |
| } catch (error) { |
| showError( |
| t('获取详情失败') + |
| ': ' + |
| (error.response?.data?.message || error.message), |
| ); |
| } finally { |
| setLoading(false); |
| } |
| }; |
|
|
| const fetchContainers = async () => { |
| if (!deployment?.id) return; |
|
|
| setContainersLoading(true); |
| try { |
| const response = await API.get( |
| `/api/deployments/${deployment.id}/containers`, |
| ); |
| if (response.data.success) { |
| setContainers(response.data.data?.containers || []); |
| } |
| } catch (error) { |
| showError( |
| t('获取容器信息失败') + |
| ': ' + |
| (error.response?.data?.message || error.message), |
| ); |
| } finally { |
| setContainersLoading(false); |
| } |
| }; |
|
|
| useEffect(() => { |
| if (visible && deployment?.id) { |
| fetchDetails(); |
| fetchContainers(); |
| } else if (!visible) { |
| setDetails(null); |
| setContainers([]); |
| } |
| }, [visible, deployment?.id]); |
|
|
| const handleCopyId = () => { |
| navigator.clipboard.writeText(deployment?.id); |
| showSuccess(t('已复制 ID 到剪贴板')); |
| }; |
|
|
| const handleRefresh = () => { |
| fetchDetails(); |
| fetchContainers(); |
| }; |
|
|
| const getStatusConfig = (status) => { |
| const statusConfig = { |
| running: { color: 'green', text: '运行中', icon: '🟢' }, |
| completed: { color: 'green', text: '已完成', icon: '✅' }, |
| 'deployment requested': { color: 'blue', text: '部署请求中', icon: '🔄' }, |
| 'termination requested': { |
| color: 'orange', |
| text: '终止请求中', |
| icon: '⏸️', |
| }, |
| destroyed: { color: 'red', text: '已销毁', icon: '🔴' }, |
| failed: { color: 'red', text: '失败', icon: '❌' }, |
| }; |
| return statusConfig[status] || { color: 'grey', text: status, icon: '❓' }; |
| }; |
|
|
| const statusConfig = getStatusConfig(deployment?.status); |
|
|
| return ( |
| <Modal |
| title={ |
| <div className='flex items-center gap-2'> |
| <FaInfoCircle className='text-blue-500' /> |
| <span>{t('容器详情')}</span> |
| </div> |
| } |
| visible={visible} |
| onCancel={onCancel} |
| footer={ |
| <div className='flex justify-between'> |
| <Button |
| icon={<IconRefresh />} |
| onClick={handleRefresh} |
| loading={loading || containersLoading} |
| theme='borderless' |
| > |
| {t('刷新')} |
| </Button> |
| <Button onClick={onCancel}>{t('关闭')}</Button> |
| </div> |
| } |
| width={800} |
| className='deployment-details-modal' |
| > |
| {loading && !details ? ( |
| <div className='flex items-center justify-center py-12'> |
| <Spin size='large' tip={t('加载详情中...')} /> |
| </div> |
| ) : details ? ( |
| <div className='space-y-4 max-h-[600px] overflow-y-auto'> |
| {/* Basic Info */} |
| <Card |
| title={ |
| <div className='flex items-center gap-2'> |
| <FaServer className='text-blue-500' /> |
| <span>{t('基本信息')}</span> |
| </div> |
| } |
| className='border-0 shadow-sm' |
| > |
| <Descriptions |
| data={[ |
| { |
| key: t('容器名称'), |
| value: ( |
| <div className='flex items-center gap-2'> |
| <Text strong className='text-base'> |
| {details.deployment_name || details.id} |
| </Text> |
| <Button |
| size='small' |
| theme='borderless' |
| icon={<FaCopy />} |
| onClick={handleCopyId} |
| className='opacity-70 hover:opacity-100' |
| /> |
| </div> |
| ), |
| }, |
| { |
| key: t('容器ID'), |
| value: ( |
| <Text type='secondary' className='font-mono text-sm'> |
| {details.id} |
| </Text> |
| ), |
| }, |
| { |
| key: t('状态'), |
| value: ( |
| <div className='flex items-center gap-2'> |
| <span>{statusConfig.icon}</span> |
| <Tag color={statusConfig.color}> |
| {t(statusConfig.text)} |
| </Tag> |
| </div> |
| ), |
| }, |
| { |
| key: t('创建时间'), |
| value: timestamp2string(details.created_at), |
| }, |
| ]} |
| /> |
| </Card> |
| |
| {/* Hardware & Performance */} |
| <Card |
| title={ |
| <div className='flex items-center gap-2'> |
| <FaChartLine className='text-green-500' /> |
| <span>{t('硬件与性能')}</span> |
| </div> |
| } |
| className='border-0 shadow-sm' |
| > |
| <div className='space-y-4'> |
| <Descriptions |
| data={[ |
| { |
| key: t('硬件类型'), |
| value: ( |
| <div className='flex items-center gap-2'> |
| <Tag color='blue'>{details.brand_name}</Tag> |
| <Text strong>{details.hardware_name}</Text> |
| </div> |
| ), |
| }, |
| { |
| key: t('GPU数量'), |
| value: ( |
| <div className='flex items-center gap-2'> |
| <Badge |
| count={details.total_gpus} |
| theme='solid' |
| type='primary' |
| > |
| <FaServer className='text-purple-500' /> |
| </Badge> |
| <Text> |
| {t('总计')} {details.total_gpus} {t('个GPU')} |
| </Text> |
| </div> |
| ), |
| }, |
| { |
| key: t('容器配置'), |
| value: ( |
| <div className='space-y-1'> |
| <div> |
| {t('每容器GPU数')}: {details.gpus_per_container} |
| </div> |
| <div> |
| {t('容器总数')}: {details.total_containers} |
| </div> |
| </div> |
| ), |
| }, |
| ]} |
| /> |
| |
| {/* Progress Bar */} |
| <div className='space-y-2'> |
| <div className='flex items-center justify-between'> |
| <Text strong>{t('完成进度')}</Text> |
| <Text>{details.completed_percent}%</Text> |
| </div> |
| <Progress |
| percent={details.completed_percent} |
| status={ |
| details.completed_percent === 100 ? 'success' : 'normal' |
| } |
| strokeWidth={8} |
| showInfo={false} |
| /> |
| <div className='flex justify-between text-xs text-gray-500'> |
| <span> |
| {t('已服务')}: {details.compute_minutes_served} {t('分钟')} |
| </span> |
| <span> |
| {t('剩余')}: {details.compute_minutes_remaining} {t('分钟')} |
| </span> |
| </div> |
| </div> |
| </div> |
| </Card> |
| |
| {/* Container Configuration */} |
| {details.container_config && ( |
| <Card |
| title={ |
| <div className='flex items-center gap-2'> |
| <FaDocker className='text-blue-600' /> |
| <span>{t('容器配置')}</span> |
| </div> |
| } |
| className='border-0 shadow-sm' |
| > |
| <div className='space-y-3'> |
| <Descriptions |
| data={[ |
| { |
| key: t('镜像地址'), |
| value: ( |
| <Text className='font-mono text-sm break-all'> |
| {details.container_config.image_url || 'N/A'} |
| </Text> |
| ), |
| }, |
| { |
| key: t('流量端口'), |
| value: details.container_config.traffic_port || 'N/A', |
| }, |
| { |
| key: t('启动命令'), |
| value: ( |
| <Text className='font-mono text-sm'> |
| {details.container_config.entrypoint |
| ? details.container_config.entrypoint.join(' ') |
| : 'N/A'} |
| </Text> |
| ), |
| }, |
| ]} |
| /> |
| |
| {/* Environment Variables */} |
| {details.container_config.env_variables && |
| Object.keys(details.container_config.env_variables).length > |
| 0 && ( |
| <div className='mt-4'> |
| <Text strong className='block mb-2'> |
| {t('环境变量')}: |
| </Text> |
| <div className='bg-gray-50 p-3 rounded-lg max-h-32 overflow-y-auto'> |
| {Object.entries( |
| details.container_config.env_variables, |
| ).map(([key, value]) => ( |
| <div |
| key={key} |
| className='flex gap-2 text-sm font-mono mb-1' |
| > |
| <span className='text-blue-600 font-medium'> |
| {key}= |
| </span> |
| <span className='text-gray-700 break-all'> |
| {String(value)} |
| </span> |
| </div> |
| ))} |
| </div> |
| </div> |
| )} |
| </div> |
| </Card> |
| )} |
| |
| {/* Containers List */} |
| <Card |
| title={ |
| <div className='flex items-center gap-2'> |
| <FaServer className='text-indigo-500' /> |
| <span>{t('容器实例')}</span> |
| </div> |
| } |
| className='border-0 shadow-sm' |
| > |
| {containersLoading ? ( |
| <div className='flex items-center justify-center py-6'> |
| <Spin tip={t('加载容器信息中...')} /> |
| </div> |
| ) : containers.length === 0 ? ( |
| <Empty |
| description={t('暂无容器信息')} |
| image={Empty.PRESENTED_IMAGE_SIMPLE} |
| /> |
| ) : ( |
| <div className='space-y-3'> |
| {containers.map((ctr) => ( |
| <Card |
| key={ctr.container_id} |
| className='bg-gray-50 border border-gray-100' |
| bodyStyle={{ padding: '12px 16px' }} |
| > |
| <div className='flex flex-wrap items-center justify-between gap-3'> |
| <div className='flex flex-col gap-1'> |
| <Text strong className='font-mono text-sm'> |
| {ctr.container_id} |
| </Text> |
| <Text size='small' type='secondary'> |
| {t('设备')} {ctr.device_id || '--'} · {t('状态')}{' '} |
| {ctr.status || '--'} |
| </Text> |
| <Text size='small' type='secondary'> |
| {t('创建时间')}:{' '} |
| {ctr.created_at |
| ? timestamp2string(ctr.created_at) |
| : '--'} |
| </Text> |
| </div> |
| <div className='flex flex-col items-end gap-2'> |
| <Tag color='blue' size='small'> |
| {t('GPU/容器')}: {ctr.gpus_per_container ?? '--'} |
| </Tag> |
| {ctr.public_url && ( |
| <Tooltip content={ctr.public_url}> |
| <Button |
| icon={<FaLink />} |
| size='small' |
| theme='light' |
| onClick={() => |
| window.open( |
| ctr.public_url, |
| '_blank', |
| 'noopener,noreferrer', |
| ) |
| } |
| > |
| {t('访问容器')} |
| </Button> |
| </Tooltip> |
| )} |
| </div> |
| </div> |
| |
| {ctr.events && ctr.events.length > 0 && ( |
| <div className='mt-3 bg-white rounded-md border border-gray-100 p-3'> |
| <Text |
| size='small' |
| type='secondary' |
| className='block mb-2' |
| > |
| {t('最近事件')} |
| </Text> |
| <div className='space-y-2 max-h-32 overflow-y-auto'> |
| {ctr.events.map((event, index) => ( |
| <div |
| key={`${ctr.container_id}-${event.time}-${index}`} |
| className='flex gap-3 text-xs font-mono' |
| > |
| <span className='text-gray-500 min-w-[140px]'> |
| {event.time |
| ? timestamp2string(event.time) |
| : '--'} |
| </span> |
| <span className='text-gray-700 break-all flex-1'> |
| {event.message || '--'} |
| </span> |
| </div> |
| ))} |
| </div> |
| </div> |
| )} |
| </Card> |
| ))} |
| </div> |
| )} |
| </Card> |
| |
| {/* Location Information */} |
| {details.locations && details.locations.length > 0 && ( |
| <Card |
| title={ |
| <div className='flex items-center gap-2'> |
| <FaMapMarkerAlt className='text-orange-500' /> |
| <span>{t('部署位置')}</span> |
| </div> |
| } |
| className='border-0 shadow-sm' |
| > |
| <div className='flex flex-wrap gap-2'> |
| {details.locations.map((location) => ( |
| <Tag key={location.id} color='orange' size='large'> |
| <div className='flex items-center gap-1'> |
| <span>🌍</span> |
| <span> |
| {location.name} ({location.iso2}) |
| </span> |
| </div> |
| </Tag> |
| ))} |
| </div> |
| </Card> |
| )} |
| |
| {/* Cost Information */} |
| <Card |
| title={ |
| <div className='flex items-center gap-2'> |
| <FaMoneyBillWave className='text-green-500' /> |
| <span>{t('费用信息')}</span> |
| </div> |
| } |
| className='border-0 shadow-sm' |
| > |
| <div className='space-y-3'> |
| <div className='flex items-center justify-between p-3 bg-green-50 rounded-lg'> |
| <Text>{t('已支付金额')}</Text> |
| <Text strong className='text-lg text-green-600'> |
| $ |
| {details.amount_paid |
| ? details.amount_paid.toFixed(2) |
| : '0.00'}{' '} |
| USDC |
| </Text> |
| </div> |
| |
| <div className='grid grid-cols-2 gap-4 text-sm'> |
| <div className='flex justify-between'> |
| <Text type='secondary'>{t('计费开始')}:</Text> |
| <Text> |
| {details.started_at |
| ? timestamp2string(details.started_at) |
| : 'N/A'} |
| </Text> |
| </div> |
| <div className='flex justify-between'> |
| <Text type='secondary'>{t('预计结束')}:</Text> |
| <Text> |
| {details.finished_at |
| ? timestamp2string(details.finished_at) |
| : 'N/A'} |
| </Text> |
| </div> |
| </div> |
| </div> |
| </Card> |
| |
| {/* Time Information */} |
| <Card |
| title={ |
| <div className='flex items-center gap-2'> |
| <FaClock className='text-purple-500' /> |
| <span>{t('时间信息')}</span> |
| </div> |
| } |
| className='border-0 shadow-sm' |
| > |
| <div className='grid grid-cols-1 md:grid-cols-2 gap-4'> |
| <div className='space-y-2'> |
| <div className='flex items-center justify-between'> |
| <Text type='secondary'>{t('已运行时间')}:</Text> |
| <Text strong> |
| {Math.floor(details.compute_minutes_served / 60)}h{' '} |
| {details.compute_minutes_served % 60}m |
| </Text> |
| </div> |
| <div className='flex items-center justify-between'> |
| <Text type='secondary'>{t('剩余时间')}:</Text> |
| <Text strong className='text-orange-600'> |
| {Math.floor(details.compute_minutes_remaining / 60)}h{' '} |
| {details.compute_minutes_remaining % 60}m |
| </Text> |
| </div> |
| </div> |
| <div className='space-y-2'> |
| <div className='flex items-center justify-between'> |
| <Text type='secondary'>{t('创建时间')}:</Text> |
| <Text>{timestamp2string(details.created_at)}</Text> |
| </div> |
| <div className='flex items-center justify-between'> |
| <Text type='secondary'>{t('最后更新')}:</Text> |
| <Text>{timestamp2string(details.updated_at)}</Text> |
| </div> |
| </div> |
| </div> |
| </Card> |
| </div> |
| ) : ( |
| <Empty |
| image={Empty.PRESENTED_IMAGE_SIMPLE} |
| description={t('无法获取容器详情')} |
| /> |
| )} |
| </Modal> |
| ); |
| }; |
|
|
| export default ViewDetailsModal; |
|
|