From 26ce456bdeb8102ace6ae5e2bdf4afeef0f01b66 Mon Sep 17 00:00:00 2001 From: gitlawr Date: Tue, 9 Jun 2026 12:07:39 +0800 Subject: [PATCH] feat: per-page extra-columns slot for list tables MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- src/pages/benchmark/config/types.ts | 3 + .../benchmark/hooks/use-benchmark-columns.tsx | 11 ++- src/pages/cluster-management/config/types.ts | 1 + .../hooks/use-cluster-columns.tsx | 49 +++++++++-- .../hooks/use-credential-columns.tsx | 11 ++- .../instances/hooks/use-instances-columns.tsx | 18 +++- .../hooks/use-public-key-columns.tsx | 11 ++- .../hooks/use-storage-type-columns.tsx | 11 ++- .../storage/hooks/use-storage-columns.tsx | 11 ++- src/pages/llmodels/config/types.ts | 3 + .../llmodels/hooks/use-models-columns.tsx | 42 +++++++++- .../instance-view/use-instance-columns.tsx | 19 ++++- src/pages/maas-provider/config/types.ts | 1 + .../hooks/use-provider-columns.tsx | 11 ++- .../model-routes/hooks/use-routes-columns.tsx | 46 ++++++++--- src/pages/resources/config/types.ts | 8 ++ .../resources/hooks/use-files-columns.tsx | 11 ++- src/pages/resources/hooks/use-gpu-columns.tsx | 11 ++- .../resources/hooks/use-worker-columns.tsx | 21 ++++- src/plugins/list-extra-columns.ts | 82 +++++++++++++++++++ 20 files changed, 349 insertions(+), 32 deletions(-) create mode 100644 src/plugins/list-extra-columns.ts diff --git a/src/pages/benchmark/config/types.ts b/src/pages/benchmark/config/types.ts index baad2fbe..044c21ba 100644 --- a/src/pages/benchmark/config/types.ts +++ b/src/pages/benchmark/config/types.ts @@ -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; diff --git a/src/pages/benchmark/hooks/use-benchmark-columns.tsx b/src/pages/benchmark/hooks/use-benchmark-columns.tsx index 7809269f..64cc9ae6 100644 --- a/src/pages/benchmark/hooks/use-benchmark-columns.tsx +++ b/src/pages/benchmark/hooks/use-benchmark-columns.tsx @@ -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 => { 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: { ) }, + ...pluginRendered, ...columns, { title: ( @@ -51,7 +60,7 @@ const useBenchmarkColumns = (params: { ) } ]; - }, [intl, onCellClick, handleSelect, columns]); + }, [intl, onCellClick, handleSelect, columns, pluginCols]); }; export default useBenchmarkColumns; diff --git a/src/pages/cluster-management/config/types.ts b/src/pages/cluster-management/config/types.ts index 3ded264d..8ae18376 100644 --- a/src/pages/cluster-management/config/types.ts +++ b/src/pages/cluster-management/config/types.ts @@ -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; } diff --git a/src/pages/cluster-management/hooks/use-cluster-columns.tsx b/src/pages/cluster-management/hooks/use-cluster-columns.tsx index 91ea494c..ee433d9a 100644 --- a/src/pages/cluster-management/hooks/use-cluster-columns.tsx +++ b/src/pages/cluster-management/hooks/use-cluster-columns.tsx @@ -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) => ( {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) => {value} }, { title: intl.formatMessage({ id: 'resources.nodes' }), dataIndex: 'workers', sorter: tableSorter(5), - span: 3, + span: spans.workers, render: (value: number, record: ClusterListItem) => ( {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) => ( ( {dayjs(value).format('YYYY-MM-DD HH:mm:ss')} @@ -206,7 +243,7 @@ const useClusterColumns = ( ) } ]; - }, [handleSelect, onCellClick]); + }, [handleSelect, onCellClick, intl, pluginCols]); }; export default useClusterColumns; diff --git a/src/pages/cluster-management/hooks/use-credential-columns.tsx b/src/pages/cluster-management/hooks/use-credential-columns.tsx index 57b4f8fd..7177895e 100644 --- a/src/pages/cluster-management/hooks/use-credential-columns.tsx +++ b/src/pages/cluster-management/hooks/use-credential-columns.tsx @@ -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 => { 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 = ( ) }, + ...pluginRendered, { title: intl.formatMessage({ id: 'clusters.table.provider' }), dataIndex: 'provider', @@ -68,7 +77,7 @@ const useCredentialColumns = ( ) } ]; - }, [intl, handleSelect]); + }, [intl, handleSelect, pluginCols]); }; export default useCredentialColumns; diff --git a/src/pages/gpu-service/instances/hooks/use-instances-columns.tsx b/src/pages/gpu-service/instances/hooks/use-instances-columns.tsx index 59e0be23..1c11bd20 100644 --- a/src/pages/gpu-service/instances/hooks/use-instances-columns.tsx +++ b/src/pages/gpu-service/instances/hooks/use-instances-columns.tsx @@ -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 => { 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; diff --git a/src/pages/gpu-service/public-keys/hooks/use-public-key-columns.tsx b/src/pages/gpu-service/public-keys/hooks/use-public-key-columns.tsx index 533f3574..ecfde62f 100644 --- a/src/pages/gpu-service/public-keys/hooks/use-public-key-columns.tsx +++ b/src/pages/gpu-service/public-keys/hooks/use-public-key-columns.tsx @@ -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 => { 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 = ({ ) }, + ...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; diff --git a/src/pages/gpu-service/storage-types/hooks/use-storage-type-columns.tsx b/src/pages/gpu-service/storage-types/hooks/use-storage-type-columns.tsx index 17a91604..6ff58711 100644 --- a/src/pages/gpu-service/storage-types/hooks/use-storage-type-columns.tsx +++ b/src/pages/gpu-service/storage-types/hooks/use-storage-type-columns.tsx @@ -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 => { 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 = ({ ) }, + ...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; diff --git a/src/pages/gpu-service/storage/hooks/use-storage-columns.tsx b/src/pages/gpu-service/storage/hooks/use-storage-columns.tsx index d381b69d..65d4cc4d 100644 --- a/src/pages/gpu-service/storage/hooks/use-storage-columns.tsx +++ b/src/pages/gpu-service/storage/hooks/use-storage-columns.tsx @@ -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 => { 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 = ({ ) }, + ...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; diff --git a/src/pages/llmodels/config/types.ts b/src/pages/llmodels/config/types.ts index b9ebaa3a..68dcc344 100644 --- a/src/pages/llmodels/config/types.ts +++ b/src/pages/llmodels/config/types.ts @@ -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[]; diff --git a/src/pages/llmodels/hooks/use-models-columns.tsx b/src/pages/llmodels/hooks/use-models-columns.tsx index 2704d1f6..4dbce0d5 100644 --- a/src/pages/llmodels/hooks/use-models-columns.tsx +++ b/src/pages/llmodels/hooks/use-models-columns.tsx @@ -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 = ({ ) }, + ...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) => ( {generateSource(record)} @@ -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) => ( {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; diff --git a/src/pages/llmodels/instance-view/use-instance-columns.tsx b/src/pages/llmodels/instance-view/use-instance-columns.tsx index f144c1a6..e2e7c7f8 100644 --- a/src/pages/llmodels/instance-view/use-instance-columns.tsx +++ b/src/pages/llmodels/instance-view/use-instance-columns.tsx @@ -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; diff --git a/src/pages/maas-provider/config/types.ts b/src/pages/maas-provider/config/types.ts index f5183253..69ca6f18 100644 --- a/src/pages/maas-provider/config/types.ts +++ b/src/pages/maas-provider/config/types.ts @@ -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: { diff --git a/src/pages/maas-provider/hooks/use-provider-columns.tsx b/src/pages/maas-provider/hooks/use-provider-columns.tsx index b8ab34b2..5cd9ab8b 100644 --- a/src/pages/maas-provider/hooks/use-provider-columns.tsx +++ b/src/pages/maas-provider/hooks/use-provider-columns.tsx @@ -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 => { 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; diff --git a/src/pages/model-routes/hooks/use-routes-columns.tsx b/src/pages/model-routes/hooks/use-routes-columns.tsx index 650ac593..b261dab7 100644 --- a/src/pages/model-routes/hooks/use-routes-columns.tsx +++ b/src/pages/model-routes/hooks/use-routes-columns.tsx @@ -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( + () => [ + ...(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 = ({ ) }, + ...afterName, { title: intl.formatMessage({ id: 'routes.table.routeTargets' }), dataIndex: 'targets', diff --git a/src/pages/resources/config/types.ts b/src/pages/resources/config/types.ts index 63186ed3..f7774b33 100644 --- a/src/pages/resources/config/types.ts +++ b/src/pages/resources/config/types.ts @@ -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[]; diff --git a/src/pages/resources/hooks/use-files-columns.tsx b/src/pages/resources/hooks/use-files-columns.tsx index 19c0a856..94fac54f 100644 --- a/src/pages/resources/hooks/use-files-columns.tsx +++ b/src/pages/resources/hooks/use-files-columns.tsx @@ -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 => { 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; diff --git a/src/pages/resources/hooks/use-gpu-columns.tsx b/src/pages/resources/hooks/use-gpu-columns.tsx index 26ef094b..13aefab6 100644 --- a/src/pages/resources/hooks/use-gpu-columns.tsx +++ b/src/pages/resources/hooks/use-gpu-columns.tsx @@ -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 => { 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) => {text} }, + ...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; diff --git a/src/pages/resources/hooks/use-worker-columns.tsx b/src/pages/resources/hooks/use-worker-columns.tsx index f6b6f942..3f8ae2fa 100644 --- a/src/pages/resources/hooks/use-worker-columns.tsx +++ b/src/pages/resources/hooks/use-worker-columns.tsx @@ -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>( () => [ { @@ -405,6 +414,7 @@ const useWorkerColumns = ({ width: 200, render: (_, record) => }, + ...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 + ] ); }; diff --git a/src/plugins/list-extra-columns.ts b/src/plugins/list-extra-columns.ts new file mode 100644 index 00000000..526551f0 --- /dev/null +++ b/src/plugins/list-extra-columns.ts @@ -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 + | 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(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]); +};