chore: instance creationg UE
This commit is contained in:
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user