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