feat: generic configActions plugin slot for api-keys & model-routes

Introduce `pages/api-keys/plugin.tsx` and `pages/model-routes/plugin.tsx`
defining a unified `{ key, labelId, icon, priority, danger, form,
useCreate }` contract that plugins use to contribute per-row
"configure this record" actions. The host renders one dropdown entry
per registered action and mounts each entry's form, ordered by a
single numeric priority (built-ins are ranked on the same scale; rows
flagged `danger` sink to the bottom).

Drops the older `apiKeys.rowActions` / `modelRoutes.rowActions` slots
and the implicit drawer half of
`<PluginExtraFields name="APIKeysPageGlobal" />`. The model-routes
mount point stays for the page-level quota-defaults bulk-fetch and now
also accepts a `refreshToken` so per-row saves can invalidate derived
page data without changing the row set.
This commit is contained in:
Yuxing Deng
2026-05-18 12:14:19 +08:00
committed by jialin
parent 38b91a558b
commit ae8ec8572c
6 changed files with 535 additions and 58 deletions
+32 -23
View File
@@ -1,65 +1,74 @@
// columns.ts
import { tableSorter } from '@/config/settings';
import { getGPUStackPlugin } from '@/plugins';
import {
AutoTooltip,
DropdownButtons,
IconFont,
icons
} from '@gpustack/core-ui';
import { AutoTooltip, DropdownButtons, icons } from '@gpustack/core-ui';
import { useIntl } from '@umijs/max';
import { MenuProps, Tag } from 'antd';
import { ColumnsType } from 'antd/lib/table';
import dayjs from 'dayjs';
import { useMemo } from 'react';
import { ListItem } from '../config/types';
import type { APIKeyConfigAction } from '../plugin';
type APIKeyAction = Global.ActionItem<ListItem> & {
onClick?: (record: ListItem) => void;
};
type RankedAction = APIKeyAction & { priority: number };
interface ColumnsHookProps {
handleSelect: (val: string, record: ListItem, item?: APIKeyAction) => void;
sortOrder: string[];
is_admin?: boolean;
onIPConfig?: (record: ListItem) => void;
configActions?: APIKeyConfigAction[];
// Dispatches the click for a plugin-contributed dropdown entry to the
// controller `useCreate()` returned for that entry.
onConfigAction?: (actionKey: string, record: ListItem) => void;
}
const useModelsColumns = ({
handleSelect,
sortOrder,
is_admin,
onIPConfig
configActions = [],
onConfigAction
}: ColumnsHookProps): ColumnsType<ListItem> => {
const intl = useIntl();
const actionList = useMemo<APIKeyAction[]>(() => {
const list: APIKeyAction[] = [
// Built-ins use a step-of-10 priority scale so plugins have room
// to insert at any position (e.g. 5 before Edit, 15 between Edit
// and Delete, 25 after Delete). The final list is sorted purely
// by priority — Delete sits last by virtue of its higher number,
// not by a special-case for `danger`.
const builtIns: RankedAction[] = [
{
label: 'common.button.edit',
key: 'edit',
icon: icons.EditOutlined
icon: icons.EditOutlined,
priority: 10
},
{
label: 'common.button.delete',
key: 'delete',
icon: icons.DeleteOutlined,
props: { danger: true }
props: { danger: true },
priority: 20
}
];
const ipConfigComponent = getGPUStackPlugin()?.APIKeyIPConfig?.form;
if (ipConfigComponent && onIPConfig) {
list.splice(1, 0, {
label: 'apikeys.button.ipConfig',
key: 'ipConfig',
icon: <IconFont type="icon-safe-ip" />,
onClick: (record: ListItem) => onIPConfig(record)
});
}
const fromPlugins: RankedAction[] = configActions.map((a) => ({
label: a.labelId,
key: a.key,
icon: a.icon,
priority: a.priority ?? 100,
props: a.danger ? { danger: true } : undefined,
onClick: (record: ListItem) => onConfigAction?.(a.key, record)
}));
return list;
}, [onIPConfig]);
return [...builtIns, ...fromPlugins].sort(
(a, b) => a.priority - b.priority
);
}, [configActions, onConfigAction]);
return useMemo(() => {
return [
+50 -14
View File
@@ -3,19 +3,23 @@ import { PaginationKey } from '@/config/settings';
import type { PageActionType } from '@/config/types';
import useTableFetch from '@/hooks/use-table-fetch';
import useQueryUserList from '@/pages/users/services/use-query-user-list';
import { getGPUStackPlugin } from '@/plugins';
import { useModel } from '@@/plugin-model';
import { DeleteModal, FilterBar, IconFont, NoResult } from '@gpustack/core-ui';
import { useIntl } from '@umijs/max';
import useMemoizedFn from 'ahooks/lib/useMemoizedFn';
import { ConfigProvider, Table } from 'antd';
import _ from 'lodash';
import { useEffect, useState } from 'react';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import PageBox from '../_components/page-box';
import { deleteApisKey, queryApisKeysList } from './apis';
import AddAPIKeyModal from './components/add-apikey-modal';
import { ListItem } from './config/types';
import useKeysColumns from './hooks/use-keys-columns';
import {
APIKeyConfigActionMount,
getAPIKeyConfigActions,
type APIKeyConfigActionController
} from './plugin';
const APIKeys: React.FC = () => {
const { initialState } = useModel('@@initialState');
@@ -55,10 +59,27 @@ const APIKeys: React.FC = () => {
const intl = useIntl();
const apiKeyIPConfig = getGPUStackPlugin()?.APIKeyIPConfig;
const APIKeyIPConfigForm = apiKeyIPConfig?.form;
const { openIPConfigModalStatus, openIPConfigModal, closeIPConfigModal } =
apiKeyIPConfig?.useCreateIPConfig?.() || {};
// Generic per-row plugin slot. Each enterprise plugin contributes a
// `{ key, labelId, icon, priority, form, useCreate }` entry; the
// host renders a button per entry in the dropdown and renders one
// `APIKeyConfigActionMount` per entry — those mounts own each
// entry's controller and register it back into `controllersRef` so
// dropdown clicks can route to the correct `openModal`. See
// `./plugin.tsx`.
//
// The action list is read once. Plugins are registered at boot and
// never recompute, so the reference is stable for the lifetime of
// the page and `useMemo([])` is safe.
const configActions = useMemo(() => getAPIKeyConfigActions(), []);
const controllersRef = useRef<Record<string, APIKeyConfigActionController>>(
{}
);
const registerController = useCallback(
(key: string, controller: APIKeyConfigActionController) => {
controllersRef.current[key] = controller;
},
[]
);
const [openAddModal, setOpenAddModal] = useState<{
open: boolean;
@@ -140,6 +161,14 @@ const APIKeys: React.FC = () => {
}
);
// Each plugin entry's button onClick routes here. The controller
// registry is populated by each `APIKeyConfigActionMount` on mount.
const handleConfigAction = useMemoizedFn(
(actionKey: string, record: ListItem) => {
controllersRef.current[actionKey]?.openModal(record);
}
);
const handleUserChange = (val: string) => {
handleQueryChange({
user_id: val || '*'
@@ -170,7 +199,8 @@ const APIKeys: React.FC = () => {
handleSelect: onSelect,
sortOrder,
is_admin: currentUser?.is_admin,
onIPConfig: openIPConfigModal
configActions,
onConfigAction: handleConfigAction
});
return (
@@ -225,14 +255,20 @@ const APIKeys: React.FC = () => {
onCancel={handleModalCancel}
onOk={handleModalOk}
></AddAPIKeyModal>
{APIKeyIPConfigForm && (
<APIKeyIPConfigForm
open={openIPConfigModalStatus.open}
apiKey={openIPConfigModalStatus.currentData}
onClose={closeIPConfigModal}
/>
)}
<DeleteModal ref={modalRef}></DeleteModal>
{/* One mount per registered action. Each mount calls its
entry's `useCreate` (single hook per component, so iterating
the plugin list doesn't violate the Rules of Hooks),
renders the form, and registers its controller so dropdown
clicks can dispatch to it. */}
{configActions.map((action) => (
<APIKeyConfigActionMount
key={action.key}
action={action}
registerController={registerController}
onOk={fetchData}
/>
))}
</>
);
};
+95
View File
@@ -0,0 +1,95 @@
import { getGPUStackPlugin } from '@/plugins';
import type { ComponentType, ReactNode } from 'react';
import { useEffect } from 'react';
import type { ListItem } from './config/types';
// Generic per-row "configure this api-key" plugin slot.
//
// Each enterprise plugin contributes one entry: a dropdown button +
// its own controlled form component + a `useCreate` hook that owns the
// open/close state for that entry's drawer. The host renders all
// entries' buttons in the dropdown (ordered by `priority`) and renders
// one `APIKeyConfigActionMount` per entry — that mount is what calls
// the entry's `useCreate` (a single hook call per component, so the
// Rules-of-Hooks aren't violated by iteration order) and reports its
// controller back to the host via `registerController`. Replaces the
// older `APIKeyIPConfig` slot, the generic `apiKeys.rowActions`, and
// the `PluginExtraFields name="APIKeysPageGlobal"` mount point —
// adding a new per-key configuration feature now only requires
// registering a new entry from the plugin side.
export type APIKeyConfigActionRecord = Partial<ListItem> & {
id: number;
name?: string;
};
export type APIKeyConfigActionFormProps = {
open: boolean;
apiKey?: APIKeyConfigActionRecord | null;
onClose: () => void;
onOk?: () => void;
};
export type APIKeyConfigActionState = {
open: boolean;
currentData?: APIKeyConfigActionRecord | null;
};
export type APIKeyConfigActionController = {
openModalStatus: APIKeyConfigActionState;
openModal: (row: APIKeyConfigActionRecord) => void;
closeModal: () => void;
};
export type APIKeyConfigAction = {
key: string;
labelId: string;
icon?: ReactNode;
// Lower comes first; default 100. Stable sort preserves declaration
// order on ties. Built-ins use `edit=0` and `delete=9999`, so a
// plugin priority of 109000 lands between Edit and Delete.
priority?: number;
// Destructive entries sink to the bottom of the dropdown regardless
// of priority — keeps Delete-style actions visually grouped.
danger?: boolean;
form: ComponentType<APIKeyConfigActionFormProps>;
useCreate: () => APIKeyConfigActionController;
};
// Static registration read — plugins are wired once at boot, so the
// list reference is stable across renders. Exposed as a helper to keep
// the host's import surface tight.
export const getAPIKeyConfigActions = (): APIKeyConfigAction[] =>
getGPUStackPlugin()?.apiKeys?.configActions ?? [];
type APIKeyConfigActionMountProps = {
action: APIKeyConfigAction;
// Called once after mount (and on controller identity change) so the
// host can route a dropdown click to the correct entry's openModal.
registerController: (
key: string,
controller: APIKeyConfigActionController
) => void;
onOk?: () => void;
};
// One component instance per registered action. Calls `useCreate` at
// the top level (single hook call per component) and renders the
// entry's form bound to its own controller. Hosts mount one of these
// per entry in `apiKeys.configActions`.
export const APIKeyConfigActionMount: React.FC<
APIKeyConfigActionMountProps
> = ({ action, registerController, onOk }) => {
const controller = action.useCreate();
useEffect(() => {
registerController(action.key, controller);
}, [action.key, controller, registerController]);
const Form = action.form;
return (
<Form
open={controller.openModalStatus.open}
apiKey={controller.openModalStatus.currentData}
onClose={controller.closeModal}
onOk={onOk}
/>
);
};