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 e8aaed440c
commit 22d0b73d21
29 changed files with 5258 additions and 2653 deletions

View File

@@ -1,6 +1,8 @@
package controller
import (
"bytes"
"encoding/json"
"fmt"
"strconv"
"strings"
@@ -23,6 +25,20 @@ func getIoAPIKey(c *gin.Context) (string, bool) {
return apiKey, true
}
func GetModelDeploymentSettings(c *gin.Context) {
common.OptionMapRWMutex.RLock()
enabled := common.OptionMap["model_deployment.ionet.enabled"] == "true"
hasAPIKey := strings.TrimSpace(common.OptionMap["model_deployment.ionet.api_key"]) != ""
common.OptionMapRWMutex.RUnlock()
common.ApiSuccess(c, gin.H{
"provider": "io.net",
"enabled": enabled,
"configured": hasAPIKey,
"can_connect": enabled && hasAPIKey,
})
}
func getIoClient(c *gin.Context) (*ionet.Client, bool) {
apiKey, ok := getIoAPIKey(c)
if !ok {
@@ -44,15 +60,28 @@ func TestIoNetConnection(c *gin.Context) {
APIKey string `json:"api_key"`
}
if err := c.ShouldBindJSON(&req); err != nil {
common.ApiErrorMsg(c, "invalid request payload")
rawBody, err := c.GetRawData()
if err != nil {
common.ApiError(c, err)
return
}
if len(bytes.TrimSpace(rawBody)) > 0 {
if err := json.Unmarshal(rawBody, &req); err != nil {
common.ApiErrorMsg(c, "invalid request payload")
return
}
}
apiKey := strings.TrimSpace(req.APIKey)
if apiKey == "" {
common.ApiErrorMsg(c, "api_key is required")
return
common.OptionMapRWMutex.RLock()
storedKey := strings.TrimSpace(common.OptionMap["model_deployment.ionet.api_key"])
common.OptionMapRWMutex.RUnlock()
if storedKey == "" {
common.ApiErrorMsg(c, "api_key is required")
return
}
apiKey = storedKey
}
client := ionet.NewEnterpriseClient(apiKey)

View File

@@ -20,7 +20,11 @@ func GetOptions(c *gin.Context) {
var options []*model.Option
common.OptionMapRWMutex.Lock()
for k, v := range common.OptionMap {
if strings.HasSuffix(k, "Token") || strings.HasSuffix(k, "Secret") || strings.HasSuffix(k, "Key") {
if strings.HasSuffix(k, "Token") ||
strings.HasSuffix(k, "Secret") ||
strings.HasSuffix(k, "Key") ||
strings.HasSuffix(k, "secret") ||
strings.HasSuffix(k, "api_key") {
continue
}
options = append(options, &model.Option{

View File

@@ -269,24 +269,18 @@ func SetApiRouter(router *gin.Engine) {
deploymentsRoute := apiRouter.Group("/deployments")
deploymentsRoute.Use(middleware.AdminAuth())
{
// List and search deployments
deploymentsRoute.GET("/settings", controller.GetModelDeploymentSettings)
deploymentsRoute.POST("/settings/test-connection", controller.TestIoNetConnection)
deploymentsRoute.GET("/", controller.GetAllDeployments)
deploymentsRoute.GET("/search", controller.SearchDeployments)
// Connection utilities
deploymentsRoute.POST("/test-connection", controller.TestIoNetConnection)
// Resource and configuration endpoints
deploymentsRoute.GET("/hardware-types", controller.GetHardwareTypes)
deploymentsRoute.GET("/locations", controller.GetLocations)
deploymentsRoute.GET("/available-replicas", controller.GetAvailableReplicas)
deploymentsRoute.POST("/price-estimation", controller.GetPriceEstimation)
deploymentsRoute.GET("/check-name", controller.CheckClusterNameAvailability)
// Create new deployment
deploymentsRoute.POST("/", controller.CreateDeployment)
// Individual deployment operations
deploymentsRoute.GET("/:id", controller.GetDeployment)
deploymentsRoute.GET("/:id/logs", controller.GetDeploymentLogs)
deploymentsRoute.GET("/:id/containers", controller.ListDeploymentContainers)
@@ -295,14 +289,6 @@ func SetApiRouter(router *gin.Engine) {
deploymentsRoute.PUT("/:id/name", controller.UpdateDeploymentName)
deploymentsRoute.POST("/:id/extend", controller.ExtendDeployment)
deploymentsRoute.DELETE("/:id", controller.DeleteDeployment)
// Future batch operations:
// deploymentsRoute.POST("/:id/start", controller.StartDeployment)
// deploymentsRoute.POST("/:id/stop", controller.StopDeployment)
// deploymentsRoute.POST("/:id/restart", controller.RestartDeployment)
// deploymentsRoute.POST("/batch_delete", controller.BatchDeleteDeployments)
// deploymentsRoute.POST("/batch_start", controller.BatchStartDeployments)
// deploymentsRoute.POST("/batch_stop", controller.BatchStopDeployments)
}
}
}

View File

@@ -25,7 +25,9 @@ export default defineConfig({
"zh",
"en",
"fr",
"ru"
"ru",
"ja",
"vi"
],
extract: {
input: [

View File

@@ -46,7 +46,7 @@ const DeploymentAccessGuard = ({
<div className='mt-[60px] px-2'>
<Card loading={true} style={{ minHeight: '400px' }}>
<div style={{ textAlign: 'center', padding: '50px 0' }}>
<Text type="secondary">{t('加载设置中...')}</Text>
<Text type='secondary'>{t('加载设置中...')}</Text>
</div>
</Card>
</div>
@@ -55,21 +55,21 @@ const DeploymentAccessGuard = ({
if (!isEnabled) {
return (
<div
className='mt-[60px] px-4'
<div
className='mt-[60px] px-4'
style={{
minHeight: 'calc(100vh - 60px)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center'
justifyContent: 'center',
}}
>
<div
<div
style={{
maxWidth: '600px',
width: '100%',
textAlign: 'center',
padding: '0 20px'
padding: '0 20px',
}}
>
<Card
@@ -78,45 +78,49 @@ const DeploymentAccessGuard = ({
borderRadius: '16px',
border: '1px solid var(--semi-color-border)',
boxShadow: '0 4px 20px rgba(0, 0, 0, 0.08)',
background: 'linear-gradient(135deg, var(--semi-color-bg-0) 0%, var(--semi-color-fill-0) 100%)'
background:
'linear-gradient(135deg, var(--semi-color-bg-0) 0%, var(--semi-color-fill-0) 100%)',
}}
>
{/* 图标区域 */}
<div style={{ marginBottom: '32px' }}>
<div style={{
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
width: '120px',
height: '120px',
borderRadius: '50%',
background: 'linear-gradient(135deg, rgba(var(--semi-orange-4), 0.15) 0%, rgba(var(--semi-orange-5), 0.1) 100%)',
border: '3px solid rgba(var(--semi-orange-4), 0.3)',
marginBottom: '24px'
}}>
<AlertCircle size={56} color="var(--semi-color-warning)" />
<div
style={{
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
width: '120px',
height: '120px',
borderRadius: '50%',
background:
'linear-gradient(135deg, rgba(var(--semi-orange-4), 0.15) 0%, rgba(var(--semi-orange-5), 0.1) 100%)',
border: '3px solid rgba(var(--semi-orange-4), 0.3)',
marginBottom: '24px',
}}
>
<AlertCircle size={56} color='var(--semi-color-warning)' />
</div>
</div>
{/* 标题区域 */}
<div style={{ marginBottom: '24px' }}>
<Title
heading={2}
style={{
color: 'var(--semi-color-text-0)',
<Title
heading={2}
style={{
color: 'var(--semi-color-text-0)',
margin: '0 0 12px 0',
fontSize: '28px',
fontWeight: '700'
fontWeight: '700',
}}
>
{t('模型部署服务未启用')}
</Title>
<Text
style={{
fontSize: '18px',
<Text
style={{
fontSize: '18px',
lineHeight: '1.6',
color: 'var(--semi-color-text-1)',
display: 'block'
display: 'block',
}}
>
{t('访问模型部署功能需要先启用 io.net 部署服务')}
@@ -124,75 +128,99 @@ const DeploymentAccessGuard = ({
</div>
{/* 配置要求区域 */}
<div
style={{
backgroundColor: 'var(--semi-color-bg-1)',
padding: '24px',
<div
style={{
backgroundColor: 'var(--semi-color-bg-1)',
padding: '24px',
borderRadius: '12px',
border: '1px solid var(--semi-color-border)',
margin: '32px 0',
boxShadow: '0 2px 8px rgba(0, 0, 0, 0.04)'
boxShadow: '0 2px 8px rgba(0, 0, 0, 0.04)',
}}
>
<div style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
gap: '12px',
marginBottom: '16px'
}}>
<div style={{
<div
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
width: '32px',
height: '32px',
borderRadius: '8px',
backgroundColor: 'rgba(var(--semi-blue-4), 0.15)'
}}>
<Server size={20} color="var(--semi-color-primary)" />
gap: '12px',
marginBottom: '16px',
}}
>
<div
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
width: '32px',
height: '32px',
borderRadius: '8px',
backgroundColor: 'rgba(var(--semi-blue-4), 0.15)',
}}
>
<Server size={20} color='var(--semi-color-primary)' />
</div>
<Text
strong
style={{
fontSize: '16px',
color: 'var(--semi-color-text-0)'
<Text
strong
style={{
fontSize: '16px',
color: 'var(--semi-color-text-0)',
}}
>
{t('需要配置的项目')}
</Text>
</div>
<div style={{
display: 'flex',
flexDirection: 'column',
gap: '12px',
alignItems: 'flex-start',
textAlign: 'left',
maxWidth: '320px',
margin: '0 auto'
}}>
<div style={{ display: 'flex', alignItems: 'center', gap: '12px' }}>
<div style={{
width: '6px',
height: '6px',
borderRadius: '50%',
backgroundColor: 'var(--semi-color-primary)',
flexShrink: 0
}}></div>
<Text style={{ fontSize: '15px', color: 'var(--semi-color-text-1)' }}>
<div
style={{
display: 'flex',
flexDirection: 'column',
gap: '12px',
alignItems: 'flex-start',
textAlign: 'left',
maxWidth: '320px',
margin: '0 auto',
}}
>
<div
style={{ display: 'flex', alignItems: 'center', gap: '12px' }}
>
<div
style={{
width: '6px',
height: '6px',
borderRadius: '50%',
backgroundColor: 'var(--semi-color-primary)',
flexShrink: 0,
}}
></div>
<Text
style={{
fontSize: '15px',
color: 'var(--semi-color-text-1)',
}}
>
{t('启用 io.net 部署开关')}
</Text>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: '12px' }}>
<div style={{
width: '6px',
height: '6px',
borderRadius: '50%',
backgroundColor: 'var(--semi-color-primary)',
flexShrink: 0
}}></div>
<Text style={{ fontSize: '15px', color: 'var(--semi-color-text-1)' }}>
<div
style={{ display: 'flex', alignItems: 'center', gap: '12px' }}
>
<div
style={{
width: '6px',
height: '6px',
borderRadius: '50%',
backgroundColor: 'var(--semi-color-primary)',
flexShrink: 0,
}}
></div>
<Text
style={{
fontSize: '15px',
color: 'var(--semi-color-text-1)',
}}
>
{t('配置有效的 io.net API Key')}
</Text>
</div>
@@ -201,9 +229,9 @@ const DeploymentAccessGuard = ({
{/* 操作链接区域 */}
<div style={{ marginBottom: '20px' }}>
<div
<div
onClick={handleGoToSettings}
style={{
style={{
display: 'inline-flex',
alignItems: 'center',
gap: '8px',
@@ -216,17 +244,18 @@ const DeploymentAccessGuard = ({
background: 'var(--semi-color-fill-0)',
border: '1px solid var(--semi-color-border)',
transition: 'all 0.2s ease',
textDecoration: 'none'
textDecoration: 'none',
}}
onMouseEnter={(e) => {
e.target.style.background = 'var(--semi-color-fill-1)';
e.target.style.transform = 'translateY(-1px)';
e.target.style.boxShadow = '0 2px 8px rgba(0, 0, 0, 0.1)';
e.currentTarget.style.background = 'var(--semi-color-fill-1)';
e.currentTarget.style.transform = 'translateY(-1px)';
e.currentTarget.style.boxShadow =
'0 2px 8px rgba(0, 0, 0, 0.1)';
}}
onMouseLeave={(e) => {
e.target.style.background = 'var(--semi-color-fill-0)';
e.target.style.transform = 'translateY(0)';
e.target.style.boxShadow = 'none';
e.currentTarget.style.background = 'var(--semi-color-fill-0)';
e.currentTarget.style.transform = 'translateY(0)';
e.currentTarget.style.boxShadow = 'none';
}}
>
<Settings size={18} />
@@ -235,12 +264,12 @@ const DeploymentAccessGuard = ({
</div>
{/* 底部提示 */}
<Text
type="tertiary"
style={{
<Text
type='tertiary'
style={{
fontSize: '14px',
color: 'var(--semi-color-text-2)',
lineHeight: '1.5'
lineHeight: '1.5',
}}
>
{t('配置完成后刷新页面即可使用模型部署功能')}
@@ -256,7 +285,7 @@ const DeploymentAccessGuard = ({
<div className='mt-[60px] px-2'>
<Card loading={true} style={{ minHeight: '400px' }}>
<div style={{ textAlign: 'center', padding: '50px 0' }}>
<Text type="secondary">{t('Checking io.net connection...')}</Text>
<Text type='secondary'>{t('正在检查 io.net 连接...')}</Text>
</div>
</Card>
</div>
@@ -265,12 +294,10 @@ const DeploymentAccessGuard = ({
if (connectionOk === false) {
const isExpired = connectionError?.type === 'expired';
const title = isExpired
? t('API key expired')
: t('io.net connection unavailable');
const title = isExpired ? t('接口密钥已过期') : t('无法连接 io.net');
const description = isExpired
? t('The current API key is expired. Please update it in settings.')
: t('Unable to connect to io.net with the current configuration.');
? t('当前 API 密钥已过期,请在设置中更新。')
: t('当前配置无法连接到 io.net。');
const detail = connectionError?.message || '';
return (
@@ -297,7 +324,8 @@ const DeploymentAccessGuard = ({
borderRadius: '16px',
border: '1px solid var(--semi-color-border)',
boxShadow: '0 4px 20px rgba(0, 0, 0, 0.08)',
background: 'linear-gradient(135deg, var(--semi-color-bg-0) 0%, var(--semi-color-fill-0) 100%)',
background:
'linear-gradient(135deg, var(--semi-color-bg-0) 0%, var(--semi-color-fill-0) 100%)',
}}
>
<div style={{ marginBottom: '32px' }}>
@@ -309,12 +337,13 @@ const DeploymentAccessGuard = ({
width: '120px',
height: '120px',
borderRadius: '50%',
background: 'linear-gradient(135deg, rgba(var(--semi-red-4), 0.15) 0%, rgba(var(--semi-red-5), 0.1) 100%)',
background:
'linear-gradient(135deg, rgba(var(--semi-red-4), 0.15) 0%, rgba(var(--semi-red-5), 0.1) 100%)',
border: '3px solid rgba(var(--semi-red-4), 0.3)',
marginBottom: '24px',
}}
>
<WifiOff size={56} color="var(--semi-color-danger)" />
<WifiOff size={56} color='var(--semi-color-danger)' />
</div>
</div>
@@ -342,7 +371,7 @@ const DeploymentAccessGuard = ({
</Text>
{detail ? (
<Text
type="tertiary"
type='tertiary'
style={{
fontSize: '14px',
lineHeight: '1.5',
@@ -355,13 +384,19 @@ const DeploymentAccessGuard = ({
) : null}
</div>
<div style={{ display: 'flex', gap: '12px', justifyContent: 'center' }}>
<Button type="primary" icon={<Settings size={18} />} onClick={handleGoToSettings}>
{t('Go to settings')}
<div
style={{ display: 'flex', gap: '12px', justifyContent: 'center' }}
>
<Button
type='primary'
icon={<Settings size={18} />}
onClick={handleGoToSettings}
>
{t('前往设置')}
</Button>
{onRetry ? (
<Button type="tertiary" onClick={onRetry}>
{t('Retry connection')}
<Button type='tertiary' onClick={onRetry}>
{t('重试连接')}
</Button>
) : null}
</div>

View File

@@ -44,7 +44,10 @@ import CodeViewer from '../../../playground/CodeViewer';
import { StatusContext } from '../../../../context/Status';
import { UserContext } from '../../../../context/User';
import { useUserPermissions } from '../../../../hooks/common/useUserPermissions';
import { useSidebar } from '../../../../hooks/common/useSidebar';
import {
mergeAdminConfig,
useSidebar,
} from '../../../../hooks/common/useSidebar';
const NotificationSettings = ({
t,
@@ -82,6 +85,7 @@ const NotificationSettings = ({
enabled: true,
channel: true,
models: true,
deployment: true,
redemption: true,
user: true,
setting: true,
@@ -164,6 +168,7 @@ const NotificationSettings = ({
enabled: true,
channel: true,
models: true,
deployment: true,
redemption: true,
user: true,
setting: true,
@@ -178,14 +183,27 @@ const NotificationSettings = ({
try {
// 获取管理员全局配置
if (statusState?.status?.SidebarModulesAdmin) {
const adminConf = JSON.parse(statusState.status.SidebarModulesAdmin);
setAdminConfig(adminConf);
try {
const adminConf = JSON.parse(
statusState.status.SidebarModulesAdmin,
);
setAdminConfig(mergeAdminConfig(adminConf));
} catch (error) {
setAdminConfig(mergeAdminConfig(null));
}
} else {
setAdminConfig(mergeAdminConfig(null));
}
// 获取用户个人配置
const userRes = await API.get('/api/user/self');
if (userRes.data.success && userRes.data.data.sidebar_modules) {
const userConf = JSON.parse(userRes.data.data.sidebar_modules);
let userConf;
if (typeof userRes.data.data.sidebar_modules === 'string') {
userConf = JSON.parse(userRes.data.data.sidebar_modules);
} else {
userConf = userRes.data.data.sidebar_modules;
}
setSidebarModulesUser(userConf);
}
} catch (error) {
@@ -273,6 +291,11 @@ const NotificationSettings = ({
modules: [
{ key: 'channel', title: t('渠道管理'), description: t('API渠道配置') },
{ key: 'models', title: t('模型管理'), description: t('AI模型配置') },
{
key: 'deployment',
title: t('模型部署'),
description: t('模型部署管理'),
},
{
key: 'redemption',
title: t('兑换码管理'),
@@ -812,7 +835,9 @@ const NotificationSettings = ({
</Typography.Text>
</div>
<Switch
checked={sidebarModulesUser[section.key]?.enabled}
checked={
sidebarModulesUser[section.key]?.enabled !== false
}
onChange={handleSectionChange(section.key)}
size='default'
/>
@@ -835,7 +860,8 @@ const NotificationSettings = ({
>
<Card
className={`!rounded-xl border border-gray-200 hover:border-blue-300 transition-all duration-200 ${
sidebarModulesUser[section.key]?.enabled
sidebarModulesUser[section.key]?.enabled !==
false
? ''
: 'opacity-50'
}`}
@@ -866,7 +892,7 @@ const NotificationSettings = ({
checked={
sidebarModulesUser[section.key]?.[
module.key
]
] !== false
}
onChange={handleModuleChange(
section.key,
@@ -874,8 +900,8 @@ const NotificationSettings = ({
)}
size='default'
disabled={
!sidebarModulesUser[section.key]
?.enabled
sidebarModulesUser[section.key]
?.enabled === false
}
/>
</div>

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

View File

@@ -25,6 +25,56 @@ import { API } from '../../helpers';
const sidebarEventTarget = new EventTarget();
const SIDEBAR_REFRESH_EVENT = 'sidebar-refresh';
export const DEFAULT_ADMIN_CONFIG = {
chat: {
enabled: true,
playground: true,
chat: true,
},
console: {
enabled: true,
detail: true,
token: true,
log: true,
midjourney: true,
task: true,
},
personal: {
enabled: true,
topup: true,
personal: true,
},
admin: {
enabled: true,
channel: true,
models: true,
deployment: true,
redemption: true,
user: true,
setting: true,
},
};
const deepClone = (value) => JSON.parse(JSON.stringify(value));
export const mergeAdminConfig = (savedConfig) => {
const merged = deepClone(DEFAULT_ADMIN_CONFIG);
if (!savedConfig || typeof savedConfig !== 'object') return merged;
for (const [sectionKey, sectionConfig] of Object.entries(savedConfig)) {
if (!sectionConfig || typeof sectionConfig !== 'object') continue;
if (!merged[sectionKey]) {
merged[sectionKey] = { ...sectionConfig };
continue;
}
merged[sectionKey] = { ...merged[sectionKey], ...sectionConfig };
}
return merged;
};
export const useSidebar = () => {
const [statusState] = useContext(StatusContext);
const [userConfig, setUserConfig] = useState(null);
@@ -37,48 +87,17 @@ export const useSidebar = () => {
instanceIdRef.current = `sidebar-${Date.now()}-${randomPart}`;
}
// 默认配置
const defaultAdminConfig = {
chat: {
enabled: true,
playground: true,
chat: true,
},
console: {
enabled: true,
detail: true,
token: true,
log: true,
midjourney: true,
task: true,
},
personal: {
enabled: true,
topup: true,
personal: true,
},
admin: {
enabled: true,
channel: true,
models: true,
deployment: true,
redemption: true,
user: true,
setting: true,
},
};
// 获取管理员配置
const adminConfig = useMemo(() => {
if (statusState?.status?.SidebarModulesAdmin) {
try {
const config = JSON.parse(statusState.status.SidebarModulesAdmin);
return config;
return mergeAdminConfig(config);
} catch (error) {
return defaultAdminConfig;
return mergeAdminConfig(null);
}
}
return defaultAdminConfig;
return mergeAdminConfig(null);
}, [statusState?.status?.SidebarModulesAdmin]);
// 加载用户配置的通用方法

View File

@@ -39,10 +39,13 @@ export const useDeploymentResources = () => {
setLoadingHardware(true);
const response = await API.get('/api/deployments/hardware-types');
if (response.data.success) {
const { hardware_types: hardwareList = [], total_available } = response.data.data || {};
const { hardware_types: hardwareList = [], total_available } =
response.data.data || {};
const normalizedHardware = hardwareList.map((hardware) => {
const availableCountValue = Number(hardware.available_count);
const availableCount = Number.isNaN(availableCountValue) ? 0 : availableCountValue;
const availableCount = Number.isNaN(availableCountValue)
? 0
: availableCountValue;
const availableBool =
typeof hardware.available === 'boolean'
? hardware.available
@@ -57,7 +60,9 @@ export const useDeploymentResources = () => {
const providedTotal = Number(total_available);
const fallbackTotal = normalizedHardware.reduce(
(acc, item) => acc + (Number.isNaN(item.available_count) ? 0 : item.available_count),
(acc, item) =>
acc +
(Number.isNaN(item.available_count) ? 0 : item.available_count),
0,
);
const hasProvidedTotal =
@@ -85,37 +90,64 @@ export const useDeploymentResources = () => {
}
}, []);
const fetchLocations = useCallback(async () => {
const fetchLocations = useCallback(async (hardwareId, gpuCount = 1) => {
if (!hardwareId) {
setLocations([]);
setLocationsTotalAvailable(0);
return [];
}
try {
setLoadingLocations(true);
const response = await API.get('/api/deployments/locations');
const response = await API.get(
`/api/deployments/available-replicas?hardware_id=${hardwareId}&gpu_count=${gpuCount}`,
);
if (response.data.success) {
const { locations: locationsList = [], total } = response.data.data || {};
const normalizedLocations = locationsList.map((location) => {
const iso2 = (location.iso2 || '').toString().toUpperCase();
const availableValue = Number(location.available);
const available = Number.isNaN(availableValue) ? 0 : availableValue;
const replicas = response.data.data?.replicas || [];
const nextLocationsMap = new Map();
replicas.forEach((replica) => {
const rawId = replica?.location_id ?? replica?.location?.id;
if (rawId === null || rawId === undefined) return;
return {
...location,
const mapKey = String(rawId);
if (nextLocationsMap.has(mapKey)) return;
const rawIso2 =
replica?.iso2 ?? replica?.location_iso2 ?? replica?.location?.iso2;
const iso2 = rawIso2 ? String(rawIso2).toUpperCase() : '';
const name =
replica?.location_name ??
replica?.location?.name ??
replica?.name ??
String(rawId);
nextLocationsMap.set(mapKey, {
id: rawId,
name: String(name),
iso2,
available,
};
region:
replica?.region ??
replica?.location_region ??
replica?.location?.region,
country:
replica?.country ??
replica?.location_country ??
replica?.location?.country,
code:
replica?.code ??
replica?.location_code ??
replica?.location?.code,
available: Number(replica?.available_count) || 0,
});
});
const providedTotal = Number(total);
const fallbackTotal = normalizedLocations.reduce(
(acc, item) => acc + (Number.isNaN(item.available) ? 0 : item.available),
0,
);
const hasProvidedTotal =
total !== undefined &&
total !== null &&
total !== '' &&
!Number.isNaN(providedTotal);
const normalizedLocations = Array.from(nextLocationsMap.values());
setLocations(normalizedLocations);
setLocationsTotalAvailable(
hasProvidedTotal ? providedTotal : fallbackTotal,
normalizedLocations.reduce(
(acc, item) => acc + (item.available || 0),
0,
),
);
return normalizedLocations;
} else {
@@ -132,34 +164,37 @@ export const useDeploymentResources = () => {
}
}, []);
const fetchAvailableReplicas = useCallback(async (hardwareId, gpuCount = 1) => {
if (!hardwareId) {
setAvailableReplicas([]);
return [];
}
try {
setLoadingReplicas(true);
const response = await API.get(
`/api/deployments/available-replicas?hardware_id=${hardwareId}&gpu_count=${gpuCount}`
);
if (response.data.success) {
const replicas = response.data.data.replicas || [];
setAvailableReplicas(replicas);
return replicas;
} else {
showError('获取可用资源失败: ' + response.data.message);
const fetchAvailableReplicas = useCallback(
async (hardwareId, gpuCount = 1) => {
if (!hardwareId) {
setAvailableReplicas([]);
return [];
}
} catch (error) {
console.error('Load available replicas error:', error);
setAvailableReplicas([]);
return [];
} finally {
setLoadingReplicas(false);
}
}, []);
try {
setLoadingReplicas(true);
const response = await API.get(
`/api/deployments/available-replicas?hardware_id=${hardwareId}&gpu_count=${gpuCount}`,
);
if (response.data.success) {
const replicas = response.data.data.replicas || [];
setAvailableReplicas(replicas);
return replicas;
} else {
showError('获取可用资源失败: ' + response.data.message);
setAvailableReplicas([]);
return [];
}
} catch (error) {
console.error('Load available replicas error:', error);
setAvailableReplicas([]);
return [];
} finally {
setLoadingReplicas(false);
}
},
[],
);
const calculatePrice = useCallback(async (params) => {
const {
@@ -167,10 +202,16 @@ export const useDeploymentResources = () => {
hardwareId,
gpusPerContainer,
durationHours,
replicaCount
replicaCount,
} = params;
if (!locationIds?.length || !hardwareId || !gpusPerContainer || !durationHours || !replicaCount) {
if (
!locationIds?.length ||
!hardwareId ||
!gpusPerContainer ||
!durationHours ||
!replicaCount
) {
setPriceEstimation(null);
return null;
}
@@ -185,7 +226,10 @@ export const useDeploymentResources = () => {
replica_count: replicaCount,
};
const response = await API.post('/api/deployments/price-estimation', requestData);
const response = await API.post(
'/api/deployments/price-estimation',
requestData,
);
if (response.data.success) {
const estimation = response.data.data;
setPriceEstimation(estimation);
@@ -208,7 +252,9 @@ export const useDeploymentResources = () => {
if (!name?.trim()) return false;
try {
const response = await API.get(`/api/deployments/check-name?name=${encodeURIComponent(name.trim())}`);
const response = await API.get(
`/api/deployments/check-name?name=${encodeURIComponent(name.trim())}`,
);
if (response.data.success) {
return response.data.data.available;
} else {

View File

@@ -17,7 +17,7 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import { useState, useEffect, useMemo } from 'react';
import { useState, useEffect, useMemo, useRef } from 'react';
import { useTranslation } from 'react-i18next';
import { API, showError, showSuccess } from '../../helpers';
import { ITEMS_PER_PAGE } from '../../constants';
@@ -26,6 +26,7 @@ import { useTableCompactMode } from '../common/useTableCompactMode';
export const useDeploymentsData = () => {
const { t } = useTranslation();
const [compactMode, setCompactMode] = useTableCompactMode('deployments');
const requestSeq = useRef(0);
// State management
const [deployments, setDeployments] = useState([]);
@@ -34,6 +35,7 @@ export const useDeploymentsData = () => {
const [pageSize, setPageSize] = useState(ITEMS_PER_PAGE);
const [searching, setSearching] = useState(false);
const [deploymentCount, setDeploymentCount] = useState(0);
const [query, setQuery] = useState({ keyword: '', status: '' });
// Modal states
const [showEdit, setShowEdit] = useState(false);
@@ -80,18 +82,12 @@ export const useDeploymentsData = () => {
}, 500);
};
// Set deployment format with key field
const setDeploymentFormat = (deployments) => {
for (let i = 0; i < deployments.length; i++) {
deployments[i].key = deployments[i].id;
}
setDeployments(deployments);
const normalizeQuery = (terms) => {
const keyword = (terms?.searchKeyword ?? '').trim();
const status = (terms?.searchStatus ?? '').trim();
return { keyword, status };
};
// Status tabs
const [activeStatusKey, setActiveStatusKey] = useState('all');
const [statusCounts, setStatusCounts] = useState({});
// Column visibility
const COLUMN_KEYS = useMemo(
() => ({
@@ -160,114 +156,127 @@ export const useDeploymentsData = () => {
// Save column visibility to localStorage
const saveColumnVisibility = (newVisibleColumns) => {
const normalized = ensureRequiredColumns(newVisibleColumns);
localStorage.setItem('deployments_visible_columns', JSON.stringify(normalized));
localStorage.setItem(
'deployments_visible_columns',
JSON.stringify(normalized),
);
setVisibleColumnsState(normalized);
};
// Load deployments data
const loadDeployments = async (
page = 1,
size = pageSize,
statusKey = activeStatusKey,
) => {
setLoading(true);
const applyDeploymentsData = ({ data, page }) => {
const items = extractItems(data);
setActivePage(data?.page ?? page);
setDeploymentCount(data?.total ?? items.length);
setSelectedKeys([]);
setDeployments(
items.map((deployment) => ({ ...deployment, key: deployment.id })),
);
};
const fetchDeployments = async ({ page, size, keyword, status }) => {
const seq = ++requestSeq.current;
const isSearchMode = Boolean(keyword) || Boolean(status);
if (isSearchMode) {
setSearching(true);
} else {
setLoading(true);
}
try {
let url = `/api/deployments/?p=${page}&page_size=${size}`;
if (statusKey && statusKey !== 'all') {
url = `/api/deployments/search?status=${statusKey}&p=${page}&page_size=${size}`;
let url;
if (isSearchMode) {
const params = new URLSearchParams({
p: String(page),
page_size: String(size),
});
if (keyword) params.append('keyword', keyword);
if (status) params.append('status', status);
url = `/api/deployments/search?${params.toString()}`;
} else {
url = `/api/deployments/?p=${page}&page_size=${size}`;
}
const res = await API.get(url);
const { success, message, data } = res.data;
if (success) {
const newPageData = extractItems(data);
setActivePage(data.page || page);
setDeploymentCount(data.total || newPageData.length);
setDeploymentFormat(newPageData);
if (seq !== requestSeq.current) return;
if (data.status_counts) {
const sumAll = Object.values(data.status_counts).reduce(
(acc, v) => acc + v,
0,
);
setStatusCounts({ ...data.status_counts, all: sumAll });
}
} else {
const { success, message, data } = res.data;
if (!success) {
showError(message);
setDeployments([]);
setDeploymentCount(0);
return;
}
applyDeploymentsData({ data, page });
} catch (error) {
if (seq !== requestSeq.current) return;
console.error(error);
showError(t('获取部署列表失败'));
showError(isSearchMode ? t('搜索失败') : t('获取部署列表失败'));
setDeployments([]);
setDeploymentCount(0);
} finally {
if (seq !== requestSeq.current) return;
setLoading(false);
setSearching(false);
}
setLoading(false);
};
// Search deployments
const searchDeployments = async (searchTerms) => {
setSearching(true);
try {
const { searchKeyword, searchStatus } = searchTerms;
const params = new URLSearchParams({
p: '1',
page_size: pageSize.toString(),
});
if (searchKeyword?.trim()) {
params.append('keyword', searchKeyword.trim());
}
if (searchStatus && searchStatus !== 'all') {
params.append('status', searchStatus);
}
const res = await API.get(`/api/deployments/search?${params}`);
const { success, message, data } = res.data;
if (success) {
const items = extractItems(data);
setActivePage(1);
setDeploymentCount(data.total || items.length);
setDeploymentFormat(items);
} else {
showError(message);
setDeployments([]);
}
} catch (error) {
console.error('Search error:', error);
showError(t('搜索失败'));
setDeployments([]);
}
setSearching(false);
};
// Refresh data
const refresh = async (page = activePage) => {
await loadDeployments(page, pageSize);
await fetchDeployments({
page,
size: pageSize,
keyword: query.keyword,
status: query.status,
});
};
// Handle page change
const handlePageChange = (page) => {
setActivePage(page);
if (!searching) {
loadDeployments(page, pageSize);
}
fetchDeployments({
page,
size: pageSize,
keyword: query.keyword,
status: query.status,
});
};
// Handle page size change
const handlePageSizeChange = (size) => {
setPageSize(size);
setActivePage(1);
if (!searching) {
loadDeployments(1, size);
}
fetchDeployments({
page: 1,
size,
keyword: query.keyword,
status: query.status,
});
};
// Handle tab change
const handleTabChange = (statusKey) => {
setActiveStatusKey(statusKey);
const loadDeployments = async (page = 1, size = pageSize) => {
await fetchDeployments({
page,
size,
keyword: query.keyword,
status: query.status,
});
};
// Search deployments (also supports pagination)
const searchDeployments = async (searchTerms) => {
const nextQuery = normalizeQuery(searchTerms);
setQuery(nextQuery);
setActivePage(1);
loadDeployments(1, pageSize, statusKey);
await fetchDeployments({
page: 1,
size: pageSize,
keyword: nextQuery.keyword,
status: nextQuery.status,
});
};
// Deployment operations
@@ -323,7 +332,9 @@ export const useDeploymentsData = () => {
}
try {
const containersResp = await API.get(`/api/deployments/${deployment.id}/containers`);
const containersResp = await API.get(
`/api/deployments/${deployment.id}/containers`,
);
if (!containersResp.data?.success) {
showError(containersResp.data?.message || t('获取容器信息失败'));
return;
@@ -344,15 +355,20 @@ export const useDeploymentsData = () => {
return;
}
const baseName = deployment.container_name || deployment.deployment_name || deployment.name || deployment.id;
const baseName =
deployment.container_name ||
deployment.deployment_name ||
deployment.name ||
deployment.id;
const safeName = String(baseName || 'ionet').slice(0, 60);
const channelName = `[IO.NET] ${safeName}`;
let randomKey;
try {
randomKey = (typeof crypto !== 'undefined' && crypto.randomUUID)
? `ionet-${crypto.randomUUID().replace(/-/g, '')}`
: null;
randomKey =
typeof crypto !== 'undefined' && crypto.randomUUID
? `ionet-${crypto.randomUUID().replace(/-/g, '')}`
: null;
} catch (err) {
randomKey = null;
}
@@ -396,7 +412,9 @@ export const useDeploymentsData = () => {
const updateDeploymentName = async (deploymentId, newName) => {
try {
const res = await API.put(`/api/deployments/${deploymentId}/name`, { name: newName });
const res = await API.put(`/api/deployments/${deploymentId}/name`, {
name: newName,
});
if (res.data.success) {
showSuccess(t('部署名称更新成功'));
await refresh();
@@ -415,9 +433,9 @@ export const useDeploymentsData = () => {
// Batch operations
const batchDeleteDeployments = async () => {
if (selectedKeys.length === 0) return;
try {
const ids = selectedKeys.map(deployment => deployment.id);
const ids = selectedKeys.map((deployment) => deployment.id);
const res = await API.post('/api/deployments/batch_delete', { ids });
if (res.data.success) {
showSuccess(t('批量删除成功'));
@@ -452,8 +470,6 @@ export const useDeploymentsData = () => {
activePage,
pageSize,
deploymentCount,
statusCounts,
activeStatusKey,
compactMode,
setCompactMode,
@@ -488,7 +504,6 @@ export const useDeploymentsData = () => {
refresh,
handlePageChange,
handlePageSizeChange,
handleTabChange,
handleRow,
// Deployment operations

View File

@@ -25,9 +25,9 @@ export const useEnhancedDeploymentActions = (t) => {
// Set loading state for specific operation
const setOperationLoading = (operation, deploymentId, isLoading) => {
setLoading(prev => ({
setLoading((prev) => ({
...prev,
[`${operation}_${deploymentId}`]: isLoading
[`${operation}_${deploymentId}`]: isLoading,
}));
};
@@ -38,20 +38,26 @@ export const useEnhancedDeploymentActions = (t) => {
// Extend deployment duration
const extendDeployment = async (deploymentId, durationHours) => {
const operationKey = `extend_${deploymentId}`;
try {
setOperationLoading('extend', deploymentId, true);
const response = await API.post(`/api/deployments/${deploymentId}/extend`, {
duration_hours: durationHours
});
const response = await API.post(
`/api/deployments/${deploymentId}/extend`,
{
duration_hours: durationHours,
},
);
if (response.data.success) {
showSuccess(t('容器时长延长成功'));
return response.data.data;
}
} catch (error) {
showError(t('延长时长失败') + ': ' + (error.response?.data?.message || error.message));
showError(
t('延长时长失败') +
': ' +
(error.response?.data?.message || error.message),
);
throw error;
} finally {
setOperationLoading('extend', deploymentId, false);
@@ -62,14 +68,18 @@ export const useEnhancedDeploymentActions = (t) => {
const getDeploymentDetails = async (deploymentId) => {
try {
setOperationLoading('details', deploymentId, true);
const response = await API.get(`/api/deployments/${deploymentId}`);
if (response.data.success) {
return response.data.data;
}
} catch (error) {
showError(t('获取详情失败') + ': ' + (error.response?.data?.message || error.message));
showError(
t('获取详情失败') +
': ' +
(error.response?.data?.message || error.message),
);
throw error;
} finally {
setOperationLoading('details', deploymentId, false);
@@ -80,24 +90,31 @@ export const useEnhancedDeploymentActions = (t) => {
const getDeploymentLogs = async (deploymentId, options = {}) => {
try {
setOperationLoading('logs', deploymentId, true);
const params = new URLSearchParams();
if (options.containerId) params.append('container_id', options.containerId);
if (options.containerId)
params.append('container_id', options.containerId);
if (options.level) params.append('level', options.level);
if (options.limit) params.append('limit', options.limit.toString());
if (options.cursor) params.append('cursor', options.cursor);
if (options.follow) params.append('follow', 'true');
if (options.startTime) params.append('start_time', options.startTime);
if (options.endTime) params.append('end_time', options.endTime);
const response = await API.get(`/api/deployments/${deploymentId}/logs?${params}`);
const response = await API.get(
`/api/deployments/${deploymentId}/logs?${params}`,
);
if (response.data.success) {
return response.data.data;
}
} catch (error) {
showError(t('获取日志失败') + ': ' + (error.response?.data?.message || error.message));
showError(
t('获取日志失败') +
': ' +
(error.response?.data?.message || error.message),
);
throw error;
} finally {
setOperationLoading('logs', deploymentId, false);
@@ -108,15 +125,22 @@ export const useEnhancedDeploymentActions = (t) => {
const updateDeploymentConfig = async (deploymentId, config) => {
try {
setOperationLoading('config', deploymentId, true);
const response = await API.put(`/api/deployments/${deploymentId}`, config);
const response = await API.put(
`/api/deployments/${deploymentId}`,
config,
);
if (response.data.success) {
showSuccess(t('容器配置更新成功'));
return response.data.data;
}
} catch (error) {
showError(t('更新配置失败') + ': ' + (error.response?.data?.message || error.message));
showError(
t('更新配置失败') +
': ' +
(error.response?.data?.message || error.message),
);
throw error;
} finally {
setOperationLoading('config', deploymentId, false);
@@ -127,15 +151,19 @@ export const useEnhancedDeploymentActions = (t) => {
const deleteDeployment = async (deploymentId) => {
try {
setOperationLoading('delete', deploymentId, true);
const response = await API.delete(`/api/deployments/${deploymentId}`);
if (response.data.success) {
showSuccess(t('容器销毁请求已提交'));
return response.data.data;
}
} catch (error) {
showError(t('销毁容器失败') + ': ' + (error.response?.data?.message || error.message));
showError(
t('销毁容器失败') +
': ' +
(error.response?.data?.message || error.message),
);
throw error;
} finally {
setOperationLoading('delete', deploymentId, false);
@@ -146,17 +174,21 @@ export const useEnhancedDeploymentActions = (t) => {
const updateDeploymentName = async (deploymentId, newName) => {
try {
setOperationLoading('rename', deploymentId, true);
const response = await API.put(`/api/deployments/${deploymentId}/name`, {
name: newName
name: newName,
});
if (response.data.success) {
showSuccess(t('容器名称更新成功'));
return response.data.data;
}
} catch (error) {
showError(t('更新名称失败') + ': ' + (error.response?.data?.message || error.message));
showError(
t('更新名称失败') +
': ' +
(error.response?.data?.message || error.message),
);
throw error;
} finally {
setOperationLoading('rename', deploymentId, false);
@@ -167,21 +199,23 @@ export const useEnhancedDeploymentActions = (t) => {
const batchDelete = async (deploymentIds) => {
try {
setOperationLoading('batch_delete', 'all', true);
const results = await Promise.allSettled(
deploymentIds.map(id => deleteDeployment(id))
deploymentIds.map((id) => deleteDeployment(id)),
);
const successful = results.filter(r => r.status === 'fulfilled').length;
const failed = results.filter(r => r.status === 'rejected').length;
const successful = results.filter((r) => r.status === 'fulfilled').length;
const failed = results.filter((r) => r.status === 'rejected').length;
if (successful > 0) {
showSuccess(t('批量操作完成: {{success}}个成功, {{failed}}个失败', {
success: successful,
failed: failed
}));
showSuccess(
t('批量操作完成: {{success}}个成功, {{failed}}个失败', {
success: successful,
failed: failed,
}),
);
}
return { successful, failed };
} catch (error) {
showError(t('批量操作失败') + ': ' + error.message);
@@ -195,17 +229,20 @@ export const useEnhancedDeploymentActions = (t) => {
const exportLogs = async (deploymentId, options = {}) => {
try {
setOperationLoading('export_logs', deploymentId, true);
const logs = await getDeploymentLogs(deploymentId, {
...options,
limit: 10000 // Get more logs for export
limit: 10000, // Get more logs for export
});
if (logs && logs.logs) {
const logText = logs.logs.map(log =>
`[${new Date(log.timestamp).toISOString()}] [${log.level}] ${log.source ? `[${log.source}] ` : ''}${log.message}`
).join('\n');
const logText = logs.logs
.map(
(log) =>
`[${new Date(log.timestamp).toISOString()}] [${log.level}] ${log.source ? `[${log.source}] ` : ''}${log.message}`,
)
.join('\n');
const blob = new Blob([logText], { type: 'text/plain' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
@@ -215,7 +252,7 @@ export const useEnhancedDeploymentActions = (t) => {
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
showSuccess(t('日志导出成功'));
}
} catch (error) {
@@ -236,14 +273,14 @@ export const useEnhancedDeploymentActions = (t) => {
updateDeploymentName,
batchDelete,
exportLogs,
// Loading states
isOperationLoading,
loading,
// Utility
setOperationLoading
setOperationLoading,
};
};
export default useEnhancedDeploymentActions;
export default useEnhancedDeploymentActions;

View File

@@ -18,13 +18,12 @@ For commercial licensing, please contact support@quantumnous.com
*/
import { useCallback, useEffect, useState } from 'react';
import { API, toBoolean } from '../../helpers';
import { API } from '../../helpers';
export const useModelDeploymentSettings = () => {
const [loading, setLoading] = useState(true);
const [settings, setSettings] = useState({
'model_deployment.ionet.enabled': false,
'model_deployment.ionet.api_key': '',
});
const [connectionState, setConnectionState] = useState({
loading: false,
@@ -35,24 +34,13 @@ export const useModelDeploymentSettings = () => {
const getSettings = async () => {
try {
setLoading(true);
const res = await API.get('/api/option/');
const res = await API.get('/api/deployments/settings');
const { success, data } = res.data;
if (success) {
const newSettings = {
'model_deployment.ionet.enabled': false,
'model_deployment.ionet.api_key': '',
};
data.forEach((item) => {
if (item.key.endsWith('enabled')) {
newSettings[item.key] = toBoolean(item.value);
} else if (newSettings.hasOwnProperty(item.key)) {
newSettings[item.key] = item.value || '';
}
setSettings({
'model_deployment.ionet.enabled': data?.enabled === true,
});
setSettings(newSettings);
}
} catch (error) {
console.error('Failed to get model deployment settings:', error);
@@ -65,10 +53,7 @@ export const useModelDeploymentSettings = () => {
getSettings();
}, []);
const apiKey = settings['model_deployment.ionet.api_key'];
const isIoNetEnabled = settings['model_deployment.ionet.enabled'] &&
apiKey &&
apiKey.trim() !== '';
const isIoNetEnabled = settings['model_deployment.ionet.enabled'];
const buildConnectionError = (rawMessage, fallbackMessage = 'Connection failed') => {
const message = (rawMessage || fallbackMessage).trim();
@@ -85,18 +70,12 @@ export const useModelDeploymentSettings = () => {
return { type: 'unknown', message };
};
const testConnection = useCallback(async (apiKey) => {
const key = (apiKey || '').trim();
if (key === '') {
setConnectionState({ loading: false, ok: null, error: null });
return;
}
const testConnection = useCallback(async () => {
setConnectionState({ loading: true, ok: null, error: null });
try {
const response = await API.post(
'/api/deployments/test-connection',
{ api_key: key },
'/api/deployments/settings/test-connection',
{},
{ skipErrorHandler: true },
);
@@ -123,16 +102,15 @@ export const useModelDeploymentSettings = () => {
useEffect(() => {
if (!loading && isIoNetEnabled) {
testConnection(apiKey);
testConnection();
return;
}
setConnectionState({ loading: false, ok: null, error: null });
}, [loading, isIoNetEnabled, apiKey, testConnection]);
}, [loading, isIoNetEnabled, testConnection]);
return {
loading,
settings,
apiKey,
isIoNetEnabled,
refresh: getSettings,
connectionLoading: connectionState.loading,

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -29,7 +29,6 @@ const ModelDeploymentPage = () => {
connectionLoading,
connectionOk,
connectionError,
apiKey,
testConnection,
} = useModelDeploymentSettings();
@@ -40,7 +39,7 @@ const ModelDeploymentPage = () => {
connectionLoading={connectionLoading}
connectionOk={connectionOk}
connectionError={connectionError}
onRetry={() => testConnection(apiKey)}
onRetry={() => testConnection()}
>
<div className='mt-[60px] px-2'>
<DeploymentsTable />

View File

@@ -48,10 +48,6 @@ export default function SettingModelDeployment(props) {
const testApiKey = async () => {
const apiKey = inputs['model_deployment.ionet.api_key'];
if (!apiKey || apiKey.trim() === '') {
showError(t('请先填写 API Key'));
return;
}
const getLocalizedMessage = (message) => {
switch (message) {
@@ -69,10 +65,8 @@ export default function SettingModelDeployment(props) {
setTesting(true);
try {
const response = await API.post(
'/api/deployments/test-connection',
{
api_key: apiKey.trim(),
},
'/api/deployments/settings/test-connection',
apiKey && apiKey.trim() !== '' ? { api_key: apiKey.trim() } : {},
{
skipErrorHandler: true,
},
@@ -108,12 +102,6 @@ export default function SettingModelDeployment(props) {
};
function onSubmit() {
// 前置校验:如果启用了 io.net 但没有填写 API Key
if (inputs['model_deployment.ionet.enabled'] &&
(!inputs['model_deployment.ionet.api_key'] || inputs['model_deployment.ionet.api_key'].trim() === '')) {
return showError(t('启用 io.net 部署时必须填写 API Key'));
}
const updateArray = compareObjects(inputs, inputsRow);
if (!updateArray.length) return showWarning(t('你似乎并没有修改什么'));
@@ -229,7 +217,7 @@ export default function SettingModelDeployment(props) {
<Form.Input
label={t('API Key')}
field={'model_deployment.ionet.api_key'}
placeholder={t('请输入 io.net API Key')}
placeholder={t('请输入 io.net API Key(敏感信息不显示)')}
onChange={(value) =>
setInputs({
...inputs,
@@ -248,9 +236,7 @@ export default function SettingModelDeployment(props) {
onClick={testApiKey}
loading={testing}
disabled={
!inputs['model_deployment.ionet.enabled'] ||
!inputs['model_deployment.ionet.api_key'] ||
inputs['model_deployment.ionet.api_key'].trim() === ''
!inputs['model_deployment.ionet.enabled']
}
style={{
height: '32px',

View File

@@ -32,7 +32,7 @@ import { API, showSuccess, showError } from '../../../helpers';
import { StatusContext } from '../../../context/Status';
import { UserContext } from '../../../context/User';
import { useUserPermissions } from '../../../hooks/common/useUserPermissions';
import { useSidebar } from '../../../hooks/common/useSidebar';
import { mergeAdminConfig, useSidebar } from '../../../hooks/common/useSidebar';
import { Settings } from 'lucide-react';
const { Text } = Typography;
@@ -198,9 +198,25 @@ export default function SettingsSidebarModulesUser() {
try {
// 获取管理员全局配置
if (statusState?.status?.SidebarModulesAdmin) {
const adminConf = JSON.parse(statusState.status.SidebarModulesAdmin);
setAdminConfig(adminConf);
console.log('加载管理员边栏配置:', adminConf);
try {
const adminConf = JSON.parse(
statusState.status.SidebarModulesAdmin,
);
const mergedAdminConf = mergeAdminConfig(adminConf);
setAdminConfig(mergedAdminConf);
console.log('加载管理员边栏配置:', mergedAdminConf);
} catch (error) {
const mergedAdminConf = mergeAdminConfig(null);
setAdminConfig(mergedAdminConf);
console.log(
'加载管理员边栏配置失败,使用默认配置:',
mergedAdminConf,
);
}
} else {
const mergedAdminConf = mergeAdminConfig(null);
setAdminConfig(mergedAdminConf);
console.log('管理员边栏配置缺失,使用默认配置:', mergedAdminConf);
}
// 获取用户个人配置
@@ -323,6 +339,11 @@ export default function SettingsSidebarModulesUser() {
modules: [
{ key: 'channel', title: t('渠道管理'), description: t('API渠道配置') },
{ key: 'models', title: t('模型管理'), description: t('AI模型配置') },
{
key: 'deployment',
title: t('模型部署'),
description: t('模型部署管理'),
},
{
key: 'redemption',
title: t('兑换码管理'),
@@ -389,7 +410,7 @@ export default function SettingsSidebarModulesUser() {
</Text>
</div>
<Switch
checked={sidebarModulesUser[section.key]?.enabled}
checked={sidebarModulesUser[section.key]?.enabled !== false}
onChange={handleSectionChange(section.key)}
size='default'
/>
@@ -401,7 +422,9 @@ export default function SettingsSidebarModulesUser() {
<Col key={module.key} xs={24} sm={12} md={8} lg={6} xl={6}>
<Card
className={`!rounded-xl border border-gray-200 hover:border-blue-300 transition-all duration-200 ${
sidebarModulesUser[section.key]?.enabled ? '' : 'opacity-50'
sidebarModulesUser[section.key]?.enabled !== false
? ''
: 'opacity-50'
}`}
bodyStyle={{ padding: '16px' }}
hoverable
@@ -417,10 +440,15 @@ export default function SettingsSidebarModulesUser() {
</div>
<div className='ml-4'>
<Switch
checked={sidebarModulesUser[section.key]?.[module.key]}
checked={
sidebarModulesUser[section.key]?.[module.key] !==
false
}
onChange={handleModuleChange(section.key, module.key)}
size='default'
disabled={!sidebarModulesUser[section.key]?.enabled}
disabled={
sidebarModulesUser[section.key]?.enabled === false
}
/>
</div>
</div>