chore: instance creationg UE

This commit is contained in:
jialin
2026-06-14 19:14:18 +08:00
committed by jialin
parent 8c06862705
commit 6f56e614f3
8 changed files with 131 additions and 87 deletions
@@ -14,7 +14,7 @@ export const useQueryClusterList = (options?: { useStateData?: boolean }) => {
const { useStateData = true } = options || {};
const axiosTokenRef = useRef<CancelTokenSource | null>(null);
const [dataList, setDataList] = useState<
Array<Partial<ClusterListItem> & { label: string; value: number }>
Array<ClusterListItem & { label: string; value: number }>
>([]);
const {
@@ -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<AddModalProps> = ({
data,
onCancel,
width,
clusterList = [],
realAction
}) => {
const intl = useIntl();
@@ -100,16 +106,18 @@ const AddModal: React.FC<AddModalProps> = ({
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<number | null | undefined>(
@@ -125,18 +133,13 @@ const AddModal: React.FC<AddModalProps> = ({
// 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<AddModalProps> = ({
};
const ownedInstanceTypes = useMemo(
() => filterTypesByOwner(instanceTypeList, clusterList as any, scopeOrgId),
() => filterTypesByOwner(instanceTypeList, scopeOrgId),
[instanceTypeList, clusterList, scopeOrgId]
);
@@ -279,11 +282,6 @@ const AddModal: React.FC<AddModalProps> = ({
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<AddModalProps> = ({
}
// 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<AddModalProps> = ({
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<AddModalProps> = ({
// 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<AddModalProps> = ({
onChange={(e) => setTemplateKeyword(e.target.value)}
/>
</div>
{filteredTemplates.length > 0 && initializedRef.current ? (
<TemplateSelector
value={templateId}
groups={templateGroups}
onChange={handleTemplateChange}
/>
) : (
<Empty image={Empty.PRESENTED_IMAGE_SIMPLE} />
)}
<TemplateSelector
value={templateId}
loading={templateLoading || !initialized}
groups={templateGroups}
onChange={handleTemplateChange}
/>
</div>
</ColumnWrapper>
<Separator></Separator>
@@ -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<InstanceTypeListProps> = ({
if (loading) {
return (
<Spin spinning size="middle">
<TypeGrid style={{ minHeight: 200 }}>
<Flex orientation="vertical" gap={16} style={{ minHeight: 200 }}>
{_.times(6, (index: number) => (
<FileSkeleton key={index} counts={3} itemHeight={106} />
<FileSkeletonRows key={index} counts={2} itemHeight={106} />
))}
</TypeGrid>
</Flex>
</Spin>
);
}
@@ -46,7 +39,7 @@ const InstanceTypeList: React.FC<InstanceTypeListProps> = ({
}
return (
<TypeGrid>
<Flex orientation="vertical" gap={16}>
{dataList.map((item) => {
const name = item.name;
return (
@@ -64,7 +57,7 @@ const InstanceTypeList: React.FC<InstanceTypeListProps> = ({
</TemplateCard>
);
})}
</TypeGrid>
</Flex>
);
};
+25 -14
View File
@@ -129,6 +129,7 @@ const GPUServiceInstanceForm: React.FC<InstanceFormProps> = 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<InstanceFormProps> = 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<InstanceFormProps> = 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<InstanceFormProps> = forwardRef(
image: '',
imagePullPolicy: DefaultImagePullPolicy,
command: [],
ports: [],
ports: [
{
protocol: 'TCP',
port: SSH_PORT,
name: 'SSH'
}
],
env: [],
volumeMount: '',
resources: {
@@ -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(
@@ -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<TemplateSelectorProps> = ({
value,
onChange,
loading,
groups = []
}) => {
const intl = useIntl();
@@ -73,6 +78,22 @@ const TemplateSelector: React.FC<TemplateSelectorProps> = ({
onChange?.(item.id, item);
};
if (loading) {
return (
<Spin spinning size="middle">
<Flex orientation="vertical" gap={16} style={{ minHeight: 200 }}>
{_.times(6, (index: number) => (
<FileSkeletonRows key={index} counts={2} itemHeight={106} />
))}
</Flex>
</Spin>
);
}
if (!groups.length) {
return <Empty image={Empty.PRESENTED_IMAGE_SIMPLE} />;
}
const renderItem = (item: TemplateItem) => (
<TemplateCard
key={item.id}
@@ -120,14 +141,14 @@ const TemplateSelector: React.FC<TemplateSelectorProps> = ({
const showGroupTitles = groups.length > 1;
return (
<TemplateGrid>
<Flex orientation="vertical" gap={16}>
{groups.map((group) => (
<Fragment key={group.key}>
{showGroupTitles && <GroupTitle>{group.label}</GroupTitle>}
{group.items.map(renderItem)}
</Fragment>
))}
</TemplateGrid>
</Flex>
);
};
+4 -2
View File
@@ -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<string, string> = {};
@@ -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}
/>
@@ -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 (
<Wrapper orientation="vertical" style={{ height: itemHeight || 'auto' }}>
<Skeleton paragraph={{ rows: 1, width: '100%' }} title={false}></Skeleton>
<Flex orientation="vertical" gap={12}>
<Skeleton
paragraph={{
rows: counts,
width: Array.from({ length: counts }, () => '60%')
}}
title={false}
></Skeleton>
</Flex>
</Wrapper>
);
};
export default FileSkeleton;