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