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 package controller
import ( import (
"bytes"
"encoding/json"
"fmt" "fmt"
"strconv" "strconv"
"strings" "strings"
@@ -23,6 +25,20 @@ func getIoAPIKey(c *gin.Context) (string, bool) {
return apiKey, true 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) { func getIoClient(c *gin.Context) (*ionet.Client, bool) {
apiKey, ok := getIoAPIKey(c) apiKey, ok := getIoAPIKey(c)
if !ok { if !ok {
@@ -44,16 +60,29 @@ func TestIoNetConnection(c *gin.Context) {
APIKey string `json:"api_key"` APIKey string `json:"api_key"`
} }
if err := c.ShouldBindJSON(&req); err != nil { 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") common.ApiErrorMsg(c, "invalid request payload")
return return
} }
}
apiKey := strings.TrimSpace(req.APIKey) apiKey := strings.TrimSpace(req.APIKey)
if apiKey == "" { if apiKey == "" {
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") common.ApiErrorMsg(c, "api_key is required")
return return
} }
apiKey = storedKey
}
client := ionet.NewEnterpriseClient(apiKey) client := ionet.NewEnterpriseClient(apiKey)
result, err := client.GetMaxGPUsPerContainer() result, err := client.GetMaxGPUsPerContainer()

View File

@@ -20,7 +20,11 @@ func GetOptions(c *gin.Context) {
var options []*model.Option var options []*model.Option
common.OptionMapRWMutex.Lock() common.OptionMapRWMutex.Lock()
for k, v := range common.OptionMap { 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 continue
} }
options = append(options, &model.Option{ options = append(options, &model.Option{

View File

@@ -269,24 +269,18 @@ func SetApiRouter(router *gin.Engine) {
deploymentsRoute := apiRouter.Group("/deployments") deploymentsRoute := apiRouter.Group("/deployments")
deploymentsRoute.Use(middleware.AdminAuth()) 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("/", controller.GetAllDeployments)
deploymentsRoute.GET("/search", controller.SearchDeployments) deploymentsRoute.GET("/search", controller.SearchDeployments)
// Connection utilities
deploymentsRoute.POST("/test-connection", controller.TestIoNetConnection) deploymentsRoute.POST("/test-connection", controller.TestIoNetConnection)
// Resource and configuration endpoints
deploymentsRoute.GET("/hardware-types", controller.GetHardwareTypes) deploymentsRoute.GET("/hardware-types", controller.GetHardwareTypes)
deploymentsRoute.GET("/locations", controller.GetLocations) deploymentsRoute.GET("/locations", controller.GetLocations)
deploymentsRoute.GET("/available-replicas", controller.GetAvailableReplicas) deploymentsRoute.GET("/available-replicas", controller.GetAvailableReplicas)
deploymentsRoute.POST("/price-estimation", controller.GetPriceEstimation) deploymentsRoute.POST("/price-estimation", controller.GetPriceEstimation)
deploymentsRoute.GET("/check-name", controller.CheckClusterNameAvailability) deploymentsRoute.GET("/check-name", controller.CheckClusterNameAvailability)
// Create new deployment
deploymentsRoute.POST("/", controller.CreateDeployment) deploymentsRoute.POST("/", controller.CreateDeployment)
// Individual deployment operations
deploymentsRoute.GET("/:id", controller.GetDeployment) deploymentsRoute.GET("/:id", controller.GetDeployment)
deploymentsRoute.GET("/:id/logs", controller.GetDeploymentLogs) deploymentsRoute.GET("/:id/logs", controller.GetDeploymentLogs)
deploymentsRoute.GET("/:id/containers", controller.ListDeploymentContainers) deploymentsRoute.GET("/:id/containers", controller.ListDeploymentContainers)
@@ -295,14 +289,6 @@ func SetApiRouter(router *gin.Engine) {
deploymentsRoute.PUT("/:id/name", controller.UpdateDeploymentName) deploymentsRoute.PUT("/:id/name", controller.UpdateDeploymentName)
deploymentsRoute.POST("/:id/extend", controller.ExtendDeployment) deploymentsRoute.POST("/:id/extend", controller.ExtendDeployment)
deploymentsRoute.DELETE("/:id", controller.DeleteDeployment) 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", "zh",
"en", "en",
"fr", "fr",
"ru" "ru",
"ja",
"vi"
], ],
extract: { extract: {
input: [ input: [

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -35,6 +35,7 @@ const DeploymentsPage = () => {
// Create deployment modal state // Create deployment modal state
const [showCreateModal, setShowCreateModal] = useState(false); const [showCreateModal, setShowCreateModal] = useState(false);
const batchOperationsEnabled = false;
const { const {
// Edit state // Edit state
@@ -109,6 +110,7 @@ const DeploymentsPage = () => {
setEditingDeployment={setEditingDeployment} setEditingDeployment={setEditingDeployment}
setShowEdit={setShowEdit} setShowEdit={setShowEdit}
batchDeleteDeployments={batchDeleteDeployments} batchDeleteDeployments={batchDeleteDeployments}
batchOperationsEnabled={batchOperationsEnabled}
compactMode={compactMode} compactMode={compactMode}
setCompactMode={setCompactMode} setCompactMode={setCompactMode}
showCreateModal={showCreateModal} showCreateModal={showCreateModal}
@@ -138,7 +140,10 @@ const DeploymentsPage = () => {
})} })}
t={deploymentsData.t} t={deploymentsData.t}
> >
<DeploymentsTable {...deploymentsData} /> <DeploymentsTable
{...deploymentsData}
batchOperationsEnabled={batchOperationsEnabled}
/>
</CardPro> </CardPro>
</> </>
); );

View File

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

View File

@@ -43,16 +43,16 @@ import {
FaLink, FaLink,
} from 'react-icons/fa'; } from 'react-icons/fa';
import { IconRefresh } from '@douyinfe/semi-icons'; 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 { Text, Title } = Typography;
const ViewDetailsModal = ({ const ViewDetailsModal = ({ visible, onCancel, deployment, t }) => {
visible,
onCancel,
deployment,
t
}) => {
const [details, setDetails] = useState(null); const [details, setDetails] = useState(null);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [containers, setContainers] = useState([]); const [containers, setContainers] = useState([]);
@@ -68,7 +68,11 @@ const ViewDetailsModal = ({
setDetails(response.data.data); setDetails(response.data.data);
} }
} catch (error) { } catch (error) {
showError(t('获取详情失败') + ': ' + (error.response?.data?.message || error.message)); showError(
t('获取详情失败') +
': ' +
(error.response?.data?.message || error.message),
);
} finally { } finally {
setLoading(false); setLoading(false);
} }
@@ -79,12 +83,18 @@ const ViewDetailsModal = ({
setContainersLoading(true); setContainersLoading(true);
try { 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) { if (response.data.success) {
setContainers(response.data.data?.containers || []); setContainers(response.data.data?.containers || []);
} }
} catch (error) { } catch (error) {
showError(t('获取容器信息失败') + ': ' + (error.response?.data?.message || error.message)); showError(
t('获取容器信息失败') +
': ' +
(error.response?.data?.message || error.message),
);
} finally { } finally {
setContainersLoading(false); setContainersLoading(false);
} }
@@ -102,7 +112,7 @@ const ViewDetailsModal = ({
const handleCopyId = () => { const handleCopyId = () => {
navigator.clipboard.writeText(deployment?.id); navigator.clipboard.writeText(deployment?.id);
showSuccess(t('ID已复制到剪贴板')); showSuccess(t('已复制 ID 到剪贴板'));
}; };
const handleRefresh = () => { const handleRefresh = () => {
@@ -112,12 +122,16 @@ const ViewDetailsModal = ({
const getStatusConfig = (status) => { const getStatusConfig = (status) => {
const statusConfig = { const statusConfig = {
'running': { color: 'green', text: '运行中', icon: '🟢' }, running: { color: 'green', text: '运行中', icon: '🟢' },
'completed': { color: 'green', text: '已完成', icon: '✅' }, completed: { color: 'green', text: '已完成', icon: '✅' },
'deployment requested': { color: 'blue', text: '部署请求中', icon: '🔄' }, 'deployment requested': { color: 'blue', text: '部署请求中', icon: '🔄' },
'termination requested': { color: 'orange', text: '终止请求中', icon: '⏸️' }, 'termination requested': {
'destroyed': { color: 'red', text: '已销毁', icon: '🔴' }, color: 'orange',
'failed': { color: 'red', text: '失败', icon: '❌' } text: '终止请求中',
icon: '⏸️',
},
destroyed: { color: 'red', text: '已销毁', icon: '🔴' },
failed: { color: 'red', text: '失败', icon: '❌' },
}; };
return statusConfig[status] || { color: 'grey', text: status, icon: '❓' }; return statusConfig[status] || { color: 'grey', text: status, icon: '❓' };
}; };
@@ -127,149 +141,167 @@ const ViewDetailsModal = ({
return ( return (
<Modal <Modal
title={ title={
<div className="flex items-center gap-2"> <div className='flex items-center gap-2'>
<FaInfoCircle className="text-blue-500" /> <FaInfoCircle className='text-blue-500' />
<span>{t('容器详情')}</span> <span>{t('容器详情')}</span>
</div> </div>
} }
visible={visible} visible={visible}
onCancel={onCancel} onCancel={onCancel}
footer={ footer={
<div className="flex justify-between"> <div className='flex justify-between'>
<Button <Button
icon={<IconRefresh />} icon={<IconRefresh />}
onClick={handleRefresh} onClick={handleRefresh}
loading={loading || containersLoading} loading={loading || containersLoading}
theme="borderless" theme='borderless'
> >
{t('刷新')} {t('刷新')}
</Button> </Button>
<Button onClick={onCancel}> <Button onClick={onCancel}>{t('关闭')}</Button>
{t('关闭')}
</Button>
</div> </div>
} }
width={800} width={800}
className="deployment-details-modal" className='deployment-details-modal'
> >
{loading && !details ? ( {loading && !details ? (
<div className="flex items-center justify-center py-12"> <div className='flex items-center justify-center py-12'>
<Spin size="large" tip={t('加载详情中...')} /> <Spin size='large' tip={t('加载详情中...')} />
</div> </div>
) : details ? ( ) : 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 */} {/* Basic Info */}
<Card <Card
title={ title={
<div className="flex items-center gap-2"> <div className='flex items-center gap-2'>
<FaServer className="text-blue-500" /> <FaServer className='text-blue-500' />
<span>{t('基本信息')}</span> <span>{t('基本信息')}</span>
</div> </div>
} }
className="border-0 shadow-sm" className='border-0 shadow-sm'
> >
<Descriptions data={[ <Descriptions
data={[
{ {
key: t('容器名称'), key: t('容器名称'),
value: ( value: (
<div className="flex items-center gap-2"> <div className='flex items-center gap-2'>
<Text strong className="text-base"> <Text strong className='text-base'>
{details.deployment_name || details.id} {details.deployment_name || details.id}
</Text> </Text>
<Button <Button
size="small" size='small'
theme="borderless" theme='borderless'
icon={<FaCopy />} icon={<FaCopy />}
onClick={handleCopyId} onClick={handleCopyId}
className="opacity-70 hover:opacity-100" className='opacity-70 hover:opacity-100'
/> />
</div> </div>
) ),
}, },
{ {
key: t('容器ID'), key: t('容器ID'),
value: ( value: (
<Text type="secondary" className="font-mono text-sm"> <Text type='secondary' className='font-mono text-sm'>
{details.id} {details.id}
</Text> </Text>
) ),
}, },
{ {
key: t('状态'), key: t('状态'),
value: ( value: (
<div className="flex items-center gap-2"> <div className='flex items-center gap-2'>
<span>{statusConfig.icon}</span> <span>{statusConfig.icon}</span>
<Tag color={statusConfig.color}> <Tag color={statusConfig.color}>
{t(statusConfig.text)} {t(statusConfig.text)}
</Tag> </Tag>
</div> </div>
) ),
}, },
{ {
key: t('创建时间'), key: t('创建时间'),
value: timestamp2string(details.created_at) value: timestamp2string(details.created_at),
} },
]} /> ]}
/>
</Card> </Card>
{/* Hardware & Performance */} {/* Hardware & Performance */}
<Card <Card
title={ title={
<div className="flex items-center gap-2"> <div className='flex items-center gap-2'>
<FaChartLine className="text-green-500" /> <FaChartLine className='text-green-500' />
<span>{t('硬件与性能')}</span> <span>{t('硬件与性能')}</span>
</div> </div>
} }
className="border-0 shadow-sm" className='border-0 shadow-sm'
> >
<div className="space-y-4"> <div className='space-y-4'>
<Descriptions data={[ <Descriptions
data={[
{ {
key: t('硬件类型'), key: t('硬件类型'),
value: ( value: (
<div className="flex items-center gap-2"> <div className='flex items-center gap-2'>
<Tag color="blue">{details.brand_name}</Tag> <Tag color='blue'>{details.brand_name}</Tag>
<Text strong>{details.hardware_name}</Text> <Text strong>{details.hardware_name}</Text>
</div> </div>
) ),
}, },
{ {
key: t('GPU数量'), key: t('GPU数量'),
value: ( value: (
<div className="flex items-center gap-2"> <div className='flex items-center gap-2'>
<Badge count={details.total_gpus} theme="solid" type="primary"> <Badge
<FaServer className="text-purple-500" /> count={details.total_gpus}
theme='solid'
type='primary'
>
<FaServer className='text-purple-500' />
</Badge> </Badge>
<Text>{t('总计')} {details.total_gpus} {t('个GPU')}</Text> <Text>
{t('总计')} {details.total_gpus} {t('个GPU')}
</Text>
</div> </div>
) ),
}, },
{ {
key: t('容器配置'), key: t('容器配置'),
value: ( value: (
<div className="space-y-1"> <div className='space-y-1'>
<div>{t('每容器GPU数')}: {details.gpus_per_container}</div> <div>
<div>{t('容器数')}: {details.total_containers}</div> {t('容器GPU数')}: {details.gpus_per_container}
</div> </div>
) <div>
} {t('容器总数')}: {details.total_containers}
]} /> </div>
</div>
),
},
]}
/>
{/* Progress Bar */} {/* Progress Bar */}
<div className="space-y-2"> <div className='space-y-2'>
<div className="flex items-center justify-between"> <div className='flex items-center justify-between'>
<Text strong>{t('完成进度')}</Text> <Text strong>{t('完成进度')}</Text>
<Text>{details.completed_percent}%</Text> <Text>{details.completed_percent}%</Text>
</div> </div>
<Progress <Progress
percent={details.completed_percent} percent={details.completed_percent}
status={details.completed_percent === 100 ? 'success' : 'normal'} status={
details.completed_percent === 100 ? 'success' : 'normal'
}
strokeWidth={8} strokeWidth={8}
showInfo={false} showInfo={false}
/> />
<div className="flex justify-between text-xs text-gray-500"> <div className='flex justify-between text-xs text-gray-500'>
<span>{t('已服务')}: {details.compute_minutes_served} {t('分钟')}</span> <span>
<span>{t('剩余')}: {details.compute_minutes_remaining} {t('分钟')}</span> {t('已服务')}: {details.compute_minutes_served} {t('分钟')}
</span>
<span>
{t('剩余')}: {details.compute_minutes_remaining} {t('分钟')}
</span>
</div> </div>
</div> </div>
</div> </div>
@@ -279,49 +311,63 @@ const ViewDetailsModal = ({
{details.container_config && ( {details.container_config && (
<Card <Card
title={ title={
<div className="flex items-center gap-2"> <div className='flex items-center gap-2'>
<FaDocker className="text-blue-600" /> <FaDocker className='text-blue-600' />
<span>{t('容器配置')}</span> <span>{t('容器配置')}</span>
</div> </div>
} }
className="border-0 shadow-sm" className='border-0 shadow-sm'
> >
<div className="space-y-3"> <div className='space-y-3'>
<Descriptions data={[ <Descriptions
data={[
{ {
key: t('镜像地址'), key: t('镜像地址'),
value: ( value: (
<Text className="font-mono text-sm break-all"> <Text className='font-mono text-sm break-all'>
{details.container_config.image_url || 'N/A'} {details.container_config.image_url || 'N/A'}
</Text> </Text>
) ),
}, },
{ {
key: t('流量端口'), key: t('流量端口'),
value: details.container_config.traffic_port || 'N/A' value: details.container_config.traffic_port || 'N/A',
}, },
{ {
key: t('启动命令'), key: t('启动命令'),
value: ( value: (
<Text className="font-mono text-sm"> <Text className='font-mono text-sm'>
{details.container_config.entrypoint ? {details.container_config.entrypoint
details.container_config.entrypoint.join(' ') : 'N/A' ? details.container_config.entrypoint.join(' ')
} : 'N/A'}
</Text> </Text>
) ),
} },
]} /> ]}
/>
{/* Environment Variables */} {/* Environment Variables */}
{details.container_config.env_variables && {details.container_config.env_variables &&
Object.keys(details.container_config.env_variables).length > 0 && ( Object.keys(details.container_config.env_variables).length >
<div className="mt-4"> 0 && (
<Text strong className="block mb-2">{t('环境变量')}:</Text> <div className='mt-4'>
<div className="bg-gray-50 p-3 rounded-lg max-h-32 overflow-y-auto"> <Text strong className='block mb-2'>
{Object.entries(details.container_config.env_variables).map(([key, value]) => ( {t('环境变量')}:
<div key={key} className="flex gap-2 text-sm font-mono mb-1"> </Text>
<span className="text-blue-600 font-medium">{key}=</span> <div className='bg-gray-50 p-3 rounded-lg max-h-32 overflow-y-auto'>
<span className="text-gray-700 break-all">{String(value)}</span> {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>
@@ -334,50 +380,63 @@ const ViewDetailsModal = ({
{/* Containers List */} {/* Containers List */}
<Card <Card
title={ title={
<div className="flex items-center gap-2"> <div className='flex items-center gap-2'>
<FaServer className="text-indigo-500" /> <FaServer className='text-indigo-500' />
<span>{t('容器实例')}</span> <span>{t('容器实例')}</span>
</div> </div>
} }
className="border-0 shadow-sm" className='border-0 shadow-sm'
> >
{containersLoading ? ( {containersLoading ? (
<div className="flex items-center justify-center py-6"> <div className='flex items-center justify-center py-6'>
<Spin tip={t('加载容器信息中...')} /> <Spin tip={t('加载容器信息中...')} />
</div> </div>
) : containers.length === 0 ? ( ) : 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) => ( {containers.map((ctr) => (
<Card <Card
key={ctr.container_id} key={ctr.container_id}
className="bg-gray-50 border border-gray-100" className='bg-gray-50 border border-gray-100'
bodyStyle={{ padding: '12px 16px' }} bodyStyle={{ padding: '12px 16px' }}
> >
<div className="flex flex-wrap items-center justify-between gap-3"> <div className='flex flex-wrap items-center justify-between gap-3'>
<div className="flex flex-col gap-1"> <div className='flex flex-col gap-1'>
<Text strong className="font-mono text-sm"> <Text strong className='font-mono text-sm'>
{ctr.container_id} {ctr.container_id}
</Text> </Text>
<Text size="small" type="secondary"> <Text size='small' type='secondary'>
{t('设备')} {ctr.device_id || '--'} · {t('状态')} {ctr.status || '--'} {t('设备')} {ctr.device_id || '--'} · {t('状态')}{' '}
{ctr.status || '--'}
</Text> </Text>
<Text size="small" type="secondary"> <Text size='small' type='secondary'>
{t('创建时间')}: {ctr.created_at ? timestamp2string(ctr.created_at) : '--'} {t('创建时间')}:{' '}
{ctr.created_at
? timestamp2string(ctr.created_at)
: '--'}
</Text> </Text>
</div> </div>
<div className="flex flex-col items-end gap-2"> <div className='flex flex-col items-end gap-2'>
<Tag color="blue" size="small"> <Tag color='blue' size='small'>
{t('GPU/容器')}: {ctr.gpus_per_container ?? '--'} {t('GPU/容器')}: {ctr.gpus_per_container ?? '--'}
</Tag> </Tag>
{ctr.public_url && ( {ctr.public_url && (
<Tooltip content={ctr.public_url}> <Tooltip content={ctr.public_url}>
<Button <Button
icon={<FaLink />} icon={<FaLink />}
size="small" size='small'
theme="light" theme='light'
onClick={() => window.open(ctr.public_url, '_blank', 'noopener,noreferrer')} onClick={() =>
window.open(
ctr.public_url,
'_blank',
'noopener,noreferrer',
)
}
> >
{t('访问容器')} {t('访问容器')}
</Button> </Button>
@@ -387,17 +446,26 @@ const ViewDetailsModal = ({
</div> </div>
{ctr.events && ctr.events.length > 0 && ( {ctr.events && ctr.events.length > 0 && (
<div className="mt-3 bg-white rounded-md border border-gray-100 p-3"> <div className='mt-3 bg-white rounded-md border border-gray-100 p-3'>
<Text size="small" type="secondary" className="block mb-2"> <Text
size='small'
type='secondary'
className='block mb-2'
>
{t('最近事件')} {t('最近事件')}
</Text> </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) => ( {ctr.events.map((event, index) => (
<div key={`${ctr.container_id}-${event.time}-${index}`} className="flex gap-3 text-xs font-mono"> <div
<span className="text-gray-500 min-w-[140px]"> key={`${ctr.container_id}-${event.time}-${index}`}
{event.time ? timestamp2string(event.time) : '--'} className='flex gap-3 text-xs font-mono'
>
<span className='text-gray-500 min-w-[140px]'>
{event.time
? timestamp2string(event.time)
: '--'}
</span> </span>
<span className="text-gray-700 break-all flex-1"> <span className='text-gray-700 break-all flex-1'>
{event.message || '--'} {event.message || '--'}
</span> </span>
</div> </div>
@@ -415,19 +483,21 @@ const ViewDetailsModal = ({
{details.locations && details.locations.length > 0 && ( {details.locations && details.locations.length > 0 && (
<Card <Card
title={ title={
<div className="flex items-center gap-2"> <div className='flex items-center gap-2'>
<FaMapMarkerAlt className="text-orange-500" /> <FaMapMarkerAlt className='text-orange-500' />
<span>{t('部署位置')}</span> <span>{t('部署位置')}</span>
</div> </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) => ( {details.locations.map((location) => (
<Tag key={location.id} color="orange" size="large"> <Tag key={location.id} color='orange' size='large'>
<div className="flex items-center gap-1"> <div className='flex items-center gap-1'>
<span>🌍</span> <span>🌍</span>
<span>{location.name} ({location.iso2})</span> <span>
{location.name} ({location.iso2})
</span>
</div> </div>
</Tag> </Tag>
))} ))}
@@ -438,29 +508,41 @@ const ViewDetailsModal = ({
{/* Cost Information */} {/* Cost Information */}
<Card <Card
title={ title={
<div className="flex items-center gap-2"> <div className='flex items-center gap-2'>
<FaMoneyBillWave className="text-green-500" /> <FaMoneyBillWave className='text-green-500' />
<span>{t('费用信息')}</span> <span>{t('费用信息')}</span>
</div> </div>
} }
className="border-0 shadow-sm" className='border-0 shadow-sm'
> >
<div className="space-y-3"> <div className='space-y-3'>
<div className="flex items-center justify-between p-3 bg-green-50 rounded-lg"> <div className='flex items-center justify-between p-3 bg-green-50 rounded-lg'>
<Text>{t('已支付金额')}</Text> <Text>{t('已支付金额')}</Text>
<Text strong className="text-lg text-green-600"> <Text strong className='text-lg text-green-600'>
${details.amount_paid ? details.amount_paid.toFixed(2) : '0.00'} USDC $
{details.amount_paid
? details.amount_paid.toFixed(2)
: '0.00'}{' '}
USDC
</Text> </Text>
</div> </div>
<div className="grid grid-cols-2 gap-4 text-sm"> <div className='grid grid-cols-2 gap-4 text-sm'>
<div className="flex justify-between"> <div className='flex justify-between'>
<Text type="secondary">{t('计费开始')}:</Text> <Text type='secondary'>{t('计费开始')}:</Text>
<Text>{details.started_at ? timestamp2string(details.started_at) : 'N/A'}</Text> <Text>
{details.started_at
? timestamp2string(details.started_at)
: 'N/A'}
</Text>
</div> </div>
<div className="flex justify-between"> <div className='flex justify-between'>
<Text type="secondary">{t('预计结束')}:</Text> <Text type='secondary'>{t('预计结束')}:</Text>
<Text>{details.finished_at ? timestamp2string(details.finished_at) : 'N/A'}</Text> <Text>
{details.finished_at
? timestamp2string(details.finished_at)
: 'N/A'}
</Text>
</div> </div>
</div> </div>
</div> </div>
@@ -469,35 +551,37 @@ const ViewDetailsModal = ({
{/* Time Information */} {/* Time Information */}
<Card <Card
title={ title={
<div className="flex items-center gap-2"> <div className='flex items-center gap-2'>
<FaClock className="text-purple-500" /> <FaClock className='text-purple-500' />
<span>{t('时间信息')}</span> <span>{t('时间信息')}</span>
</div> </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='grid grid-cols-1 md:grid-cols-2 gap-4'>
<div className="space-y-2"> <div className='space-y-2'>
<div className="flex items-center justify-between"> <div className='flex items-center justify-between'>
<Text type="secondary">{t('已运行时间')}:</Text> <Text type='secondary'>{t('已运行时间')}:</Text>
<Text strong> <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> </Text>
</div> </div>
<div className="flex items-center justify-between"> <div className='flex items-center justify-between'>
<Text type="secondary">{t('剩余时间')}:</Text> <Text type='secondary'>{t('剩余时间')}:</Text>
<Text strong className="text-orange-600"> <Text strong className='text-orange-600'>
{Math.floor(details.compute_minutes_remaining / 60)}h {details.compute_minutes_remaining % 60}m {Math.floor(details.compute_minutes_remaining / 60)}h{' '}
{details.compute_minutes_remaining % 60}m
</Text> </Text>
</div> </div>
</div> </div>
<div className="space-y-2"> <div className='space-y-2'>
<div className="flex items-center justify-between"> <div className='flex items-center justify-between'>
<Text type="secondary">{t('创建时间')}:</Text> <Text type='secondary'>{t('创建时间')}:</Text>
<Text>{timestamp2string(details.created_at)}</Text> <Text>{timestamp2string(details.created_at)}</Text>
</div> </div>
<div className="flex items-center justify-between"> <div className='flex items-center justify-between'>
<Text type="secondary">{t('最后更新')}:</Text> <Text type='secondary'>{t('最后更新')}:</Text>
<Text>{timestamp2string(details.updated_at)}</Text> <Text>{timestamp2string(details.updated_at)}</Text>
</div> </div>
</div> </div>

View File

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

View File

@@ -25,20 +25,7 @@ import { API } from '../../helpers';
const sidebarEventTarget = new EventTarget(); const sidebarEventTarget = new EventTarget();
const SIDEBAR_REFRESH_EVENT = 'sidebar-refresh'; const SIDEBAR_REFRESH_EVENT = 'sidebar-refresh';
export const useSidebar = () => { export const DEFAULT_ADMIN_CONFIG = {
const [statusState] = useContext(StatusContext);
const [userConfig, setUserConfig] = useState(null);
const [loading, setLoading] = useState(true);
const instanceIdRef = useRef(null);
const hasLoadedOnceRef = useRef(false);
if (!instanceIdRef.current) {
const randomPart = Math.random().toString(16).slice(2);
instanceIdRef.current = `sidebar-${Date.now()}-${randomPart}`;
}
// 默认配置
const defaultAdminConfig = {
chat: { chat: {
enabled: true, enabled: true,
playground: true, playground: true,
@@ -66,19 +53,51 @@ export const useSidebar = () => {
user: true, user: true,
setting: 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);
const [loading, setLoading] = useState(true);
const instanceIdRef = useRef(null);
const hasLoadedOnceRef = useRef(false);
if (!instanceIdRef.current) {
const randomPart = Math.random().toString(16).slice(2);
instanceIdRef.current = `sidebar-${Date.now()}-${randomPart}`;
}
// 获取管理员配置 // 获取管理员配置
const adminConfig = useMemo(() => { const adminConfig = useMemo(() => {
if (statusState?.status?.SidebarModulesAdmin) { if (statusState?.status?.SidebarModulesAdmin) {
try { try {
const config = JSON.parse(statusState.status.SidebarModulesAdmin); const config = JSON.parse(statusState.status.SidebarModulesAdmin);
return config; return mergeAdminConfig(config);
} catch (error) { } catch (error) {
return defaultAdminConfig; return mergeAdminConfig(null);
} }
} }
return defaultAdminConfig; return mergeAdminConfig(null);
}, [statusState?.status?.SidebarModulesAdmin]); }, [statusState?.status?.SidebarModulesAdmin]);
// 加载用户配置的通用方法 // 加载用户配置的通用方法

View File

@@ -39,10 +39,13 @@ export const useDeploymentResources = () => {
setLoadingHardware(true); setLoadingHardware(true);
const response = await API.get('/api/deployments/hardware-types'); const response = await API.get('/api/deployments/hardware-types');
if (response.data.success) { 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 normalizedHardware = hardwareList.map((hardware) => {
const availableCountValue = Number(hardware.available_count); const availableCountValue = Number(hardware.available_count);
const availableCount = Number.isNaN(availableCountValue) ? 0 : availableCountValue; const availableCount = Number.isNaN(availableCountValue)
? 0
: availableCountValue;
const availableBool = const availableBool =
typeof hardware.available === 'boolean' typeof hardware.available === 'boolean'
? hardware.available ? hardware.available
@@ -57,7 +60,9 @@ export const useDeploymentResources = () => {
const providedTotal = Number(total_available); const providedTotal = Number(total_available);
const fallbackTotal = normalizedHardware.reduce( 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, 0,
); );
const hasProvidedTotal = 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 { try {
setLoadingLocations(true); setLoadingLocations(true);
const response = await API.get('/api/deployments/locations'); const response = await API.get(
if (response.data.success) { `/api/deployments/available-replicas?hardware_id=${hardwareId}&gpu_count=${gpuCount}`,
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;
return {
...location,
iso2,
available,
};
});
const providedTotal = Number(total);
const fallbackTotal = normalizedLocations.reduce(
(acc, item) => acc + (Number.isNaN(item.available) ? 0 : item.available),
0,
); );
const hasProvidedTotal = if (response.data.success) {
total !== undefined && const replicas = response.data.data?.replicas || [];
total !== null && const nextLocationsMap = new Map();
total !== '' && replicas.forEach((replica) => {
!Number.isNaN(providedTotal); const rawId = replica?.location_id ?? replica?.location?.id;
if (rawId === null || rawId === undefined) return;
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,
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 normalizedLocations = Array.from(nextLocationsMap.values());
setLocations(normalizedLocations); setLocations(normalizedLocations);
setLocationsTotalAvailable( setLocationsTotalAvailable(
hasProvidedTotal ? providedTotal : fallbackTotal, normalizedLocations.reduce(
(acc, item) => acc + (item.available || 0),
0,
),
); );
return normalizedLocations; return normalizedLocations;
} else { } else {
@@ -132,7 +164,8 @@ export const useDeploymentResources = () => {
} }
}, []); }, []);
const fetchAvailableReplicas = useCallback(async (hardwareId, gpuCount = 1) => { const fetchAvailableReplicas = useCallback(
async (hardwareId, gpuCount = 1) => {
if (!hardwareId) { if (!hardwareId) {
setAvailableReplicas([]); setAvailableReplicas([]);
return []; return [];
@@ -141,7 +174,7 @@ export const useDeploymentResources = () => {
try { try {
setLoadingReplicas(true); setLoadingReplicas(true);
const response = await API.get( const response = await API.get(
`/api/deployments/available-replicas?hardware_id=${hardwareId}&gpu_count=${gpuCount}` `/api/deployments/available-replicas?hardware_id=${hardwareId}&gpu_count=${gpuCount}`,
); );
if (response.data.success) { if (response.data.success) {
const replicas = response.data.data.replicas || []; const replicas = response.data.data.replicas || [];
@@ -159,7 +192,9 @@ export const useDeploymentResources = () => {
} finally { } finally {
setLoadingReplicas(false); setLoadingReplicas(false);
} }
}, []); },
[],
);
const calculatePrice = useCallback(async (params) => { const calculatePrice = useCallback(async (params) => {
const { const {
@@ -167,10 +202,16 @@ export const useDeploymentResources = () => {
hardwareId, hardwareId,
gpusPerContainer, gpusPerContainer,
durationHours, durationHours,
replicaCount replicaCount,
} = params; } = params;
if (!locationIds?.length || !hardwareId || !gpusPerContainer || !durationHours || !replicaCount) { if (
!locationIds?.length ||
!hardwareId ||
!gpusPerContainer ||
!durationHours ||
!replicaCount
) {
setPriceEstimation(null); setPriceEstimation(null);
return null; return null;
} }
@@ -185,7 +226,10 @@ export const useDeploymentResources = () => {
replica_count: replicaCount, 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) { if (response.data.success) {
const estimation = response.data.data; const estimation = response.data.data;
setPriceEstimation(estimation); setPriceEstimation(estimation);
@@ -208,7 +252,9 @@ export const useDeploymentResources = () => {
if (!name?.trim()) return false; if (!name?.trim()) return false;
try { 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) { if (response.data.success) {
return response.data.data.available; return response.data.data.available;
} else { } 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 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 { useTranslation } from 'react-i18next';
import { API, showError, showSuccess } from '../../helpers'; import { API, showError, showSuccess } from '../../helpers';
import { ITEMS_PER_PAGE } from '../../constants'; import { ITEMS_PER_PAGE } from '../../constants';
@@ -26,6 +26,7 @@ import { useTableCompactMode } from '../common/useTableCompactMode';
export const useDeploymentsData = () => { export const useDeploymentsData = () => {
const { t } = useTranslation(); const { t } = useTranslation();
const [compactMode, setCompactMode] = useTableCompactMode('deployments'); const [compactMode, setCompactMode] = useTableCompactMode('deployments');
const requestSeq = useRef(0);
// State management // State management
const [deployments, setDeployments] = useState([]); const [deployments, setDeployments] = useState([]);
@@ -34,6 +35,7 @@ export const useDeploymentsData = () => {
const [pageSize, setPageSize] = useState(ITEMS_PER_PAGE); const [pageSize, setPageSize] = useState(ITEMS_PER_PAGE);
const [searching, setSearching] = useState(false); const [searching, setSearching] = useState(false);
const [deploymentCount, setDeploymentCount] = useState(0); const [deploymentCount, setDeploymentCount] = useState(0);
const [query, setQuery] = useState({ keyword: '', status: '' });
// Modal states // Modal states
const [showEdit, setShowEdit] = useState(false); const [showEdit, setShowEdit] = useState(false);
@@ -80,18 +82,12 @@ export const useDeploymentsData = () => {
}, 500); }, 500);
}; };
// Set deployment format with key field const normalizeQuery = (terms) => {
const setDeploymentFormat = (deployments) => { const keyword = (terms?.searchKeyword ?? '').trim();
for (let i = 0; i < deployments.length; i++) { const status = (terms?.searchStatus ?? '').trim();
deployments[i].key = deployments[i].id; return { keyword, status };
}
setDeployments(deployments);
}; };
// Status tabs
const [activeStatusKey, setActiveStatusKey] = useState('all');
const [statusCounts, setStatusCounts] = useState({});
// Column visibility // Column visibility
const COLUMN_KEYS = useMemo( const COLUMN_KEYS = useMemo(
() => ({ () => ({
@@ -160,114 +156,127 @@ export const useDeploymentsData = () => {
// Save column visibility to localStorage // Save column visibility to localStorage
const saveColumnVisibility = (newVisibleColumns) => { const saveColumnVisibility = (newVisibleColumns) => {
const normalized = ensureRequiredColumns(newVisibleColumns); const normalized = ensureRequiredColumns(newVisibleColumns);
localStorage.setItem('deployments_visible_columns', JSON.stringify(normalized)); localStorage.setItem(
'deployments_visible_columns',
JSON.stringify(normalized),
);
setVisibleColumnsState(normalized); setVisibleColumnsState(normalized);
}; };
// Load deployments data const applyDeploymentsData = ({ data, page }) => {
const loadDeployments = async ( const items = extractItems(data);
page = 1, setActivePage(data?.page ?? page);
size = pageSize, setDeploymentCount(data?.total ?? items.length);
statusKey = activeStatusKey, 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); setLoading(true);
}
try { try {
let url = `/api/deployments/?p=${page}&page_size=${size}`; let url;
if (statusKey && statusKey !== 'all') { if (isSearchMode) {
url = `/api/deployments/search?status=${statusKey}&p=${page}&page_size=${size}`; 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 res = await API.get(url);
const { success, message, data } = res.data; if (seq !== requestSeq.current) return;
if (success) {
const newPageData = extractItems(data);
setActivePage(data.page || page);
setDeploymentCount(data.total || newPageData.length);
setDeploymentFormat(newPageData);
if (data.status_counts) { const { success, message, data } = res.data;
const sumAll = Object.values(data.status_counts).reduce( if (!success) {
(acc, v) => acc + v,
0,
);
setStatusCounts({ ...data.status_counts, all: sumAll });
}
} else {
showError(message); showError(message);
setDeployments([]); setDeployments([]);
setDeploymentCount(0);
return;
} }
applyDeploymentsData({ data, page });
} catch (error) { } catch (error) {
if (seq !== requestSeq.current) return;
console.error(error); console.error(error);
showError(t('获取部署列表失败')); showError(isSearchMode ? t('搜索失败') : t('获取部署列表失败'));
setDeployments([]); setDeployments([]);
} setDeploymentCount(0);
} finally {
if (seq !== requestSeq.current) return;
setLoading(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); setSearching(false);
}
}; };
// Refresh data // Refresh data
const refresh = async (page = activePage) => { const refresh = async (page = activePage) => {
await loadDeployments(page, pageSize); await fetchDeployments({
page,
size: pageSize,
keyword: query.keyword,
status: query.status,
});
}; };
// Handle page change // Handle page change
const handlePageChange = (page) => { const handlePageChange = (page) => {
setActivePage(page); setActivePage(page);
if (!searching) { fetchDeployments({
loadDeployments(page, pageSize); page,
} size: pageSize,
keyword: query.keyword,
status: query.status,
});
}; };
// Handle page size change // Handle page size change
const handlePageSizeChange = (size) => { const handlePageSizeChange = (size) => {
setPageSize(size); setPageSize(size);
setActivePage(1); setActivePage(1);
if (!searching) { fetchDeployments({
loadDeployments(1, size); page: 1,
} size,
keyword: query.keyword,
status: query.status,
});
}; };
// Handle tab change const loadDeployments = async (page = 1, size = pageSize) => {
const handleTabChange = (statusKey) => { await fetchDeployments({
setActiveStatusKey(statusKey); 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); setActivePage(1);
loadDeployments(1, pageSize, statusKey); await fetchDeployments({
page: 1,
size: pageSize,
keyword: nextQuery.keyword,
status: nextQuery.status,
});
}; };
// Deployment operations // Deployment operations
@@ -323,7 +332,9 @@ export const useDeploymentsData = () => {
} }
try { 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) { if (!containersResp.data?.success) {
showError(containersResp.data?.message || t('获取容器信息失败')); showError(containersResp.data?.message || t('获取容器信息失败'));
return; return;
@@ -344,13 +355,18 @@ export const useDeploymentsData = () => {
return; 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 safeName = String(baseName || 'ionet').slice(0, 60);
const channelName = `[IO.NET] ${safeName}`; const channelName = `[IO.NET] ${safeName}`;
let randomKey; let randomKey;
try { try {
randomKey = (typeof crypto !== 'undefined' && crypto.randomUUID) randomKey =
typeof crypto !== 'undefined' && crypto.randomUUID
? `ionet-${crypto.randomUUID().replace(/-/g, '')}` ? `ionet-${crypto.randomUUID().replace(/-/g, '')}`
: null; : null;
} catch (err) { } catch (err) {
@@ -396,7 +412,9 @@ export const useDeploymentsData = () => {
const updateDeploymentName = async (deploymentId, newName) => { const updateDeploymentName = async (deploymentId, newName) => {
try { 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) { if (res.data.success) {
showSuccess(t('部署名称更新成功')); showSuccess(t('部署名称更新成功'));
await refresh(); await refresh();
@@ -417,7 +435,7 @@ export const useDeploymentsData = () => {
if (selectedKeys.length === 0) return; if (selectedKeys.length === 0) return;
try { 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 }); const res = await API.post('/api/deployments/batch_delete', { ids });
if (res.data.success) { if (res.data.success) {
showSuccess(t('批量删除成功')); showSuccess(t('批量删除成功'));
@@ -452,8 +470,6 @@ export const useDeploymentsData = () => {
activePage, activePage,
pageSize, pageSize,
deploymentCount, deploymentCount,
statusCounts,
activeStatusKey,
compactMode, compactMode,
setCompactMode, setCompactMode,
@@ -488,7 +504,6 @@ export const useDeploymentsData = () => {
refresh, refresh,
handlePageChange, handlePageChange,
handlePageSizeChange, handlePageSizeChange,
handleTabChange,
handleRow, handleRow,
// Deployment operations // Deployment operations

View File

@@ -25,9 +25,9 @@ export const useEnhancedDeploymentActions = (t) => {
// Set loading state for specific operation // Set loading state for specific operation
const setOperationLoading = (operation, deploymentId, isLoading) => { const setOperationLoading = (operation, deploymentId, isLoading) => {
setLoading(prev => ({ setLoading((prev) => ({
...prev, ...prev,
[`${operation}_${deploymentId}`]: isLoading [`${operation}_${deploymentId}`]: isLoading,
})); }));
}; };
@@ -38,20 +38,26 @@ export const useEnhancedDeploymentActions = (t) => {
// Extend deployment duration // Extend deployment duration
const extendDeployment = async (deploymentId, durationHours) => { const extendDeployment = async (deploymentId, durationHours) => {
const operationKey = `extend_${deploymentId}`;
try { try {
setOperationLoading('extend', deploymentId, true); setOperationLoading('extend', deploymentId, true);
const response = await API.post(`/api/deployments/${deploymentId}/extend`, { const response = await API.post(
duration_hours: durationHours `/api/deployments/${deploymentId}/extend`,
}); {
duration_hours: durationHours,
},
);
if (response.data.success) { if (response.data.success) {
showSuccess(t('容器时长延长成功')); showSuccess(t('容器时长延长成功'));
return response.data.data; return response.data.data;
} }
} catch (error) { } catch (error) {
showError(t('延长时长失败') + ': ' + (error.response?.data?.message || error.message)); showError(
t('延长时长失败') +
': ' +
(error.response?.data?.message || error.message),
);
throw error; throw error;
} finally { } finally {
setOperationLoading('extend', deploymentId, false); setOperationLoading('extend', deploymentId, false);
@@ -69,7 +75,11 @@ export const useEnhancedDeploymentActions = (t) => {
return response.data.data; return response.data.data;
} }
} catch (error) { } catch (error) {
showError(t('获取详情失败') + ': ' + (error.response?.data?.message || error.message)); showError(
t('获取详情失败') +
': ' +
(error.response?.data?.message || error.message),
);
throw error; throw error;
} finally { } finally {
setOperationLoading('details', deploymentId, false); setOperationLoading('details', deploymentId, false);
@@ -83,7 +93,8 @@ export const useEnhancedDeploymentActions = (t) => {
const params = new URLSearchParams(); 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.level) params.append('level', options.level);
if (options.limit) params.append('limit', options.limit.toString()); if (options.limit) params.append('limit', options.limit.toString());
if (options.cursor) params.append('cursor', options.cursor); if (options.cursor) params.append('cursor', options.cursor);
@@ -91,13 +102,19 @@ export const useEnhancedDeploymentActions = (t) => {
if (options.startTime) params.append('start_time', options.startTime); if (options.startTime) params.append('start_time', options.startTime);
if (options.endTime) params.append('end_time', options.endTime); 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) { if (response.data.success) {
return response.data.data; return response.data.data;
} }
} catch (error) { } catch (error) {
showError(t('获取日志失败') + ': ' + (error.response?.data?.message || error.message)); showError(
t('获取日志失败') +
': ' +
(error.response?.data?.message || error.message),
);
throw error; throw error;
} finally { } finally {
setOperationLoading('logs', deploymentId, false); setOperationLoading('logs', deploymentId, false);
@@ -109,14 +126,21 @@ export const useEnhancedDeploymentActions = (t) => {
try { try {
setOperationLoading('config', deploymentId, true); 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) { if (response.data.success) {
showSuccess(t('容器配置更新成功')); showSuccess(t('容器配置更新成功'));
return response.data.data; return response.data.data;
} }
} catch (error) { } catch (error) {
showError(t('更新配置失败') + ': ' + (error.response?.data?.message || error.message)); showError(
t('更新配置失败') +
': ' +
(error.response?.data?.message || error.message),
);
throw error; throw error;
} finally { } finally {
setOperationLoading('config', deploymentId, false); setOperationLoading('config', deploymentId, false);
@@ -135,7 +159,11 @@ export const useEnhancedDeploymentActions = (t) => {
return response.data.data; return response.data.data;
} }
} catch (error) { } catch (error) {
showError(t('销毁容器失败') + ': ' + (error.response?.data?.message || error.message)); showError(
t('销毁容器失败') +
': ' +
(error.response?.data?.message || error.message),
);
throw error; throw error;
} finally { } finally {
setOperationLoading('delete', deploymentId, false); setOperationLoading('delete', deploymentId, false);
@@ -148,7 +176,7 @@ export const useEnhancedDeploymentActions = (t) => {
setOperationLoading('rename', deploymentId, true); setOperationLoading('rename', deploymentId, true);
const response = await API.put(`/api/deployments/${deploymentId}/name`, { const response = await API.put(`/api/deployments/${deploymentId}/name`, {
name: newName name: newName,
}); });
if (response.data.success) { if (response.data.success) {
@@ -156,7 +184,11 @@ export const useEnhancedDeploymentActions = (t) => {
return response.data.data; return response.data.data;
} }
} catch (error) { } catch (error) {
showError(t('更新名称失败') + ': ' + (error.response?.data?.message || error.message)); showError(
t('更新名称失败') +
': ' +
(error.response?.data?.message || error.message),
);
throw error; throw error;
} finally { } finally {
setOperationLoading('rename', deploymentId, false); setOperationLoading('rename', deploymentId, false);
@@ -169,17 +201,19 @@ export const useEnhancedDeploymentActions = (t) => {
setOperationLoading('batch_delete', 'all', true); setOperationLoading('batch_delete', 'all', true);
const results = await Promise.allSettled( 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 successful = results.filter((r) => r.status === 'fulfilled').length;
const failed = results.filter(r => r.status === 'rejected').length; const failed = results.filter((r) => r.status === 'rejected').length;
if (successful > 0) { if (successful > 0) {
showSuccess(t('批量操作完成: {{success}}个成功, {{failed}}个失败', { showSuccess(
t('批量操作完成: {{success}}个成功, {{failed}}个失败', {
success: successful, success: successful,
failed: failed failed: failed,
})); }),
);
} }
return { successful, failed }; return { successful, failed };
@@ -198,13 +232,16 @@ export const useEnhancedDeploymentActions = (t) => {
const logs = await getDeploymentLogs(deploymentId, { const logs = await getDeploymentLogs(deploymentId, {
...options, ...options,
limit: 10000 // Get more logs for export limit: 10000, // Get more logs for export
}); });
if (logs && logs.logs) { if (logs && logs.logs) {
const logText = logs.logs.map(log => const logText = logs.logs
`[${new Date(log.timestamp).toISOString()}] [${log.level}] ${log.source ? `[${log.source}] ` : ''}${log.message}` .map(
).join('\n'); (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 blob = new Blob([logText], { type: 'text/plain' });
const url = URL.createObjectURL(blob); const url = URL.createObjectURL(blob);
@@ -242,7 +279,7 @@ export const useEnhancedDeploymentActions = (t) => {
loading, loading,
// Utility // Utility
setOperationLoading setOperationLoading,
}; };
}; };

View File

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

View File

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

View File

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