feat: ionet integrate (#2105)

* wip ionet integrate

* wip ionet integrate

* wip ionet integrate

* ollama wip

* wip

* feat: ionet integration & ollama manage

* fix merge conflict

* wip

* fix: test conn cors

* wip

* fix ionet

* fix ionet

* wip

* fix model select

* refactor: Remove `pkg/ionet` test files and update related Go source and web UI model deployment components.

* feat: Enhance model deployment UI with styling improvements, updated text, and a new description component.

* Revert "feat: Enhance model deployment UI with styling improvements, updated text, and a new description component."

This reverts commit 8b75cb5bf0d1a534b339df8c033be9a6c7df7964.
This commit is contained in:
Seefs
2025-12-28 15:55:35 +08:00
committed by GitHub
parent 984ae32667
commit b10f1f7b85
51 changed files with 11895 additions and 369 deletions

View File

@@ -47,7 +47,8 @@ import {
import { FaRandom } from 'react-icons/fa';
// Render functions
const renderType = (type, channelInfo = undefined, t) => {
const renderType = (type, record = {}, t) => {
const channelInfo = record?.channel_info;
let type2label = new Map();
for (let i = 0; i < CHANNEL_OPTIONS.length; i++) {
type2label[CHANNEL_OPTIONS[i].value] = CHANNEL_OPTIONS[i];
@@ -71,11 +72,65 @@ const renderType = (type, channelInfo = undefined, t) => {
);
}
return (
const typeTag = (
<Tag color={type2label[type]?.color} shape='circle' prefixIcon={icon}>
{type2label[type]?.label}
</Tag>
);
let ionetMeta = null;
if (record?.other_info) {
try {
const parsed = JSON.parse(record.other_info);
if (parsed && typeof parsed === 'object' && parsed.source === 'ionet') {
ionetMeta = parsed;
}
} catch (error) {
// ignore invalid metadata
}
}
if (!ionetMeta) {
return typeTag;
}
const handleNavigate = (event) => {
event?.stopPropagation?.();
if (!ionetMeta?.deployment_id) {
return;
}
const targetUrl = `/console/deployment?deployment_id=${ionetMeta.deployment_id}`;
window.open(targetUrl, '_blank', 'noopener');
};
return (
<Space spacing={6}>
{typeTag}
<Tooltip
content={
<div className='max-w-xs'>
<div className='text-xs text-gray-600'>{t('来源于 IO.NET 部署')}</div>
{ionetMeta?.deployment_id && (
<div className='text-xs text-gray-500 mt-1'>
{t('部署 ID')}: {ionetMeta.deployment_id}
</div>
)}
</div>
}
>
<span>
<Tag
color='purple'
type='light'
className='cursor-pointer'
onClick={handleNavigate}
>
IO.NET
</Tag>
</span>
</Tooltip>
</Space>
);
};
const renderTagType = (t) => {
@@ -231,6 +286,7 @@ export const getChannelsColumns = ({
refresh,
activePage,
channels,
checkOllamaVersion,
setShowMultiKeyManageModal,
setCurrentMultiKeyChannel,
}) => {
@@ -330,12 +386,7 @@ export const getChannelsColumns = ({
dataIndex: 'type',
render: (text, record, index) => {
if (record.children === undefined) {
if (record.channel_info) {
if (record.channel_info.is_multi_key) {
return <>{renderType(text, record.channel_info, t)}</>;
}
}
return <>{renderType(text, undefined, t)}</>;
return <>{renderType(text, record, t)}</>;
} else {
return <>{renderTagType(t)}</>;
}
@@ -569,6 +620,15 @@ export const getChannelsColumns = ({
},
];
if (record.type === 4) {
moreMenuItems.unshift({
node: 'item',
name: t('测活'),
type: 'tertiary',
onClick: () => checkOllamaVersion(record),
});
}
return (
<Space wrap>
<SplitButtonGroup

View File

@@ -57,6 +57,7 @@ const ChannelsTable = (channelsData) => {
setEditingTag,
copySelectedChannel,
refresh,
checkOllamaVersion,
// Multi-key management
setShowMultiKeyManageModal,
setCurrentMultiKeyChannel,
@@ -82,6 +83,7 @@ const ChannelsTable = (channelsData) => {
refresh,
activePage,
channels,
checkOllamaVersion,
setShowMultiKeyManageModal,
setCurrentMultiKeyChannel,
});
@@ -103,6 +105,7 @@ const ChannelsTable = (channelsData) => {
refresh,
activePage,
channels,
checkOllamaVersion,
setShowMultiKeyManageModal,
setCurrentMultiKeyChannel,
]);

View File

@@ -55,6 +55,7 @@ import {
selectFilter,
} from '../../../../helpers';
import ModelSelectModal from './ModelSelectModal';
import OllamaModelModal from './OllamaModelModal';
import JSONEditor from '../../../common/ui/JSONEditor';
import SecureVerificationModal from '../../../common/modals/SecureVerificationModal';
import ChannelKeyDisplay from '../../../common/ui/ChannelKeyDisplay';
@@ -180,6 +181,7 @@ const EditChannelModal = (props) => {
const [isModalOpenurl, setIsModalOpenurl] = useState(false);
const [modelModalVisible, setModelModalVisible] = useState(false);
const [fetchedModels, setFetchedModels] = useState([]);
const [ollamaModalVisible, setOllamaModalVisible] = useState(false);
const formApiRef = useRef(null);
const [vertexKeys, setVertexKeys] = useState([]);
const [vertexFileList, setVertexFileList] = useState([]);
@@ -214,6 +216,8 @@ const EditChannelModal = (props) => {
return [];
}
}, [inputs.model_mapping]);
const [isIonetChannel, setIsIonetChannel] = useState(false);
const [ionetMetadata, setIonetMetadata] = useState(null);
// 密钥显示状态
const [keyDisplayState, setKeyDisplayState] = useState({
@@ -224,6 +228,21 @@ const EditChannelModal = (props) => {
// 专门的2FA验证状态用于TwoFactorAuthModal
const [show2FAVerifyModal, setShow2FAVerifyModal] = useState(false);
const [verifyCode, setVerifyCode] = useState('');
useEffect(() => {
if (!isEdit) {
setIsIonetChannel(false);
setIonetMetadata(null);
}
}, [isEdit]);
const handleOpenIonetDeployment = () => {
if (!ionetMetadata?.deployment_id) {
return;
}
const targetUrl = `/console/deployment?deployment_id=${ionetMetadata.deployment_id}`;
window.open(targetUrl, '_blank', 'noopener');
};
const [verifyLoading, setVerifyLoading] = useState(false);
// 表单块导航相关状态
@@ -404,7 +423,12 @@ const EditChannelModal = (props) => {
handleInputChange('settings', settingsJson);
};
const isIonetLocked = isIonetChannel && isEdit;
const handleInputChange = (name, value) => {
if (isIonetChannel && isEdit && ['type', 'key', 'base_url'].includes(name)) {
return;
}
if (formApiRef.current) {
formApiRef.current.setValue(name, value);
}
@@ -625,6 +649,25 @@ const EditChannelModal = (props) => {
.map((model) => (model || '').trim())
.filter(Boolean);
initialModelMappingRef.current = data.model_mapping || '';
let parsedIonet = null;
if (data.other_info) {
try {
const maybeMeta = JSON.parse(data.other_info);
if (
maybeMeta &&
typeof maybeMeta === 'object' &&
maybeMeta.source === 'ionet'
) {
parsedIonet = maybeMeta;
}
} catch (error) {
// ignore parse error
}
}
const managedByIonet = !!parsedIonet;
setIsIonetChannel(managedByIonet);
setIonetMetadata(parsedIonet);
// console.log(data);
} else {
showError(message);
@@ -632,7 +675,8 @@ const EditChannelModal = (props) => {
setLoading(false);
};
const fetchUpstreamModelList = async (name) => {
const fetchUpstreamModelList = async (name, options = {}) => {
const silent = !!options.silent;
// if (inputs['type'] !== 1) {
// showError(t('仅支持 OpenAI 接口格式'));
// return;
@@ -683,7 +727,9 @@ const EditChannelModal = (props) => {
if (!err) {
const uniqueModels = Array.from(new Set(models));
setFetchedModels(uniqueModels);
setModelModalVisible(true);
if (!silent) {
setModelModalVisible(true);
}
} else {
showError(t('获取模型列表失败'));
}
@@ -1626,20 +1672,44 @@ const EditChannelModal = (props) => {
</div>
</div>
<Form.Select
field='type'
label={t('类型')}
placeholder={t('请选择渠道类型')}
rules={[{ required: true, message: t('请选择渠道类型') }]}
optionList={channelOptionList}
style={{ width: '100%' }}
filter={selectFilter}
autoClearSearchValue={false}
searchPosition='dropdown'
onSearch={(value) => setChannelSearchValue(value)}
renderOptionItem={renderChannelOption}
onChange={(value) => handleInputChange('type', value)}
/>
{isIonetChannel && (
<Banner
type='info'
closeIcon={null}
className='mb-4 rounded-xl'
description={t('此渠道由 IO.NET 自动同步,类型、密钥和 API 地址已锁定。')}
>
<Space>
{ionetMetadata?.deployment_id && (
<Button
size='small'
theme='light'
type='primary'
icon={<IconGlobe />}
onClick={handleOpenIonetDeployment}
>
{t('查看关联部署')}
</Button>
)}
</Space>
</Banner>
)}
<Form.Select
field='type'
label={t('类型')}
placeholder={t('请选择渠道类型')}
rules={[{ required: true, message: t('请选择渠道类型') }]}
optionList={channelOptionList}
style={{ width: '100%' }}
filter={selectFilter}
autoClearSearchValue={false}
searchPosition='dropdown'
onSearch={(value) => setChannelSearchValue(value)}
renderOptionItem={renderChannelOption}
onChange={(value) => handleInputChange('type', value)}
disabled={isIonetLocked}
/>
{inputs.type === 20 && (
<Form.Switch
@@ -1778,87 +1848,86 @@ const EditChannelModal = (props) => {
autosize
autoComplete='new-password'
onChange={(value) => handleInputChange('key', value)}
extraText={
<div className='flex items-center gap-2 flex-wrap'>
{isEdit &&
isMultiKeyChannel &&
keyMode === 'append' && (
<Text type='warning' size='small'>
{t(
'追加模式:新密钥将添加到现有密钥列表的末尾',
)}
</Text>
)}
{isEdit && (
disabled={isIonetLocked}
extraText={
<div className='flex items-center gap-2 flex-wrap'>
{isEdit &&
isMultiKeyChannel &&
keyMode === 'append' && (
<Text type='warning' size='small'>
{t(
'追加模式:新密钥将添加到现有密钥列表的末尾',
)}
</Text>
)}
{isEdit && (
<Button
size='small'
type='primary'
theme='outline'
onClick={handleShow2FAModal}
>
{t('查看密钥')}
</Button>
)}
{batchExtra}
</div>
}
showClear
/>
)
) : (
<>
{inputs.type === 41 &&
(inputs.vertex_key_type || 'json') === 'json' ? (
<>
{!batch && (
<div className='flex items-center justify-between mb-3'>
<Text className='text-sm font-medium'>
{t('密钥输入方式')}
</Text>
<Space>
<Button
size='small'
type='primary'
theme='outline'
onClick={handleShow2FAModal}
type={
!useManualInput ? 'primary' : 'tertiary'
}
onClick={() => {
setUseManualInput(false);
// 切换到文件上传模式时清空手动输入的密钥
if (formApiRef.current) {
formApiRef.current.setValue('key', '');
}
handleInputChange('key', '');
}}
>
{t('查看密钥')}
{t('文件上传')}
</Button>
)}
{batchExtra}
<Button
size='small'
type={useManualInput ? 'primary' : 'tertiary'}
onClick={() => {
setUseManualInput(true);
// 切换到手动输入模式时清空文件上传相关状态
setVertexKeys([]);
setVertexFileList([]);
if (formApiRef.current) {
formApiRef.current.setValue(
'vertex_files',
[],
);
}
setInputs((prev) => ({
...prev,
vertex_files: [],
}));
}}
>
{t('手动输入')}
</Button>
</Space>
</div>
}
showClear
/>
)
) : (
<>
{inputs.type === 41 &&
(inputs.vertex_key_type || 'json') === 'json' ? (
<>
{!batch && (
<div className='flex items-center justify-between mb-3'>
<Text className='text-sm font-medium'>
{t('密钥输入方式')}
</Text>
<Space>
<Button
size='small'
type={
!useManualInput ? 'primary' : 'tertiary'
}
onClick={() => {
setUseManualInput(false);
// 切换到文件上传模式时清空手动输入的密钥
if (formApiRef.current) {
formApiRef.current.setValue('key', '');
}
handleInputChange('key', '');
}}
>
{t('文件上传')}
</Button>
<Button
size='small'
type={
useManualInput ? 'primary' : 'tertiary'
}
onClick={() => {
setUseManualInput(true);
// 切换到手动输入模式时清空文件上传相关状态
setVertexKeys([]);
setVertexFileList([]);
if (formApiRef.current) {
formApiRef.current.setValue(
'vertex_files',
[],
);
}
setInputs((prev) => ({
...prev,
vertex_files: [],
}));
}}
>
{t('手动输入')}
</Button>
</Space>
</div>
)}
)}
{batch && (
<Banner
@@ -2189,84 +2258,86 @@ const EditChannelModal = (props) => {
/>
)}
{inputs.type === 3 && (
<>
<Banner
type='warning'
description={t(
'2025年5月10日后添加的渠道不需要再在部署的时候移除模型名称中的"."',
{inputs.type === 3 && (
<>
<Banner
type='warning'
description={t(
'2025年5月10日后添加的渠道不需要再在部署的时候移除模型名称中的"."',
)}
className='!rounded-lg'
/>
<div>
<Form.Input
field='base_url'
label='AZURE_OPENAI_ENDPOINT'
placeholder={t(
'请输入 AZURE_OPENAI_ENDPOINT例如https://docs-test-001.openai.azure.com',
)}
className='!rounded-lg'
onChange={(value) =>
handleInputChange('base_url', value)
}
showClear
disabled={isIonetLocked}
/>
<div>
<Form.Input
field='base_url'
label='AZURE_OPENAI_ENDPOINT'
placeholder={t(
'请输入 AZURE_OPENAI_ENDPOINT例如https://docs-test-001.openai.azure.com',
)}
onChange={(value) =>
handleInputChange('base_url', value)
}
showClear
/>
</div>
<div>
<Form.Input
field='other'
label={t('默认 API 版本')}
placeholder={t(
'请输入默认 API 版本例如2025-04-01-preview',
)}
onChange={(value) =>
handleInputChange('other', value)
}
showClear
/>
</div>
<div>
<Form.Input
field='azure_responses_version'
label={t(
'默认 Responses API 版本,为空则使用上方版本',
)}
placeholder={t('例如preview')}
onChange={(value) =>
handleChannelOtherSettingsChange(
'azure_responses_version',
value,
)
}
showClear
/>
</div>
</>
)}
</div>
<div>
<Form.Input
field='other'
label={t('默认 API 版本')}
placeholder={t(
'请输入默认 API 版本例如2025-04-01-preview',
)}
onChange={(value) =>
handleInputChange('other', value)
}
showClear
/>
</div>
<div>
<Form.Input
field='azure_responses_version'
label={t(
'默认 Responses API 版本,为空则使用上方版本',
)}
placeholder={t('例如preview')}
onChange={(value) =>
handleChannelOtherSettingsChange(
'azure_responses_version',
value,
)
}
showClear
/>
</div>
</>
)}
{inputs.type === 8 && (
<>
<Banner
type='warning'
description={t(
'如果你对接的是上游One API或者New API等转发项目请使用OpenAI类型不要使用此类型除非你知道你在做什么。',
{inputs.type === 8 && (
<>
<Banner
type='warning'
description={t(
'如果你对接的是上游One API或者New API等转发项目请使用OpenAI类型不要使用此类型除非你知道你在做什么。',
)}
className='!rounded-lg'
/>
<div>
<Form.Input
field='base_url'
label={t('完整的 Base URL支持变量{model}')}
placeholder={t(
'请输入完整的URL例如https://api.openai.com/v1/chat/completions',
)}
className='!rounded-lg'
onChange={(value) =>
handleInputChange('base_url', value)
}
showClear
disabled={isIonetLocked}
/>
<div>
<Form.Input
field='base_url'
label={t('完整的 Base URL支持变量{model}')}
placeholder={t(
'请输入完整的URL例如https://api.openai.com/v1/chat/completions',
)}
onChange={(value) =>
handleInputChange('base_url', value)
}
showClear
/>
</div>
</>
)}
</div>
</>
)}
{inputs.type === 37 && (
<Banner
@@ -2294,76 +2365,77 @@ const EditChannelModal = (props) => {
handleInputChange('base_url', value)
}
showClear
extraText={t(
'对于官方渠道new-api已经内置地址除非是第三方代理站点或者Azure的特殊接入地址否则不需要填写',
)}
/>
</div>
)}
{inputs.type === 22 && (
<div>
<Form.Input
field='base_url'
label={t('私有部署地址')}
placeholder={t(
'请输入私有部署地址格式为https://fastgpt.run/api/openapi',
disabled={isIonetLocked}
extraText={t(
'对于官方渠道new-api已经内置地址除非是第三方代理站点或者Azure的特殊接入地址否则不需要填写',
)}
onChange={(value) =>
handleInputChange('base_url', value)
}
showClear
/>
</div>
)}
{inputs.type === 36 && (
<div>
<Form.Input
field='base_url'
label={t(
'注意非Chat API请务必填写正确的API地址否则可能导致无法使用',
)}
placeholder={t(
'请输入到 /suno 前的路径通常就是域名例如https://api.example.com',
)}
onChange={(value) =>
handleInputChange('base_url', value)
}
showClear
/>
</div>
)}
{inputs.type === 22 && (
<div>
<Form.Input
field='base_url'
label={t('私有部署地址')}
placeholder={t(
'请输入私有部署地址格式为https://fastgpt.run/api/openapi',
)}
onChange={(value) =>
handleInputChange('base_url', value)
}
showClear
disabled={isIonetLocked}
/>
</div>
)}
{inputs.type === 45 && !doubaoApiEditUnlocked && (
<div>
<Form.Select
field='base_url'
label={t('API地址')}
placeholder={t('请选择API地址')}
onChange={(value) =>
{inputs.type === 36 && (
<div>
<Form.Input
field='base_url'
label={t(
'注意非Chat API请务必填写正确的API地址否则可能导致无法使用',
)}
placeholder={t(
'请输入到 /suno 前的路径通常就是域名例如https://api.example.com',
)}
onChange={(value) =>
handleInputChange('base_url', value)
}
showClear
disabled={isIonetLocked}
/>
</div>
)}
{inputs.type === 45 && !doubaoApiEditUnlocked && (
<div>
<Form.Select
field='base_url'
label={t('API地址')}
placeholder={t('请选择API地址')}
onChange={(value) =>
handleInputChange('base_url', value)
}
optionList={[
{
value: 'https://ark.cn-beijing.volces.com',
label: 'https://ark.cn-beijing.volces.com',
},
{
value:
'https://ark.ap-southeast.bytepluses.com',
label:
'https://ark.ap-southeast.bytepluses.com',
},
{
value: 'doubao-coding-plan',
}
optionList={[
{
value: 'https://ark.cn-beijing.volces.com',
label: 'https://ark.cn-beijing.volces.com',
},
{
value: 'https://ark.ap-southeast.bytepluses.com',
label: 'https://ark.ap-southeast.bytepluses.com',
},
{
value: 'doubao-coding-plan',
label: 'Doubao Coding Plan',
},
]}
defaultValue='https://ark.cn-beijing.volces.com'
/>
</div>
)}
]}defaultValue='https://ark.cn-beijing.volces.com'
disabled={isIonetLocked}
/>
</div>
)}
</Card>
</div>
)}
@@ -2458,72 +2530,80 @@ const EditChannelModal = (props) => {
{t('获取模型列表')}
</Button>
)}
{inputs.type === 4 && isEdit && (
<Button
size='small'
type='warning'
onClick={() => handleInputChange('models', [])}
type='primary'
theme='light'
onClick={() => setOllamaModalVisible(true)}
>
{t('清除所有模型')}
{t('Ollama 模型管理')}
</Button>
<Button
size='small'
type='tertiary'
onClick={() => {
if (inputs.models.length === 0) {
showInfo(t('没有模型可以复制'));
return;
}
try {
copy(inputs.models.join(','));
showSuccess(t('模型列表已复制到剪贴板'));
} catch (error) {
showError(t('复制失败'));
}
}}
>
{t('复制所有模型')}
</Button>
{modelGroups &&
modelGroups.length > 0 &&
modelGroups.map((group) => (
<Button
key={group.id}
size='small'
type='primary'
onClick={() => {
let items = [];
try {
if (Array.isArray(group.items)) {
items = group.items;
} else if (
typeof group.items === 'string'
) {
const parsed = JSON.parse(
group.items || '[]',
);
if (Array.isArray(parsed)) items = parsed;
}
} catch {}
const current =
formApiRef.current?.getValue('models') ||
inputs.models ||
[];
const merged = Array.from(
new Set(
[...current, ...items]
.map((m) => (m || '').trim())
.filter(Boolean),
),
);
handleInputChange('models', merged);
}}
>
{group.name}
</Button>
))}
</Space>
}
/>
)}
<Button
size='small'
type='warning'
onClick={() => handleInputChange('models', [])}
>
{t('清除所有模型')}
</Button>
<Button
size='small'
type='tertiary'
onClick={() => {
if (inputs.models.length === 0) {
showInfo(t('没有模型可以复制'));
return;
}
try {
copy(inputs.models.join(','));
showSuccess(t('模型列表已复制到剪贴板'));
} catch (error) {
showError(t('复制失败'));
}
}}
>
{t('复制所有模型')}
</Button>
{modelGroups &&
modelGroups.length > 0 &&
modelGroups.map((group) => (
<Button
key={group.id}
size='small'
type='primary'
onClick={() => {
let items = [];
try {
if (Array.isArray(group.items)) {
items = group.items;
} else if (typeof group.items === 'string') {
const parsed = JSON.parse(
group.items || '[]',
);
if (Array.isArray(parsed)) items = parsed;
}
} catch {}
const current =
formApiRef.current?.getValue('models') ||
inputs.models ||
[];
const merged = Array.from(
new Set(
[...current, ...items]
.map((m) => (m || '').trim())
.filter(Boolean),
),
);
handleInputChange('models', merged);
}}
>
{group.name}
</Button>
))}
</Space>
}
/>
<Form.Input
field='custom_model'
@@ -3083,6 +3163,33 @@ const EditChannelModal = (props) => {
}}
onCancel={() => setModelModalVisible(false)}
/>
<OllamaModelModal
visible={ollamaModalVisible}
onCancel={() => setOllamaModalVisible(false)}
channelId={channelId}
channelInfo={inputs}
onModelsUpdate={(options = {}) => {
// 当模型更新后,重新获取模型列表以更新表单
fetchUpstreamModelList('models', { silent: !!options.silent });
}}
onApplyModels={({ mode, modelIds } = {}) => {
if (!Array.isArray(modelIds) || modelIds.length === 0) {
return;
}
const existingModels = Array.isArray(inputs.models)
? inputs.models.map(String)
: [];
const incoming = modelIds.map(String);
const nextModels = Array.from(new Set([...existingModels, ...incoming]));
handleInputChange('models', nextModels);
if (formApiRef.current) {
formApiRef.current.setValue('models', nextModels);
}
showSuccess(t('模型列表已追加更新'));
}}
/>
</>
);
};

View File

@@ -47,7 +47,20 @@ const ModelSelectModal = ({
onCancel,
}) => {
const { t } = useTranslation();
const [checkedList, setCheckedList] = useState(selected);
const getModelName = (model) => {
if (!model) return '';
if (typeof model === 'string') return model;
if (typeof model === 'object' && model.model_name) return model.model_name;
return String(model ?? '');
};
const normalizedSelected = useMemo(
() => (selected || []).map(getModelName),
[selected],
);
const [checkedList, setCheckedList] = useState(normalizedSelected);
const [keyword, setKeyword] = useState('');
const [activeTab, setActiveTab] = useState('new');
@@ -105,9 +118,9 @@ const ModelSelectModal = ({
// 同步外部选中值
useEffect(() => {
if (visible) {
setCheckedList(selected);
setCheckedList(normalizedSelected);
}
}, [visible, selected]);
}, [visible, normalizedSelected]);
// 当模型列表变化时设置默认tab
useEffect(() => {

View File

@@ -0,0 +1,806 @@
/*
Copyright (C) 2025 QuantumNous
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import React, { useState, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import {
Modal,
Button,
Typography,
Card,
List,
Space,
Input,
Spin,
Popconfirm,
Tag,
Avatar,
Empty,
Divider,
Row,
Col,
Progress,
Checkbox,
Radio,
} from '@douyinfe/semi-ui';
import {
IconClose,
IconDownload,
IconDelete,
IconRefresh,
IconSearch,
IconPlus,
IconServer,
} from '@douyinfe/semi-icons';
import {
API,
authHeader,
getUserIdFromLocalStorage,
showError,
showInfo,
showSuccess,
} from '../../../../helpers';
const { Text, Title } = Typography;
const CHANNEL_TYPE_OLLAMA = 4;
const parseMaybeJSON = (value) => {
if (!value) return null;
if (typeof value === 'object') return value;
if (typeof value === 'string') {
try {
return JSON.parse(value);
} catch (error) {
return null;
}
}
return null;
};
const resolveOllamaBaseUrl = (info) => {
if (!info) {
return '';
}
const direct = typeof info.base_url === 'string' ? info.base_url.trim() : '';
if (direct) {
return direct;
}
const alt =
typeof info.ollama_base_url === 'string'
? info.ollama_base_url.trim()
: '';
if (alt) {
return alt;
}
const parsed = parseMaybeJSON(info.other_info);
if (parsed && typeof parsed === 'object') {
const candidate =
(typeof parsed.base_url === 'string' && parsed.base_url.trim()) ||
(typeof parsed.public_url === 'string' && parsed.public_url.trim()) ||
(typeof parsed.api_url === 'string' && parsed.api_url.trim());
if (candidate) {
return candidate;
}
}
return '';
};
const normalizeModels = (items) => {
if (!Array.isArray(items)) {
return [];
}
return items
.map((item) => {
if (!item) {
return null;
}
if (typeof item === 'string') {
return {
id: item,
owned_by: 'ollama',
};
}
if (typeof item === 'object') {
const candidateId = item.id || item.ID || item.name || item.model || item.Model;
if (!candidateId) {
return null;
}
const metadata = item.metadata || item.Metadata;
const normalized = {
...item,
id: candidateId,
owned_by: item.owned_by || item.ownedBy || 'ollama',
};
if (typeof item.size === 'number' && !normalized.size) {
normalized.size = item.size;
}
if (metadata && typeof metadata === 'object') {
if (typeof metadata.size === 'number' && !normalized.size) {
normalized.size = metadata.size;
}
if (!normalized.digest && typeof metadata.digest === 'string') {
normalized.digest = metadata.digest;
}
if (!normalized.modified_at && typeof metadata.modified_at === 'string') {
normalized.modified_at = metadata.modified_at;
}
if (metadata.details && !normalized.details) {
normalized.details = metadata.details;
}
}
return normalized;
}
return null;
})
.filter(Boolean);
};
const OllamaModelModal = ({
visible,
onCancel,
channelId,
channelInfo,
onModelsUpdate,
onApplyModels,
}) => {
const { t } = useTranslation();
const [loading, setLoading] = useState(false);
const [models, setModels] = useState([]);
const [filteredModels, setFilteredModels] = useState([]);
const [searchValue, setSearchValue] = useState('');
const [pullModelName, setPullModelName] = useState('');
const [pullLoading, setPullLoading] = useState(false);
const [pullProgress, setPullProgress] = useState(null);
const [eventSource, setEventSource] = useState(null);
const [selectedModelIds, setSelectedModelIds] = useState([]);
const handleApplyAllModels = () => {
if (!onApplyModels || selectedModelIds.length === 0) {
return;
}
onApplyModels({ mode: 'append', modelIds: selectedModelIds });
};
const handleToggleModel = (modelId, checked) => {
if (!modelId) {
return;
}
setSelectedModelIds((prev) => {
if (checked) {
if (prev.includes(modelId)) {
return prev;
}
return [...prev, modelId];
}
return prev.filter((id) => id !== modelId);
});
};
const handleSelectAll = () => {
setSelectedModelIds(models.map((item) => item?.id).filter(Boolean));
};
const handleClearSelection = () => {
setSelectedModelIds([]);
};
// 获取模型列表
const fetchModels = async () => {
const channelType = Number(channelInfo?.type ?? CHANNEL_TYPE_OLLAMA);
const shouldTryLiveFetch = channelType === CHANNEL_TYPE_OLLAMA;
const resolvedBaseUrl = resolveOllamaBaseUrl(channelInfo);
setLoading(true);
let liveFetchSucceeded = false;
let fallbackSucceeded = false;
let lastError = '';
let nextModels = [];
try {
if (shouldTryLiveFetch && resolvedBaseUrl) {
try {
const payload = {
base_url: resolvedBaseUrl,
type: CHANNEL_TYPE_OLLAMA,
key: channelInfo?.key || '',
};
const res = await API.post('/api/channel/fetch_models', payload, {
skipErrorHandler: true,
});
if (res?.data?.success) {
nextModels = normalizeModels(res.data.data);
liveFetchSucceeded = true;
} else if (res?.data?.message) {
lastError = res.data.message;
}
} catch (error) {
const message = error?.response?.data?.message || error.message;
if (message) {
lastError = message;
}
}
} else if (shouldTryLiveFetch && !resolvedBaseUrl && !channelId) {
lastError = t('请先填写 Ollama API 地址');
}
if ((!liveFetchSucceeded || nextModels.length === 0) && channelId) {
try {
const res = await API.get(`/api/channel/fetch_models/${channelId}`, {
skipErrorHandler: true,
});
if (res?.data?.success) {
nextModels = normalizeModels(res.data.data);
fallbackSucceeded = true;
lastError = '';
} else if (res?.data?.message) {
lastError = res.data.message;
}
} catch (error) {
const message = error?.response?.data?.message || error.message;
if (message) {
lastError = message;
}
}
}
if (!liveFetchSucceeded && !fallbackSucceeded && lastError) {
showError(`${t('获取模型列表失败')}: ${lastError}`);
}
const normalized = nextModels;
setModels(normalized);
setFilteredModels(normalized);
setSelectedModelIds((prev) => {
if (!normalized || normalized.length === 0) {
return [];
}
if (!prev || prev.length === 0) {
return normalized.map((item) => item.id).filter(Boolean);
}
const available = prev.filter((id) =>
normalized.some((item) => item.id === id),
);
return available.length > 0
? available
: normalized.map((item) => item.id).filter(Boolean);
});
} finally {
setLoading(false);
}
};
// 拉取模型 (流式,支持进度)
const pullModel = async () => {
if (!pullModelName.trim()) {
showError(t('请输入模型名称'));
return;
}
setPullLoading(true);
setPullProgress({ status: 'starting', completed: 0, total: 0 });
let hasRefreshed = false;
const refreshModels = async () => {
if (hasRefreshed) return;
hasRefreshed = true;
await fetchModels();
if (onModelsUpdate) {
onModelsUpdate({ silent: true });
}
};
try {
// 关闭之前的连接
if (eventSource) {
eventSource.close();
setEventSource(null);
}
const controller = new AbortController();
const closable = {
close: () => controller.abort(),
};
setEventSource(closable);
// 使用 fetch 请求 SSE 流
const authHeaders = authHeader();
const userId = getUserIdFromLocalStorage();
const fetchHeaders = {
'Content-Type': 'application/json',
Accept: 'text/event-stream',
'New-API-User': String(userId),
...authHeaders,
};
const response = await fetch('/api/channel/ollama/pull/stream', {
method: 'POST',
headers: fetchHeaders,
body: JSON.stringify({
channel_id: channelId,
model_name: pullModelName.trim(),
}),
signal: controller.signal,
});
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = '';
// 读取 SSE 流
const processStream = async () => {
try {
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split('\n');
buffer = lines.pop() || '';
for (const line of lines) {
if (!line.startsWith('data: ')) {
continue;
}
try {
const eventData = line.substring(6);
if (eventData === '[DONE]') {
setPullLoading(false);
setPullProgress(null);
setEventSource(null);
return;
}
const data = JSON.parse(eventData);
if (data.status) {
// 处理进度数据
setPullProgress(data);
} else if (data.error) {
// 处理错误
showError(data.error);
setPullProgress(null);
setPullLoading(false);
setEventSource(null);
return;
} else if (data.message) {
// 处理成功消息
showSuccess(data.message);
setPullModelName('');
setPullProgress(null);
setPullLoading(false);
setEventSource(null);
await fetchModels();
if (onModelsUpdate) {
onModelsUpdate({ silent: true });
}
await refreshModels();
return;
}
} catch (e) {
console.error('Failed to parse SSE data:', e);
}
}
}
// 正常结束流
setPullLoading(false);
setPullProgress(null);
setEventSource(null);
await refreshModels();
} catch (error) {
if (error?.name === 'AbortError') {
setPullProgress(null);
setPullLoading(false);
setEventSource(null);
return;
}
console.error('Stream processing error:', error);
showError(t('数据传输中断'));
setPullProgress(null);
setPullLoading(false);
setEventSource(null);
await refreshModels();
}
};
await processStream();
} catch (error) {
if (error?.name !== 'AbortError') {
showError(t('模型拉取失败: {{error}}', { error: error.message }));
}
setPullLoading(false);
setPullProgress(null);
setEventSource(null);
await refreshModels();
}
};
// 删除模型
const deleteModel = async (modelName) => {
try {
const res = await API.delete('/api/channel/ollama/delete', {
data: {
channel_id: channelId,
model_name: modelName,
},
});
if (res.data.success) {
showSuccess(t('模型删除成功'));
await fetchModels(); // 重新获取模型列表
if (onModelsUpdate) {
onModelsUpdate({ silent: true }); // 通知父组件更新
}
} else {
showError(res.data.message || t('模型删除失败'));
}
} catch (error) {
showError(t('模型删除失败: {{error}}', { error: error.message }));
}
};
// 搜索过滤
useEffect(() => {
if (!searchValue) {
setFilteredModels(models);
} else {
const filtered = models.filter(model =>
model.id.toLowerCase().includes(searchValue.toLowerCase())
);
setFilteredModels(filtered);
}
}, [models, searchValue]);
useEffect(() => {
if (!visible) {
setSelectedModelIds([]);
setPullModelName('');
setPullProgress(null);
setPullLoading(false);
}
}, [visible]);
// 组件加载时获取模型列表
useEffect(() => {
if (!visible) {
return;
}
if (channelId || Number(channelInfo?.type) === CHANNEL_TYPE_OLLAMA) {
fetchModels();
}
}, [
visible,
channelId,
channelInfo?.type,
channelInfo?.base_url,
channelInfo?.other_info,
channelInfo?.ollama_base_url,
]);
// 组件卸载时清理 EventSource
useEffect(() => {
return () => {
if (eventSource) {
eventSource.close();
}
};
}, [eventSource]);
const formatModelSize = (size) => {
if (!size) return '-';
const gb = size / (1024 * 1024 * 1024);
return gb >= 1 ? `${gb.toFixed(1)} GB` : `${(size / (1024 * 1024)).toFixed(0)} MB`;
};
return (
<Modal
title={
<div className='flex items-center'>
<Avatar
size='small'
color='blue'
className='mr-3 shadow-md'
>
<IconServer size={16} />
</Avatar>
<div>
<Title heading={4} className='m-0'>
{t('Ollama 模型管理')}
</Title>
<Text type='tertiary' size='small'>
{channelInfo?.name && `${channelInfo.name} - `}
{t('管理 Ollama 模型的拉取和删除')}
</Text>
</div>
</div>
}
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'>
<div className='flex items-center mb-4'>
<Avatar size='small' color='green' className='mr-2'>
<IconPlus size={16} />
</Avatar>
<Title heading={5} className='m-0'>
{t('拉取新模型')}
</Title>
</div>
<Row gutter={12} align='middle'>
<Col span={16}>
<Input
placeholder={t('请输入模型名称,例如: llama3.2, qwen2.5:7b')}
value={pullModelName}
onChange={(value) => setPullModelName(value)}
onEnterPress={pullModel}
disabled={pullLoading}
showClear
/>
</Col>
<Col span={8}>
<Button
theme='solid'
type='primary'
onClick={pullModel}
loading={pullLoading}
disabled={!pullModelName.trim()}
icon={<IconDownload />}
block
>
{pullLoading ? t('拉取中...') : t('拉取模型')}
</Button>
</Col>
</Row>
{/* 进度条显示 */}
{pullProgress && (() => {
const completedBytes = Number(pullProgress.completed) || 0;
const totalBytes = Number(pullProgress.total) || 0;
const hasTotal = Number.isFinite(totalBytes) && totalBytes > 0;
const safePercent = hasTotal
? Math.min(
100,
Math.max(0, Math.round((completedBytes / totalBytes) * 100)),
)
: null;
const percentText = hasTotal && safePercent !== null
? `${safePercent.toFixed(0)}%`
: pullProgress.status || t('处理中');
return (
<div className='mt-3 p-3 bg-gray-50 rounded-lg'>
<div className='flex items-center justify-between mb-2'>
<Text strong>{t('拉取进度')}</Text>
<Text type='tertiary' size='small'>{percentText}</Text>
</div>
{hasTotal && safePercent !== null ? (
<div>
<Progress
percent={safePercent}
showInfo={false}
stroke='#1890ff'
size='small'
/>
<div className='flex justify-between mt-1'>
<Text type='tertiary' size='small'>
{(completedBytes / (1024 * 1024 * 1024)).toFixed(2)} GB
</Text>
<Text type='tertiary' size='small'>
{(totalBytes / (1024 * 1024 * 1024)).toFixed(2)} GB
</Text>
</div>
</div>
) : (
<div className='flex items-center gap-2 text-xs text-[var(--semi-color-text-2)]'>
<Spin size='small' />
<span>{t('准备中...')}</span>
</div>
)}
</div>
);
})()}
<Text type='tertiary' size='small' className='mt-2 block'>
{t('支持拉取 Ollama 官方模型库中的所有模型,拉取过程可能需要几分钟时间')}
</Text>
</Card>
{/* 已有模型列表 */}
<Card className='!rounded-2xl shadow-sm border-0'>
<div className='flex items-center justify-between mb-4'>
<div className='flex items-center'>
<Avatar size='small' color='purple' className='mr-2'>
<IconServer size={16} />
</Avatar>
<Title heading={5} className='m-0'>
{t('已有模型')}
{models.length > 0 && (
<Tag color='blue' className='ml-2'>
{models.length}
</Tag>
)}
</Title>
</div>
<Space wrap>
<Input
prefix={<IconSearch />}
placeholder={t('搜索模型...')}
value={searchValue}
onChange={(value) => setSearchValue(value)}
style={{ width: 200 }}
showClear
/>
<Button
size='small'
theme='borderless'
onClick={handleSelectAll}
disabled={models.length === 0}
>
{t('全选')}
</Button>
<Button
size='small'
theme='borderless'
onClick={handleClearSelection}
disabled={selectedModelIds.length === 0}
>
{t('清空')}
</Button>
<Button
theme='solid'
type='primary'
icon={<IconPlus />}
onClick={handleApplyAllModels}
disabled={selectedModelIds.length === 0}
size='small'
>
{t('加入渠道')}
</Button>
<Button
theme='light'
type='primary'
onClick={fetchModels}
loading={loading}
icon={<IconRefresh />}
size='small'
>
{t('刷新')}
</Button>
</Space>
</div>
<Spin spinning={loading}>
{filteredModels.length === 0 ? (
<Empty
image={<IconServer size={60} />}
title={searchValue ? t('未找到匹配的模型') : t('暂无模型')}
description={
searchValue
? t('请尝试其他搜索关键词')
: t('您可以在上方拉取需要的模型')
}
style={{ padding: '40px 0' }}
/>
) : (
<List
dataSource={filteredModels}
split={false}
renderItem={(model, index) => (
<List.Item
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 flex-1 min-w-0 gap-3'>
<Checkbox
checked={selectedModelIds.includes(model.id)}
onChange={(checked) => handleToggleModel(model.id, checked)}
/>
<Avatar
size='small'
color='blue'
className='flex-shrink-0'
>
{model.id.charAt(0).toUpperCase()}
</Avatar>
<div className='flex-1 min-w-0'>
<Text strong className='block truncate'>
{model.id}
</Text>
<div className='flex items-center space-x-2 mt-1'>
<Tag color='cyan' size='small'>
{model.owned_by || 'ollama'}
</Tag>
{model.size && (
<Text type='tertiary' size='small'>
{formatModelSize(model.size)}
</Text>
)}
</div>
</div>
</div>
<div className='flex items-center space-x-2 ml-4'>
<Popconfirm
title={t('确认删除模型')}
content={t('删除后无法恢复,确定要删除模型 "{{name}}" 吗?', { name: model.id })}
onConfirm={() => deleteModel(model.id)}
okText={t('确认')}
cancelText={t('取消')}
>
<Button
theme='borderless'
type='danger'
size='small'
icon={<IconDelete />}
/>
</Popconfirm>
</div>
</div>
</List.Item>
)}
/>
)}
</Spin>
</Card>
</div>
</Modal>
);
};
export default OllamaModelModal;

View File

@@ -0,0 +1,109 @@
/*
Copyright (C) 2025 QuantumNous
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import React from 'react';
import { Button, Popconfirm } from '@douyinfe/semi-ui';
import CompactModeToggle from '../../common/ui/CompactModeToggle';
const DeploymentsActions = ({
selectedKeys,
setSelectedKeys,
setEditingDeployment,
setShowEdit,
batchDeleteDeployments,
compactMode,
setCompactMode,
showCreateModal,
setShowCreateModal,
t,
}) => {
const hasSelected = selectedKeys.length > 0;
const handleAddDeployment = () => {
if (setShowCreateModal) {
setShowCreateModal(true);
} else {
// Fallback to old behavior if setShowCreateModal is not provided
setEditingDeployment({ id: undefined });
setShowEdit(true);
}
};
const handleBatchDelete = () => {
batchDeleteDeployments();
};
const handleDeselectAll = () => {
setSelectedKeys([]);
};
return (
<div className='flex flex-wrap gap-2 w-full md:w-auto order-2 md:order-1'>
<Button
type='primary'
className='flex-1 md:flex-initial'
onClick={handleAddDeployment}
size='small'
>
{t('新建容器')}
</Button>
{hasSelected && (
<>
<Popconfirm
title={t('确认删除')}
content={`${t('确定要删除选中的')} ${selectedKeys.length} ${t('个部署吗?此操作不可逆。')}`}
okText={t('删除')}
cancelText={t('取消')}
okType='danger'
onConfirm={handleBatchDelete}
>
<Button
type='danger'
className='flex-1 md:flex-initial'
disabled={selectedKeys.length === 0}
size='small'
>
{t('批量删除')} ({selectedKeys.length})
</Button>
</Popconfirm>
<Button
type='tertiary'
className='flex-1 md:flex-initial'
onClick={handleDeselectAll}
size='small'
>
{t('取消选择')}
</Button>
</>
)}
{/* Compact Mode */}
<CompactModeToggle
compactMode={compactMode}
setCompactMode={setCompactMode}
t={t}
/>
</div>
);
};
export default DeploymentsActions;

View File

@@ -0,0 +1,672 @@
/*
Copyright (C) 2025 QuantumNous
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import React from 'react';
import {
Button,
Dropdown,
Tag,
Typography,
} from '@douyinfe/semi-ui';
import {
timestamp2string,
showSuccess,
showError,
} from '../../../helpers';
import { IconMore } from '@douyinfe/semi-icons';
import {
FaPlay,
FaTrash,
FaServer,
FaMemory,
FaMicrochip,
FaCheckCircle,
FaSpinner,
FaClock,
FaExclamationCircle,
FaBan,
FaTerminal,
FaPlus,
FaCog,
FaInfoCircle,
FaLink,
FaStop,
FaHourglassHalf,
FaGlobe,
} from 'react-icons/fa';
import {t} from "i18next";
const normalizeStatus = (status) =>
typeof status === 'string' ? status.trim().toLowerCase() : '';
const STATUS_TAG_CONFIG = {
running: {
color: 'green',
label: t('运行中'),
icon: <FaPlay size={12} className='text-green-600' />,
},
deploying: {
color: 'blue',
label: t('部署中'),
icon: <FaSpinner size={12} className='text-blue-600' />,
},
pending: {
color: 'orange',
label: t('待部署'),
icon: <FaClock size={12} className='text-orange-600' />,
},
stopped: {
color: 'grey',
label: t('已停止'),
icon: <FaStop size={12} className='text-gray-500' />,
},
error: {
color: 'red',
label: t('错误'),
icon: <FaExclamationCircle size={12} className='text-red-500' />,
},
failed: {
color: 'red',
label: t('失败'),
icon: <FaExclamationCircle size={12} className='text-red-500' />,
},
destroyed: {
color: 'red',
label: t('已销毁'),
icon: <FaBan size={12} className='text-red-500' />,
},
completed: {
color: 'green',
label: t('已完成'),
icon: <FaCheckCircle size={12} className='text-green-600' />,
},
'deployment requested': {
color: 'blue',
label: t('部署请求中'),
icon: <FaSpinner size={12} className='text-blue-600' />,
},
'termination requested': {
color: 'orange',
label: t('终止请求中'),
icon: <FaClock size={12} className='text-orange-600' />,
},
};
const DEFAULT_STATUS_CONFIG = {
color: 'grey',
label: null,
icon: <FaInfoCircle size={12} className='text-gray-500' />,
};
const parsePercentValue = (value) => {
if (value === null || value === undefined) return null;
if (typeof value === 'string') {
const parsed = parseFloat(value.replace(/[^0-9.+-]/g, ''));
return Number.isFinite(parsed) ? parsed : null;
}
if (typeof value === 'number') {
return Number.isFinite(value) ? value : null;
}
return null;
};
const clampPercent = (value) => {
if (value === null || value === undefined) return null;
return Math.min(100, Math.max(0, Math.round(value)));
};
const formatRemainingMinutes = (minutes, t) => {
if (minutes === null || minutes === undefined) return null;
const numeric = Number(minutes);
if (!Number.isFinite(numeric)) return null;
const totalMinutes = Math.max(0, Math.round(numeric));
const days = Math.floor(totalMinutes / 1440);
const hours = Math.floor((totalMinutes % 1440) / 60);
const mins = totalMinutes % 60;
const parts = [];
if (days > 0) {
parts.push(`${days}${t('天')}`);
}
if (hours > 0) {
parts.push(`${hours}${t('小时')}`);
}
if (parts.length === 0 || mins > 0) {
parts.push(`${mins}${t('分钟')}`);
}
return parts.join(' ');
};
const getRemainingTheme = (percentRemaining) => {
if (percentRemaining === null) {
return {
iconColor: 'var(--semi-color-primary)',
tagColor: 'blue',
textColor: 'var(--semi-color-text-2)',
};
}
if (percentRemaining <= 10) {
return {
iconColor: '#ff5a5f',
tagColor: 'red',
textColor: '#ff5a5f',
};
}
if (percentRemaining <= 30) {
return {
iconColor: '#ffb400',
tagColor: 'orange',
textColor: '#ffb400',
};
}
return {
iconColor: '#2ecc71',
tagColor: 'green',
textColor: '#2ecc71',
};
};
const renderStatus = (status, t) => {
const normalizedStatus = normalizeStatus(status);
const config = STATUS_TAG_CONFIG[normalizedStatus] || DEFAULT_STATUS_CONFIG;
const statusText = typeof status === 'string' ? status : '';
const labelText = config.label ? t(config.label) : statusText || t('未知状态');
return (
<Tag
color={config.color}
shape='circle'
size='small'
prefixIcon={config.icon}
>
{labelText}
</Tag>
);
};
// Container Name Cell Component - to properly handle React hooks
const ContainerNameCell = ({ text, record, t }) => {
const handleCopyId = () => {
navigator.clipboard.writeText(record.id);
showSuccess(t('ID已复制到剪贴板'));
};
return (
<div className="flex flex-col gap-1">
<Typography.Text strong className="text-base">
{text}
</Typography.Text>
<Typography.Text
type="secondary"
size="small"
className="text-xs cursor-pointer hover:text-blue-600 transition-colors select-all"
onClick={handleCopyId}
title={t('点击复制ID')}
>
ID: {record.id}
</Typography.Text>
</div>
);
};
// Render resource configuration
const renderResourceConfig = (resource, t) => {
if (!resource) return '-';
const { cpu, memory, gpu } = resource;
return (
<div className="flex flex-col gap-1">
{cpu && (
<div className="flex items-center gap-1 text-xs">
<FaMicrochip className="text-blue-500" />
<span>CPU: {cpu}</span>
</div>
)}
{memory && (
<div className="flex items-center gap-1 text-xs">
<FaMemory className="text-green-500" />
<span>内存: {memory}</span>
</div>
)}
{gpu && (
<div className="flex items-center gap-1 text-xs">
<FaServer className="text-purple-500" />
<span>GPU: {gpu}</span>
</div>
)}
</div>
);
};
// Render instance count with status indicator
const renderInstanceCount = (count, record, t) => {
const normalizedStatus = normalizeStatus(record?.status);
const statusConfig = STATUS_TAG_CONFIG[normalizedStatus];
const countColor = statusConfig?.color ?? 'grey';
return (
<Tag color={countColor} size="small" shape='circle'>
{count || 0} {t('个实例')}
</Tag>
);
};
// Main function to get all deployment columns
export const getDeploymentsColumns = ({
t,
COLUMN_KEYS,
startDeployment,
restartDeployment,
deleteDeployment,
setEditingDeployment,
setShowEdit,
refresh,
activePage,
deployments,
// New handlers for enhanced operations
onViewLogs,
onExtendDuration,
onViewDetails,
onUpdateConfig,
onSyncToChannel,
}) => {
const columns = [
{
title: t('容器名称'),
dataIndex: 'container_name',
key: COLUMN_KEYS.container_name,
width: 300,
ellipsis: true,
render: (text, record) => (
<ContainerNameCell
text={text}
record={record}
t={t}
/>
),
},
{
title: t('状态'),
dataIndex: 'status',
key: COLUMN_KEYS.status,
width: 140,
render: (status) => (
<div className="flex items-center gap-2">
{renderStatus(status, t)}
</div>
),
},
{
title: t('服务商'),
dataIndex: 'provider',
key: COLUMN_KEYS.provider,
width: 140,
render: (provider) =>
provider ? (
<div
className="flex items-center gap-1.5 rounded-full border px-2 py-0.5 text-[10px] font-medium uppercase tracking-wide"
style={{
borderColor: 'rgba(59, 130, 246, 0.4)',
backgroundColor: 'rgba(59, 130, 246, 0.08)',
color: '#2563eb',
}}
>
<FaGlobe className="text-[11px]" />
<span>{provider}</span>
</div>
) : (
<Typography.Text type="tertiary" size="small" className="text-xs text-gray-500">
{t('暂无')}
</Typography.Text>
),
},
{
title: t('剩余时间'),
dataIndex: 'time_remaining',
key: COLUMN_KEYS.time_remaining,
width: 140,
render: (text, record) => {
const normalizedStatus = normalizeStatus(record?.status);
const percentUsedRaw = parsePercentValue(record?.completed_percent);
const percentUsed = clampPercent(percentUsedRaw);
const percentRemaining =
percentUsed === null ? null : clampPercent(100 - percentUsed);
const theme = getRemainingTheme(percentRemaining);
const statusDisplayMap = {
completed: t('已完成'),
destroyed: t('已销毁'),
failed: t('失败'),
error: t('失败'),
stopped: t('已停止'),
pending: t('待部署'),
deploying: t('部署中'),
'deployment requested': t('部署请求中'),
'termination requested': t('终止中'),
};
const statusOverride = statusDisplayMap[normalizedStatus];
const baseTimeDisplay =
text && String(text).trim() !== '' ? text : t('计算中');
const timeDisplay = baseTimeDisplay;
const humanReadable = formatRemainingMinutes(
record.compute_minutes_remaining,
t,
);
const showProgress = !statusOverride && normalizedStatus === 'running';
const showExtraInfo = Boolean(humanReadable || percentUsed !== null);
const showRemainingMeta =
record.compute_minutes_remaining !== undefined &&
record.compute_minutes_remaining !== null &&
percentRemaining !== null;
return (
<div className="flex flex-col gap-1 leading-tight text-xs">
<div className="flex items-center gap-1.5">
<FaHourglassHalf
className="text-sm"
style={{ color: theme.iconColor }}
/>
<Typography.Text className="text-sm font-medium text-[var(--semi-color-text-0)]">
{timeDisplay}
</Typography.Text>
{showProgress && percentRemaining !== null ? (
<Tag size="small" color={theme.tagColor}>
{percentRemaining}%
</Tag>
) : statusOverride ? (
<Tag size="small" color="grey">
{statusOverride}
</Tag>
) : null}
</div>
{showExtraInfo && (
<div className="flex items-center gap-3 text-[var(--semi-color-text-2)]">
{humanReadable && (
<span className="flex items-center gap-1">
<FaClock className="text-[11px]" />
{t('约')} {humanReadable}
</span>
)}
{percentUsed !== null && (
<span className="flex items-center gap-1">
<FaCheckCircle className="text-[11px]" />
{t('已用')} {percentUsed}%
</span>
)}
</div>
)}
{showProgress && showRemainingMeta && (
<div className="text-[10px]" style={{ color: theme.textColor }}>
{t('剩余')} {record.compute_minutes_remaining} {t('分钟')}
</div>
)}
</div>
);
},
},
{
title: t('硬件配置'),
dataIndex: 'hardware_info',
key: COLUMN_KEYS.hardware_info,
width: 220,
ellipsis: true,
render: (text, record) => (
<div className="flex items-center gap-2">
<div className="flex items-center gap-1 px-2 py-1 bg-green-50 border border-green-200 rounded-md">
<FaServer className="text-green-600 text-xs" />
<span className="text-xs font-medium text-green-700">
{record.hardware_name}
</span>
</div>
<span className="text-xs text-gray-500 font-medium">x{record.hardware_quantity}</span>
</div>
),
},
{
title: t('创建时间'),
dataIndex: 'created_at',
key: COLUMN_KEYS.created_at,
width: 150,
render: (text) => (
<span className="text-sm text-gray-600">{timestamp2string(text)}</span>
),
},
{
title: t('操作'),
key: COLUMN_KEYS.actions,
fixed: 'right',
width: 120,
render: (_, record) => {
const { status, id } = record;
const normalizedStatus = normalizeStatus(status);
const isEnded = normalizedStatus === 'completed' || normalizedStatus === 'destroyed';
const handleDelete = () => {
// Use enhanced confirmation dialog
onUpdateConfig?.(record, 'delete');
};
// Get primary action based on status
const getPrimaryAction = () => {
switch (normalizedStatus) {
case 'running':
return {
icon: <FaInfoCircle className="text-xs" />,
text: t('查看详情'),
onClick: () => onViewDetails?.(record),
type: 'secondary',
theme: 'borderless',
};
case 'failed':
case 'error':
return {
icon: <FaPlay className="text-xs" />,
text: t('重试'),
onClick: () => startDeployment(id),
type: 'primary',
theme: 'solid',
};
case 'stopped':
return {
icon: <FaPlay className="text-xs" />,
text: t('启动'),
onClick: () => startDeployment(id),
type: 'primary',
theme: 'solid',
};
case 'deployment requested':
case 'deploying':
return {
icon: <FaClock className="text-xs" />,
text: t('部署中'),
onClick: () => {},
type: 'secondary',
theme: 'light',
disabled: true,
};
case 'pending':
return {
icon: <FaClock className="text-xs" />,
text: t('待部署'),
onClick: () => {},
type: 'secondary',
theme: 'light',
disabled: true,
};
case 'termination requested':
return {
icon: <FaClock className="text-xs" />,
text: t('终止中'),
onClick: () => {},
type: 'secondary',
theme: 'light',
disabled: true,
};
case 'completed':
case 'destroyed':
default:
return {
icon: <FaInfoCircle className="text-xs" />,
text: t('已结束'),
onClick: () => {},
type: 'tertiary',
theme: 'borderless',
disabled: true,
};
}
};
const primaryAction = getPrimaryAction();
const primaryTheme = primaryAction.theme || 'solid';
const primaryType = primaryAction.type || 'primary';
if (isEnded) {
return (
<div className="flex w-full items-center justify-start gap-1 pr-2">
<Button
size="small"
type="tertiary"
theme="borderless"
onClick={() => onViewDetails?.(record)}
icon={<FaInfoCircle className="text-xs" />}
>
{t('查看详情')}
</Button>
</div>
);
}
// All actions dropdown with enhanced operations
const dropdownItems = [
<Dropdown.Item key="details" onClick={() => onViewDetails?.(record)} icon={<FaInfoCircle />}>
{t('查看详情')}
</Dropdown.Item>,
];
if (!isEnded) {
dropdownItems.push(
<Dropdown.Item key="logs" onClick={() => onViewLogs?.(record)} icon={<FaTerminal />}>
{t('查看日志')}
</Dropdown.Item>,
);
}
const managementItems = [];
if (normalizedStatus === 'running') {
if (onSyncToChannel) {
managementItems.push(
<Dropdown.Item key="sync-channel" onClick={() => onSyncToChannel(record)} icon={<FaLink />}>
{t('同步到渠道')}
</Dropdown.Item>,
);
}
}
if (normalizedStatus === 'failed' || normalizedStatus === 'error') {
managementItems.push(
<Dropdown.Item key="retry" onClick={() => startDeployment(id)} icon={<FaPlay />}>
{t('重试')}
</Dropdown.Item>,
);
}
if (normalizedStatus === 'stopped') {
managementItems.push(
<Dropdown.Item key="start" onClick={() => startDeployment(id)} icon={<FaPlay />}>
{t('启动')}
</Dropdown.Item>,
);
}
if (managementItems.length > 0) {
dropdownItems.push(<Dropdown.Divider key="management-divider" />);
dropdownItems.push(...managementItems);
}
const configItems = [];
if (!isEnded && (normalizedStatus === 'running' || normalizedStatus === 'deployment requested')) {
configItems.push(
<Dropdown.Item key="extend" onClick={() => onExtendDuration?.(record)} icon={<FaPlus />}>
{t('延长时长')}
</Dropdown.Item>,
);
}
// if (!isEnded && normalizedStatus === 'running') {
// configItems.push(
// <Dropdown.Item key="update-config" onClick={() => onUpdateConfig?.(record)} icon={<FaCog />}>
// {t('更新配置')}
// </Dropdown.Item>,
// );
// }
if (configItems.length > 0) {
dropdownItems.push(<Dropdown.Divider key="config-divider" />);
dropdownItems.push(...configItems);
}
if (!isEnded) {
dropdownItems.push(<Dropdown.Divider key="danger-divider" />);
dropdownItems.push(
<Dropdown.Item key="delete" type="danger" onClick={handleDelete} icon={<FaTrash />}>
{t('销毁容器')}
</Dropdown.Item>,
);
}
const allActions = <Dropdown.Menu>{dropdownItems}</Dropdown.Menu>;
const hasDropdown = dropdownItems.length > 0;
return (
<div className="flex w-full items-center justify-start gap-1 pr-2">
<Button
size="small"
theme={primaryTheme}
type={primaryType}
icon={primaryAction.icon}
onClick={primaryAction.onClick}
className="px-2 text-xs"
disabled={primaryAction.disabled}
>
{primaryAction.text}
</Button>
{hasDropdown && (
<Dropdown
trigger="click"
position="bottomRight"
render={allActions}
>
<Button
size="small"
theme="light"
type="tertiary"
icon={<IconMore />}
className="px-1"
/>
</Dropdown>
)}
</div>
);
},
},
];
return columns;
};

View File

@@ -0,0 +1,130 @@
/*
Copyright (C) 2025 QuantumNous
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import React, { useRef } from 'react';
import { Form, Button } from '@douyinfe/semi-ui';
import { IconSearch, IconRefresh } from '@douyinfe/semi-icons';
const DeploymentsFilters = ({
formInitValues,
setFormApi,
searchDeployments,
loading,
searching,
setShowColumnSelector,
t,
}) => {
const formApiRef = useRef(null);
const handleSubmit = (values) => {
searchDeployments(values);
};
const handleReset = () => {
if (!formApiRef.current) return;
formApiRef.current.reset();
setTimeout(() => {
formApiRef.current.submitForm();
}, 0);
};
const statusOptions = [
{ label: t('全部状态'), value: '' },
{ label: t('运行中'), value: 'running' },
{ label: t('已完成'), value: 'completed' },
{ label: t('失败'), value: 'failed' },
{ label: t('部署请求中'), value: 'deployment requested' },
{ label: t('终止请求中'), value: 'termination requested' },
{ label: t('已销毁'), value: 'destroyed' },
];
return (
<Form
layout='horizontal'
onSubmit={handleSubmit}
initValues={formInitValues}
getFormApi={(formApi) => {
setFormApi(formApi);
formApiRef.current = formApi;
}}
className='w-full md:w-auto order-1 md:order-2'
>
<div className='flex flex-col md:flex-row items-center gap-2 w-full md:w-auto'>
<div className='w-full md:w-64'>
<Form.Input
field='searchKeyword'
placeholder={t('搜索部署名称')}
prefix={<IconSearch />}
showClear
size='small'
pure
/>
</div>
<div className='w-full md:w-48'>
<Form.Select
field='searchStatus'
placeholder={t('选择状态')}
optionList={statusOptions}
className='w-full'
showClear
size='small'
pure
/>
</div>
<div className='flex gap-2 w-full md:w-auto'>
<Button
htmlType='submit'
type='tertiary'
icon={<IconSearch />}
loading={searching}
disabled={loading}
size='small'
className='flex-1 md:flex-initial md:w-auto'
>
{t('查询')}
</Button>
<Button
type='tertiary'
icon={<IconRefresh />}
onClick={handleReset}
disabled={loading || searching}
size='small'
className='flex-1 md:flex-initial md:w-auto'
>
{t('重置')}
</Button>
<Button
type='tertiary'
onClick={() => setShowColumnSelector(true)}
size='small'
className='flex-1 md:flex-initial md:w-auto'
>
{t('列设置')}
</Button>
</div>
</div>
</Form>
);
};
export default DeploymentsFilters;

View File

@@ -0,0 +1,247 @@
/*
Copyright (C) 2025 QuantumNous
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import React, { useMemo, useState } from 'react';
import { Empty } from '@douyinfe/semi-ui';
import CardTable from '../../common/ui/CardTable';
import {
IllustrationNoResult,
IllustrationNoResultDark,
} from '@douyinfe/semi-illustrations';
import { getDeploymentsColumns } from './DeploymentsColumnDefs';
// Import all the new modals
import ViewLogsModal from './modals/ViewLogsModal';
import ExtendDurationModal from './modals/ExtendDurationModal';
import ViewDetailsModal from './modals/ViewDetailsModal';
import UpdateConfigModal from './modals/UpdateConfigModal';
import ConfirmationDialog from './modals/ConfirmationDialog';
const DeploymentsTable = (deploymentsData) => {
const {
deployments,
loading,
searching,
activePage,
pageSize,
deploymentCount,
compactMode,
visibleColumns,
setSelectedKeys,
handlePageChange,
handlePageSizeChange,
handleRow,
t,
COLUMN_KEYS,
// Column functions and data
startDeployment,
restartDeployment,
deleteDeployment,
syncDeploymentToChannel,
setEditingDeployment,
setShowEdit,
refresh,
} = deploymentsData;
// Modal states
const [selectedDeployment, setSelectedDeployment] = useState(null);
const [showLogsModal, setShowLogsModal] = useState(false);
const [showExtendModal, setShowExtendModal] = useState(false);
const [showDetailsModal, setShowDetailsModal] = useState(false);
const [showConfigModal, setShowConfigModal] = useState(false);
const [showConfirmDialog, setShowConfirmDialog] = useState(false);
const [confirmOperation, setConfirmOperation] = useState('delete');
// Enhanced modal handlers
const handleViewLogs = (deployment) => {
setSelectedDeployment(deployment);
setShowLogsModal(true);
};
const handleExtendDuration = (deployment) => {
setSelectedDeployment(deployment);
setShowExtendModal(true);
};
const handleViewDetails = (deployment) => {
setSelectedDeployment(deployment);
setShowDetailsModal(true);
};
const handleUpdateConfig = (deployment, operation = 'update') => {
setSelectedDeployment(deployment);
if (operation === 'delete' || operation === 'destroy') {
setConfirmOperation(operation);
setShowConfirmDialog(true);
} else {
setShowConfigModal(true);
}
};
const handleConfirmAction = () => {
if (selectedDeployment && confirmOperation === 'delete') {
deleteDeployment(selectedDeployment.id);
}
setShowConfirmDialog(false);
setSelectedDeployment(null);
};
const handleModalSuccess = (updatedDeployment) => {
// Refresh the deployments list
refresh?.();
};
// Get all columns
const allColumns = useMemo(() => {
return getDeploymentsColumns({
t,
COLUMN_KEYS,
startDeployment,
restartDeployment,
deleteDeployment,
setEditingDeployment,
setShowEdit,
refresh,
activePage,
deployments,
// Enhanced handlers
onViewLogs: handleViewLogs,
onExtendDuration: handleExtendDuration,
onViewDetails: handleViewDetails,
onUpdateConfig: handleUpdateConfig,
onSyncToChannel: syncDeploymentToChannel,
});
}, [
t,
COLUMN_KEYS,
startDeployment,
restartDeployment,
deleteDeployment,
syncDeploymentToChannel,
setEditingDeployment,
setShowEdit,
refresh,
activePage,
deployments,
]);
// Filter columns based on visibility settings
const getVisibleColumns = () => {
return allColumns.filter((column) => visibleColumns[column.key]);
};
const visibleColumnsList = useMemo(() => {
return getVisibleColumns();
}, [visibleColumns, allColumns]);
const tableColumns = useMemo(() => {
if (compactMode) {
// In compact mode, remove fixed columns and adjust widths
return visibleColumnsList.map(({ fixed, width, ...rest }) => ({
...rest,
width: width ? Math.max(width * 0.8, 80) : undefined, // Reduce width by 20% but keep minimum
}));
}
return visibleColumnsList;
}, [compactMode, visibleColumnsList]);
return (
<>
<CardTable
columns={tableColumns}
dataSource={deployments}
scroll={compactMode ? { x: 800 } : { x: 1200 }}
pagination={{
currentPage: activePage,
pageSize: pageSize,
total: deploymentCount,
pageSizeOpts: [10, 20, 50, 100],
showSizeChanger: true,
onPageSizeChange: handlePageSizeChange,
onPageChange: handlePageChange,
}}
hidePagination={true}
expandAllRows={false}
onRow={handleRow}
rowSelection={{
onChange: (selectedRowKeys, selectedRows) => {
setSelectedKeys(selectedRows);
},
}}
empty={
<Empty
image={<IllustrationNoResult style={{ width: 150, height: 150 }} />}
darkModeImage={
<IllustrationNoResultDark style={{ width: 150, height: 150 }} />
}
description={t('搜索无结果')}
style={{ padding: 30 }}
/>
}
className='rounded-xl overflow-hidden'
size='middle'
loading={loading || searching}
/>
{/* Enhanced Modals */}
<ViewLogsModal
visible={showLogsModal}
onCancel={() => setShowLogsModal(false)}
deployment={selectedDeployment}
t={t}
/>
<ExtendDurationModal
visible={showExtendModal}
onCancel={() => setShowExtendModal(false)}
deployment={selectedDeployment}
onSuccess={handleModalSuccess}
t={t}
/>
<ViewDetailsModal
visible={showDetailsModal}
onCancel={() => setShowDetailsModal(false)}
deployment={selectedDeployment}
t={t}
/>
<UpdateConfigModal
visible={showConfigModal}
onCancel={() => setShowConfigModal(false)}
deployment={selectedDeployment}
onSuccess={handleModalSuccess}
t={t}
/>
<ConfirmationDialog
visible={showConfirmDialog}
onCancel={() => setShowConfirmDialog(false)}
onConfirm={handleConfirmAction}
title={t('确认操作')}
type="danger"
deployment={selectedDeployment}
operation={confirmOperation}
t={t}
/>
</>
);
};
export default DeploymentsTable;

View File

@@ -0,0 +1,147 @@
/*
Copyright (C) 2025 QuantumNous
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import React, { useState } from 'react';
import CardPro from '../../common/ui/CardPro';
import DeploymentsTable from './DeploymentsTable';
import DeploymentsActions from './DeploymentsActions';
import DeploymentsFilters from './DeploymentsFilters';
import EditDeploymentModal from './modals/EditDeploymentModal';
import CreateDeploymentModal from './modals/CreateDeploymentModal';
import ColumnSelectorModal from './modals/ColumnSelectorModal';
import { useDeploymentsData } from '../../../hooks/model-deployments/useDeploymentsData';
import { useIsMobile } from '../../../hooks/common/useIsMobile';
import { createCardProPagination } from '../../../helpers/utils';
const DeploymentsPage = () => {
const deploymentsData = useDeploymentsData();
const isMobile = useIsMobile();
// Create deployment modal state
const [showCreateModal, setShowCreateModal] = useState(false);
const {
// Edit state
showEdit,
editingDeployment,
closeEdit,
refresh,
// Actions state
selectedKeys,
setSelectedKeys,
setEditingDeployment,
setShowEdit,
batchDeleteDeployments,
// Filters state
formInitValues,
setFormApi,
searchDeployments,
loading,
searching,
// Column visibility
showColumnSelector,
setShowColumnSelector,
visibleColumns,
setVisibleColumns,
COLUMN_KEYS,
// Description state
compactMode,
setCompactMode,
// Translation
t,
} = deploymentsData;
return (
<>
{/* Modals */}
<EditDeploymentModal
refresh={refresh}
editingDeployment={editingDeployment}
visible={showEdit}
handleClose={closeEdit}
/>
<CreateDeploymentModal
visible={showCreateModal}
onCancel={() => setShowCreateModal(false)}
onSuccess={refresh}
t={t}
/>
<ColumnSelectorModal
visible={showColumnSelector}
onCancel={() => setShowColumnSelector(false)}
visibleColumns={visibleColumns}
onVisibleColumnsChange={setVisibleColumns}
columnKeys={COLUMN_KEYS}
t={t}
/>
{/* Main Content */}
<CardPro
type='type3'
actionsArea={
<div className='flex flex-col md:flex-row justify-between items-center gap-2 w-full'>
<DeploymentsActions
selectedKeys={selectedKeys}
setSelectedKeys={setSelectedKeys}
setEditingDeployment={setEditingDeployment}
setShowEdit={setShowEdit}
batchDeleteDeployments={batchDeleteDeployments}
compactMode={compactMode}
setCompactMode={setCompactMode}
showCreateModal={showCreateModal}
setShowCreateModal={setShowCreateModal}
setShowColumnSelector={setShowColumnSelector}
t={t}
/>
<DeploymentsFilters
formInitValues={formInitValues}
setFormApi={setFormApi}
searchDeployments={searchDeployments}
loading={loading}
searching={searching}
setShowColumnSelector={setShowColumnSelector}
t={t}
/>
</div>
}
paginationArea={createCardProPagination({
currentPage: deploymentsData.activePage,
pageSize: deploymentsData.pageSize,
total: deploymentsData.deploymentCount,
onPageChange: deploymentsData.handlePageChange,
onPageSizeChange: deploymentsData.handlePageSizeChange,
isMobile: isMobile,
t: deploymentsData.t,
})}
t={deploymentsData.t}
>
<DeploymentsTable {...deploymentsData} />
</CardPro>
</>
);
};
export default DeploymentsPage;

View File

@@ -0,0 +1,127 @@
/*
Copyright (C) 2025 QuantumNous
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import React, { useMemo } from 'react';
import { Modal, Button, Checkbox } from '@douyinfe/semi-ui';
const ColumnSelectorModal = ({
visible,
onCancel,
visibleColumns,
onVisibleColumnsChange,
columnKeys,
t,
}) => {
const columnOptions = useMemo(
() => [
{ key: columnKeys.container_name, label: t('容器名称'), required: true },
{ key: columnKeys.status, label: t('状态') },
{ key: columnKeys.time_remaining, label: t('剩余时间') },
{ key: columnKeys.hardware_info, label: t('硬件配置') },
{ key: columnKeys.created_at, label: t('创建时间') },
{ key: columnKeys.actions, label: t('操作'), required: true },
],
[columnKeys, t],
);
const handleColumnVisibilityChange = (key, checked) => {
const column = columnOptions.find((option) => option.key === key);
if (column?.required) return;
onVisibleColumnsChange({
...visibleColumns,
[key]: checked,
});
};
const handleSelectAll = (checked) => {
const updated = { ...visibleColumns };
columnOptions.forEach(({ key, required }) => {
updated[key] = required ? true : checked;
});
onVisibleColumnsChange(updated);
};
const handleReset = () => {
const defaults = columnOptions.reduce((acc, { key }) => {
acc[key] = true;
return acc;
}, {});
onVisibleColumnsChange({
...visibleColumns,
...defaults,
});
};
const allSelected = columnOptions.every(
({ key, required }) => required || visibleColumns[key],
);
const indeterminate =
columnOptions.some(
({ key, required }) => !required && visibleColumns[key],
) && !allSelected;
const handleConfirm = () => onCancel();
return (
<Modal
title={t('列设置')}
visible={visible}
onCancel={onCancel}
footer={
<div className='flex justify-end gap-2'>
<Button onClick={handleReset}>{t('重置')}</Button>
<Button onClick={onCancel}>{t('取消')}</Button>
<Button type='primary' onClick={handleConfirm}>
{t('确定')}
</Button>
</div>
}
>
<div style={{ marginBottom: 20 }}>
<Checkbox
checked={allSelected}
indeterminate={indeterminate}
onChange={(e) => handleSelectAll(e.target.checked)}
>
{t('全选')}
</Checkbox>
</div>
<div
className='flex flex-wrap max-h-96 overflow-y-auto rounded-lg p-4'
style={{ border: '1px solid var(--semi-color-border)' }}
>
{columnOptions.map(({ key, label, required }) => (
<div key={key} className='w-1/2 mb-4 pr-2'>
<Checkbox
checked={!!visibleColumns[key]}
disabled={required}
onChange={(e) =>
handleColumnVisibilityChange(key, e.target.checked)
}
>
{label}
</Checkbox>
</div>
))}
</div>
</Modal>
);
};
export default ColumnSelectorModal;

View File

@@ -0,0 +1,99 @@
/*
Copyright (C) 2025 QuantumNous
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import React, { useState, useEffect } from 'react';
import { Modal, Typography, Input } from '@douyinfe/semi-ui';
const { Text } = Typography;
const ConfirmationDialog = ({
visible,
onCancel,
onConfirm,
title,
type = 'danger',
deployment,
t,
loading = false
}) => {
const [confirmText, setConfirmText] = useState('');
useEffect(() => {
if (!visible) {
setConfirmText('');
}
}, [visible]);
const requiredText = deployment?.container_name || deployment?.id || '';
const isConfirmed = Boolean(requiredText) && confirmText === requiredText;
const handleCancel = () => {
setConfirmText('');
onCancel();
};
const handleConfirm = () => {
if (isConfirmed) {
onConfirm();
handleCancel();
}
};
return (
<Modal
title={title}
visible={visible}
onCancel={handleCancel}
onOk={handleConfirm}
okText={t('确认')}
cancelText={t('取消')}
okButtonProps={{
disabled: !isConfirmed,
type: type === 'danger' ? 'danger' : 'primary',
loading
}}
width={480}
>
<div className="space-y-4">
<Text type="danger" strong>
{t('此操作具有风险,请确认要继续执行')}
</Text>
<Text>
{t('请输入部署名称以完成二次确认')}
<Text code className="ml-1">
{requiredText || t('未知部署')}
</Text>
</Text>
<Input
value={confirmText}
onChange={setConfirmText}
placeholder={t('再次输入部署名称')}
autoFocus
/>
{!isConfirmed && confirmText && (
<Text type="danger" size="small">
{t('部署名称不匹配,请检查后重新输入')}
</Text>
)}
</div>
</Modal>
);
};
export default ConfirmationDialog;

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,241 @@
/*
Copyright (C) 2025 QuantumNous
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import React, { useState, useEffect, useRef } from 'react';
import {
SideSheet,
Form,
Button,
Space,
Spin,
Typography,
Card,
InputNumber,
Select,
Input,
Row,
Col,
Divider,
Tag,
} from '@douyinfe/semi-ui';
import { Save, X, Server } from 'lucide-react';
import { API, showError, showSuccess } from '../../../../helpers';
import { useTranslation } from 'react-i18next';
import { useIsMobile } from '../../../../hooks/common/useIsMobile';
const { Text, Title } = Typography;
const EditDeploymentModal = ({
refresh,
editingDeployment,
visible,
handleClose,
}) => {
const { t } = useTranslation();
const isMobile = useIsMobile();
const [loading, setLoading] = useState(false);
const [models, setModels] = useState([]);
const [loadingModels, setLoadingModels] = useState(false);
const formRef = useRef();
const isEdit = Boolean(editingDeployment?.id);
const title = t('重命名部署');
// Resource configuration options
const cpuOptions = [
{ label: '0.5 Core', value: '0.5' },
{ label: '1 Core', value: '1' },
{ label: '2 Cores', value: '2' },
{ label: '4 Cores', value: '4' },
{ label: '8 Cores', value: '8' },
];
const memoryOptions = [
{ label: '1GB', value: '1Gi' },
{ label: '2GB', value: '2Gi' },
{ label: '4GB', value: '4Gi' },
{ label: '8GB', value: '8Gi' },
{ label: '16GB', value: '16Gi' },
{ label: '32GB', value: '32Gi' },
];
const gpuOptions = [
{ label: t('无GPU'), value: '' },
{ label: '1 GPU', value: '1' },
{ label: '2 GPUs', value: '2' },
{ label: '4 GPUs', value: '4' },
];
// Load available models
const loadModels = async () => {
setLoadingModels(true);
try {
const res = await API.get('/api/models/?page_size=1000');
if (res.data.success) {
const items = res.data.data.items || res.data.data || [];
const modelOptions = items.map((model) => ({
label: `${model.model_name} (${model.vendor?.name || 'Unknown'})`,
value: model.model_name,
model_id: model.id,
}));
setModels(modelOptions);
}
} catch (error) {
console.error('Failed to load models:', error);
showError(t('加载模型列表失败'));
}
setLoadingModels(false);
};
// Form submission
const handleSubmit = async (values) => {
if (!isEdit || !editingDeployment?.id) {
showError(t('无效的部署信息'));
return;
}
setLoading(true);
try {
// Only handle name update for now
const res = await API.put(
`/api/deployments/${editingDeployment.id}/name`,
{
name: values.deployment_name,
},
);
if (res.data.success) {
showSuccess(t('部署名称更新成功'));
handleClose();
refresh();
} else {
showError(res.data.message || t('更新失败'));
}
} catch (error) {
console.error('Submit error:', error);
showError(t('更新失败,请检查输入信息'));
}
setLoading(false);
};
// Load models when modal opens
useEffect(() => {
if (visible) {
loadModels();
}
}, [visible]);
// Set form values when editing
useEffect(() => {
if (formRef.current && editingDeployment && visible && isEdit) {
formRef.current.setValues({
deployment_name: editingDeployment.deployment_name || '',
});
}
}, [editingDeployment, visible, isEdit]);
return (
<SideSheet
title={
<div className='flex items-center gap-2'>
<Server size={20} />
<span>{title}</span>
</div>
}
visible={visible}
onCancel={handleClose}
width={isMobile ? '100%' : 600}
bodyStyle={{ padding: 0 }}
maskClosable={false}
closeOnEsc={true}
>
<div className='p-6 h-full overflow-auto'>
<Spin spinning={loading} style={{ width: '100%' }}>
<Form
ref={formRef}
onSubmit={handleSubmit}
labelPosition='top'
style={{ width: '100%' }}
>
<Card>
<Title heading={5} style={{ marginBottom: 16 }}>
{t('修改部署名称')}
</Title>
<Row gutter={16}>
<Col span={24}>
<Form.Input
field='deployment_name'
label={t('部署名称')}
placeholder={t('请输入新的部署名称')}
rules={[
{ required: true, message: t('请输入部署名称') },
{
pattern: /^[a-zA-Z0-9-_\u4e00-\u9fa5]+$/,
message: t(
'部署名称只能包含字母、数字、横线、下划线和中文',
),
},
]}
/>
</Col>
</Row>
{isEdit && (
<div className='mt-4 p-3 bg-gray-50 rounded'>
<Text type='secondary'>{t('部署ID')}: </Text>
<Text code>{editingDeployment.id}</Text>
<br />
<Text type='secondary'>{t('当前状态')}: </Text>
<Tag
color={
editingDeployment.status === 'running' ? 'green' : 'grey'
}
>
{editingDeployment.status}
</Tag>
</div>
)}
</Card>
</Form>
</Spin>
</div>
<div className='p-4 border-t border-gray-200 bg-gray-50 flex justify-end'>
<Space>
<Button theme='outline' onClick={handleClose} disabled={loading}>
<X size={16} className='mr-1' />
{t('取消')}
</Button>
<Button
theme='solid'
type='primary'
loading={loading}
onClick={() => formRef.current?.submitForm()}
>
<Save size={16} className='mr-1' />
{isEdit ? t('更新') : t('创建')}
</Button>
</Space>
</div>
</SideSheet>
);
};
export default EditDeploymentModal;

View File

@@ -0,0 +1,548 @@
/*
Copyright (C) 2025 QuantumNous
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import React, { useEffect, useRef, useState } from 'react';
import {
Modal,
Form,
InputNumber,
Typography,
Card,
Space,
Divider,
Button,
Tag,
Banner,
Spin,
} from '@douyinfe/semi-ui';
import {
FaClock,
FaCalculator,
FaInfoCircle,
FaExclamationTriangle,
} from 'react-icons/fa';
import { API, showError, showSuccess } from '../../../../helpers';
const { Text } = Typography;
const ExtendDurationModal = ({
visible,
onCancel,
deployment,
onSuccess,
t,
}) => {
const formRef = useRef(null);
const [loading, setLoading] = useState(false);
const [durationHours, setDurationHours] = useState(1);
const [costLoading, setCostLoading] = useState(false);
const [priceEstimation, setPriceEstimation] = useState(null);
const [priceError, setPriceError] = useState(null);
const [detailsLoading, setDetailsLoading] = useState(false);
const [deploymentDetails, setDeploymentDetails] = useState(null);
const costRequestIdRef = useRef(0);
const resetState = () => {
costRequestIdRef.current += 1;
setDurationHours(1);
setPriceEstimation(null);
setPriceError(null);
setDeploymentDetails(null);
setCostLoading(false);
};
const fetchDeploymentDetails = async (deploymentId) => {
setDetailsLoading(true);
try {
const response = await API.get(`/api/deployments/${deploymentId}`);
if (response.data.success) {
const details = response.data.data;
setDeploymentDetails(details);
setPriceError(null);
return details;
}
const message = response.data.message || '';
const errorMessage = t('获取详情失败') + (message ? `: ${message}` : '');
showError(errorMessage);
setDeploymentDetails(null);
setPriceEstimation(null);
setPriceError(errorMessage);
return null;
} catch (error) {
const message = error?.response?.data?.message || error.message || '';
const errorMessage = t('获取详情失败') + (message ? `: ${message}` : '');
showError(errorMessage);
setDeploymentDetails(null);
setPriceEstimation(null);
setPriceError(errorMessage);
return null;
} finally {
setDetailsLoading(false);
}
};
const calculatePrice = async (hours, details) => {
if (!visible || !details) {
return;
}
const sanitizedHours = Number.isFinite(hours) ? Math.round(hours) : 0;
if (sanitizedHours <= 0) {
setPriceEstimation(null);
setPriceError(null);
return;
}
const hardwareId = Number(details?.hardware_id) || 0;
const totalGPUs = Number(details?.total_gpus) || 0;
const totalContainers = Number(details?.total_containers) || 0;
const baseGpusPerContainer = Number(details?.gpus_per_container) || 0;
const resolvedGpusPerContainer =
baseGpusPerContainer > 0
? baseGpusPerContainer
: totalContainers > 0 && totalGPUs > 0
? Math.max(1, Math.round(totalGPUs / totalContainers))
: 0;
const resolvedReplicaCount =
totalContainers > 0
? totalContainers
: resolvedGpusPerContainer > 0 && totalGPUs > 0
? Math.max(1, Math.round(totalGPUs / resolvedGpusPerContainer))
: 0;
const locationIds = Array.isArray(details?.locations)
? details.locations
.map((location) =>
Number(
location?.id ??
location?.location_id ??
location?.locationId,
),
)
.filter((id) => Number.isInteger(id) && id > 0)
: [];
if (
hardwareId <= 0 ||
resolvedGpusPerContainer <= 0 ||
resolvedReplicaCount <= 0 ||
locationIds.length === 0
) {
setPriceEstimation(null);
setPriceError(t('价格计算失败'));
return;
}
const requestId = Date.now();
costRequestIdRef.current = requestId;
setCostLoading(true);
setPriceError(null);
const payload = {
location_ids: locationIds,
hardware_id: hardwareId,
gpus_per_container: resolvedGpusPerContainer,
duration_hours: sanitizedHours,
replica_count: resolvedReplicaCount,
currency: 'usdc',
duration_type: 'hour',
duration_qty: sanitizedHours,
hardware_qty: resolvedGpusPerContainer,
};
try {
const response = await API.post(
'/api/deployments/price-estimation',
payload,
);
if (costRequestIdRef.current !== requestId) {
return;
}
if (response.data.success) {
setPriceEstimation(response.data.data);
} else {
const message = response.data.message || '';
setPriceEstimation(null);
setPriceError(
t('价格计算失败') + (message ? `: ${message}` : ''),
);
}
} catch (error) {
if (costRequestIdRef.current !== requestId) {
return;
}
const message = error?.response?.data?.message || error.message || '';
setPriceEstimation(null);
setPriceError(
t('价格计算失败') + (message ? `: ${message}` : ''),
);
} finally {
if (costRequestIdRef.current === requestId) {
setCostLoading(false);
}
}
};
useEffect(() => {
if (visible && deployment?.id) {
resetState();
if (formRef.current) {
formRef.current.setValue('duration_hours', 1);
}
fetchDeploymentDetails(deployment.id);
}
if (!visible) {
resetState();
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [visible, deployment?.id]);
useEffect(() => {
if (!visible) {
return;
}
if (!deploymentDetails) {
return;
}
calculatePrice(durationHours, deploymentDetails);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [durationHours, deploymentDetails, visible]);
const handleExtend = async () => {
try {
if (formRef.current) {
await formRef.current.validate();
}
setLoading(true);
const response = await API.post(
`/api/deployments/${deployment.id}/extend`,
{
duration_hours: Math.round(durationHours),
},
);
if (response.data.success) {
showSuccess(t('容器时长延长成功'));
onSuccess?.(response.data.data);
handleCancel();
}
} catch (error) {
showError(
t('延长时长失败') +
': ' +
(error?.response?.data?.message || error.message),
);
} finally {
setLoading(false);
}
};
const handleCancel = () => {
if (formRef.current) {
formRef.current.reset();
}
resetState();
onCancel();
};
const currentRemainingTime = deployment?.time_remaining || '0分钟';
const newTotalTime = `${currentRemainingTime} + ${durationHours}${t('小时')}`;
const priceData = priceEstimation || {};
const breakdown =
priceData.price_breakdown || priceData.PriceBreakdown || {};
const currencyLabel = (
priceData.currency || priceData.Currency || 'USDC'
)
.toString()
.toUpperCase();
const estimatedTotalCost =
typeof priceData.estimated_cost === 'number'
? priceData.estimated_cost
: typeof priceData.EstimatedCost === 'number'
? priceData.EstimatedCost
: typeof breakdown.total_cost === 'number'
? breakdown.total_cost
: breakdown.TotalCost;
const hourlyRate =
typeof breakdown.hourly_rate === 'number'
? breakdown.hourly_rate
: breakdown.HourlyRate;
const computeCost =
typeof breakdown.compute_cost === 'number'
? breakdown.compute_cost
: breakdown.ComputeCost;
const resolvedHardwareName =
deploymentDetails?.hardware_name || deployment?.hardware_name || '--';
const gpuCount =
deploymentDetails?.total_gpus || deployment?.hardware_quantity || 0;
const containers = deploymentDetails?.total_containers || 0;
return (
<Modal
title={
<div className='flex items-center gap-2'>
<FaClock className='text-blue-500' />
<span>{t('延长容器时长')}</span>
</div>
}
visible={visible}
onCancel={handleCancel}
onOk={handleExtend}
okText={t('确认延长')}
cancelText={t('取消')}
confirmLoading={loading}
okButtonProps={{
disabled:
!deployment?.id || detailsLoading || !durationHours || durationHours < 1,
}}
width={600}
className='extend-duration-modal'
>
<div className='space-y-4'>
<Card className='border-0 bg-gray-50'>
<div className='flex items-center justify-between'>
<div>
<Text strong className='text-base'>
{deployment?.container_name || deployment?.deployment_name}
</Text>
<div className='mt-1'>
<Text type='secondary' size='small'>
ID: {deployment?.id}
</Text>
</div>
</div>
<div className='text-right'>
<div className='flex items-center gap-2 mb-1'>
<Tag color='blue' size='small'>
{resolvedHardwareName}
{gpuCount ? ` x${gpuCount}` : ''}
</Tag>
</div>
<Text size='small' type='secondary'>
{t('当前剩余')}: <Text strong>{currentRemainingTime}</Text>
</Text>
</div>
</div>
</Card>
<Banner
type='warning'
icon={<FaExclamationTriangle />}
title={t('重要提醒')}
description={
<div className='space-y-2'>
<p>
{t('延长容器时长将会产生额外费用,请确认您有足够的账户余额。')}
</p>
<p>
{t('延长操作一旦确认无法撤销,费用将立即扣除。')}
</p>
</div>
}
/>
<Form
getFormApi={(api) => (formRef.current = api)}
layout='vertical'
onValueChange={(values) => {
if (values.duration_hours !== undefined) {
const numericValue = Number(values.duration_hours);
setDurationHours(Number.isFinite(numericValue) ? numericValue : 0);
}
}}
>
<Form.InputNumber
field='duration_hours'
label={t('延长时长(小时)')}
placeholder={t('请输入要延长的小时数')}
min={1}
max={720}
step={1}
initValue={1}
style={{ width: '100%' }}
suffix={t('小时')}
rules={[
{ required: true, message: t('请输入延长时长') },
{
type: 'number',
min: 1,
message: t('延长时长至少为1小时'),
},
{
type: 'number',
max: 720,
message: t('延长时长不能超过720小时30天'),
},
]}
/>
</Form>
<div className='space-y-2'>
<Text size='small' type='secondary'>
{t('快速选择')}:
</Text>
<Space wrap>
{[1, 2, 6, 12, 24, 48, 72, 168].map((hours) => (
<Button
key={hours}
size='small'
theme={durationHours === hours ? 'solid' : 'borderless'}
type={durationHours === hours ? 'primary' : 'secondary'}
onClick={() => {
setDurationHours(hours);
if (formRef.current) {
formRef.current.setValue('duration_hours', hours);
}
}}
>
{hours < 24
? `${hours}${t('小时')}`
: `${hours / 24}${t('天')}`}
</Button>
))}
</Space>
</div>
<Divider />
<Card
title={
<div className='flex items-center gap-2'>
<FaCalculator className='text-green-500' />
<span>{t('费用预估')}</span>
</div>
}
className='border border-green-200'
>
{priceEstimation ? (
<div className='space-y-3'>
<div className='flex items-center justify-between'>
<Text>{t('延长时长')}:</Text>
<Text strong>
{Math.round(durationHours)} {t('小时')}
</Text>
</div>
<div className='flex items-center justify-between'>
<Text>{t('硬件配置')}:</Text>
<Text strong>
{resolvedHardwareName}
{gpuCount ? ` x${gpuCount}` : ''}
</Text>
</div>
{containers ? (
<div className='flex items-center justify-between'>
<Text>{t('容器数量')}:</Text>
<Text strong>{containers}</Text>
</div>
) : null}
<div className='flex items-center justify-between'>
<Text>{t('单GPU小时费率')}:</Text>
<Text strong>
{typeof hourlyRate === 'number'
? `${hourlyRate.toFixed(4)} ${currencyLabel}`
: '--'}
</Text>
</div>
{typeof computeCost === 'number' && (
<div className='flex items-center justify-between'>
<Text>{t('计算成本')}:</Text>
<Text strong>
{computeCost.toFixed(4)} {currencyLabel}
</Text>
</div>
)}
<Divider margin='12px' />
<div className='flex items-center justify-between'>
<Text strong className='text-lg'>
{t('预估总费用')}:
</Text>
<Text strong className='text-lg text-green-600'>
{typeof estimatedTotalCost === 'number'
? `${estimatedTotalCost.toFixed(4)} ${currencyLabel}`
: '--'}
</Text>
</div>
<div className='bg-blue-50 p-3 rounded-lg'>
<div className='flex items-start gap-2'>
<FaInfoCircle className='text-blue-500 mt-0.5' />
<div>
<Text size='small' type='secondary'>
{t('延长后总时长')}: <Text strong>{newTotalTime}</Text>
</Text>
<br />
<Text size='small' type='secondary'>
{t('预估费用仅供参考,实际费用可能略有差异')}
</Text>
</div>
</div>
</div>
</div>
) : (
<div className='text-center text-gray-500 py-4'>
{costLoading ? (
<Space align='center' className='justify-center'>
<Spin size='small' />
<Text type='secondary'>{t('计算费用中...')}</Text>
</Space>
) : priceError ? (
<Text type='danger'>{priceError}</Text>
) : deploymentDetails ? (
<Text type='secondary'>{t('请输入延长时长')}</Text>
) : (
<Text type='secondary'>{t('加载详情中...')}</Text>
)}
</div>
)}
</Card>
<div className='bg-red-50 border border-red-200 rounded-lg p-3'>
<div className='flex items-start gap-2'>
<FaExclamationTriangle className='text-red-500 mt-0.5' />
<div>
<Text strong className='text-red-700'>
{t('确认延长容器时长')}
</Text>
<div className='mt-1'>
<Text size='small' className='text-red-600'>
{t('点击"确认延长"后将立即扣除费用并延长容器运行时间')}
</Text>
</div>
</div>
</div>
</div>
</div>
</Modal>
);
};
export default ExtendDurationModal;

View File

@@ -0,0 +1,475 @@
/*
Copyright (C) 2025 QuantumNous
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import React, { useState, useEffect, useRef } from 'react';
import {
Modal,
Form,
Input,
InputNumber,
Typography,
Card,
Space,
Divider,
Button,
Banner,
Tag,
Collapse,
TextArea,
Switch,
} from '@douyinfe/semi-ui';
import {
FaCog,
FaDocker,
FaKey,
FaTerminal,
FaNetworkWired,
FaExclamationTriangle,
FaPlus,
FaMinus
} from 'react-icons/fa';
import { API, showError, showSuccess } from '../../../../helpers';
const { Text, Title } = Typography;
const UpdateConfigModal = ({
visible,
onCancel,
deployment,
onSuccess,
t
}) => {
const formRef = useRef(null);
const [loading, setLoading] = useState(false);
const [envVars, setEnvVars] = useState([]);
const [secretEnvVars, setSecretEnvVars] = useState([]);
// Initialize form data when modal opens
useEffect(() => {
if (visible && deployment) {
// Set initial form values based on deployment data
const initialValues = {
image_url: deployment.container_config?.image_url || '',
traffic_port: deployment.container_config?.traffic_port || null,
entrypoint: deployment.container_config?.entrypoint?.join(' ') || '',
registry_username: '',
registry_secret: '',
command: '',
};
if (formRef.current) {
formRef.current.setValues(initialValues);
}
// Initialize environment variables
const envVarsList = deployment.container_config?.env_variables
? Object.entries(deployment.container_config.env_variables).map(([key, value]) => ({
key, value: String(value)
}))
: [];
setEnvVars(envVarsList);
setSecretEnvVars([]);
}
}, [visible, deployment]);
const handleUpdate = async () => {
try {
const formValues = formRef.current ? await formRef.current.validate() : {};
setLoading(true);
// Prepare the update payload
const payload = {};
if (formValues.image_url) payload.image_url = formValues.image_url;
if (formValues.traffic_port) payload.traffic_port = formValues.traffic_port;
if (formValues.registry_username) payload.registry_username = formValues.registry_username;
if (formValues.registry_secret) payload.registry_secret = formValues.registry_secret;
if (formValues.command) payload.command = formValues.command;
// Process entrypoint
if (formValues.entrypoint) {
payload.entrypoint = formValues.entrypoint.split(' ').filter(cmd => cmd.trim());
}
// Process environment variables
if (envVars.length > 0) {
payload.env_variables = envVars.reduce((acc, env) => {
if (env.key && env.value !== undefined) {
acc[env.key] = env.value;
}
return acc;
}, {});
}
// Process secret environment variables
if (secretEnvVars.length > 0) {
payload.secret_env_variables = secretEnvVars.reduce((acc, env) => {
if (env.key && env.value !== undefined) {
acc[env.key] = env.value;
}
return acc;
}, {});
}
const response = await API.put(`/api/deployments/${deployment.id}`, payload);
if (response.data.success) {
showSuccess(t('容器配置更新成功'));
onSuccess?.(response.data.data);
handleCancel();
}
} catch (error) {
showError(t('更新配置失败') + ': ' + (error.response?.data?.message || error.message));
} finally {
setLoading(false);
}
};
const handleCancel = () => {
if (formRef.current) {
formRef.current.reset();
}
setEnvVars([]);
setSecretEnvVars([]);
onCancel();
};
const addEnvVar = () => {
setEnvVars([...envVars, { key: '', value: '' }]);
};
const removeEnvVar = (index) => {
const newEnvVars = envVars.filter((_, i) => i !== index);
setEnvVars(newEnvVars);
};
const updateEnvVar = (index, field, value) => {
const newEnvVars = [...envVars];
newEnvVars[index][field] = value;
setEnvVars(newEnvVars);
};
const addSecretEnvVar = () => {
setSecretEnvVars([...secretEnvVars, { key: '', value: '' }]);
};
const removeSecretEnvVar = (index) => {
const newSecretEnvVars = secretEnvVars.filter((_, i) => i !== index);
setSecretEnvVars(newSecretEnvVars);
};
const updateSecretEnvVar = (index, field, value) => {
const newSecretEnvVars = [...secretEnvVars];
newSecretEnvVars[index][field] = value;
setSecretEnvVars(newSecretEnvVars);
};
return (
<Modal
title={
<div className="flex items-center gap-2">
<FaCog className="text-blue-500" />
<span>{t('更新容器配置')}</span>
</div>
}
visible={visible}
onCancel={handleCancel}
onOk={handleUpdate}
okText={t('更新配置')}
cancelText={t('取消')}
confirmLoading={loading}
width={700}
className="update-config-modal"
>
<div className="space-y-4 max-h-[600px] overflow-y-auto">
{/* Container Info */}
<Card className="border-0 bg-gray-50">
<div className="flex items-center justify-between">
<div>
<Text strong className="text-base">
{deployment?.container_name}
</Text>
<div className="mt-1">
<Text type="secondary" size="small">
ID: {deployment?.id}
</Text>
</div>
</div>
<Tag color="blue">{deployment?.status}</Tag>
</div>
</Card>
{/* Warning Banner */}
<Banner
type="warning"
icon={<FaExclamationTriangle />}
title={t('重要提醒')}
description={
<div className="space-y-2">
<p>{t('更新容器配置可能会导致容器重启,请确保在合适的时间进行此操作。')}</p>
<p>{t('某些配置更改可能需要几分钟才能生效。')}</p>
</div>
}
/>
<Form
getFormApi={(api) => (formRef.current = api)}
layout="vertical"
>
<Collapse defaultActiveKey={['docker']}>
{/* Docker Configuration */}
<Collapse.Panel
header={
<div className="flex items-center gap-2">
<FaDocker className="text-blue-600" />
<span>{t('Docker 配置')}</span>
</div>
}
itemKey="docker"
>
<div className="space-y-4">
<Form.Input
field="image_url"
label={t('镜像地址')}
placeholder={t('例如: nginx:latest')}
rules={[
{
type: 'string',
message: t('请输入有效的镜像地址')
}
]}
/>
<Form.Input
field="registry_username"
label={t('镜像仓库用户名')}
placeholder={t('如果镜像为私有,请填写用户名')}
/>
<Form.Input
field="registry_secret"
label={t('镜像仓库密码')}
mode="password"
placeholder={t('如果镜像为私有请填写密码或Token')}
/>
</div>
</Collapse.Panel>
{/* Network Configuration */}
<Collapse.Panel
header={
<div className="flex items-center gap-2">
<FaNetworkWired className="text-green-600" />
<span>{t('网络配置')}</span>
</div>
}
itemKey="network"
>
<Form.InputNumber
field="traffic_port"
label={t('流量端口')}
placeholder={t('容器对外暴露的端口')}
min={1}
max={65535}
style={{ width: '100%' }}
rules={[
{
type: 'number',
min: 1,
max: 65535,
message: t('端口号必须在1-65535之间')
}
]}
/>
</Collapse.Panel>
{/* Startup Configuration */}
<Collapse.Panel
header={
<div className="flex items-center gap-2">
<FaTerminal className="text-purple-600" />
<span>{t('启动配置')}</span>
</div>
}
itemKey="startup"
>
<div className="space-y-4">
<Form.Input
field="entrypoint"
label={t('启动命令 (Entrypoint)')}
placeholder={t('例如: /bin/bash -c "python app.py"')}
helpText={t('多个命令用空格分隔')}
/>
<Form.Input
field="command"
label={t('运行命令 (Command)')}
placeholder={t('容器启动后执行的命令')}
/>
</div>
</Collapse.Panel>
{/* Environment Variables */}
<Collapse.Panel
header={
<div className="flex items-center gap-2">
<FaKey className="text-orange-600" />
<span>{t('环境变量')}</span>
<Tag size="small">{envVars.length}</Tag>
</div>
}
itemKey="env"
>
<div className="space-y-4">
{/* Regular Environment Variables */}
<div>
<div className="flex items-center justify-between mb-3">
<Text strong>{t('普通环境变量')}</Text>
<Button
size="small"
icon={<FaPlus />}
onClick={addEnvVar}
theme="borderless"
type="primary"
>
{t('添加')}
</Button>
</div>
{envVars.map((envVar, index) => (
<div key={index} className="flex items-end gap-2 mb-2">
<Input
placeholder={t('变量名')}
value={envVar.key}
onChange={(value) => updateEnvVar(index, 'key', value)}
style={{ flex: 1 }}
/>
<Text>=</Text>
<Input
placeholder={t('变量值')}
value={envVar.value}
onChange={(value) => updateEnvVar(index, 'value', value)}
style={{ flex: 2 }}
/>
<Button
size="small"
icon={<FaMinus />}
onClick={() => removeEnvVar(index)}
theme="borderless"
type="danger"
/>
</div>
))}
{envVars.length === 0 && (
<div className="text-center text-gray-500 py-4 border-2 border-dashed border-gray-300 rounded-lg">
<Text type="secondary">{t('暂无环境变量')}</Text>
</div>
)}
</div>
<Divider />
{/* Secret Environment Variables */}
<div>
<div className="flex items-center justify-between mb-3">
<div className="flex items-center gap-2">
<Text strong>{t('机密环境变量')}</Text>
<Tag size="small" type="danger">
{t('加密存储')}
</Tag>
</div>
<Button
size="small"
icon={<FaPlus />}
onClick={addSecretEnvVar}
theme="borderless"
type="danger"
>
{t('添加')}
</Button>
</div>
{secretEnvVars.map((envVar, index) => (
<div key={index} className="flex items-end gap-2 mb-2">
<Input
placeholder={t('变量名')}
value={envVar.key}
onChange={(value) => updateSecretEnvVar(index, 'key', value)}
style={{ flex: 1 }}
/>
<Text>=</Text>
<Input
mode="password"
placeholder={t('变量值')}
value={envVar.value}
onChange={(value) => updateSecretEnvVar(index, 'value', value)}
style={{ flex: 2 }}
/>
<Button
size="small"
icon={<FaMinus />}
onClick={() => removeSecretEnvVar(index)}
theme="borderless"
type="danger"
/>
</div>
))}
{secretEnvVars.length === 0 && (
<div className="text-center text-gray-500 py-4 border-2 border-dashed border-red-200 rounded-lg bg-red-50">
<Text type="secondary">{t('暂无机密环境变量')}</Text>
</div>
)}
<Banner
type="info"
title={t('机密环境变量说明')}
description={t('机密环境变量将被加密存储适用于存储密码、API密钥等敏感信息。')}
size="small"
/>
</div>
</div>
</Collapse.Panel>
</Collapse>
</Form>
{/* Final Warning */}
<div className="bg-yellow-50 border border-yellow-200 rounded-lg p-3">
<div className="flex items-start gap-2">
<FaExclamationTriangle className="text-yellow-600 mt-0.5" />
<div>
<Text strong className="text-yellow-800">
{t('配置更新确认')}
</Text>
<div className="mt-1">
<Text size="small" className="text-yellow-700">
{t('更新配置后,容器可能需要重启以应用新的设置。请确保您了解这些更改的影响。')}
</Text>
</div>
</div>
</div>
</div>
</div>
</Modal>
);
};
export default UpdateConfigModal;

View File

@@ -0,0 +1,517 @@
/*
Copyright (C) 2025 QuantumNous
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import React, { useState, useEffect } from 'react';
import {
Modal,
Typography,
Card,
Tag,
Progress,
Descriptions,
Spin,
Empty,
Button,
Badge,
Tooltip,
} from '@douyinfe/semi-ui';
import {
FaInfoCircle,
FaServer,
FaClock,
FaMapMarkerAlt,
FaDocker,
FaMoneyBillWave,
FaChartLine,
FaCopy,
FaLink,
} from 'react-icons/fa';
import { IconRefresh } from '@douyinfe/semi-icons';
import { API, showError, showSuccess, timestamp2string } from '../../../../helpers';
const { Text, Title } = Typography;
const ViewDetailsModal = ({
visible,
onCancel,
deployment,
t
}) => {
const [details, setDetails] = useState(null);
const [loading, setLoading] = useState(false);
const [containers, setContainers] = useState([]);
const [containersLoading, setContainersLoading] = useState(false);
const fetchDetails = async () => {
if (!deployment?.id) return;
setLoading(true);
try {
const response = await API.get(`/api/deployments/${deployment.id}`);
if (response.data.success) {
setDetails(response.data.data);
}
} catch (error) {
showError(t('获取详情失败') + ': ' + (error.response?.data?.message || error.message));
} finally {
setLoading(false);
}
};
const fetchContainers = async () => {
if (!deployment?.id) return;
setContainersLoading(true);
try {
const response = await API.get(`/api/deployments/${deployment.id}/containers`);
if (response.data.success) {
setContainers(response.data.data?.containers || []);
}
} catch (error) {
showError(t('获取容器信息失败') + ': ' + (error.response?.data?.message || error.message));
} finally {
setContainersLoading(false);
}
};
useEffect(() => {
if (visible && deployment?.id) {
fetchDetails();
fetchContainers();
} else if (!visible) {
setDetails(null);
setContainers([]);
}
}, [visible, deployment?.id]);
const handleCopyId = () => {
navigator.clipboard.writeText(deployment?.id);
showSuccess(t('ID已复制到剪贴板'));
};
const handleRefresh = () => {
fetchDetails();
fetchContainers();
};
const getStatusConfig = (status) => {
const statusConfig = {
'running': { color: 'green', text: '运行中', icon: '🟢' },
'completed': { color: 'green', text: '已完成', icon: '✅' },
'deployment requested': { color: 'blue', text: '部署请求中', icon: '🔄' },
'termination requested': { color: 'orange', text: '终止请求中', icon: '⏸️' },
'destroyed': { color: 'red', text: '已销毁', icon: '🔴' },
'failed': { color: 'red', text: '失败', icon: '❌' }
};
return statusConfig[status] || { color: 'grey', text: status, icon: '❓' };
};
const statusConfig = getStatusConfig(deployment?.status);
return (
<Modal
title={
<div className="flex items-center gap-2">
<FaInfoCircle className="text-blue-500" />
<span>{t('容器详情')}</span>
</div>
}
visible={visible}
onCancel={onCancel}
footer={
<div className="flex justify-between">
<Button
icon={<IconRefresh />}
onClick={handleRefresh}
loading={loading || containersLoading}
theme="borderless"
>
{t('刷新')}
</Button>
<Button onClick={onCancel}>
{t('关闭')}
</Button>
</div>
}
width={800}
className="deployment-details-modal"
>
{loading && !details ? (
<div className="flex items-center justify-center py-12">
<Spin size="large" tip={t('加载详情中...')} />
</div>
) : details ? (
<div className="space-y-4 max-h-[600px] overflow-y-auto">
{/* Basic Info */}
<Card
title={
<div className="flex items-center gap-2">
<FaServer className="text-blue-500" />
<span>{t('基本信息')}</span>
</div>
}
className="border-0 shadow-sm"
>
<Descriptions data={[
{
key: t('容器名称'),
value: (
<div className="flex items-center gap-2">
<Text strong className="text-base">
{details.deployment_name || details.id}
</Text>
<Button
size="small"
theme="borderless"
icon={<FaCopy />}
onClick={handleCopyId}
className="opacity-70 hover:opacity-100"
/>
</div>
)
},
{
key: t('容器ID'),
value: (
<Text type="secondary" className="font-mono text-sm">
{details.id}
</Text>
)
},
{
key: t('状态'),
value: (
<div className="flex items-center gap-2">
<span>{statusConfig.icon}</span>
<Tag color={statusConfig.color}>
{t(statusConfig.text)}
</Tag>
</div>
)
},
{
key: t('创建时间'),
value: timestamp2string(details.created_at)
}
]} />
</Card>
{/* Hardware & Performance */}
<Card
title={
<div className="flex items-center gap-2">
<FaChartLine className="text-green-500" />
<span>{t('硬件与性能')}</span>
</div>
}
className="border-0 shadow-sm"
>
<div className="space-y-4">
<Descriptions data={[
{
key: t('硬件类型'),
value: (
<div className="flex items-center gap-2">
<Tag color="blue">{details.brand_name}</Tag>
<Text strong>{details.hardware_name}</Text>
</div>
)
},
{
key: t('GPU数量'),
value: (
<div className="flex items-center gap-2">
<Badge count={details.total_gpus} theme="solid" type="primary">
<FaServer className="text-purple-500" />
</Badge>
<Text>{t('总计')} {details.total_gpus} {t('个GPU')}</Text>
</div>
)
},
{
key: t('容器配置'),
value: (
<div className="space-y-1">
<div>{t('每容器GPU数')}: {details.gpus_per_container}</div>
<div>{t('容器总数')}: {details.total_containers}</div>
</div>
)
}
]} />
{/* Progress Bar */}
<div className="space-y-2">
<div className="flex items-center justify-between">
<Text strong>{t('完成进度')}</Text>
<Text>{details.completed_percent}%</Text>
</div>
<Progress
percent={details.completed_percent}
status={details.completed_percent === 100 ? 'success' : 'normal'}
strokeWidth={8}
showInfo={false}
/>
<div className="flex justify-between text-xs text-gray-500">
<span>{t('已服务')}: {details.compute_minutes_served} {t('分钟')}</span>
<span>{t('剩余')}: {details.compute_minutes_remaining} {t('分钟')}</span>
</div>
</div>
</div>
</Card>
{/* Container Configuration */}
{details.container_config && (
<Card
title={
<div className="flex items-center gap-2">
<FaDocker className="text-blue-600" />
<span>{t('容器配置')}</span>
</div>
}
className="border-0 shadow-sm"
>
<div className="space-y-3">
<Descriptions data={[
{
key: t('镜像地址'),
value: (
<Text className="font-mono text-sm break-all">
{details.container_config.image_url || 'N/A'}
</Text>
)
},
{
key: t('流量端口'),
value: details.container_config.traffic_port || 'N/A'
},
{
key: t('启动命令'),
value: (
<Text className="font-mono text-sm">
{details.container_config.entrypoint ?
details.container_config.entrypoint.join(' ') : 'N/A'
}
</Text>
)
}
]} />
{/* Environment Variables */}
{details.container_config.env_variables &&
Object.keys(details.container_config.env_variables).length > 0 && (
<div className="mt-4">
<Text strong className="block mb-2">{t('环境变量')}:</Text>
<div className="bg-gray-50 p-3 rounded-lg max-h-32 overflow-y-auto">
{Object.entries(details.container_config.env_variables).map(([key, value]) => (
<div key={key} className="flex gap-2 text-sm font-mono mb-1">
<span className="text-blue-600 font-medium">{key}=</span>
<span className="text-gray-700 break-all">{String(value)}</span>
</div>
))}
</div>
</div>
)}
</div>
</Card>
)}
{/* Containers List */}
<Card
title={
<div className="flex items-center gap-2">
<FaServer className="text-indigo-500" />
<span>{t('容器实例')}</span>
</div>
}
className="border-0 shadow-sm"
>
{containersLoading ? (
<div className="flex items-center justify-center py-6">
<Spin tip={t('加载容器信息中...')} />
</div>
) : containers.length === 0 ? (
<Empty description={t('暂无容器信息')} image={Empty.PRESENTED_IMAGE_SIMPLE} />
) : (
<div className="space-y-3">
{containers.map((ctr) => (
<Card
key={ctr.container_id}
className="bg-gray-50 border border-gray-100"
bodyStyle={{ padding: '12px 16px' }}
>
<div className="flex flex-wrap items-center justify-between gap-3">
<div className="flex flex-col gap-1">
<Text strong className="font-mono text-sm">
{ctr.container_id}
</Text>
<Text size="small" type="secondary">
{t('设备')} {ctr.device_id || '--'} · {t('状态')} {ctr.status || '--'}
</Text>
<Text size="small" type="secondary">
{t('创建时间')}: {ctr.created_at ? timestamp2string(ctr.created_at) : '--'}
</Text>
</div>
<div className="flex flex-col items-end gap-2">
<Tag color="blue" size="small">
{t('GPU/容器')}: {ctr.gpus_per_container ?? '--'}
</Tag>
{ctr.public_url && (
<Tooltip content={ctr.public_url}>
<Button
icon={<FaLink />}
size="small"
theme="light"
onClick={() => window.open(ctr.public_url, '_blank', 'noopener,noreferrer')}
>
{t('访问容器')}
</Button>
</Tooltip>
)}
</div>
</div>
{ctr.events && ctr.events.length > 0 && (
<div className="mt-3 bg-white rounded-md border border-gray-100 p-3">
<Text size="small" type="secondary" className="block mb-2">
{t('最近事件')}
</Text>
<div className="space-y-2 max-h-32 overflow-y-auto">
{ctr.events.map((event, index) => (
<div key={`${ctr.container_id}-${event.time}-${index}`} className="flex gap-3 text-xs font-mono">
<span className="text-gray-500 min-w-[140px]">
{event.time ? timestamp2string(event.time) : '--'}
</span>
<span className="text-gray-700 break-all flex-1">
{event.message || '--'}
</span>
</div>
))}
</div>
</div>
)}
</Card>
))}
</div>
)}
</Card>
{/* Location Information */}
{details.locations && details.locations.length > 0 && (
<Card
title={
<div className="flex items-center gap-2">
<FaMapMarkerAlt className="text-orange-500" />
<span>{t('部署位置')}</span>
</div>
}
className="border-0 shadow-sm"
>
<div className="flex flex-wrap gap-2">
{details.locations.map((location) => (
<Tag key={location.id} color="orange" size="large">
<div className="flex items-center gap-1">
<span>🌍</span>
<span>{location.name} ({location.iso2})</span>
</div>
</Tag>
))}
</div>
</Card>
)}
{/* Cost Information */}
<Card
title={
<div className="flex items-center gap-2">
<FaMoneyBillWave className="text-green-500" />
<span>{t('费用信息')}</span>
</div>
}
className="border-0 shadow-sm"
>
<div className="space-y-3">
<div className="flex items-center justify-between p-3 bg-green-50 rounded-lg">
<Text>{t('已支付金额')}</Text>
<Text strong className="text-lg text-green-600">
${details.amount_paid ? details.amount_paid.toFixed(2) : '0.00'} USDC
</Text>
</div>
<div className="grid grid-cols-2 gap-4 text-sm">
<div className="flex justify-between">
<Text type="secondary">{t('计费开始')}:</Text>
<Text>{details.started_at ? timestamp2string(details.started_at) : 'N/A'}</Text>
</div>
<div className="flex justify-between">
<Text type="secondary">{t('预计结束')}:</Text>
<Text>{details.finished_at ? timestamp2string(details.finished_at) : 'N/A'}</Text>
</div>
</div>
</div>
</Card>
{/* Time Information */}
<Card
title={
<div className="flex items-center gap-2">
<FaClock className="text-purple-500" />
<span>{t('时间信息')}</span>
</div>
}
className="border-0 shadow-sm"
>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="space-y-2">
<div className="flex items-center justify-between">
<Text type="secondary">{t('已运行时间')}:</Text>
<Text strong>
{Math.floor(details.compute_minutes_served / 60)}h {details.compute_minutes_served % 60}m
</Text>
</div>
<div className="flex items-center justify-between">
<Text type="secondary">{t('剩余时间')}:</Text>
<Text strong className="text-orange-600">
{Math.floor(details.compute_minutes_remaining / 60)}h {details.compute_minutes_remaining % 60}m
</Text>
</div>
</div>
<div className="space-y-2">
<div className="flex items-center justify-between">
<Text type="secondary">{t('创建时间')}:</Text>
<Text>{timestamp2string(details.created_at)}</Text>
</div>
<div className="flex items-center justify-between">
<Text type="secondary">{t('最后更新')}:</Text>
<Text>{timestamp2string(details.updated_at)}</Text>
</div>
</div>
</div>
</Card>
</div>
) : (
<Empty
image={Empty.PRESENTED_IMAGE_SIMPLE}
description={t('无法获取容器详情')}
/>
)}
</Modal>
);
};
export default ViewDetailsModal;

View File

@@ -0,0 +1,660 @@
/*
Copyright (C) 2025 QuantumNous
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import React, { useState, useEffect, useRef } from 'react';
import {
Modal,
Button,
Typography,
Select,
Input,
Space,
Spin,
Card,
Tag,
Empty,
Switch,
Divider,
Tooltip,
Radio,
} from '@douyinfe/semi-ui';
import {
FaCopy,
FaSearch,
FaClock,
FaTerminal,
FaServer,
FaInfoCircle,
FaLink,
} from 'react-icons/fa';
import { IconRefresh, IconDownload } from '@douyinfe/semi-icons';
import { API, showError, showSuccess, copy, timestamp2string } from '../../../../helpers';
const { Text } = Typography;
const ALL_CONTAINERS = '__all__';
const ViewLogsModal = ({
visible,
onCancel,
deployment,
t
}) => {
const [logLines, setLogLines] = useState([]);
const [loading, setLoading] = useState(false);
const [autoRefresh, setAutoRefresh] = useState(false);
const [searchTerm, setSearchTerm] = useState('');
const [following, setFollowing] = useState(false);
const [containers, setContainers] = useState([]);
const [containersLoading, setContainersLoading] = useState(false);
const [selectedContainerId, setSelectedContainerId] = useState(ALL_CONTAINERS);
const [containerDetails, setContainerDetails] = useState(null);
const [containerDetailsLoading, setContainerDetailsLoading] = useState(false);
const [streamFilter, setStreamFilter] = useState('stdout');
const [lastUpdatedAt, setLastUpdatedAt] = useState(null);
const logContainerRef = useRef(null);
const autoRefreshRef = useRef(null);
// Auto scroll to bottom when new logs arrive
const scrollToBottom = () => {
if (logContainerRef.current) {
logContainerRef.current.scrollTop = logContainerRef.current.scrollHeight;
}
};
const resolveStreamValue = (value) => {
if (typeof value === 'string') {
return value;
}
if (value && typeof value.value === 'string') {
return value.value;
}
if (value && value.target && typeof value.target.value === 'string') {
return value.target.value;
}
return '';
};
const handleStreamChange = (value) => {
const next = resolveStreamValue(value) || 'stdout';
setStreamFilter(next);
};
const fetchLogs = async (containerIdOverride = undefined) => {
if (!deployment?.id) return;
const containerId = typeof containerIdOverride === 'string' ? containerIdOverride : selectedContainerId;
if (!containerId || containerId === ALL_CONTAINERS) {
setLogLines([]);
setLastUpdatedAt(null);
setLoading(false);
return;
}
setLoading(true);
try {
const params = new URLSearchParams();
params.append('container_id', containerId);
const streamValue = resolveStreamValue(streamFilter) || 'stdout';
if (streamValue && streamValue !== 'all') {
params.append('stream', streamValue);
}
if (following) params.append('follow', 'true');
const response = await API.get(`/api/deployments/${deployment.id}/logs?${params}`);
if (response.data.success) {
const rawContent = typeof response.data.data === 'string' ? response.data.data : '';
const normalized = rawContent.replace(/\r\n?/g, '\n');
const lines = normalized ? normalized.split('\n') : [];
setLogLines(lines);
setLastUpdatedAt(new Date());
setTimeout(scrollToBottom, 100);
}
} catch (error) {
showError(t('获取日志失败') + ': ' + (error.response?.data?.message || error.message));
} finally {
setLoading(false);
}
};
const fetchContainers = async () => {
if (!deployment?.id) return;
setContainersLoading(true);
try {
const response = await API.get(`/api/deployments/${deployment.id}/containers`);
if (response.data.success) {
const list = response.data.data?.containers || [];
setContainers(list);
setSelectedContainerId((current) => {
if (current !== ALL_CONTAINERS && list.some(item => item.container_id === current)) {
return current;
}
return list.length > 0 ? list[0].container_id : ALL_CONTAINERS;
});
if (list.length === 0) {
setContainerDetails(null);
}
}
} catch (error) {
showError(t('获取容器列表失败') + ': ' + (error.response?.data?.message || error.message));
} finally {
setContainersLoading(false);
}
};
const fetchContainerDetails = async (containerId) => {
if (!deployment?.id || !containerId || containerId === ALL_CONTAINERS) {
setContainerDetails(null);
return;
}
setContainerDetailsLoading(true);
try {
const response = await API.get(`/api/deployments/${deployment.id}/containers/${containerId}`);
if (response.data.success) {
setContainerDetails(response.data.data || null);
}
} catch (error) {
showError(t('获取容器详情失败') + ': ' + (error.response?.data?.message || error.message));
} finally {
setContainerDetailsLoading(false);
}
};
const handleContainerChange = (value) => {
const newValue = value || ALL_CONTAINERS;
setSelectedContainerId(newValue);
setLogLines([]);
setLastUpdatedAt(null);
};
const refreshContainerDetails = () => {
if (selectedContainerId && selectedContainerId !== ALL_CONTAINERS) {
fetchContainerDetails(selectedContainerId);
}
};
const renderContainerStatusTag = (status) => {
if (!status) {
return (
<Tag color="grey" size="small">
{t('未知状态')}
</Tag>
);
}
const normalized = typeof status === 'string' ? status.trim().toLowerCase() : '';
const statusMap = {
running: { color: 'green', label: '运行中' },
pending: { color: 'orange', label: '准备中' },
deployed: { color: 'blue', label: '已部署' },
failed: { color: 'red', label: '失败' },
destroyed: { color: 'red', label: '已销毁' },
stopping: { color: 'orange', label: '停止中' },
terminated: { color: 'grey', label: '已终止' },
};
const config = statusMap[normalized] || { color: 'grey', label: status };
return (
<Tag color={config.color} size="small">
{t(config.label)}
</Tag>
);
};
const currentContainer = selectedContainerId !== ALL_CONTAINERS
? containers.find((ctr) => ctr.container_id === selectedContainerId)
: null;
const refreshLogs = () => {
if (selectedContainerId && selectedContainerId !== ALL_CONTAINERS) {
fetchContainerDetails(selectedContainerId);
}
fetchLogs();
};
const downloadLogs = () => {
const sourceLogs = filteredLogs.length > 0 ? filteredLogs : logLines;
if (sourceLogs.length === 0) {
showError(t('暂无日志可下载'));
return;
}
const logText = sourceLogs.join('\n');
const blob = new Blob([logText], { type: 'text/plain' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
const safeContainerId = selectedContainerId && selectedContainerId !== ALL_CONTAINERS
? selectedContainerId.replace(/[^a-zA-Z0-9_-]/g, '-')
: '';
const fileName = safeContainerId
? `deployment-${deployment.id}-container-${safeContainerId}-logs.txt`
: `deployment-${deployment.id}-logs.txt`;
a.download = fileName;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
showSuccess(t('日志已下载'));
};
const copyAllLogs = async () => {
const sourceLogs = filteredLogs.length > 0 ? filteredLogs : logLines;
if (sourceLogs.length === 0) {
showError(t('暂无日志可复制'));
return;
}
const logText = sourceLogs.join('\n');
const copied = await copy(logText);
if (copied) {
showSuccess(t('日志已复制到剪贴板'));
} else {
showError(t('复制失败,请手动选择文本复制'));
}
};
// Auto refresh functionality
useEffect(() => {
if (autoRefresh && visible) {
autoRefreshRef.current = setInterval(() => {
fetchLogs();
}, 5000);
} else {
if (autoRefreshRef.current) {
clearInterval(autoRefreshRef.current);
autoRefreshRef.current = null;
}
}
return () => {
if (autoRefreshRef.current) {
clearInterval(autoRefreshRef.current);
}
};
}, [autoRefresh, visible, selectedContainerId, streamFilter, following]);
useEffect(() => {
if (visible && deployment?.id) {
fetchContainers();
} else if (!visible) {
setContainers([]);
setSelectedContainerId(ALL_CONTAINERS);
setContainerDetails(null);
setStreamFilter('stdout');
setLogLines([]);
setLastUpdatedAt(null);
}
}, [visible, deployment?.id]);
useEffect(() => {
if (visible) {
setStreamFilter('stdout');
}
}, [selectedContainerId, visible]);
useEffect(() => {
if (visible && deployment?.id) {
fetchContainerDetails(selectedContainerId);
}
}, [visible, deployment?.id, selectedContainerId]);
// Initial load and cleanup
useEffect(() => {
if (visible && deployment?.id) {
fetchLogs();
}
return () => {
if (autoRefreshRef.current) {
clearInterval(autoRefreshRef.current);
}
};
}, [visible, deployment?.id, streamFilter, selectedContainerId, following]);
// Filter logs based on search term
const filteredLogs = logLines
.map((line) => line ?? '')
.filter((line) =>
!searchTerm || line.toLowerCase().includes(searchTerm.toLowerCase()),
);
const renderLogEntry = (line, index) => (
<div
key={`${index}-${line.slice(0, 20)}`}
className="py-1 px-3 hover:bg-gray-50 font-mono text-sm border-b border-gray-100 whitespace-pre-wrap break-words"
>
{line}
</div>
);
return (
<Modal
title={
<div className="flex items-center gap-2">
<FaTerminal className="text-blue-500" />
<span>{t('容器日志')}</span>
<Text type="secondary" size="small">
- {deployment?.container_name || deployment?.id}
</Text>
</div>
}
visible={visible}
onCancel={onCancel}
footer={null}
width={1000}
height={700}
className="logs-modal"
style={{ top: 20 }}
>
<div className="flex flex-col h-full max-h-[600px]">
{/* Controls */}
<Card className="mb-4 border-0 shadow-sm">
<div className="flex items-center justify-between flex-wrap gap-3">
<Space wrap>
<Select
prefix={<FaServer />}
placeholder={t('选择容器')}
value={selectedContainerId}
onChange={handleContainerChange}
style={{ width: 240 }}
size="small"
loading={containersLoading}
dropdownStyle={{ maxHeight: 320, overflowY: 'auto' }}
>
<Select.Option value={ALL_CONTAINERS}>
{t('全部容器')}
</Select.Option>
{containers.map((ctr) => (
<Select.Option key={ctr.container_id} value={ctr.container_id}>
<div className="flex flex-col">
<span className="font-mono text-xs">{ctr.container_id}</span>
<span className="text-xs text-gray-500">
{ctr.brand_name || 'IO.NET'}
{ctr.hardware ? ` · ${ctr.hardware}` : ''}
</span>
</div>
</Select.Option>
))}
</Select>
<Input
prefix={<FaSearch />}
placeholder={t('搜索日志内容')}
value={searchTerm}
onChange={setSearchTerm}
style={{ width: 200 }}
size="small"
/>
<Space align="center" className="ml-2">
<Text size="small" type="secondary">
{t('日志流')}
</Text>
<Radio.Group
type="button"
size="small"
value={streamFilter}
onChange={handleStreamChange}
>
<Radio value="stdout">STDOUT</Radio>
<Radio value="stderr">STDERR</Radio>
</Radio.Group>
</Space>
<div className="flex items-center gap-2">
<Switch
checked={autoRefresh}
onChange={setAutoRefresh}
size="small"
/>
<Text size="small">{t('自动刷新')}</Text>
</div>
<div className="flex items-center gap-2">
<Switch
checked={following}
onChange={setFollowing}
size="small"
/>
<Text size="small">{t('跟随日志')}</Text>
</div>
</Space>
<Space>
<Tooltip content={t('刷新日志')}>
<Button
icon={<IconRefresh />}
onClick={refreshLogs}
loading={loading}
size="small"
theme="borderless"
/>
</Tooltip>
<Tooltip content={t('复制日志')}>
<Button
icon={<FaCopy />}
onClick={copyAllLogs}
size="small"
theme="borderless"
disabled={logLines.length === 0}
/>
</Tooltip>
<Tooltip content={t('下载日志')}>
<Button
icon={<IconDownload />}
onClick={downloadLogs}
size="small"
theme="borderless"
disabled={logLines.length === 0}
/>
</Tooltip>
</Space>
</div>
{/* Status Info */}
<Divider margin="12px" />
<div className="flex items-center justify-between">
<Space size="large">
<Text size="small" type="secondary">
{t('共 {{count}} 条日志', { count: logLines.length })}
</Text>
{searchTerm && (
<Text size="small" type="secondary">
{t('(筛选后显示 {{count}} 条)', { count: filteredLogs.length })}
</Text>
)}
{autoRefresh && (
<Tag color="green" size="small">
<FaClock className="mr-1" />
{t('自动刷新中')}
</Tag>
)}
</Space>
<Text size="small" type="secondary">
{t('状态')}: {deployment?.status || 'unknown'}
</Text>
</div>
{selectedContainerId !== ALL_CONTAINERS && (
<>
<Divider margin="12px" />
<div className="flex flex-col gap-3">
<div className="flex items-center justify-between flex-wrap gap-2">
<Space>
<Tag color="blue" size="small">
{t('容器')}
</Tag>
<Text className="font-mono text-xs">
{selectedContainerId}
</Text>
{renderContainerStatusTag(containerDetails?.status || currentContainer?.status)}
</Space>
<Space>
{containerDetails?.public_url && (
<Tooltip content={containerDetails.public_url}>
<Button
icon={<FaLink />}
size="small"
theme="borderless"
onClick={() => window.open(containerDetails.public_url, '_blank')}
/>
</Tooltip>
)}
<Tooltip content={t('刷新容器信息')}>
<Button
icon={<IconRefresh />}
onClick={refreshContainerDetails}
size="small"
theme="borderless"
loading={containerDetailsLoading}
/>
</Tooltip>
</Space>
</div>
{containerDetailsLoading ? (
<div className="flex items-center justify-center py-6">
<Spin tip={t('加载容器详情中...')} />
</div>
) : containerDetails ? (
<div className="grid gap-4 md:grid-cols-2 text-sm">
<div className="flex items-center gap-2">
<FaInfoCircle className="text-blue-500" />
<Text type="secondary">{t('硬件')}</Text>
<Text>
{containerDetails?.brand_name || currentContainer?.brand_name || t('未知品牌')}
{(containerDetails?.hardware || currentContainer?.hardware) ? ` · ${containerDetails?.hardware || currentContainer?.hardware}` : ''}
</Text>
</div>
<div className="flex items-center gap-2">
<FaServer className="text-purple-500" />
<Text type="secondary">{t('GPU/容器')}</Text>
<Text>{containerDetails?.gpus_per_container ?? currentContainer?.gpus_per_container ?? 0}</Text>
</div>
<div className="flex items-center gap-2">
<FaClock className="text-orange-500" />
<Text type="secondary">{t('创建时间')}</Text>
<Text>
{containerDetails?.created_at
? timestamp2string(containerDetails.created_at)
: currentContainer?.created_at
? timestamp2string(currentContainer.created_at)
: t('未知')}
</Text>
</div>
<div className="flex items-center gap-2">
<FaInfoCircle className="text-green-500" />
<Text type="secondary">{t('运行时长')}</Text>
<Text>{containerDetails?.uptime_percent ?? currentContainer?.uptime_percent ?? 0}%</Text>
</div>
</div>
) : (
<Text size="small" type="secondary">
{t('暂无容器详情')}
</Text>
)}
{containerDetails?.events && containerDetails.events.length > 0 && (
<div className="bg-gray-50 rounded-lg p-3">
<Text size="small" type="secondary">
{t('最近事件')}
</Text>
<div className="mt-2 space-y-2 max-h-32 overflow-y-auto">
{containerDetails.events.slice(0, 5).map((event, index) => (
<div key={`${event.time}-${index}`} className="flex gap-3 text-xs font-mono">
<span className="text-gray-500">
{event.time ? timestamp2string(event.time) : '--'}
</span>
<span className="text-gray-700 break-all flex-1">
{event.message}
</span>
</div>
))}
</div>
</div>
)}
</div>
</>
)}
</Card>
{/* Log Content */}
<div className="flex-1 flex flex-col border rounded-lg bg-gray-50 overflow-hidden">
<div
ref={logContainerRef}
className="flex-1 overflow-y-auto bg-white"
style={{ maxHeight: '400px' }}
>
{loading && logLines.length === 0 ? (
<div className="flex items-center justify-center p-8">
<Spin tip={t('加载日志中...')} />
</div>
) : filteredLogs.length === 0 ? (
<Empty
image={Empty.PRESENTED_IMAGE_SIMPLE}
description={
searchTerm ? t('没有匹配的日志条目') : t('暂无日志')
}
style={{ padding: '60px 20px' }}
/>
) : (
<div>
{filteredLogs.map((log, index) => renderLogEntry(log, index))}
</div>
)}
</div>
{/* Footer status */}
{logLines.length > 0 && (
<div className="flex items-center justify-between px-3 py-2 bg-gray-50 border-t text-xs text-gray-500">
<span>
{following ? t('正在跟随最新日志') : t('日志已加载')}
</span>
<span>
{t('最后更新')}: {lastUpdatedAt ? lastUpdatedAt.toLocaleTimeString() : '--'}
</span>
</div>
)}
</div>
</div>
</Modal>
);
};
export default ViewLogsModal;