feat: restore cluster detail surface, gate cluster-list name link via plugin

The "chore: hide cluster detail" change had commented out both the
cluster-detail route and the cluster name link in the list. The
detail page component still ships in the OSS tree, so reach is
restored but the link is plugin-gated rather than unconditional:
plain OSS renders the name as a span; with a registered
`clusterDetail.linkableName` flag the name turns into a
`Typography.Link` that fires the parent's existing `onCellClick`
(which navigates to the detail route).

Detail tabs adjustments for the in-cluster context:
- Drop the Deployments tab (deployments are managed from the
  top-level Models page; the detail view is scoped to a single
  cluster's hardware).
- `WorkerList` and `GPUList` accept an optional `clusterId`. When
  set, the list pins `cluster_id` in its query and hides the
  cluster-filter dropdown so the user can't scope away from the
  cluster they're inside.

`clusterDetail.extraTabs(clusterId, intl)` exposes a plugin slot
that appends additional tab items — used by the enterprise plugin
to inject per-cluster Access / Quotas surfaces. No-op without a
plugin.
This commit is contained in:
gitlawr
2026-05-11 20:28:29 +08:00
committed by jialin
parent e25d48cc89
commit 125a5be91f
5 changed files with 76 additions and 30 deletions
+10 -10
View File
@@ -302,16 +302,16 @@ const baseRoutes = [
'/cluster-management/clusters/create' '/cluster-management/clusters/create'
] ]
}, },
// { {
// name: 'clusterDetail', name: 'clusterDetail',
// path: '/cluster-management/clusters/detail', path: '/cluster-management/clusters/detail',
// key: 'clusterDetail', key: 'clusterDetail',
// icon: 'icon-cluster2-outline', icon: 'icon-cluster2-outline',
// selectedIcon: 'icon-cluster2-filled', selectedIcon: 'icon-cluster2-filled',
// defaultIcon: 'icon-cluster2-outline', defaultIcon: 'icon-cluster2-outline',
// hideInMenu: true, hideInMenu: true,
// component: './cluster-management/cluster-detail' component: './cluster-management/cluster-detail'
// }, },
{ {
name: 'credentials', name: 'credentials',
path: '/cluster-management/credentials', path: '/cluster-management/credentials',
+22 -11
View File
@@ -1,10 +1,10 @@
import { clusterDetailAtom } from '@/atoms/clusters'; import { clusterDetailAtom } from '@/atoms/clusters';
import Deployments from '@/pages/llmodels/deployments';
import GPUList from '@/pages/resources/components/gpus'; import GPUList from '@/pages/resources/components/gpus';
import WorkerList from '@/pages/resources/components/workers'; import WorkerList from '@/pages/resources/components/workers';
import { getGPUStackPlugin } from '@/plugins';
import { IconFont } from '@gpustack/core-ui'; import { IconFont } from '@gpustack/core-ui';
import { useIntl, useNavigate, useSearchParams } from '@umijs/max'; import { useIntl, useNavigate, useSearchParams } from '@umijs/max';
import { Tabs } from 'antd'; import { Tabs, type TabsProps } from 'antd';
import { useAtomValue } from 'jotai'; import { useAtomValue } from 'jotai';
import { PageContainerInner } from '../_components/page-box'; import { PageContainerInner } from '../_components/page-box';
import PageBreadcrumb from '../_components/page-breadcrumb'; import PageBreadcrumb from '../_components/page-breadcrumb';
@@ -29,6 +29,22 @@ const ClusterDetailModal = () => {
} }
]; ];
// Extension slot: a registered plugin may inject additional tab
// items (e.g. per-cluster access / quota panels) by exporting
// ``clusterDetail.extraTabs(clusterId, intl)`` that returns an
// ``items`` array. The plugin's items are appended after the
// built-in tabs; without a plugin this is just ``[]`` and the
// tab strip is unchanged. ``intl`` is threaded through so plugin
// tabs can resolve i18n labels to plain strings — antd Tabs'
// icon-text gap collapses when ``label`` is a ReactNode rather
// than a string, so matching the built-in tabs' shape keeps the
// gap visually consistent.
type TabItem = NonNullable<TabsProps['items']>[number];
const extraTabs = (getGPUStackPlugin()?.clusterDetail?.extraTabs?.(
Number(id),
intl
) ?? []) as TabItem[];
return ( return (
<PageContainerInner <PageContainerInner
header={{ header={{
@@ -46,20 +62,15 @@ const ClusterDetailModal = () => {
key: 'workers', key: 'workers',
label: `Workers`, label: `Workers`,
icon: <IconFont type="icon-resources" />, icon: <IconFont type="icon-resources" />,
children: <WorkerList /> children: <WorkerList clusterId={Number(id)} />
},
{
key: 'deployments',
label: `Deployments`,
icon: <IconFont type="icon-rocket-launch1" />,
children: <Deployments></Deployments>
}, },
{ {
key: 'gpus', key: 'gpus',
label: `GPUs`, label: `GPUs`,
icon: <IconFont type="icon-gpu1" />, icon: <IconFont type="icon-gpu1" />,
children: <GPUList /> children: <GPUList clusterId={Number(id)} />
} },
...extraTabs
]} ]}
/> />
</PageContainerInner> </PageContainerInner>
@@ -1,6 +1,7 @@
// columns.ts // columns.ts
import { systemConfigAtom } from '@/atoms/system'; import { systemConfigAtom } from '@/atoms/system';
import { tableSorter } from '@/config/settings'; import { tableSorter } from '@/config/settings';
import { getGPUStackPlugin } from '@/plugins';
import { StarFilled } from '@ant-design/icons'; import { StarFilled } from '@ant-design/icons';
import { import {
AutoTooltip, AutoTooltip,
@@ -11,7 +12,7 @@ import {
type TableColumnProps as SealColumnProps type TableColumnProps as SealColumnProps
} from '@gpustack/core-ui'; } from '@gpustack/core-ui';
import { useIntl } from '@umijs/max'; import { useIntl } from '@umijs/max';
import { Tooltip } from 'antd'; import { Tooltip, Typography } from 'antd';
import dayjs from 'dayjs'; import dayjs from 'dayjs';
import { useAtomValue } from 'jotai'; import { useAtomValue } from 'jotai';
import { useMemo } from 'react'; import { useMemo } from 'react';
@@ -79,6 +80,14 @@ const useClusterColumns = (
): SealColumnProps[] => { ): SealColumnProps[] => {
const intl = useIntl(); const intl = useIntl();
const systemConfig = useAtomValue(systemConfigAtom); const systemConfig = useAtomValue(systemConfigAtom);
// 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) => { const setActionsItems = (row: ClusterListItem) => {
return clusterActionList.filter((item) => { return clusterActionList.filter((item) => {
@@ -101,8 +110,16 @@ const useClusterColumns = (
span: 3, span: 3,
render: (text: string, record: ClusterListItem) => ( render: (text: string, record: ClusterListItem) => (
<> <>
<AutoTooltip ghost> <AutoTooltip ghost title={text}>
<span className="text-primary">{record.name}</span> {nameLinkable ? (
<Typography.Link
onClick={() => onCellClick?.(record, 'name')}
>
{record.name}
</Typography.Link>
) : (
<span className="text-primary">{record.name}</span>
)}
</AutoTooltip> </AutoTooltip>
{record.is_default && ( {record.is_default && (
<Tooltip <Tooltip
+12 -3
View File
@@ -11,7 +11,15 @@ import { GPU_DEVICES_API, queryGpuDevicesList } from '../apis';
import { GPUDeviceItem } from '../config/types'; import { GPUDeviceItem } from '../config/types';
import useGPUColumns from '../hooks/use-gpu-columns'; import useGPUColumns from '../hooks/use-gpu-columns';
const GPUList = () => { // Optional ``clusterId`` pins the list to a single cluster (used by
// the cluster-detail page) and hides the cluster-filter dropdown so
// the user can't change scope away from the cluster they're already
// inside.
interface GPUListProps {
clusterId?: number;
}
const GPUList: React.FC<GPUListProps> = ({ clusterId }) => {
const { const {
dataSource, dataSource,
queryParams, queryParams,
@@ -26,7 +34,8 @@ const GPUList = () => {
key: PaginationKey.GPUs, key: PaginationKey.GPUs,
fetchAPI: queryGpuDevicesList, fetchAPI: queryGpuDevicesList,
polling: true, polling: true,
API: GPU_DEVICES_API API: GPU_DEVICES_API,
defaultQueryParams: clusterId ? { cluster_id: clusterId } : undefined
}); });
const [searchParams] = useSearchParams(); const [searchParams] = useSearchParams();
const page = searchParams.get('page'); const page = searchParams.get('page');
@@ -100,7 +109,7 @@ const GPUList = () => {
handleInputChange={handleNameChange} handleInputChange={handleNameChange}
handleSelectChange={handleClusterChange} handleSelectChange={handleClusterChange}
selectOptions={clusterList} selectOptions={clusterList}
showSelect={true} showSelect={!clusterId}
></FilterBar> ></FilterBar>
<ConfigProvider renderEmpty={renderEmpty}> <ConfigProvider renderEmpty={renderEmpty}>
<Table <Table
+12 -3
View File
@@ -26,7 +26,15 @@ import UpdateLabels from './update-labels';
import WorkerDetailModal from './worker-detail-modal'; import WorkerDetailModal from './worker-detail-modal';
import WorkerRightActions from './worker-right-actions'; import WorkerRightActions from './worker-right-actions';
const Workers = () => { // Optional ``clusterId`` pins the list to a single cluster (used by
// the cluster-detail page) and hides the cluster-filter dropdown so
// the user can't change scope away from the cluster they're already
// inside.
interface WorkersProps {
clusterId?: number;
}
const Workers: React.FC<WorkersProps> = ({ clusterId }) => {
const { const {
dataSource, dataSource,
rowSelection, rowSelection,
@@ -50,7 +58,8 @@ const Workers = () => {
contentForDelete: 'resources.worker', contentForDelete: 'resources.worker',
watch: true, watch: true,
API: WORKERS_API, API: WORKERS_API,
updateManually: true updateManually: true,
defaultQueryParams: clusterId ? { cluster_id: clusterId } : undefined
}); });
const { goToGrafana, ActionButton } = useGranfanaLink({ const { goToGrafana, ActionButton } = useGranfanaLink({
type: 'worker' type: 'worker'
@@ -251,7 +260,7 @@ const Workers = () => {
<> <>
<PageBox> <PageBox>
<FilterBar <FilterBar
showSelect={true} showSelect={!clusterId}
selectHolder={intl.formatMessage({ id: 'clusters.filterBy.cluster' })} selectHolder={intl.formatMessage({ id: 'clusters.filterBy.cluster' })}
marginBottom={22} marginBottom={22}
marginTop={30} marginTop={30}