fix: ssh key, template test
This commit is contained in:
@@ -356,7 +356,7 @@ export default (props: any) => {
|
|||||||
config={{
|
config={{
|
||||||
apiBaseUrl: GPUSTACK_API_BASE_URL,
|
apiBaseUrl: GPUSTACK_API_BASE_URL,
|
||||||
theme: userSettings.theme,
|
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,
|
isDarkTheme: userSettings.isDarkTheme,
|
||||||
defaultColorPrimary: COLOR_PRIMARY
|
defaultColorPrimary: COLOR_PRIMARY
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -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'
|
||||||
|
};
|
||||||
@@ -52,5 +52,10 @@ export default {
|
|||||||
'noresult.instances.title': 'No instances',
|
'noresult.instances.title': 'No instances',
|
||||||
'noresult.instances.subTitle':
|
'noresult.instances.subTitle':
|
||||||
'There are no running model instances. Deploy a model to create one automatically.',
|
'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.'
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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'
|
||||||
|
};
|
||||||
@@ -52,5 +52,10 @@ export default {
|
|||||||
'noresult.instances.title': 'No instances',
|
'noresult.instances.title': 'No instances',
|
||||||
'noresult.instances.subTitle':
|
'noresult.instances.subTitle':
|
||||||
'There are no running model instances. Deploy a model to create one automatically.',
|
'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':
|
||||||
|
'一致するインスタンステンプレートが見つかりません。'
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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'
|
||||||
|
};
|
||||||
@@ -53,7 +53,12 @@ export default {
|
|||||||
'noresult.instances.title': 'No instances',
|
'noresult.instances.title': 'No instances',
|
||||||
'noresult.instances.subTitle':
|
'noresult.instances.subTitle':
|
||||||
'There are no running model instances. Deploy a model to create one automatically.',
|
'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) ==========
|
// ========== To-Do: Translate Keys (Remove After Translation) ==========
|
||||||
|
|||||||
@@ -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'
|
||||||
|
};
|
||||||
@@ -54,7 +54,11 @@ export default {
|
|||||||
'noresult.instances.title': 'No instances',
|
'noresult.instances.title': 'No instances',
|
||||||
'noresult.instances.subTitle':
|
'noresult.instances.subTitle':
|
||||||
'There are no running model instances. Deploy a model to create one automatically.',
|
'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) ==========
|
// ========== To-Do: Translate Keys (Remove After Translation) ==========
|
||||||
|
|||||||
@@ -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'
|
||||||
|
};
|
||||||
@@ -50,5 +50,8 @@ export default {
|
|||||||
'noresult.instances.title': '暂无实例',
|
'noresult.instances.title': '暂无实例',
|
||||||
'noresult.instances.subTitle':
|
'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 {
|
.overlay {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
top: 0;
|
top: 0;
|
||||||
@@ -15,6 +30,13 @@
|
|||||||
-6px 0 16px 0 rgb(0 0 0 / 8%),
|
-6px 0 16px 0 rgb(0 0 0 / 8%),
|
||||||
-3px 0 6px -4px rgb(0 0 0 / 12%),
|
-3px 0 6px -4px rgb(0 0 0 / 12%),
|
||||||
-9px 0 28px 8px rgb(0 0 0 / 5%);
|
-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 {
|
.header {
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { ColumnWrapper, IconFont } from '@gpustack/core-ui';
|
import { ColumnWrapper, IconFont } from '@gpustack/core-ui';
|
||||||
import { Button, Tag } from 'antd';
|
import { Button, Tag } from 'antd';
|
||||||
|
import classNames from 'classnames';
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { createPortal } from 'react-dom';
|
import { createPortal } from 'react-dom';
|
||||||
import styles from './form-overlay-view.module.less';
|
import styles from './form-overlay-view.module.less';
|
||||||
@@ -15,6 +16,7 @@ type FormOverlayViewProps = {
|
|||||||
width?: number | string;
|
width?: number | string;
|
||||||
className?: string;
|
className?: string;
|
||||||
style?: React.CSSProperties;
|
style?: React.CSSProperties;
|
||||||
|
maskClosable?: boolean;
|
||||||
getContainer?: () => HTMLElement | null | undefined;
|
getContainer?: () => HTMLElement | null | undefined;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -29,60 +31,90 @@ const FormOverlayView: React.FC<FormOverlayViewProps> = ({
|
|||||||
width = 600,
|
width = 600,
|
||||||
className,
|
className,
|
||||||
style,
|
style,
|
||||||
|
maskClosable = false,
|
||||||
getContainer
|
getContainer
|
||||||
}) => {
|
}) => {
|
||||||
const [container, setContainer] = React.useState<HTMLElement | null>(null);
|
const [container, setContainer] = React.useState<HTMLElement | null>(null);
|
||||||
|
const [mounted, setMounted] = React.useState(false);
|
||||||
|
const [active, setActive] = React.useState(false);
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
if (!open) {
|
if (open) {
|
||||||
setContainer(null);
|
setContainer(getContainer?.() ?? null);
|
||||||
|
setMounted(true);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
setContainer(getContainer?.() ?? null);
|
setActive(false);
|
||||||
}, [getContainer, open]);
|
}, [getContainer, open]);
|
||||||
|
|
||||||
if (!open) {
|
React.useEffect(() => {
|
||||||
return null;
|
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 null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return createPortal(
|
return createPortal(
|
||||||
<div
|
<>
|
||||||
className={[styles.overlay, className].filter(Boolean).join(' ')}
|
<div
|
||||||
style={{ width, ...style }}
|
className={classNames(styles.mask, { [styles.maskOpen]: active })}
|
||||||
role="dialog"
|
onClick={maskClosable ? onCancel : undefined}
|
||||||
aria-modal="true"
|
/>
|
||||||
>
|
<div
|
||||||
<div className={styles.header}>
|
className={classNames(
|
||||||
<Button
|
styles.overlay,
|
||||||
type="text"
|
{ [styles.overlayOpen]: active },
|
||||||
size="small"
|
className
|
||||||
style={{ fontWeight: 600, fontSize: 16 }}
|
)}
|
||||||
icon={<IconFont type="icon-down2" rotate={90} />}
|
style={{ width, ...style }}
|
||||||
onClick={onCancel}
|
role="dialog"
|
||||||
/>
|
aria-modal="true"
|
||||||
<div className={styles.title}>
|
onTransitionEnd={handleTransitionEnd}
|
||||||
{title}
|
|
||||||
{subTitle && (
|
|
||||||
<Tag variant="outlined" className={styles.subTitle}>
|
|
||||||
{subTitle}
|
|
||||||
</Tag>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<ColumnWrapper
|
|
||||||
styles={{
|
|
||||||
container: { paddingBlock: 16 }
|
|
||||||
}}
|
|
||||||
footer={footer}
|
|
||||||
>
|
>
|
||||||
{children}
|
<div className={styles.header}>
|
||||||
</ColumnWrapper>
|
<Button
|
||||||
</div>,
|
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
|
container
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
justify-content: center;
|
justify-content: center;
|
||||||
top: 0;
|
top: 0;
|
||||||
left: 0;
|
left: 0;
|
||||||
|
min-height: 160px;
|
||||||
max-height: 400px;
|
max-height: 400px;
|
||||||
right: 0;
|
right: 0;
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,8 @@ import { ColumnWrapper, GSDrawer, ModalFooter } from '@gpustack/core-ui';
|
|||||||
import { Empty, Input, Typography } from 'antd';
|
import { Empty, Input, Typography } from 'antd';
|
||||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import styled from 'styled-components';
|
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 { FormData, InstanceTypeItem, ListItem } from '../config/types';
|
||||||
import GPUServiceInstanceForm from '../forms';
|
import GPUServiceInstanceForm from '../forms';
|
||||||
import TemplateSelector from '../forms/template-selector';
|
import TemplateSelector from '../forms/template-selector';
|
||||||
@@ -64,14 +65,18 @@ const AddModal: React.FC<AddModalProps> = ({
|
|||||||
const [templateKeyword, setTemplateKeyword] = useState('');
|
const [templateKeyword, setTemplateKeyword] = useState('');
|
||||||
|
|
||||||
const { detailData, fetchData } = useQueryInstanceTypes();
|
const { detailData, fetchData } = useQueryInstanceTypes();
|
||||||
|
const { detailData: templatesData, fetchData: fetchTemplates } =
|
||||||
|
useQueryTemplates();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (open) {
|
if (open) {
|
||||||
fetchData({});
|
fetchData({});
|
||||||
|
fetchTemplates({ page: 1, perPage: 100 });
|
||||||
}
|
}
|
||||||
}, [open, fetchData]);
|
}, [open, fetchData, fetchTemplates]);
|
||||||
|
|
||||||
const instanceTypeList = detailData?.items || [];
|
const instanceTypeList = detailData?.items || [];
|
||||||
|
const templateList = templatesData?.items || [];
|
||||||
|
|
||||||
const filteredInstanceTypes = useMemo(() => {
|
const filteredInstanceTypes = useMemo(() => {
|
||||||
const keyword = instanceKeyword.trim().toLowerCase();
|
const keyword = instanceKeyword.trim().toLowerCase();
|
||||||
@@ -93,14 +98,14 @@ const AddModal: React.FC<AddModalProps> = ({
|
|||||||
const filteredTemplates = useMemo(() => {
|
const filteredTemplates = useMemo(() => {
|
||||||
const keyword = templateKeyword.trim().toLowerCase();
|
const keyword = templateKeyword.trim().toLowerCase();
|
||||||
if (!keyword) {
|
if (!keyword) {
|
||||||
return mockTemplateData;
|
return templateList;
|
||||||
}
|
}
|
||||||
return mockTemplateData.filter((item) =>
|
return templateList.filter((item) =>
|
||||||
[item.name, item.image, item.volumeMount].some((text) =>
|
[item.name, item.spec?.image, item.spec?.volumeMount].some((text) =>
|
||||||
(text || '').toLowerCase().includes(keyword)
|
(text || '').toLowerCase().includes(keyword)
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
}, [templateKeyword]);
|
}, [templateKeyword, templateList]);
|
||||||
|
|
||||||
const handleSubmit = () => {
|
const handleSubmit = () => {
|
||||||
form.current?.submit();
|
form.current?.submit();
|
||||||
@@ -118,7 +123,30 @@ const AddModal: React.FC<AddModalProps> = ({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleInstanceTypeChange = (item: InstanceTypeItem) => {
|
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 (
|
return (
|
||||||
@@ -194,7 +222,7 @@ const AddModal: React.FC<AddModalProps> = ({
|
|||||||
<TemplateSelector
|
<TemplateSelector
|
||||||
value={templateId}
|
value={templateId}
|
||||||
dataList={filteredTemplates}
|
dataList={filteredTemplates}
|
||||||
onChange={setTemplateId}
|
onChange={handleTemplateChange}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<Empty image={Empty.PRESENTED_IMAGE_SIMPLE} />
|
<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: {
|
spec: {
|
||||||
type: string;
|
type: string;
|
||||||
image: string;
|
image: string;
|
||||||
|
imagePullPolicy: ImagePullPolicy;
|
||||||
displayName: string;
|
displayName: string;
|
||||||
command: string[];
|
command: string[];
|
||||||
ports: InstancePort[];
|
ports: InstancePort[];
|
||||||
|
|||||||
@@ -18,15 +18,10 @@ import {
|
|||||||
useMemo,
|
useMemo,
|
||||||
useRef
|
useRef
|
||||||
} from 'react';
|
} from 'react';
|
||||||
import { mockStorageData } from '../../storage/config/mock-data';
|
import { DefaultImagePullPolicy } from '../../templates/config';
|
||||||
import { mockTemplateData } from '../../templates/config/mock-data';
|
|
||||||
import {
|
|
||||||
EnvItem as TemplateEnvItem,
|
|
||||||
PortItem as TemplatePortItem
|
|
||||||
} from '../../templates/config/types';
|
|
||||||
import TemplateBasicForm from '../../templates/forms/basic';
|
import TemplateBasicForm from '../../templates/forms/basic';
|
||||||
import { DEFAULT_SSH_PUBLIC_KEY_NAME, StorageModeValueMap } from '../config';
|
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 Basic from './basic';
|
||||||
import InstanceTypeFormItem from './instance-type';
|
import InstanceTypeFormItem from './instance-type';
|
||||||
import StorageVolume from './storage-volume';
|
import StorageVolume from './storage-volume';
|
||||||
@@ -34,16 +29,6 @@ import StorageVolume from './storage-volume';
|
|||||||
const SSH_PORT = 22;
|
const SSH_PORT = 22;
|
||||||
|
|
||||||
type InstanceFormValues = FormData & {
|
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 holders
|
||||||
storage_mode?: string;
|
storage_mode?: string;
|
||||||
storage_name?: string;
|
storage_name?: string;
|
||||||
@@ -75,7 +60,7 @@ const requiredFields = {
|
|||||||
},
|
},
|
||||||
[TABKeysMap.INSTANCE_TYPE]: {
|
[TABKeysMap.INSTANCE_TYPE]: {
|
||||||
sort: 2,
|
sort: 2,
|
||||||
fields: ['type', 'gpu_count']
|
fields: ['type']
|
||||||
},
|
},
|
||||||
[TABKeysMap.TEMPLATE]: {
|
[TABKeysMap.TEMPLATE]: {
|
||||||
sort: 3,
|
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(
|
const hasSshPort = useMemo(
|
||||||
() => ports.some((p) => Number(p?.port) === SSH_PORT),
|
() => ports.some((p) => Number(p?.port) === SSH_PORT),
|
||||||
[ports]
|
[ports]
|
||||||
@@ -180,20 +166,10 @@ const GPUServiceInstanceForm: React.FC<InstanceFormProps> = forwardRef(
|
|||||||
name: currentData.metadata?.name,
|
name: currentData.metadata?.name,
|
||||||
namespace: currentData.metadata?.namespace
|
namespace: currentData.metadata?.namespace
|
||||||
},
|
},
|
||||||
spec: { ...currentData.spec },
|
spec: {
|
||||||
type: currentData.spec?.type,
|
...currentData.spec,
|
||||||
gpu_count: currentData.spec?.resources?.accelerator
|
imagePullPolicy:
|
||||||
? Number(currentData.spec.resources.accelerator) || 1
|
currentData.spec?.imagePullPolicy || DefaultImagePullPolicy
|
||||||
: 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
|
|
||||||
},
|
},
|
||||||
storage_mode: isExisting
|
storage_mode: isExisting
|
||||||
? StorageModeValueMap.Existing
|
? StorageModeValueMap.Existing
|
||||||
@@ -207,53 +183,42 @@ const GPUServiceInstanceForm: React.FC<InstanceFormProps> = forwardRef(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const defaultTemplate = mockTemplateData[0];
|
|
||||||
const defaultStorage = mockStorageData[0];
|
|
||||||
|
|
||||||
form.setFieldsValue({
|
form.setFieldsValue({
|
||||||
metadata: {
|
metadata: {
|
||||||
namespace
|
namespace
|
||||||
},
|
},
|
||||||
spec: {
|
spec: {
|
||||||
description: '',
|
description: '',
|
||||||
displayName: ''
|
displayName: '',
|
||||||
} as FormData['spec'],
|
image: '',
|
||||||
gpu_count: 1,
|
imagePullPolicy: DefaultImagePullPolicy,
|
||||||
// template holders seeded from default template
|
command: [],
|
||||||
image: defaultTemplate?.image || '',
|
ports: [],
|
||||||
command: defaultTemplate?.command || [],
|
env: [],
|
||||||
ports: (defaultTemplate?.ports || []) as TemplatePortItem[],
|
volumeMount: '',
|
||||||
env: (defaultTemplate?.env || []) as TemplateEnvItem[],
|
resources: {
|
||||||
volumeMount: defaultTemplate?.volumeMount || '',
|
accelerator: '1'
|
||||||
resources: {
|
}
|
||||||
cpu: defaultTemplate?.resources?.cpu,
|
} as Partial<FormData['spec']> as FormData['spec'],
|
||||||
ram: defaultTemplate?.resources?.ram
|
|
||||||
},
|
|
||||||
storage_mode: StorageModeValueMap.Existing,
|
storage_mode: StorageModeValueMap.Existing,
|
||||||
storage_name: defaultStorage?.metadata?.name,
|
|
||||||
local_storage_size_gb: 50,
|
local_storage_size_gb: 50,
|
||||||
enable_ssh: false
|
enable_ssh: false
|
||||||
});
|
});
|
||||||
}, [action, currentData, form, open, namespace]);
|
}, [action, currentData, form, open, namespace]);
|
||||||
|
|
||||||
const buildPayload = (values: InstanceFormValues): FormData => {
|
const buildPayload = (values: InstanceFormValues): FormData => {
|
||||||
const acceleratorStr = values.gpu_count
|
const acceleratorVal = values.spec?.resources?.accelerator;
|
||||||
? String(values.gpu_count)
|
const acceleratorStr =
|
||||||
: undefined;
|
acceleratorVal !== undefined &&
|
||||||
|
acceleratorVal !== null &&
|
||||||
|
(acceleratorVal as unknown) !== ''
|
||||||
|
? String(acceleratorVal)
|
||||||
|
: undefined;
|
||||||
|
|
||||||
const spec: FormData['spec'] = {
|
const spec: FormData['spec'] = {
|
||||||
...values.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: {
|
resources: {
|
||||||
...values.spec?.resources,
|
...values.spec?.resources,
|
||||||
cpu: values.resources?.cpu ?? values.spec?.resources?.cpu,
|
|
||||||
ram: values.resources?.ram ?? values.spec?.resources?.ram,
|
|
||||||
accelerator: acceleratorStr
|
accelerator: acceleratorStr
|
||||||
}
|
}
|
||||||
} as FormData['spec'];
|
} 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
|
// 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);
|
const has22 = portsList.some((p) => Number(p?.port) === SSH_PORT);
|
||||||
if (values.enable_ssh && has22) {
|
if (values.enable_ssh && has22) {
|
||||||
spec.sshPublicKey = { name: DEFAULT_SSH_PUBLIC_KEY_NAME };
|
spec.sshPublicKey = { name: DEFAULT_SSH_PUBLIC_KEY_NAME };
|
||||||
@@ -300,6 +265,9 @@ const GPUServiceInstanceForm: React.FC<InstanceFormProps> = forwardRef(
|
|||||||
},
|
},
|
||||||
resetFields: () => {
|
resetFields: () => {
|
||||||
form.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
|
InputNumber as CInputNumber
|
||||||
} from '@gpustack/core-ui';
|
} from '@gpustack/core-ui';
|
||||||
import { Flex, Form, Tag } from 'antd';
|
import { Flex, Form, Tag } from 'antd';
|
||||||
import { useEffect, useMemo, useState } from 'react';
|
import { useEffect, useMemo } from 'react';
|
||||||
import styled from 'styled-components';
|
import styled from 'styled-components';
|
||||||
import { InstanceTypePhaseValueMap } from '../config';
|
import { InstanceTypePhaseValueMap } from '../config';
|
||||||
import { FormData, InstanceTypeItem } from '../config/types';
|
import { FormData, InstanceTypeItem } from '../config/types';
|
||||||
import useQueryInstanceTypes from '../services/use-query-instance-types';
|
import useQueryInstanceTypes from '../services/use-query-instance-types';
|
||||||
import InstanceTypeOverlay from './instance-type-overlay';
|
|
||||||
|
|
||||||
const FieldBlock = styled.div`
|
const FieldBlock = styled.div`
|
||||||
margin-bottom: 24px;
|
margin-bottom: 24px;
|
||||||
@@ -54,18 +53,12 @@ const InstanceTypePicker: React.FC<InstanceTypePickerProps> = ({
|
|||||||
onChange,
|
onChange,
|
||||||
dataList
|
dataList
|
||||||
}) => {
|
}) => {
|
||||||
const [open, setOpen] = useState(false);
|
|
||||||
|
|
||||||
const selected = useMemo(() => {
|
const selected = useMemo(() => {
|
||||||
return dataList.find(
|
return dataList.find(
|
||||||
(item) => (item.metadata?.name || item.name) === value
|
(item) => (item.metadata?.name || item.name) === value
|
||||||
);
|
);
|
||||||
}, [value, dataList]);
|
}, [value, dataList]);
|
||||||
|
|
||||||
const handleChange = (item: InstanceTypeItem) => {
|
|
||||||
onChange?.(item.metadata?.name || item.name);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<SelectedCard>
|
<SelectedCard>
|
||||||
@@ -97,21 +90,12 @@ const InstanceTypePicker: React.FC<InstanceTypePickerProps> = ({
|
|||||||
</SummaryMeta>
|
</SummaryMeta>
|
||||||
</div>
|
</div>
|
||||||
</SelectedCard>
|
</SelectedCard>
|
||||||
<InstanceTypeOverlay
|
|
||||||
open={open}
|
|
||||||
value={value}
|
|
||||||
dataList={dataList}
|
|
||||||
onCancel={() => setOpen(false)}
|
|
||||||
onChange={handleChange}
|
|
||||||
/>
|
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const InstanceTypeFormItem = () => {
|
const InstanceTypeFormItem = () => {
|
||||||
const form = Form.useFormInstance<
|
const form = Form.useFormInstance<FormData & { type?: string }>();
|
||||||
FormData & { type?: string; gpu_count?: number }
|
|
||||||
>();
|
|
||||||
const typeName = Form.useWatch('type', form) as string | undefined;
|
const typeName = Form.useWatch('type', form) as string | undefined;
|
||||||
|
|
||||||
const { detailData, fetchData } = useQueryInstanceTypes();
|
const { detailData, fetchData } = useQueryInstanceTypes();
|
||||||
@@ -148,13 +132,14 @@ const InstanceTypeFormItem = () => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const currentGpuCount = form.getFieldValue('gpu_count') ?? 1;
|
const currentAccelerator =
|
||||||
if (currentGpuCount > maxGpuCount) {
|
Number(form.getFieldValue(['spec', 'resources', 'accelerator'])) || 1;
|
||||||
form.setFieldValue('gpu_count', maxGpuCount);
|
if (currentAccelerator > maxGpuCount) {
|
||||||
|
form.setFieldValue(['spec', 'resources', 'accelerator'], maxGpuCount);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (currentGpuCount < 1) {
|
if (currentAccelerator < 1) {
|
||||||
form.setFieldValue('gpu_count', 1);
|
form.setFieldValue(['spec', 'resources', 'accelerator'], 1);
|
||||||
}
|
}
|
||||||
}, [form, typeName, maxGpuCount, dataList]);
|
}, [form, typeName, maxGpuCount, dataList]);
|
||||||
|
|
||||||
@@ -174,7 +159,7 @@ const InstanceTypeFormItem = () => {
|
|||||||
</Form.Item>
|
</Form.Item>
|
||||||
</FieldBlock>
|
</FieldBlock>
|
||||||
<Form.Item
|
<Form.Item
|
||||||
name="gpu_count"
|
name={['spec', 'resources', 'accelerator']}
|
||||||
rules={[
|
rules={[
|
||||||
{
|
{
|
||||||
required: true,
|
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 { InputNumber as CInputNumber, Select } from '@gpustack/core-ui';
|
||||||
import { Button, Flex, Form, Radio } from 'antd';
|
import { Button, Flex, Form, message, Radio } from 'antd';
|
||||||
import { useEffect } from 'react';
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
import styled from 'styled-components';
|
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 { StorageModeValueMap } from '../config';
|
||||||
import { FormData } from '../config/types';
|
import { FormData } from '../config/types';
|
||||||
|
import StorageOverlay from './storage-overlay';
|
||||||
|
|
||||||
const FieldBlock = styled.div`
|
const FieldBlock = styled.div`
|
||||||
margin-bottom: 24px;
|
margin-bottom: 24px;
|
||||||
@@ -17,6 +21,9 @@ type StorageHolderFields = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const StorageVolume = () => {
|
const StorageVolume = () => {
|
||||||
|
const { initialState } = useModel('@@initialState');
|
||||||
|
const namespace = initialState?.currentUser?.org_name || 'default';
|
||||||
|
|
||||||
const form = Form.useFormInstance<FormData & StorageHolderFields>();
|
const form = Form.useFormInstance<FormData & StorageHolderFields>();
|
||||||
const storageMode = Form.useWatch('storage_mode', form) as string | undefined;
|
const storageMode = Form.useWatch('storage_mode', form) as string | undefined;
|
||||||
const storageName = Form.useWatch('storage_name', form) as string | undefined;
|
const storageName = Form.useWatch('storage_name', form) as string | undefined;
|
||||||
@@ -24,6 +31,24 @@ const StorageVolume = () => {
|
|||||||
| number
|
| number
|
||||||
| undefined;
|
| 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(() => {
|
useEffect(() => {
|
||||||
if (storageMode === StorageModeValueMap.Existing) {
|
if (storageMode === StorageModeValueMap.Existing) {
|
||||||
form.setFieldValue(['spec', 'volume'], {
|
form.setFieldValue(['spec', 'volume'], {
|
||||||
@@ -38,6 +63,18 @@ const StorageVolume = () => {
|
|||||||
}
|
}
|
||||||
}, [form, storageMode, storageName, localSize]);
|
}, [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 (
|
return (
|
||||||
<FieldBlock data-field="storage">
|
<FieldBlock data-field="storage">
|
||||||
<Flex
|
<Flex
|
||||||
@@ -64,7 +101,12 @@ const StorageVolume = () => {
|
|||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
<Button type="link" size="small" style={{ marginBottom: 6 }}>
|
<Button
|
||||||
|
type="link"
|
||||||
|
size="small"
|
||||||
|
style={{ marginBottom: 6 }}
|
||||||
|
onClick={() => setOverlayOpen(true)}
|
||||||
|
>
|
||||||
添加存储
|
添加存储
|
||||||
</Button>
|
</Button>
|
||||||
</Flex>
|
</Flex>
|
||||||
@@ -79,14 +121,7 @@ const StorageVolume = () => {
|
|||||||
}
|
}
|
||||||
]}
|
]}
|
||||||
>
|
>
|
||||||
<Select
|
<Select label="持久卷" required options={storageOptions} />
|
||||||
label="持久卷"
|
|
||||||
required
|
|
||||||
options={mockStorageData.map((item) => ({
|
|
||||||
label: `${item.metadata?.name} / ${item.spec?.capacity ?? '-'}`,
|
|
||||||
value: item.metadata?.name
|
|
||||||
}))}
|
|
||||||
/>
|
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -103,6 +138,13 @@ const StorageVolume = () => {
|
|||||||
<CInputNumber min={1} precision={0} label="存储容量 (GB)" required />
|
<CInputNumber min={1} precision={0} label="存储容量 (GB)" required />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<StorageOverlay
|
||||||
|
open={overlayOpen}
|
||||||
|
namespace={namespace}
|
||||||
|
onCancel={() => setOverlayOpen(false)}
|
||||||
|
onSubmit={handleCreateStorage}
|
||||||
|
/>
|
||||||
</FieldBlock>
|
</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 { AutoTooltip, IconFont, TemplateCard } from '@gpustack/core-ui';
|
||||||
import styled from 'styled-components';
|
import styled from 'styled-components';
|
||||||
import { mockTemplateData } from '../../templates/config/mock-data';
|
|
||||||
import { ListItem as TemplateItem } from '../../templates/config/types';
|
import { ListItem as TemplateItem } from '../../templates/config/types';
|
||||||
|
|
||||||
const TemplateGrid = styled.div`
|
const TemplateGrid = styled.div`
|
||||||
@@ -43,14 +42,14 @@ const TemplateContent = styled.div`
|
|||||||
|
|
||||||
interface TemplateSelectorProps {
|
interface TemplateSelectorProps {
|
||||||
value?: number;
|
value?: number;
|
||||||
onChange?: (value: number) => void;
|
onChange?: (value: number, item: TemplateItem) => void;
|
||||||
dataList?: TemplateItem[];
|
dataList?: TemplateItem[];
|
||||||
}
|
}
|
||||||
|
|
||||||
const TemplateSelector: React.FC<TemplateSelectorProps> = ({
|
const TemplateSelector: React.FC<TemplateSelectorProps> = ({
|
||||||
value,
|
value,
|
||||||
onChange,
|
onChange,
|
||||||
dataList = mockTemplateData
|
dataList = []
|
||||||
}) => {
|
}) => {
|
||||||
return (
|
return (
|
||||||
<TemplateGrid>
|
<TemplateGrid>
|
||||||
@@ -63,7 +62,7 @@ const TemplateSelector: React.FC<TemplateSelectorProps> = ({
|
|||||||
height={102}
|
height={102}
|
||||||
active={value === item.id}
|
active={value === item.id}
|
||||||
disabled={item.status !== 'enabled'}
|
disabled={item.status !== 'enabled'}
|
||||||
onClick={() => onChange?.(item.id)}
|
onClick={() => onChange?.(item.id, item)}
|
||||||
>
|
>
|
||||||
<TemplateContent>
|
<TemplateContent>
|
||||||
<div className="name">
|
<div className="name">
|
||||||
@@ -76,14 +75,14 @@ const TemplateSelector: React.FC<TemplateSelectorProps> = ({
|
|||||||
<IconFont className="icon" type="icon-model" /> 镜像:
|
<IconFont className="icon" type="icon-model" /> 镜像:
|
||||||
</span>
|
</span>
|
||||||
<AutoTooltip ghost minWidth={20}>
|
<AutoTooltip ghost minWidth={20}>
|
||||||
<span className="value">{item.image || '-'}</span>
|
<span className="value">{item.spec?.image || '-'}</span>
|
||||||
</AutoTooltip>
|
</AutoTooltip>
|
||||||
</div>
|
</div>
|
||||||
<div className="info">
|
<div className="info">
|
||||||
<span>
|
<span>
|
||||||
<IconFont className="icon" type="icon-storage-outlined" /> 挂载:
|
<IconFont className="icon" type="icon-storage-outlined" /> 挂载:
|
||||||
</span>
|
</span>
|
||||||
<span className="value">{item.volumeMount || '-'}</span>
|
<span className="value">{item.spec?.volumeMount || '-'}</span>
|
||||||
</div>
|
</div>
|
||||||
</TemplateContent>
|
</TemplateContent>
|
||||||
</TemplateCard>
|
</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;
|
|
||||||
@@ -1,55 +1,35 @@
|
|||||||
import { request } from '@umijs/max';
|
import { request } from '@umijs/max';
|
||||||
import { FormData, ListItem } from '../types';
|
import { FormData, ListItem } from '../types';
|
||||||
|
|
||||||
export const GPU_SERVICE_PUBLIC_KEY_API = (namespace: string) =>
|
export const GPU_SERVICE_PUBLIC_KEY_API = '/gpu-instance-ssh-public-keys/data';
|
||||||
`/proxy/apis/worker.gpustack.ai/v1/namespaces/${namespace}/instancesshpublickeys`;
|
|
||||||
|
|
||||||
export async function queryGPUServicePublicKeys(
|
export async function queryGPUServicePublicKeys(
|
||||||
params: Global.K8sSearchParams,
|
params: Global.SearchParams,
|
||||||
options?: any
|
options?: any
|
||||||
) {
|
) {
|
||||||
return request<Global.K8sPageResponse<ListItem>>(
|
return request<ListItem>(GPU_SERVICE_PUBLIC_KEY_API, {
|
||||||
GPU_SERVICE_PUBLIC_KEY_API(params.namespace || ''),
|
method: 'GET',
|
||||||
{
|
params,
|
||||||
method: 'GET',
|
cancelToken: options?.token
|
||||||
params,
|
});
|
||||||
cancelToken: options?.token
|
|
||||||
}
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function createGPUServicePublicKey(params: {
|
export async function createGPUServicePublicKey(params: { data: FormData }) {
|
||||||
namespace: string;
|
return request<ListItem>(GPU_SERVICE_PUBLIC_KEY_API, {
|
||||||
data: FormData;
|
|
||||||
}) {
|
|
||||||
return request<ListItem>(GPU_SERVICE_PUBLIC_KEY_API(params.namespace), {
|
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
data: params.data
|
data: params.data
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function updateGPUServicePublicKey(params: {
|
export async function updateGPUServicePublicKey(params: { data: FormData }) {
|
||||||
namespace: string;
|
return request<ListItem>(`${GPU_SERVICE_PUBLIC_KEY_API}`, {
|
||||||
id: number;
|
method: 'PUT',
|
||||||
data: FormData;
|
data: params.data
|
||||||
}) {
|
});
|
||||||
return request<ListItem>(
|
|
||||||
`${GPU_SERVICE_PUBLIC_KEY_API(params.namespace)}/${params.id}`,
|
|
||||||
{
|
|
||||||
method: 'PUT',
|
|
||||||
data: params.data
|
|
||||||
}
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function deleteGPUServicePublicKey(params: {
|
export async function deleteGPUServicePublicKey(id: number) {
|
||||||
namespace: string;
|
return request(`${GPU_SERVICE_PUBLIC_KEY_API}/${id}`, {
|
||||||
id: number;
|
method: 'DELETE'
|
||||||
}) {
|
});
|
||||||
return request(
|
|
||||||
`${GPU_SERVICE_PUBLIC_KEY_API(params.namespace)}/${params.id}`,
|
|
||||||
{
|
|
||||||
method: 'DELETE'
|
|
||||||
}
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,31 +1,42 @@
|
|||||||
import { INPUT_WIDTH } from '@/constants';
|
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 { useIntl } from '@umijs/max';
|
||||||
import { Button, Form, message } from 'antd';
|
import { Button, Form } from 'antd';
|
||||||
import React from 'react';
|
import React, { useEffect } from 'react';
|
||||||
import PageBox from '../../_components/page-box';
|
import PageBox from '../../_components/page-box';
|
||||||
|
import useGetSshkey from './services/use-get-sshkey';
|
||||||
interface PublicKeyFormData {
|
import useUpdateSshkey from './services/use-update-sshkey';
|
||||||
public_key?: string;
|
import { FormData } from './types';
|
||||||
}
|
|
||||||
|
|
||||||
const PLACEHOLDER = `以 ssh-rsa 或 ssh-ed25519 开头,每个 Public Key 单独一行
|
|
||||||
|
|
||||||
查看 Public Key:
|
|
||||||
- RSA
|
|
||||||
cat ~/.ssh/id_rsa.pub
|
|
||||||
- Ed25519
|
|
||||||
cat ~/.ssh/id_ed25519.pub`;
|
|
||||||
|
|
||||||
const GPUServicePublicKeys: React.FC = () => {
|
const GPUServicePublicKeys: React.FC = () => {
|
||||||
const intl = useIntl();
|
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 () => {
|
const handleSave = async () => {
|
||||||
|
form.submit();
|
||||||
|
};
|
||||||
|
|
||||||
|
const onFinish = async (values: FormData) => {
|
||||||
try {
|
try {
|
||||||
await form.validateFields();
|
await updateSshkey({
|
||||||
message.success('操作成功');
|
data: values
|
||||||
} catch {
|
});
|
||||||
|
} catch (error) {
|
||||||
// ignore
|
// ignore
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -36,12 +47,32 @@ const GPUServicePublicKeys: React.FC = () => {
|
|||||||
style={{ width: INPUT_WIDTH.large }}
|
style={{ width: INPUT_WIDTH.large }}
|
||||||
name="gpuServicePublicKeyForm"
|
name="gpuServicePublicKeyForm"
|
||||||
form={form}
|
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
|
<Textarea
|
||||||
required
|
required
|
||||||
label="SSH Public Key"
|
label={intl.formatMessage({ id: 'gpuservice.publicKey.label' })}
|
||||||
placeholder={PLACEHOLDER}
|
placeholder={intl.formatMessage({
|
||||||
|
id: 'gpuservice.publicKey.placeholder'
|
||||||
|
})}
|
||||||
trim={false}
|
trim={false}
|
||||||
alwaysFocus
|
alwaysFocus
|
||||||
autoSize={{ minRows: 8, maxRows: 16 }}
|
autoSize={{ minRows: 8, maxRows: 16 }}
|
||||||
@@ -50,6 +81,7 @@ const GPUServicePublicKeys: React.FC = () => {
|
|||||||
</Form.Item>
|
</Form.Item>
|
||||||
<Button
|
<Button
|
||||||
type="primary"
|
type="primary"
|
||||||
|
loading={updating}
|
||||||
style={{ width: 120, marginTop: 24 }}
|
style={{ width: 120, marginTop: 24 }}
|
||||||
onClick={handleSave}
|
onClick={handleSave}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -1,34 +1,17 @@
|
|||||||
import { useModel } from '@@/plugin-model';
|
|
||||||
import { useQueryData } from '@gpustack/core-ui';
|
import { useQueryData } from '@gpustack/core-ui';
|
||||||
import { useCallback } from 'react';
|
import { useCallback } from 'react';
|
||||||
import { apiVersion, KindMapping } from '../../constants';
|
|
||||||
import { createGPUServicePublicKey } from '../apis';
|
import { createGPUServicePublicKey } from '../apis';
|
||||||
import { ListItem } from '../types';
|
import { FormData, ListItem } from '../types';
|
||||||
|
|
||||||
interface CreateSshkeyParams {
|
interface CreateSshkeyParams {
|
||||||
name: string;
|
data: FormData;
|
||||||
data: string;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function useCreateSshkey() {
|
export default function useCreateSshkey() {
|
||||||
const { initialState } = useModel('@@initialState');
|
|
||||||
const namespace = initialState?.currentUser?.org_name || 'default';
|
|
||||||
|
|
||||||
const fetchDetail = useCallback(
|
const fetchDetail = useCallback(
|
||||||
(params: CreateSshkeyParams) =>
|
(params: CreateSshkeyParams) =>
|
||||||
createGPUServicePublicKey({
|
createGPUServicePublicKey({ data: params.data }),
|
||||||
namespace,
|
[]
|
||||||
data: {
|
|
||||||
apiVersion,
|
|
||||||
kind: KindMapping.sshPublicKey,
|
|
||||||
metadata: {
|
|
||||||
name: params.name,
|
|
||||||
namespace
|
|
||||||
},
|
|
||||||
data: params.data
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
[namespace]
|
|
||||||
);
|
);
|
||||||
|
|
||||||
const { detailData, loading, cancelRequest, fetchData } = useQueryData<
|
const { detailData, loading, cancelRequest, fetchData } = useQueryData<
|
||||||
|
|||||||
@@ -1,26 +1,20 @@
|
|||||||
import { useModel } from '@@/plugin-model';
|
|
||||||
import { useQueryData } from '@gpustack/core-ui';
|
import { useQueryData } from '@gpustack/core-ui';
|
||||||
import { useCallback } from 'react';
|
import { useCallback } from 'react';
|
||||||
import { queryGPUServicePublicKeys } from '../apis';
|
import { queryGPUServicePublicKeys } from '../apis';
|
||||||
import { ListItem } from '../types';
|
import { ListItem } from '../types';
|
||||||
|
|
||||||
export default function useGetSshkey() {
|
export default function useGetSshkey() {
|
||||||
const { initialState } = useModel('@@initialState');
|
|
||||||
const namespace = initialState?.currentUser?.org_name || 'default';
|
|
||||||
|
|
||||||
const fetchDetail = useCallback(
|
const fetchDetail = useCallback(
|
||||||
(params: Global.K8sSearchParams = {}, options?: any) =>
|
(params: Global.SearchParams = { page: 1, perPage: 100 }, options?: any) =>
|
||||||
queryGPUServicePublicKeys({ ...params, namespace }, options),
|
queryGPUServicePublicKeys(params, options),
|
||||||
[namespace]
|
[]
|
||||||
);
|
);
|
||||||
|
|
||||||
const { detailData, loading, cancelRequest, fetchData } = useQueryData<
|
const { detailData, loading, cancelRequest, fetchData } =
|
||||||
Global.K8sPageResponse<ListItem>,
|
useQueryData<ListItem>({
|
||||||
Global.K8sSearchParams
|
fetchDetail,
|
||||||
>({
|
key: 'sshkey'
|
||||||
fetchDetail,
|
});
|
||||||
key: 'sshkey'
|
|
||||||
});
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
detailData,
|
detailData,
|
||||||
|
|||||||
@@ -1,36 +1,17 @@
|
|||||||
import { useModel } from '@@/plugin-model';
|
|
||||||
import { useQueryData } from '@gpustack/core-ui';
|
import { useQueryData } from '@gpustack/core-ui';
|
||||||
import { useCallback } from 'react';
|
import { useCallback } from 'react';
|
||||||
import { apiVersion, KindMapping } from '../../constants';
|
|
||||||
import { updateGPUServicePublicKey } from '../apis';
|
import { updateGPUServicePublicKey } from '../apis';
|
||||||
import { ListItem } from '../types';
|
import { FormData, ListItem } from '../types';
|
||||||
|
|
||||||
interface UpdateSshkeyParams {
|
interface UpdateSshkeyParams {
|
||||||
id: number;
|
data: FormData;
|
||||||
name: string;
|
|
||||||
data: string;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function useUpdateSshkey() {
|
export default function useUpdateSshkey() {
|
||||||
const { initialState } = useModel('@@initialState');
|
|
||||||
const namespace = initialState?.currentUser?.org_name || 'default';
|
|
||||||
|
|
||||||
const fetchDetail = useCallback(
|
const fetchDetail = useCallback(
|
||||||
(params: UpdateSshkeyParams) =>
|
(params: UpdateSshkeyParams) =>
|
||||||
updateGPUServicePublicKey({
|
updateGPUServicePublicKey({ data: params.data }),
|
||||||
namespace,
|
[]
|
||||||
id: params.id,
|
|
||||||
data: {
|
|
||||||
apiVersion,
|
|
||||||
kind: KindMapping.sshPublicKey,
|
|
||||||
metadata: {
|
|
||||||
name: params.name,
|
|
||||||
namespace
|
|
||||||
},
|
|
||||||
data: params.data
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
[namespace]
|
|
||||||
);
|
);
|
||||||
|
|
||||||
const { detailData, loading, cancelRequest, fetchData } = useQueryData<
|
const { detailData, loading, cancelRequest, fetchData } = useQueryData<
|
||||||
|
|||||||
@@ -1,5 +1,9 @@
|
|||||||
export interface FormData extends Global.K8sCommonData {
|
export interface FormData {
|
||||||
data: string;
|
name: string;
|
||||||
|
description: string;
|
||||||
|
spec: {
|
||||||
|
data: string;
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ListItem extends FormData {
|
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 { request } from '@umijs/max';
|
||||||
import { FormData, ListItem } from '../config/types';
|
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(
|
export async function queryGPUServiceTemplates(
|
||||||
params: Global.SearchParams,
|
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 {
|
import {
|
||||||
AutoTooltip,
|
AutoTooltip,
|
||||||
DropdownActions,
|
DropdownActions,
|
||||||
IconFont,
|
IconFont,
|
||||||
TemplateCard
|
TemplateCard
|
||||||
} from '@gpustack/core-ui';
|
} from '@gpustack/core-ui';
|
||||||
import { Button } from 'antd';
|
import { useIntl } from '@umijs/max';
|
||||||
|
import { Button, Tag } from 'antd';
|
||||||
import styled from 'styled-components';
|
import styled from 'styled-components';
|
||||||
import { templateActions } from '../config';
|
import { templateActions } from '../config';
|
||||||
import { ListItem } from '../config/types';
|
import { ListItem } from '../config/types';
|
||||||
@@ -24,16 +35,21 @@ const Header = styled.div`
|
|||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
height: 24px;
|
height: 24px;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
.title {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const CardTitle = styled.div`
|
const CardName = styled.div`
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
color: var(--ant-color-text);
|
color: var(--ant-color-text);
|
||||||
|
margin-bottom: 8px;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
flex: 1;
|
width: 100%;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
`;
|
`;
|
||||||
|
|
||||||
@@ -61,12 +77,18 @@ const InfoItem = styled.div`
|
|||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
|
const LogoImg = styled.img`
|
||||||
|
object-fit: contain;
|
||||||
|
`;
|
||||||
|
|
||||||
interface TemplateCardProps {
|
interface TemplateCardProps {
|
||||||
data: ListItem;
|
data: ListItem;
|
||||||
onSelect?: (item: { action: string; data: ListItem }) => void;
|
onSelect?: (item: { action: string; data: ListItem }) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const TemplateCardItem: React.FC<TemplateCardProps> = ({ data, onSelect }) => {
|
const TemplateCardItem: React.FC<TemplateCardProps> = ({ data, onSelect }) => {
|
||||||
|
const intl = useIntl();
|
||||||
|
|
||||||
const handleOnSelect = (item: any) => {
|
const handleOnSelect = (item: any) => {
|
||||||
onSelect?.({ action: item.key, data });
|
onSelect?.({ action: item.key, data });
|
||||||
};
|
};
|
||||||
@@ -75,6 +97,50 @@ const TemplateCardItem: React.FC<TemplateCardProps> = ({ data, onSelect }) => {
|
|||||||
e.stopPropagation();
|
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 = () => {
|
const renderActions = () => {
|
||||||
return (
|
return (
|
||||||
<span onClick={handleonClickAction} className="operations">
|
<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 (
|
return (
|
||||||
<StyledCard
|
<StyledCard
|
||||||
clickable={false}
|
clickable={false}
|
||||||
@@ -112,46 +169,24 @@ const TemplateCardItem: React.FC<TemplateCardProps> = ({ data, onSelect }) => {
|
|||||||
ghost
|
ghost
|
||||||
header={
|
header={
|
||||||
<Header>
|
<Header>
|
||||||
<CardTitle>
|
<div className="title">{renderLogo()}</div>
|
||||||
<AutoTooltip ghost minWidth={20}>
|
|
||||||
{data.name || '-'}
|
|
||||||
</AutoTooltip>
|
|
||||||
</CardTitle>
|
|
||||||
{renderActions()}
|
{renderActions()}
|
||||||
</Header>
|
</Header>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<Content>
|
<Content>
|
||||||
<InfoItem>
|
<CardName>
|
||||||
<span>
|
|
||||||
<IconFont className="icon" type="icon-model" /> 镜像:
|
|
||||||
</span>
|
|
||||||
<AutoTooltip ghost minWidth={20}>
|
<AutoTooltip ghost minWidth={20}>
|
||||||
<span className="value">{data.image || '-'}</span>
|
{data.name || '-'}
|
||||||
</AutoTooltip>
|
</AutoTooltip>
|
||||||
</InfoItem>
|
</CardName>
|
||||||
<InfoItem>
|
<InfoItem>
|
||||||
<span>
|
<span>
|
||||||
<IconFont className="icon" type="icon-storage-outlined" /> 挂载:
|
<IconFont className="icon" type="icon-model" />{' '}
|
||||||
|
{intl.formatMessage({ id: 'gpuservice.template.card.image' })}:
|
||||||
</span>
|
</span>
|
||||||
<AutoTooltip ghost minWidth={20}>
|
<AutoTooltip ghost minWidth={20}>
|
||||||
<span className="value">{data.volumeMount || '-'}</span>
|
<span className="value">{data.spec?.image || '-'}</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>
|
|
||||||
</AutoTooltip>
|
</AutoTooltip>
|
||||||
</InfoItem>
|
</InfoItem>
|
||||||
</Content>
|
</Content>
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { icons } from '@gpustack/core-ui';
|
import { icons } from '@gpustack/core-ui';
|
||||||
|
import { ImagePullPolicy } from './types';
|
||||||
|
|
||||||
export const TemplateStatusValueMap = {
|
export const TemplateStatusValueMap = {
|
||||||
Enabled: 'enabled',
|
Enabled: 'enabled',
|
||||||
@@ -6,19 +7,45 @@ export const TemplateStatusValueMap = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const TemplateStatusLabelMap: Record<string, string> = {
|
export const TemplateStatusLabelMap: Record<string, string> = {
|
||||||
[TemplateStatusValueMap.Enabled]: '启用',
|
[TemplateStatusValueMap.Enabled]: 'common.button.enable',
|
||||||
[TemplateStatusValueMap.Disabled]: '禁用'
|
[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 = [
|
export const templateActions = [
|
||||||
{
|
{
|
||||||
label: '编辑',
|
label: 'common.button.edit',
|
||||||
key: 'edit',
|
key: 'edit',
|
||||||
|
locale: true,
|
||||||
icon: icons.EditOutlined
|
icon: icons.EditOutlined
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: '删除',
|
label: 'common.button.delete',
|
||||||
key: 'delete',
|
key: 'delete',
|
||||||
|
locale: true,
|
||||||
icon: icons.DeleteOutlined,
|
icon: icons.DeleteOutlined,
|
||||||
danger: true
|
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'
|
|
||||||
}
|
|
||||||
];
|
|
||||||
@@ -1,8 +1,10 @@
|
|||||||
export interface PortItem {
|
export interface PortItem {
|
||||||
protocol: 'udp' | 'tcp';
|
protocol: 'UDP' | 'TCP';
|
||||||
port: number;
|
port: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type ImagePullPolicy = 'Always' | 'IfNotPresent' | 'Never';
|
||||||
|
|
||||||
export interface EnvItem {
|
export interface EnvItem {
|
||||||
name: string;
|
name: string;
|
||||||
value: string;
|
value: string;
|
||||||
@@ -10,18 +12,21 @@ export interface EnvItem {
|
|||||||
|
|
||||||
export interface FormData {
|
export interface FormData {
|
||||||
name: string;
|
name: string;
|
||||||
image: string;
|
description: string;
|
||||||
// imagePullPolicy: string;
|
manufacturer: string;
|
||||||
// imagePullSecret: {
|
spec: {
|
||||||
// name: string;
|
image: string;
|
||||||
// };
|
imagePullPolicy: ImagePullPolicy;
|
||||||
command: string[];
|
command: string[];
|
||||||
ports: PortItem[];
|
ports: PortItem[];
|
||||||
env: EnvItem[];
|
env: EnvItem[];
|
||||||
volumeMount: string;
|
volumeMount: string;
|
||||||
resources: {
|
resources: {
|
||||||
cpu: string;
|
cpu: string;
|
||||||
ram: string;
|
ram: string;
|
||||||
|
localStorage: string;
|
||||||
|
accelerator: string;
|
||||||
|
};
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,16 @@
|
|||||||
import { Input as CInput, Textarea } from '@gpustack/core-ui';
|
import {
|
||||||
import { Form } from 'antd';
|
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 { FormData } from '../config/types';
|
||||||
import Env from './env';
|
import Env from './env';
|
||||||
import Ports from './ports';
|
import Ports from './ports';
|
||||||
@@ -9,72 +20,146 @@ interface BasicProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const Basic: React.FC<BasicProps> = ({ page = 'template' }) => {
|
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 manufacturerOptions = useMemo(
|
||||||
const value = e.target.value;
|
() =>
|
||||||
const list = value
|
Object.values(GPUsConfigs).map((item) => ({
|
||||||
.split('\n')
|
label: item.label,
|
||||||
.map((line) => line.trim())
|
value: item.value
|
||||||
.filter((line) => line.length > 0);
|
})),
|
||||||
form.setFieldValue('command', list);
|
[]
|
||||||
};
|
);
|
||||||
|
|
||||||
const command = Form.useWatch('command', form);
|
|
||||||
const commandText = Array.isArray(command) ? command.join('\n') : '';
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{page === 'template' && (
|
{page === 'template' && (
|
||||||
<Form.Item<FormData>
|
<>
|
||||||
name="name"
|
<Form.Item<FormData>
|
||||||
rules={[
|
name="name"
|
||||||
{
|
rules={[
|
||||||
required: true,
|
{
|
||||||
message: '请输入模板名称'
|
required: true,
|
||||||
}
|
message: getRuleMessage('input', 'common.table.name')
|
||||||
]}
|
}
|
||||||
>
|
]}
|
||||||
<CInput.Input label="名称" required />
|
>
|
||||||
</Form.Item>
|
<CInput.Input
|
||||||
|
label={intl.formatMessage({ id: 'common.table.name' })}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
<Form.Item<FormData>
|
<Form.Item<FormData>
|
||||||
name="image"
|
name="manufacturer"
|
||||||
rules={[
|
rules={[
|
||||||
{
|
{
|
||||||
required: true,
|
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>
|
<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
|
<Textarea
|
||||||
label="容器启动命令"
|
label={intl.formatMessage({ id: 'gpuservice.template.command' })}
|
||||||
placeholder={'每行一个参数,例如:\n/bin/bash\n-c\nyour command'}
|
placeholder={intl.formatMessage({
|
||||||
|
id: 'gpuservice.template.command.placeholder'
|
||||||
|
})}
|
||||||
trim={false}
|
trim={false}
|
||||||
alwaysFocus
|
alwaysFocus
|
||||||
scaleSize
|
scaleSize
|
||||||
value={commandText}
|
|
||||||
onChange={handleCommandChange}
|
|
||||||
/>
|
/>
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
<Form.Item<FormData> name="volumeMount">
|
|
||||||
<CInput.Input label="挂载路径" placeholder="例如:/workspace" />
|
<Flex gap={16}>
|
||||||
</Form.Item>
|
|
||||||
<div style={{ display: 'flex', gap: 16 }}>
|
|
||||||
<div style={{ flex: 1 }}>
|
<div style={{ flex: 1 }}>
|
||||||
<Form.Item<FormData> name={['resources', 'cpu']}>
|
<Form.Item<FormData> name={['spec', 'volumeMount']}>
|
||||||
<CInput.Input label="CPU" placeholder="例如:4" />
|
<CInput.Input
|
||||||
|
label={intl.formatMessage({ id: 'gpuservice.template.mountPath' })}
|
||||||
|
placeholder={intl.formatMessage({
|
||||||
|
id: 'clusters.volume.mountPath.format'
|
||||||
|
})}
|
||||||
|
/>
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
</div>
|
</div>
|
||||||
<div style={{ flex: 1 }}>
|
<div style={{ flex: 1 }}>
|
||||||
<Form.Item<FormData> name={['resources', 'ram']}>
|
<Form.Item<FormData>
|
||||||
<CInput.Input label="内存" placeholder="例如:8Gi" />
|
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>
|
</Form.Item>
|
||||||
</div>
|
</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 />
|
<Ports />
|
||||||
<Env />
|
<Env />
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { Input as CInput, MetadataList } from '@gpustack/core-ui';
|
import { Input as CInput, MetadataList } from '@gpustack/core-ui';
|
||||||
|
import { useIntl } from '@umijs/max';
|
||||||
import { Form } from 'antd';
|
import { Form } from 'antd';
|
||||||
import { EnvItem as EnvItemType, FormData } from '../config/types';
|
import { EnvItem as EnvItemType, FormData } from '../config/types';
|
||||||
|
|
||||||
@@ -9,12 +10,15 @@ interface EnvItemProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const EnvItem: React.FC<EnvItemProps> = ({ item, onChange }) => {
|
const EnvItem: React.FC<EnvItemProps> = ({ item, onChange }) => {
|
||||||
|
const intl = useIntl();
|
||||||
return (
|
return (
|
||||||
<div style={{ display: 'flex', gap: 12, width: '100%' }}>
|
<div style={{ display: 'flex', gap: 12, width: '100%' }}>
|
||||||
<div style={{ flex: 1 }}>
|
<div style={{ flex: 1 }}>
|
||||||
<CInput.Input
|
<CInput.Input
|
||||||
value={item.name}
|
value={item.name}
|
||||||
placeholder="名称"
|
placeholder={intl.formatMessage({
|
||||||
|
id: 'gpuservice.template.env.name'
|
||||||
|
})}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
onChange({
|
onChange({
|
||||||
...item,
|
...item,
|
||||||
@@ -27,7 +31,9 @@ const EnvItem: React.FC<EnvItemProps> = ({ item, onChange }) => {
|
|||||||
<div style={{ flex: 1 }}>
|
<div style={{ flex: 1 }}>
|
||||||
<CInput.Input
|
<CInput.Input
|
||||||
value={item.value}
|
value={item.value}
|
||||||
placeholder="值"
|
placeholder={intl.formatMessage({
|
||||||
|
id: 'gpuservice.template.env.value'
|
||||||
|
})}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
onChange({
|
onChange({
|
||||||
...item,
|
...item,
|
||||||
@@ -42,11 +48,12 @@ const EnvItem: React.FC<EnvItemProps> = ({ item, onChange }) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const Env: React.FC = () => {
|
const Env: React.FC = () => {
|
||||||
|
const intl = useIntl();
|
||||||
const form = Form.useFormInstance<FormData>();
|
const form = Form.useFormInstance<FormData>();
|
||||||
const env = Form.useWatch('env', form) || [];
|
const env = Form.useWatch(['spec', 'env'], form) || [];
|
||||||
|
|
||||||
const updateEnv = (list: EnvItemType[]) => {
|
const updateEnv = (list: EnvItemType[]) => {
|
||||||
form.setFieldValue('env', list);
|
form.setFieldValue(['spec', 'env'], list);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleAdd = () => {
|
const handleAdd = () => {
|
||||||
@@ -73,7 +80,7 @@ const Env: React.FC = () => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Form.Item<FormData>
|
<Form.Item<FormData>
|
||||||
name="env"
|
name={['spec', 'env']}
|
||||||
rules={[
|
rules={[
|
||||||
{
|
{
|
||||||
validator: async (_, value) => {
|
validator: async (_, value) => {
|
||||||
@@ -82,7 +89,13 @@ const Env: React.FC = () => {
|
|||||||
}
|
}
|
||||||
const hasInvalid = value.some((item: EnvItemType) => !item.name);
|
const hasInvalid = value.some((item: EnvItemType) => !item.name);
|
||||||
if (hasInvalid) {
|
if (hasInvalid) {
|
||||||
return Promise.reject(new Error('请填写完整的环境变量'));
|
return Promise.reject(
|
||||||
|
new Error(
|
||||||
|
intl.formatMessage({
|
||||||
|
id: 'gpuservice.template.env.invalid'
|
||||||
|
})
|
||||||
|
)
|
||||||
|
);
|
||||||
}
|
}
|
||||||
return Promise.resolve();
|
return Promise.resolve();
|
||||||
}
|
}
|
||||||
@@ -91,8 +104,8 @@ const Env: React.FC = () => {
|
|||||||
>
|
>
|
||||||
<MetadataList
|
<MetadataList
|
||||||
dataList={env}
|
dataList={env}
|
||||||
btnText="添加环境变量"
|
btnText={intl.formatMessage({ id: 'gpuservice.template.env.add' })}
|
||||||
label="环境变量"
|
label={intl.formatMessage({ id: 'gpuservice.template.env' })}
|
||||||
onAdd={handleAdd}
|
onAdd={handleAdd}
|
||||||
onDelete={handleDelete}
|
onDelete={handleDelete}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { PageAction } from '@/config';
|
|||||||
import { PageActionType } from '@/config/types';
|
import { PageActionType } from '@/config/types';
|
||||||
import { Form } from 'antd';
|
import { Form } from 'antd';
|
||||||
import { forwardRef, useEffect, useImperativeHandle } from 'react';
|
import { forwardRef, useEffect, useImperativeHandle } from 'react';
|
||||||
|
import { DefaultImagePullPolicy } from '../config';
|
||||||
import { FormData, ListItem } from '../config/types';
|
import { FormData, ListItem } from '../config/types';
|
||||||
import Basic from './basic';
|
import Basic from './basic';
|
||||||
|
|
||||||
@@ -26,32 +27,22 @@ const GPUServiceTemplateForm: React.FC<TemplateFormProps> = forwardRef(
|
|||||||
|
|
||||||
if (action === PageAction.EDIT && currentData) {
|
if (action === PageAction.EDIT && currentData) {
|
||||||
form.setFieldsValue({
|
form.setFieldsValue({
|
||||||
name: currentData.name,
|
...currentData
|
||||||
image: currentData.image,
|
|
||||||
command: currentData.command || [],
|
|
||||||
ports: currentData.ports || [],
|
|
||||||
env: currentData.env || [],
|
|
||||||
volumeMount: currentData.volumeMount,
|
|
||||||
resources: {
|
|
||||||
cpu: currentData.resources?.cpu,
|
|
||||||
ram: currentData.resources?.ram
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
form.setFieldsValue({
|
|
||||||
command: [],
|
|
||||||
ports: [
|
|
||||||
{
|
|
||||||
protocol: 'tcp',
|
|
||||||
port: 22
|
|
||||||
}
|
|
||||||
],
|
|
||||||
env: []
|
|
||||||
});
|
|
||||||
}, [action, currentData, form, open]);
|
}, [action, currentData, form, open]);
|
||||||
|
|
||||||
|
const handleFinish = async (values: FormData) => {
|
||||||
|
await onFinish({
|
||||||
|
...values,
|
||||||
|
spec: {
|
||||||
|
...values.spec,
|
||||||
|
command: values.spec?.command?.filter(Boolean) ?? []
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
useImperativeHandle(ref, () => ({
|
useImperativeHandle(ref, () => ({
|
||||||
submit: () => {
|
submit: () => {
|
||||||
form.submit();
|
form.submit();
|
||||||
@@ -65,8 +56,21 @@ const GPUServiceTemplateForm: React.FC<TemplateFormProps> = forwardRef(
|
|||||||
<Form
|
<Form
|
||||||
name="gpuServiceTemplateForm"
|
name="gpuServiceTemplateForm"
|
||||||
form={form}
|
form={form}
|
||||||
onFinish={onFinish}
|
onFinish={handleFinish}
|
||||||
preserve={false}
|
preserve={false}
|
||||||
|
initialValues={{
|
||||||
|
spec: {
|
||||||
|
imagePullPolicy: DefaultImagePullPolicy,
|
||||||
|
command: [],
|
||||||
|
ports: [
|
||||||
|
{
|
||||||
|
protocol: 'TCP',
|
||||||
|
port: 22
|
||||||
|
}
|
||||||
|
],
|
||||||
|
env: []
|
||||||
|
}
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<Basic />
|
<Basic />
|
||||||
</Form>
|
</Form>
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import {
|
|||||||
MetadataList,
|
MetadataList,
|
||||||
Select as SealSelect
|
Select as SealSelect
|
||||||
} from '@gpustack/core-ui';
|
} from '@gpustack/core-ui';
|
||||||
|
import { useIntl } from '@umijs/max';
|
||||||
import { Form } from 'antd';
|
import { Form } from 'antd';
|
||||||
import { FormData, PortItem as PortItemType } from '../config/types';
|
import { FormData, PortItem as PortItemType } from '../config/types';
|
||||||
|
|
||||||
@@ -11,11 +12,11 @@ type PortProtocol = PortItemType['protocol'];
|
|||||||
const protocolOptions = [
|
const protocolOptions = [
|
||||||
{
|
{
|
||||||
label: 'UDP',
|
label: 'UDP',
|
||||||
value: 'udp'
|
value: 'UDP'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'TCP',
|
label: 'TCP',
|
||||||
value: 'tcp'
|
value: 'TCP'
|
||||||
}
|
}
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -41,7 +42,7 @@ const PortItem: React.FC<PortItemProps> = ({ item, index, onChange }) => {
|
|||||||
style={{ width: '100%' }}
|
style={{ width: '100%' }}
|
||||||
></SealSelect>
|
></SealSelect>
|
||||||
</div>
|
</div>
|
||||||
<div style={{ flex: 2 }}>
|
<div style={{ flex: 1 }}>
|
||||||
<CInputNumber
|
<CInputNumber
|
||||||
min={1}
|
min={1}
|
||||||
max={65535}
|
max={65535}
|
||||||
@@ -61,18 +62,19 @@ const PortItem: React.FC<PortItemProps> = ({ item, index, onChange }) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const Ports: React.FC = () => {
|
const Ports: React.FC = () => {
|
||||||
|
const intl = useIntl();
|
||||||
const form = Form.useFormInstance<FormData>();
|
const form = Form.useFormInstance<FormData>();
|
||||||
const ports = Form.useWatch('ports', form) || [];
|
const ports = Form.useWatch(['spec', 'ports'], form) || [];
|
||||||
|
|
||||||
const updatePorts = (list: PortItemType[]) => {
|
const updatePorts = (list: PortItemType[]) => {
|
||||||
form.setFieldValue('ports', list);
|
form.setFieldValue(['spec', 'ports'], list);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleAdd = () => {
|
const handleAdd = () => {
|
||||||
updatePorts([
|
updatePorts([
|
||||||
...ports,
|
...ports,
|
||||||
{
|
{
|
||||||
protocol: 'tcp',
|
protocol: 'TCP',
|
||||||
port: undefined as any
|
port: undefined as any
|
||||||
}
|
}
|
||||||
]);
|
]);
|
||||||
@@ -92,7 +94,7 @@ const Ports: React.FC = () => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Form.Item<FormData>
|
<Form.Item<FormData>
|
||||||
name="ports"
|
name={['spec', 'ports']}
|
||||||
rules={[
|
rules={[
|
||||||
{
|
{
|
||||||
validator: async (_, value) => {
|
validator: async (_, value) => {
|
||||||
@@ -103,7 +105,13 @@ const Ports: React.FC = () => {
|
|||||||
(item: PortItemType) => !item.protocol || !item.port
|
(item: PortItemType) => !item.protocol || !item.port
|
||||||
);
|
);
|
||||||
if (hasInvalidPort) {
|
if (hasInvalidPort) {
|
||||||
return Promise.reject(new Error('请填写完整的端口配置'));
|
return Promise.reject(
|
||||||
|
new Error(
|
||||||
|
intl.formatMessage({
|
||||||
|
id: 'gpuservice.template.ports.invalid'
|
||||||
|
})
|
||||||
|
)
|
||||||
|
);
|
||||||
}
|
}
|
||||||
return Promise.resolve();
|
return Promise.resolve();
|
||||||
}
|
}
|
||||||
@@ -112,8 +120,8 @@ const Ports: React.FC = () => {
|
|||||||
>
|
>
|
||||||
<MetadataList
|
<MetadataList
|
||||||
dataList={ports}
|
dataList={ports}
|
||||||
btnText="添加端口"
|
btnText={intl.formatMessage({ id: 'gpuservice.template.ports.add' })}
|
||||||
label="端口"
|
label={intl.formatMessage({ id: 'gpuservice.template.ports' })}
|
||||||
onAdd={handleAdd}
|
onAdd={handleAdd}
|
||||||
onDelete={handleDelete}
|
onDelete={handleDelete}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -7,9 +7,11 @@ import {
|
|||||||
InfiniteScrollerProvider,
|
InfiniteScrollerProvider,
|
||||||
NoResult
|
NoResult
|
||||||
} from '@gpustack/core-ui';
|
} from '@gpustack/core-ui';
|
||||||
|
import { useIntl } from '@umijs/max';
|
||||||
import { useMemoizedFn } from 'ahooks';
|
import { useMemoizedFn } from 'ahooks';
|
||||||
import { message } from 'antd';
|
|
||||||
import _ from 'lodash';
|
import _ from 'lodash';
|
||||||
|
import { useMemo } from 'react';
|
||||||
|
import { GPUsConfigs } from '../../resources/config/gpu-driver';
|
||||||
import PageBox from '../../_components/page-box';
|
import PageBox from '../../_components/page-box';
|
||||||
import {
|
import {
|
||||||
createGPUServiceTemplate,
|
createGPUServiceTemplate,
|
||||||
@@ -24,6 +26,7 @@ import { FormData, ListItem } from './config/types';
|
|||||||
import useCreateTemplate from './hooks/use-create-template';
|
import useCreateTemplate from './hooks/use-create-template';
|
||||||
|
|
||||||
const GPUServiceTemplates: React.FC = () => {
|
const GPUServiceTemplates: React.FC = () => {
|
||||||
|
const intl = useIntl();
|
||||||
const {
|
const {
|
||||||
dataSource,
|
dataSource,
|
||||||
rowSelection,
|
rowSelection,
|
||||||
@@ -32,14 +35,15 @@ const GPUServiceTemplates: React.FC = () => {
|
|||||||
fetchData,
|
fetchData,
|
||||||
handleDelete,
|
handleDelete,
|
||||||
handleSearch,
|
handleSearch,
|
||||||
handleNameChange
|
handleNameChange,
|
||||||
|
handleQueryChange
|
||||||
} = useTableFetch<ListItem>({
|
} = useTableFetch<ListItem>({
|
||||||
fetchAPI: queryGPUServiceTemplates,
|
fetchAPI: queryGPUServiceTemplates,
|
||||||
deleteAPI: deleteGPUServiceTemplate,
|
deleteAPI: deleteGPUServiceTemplate,
|
||||||
API: GPU_SERVICE_TEMPLATES_API,
|
API: GPU_SERVICE_TEMPLATES_API,
|
||||||
watch: false,
|
watch: false,
|
||||||
isInfiniteScroll: true,
|
isInfiniteScroll: true,
|
||||||
contentForDelete: 'GPU 实例模板',
|
contentForDelete: intl.formatMessage({ id: 'gpuservice.template' }),
|
||||||
defaultQueryParams: {
|
defaultQueryParams: {
|
||||||
perPage: 24
|
perPage: 24
|
||||||
}
|
}
|
||||||
@@ -47,12 +51,37 @@ const GPUServiceTemplates: React.FC = () => {
|
|||||||
const { openTemplateModalStatus, openTemplateModal, closeTemplateModal } =
|
const { openTemplateModalStatus, openTemplateModal, closeTemplateModal } =
|
||||||
useCreateTemplate();
|
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 = () => {
|
const handleAddTemplate = () => {
|
||||||
openTemplateModal(PageAction.CREATE, '添加实例模板');
|
openTemplateModal(
|
||||||
|
PageAction.CREATE,
|
||||||
|
intl.formatMessage({ id: 'gpuservice.template.add' })
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleEditTemplate = (row: ListItem) => {
|
const handleEditTemplate = (row: ListItem) => {
|
||||||
openTemplateModal(PageAction.EDIT, '编辑实例模板', row);
|
openTemplateModal(
|
||||||
|
PageAction.EDIT,
|
||||||
|
intl.formatMessage({ id: 'gpuservice.template.edit' }),
|
||||||
|
row
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleModalOk = async (data: FormData) => {
|
const handleModalOk = async (data: FormData) => {
|
||||||
@@ -67,9 +96,8 @@ const GPUServiceTemplates: React.FC = () => {
|
|||||||
}
|
}
|
||||||
closeTemplateModal();
|
closeTemplateModal();
|
||||||
handleSearch();
|
handleSearch();
|
||||||
message.success('操作成功');
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
message.error('操作失败');
|
// ignore
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -101,13 +129,20 @@ const GPUServiceTemplates: React.FC = () => {
|
|||||||
widths={{
|
widths={{
|
||||||
input: 230
|
input: 230
|
||||||
}}
|
}}
|
||||||
inputHolder="按名称过滤"
|
inputHolder={intl.formatMessage({
|
||||||
buttonText="添加实例模板"
|
id: 'gpuservice.template.filter.name'
|
||||||
|
})}
|
||||||
|
selectHolder={intl.formatMessage({
|
||||||
|
id: 'gpuservice.template.filter.vendor'
|
||||||
|
})}
|
||||||
|
buttonText={intl.formatMessage({ id: 'gpuservice.template.add' })}
|
||||||
handleClickPrimary={handleAddTemplate}
|
handleClickPrimary={handleAddTemplate}
|
||||||
handleSearch={handleSearch}
|
handleSearch={handleSearch}
|
||||||
|
handleSelectChange={handleFilterByManufacturer}
|
||||||
handleInputChange={handleNameChange}
|
handleInputChange={handleNameChange}
|
||||||
rowSelection={rowSelection}
|
rowSelection={rowSelection}
|
||||||
showSelect={false}
|
showSelect={true}
|
||||||
|
selectOptions={manufacturerOptions}
|
||||||
/>
|
/>
|
||||||
<InfiniteScrollerProvider
|
<InfiniteScrollerProvider
|
||||||
value={{
|
value={{
|
||||||
@@ -130,11 +165,17 @@ const GPUServiceTemplates: React.FC = () => {
|
|||||||
dataSource={dataSource.dataList}
|
dataSource={dataSource.dataList}
|
||||||
image={<IconFont type="icon-instance-template-filled" />}
|
image={<IconFont type="icon-instance-template-filled" />}
|
||||||
filters={_.omit(queryParams, ['sort_by'])}
|
filters={_.omit(queryParams, ['sort_by'])}
|
||||||
noFoundText="未找到匹配的实例模板"
|
noFoundText={intl.formatMessage({
|
||||||
title="暂无实例模板"
|
id: 'noresult.gpuservice.template.nofound'
|
||||||
subTitle="创建一个实例模板后会显示在这里"
|
})}
|
||||||
|
title={intl.formatMessage({
|
||||||
|
id: 'noresult.gpuservice.template.title'
|
||||||
|
})}
|
||||||
|
subTitle={intl.formatMessage({
|
||||||
|
id: 'noresult.gpuservice.template.subTitle'
|
||||||
|
})}
|
||||||
onClick={handleAddTemplate}
|
onClick={handleAddTemplate}
|
||||||
buttonText="立即添加"
|
buttonText={intl.formatMessage({ id: 'noresult.button.add' })}
|
||||||
/>
|
/>
|
||||||
</InfiniteScrollerProvider>
|
</InfiniteScrollerProvider>
|
||||||
<AddModal
|
<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
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -29,7 +29,7 @@ export const GPUsConfigs: Record<
|
|||||||
{ label: string; value: string; runtime: string; driver: string }
|
{ label: string; value: string; runtime: string; driver: string }
|
||||||
> = {
|
> = {
|
||||||
[GPUDriverMap.NVIDIA]: {
|
[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,
|
value: GPUDriverMap.NVIDIA,
|
||||||
runtime: 'nvidia',
|
runtime: 'nvidia',
|
||||||
driver: 'nvidia-smi'
|
driver: 'nvidia-smi'
|
||||||
|
|||||||
Reference in New Issue
Block a user