| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| |
|
| |
|
| | import React from 'react';
|
| | import { Button, Dropdown, Tag, Typography } from '@douyinfe/semi-ui';
|
| | import { timestamp2string, showSuccess, showError } from '../../../helpers';
|
| | import { IconMore } from '@douyinfe/semi-icons';
|
| | import {
|
| | FaPlay,
|
| | FaTrash,
|
| | FaServer,
|
| | FaMemory,
|
| | FaMicrochip,
|
| | FaCheckCircle,
|
| | FaSpinner,
|
| | FaClock,
|
| | FaExclamationCircle,
|
| | FaBan,
|
| | FaTerminal,
|
| | FaPlus,
|
| | FaCog,
|
| | FaInfoCircle,
|
| | FaLink,
|
| | FaStop,
|
| | FaHourglassHalf,
|
| | FaGlobe,
|
| | } from 'react-icons/fa';
|
| |
|
| | const normalizeStatus = (status) =>
|
| | typeof status === 'string' ? status.trim().toLowerCase() : '';
|
| |
|
| | const STATUS_TAG_CONFIG = {
|
| | running: {
|
| | color: 'green',
|
| | labelKey: '运行中',
|
| | icon: <FaPlay size={12} className='text-green-600' />,
|
| | },
|
| | deploying: {
|
| | color: 'blue',
|
| | labelKey: '部署中',
|
| | icon: <FaSpinner size={12} className='text-blue-600' />,
|
| | },
|
| | pending: {
|
| | color: 'orange',
|
| | labelKey: '待部署',
|
| | icon: <FaClock size={12} className='text-orange-600' />,
|
| | },
|
| | stopped: {
|
| | color: 'grey',
|
| | labelKey: '已停止',
|
| | icon: <FaStop size={12} className='text-gray-500' />,
|
| | },
|
| | error: {
|
| | color: 'red',
|
| | labelKey: '错误',
|
| | icon: <FaExclamationCircle size={12} className='text-red-500' />,
|
| | },
|
| | failed: {
|
| | color: 'red',
|
| | labelKey: '失败',
|
| | icon: <FaExclamationCircle size={12} className='text-red-500' />,
|
| | },
|
| | destroyed: {
|
| | color: 'red',
|
| | labelKey: '已销毁',
|
| | icon: <FaBan size={12} className='text-red-500' />,
|
| | },
|
| | completed: {
|
| | color: 'green',
|
| | labelKey: '已完成',
|
| | icon: <FaCheckCircle size={12} className='text-green-600' />,
|
| | },
|
| | 'deployment requested': {
|
| | color: 'blue',
|
| | labelKey: '部署请求中',
|
| | icon: <FaSpinner size={12} className='text-blue-600' />,
|
| | },
|
| | 'termination requested': {
|
| | color: 'orange',
|
| | labelKey: '终止请求中',
|
| | icon: <FaClock size={12} className='text-orange-600' />,
|
| | },
|
| | };
|
| |
|
| | const DEFAULT_STATUS_CONFIG = {
|
| | color: 'grey',
|
| | labelKey: null,
|
| | icon: <FaInfoCircle size={12} className='text-gray-500' />,
|
| | };
|
| |
|
| | const parsePercentValue = (value) => {
|
| | if (value === null || value === undefined) return null;
|
| | if (typeof value === 'string') {
|
| | const parsed = parseFloat(value.replace(/[^0-9.+-]/g, ''));
|
| | return Number.isFinite(parsed) ? parsed : null;
|
| | }
|
| | if (typeof value === 'number') {
|
| | return Number.isFinite(value) ? value : null;
|
| | }
|
| | return null;
|
| | };
|
| |
|
| | const clampPercent = (value) => {
|
| | if (value === null || value === undefined) return null;
|
| | return Math.min(100, Math.max(0, Math.round(value)));
|
| | };
|
| |
|
| | const formatRemainingMinutes = (minutes, t) => {
|
| | if (minutes === null || minutes === undefined) return null;
|
| | const numeric = Number(minutes);
|
| | if (!Number.isFinite(numeric)) return null;
|
| | const totalMinutes = Math.max(0, Math.round(numeric));
|
| | const days = Math.floor(totalMinutes / 1440);
|
| | const hours = Math.floor((totalMinutes % 1440) / 60);
|
| | const mins = totalMinutes % 60;
|
| | const parts = [];
|
| |
|
| | if (days > 0) {
|
| | parts.push(`${days}${t('天')}`);
|
| | }
|
| | if (hours > 0) {
|
| | parts.push(`${hours}${t('小时')}`);
|
| | }
|
| | if (parts.length === 0 || mins > 0) {
|
| | parts.push(`${mins}${t('分钟')}`);
|
| | }
|
| |
|
| | return parts.join(' ');
|
| | };
|
| |
|
| | const getRemainingTheme = (percentRemaining) => {
|
| | if (percentRemaining === null) {
|
| | return {
|
| | iconColor: 'var(--semi-color-primary)',
|
| | tagColor: 'blue',
|
| | textColor: 'var(--semi-color-text-2)',
|
| | };
|
| | }
|
| |
|
| | if (percentRemaining <= 10) {
|
| | return {
|
| | iconColor: '#ff5a5f',
|
| | tagColor: 'red',
|
| | textColor: '#ff5a5f',
|
| | };
|
| | }
|
| |
|
| | if (percentRemaining <= 30) {
|
| | return {
|
| | iconColor: '#ffb400',
|
| | tagColor: 'orange',
|
| | textColor: '#ffb400',
|
| | };
|
| | }
|
| |
|
| | return {
|
| | iconColor: '#2ecc71',
|
| | tagColor: 'green',
|
| | textColor: '#2ecc71',
|
| | };
|
| | };
|
| |
|
| | const renderStatus = (status, t) => {
|
| | const normalizedStatus = normalizeStatus(status);
|
| | const config = STATUS_TAG_CONFIG[normalizedStatus] || DEFAULT_STATUS_CONFIG;
|
| | const statusText = typeof status === 'string' ? status : '';
|
| | const labelText = config.labelKey
|
| | ? t(config.labelKey)
|
| | : statusText || t('未知状态');
|
| |
|
| | return (
|
| | <Tag
|
| | color={config.color}
|
| | shape='circle'
|
| | size='small'
|
| | prefixIcon={config.icon}
|
| | >
|
| | {labelText}
|
| | </Tag>
|
| | );
|
| | };
|
| |
|
| |
|
| | const ContainerNameCell = ({ text, record, t }) => {
|
| | const handleCopyId = async () => {
|
| | try {
|
| | await navigator.clipboard.writeText(record.id);
|
| | showSuccess(t('已复制 ID 到剪贴板'));
|
| | } catch (err) {
|
| | showError(t('复制失败'));
|
| | }
|
| | };
|
| |
|
| | return (
|
| | <div className='flex flex-col gap-1'>
|
| | <Typography.Text strong className='text-base'>
|
| | {text}
|
| | </Typography.Text>
|
| | <Typography.Text
|
| | type='secondary'
|
| | size='small'
|
| | className='text-xs cursor-pointer hover:text-blue-600 transition-colors select-all'
|
| | onClick={handleCopyId}
|
| | title={t('点击复制ID')}
|
| | >
|
| | ID: {record.id}
|
| | </Typography.Text>
|
| | </div>
|
| | );
|
| | };
|
| |
|
| |
|
| | const renderResourceConfig = (resource, t) => {
|
| | if (!resource) return '-';
|
| |
|
| | const { cpu, memory, gpu } = resource;
|
| |
|
| | return (
|
| | <div className='flex flex-col gap-1'>
|
| | {cpu && (
|
| | <div className='flex items-center gap-1 text-xs'>
|
| | <FaMicrochip className='text-blue-500' />
|
| | <span>CPU: {cpu}</span>
|
| | </div>
|
| | )}
|
| | {memory && (
|
| | <div className='flex items-center gap-1 text-xs'>
|
| | <FaMemory className='text-green-500' />
|
| | <span>内存: {memory}</span>
|
| | </div>
|
| | )}
|
| | {gpu && (
|
| | <div className='flex items-center gap-1 text-xs'>
|
| | <FaServer className='text-purple-500' />
|
| | <span>GPU: {gpu}</span>
|
| | </div>
|
| | )}
|
| | </div>
|
| | );
|
| | };
|
| |
|
| |
|
| | const renderInstanceCount = (count, record, t) => {
|
| | const normalizedStatus = normalizeStatus(record?.status);
|
| | const statusConfig = STATUS_TAG_CONFIG[normalizedStatus];
|
| | const countColor = statusConfig?.color ?? 'grey';
|
| |
|
| | return (
|
| | <Tag color={countColor} size='small' shape='circle'>
|
| | {count || 0} {t('个实例')}
|
| | </Tag>
|
| | );
|
| | };
|
| |
|
| |
|
| | export const getDeploymentsColumns = ({
|
| | t,
|
| | COLUMN_KEYS,
|
| | startDeployment,
|
| | restartDeployment,
|
| | deleteDeployment,
|
| | setEditingDeployment,
|
| | setShowEdit,
|
| | refresh,
|
| | activePage,
|
| | deployments,
|
| | // New handlers for enhanced operations
|
| | onViewLogs,
|
| | onExtendDuration,
|
| | onViewDetails,
|
| | onUpdateConfig,
|
| | onSyncToChannel,
|
| | }) => {
|
| | const columns = [
|
| | {
|
| | title: t('容器名称'),
|
| | dataIndex: 'container_name',
|
| | key: COLUMN_KEYS.container_name,
|
| | width: 300,
|
| | ellipsis: true,
|
| | render: (text, record) => (
|
| | <ContainerNameCell text={text} record={record} t={t} />
|
| | ),
|
| | },
|
| | {
|
| | title: t('状态'),
|
| | dataIndex: 'status',
|
| | key: COLUMN_KEYS.status,
|
| | width: 140,
|
| | render: (status) => (
|
| | <div className='flex items-center gap-2'>{renderStatus(status, t)}</div>
|
| | ),
|
| | },
|
| | {
|
| | title: t('服务商'),
|
| | dataIndex: 'provider',
|
| | key: COLUMN_KEYS.provider,
|
| | width: 140,
|
| | render: (provider) =>
|
| | provider ? (
|
| | <div
|
| | className='flex items-center gap-1.5 rounded-full border px-2 py-0.5 text-[10px] font-medium uppercase tracking-wide'
|
| | style={{
|
| | borderColor: 'rgba(59, 130, 246, 0.4)',
|
| | backgroundColor: 'rgba(59, 130, 246, 0.08)',
|
| | color: '#2563eb',
|
| | }}
|
| | >
|
| | <FaGlobe className='text-[11px]' />
|
| | <span>{provider}</span>
|
| | </div>
|
| | ) : (
|
| | <Typography.Text
|
| | type='tertiary'
|
| | size='small'
|
| | className='text-xs text-gray-500'
|
| | >
|
| | {t('暂无')}
|
| | </Typography.Text>
|
| | ),
|
| | },
|
| | {
|
| | title: t('剩余时间'),
|
| | dataIndex: 'time_remaining',
|
| | key: COLUMN_KEYS.time_remaining,
|
| | width: 200,
|
| | render: (text, record) => {
|
| | const normalizedStatus = normalizeStatus(record?.status);
|
| | const percentUsedRaw = parsePercentValue(record?.completed_percent);
|
| | const percentUsed = clampPercent(percentUsedRaw);
|
| | const percentRemaining =
|
| | percentUsed === null ? null : clampPercent(100 - percentUsed);
|
| | const theme = getRemainingTheme(percentRemaining);
|
| | const statusDisplayMap = {
|
| | completed: t('已完成'),
|
| | destroyed: t('已销毁'),
|
| | failed: t('失败'),
|
| | error: t('失败'),
|
| | stopped: t('已停止'),
|
| | pending: t('待部署'),
|
| | deploying: t('部署中'),
|
| | 'deployment requested': t('部署请求中'),
|
| | 'termination requested': t('终止中'),
|
| | };
|
| | const statusOverride = statusDisplayMap[normalizedStatus];
|
| | const baseTimeDisplay =
|
| | text && String(text).trim() !== '' ? text : t('计算中');
|
| | const timeDisplay = baseTimeDisplay;
|
| | const humanReadable = formatRemainingMinutes(
|
| | record.compute_minutes_remaining,
|
| | t,
|
| | );
|
| | const showProgress = !statusOverride && normalizedStatus === 'running';
|
| | const showExtraInfo = Boolean(humanReadable || percentUsed !== null);
|
| | const showRemainingMeta =
|
| | record.compute_minutes_remaining !== undefined &&
|
| | record.compute_minutes_remaining !== null &&
|
| | percentRemaining !== null;
|
| |
|
| | return (
|
| | <div className='flex flex-col gap-1 leading-tight text-xs'>
|
| | <div className='flex items-center gap-1.5'>
|
| | <FaHourglassHalf
|
| | className='text-sm'
|
| | style={{ color: theme.iconColor }}
|
| | />
|
| | <Typography.Text className='text-sm font-medium text-[var(--semi-color-text-0)]'>
|
| | {timeDisplay}
|
| | </Typography.Text>
|
| | {showProgress && percentRemaining !== null ? (
|
| | <Tag size='small' color={theme.tagColor}>
|
| | {percentRemaining}%
|
| | </Tag>
|
| | ) : statusOverride ? (
|
| | <Tag size='small' color='grey'>
|
| | {statusOverride}
|
| | </Tag>
|
| | ) : null}
|
| | </div>
|
| | {showExtraInfo && (
|
| | <div className='flex items-center gap-3 text-[var(--semi-color-text-2)]'>
|
| | {humanReadable && (
|
| | <span className='flex items-center gap-1'>
|
| | <FaClock className='text-[11px]' />
|
| | {t('约')} {humanReadable}
|
| | </span>
|
| | )}
|
| | {percentUsed !== null && (
|
| | <span className='flex items-center gap-1'>
|
| | <FaCheckCircle className='text-[11px]' />
|
| | {t('已用')} {percentUsed}%
|
| | </span>
|
| | )}
|
| | </div>
|
| | )}
|
| | {showProgress && showRemainingMeta && (
|
| | <div className='text-[10px]' style={{ color: theme.textColor }}>
|
| | {t('剩余')} {record.compute_minutes_remaining} {t('分钟')}
|
| | </div>
|
| | )}
|
| | </div>
|
| | );
|
| | },
|
| | },
|
| | {
|
| | title: t('硬件配置'),
|
| | dataIndex: 'hardware_info',
|
| | key: COLUMN_KEYS.hardware_info,
|
| | width: 220,
|
| | ellipsis: true,
|
| | render: (text, record) => (
|
| | <div className='flex items-center gap-2'>
|
| | <div className='flex items-center gap-1 px-2 py-1 bg-green-50 border border-green-200 rounded-md'>
|
| | <FaServer className='text-green-600 text-xs' />
|
| | <span className='text-xs font-medium text-green-700'>
|
| | {record.hardware_name}
|
| | </span>
|
| | </div>
|
| | <span className='text-xs text-gray-500 font-medium'>
|
| | x{record.hardware_quantity}
|
| | </span>
|
| | </div>
|
| | ),
|
| | },
|
| | {
|
| | title: t('创建时间'),
|
| | dataIndex: 'created_at',
|
| | key: COLUMN_KEYS.created_at,
|
| | width: 150,
|
| | render: (text) => (
|
| | <span className='text-sm text-gray-600'>{timestamp2string(text)}</span>
|
| | ),
|
| | },
|
| | {
|
| | title: t('操作'),
|
| | key: COLUMN_KEYS.actions,
|
| | fixed: 'right',
|
| | width: 120,
|
| | render: (_, record) => {
|
| | const { status, id } = record;
|
| | const normalizedStatus = normalizeStatus(status);
|
| | const isEnded =
|
| | normalizedStatus === 'completed' || normalizedStatus === 'destroyed';
|
| |
|
| | const handleDelete = () => {
|
| |
|
| | onUpdateConfig?.(record, 'delete');
|
| | };
|
| |
|
| |
|
| | const getPrimaryAction = () => {
|
| | switch (normalizedStatus) {
|
| | case 'running':
|
| | return {
|
| | icon: <FaInfoCircle className='text-xs' />,
|
| | text: t('查看详情'),
|
| | onClick: () => onViewDetails?.(record),
|
| | type: 'secondary',
|
| | theme: 'borderless',
|
| | };
|
| | case 'failed':
|
| | case 'error':
|
| | return {
|
| | icon: <FaPlay className='text-xs' />,
|
| | text: t('重试'),
|
| | onClick: () => startDeployment(id),
|
| | type: 'primary',
|
| | theme: 'solid',
|
| | };
|
| | case 'stopped':
|
| | return {
|
| | icon: <FaPlay className='text-xs' />,
|
| | text: t('启动'),
|
| | onClick: () => startDeployment(id),
|
| | type: 'primary',
|
| | theme: 'solid',
|
| | };
|
| | case 'deployment requested':
|
| | case 'deploying':
|
| | return {
|
| | icon: <FaClock className='text-xs' />,
|
| | text: t('部署中'),
|
| | onClick: () => {},
|
| | type: 'secondary',
|
| | theme: 'light',
|
| | disabled: true,
|
| | };
|
| | case 'pending':
|
| | return {
|
| | icon: <FaClock className='text-xs' />,
|
| | text: t('待部署'),
|
| | onClick: () => {},
|
| | type: 'secondary',
|
| | theme: 'light',
|
| | disabled: true,
|
| | };
|
| | case 'termination requested':
|
| | return {
|
| | icon: <FaClock className='text-xs' />,
|
| | text: t('终止中'),
|
| | onClick: () => {},
|
| | type: 'secondary',
|
| | theme: 'light',
|
| | disabled: true,
|
| | };
|
| | case 'completed':
|
| | case 'destroyed':
|
| | default:
|
| | return {
|
| | icon: <FaInfoCircle className='text-xs' />,
|
| | text: t('已结束'),
|
| | onClick: () => {},
|
| | type: 'tertiary',
|
| | theme: 'borderless',
|
| | disabled: true,
|
| | };
|
| | }
|
| | };
|
| |
|
| | const primaryAction = getPrimaryAction();
|
| | const primaryTheme = primaryAction.theme || 'solid';
|
| | const primaryType = primaryAction.type || 'primary';
|
| |
|
| | if (isEnded) {
|
| | return (
|
| | <div className='flex w-full items-center justify-start gap-1 pr-2'>
|
| | <Button
|
| | size='small'
|
| | type='tertiary'
|
| | theme='borderless'
|
| | onClick={() => onViewDetails?.(record)}
|
| | icon={<FaInfoCircle className='text-xs' />}
|
| | >
|
| | {t('查看详情')}
|
| | </Button>
|
| | </div>
|
| | );
|
| | }
|
| |
|
| |
|
| | const dropdownItems = [
|
| | <Dropdown.Item
|
| | key='details'
|
| | onClick={() => onViewDetails?.(record)}
|
| | icon={<FaInfoCircle />}
|
| | >
|
| | {t('查看详情')}
|
| | </Dropdown.Item>,
|
| | ];
|
| |
|
| | if (!isEnded) {
|
| | dropdownItems.push(
|
| | <Dropdown.Item
|
| | key='logs'
|
| | onClick={() => onViewLogs?.(record)}
|
| | icon={<FaTerminal />}
|
| | >
|
| | {t('查看日志')}
|
| | </Dropdown.Item>,
|
| | );
|
| | }
|
| |
|
| | const managementItems = [];
|
| | if (normalizedStatus === 'running') {
|
| | if (onSyncToChannel) {
|
| | managementItems.push(
|
| | <Dropdown.Item
|
| | key='sync-channel'
|
| | onClick={() => onSyncToChannel(record)}
|
| | icon={<FaLink />}
|
| | >
|
| | {t('同步到渠道')}
|
| | </Dropdown.Item>,
|
| | );
|
| | }
|
| | }
|
| | if (normalizedStatus === 'failed' || normalizedStatus === 'error') {
|
| | managementItems.push(
|
| | <Dropdown.Item
|
| | key='retry'
|
| | onClick={() => startDeployment(id)}
|
| | icon={<FaPlay />}
|
| | >
|
| | {t('重试')}
|
| | </Dropdown.Item>,
|
| | );
|
| | }
|
| | if (normalizedStatus === 'stopped') {
|
| | managementItems.push(
|
| | <Dropdown.Item
|
| | key='start'
|
| | onClick={() => startDeployment(id)}
|
| | icon={<FaPlay />}
|
| | >
|
| | {t('启动')}
|
| | </Dropdown.Item>,
|
| | );
|
| | }
|
| |
|
| | if (managementItems.length > 0) {
|
| | dropdownItems.push(<Dropdown.Divider key='management-divider' />);
|
| | dropdownItems.push(...managementItems);
|
| | }
|
| |
|
| | const configItems = [];
|
| | if (
|
| | !isEnded &&
|
| | (normalizedStatus === 'running' ||
|
| | normalizedStatus === 'deployment requested')
|
| | ) {
|
| | configItems.push(
|
| | <Dropdown.Item
|
| | key='extend'
|
| | onClick={() => onExtendDuration?.(record)}
|
| | icon={<FaPlus />}
|
| | >
|
| | {t('延长时长')}
|
| | </Dropdown.Item>,
|
| | );
|
| | }
|
| |
|
| |
|
| |
|
| |
|
| |
|
| |
|
| |
|
| |
|
| | if (configItems.length > 0) {
|
| | dropdownItems.push(<Dropdown.Divider key='config-divider' />);
|
| | dropdownItems.push(...configItems);
|
| | }
|
| | if (!isEnded) {
|
| | dropdownItems.push(<Dropdown.Divider key='danger-divider' />);
|
| | dropdownItems.push(
|
| | <Dropdown.Item
|
| | key='delete'
|
| | type='danger'
|
| | onClick={handleDelete}
|
| | icon={<FaTrash />}
|
| | >
|
| | {t('销毁容器')}
|
| | </Dropdown.Item>,
|
| | );
|
| | }
|
| |
|
| | const allActions = <Dropdown.Menu>{dropdownItems}</Dropdown.Menu>;
|
| | const hasDropdown = dropdownItems.length > 0;
|
| |
|
| | return (
|
| | <div className='flex w-full items-center justify-start gap-1 pr-2'>
|
| | <Button
|
| | size='small'
|
| | theme={primaryTheme}
|
| | type={primaryType}
|
| | icon={primaryAction.icon}
|
| | onClick={primaryAction.onClick}
|
| | className='px-2 text-xs'
|
| | disabled={primaryAction.disabled}
|
| | >
|
| | {primaryAction.text}
|
| | </Button>
|
| |
|
| | {hasDropdown && (
|
| | <Dropdown
|
| | trigger='click'
|
| | position='bottomRight'
|
| | render={allActions}
|
| | >
|
| | <Button
|
| | size='small'
|
| | theme='light'
|
| | type='tertiary'
|
| | icon={<IconMore />}
|
| | className='px-1'
|
| | />
|
| | </Dropdown>
|
| | )}
|
| | </div>
|
| | );
|
| | },
|
| | },
|
| | ];
|
| |
|
| | return columns;
|
| | };
|
| |
|