From 2889ba7c787fcecf87193d261003433f79224286 Mon Sep 17 00:00:00 2001 From: gitlawr Date: Fri, 8 May 2026 15:46:31 +0800 Subject: [PATCH] feat: plugin extension slots for access, request interceptors, Users actions, and form fields MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four purely-additive seams that let build-time tooling extend host behaviour without forking files: * `src/access.extensions.ts` — identity `applyAccessExtensions` (mirrors `config/routes.extensions.ts`). `src/access.ts` runs the resolved predicate object through it. Adds two new predicates, `canSeeOrgAdmin` and `canManageCurrentOrg`, alongside the existing `canSeeAdmin`. Resources, Models children, Cluster Management, and Resources/Backends are retagged from `canSeeAdmin` to `canSeeOrgAdmin`. Users and Dashboard stay strict. * `src/request.extensions.ts` — identity-empty `extraRequestInterceptors`. `src/request-config.tsx` spreads it into the existing `requestInterceptors` list so extensions can inject context-aware headers without forking the request config. * Users page action column — renders `getGPUStackPlugin()?.components?.UserRowActions` next to the existing DropdownButtons inside a Space when a plugin component is registered. If absent, the cell renders exactly as before. * `src/components/plugin-extra-fields.tsx` — generic component-slot helper. Renders `pluginManager.components.` if registered, forwarding a `context` payload. Used by create/edit forms to let plugins inject extra `Form.Item` fields. Mounted in the relevant create forms — API Keys, Cloud Credentials, Clusters, Model Routes, Model Providers, and Inference Backends — under the slot name `CreateOrgScopeField`. Resources whose org is implicit from a parent (Models / Workers / Benchmarks / Worker Pools / Model Files inherit from the chosen Cluster) deliberately don't mount the slot. --- config/routes.ts | 39 +++++++------- src/access.extensions.ts | 14 +++++ src/access.ts | 24 +++++++-- src/components/plugin-extra-fields.tsx | 27 ++++++++++ src/layouts/Exception.tsx | 44 +++++++++++----- src/layouts/index.tsx | 2 +- .../components/add-apikey-modal/form.tsx | 3 ++ .../backends/components/backend-card.tsx | 52 +++++++++++-------- src/pages/backends/config/index.ts | 6 ++- src/pages/backends/forms/basic.tsx | 5 ++ src/pages/backends/index.tsx | 10 +++- .../components/add-credential.tsx | 2 + .../components/cluster-form.tsx | 2 + src/pages/maas-provider/forms/basic.tsx | 2 + src/pages/model-routes/forms/basic.tsx | 5 ++ src/pages/users/hooks/use-users-columns.tsx | 20 +++++-- src/request-config.tsx | 6 ++- src/request.extensions.ts | 15 ++++++ 18 files changed, 211 insertions(+), 67 deletions(-) create mode 100644 src/access.extensions.ts create mode 100644 src/components/plugin-extra-fields.tsx create mode 100644 src/request.extensions.ts diff --git a/config/routes.ts b/config/routes.ts index fb461d0c..4ad99185 100644 --- a/config/routes.ts +++ b/config/routes.ts @@ -104,9 +104,19 @@ const baseRoutes = [ icon: 'icon-layers', selectedIcon: 'icon-layers-filled', defaultIcon: 'icon-layers', - access: 'canSeeAdmin', + access: 'canSeeOrgAdmin', component: './llmodels/catalog' }, + { + name: 'userModels', + path: '/models/user-models', + key: 'userModels', + icon: 'icon-models', + selectedIcon: 'icon-models-filled', + defaultIcon: 'icon-models', + access: 'canSeeUser', + component: './llmodels/user-models' + }, { name: 'deployment', path: '/models/deployments', @@ -114,7 +124,7 @@ const baseRoutes = [ icon: 'icon-rocket-launch1', selectedIcon: 'icon-rocket-launch-fill', defaultIcon: 'icon-rocket-launch1', - access: 'canSeeAdmin', + access: 'canSeeOrgAdmin', component: './llmodels/index' }, { @@ -124,7 +134,7 @@ const baseRoutes = [ icon: 'icon-captive_portal', selectedIcon: 'icon-captive_portal', defaultIcon: 'icon-captive_portal', - access: 'canSeeAdmin', + access: 'canSeeOrgAdmin', component: './model-routes/index' }, { @@ -143,20 +153,9 @@ const baseRoutes = [ icon: 'icon-extension-outline', selectedIcon: 'icon-extension-filled', defaultIcon: 'icon-extension-outline', - access: 'canSeeAdmin', + access: 'canSeeOrgAdmin', component: './maas-provider/index' }, - - { - name: 'userModels', - path: '/models/user-models', - key: 'userModels', - icon: 'icon-models', - selectedIcon: 'icon-models-filled', - defaultIcon: 'icon-models', - access: 'canSeeUser', - component: './llmodels/user-models' - }, { name: 'benchmark', path: '/models/benchmark', @@ -164,7 +163,7 @@ const baseRoutes = [ icon: 'icon-speed', selectedIcon: 'icon-speed-filled', defaultIcon: 'icon-speed', - access: 'canSeeAdmin', + access: 'canSeeOrgAdmin', component: './benchmark/index' }, { @@ -174,7 +173,7 @@ const baseRoutes = [ icon: 'icon-speed', selectedIcon: 'icon-speed-filled', defaultIcon: 'icon-speed', - access: 'canSeeAdmin', + access: 'canSeeOrgAdmin', hideInMenu: true, component: './benchmark/details' } @@ -184,7 +183,7 @@ const baseRoutes = [ name: 'resources', path: '/resources', key: 'resources', - access: 'canSeeAdmin', + access: 'canSeeOrgAdmin', routes: [ { path: '/resources', @@ -215,7 +214,7 @@ const baseRoutes = [ icon: 'icon-backend', selectedIcon: 'icon-backend-filled', defaultIcon: 'icon-backend', - access: 'canSeeAdmin', + access: 'canSeeOrgAdmin', component: './backends/index' }, { @@ -233,7 +232,7 @@ const baseRoutes = [ name: 'clusterManagement', path: '/cluster-management', key: 'clusterManagement', - access: 'canSeeAdmin', + access: 'canSeeOrgAdmin', routes: [ { path: '/cluster-management', diff --git a/src/access.extensions.ts b/src/access.extensions.ts new file mode 100644 index 00000000..d05da612 --- /dev/null +++ b/src/access.extensions.ts @@ -0,0 +1,14 @@ +// Identity hook for build-time access-predicate extensions. Tooling may +// overwrite this file to widen predicates; the original is restored on +// cleanup. Mirrors `config/routes.extensions.ts`. +export type AccessPredicates = { + canSeeAdmin: boolean; + canSeeOrgAdmin: boolean; + canManageCurrentOrg: boolean; + canSeeUser: boolean; + canDelete: boolean; + canLogin: boolean; +}; + +export const applyAccessExtensions = (base: T): T => + base; diff --git a/src/access.ts b/src/access.ts index c5dd7dc1..201f338d 100644 --- a/src/access.ts +++ b/src/access.ts @@ -1,5 +1,7 @@ +import { applyAccessExtensions } from './access.extensions'; + export default (initialState: { currentUser?: Global.UserInfo }) => { - const canSeeAdmin = !!( + const isPlatformAdmin = !!( initialState && initialState.currentUser && initialState.currentUser.is_admin @@ -10,10 +12,24 @@ export default (initialState: { currentUser?: Global.UserInfo }) => { !initialState.currentUser.is_admin ); - return { - canSeeAdmin, + // Predicate roles, top-down by strictness: + // * `canSeeAdmin` — strictly platform admin (`users.is_admin`). + // Gates Users, Dashboard. + // * `canSeeOrgAdmin` — admin-style menus that work cross-org + // (Resources, Models, Cluster Management). Defaults to platform + // admin; extensions widen to include org admins. + // * `canManageCurrentOrg` — pages that only make sense inside a + // specific org context (member / group management). Defaults to + // `false`; extensions widen when both an org is selected AND + // the caller is admin of it. + // Pass through `applyAccessExtensions` so build-time tooling can + // widen these without editing this file. Default is a no-op. + return applyAccessExtensions({ + canSeeAdmin: isPlatformAdmin, + canSeeOrgAdmin: isPlatformAdmin, + canManageCurrentOrg: false, canSeeUser, canDelete: true, canLogin: true - }; + }); }; diff --git a/src/components/plugin-extra-fields.tsx b/src/components/plugin-extra-fields.tsx new file mode 100644 index 00000000..e8ed1fc4 --- /dev/null +++ b/src/components/plugin-extra-fields.tsx @@ -0,0 +1,27 @@ +import { getGPUStackPlugin } from '@/plugins'; +import React from 'react'; + +// Render a plugin-provided component registered under +// `pluginManager.components.`. If no plugin is registered, or +// the plugin doesn't export this slot, renders nothing — host pages +// embedding the slot stay unchanged when no plugin is present. +// +// Used by create / edit forms to let plugins inject extra Form.Item +// fields. Because antd `Form` walks the JSX tree to collect named +// `Form.Item` values, the plugin's content gets picked up by the +// surrounding form automatically — no host-side wiring required. +const PluginExtraFields: React.FC<{ + name: string; + // Free-form payload forwarded to the plugin component, e.g. so a + // form-specific slot can pass its action / record context if it + // needs to. + context?: Record; +}> = ({ name, context }) => { + const Slot = getGPUStackPlugin()?.components?.[name]; + if (!Slot) { + return null; + } + return ; +}; + +export default PluginExtraFields; diff --git a/src/layouts/Exception.tsx b/src/layouts/Exception.tsx index 6e3ccf1a..c2a0f5fa 100644 --- a/src/layouts/Exception.tsx +++ b/src/layouts/Exception.tsx @@ -1,8 +1,13 @@ import { history, useIntl } from '@umijs/max'; import { Button, Result } from 'antd'; -import React from 'react'; +import React, { useEffect } from 'react'; import { PageContainerInner } from '../pages/_components/page-box'; +// On a 403, auto-redirect to root so context changes (e.g. an org +// switch into a context that can't see the current route) don't +// dead-end the user on a permission page. The 404 path stays as a +// classic Result with a Back button — those are usually genuine +// "wrong URL" landings the user should see and acknowledge. const Exception: React.FC<{ children: React.ReactNode; route?: any; @@ -12,23 +17,36 @@ const Exception: React.FC<{ noFound?: React.ReactNode; }> = (props) => { const intl = useIntl(); - // render custom 404 - console.log('exception====', props); + const unaccessible = !!props.route?.unaccessible; + const customNoAccessible = props.unAccessible || props.noAccessible; + const willAutoRedirect = unaccessible && !customNoAccessible; + + useEffect(() => { + if (willAutoRedirect) { + // `replace` instead of `push` so the user's Back button doesn't + // bounce them back into the unauthorized page (which would just + // redirect forward again). + history.replace('/'); + } + }, [willAutoRedirect]); + + // Render `null` during the auto-redirect path so the default 403 + // Result doesn't flash for one frame before useEffect fires. + if (willAutoRedirect) { + return null; + } + return ( (!props.route && (props.noFound || props.notFound)) || // render custom 403 - (props.route?.unaccessible && (props.unAccessible || props.noAccessible)) || - // render default exception - ((!props.route || props.route?.unaccessible) && ( + (unaccessible && customNoAccessible) || + // render default 404 + (!props.route && ( history.push('/')}> {intl.formatMessage({ id: 'common.button.back' })} diff --git a/src/layouts/index.tsx b/src/layouts/index.tsx index 65006030..5df4a51d 100644 --- a/src/layouts/index.tsx +++ b/src/layouts/index.tsx @@ -342,7 +342,7 @@ export default (props: any) => { config={{ apiBaseUrl: GPUSTACK_API_BASE_URL, theme: userSettings.theme, - iconUrl: '//at.alicdn.com/t/c/font_4613488_ueevrwt2v9.js', + iconUrl: '//at.alicdn.com/t/c/font_4613488_tzcsatubq4f.js', isDarkTheme: userSettings.isDarkTheme, defaultColorPrimary: COLOR_PRIMARY }} diff --git a/src/pages/api-keys/components/add-apikey-modal/form.tsx b/src/pages/api-keys/components/add-apikey-modal/form.tsx index e98305f0..9f905b8a 100644 --- a/src/pages/api-keys/components/add-apikey-modal/form.tsx +++ b/src/pages/api-keys/components/add-apikey-modal/form.tsx @@ -1,3 +1,4 @@ +import PluginExtraFields from '@/components/plugin-extra-fields'; import { PageAction } from '@/config'; import { PageActionType } from '@/config/types'; import { @@ -44,6 +45,8 @@ const APIKeyForm: React.FC<{ > + + name="expires_in" rules={[ diff --git a/src/pages/backends/components/backend-card.tsx b/src/pages/backends/components/backend-card.tsx index 8e4e7158..dde205c2 100644 --- a/src/pages/backends/components/backend-card.tsx +++ b/src/pages/backends/components/backend-card.tsx @@ -1,3 +1,4 @@ +import PluginExtraFields from '@/components/plugin-extra-fields'; import { AutoTooltip, DropdownActions, @@ -248,30 +249,39 @@ const BackendCard: React.FC = ({ const source = data.is_built_in ? BackendSourceLabelMap[BackendSourceValueMap.BUILTIN] || '' : BackendSourceLabelMap[data.backend_source] || ''; + const ownerTag = ( + + ); if (!source) { - return null; + return ownerTag; } return ( - - {intl.formatMessage({ - id: source - })} - +
+ + {intl.formatMessage({ + id: source + })} + + {ownerTag} +
); }; diff --git a/src/pages/backends/config/index.ts b/src/pages/backends/config/index.ts index d6e06322..72e8881f 100644 --- a/src/pages/backends/config/index.ts +++ b/src/pages/backends/config/index.ts @@ -109,7 +109,11 @@ export const backendActions = [ icon: icons.DeleteOutlined, locale: true, danger: true, - show: (record: any) => !record.is_built_in + // Platform built-ins are admin-curated and not user-deletable. + // An org-scoped override of a built-in IS deletable — deleting + // the override is how the user reverts to the Platform row. + // Plain custom backends are always deletable. + show: (record: any) => !record.is_built_in || record.organization_id != null } ]; diff --git a/src/pages/backends/forms/basic.tsx b/src/pages/backends/forms/basic.tsx index 3d256d6f..24bcbb92 100644 --- a/src/pages/backends/forms/basic.tsx +++ b/src/pages/backends/forms/basic.tsx @@ -1,3 +1,4 @@ +import PluginExtraFields from '@/components/plugin-extra-fields'; import { PageAction } from '@/config'; import { backendOptionsMap } from '@/pages/llmodels/constants/backend-parameters'; import { @@ -67,6 +68,10 @@ const BasicForm = () => { required > + hidden name="backend_source"> diff --git a/src/pages/backends/index.tsx b/src/pages/backends/index.tsx index 22aa6ca0..46b99f99 100644 --- a/src/pages/backends/index.tsx +++ b/src/pages/backends/index.tsx @@ -166,7 +166,15 @@ const BackendList = () => { } // ================ Delete ================ if (item.action === 'delete') { - if (item.data.backend_source === BackendSourceValueMap.COMMUNITY) { + // Platform community rows aren't actually deletable — the + // "delete" action there is a soft-disable on the admin-curated + // catalog. An org-scoped row of any source (including an org's + // override of a community backend) IS owner-mutable data and + // gets a real DELETE. + const isPlatformCommunity = + item.data.backend_source === BackendSourceValueMap.COMMUNITY && + item.data.organization_id == null; + if (isPlatformCommunity) { modalRef.current?.show({ content: 'backends.title', operation: 'common.delete.single.confirm', diff --git a/src/pages/cluster-management/components/add-credential.tsx b/src/pages/cluster-management/components/add-credential.tsx index b4331080..8ca34ab6 100644 --- a/src/pages/cluster-management/components/add-credential.tsx +++ b/src/pages/cluster-management/components/add-credential.tsx @@ -1,3 +1,4 @@ +import PluginExtraFields from '@/components/plugin-extra-fields'; import { PageAction } from '@/config'; import { PageActionType } from '@/config/types'; import { Input as CInput, FormDrawer, useAppUtils } from '@gpustack/core-ui'; @@ -77,6 +78,7 @@ const AddModal: React.FC = ({ required > + {provider === ProviderValueMap.DigitalOcean && ( <> diff --git a/src/pages/cluster-management/components/cluster-form.tsx b/src/pages/cluster-management/components/cluster-form.tsx index fe954b15..2af77642 100644 --- a/src/pages/cluster-management/components/cluster-form.tsx +++ b/src/pages/cluster-management/components/cluster-form.tsx @@ -1,3 +1,4 @@ +import PluginExtraFields from '@/components/plugin-extra-fields'; import { PageAction } from '@/config'; import { PageActionType } from '@/config/types'; import { json2Yaml, yaml2Json } from '@/pages/backends/config'; @@ -172,6 +173,7 @@ const ClusterForm: React.FC = forwardRef( trim={false} > + {provider === ProviderValueMap.DigitalOcean && ( + name={['config', 'type']} rules={[ diff --git a/src/pages/model-routes/forms/basic.tsx b/src/pages/model-routes/forms/basic.tsx index 80c5addb..57cc9dac 100644 --- a/src/pages/model-routes/forms/basic.tsx +++ b/src/pages/model-routes/forms/basic.tsx @@ -1,16 +1,20 @@ +import PluginExtraFields from '@/components/plugin-extra-fields'; import CategorySelect from '@/pages/_components/category-select'; import DocLink from '@/pages/_components/doc-link'; import { categoryOptions } from '@/pages/llmodels/config'; import { CheckboxField, Input as CInput, useAppUtils } from '@gpustack/core-ui'; import { useIntl } from '@umijs/max'; import { Form } from 'antd'; +import { useContext } from 'react'; import { genericReferLink } from '../config'; +import FormContext from '../config/form-context'; import { FormData } from '../config/types'; const Basic = () => { const intl = useIntl(); const form = Form.useFormInstance(); const { getRuleMessage } = useAppUtils(); + const { action } = useContext(FormContext); return ( <> { label={intl.formatMessage({ id: 'common.table.name' })} /> + (value ? [value] : [])} diff --git a/src/pages/users/hooks/use-users-columns.tsx b/src/pages/users/hooks/use-users-columns.tsx index 0c48f57a..6a8855d9 100644 --- a/src/pages/users/hooks/use-users-columns.tsx +++ b/src/pages/users/hooks/use-users-columns.tsx @@ -1,4 +1,5 @@ // columns.ts +import PluginExtraFields from '@/components/plugin-extra-fields'; import { tableSorter } from '@/config/settings'; import { AutoTooltip, @@ -8,7 +9,7 @@ import { } from '@gpustack/core-ui'; import { useIntl, useModel } from '@umijs/max'; import { useMemoizedFn } from 'ahooks'; -import { Tag } from 'antd'; +import { Space, Tag } from 'antd'; import { ColumnsType } from 'antd/lib/table'; import dayjs from 'dayjs'; import { useMemo } from 'react'; @@ -197,10 +198,19 @@ const useUsersColumns = ({ dataIndex: 'operation', span: 3, render: (text, record) => ( - handleSelect(val, record)} - /> + // Plugin slot for per-row user actions. If no plugin is + // registered, `PluginExtraFields` renders nothing and the + // cell looks exactly as before. + + handleSelect(val, record)} + /> + + ) } ]; diff --git a/src/request-config.tsx b/src/request-config.tsx index f85276a0..f880f9c9 100644 --- a/src/request-config.tsx +++ b/src/request-config.tsx @@ -4,6 +4,7 @@ import { history, RequestConfig } from '@umijs/max'; import { message } from 'antd'; import { DEFAULT_ENTER_PAGE } from './config/settings'; import ErrorMessageContent from './pages/_components/error-message-content'; +import { extraRequestInterceptors } from './request.extensions'; // these APIs do not via the GPUSTACK_API_BASE_URL const NoBaseURLAPIs = ['/auth', '/v1', '/version', '/proxy', '/update']; @@ -43,7 +44,10 @@ export const requestConfig: RequestConfig = { return { url, options }; } return { url, options }; - } + }, + // Build-time tooling can plug additional interceptors via + // `request.extensions.ts`. Default is an empty list. + ...extraRequestInterceptors ], responseInterceptors: [ (response) => { diff --git a/src/request.extensions.ts b/src/request.extensions.ts new file mode 100644 index 00000000..9ca2bbb6 --- /dev/null +++ b/src/request.extensions.ts @@ -0,0 +1,15 @@ +// Identity hook for build-time request-interceptor extensions. Tooling +// may overwrite this file to inject extra interceptors (e.g. context +// headers); the original is restored on cleanup. Mirrors +// `src/access.extensions.ts` and `config/routes.extensions.ts`. +// +// Each interceptor follows the umi/max signature: it receives +// `(url, options)` and returns `{ url, options }` — typed loosely +// here so this file has no dependency on the framework's exact +// option types. +export type RequestInterceptor = ( + url: string, + options: Record +) => { url: string; options: Record }; + +export const extraRequestInterceptors: RequestInterceptor[] = [];