From 6f56e614f30b103e673c3a0b5274707fdd15e1da Mon Sep 17 00:00:00 2001 From: jialin Date: Sat, 13 Jun 2026 13:35:07 +0800 Subject: [PATCH] chore: instance creationg UE --- .../services/use-query-cluster-list.tsx | 2 +- .../instances/components/add-modal.tsx | 85 +++++++++---------- .../components/instance-type-list.tsx | 21 ++--- .../gpu-service/instances/forms/index.tsx | 39 ++++++--- .../instances/forms/storage-volume.tsx | 8 +- .../instances/forms/template-selector.tsx | 37 ++++++-- src/pages/gpu-service/instances/index.tsx | 6 +- .../components/model-source/file-skeleton.tsx | 20 +++++ 8 files changed, 131 insertions(+), 87 deletions(-) diff --git a/src/pages/cluster-management/services/use-query-cluster-list.tsx b/src/pages/cluster-management/services/use-query-cluster-list.tsx index 04f9237d..75b8679a 100644 --- a/src/pages/cluster-management/services/use-query-cluster-list.tsx +++ b/src/pages/cluster-management/services/use-query-cluster-list.tsx @@ -14,7 +14,7 @@ export const useQueryClusterList = (options?: { useStateData?: boolean }) => { const { useStateData = true } = options || {}; const axiosTokenRef = useRef(null); const [dataList, setDataList] = useState< - Array & { label: string; value: number }> + Array >([]); const { diff --git a/src/pages/gpu-service/instances/components/add-modal.tsx b/src/pages/gpu-service/instances/components/add-modal.tsx index 88c5a47a..4d883199 100644 --- a/src/pages/gpu-service/instances/components/add-modal.tsx +++ b/src/pages/gpu-service/instances/components/add-modal.tsx @@ -1,7 +1,6 @@ import { PageAction } from '@/config'; import { PageActionType } from '@/config/types'; import useSubmitLock from '@/hooks/use-submit-lock'; -import { useQueryClusterList } from '@/pages/cluster-management/services/use-query-cluster-list'; import useUserDirectory from '@/pages/gpu-service/hooks/use-user-directory'; import Separator from '@/pages/llmodels/components/separator'; import { getGPUStackPlugin } from '@/plugins'; @@ -13,7 +12,7 @@ import { ModalFooter } from '@gpustack/core-ui'; import { useIntl, useModel } from '@umijs/max'; -import { Empty, Input, Typography } from 'antd'; +import { Input, Typography } from 'antd'; import _ from 'lodash'; import { useEffect, useMemo, useRef, useState } from 'react'; import { ListItem as TemplateItem } from '../../templates/config/types'; @@ -31,6 +30,12 @@ type AddModalProps = { open: boolean; width?: number | string; realAction?: string; + clusterList?: Array<{ + label: string; + value: number; + id: number; + owner_principal_id?: number; + }>; onOk: (values: FormData) => void; data?: ListItem | null; onCancel: () => void; @@ -78,6 +83,7 @@ const AddModal: React.FC = ({ data, onCancel, width, + clusterList = [], realAction }) => { const intl = useIntl(); @@ -100,16 +106,18 @@ const AddModal: React.FC = ({ const [instanceKeyword, setInstanceKeyword] = useState(''); const [templateKeyword, setTemplateKeyword] = useState(''); const { loading, guard, run, release } = useSubmitLock(); - const initializedRef = useRef(false); + const [initialized, setInitialized] = useState(false); const { detailData: instanceTypeList, loading: instanceTypesLoading, fetchData } = useQueryInstanceTypes(); - const { detailData: templatesData, fetchData: fetchTemplates } = - useQueryTemplates(); - const { clusterList, fetchClusterList } = useQueryClusterList(); + const { + detailData: templatesData, + loading: templateLoading, + fetchData: fetchTemplates + } = useQueryTemplates(); // Set by the create-scope picker (admin "All" view) via onScopeChange. // undefined = no picker (org context) → no client-side scoping. const [scopeOrgId, setScopeOrgId] = useState( @@ -125,18 +133,13 @@ const AddModal: React.FC = ({ // fetched list client-side, so it doesn't rely on the request scope. const filterTypesByOwner = ( types: InstanceTypeItem[], - clusters: Array<{ - id?: number; - value?: number; - owner_principal_id?: number; - }>, orgId?: number | null ): InstanceTypeItem[] => { if (orgId == null) return types; const owned = new Set( - (clusters || []) + (clusterList || []) .filter((c) => c.owner_principal_id === orgId) - .map((c) => c.id ?? c.value) + .map((c) => c.id || c.value) ); return types .map((it) => ({ @@ -157,7 +160,7 @@ const AddModal: React.FC = ({ }; const ownedInstanceTypes = useMemo( - () => filterTypesByOwner(instanceTypeList, clusterList as any, scopeOrgId), + () => filterTypesByOwner(instanceTypeList, scopeOrgId), [instanceTypeList, clusterList, scopeOrgId] ); @@ -279,11 +282,6 @@ const AddModal: React.FC = ({ const applyAutoSelection = ( instanceTypes: InstanceTypeItem[], templates: TemplateItem[], - clusters?: Array<{ - id?: number; - value?: number; - owner_principal_id?: number; - }>, orgId?: number | null ) => { // On edit / view, surface the persisted selection in the card list. @@ -303,7 +301,7 @@ const AddModal: React.FC = ({ } // Scope to clusters the chosen org owns (admin "All" view). - const owned = filterTypesByOwner(instanceTypes, clusters || [], orgId); + const owned = filterTypesByOwner(instanceTypes, orgId); // On create, auto-select the first available instance type (clears the // selection when the chosen org has none). @@ -317,23 +315,19 @@ const AddModal: React.FC = ({ const loadCreateResources = (orgId?: number | null) => { const session = ++sessionRef.current; try { - Promise.all([ - fetchData({ page: -1 }), - fetchTemplates({ page: -1 }), - fetchClusterList({ page: -1 }) - ]).then(([instanceResItems, templatesRes, clusters]) => { - if (sessionRef.current !== session) return; - applyAutoSelection( - instanceResItems || [], - templatesRes?.items || [], - (Array.isArray(clusters) ? clusters : (clusters as any)?.items) || [], - orgId - ); - initializedRef.current = true; - }); + Promise.all([fetchData({ page: -1 }), fetchTemplates({ page: -1 })]).then( + ([instanceResItems, templatesRes]) => { + if (sessionRef.current !== session) return; + applyAutoSelection( + instanceResItems || [], + templatesRes?.items || [], + orgId + ); + setInitialized(true); + } + ); } catch (error) { - } finally { - initializedRef.current = true; + setInitialized(true); } }; @@ -352,13 +346,13 @@ const AddModal: React.FC = ({ // owner-scoped auto-selection re-fills them from the new org, or leaves // them empty (blocking submit) when the chosen org has no clusters. clearSelection(); - initializedRef.current = false; + setInitialized(false); loadCreateResources(orgId); }; useEffect(() => { if (!open) { - initializedRef.current = false; + setInitialized(false); sessionRef.current += 1; setInstanceTypeSelection({ instanceType: undefined, @@ -619,15 +613,12 @@ const AddModal: React.FC = ({ onChange={(e) => setTemplateKeyword(e.target.value)} /> - {filteredTemplates.length > 0 && initializedRef.current ? ( - - ) : ( - - )} + 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 565b3f50..0c226c1b 100644 --- a/src/pages/gpu-service/instances/components/instance-type-list.tsx +++ b/src/pages/gpu-service/instances/components/instance-type-list.tsx @@ -1,17 +1,10 @@ -import FileSkeleton from '@/pages/llmodels/components/model-source/file-skeleton'; +import { FileSkeletonRows } from '@/pages/llmodels/components/model-source/file-skeleton'; import { TemplateCard } from '@gpustack/core-ui'; -import { Empty, Spin } from 'antd'; +import { Empty, Flex, Spin } from 'antd'; import _ from 'lodash'; -import styled from 'styled-components'; import { InstanceTypeItem as InstanceTypeItemModel } from '../config/types'; import InstanceTypeItem from './instance-type-item'; -const TypeGrid = styled.div` - display: flex; - flex-direction: column; - gap: 16px; -`; - interface InstanceTypeListProps { value?: string; onChange?: (item: InstanceTypeItemModel) => void; @@ -34,11 +27,11 @@ const InstanceTypeList: React.FC = ({ if (loading) { return ( - + {_.times(6, (index: number) => ( - + ))} - + ); } @@ -46,7 +39,7 @@ const InstanceTypeList: React.FC = ({ } return ( - + {dataList.map((item) => { const name = item.name; return ( @@ -64,7 +57,7 @@ const InstanceTypeList: React.FC = ({ ); })} - + ); }; diff --git a/src/pages/gpu-service/instances/forms/index.tsx b/src/pages/gpu-service/instances/forms/index.tsx index e51d69a9..9ce14b2f 100644 --- a/src/pages/gpu-service/instances/forms/index.tsx +++ b/src/pages/gpu-service/instances/forms/index.tsx @@ -129,6 +129,7 @@ const GPUServiceInstanceForm: React.FC = forwardRef( // exists/changes when a platform admin retargets the form. Watch it // so the parent can re-scope offerings (see onScopeChange). const scopeOrgId = Form.useWatch('organization_id', form); + const ports = Form.useWatch(['spec', 'ports'], form) || []; const scopeInitRef = useRef(true); // Keep the latest callback in a ref so the scope-change effect can call it // without depending on its identity (parent may pass a new fn each render). @@ -151,17 +152,31 @@ const GPUServiceInstanceForm: React.FC = forwardRef( : [TABKeysMap.INSTANCE_TYPE, TABKeysMap.TEMPLATE, TABKeysMap.STORAGE] }); + const hasSSHPort = useMemo( + () => + ports.some( + (item: any) => item?.protocol === 'TCP' && item?.port === SSH_PORT + ), + [ports] + ); + const isGPUType = useMemo(() => { const spec = parseJsonSafe(description || '{}', {} as any)?.spec; - console.log('derived spec from description', spec); return spec?.acceleratable; }, [description]); useEffect(() => { if (open) { - fetchSSHData({ page: 1, perPage: 100 }); + const initSSHKeys = async () => { + // await 200 ms + await new Promise((resolve) => { + setTimeout(resolve, 200); + }); + fetchSSHData({ page: -1 }); + }; + initSSHKeys(); } - }, [open, action]); + }, [open]); // Skip the first run (initial mount value); thereafter notify the // parent whenever the chosen create scope changes so it can reload @@ -174,16 +189,6 @@ const GPUServiceInstanceForm: React.FC = forwardRef( onScopeChangeRef.current?.(scopeOrgId); }, [scopeOrgId]); - const ports = Form.useWatch(['spec', 'ports'], form) || []; - - const hasSSHPort = useMemo( - () => - ports.some( - (item: any) => item?.protocol === 'TCP' && item?.port === SSH_PORT - ), - [ports] - ); - useEffect(() => { if ( hasSSHPort && @@ -554,7 +559,13 @@ const GPUServiceInstanceForm: React.FC = forwardRef( image: '', imagePullPolicy: DefaultImagePullPolicy, command: [], - ports: [], + ports: [ + { + protocol: 'TCP', + port: SSH_PORT, + name: 'SSH' + } + ], env: [], volumeMount: '', resources: { diff --git a/src/pages/gpu-service/instances/forms/storage-volume.tsx b/src/pages/gpu-service/instances/forms/storage-volume.tsx index 93179584..bc95c269 100644 --- a/src/pages/gpu-service/instances/forms/storage-volume.tsx +++ b/src/pages/gpu-service/instances/forms/storage-volume.tsx @@ -36,7 +36,13 @@ const StorageVolume = ({ const [overlayOpen, setOverlayOpen] = useState(false); useEffect(() => { - fetchStorage({ page: 1, perPage: 100 }); + const initStorage = async () => { + await new Promise((resolve) => { + setTimeout(resolve, 200); + }); + fetchStorage({ page: -1 }); + }; + initStorage(); }, []); const storageOptions = useMemo( diff --git a/src/pages/gpu-service/instances/forms/template-selector.tsx b/src/pages/gpu-service/instances/forms/template-selector.tsx index 0216312c..93ed19e3 100644 --- a/src/pages/gpu-service/instances/forms/template-selector.tsx +++ b/src/pages/gpu-service/instances/forms/template-selector.tsx @@ -1,15 +1,12 @@ +import { FileSkeletonRows } from '@/pages/llmodels/components/model-source/file-skeleton'; import { AutoTooltip, IconFont, TemplateCard } from '@gpustack/core-ui'; import { useIntl } from '@umijs/max'; +import { Empty, Flex, Spin } from 'antd'; +import _ from 'lodash'; import { Fragment } from 'react'; import styled from 'styled-components'; import { ListItem as TemplateItem } from '../../templates/config/types'; -const TemplateGrid = styled.div` - display: flex; - flex-direction: column; - gap: 16px; -`; - const GroupTitle = styled.div` font-size: 12px; font-weight: 500; @@ -49,6 +46,12 @@ const TemplateContent = styled.div` } `; +const TypeGrid = styled.div` + display: flex; + flex-direction: column; + gap: 16px; +`; + export interface TemplateGroup { key: string; label: React.ReactNode; @@ -57,6 +60,7 @@ export interface TemplateGroup { interface TemplateSelectorProps { value?: number; + loading?: boolean; onChange?: (value: number, item: TemplateItem) => void; groups?: TemplateGroup[]; } @@ -64,6 +68,7 @@ interface TemplateSelectorProps { const TemplateSelector: React.FC = ({ value, onChange, + loading, groups = [] }) => { const intl = useIntl(); @@ -73,6 +78,22 @@ const TemplateSelector: React.FC = ({ onChange?.(item.id, item); }; + if (loading) { + return ( + + + {_.times(6, (index: number) => ( + + ))} + + + ); + } + + if (!groups.length) { + return ; + } + const renderItem = (item: TemplateItem) => ( = ({ const showGroupTitles = groups.length > 1; return ( - + {groups.map((group) => ( {showGroupTitles && {group.label}} {group.items.map(renderItem)} ))} - + ); }; diff --git a/src/pages/gpu-service/instances/index.tsx b/src/pages/gpu-service/instances/index.tsx index 86c7cf55..69ec89c0 100644 --- a/src/pages/gpu-service/instances/index.tsx +++ b/src/pages/gpu-service/instances/index.tsx @@ -111,7 +111,7 @@ const GPUService: React.FC = () => { useEffect(() => { fetchClusterList({ page: -1 }); - (async () => { + const fetchPVCapacities = async () => { try { const res = await queryGPUServiceStorage({ page: -1 } as any); const map: Record = {}; @@ -124,7 +124,8 @@ const GPUService: React.FC = () => { } catch { // best-effort; the popover falls back to the PV name } - })(); + }; + fetchPVCapacities(); }, []); const hasK8sCluster = useMemo( @@ -387,6 +388,7 @@ const GPUService: React.FC = () => { data={openInstanceModalStatus.currentData} width={openInstanceModalStatus.width} realAction={openInstanceModalStatus.realAction} + clusterList={clusterList} onCancel={closeInstanceModal} onOk={handleModalOk} /> diff --git a/src/pages/llmodels/components/model-source/file-skeleton.tsx b/src/pages/llmodels/components/model-source/file-skeleton.tsx index 93442adc..5f478be7 100644 --- a/src/pages/llmodels/components/model-source/file-skeleton.tsx +++ b/src/pages/llmodels/components/model-source/file-skeleton.tsx @@ -31,4 +31,24 @@ const FileSkeleton: React.FC<{ counts: number; itemHeight?: number }> = ({ ); }; +export const FileSkeletonRows: React.FC<{ + counts: number; + itemHeight?: number; +}> = ({ counts = 2, itemHeight }) => { + return ( + + + + '60%') + }} + title={false} + > + + + ); +}; + export default FileSkeleton;