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