fix: scope storage type picker to the target organization
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.
This commit is contained in:
@@ -10,12 +10,18 @@ import useOverlayLayout from '../hooks/use-overlay-layout';
|
|||||||
|
|
||||||
interface StorageOverlayProps {
|
interface StorageOverlayProps {
|
||||||
open: boolean;
|
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;
|
onCancel: () => void;
|
||||||
onSubmit: (values: StorageFormData) => Promise<void> | void;
|
onSubmit: (values: StorageFormData) => Promise<void> | void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const StorageOverlay: React.FC<StorageOverlayProps> = ({
|
const StorageOverlay: React.FC<StorageOverlayProps> = ({
|
||||||
open,
|
open,
|
||||||
|
scopeOrgId,
|
||||||
onCancel,
|
onCancel,
|
||||||
onSubmit
|
onSubmit
|
||||||
}) => {
|
}) => {
|
||||||
@@ -28,9 +34,14 @@ const StorageOverlay: React.FC<StorageOverlayProps> = ({
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (open) {
|
if (open) {
|
||||||
fetchStorageClass({ page: -1 });
|
fetchStorageClass(
|
||||||
|
{ page: -1 },
|
||||||
|
scopeOrgId != null
|
||||||
|
? { headers: { 'X-Organization-Id': String(scopeOrgId) } }
|
||||||
|
: undefined
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}, [open]);
|
}, [open, scopeOrgId]);
|
||||||
|
|
||||||
const handleSubmit = () => {
|
const handleSubmit = () => {
|
||||||
formRef.current?.submit();
|
formRef.current?.submit();
|
||||||
@@ -75,6 +86,7 @@ const StorageOverlay: React.FC<StorageOverlayProps> = ({
|
|||||||
ref={formRef}
|
ref={formRef}
|
||||||
action={PageAction.CREATE}
|
action={PageAction.CREATE}
|
||||||
open={open}
|
open={open}
|
||||||
|
showOrgScope={false}
|
||||||
onFinish={handleFinish}
|
onFinish={handleFinish}
|
||||||
/>
|
/>
|
||||||
</FormContext.Provider>
|
</FormContext.Provider>
|
||||||
|
|||||||
@@ -30,6 +30,10 @@ const StorageVolume = ({
|
|||||||
const { getRuleMessage } = useAppUtils();
|
const { getRuleMessage } = useAppUtils();
|
||||||
const form = Form.useFormInstance<FormData>();
|
const form = Form.useFormInstance<FormData>();
|
||||||
const storageMode = Form.useWatch('storageMode', form);
|
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 { fetchData: createStorage } = useCreateStorage();
|
||||||
const { detailData: storageData, fetchData: fetchStorage } =
|
const { detailData: storageData, fetchData: fetchStorage } =
|
||||||
useQueryStorage();
|
useQueryStorage();
|
||||||
@@ -232,6 +236,7 @@ const StorageVolume = ({
|
|||||||
|
|
||||||
<StorageOverlay
|
<StorageOverlay
|
||||||
open={overlayOpen}
|
open={overlayOpen}
|
||||||
|
scopeOrgId={scopeOrgId}
|
||||||
onCancel={() => setOverlayOpen(false)}
|
onCancel={() => setOverlayOpen(false)}
|
||||||
onSubmit={handleCreateStorage}
|
onSubmit={handleCreateStorage}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -51,6 +51,11 @@ export async function queryStorageClass(
|
|||||||
return request<Global.PageResponse<StorageClassItem>>(STORAGE_CLASS_API, {
|
return request<Global.PageResponse<StorageClassItem>>(STORAGE_CLASS_API, {
|
||||||
method: 'GET',
|
method: 'GET',
|
||||||
params,
|
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
|
cancelToken: options?.token
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
import { PageActionType } from '@/config/types';
|
import { PageActionType } from '@/config/types';
|
||||||
import useSubmitLock from '@/hooks/use-submit-lock';
|
import useSubmitLock from '@/hooks/use-submit-lock';
|
||||||
import { FormDrawer, ModalFooter } from '@gpustack/core-ui';
|
import { FormDrawer, ModalFooter } from '@gpustack/core-ui';
|
||||||
import { useRef } from 'react';
|
import { useEffect, useRef } from 'react';
|
||||||
import { FormContext } from '../config/form-context';
|
import { FormContext } from '../config/form-context';
|
||||||
import { FormData, ListItem } from '../config/types';
|
import { FormData, ListItem } from '../config/types';
|
||||||
import GPUServiceStorageForm from '../forms';
|
import GPUServiceStorageForm from '../forms';
|
||||||
|
import useQueryStorageClass from '../services/use-query-storage-class';
|
||||||
|
|
||||||
type AddModalProps = {
|
type AddModalProps = {
|
||||||
title: string;
|
title: string;
|
||||||
@@ -13,7 +14,6 @@ type AddModalProps = {
|
|||||||
onOk: (values: FormData) => void;
|
onOk: (values: FormData) => void;
|
||||||
data?: ListItem | null;
|
data?: ListItem | null;
|
||||||
onCancel: () => void;
|
onCancel: () => void;
|
||||||
storageClassList: Global.BaseOption<string>[];
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const AddModal: React.FC<AddModalProps> = ({
|
const AddModal: React.FC<AddModalProps> = ({
|
||||||
@@ -22,11 +22,33 @@ const AddModal: React.FC<AddModalProps> = ({
|
|||||||
open,
|
open,
|
||||||
onOk,
|
onOk,
|
||||||
data,
|
data,
|
||||||
onCancel,
|
onCancel
|
||||||
storageClassList
|
|
||||||
}) => {
|
}) => {
|
||||||
const form = useRef<any>(null);
|
const form = useRef<any>(null);
|
||||||
const { loading, guard, run, release } = useSubmitLock();
|
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 = () => {
|
const handleSubmit = () => {
|
||||||
guard(() => form.current?.submit());
|
guard(() => form.current?.submit());
|
||||||
@@ -70,6 +92,7 @@ const AddModal: React.FC<AddModalProps> = ({
|
|||||||
ref={form}
|
ref={form}
|
||||||
action={action}
|
action={action}
|
||||||
currentData={data}
|
currentData={data}
|
||||||
|
onScopeChange={handleScopeChange}
|
||||||
onFinish={onFinish}
|
onFinish={onFinish}
|
||||||
onFinishFailed={release}
|
onFinishFailed={release}
|
||||||
open={open}
|
open={open}
|
||||||
|
|||||||
@@ -12,7 +12,23 @@ import { useContext } from 'react';
|
|||||||
import { FormContext } from '../config/form-context';
|
import { FormContext } from '../config/form-context';
|
||||||
import { FormData } from '../config/types';
|
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 intl = useIntl();
|
||||||
const { getRuleMessage } = useAppUtils();
|
const { getRuleMessage } = useAppUtils();
|
||||||
const { storageClassList } = useContext(FormContext);
|
const { storageClassList } = useContext(FormContext);
|
||||||
@@ -44,7 +60,12 @@ const Basic = ({ action, open }: { action: string; open: boolean }) => {
|
|||||||
label={intl.formatMessage({ id: 'common.table.displayName' })}
|
label={intl.formatMessage({ id: 'common.table.displayName' })}
|
||||||
/>
|
/>
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
<PluginExtraFields name="CreateOrgScopeField" context={{ action }} />
|
{showOrgScope && (
|
||||||
|
<PluginExtraFields
|
||||||
|
name="CreateOrgScopeField"
|
||||||
|
context={{ action, onChange: onOrgScopeChange }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
<Flex gap={16}>
|
<Flex gap={16}>
|
||||||
<div style={{ flex: 1 }}>
|
<div style={{ flex: 1 }}>
|
||||||
<Form.Item<FormData>
|
<Form.Item<FormData>
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import { PageAction } from '@/config';
|
import { PageAction } from '@/config';
|
||||||
import { PageActionType } from '@/config/types';
|
import { PageActionType } from '@/config/types';
|
||||||
|
import { useMemoizedFn } from 'ahooks';
|
||||||
import { Form } from 'antd';
|
import { Form } from 'antd';
|
||||||
import { forwardRef, useEffect, useImperativeHandle } from 'react';
|
import { forwardRef, useEffect, useImperativeHandle, useRef } from 'react';
|
||||||
import { FormData, ListItem } from '../config/types';
|
import { FormData, ListItem } from '../config/types';
|
||||||
import Basic from './basic';
|
import Basic from './basic';
|
||||||
|
|
||||||
@@ -10,14 +11,67 @@ interface StorageFormProps {
|
|||||||
open: boolean;
|
open: boolean;
|
||||||
action: PageActionType;
|
action: PageActionType;
|
||||||
currentData?: ListItem | null;
|
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<void>;
|
onFinish: (values: FormData) => Promise<void>;
|
||||||
onFinishFailed?: (errorInfo: any) => void;
|
onFinishFailed?: (errorInfo: any) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const GPUServiceStorageForm: React.FC<StorageFormProps> = forwardRef(
|
const GPUServiceStorageForm: React.FC<StorageFormProps> = forwardRef(
|
||||||
(props, ref) => {
|
(props, ref) => {
|
||||||
const { action, currentData, open, onFinish, onFinishFailed } = props;
|
const {
|
||||||
|
action,
|
||||||
|
currentData,
|
||||||
|
open,
|
||||||
|
showOrgScope = true,
|
||||||
|
onScopeChange,
|
||||||
|
onFinish,
|
||||||
|
onFinishFailed
|
||||||
|
} = props;
|
||||||
const [form] = Form.useForm<FormData>();
|
const [form] = Form.useForm<FormData>();
|
||||||
|
// `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(() => {
|
useEffect(() => {
|
||||||
if (!open) {
|
if (!open) {
|
||||||
@@ -56,7 +110,12 @@ const GPUServiceStorageForm: React.FC<StorageFormProps> = forwardRef(
|
|||||||
preserve={false}
|
preserve={false}
|
||||||
initialValues={{}}
|
initialValues={{}}
|
||||||
>
|
>
|
||||||
<Basic action={action} open={open} />
|
<Basic
|
||||||
|
action={action}
|
||||||
|
open={open}
|
||||||
|
showOrgScope={showOrgScope}
|
||||||
|
onOrgScopeChange={handleOrgScopeChange}
|
||||||
|
/>
|
||||||
</Form>
|
</Form>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -177,7 +177,6 @@ const GPUServiceStorage: React.FC = () => {
|
|||||||
action={openStorageModalStatus.action}
|
action={openStorageModalStatus.action}
|
||||||
title={openStorageModalStatus.title}
|
title={openStorageModalStatus.title}
|
||||||
data={openStorageModalStatus.currentData}
|
data={openStorageModalStatus.currentData}
|
||||||
storageClassList={storageClassList}
|
|
||||||
onCancel={closeStorageModal}
|
onCancel={closeStorageModal}
|
||||||
onOk={handleModalOk}
|
onOk={handleModalOk}
|
||||||
/>
|
/>
|
||||||
|
|||||||
Reference in New Issue
Block a user