feat: plugin extension slots for access, request interceptors, Users actions, and form fields
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.<name>` 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.
This commit is contained in:
@@ -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<{
|
||||
></CInput.Input>
|
||||
</Form.Item>
|
||||
|
||||
<PluginExtraFields name="CreateOrgScopeField" context={{ action }} />
|
||||
|
||||
<Form.Item<FormData>
|
||||
name="expires_in"
|
||||
rules={[
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import PluginExtraFields from '@/components/plugin-extra-fields';
|
||||
import {
|
||||
AutoTooltip,
|
||||
DropdownActions,
|
||||
@@ -248,30 +249,39 @@ const BackendCard: React.FC<BackendCardProps> = ({
|
||||
const source = data.is_built_in
|
||||
? BackendSourceLabelMap[BackendSourceValueMap.BUILTIN] || ''
|
||||
: BackendSourceLabelMap[data.backend_source] || '';
|
||||
const ownerTag = (
|
||||
<PluginExtraFields
|
||||
name="BackendOwnerTag"
|
||||
context={{ organizationId: data.organization_id }}
|
||||
/>
|
||||
);
|
||||
if (!source) {
|
||||
return null;
|
||||
return ownerTag;
|
||||
}
|
||||
return (
|
||||
<Tag
|
||||
color={
|
||||
TagColorMap[
|
||||
data.is_built_in
|
||||
? BackendSourceValueMap.BUILTIN
|
||||
: data.backend_source
|
||||
]
|
||||
}
|
||||
className="font-400"
|
||||
variant="filled"
|
||||
style={{
|
||||
borderRadius: 'var(--ant-border-radius)',
|
||||
margin: 0,
|
||||
width: 'max-content'
|
||||
}}
|
||||
>
|
||||
{intl.formatMessage({
|
||||
id: source
|
||||
})}
|
||||
</Tag>
|
||||
<div style={{ display: 'flex', gap: 6, alignItems: 'center' }}>
|
||||
<Tag
|
||||
color={
|
||||
TagColorMap[
|
||||
data.is_built_in
|
||||
? BackendSourceValueMap.BUILTIN
|
||||
: data.backend_source
|
||||
]
|
||||
}
|
||||
className="font-400"
|
||||
variant="filled"
|
||||
style={{
|
||||
borderRadius: 'var(--ant-border-radius)',
|
||||
margin: 0,
|
||||
width: 'max-content'
|
||||
}}
|
||||
>
|
||||
{intl.formatMessage({
|
||||
id: source
|
||||
})}
|
||||
</Tag>
|
||||
{ownerTag}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
];
|
||||
|
||||
|
||||
@@ -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
|
||||
></CInput.Input>
|
||||
</Form.Item>
|
||||
<PluginExtraFields
|
||||
name="CreateOrgScopeField"
|
||||
context={{ action, allowGlobal: true }}
|
||||
/>
|
||||
<Form.Item<FormData> hidden name="backend_source">
|
||||
<CInput.Input></CInput.Input>
|
||||
</Form.Item>
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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<AddModalProps> = ({
|
||||
required
|
||||
></CInput.Input>
|
||||
</Form.Item>
|
||||
<PluginExtraFields name="CreateOrgScopeField" context={{ action }} />
|
||||
{provider === ProviderValueMap.DigitalOcean && (
|
||||
<>
|
||||
<Form.Item<FormData>
|
||||
|
||||
@@ -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<AddModalProps> = forwardRef(
|
||||
trim={false}
|
||||
></CInput.Input>
|
||||
</Form.Item>
|
||||
<PluginExtraFields name="CreateOrgScopeField" context={{ action }} />
|
||||
{provider === ProviderValueMap.DigitalOcean && (
|
||||
<CloudProvider
|
||||
provider={provider}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import PluginExtraFields from '@/components/plugin-extra-fields';
|
||||
import { PageAction } from '@/config';
|
||||
import {
|
||||
Input as CInput,
|
||||
@@ -57,6 +58,7 @@ const Basic: React.FC<{
|
||||
})}
|
||||
/>
|
||||
</Form.Item>
|
||||
<PluginExtraFields name="CreateOrgScopeField" context={{ action }} />
|
||||
<Form.Item<FormData>
|
||||
name={['config', 'type']}
|
||||
rules={[
|
||||
|
||||
@@ -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<FormData>();
|
||||
const { getRuleMessage } = useAppUtils();
|
||||
const { action } = useContext(FormContext);
|
||||
return (
|
||||
<>
|
||||
<Form.Item
|
||||
@@ -28,6 +32,7 @@ const Basic = () => {
|
||||
label={intl.formatMessage({ id: 'common.table.name' })}
|
||||
/>
|
||||
</Form.Item>
|
||||
<PluginExtraFields name="CreateOrgScopeField" context={{ action }} />
|
||||
<Form.Item
|
||||
name="categories"
|
||||
normalize={(value) => (value ? [value] : [])}
|
||||
|
||||
@@ -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) => (
|
||||
<DropdownButtons
|
||||
items={setActions(record)}
|
||||
onSelect={(val) => 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.
|
||||
<Space size={4}>
|
||||
<DropdownButtons
|
||||
items={setActions(record)}
|
||||
onSelect={(val) => handleSelect(val, record)}
|
||||
/>
|
||||
<PluginExtraFields
|
||||
name="UserRowActions"
|
||||
context={{ user: record }}
|
||||
/>
|
||||
</Space>
|
||||
)
|
||||
}
|
||||
];
|
||||
|
||||
Reference in New Issue
Block a user