Files
gpustack-ui/src/pages/cluster-management/hooks/use-cluster-columns.tsx
T
gitlawrandjialin 26ce456bde 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.
2026-06-09 16:28:59 +08:00

250 lines
7.5 KiB
TypeScript

// columns.ts
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,
DropdownButtons,
GrafanaIcon,
StatusTag,
icons,
type TableColumnProps as SealColumnProps
} from '@gpustack/core-ui';
import { useIntl } from '@umijs/max';
import { Tooltip, Typography } from 'antd';
import dayjs from 'dayjs';
import { useAtomValue } from 'jotai';
import { useMemo } from 'react';
import {
ClusterStatus,
ClusterStatusLabelMap,
ProviderLabelMap,
ProviderValueMap
} from '../config';
import { ClusterListItem } from '../config/types';
const clusterActionList = [
{
key: 'edit',
label: 'common.button.edit',
icon: icons.EditOutlined
},
{
label: 'resources.metrics.details',
key: 'metrics',
icon: (
<span className="flex-center">
<GrafanaIcon style={{ width: 14, height: 14 }}></GrafanaIcon>
</span>
)
},
{
key: 'add_worker',
label: 'resources.button.create',
provider: ProviderValueMap.Docker,
locale: true,
icon: icons.DockerOutlined
},
{
key: 'register_cluster',
label: 'clusters.button.register',
provider: ProviderValueMap.Kubernetes,
locale: true,
icon: icons.KubernetesOutlined
},
{
key: 'addPool',
label: 'clusters.button.addNodePool',
provider: ProviderValueMap.DigitalOcean,
locale: true,
icon: icons.Catalog1
},
{
key: 'isDefault',
label: 'clusters.form.setDefault',
icon: icons.StarOutlined
},
{
key: 'delete',
label: 'common.button.delete',
icon: icons.DeleteOutlined,
props: {
danger: true
}
}
];
const useClusterColumns = (
handleSelect: (val: string, record: ClusterListItem) => void,
onCellClick?: (record: ClusterListItem, dataIndex: string) => void
): 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
// `clusterDetail.linkableName`. Without a plugin we render the
// name as plain text (matches the pre-restore behaviour); with one
// we use Typography.Link wired to the parent's `onCellClick`.
const nameLinkable: boolean =
!!getGPUStackPlugin()?.clusterDetail?.linkableName;
const setActionsItems = (row: ClusterListItem) => {
return clusterActionList.filter((item) => {
if (item.provider) {
return item.provider === row.provider;
}
if (item.key === 'metrics') {
return systemConfig?.showMonitoring;
}
return true;
});
};
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' }),
dataIndex: 'name',
sorter: tableSorter(1),
span: 3,
render: (text: string, record: ClusterListItem) => (
<>
<AutoTooltip ghost title={text}>
{nameLinkable ? (
<Typography.Link onClick={() => onCellClick?.(record, 'name')}>
{record.name}
</Typography.Link>
) : (
<span className="text-primary">{record.name}</span>
)}
</AutoTooltip>
{record.is_default && (
<Tooltip
title={intl.formatMessage({
id: 'clusters.form.setDefault.tips'
})}
>
<StarFilled
style={{ color: 'var(--ant-gold-4)', marginLeft: 4 }}
/>
</Tooltip>
)}
</>
)
},
...pluginRendered,
{
title: intl.formatMessage({ id: 'clusters.table.provider' }),
dataIndex: 'provider',
sorter: tableSorter(2),
span: spans.provider,
render: (value: string) => (
<AutoTooltip ghost minWidth={20}>
{ProviderLabelMap[value]}
</AutoTooltip>
)
},
{
title: 'GPUs',
dataIndex: 'gpus',
span: 2,
sorter: tableSorter(3),
render: (value: number) => <span>{value}</span>
},
{
title: intl.formatMessage({ id: 'clusters.table.deployments' }),
dataIndex: 'models',
sorter: tableSorter(4),
span: spans.deployments,
render: (value: number) => <span>{value}</span>
},
{
title: intl.formatMessage({ id: 'resources.nodes' }),
dataIndex: 'workers',
sorter: tableSorter(5),
span: spans.workers,
render: (value: number, record: ClusterListItem) => (
<span>
{record.ready_workers} / {record.workers}
</span>
)
},
{
title: intl.formatMessage({ id: 'common.table.status' }),
dataIndex: 'state',
span: spans.status,
render: (value: number, record: ClusterListItem) => (
<StatusTag
statusValue={{
status: ClusterStatus[value],
text: ClusterStatusLabelMap[value],
message: record.state_message || undefined
}}
/>
)
},
{
title: intl.formatMessage({ id: 'common.table.createTime' }),
dataIndex: 'created_at',
sorter: tableSorter(6),
span: spans.createTime,
render: (value: string) => (
<AutoTooltip ghost minWidth={20}>
{dayjs(value).format('YYYY-MM-DD HH:mm:ss')}
</AutoTooltip>
)
},
{
title: intl.formatMessage({ id: 'common.table.operation' }),
dataIndex: 'operations',
span: 3,
render: (value: string, record: ClusterListItem) => (
<DropdownButtons
items={setActionsItems(record)}
onSelect={(val) => handleSelect(val, record)}
></DropdownButtons>
)
}
];
}, [handleSelect, onCellClick, intl, pluginCols]);
};
export default useClusterColumns;