Files
gpustack-ui/src/pages/model-routes/hooks/use-routes-columns.tsx
T
gitlawrandjialin 26ce456bde feat: per-page extra-columns slot for list tables
Add `usePluginListColumns(pageKey)` so a registered plugin can splice
extra columns into a list page's columns hook keyed by page id. The
slot is wired into 11 list pages (Models, Model Routes, Clusters,
Cloud Credentials, MaaS Providers, Model Files, Model Instances,
GPU Service Instances / Public Keys / Storage / Storage Types) so any
consumer of the seam reaches every org-scoped list without per-page
plumbing.

The slot accepts either a static array or a hook function — the hook
form lets the registrant use React hooks to decide visibility without
the host having to evaluate it. Columns carry a `placement`:
`after-name` is the default (sits next to the row's identifying
column); `before-time` / `before-operation` are kept for back-compat
with the existing per-page `modelRoutes.extraColumns` slot.

SealTable-grid pages (Models / Model Routes / Clusters) absorb the
plugin column's span by shrinking the widest right-side columns so
the 24-unit grid stays balanced. Model Routes' `CREATE_TIME_MIN_SPAN`
drops from 3 to 2 because with two plugin columns active the grid
was 1 unit over and the action dropdown wrapped onto a new row; the
date string ellipsizes cleanly at the lower min.

ListItem types for Model Instances, MaaS Providers, Cloud Credentials,
and Model Files gain an optional `owner_principal_id` field — the
backend already emits it (denormalized from the parent resource), the
TS types just hadn't declared it.

The GPU Instances page splices the plugin column right before the
Cluster column (rather than after Name); the two read together since
Cluster narrows down to one Org.
2026-06-09 16:28:59 +08:00

261 lines
9.1 KiB
TypeScript

// columns.ts
import { tableSorter } from '@/config/settings';
import ModelTag from '@/pages/_components/model-tag';
import { getGPUStackPlugin } from '@/plugins';
import { usePluginListColumns } from '@/plugins/list-extra-columns';
import {
AutoTooltip,
DropdownButtons,
icons,
type TableColumnProps
} from '@gpustack/core-ui';
import { useIntl } from '@umijs/max';
import { useMemoizedFn } from 'ahooks';
import { MenuProps } from 'antd';
import dayjs from 'dayjs';
import { useMemo } from 'react';
import { RouteItem } from '../config/types';
import type { ModelRouteConfigAction } from '../plugin';
// 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?: 'after-name' | '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 2 — the date string ellipsizes
// gracefully); `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 = 2;
const useAccessColumns = ({
handleSelect,
configActions = [],
onConfigAction
}: ColumnsHookProps): TableColumnProps[] => {
const intl = useIntl();
// Merge the route-specific `modelRoutes.extraColumns` slot with the
// generic per-page `listExtraColumns.modelRoutes` slot — both feed
// the same splice point, so plugins can pick whichever slot fits
// (page-specific quota cell vs. cross-page Organization-style col).
// Memoized on `genericCols` because the route-specific slot is wired
// once at boot (stable identity) so only `genericCols` can change.
const genericCols = usePluginListColumns('modelRoutes');
const pluginColumns = useMemo<PluginColumn[]>(
() => [
...(getGPUStackPlugin()?.modelRoutes?.extraColumns ?? []),
...(genericCols as unknown as PluginColumn[])
],
[genericCols]
);
// 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
}
];
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 = useMemoizedFn((record: RouteItem) =>
sortedActions.filter((a) => (a.show ? a.show(record) : true))
);
// 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 = useMemoizedFn(
(val: string, record: RouteItem, item?: RankedAction) => {
if (item?.onClick) {
item.onClick(record);
return;
}
handleSelect(val, record);
}
);
return useMemo(() => {
// When plugin columns are present we first steal width from
// `targets` (down to its 4-unit min), then from `createTime`
// (down to 2). Stays within the 24-unit grid as long as the
// combined plugin span is ≤ 9.
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)
}));
// Default placement for plugin cols on this page predates the
// generic seam and stays `before-time` (the existing per-page
// `modelRoutes.extraColumns` slot relies on it for the quota
// cell). Entries from the generic `listExtraColumns.modelRoutes`
// slot pick `after-name` explicitly when they want to read
// alongside the row's name.
const afterName = pluginColsRendered.filter(
(_c, i) => pluginColumns[i].placement === 'after-name'
);
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' }),
dataIndex: 'name',
sorter: tableSorter(1),
span: 5,
render: (text: string, record: RouteItem) => (
<span className="flex-center" style={{ maxWidth: '100%' }}>
<AutoTooltip ghost title={text}>
<span className="m-r-5 text-primary">{text}</span>
</AutoTooltip>
<ModelTag categoryKey={record.categories?.[0]}></ModelTag>
</span>
)
},
...afterName,
{
title: intl.formatMessage({ id: 'routes.table.routeTargets' }),
dataIndex: 'targets',
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: 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) as MenuProps['items']}
onSelect={(val, item) =>
onSelectAction(val, record, item as RankedAction)
}
></DropdownButtons>
)
}
];
}, [intl, pluginColumns, filterActions, onSelectAction]);
};
export default useAccessColumns;