|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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'; |
|
|
import {t} from "i18next"; |
|
|
|
|
|
const normalizeStatus = (status) => |
|
|
typeof status === 'string' ? status.trim().toLowerCase() : ''; |
|
|
|
|
|
const STATUS_TAG_CONFIG = { |
|
|
running: { |
|
|
color: 'green', |
|
|
label: t('运行中'), |
|
|
icon: <FaPlay size={12} className='text-green-600' />, |
|
|
}, |
|
|
deploying: { |
|
|
color: 'blue', |
|
|
label: t('部署中'), |
|
|
icon: <FaSpinner size={12} className='text-blue-600' />, |
|
|
}, |
|
|
pending: { |
|
|
color: 'orange', |
|
|
label: t('待部署'), |
|
|
icon: <FaClock size={12} className='text-orange-600' />, |
|
|
}, |
|
|
stopped: { |
|
|
color: 'grey', |
|
|
label: t('已停止'), |
|
|
icon: <FaStop size={12} className='text-gray-500' />, |
|
|
}, |
|
|
error: { |
|
|
color: 'red', |
|
|
label: t('错误'), |
|
|
icon: <FaExclamationCircle size={12} className='text-red-500' />, |
|
|
}, |
|
|
failed: { |
|
|
color: 'red', |
|
|
label: t('失败'), |
|
|
icon: <FaExclamationCircle size={12} className='text-red-500' />, |
|
|
}, |
|
|
destroyed: { |
|
|
color: 'red', |
|
|
label: t('已销毁'), |
|
|
icon: <FaBan size={12} className='text-red-500' />, |
|
|
}, |
|
|
completed: { |
|
|
color: 'green', |
|
|
label: t('已完成'), |
|
|
icon: <FaCheckCircle size={12} className='text-green-600' />, |
|
|
}, |
|
|
'deployment requested': { |
|
|
color: 'blue', |
|
|
label: t('部署请求中'), |
|
|
icon: <FaSpinner size={12} className='text-blue-600' />, |
|
|
}, |
|
|
'termination requested': { |
|
|
color: 'orange', |
|
|
label: t('终止请求中'), |
|
|
icon: <FaClock size={12} className='text-orange-600' />, |
|
|
}, |
|
|
}; |
|
|
|
|
|
const DEFAULT_STATUS_CONFIG = { |
|
|
color: 'grey', |
|
|
label: 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.label ? t(config.label) : statusText || t('未知状态'); |
|
|
|
|
|
return ( |
|
|
<Tag |
|
|
color={config.color} |
|
|
shape='circle' |
|
|
size='small' |
|
|
prefixIcon={config.icon} |
|
|
> |
|
|
{labelText} |
|
|
</Tag> |
|
|
); |
|
|
}; |
|
|
|
|
|
|
|
|
const ContainerNameCell = ({ text, record, t }) => { |
|
|
const handleCopyId = () => { |
|
|
navigator.clipboard.writeText(record.id); |
|
|
showSuccess(t('ID已复制到剪贴板')); |
|
|
}; |
|
|
|
|
|
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: 140, |
|
|
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; |
|
|
}; |
|
|
|