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:
@@ -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 [
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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 10–9000 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}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -1,32 +1,185 @@
|
||||
// columns.ts
|
||||
import { tableSorter } from '@/config/settings';
|
||||
import ModelTag from '@/pages/_components/model-tag';
|
||||
import { getGPUStackPlugin } from '@/plugins';
|
||||
import {
|
||||
AutoTooltip,
|
||||
DropdownButtons,
|
||||
icons,
|
||||
type TableColumnProps
|
||||
} from '@gpustack/core-ui';
|
||||
import { useIntl } from '@umijs/max';
|
||||
import { MenuProps } from 'antd';
|
||||
import dayjs from 'dayjs';
|
||||
import { useMemo } from 'react';
|
||||
import { rowActionList } from '../config';
|
||||
import { useCallback, useMemo } from 'react';
|
||||
import { RouteItem } from '../config/types';
|
||||
const useAccessColumns = (
|
||||
handleSelect: (val: string, record: RouteItem) => void,
|
||||
onCellClick?: (record: RouteItem, dataIndex: string) => void
|
||||
): TableColumnProps[] => {
|
||||
const intl = useIntl();
|
||||
import type { ModelRouteConfigAction } from '../plugin';
|
||||
|
||||
const filterActions = (record: RouteItem) => {
|
||||
return rowActionList.filter((action) => {
|
||||
if (action.key === 'chat' || action.key === 'api') {
|
||||
return record.ready_targets > 0;
|
||||
// Plugin slot: enterprise plugins can contribute extra columns to the
|
||||
// route-list table via `modelRoutes.extraColumns`. `placement` decides
|
||||
// where in the existing column order they land; `span` is the column
|
||||
// width in the SealTable grid. The host adjusts the built-in spans to
|
||||
// make room when plugin columns are present.
|
||||
type PluginColumn = {
|
||||
key: string;
|
||||
titleId: string;
|
||||
span?: number;
|
||||
placement?: 'before-time' | 'before-operation';
|
||||
render: (record: RouteItem) => React.ReactNode;
|
||||
};
|
||||
|
||||
// Local ranked-action shape used for the unified priority sort. Mirrors
|
||||
// the api-keys page approach: every entry — built-in or plugin —
|
||||
// participates in the same numeric ordering.
|
||||
type RankedAction = {
|
||||
label: string;
|
||||
key: string;
|
||||
icon?: React.ReactNode;
|
||||
props?: { danger?: boolean };
|
||||
priority: number;
|
||||
show?: (route: RouteItem) => boolean;
|
||||
onClick?: (record: RouteItem) => void;
|
||||
};
|
||||
|
||||
interface ColumnsHookProps {
|
||||
handleSelect: (val: string, record: RouteItem) => void;
|
||||
configActions?: ModelRouteConfigAction[];
|
||||
// Dispatches a click for a plugin-contributed dropdown entry to the
|
||||
// controller `useCreate()` returned for that entry.
|
||||
onConfigAction?: (actionKey: string, record: RouteItem) => void;
|
||||
}
|
||||
|
||||
// SealTable's grid is 24 units. With the built-in `targets` column
|
||||
// contributing slack (preferred 10, min 4), there's room for plugin
|
||||
// columns up to a combined span of 6 before we have to take from
|
||||
// other built-ins. Layout above this threshold is preserved by also
|
||||
// shrinking `createTime` (min 3); `name` and `operation` stay fixed
|
||||
// so the action dropdown alignment isn't disturbed. Plugins whose
|
||||
// combined span would exceed what these reductions can absorb fall
|
||||
// back to their declared widths and may visibly wrap — that's a
|
||||
// plugin-author concern, not a host one.
|
||||
const TARGETS_PREFERRED_SPAN = 10;
|
||||
const TARGETS_MIN_SPAN = 4;
|
||||
const CREATE_TIME_PREFERRED_SPAN = 5;
|
||||
const CREATE_TIME_MIN_SPAN = 3;
|
||||
|
||||
const useAccessColumns = ({
|
||||
handleSelect,
|
||||
configActions = [],
|
||||
onConfigAction
|
||||
}: ColumnsHookProps): TableColumnProps[] => {
|
||||
const intl = useIntl();
|
||||
const pluginColumns: PluginColumn[] =
|
||||
getGPUStackPlugin()?.modelRoutes?.extraColumns ?? [];
|
||||
|
||||
// Sort-order is row-independent: priority and danger are fixed at
|
||||
// registration time. Compute the sorted list once and only do the
|
||||
// `show` filter per row.
|
||||
const sortedActions = useMemo<RankedAction[]>(() => {
|
||||
const builtIns: RankedAction[] = [
|
||||
{
|
||||
label: 'common.button.edit',
|
||||
key: 'edit',
|
||||
icon: icons.EditOutlined,
|
||||
priority: 0
|
||||
},
|
||||
{
|
||||
label: 'models.openinplayground',
|
||||
key: 'chat',
|
||||
icon: icons.ExperimentOutlined,
|
||||
priority: 10,
|
||||
show: (r) => r.ready_targets > 0
|
||||
},
|
||||
{
|
||||
label: 'models.table.button.apiAccessInfo',
|
||||
key: 'api',
|
||||
icon: icons.ApiOutlined,
|
||||
priority: 20,
|
||||
show: (r) => r.ready_targets > 0
|
||||
},
|
||||
{
|
||||
label: 'models.button.accessSettings',
|
||||
key: 'accessControl',
|
||||
icon: icons.Permission,
|
||||
priority: 30
|
||||
},
|
||||
{
|
||||
label: 'common.button.delete',
|
||||
key: 'delete',
|
||||
icon: icons.DeleteOutlined,
|
||||
props: { danger: true },
|
||||
priority: 9999
|
||||
}
|
||||
return true;
|
||||
];
|
||||
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,
|
||||
show: a.show,
|
||||
onClick: (record: RouteItem) => onConfigAction?.(a.key, record)
|
||||
}));
|
||||
return [...builtIns, ...fromPlugins].sort((a, b) => {
|
||||
const aDanger = a.props?.danger ? 1 : 0;
|
||||
const bDanger = b.props?.danger ? 1 : 0;
|
||||
if (aDanger !== bDanger) return aDanger - bDanger;
|
||||
return a.priority - b.priority;
|
||||
});
|
||||
};
|
||||
}, [configActions, onConfigAction]);
|
||||
|
||||
const filterActions = useCallback(
|
||||
(record: RouteItem) =>
|
||||
sortedActions.filter((a) => (a.show ? a.show(record) : true)),
|
||||
[sortedActions]
|
||||
);
|
||||
|
||||
// Plugin entries carry their own `onClick` (wired to `onConfigAction`
|
||||
// during action construction above), so the dispatcher just runs it
|
||||
// when present. Built-ins fall through to the page's `handleSelect`
|
||||
// dispatcher keyed by `val`. Mirrors the api-keys page's onSelect
|
||||
// path — no key lookup needed.
|
||||
const onSelectAction = useCallback(
|
||||
(val: string, record: RouteItem, item?: RankedAction) => {
|
||||
if (item?.onClick) {
|
||||
item.onClick(record);
|
||||
return;
|
||||
}
|
||||
handleSelect(val, record);
|
||||
},
|
||||
[handleSelect]
|
||||
);
|
||||
|
||||
return useMemo(() => {
|
||||
// When plugin columns are present we first steal width from
|
||||
// `targets` (down to its 4-unit min), then from `createTime`
|
||||
// (down to 3). Stays within the 24-unit grid as long as the
|
||||
// combined plugin span is ≤ 8.
|
||||
const pluginSpan = pluginColumns.reduce((sum, c) => sum + (c.span ?? 4), 0);
|
||||
const targetsSpan = Math.max(
|
||||
TARGETS_MIN_SPAN,
|
||||
TARGETS_PREFERRED_SPAN - pluginSpan
|
||||
);
|
||||
const overflow = Math.max(
|
||||
0,
|
||||
pluginSpan - (TARGETS_PREFERRED_SPAN - TARGETS_MIN_SPAN)
|
||||
);
|
||||
const createTimeSpan = Math.max(
|
||||
CREATE_TIME_MIN_SPAN,
|
||||
CREATE_TIME_PREFERRED_SPAN - overflow
|
||||
);
|
||||
const pluginColsRendered: TableColumnProps[] = pluginColumns.map((c) => ({
|
||||
title: intl.formatMessage({ id: c.titleId }),
|
||||
dataIndex: c.key,
|
||||
span: c.span ?? 4,
|
||||
render: (_value: any, record: RouteItem) => c.render(record)
|
||||
}));
|
||||
const beforeTime = pluginColsRendered.filter(
|
||||
(_c, i) => (pluginColumns[i].placement ?? 'before-time') === 'before-time'
|
||||
);
|
||||
const beforeOperation = pluginColsRendered.filter(
|
||||
(_c, i) => pluginColumns[i].placement === 'before-operation'
|
||||
);
|
||||
return [
|
||||
{
|
||||
title: intl.formatMessage({ id: 'common.table.name' }),
|
||||
@@ -45,37 +198,41 @@ const useAccessColumns = (
|
||||
{
|
||||
title: intl.formatMessage({ id: 'routes.table.routeTargets' }),
|
||||
dataIndex: 'targets',
|
||||
span: 10,
|
||||
span: targetsSpan,
|
||||
render: (value: number, record: RouteItem) => (
|
||||
<span>
|
||||
{record.ready_targets} / {value}
|
||||
</span>
|
||||
)
|
||||
},
|
||||
...beforeTime,
|
||||
{
|
||||
title: intl.formatMessage({ id: 'common.table.createTime' }),
|
||||
dataIndex: 'created_at',
|
||||
sorter: tableSorter(6),
|
||||
span: 5,
|
||||
span: createTimeSpan,
|
||||
render: (value: string) => (
|
||||
<AutoTooltip ghost minWidth={20}>
|
||||
{dayjs(value).format('YYYY-MM-DD HH:mm:ss')}
|
||||
</AutoTooltip>
|
||||
)
|
||||
},
|
||||
...beforeOperation,
|
||||
{
|
||||
title: intl.formatMessage({ id: 'common.table.operation' }),
|
||||
dataIndex: 'operations',
|
||||
span: 4,
|
||||
render: (value: string, record: RouteItem) => (
|
||||
<DropdownButtons
|
||||
items={filterActions(record)}
|
||||
onSelect={(val) => handleSelect(val, record)}
|
||||
items={filterActions(record) as MenuProps['items']}
|
||||
onSelect={(val, item) =>
|
||||
onSelectAction(val, record, item as RankedAction)
|
||||
}
|
||||
></DropdownButtons>
|
||||
)
|
||||
}
|
||||
];
|
||||
}, [handleSelect, onCellClick]);
|
||||
}, [intl, pluginColumns, filterActions, onSelectAction]);
|
||||
};
|
||||
|
||||
export default useAccessColumns;
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { expandKeysAtom } from '@/atoms/clusters';
|
||||
import { registerRouteConfigAtom } from '@/atoms/routes';
|
||||
import PluginExtraFields from '@/components/plugin-extra-fields';
|
||||
import { PageAction } from '@/config';
|
||||
import { PaginationKey, TABLE_SORT_DIRECTIONS } from '@/config/settings';
|
||||
import useExpandedRowKeys from '@/hooks/use-expanded-row-keys';
|
||||
@@ -20,7 +21,7 @@ import { useMemoizedFn } from 'ahooks';
|
||||
import { message } from 'antd';
|
||||
import { useAtom } from 'jotai';
|
||||
import _ from 'lodash';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import PageBox from '../_components/page-box';
|
||||
import { queryModelsList } from '../llmodels/apis';
|
||||
import AccessControlModal from '../llmodels/components/access-control-modal';
|
||||
@@ -44,8 +45,39 @@ import useOpenPlayground from './hooks/use-open-playground';
|
||||
import useRoutesColumns from './hooks/use-routes-columns';
|
||||
import useTargetSourceModels from './hooks/use-target-source-models';
|
||||
import useViewApIInfo from './hooks/use-view-api-info';
|
||||
import {
|
||||
ModelRouteConfigActionMount,
|
||||
getModelRouteConfigActions,
|
||||
type ModelRouteConfigActionController
|
||||
} from './plugin';
|
||||
|
||||
const ModelRoutes: React.FC = () => {
|
||||
// Single source of truth for plugin data lifecycle. Every successful
|
||||
// table fetch updates this atomically: `routeIds` mirrors the rows
|
||||
// currently visible (so plugins can bulk-fetch per-row data without
|
||||
// N round-trips), and `refreshToken` bumps so plugins refetch even
|
||||
// when the id set is unchanged — e.g. an in-place save from the
|
||||
// quota-limit drawer leaves the row set intact but invalidates the
|
||||
// derived defaults map.
|
||||
const [pluginContext, setPluginContext] = useState<{
|
||||
routeIds: number[];
|
||||
refreshToken: number;
|
||||
}>({
|
||||
routeIds: [],
|
||||
refreshToken: 0
|
||||
});
|
||||
|
||||
// Wraps `queryModelRoutes` so the plugin context updates in lockstep
|
||||
// with the table data — no separate "bump after save" signal needed.
|
||||
const fetchAPI = useMemoizedFn(async (params: any, options?: any) => {
|
||||
const res = await queryModelRoutes(params, options);
|
||||
setPluginContext((prev) => ({
|
||||
routeIds: (res.items ?? []).map((r: ListItem) => r.id),
|
||||
refreshToken: prev.refreshToken + 1
|
||||
}));
|
||||
return res;
|
||||
});
|
||||
|
||||
const {
|
||||
dataSource,
|
||||
rowSelection,
|
||||
@@ -60,7 +92,7 @@ const ModelRoutes: React.FC = () => {
|
||||
handleNameChange
|
||||
} = useTableFetch<ListItem>({
|
||||
key: PaginationKey.Routes,
|
||||
fetchAPI: queryModelRoutes,
|
||||
fetchAPI,
|
||||
deleteAPI: deleteModelRoute,
|
||||
watch: true,
|
||||
API: MODEL_ROUTES,
|
||||
@@ -276,7 +308,46 @@ const ModelRoutes: React.FC = () => {
|
||||
}
|
||||
}, [registerRouteConfig, dataSource.loadend]);
|
||||
|
||||
const columns = useRoutesColumns(handleSelect);
|
||||
// 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
|
||||
// `ModelRouteConfigActionMount` 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(() => getModelRouteConfigActions(), []);
|
||||
const controllersRef = useRef<
|
||||
Record<string, ModelRouteConfigActionController>
|
||||
>({});
|
||||
const registerController = useCallback(
|
||||
(key: string, controller: ModelRouteConfigActionController) => {
|
||||
controllersRef.current[key] = controller;
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
const handleConfigAction = useMemoizedFn(
|
||||
(actionKey: string, record: ListItem) => {
|
||||
controllersRef.current[actionKey]?.openModal(record);
|
||||
}
|
||||
);
|
||||
|
||||
// Per-row save closes the drawer and refetches the table. The wrapped
|
||||
// `fetchAPI` above takes care of bumping `pluginContext.refreshToken`
|
||||
// for derived plugin data.
|
||||
const handleConfigActionOk = useMemoizedFn(() => {
|
||||
fetchData();
|
||||
});
|
||||
|
||||
const columns = useRoutesColumns({
|
||||
handleSelect,
|
||||
configActions,
|
||||
onConfigAction: handleConfigAction
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -368,6 +439,27 @@ const ModelRoutes: React.FC = () => {
|
||||
onClose={closeViewAPIInfo}
|
||||
></APIAccessInfoModal>
|
||||
<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) => (
|
||||
<ModelRouteConfigActionMount
|
||||
key={action.key}
|
||||
action={action}
|
||||
registerController={registerController}
|
||||
onOk={handleConfigActionOk}
|
||||
/>
|
||||
))}
|
||||
{/* Page-level data lifecycle for plugin-contributed extra
|
||||
columns. Receives the current list of visible route ids so
|
||||
the plugin can bulk-fetch their per-user defaults in one
|
||||
call (used by the quota-default column cells); `refreshToken`
|
||||
bumps after a per-row save so derived page data refetches
|
||||
even when the row set is unchanged. Renders nothing when no
|
||||
plugin is registered. */}
|
||||
<PluginExtraFields name="ModelRoutesPageGlobal" context={pluginContext} />
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
import { getGPUStackPlugin } from '@/plugins';
|
||||
import type { ComponentType, ReactNode } from 'react';
|
||||
import { useEffect } from 'react';
|
||||
import type { RouteItem } from './config/types';
|
||||
|
||||
// Generic per-row "configure this route" plugin slot. Same shape as
|
||||
// the api-keys page's `apiKeys.configActions`. Each enterprise plugin
|
||||
// contributes a `{ key, labelId, icon, priority, form, useCreate }`
|
||||
// entry — the host renders one dropdown button per entry and one
|
||||
// `ModelRouteConfigActionMount` per entry. That mount is the only
|
||||
// place `useCreate` is called (one hook per component, so iterating
|
||||
// the plugin list doesn't violate the Rules of Hooks). Replaces the
|
||||
// older `modelRoutes.rowActions` plus the drawer half of
|
||||
// `<PluginExtraFields name="ModelRoutesPageGlobal" />`.
|
||||
export type ModelRouteConfigActionRecord = RouteItem;
|
||||
|
||||
export type ModelRouteConfigActionFormProps = {
|
||||
open: boolean;
|
||||
route?: ModelRouteConfigActionRecord | null;
|
||||
onClose: () => void;
|
||||
onOk?: () => void;
|
||||
};
|
||||
|
||||
export type ModelRouteConfigActionState = {
|
||||
open: boolean;
|
||||
currentData?: ModelRouteConfigActionRecord | null;
|
||||
};
|
||||
|
||||
export type ModelRouteConfigActionController = {
|
||||
openModalStatus: ModelRouteConfigActionState;
|
||||
openModal: (row: ModelRouteConfigActionRecord) => void;
|
||||
closeModal: () => void;
|
||||
};
|
||||
|
||||
export type ModelRouteConfigAction = {
|
||||
key: string;
|
||||
labelId: string;
|
||||
icon?: ReactNode;
|
||||
// Lower comes first; default 100. Built-ins use edit=0, chat=10,
|
||||
// api=20, accessControl=30, delete=9999. A plugin priority of
|
||||
// 40–9000 lands after accessControl and before delete.
|
||||
priority?: number;
|
||||
// Sinks the entry to the bottom of the dropdown regardless of
|
||||
// priority — keeps destructive actions grouped with Delete.
|
||||
danger?: boolean;
|
||||
// Per-row visibility predicate. Returning false hides the entry for
|
||||
// that route (e.g. for routes without ready targets).
|
||||
show?: (route: ModelRouteConfigActionRecord) => boolean;
|
||||
form: ComponentType<ModelRouteConfigActionFormProps>;
|
||||
useCreate: () => ModelRouteConfigActionController;
|
||||
};
|
||||
|
||||
// Static registration read — plugins are wired once at boot, so the
|
||||
// list reference is stable across renders.
|
||||
export const getModelRouteConfigActions = (): ModelRouteConfigAction[] =>
|
||||
getGPUStackPlugin()?.modelRoutes?.configActions ?? [];
|
||||
|
||||
type ModelRouteConfigActionMountProps = {
|
||||
action: ModelRouteConfigAction;
|
||||
// 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: ModelRouteConfigActionController
|
||||
) => void;
|
||||
onOk?: () => void;
|
||||
};
|
||||
|
||||
// One component instance per registered action. Single `useCreate`
|
||||
// call at the top level keeps the hook contract clean even as the
|
||||
// plugin list grows.
|
||||
export const ModelRouteConfigActionMount: React.FC<
|
||||
ModelRouteConfigActionMountProps
|
||||
> = ({ 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}
|
||||
route={controller.openModalStatus.currentData}
|
||||
onClose={controller.closeModal}
|
||||
onOk={onOk}
|
||||
/>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user