Files
gpustack-ui/src/pages/model-routes/hooks/use-routes-columns.tsx
T
jialinandjialin 67e2d914f4 refactor(tables): share parent column grid in expandable child rows
- llmodels/model-routes/cluster child rows use ExpandedRowGrid + Cell
  (auto-flow spans, no parent-key/grid-line math)
- define --seal-table-row-min-height token in global.less
- routes created_at column: span -> fixed width 180
2026-07-16 13:09:18 +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),
width: 180,
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;