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]); +};