fix: ssh key, template test

This commit is contained in:
jialin
2026-05-11 19:49:21 +08:00
committed by jialin
parent 63ac4e0e75
commit fc900c8ce6
45 changed files with 1027 additions and 1090 deletions
+1 -1
View File
@@ -356,7 +356,7 @@ export default (props: any) => {
config={{
apiBaseUrl: GPUSTACK_API_BASE_URL,
theme: userSettings.theme,
iconUrl: '//at.alicdn.com/t/c/font_4613488_tzcsatubq4f.js',
iconUrl: '//at.alicdn.com/t/c/font_4613488_nujs54seb6.js',
isDarkTheme: userSettings.isDarkTheme,
defaultColorPrimary: COLOR_PRIMARY
}}
+35
View File
@@ -0,0 +1,35 @@
export default {
'gpuservice.template': 'GPU Instance Template',
'gpuservice.template.add': 'Add Instance Template',
'gpuservice.template.edit': 'Edit Instance Template',
'gpuservice.template.filter.name': 'Filter by name',
'gpuservice.template.filter.vendor': 'Filter by vendor',
'gpuservice.template.image': 'Image',
'gpuservice.template.imagePullPolicy': 'Image Pull Policy',
'gpuservice.template.imagePullPolicy.always': 'Always',
'gpuservice.template.imagePullPolicy.ifNotPresent': 'If Not Present',
'gpuservice.template.imagePullPolicy.never': 'Never',
'gpuservice.template.command': 'Container Startup Command',
'gpuservice.template.command.placeholder':
'One argument per line, e.g.:\n/bin/bash\n-c\nyour command',
'gpuservice.template.mountPath': 'Mount Path',
'gpuservice.template.containerDisk': 'Container Disk (Gi)',
'gpuservice.template.memory': 'Memory (Gi)',
'gpuservice.template.ports': 'Ports',
'gpuservice.template.ports.add': 'Add Port',
'gpuservice.template.ports.invalid':
'Please complete the port configuration.',
'gpuservice.template.env': 'Environment Variables',
'gpuservice.template.env.add': 'Add Environment Variable',
'gpuservice.template.env.invalid':
'Please complete the environment variables.',
'gpuservice.template.env.name': 'Name',
'gpuservice.template.env.value': 'Value',
'gpuservice.template.card.image': 'Image',
'gpuservice.template.card.mount': 'Mount',
'gpuservice.template.card.resources': 'Resources',
'gpuservice.template.card.ports': 'Ports',
'gpuservice.publicKey.label': 'SSH Public Key',
'gpuservice.publicKey.placeholder':
'Begin with ssh-rsa or ssh-ed25519. One Public Key per line.\n\nView Public Key:\n- RSA\ncat ~/.ssh/id_rsa.pub\n- Ed25519\ncat ~/.ssh/id_ed25519.pub'
};
+6 -1
View File
@@ -52,5 +52,10 @@ export default {
'noresult.instances.title': 'No instances',
'noresult.instances.subTitle':
'There are no running model instances. Deploy a model to create one automatically.',
'noresult.instances.nofound': 'No matching instances found.'
'noresult.instances.nofound': 'No matching instances found.',
'noresult.gpuservice.template.title': 'No Instance Templates',
'noresult.gpuservice.template.subTitle':
'Once you create an instance template, it will appear here.',
'noresult.gpuservice.template.nofound':
'No matching instance templates found.'
};
+33
View File
@@ -0,0 +1,33 @@
export default {
'gpuservice.template': 'GPU インスタンステンプレート',
'gpuservice.template.add': 'インスタンステンプレートを追加',
'gpuservice.template.edit': 'インスタンステンプレートを編集',
'gpuservice.template.filter.name': '名前でフィルター',
'gpuservice.template.filter.vendor': 'ベンダーでフィルター',
'gpuservice.template.image': 'コンテナイメージ',
'gpuservice.template.imagePullPolicy': 'イメージ取得ポリシー',
'gpuservice.template.imagePullPolicy.always': '常に取得',
'gpuservice.template.imagePullPolicy.ifNotPresent': '存在しない場合に取得',
'gpuservice.template.imagePullPolicy.never': '取得しない',
'gpuservice.template.command': 'コンテナ起動コマンド',
'gpuservice.template.command.placeholder':
'1行に1つの引数、例:\n/bin/bash\n-c\nyour command',
'gpuservice.template.mountPath': 'マウントパス',
'gpuservice.template.containerDisk': 'コンテナディスク (Gi)',
'gpuservice.template.memory': 'メモリ (Gi)',
'gpuservice.template.ports': 'ポート',
'gpuservice.template.ports.add': 'ポートを追加',
'gpuservice.template.ports.invalid': 'ポート設定を完成させてください。',
'gpuservice.template.env': '環境変数',
'gpuservice.template.env.add': '環境変数を追加',
'gpuservice.template.env.invalid': '環境変数を完成させてください。',
'gpuservice.template.env.name': '名前',
'gpuservice.template.env.value': '値',
'gpuservice.template.card.image': 'イメージ',
'gpuservice.template.card.mount': 'マウント',
'gpuservice.template.card.resources': 'リソース',
'gpuservice.template.card.ports': 'ポート',
'gpuservice.publicKey.label': 'SSH 公開鍵',
'gpuservice.publicKey.placeholder':
'ssh-rsa または ssh-ed25519 で始まり、各公開鍵は1行ずつ記述します\n\n公開鍵を確認:\n- RSA\ncat ~/.ssh/id_rsa.pub\n- Ed25519\ncat ~/.ssh/id_ed25519.pub'
};
+6 -1
View File
@@ -52,5 +52,10 @@ export default {
'noresult.instances.title': 'No instances',
'noresult.instances.subTitle':
'There are no running model instances. Deploy a model to create one automatically.',
'noresult.instances.nofound': 'No matching instances found.'
'noresult.instances.nofound': 'No matching instances found.',
'noresult.gpuservice.template.title': 'インスタンステンプレートなし',
'noresult.gpuservice.template.subTitle':
'インスタンステンプレートを作成すると、ここに表示されます。',
'noresult.gpuservice.template.nofound':
'一致するインスタンステンプレートが見つかりません。'
};
+35
View File
@@ -0,0 +1,35 @@
export default {
'gpuservice.template': 'Шаблон экземпляра GPU',
'gpuservice.template.add': 'Добавить шаблон экземпляра',
'gpuservice.template.edit': 'Редактировать шаблон экземпляра',
'gpuservice.template.filter.name': 'Фильтр по имени',
'gpuservice.template.filter.vendor': 'Фильтр по производителю',
'gpuservice.template.image': 'Образ',
'gpuservice.template.imagePullPolicy': 'Политика загрузки образа',
'gpuservice.template.imagePullPolicy.always': 'Всегда загружать',
'gpuservice.template.imagePullPolicy.ifNotPresent':
'Загружать при отсутствии',
'gpuservice.template.imagePullPolicy.never': 'Никогда не загружать',
'gpuservice.template.command': 'Команда запуска контейнера',
'gpuservice.template.command.placeholder':
'Один аргумент на строку, например:\n/bin/bash\n-c\nyour command',
'gpuservice.template.mountPath': 'Путь монтирования',
'gpuservice.template.containerDisk': 'Диск контейнера (Gi)',
'gpuservice.template.memory': 'Память (Gi)',
'gpuservice.template.ports': 'Порты',
'gpuservice.template.ports.add': 'Добавить порт',
'gpuservice.template.ports.invalid': 'Заполните настройки портов полностью.',
'gpuservice.template.env': 'Переменные окружения',
'gpuservice.template.env.add': 'Добавить переменную окружения',
'gpuservice.template.env.invalid':
'Заполните переменные окружения полностью.',
'gpuservice.template.env.name': 'Имя',
'gpuservice.template.env.value': 'Значение',
'gpuservice.template.card.image': 'Образ',
'gpuservice.template.card.mount': 'Монтирование',
'gpuservice.template.card.resources': 'Ресурсы',
'gpuservice.template.card.ports': 'Порты',
'gpuservice.publicKey.label': 'Открытый ключ SSH',
'gpuservice.publicKey.placeholder':
'Начинается с ssh-rsa или ssh-ed25519, по одному открытому ключу на строку\n\nПросмотр открытого ключа:\n- RSA\ncat ~/.ssh/id_rsa.pub\n- Ed25519\ncat ~/.ssh/id_ed25519.pub'
};
+6 -1
View File
@@ -53,7 +53,12 @@ export default {
'noresult.instances.title': 'No instances',
'noresult.instances.subTitle':
'There are no running model instances. Deploy a model to create one automatically.',
'noresult.instances.nofound': 'No matching instances found.'
'noresult.instances.nofound': 'No matching instances found.',
'noresult.gpuservice.template.title': 'Нет шаблонов экземпляров',
'noresult.gpuservice.template.subTitle':
'После создания шаблона экземпляра он появится здесь.',
'noresult.gpuservice.template.nofound':
'Подходящие шаблоны экземпляров не найдены.'
};
// ========== To-Do: Translate Keys (Remove After Translation) ==========
+34
View File
@@ -0,0 +1,34 @@
export default {
'gpuservice.template': 'GPU Örnek Şablonu',
'gpuservice.template.add': 'Örnek Şablonu Ekle',
'gpuservice.template.edit': 'Örnek Şablonunu Düzenle',
'gpuservice.template.filter.name': 'Ada göre filtrele',
'gpuservice.template.filter.vendor': 'Tedarikçiye göre filtrele',
'gpuservice.template.image': 'İmaj',
'gpuservice.template.imagePullPolicy': 'İmaj Çekme Politikası',
'gpuservice.template.imagePullPolicy.always': 'Her zaman çek',
'gpuservice.template.imagePullPolicy.ifNotPresent': 'Yoksa çek',
'gpuservice.template.imagePullPolicy.never': 'Asla çekme',
'gpuservice.template.command': 'Konteyner Başlatma Komutu',
'gpuservice.template.command.placeholder':
'Her satırda bir argüman, örn.:\n/bin/bash\n-c\nyour command',
'gpuservice.template.mountPath': 'Bağlama Yolu',
'gpuservice.template.containerDisk': 'Konteyner Diski (Gi)',
'gpuservice.template.memory': 'Bellek (Gi)',
'gpuservice.template.ports': 'Bağlantı Noktaları',
'gpuservice.template.ports.add': 'Bağlantı Noktası Ekle',
'gpuservice.template.ports.invalid':
'Bağlantı noktası yapılandırmasını eksiksiz doldurun.',
'gpuservice.template.env': 'Ortam Değişkenleri',
'gpuservice.template.env.add': 'Ortam Değişkeni Ekle',
'gpuservice.template.env.invalid': 'Ortam değişkenlerini eksiksiz doldurun.',
'gpuservice.template.env.name': 'Ad',
'gpuservice.template.env.value': 'Değer',
'gpuservice.template.card.image': 'İmaj',
'gpuservice.template.card.mount': 'Bağlama',
'gpuservice.template.card.resources': 'Kaynaklar',
'gpuservice.template.card.ports': 'Bağlantı Noktaları',
'gpuservice.publicKey.label': 'SSH Açık Anahtarı',
'gpuservice.publicKey.placeholder':
'ssh-rsa veya ssh-ed25519 ile başlar, her açık anahtar ayrı bir satırda\n\nAçık anahtarı görüntüle:\n- RSA\ncat ~/.ssh/id_rsa.pub\n- Ed25519\ncat ~/.ssh/id_ed25519.pub'
};
+5 -1
View File
@@ -54,7 +54,11 @@ export default {
'noresult.instances.title': 'No instances',
'noresult.instances.subTitle':
'There are no running model instances. Deploy a model to create one automatically.',
'noresult.instances.nofound': 'No matching instances found.'
'noresult.instances.nofound': 'No matching instances found.',
'noresult.gpuservice.template.title': 'Örnek Şablonu Yok',
'noresult.gpuservice.template.subTitle':
'Bir örnek şablonu oluşturduğunuzda burada görünecek.',
'noresult.gpuservice.template.nofound': 'Eşleşen örnek şablonu bulunamadı.'
};
// ========== To-Do: Translate Keys (Remove After Translation) ==========
+33
View File
@@ -0,0 +1,33 @@
export default {
'gpuservice.template': 'GPU 实例模板',
'gpuservice.template.add': '添加实例模板',
'gpuservice.template.edit': '编辑实例模板',
'gpuservice.template.filter.name': '按名称过滤',
'gpuservice.template.filter.vendor': '按厂商过滤',
'gpuservice.template.image': '镜像',
'gpuservice.template.imagePullPolicy': '镜像拉取策略',
'gpuservice.template.imagePullPolicy.always': '总是拉取',
'gpuservice.template.imagePullPolicy.ifNotPresent': '不存在时拉取',
'gpuservice.template.imagePullPolicy.never': '从不拉取',
'gpuservice.template.command': '容器启动命令',
'gpuservice.template.command.placeholder':
'每行一个参数,例如:\n/bin/bash\n-c\nyour command',
'gpuservice.template.mountPath': '挂载路径',
'gpuservice.template.containerDisk': '容器磁盘(Gi)',
'gpuservice.template.memory': '内存(Gi)',
'gpuservice.template.ports': '端口',
'gpuservice.template.ports.add': '添加端口',
'gpuservice.template.ports.invalid': '请填写完整的端口配置',
'gpuservice.template.env': '环境变量',
'gpuservice.template.env.add': '添加环境变量',
'gpuservice.template.env.invalid': '请填写完整的环境变量',
'gpuservice.template.env.name': '名称',
'gpuservice.template.env.value': '值',
'gpuservice.template.card.image': '镜像',
'gpuservice.template.card.mount': '挂载',
'gpuservice.template.card.resources': '资源',
'gpuservice.template.card.ports': '端口',
'gpuservice.publicKey.label': 'SSH 公钥',
'gpuservice.publicKey.placeholder':
'以 ssh-rsa 或 ssh-ed25519 开头,每个 Public Key 单独一行\n\n查看 Public Key\n- RSA\ncat ~/.ssh/id_rsa.pub\n- Ed25519\ncat ~/.ssh/id_ed25519.pub'
};
+4 -1
View File
@@ -50,5 +50,8 @@ export default {
'noresult.instances.title': '暂无实例',
'noresult.instances.subTitle':
'当前没有运行中的模型实例,部署模型后将自动创建。',
'noresult.instances.nofound': '未找到匹配的实例'
'noresult.instances.nofound': '未找到匹配的实例',
'noresult.gpuservice.template.title': '暂无实例模板',
'noresult.gpuservice.template.subTitle': '创建一个实例模板后会显示在这里',
'noresult.gpuservice.template.nofound': '未找到匹配的实例模板'
};
@@ -1,3 +1,18 @@
.mask {
position: fixed;
inset: 0;
z-index: 1004;
height: 100%;
background-color: var(--ant-color-bg-mask);
opacity: 0;
transition: opacity var(--ant-motion-duration-slow)
var(--ant-motion-ease-in-out);
}
.maskOpen {
opacity: 1;
}
.overlay {
position: fixed;
top: 0;
@@ -15,6 +30,13 @@
-6px 0 16px 0 rgb(0 0 0 / 8%),
-3px 0 6px -4px rgb(0 0 0 / 12%),
-9px 0 28px 8px rgb(0 0 0 / 5%);
transform: translateX(100%);
transition: transform var(--ant-motion-duration-slow)
var(--ant-motion-ease-in-out);
}
.overlayOpen {
transform: translateX(0);
}
.header {
+70 -38
View File
@@ -1,5 +1,6 @@
import { ColumnWrapper, IconFont } from '@gpustack/core-ui';
import { Button, Tag } from 'antd';
import classNames from 'classnames';
import React from 'react';
import { createPortal } from 'react-dom';
import styles from './form-overlay-view.module.less';
@@ -15,6 +16,7 @@ type FormOverlayViewProps = {
width?: number | string;
className?: string;
style?: React.CSSProperties;
maskClosable?: boolean;
getContainer?: () => HTMLElement | null | undefined;
};
@@ -29,60 +31,90 @@ const FormOverlayView: React.FC<FormOverlayViewProps> = ({
width = 600,
className,
style,
maskClosable = false,
getContainer
}) => {
const [container, setContainer] = React.useState<HTMLElement | null>(null);
const [mounted, setMounted] = React.useState(false);
const [active, setActive] = React.useState(false);
React.useEffect(() => {
if (!open) {
setContainer(null);
if (open) {
setContainer(getContainer?.() ?? null);
setMounted(true);
return;
}
setContainer(getContainer?.() ?? null);
setActive(false);
}, [getContainer, open]);
if (!open) {
return null;
}
React.useEffect(() => {
if (!mounted || !container) {
return;
}
if (!container) {
const id = requestAnimationFrame(() => setActive(true));
return () => cancelAnimationFrame(id);
}, [mounted, container]);
const handleTransitionEnd = (e: React.TransitionEvent<HTMLDivElement>) => {
if (e.target !== e.currentTarget || e.propertyName !== 'transform') {
return;
}
if (!open && !active) {
setMounted(false);
setContainer(null);
}
};
if (!mounted || !container) {
return null;
}
return createPortal(
<div
className={[styles.overlay, className].filter(Boolean).join(' ')}
style={{ width, ...style }}
role="dialog"
aria-modal="true"
>
<div className={styles.header}>
<Button
type="text"
size="small"
style={{ fontWeight: 600, fontSize: 16 }}
icon={<IconFont type="icon-down2" rotate={90} />}
onClick={onCancel}
/>
<div className={styles.title}>
{title}
{subTitle && (
<Tag variant="outlined" className={styles.subTitle}>
{subTitle}
</Tag>
)}
</div>
</div>
<ColumnWrapper
styles={{
container: { paddingBlock: 16 }
}}
footer={footer}
<>
<div
className={classNames(styles.mask, { [styles.maskOpen]: active })}
onClick={maskClosable ? onCancel : undefined}
/>
<div
className={classNames(
styles.overlay,
{ [styles.overlayOpen]: active },
className
)}
style={{ width, ...style }}
role="dialog"
aria-modal="true"
onTransitionEnd={handleTransitionEnd}
>
{children}
</ColumnWrapper>
</div>,
<div className={styles.header}>
<Button
type="text"
size="small"
style={{ fontWeight: 600, fontSize: 16 }}
icon={<IconFont type="icon-down2" rotate={90} />}
onClick={onCancel}
/>
<div className={styles.title}>
{title}
{subTitle && (
<Tag variant="outlined" className={styles.subTitle}>
{subTitle}
</Tag>
)}
</div>
</div>
<ColumnWrapper
styles={{
container: { paddingBlock: 16 }
}}
footer={footer}
>
{children}
</ColumnWrapper>
</div>
</>,
container
);
};
@@ -6,6 +6,7 @@
justify-content: center;
top: 0;
left: 0;
min-height: 160px;
max-height: 400px;
right: 0;
@@ -5,7 +5,8 @@ import { ColumnWrapper, GSDrawer, ModalFooter } from '@gpustack/core-ui';
import { Empty, Input, Typography } from 'antd';
import { useEffect, useMemo, useRef, useState } from 'react';
import styled from 'styled-components';
import { mockTemplateData } from '../../templates/config/mock-data';
import useQueryTemplates from '../../templates/services/use-query-templates';
import { ListItem as TemplateItem } from '../../templates/config/types';
import { FormData, InstanceTypeItem, ListItem } from '../config/types';
import GPUServiceInstanceForm from '../forms';
import TemplateSelector from '../forms/template-selector';
@@ -64,14 +65,18 @@ const AddModal: React.FC<AddModalProps> = ({
const [templateKeyword, setTemplateKeyword] = useState('');
const { detailData, fetchData } = useQueryInstanceTypes();
const { detailData: templatesData, fetchData: fetchTemplates } =
useQueryTemplates();
useEffect(() => {
if (open) {
fetchData({});
fetchTemplates({ page: 1, perPage: 100 });
}
}, [open, fetchData]);
}, [open, fetchData, fetchTemplates]);
const instanceTypeList = detailData?.items || [];
const templateList = templatesData?.items || [];
const filteredInstanceTypes = useMemo(() => {
const keyword = instanceKeyword.trim().toLowerCase();
@@ -93,14 +98,14 @@ const AddModal: React.FC<AddModalProps> = ({
const filteredTemplates = useMemo(() => {
const keyword = templateKeyword.trim().toLowerCase();
if (!keyword) {
return mockTemplateData;
return templateList;
}
return mockTemplateData.filter((item) =>
[item.name, item.image, item.volumeMount].some((text) =>
return templateList.filter((item) =>
[item.name, item.spec?.image, item.spec?.volumeMount].some((text) =>
(text || '').toLowerCase().includes(keyword)
)
);
}, [templateKeyword]);
}, [templateKeyword, templateList]);
const handleSubmit = () => {
form.current?.submit();
@@ -118,7 +123,30 @@ const AddModal: React.FC<AddModalProps> = ({
};
const handleInstanceTypeChange = (item: InstanceTypeItem) => {
setInstanceTypeName(item.metadata?.name || item.name);
const name = item.metadata?.name || item.name;
setInstanceTypeName(name);
form.current?.setFieldsValue({
type: name,
spec: { resources: { accelerator: '1' } }
});
};
const handleTemplateChange = (id: number, item: TemplateItem) => {
setTemplateId(id);
form.current?.setFieldsValue({
spec: {
image: item.spec?.image,
imagePullPolicy: item.spec?.imagePullPolicy,
command: item.spec?.command || [],
ports: item.spec?.ports || [],
env: item.spec?.env || [],
volumeMount: item.spec?.volumeMount,
resources: {
cpu: item.spec?.resources?.cpu,
ram: item.spec?.resources?.ram
}
}
});
};
return (
@@ -194,7 +222,7 @@ const AddModal: React.FC<AddModalProps> = ({
<TemplateSelector
value={templateId}
dataList={filteredTemplates}
onChange={setTemplateId}
onChange={handleTemplateChange}
/>
) : (
<Empty image={Empty.PRESENTED_IMAGE_SIMPLE} />
@@ -1,95 +0,0 @@
import { InstanceStatusValueMap } from '.';
import { ListItem } from './types';
export const mockInstanceData: (ListItem & { cluster_id: number })[] = [
{
id: 1,
cluster_id: 1,
metadata: {
name: 'cuda-dev-01',
namespace: 'default'
},
spec: {
type: 'nvidia-l4-small',
image: 'nvidia/cuda:12.4.1-devel-ubuntu22.04',
displayName: 'CUDA Dev 01',
command: ['/bin/bash'],
ports: [{ protocol: 'tcp', port: 22 }],
env: [],
volumeMount: '/workspace',
resources: {
cpu: '4',
ram: '16Gi',
accelerator: '1'
},
description: 'CUDA development workspace',
volume: {
persistent: { name: 'local-nvme-cache' }
},
sshPublicKey: { name: 'default' }
},
status: InstanceStatusValueMap.Ready,
created_at: '2026-04-01T10:00:00Z',
updated_at: '2026-04-10T10:00:00Z'
},
{
id: 2,
cluster_id: 1,
metadata: {
name: 'training-job-a100',
namespace: 'default'
},
spec: {
type: 'nvidia-a100-training',
image: 'pytorch/pytorch:2.5.1-cuda12.4-cudnn9-devel',
displayName: 'Training A100',
command: ['python', 'train.py'],
ports: [],
env: [{ name: 'PYTHONUNBUFFERED', value: '1' }],
volumeMount: '/data',
resources: {
cpu: '32',
ram: '128Gi',
accelerator: '4'
},
description: 'PyTorch training environment',
volume: {
persistent: { name: 'shared-model-store' }
},
sshPublicKey: { name: '' }
},
status: InstanceStatusValueMap.Pending,
created_at: '2026-04-02T10:00:00Z',
updated_at: '2026-04-11T10:00:00Z'
},
{
id: 3,
cluster_id: 1,
metadata: {
name: 'inference-vllm',
namespace: 'default'
},
spec: {
type: 'nvidia-h100-inference',
image: 'vllm/vllm-openai:latest',
displayName: 'Inference vLLM',
command: ['python', '-m', 'vllm.entrypoints.openai.api_server'],
ports: [{ protocol: 'tcp', port: 8080 }],
env: [{ name: 'VLLM_WORKER_MULTIPROC_METHOD', value: 'spawn' }],
volumeMount: '/models',
resources: {
cpu: '16',
ram: '64Gi',
accelerator: '2'
},
description: 'OpenAI-compatible inference service',
volume: {
ephemeral: { capacity: '100Gi' }
},
sshPublicKey: { name: '' }
},
status: InstanceStatusValueMap.Error,
created_at: '2026-04-03T10:00:00Z',
updated_at: '2026-04-12T10:00:00Z'
}
];
@@ -35,6 +35,7 @@ export interface FormData {
spec: {
type: string;
image: string;
imagePullPolicy: ImagePullPolicy;
displayName: string;
command: string[];
ports: InstancePort[];
+31 -63
View File
@@ -18,15 +18,10 @@ import {
useMemo,
useRef
} from 'react';
import { mockStorageData } from '../../storage/config/mock-data';
import { mockTemplateData } from '../../templates/config/mock-data';
import {
EnvItem as TemplateEnvItem,
PortItem as TemplatePortItem
} from '../../templates/config/types';
import { DefaultImagePullPolicy } from '../../templates/config';
import TemplateBasicForm from '../../templates/forms/basic';
import { DEFAULT_SSH_PUBLIC_KEY_NAME, StorageModeValueMap } from '../config';
import { FormData, ListItem } from '../config/types';
import { FormData, InstancePort, ListItem } from '../config/types';
import Basic from './basic';
import InstanceTypeFormItem from './instance-type';
import StorageVolume from './storage-volume';
@@ -34,16 +29,6 @@ import StorageVolume from './storage-volume';
const SSH_PORT = 22;
type InstanceFormValues = FormData & {
// instance-type holders
type?: string;
gpu_count?: number;
// template holders (mirrored into spec.* on submit)
image?: string;
command?: string[];
ports?: TemplatePortItem[];
env?: TemplateEnvItem[];
volumeMount?: string;
resources?: { cpu?: string; ram?: string };
// storage holders
storage_mode?: string;
storage_name?: string;
@@ -75,7 +60,7 @@ const requiredFields = {
},
[TABKeysMap.INSTANCE_TYPE]: {
sort: 2,
fields: ['type', 'gpu_count']
fields: ['type']
},
[TABKeysMap.TEMPLATE]: {
sort: 3,
@@ -144,7 +129,8 @@ const GPUServiceInstanceForm: React.FC<InstanceFormProps> = forwardRef(
[]
);
const ports = (Form.useWatch('ports', form) || []) as TemplatePortItem[];
const ports = (Form.useWatch(['spec', 'ports'], form) ||
[]) as InstancePort[];
const hasSshPort = useMemo(
() => ports.some((p) => Number(p?.port) === SSH_PORT),
[ports]
@@ -180,20 +166,10 @@ const GPUServiceInstanceForm: React.FC<InstanceFormProps> = forwardRef(
name: currentData.metadata?.name,
namespace: currentData.metadata?.namespace
},
spec: { ...currentData.spec },
type: currentData.spec?.type,
gpu_count: currentData.spec?.resources?.accelerator
? Number(currentData.spec.resources.accelerator) || 1
: 1,
// template holders mirrored from spec.*
image: currentData.spec?.image,
command: currentData.spec?.command || [],
ports: (currentData.spec?.ports || []) as TemplatePortItem[],
env: (currentData.spec?.env || []) as TemplateEnvItem[],
volumeMount: currentData.spec?.volumeMount,
resources: {
cpu: currentData.spec?.resources?.cpu,
ram: currentData.spec?.resources?.ram
spec: {
...currentData.spec,
imagePullPolicy:
currentData.spec?.imagePullPolicy || DefaultImagePullPolicy
},
storage_mode: isExisting
? StorageModeValueMap.Existing
@@ -207,53 +183,42 @@ const GPUServiceInstanceForm: React.FC<InstanceFormProps> = forwardRef(
return;
}
const defaultTemplate = mockTemplateData[0];
const defaultStorage = mockStorageData[0];
form.setFieldsValue({
metadata: {
namespace
},
spec: {
description: '',
displayName: ''
} as FormData['spec'],
gpu_count: 1,
// template holders seeded from default template
image: defaultTemplate?.image || '',
command: defaultTemplate?.command || [],
ports: (defaultTemplate?.ports || []) as TemplatePortItem[],
env: (defaultTemplate?.env || []) as TemplateEnvItem[],
volumeMount: defaultTemplate?.volumeMount || '',
resources: {
cpu: defaultTemplate?.resources?.cpu,
ram: defaultTemplate?.resources?.ram
},
displayName: '',
image: '',
imagePullPolicy: DefaultImagePullPolicy,
command: [],
ports: [],
env: [],
volumeMount: '',
resources: {
accelerator: '1'
}
} as Partial<FormData['spec']> as FormData['spec'],
storage_mode: StorageModeValueMap.Existing,
storage_name: defaultStorage?.metadata?.name,
local_storage_size_gb: 50,
enable_ssh: false
});
}, [action, currentData, form, open, namespace]);
const buildPayload = (values: InstanceFormValues): FormData => {
const acceleratorStr = values.gpu_count
? String(values.gpu_count)
: undefined;
const acceleratorVal = values.spec?.resources?.accelerator;
const acceleratorStr =
acceleratorVal !== undefined &&
acceleratorVal !== null &&
(acceleratorVal as unknown) !== ''
? String(acceleratorVal)
: undefined;
const spec: FormData['spec'] = {
...values.spec,
type: values.type || values.spec?.type,
image: values.image ?? values.spec?.image,
command: values.command ?? values.spec?.command,
ports: (values.ports ??
values.spec?.ports) as FormData['spec']['ports'],
env: (values.env ?? values.spec?.env) as FormData['spec']['env'],
volumeMount: values.volumeMount ?? values.spec?.volumeMount,
resources: {
...values.spec?.resources,
cpu: values.resources?.cpu ?? values.spec?.resources?.cpu,
ram: values.resources?.ram ?? values.spec?.resources?.ram,
accelerator: acceleratorStr
}
} as FormData['spec'];
@@ -272,7 +237,7 @@ const GPUServiceInstanceForm: React.FC<InstanceFormProps> = forwardRef(
}
// SSH public key — include only when checkbox is checked AND port 22 is in ports
const portsList = (values.ports || []) as TemplatePortItem[];
const portsList = (values.spec?.ports || []) as InstancePort[];
const has22 = portsList.some((p) => Number(p?.port) === SSH_PORT);
if (values.enable_ssh && has22) {
spec.sshPublicKey = { name: DEFAULT_SSH_PUBLIC_KEY_NAME };
@@ -300,6 +265,9 @@ const GPUServiceInstanceForm: React.FC<InstanceFormProps> = forwardRef(
},
resetFields: () => {
form.resetFields();
},
setFieldsValue: (values: Partial<InstanceFormValues>) => {
form.setFieldsValue(values as any);
}
}));
@@ -1,94 +0,0 @@
import FormOverlayView from '@/pages/_components/form-overlay-view';
import { SearchOutlined } from '@ant-design/icons';
import { Empty, Input } from 'antd';
import { useCallback, useMemo, useState } from 'react';
import styled from 'styled-components';
import InstanceTypeList from '../components/instance-type-list';
import { InstanceTypeItem } from '../config/types';
const DrawerBody = styled.div`
display: flex;
flex-direction: column;
gap: 16px;
`;
interface InstanceTypeOverlayProps {
open: boolean;
value?: string;
dataList: InstanceTypeItem[];
onCancel: () => void;
onChange?: (item: InstanceTypeItem) => void;
}
const InstanceTypeOverlay: React.FC<InstanceTypeOverlayProps> = ({
open,
value,
dataList,
onCancel,
onChange
}) => {
const [keyword, setKeyword] = useState('');
const filteredOptions = useMemo(() => {
const currentKeyword = keyword.trim().toLowerCase();
if (!currentKeyword) {
return dataList;
}
return dataList.filter((item) => {
const name = item.metadata?.name || item.name || '';
return [
name,
item.spec?.memory ?? '',
item.status?.cpu?.capacity ?? '',
item.status?.ram?.capacity ?? '',
item.status?.accelerator?.remaining ?? ''
].some((text) => String(text).toLowerCase().includes(currentKeyword));
});
}, [keyword, dataList]);
const handleChange = (item: InstanceTypeItem) => {
onChange?.(item);
onCancel();
};
const getOverlayContainer = useCallback(() => {
const containers = document.querySelectorAll<HTMLElement>(
'.ant-layout-content'
);
return containers[containers.length - 1] ?? null;
}, []);
return (
<FormOverlayView
title="选择实例类型"
open={open}
width={600}
onCancel={onCancel}
footer={false}
getContainer={getOverlayContainer}
>
<DrawerBody>
<Input
allowClear
prefix={<SearchOutlined className="text-tertiary" />}
placeholder="搜索名称、显存、内存或 vCPU"
value={keyword}
onChange={(e) => setKeyword(e.target.value)}
/>
{filteredOptions.length > 0 ? (
<InstanceTypeList
value={value}
dataList={filteredOptions}
onChange={handleChange}
/>
) : (
<Empty image={Empty.PRESENTED_IMAGE_SIMPLE} />
)}
</DrawerBody>
</FormOverlayView>
);
};
export default InstanceTypeOverlay;
@@ -4,12 +4,11 @@ import {
InputNumber as CInputNumber
} from '@gpustack/core-ui';
import { Flex, Form, Tag } from 'antd';
import { useEffect, useMemo, useState } from 'react';
import { useEffect, useMemo } from 'react';
import styled from 'styled-components';
import { InstanceTypePhaseValueMap } from '../config';
import { FormData, InstanceTypeItem } from '../config/types';
import useQueryInstanceTypes from '../services/use-query-instance-types';
import InstanceTypeOverlay from './instance-type-overlay';
const FieldBlock = styled.div`
margin-bottom: 24px;
@@ -54,18 +53,12 @@ const InstanceTypePicker: React.FC<InstanceTypePickerProps> = ({
onChange,
dataList
}) => {
const [open, setOpen] = useState(false);
const selected = useMemo(() => {
return dataList.find(
(item) => (item.metadata?.name || item.name) === value
);
}, [value, dataList]);
const handleChange = (item: InstanceTypeItem) => {
onChange?.(item.metadata?.name || item.name);
};
return (
<>
<SelectedCard>
@@ -97,21 +90,12 @@ const InstanceTypePicker: React.FC<InstanceTypePickerProps> = ({
</SummaryMeta>
</div>
</SelectedCard>
<InstanceTypeOverlay
open={open}
value={value}
dataList={dataList}
onCancel={() => setOpen(false)}
onChange={handleChange}
/>
</>
);
};
const InstanceTypeFormItem = () => {
const form = Form.useFormInstance<
FormData & { type?: string; gpu_count?: number }
>();
const form = Form.useFormInstance<FormData & { type?: string }>();
const typeName = Form.useWatch('type', form) as string | undefined;
const { detailData, fetchData } = useQueryInstanceTypes();
@@ -148,13 +132,14 @@ const InstanceTypeFormItem = () => {
return;
}
const currentGpuCount = form.getFieldValue('gpu_count') ?? 1;
if (currentGpuCount > maxGpuCount) {
form.setFieldValue('gpu_count', maxGpuCount);
const currentAccelerator =
Number(form.getFieldValue(['spec', 'resources', 'accelerator'])) || 1;
if (currentAccelerator > maxGpuCount) {
form.setFieldValue(['spec', 'resources', 'accelerator'], maxGpuCount);
}
if (currentGpuCount < 1) {
form.setFieldValue('gpu_count', 1);
if (currentAccelerator < 1) {
form.setFieldValue(['spec', 'resources', 'accelerator'], 1);
}
}, [form, typeName, maxGpuCount, dataList]);
@@ -174,7 +159,7 @@ const InstanceTypeFormItem = () => {
</Form.Item>
</FieldBlock>
<Form.Item
name="gpu_count"
name={['spec', 'resources', 'accelerator']}
rules={[
{
required: true,
@@ -0,0 +1,74 @@
import { PageAction } from '@/config';
import FormOverlayView from '@/pages/_components/form-overlay-view';
import { ModalFooter } from '@gpustack/core-ui';
import { useCallback, useRef } from 'react';
import { FormData as StorageFormData } from '../../storage/config/types';
import GPUServiceStorageForm from '../../storage/forms';
interface StorageOverlayProps {
open: boolean;
namespace?: string;
onCancel: () => void;
onSubmit: (values: StorageFormData) => Promise<void> | void;
}
const StorageOverlay: React.FC<StorageOverlayProps> = ({
open,
namespace,
onCancel,
onSubmit
}) => {
const formRef = useRef<any>(null);
const handleSubmit = () => {
formRef.current?.submit();
};
const handleCancel = () => {
formRef.current?.resetFields();
onCancel();
};
const handleFinish = async (values: StorageFormData) => {
await onSubmit(values);
};
const getOverlayContainer = useCallback(() => {
const containers = document.querySelectorAll<HTMLElement>(
'.ant-layout-content'
);
return containers[containers.length - 1] ?? null;
}, []);
return (
<FormOverlayView
title="添加存储"
open={open}
width={600}
onCancel={handleCancel}
getContainer={getOverlayContainer}
footer={
<ModalFooter
onOk={handleSubmit}
onCancel={handleCancel}
style={{
padding: '16px 24px 24px',
display: 'flex',
justifyContent: 'flex-end'
}}
/>
}
>
<GPUServiceStorageForm
ref={formRef}
action={PageAction.CREATE}
open={open}
namespace={namespace}
onFinish={handleFinish}
/>
</FormOverlayView>
);
};
export default StorageOverlay;
@@ -1,10 +1,14 @@
import { useModel } from '@@/plugin-model';
import { InputNumber as CInputNumber, Select } from '@gpustack/core-ui';
import { Button, Flex, Form, Radio } from 'antd';
import { useEffect } from 'react';
import { Button, Flex, Form, message, Radio } from 'antd';
import { useEffect, useMemo, useState } from 'react';
import styled from 'styled-components';
import { mockStorageData } from '../../storage/config/mock-data';
import { FormData as StorageFormData } from '../../storage/config/types';
import useCreateStorage from '../../storage/services/use-create-storage';
import useQueryStorage from '../../storage/services/use-query-storage';
import { StorageModeValueMap } from '../config';
import { FormData } from '../config/types';
import StorageOverlay from './storage-overlay';
const FieldBlock = styled.div`
margin-bottom: 24px;
@@ -17,6 +21,9 @@ type StorageHolderFields = {
};
const StorageVolume = () => {
const { initialState } = useModel('@@initialState');
const namespace = initialState?.currentUser?.org_name || 'default';
const form = Form.useFormInstance<FormData & StorageHolderFields>();
const storageMode = Form.useWatch('storage_mode', form) as string | undefined;
const storageName = Form.useWatch('storage_name', form) as string | undefined;
@@ -24,6 +31,24 @@ const StorageVolume = () => {
| number
| undefined;
const [overlayOpen, setOverlayOpen] = useState(false);
const { fetchData: createStorage } = useCreateStorage();
const { detailData: storageData, fetchData: fetchStorage } = useQueryStorage();
useEffect(() => {
fetchStorage({});
}, [fetchStorage]);
const storageOptions = useMemo(
() =>
(storageData?.items || []).map((item) => ({
label: `${item.metadata?.name} / ${item.spec?.capacity ?? '-'}`,
value: item.metadata?.name
})),
[storageData]
);
useEffect(() => {
if (storageMode === StorageModeValueMap.Existing) {
form.setFieldValue(['spec', 'volume'], {
@@ -38,6 +63,18 @@ const StorageVolume = () => {
}
}, [form, storageMode, storageName, localSize]);
const handleCreateStorage = async (values: StorageFormData) => {
try {
await createStorage({ data: values });
await fetchStorage({});
form.setFieldValue('storage_name', values.metadata.name);
setOverlayOpen(false);
message.success('操作成功');
} catch (error) {
message.error('操作失败');
}
};
return (
<FieldBlock data-field="storage">
<Flex
@@ -64,7 +101,12 @@ const StorageVolume = () => {
]}
/>
</Form.Item>
<Button type="link" size="small" style={{ marginBottom: 6 }}>
<Button
type="link"
size="small"
style={{ marginBottom: 6 }}
onClick={() => setOverlayOpen(true)}
>
</Button>
</Flex>
@@ -79,14 +121,7 @@ const StorageVolume = () => {
}
]}
>
<Select
label="持久卷"
required
options={mockStorageData.map((item) => ({
label: `${item.metadata?.name} / ${item.spec?.capacity ?? '-'}`,
value: item.metadata?.name
}))}
/>
<Select label="持久卷" required options={storageOptions} />
</Form.Item>
)}
@@ -103,6 +138,13 @@ const StorageVolume = () => {
<CInputNumber min={1} precision={0} label="存储容量 (GB)" required />
</Form.Item>
)}
<StorageOverlay
open={overlayOpen}
namespace={namespace}
onCancel={() => setOverlayOpen(false)}
onSubmit={handleCreateStorage}
/>
</FieldBlock>
);
};
@@ -1,94 +0,0 @@
import FormOverlayView from '@/pages/_components/form-overlay-view';
import { PlusOutlined, SearchOutlined } from '@ant-design/icons';
import { Button, Empty, Flex, Input } from 'antd';
import { useCallback, useMemo, useState } from 'react';
import styled from 'styled-components';
import { mockTemplateData } from '../../templates/config/mock-data';
import TemplateSelector from './template-selector';
const DrawerBody = styled.div`
display: flex;
flex-direction: column;
gap: 16px;
`;
interface TemplateOverlayProps {
open: boolean;
value?: number;
onCancel: () => void;
onChange?: (value: number) => void;
onCreate?: () => void;
}
const TemplateOverlay: React.FC<TemplateOverlayProps> = ({
open,
value,
onCancel,
onChange,
onCreate
}) => {
const [keyword, setKeyword] = useState('');
const filteredTemplates = useMemo(() => {
const currentKeyword = keyword.trim().toLowerCase();
if (!currentKeyword) {
return mockTemplateData;
}
return mockTemplateData.filter((item) => {
return [item.name, item.image, item.volumeMount].some((text) =>
(text || '').toLowerCase().includes(currentKeyword)
);
});
}, [keyword]);
const handleChange = (id: number) => {
onChange?.(id);
onCancel();
};
const getOverlayContainer = useCallback(() => {
const containers = document.querySelectorAll<HTMLElement>(
'.ant-layout-content'
);
return containers[containers.length - 1] ?? null;
}, []);
return (
<FormOverlayView
title="选择实例模板"
open={open}
width={600}
onCancel={onCancel}
getContainer={getOverlayContainer}
footer={false}
>
<DrawerBody>
<Flex gap={8}>
<Input
allowClear
prefix={<SearchOutlined className="text-tertiary" />}
placeholder="搜索模板名称、镜像或挂载路径"
value={keyword}
onChange={(e) => setKeyword(e.target.value)}
/>
<Button type="primary" icon={<PlusOutlined />} onClick={onCreate}>
</Button>
</Flex>
{filteredTemplates.length > 0 ? (
<TemplateSelector
value={value}
dataList={filteredTemplates}
onChange={handleChange}
/>
) : (
<Empty image={Empty.PRESENTED_IMAGE_SIMPLE} />
)}
</DrawerBody>
</FormOverlayView>
);
};
export default TemplateOverlay;
@@ -1,6 +1,5 @@
import { AutoTooltip, IconFont, TemplateCard } from '@gpustack/core-ui';
import styled from 'styled-components';
import { mockTemplateData } from '../../templates/config/mock-data';
import { ListItem as TemplateItem } from '../../templates/config/types';
const TemplateGrid = styled.div`
@@ -43,14 +42,14 @@ const TemplateContent = styled.div`
interface TemplateSelectorProps {
value?: number;
onChange?: (value: number) => void;
onChange?: (value: number, item: TemplateItem) => void;
dataList?: TemplateItem[];
}
const TemplateSelector: React.FC<TemplateSelectorProps> = ({
value,
onChange,
dataList = mockTemplateData
dataList = []
}) => {
return (
<TemplateGrid>
@@ -63,7 +62,7 @@ const TemplateSelector: React.FC<TemplateSelectorProps> = ({
height={102}
active={value === item.id}
disabled={item.status !== 'enabled'}
onClick={() => onChange?.(item.id)}
onClick={() => onChange?.(item.id, item)}
>
<TemplateContent>
<div className="name">
@@ -76,14 +75,14 @@ const TemplateSelector: React.FC<TemplateSelectorProps> = ({
<IconFont className="icon" type="icon-model" /> :
</span>
<AutoTooltip ghost minWidth={20}>
<span className="value">{item.image || '-'}</span>
<span className="value">{item.spec?.image || '-'}</span>
</AutoTooltip>
</div>
<div className="info">
<span>
<IconFont className="icon" type="icon-storage-outlined" /> :
</span>
<span className="value">{item.volumeMount || '-'}</span>
<span className="value">{item.spec?.volumeMount || '-'}</span>
</div>
</TemplateContent>
</TemplateCard>
@@ -1,203 +0,0 @@
import { PageAction } from '@/config';
import { EditOutlined } from '@ant-design/icons';
import { AutoTooltip, Input as CInput } from '@gpustack/core-ui';
import { Button, Flex, Form, message } from 'antd';
import { useEffect, useMemo, useState } from 'react';
import styled from 'styled-components';
import {
createGPUServiceTemplate,
updateGPUServiceTemplate
} from '../../templates/apis';
import AddTemplateModal from '../../templates/components/add-modal';
import { mockTemplateData } from '../../templates/config/mock-data';
import {
FormData as TemplateFormData,
ListItem as TemplateListItem
} from '../../templates/config/types';
import useCreateTemplate from '../../templates/hooks/use-create-template';
import { FormData } from '../config/types';
import TemplateOverlay from './template-overlay';
const FieldBlock = styled.div`
margin-bottom: 24px;
`;
const SelectedCard = styled.div`
display: grid;
grid-template-columns: minmax(0, 1fr) max-content;
align-items: center;
gap: 12px;
padding: 14px;
border: 1px solid var(--ant-color-border);
border-radius: var(--ant-border-radius-lg);
background: var(--ant-color-bg-container);
`;
const SummaryTitle = styled.div`
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 8px;
color: var(--ant-color-text);
font-weight: 500;
`;
const SummaryMeta = styled.div`
display: grid;
gap: 4px;
color: var(--ant-color-text-secondary);
font-size: 13px;
`;
interface TemplateSelectorProps {
value?: number;
onChange?: (value: number) => void;
onCreate?: () => void;
onEdit?: (row: TemplateListItem) => void;
}
const TemplatePicker: React.FC<TemplateSelectorProps> = ({
value,
onChange,
onCreate,
onEdit
}) => {
const [open, setOpen] = useState(false);
const selected = useMemo(() => {
return mockTemplateData.find((item) => item.id === value);
}, [value]);
return (
<>
<SelectedCard>
<div>
<SummaryTitle>
<AutoTooltip ghost minWidth={20}>
{selected?.name || '请选择实例模板'}
</AutoTooltip>
</SummaryTitle>
<SummaryMeta>
<AutoTooltip ghost minWidth={20}>
<span>: {selected?.image || '-'}</span>
</AutoTooltip>
<span>: {selected?.volumeMount || '-'}</span>
</SummaryMeta>
</div>
<Flex gap={8}>
<Button
type="text"
icon={<EditOutlined />}
disabled={!selected}
onClick={() => selected && onEdit?.(selected)}
>
</Button>
<Button onClick={() => setOpen(true)}></Button>
</Flex>
</SelectedCard>
<TemplateOverlay
open={open}
value={value}
onCancel={() => setOpen(false)}
onChange={onChange}
onCreate={() => {
onCreate?.();
}}
/>
</>
);
};
const TemplateFormItem = () => {
const form = Form.useFormInstance<FormData & { template_id?: number }>();
const templateId = Form.useWatch('template_id', form) as number | undefined;
const { openTemplateModalStatus, openTemplateModal, closeTemplateModal } =
useCreateTemplate();
const selectedTemplate = useMemo(() => {
return mockTemplateData.find((item) => item.id === templateId);
}, [templateId]);
useEffect(() => {
if (!templateId && mockTemplateData[0]) {
form.setFieldValue('template_id', mockTemplateData[0].id);
return;
}
if (selectedTemplate) {
form.setFieldValue(['spec', 'image'], selectedTemplate.image);
form.setFieldValue(['spec', 'command'], selectedTemplate.command || []);
form.setFieldValue(['spec', 'ports'], selectedTemplate.ports || []);
form.setFieldValue(['spec', 'env'], selectedTemplate.env || []);
form.setFieldValue(['spec', 'volumeMount'], selectedTemplate.volumeMount);
const currentResources = form.getFieldValue(['spec', 'resources']) || {};
form.setFieldValue(['spec', 'resources'], {
...currentResources,
cpu: selectedTemplate.resources?.cpu,
ram: selectedTemplate.resources?.ram
});
}
}, [form, selectedTemplate, templateId]);
const handleAddTemplate = () => {
openTemplateModal(PageAction.CREATE, '添加实例模板');
};
const handleEditTemplate = (row: TemplateListItem) => {
openTemplateModal(PageAction.EDIT, '编辑实例模板', row);
};
const handleTemplateModalOk = async (data: TemplateFormData) => {
try {
if (openTemplateModalStatus.action === PageAction.EDIT) {
await updateGPUServiceTemplate({
id: openTemplateModalStatus.currentData!.id,
data
});
} else {
await createGPUServiceTemplate({ data });
}
closeTemplateModal();
message.success('操作成功');
} catch (error) {
message.error('操作失败');
}
};
return (
<div>
<FieldBlock data-field="template">
<Form.Item
name="template_id"
rules={[
{
required: true,
message: '请选择实例模板'
}
]}
>
<TemplatePicker
onCreate={handleAddTemplate}
onEdit={handleEditTemplate}
/>
</Form.Item>
</FieldBlock>
<Form.Item<FormData> name={['spec', 'image']} hidden>
<CInput.Input />
</Form.Item>
<AddTemplateModal
action={openTemplateModalStatus.action}
open={openTemplateModalStatus.open}
title={openTemplateModalStatus.title}
currentData={openTemplateModalStatus.currentData}
onCancel={closeTemplateModal}
onOk={handleTemplateModalOk}
/>
</div>
);
};
export default TemplateFormItem;
+18 -38
View File
@@ -1,55 +1,35 @@
import { request } from '@umijs/max';
import { FormData, ListItem } from '../types';
export const GPU_SERVICE_PUBLIC_KEY_API = (namespace: string) =>
`/proxy/apis/worker.gpustack.ai/v1/namespaces/${namespace}/instancesshpublickeys`;
export const GPU_SERVICE_PUBLIC_KEY_API = '/gpu-instance-ssh-public-keys/data';
export async function queryGPUServicePublicKeys(
params: Global.K8sSearchParams,
params: Global.SearchParams,
options?: any
) {
return request<Global.K8sPageResponse<ListItem>>(
GPU_SERVICE_PUBLIC_KEY_API(params.namespace || ''),
{
method: 'GET',
params,
cancelToken: options?.token
}
);
return request<ListItem>(GPU_SERVICE_PUBLIC_KEY_API, {
method: 'GET',
params,
cancelToken: options?.token
});
}
export async function createGPUServicePublicKey(params: {
namespace: string;
data: FormData;
}) {
return request<ListItem>(GPU_SERVICE_PUBLIC_KEY_API(params.namespace), {
export async function createGPUServicePublicKey(params: { data: FormData }) {
return request<ListItem>(GPU_SERVICE_PUBLIC_KEY_API, {
method: 'POST',
data: params.data
});
}
export async function updateGPUServicePublicKey(params: {
namespace: string;
id: number;
data: FormData;
}) {
return request<ListItem>(
`${GPU_SERVICE_PUBLIC_KEY_API(params.namespace)}/${params.id}`,
{
method: 'PUT',
data: params.data
}
);
export async function updateGPUServicePublicKey(params: { data: FormData }) {
return request<ListItem>(`${GPU_SERVICE_PUBLIC_KEY_API}`, {
method: 'PUT',
data: params.data
});
}
export async function deleteGPUServicePublicKey(params: {
namespace: string;
id: number;
}) {
return request(
`${GPU_SERVICE_PUBLIC_KEY_API(params.namespace)}/${params.id}`,
{
method: 'DELETE'
}
);
export async function deleteGPUServicePublicKey(id: number) {
return request(`${GPU_SERVICE_PUBLIC_KEY_API}/${id}`, {
method: 'DELETE'
});
}
+54 -22
View File
@@ -1,31 +1,42 @@
import { INPUT_WIDTH } from '@/constants';
import { Textarea } from '@gpustack/core-ui';
import { Input as CInput, Textarea, useAppUtils } from '@gpustack/core-ui';
import { useIntl } from '@umijs/max';
import { Button, Form, message } from 'antd';
import React from 'react';
import { Button, Form } from 'antd';
import React, { useEffect } from 'react';
import PageBox from '../../_components/page-box';
interface PublicKeyFormData {
public_key?: string;
}
const PLACEHOLDER = `以 ssh-rsa 或 ssh-ed25519 开头,每个 Public Key 单独一行
查看 Public Key
- RSA
cat ~/.ssh/id_rsa.pub
- Ed25519
cat ~/.ssh/id_ed25519.pub`;
import useGetSshkey from './services/use-get-sshkey';
import useUpdateSshkey from './services/use-update-sshkey';
import { FormData } from './types';
const GPUServicePublicKeys: React.FC = () => {
const intl = useIntl();
const [form] = Form.useForm<PublicKeyFormData>();
const [form] = Form.useForm<FormData>();
const { getRuleMessage } = useAppUtils();
const { fetchData: fetchSshkey } = useGetSshkey();
const { fetchData: updateSshkey, loading: updating } = useUpdateSshkey();
useEffect(() => {
fetchSshkey({ page: 1, perPage: 100 }).then((res) => {
const data = res?.spec?.data || '';
form.setFieldsValue({
name: res?.name,
spec: {
data
}
});
});
}, []);
const handleSave = async () => {
form.submit();
};
const onFinish = async (values: FormData) => {
try {
await form.validateFields();
message.success('操作成功');
} catch {
await updateSshkey({
data: values
});
} catch (error) {
// ignore
}
};
@@ -36,12 +47,32 @@ const GPUServicePublicKeys: React.FC = () => {
style={{ width: INPUT_WIDTH.large }}
name="gpuServicePublicKeyForm"
form={form}
onFinish={onFinish}
initialValues={{
name: 'SSHPublicKey',
spec: {
data: ''
}
}}
>
<Form.Item<PublicKeyFormData> name="public_key">
<Form.Item<FormData> name="name" hidden>
<CInput.Input />
</Form.Item>
<Form.Item<FormData>
name={['spec', 'data']}
rules={[
{
required: true,
message: getRuleMessage('input', 'gpuservice.publicKey.label')
}
]}
>
<Textarea
required
label="SSH Public Key"
placeholder={PLACEHOLDER}
label={intl.formatMessage({ id: 'gpuservice.publicKey.label' })}
placeholder={intl.formatMessage({
id: 'gpuservice.publicKey.placeholder'
})}
trim={false}
alwaysFocus
autoSize={{ minRows: 8, maxRows: 16 }}
@@ -50,6 +81,7 @@ const GPUServicePublicKeys: React.FC = () => {
</Form.Item>
<Button
type="primary"
loading={updating}
style={{ width: 120, marginTop: 24 }}
onClick={handleSave}
>
@@ -1,34 +1,17 @@
import { useModel } from '@@/plugin-model';
import { useQueryData } from '@gpustack/core-ui';
import { useCallback } from 'react';
import { apiVersion, KindMapping } from '../../constants';
import { createGPUServicePublicKey } from '../apis';
import { ListItem } from '../types';
import { FormData, ListItem } from '../types';
interface CreateSshkeyParams {
name: string;
data: string;
data: FormData;
}
export default function useCreateSshkey() {
const { initialState } = useModel('@@initialState');
const namespace = initialState?.currentUser?.org_name || 'default';
const fetchDetail = useCallback(
(params: CreateSshkeyParams) =>
createGPUServicePublicKey({
namespace,
data: {
apiVersion,
kind: KindMapping.sshPublicKey,
metadata: {
name: params.name,
namespace
},
data: params.data
}
}),
[namespace]
createGPUServicePublicKey({ data: params.data }),
[]
);
const { detailData, loading, cancelRequest, fetchData } = useQueryData<
@@ -1,26 +1,20 @@
import { useModel } from '@@/plugin-model';
import { useQueryData } from '@gpustack/core-ui';
import { useCallback } from 'react';
import { queryGPUServicePublicKeys } from '../apis';
import { ListItem } from '../types';
export default function useGetSshkey() {
const { initialState } = useModel('@@initialState');
const namespace = initialState?.currentUser?.org_name || 'default';
const fetchDetail = useCallback(
(params: Global.K8sSearchParams = {}, options?: any) =>
queryGPUServicePublicKeys({ ...params, namespace }, options),
[namespace]
(params: Global.SearchParams = { page: 1, perPage: 100 }, options?: any) =>
queryGPUServicePublicKeys(params, options),
[]
);
const { detailData, loading, cancelRequest, fetchData } = useQueryData<
Global.K8sPageResponse<ListItem>,
Global.K8sSearchParams
>({
fetchDetail,
key: 'sshkey'
});
const { detailData, loading, cancelRequest, fetchData } =
useQueryData<ListItem>({
fetchDetail,
key: 'sshkey'
});
return {
detailData,
@@ -1,36 +1,17 @@
import { useModel } from '@@/plugin-model';
import { useQueryData } from '@gpustack/core-ui';
import { useCallback } from 'react';
import { apiVersion, KindMapping } from '../../constants';
import { updateGPUServicePublicKey } from '../apis';
import { ListItem } from '../types';
import { FormData, ListItem } from '../types';
interface UpdateSshkeyParams {
id: number;
name: string;
data: string;
data: FormData;
}
export default function useUpdateSshkey() {
const { initialState } = useModel('@@initialState');
const namespace = initialState?.currentUser?.org_name || 'default';
const fetchDetail = useCallback(
(params: UpdateSshkeyParams) =>
updateGPUServicePublicKey({
namespace,
id: params.id,
data: {
apiVersion,
kind: KindMapping.sshPublicKey,
metadata: {
name: params.name,
namespace
},
data: params.data
}
}),
[namespace]
updateGPUServicePublicKey({ data: params.data }),
[]
);
const { detailData, loading, cancelRequest, fetchData } = useQueryData<
+6 -2
View File
@@ -1,5 +1,9 @@
export interface FormData extends Global.K8sCommonData {
data: string;
export interface FormData {
name: string;
description: string;
spec: {
data: string;
};
}
export interface ListItem extends FormData {
@@ -1,65 +0,0 @@
import { StorageTypeValueMap } from '.';
import { ListItem } from './types';
export const mockStorageData: (ListItem & { cluster_id: number })[] = [
{
id: 1,
cluster_id: 1,
metadata: {
name: 'local-nvme-cache',
namespace: 'default'
},
spec: {
accessMode: 'ReadWriteOnce',
capacity: '500Gi',
type: StorageTypeValueMap.Local
},
created_at: '2026-04-01T10:00:00Z',
updated_at: '2026-04-10T10:00:00Z'
},
{
id: 2,
cluster_id: 1,
metadata: {
name: 'shared-model-store',
namespace: 'default'
},
spec: {
accessMode: 'ReadWriteMany',
capacity: '2Ti',
type: StorageTypeValueMap.Shared
},
created_at: '2026-04-02T10:00:00Z',
updated_at: '2026-04-11T10:00:00Z'
},
{
id: 3,
cluster_id: 2,
metadata: {
name: 'object-dataset-bucket',
namespace: 'default'
},
spec: {
accessMode: 'ReadOnlyMany',
capacity: '10Ti',
type: StorageTypeValueMap.Object
},
created_at: '2026-04-03T10:00:00Z',
updated_at: '2026-04-12T10:00:00Z'
},
{
id: 4,
cluster_id: 2,
metadata: {
name: 'failed-shared-cache',
namespace: 'default'
},
spec: {
accessMode: 'ReadWriteMany',
capacity: '1Ti',
type: StorageTypeValueMap.Shared
},
created_at: '2026-04-04T10:00:00Z',
updated_at: '2026-04-13T10:00:00Z'
}
];
@@ -0,0 +1,31 @@
import { useModel } from '@@/plugin-model';
import { useQueryData } from '@gpustack/core-ui';
import { useCallback } from 'react';
import { queryGPUServiceStorage } from '../apis';
import { ListItem } from '../config/types';
export default function useQueryStorage() {
const { initialState } = useModel('@@initialState');
const namespace = initialState?.currentUser?.org_name || 'default';
const fetchDetail = useCallback(
(params: Global.K8sSearchParams = {}, options?: any) =>
queryGPUServiceStorage({ ...params, namespace }, options),
[namespace]
);
const { detailData, loading, cancelRequest, fetchData } = useQueryData<
Global.K8sPageResponse<ListItem>,
Global.K8sSearchParams
>({
fetchDetail,
key: 'storage'
});
return {
detailData,
loading,
cancelRequest,
fetchData
};
}
@@ -1,7 +1,7 @@
import { request } from '@umijs/max';
import { FormData, ListItem } from '../config/types';
export const GPU_SERVICE_TEMPLATES_API = '/gpu-service-templates';
export const GPU_SERVICE_TEMPLATES_API = '/gpu-instance-templates';
export async function queryGPUServiceTemplates(
params: Global.SearchParams,
@@ -1,10 +1,21 @@
import ascendLogo from '@/assets/logo/ascend.png';
import CambriconPNG from '@/assets/logo/cambricon.png';
import hyponPNG from '@/assets/logo/hygon.png';
import iluvatarWEBP from '@/assets/logo/Iluvatar.png';
import metaxLogo from '@/assets/logo/metax.png';
import mooreLogo from '@/assets/logo/moore-logo.png';
import nvidiaLogo from '@/assets/logo/nvidia.png';
import theadLogoEN from '@/assets/logo/t-head-en.png';
import theadLogoZH from '@/assets/logo/t-head-zh.png';
import { GPUDriverMap } from '@/pages/resources/config/gpu-driver';
import {
AutoTooltip,
DropdownActions,
IconFont,
TemplateCard
} from '@gpustack/core-ui';
import { Button } from 'antd';
import { useIntl } from '@umijs/max';
import { Button, Tag } from 'antd';
import styled from 'styled-components';
import { templateActions } from '../config';
import { ListItem } from '../config/types';
@@ -24,16 +35,21 @@ const Header = styled.div`
justify-content: space-between;
height: 24px;
width: 100%;
.title {
display: flex;
align-items: center;
}
`;
const CardTitle = styled.div`
const CardName = styled.div`
font-weight: 500;
font-size: 14px;
display: flex;
align-items: center;
color: var(--ant-color-text);
margin-bottom: 8px;
gap: 8px;
flex: 1;
width: 100%;
min-width: 0;
`;
@@ -61,12 +77,18 @@ const InfoItem = styled.div`
}
`;
const LogoImg = styled.img`
object-fit: contain;
`;
interface TemplateCardProps {
data: ListItem;
onSelect?: (item: { action: string; data: ListItem }) => void;
}
const TemplateCardItem: React.FC<TemplateCardProps> = ({ data, onSelect }) => {
const intl = useIntl();
const handleOnSelect = (item: any) => {
onSelect?.({ action: item.key, data });
};
@@ -75,6 +97,50 @@ const TemplateCardItem: React.FC<TemplateCardProps> = ({ data, onSelect }) => {
e.stopPropagation();
};
const renderLogo = () => {
switch (data.manufacturer) {
case GPUDriverMap.NVIDIA:
return <LogoImg src={nvidiaLogo} height={18} />;
case GPUDriverMap.AMD:
return (
<IconFont
type="icon-amd-logo"
style={{ fontSize: 36, color: 'var(--ant-color-text)' }}
/>
);
case GPUDriverMap.ASCEND:
return <LogoImg src={ascendLogo} height={24} />;
case GPUDriverMap.HYGON:
return <LogoImg src={hyponPNG} height={18} />;
case GPUDriverMap.METAX:
return <LogoImg src={metaxLogo} height={20} />;
case GPUDriverMap.MOORE_THREADS:
return <LogoImg src={mooreLogo} height={22} />;
case GPUDriverMap.ILUVATAR:
return <LogoImg src={iluvatarWEBP} height={22} />;
case GPUDriverMap.CAMBRICON:
return <LogoImg src={CambriconPNG} height={22} />;
case GPUDriverMap.THEAD:
return (
<LogoImg
src={intl?.locale === 'zh-CN' ? theadLogoZH : theadLogoEN}
height={20}
/>
);
case 'cpu':
return (
<Tag color="geekblue" style={{ paddingBlock: 2 }}>
<IconFont
type="icon-cpu"
style={{ fontSize: 22, display: 'flex' }}
/>
</Tag>
);
default:
return null;
}
};
const renderActions = () => {
return (
<span onClick={handleonClickAction} className="operations">
@@ -94,15 +160,6 @@ const TemplateCardItem: React.FC<TemplateCardProps> = ({ data, onSelect }) => {
);
};
const ports = data.ports || [];
const portText = ports.length
? ports.map((p) => `${p.protocol?.toUpperCase()}:${p.port}`).join(', ')
: '-';
const resources = [data.resources?.cpu, data.resources?.ram]
.filter(Boolean)
.join(' / ');
return (
<StyledCard
clickable={false}
@@ -112,46 +169,24 @@ const TemplateCardItem: React.FC<TemplateCardProps> = ({ data, onSelect }) => {
ghost
header={
<Header>
<CardTitle>
<AutoTooltip ghost minWidth={20}>
{data.name || '-'}
</AutoTooltip>
</CardTitle>
<div className="title">{renderLogo()}</div>
{renderActions()}
</Header>
}
>
<Content>
<InfoItem>
<span>
<IconFont className="icon" type="icon-model" /> :
</span>
<CardName>
<AutoTooltip ghost minWidth={20}>
<span className="value">{data.image || '-'}</span>
{data.name || '-'}
</AutoTooltip>
</InfoItem>
</CardName>
<InfoItem>
<span>
<IconFont className="icon" type="icon-storage-outlined" /> :
<IconFont className="icon" type="icon-model" />{' '}
{intl.formatMessage({ id: 'gpuservice.template.card.image' })}:
</span>
<AutoTooltip ghost minWidth={20}>
<span className="value">{data.volumeMount || '-'}</span>
</AutoTooltip>
</InfoItem>
<InfoItem>
<span>
<IconFont className="icon" type="icon-gpu1" /> :
</span>
<AutoTooltip ghost minWidth={20}>
<span className="value">{resources || '-'}</span>
</AutoTooltip>
</InfoItem>
<InfoItem>
<span>
<IconFont className="icon" type="icon-network" /> :
</span>
<AutoTooltip ghost minWidth={20}>
<span className="value">{portText}</span>
<span className="value">{data.spec?.image || '-'}</span>
</AutoTooltip>
</InfoItem>
</Content>
@@ -1,4 +1,5 @@
import { icons } from '@gpustack/core-ui';
import { ImagePullPolicy } from './types';
export const TemplateStatusValueMap = {
Enabled: 'enabled',
@@ -6,19 +7,45 @@ export const TemplateStatusValueMap = {
};
export const TemplateStatusLabelMap: Record<string, string> = {
[TemplateStatusValueMap.Enabled]: '启用',
[TemplateStatusValueMap.Disabled]: '禁用'
[TemplateStatusValueMap.Enabled]: 'common.button.enable',
[TemplateStatusValueMap.Disabled]: 'common.button.disable'
};
export const ImagePullPolicyOptions: {
label: string;
value: ImagePullPolicy;
locale: boolean;
}[] = [
{
label: 'gpuservice.template.imagePullPolicy.always',
value: 'Always',
locale: true
},
{
label: 'gpuservice.template.imagePullPolicy.ifNotPresent',
value: 'IfNotPresent',
locale: true
},
{
label: 'gpuservice.template.imagePullPolicy.never',
value: 'Never',
locale: true
}
];
export const DefaultImagePullPolicy: ImagePullPolicy = 'IfNotPresent';
export const templateActions = [
{
label: '编辑',
label: 'common.button.edit',
key: 'edit',
locale: true,
icon: icons.EditOutlined
},
{
label: '删除',
label: 'common.button.delete',
key: 'delete',
locale: true,
icon: icons.DeleteOutlined,
danger: true
}
@@ -1,106 +0,0 @@
import { ListItem } from './types';
export const mockTemplateData: ListItem[] = [
{
id: 1,
name: 'Ubuntu CUDA Dev',
image: 'nvidia/cuda:12.4.1-devel-ubuntu22.04',
command: ['/bin/bash'],
volumeMount: '/workspace',
resources: {
cpu: '4',
ram: '16Gi'
},
ports: [
{ protocol: 'tcp', port: 22 },
{ protocol: 'udp', port: 8888 }
],
env: [{ name: 'NVIDIA_VISIBLE_DEVICES', value: 'all' }],
status: 'enabled',
created_at: '2026-04-01T10:00:00Z',
updated_at: '2026-04-10T10:00:00Z'
},
{
id: 2,
name: 'PyTorch Training',
image: 'pytorch/pytorch:2.5.1-cuda12.4-cudnn9-devel',
command: ['python', 'train.py'],
volumeMount: '/data',
resources: {
cpu: '8',
ram: '32Gi'
},
ports: [{ protocol: 'tcp', port: 6006 }],
env: [{ name: 'PYTHONUNBUFFERED', value: '1' }],
status: 'enabled',
created_at: '2026-04-02T10:00:00Z',
updated_at: '2026-04-11T10:00:00Z'
},
{
id: 3,
name: 'ROCm Notebook',
image: 'rocm/pytorch:latest',
command: ['jupyter', 'lab', '--ip=0.0.0.0', '--allow-root'],
volumeMount: '/notebooks',
resources: {
cpu: '4',
ram: '16Gi'
},
ports: [{ protocol: 'tcp', port: 8888 }],
env: [{ name: 'HSA_OVERRIDE_GFX_VERSION', value: '10.3.0' }],
status: 'enabled',
created_at: '2026-04-03T10:00:00Z',
updated_at: '2026-04-12T10:00:00Z'
},
{
id: 4,
name: 'Ascend MindIE',
image: 'ascend/mindie:latest',
command: ['/usr/local/Ascend/mindie/latest/bin/mindieservice_daemon'],
volumeMount: '/models',
resources: {
cpu: '4',
ram: '16Gi'
},
ports: [{ protocol: 'tcp', port: 1025 }],
env: [{ name: 'ASCEND_VISIBLE_DEVICES', value: '0' }],
status: 'enabled',
created_at: '2026-04-04T10:00:00Z',
updated_at: '2026-04-13T10:00:00Z'
},
{
id: 5,
name: 'CPU Utility',
image: 'ubuntu:22.04',
command: ['sleep', 'infinity'],
volumeMount: '/mnt/data',
resources: {
cpu: '2',
ram: '4Gi'
},
ports: [{ protocol: 'tcp', port: 22 }],
env: [],
status: 'disabled',
created_at: '2026-04-05T10:00:00Z',
updated_at: '2026-04-14T10:00:00Z'
},
{
id: 6,
name: 'Inference Server',
image: 'vllm/vllm-openai:latest',
command: ['python', '-m', 'vllm.entrypoints.openai.api_server'],
volumeMount: '/models',
resources: {
cpu: '16',
ram: '64Gi'
},
ports: [
{ protocol: 'udp', port: 8000 },
{ protocol: 'tcp', port: 8080 }
],
env: [{ name: 'VLLM_WORKER_MULTIPROC_METHOD', value: 'spawn' }],
status: 'enabled',
created_at: '2026-04-06T10:00:00Z',
updated_at: '2026-04-15T10:00:00Z'
}
];
+18 -13
View File
@@ -1,8 +1,10 @@
export interface PortItem {
protocol: 'udp' | 'tcp';
protocol: 'UDP' | 'TCP';
port: number;
}
export type ImagePullPolicy = 'Always' | 'IfNotPresent' | 'Never';
export interface EnvItem {
name: string;
value: string;
@@ -10,18 +12,21 @@ export interface EnvItem {
export interface FormData {
name: string;
image: string;
// imagePullPolicy: string;
// imagePullSecret: {
// name: string;
// };
command: string[];
ports: PortItem[];
env: EnvItem[];
volumeMount: string;
resources: {
cpu: string;
ram: string;
description: string;
manufacturer: string;
spec: {
image: string;
imagePullPolicy: ImagePullPolicy;
command: string[];
ports: PortItem[];
env: EnvItem[];
volumeMount: string;
resources: {
cpu: string;
ram: string;
localStorage: string;
accelerator: string;
};
};
}
+127 -42
View File
@@ -1,5 +1,16 @@
import { Input as CInput, Textarea } from '@gpustack/core-ui';
import { Form } from 'antd';
import {
Input as CInput,
InputNumber,
Select as SealSelect,
Textarea,
useAppUtils
} from '@gpustack/core-ui';
import { useIntl } from '@umijs/max';
import { Flex, Form } from 'antd';
import _ from 'lodash';
import { useMemo } from 'react';
import { GPUsConfigs } from '../../../resources/config/gpu-driver';
import { ImagePullPolicyOptions } from '../config';
import { FormData } from '../config/types';
import Env from './env';
import Ports from './ports';
@@ -9,72 +20,146 @@ interface BasicProps {
}
const Basic: React.FC<BasicProps> = ({ page = 'template' }) => {
const form = Form.useFormInstance<FormData>();
const { getRuleMessage } = useAppUtils();
const intl = useIntl();
const handleCommandChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
const value = e.target.value;
const list = value
.split('\n')
.map((line) => line.trim())
.filter((line) => line.length > 0);
form.setFieldValue('command', list);
};
const command = Form.useWatch('command', form);
const commandText = Array.isArray(command) ? command.join('\n') : '';
const manufacturerOptions = useMemo(
() =>
Object.values(GPUsConfigs).map((item) => ({
label: item.label,
value: item.value
})),
[]
);
return (
<>
{page === 'template' && (
<Form.Item<FormData>
name="name"
rules={[
{
required: true,
message: '请输入模板名称'
}
]}
>
<CInput.Input label="名称" required />
</Form.Item>
<>
<Form.Item<FormData>
name="name"
rules={[
{
required: true,
message: getRuleMessage('input', 'common.table.name')
}
]}
>
<CInput.Input
label={intl.formatMessage({ id: 'common.table.name' })}
required
/>
</Form.Item>
</>
)}
<Form.Item<FormData>
name="image"
name="manufacturer"
rules={[
{
required: true,
message: '请输入容器镜像'
message: getRuleMessage('select', 'resources.table.vendor')
}
]}
>
<CInput.Input label="容器镜像" required />
<SealSelect
label={intl.formatMessage({ id: 'resources.table.vendor' })}
required
options={[...manufacturerOptions, { label: 'CPU', value: 'cpu' }]}
/>
</Form.Item>
<Form.Item>
<Form.Item<FormData>
name={['spec', 'image']}
rules={[
{
required: true,
message: getRuleMessage('input', 'gpuservice.template.image')
}
]}
>
<CInput.Input
label={intl.formatMessage({ id: 'gpuservice.template.image' })}
required
/>
</Form.Item>
<Form.Item<FormData> name={['spec', 'imagePullPolicy']}>
<SealSelect
label={intl.formatMessage({
id: 'gpuservice.template.imagePullPolicy'
})}
options={ImagePullPolicyOptions.map((item) => ({
label: intl.formatMessage({ id: item.label }),
value: item.value
}))}
/>
</Form.Item>
<Form.Item<FormData>
name={['spec', 'command']}
normalize={(value: string) => _.split(value, '\n').map(_.trim)}
getValueProps={(value) => ({ value: _.join(value, '\n') })}
>
<Textarea
label="容器启动命令"
placeholder={'每行一个参数,例如:\n/bin/bash\n-c\nyour command'}
label={intl.formatMessage({ id: 'gpuservice.template.command' })}
placeholder={intl.formatMessage({
id: 'gpuservice.template.command.placeholder'
})}
trim={false}
alwaysFocus
scaleSize
value={commandText}
onChange={handleCommandChange}
/>
</Form.Item>
<Form.Item<FormData> name="volumeMount">
<CInput.Input label="挂载路径" placeholder="例如:/workspace" />
</Form.Item>
<div style={{ display: 'flex', gap: 16 }}>
<Flex gap={16}>
<div style={{ flex: 1 }}>
<Form.Item<FormData> name={['resources', 'cpu']}>
<CInput.Input label="CPU" placeholder="例如:4" />
<Form.Item<FormData> name={['spec', 'volumeMount']}>
<CInput.Input
label={intl.formatMessage({ id: 'gpuservice.template.mountPath' })}
placeholder={intl.formatMessage({
id: 'clusters.volume.mountPath.format'
})}
/>
</Form.Item>
</div>
<div style={{ flex: 1 }}>
<Form.Item<FormData> name={['resources', 'ram']}>
<CInput.Input label="内存" placeholder="例如:8Gi" />
<Form.Item<FormData>
name={['spec', 'resources', 'localStorage']}
normalize={(value) => (value ? `${value}Gi` : undefined)}
getValueProps={(value) => ({
value: value ? String(value).replace(/Gi$/, '') : ''
})}
>
<InputNumber
label={intl.formatMessage({
id: 'gpuservice.template.containerDisk'
})}
/>
</Form.Item>
</div>
</div>
</Flex>
<Flex gap={16}>
<div style={{ flex: 1 }}>
<Form.Item<FormData>
name={['spec', 'resources', 'cpu']}
normalize={(value) => (value ? `${value}` : '')}
getValueProps={(value) => ({ value: value ? String(value) : '' })}
>
<InputNumber label="CPU" />
</Form.Item>
</div>
<div style={{ flex: 1 }}>
<Form.Item<FormData>
name={['spec', 'resources', 'ram']}
normalize={(value) => (value ? `${value}Gi` : undefined)}
getValueProps={(value) => ({
value: value ? String(value).replace(/Gi$/, '') : ''
})}
>
<InputNumber
label={intl.formatMessage({ id: 'gpuservice.template.memory' })}
/>
</Form.Item>
</div>
</Flex>
<Ports />
<Env />
</>
+21 -8
View File
@@ -1,4 +1,5 @@
import { Input as CInput, MetadataList } from '@gpustack/core-ui';
import { useIntl } from '@umijs/max';
import { Form } from 'antd';
import { EnvItem as EnvItemType, FormData } from '../config/types';
@@ -9,12 +10,15 @@ interface EnvItemProps {
}
const EnvItem: React.FC<EnvItemProps> = ({ item, onChange }) => {
const intl = useIntl();
return (
<div style={{ display: 'flex', gap: 12, width: '100%' }}>
<div style={{ flex: 1 }}>
<CInput.Input
value={item.name}
placeholder="名称"
placeholder={intl.formatMessage({
id: 'gpuservice.template.env.name'
})}
onChange={(e) => {
onChange({
...item,
@@ -27,7 +31,9 @@ const EnvItem: React.FC<EnvItemProps> = ({ item, onChange }) => {
<div style={{ flex: 1 }}>
<CInput.Input
value={item.value}
placeholder="值"
placeholder={intl.formatMessage({
id: 'gpuservice.template.env.value'
})}
onChange={(e) => {
onChange({
...item,
@@ -42,11 +48,12 @@ const EnvItem: React.FC<EnvItemProps> = ({ item, onChange }) => {
};
const Env: React.FC = () => {
const intl = useIntl();
const form = Form.useFormInstance<FormData>();
const env = Form.useWatch('env', form) || [];
const env = Form.useWatch(['spec', 'env'], form) || [];
const updateEnv = (list: EnvItemType[]) => {
form.setFieldValue('env', list);
form.setFieldValue(['spec', 'env'], list);
};
const handleAdd = () => {
@@ -73,7 +80,7 @@ const Env: React.FC = () => {
return (
<Form.Item<FormData>
name="env"
name={['spec', 'env']}
rules={[
{
validator: async (_, value) => {
@@ -82,7 +89,13 @@ const Env: React.FC = () => {
}
const hasInvalid = value.some((item: EnvItemType) => !item.name);
if (hasInvalid) {
return Promise.reject(new Error('请填写完整的环境变量'));
return Promise.reject(
new Error(
intl.formatMessage({
id: 'gpuservice.template.env.invalid'
})
)
);
}
return Promise.resolve();
}
@@ -91,8 +104,8 @@ const Env: React.FC = () => {
>
<MetadataList
dataList={env}
btnText="添加环境变量"
label="环境变量"
btnText={intl.formatMessage({ id: 'gpuservice.template.env.add' })}
label={intl.formatMessage({ id: 'gpuservice.template.env' })}
onAdd={handleAdd}
onDelete={handleDelete}
>
+26 -22
View File
@@ -2,6 +2,7 @@ import { PageAction } from '@/config';
import { PageActionType } from '@/config/types';
import { Form } from 'antd';
import { forwardRef, useEffect, useImperativeHandle } from 'react';
import { DefaultImagePullPolicy } from '../config';
import { FormData, ListItem } from '../config/types';
import Basic from './basic';
@@ -26,32 +27,22 @@ const GPUServiceTemplateForm: React.FC<TemplateFormProps> = forwardRef(
if (action === PageAction.EDIT && currentData) {
form.setFieldsValue({
name: currentData.name,
image: currentData.image,
command: currentData.command || [],
ports: currentData.ports || [],
env: currentData.env || [],
volumeMount: currentData.volumeMount,
resources: {
cpu: currentData.resources?.cpu,
ram: currentData.resources?.ram
}
...currentData
});
return;
}
form.setFieldsValue({
command: [],
ports: [
{
protocol: 'tcp',
port: 22
}
],
env: []
});
}, [action, currentData, form, open]);
const handleFinish = async (values: FormData) => {
await onFinish({
...values,
spec: {
...values.spec,
command: values.spec?.command?.filter(Boolean) ?? []
}
});
};
useImperativeHandle(ref, () => ({
submit: () => {
form.submit();
@@ -65,8 +56,21 @@ const GPUServiceTemplateForm: React.FC<TemplateFormProps> = forwardRef(
<Form
name="gpuServiceTemplateForm"
form={form}
onFinish={onFinish}
onFinish={handleFinish}
preserve={false}
initialValues={{
spec: {
imagePullPolicy: DefaultImagePullPolicy,
command: [],
ports: [
{
protocol: 'TCP',
port: 22
}
],
env: []
}
}}
>
<Basic />
</Form>
+18 -10
View File
@@ -3,6 +3,7 @@ import {
MetadataList,
Select as SealSelect
} from '@gpustack/core-ui';
import { useIntl } from '@umijs/max';
import { Form } from 'antd';
import { FormData, PortItem as PortItemType } from '../config/types';
@@ -11,11 +12,11 @@ type PortProtocol = PortItemType['protocol'];
const protocolOptions = [
{
label: 'UDP',
value: 'udp'
value: 'UDP'
},
{
label: 'TCP',
value: 'tcp'
value: 'TCP'
}
];
@@ -41,7 +42,7 @@ const PortItem: React.FC<PortItemProps> = ({ item, index, onChange }) => {
style={{ width: '100%' }}
></SealSelect>
</div>
<div style={{ flex: 2 }}>
<div style={{ flex: 1 }}>
<CInputNumber
min={1}
max={65535}
@@ -61,18 +62,19 @@ const PortItem: React.FC<PortItemProps> = ({ item, index, onChange }) => {
};
const Ports: React.FC = () => {
const intl = useIntl();
const form = Form.useFormInstance<FormData>();
const ports = Form.useWatch('ports', form) || [];
const ports = Form.useWatch(['spec', 'ports'], form) || [];
const updatePorts = (list: PortItemType[]) => {
form.setFieldValue('ports', list);
form.setFieldValue(['spec', 'ports'], list);
};
const handleAdd = () => {
updatePorts([
...ports,
{
protocol: 'tcp',
protocol: 'TCP',
port: undefined as any
}
]);
@@ -92,7 +94,7 @@ const Ports: React.FC = () => {
return (
<Form.Item<FormData>
name="ports"
name={['spec', 'ports']}
rules={[
{
validator: async (_, value) => {
@@ -103,7 +105,13 @@ const Ports: React.FC = () => {
(item: PortItemType) => !item.protocol || !item.port
);
if (hasInvalidPort) {
return Promise.reject(new Error('请填写完整的端口配置'));
return Promise.reject(
new Error(
intl.formatMessage({
id: 'gpuservice.template.ports.invalid'
})
)
);
}
return Promise.resolve();
}
@@ -112,8 +120,8 @@ const Ports: React.FC = () => {
>
<MetadataList
dataList={ports}
btnText="添加端口"
label="端口"
btnText={intl.formatMessage({ id: 'gpuservice.template.ports.add' })}
label={intl.formatMessage({ id: 'gpuservice.template.ports' })}
onAdd={handleAdd}
onDelete={handleDelete}
>
+55 -14
View File
@@ -7,9 +7,11 @@ import {
InfiniteScrollerProvider,
NoResult
} from '@gpustack/core-ui';
import { useIntl } from '@umijs/max';
import { useMemoizedFn } from 'ahooks';
import { message } from 'antd';
import _ from 'lodash';
import { useMemo } from 'react';
import { GPUsConfigs } from '../../resources/config/gpu-driver';
import PageBox from '../../_components/page-box';
import {
createGPUServiceTemplate,
@@ -24,6 +26,7 @@ import { FormData, ListItem } from './config/types';
import useCreateTemplate from './hooks/use-create-template';
const GPUServiceTemplates: React.FC = () => {
const intl = useIntl();
const {
dataSource,
rowSelection,
@@ -32,14 +35,15 @@ const GPUServiceTemplates: React.FC = () => {
fetchData,
handleDelete,
handleSearch,
handleNameChange
handleNameChange,
handleQueryChange
} = useTableFetch<ListItem>({
fetchAPI: queryGPUServiceTemplates,
deleteAPI: deleteGPUServiceTemplate,
API: GPU_SERVICE_TEMPLATES_API,
watch: false,
isInfiniteScroll: true,
contentForDelete: 'GPU 实例模板',
contentForDelete: intl.formatMessage({ id: 'gpuservice.template' }),
defaultQueryParams: {
perPage: 24
}
@@ -47,12 +51,37 @@ const GPUServiceTemplates: React.FC = () => {
const { openTemplateModalStatus, openTemplateModal, closeTemplateModal } =
useCreateTemplate();
const manufacturerOptions = useMemo(
() => [
...Object.values(GPUsConfigs).map((item) => ({
label: item.label,
value: item.value
})),
{ label: 'CPU', value: 'cpu' }
],
[]
);
const handleFilterByManufacturer = (value: string) => {
handleQueryChange({
manufacturer: value,
page: 1
});
};
const handleAddTemplate = () => {
openTemplateModal(PageAction.CREATE, '添加实例模板');
openTemplateModal(
PageAction.CREATE,
intl.formatMessage({ id: 'gpuservice.template.add' })
);
};
const handleEditTemplate = (row: ListItem) => {
openTemplateModal(PageAction.EDIT, '编辑实例模板', row);
openTemplateModal(
PageAction.EDIT,
intl.formatMessage({ id: 'gpuservice.template.edit' }),
row
);
};
const handleModalOk = async (data: FormData) => {
@@ -67,9 +96,8 @@ const GPUServiceTemplates: React.FC = () => {
}
closeTemplateModal();
handleSearch();
message.success('操作成功');
} catch (error) {
message.error('操作失败');
// ignore
}
};
@@ -101,13 +129,20 @@ const GPUServiceTemplates: React.FC = () => {
widths={{
input: 230
}}
inputHolder="按名称过滤"
buttonText="添加实例模板"
inputHolder={intl.formatMessage({
id: 'gpuservice.template.filter.name'
})}
selectHolder={intl.formatMessage({
id: 'gpuservice.template.filter.vendor'
})}
buttonText={intl.formatMessage({ id: 'gpuservice.template.add' })}
handleClickPrimary={handleAddTemplate}
handleSearch={handleSearch}
handleSelectChange={handleFilterByManufacturer}
handleInputChange={handleNameChange}
rowSelection={rowSelection}
showSelect={false}
showSelect={true}
selectOptions={manufacturerOptions}
/>
<InfiniteScrollerProvider
value={{
@@ -130,11 +165,17 @@ const GPUServiceTemplates: React.FC = () => {
dataSource={dataSource.dataList}
image={<IconFont type="icon-instance-template-filled" />}
filters={_.omit(queryParams, ['sort_by'])}
noFoundText="未找到匹配的实例模板"
title="暂无实例模板"
subTitle="创建一个实例模板后会显示在这里"
noFoundText={intl.formatMessage({
id: 'noresult.gpuservice.template.nofound'
})}
title={intl.formatMessage({
id: 'noresult.gpuservice.template.title'
})}
subTitle={intl.formatMessage({
id: 'noresult.gpuservice.template.subTitle'
})}
onClick={handleAddTemplate}
buttonText="立即添加"
buttonText={intl.formatMessage({ id: 'noresult.button.add' })}
/>
</InfiniteScrollerProvider>
<AddModal
@@ -0,0 +1,27 @@
import { useQueryData } from '@gpustack/core-ui';
import { useCallback } from 'react';
import { queryGPUServiceTemplates } from '../apis';
import { ListItem } from '../config/types';
export default function useQueryTemplates() {
const fetchDetail = useCallback(
(params: Global.SearchParams = { page: 1, perPage: 100 }, options?: any) =>
queryGPUServiceTemplates(params, options),
[]
);
const { detailData, loading, cancelRequest, fetchData } = useQueryData<
Global.PageResponse<ListItem>,
Global.SearchParams
>({
fetchDetail,
key: 'templates'
});
return {
detailData,
loading,
cancelRequest,
fetchData
};
}
+1 -1
View File
@@ -29,7 +29,7 @@ export const GPUsConfigs: Record<
{ label: string; value: string; runtime: string; driver: string }
> = {
[GPUDriverMap.NVIDIA]: {
label: 'Nvidia', // this label is used in echo command, do not translate
label: 'NVIDIA', // this label is used in echo command, do not translate
value: GPUDriverMap.NVIDIA,
runtime: 'nvidia',
driver: 'nvidia-smi'