- 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
261 lines
9.1 KiB
TypeScript
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;
|