diff --git a/src/pages/api-keys/hooks/use-keys-columns.tsx b/src/pages/api-keys/hooks/use-keys-columns.tsx index eb7890fd..266ee88e 100644 --- a/src/pages/api-keys/hooks/use-keys-columns.tsx +++ b/src/pages/api-keys/hooks/use-keys-columns.tsx @@ -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 & { 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 => { const intl = useIntl(); const actionList = useMemo(() => { - 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: , - 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 [ diff --git a/src/pages/api-keys/index.tsx b/src/pages/api-keys/index.tsx index d738b77f..8a52f821 100644 --- a/src/pages/api-keys/index.tsx +++ b/src/pages/api-keys/index.tsx @@ -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>( + {} + ); + 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} > - {APIKeyIPConfigForm && ( - - )} + {/* 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) => ( + + ))} ); }; diff --git a/src/pages/api-keys/plugin.tsx b/src/pages/api-keys/plugin.tsx new file mode 100644 index 00000000..06e7a2f5 --- /dev/null +++ b/src/pages/api-keys/plugin.tsx @@ -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 & { + 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; + 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 ( +
+ ); +}; diff --git a/src/pages/model-routes/hooks/use-routes-columns.tsx b/src/pages/model-routes/hooks/use-routes-columns.tsx index f89c8001..583ff1bc 100644 --- a/src/pages/model-routes/hooks/use-routes-columns.tsx +++ b/src/pages/model-routes/hooks/use-routes-columns.tsx @@ -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(() => { + 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) => ( {record.ready_targets} / {value} ) }, + ...beforeTime, { title: intl.formatMessage({ id: 'common.table.createTime' }), dataIndex: 'created_at', sorter: tableSorter(6), - span: 5, + span: createTimeSpan, render: (value: string) => ( {dayjs(value).format('YYYY-MM-DD HH:mm:ss')} ) }, + ...beforeOperation, { title: intl.formatMessage({ id: 'common.table.operation' }), dataIndex: 'operations', span: 4, render: (value: string, record: RouteItem) => ( handleSelect(val, record)} + items={filterActions(record) as MenuProps['items']} + onSelect={(val, item) => + onSelectAction(val, record, item as RankedAction) + } > ) } ]; - }, [handleSelect, onCellClick]); + }, [intl, pluginColumns, filterActions, onSelectAction]); }; export default useAccessColumns; diff --git a/src/pages/model-routes/index.tsx b/src/pages/model-routes/index.tsx index 0e198632..f7cdad10 100644 --- a/src/pages/model-routes/index.tsx +++ b/src/pages/model-routes/index.tsx @@ -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({ 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 + >({}); + 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} > + {/* 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) => ( + + ))} + {/* 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. */} + ); }; diff --git a/src/pages/model-routes/plugin.tsx b/src/pages/model-routes/plugin.tsx new file mode 100644 index 00000000..05495298 --- /dev/null +++ b/src/pages/model-routes/plugin.tsx @@ -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 +// ``. +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; + 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 ( + + ); +};