From 644aba3fc20e3566f8731aaeb4c7bba69d0b8915 Mon Sep 17 00:00:00 2001 From: gitlawr Date: Wed, 8 Jul 2026 12:20:27 +0800 Subject: [PATCH] fix: scope storage type picker to the target organization MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The storage create form fetched persistent-volume types once with no tenant scope, so in the platform-admin "All" view the dropdown listed every org's types and never re-scoped when the create-scope picker retargeted the form — letting an org with no cluster-access grant pick another org's types. The inline "add storage" sub-drawer in the instance create form had the same gap plus a redundant org picker that could retarget the storage away from the instance's org. Both surfaces now pin the storage-type list to the chosen org (via the X-Organization-Id header); the sub-drawer inherits the instance's scope and hides its own picker. Page/table listings stay unscoped so every row's type label still renders. --- .../instances/forms/storage-overlay.tsx | 16 ++++- .../instances/forms/storage-volume.tsx | 5 ++ src/pages/gpu-service/storage/apis/index.ts | 5 ++ .../storage/components/add-modal.tsx | 31 +++++++-- src/pages/gpu-service/storage/forms/basic.tsx | 25 ++++++- src/pages/gpu-service/storage/forms/index.tsx | 65 ++++++++++++++++++- src/pages/gpu-service/storage/index.tsx | 1 - 7 files changed, 136 insertions(+), 12 deletions(-) diff --git a/src/pages/gpu-service/instances/forms/storage-overlay.tsx b/src/pages/gpu-service/instances/forms/storage-overlay.tsx index 524aa2f1..abece0a6 100644 --- a/src/pages/gpu-service/instances/forms/storage-overlay.tsx +++ b/src/pages/gpu-service/instances/forms/storage-overlay.tsx @@ -10,12 +10,18 @@ import useOverlayLayout from '../hooks/use-overlay-layout'; interface StorageOverlayProps { open: boolean; + // Org the surrounding instance create form targets (platform admin "All" + // view). The storage inherits this scope, so the type list is pinned to + // it — the picker only offers types that org can reference. Undefined + // when there's no create-scope picker (the ambient org context applies). + scopeOrgId?: number | null; onCancel: () => void; onSubmit: (values: StorageFormData) => Promise | void; } const StorageOverlay: React.FC = ({ open, + scopeOrgId, onCancel, onSubmit }) => { @@ -28,9 +34,14 @@ const StorageOverlay: React.FC = ({ useEffect(() => { if (open) { - fetchStorageClass({ page: -1 }); + fetchStorageClass( + { page: -1 }, + scopeOrgId != null + ? { headers: { 'X-Organization-Id': String(scopeOrgId) } } + : undefined + ); } - }, [open]); + }, [open, scopeOrgId]); const handleSubmit = () => { formRef.current?.submit(); @@ -75,6 +86,7 @@ const StorageOverlay: React.FC = ({ ref={formRef} action={PageAction.CREATE} open={open} + showOrgScope={false} onFinish={handleFinish} /> diff --git a/src/pages/gpu-service/instances/forms/storage-volume.tsx b/src/pages/gpu-service/instances/forms/storage-volume.tsx index bc95c269..bec8a180 100644 --- a/src/pages/gpu-service/instances/forms/storage-volume.tsx +++ b/src/pages/gpu-service/instances/forms/storage-volume.tsx @@ -30,6 +30,10 @@ const StorageVolume = ({ const { getRuleMessage } = useAppUtils(); const form = Form.useFormInstance(); const storageMode = Form.useWatch('storageMode', form); + // Owned by the instance create-scope picker (platform admin "All" view). + // A storage added inline belongs to the same org as the instance, so pass + // it to the overlay to scope the storage-type list to that org. + const scopeOrgId = Form.useWatch('organization_id', form); const { fetchData: createStorage } = useCreateStorage(); const { detailData: storageData, fetchData: fetchStorage } = useQueryStorage(); @@ -232,6 +236,7 @@ const StorageVolume = ({ setOverlayOpen(false)} onSubmit={handleCreateStorage} /> diff --git a/src/pages/gpu-service/storage/apis/index.ts b/src/pages/gpu-service/storage/apis/index.ts index 7617c111..c116d88c 100644 --- a/src/pages/gpu-service/storage/apis/index.ts +++ b/src/pages/gpu-service/storage/apis/index.ts @@ -51,6 +51,11 @@ export async function queryStorageClass( return request>(STORAGE_CLASS_API, { method: 'GET', params, + // `headers` lets the create form pin the request to a specific org so + // the picker only offers storage types that org can reference. GETs + // otherwise inherit the ambient org context, which is empty in the + // platform-admin "All" view and would list every org's types. + headers: options?.headers, cancelToken: options?.token }); } diff --git a/src/pages/gpu-service/storage/components/add-modal.tsx b/src/pages/gpu-service/storage/components/add-modal.tsx index 67f5a5b6..7145fbb8 100644 --- a/src/pages/gpu-service/storage/components/add-modal.tsx +++ b/src/pages/gpu-service/storage/components/add-modal.tsx @@ -1,10 +1,11 @@ import { PageActionType } from '@/config/types'; import useSubmitLock from '@/hooks/use-submit-lock'; import { FormDrawer, ModalFooter } from '@gpustack/core-ui'; -import { useRef } from 'react'; +import { useEffect, useRef } from 'react'; import { FormContext } from '../config/form-context'; import { FormData, ListItem } from '../config/types'; import GPUServiceStorageForm from '../forms'; +import useQueryStorageClass from '../services/use-query-storage-class'; type AddModalProps = { title: string; @@ -13,7 +14,6 @@ type AddModalProps = { onOk: (values: FormData) => void; data?: ListItem | null; onCancel: () => void; - storageClassList: Global.BaseOption[]; }; const AddModal: React.FC = ({ @@ -22,11 +22,33 @@ const AddModal: React.FC = ({ open, onOk, data, - onCancel, - storageClassList + onCancel }) => { const form = useRef(null); const { loading, guard, run, release } = useSubmitLock(); + // The dropdown gets its own storage-type list, scoped to the org the + // create-scope picker targets — distinct from the page-level list, which + // stays unscoped so the table can label every org's rows. + const { storageClassList, fetchData: fetchStorageClass } = + useQueryStorageClass(); + + useEffect(() => { + if (open) { + fetchStorageClass({ page: -1 }); + } + }, [open]); + + // Platform admin picked a target org in the create-scope slot: reload the + // storage-type list pinned to that org so the dropdown only offers types + // the org can reference (its own plus any reachable via cluster access). + const handleScopeChange = (orgId?: number | null) => { + fetchStorageClass( + { page: -1 }, + orgId != null + ? { headers: { 'X-Organization-Id': String(orgId) } } + : undefined + ); + }; const handleSubmit = () => { guard(() => form.current?.submit()); @@ -70,6 +92,7 @@ const AddModal: React.FC = ({ ref={form} action={action} currentData={data} + onScopeChange={handleScopeChange} onFinish={onFinish} onFinishFailed={release} open={open} diff --git a/src/pages/gpu-service/storage/forms/basic.tsx b/src/pages/gpu-service/storage/forms/basic.tsx index d6dd0dc2..242bba80 100644 --- a/src/pages/gpu-service/storage/forms/basic.tsx +++ b/src/pages/gpu-service/storage/forms/basic.tsx @@ -12,7 +12,23 @@ import { useContext } from 'react'; import { FormContext } from '../config/form-context'; import { FormData } from '../config/types'; -const Basic = ({ action, open }: { action: string; open: boolean }) => { +const Basic = ({ + action, + open, + showOrgScope = true, + onOrgScopeChange +}: { + action: string; + open: boolean; + // Hosts that already fix the tenant scope elsewhere (e.g. the instance + // create form, which owns the org picker) hide this slot so the storage + // is created in the surrounding scope rather than a second, conflicting + // one. + showOrgScope?: boolean; + // Fired on a user selection in the create-scope picker so the host can + // reload the org-scoped storage-type list. + onOrgScopeChange?: (orgId: number | null | undefined) => void; +}) => { const intl = useIntl(); const { getRuleMessage } = useAppUtils(); const { storageClassList } = useContext(FormContext); @@ -44,7 +60,12 @@ const Basic = ({ action, open }: { action: string; open: boolean }) => { label={intl.formatMessage({ id: 'common.table.displayName' })} /> - + {showOrgScope && ( + + )}
diff --git a/src/pages/gpu-service/storage/forms/index.tsx b/src/pages/gpu-service/storage/forms/index.tsx index 890f2311..6e76962f 100644 --- a/src/pages/gpu-service/storage/forms/index.tsx +++ b/src/pages/gpu-service/storage/forms/index.tsx @@ -1,7 +1,8 @@ import { PageAction } from '@/config'; import { PageActionType } from '@/config/types'; +import { useMemoizedFn } from 'ahooks'; import { Form } from 'antd'; -import { forwardRef, useEffect, useImperativeHandle } from 'react'; +import { forwardRef, useEffect, useImperativeHandle, useRef } from 'react'; import { FormData, ListItem } from '../config/types'; import Basic from './basic'; @@ -10,14 +11,67 @@ interface StorageFormProps { open: boolean; action: PageActionType; currentData?: ListItem | null; + // Hide the create-scope org picker when the host already fixes the + // tenant scope (e.g. the instance create form). Defaults to shown. + showOrgScope?: boolean; + // Fired when the create-scope picker (platform admin "All" view) + // retargets the form to another org. Only emitted on genuine changes, + // never on the initial mount, and never in builds where the picker + // isn't mounted (the watched field stays undefined). Lets the parent + // reload the org-scoped storage-type options. + onScopeChange?: (orgId: number | null | undefined) => void; onFinish: (values: FormData) => Promise; onFinishFailed?: (errorInfo: any) => void; } const GPUServiceStorageForm: React.FC = forwardRef( (props, ref) => { - const { action, currentData, open, onFinish, onFinishFailed } = props; + const { + action, + currentData, + open, + showOrgScope = true, + onScopeChange, + onFinish, + onFinishFailed + } = props; const [form] = Form.useForm(); + // `organization_id` is owned by the create-scope picker slot; it only + // exists/changes when a platform admin retargets the form. Watch it so + // the initial/default scope can be propagated once the picker resolves it. + const scopeOrgId = Form.useWatch('organization_id', form); + const scopeInitRef = useRef(true); + + // Stable wrapper so the effect / change handler always call the latest + // callback without re-subscribing on the parent's fn identity. + const orgScope = useMemoizedFn((orgId?: number | null) => { + onScopeChange?.(orgId); + }); + + // Genuine retarget by the create-scope picker: drop the stale type pick + // (it may name a type the newly chosen org can't reference) and reload the + // org-scoped list. Wired to the picker's own onChange, so it fires only on + // a user selection — never on the picker's programmatic default. + const handleOrgScopeChange = useMemoizedFn((orgId?: number | null) => { + scopeInitRef.current = false; + form.setFieldValue(['spec', 'type'], undefined); + orgScope(orgId ?? null); + }); + + useEffect(() => { + if (!open) { + // Re-arm the initial-scope propagation for the next open. + scopeInitRef.current = true; + return; + } + // Propagate the initial/default scope once the picker resolves it so the + // parent loads the org-scoped type list, leaving any pre-filled type + // intact. User-driven retargets go through handleOrgScopeChange instead. + if (scopeInitRef.current && scopeOrgId != null) { + scopeInitRef.current = false; + orgScope(scopeOrgId); + } + }, [open, scopeOrgId, orgScope]); useEffect(() => { if (!open) { @@ -56,7 +110,12 @@ const GPUServiceStorageForm: React.FC = forwardRef( preserve={false} initialValues={{}} > - + ); } diff --git a/src/pages/gpu-service/storage/index.tsx b/src/pages/gpu-service/storage/index.tsx index 17e77423..f05ec9f9 100644 --- a/src/pages/gpu-service/storage/index.tsx +++ b/src/pages/gpu-service/storage/index.tsx @@ -177,7 +177,6 @@ const GPUServiceStorage: React.FC = () => { action={openStorageModalStatus.action} title={openStorageModalStatus.title} data={openStorageModalStatus.currentData} - storageClassList={storageClassList} onCancel={closeStorageModal} onOk={handleModalOk} />