fix: fix model deployment style issues, lint problems, and i18n gaps. (#2556)

* fix: fix model deployment style issues, lint problems, and i18n gaps.

* fix: adjust the key not to be displayed on the frontend, tested via the backend.

* fix: adjust the sidebar configuration logic to use the default configuration items if they are not defined.
This commit is contained in:
Seefs
2026-01-03 12:37:50 +08:00
committed by GitHub
parent 1c95a9febc
commit be567ef7c9
29 changed files with 5258 additions and 2653 deletions

View File

@@ -30,30 +30,24 @@ import {
Spin,
Popconfirm,
Tag,
Avatar,
Empty,
Divider,
Row,
Col,
Progress,
Checkbox,
Radio,
} from '@douyinfe/semi-ui';
import {
IconClose,
IconDownload,
IconDelete,
IconRefresh,
IconSearch,
IconPlus,
IconServer,
} from '@douyinfe/semi-icons';
import {
API,
authHeader,
getUserIdFromLocalStorage,
showError,
showInfo,
showSuccess,
} from '../../../../helpers';
@@ -85,9 +79,7 @@ const resolveOllamaBaseUrl = (info) => {
}
const alt =
typeof info.ollama_base_url === 'string'
? info.ollama_base_url.trim()
: '';
typeof info.ollama_base_url === 'string' ? info.ollama_base_url.trim() : '';
if (alt) {
return alt;
}
@@ -125,7 +117,8 @@ const normalizeModels = (items) => {
}
if (typeof item === 'object') {
const candidateId = item.id || item.ID || item.name || item.model || item.Model;
const candidateId =
item.id || item.ID || item.name || item.model || item.Model;
if (!candidateId) {
return null;
}
@@ -147,7 +140,10 @@ const normalizeModels = (items) => {
if (!normalized.digest && typeof metadata.digest === 'string') {
normalized.digest = metadata.digest;
}
if (!normalized.modified_at && typeof metadata.modified_at === 'string') {
if (
!normalized.modified_at &&
typeof metadata.modified_at === 'string'
) {
normalized.modified_at = metadata.modified_at;
}
if (metadata.details && !normalized.details) {
@@ -440,7 +436,6 @@ const OllamaModelModal = ({
};
await processStream();
} catch (error) {
if (error?.name !== 'AbortError') {
showError(t('模型拉取失败: {{error}}', { error: error.message }));
@@ -461,7 +456,7 @@ const OllamaModelModal = ({
model_name: modelName,
},
});
if (res.data.success) {
showSuccess(t('模型删除成功'));
await fetchModels(); // 重新获取模型列表
@@ -481,8 +476,8 @@ const OllamaModelModal = ({
if (!searchValue) {
setFilteredModels(models);
} else {
const filtered = models.filter(model =>
model.id.toLowerCase().includes(searchValue.toLowerCase())
const filtered = models.filter((model) =>
model.id.toLowerCase().includes(searchValue.toLowerCase()),
);
setFilteredModels(filtered);
}
@@ -527,60 +522,38 @@ const OllamaModelModal = ({
const formatModelSize = (size) => {
if (!size) return '-';
const gb = size / (1024 * 1024 * 1024);
return gb >= 1 ? `${gb.toFixed(1)} GB` : `${(size / (1024 * 1024)).toFixed(0)} MB`;
return gb >= 1
? `${gb.toFixed(1)} GB`
: `${(size / (1024 * 1024)).toFixed(0)} MB`;
};
return (
<Modal
title={
<div className='flex items-center'>
<Avatar
size='small'
color='blue'
className='mr-3 shadow-md'
>
<IconServer size={16} />
</Avatar>
<div>
<Title heading={4} className='m-0'>
{t('Ollama 模型管理')}
</Title>
<Text type='tertiary' size='small'>
{channelInfo?.name && `${channelInfo.name} - `}
{t('管理 Ollama 模型的拉取和删除')}
</Text>
</div>
</div>
}
title={t('Ollama 模型管理')}
visible={visible}
onCancel={onCancel}
width={800}
width={720}
style={{ maxWidth: '95vw' }}
footer={
<div className='flex justify-end'>
<Button
theme='light'
type='primary'
onClick={onCancel}
icon={<IconClose />}
>
{t('关闭')}
</Button>
</div>
<Button theme='solid' type='primary' onClick={onCancel}>
{t('关闭')}
</Button>
}
>
<div className='space-y-6'>
<Space vertical spacing='medium' style={{ width: '100%' }}>
<div>
<Text type='tertiary' size='small'>
{channelInfo?.name ? `${channelInfo.name} - ` : ''}
{t('管理 Ollama 模型的拉取和删除')}
</Text>
</div>
{/* 拉取新模型 */}
<Card className='!rounded-2xl shadow-sm border-0'>
<div className='flex items-center mb-4'>
<Avatar size='small' color='green' className='mr-2'>
<IconPlus size={16} />
</Avatar>
<Title heading={5} className='m-0'>
{t('拉取新模型')}
</Title>
</div>
<Card>
<Title heading={6} className='m-0 mb-3'>
{t('拉取新模型')}
</Title>
<Row gutter={12} align='middle'>
<Col span={16}>
<Input
@@ -606,76 +579,81 @@ const OllamaModelModal = ({
</Button>
</Col>
</Row>
{/* 进度条显示 */}
{pullProgress && (() => {
const completedBytes = Number(pullProgress.completed) || 0;
const totalBytes = Number(pullProgress.total) || 0;
const hasTotal = Number.isFinite(totalBytes) && totalBytes > 0;
const safePercent = hasTotal
? Math.min(
100,
Math.max(0, Math.round((completedBytes / totalBytes) * 100)),
)
: null;
const percentText = hasTotal && safePercent !== null
? `${safePercent.toFixed(0)}%`
: pullProgress.status || t('处理中');
{pullProgress &&
(() => {
const completedBytes = Number(pullProgress.completed) || 0;
const totalBytes = Number(pullProgress.total) || 0;
const hasTotal = Number.isFinite(totalBytes) && totalBytes > 0;
const safePercent = hasTotal
? Math.min(
100,
Math.max(
0,
Math.round((completedBytes / totalBytes) * 100),
),
)
: null;
const percentText =
hasTotal && safePercent !== null
? `${safePercent.toFixed(0)}%`
: pullProgress.status || t('处理中');
return (
<div className='mt-3 p-3 bg-gray-50 rounded-lg'>
<div className='flex items-center justify-between mb-2'>
<Text strong>{t('拉取进度')}</Text>
<Text type='tertiary' size='small'>{percentText}</Text>
</div>
return (
<div style={{ marginTop: 12 }}>
<div className='flex items-center justify-between mb-2'>
<Text strong>{t('拉取进度')}</Text>
<Text type='tertiary' size='small'>
{percentText}
</Text>
</div>
{hasTotal && safePercent !== null ? (
<div>
<Progress
percent={safePercent}
showInfo={false}
stroke='#1890ff'
size='small'
/>
<div className='flex justify-between mt-1'>
<Text type='tertiary' size='small'>
{(completedBytes / (1024 * 1024 * 1024)).toFixed(2)} GB
</Text>
<Text type='tertiary' size='small'>
{(totalBytes / (1024 * 1024 * 1024)).toFixed(2)} GB
</Text>
{hasTotal && safePercent !== null ? (
<div>
<Progress
percent={safePercent}
showInfo={false}
stroke='#1890ff'
size='small'
/>
<div className='flex justify-between mt-1'>
<Text type='tertiary' size='small'>
{(completedBytes / (1024 * 1024 * 1024)).toFixed(2)}{' '}
GB
</Text>
<Text type='tertiary' size='small'>
{(totalBytes / (1024 * 1024 * 1024)).toFixed(2)} GB
</Text>
</div>
</div>
</div>
) : (
<div className='flex items-center gap-2 text-xs text-[var(--semi-color-text-2)]'>
<Spin size='small' />
<span>{t('准备中...')}</span>
</div>
)}
</div>
);
})()}
) : (
<div className='flex items-center gap-2 text-xs text-[var(--semi-color-text-2)]'>
<Spin size='small' />
<span>{t('准备中...')}</span>
</div>
)}
</div>
);
})()}
<Text type='tertiary' size='small' className='mt-2 block'>
{t('支持拉取 Ollama 官方模型库中的所有模型,拉取过程可能需要几分钟时间')}
{t(
'支持拉取 Ollama 官方模型库中的所有模型,拉取过程可能需要几分钟时间',
)}
</Text>
</Card>
{/* 已有模型列表 */}
<Card className='!rounded-2xl shadow-sm border-0'>
<div className='flex items-center justify-between mb-4'>
<div className='flex items-center'>
<Avatar size='small' color='purple' className='mr-2'>
<IconServer size={16} />
</Avatar>
<Title heading={5} className='m-0'>
<Card>
<div className='flex items-center justify-between mb-3'>
<div className='flex items-center gap-2'>
<Title heading={6} className='m-0'>
{t('已有模型')}
{models.length > 0 && (
<Tag color='blue' className='ml-2'>
{models.length}
</Tag>
)}
</Title>
{models.length > 0 ? (
<Tag color='blue'>{models.length}</Tag>
) : null}
</div>
<Space wrap>
<Input
@@ -688,7 +666,7 @@ const OllamaModelModal = ({
/>
<Button
size='small'
theme='borderless'
theme='light'
onClick={handleSelectAll}
disabled={models.length === 0}
>
@@ -696,7 +674,7 @@ const OllamaModelModal = ({
</Button>
<Button
size='small'
theme='borderless'
theme='light'
onClick={handleClearSelection}
disabled={selectedModelIds.length === 0}
>
@@ -728,11 +706,10 @@ const OllamaModelModal = ({
<Spin spinning={loading}>
{filteredModels.length === 0 ? (
<Empty
image={<IconServer size={60} />}
title={searchValue ? t('未找到匹配的模型') : t('暂无模型')}
description={
searchValue
? t('请尝试其他搜索关键词')
searchValue
? t('请尝试其他搜索关键词')
: t('您可以在上方拉取需要的模型')
}
style={{ padding: '40px 0' }}
@@ -740,25 +717,17 @@ const OllamaModelModal = ({
) : (
<List
dataSource={filteredModels}
split={false}
renderItem={(model, index) => (
<List.Item
key={model.id}
className='hover:bg-gray-50 rounded-lg p-3 transition-colors'
>
split
renderItem={(model) => (
<List.Item key={model.id}>
<div className='flex items-center justify-between w-full'>
<div className='flex items-center flex-1 min-w-0 gap-3'>
<Checkbox
checked={selectedModelIds.includes(model.id)}
onChange={(checked) => handleToggleModel(model.id, checked)}
onChange={(checked) =>
handleToggleModel(model.id, checked)
}
/>
<Avatar
size='small'
color='blue'
className='flex-shrink-0'
>
{model.id.charAt(0).toUpperCase()}
</Avatar>
<div className='flex-1 min-w-0'>
<Text strong className='block truncate'>
{model.id}
@@ -775,10 +744,13 @@ const OllamaModelModal = ({
</div>
</div>
</div>
<div className='flex items-center space-x-2 ml-4'>
<div className='flex items-center space-x-2 ml-4'>
<Popconfirm
title={t('确认删除模型')}
content={t('删除后无法恢复,确定要删除模型 "{{name}}" 吗?', { name: model.id })}
content={t(
'删除后无法恢复,确定要删除模型 "{{name}}" 吗?',
{ name: model.id },
)}
onConfirm={() => deleteModel(model.id)}
okText={t('确认')}
cancelText={t('取消')}
@@ -798,7 +770,7 @@ const OllamaModelModal = ({
)}
</Spin>
</Card>
</div>
</Space>
</Modal>
);
};

View File

@@ -27,13 +27,14 @@ const DeploymentsActions = ({
setEditingDeployment,
setShowEdit,
batchDeleteDeployments,
batchOperationsEnabled = true,
compactMode,
setCompactMode,
showCreateModal,
setShowCreateModal,
t,
}) => {
const hasSelected = selectedKeys.length > 0;
const hasSelected = batchOperationsEnabled && selectedKeys.length > 0;
const handleAddDeployment = () => {
if (setShowCreateModal) {
@@ -53,7 +54,6 @@ const DeploymentsActions = ({
setSelectedKeys([]);
};
return (
<div className='flex flex-wrap gap-2 w-full md:w-auto order-2 md:order-1'>
<Button

View File

@@ -18,17 +18,8 @@ For commercial licensing, please contact support@quantumnous.com
*/
import React from 'react';
import {
Button,
Dropdown,
Tag,
Typography,
} from '@douyinfe/semi-ui';
import {
timestamp2string,
showSuccess,
showError,
} from '../../../helpers';
import { Button, Dropdown, Tag, Typography } from '@douyinfe/semi-ui';
import { timestamp2string, showSuccess, showError } from '../../../helpers';
import { IconMore } from '@douyinfe/semi-icons';
import {
FaPlay,
@@ -50,7 +41,6 @@ import {
FaHourglassHalf,
FaGlobe,
} from 'react-icons/fa';
import {t} from "i18next";
const normalizeStatus = (status) =>
typeof status === 'string' ? status.trim().toLowerCase() : '';
@@ -58,59 +48,59 @@ const normalizeStatus = (status) =>
const STATUS_TAG_CONFIG = {
running: {
color: 'green',
label: t('运行中'),
labelKey: '运行中',
icon: <FaPlay size={12} className='text-green-600' />,
},
deploying: {
color: 'blue',
label: t('部署中'),
labelKey: '部署中',
icon: <FaSpinner size={12} className='text-blue-600' />,
},
pending: {
color: 'orange',
label: t('待部署'),
labelKey: '待部署',
icon: <FaClock size={12} className='text-orange-600' />,
},
stopped: {
color: 'grey',
label: t('已停止'),
labelKey: '已停止',
icon: <FaStop size={12} className='text-gray-500' />,
},
error: {
color: 'red',
label: t('错误'),
labelKey: '错误',
icon: <FaExclamationCircle size={12} className='text-red-500' />,
},
failed: {
color: 'red',
label: t('失败'),
labelKey: '失败',
icon: <FaExclamationCircle size={12} className='text-red-500' />,
},
destroyed: {
color: 'red',
label: t('已销毁'),
labelKey: '已销毁',
icon: <FaBan size={12} className='text-red-500' />,
},
completed: {
color: 'green',
label: t('已完成'),
labelKey: '已完成',
icon: <FaCheckCircle size={12} className='text-green-600' />,
},
'deployment requested': {
color: 'blue',
label: t('部署请求中'),
labelKey: '部署请求中',
icon: <FaSpinner size={12} className='text-blue-600' />,
},
'termination requested': {
color: 'orange',
label: t('终止请求中'),
labelKey: '终止请求中',
icon: <FaClock size={12} className='text-orange-600' />,
},
};
const DEFAULT_STATUS_CONFIG = {
color: 'grey',
label: null,
labelKey: null,
icon: <FaInfoCircle size={12} className='text-gray-500' />,
};
@@ -190,7 +180,9 @@ 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('未知状态');
const labelText = config.labelKey
? t(config.labelKey)
: statusText || t('未知状态');
return (
<Tag
@@ -206,20 +198,24 @@ const renderStatus = (status, t) => {
// Container Name Cell Component - to properly handle React hooks
const ContainerNameCell = ({ text, record, t }) => {
const handleCopyId = () => {
navigator.clipboard.writeText(record.id);
showSuccess(t('ID已复制到剪贴板'));
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">
<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"
<Typography.Text
type='secondary'
size='small'
className='text-xs cursor-pointer hover:text-blue-600 transition-colors select-all'
onClick={handleCopyId}
title={t('点击复制ID')}
>
@@ -232,26 +228,26 @@ const ContainerNameCell = ({ text, record, t }) => {
// Render resource configuration
const renderResourceConfig = (resource, t) => {
if (!resource) return '-';
const { cpu, memory, gpu } = resource;
return (
<div className="flex flex-col gap-1">
<div className='flex flex-col gap-1'>
{cpu && (
<div className="flex items-center gap-1 text-xs">
<FaMicrochip className="text-blue-500" />
<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" />
<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" />
<div className='flex items-center gap-1 text-xs'>
<FaServer className='text-purple-500' />
<span>GPU: {gpu}</span>
</div>
)}
@@ -266,7 +262,7 @@ const renderInstanceCount = (count, record, t) => {
const countColor = statusConfig?.color ?? 'grey';
return (
<Tag color={countColor} size="small" shape='circle'>
<Tag color={countColor} size='small' shape='circle'>
{count || 0} {t('个实例')}
</Tag>
);
@@ -299,11 +295,7 @@ export const getDeploymentsColumns = ({
width: 300,
ellipsis: true,
render: (text, record) => (
<ContainerNameCell
text={text}
record={record}
t={t}
/>
<ContainerNameCell text={text} record={record} t={t} />
),
},
{
@@ -312,9 +304,7 @@ export const getDeploymentsColumns = ({
key: COLUMN_KEYS.status,
width: 140,
render: (status) => (
<div className="flex items-center gap-2">
{renderStatus(status, t)}
</div>
<div className='flex items-center gap-2'>{renderStatus(status, t)}</div>
),
},
{
@@ -325,18 +315,22 @@ export const getDeploymentsColumns = ({
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"
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]" />
<FaGlobe className='text-[11px]' />
<span>{provider}</span>
</div>
) : (
<Typography.Text type="tertiary" size="small" className="text-xs text-gray-500">
<Typography.Text
type='tertiary'
size='small'
className='text-xs text-gray-500'
>
{t('暂无')}
</Typography.Text>
),
@@ -345,7 +339,7 @@ export const getDeploymentsColumns = ({
title: t('剩余时间'),
dataIndex: 'time_remaining',
key: COLUMN_KEYS.time_remaining,
width: 140,
width: 200,
render: (text, record) => {
const normalizedStatus = normalizeStatus(record?.status);
const percentUsedRaw = parsePercentValue(record?.completed_percent);
@@ -380,43 +374,43 @@ export const getDeploymentsColumns = ({
percentRemaining !== null;
return (
<div className="flex flex-col gap-1 leading-tight text-xs">
<div className="flex items-center gap-1.5">
<div className='flex flex-col gap-1 leading-tight text-xs'>
<div className='flex items-center gap-1.5'>
<FaHourglassHalf
className="text-sm"
className='text-sm'
style={{ color: theme.iconColor }}
/>
<Typography.Text className="text-sm font-medium text-[var(--semi-color-text-0)]">
<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}>
<Tag size='small' color={theme.tagColor}>
{percentRemaining}%
</Tag>
) : statusOverride ? (
<Tag size="small" color="grey">
<Tag size='small' color='grey'>
{statusOverride}
</Tag>
) : null}
</div>
{showExtraInfo && (
<div className="flex items-center gap-3 text-[var(--semi-color-text-2)]">
<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]" />
<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]" />
<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 }}>
<div className='text-[10px]' style={{ color: theme.textColor }}>
{t('剩余')} {record.compute_minutes_remaining} {t('分钟')}
</div>
)}
@@ -431,14 +425,16 @@ export const getDeploymentsColumns = ({
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">
<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>
<span className='text-xs text-gray-500 font-medium'>
x{record.hardware_quantity}
</span>
</div>
),
},
@@ -448,7 +444,7 @@ export const getDeploymentsColumns = ({
key: COLUMN_KEYS.created_at,
width: 150,
render: (text) => (
<span className="text-sm text-gray-600">{timestamp2string(text)}</span>
<span className='text-sm text-gray-600'>{timestamp2string(text)}</span>
),
},
{
@@ -459,7 +455,8 @@ export const getDeploymentsColumns = ({
render: (_, record) => {
const { status, id } = record;
const normalizedStatus = normalizeStatus(status);
const isEnded = normalizedStatus === 'completed' || normalizedStatus === 'destroyed';
const isEnded =
normalizedStatus === 'completed' || normalizedStatus === 'destroyed';
const handleDelete = () => {
// Use enhanced confirmation dialog
@@ -471,7 +468,7 @@ export const getDeploymentsColumns = ({
switch (normalizedStatus) {
case 'running':
return {
icon: <FaInfoCircle className="text-xs" />,
icon: <FaInfoCircle className='text-xs' />,
text: t('查看详情'),
onClick: () => onViewDetails?.(record),
type: 'secondary',
@@ -480,7 +477,7 @@ export const getDeploymentsColumns = ({
case 'failed':
case 'error':
return {
icon: <FaPlay className="text-xs" />,
icon: <FaPlay className='text-xs' />,
text: t('重试'),
onClick: () => startDeployment(id),
type: 'primary',
@@ -488,7 +485,7 @@ export const getDeploymentsColumns = ({
};
case 'stopped':
return {
icon: <FaPlay className="text-xs" />,
icon: <FaPlay className='text-xs' />,
text: t('启动'),
onClick: () => startDeployment(id),
type: 'primary',
@@ -497,7 +494,7 @@ export const getDeploymentsColumns = ({
case 'deployment requested':
case 'deploying':
return {
icon: <FaClock className="text-xs" />,
icon: <FaClock className='text-xs' />,
text: t('部署中'),
onClick: () => {},
type: 'secondary',
@@ -506,7 +503,7 @@ export const getDeploymentsColumns = ({
};
case 'pending':
return {
icon: <FaClock className="text-xs" />,
icon: <FaClock className='text-xs' />,
text: t('待部署'),
onClick: () => {},
type: 'secondary',
@@ -515,7 +512,7 @@ export const getDeploymentsColumns = ({
};
case 'termination requested':
return {
icon: <FaClock className="text-xs" />,
icon: <FaClock className='text-xs' />,
text: t('终止中'),
onClick: () => {},
type: 'secondary',
@@ -526,7 +523,7 @@ export const getDeploymentsColumns = ({
case 'destroyed':
default:
return {
icon: <FaInfoCircle className="text-xs" />,
icon: <FaInfoCircle className='text-xs' />,
text: t('已结束'),
onClick: () => {},
type: 'tertiary',
@@ -542,13 +539,13 @@ export const getDeploymentsColumns = ({
if (isEnded) {
return (
<div className="flex w-full items-center justify-start gap-1 pr-2">
<div className='flex w-full items-center justify-start gap-1 pr-2'>
<Button
size="small"
type="tertiary"
theme="borderless"
size='small'
type='tertiary'
theme='borderless'
onClick={() => onViewDetails?.(record)}
icon={<FaInfoCircle className="text-xs" />}
icon={<FaInfoCircle className='text-xs' />}
>
{t('查看详情')}
</Button>
@@ -558,14 +555,22 @@ export const getDeploymentsColumns = ({
// All actions dropdown with enhanced operations
const dropdownItems = [
<Dropdown.Item key="details" onClick={() => onViewDetails?.(record)} icon={<FaInfoCircle />}>
<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 />}>
<Dropdown.Item
key='logs'
onClick={() => onViewLogs?.(record)}
icon={<FaTerminal />}
>
{t('查看日志')}
</Dropdown.Item>,
);
@@ -575,7 +580,11 @@ export const getDeploymentsColumns = ({
if (normalizedStatus === 'running') {
if (onSyncToChannel) {
managementItems.push(
<Dropdown.Item key="sync-channel" onClick={() => onSyncToChannel(record)} icon={<FaLink />}>
<Dropdown.Item
key='sync-channel'
onClick={() => onSyncToChannel(record)}
icon={<FaLink />}
>
{t('同步到渠道')}
</Dropdown.Item>,
);
@@ -583,28 +592,44 @@ export const getDeploymentsColumns = ({
}
if (normalizedStatus === 'failed' || normalizedStatus === 'error') {
managementItems.push(
<Dropdown.Item key="retry" onClick={() => startDeployment(id)} icon={<FaPlay />}>
<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 />}>
<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(<Dropdown.Divider key='management-divider' />);
dropdownItems.push(...managementItems);
}
const configItems = [];
if (!isEnded && (normalizedStatus === 'running' || normalizedStatus === 'deployment requested')) {
if (
!isEnded &&
(normalizedStatus === 'running' ||
normalizedStatus === 'deployment requested')
) {
configItems.push(
<Dropdown.Item key="extend" onClick={() => onExtendDuration?.(record)} icon={<FaPlus />}>
<Dropdown.Item
key='extend'
onClick={() => onExtendDuration?.(record)}
icon={<FaPlus />}
>
{t('延长时长')}
</Dropdown.Item>,
);
@@ -618,13 +643,18 @@ export const getDeploymentsColumns = ({
// }
if (configItems.length > 0) {
dropdownItems.push(<Dropdown.Divider key="config-divider" />);
dropdownItems.push(<Dropdown.Divider key='config-divider' />);
dropdownItems.push(...configItems);
}
if (!isEnded) {
dropdownItems.push(<Dropdown.Divider key="danger-divider" />);
dropdownItems.push(<Dropdown.Divider key='danger-divider' />);
dropdownItems.push(
<Dropdown.Item key="delete" type="danger" onClick={handleDelete} icon={<FaTrash />}>
<Dropdown.Item
key='delete'
type='danger'
onClick={handleDelete}
icon={<FaTrash />}
>
{t('销毁容器')}
</Dropdown.Item>,
);
@@ -634,31 +664,31 @@ export const getDeploymentsColumns = ({
const hasDropdown = dropdownItems.length > 0;
return (
<div className="flex w-full items-center justify-start gap-1 pr-2">
<div className='flex w-full items-center justify-start gap-1 pr-2'>
<Button
size="small"
size='small'
theme={primaryTheme}
type={primaryType}
icon={primaryAction.icon}
onClick={primaryAction.onClick}
className="px-2 text-xs"
className='px-2 text-xs'
disabled={primaryAction.disabled}
>
{primaryAction.text}
</Button>
{hasDropdown && (
<Dropdown
trigger="click"
position="bottomRight"
trigger='click'
position='bottomRight'
render={allActions}
>
<Button
size="small"
theme="light"
type="tertiary"
size='small'
theme='light'
type='tertiary'
icon={<IconMore />}
className="px-1"
className='px-1'
/>
</Dropdown>
)}

View File

@@ -43,7 +43,8 @@ const DeploymentsTable = (deploymentsData) => {
deploymentCount,
compactMode,
visibleColumns,
setSelectedKeys,
rowSelection,
batchOperationsEnabled = true,
handlePageChange,
handlePageSizeChange,
handleRow,
@@ -95,7 +96,10 @@ const DeploymentsTable = (deploymentsData) => {
};
const handleConfirmAction = () => {
if (selectedDeployment && confirmOperation === 'delete') {
if (
selectedDeployment &&
(confirmOperation === 'delete' || confirmOperation === 'destroy')
) {
deleteDeployment(selectedDeployment.id);
}
setShowConfirmDialog(false);
@@ -179,11 +183,7 @@ const DeploymentsTable = (deploymentsData) => {
hidePagination={true}
expandAllRows={false}
onRow={handleRow}
rowSelection={{
onChange: (selectedRowKeys, selectedRows) => {
setSelectedKeys(selectedRows);
},
}}
rowSelection={batchOperationsEnabled ? rowSelection : undefined}
empty={
<Empty
image={<IllustrationNoResult style={{ width: 150, height: 150 }} />}
@@ -235,7 +235,7 @@ const DeploymentsTable = (deploymentsData) => {
onCancel={() => setShowConfirmDialog(false)}
onConfirm={handleConfirmAction}
title={t('确认操作')}
type="danger"
type='danger'
deployment={selectedDeployment}
operation={confirmOperation}
t={t}

View File

@@ -32,9 +32,10 @@ import { createCardProPagination } from '../../../helpers/utils';
const DeploymentsPage = () => {
const deploymentsData = useDeploymentsData();
const isMobile = useIsMobile();
// Create deployment modal state
const [showCreateModal, setShowCreateModal] = useState(false);
const batchOperationsEnabled = false;
const {
// Edit state
@@ -81,7 +82,7 @@ const DeploymentsPage = () => {
visible={showEdit}
handleClose={closeEdit}
/>
<CreateDeploymentModal
visible={showCreateModal}
onCancel={() => setShowCreateModal(false)}
@@ -109,6 +110,7 @@ const DeploymentsPage = () => {
setEditingDeployment={setEditingDeployment}
setShowEdit={setShowEdit}
batchDeleteDeployments={batchDeleteDeployments}
batchOperationsEnabled={batchOperationsEnabled}
compactMode={compactMode}
setCompactMode={setCompactMode}
showCreateModal={showCreateModal}
@@ -138,7 +140,10 @@ const DeploymentsPage = () => {
})}
t={deploymentsData.t}
>
<DeploymentsTable {...deploymentsData} />
<DeploymentsTable
{...deploymentsData}
batchOperationsEnabled={batchOperationsEnabled}
/>
</CardPro>
</>
);

View File

@@ -34,27 +34,21 @@ import {
TextArea,
Switch,
} from '@douyinfe/semi-ui';
import {
FaCog,
import {
FaCog,
FaDocker,
FaKey,
FaTerminal,
FaNetworkWired,
FaExclamationTriangle,
FaPlus,
FaMinus
FaMinus,
} from 'react-icons/fa';
import { API, showError, showSuccess } from '../../../../helpers';
const { Text, Title } = Typography;
const UpdateConfigModal = ({
visible,
onCancel,
deployment,
onSuccess,
t
}) => {
const UpdateConfigModal = ({ visible, onCancel, deployment, onSuccess, t }) => {
const formRef = useRef(null);
const [loading, setLoading] = useState(false);
const [envVars, setEnvVars] = useState([]);
@@ -72,18 +66,21 @@ const UpdateConfigModal = ({
registry_secret: '',
command: '',
};
if (formRef.current) {
formRef.current.setValues(initialValues);
}
// Initialize environment variables
const envVarsList = deployment.container_config?.env_variables
? Object.entries(deployment.container_config.env_variables).map(([key, value]) => ({
key, value: String(value)
}))
const envVarsList = deployment.container_config?.env_variables
? Object.entries(deployment.container_config.env_variables).map(
([key, value]) => ({
key,
value: String(value),
}),
)
: [];
setEnvVars(envVarsList);
setSecretEnvVars([]);
}
@@ -91,23 +88,30 @@ const UpdateConfigModal = ({
const handleUpdate = async () => {
try {
const formValues = formRef.current ? await formRef.current.validate() : {};
const formValues = formRef.current
? await formRef.current.validate()
: {};
setLoading(true);
// Prepare the update payload
const payload = {};
if (formValues.image_url) payload.image_url = formValues.image_url;
if (formValues.traffic_port) payload.traffic_port = formValues.traffic_port;
if (formValues.registry_username) payload.registry_username = formValues.registry_username;
if (formValues.registry_secret) payload.registry_secret = formValues.registry_secret;
if (formValues.traffic_port)
payload.traffic_port = formValues.traffic_port;
if (formValues.registry_username)
payload.registry_username = formValues.registry_username;
if (formValues.registry_secret)
payload.registry_secret = formValues.registry_secret;
if (formValues.command) payload.command = formValues.command;
// Process entrypoint
if (formValues.entrypoint) {
payload.entrypoint = formValues.entrypoint.split(' ').filter(cmd => cmd.trim());
payload.entrypoint = formValues.entrypoint
.split(' ')
.filter((cmd) => cmd.trim());
}
// Process environment variables
if (envVars.length > 0) {
payload.env_variables = envVars.reduce((acc, env) => {
@@ -117,7 +121,7 @@ const UpdateConfigModal = ({
return acc;
}, {});
}
// Process secret environment variables
if (secretEnvVars.length > 0) {
payload.secret_env_variables = secretEnvVars.reduce((acc, env) => {
@@ -128,7 +132,10 @@ const UpdateConfigModal = ({
}, {});
}
const response = await API.put(`/api/deployments/${deployment.id}`, payload);
const response = await API.put(
`/api/deployments/${deployment.id}`,
payload,
);
if (response.data.success) {
showSuccess(t('容器配置更新成功'));
@@ -136,7 +143,11 @@ const UpdateConfigModal = ({
handleCancel();
}
} catch (error) {
showError(t('更新配置失败') + ': ' + (error.response?.data?.message || error.message));
showError(
t('更新配置失败') +
': ' +
(error.response?.data?.message || error.message),
);
} finally {
setLoading(false);
}
@@ -184,8 +195,8 @@ const UpdateConfigModal = ({
return (
<Modal
title={
<div className="flex items-center gap-2">
<FaCog className="text-blue-500" />
<div className='flex items-center gap-2'>
<FaCog className='text-blue-500' />
<span>{t('更新容器配置')}</span>
</div>
}
@@ -196,130 +207,131 @@ const UpdateConfigModal = ({
cancelText={t('取消')}
confirmLoading={loading}
width={700}
className="update-config-modal"
className='update-config-modal'
>
<div className="space-y-4 max-h-[600px] overflow-y-auto">
<div className='space-y-4 max-h-[600px] overflow-y-auto'>
{/* Container Info */}
<Card className="border-0 bg-gray-50">
<div className="flex items-center justify-between">
<Card className='border-0 bg-gray-50'>
<div className='flex items-center justify-between'>
<div>
<Text strong className="text-base">
<Text strong className='text-base'>
{deployment?.container_name}
</Text>
<div className="mt-1">
<Text type="secondary" size="small">
<div className='mt-1'>
<Text type='secondary' size='small'>
ID: {deployment?.id}
</Text>
</div>
</div>
<Tag color="blue">{deployment?.status}</Tag>
<Tag color='blue'>{deployment?.status}</Tag>
</div>
</Card>
{/* Warning Banner */}
<Banner
type="warning"
type='warning'
icon={<FaExclamationTriangle />}
title={t('重要提醒')}
description={
<div className="space-y-2">
<p>{t('更新容器配置可能会导致容器重启,请确保在合适的时间进行此操作。')}</p>
<div className='space-y-2'>
<p>
{t(
'更新容器配置可能会导致容器重启,请确保在合适的时间进行此操作。',
)}
</p>
<p>{t('某些配置更改可能需要几分钟才能生效。')}</p>
</div>
}
/>
<Form
getFormApi={(api) => (formRef.current = api)}
layout="vertical"
>
<Form getFormApi={(api) => (formRef.current = api)} layout='vertical'>
<Collapse defaultActiveKey={['docker']}>
{/* Docker Configuration */}
<Collapse.Panel
<Collapse.Panel
header={
<div className="flex items-center gap-2">
<FaDocker className="text-blue-600" />
<span>{t('Docker 配置')}</span>
<div className='flex items-center gap-2'>
<FaDocker className='text-blue-600' />
<span>{t('镜像配置')}</span>
</div>
}
itemKey="docker"
itemKey='docker'
>
<div className="space-y-4">
<div className='space-y-4'>
<Form.Input
field="image_url"
field='image_url'
label={t('镜像地址')}
placeholder={t('例如: nginx:latest')}
rules={[
{
{
type: 'string',
message: t('请输入有效的镜像地址')
}
message: t('请输入有效的镜像地址'),
},
]}
/>
<Form.Input
field="registry_username"
field='registry_username'
label={t('镜像仓库用户名')}
placeholder={t('如果镜像为私有,请填写用户名')}
/>
<Form.Input
field="registry_secret"
field='registry_secret'
label={t('镜像仓库密码')}
mode="password"
mode='password'
placeholder={t('如果镜像为私有请填写密码或Token')}
/>
</div>
</Collapse.Panel>
{/* Network Configuration */}
<Collapse.Panel
<Collapse.Panel
header={
<div className="flex items-center gap-2">
<FaNetworkWired className="text-green-600" />
<div className='flex items-center gap-2'>
<FaNetworkWired className='text-green-600' />
<span>{t('网络配置')}</span>
</div>
}
itemKey="network"
itemKey='network'
>
<Form.InputNumber
field="traffic_port"
field='traffic_port'
label={t('流量端口')}
placeholder={t('容器对外暴露的端口')}
min={1}
max={65535}
style={{ width: '100%' }}
rules={[
{
{
type: 'number',
min: 1,
max: 65535,
message: t('端口号必须在1-65535之间')
}
message: t('端口号必须在1-65535之间'),
},
]}
/>
</Collapse.Panel>
{/* Startup Configuration */}
<Collapse.Panel
<Collapse.Panel
header={
<div className="flex items-center gap-2">
<FaTerminal className="text-purple-600" />
<div className='flex items-center gap-2'>
<FaTerminal className='text-purple-600' />
<span>{t('启动配置')}</span>
</div>
}
itemKey="startup"
itemKey='startup'
>
<div className="space-y-4">
<div className='space-y-4'>
<Form.Input
field="entrypoint"
field='entrypoint'
label={t('启动命令 (Entrypoint)')}
placeholder={t('例如: /bin/bash -c "python app.py"')}
helpText={t('多个命令用空格分隔')}
/>
<Form.Input
field="command"
field='command'
label={t('运行命令 (Command)')}
placeholder={t('容器启动后执行的命令')}
/>
@@ -327,34 +339,34 @@ const UpdateConfigModal = ({
</Collapse.Panel>
{/* Environment Variables */}
<Collapse.Panel
<Collapse.Panel
header={
<div className="flex items-center gap-2">
<FaKey className="text-orange-600" />
<div className='flex items-center gap-2'>
<FaKey className='text-orange-600' />
<span>{t('环境变量')}</span>
<Tag size="small">{envVars.length}</Tag>
<Tag size='small'>{envVars.length}</Tag>
</div>
}
itemKey="env"
itemKey='env'
>
<div className="space-y-4">
<div className='space-y-4'>
{/* Regular Environment Variables */}
<div>
<div className="flex items-center justify-between mb-3">
<div className='flex items-center justify-between mb-3'>
<Text strong>{t('普通环境变量')}</Text>
<Button
size="small"
size='small'
icon={<FaPlus />}
onClick={addEnvVar}
theme="borderless"
type="primary"
theme='borderless'
type='primary'
>
{t('添加')}
</Button>
</div>
{envVars.map((envVar, index) => (
<div key={index} className="flex items-end gap-2 mb-2">
<div key={index} className='flex items-end gap-2 mb-2'>
<Input
placeholder={t('变量名')}
value={envVar.key}
@@ -365,22 +377,24 @@ const UpdateConfigModal = ({
<Input
placeholder={t('变量值')}
value={envVar.value}
onChange={(value) => updateEnvVar(index, 'value', value)}
onChange={(value) =>
updateEnvVar(index, 'value', value)
}
style={{ flex: 2 }}
/>
<Button
size="small"
size='small'
icon={<FaMinus />}
onClick={() => removeEnvVar(index)}
theme="borderless"
type="danger"
theme='borderless'
type='danger'
/>
</div>
))}
{envVars.length === 0 && (
<div className="text-center text-gray-500 py-4 border-2 border-dashed border-gray-300 rounded-lg">
<Text type="secondary">{t('暂无环境变量')}</Text>
<div className='text-center text-gray-500 py-4 border-2 border-dashed border-gray-300 rounded-lg'>
<Text type='secondary'>{t('暂无环境变量')}</Text>
</div>
)}
</div>
@@ -389,61 +403,67 @@ const UpdateConfigModal = ({
{/* Secret Environment Variables */}
<div>
<div className="flex items-center justify-between mb-3">
<div className="flex items-center gap-2">
<div className='flex items-center justify-between mb-3'>
<div className='flex items-center gap-2'>
<Text strong>{t('机密环境变量')}</Text>
<Tag size="small" type="danger">
<Tag size='small' type='danger'>
{t('加密存储')}
</Tag>
</div>
<Button
size="small"
size='small'
icon={<FaPlus />}
onClick={addSecretEnvVar}
theme="borderless"
type="danger"
theme='borderless'
type='danger'
>
{t('添加')}
</Button>
</div>
{secretEnvVars.map((envVar, index) => (
<div key={index} className="flex items-end gap-2 mb-2">
<div key={index} className='flex items-end gap-2 mb-2'>
<Input
placeholder={t('变量名')}
value={envVar.key}
onChange={(value) => updateSecretEnvVar(index, 'key', value)}
onChange={(value) =>
updateSecretEnvVar(index, 'key', value)
}
style={{ flex: 1 }}
/>
<Text>=</Text>
<Input
mode="password"
mode='password'
placeholder={t('变量值')}
value={envVar.value}
onChange={(value) => updateSecretEnvVar(index, 'value', value)}
onChange={(value) =>
updateSecretEnvVar(index, 'value', value)
}
style={{ flex: 2 }}
/>
<Button
size="small"
size='small'
icon={<FaMinus />}
onClick={() => removeSecretEnvVar(index)}
theme="borderless"
type="danger"
theme='borderless'
type='danger'
/>
</div>
))}
{secretEnvVars.length === 0 && (
<div className="text-center text-gray-500 py-4 border-2 border-dashed border-red-200 rounded-lg bg-red-50">
<Text type="secondary">{t('暂无机密环境变量')}</Text>
<div className='text-center text-gray-500 py-4 border-2 border-dashed border-red-200 rounded-lg bg-red-50'>
<Text type='secondary'>{t('暂无机密环境变量')}</Text>
</div>
)}
<Banner
type="info"
type='info'
title={t('机密环境变量说明')}
description={t('机密环境变量将被加密存储适用于存储密码、API密钥等敏感信息。')}
size="small"
description={t(
'机密环境变量将被加密存储适用于存储密码、API密钥等敏感信息。',
)}
size='small'
/>
</div>
</div>
@@ -452,16 +472,18 @@ const UpdateConfigModal = ({
</Form>
{/* Final Warning */}
<div className="bg-yellow-50 border border-yellow-200 rounded-lg p-3">
<div className="flex items-start gap-2">
<FaExclamationTriangle className="text-yellow-600 mt-0.5" />
<div className='bg-yellow-50 border border-yellow-200 rounded-lg p-3'>
<div className='flex items-start gap-2'>
<FaExclamationTriangle className='text-yellow-600 mt-0.5' />
<div>
<Text strong className="text-yellow-800">
<Text strong className='text-yellow-800'>
{t('配置更新确认')}
</Text>
<div className="mt-1">
<Text size="small" className="text-yellow-700">
{t('更新配置后,容器可能需要重启以应用新的设置。请确保您了解这些更改的影响。')}
<div className='mt-1'>
<Text size='small' className='text-yellow-700'>
{t(
'更新配置后,容器可能需要重启以应用新的设置。请确保您了解这些更改的影响。',
)}
</Text>
</div>
</div>
@@ -472,4 +494,4 @@ const UpdateConfigModal = ({
);
};
export default UpdateConfigModal;
export default UpdateConfigModal;

View File

@@ -31,8 +31,8 @@ import {
Badge,
Tooltip,
} from '@douyinfe/semi-ui';
import {
FaInfoCircle,
import {
FaInfoCircle,
FaServer,
FaClock,
FaMapMarkerAlt,
@@ -43,16 +43,16 @@ import {
FaLink,
} from 'react-icons/fa';
import { IconRefresh } from '@douyinfe/semi-icons';
import { API, showError, showSuccess, timestamp2string } from '../../../../helpers';
import {
API,
showError,
showSuccess,
timestamp2string,
} from '../../../../helpers';
const { Text, Title } = Typography;
const ViewDetailsModal = ({
visible,
onCancel,
deployment,
t
}) => {
const ViewDetailsModal = ({ visible, onCancel, deployment, t }) => {
const [details, setDetails] = useState(null);
const [loading, setLoading] = useState(false);
const [containers, setContainers] = useState([]);
@@ -60,7 +60,7 @@ const ViewDetailsModal = ({
const fetchDetails = async () => {
if (!deployment?.id) return;
setLoading(true);
try {
const response = await API.get(`/api/deployments/${deployment.id}`);
@@ -68,7 +68,11 @@ const ViewDetailsModal = ({
setDetails(response.data.data);
}
} catch (error) {
showError(t('获取详情失败') + ': ' + (error.response?.data?.message || error.message));
showError(
t('获取详情失败') +
': ' +
(error.response?.data?.message || error.message),
);
} finally {
setLoading(false);
}
@@ -79,12 +83,18 @@ const ViewDetailsModal = ({
setContainersLoading(true);
try {
const response = await API.get(`/api/deployments/${deployment.id}/containers`);
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));
showError(
t('获取容器信息失败') +
': ' +
(error.response?.data?.message || error.message),
);
} finally {
setContainersLoading(false);
}
@@ -102,7 +112,7 @@ const ViewDetailsModal = ({
const handleCopyId = () => {
navigator.clipboard.writeText(deployment?.id);
showSuccess(t('ID已复制到剪贴板'));
showSuccess(t('已复制 ID 到剪贴板'));
};
const handleRefresh = () => {
@@ -112,12 +122,16 @@ const ViewDetailsModal = ({
const getStatusConfig = (status) => {
const statusConfig = {
'running': { color: 'green', text: '运行中', icon: '🟢' },
'completed': { color: 'green', text: '已完成', icon: '✅' },
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: '❌' }
'termination requested': {
color: 'orange',
text: '终止请求中',
icon: '⏸️',
},
destroyed: { color: 'red', text: '已销毁', icon: '🔴' },
failed: { color: 'red', text: '失败', icon: '❌' },
};
return statusConfig[status] || { color: 'grey', text: status, icon: '❓' };
};
@@ -127,149 +141,167 @@ const ViewDetailsModal = ({
return (
<Modal
title={
<div className="flex items-center gap-2">
<FaInfoCircle className="text-blue-500" />
<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 />}
<div className='flex justify-between'>
<Button
icon={<IconRefresh />}
onClick={handleRefresh}
loading={loading || containersLoading}
theme="borderless"
theme='borderless'
>
{t('刷新')}
</Button>
<Button onClick={onCancel}>
{t('关闭')}
</Button>
<Button onClick={onCancel}>{t('关闭')}</Button>
</div>
}
width={800}
className="deployment-details-modal"
className='deployment-details-modal'
>
{loading && !details ? (
<div className="flex items-center justify-center py-12">
<Spin size="large" tip={t('加载详情中...')} />
<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">
<div className='space-y-4 max-h-[600px] overflow-y-auto'>
{/* Basic Info */}
<Card
<Card
title={
<div className="flex items-center gap-2">
<FaServer className="text-blue-500" />
<div className='flex items-center gap-2'>
<FaServer className='text-blue-500' />
<span>{t('基本信息')}</span>
</div>
}
className="border-0 shadow-sm"
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}
<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>
<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)
}
]} />
),
},
{
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
<Card
title={
<div className="flex items-center gap-2">
<FaChartLine className="text-green-500" />
<div className='flex items-center gap-2'>
<FaChartLine className='text-green-500' />
<span>{t('硬件与性能')}</span>
</div>
}
className="border-0 shadow-sm"
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>
)
}
]} />
<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">
<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'}
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 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>
@@ -277,56 +309,70 @@ const ViewDetailsModal = ({
{/* Container Configuration */}
{details.container_config && (
<Card
<Card
title={
<div className="flex items-center gap-2">
<FaDocker className="text-blue-600" />
<div className='flex items-center gap-2'>
<FaDocker className='text-blue-600' />
<span>{t('容器配置')}</span>
</div>
}
className="border-0 shadow-sm"
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>
)
}
]} />
<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>
))}
{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>
)}
)}
</div>
</Card>
)}
@@ -334,50 +380,63 @@ const ViewDetailsModal = ({
{/* Containers List */}
<Card
title={
<div className="flex items-center gap-2">
<FaServer className="text-indigo-500" />
<div className='flex items-center gap-2'>
<FaServer className='text-indigo-500' />
<span>{t('容器实例')}</span>
</div>
}
className="border-0 shadow-sm"
className='border-0 shadow-sm'
>
{containersLoading ? (
<div className="flex items-center justify-center py-6">
<div className='flex items-center justify-center py-6'>
<Spin tip={t('加载容器信息中...')} />
</div>
) : containers.length === 0 ? (
<Empty description={t('暂无容器信息')} image={Empty.PRESENTED_IMAGE_SIMPLE} />
<Empty
description={t('暂无容器信息')}
image={Empty.PRESENTED_IMAGE_SIMPLE}
/>
) : (
<div className="space-y-3">
<div className='space-y-3'>
{containers.map((ctr) => (
<Card
key={ctr.container_id}
className="bg-gray-50 border border-gray-100"
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">
<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 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 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">
<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')}
size='small'
theme='light'
onClick={() =>
window.open(
ctr.public_url,
'_blank',
'noopener,noreferrer',
)
}
>
{t('访问容器')}
</Button>
@@ -387,17 +446,26 @@ const ViewDetailsModal = ({
</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">
<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">
<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) : '--'}
<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">
<span className='text-gray-700 break-all flex-1'>
{event.message || '--'}
</span>
</div>
@@ -413,21 +481,23 @@ const ViewDetailsModal = ({
{/* Location Information */}
{details.locations && details.locations.length > 0 && (
<Card
<Card
title={
<div className="flex items-center gap-2">
<FaMapMarkerAlt className="text-orange-500" />
<div className='flex items-center gap-2'>
<FaMapMarkerAlt className='text-orange-500' />
<span>{t('部署位置')}</span>
</div>
}
className="border-0 shadow-sm"
className='border-0 shadow-sm'
>
<div className="flex flex-wrap gap-2">
<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">
<Tag key={location.id} color='orange' size='large'>
<div className='flex items-center gap-1'>
<span>🌍</span>
<span>{location.name} ({location.iso2})</span>
<span>
{location.name} ({location.iso2})
</span>
</div>
</Tag>
))}
@@ -436,68 +506,82 @@ const ViewDetailsModal = ({
)}
{/* Cost Information */}
<Card
<Card
title={
<div className="flex items-center gap-2">
<FaMoneyBillWave className="text-green-500" />
<div className='flex items-center gap-2'>
<FaMoneyBillWave className='text-green-500' />
<span>{t('费用信息')}</span>
</div>
}
className="border-0 shadow-sm"
className='border-0 shadow-sm'
>
<div className="space-y-3">
<div className="flex items-center justify-between p-3 bg-green-50 rounded-lg">
<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 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 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 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
<Card
title={
<div className="flex items-center gap-2">
<FaClock className="text-purple-500" />
<div className='flex items-center gap-2'>
<FaClock className='text-purple-500' />
<span>{t('时间信息')}</span>
</div>
}
className="border-0 shadow-sm"
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>
<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
{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
<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>
<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>
<div className='flex items-center justify-between'>
<Text type='secondary'>{t('最后更新')}:</Text>
<Text>{timestamp2string(details.updated_at)}</Text>
</div>
</div>
@@ -505,7 +589,7 @@ const ViewDetailsModal = ({
</Card>
</div>
) : (
<Empty
<Empty
image={Empty.PRESENTED_IMAGE_SIMPLE}
description={t('无法获取容器详情')}
/>

View File

@@ -44,18 +44,19 @@ import {
FaLink,
} from 'react-icons/fa';
import { IconRefresh, IconDownload } from '@douyinfe/semi-icons';
import { API, showError, showSuccess, copy, timestamp2string } from '../../../../helpers';
import {
API,
showError,
showSuccess,
copy,
timestamp2string,
} from '../../../../helpers';
const { Text } = Typography;
const ALL_CONTAINERS = '__all__';
const ViewLogsModal = ({
visible,
onCancel,
deployment,
t
}) => {
const ViewLogsModal = ({ visible, onCancel, deployment, t }) => {
const [logLines, setLogLines] = useState([]);
const [loading, setLoading] = useState(false);
const [autoRefresh, setAutoRefresh] = useState(false);
@@ -63,12 +64,13 @@ const ViewLogsModal = ({
const [following, setFollowing] = useState(false);
const [containers, setContainers] = useState([]);
const [containersLoading, setContainersLoading] = useState(false);
const [selectedContainerId, setSelectedContainerId] = useState(ALL_CONTAINERS);
const [selectedContainerId, setSelectedContainerId] =
useState(ALL_CONTAINERS);
const [containerDetails, setContainerDetails] = useState(null);
const [containerDetailsLoading, setContainerDetailsLoading] = useState(false);
const [streamFilter, setStreamFilter] = useState('stdout');
const [lastUpdatedAt, setLastUpdatedAt] = useState(null);
const logContainerRef = useRef(null);
const autoRefreshRef = useRef(null);
@@ -100,7 +102,10 @@ const ViewLogsModal = ({
const fetchLogs = async (containerIdOverride = undefined) => {
if (!deployment?.id) return;
const containerId = typeof containerIdOverride === 'string' ? containerIdOverride : selectedContainerId;
const containerId =
typeof containerIdOverride === 'string'
? containerIdOverride
: selectedContainerId;
if (!containerId || containerId === ALL_CONTAINERS) {
setLogLines([]);
@@ -120,10 +125,13 @@ const ViewLogsModal = ({
}
if (following) params.append('follow', 'true');
const response = await API.get(`/api/deployments/${deployment.id}/logs?${params}`);
const response = await API.get(
`/api/deployments/${deployment.id}/logs?${params}`,
);
if (response.data.success) {
const rawContent = typeof response.data.data === 'string' ? response.data.data : '';
const rawContent =
typeof response.data.data === 'string' ? response.data.data : '';
const normalized = rawContent.replace(/\r\n?/g, '\n');
const lines = normalized ? normalized.split('\n') : [];
@@ -133,7 +141,11 @@ const ViewLogsModal = ({
setTimeout(scrollToBottom, 100);
}
} catch (error) {
showError(t('获取日志失败') + ': ' + (error.response?.data?.message || error.message));
showError(
t('获取日志失败') +
': ' +
(error.response?.data?.message || error.message),
);
} finally {
setLoading(false);
}
@@ -144,14 +156,19 @@ const ViewLogsModal = ({
setContainersLoading(true);
try {
const response = await API.get(`/api/deployments/${deployment.id}/containers`);
const response = await API.get(
`/api/deployments/${deployment.id}/containers`,
);
if (response.data.success) {
const list = response.data.data?.containers || [];
setContainers(list);
setSelectedContainerId((current) => {
if (current !== ALL_CONTAINERS && list.some(item => item.container_id === current)) {
if (
current !== ALL_CONTAINERS &&
list.some((item) => item.container_id === current)
) {
return current;
}
@@ -163,7 +180,11 @@ const ViewLogsModal = ({
}
}
} catch (error) {
showError(t('获取容器列表失败') + ': ' + (error.response?.data?.message || error.message));
showError(
t('获取容器列表失败') +
': ' +
(error.response?.data?.message || error.message),
);
} finally {
setContainersLoading(false);
}
@@ -177,13 +198,19 @@ const ViewLogsModal = ({
setContainerDetailsLoading(true);
try {
const response = await API.get(`/api/deployments/${deployment.id}/containers/${containerId}`);
const response = await API.get(
`/api/deployments/${deployment.id}/containers/${containerId}`,
);
if (response.data.success) {
setContainerDetails(response.data.data || null);
}
} catch (error) {
showError(t('获取容器详情失败') + ': ' + (error.response?.data?.message || error.message));
showError(
t('获取容器详情失败') +
': ' +
(error.response?.data?.message || error.message),
);
} finally {
setContainerDetailsLoading(false);
}
@@ -205,13 +232,14 @@ const ViewLogsModal = ({
const renderContainerStatusTag = (status) => {
if (!status) {
return (
<Tag color="grey" size="small">
<Tag color='grey' size='small'>
{t('未知状态')}
</Tag>
);
}
const normalized = typeof status === 'string' ? status.trim().toLowerCase() : '';
const normalized =
typeof status === 'string' ? status.trim().toLowerCase() : '';
const statusMap = {
running: { color: 'green', label: '运行中' },
pending: { color: 'orange', label: '准备中' },
@@ -225,15 +253,16 @@ const ViewLogsModal = ({
const config = statusMap[normalized] || { color: 'grey', label: status };
return (
<Tag color={config.color} size="small">
<Tag color={config.color} size='small'>
{t(config.label)}
</Tag>
);
};
const currentContainer = selectedContainerId !== ALL_CONTAINERS
? containers.find((ctr) => ctr.container_id === selectedContainerId)
: null;
const currentContainer =
selectedContainerId !== ALL_CONTAINERS
? containers.find((ctr) => ctr.container_id === selectedContainerId)
: null;
const refreshLogs = () => {
if (selectedContainerId && selectedContainerId !== ALL_CONTAINERS) {
@@ -254,9 +283,10 @@ const ViewLogsModal = ({
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
const safeContainerId = selectedContainerId && selectedContainerId !== ALL_CONTAINERS
? selectedContainerId.replace(/[^a-zA-Z0-9_-]/g, '-')
: '';
const safeContainerId =
selectedContainerId && selectedContainerId !== ALL_CONTAINERS
? selectedContainerId.replace(/[^a-zA-Z0-9_-]/g, '-')
: '';
const fileName = safeContainerId
? `deployment-${deployment.id}-container-${safeContainerId}-logs.txt`
: `deployment-${deployment.id}-logs.txt`;
@@ -265,7 +295,7 @@ const ViewLogsModal = ({
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
showSuccess(t('日志已下载'));
};
@@ -346,14 +376,15 @@ const ViewLogsModal = ({
// Filter logs based on search term
const filteredLogs = logLines
.map((line) => line ?? '')
.filter((line) =>
!searchTerm || line.toLowerCase().includes(searchTerm.toLowerCase()),
.filter(
(line) =>
!searchTerm || line.toLowerCase().includes(searchTerm.toLowerCase()),
);
const renderLogEntry = (line, index) => (
<div
key={`${index}-${line.slice(0, 20)}`}
className="py-1 px-3 hover:bg-gray-50 font-mono text-sm border-b border-gray-100 whitespace-pre-wrap break-words"
className='py-1 px-3 hover:bg-gray-50 font-mono text-sm border-b border-gray-100 whitespace-pre-wrap break-words'
>
{line}
</div>
@@ -362,10 +393,10 @@ const ViewLogsModal = ({
return (
<Modal
title={
<div className="flex items-center gap-2">
<FaTerminal className="text-blue-500" />
<div className='flex items-center gap-2'>
<FaTerminal className='text-blue-500' />
<span>{t('容器日志')}</span>
<Text type="secondary" size="small">
<Text type='secondary' size='small'>
- {deployment?.container_name || deployment?.id}
</Text>
</div>
@@ -375,13 +406,13 @@ const ViewLogsModal = ({
footer={null}
width={1000}
height={700}
className="logs-modal"
className='logs-modal'
style={{ top: 20 }}
>
<div className="flex flex-col h-full max-h-[600px]">
<div className='flex flex-col h-full max-h-[600px]'>
{/* Controls */}
<Card className="mb-4 border-0 shadow-sm">
<div className="flex items-center justify-between flex-wrap gap-3">
<Card className='mb-4 border-0 shadow-sm'>
<div className='flex items-center justify-between flex-wrap gap-3'>
<Space wrap>
<Select
prefix={<FaServer />}
@@ -389,7 +420,7 @@ const ViewLogsModal = ({
value={selectedContainerId}
onChange={handleContainerChange}
style={{ width: 240 }}
size="small"
size='small'
loading={containersLoading}
dropdownStyle={{ maxHeight: 320, overflowY: 'auto' }}
>
@@ -397,10 +428,15 @@ const ViewLogsModal = ({
{t('全部容器')}
</Select.Option>
{containers.map((ctr) => (
<Select.Option key={ctr.container_id} value={ctr.container_id}>
<div className="flex flex-col">
<span className="font-mono text-xs">{ctr.container_id}</span>
<span className="text-xs text-gray-500">
<Select.Option
key={ctr.container_id}
value={ctr.container_id}
>
<div className='flex flex-col'>
<span className='font-mono text-xs'>
{ctr.container_id}
</span>
<span className='text-xs text-gray-500'>
{ctr.brand_name || 'IO.NET'}
{ctr.hardware ? ` · ${ctr.hardware}` : ''}
</span>
@@ -415,114 +451,118 @@ const ViewLogsModal = ({
value={searchTerm}
onChange={setSearchTerm}
style={{ width: 200 }}
size="small"
size='small'
/>
<Space align="center" className="ml-2">
<Text size="small" type="secondary">
<Space align='center' className='ml-2'>
<Text size='small' type='secondary'>
{t('日志流')}
</Text>
<Radio.Group
type="button"
size="small"
type='button'
size='small'
value={streamFilter}
onChange={handleStreamChange}
>
<Radio value="stdout">STDOUT</Radio>
<Radio value="stderr">STDERR</Radio>
<Radio value='stdout'>STDOUT</Radio>
<Radio value='stderr'>STDERR</Radio>
</Radio.Group>
</Space>
<div className="flex items-center gap-2">
<div className='flex items-center gap-2'>
<Switch
checked={autoRefresh}
onChange={setAutoRefresh}
size="small"
size='small'
/>
<Text size="small">{t('自动刷新')}</Text>
<Text size='small'>{t('自动刷新')}</Text>
</div>
<div className="flex items-center gap-2">
<div className='flex items-center gap-2'>
<Switch
checked={following}
onChange={setFollowing}
size="small"
size='small'
/>
<Text size="small">{t('跟随日志')}</Text>
<Text size='small'>{t('跟随日志')}</Text>
</div>
</Space>
<Space>
<Tooltip content={t('刷新日志')}>
<Button
icon={<IconRefresh />}
<Button
icon={<IconRefresh />}
onClick={refreshLogs}
loading={loading}
size="small"
theme="borderless"
size='small'
theme='borderless'
/>
</Tooltip>
<Tooltip content={t('复制日志')}>
<Button
icon={<FaCopy />}
<Button
icon={<FaCopy />}
onClick={copyAllLogs}
size="small"
theme="borderless"
size='small'
theme='borderless'
disabled={logLines.length === 0}
/>
</Tooltip>
<Tooltip content={t('下载日志')}>
<Button
icon={<IconDownload />}
<Button
icon={<IconDownload />}
onClick={downloadLogs}
size="small"
theme="borderless"
size='small'
theme='borderless'
disabled={logLines.length === 0}
/>
</Tooltip>
</Space>
</div>
{/* Status Info */}
<Divider margin="12px" />
<div className="flex items-center justify-between">
<Space size="large">
<Text size="small" type="secondary">
<Divider margin='12px' />
<div className='flex items-center justify-between'>
<Space size='large'>
<Text size='small' type='secondary'>
{t('共 {{count}} 条日志', { count: logLines.length })}
</Text>
{searchTerm && (
<Text size="small" type="secondary">
{t('(筛选后显示 {{count}} 条)', { count: filteredLogs.length })}
<Text size='small' type='secondary'>
{t('(筛选后显示 {{count}} 条)', {
count: filteredLogs.length,
})}
</Text>
)}
{autoRefresh && (
<Tag color="green" size="small">
<FaClock className="mr-1" />
<Tag color='green' size='small'>
<FaClock className='mr-1' />
{t('自动刷新中')}
</Tag>
)}
</Space>
<Text size="small" type="secondary">
<Text size='small' type='secondary'>
{t('状态')}: {deployment?.status || 'unknown'}
</Text>
</div>
{selectedContainerId !== ALL_CONTAINERS && (
<>
<Divider margin="12px" />
<div className="flex flex-col gap-3">
<div className="flex items-center justify-between flex-wrap gap-2">
<Divider margin='12px' />
<div className='flex flex-col gap-3'>
<div className='flex items-center justify-between flex-wrap gap-2'>
<Space>
<Tag color="blue" size="small">
<Tag color='blue' size='small'>
{t('容器')}
</Tag>
<Text className="font-mono text-xs">
<Text className='font-mono text-xs'>
{selectedContainerId}
</Text>
{renderContainerStatusTag(containerDetails?.status || currentContainer?.status)}
{renderContainerStatusTag(
containerDetails?.status || currentContainer?.status,
)}
</Space>
<Space>
@@ -530,9 +570,11 @@ const ViewLogsModal = ({
<Tooltip content={containerDetails.public_url}>
<Button
icon={<FaLink />}
size="small"
theme="borderless"
onClick={() => window.open(containerDetails.public_url, '_blank')}
size='small'
theme='borderless'
onClick={() =>
window.open(containerDetails.public_url, '_blank')
}
/>
</Tooltip>
)}
@@ -540,8 +582,8 @@ const ViewLogsModal = ({
<Button
icon={<IconRefresh />}
onClick={refreshContainerDetails}
size="small"
theme="borderless"
size='small'
theme='borderless'
loading={containerDetailsLoading}
/>
</Tooltip>
@@ -549,27 +591,36 @@ const ViewLogsModal = ({
</div>
{containerDetailsLoading ? (
<div className="flex items-center justify-center py-6">
<div className='flex items-center justify-center py-6'>
<Spin tip={t('加载容器详情中...')} />
</div>
) : containerDetails ? (
<div className="grid gap-4 md:grid-cols-2 text-sm">
<div className="flex items-center gap-2">
<FaInfoCircle className="text-blue-500" />
<Text type="secondary">{t('硬件')}</Text>
<div className='grid gap-4 md:grid-cols-2 text-sm'>
<div className='flex items-center gap-2'>
<FaInfoCircle className='text-blue-500' />
<Text type='secondary'>{t('硬件')}</Text>
<Text>
{containerDetails?.brand_name || currentContainer?.brand_name || t('未知品牌')}
{(containerDetails?.hardware || currentContainer?.hardware) ? ` · ${containerDetails?.hardware || currentContainer?.hardware}` : ''}
{containerDetails?.brand_name ||
currentContainer?.brand_name ||
t('未知品牌')}
{containerDetails?.hardware ||
currentContainer?.hardware
? ` · ${containerDetails?.hardware || currentContainer?.hardware}`
: ''}
</Text>
</div>
<div className="flex items-center gap-2">
<FaServer className="text-purple-500" />
<Text type="secondary">{t('GPU/容器')}</Text>
<Text>{containerDetails?.gpus_per_container ?? currentContainer?.gpus_per_container ?? 0}</Text>
<div className='flex items-center gap-2'>
<FaServer className='text-purple-500' />
<Text type='secondary'>{t('GPU/容器')}</Text>
<Text>
{containerDetails?.gpus_per_container ??
currentContainer?.gpus_per_container ??
0}
</Text>
</div>
<div className="flex items-center gap-2">
<FaClock className="text-orange-500" />
<Text type="secondary">{t('创建时间')}</Text>
<div className='flex items-center gap-2'>
<FaClock className='text-orange-500' />
<Text type='secondary'>{t('创建时间')}</Text>
<Text>
{containerDetails?.created_at
? timestamp2string(containerDetails.created_at)
@@ -578,51 +629,64 @@ const ViewLogsModal = ({
: t('未知')}
</Text>
</div>
<div className="flex items-center gap-2">
<FaInfoCircle className="text-green-500" />
<Text type="secondary">{t('运行时长')}</Text>
<Text>{containerDetails?.uptime_percent ?? currentContainer?.uptime_percent ?? 0}%</Text>
<div className='flex items-center gap-2'>
<FaInfoCircle className='text-green-500' />
<Text type='secondary'>{t('运行时长')}</Text>
<Text>
{containerDetails?.uptime_percent ??
currentContainer?.uptime_percent ??
0}
%
</Text>
</div>
</div>
) : (
<Text size="small" type="secondary">
<Text size='small' type='secondary'>
{t('暂无容器详情')}
</Text>
)}
{containerDetails?.events && containerDetails.events.length > 0 && (
<div className="bg-gray-50 rounded-lg p-3">
<Text size="small" type="secondary">
{t('最近事件')}
</Text>
<div className="mt-2 space-y-2 max-h-32 overflow-y-auto">
{containerDetails.events.slice(0, 5).map((event, index) => (
<div key={`${event.time}-${index}`} className="flex gap-3 text-xs font-mono">
<span className="text-gray-500">
{event.time ? timestamp2string(event.time) : '--'}
</span>
<span className="text-gray-700 break-all flex-1">
{event.message}
</span>
</div>
))}
{containerDetails?.events &&
containerDetails.events.length > 0 && (
<div className='bg-gray-50 rounded-lg p-3'>
<Text size='small' type='secondary'>
{t('最近事件')}
</Text>
<div className='mt-2 space-y-2 max-h-32 overflow-y-auto'>
{containerDetails.events
.slice(0, 5)
.map((event, index) => (
<div
key={`${event.time}-${index}`}
className='flex gap-3 text-xs font-mono'
>
<span className='text-gray-500'>
{event.time
? timestamp2string(event.time)
: '--'}
</span>
<span className='text-gray-700 break-all flex-1'>
{event.message}
</span>
</div>
))}
</div>
</div>
</div>
)}
)}
</div>
</>
)}
</Card>
{/* Log Content */}
<div className="flex-1 flex flex-col border rounded-lg bg-gray-50 overflow-hidden">
<div
<div className='flex-1 flex flex-col border rounded-lg bg-gray-50 overflow-hidden'>
<div
ref={logContainerRef}
className="flex-1 overflow-y-auto bg-white"
className='flex-1 overflow-y-auto bg-white'
style={{ maxHeight: '400px' }}
>
{loading && logLines.length === 0 ? (
<div className="flex items-center justify-center p-8">
<div className='flex items-center justify-center p-8'>
<Spin tip={t('加载日志中...')} />
</div>
) : filteredLogs.length === 0 ? (
@@ -639,15 +703,14 @@ const ViewLogsModal = ({
</div>
)}
</div>
{/* Footer status */}
{logLines.length > 0 && (
<div className="flex items-center justify-between px-3 py-2 bg-gray-50 border-t text-xs text-gray-500">
<div className='flex items-center justify-between px-3 py-2 bg-gray-50 border-t text-xs text-gray-500'>
<span>{following ? t('正在跟随最新日志') : t('日志已加载')}</span>
<span>
{following ? t('正在跟随最新日志') : t('日志已加载')}
</span>
<span>
{t('最后更新')}: {lastUpdatedAt ? lastUpdatedAt.toLocaleTimeString() : '--'}
{t('最后更新')}:{' '}
{lastUpdatedAt ? lastUpdatedAt.toLocaleTimeString() : '--'}
</span>
</div>
)}