import { PageAction } from '@/config'; import { PageActionType } from '@/config/types'; import useSubmitLock from '@/hooks/use-submit-lock'; import useUserDirectory from '@/pages/gpu-service/hooks/use-user-directory'; import Separator from '@/pages/llmodels/components/separator'; import { getGPUStackPlugin } from '@/plugins'; import { SearchOutlined } from '@ant-design/icons'; import { AlertBlockInfo, ColumnWrapper, GSDrawer, ModalFooter } from '@gpustack/core-ui'; import { useIntl, useModel } from '@umijs/max'; import { Input, Typography } from 'antd'; import _ from 'lodash'; import { useEffect, useMemo, useRef, useState } from 'react'; import { ListItem as TemplateItem } from '../../templates/config/types'; import useQueryTemplates from '../../templates/services/use-query-templates'; import { FormData, InstanceTypeItem, ListItem } from '../config/types'; import GPUServiceInstanceForm from '../forms'; import TemplateSelector, { TemplateGroup } from '../forms/template-selector'; import useQueryInstanceTypes from '../services/use-query-instance-types'; import styles from '../styles/instances.module.less'; import InstanceTypeList from './instance-type-list'; type AddModalProps = { title: string; action: PageActionType; 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; }; const matchKeyword = (fields: Array, keyword: string) => { const trimmed = keyword.trim().toLowerCase(); if (!trimmed) return true; return fields.some((text) => String(text ?? '') .toLowerCase() .includes(trimmed) ); }; const ColTitle: React.FC<{ children: React.ReactNode; style?: React.CSSProperties; }> = ({ children, style }) => { return ( {children} ); }; const AddModal: React.FC = ({ title, action, open, onOk, data, onCancel, width, clusterList = [], realAction }) => { const intl = useIntl(); const { initialState } = useModel('@@initialState') || {}; const currentUser = initialState?.currentUser; const pluginActive = !!getGPUStackPlugin(); const userDirectory = useUserDirectory( !!currentUser?.is_admin && !pluginActive ); const form = useRef(null); const sessionRef = useRef(0); const [instanceTypeSelection, setInstanceTypeSelection] = useState<{ instanceType?: string; manufacturer?: string; }>({ instanceType: undefined, manufacturer: undefined }); const [templateId, setTemplateId] = useState(); const [instanceKeyword, setInstanceKeyword] = useState(''); const [templateKeyword, setTemplateKeyword] = useState(''); const { loading, guard, run, release } = useSubmitLock(); const [initialized, setInitialized] = useState(false); const { detailData: instanceTypeList, loading: instanceTypesLoading, fetchData } = useQueryInstanceTypes(); 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( undefined ); const templateList = templatesData?.items || []; // A GPU instance is scheduled on the chosen instance type's cluster, and // its owner is that cluster's owner. So when a platform admin targets an // org, restrict each instance type's candidates to clusters that org owns // (dropping tiers/types left with none). Header-independent: filters the // fetched list client-side, so it doesn't rely on the request scope. const filterTypesByOwner = ( types: InstanceTypeItem[], orgId?: number | null ): InstanceTypeItem[] => { if (orgId == null) return types; const owned = new Set( (clusterList || []) .filter((c) => c.owner_principal_id === orgId) .map((c) => c.id || c.value) ); return types .map((it) => ({ ...it, status: { ...it.status, tiers: (it.status?.tiers ?? []) .map((tier: any) => ({ ...tier, candidates: (tier.candidates ?? []).filter((c: any) => owned.has(Number(c.cluster)) ) })) .filter((tier: any) => (tier.candidates ?? []).length > 0) } })) .filter((it) => (it.status?.tiers ?? []).length > 0); }; const ownedInstanceTypes = useMemo( () => filterTypesByOwner(instanceTypeList, scopeOrgId), [instanceTypeList, clusterList, scopeOrgId] ); // const readonly = action === PageAction.VIEW; const readonly = false; const isRecreate = realAction === PageAction.CREATE; const showResourceSelectors = action === PageAction.CREATE || isRecreate; const shouldAutoSelectResource = action === PageAction.CREATE && !isRecreate; const findTemplateByManufacturer = ( manufacturer: string | undefined, templates: TemplateItem[] ) => { return manufacturer ? templates.find((t) => t.manufacturer === manufacturer) : undefined; }; const saveInstanceDataInDescription = (instanceType: InstanceTypeItem) => { return JSON.stringify({ name: instanceType.name, spec: { ..._.omit(instanceType.spec, ['cache', 'cpu']), cpu: _.pick(instanceType.spec?.cpu, [ 'manufacturer', 'product', 'family' ]) } }); }; // GPU types carry their accelerator vendor; non-acceleratable (CPU) types // all map to the single 'cpu' bucket used to match templates. const manufacturerOf = (instanceType: InstanceTypeItem) => instanceType.spec.acceleratable ? instanceType.spec?.manufacturer : 'cpu'; // apply the selection of instance type and template const applySelection = ( instanceType: InstanceTypeItem, template: TemplateItem | undefined ) => { const manufacturer = manufacturerOf(instanceType); setInstanceTypeSelection({ instanceType: instanceType.name, manufacturer }); setTemplateId(template?.id); if (template) { const formValues = form.current?.getFieldsValue(); form.current?.setFieldsValue({ description: saveInstanceDataInDescription(instanceType), spec: { ...formValues?.spec, ...template.spec, sshPublicKeys: formValues?.spec?.sshPublicKeys, volume: { ...formValues?.spec?.volume } } }); } else { form.current?.setFieldsValue({ description: saveInstanceDataInDescription(instanceType) }); } // update form form.current?.applyInstanceType?.(instanceType); }; // Drop the instance-type-derived selection + form state. Used when no // candidate is available (empty segment / org with no clusters) so a stale // type / cluster never survives a switch or reload. const clearSelection = () => { setInstanceTypeSelection({ instanceType: undefined, manufacturer: undefined }); setTemplateId(undefined); form.current?.applyInstanceType?.(undefined); form.current?.setFieldValue?.('clusterId', null); form.current?.setFieldValue?.(['spec', 'type'], undefined); }; const autoSelectFirst = ( types: InstanceTypeItem[], templates: TemplateItem[] ) => { const first = types.find((item) => !item.disabled); if (!first) { clearSelection(); return; } applySelection( first, findTemplateByManufacturer(manufacturerOf(first), templates) ); }; const findAggregateOf = ( candidateName: string | undefined, clusterId: number | null | undefined, instanceTypes: InstanceTypeItem[] ): InstanceTypeItem | undefined => { if (!candidateName) return undefined; return instanceTypes.find((item) => (item.status?.tiers ?? []).some((tier) => (tier.candidates ?? []).some( (c) => c.name === candidateName && Number(c.cluster) === clusterId ) ) ); }; // initial for first const applyAutoSelection = ( instanceTypes: InstanceTypeItem[], templates: TemplateItem[], orgId?: number | null ) => { // On edit / view, surface the persisted selection in the card list. if (!shouldAutoSelectResource) { const aggregate = findAggregateOf( data?.spec?.type, data?.clusterId, instanceTypes ); if (aggregate) { setInstanceTypeSelection({ instanceType: aggregate.name, manufacturer: manufacturerOf(aggregate) }); } return; } // Scope to clusters the chosen org owns (admin "All" view). const owned = filterTypesByOwner(instanceTypes, orgId); // On create, auto-select the first available instance type (clears the // selection when the chosen org has none). autoSelectFirst(owned, templates); }; // Fetch the (tenant-scoped) instance types + templates and auto-select. // The query hook cancels any in-flight request on each new call, so when // this runs twice in quick succession (drawer open, then the scope // picker settling on its default) the latest scope's result wins. const loadCreateResources = async (orgId?: number | null) => { const session = ++sessionRef.current; try { const [instanceResItems, templatesRes] = await Promise.all([ fetchData({ page: -1 }), fetchTemplates({ page: -1 }) ]); if (sessionRef.current !== session) return; applyAutoSelection( instanceResItems || [], templatesRes?.items || [], orgId ); setInitialized(true); } catch (error) { setInitialized(true); } }; // Platform admin retargeted the create to another org (or Global). The // instance-type / cluster offerings are tenant-scoped, so drop the // current pick and reload for the new scope. The request interceptor // already carries the new org header by the time this fires. const handleScopeChange = (orgId?: number | null) => { if (!open || action !== PageAction.CREATE) return; setScopeOrgId(orgId); // Drop the instance-type-derived selection + form state (the selected type // card + its limits, the cluster, and spec.type). The cluster decides // where the instance is scheduled, so a stale pick from the previous // scope must not survive — otherwise an instance owned by the newly // chosen org could land on the old org's cluster. The reload's // 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(); setInitialized(false); loadCreateResources(orgId); }; useEffect(() => { if (!open) { setInitialized(false); sessionRef.current += 1; setInstanceTypeSelection({ instanceType: undefined, manufacturer: undefined }); setTemplateId(undefined); setInstanceKeyword(''); setTemplateKeyword(''); setScopeOrgId(undefined); return; } if (action === PageAction.CREATE) { loadCreateResources(); } }, [open, shouldAutoSelectResource, action]); // filter instance types (already scoped to the chosen org's clusters) const filteredInstanceTypes = ownedInstanceTypes.filter((item) => matchKeyword([item.name], instanceKeyword) ); // No instance types for the chosen org (e.g. it owns no clusters), and not // mid-fetch — drives the "no available instance type" message in the form. const noAvailableInstanceTypes = action === PageAction.CREATE && !instanceTypesLoading && ownedInstanceTypes.length === 0; // filter templates based on selection and keyword const filteredTemplates = templateList.filter((item) => { if ( instanceTypeSelection.manufacturer && item.manufacturer !== instanceTypeSelection.manufacturer ) { return false; } return matchKeyword( [item.name, item.spec?.image, item.spec?.volumeMount], templateKeyword ); }); // Group the picker by owning scope so same-name templates stay // distinguishable: the caller's own templates first, then the // admin-curated Global presets, then — platform admin's cross-tenant // view only — other users' templates, one group per owner. // // The default buckets below assume every non-Global owner is a USER // principal. A plugin's principal model may scope templates to other // owner kinds (no user-directory entry, not the caller's user id), // which these buckets would mislabel — so a plugin can take over // grouping via `hooks.useTemplateOwnerGroups`. The registry is wired // at boot, so the conditional hook call is render-stable — same // contract as `usePluginListColumns`' function entries. const usePluginTemplateGroups = getGPUStackPlugin()?.hooks ?.useTemplateOwnerGroups as | ((items: TemplateItem[]) => TemplateGroup[]) | undefined; const pluginTemplateGroups = usePluginTemplateGroups?.(filteredTemplates); const templateGroups: TemplateGroup[] = useMemo(() => { if (pluginTemplateGroups) { return pluginTemplateGroups; } if (pluginActive) { // Plugin present but without the grouping hook (older plugin // build): keep the flat list rather than mislabeling owners // outside the USER-principal model. return filteredTemplates.length ? [{ key: 'all', label: null, items: filteredTemplates }] : []; } const yours: TemplateItem[] = []; const globals: TemplateItem[] = []; const byOwner = new Map(); filteredTemplates.forEach((item) => { if (item.owner_principal_id == null) { globals.push(item); } else if (item.owner_principal_id === currentUser?.id) { yours.push(item); } else { const list = byOwner.get(item.owner_principal_id) || []; list.push(item); byOwner.set(item.owner_principal_id, list); } }); const groups: TemplateGroup[] = []; if (yours.length) { groups.push({ key: 'yours', label: intl.formatMessage({ id: 'gpuservice.template.group.yours' }), items: yours }); } if (globals.length) { groups.push({ key: 'global', label: intl.formatMessage({ id: 'gpuservice.template.group.global' }), items: globals }); } groups.push( ...[...byOwner.entries()] .map(([ownerId, items]) => ({ key: `owner-${ownerId}`, // `#id` is a placeholder for the moment before the user // directory resolves (the memo recomputes once it lands) // and for the API-only case of a non-USER owner. label: userDirectory.get(ownerId) || `#${ownerId}`, items })) .sort((a, b) => String(a.label).localeCompare(String(b.label))) ); return groups; }, [ filteredTemplates, currentUser?.id, userDirectory, intl, pluginActive, pluginTemplateGroups ]); const handleSubmit = () => { guard(() => form.current?.submit()); }; const handleCancel = () => { form.current?.resetFields(); onCancel(); }; const onFinish = async (values: FormData) => { await run(async () => { await onOk({ ...values }); console.log('submit form values', values); }); }; const handleInstanceTypeChange = (item: InstanceTypeItem) => { const template = findTemplateByManufacturer( manufacturerOf(item), templateList ); applySelection(item, template); }; const handleTemplateChange = (id: number, item: TemplateItem) => { setTemplateId(id); const formValues = form.current?.getFieldsValue(); form.current?.setFieldsValue({ spec: { ...formValues?.spec, ...item.spec, sshPublicKeys: formValues?.spec?.sshPublicKeys, resources: { ...formValues?.spec?.resources, localStorage: item?.spec?.resources?.localStorage }, volume: { ...formValues?.spec?.volume } } }); }; return (
{showResourceSelectors && ( <>
{intl.formatMessage({ id: 'gpuservice.instance.types' })} } placeholder={intl.formatMessage({ id: 'gpuservice.instance.search.type.placeholder' })} value={instanceKeyword} onChange={(e) => setInstanceKeyword(e.target.value)} />
{intl.formatMessage({ id: 'gpuservice.instance.templates' })} } placeholder={intl.formatMessage({ id: 'gpuservice.instance.search.template.placeholder' })} value={templateKeyword} onChange={(e) => setTemplateKeyword(e.target.value)} />
)}
{isRecreate && open && (
)} } > <> {action !== PageAction.EDIT && ( {intl.formatMessage({ id: 'common.title.config' })} )}
); }; export default AddModal;