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.
This commit is contained in:
gitlawr
2026-06-09 16:28:59 +08:00
committed by jialin
parent 7ce1faaf6c
commit 26ce456bde
20 changed files with 349 additions and 32 deletions
+3
View File
@@ -87,6 +87,9 @@ export interface FormData {
export interface BenchmarkListItem extends FormData {
id: number;
// Inherited from the parent cluster's owner_principal_id on the
// wire so per-row tenant filtering works without joining.
owner_principal_id?: number | null;
created_at: string;
updated_at: string;
state: string;
@@ -1,5 +1,6 @@
// columns.ts
import { tableSorter } from '@/config/settings';
import { usePluginListColumns } from '@/plugins/list-extra-columns';
import { AutoTooltip } from '@gpustack/core-ui';
import { useIntl } from '@umijs/max';
import { Typography } from 'antd';
@@ -15,8 +16,15 @@ const useBenchmarkColumns = (params: {
}): ColumnsType<ListItem> => {
const intl = useIntl();
const { onCellClick, handleSelect, columns } = params;
const pluginCols = usePluginListColumns('benchmarks');
return useMemo(() => {
const pluginRendered = pluginCols.map((c) => ({
title: intl.formatMessage({ id: c.titleId }),
key: c.key,
ellipsis: { showTitle: false },
render: (_text: any, record: ListItem) => c.render(record)
}));
return [
{
title: (
@@ -34,6 +42,7 @@ const useBenchmarkColumns = (params: {
</AutoTooltip>
)
},
...pluginRendered,
...columns,
{
title: (
@@ -51,7 +60,7 @@ const useBenchmarkColumns = (params: {
)
}
];
}, [intl, onCellClick, handleSelect, columns]);
}, [intl, onCellClick, handleSelect, columns, pluginCols]);
};
export default useBenchmarkColumns;
@@ -18,6 +18,7 @@ export interface CredentialListItem {
access_key: string;
secret_key: string;
description?: string;
owner_principal_id?: number | null;
created_at: string;
updated_at: string;
}
@@ -2,6 +2,7 @@
import { systemConfigAtom } from '@/atoms/system';
import { tableSorter } from '@/config/settings';
import { getGPUStackPlugin } from '@/plugins';
import { usePluginListColumns } from '@/plugins/list-extra-columns';
import { StarFilled } from '@ant-design/icons';
import {
AutoTooltip,
@@ -80,6 +81,7 @@ const useClusterColumns = (
): SealColumnProps[] => {
const intl = useIntl();
const systemConfig = useAtomValue(systemConfigAtom);
const pluginCols = usePluginListColumns('clusters');
// The cluster-detail page is shipped in OSS source, but OSS keeps
// it unreachable from the cluster list — the link is only
// surfaced when a plugin opts in via
@@ -102,6 +104,40 @@ const useClusterColumns = (
};
return useMemo(() => {
// Two prebuilt span maps for the 24-unit SealTable grid: one for
// the default layout, one for when a plugin contributes an extra
// column (currently always the 3-span Organization cell — wider
// would visually dominate this row of 2/3-span built-ins). Width
// absorbed comes from the columns whose content underfills its
// slot (single-digit `provider` / `models` / `status` tag), not
// from `created_at` (a full date string) or `workers` (the
// `x / y` digit pair reads better with breathing room). Picking
// by map rather than a `pluginSpan`-driven formula trades the
// formula's built-in handling of multi-column / variable-width
// plugins for readability and easier tweaks — the sole consumer
// today is one fixed-width column, so the formula's generality
// was unused.
const SPANS_DEFAULT = {
provider: 3,
deployments: 3,
workers: 3,
status: 3,
createTime: 4
};
const SPANS_WITH_PLUGIN = {
provider: 2,
deployments: 2,
workers: 3,
status: 2,
createTime: 4
};
const spans = pluginCols.length > 0 ? SPANS_WITH_PLUGIN : SPANS_DEFAULT;
const pluginRendered = pluginCols.map((c) => ({
title: intl.formatMessage({ id: c.titleId }),
dataIndex: c.key,
span: c.span ?? 4,
render: (_value: any, record: ClusterListItem) => c.render(record)
}));
return [
{
title: intl.formatMessage({ id: 'common.table.name' }),
@@ -133,11 +169,12 @@ const useClusterColumns = (
</>
)
},
...pluginRendered,
{
title: intl.formatMessage({ id: 'clusters.table.provider' }),
dataIndex: 'provider',
sorter: tableSorter(2),
span: 3,
span: spans.provider,
render: (value: string) => (
<AutoTooltip ghost minWidth={20}>
{ProviderLabelMap[value]}
@@ -155,14 +192,14 @@ const useClusterColumns = (
title: intl.formatMessage({ id: 'clusters.table.deployments' }),
dataIndex: 'models',
sorter: tableSorter(4),
span: 3,
span: spans.deployments,
render: (value: number) => <span>{value}</span>
},
{
title: intl.formatMessage({ id: 'resources.nodes' }),
dataIndex: 'workers',
sorter: tableSorter(5),
span: 3,
span: spans.workers,
render: (value: number, record: ClusterListItem) => (
<span>
{record.ready_workers} / {record.workers}
@@ -172,7 +209,7 @@ const useClusterColumns = (
{
title: intl.formatMessage({ id: 'common.table.status' }),
dataIndex: 'state',
span: 3,
span: spans.status,
render: (value: number, record: ClusterListItem) => (
<StatusTag
statusValue={{
@@ -187,7 +224,7 @@ const useClusterColumns = (
title: intl.formatMessage({ id: 'common.table.createTime' }),
dataIndex: 'created_at',
sorter: tableSorter(6),
span: 4,
span: spans.createTime,
render: (value: string) => (
<AutoTooltip ghost minWidth={20}>
{dayjs(value).format('YYYY-MM-DD HH:mm:ss')}
@@ -206,7 +243,7 @@ const useClusterColumns = (
)
}
];
}, [handleSelect, onCellClick]);
}, [handleSelect, onCellClick, intl, pluginCols]);
};
export default useClusterColumns;
@@ -1,5 +1,6 @@
// columns.ts
import { tableSorter } from '@/config/settings';
import { usePluginListColumns } from '@/plugins/list-extra-columns';
import { AutoTooltip, DropdownButtons } from '@gpustack/core-ui';
import { useIntl } from '@umijs/max';
import { ColumnsType } from 'antd/es/table';
@@ -12,8 +13,15 @@ const useCredentialColumns = (
handleSelect: (val: string, record: ListItem) => void
): ColumnsType<ListItem> => {
const intl = useIntl();
const pluginCols = usePluginListColumns('cloudCredentials');
return useMemo(() => {
const pluginRendered = pluginCols.map((c) => ({
title: intl.formatMessage({ id: c.titleId }),
key: c.key,
ellipsis: { showTitle: false },
render: (_text: any, record: ListItem) => c.render(record)
}));
return [
{
title: intl.formatMessage({ id: 'common.table.name' }),
@@ -25,6 +33,7 @@ const useCredentialColumns = (
</AutoTooltip>
)
},
...pluginRendered,
{
title: intl.formatMessage({ id: 'clusters.table.provider' }),
dataIndex: 'provider',
@@ -68,7 +77,7 @@ const useCredentialColumns = (
)
}
];
}, [intl, handleSelect]);
}, [intl, handleSelect, pluginCols]);
};
export default useCredentialColumns;
@@ -1,3 +1,4 @@
import { usePluginListColumns } from '@/plugins/list-extra-columns';
import { ExportOutlined } from '@ant-design/icons';
import {
AutoTooltip,
@@ -170,6 +171,7 @@ const useInstancesColumns = ({
}: ColumnsHookProps): ColumnsType<ListItem> => {
const intl = useIntl();
const access = useAccess();
const pluginCols = usePluginListColumns('gpuInstances');
const renderInstanceType = (record: ListItem) => {
const description =
@@ -247,6 +249,12 @@ const useInstancesColumns = ({
};
return useMemo(() => {
const pluginRendered = pluginCols.map((c) => ({
title: intl.formatMessage({ id: c.titleId }),
key: c.key,
ellipsis: { showTitle: false },
render: (_text: any, record: ListItem) => c.render(record)
}));
return [
{
title: intl.formatMessage({ id: 'common.table.name' }),
@@ -381,6 +389,7 @@ const useInstancesColumns = ({
width: 300,
render: (_text: string, record: ListItem) => renderInstanceType(record)
},
...pluginRendered,
{
title: intl.formatMessage({ id: 'clusters.title' }),
dataIndex: 'clusterId',
@@ -426,7 +435,14 @@ const useInstancesColumns = ({
}
}
];
}, [handleSelect, sortOrder, clusterList, intl, pvCapacityByName]);
}, [
handleSelect,
sortOrder,
clusterList,
intl,
pvCapacityByName,
pluginCols
]);
};
export default useInstancesColumns;
@@ -1,3 +1,4 @@
import { usePluginListColumns } from '@/plugins/list-extra-columns';
import { AutoTooltip, DropdownButtons, icons } from '@gpustack/core-ui';
import { useIntl } from '@umijs/max';
import type { ColumnsType } from 'antd/lib/table';
@@ -33,7 +34,14 @@ const usePublicKeyColumns = ({
sortOrder
}: ColumnsHookProps): ColumnsType<ListItem> => {
const intl = useIntl();
const pluginCols = usePluginListColumns('gpuPublicKeys');
return useMemo(() => {
const pluginRendered = pluginCols.map((c) => ({
title: intl.formatMessage({ id: c.titleId }),
key: c.key,
ellipsis: { showTitle: false },
render: (_text: any, record: ListItem) => c.render(record)
}));
return [
{
title: intl.formatMessage({ id: 'common.table.name' }),
@@ -53,6 +61,7 @@ const usePublicKeyColumns = ({
</AutoTooltip>
)
},
...pluginRendered,
{
title: intl.formatMessage({ id: 'common.table.createTime' }),
dataIndex: 'created_at',
@@ -80,7 +89,7 @@ const usePublicKeyColumns = ({
)
}
];
}, [handleSelect, sortOrder, intl]);
}, [handleSelect, sortOrder, intl, pluginCols]);
};
export default usePublicKeyColumns;
@@ -1,3 +1,4 @@
import { usePluginListColumns } from '@/plugins/list-extra-columns';
import { FolderOutlined } from '@ant-design/icons';
import {
AutoTooltip,
@@ -46,7 +47,14 @@ const useStorageTypeColumns = ({
sortOrder
}: ColumnsHookProps): ColumnsType<ListItem> => {
const intl = useIntl();
const pluginCols = usePluginListColumns('gpuStorageTypes');
return useMemo(() => {
const pluginRendered = pluginCols.map((c) => ({
title: intl.formatMessage({ id: c.titleId }),
key: c.key,
ellipsis: { showTitle: false },
render: (_text: any, record: ListItem) => c.render(record)
}));
return [
{
title: intl.formatMessage({ id: 'common.table.name' }),
@@ -64,6 +72,7 @@ const useStorageTypeColumns = ({
</AutoTooltip>
)
},
...pluginRendered,
{
title: intl.formatMessage({ id: 'common.table.type' }),
key: 'kind',
@@ -106,7 +115,7 @@ const useStorageTypeColumns = ({
)
}
];
}, [handleSelect, sortOrder, intl]);
}, [handleSelect, sortOrder, intl, pluginCols]);
};
export default useStorageTypeColumns;
@@ -1,3 +1,4 @@
import { usePluginListColumns } from '@/plugins/list-extra-columns';
import { AutoTooltip, DropdownButtons } from '@gpustack/core-ui';
import { useIntl } from '@umijs/max';
import type { ColumnsType } from 'antd/lib/table';
@@ -18,7 +19,14 @@ const useStorageColumns = ({
sortOrder
}: ColumnsHookProps): ColumnsType<ListItem> => {
const intl = useIntl();
const pluginCols = usePluginListColumns('gpuStorage');
return useMemo(() => {
const pluginRendered = pluginCols.map((c) => ({
title: intl.formatMessage({ id: c.titleId }),
key: c.key,
ellipsis: { showTitle: false },
render: (_text: any, record: ListItem) => c.render(record)
}));
return [
{
title: intl.formatMessage({ id: 'common.table.name' }),
@@ -38,6 +46,7 @@ const useStorageColumns = ({
</AutoTooltip>
)
},
...pluginRendered,
{
title: intl.formatMessage({ id: 'common.table.type' }),
dataIndex: ['spec', 'type'],
@@ -102,7 +111,7 @@ const useStorageColumns = ({
)
}
];
}, [handleSelect, sortOrder, storageClassList, intl]);
}, [handleSelect, sortOrder, storageClassList, intl, pluginCols]);
};
export default useStorageColumns;
+3
View File
@@ -145,6 +145,9 @@ export interface DistributedServers {
export interface ModelInstanceListItem {
backend?: string;
cluster_id: number;
// Inherited from the parent Model's owner_principal_id on the
// wire so per-row tenant filtering works without joining.
owner_principal_id?: number | null;
backend_version?: string;
source: string;
categories?: string[];
@@ -2,6 +2,7 @@
import { systemConfigAtom } from '@/atoms/system';
import { OPENAI_COMPATIBLE, tableSorter } from '@/config/settings';
import { TargetStatusValueMap } from '@/pages/model-routes/config';
import { usePluginListColumns } from '@/plugins/list-extra-columns';
import { QuestionCircleOutlined } from '@ant-design/icons';
import {
AutoTooltip,
@@ -105,6 +106,7 @@ const useModelsColumns = ({
}: ModelsColumnsHookProps & { targetList: any[] }): TableColumnProps[] => {
const intl = useIntl();
const systemConfig = useAtomValue(systemConfigAtom);
const pluginCols = usePluginListColumns('llmodels');
const setModelActionList = useMemoizedFn((record: any) => {
return _.filter(ActionList, (action: any) => {
@@ -150,6 +152,30 @@ const useModelsColumns = ({
};
return useMemo(() => {
// Two prebuilt span maps for the 24-unit SealTable grid: one for
// the default layout, one for when a plugin contributes an extra
// column (currently always the 4-span Organization cell). Width
// absorbed comes from the widest non-name columns (`source`,
// `replicas`, `created_at`). See the matching map in
// `use-cluster-columns.tsx` for rationale.
const SPANS_DEFAULT = {
source: 5,
replicas: 4,
createTime: 4
};
const SPANS_WITH_PLUGIN = {
source: 3,
replicas: 3,
createTime: 3
};
const spans = pluginCols.length > 0 ? SPANS_WITH_PLUGIN : SPANS_DEFAULT;
const pluginRendered = pluginCols.map((c) => ({
title: intl.formatMessage({ id: c.titleId }),
dataIndex: c.key,
key: c.key,
span: c.span ?? 4,
render: (_text: any, record: ListItem) => c.render(record)
}));
return [
{
title: intl.formatMessage({ id: 'common.table.name' }),
@@ -173,6 +199,7 @@ const useModelsColumns = ({
</Flex>
)
},
...pluginRendered,
{
title: intl.formatMessage({ id: 'clusters.title' }),
dataIndex: 'cluster_id',
@@ -193,7 +220,7 @@ const useModelsColumns = ({
dataIndex: 'source',
key: 'source',
sorter: tableSorter(3),
span: 5,
span: spans.source,
render: (text: string, record: ListItem) => (
<span className="flex flex-column" style={{ width: '100%' }}>
<AutoTooltip ghost>{generateSource(record)}</AutoTooltip>
@@ -216,7 +243,7 @@ const useModelsColumns = ({
key: 'replicas',
align: 'left',
sorter: tableSorter(4),
span: 4,
span: spans.replicas,
editable: {
valueType: 'number',
title: intl.formatMessage({ id: 'models.table.replicas.edit' })
@@ -241,7 +268,7 @@ const useModelsColumns = ({
dataIndex: 'created_at',
key: 'created_at',
sorter: tableSorter(5),
span: 4,
span: spans.createTime,
render: (text: number) => (
<AutoTooltip ghost>
{dayjs(text).format('YYYY-MM-DD HH:mm:ss')}
@@ -261,7 +288,14 @@ const useModelsColumns = ({
)
}
];
}, [sortOrder, clusterList, intl, handleSelect, setModelActionList]);
}, [
sortOrder,
clusterList,
intl,
handleSelect,
setModelActionList,
pluginCols
]);
};
export default useModelsColumns;
@@ -1,6 +1,7 @@
// columns.ts
import { tableSorter } from '@/config/settings';
import { ListItem as workerListItem } from '@/pages/resources/config/types';
import { usePluginListColumns } from '@/plugins/list-extra-columns';
import { convertFileSize } from '@/utils';
import { ThunderboltFilled } from '@ant-design/icons';
import { AutoTooltip, IconFont } from '@gpustack/core-ui';
@@ -67,6 +68,7 @@ const useInstancesColumns = (options: {
const intl = useIntl();
const { workerList, clusterList, modelList, handleSelect, onCellClick } =
options;
const pluginCols = usePluginListColumns('modelInstances');
const renderWorkerCell = (text: number, record: ListItem) => {
if (text) {
@@ -84,6 +86,12 @@ const useInstancesColumns = (options: {
};
return useMemo(() => {
const pluginRendered = pluginCols.map((c) => ({
title: intl.formatMessage({ id: c.titleId }),
key: c.key,
ellipsis: { showTitle: false },
render: (_text: any, record: ListItem) => c.render(record)
}));
return [
{
title: intl.formatMessage({ id: 'common.table.name' }),
@@ -118,6 +126,7 @@ const useInstancesColumns = (options: {
</>
)
},
...pluginRendered,
{
title: intl.formatMessage({ id: 'clusters.title' }),
dataIndex: 'cluster_id',
@@ -202,7 +211,15 @@ const useInstancesColumns = (options: {
)
}
];
}, [handleSelect, onCellClick, workerList, clusterList, modelList]);
}, [
handleSelect,
onCellClick,
workerList,
clusterList,
modelList,
intl,
pluginCols
]);
};
export default useInstancesColumns;
+1
View File
@@ -22,6 +22,7 @@ export interface FormData {
export interface MaasProviderItem {
name: string;
description: string;
owner_principal_id?: number | null;
proxy_url: string;
proxy_timeout: number;
config: {
@@ -1,5 +1,6 @@
// columns.ts
import { tableSorter } from '@/config/settings';
import { usePluginListColumns } from '@/plugins/list-extra-columns';
import { AutoTooltip, DropdownButtons } from '@gpustack/core-ui';
import { useIntl } from '@umijs/max';
import { Tag } from 'antd';
@@ -16,6 +17,7 @@ const useProviderColumns = (
onCellClick?: (record: MaasProviderItem, dataIndex: string) => void
): ColumnsType<MaasProviderItem> => {
const intl = useIntl();
const pluginCols = usePluginListColumns('maasProviders');
return useMemo(() => {
const setActionList = (record: MaasProviderItem) => {
@@ -26,6 +28,12 @@ const useProviderColumns = (
return true;
});
};
const pluginRendered = pluginCols.map((c) => ({
title: intl.formatMessage({ id: c.titleId }),
dataIndex: c.key,
span: c.span ?? 4,
render: (_value: any, record: MaasProviderItem) => c.render(record)
}));
return [
{
title: intl.formatMessage({ id: 'common.table.name' }),
@@ -46,6 +54,7 @@ const useProviderColumns = (
</>
)
},
...pluginRendered,
{
title: intl.formatMessage({ id: 'providers.table.providerName' }),
dataIndex: ['config', 'type'],
@@ -96,7 +105,7 @@ const useProviderColumns = (
)
}
];
}, [handleSelect, onCellClick]);
}, [handleSelect, onCellClick, intl, pluginCols]);
};
export default useProviderColumns;
@@ -2,6 +2,7 @@
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,
@@ -25,7 +26,7 @@ type PluginColumn = {
key: string;
titleId: string;
span?: number;
placement?: 'before-time' | 'before-operation';
placement?: 'after-name' | 'before-time' | 'before-operation';
render: (record: RouteItem) => React.ReactNode;
};
@@ -54,15 +55,16 @@ interface ColumnsHookProps {
// 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.
// 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 = 3;
const CREATE_TIME_MIN_SPAN = 2;
const useAccessColumns = ({
handleSelect,
@@ -70,8 +72,20 @@ const useAccessColumns = ({
onConfigAction
}: ColumnsHookProps): TableColumnProps[] => {
const intl = useIntl();
const pluginColumns: PluginColumn[] =
getGPUStackPlugin()?.modelRoutes?.extraColumns ?? [];
// 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
@@ -151,8 +165,8 @@ const useAccessColumns = ({
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.
// (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,
@@ -172,6 +186,15 @@ const useAccessColumns = ({
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'
);
@@ -193,6 +216,7 @@ const useAccessColumns = ({
</span>
)
},
...afterName,
{
title: intl.formatMessage({ id: 'routes.table.routeTargets' }),
dataIndex: 'targets',
+8
View File
@@ -38,6 +38,10 @@ export interface GPUDeviceItem {
worker_id: number;
worker_name: string;
worker_ip: string;
cluster_id?: number;
// Denormalized from the parent worker → cluster on the wire so
// per-row tenant filtering works without joining.
owner_principal_id?: number | null;
}
export interface Filesystem {
@@ -65,6 +69,9 @@ export interface ListItem {
state: string;
ip: string;
cluster_id: number;
// Denormalized from the parent cluster on the wire so per-row
// tenant filtering works without joining.
owner_principal_id?: number | null;
state_message: string;
ssh_key_id: string;
advertise_address: string;
@@ -116,6 +123,7 @@ export interface ModelFile {
local_path: string;
local_dir: string;
worker_id: number;
owner_principal_id?: number | null;
size: number;
download_progress: number;
resolved_paths: string[];
@@ -1,6 +1,7 @@
import { tableSorter } from '@/config/settings';
import { modelSourceMap } from '@/pages/llmodels/config';
import { modelFileActions } from '@/pages/llmodels/config/button-actions';
import { usePluginListColumns } from '@/plugins/list-extra-columns';
import { convertFileSize } from '@/utils';
import {
CheckCircleFilled,
@@ -270,8 +271,15 @@ const useFilesColumns = (props: {
}): ColumnsType<ListItem> => {
const { workersList, sortOrder, handleSelect } = props;
const intl = useIntl();
const pluginCols = usePluginListColumns('modelFiles');
return useMemo(() => {
const pluginRendered = pluginCols.map((c) => ({
title: intl.formatMessage({ id: c.titleId }),
key: c.key,
ellipsis: { showTitle: false },
render: (_text: any, record: ListItem) => c.render(record)
}));
return [
{
title: intl.formatMessage({ id: 'models.form.source' }),
@@ -294,6 +302,7 @@ const useFilesColumns = (props: {
);
}
},
...pluginRendered,
{
title: intl.formatMessage({ id: 'resources.worker' }),
dataIndex: 'worker_id',
@@ -370,7 +379,7 @@ const useFilesColumns = (props: {
)
}
];
}, [intl, workersList, handleSelect]);
}, [intl, workersList, handleSelect, pluginCols]);
};
export default useFilesColumns;
+10 -1
View File
@@ -1,4 +1,5 @@
import { tableSorter } from '@/config/settings';
import { usePluginListColumns } from '@/plugins/list-extra-columns';
import { convertFileSize } from '@/utils';
import { AutoTooltip, InfoColumn, ProgressBar } from '@gpustack/core-ui';
import { useIntl } from '@umijs/max';
@@ -41,8 +42,15 @@ const useGPUColumns = (props: {
}): ColumnsType<GPUDeviceItem> => {
const { clusterList, loadend, firstLoad, sortOrder } = props;
const intl = useIntl();
const pluginCols = usePluginListColumns('gpus');
return useMemo(() => {
const pluginRendered = pluginCols.map((c) => ({
title: intl.formatMessage({ id: c.titleId }),
key: c.key,
ellipsis: { showTitle: false },
render: (_text: any, record: GPUDeviceItem) => c.render(record)
}));
return [
{
title: intl.formatMessage({ id: 'common.table.name' }),
@@ -62,6 +70,7 @@ const useGPUColumns = (props: {
sorter: tableSorter(2),
render: (text: string, record: GPUDeviceItem) => <span>{text}</span>
},
...pluginRendered,
{
title: intl.formatMessage({ id: 'clusters.title' }),
dataIndex: 'cluster_id',
@@ -141,7 +150,7 @@ const useGPUColumns = (props: {
}
}
];
}, [intl, clusterList, loadend, firstLoad]);
}, [intl, clusterList, loadend, firstLoad, pluginCols]);
};
export default useGPUColumns;
@@ -1,6 +1,7 @@
import { systemConfigAtom } from '@/atoms/system';
import { GPUStackVersionAtom } from '@/atoms/user';
import { tableSorter } from '@/config/settings';
import { usePluginListColumns } from '@/plugins/list-extra-columns';
import { convertFileSize } from '@/utils';
import {
DeleteOutlined,
@@ -249,6 +250,7 @@ const useWorkerColumns = ({
const intl = useIntl();
const systemConfig = useAtomValue(systemConfigAtom);
const [version] = useAtom(GPUStackVersionAtom);
const pluginCols = usePluginListColumns('workers');
const renderIP = (text: string, record: ListItem) => {
if (record.advertise_address === record.ip) {
@@ -383,6 +385,13 @@ const useWorkerColumns = ({
);
};
const pluginRendered = pluginCols.map((c) => ({
title: intl.formatMessage({ id: c.titleId }),
key: c.key,
ellipsis: { showTitle: false },
render: (_text: any, record: ListItem) => c.render(record)
}));
return useMemo<ColumnsType<ListItem>>(
() => [
{
@@ -405,6 +414,7 @@ const useWorkerColumns = ({
width: 200,
render: (_, record) => <LabelCell labels={record.labels} />
},
...pluginRendered,
{
title: intl.formatMessage({ id: 'clusters.title' }),
dataIndex: 'cluster_id',
@@ -532,7 +542,16 @@ const useWorkerColumns = ({
)
}
],
[intl, sortOrder, clusterData, loadend, source, firstLoad, handleSelect]
[
intl,
sortOrder,
clusterData,
loadend,
source,
firstLoad,
handleSelect,
pluginRendered
]
);
};
+82
View File
@@ -0,0 +1,82 @@
import { useMemo, useRef } from 'react';
import { getGPUStackPlugin } from './index';
// Generic per-page "extra columns" seam. Each list page (Models,
// Model Routes, Clusters, etc.) reads its own slot keyed by a stable
// page id and splices the returned columns into its column array.
// Plugins that aren't loaded — or that don't register a slot for the
// page — produce an empty list, so the host stays unchanged.
export type PluginListColumn = {
key: string;
// i18n message id used as the column title.
titleId: string;
// Optional SealTable grid span; antd-`Table` consumers ignore it.
span?: number;
// Where in the existing column order the entry lands. `after-name`
// (the default) is the natural slot for identifying columns like
// "Organization" that read alongside the row's name; the other two
// are kept for back-compat with the per-page slots that predate
// this generic seam (`modelRoutes.extraColumns` uses them for the
// quota-default cell). Each page's hook decides which placements
// it honors.
placement?: 'after-name' | 'before-operation' | 'before-time';
render: (record: any) => React.ReactNode;
};
// Plugins may register either a static array OR a function returning
// one. The function form lets the plugin use React hooks (e.g. to
// read jotai atoms for visibility) since the host calls it inside its
// own `useMemo`-wrapping hook below. The function must obey the rules
// of hooks — call the same hooks in the same order each render and
// short-circuit to `[]` rather than skipping hook calls when hidden.
export type PluginListColumnsEntry =
| PluginListColumn[]
| (() => PluginListColumn[]);
const readEntry = (pageKey: string): PluginListColumnsEntry | undefined => {
const slots = getGPUStackPlugin()?.listExtraColumns as
| Record<string, PluginListColumnsEntry>
| undefined;
return slots?.[pageKey];
};
// Host hook: each list page's columns hook calls this once with its
// page key. Returns `[]` when no plugin / no slot. The function-form
// entry is always called when present so its inner hooks run the same
// number of times every render — visibility logic must live inside
// the entry's return value, not around the call.
export const usePluginListColumns = (pageKey: string): PluginListColumn[] => {
const entry = readEntry(pageKey);
// `entry` is read once per render from a registry wired at boot, so
// its identity is stable across renders. The function form is
// invoked unconditionally so the same hooks run in the same order
// every render — visibility logic must live in the entry's return
// value, not around the call.
const fromFn = typeof entry === 'function' ? entry() : null;
// Belt-and-suspenders stability: if the plugin's hook returns an
// array with the same elements but a fresh outer reference (it can
// happen even when the plugin author tried to memoize), pin it to
// the previous reference so downstream `useMemo`s in every list
// page don't re-fire and rebuild their `columns` arrays. Cheap
// shallow check by length + element identity — same shape every
// call site already promises through the schema.
const prevRef = useRef<PluginListColumn[] | null>(null);
const stableFromFn = useMemo(() => {
if (!fromFn) return null;
const prev = prevRef.current;
if (
prev &&
prev.length === fromFn.length &&
prev.every((col, i) => col === fromFn[i])
) {
return prev;
}
prevRef.current = fromFn;
return fromFn;
}, [fromFn]);
return useMemo(() => {
if (!entry) return [];
if (typeof entry === 'function') return stableFromFn ?? [];
return entry;
}, [entry, stableFromFn]);
};