diff --git a/src/atoms/user.ts b/src/atoms/user.ts index f36ac76f..b3bdf5f0 100644 --- a/src/atoms/user.ts +++ b/src/atoms/user.ts @@ -27,3 +27,14 @@ export const initialPasswordAtom = atomWithStorage( 'initialPassword', '' ); + +export const getCurrentOrganizationId = (): string => { + try { + const raw = localStorage.getItem('currentOrganizationId'); + if (raw) { + const value = JSON.parse(raw); + if (value != null) return String(value); + } + } catch {} + return 'gpustack-organization-1'; +}; diff --git a/src/layouts/index.tsx b/src/layouts/index.tsx index 84e892ff..8ea8abe5 100644 --- a/src/layouts/index.tsx +++ b/src/layouts/index.tsx @@ -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_nujs54seb6.js', + iconUrl: '//at.alicdn.com/t/c/font_4613488_t4uxqkp138r.js', isDarkTheme: userSettings.isDarkTheme, defaultColorPrimary: COLOR_PRIMARY }} diff --git a/src/locales/en-US/gpuservice.ts b/src/locales/en-US/gpuservice.ts index 50781bbd..be0cd514 100644 --- a/src/locales/en-US/gpuservice.ts +++ b/src/locales/en-US/gpuservice.ts @@ -11,7 +11,7 @@ export default { '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', + 'Separate arguments with spaces; wrap arguments containing spaces in quotes, e.g.: /bin/bash -c "echo hello world"', 'gpuservice.template.mountPath': 'Mount Path', 'gpuservice.template.containerDisk': 'Container Disk (Gi)', 'gpuservice.template.memory': 'Memory (Gi)', @@ -48,14 +48,16 @@ export default { 'gpuservice.instance.gpuCount.max': 'The current instance type supports at most {count} GPU(s)', 'gpuservice.instance.stock': 'Stock', - 'gpuservice.instance.memory': 'VRAM', - 'gpuservice.instance.ram': 'Memory', + 'gpuservice.instance.memory': 'Memory', + 'gpuservice.instance.ram': 'RAM', 'gpuservice.instance.search.type.placeholder': 'Search by name, VRAM, memory or vCPU', 'gpuservice.instance.search.template.placeholder': 'Search by template name, image or mount path', 'gpuservice.instance.template.image': 'Image', 'gpuservice.instance.template.mount': 'Mount', + 'gpuservice.instance.connect': 'Connect', + 'gpuservice.instance.connect.copySshCommand': 'Copy SSH Command', 'gpuservice.storage': 'Storage', 'gpuservice.storage.add': 'Add Storage', 'gpuservice.storage.edit': 'Edit Storage', diff --git a/src/locales/ja-JP/gpuservice.ts b/src/locales/ja-JP/gpuservice.ts index a86d8a1f..7b945784 100644 --- a/src/locales/ja-JP/gpuservice.ts +++ b/src/locales/ja-JP/gpuservice.ts @@ -11,7 +11,7 @@ export default { 'gpuservice.template.imagePullPolicy.never': '取得しない', 'gpuservice.template.command': 'コンテナ起動コマンド', 'gpuservice.template.command.placeholder': - '1行に1つの引数、例:\n/bin/bash\n-c\nyour command', + '引数はスペースで区切り、スペースを含む引数は引用符で囲んでください。例:/bin/bash -c "echo hello world"', 'gpuservice.template.mountPath': 'マウントパス', 'gpuservice.template.containerDisk': 'コンテナディスク (Gi)', 'gpuservice.template.memory': 'メモリ (Gi)', @@ -46,14 +46,16 @@ export default { 'gpuservice.instance.gpuCount.max': '現在のインスタンスタイプは最大 {count} 個の GPU をサポートします', 'gpuservice.instance.stock': '在庫', - 'gpuservice.instance.memory': 'VRAM', - 'gpuservice.instance.ram': 'メモリ', + 'gpuservice.instance.memory': 'Memory', + 'gpuservice.instance.ram': 'RAM', 'gpuservice.instance.search.type.placeholder': '名前、VRAM、メモリまたは vCPU で検索', 'gpuservice.instance.search.template.placeholder': 'テンプレート名、イメージまたはマウントパスで検索', 'gpuservice.instance.template.image': 'イメージ', 'gpuservice.instance.template.mount': 'マウント', + 'gpuservice.instance.connect': '接続', + 'gpuservice.instance.connect.copySshCommand': 'SSH コマンドをコピー', 'gpuservice.storage': 'ストレージ', 'gpuservice.storage.add': 'ストレージを追加', 'gpuservice.storage.edit': 'ストレージを編集', diff --git a/src/locales/ru-RU/gpuservice.ts b/src/locales/ru-RU/gpuservice.ts index 39a0bb6b..2842ed1c 100644 --- a/src/locales/ru-RU/gpuservice.ts +++ b/src/locales/ru-RU/gpuservice.ts @@ -12,7 +12,7 @@ export default { 'gpuservice.template.imagePullPolicy.never': 'Никогда не загружать', 'gpuservice.template.command': 'Команда запуска контейнера', 'gpuservice.template.command.placeholder': - 'Один аргумент на строку, например:\n/bin/bash\n-c\nyour command', + 'Разделяйте аргументы пробелами; аргументы с пробелами заключайте в кавычки, например: /bin/bash -c "echo hello world"', 'gpuservice.template.mountPath': 'Путь монтирования', 'gpuservice.template.containerDisk': 'Диск контейнера (Gi)', 'gpuservice.template.memory': 'Память (Gi)', @@ -48,14 +48,16 @@ export default { 'gpuservice.instance.gpuCount.max': 'Текущий тип экземпляра поддерживает максимум {count} GPU', 'gpuservice.instance.stock': 'Остаток', - 'gpuservice.instance.memory': 'VRAM', - 'gpuservice.instance.ram': 'Память', + 'gpuservice.instance.memory': 'Память', + 'gpuservice.instance.ram': 'RAM', 'gpuservice.instance.search.type.placeholder': 'Поиск по имени, VRAM, памяти или vCPU', 'gpuservice.instance.search.template.placeholder': 'Поиск по имени шаблона, образу или пути монтирования', 'gpuservice.instance.template.image': 'Образ', 'gpuservice.instance.template.mount': 'Монтирование', + 'gpuservice.instance.connect': 'Подключение', + 'gpuservice.instance.connect.copySshCommand': 'Скопировать команду SSH', 'gpuservice.storage': 'Хранилище', 'gpuservice.storage.add': 'Добавить хранилище', 'gpuservice.storage.edit': 'Редактировать хранилище', diff --git a/src/locales/tr-TR/gpuservice.ts b/src/locales/tr-TR/gpuservice.ts index ba8cee88..046e87ee 100644 --- a/src/locales/tr-TR/gpuservice.ts +++ b/src/locales/tr-TR/gpuservice.ts @@ -11,7 +11,7 @@ export default { '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', + 'Argümanları boşlukla ayırın; boşluk içeren argümanları tırnak içine alın, örn.: /bin/bash -c "echo hello world"', 'gpuservice.template.mountPath': 'Bağlama Yolu', 'gpuservice.template.containerDisk': 'Konteyner Diski (Gi)', 'gpuservice.template.memory': 'Bellek (Gi)', @@ -47,14 +47,16 @@ export default { 'gpuservice.instance.gpuCount.max': 'Mevcut örnek türü en fazla {count} GPU destekler', 'gpuservice.instance.stock': 'Stok', - 'gpuservice.instance.memory': 'VRAM', - 'gpuservice.instance.ram': 'Bellek', + 'gpuservice.instance.memory': 'Bellek', + 'gpuservice.instance.ram': 'RAM', 'gpuservice.instance.search.type.placeholder': "Ada, VRAM, belleğe veya vCPU'ya göre ara", 'gpuservice.instance.search.template.placeholder': 'Şablon adına, imaja veya bağlama yoluna göre ara', 'gpuservice.instance.template.image': 'İmaj', 'gpuservice.instance.template.mount': 'Bağlama', + 'gpuservice.instance.connect': 'Bağlan', + 'gpuservice.instance.connect.copySshCommand': 'SSH Komutunu Kopyala', 'gpuservice.storage': 'Depolama', 'gpuservice.storage.add': 'Depolama Ekle', 'gpuservice.storage.edit': 'Depolamayı Düzenle', diff --git a/src/locales/zh-CN/gpuservice.ts b/src/locales/zh-CN/gpuservice.ts index 9bf6c09b..f71874a6 100644 --- a/src/locales/zh-CN/gpuservice.ts +++ b/src/locales/zh-CN/gpuservice.ts @@ -11,7 +11,7 @@ export default { 'gpuservice.template.imagePullPolicy.never': '从不拉取', 'gpuservice.template.command': '容器启动命令', 'gpuservice.template.command.placeholder': - '每行一个参数,例如:\n/bin/bash\n-c\nyour command', + '使用空格分隔参数;含空格的参数请用引号包裹,例如:/bin/bash -c "echo hello world"', 'gpuservice.template.mountPath': '挂载路径', 'gpuservice.template.containerDisk': '容器磁盘(Gi)', 'gpuservice.template.memory': '内存(Gi)', @@ -52,6 +52,8 @@ export default { '搜索模板名称、镜像或挂载路径', 'gpuservice.instance.template.image': '镜像', 'gpuservice.instance.template.mount': '挂载', + 'gpuservice.instance.connect': '连接', + 'gpuservice.instance.connect.copySshCommand': '复制 SSH 命令', 'gpuservice.storage': '存储', 'gpuservice.storage.add': '添加存储', 'gpuservice.storage.edit': '编辑存储', diff --git a/src/pages/gpu-service/instances/apis/index.ts b/src/pages/gpu-service/instances/apis/index.ts index 3f84ac7b..ba0832a4 100644 --- a/src/pages/gpu-service/instances/apis/index.ts +++ b/src/pages/gpu-service/instances/apis/index.ts @@ -1,4 +1,5 @@ import { request } from '@umijs/max'; +import { omitPathParams } from '../../utils'; import { InstanceTypeItem, ListItem } from '../config/types'; export const GPU_SERVICE_INSTANCES_API = (params: { @@ -19,6 +20,9 @@ export async function queryGPUServiceInstances( }, options?: any ) { + if (!params.clusterID) { + return; + } return request>( GPU_SERVICE_INSTANCES_API({ namespace: params.namespace, @@ -26,7 +30,7 @@ export async function queryGPUServiceInstances( }), { method: 'GET', - params, + params: omitPathParams(params), cancelToken: options?.token } ); @@ -40,6 +44,9 @@ export async function createGPUServiceInstance( }, option?: any ) { + if (!params.clusterID) { + return; + } return request( GPU_SERVICE_INSTANCES_API({ namespace: params.namespace, @@ -62,6 +69,9 @@ export async function updateGPUServiceInstance( }, option?: any ) { + if (!params.clusterID) { + return; + } return request( `${GPU_SERVICE_INSTANCES_API({ namespace: params.namespace, @@ -83,6 +93,9 @@ export async function deleteGPUServiceInstance( }, option?: any ) { + if (!params.clusterID) { + return; + } return request( `${GPU_SERVICE_INSTANCES_API({ namespace: params.namespace, @@ -101,11 +114,14 @@ export async function queryGPUServiceInstanceTypes( params: Global.K8sSearchParams & { clusterID?: number }, options?: any ) { + if (!params.clusterID) { + return; + } return request>( GPU_SERVICE_INSTANCES_TYPE_API({ clusterID: params.clusterID }), { method: 'GET', - params, + params: omitPathParams(params), cancelToken: options?.token } ); @@ -118,11 +134,14 @@ export async function queryGPUServiceInstanceTypeItems( }, options?: any ) { + if (!params.clusterID) { + return; + } return request( `${GPU_SERVICE_INSTANCES_TYPE_API({ clusterID: params.clusterID })}/${params.name}`, { method: 'GET', - params, + params: omitPathParams(params), cancelToken: options?.token } ); diff --git a/src/pages/gpu-service/instances/components/add-modal.tsx b/src/pages/gpu-service/instances/components/add-modal.tsx index 88ae2655..6b1f082e 100644 --- a/src/pages/gpu-service/instances/components/add-modal.tsx +++ b/src/pages/gpu-service/instances/components/add-modal.tsx @@ -1,3 +1,4 @@ +import { PageAction } from '@/config'; import { PageActionType } from '@/config/types'; import Separator from '@/pages/llmodels/components/separator'; import { SearchOutlined } from '@ant-design/icons'; @@ -61,20 +62,32 @@ const AddModal: React.FC = ({ }) => { const intl = useIntl(); const form = useRef(null); + const autoSelectedRef = useRef(false); const [selectedInstanceType, setSelectedInstanceType] = useState(); const [selectedManufacturer, setSelectedManufacturer] = useState(); const [templateId, setTemplateId] = useState(); const [instanceKeyword, setInstanceKeyword] = useState(''); const [templateKeyword, setTemplateKeyword] = useState(''); - const { detailData, fetchData } = useQueryInstanceTypes(); + const { + detailData, + loading: instanceTypesLoading, + fetchData + } = useQueryInstanceTypes(); const { detailData: templatesData, fetchData: fetchTemplates } = useQueryTemplates(); useEffect(() => { if (open) { + autoSelectedRef.current = false; fetchData({}); fetchTemplates({ page: 1, perPage: 100 }); + } else { + setSelectedInstanceType(undefined); + setSelectedManufacturer(undefined); + setTemplateId(undefined); + setInstanceKeyword(''); + setTemplateKeyword(''); } }, [open, fetchData, fetchTemplates]); @@ -119,6 +132,59 @@ const AddModal: React.FC = ({ ); }, [templateKeyword, manufacturerMatchedTemplates]); + useEffect(() => { + if (!open) return; + if (action !== PageAction.CREATE) return; + if (autoSelectedRef.current) return; + if (!instanceTypeList.length) return; + if (!templatesData) return; + + const firstInstanceType = instanceTypeList[0]; + const manufacturer = firstInstanceType.spec?.manufacturer; + const name = firstInstanceType.metadata?.name; + const matchedTemplate = manufacturer + ? templateList.find((t) => t.manufacturer === manufacturer) + : templateList[0]; + + autoSelectedRef.current = true; + setSelectedInstanceType(name); + setSelectedManufacturer(manufacturer); + if (matchedTemplate) { + setTemplateId(matchedTemplate.id); + } + + const applyToForm = () => { + const currentSpec = form.current?.getFieldsValue()?.spec || {}; + const updatedSpec = { + ...currentSpec, + type: name, + resources: { ...(currentSpec.resources || {}), accelerator: '1' } + }; + + if (matchedTemplate) { + form.current?.setFieldsValue({ + manufacturer: matchedTemplate.manufacturer, + spec: { + ...updatedSpec, + ...matchedTemplate.spec, + resources: { + ...(updatedSpec.resources || {}), + ...(matchedTemplate.spec?.resources || {}) + } + } + }); + } else { + form.current?.setFieldsValue({ spec: updatedSpec }); + } + }; + + if (form.current) { + applyToForm(); + } else { + queueMicrotask(applyToForm); + } + }, [open, action, instanceTypeList, templateList, templatesData]); + const handleSubmit = () => { form.current?.submit(); }; @@ -229,15 +295,12 @@ const AddModal: React.FC = ({ onChange={(e) => setInstanceKeyword(e.target.value)} /> - {filteredInstanceTypes.length > 0 ? ( - - ) : ( - - )} + diff --git a/src/pages/gpu-service/instances/components/instance-type-item.tsx b/src/pages/gpu-service/instances/components/instance-type-item.tsx new file mode 100644 index 00000000..bc2de5b9 --- /dev/null +++ b/src/pages/gpu-service/instances/components/instance-type-item.tsx @@ -0,0 +1,155 @@ +import { AutoTooltip, IconFont, StatusTag, ThemeTag } from '@gpustack/core-ui'; +import { useIntl } from '@umijs/max'; +import { Flex } from 'antd'; +import styled from 'styled-components'; +import { + convertKiToGi, + InstanceTypePhaseLabelMap, + InstanceTypePhaseStatus +} from '../config'; +import { InstanceTypeItem as InstanceTypeItemModel } from '../config/types'; + +const Title = styled.div` + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + margin-bottom: 12px; + color: var(--ant-color-text); + font-weight: 500; +`; + +const Meta = styled.div` + display: grid; + gap: 8px; + color: var(--ant-color-text-secondary); + font-size: 13px; + + .meta-row { + display: flex; + align-items: center; + gap: 8px; + color: var(--ant-color-text-tertiary); + } + .dot { + width: 3px; + height: 3px; + border-radius: 50%; + background-color: var(--ant-color-text-quaternary); + flex: none; + } + + .meta-item { + display: inline-flex; + align-items: center; + gap: 4px; + } + + .meta-icon { + font-size: 14px; + color: var(--ant-color-text-quaternary); + } +`; + +interface InstanceTypeItemProps { + item: InstanceTypeItemModel; + showStatus?: boolean; +} + +const InstanceTypeItem: React.FC = ({ + item, + showStatus = true +}) => { + const intl = useIntl(); + const name = item.metadata?.name; + const acceleratable = item.spec?.acceleratable; + const product = item.spec?.product; + const displayName = product || (!acceleratable ? 'CPU' : name); + const manufacturer = item.spec?.manufacturer?.toUpperCase(); + + return ( + <> + + <Flex gap={8} align="center"> + <AutoTooltip ghost minWidth={20} maxWidth={180}> + {displayName} + </AutoTooltip> + {acceleratable && manufacturer && ( + <span + style={{ + color: 'var(--ant-color-text-tertiary)', + fontWeight: 400 + }} + > + <ThemeTag color="purple">{manufacturer}</ThemeTag> + </span> + )} + </Flex> + {showStatus && ( + <Flex gap={8} align="center"> + {item.status?.phase && ( + <StatusTag + statusValue={{ + status: + InstanceTypePhaseStatus[item.status.phase] ?? 'inactive', + text: + InstanceTypePhaseLabelMap[item.status.phase] ?? + item.status.phase, + message: '' + }} + /> + )} + </Flex> + )} + + + + {acceleratable && ( + + + + + + {intl.formatMessage({ id: 'gpuservice.instance.memory' })} + + {convertKiToGi(item.spec?.memory) ?? '-'} + + + + + + + + {intl.formatMessage({ id: 'gpuservice.instance.stock' })} + + {item.status?.accelerator?.remaining ?? '-'} + + + + )} + + + + + + + {intl.formatMessage({ id: 'gpuservice.instance.ram' })} + + {item.status?.ram?.capacity ?? '-'} + + + + + + + vCPU + {item.status?.cpu?.capacity ?? '-'} + + + + + + ); +}; + +export default InstanceTypeItem; diff --git a/src/pages/gpu-service/instances/components/instance-type-list.tsx b/src/pages/gpu-service/instances/components/instance-type-list.tsx index 46c32ae3..1cef55c6 100644 --- a/src/pages/gpu-service/instances/components/instance-type-list.tsx +++ b/src/pages/gpu-service/instances/components/instance-type-list.tsx @@ -1,13 +1,11 @@ -import { AutoTooltip, StatusTag, TemplateCard } from '@gpustack/core-ui'; -import { useIntl } from '@umijs/max'; -import { Flex, Tag } from 'antd'; +import FileSkeleton from '@/pages/llmodels/components/model-source/file-skeleton'; +import { TemplateCard } from '@gpustack/core-ui'; +import { Empty, Spin } from 'antd'; +import _ from 'lodash'; import styled from 'styled-components'; -import { - InstanceTypePhaseLabelMap, - InstanceTypePhaseStatus, - InstanceTypePhaseValueMap -} from '../config'; -import { InstanceTypeItem } from '../config/types'; +import { InstanceTypePhaseValueMap } from '../config'; +import { InstanceTypeItem as InstanceTypeItemModel } from '../config/types'; +import InstanceTypeItem from './instance-type-item'; const TypeGrid = styled.div` display: flex; @@ -15,53 +13,42 @@ const TypeGrid = styled.div` gap: 16px; `; -const TypeName = styled.div` - display: flex; - align-items: center; - justify-content: space-between; - gap: 8px; - margin-bottom: 12px; - color: var(--ant-color-text); - font-weight: 500; -`; - -const TypeMeta = styled.div` - display: grid; - gap: 8px; - color: var(--ant-color-text-secondary); - font-size: 13px; - - .meta-row { - display: flex; - align-items: center; - gap: 16px; - } - - .icon { - color: var(--ant-color-text-quaternary); - } -`; - interface InstanceTypeListProps { value?: string; - onChange?: (item: InstanceTypeItem) => void; - dataList?: InstanceTypeItem[]; + onChange?: (item: InstanceTypeItemModel) => void; + dataList?: InstanceTypeItemModel[]; + loading?: boolean; } -const isAvailable = (item: InstanceTypeItem) => +const isAvailable = (item: InstanceTypeItemModel) => item.status?.phase === InstanceTypePhaseValueMap.Active; const InstanceTypeList: React.FC = ({ value, onChange, - dataList = [] + dataList = [], + loading }) => { - const intl = useIntl(); - const handleSelect = (item: InstanceTypeItem) => { + const handleSelect = (item: InstanceTypeItemModel) => { if (!isAvailable(item)) return; onChange?.(item); }; + if (!dataList.length) { + if (loading) { + return ( + + + {_.times(6, (index: number) => ( + + ))} + + + ); + } + return ; + } + return ( {dataList.map((item) => { @@ -78,58 +65,7 @@ const InstanceTypeList: React.FC = ({ disabled={disabled} onClick={() => handleSelect(item)} > - - - - {name} - - - - {item.status?.phase && ( - - )} - {item.spec?.acceleratable && ( - - - {intl.formatMessage({ id: 'gpuservice.instance.stock' })}{' '} - {item.status?.accelerator?.remaining ?? '-'} - - - )} - - - - - {item.spec?.acceleratable && ( - - {intl.formatMessage({ id: 'gpuservice.instance.memory' })}{' '} - {item.spec?.memory ?? '-'} - - )} - - - - {intl.formatMessage({ id: 'gpuservice.instance.ram' })}{' '} - {item.status?.ram?.capacity ?? '-'} - - vCPU {item.status?.cpu?.capacity ?? '-'} - - + ); })} diff --git a/src/pages/gpu-service/instances/config/index.ts b/src/pages/gpu-service/instances/config/index.ts index 4d900662..6a119caa 100644 --- a/src/pages/gpu-service/instances/config/index.ts +++ b/src/pages/gpu-service/instances/config/index.ts @@ -40,7 +40,7 @@ export const status: Record = { [InstanceStatusValueMap.InitializeFailed]: StatusMaps.error, [InstanceStatusValueMap.Initialized]: StatusMaps.transitioning, [InstanceStatusValueMap.Preparing]: StatusMaps.transitioning, - [InstanceStatusValueMap.NotReady]: StatusMaps.warning, + [InstanceStatusValueMap.NotReady]: StatusMaps.error, [InstanceStatusValueMap.Ready]: StatusMaps.success }; @@ -90,13 +90,19 @@ export const StorageModeValueMap = { // Constant SSH public key resource name used when SSH is enabled export const DEFAULT_SSH_PUBLIC_KEY_NAME = 'default'; -const KI_TO_GI = 1024 * 1024; +const GI_DIVISOR: Record = { + Ki: 1024 * 1024, + Mi: 1024, + Gi: 1 +}; export const convertKiToGi = (value?: string): string | undefined => { if (!value) return value; - const match = /^(-?\d+(?:\.\d+)?)Ki$/.exec(value); + const match = /^(-?\d+(?:\.\d+)?)(Ki|Mi|Gi|Ti)$/.exec(value); if (!match) return value; - return `${_.round(Number(match[1]) / KI_TO_GI, 2)}Gi`; + const [, num, unit] = match; + if (unit === 'Ti') return `${_.round(Number(num), 2)}Ti`; + return `${_.round(Number(num) / GI_DIVISOR[unit], 2)}Gi`; }; export const transformInstanceTypeResource = ( diff --git a/src/pages/gpu-service/instances/config/types.ts b/src/pages/gpu-service/instances/config/types.ts index 506545a4..a9945bd7 100644 --- a/src/pages/gpu-service/instances/config/types.ts +++ b/src/pages/gpu-service/instances/config/types.ts @@ -82,7 +82,12 @@ export interface ListItem extends Omit { }; } -type Quality = `${number}Ki` | `${number}Gi`; +type Quality = + | `${number}` + | `${number}Ki` + | `${number}Mi` + | `${number}Gi` + | `${number}Ti`; export interface InstanceTypeResource { capacity?: Quality; @@ -90,30 +95,42 @@ export interface InstanceTypeResource { remaining?: Quality; } +export interface InstanceTypeStatus { + accelerator: InstanceTypeResource; + cpu: InstanceTypeResource; + localStorage: InstanceTypeResource; + phase: string; + phaseMessage?: string; + ram: InstanceTypeResource; +} + +export interface InstanceTypeMetadata { + name: string; + namespace?: string; + uid?: string; + resourceVersion?: string; + creationTimestamp?: string; + generateName?: string; + generation?: number; + finalizers?: string[]; +} + +export interface InstanceTypeSpec { + acceleratable: boolean; + group: string; + computeCapability?: string; + family?: string; + manufacturer?: string; + memory?: string; + product?: string; + sliced?: number; +} + export interface InstanceTypeItem { apiVersion: string; kind: string; - metadata: { - name: string; - namespace: string; - }; id: number; - spec: { - acceleratable: boolean; - computeCapability?: string; - family?: string; - group: string; - manufacturer?: string; - memory?: string; - product?: string; - sliced?: number; - }; - status: { - accelerator: InstanceTypeResource; - cpu: InstanceTypeResource; - localStorage: InstanceTypeResource; - phase: string; - phaseMessage?: string; - ram: InstanceTypeResource; - }; + metadata: InstanceTypeMetadata; + spec: InstanceTypeSpec; + status: InstanceTypeStatus; } diff --git a/src/pages/gpu-service/instances/forms/index.tsx b/src/pages/gpu-service/instances/forms/index.tsx index 02725318..d6fbd169 100644 --- a/src/pages/gpu-service/instances/forms/index.tsx +++ b/src/pages/gpu-service/instances/forms/index.tsx @@ -21,7 +21,6 @@ import { import useGetSshkey from '../../public-keys/services/use-get-sshkey'; import { DefaultImagePullPolicy } from '../../templates/config'; import TemplateBasicForm from '../../templates/forms/basic'; -import { DEFAULT_SSH_PUBLIC_KEY_NAME } from '../config'; import { FormData, InstancePort, ListItem } from '../config/types'; import Basic from './basic'; import InstanceTypeFormItem from './instance-type'; @@ -53,19 +52,19 @@ const TABKeysMap = { const requiredFields = { [TABKeysMap.BASIC]: { sort: 1, - fields: ['name'] + fields: ['metadata.name'] }, [TABKeysMap.INSTANCE_TYPE]: { sort: 2, - fields: ['type'] + fields: ['spec.type', 'spec.resources.accelerator'] }, [TABKeysMap.TEMPLATE]: { sort: 3, - fields: ['image', 'ports'] + fields: ['spec.image', 'spec.ports', 'spec.env'] }, [TABKeysMap.STORAGE]: { sort: 4, - fields: ['volume'] + fields: ['spec.volume.persistent.name', 'spec.volume.ephemeral.capacity'] } }; @@ -143,7 +142,8 @@ const GPUServiceInstanceForm: React.FC = forwardRef( const ports = (Form.useWatch(['spec', 'ports'], form) || []) as InstancePort[]; const hasSshPort = useMemo( - () => ports.some((p) => Number(p?.port) === SSH_PORT), + () => + ports.some((p) => Number(p?.port) === SSH_PORT && p.protocol === 'TCP'), [ports] ); @@ -151,12 +151,25 @@ const GPUServiceInstanceForm: React.FC = forwardRef( scrollTabsRef.current?.handleTargetChange(key); }; - const { handleOnFinishFailed } = useFinishFailed({ + const { handleOnFinishFailed: rawHandleOnFinishFailed } = useFinishFailed({ requiredFields, onTargetChange, updateActiveKey }); + // useFinishFailed only matches errorFields[].name[0] against `fields`, + // but our form fields are nested (e.g. ['spec','type']), so collapse each + // error path into a single dotted string before delegating. + const handleOnFinishFailed = (errorInfo: any) => { + const errorFields = (errorInfo?.errorFields || []).map((field: any) => ({ + ...field, + name: [ + Array.isArray(field.name) ? field.name.join('.') : String(field.name) + ] + })); + rawHandleOnFinishFailed({ ...errorInfo, errorFields }); + }; + useEffect(() => { if (!open) { form.resetFields(); @@ -180,43 +193,7 @@ const GPUServiceInstanceForm: React.FC = forwardRef( } }, [action, currentData, form, open, namespace]); - const buildPayload = (values: InstanceFormValues): FormData => { - const acceleratorVal = values.spec?.resources?.accelerator; - const acceleratorStr = - acceleratorVal !== undefined && - acceleratorVal !== null && - (acceleratorVal as unknown) !== '' - ? String(acceleratorVal) - : undefined; - - const spec: FormData['spec'] = { - ...values.spec, - resources: { - ...values.spec?.resources, - accelerator: acceleratorStr - } - } as FormData['spec']; - - // SSH public key — include only when checkbox is checked AND port 22 is in ports - 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 }; - } else { - delete (spec as any).sshPublicKey; - } - - return { - metadata: { - name: values.metadata?.name, - namespace: values.metadata?.namespace || namespace - }, - spec - } as FormData; - }; - const handleFinish = async (values: InstanceFormValues) => { - const payload = buildPayload(values); await onFinish(values); }; @@ -265,9 +242,16 @@ const GPUServiceInstanceForm: React.FC = forwardRef( resources: { accelerator: '1' }, - volume: { persistent: { name: '' } }, sshPublicKey: { name: 'gpustack-organization-ssh-public-key' + }, + volume: { + ephemeral: { + capacity: '20Gi' + }, + persistent: { + name: '' + } } }, enable_ssh: false diff --git a/src/pages/gpu-service/instances/forms/instance-type.tsx b/src/pages/gpu-service/instances/forms/instance-type.tsx index 8c57e8c7..6a0d60e5 100644 --- a/src/pages/gpu-service/instances/forms/instance-type.tsx +++ b/src/pages/gpu-service/instances/forms/instance-type.tsx @@ -1,10 +1,14 @@ -import { AutoTooltip, InputNumber as CInputNumber } from '@gpustack/core-ui'; +import { InputNumber as CInputNumber } from '@gpustack/core-ui'; import { useIntl } from '@umijs/max'; -import { Flex, Form, Tag } from 'antd'; +import { Form } from 'antd'; import { useEffect, useMemo } from 'react'; import styled from 'styled-components'; +import InstanceTypeItem from '../components/instance-type-item'; import { InstanceTypePhaseValueMap } from '../config'; -import { FormData, InstanceTypeItem } from '../config/types'; +import { + FormData, + InstanceTypeItem as InstanceTypeItemModel +} from '../config/types'; import useQueryInstanceTypes from '../services/use-query-instance-types'; const FieldBlock = styled.div` @@ -12,87 +16,31 @@ const FieldBlock = styled.div` `; 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: flex; - flex-direction: column; - gap: 4px; - color: var(--ant-color-text-secondary); - font-size: 13px; -`; - interface InstanceTypePickerProps { value?: string; - dataList: InstanceTypeItem[]; + dataList: InstanceTypeItemModel[]; } const InstanceTypePicker: React.FC = ({ value, dataList }) => { - const intl = useIntl(); const selected = useMemo(() => { return dataList.find((item) => (item.metadata?.name || '') === value); }, [value, dataList]); + if (!selected) return null; + return ( - <> - -
- - - - {selected?.metadata?.name || '-'} - - {selected && selected.spec?.acceleratable && ( - - {intl.formatMessage({ id: 'gpuservice.instance.stock' })}{' '} - {selected?.status?.accelerator?.remaining ?? '-'} - - )} - - - - {selected?.spec?.acceleratable && ( - - {intl.formatMessage({ id: 'gpuservice.instance.memory' })}{' '} - {selected?.spec?.memory ?? '-'} - - )} - - - {intl.formatMessage({ id: 'gpuservice.instance.ram' })}{' '} - {selected?.status?.ram?.capacity ?? '-'} - - vCPU {selected?.status?.cpu?.capacity ?? '-'} - - -
-
- + + + ); }; @@ -114,8 +62,9 @@ const InstanceTypeFormItem = () => { }, [dataList, typeName]); const maxGpuCount = useMemo(() => { - const remaining = selectedInstanceType?.status?.accelerator?.remaining; - const num = remaining ? Number(remaining) : NaN; + const onceMaxRequest = + selectedInstanceType?.status?.accelerator?.onceMaxRequest; + const num = onceMaxRequest ? Number(onceMaxRequest) : NaN; return Number.isFinite(num) && num > 0 ? num : 1; }, [selectedInstanceType]); diff --git a/src/pages/gpu-service/instances/forms/storage-volume.tsx b/src/pages/gpu-service/instances/forms/storage-volume.tsx index 94d9c613..db15c367 100644 --- a/src/pages/gpu-service/instances/forms/storage-volume.tsx +++ b/src/pages/gpu-service/instances/forms/storage-volume.tsx @@ -1,4 +1,4 @@ -import { useModel } from '@@/plugin-model'; +import { getCurrentOrganizationId } from '@/atoms/user'; import { InputNumber as CInputNumber, Select } from '@gpustack/core-ui'; import { useIntl } from '@umijs/max'; import { Button, Flex, Form, Radio } from 'antd'; @@ -8,7 +8,7 @@ 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, InstanceVolume } from '../config/types'; +import { FormData } from '../config/types'; import StorageOverlay from './storage-overlay'; const FieldBlock = styled.div` @@ -19,33 +19,22 @@ const DEFAULT_TEMP_CAPACITY_GB = 50; const StorageVolume = () => { const intl = useIntl(); - const { initialState } = useModel('@@initialState'); const { fetchData: createStorage } = useCreateStorage(); const { detailData: storageData, fetchData: fetchStorage } = useQueryStorage(); const [overlayOpen, setOverlayOpen] = useState(false); const [storageMode, setStorageMode] = useState( - StorageModeValueMap.Existing + StorageModeValueMap.Temporary ); const form = Form.useFormInstance(); - const volume = Form.useWatch(['spec', 'volume'], form) as - | InstanceVolume - | undefined; - const namespace = initialState?.currentUser?.org_name || 'default'; + const namespace = getCurrentOrganizationId(); useEffect(() => { fetchStorage({}); }, []); - // const storageMode = useMemo(() => { - // if (volume?.ephemeral && !volume?.persistent) { - // return StorageModeValueMap.Temporary; - // } - // return StorageModeValueMap.Existing; - // }, [volume?.ephemeral, volume?.persistent]); - const storageOptions = useMemo( () => (storageData?.items || []).map((item) => ({ @@ -94,15 +83,15 @@ const StorageVolume = () => { value={storageMode} onChange={(e) => handleModeChange(e.target.value)} options={[ + { + label: intl.formatMessage({ id: 'gpuservice.storage.temporary' }), + value: StorageModeValueMap.Temporary + }, { label: intl.formatMessage({ id: 'gpuservice.storage.persistent' }), value: StorageModeValueMap.Existing - }, - { - label: intl.formatMessage({ id: 'gpuservice.storage.temporary' }), - value: StorageModeValueMap.Temporary } ]} /> diff --git a/src/pages/gpu-service/instances/hooks/use-instances-columns.tsx b/src/pages/gpu-service/instances/hooks/use-instances-columns.tsx index d07172c2..979eec5c 100644 --- a/src/pages/gpu-service/instances/hooks/use-instances-columns.tsx +++ b/src/pages/gpu-service/instances/hooks/use-instances-columns.tsx @@ -1,12 +1,50 @@ -import { tableSorter } from '@/config/settings'; -import { AutoTooltip, DropdownButtons, StatusTag } from '@gpustack/core-ui'; +import { ExportOutlined } from '@ant-design/icons'; +import { + AutoTooltip, + CopyButton, + DropdownButtons, + StatusTag +} from '@gpustack/core-ui'; import { useIntl } from '@umijs/max'; +import { Button, Flex } from 'antd'; import type { ColumnsType } from 'antd/lib/table'; import dayjs from 'dayjs'; import { useMemo } from 'react'; import { InstanceStatusLabelMap, rowActionList, status } from '../config'; import { ListItem } from '../config/types'; +type ConnectEntry = + | { type: 'ssh'; key: string; command: string } + | { type: 'http'; key: string; port: number; url: string }; + +const getConnectEntries = (record: ListItem): ConnectEntry[] => { + const ip = record.status?.hostIPs?.[0]?.ip; + const ports = record.status?.ports || []; + const hasSshKey = !!record.spec?.sshPublicKey?.name; + + if (!ip) { + return []; + } + + return ports + .filter((p) => p.nodePort) + .map((p) => { + const isSsh = hasSshKey && p.protocol === 'TCP' && p.port === 22; + return isSsh + ? { + type: 'ssh', + key: `ssh-${p.nodePort}`, + command: `ssh root@${ip} -p ${p.nodePort}` + } + : { + type: 'http', + key: `http-${p.nodePort}`, + port: p.port, + url: `http://${ip}:${p.nodePort}` + }; + }); +}; + interface ColumnsHookProps { handleSelect: (val: string, record: ListItem) => void; sortOrder: string[]; @@ -23,7 +61,7 @@ const useInstancesColumns = ({ title: intl.formatMessage({ id: 'common.table.name' }), dataIndex: ['metadata', 'name'], key: 'name', - sorter: tableSorter(1), + sorter: false, ellipsis: { showTitle: false }, @@ -33,39 +71,101 @@ const useInstancesColumns = ({ ) }, + { + title: intl.formatMessage({ id: 'gpuservice.instance.connect' }), + key: 'connect', + render: (_text, record: ListItem) => { + const entries = getConnectEntries(record); + + if (entries.length === 0) { + return '-'; + } + + return ( + + {entries.map((entry) => + entry.type === 'ssh' ? ( + + + SSH + + + + ) : ( + + + HTTP + + + + ) + )} + + ); + } + }, { title: intl.formatMessage({ id: 'common.table.status' }), dataIndex: ['status', 'phase'], key: 'status', - sorter: tableSorter(2), + sorter: false, render: (value: string, record: ListItem) => { return ( ); } }, - { - title: intl.formatMessage({ id: 'gpuservice.template.image' }), - dataIndex: ['spec', 'image'], - key: 'image', - sorter: tableSorter(3), - ellipsis: { - showTitle: false - }, - render: (value: string) => ( - {value || '-'} - ) - }, { title: intl.formatMessage({ id: 'common.table.createTime' }), dataIndex: ['metadata', 'creationTimestamp'], key: 'creationTimestamp', - sorter: tableSorter(5), + sorter: false, ellipsis: { showTitle: false }, diff --git a/src/pages/gpu-service/instances/index.tsx b/src/pages/gpu-service/instances/index.tsx index 575e4c95..051ad130 100644 --- a/src/pages/gpu-service/instances/index.tsx +++ b/src/pages/gpu-service/instances/index.tsx @@ -1,11 +1,11 @@ import { currentClusterAtom } from '@/atoms/gpuservice'; +import { getCurrentOrganizationId } from '@/atoms/user'; import { PageAction } from '@/config'; import { PaginationKey, TABLE_SORT_DIRECTIONS } from '@/config/settings'; import type { PageActionType } from '@/config/types'; import useTableFetch from '@/hooks/use-table-fetch'; import { ProviderValueMap } from '@/pages/cluster-management/config'; import { useQueryClusterList } from '@/pages/cluster-management/services/use-query-cluster-list'; -import { useModel } from '@@/plugin-model'; import { BaseSelect, DeleteModal, @@ -33,8 +33,7 @@ import useUpdateInstance from './services/use-update-instance'; const GPUService: React.FC = () => { const intl = useIntl(); - const { initialState } = useModel('@@initialState'); - const namespace = initialState?.currentUser?.org_name || 'default'; + const namespace = getCurrentOrganizationId(); const [currentCluster, setCurrentCluster] = useAtom(currentClusterAtom); const clusterID = currentCluster?.id; @@ -43,12 +42,13 @@ const GPUService: React.FC = () => { [namespace, clusterID] ); - const fetchInstances = useCallback( + const fetchInstances = useMemoizedFn( async ( params: any, options?: any ): Promise> => { - if (!clusterID) { + const effectiveClusterID = params.cluster_id ?? clusterID; + if (!effectiveClusterID) { return { items: [], pagination: { @@ -60,7 +60,7 @@ const GPUService: React.FC = () => { } as Global.PageResponse; } const res = await queryGPUServiceInstances( - { ...params, namespace, clusterID: params.cluster_id ?? clusterID }, + { ...params, namespace, clusterID: effectiveClusterID }, options ); const total = res.items?.length ?? 0; @@ -74,8 +74,7 @@ const GPUService: React.FC = () => { perPage } }; - }, - [namespace, clusterID] + } ); const { @@ -96,7 +95,8 @@ const GPUService: React.FC = () => { key: PaginationKey.Instances, fetchAPI: fetchInstances, deleteAPI: deleteInstance, - watch: true, + watch: false, + polling: true, API: GPU_SERVICE_INSTANCES_API({ namespace, clusterID }), contentForDelete: intl.formatMessage({ id: 'gpuservice.instance' }) }); @@ -134,18 +134,24 @@ const GPUService: React.FC = () => { const k8sClusters = clusters.filter( (item: any) => item.provider === ProviderValueMap.Kubernetes ); - if (k8sClusters.length > 0) { - const firstCluster = k8sClusters[0]; + if (k8sClusters.length === 0) { + return; + } + const storedCluster = k8sClusters.find( + (item: any) => item.id === currentCluster?.id + ); + const targetCluster = storedCluster ?? k8sClusters[0]; + if (!storedCluster) { setCurrentCluster({ - ...firstCluster, - label: firstCluster.name, - value: firstCluster.id - }); - handleQueryChange({ - cluster_id: firstCluster.id, - page: 1 + ...targetCluster, + label: targetCluster.name, + value: targetCluster.id }); } + handleQueryChange({ + cluster_id: targetCluster.id, + page: 1 + }); }); return () => { cancelClusterRequest(); @@ -202,7 +208,11 @@ const GPUService: React.FC = () => { if (val === 'edit') { handleEditInstance(row); } else if (val === 'delete') { - handleDelete({ ...row, name: row.metadata?.name }); + handleDelete({ + ...row, + name: row.metadata?.name, + id: row.metadata?.name as any + }); } }); @@ -225,7 +235,7 @@ const GPUService: React.FC = () => { loadend={dataSource.loadend} dataSource={dataSource.dataList} image={} - filters={_.omit(queryParams, ['sort_by'])} + filters={_.pick(queryParams, ['search', 'manufacturer'])} noFoundText={intl.formatMessage({ id: 'noresult.gpuservice.instance.nofound' })} diff --git a/src/pages/gpu-service/instances/services/use-create-instance.ts b/src/pages/gpu-service/instances/services/use-create-instance.ts index a61ef1f7..a3196b10 100644 --- a/src/pages/gpu-service/instances/services/use-create-instance.ts +++ b/src/pages/gpu-service/instances/services/use-create-instance.ts @@ -1,5 +1,5 @@ import { currentClusterAtom } from '@/atoms/gpuservice'; -import { useModel } from '@@/plugin-model'; +import { getCurrentOrganizationId } from '@/atoms/user'; import { useQueryData } from '@gpustack/core-ui'; import { useAtomValue } from 'jotai'; import { useCallback } from 'react'; @@ -12,8 +12,7 @@ interface CreateInstanceParams { } export default function useCreateInstance() { - const { initialState } = useModel('@@initialState'); - const namespace = initialState?.currentUser?.org_name || 'default'; + const namespace = getCurrentOrganizationId(); const currentCluster = useAtomValue(currentClusterAtom); const clusterID = currentCluster?.id; diff --git a/src/pages/gpu-service/instances/services/use-update-instance.ts b/src/pages/gpu-service/instances/services/use-update-instance.ts index 97973377..239dd378 100644 --- a/src/pages/gpu-service/instances/services/use-update-instance.ts +++ b/src/pages/gpu-service/instances/services/use-update-instance.ts @@ -1,5 +1,5 @@ import { currentClusterAtom } from '@/atoms/gpuservice'; -import { useModel } from '@@/plugin-model'; +import { getCurrentOrganizationId } from '@/atoms/user'; import { useQueryData } from '@gpustack/core-ui'; import { useAtomValue } from 'jotai'; import { useCallback } from 'react'; @@ -13,8 +13,7 @@ interface UpdateInstanceParams { } export default function useUpdateInstance() { - const { initialState } = useModel('@@initialState'); - const namespace = initialState?.currentUser?.org_name || 'default'; + const namespace = getCurrentOrganizationId(); const currentCluster = useAtomValue(currentClusterAtom); const clusterID = currentCluster?.id; diff --git a/src/pages/gpu-service/public-keys/index.tsx b/src/pages/gpu-service/public-keys/index.tsx index 5532335c..1fece56f 100644 --- a/src/pages/gpu-service/public-keys/index.tsx +++ b/src/pages/gpu-service/public-keys/index.tsx @@ -1,7 +1,6 @@ -import { INPUT_WIDTH } from '@/constants'; import { Input as CInput, Textarea, useAppUtils } from '@gpustack/core-ui'; import { useIntl } from '@umijs/max'; -import { Button, Form } from 'antd'; +import { Button, Form, message } from 'antd'; import React, { useEffect } from 'react'; import PageBox from '../../_components/page-box'; import useGetSshkey from './services/use-get-sshkey'; @@ -36,6 +35,7 @@ const GPUServicePublicKeys: React.FC = () => { await updateSshkey({ data: values }); + message.success(intl.formatMessage({ id: 'common.message.success' })); } catch (error) { // ignore } @@ -44,7 +44,7 @@ const GPUServicePublicKeys: React.FC = () => { return (
{ trim={false} alwaysFocus autoSize={{ minRows: 8, maxRows: 16 }} - style={{ width: INPUT_WIDTH.large, minHeight: 186 }} + style={{ width: 700, minHeight: 186 }} />