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
+22 -11
View File
@@ -1,10 +1,10 @@
import { clusterDetailAtom } from '@/atoms/clusters';
import Deployments from '@/pages/llmodels/deployments';
import GPUList from '@/pages/resources/components/gpus';
import WorkerList from '@/pages/resources/components/workers';
import { getGPUStackPlugin } from '@/plugins';
import { IconFont } from '@gpustack/core-ui';
import { useIntl, useNavigate, useSearchParams } from '@umijs/max';
import { Tabs } from 'antd';
import { Tabs, type TabsProps } from 'antd';
import { useAtomValue } from 'jotai';
import { PageContainerInner } from '../_components/page-box';
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 (
<PageContainerInner
header={{
@@ -46,20 +62,15 @@ const ClusterDetailModal = () => {
key: 'workers',
label: `Workers`,
icon: <IconFont type="icon-resources" />,
children: <WorkerList />
},
{
key: 'deployments',
label: `Deployments`,
icon: <IconFont type="icon-rocket-launch1" />,
children: <Deployments></Deployments>
children: <WorkerList clusterId={Number(id)} />
},
{
key: 'gpus',
label: `GPUs`,
icon: <IconFont type="icon-gpu1" />,
children: <GPUList />
}
children: <GPUList clusterId={Number(id)} />
},
...extraTabs
]}
/>
</PageContainerInner>
@@ -1,6 +1,7 @@
// columns.ts
import { systemConfigAtom } from '@/atoms/system';
import { tableSorter } from '@/config/settings';
import { getGPUStackPlugin } from '@/plugins';
import { StarFilled } from '@ant-design/icons';
import {
AutoTooltip,
@@ -11,7 +12,7 @@ import {
type TableColumnProps as SealColumnProps
} from '@gpustack/core-ui';
import { useIntl } from '@umijs/max';
import { Tooltip } from 'antd';
import { Tooltip, Typography } from 'antd';
import dayjs from 'dayjs';
import { useAtomValue } from 'jotai';
import { useMemo } from 'react';
@@ -79,6 +80,14 @@ const useClusterColumns = (
): SealColumnProps[] => {
const intl = useIntl();
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) => {
return clusterActionList.filter((item) => {
@@ -101,8 +110,16 @@ const useClusterColumns = (
span: 3,
render: (text: string, record: ClusterListItem) => (
<>
<AutoTooltip ghost>
<span className="text-primary">{record.name}</span>
<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
+12 -3
View File
@@ -11,7 +11,15 @@ import { GPU_DEVICES_API, queryGpuDevicesList } from '../apis';
import { GPUDeviceItem } from '../config/types';
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 {
dataSource,
queryParams,
@@ -26,7 +34,8 @@ const GPUList = () => {
key: PaginationKey.GPUs,
fetchAPI: queryGpuDevicesList,
polling: true,
API: GPU_DEVICES_API
API: GPU_DEVICES_API,
defaultQueryParams: clusterId ? { cluster_id: clusterId } : undefined
});
const [searchParams] = useSearchParams();
const page = searchParams.get('page');
@@ -100,7 +109,7 @@ const GPUList = () => {
handleInputChange={handleNameChange}
handleSelectChange={handleClusterChange}
selectOptions={clusterList}
showSelect={true}
showSelect={!clusterId}
></FilterBar>
<ConfigProvider renderEmpty={renderEmpty}>
<Table
+12 -3
View File
@@ -26,7 +26,15 @@ import UpdateLabels from './update-labels';
import WorkerDetailModal from './worker-detail-modal';
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 {
dataSource,
rowSelection,
@@ -50,7 +58,8 @@ const Workers = () => {
contentForDelete: 'resources.worker',
watch: true,
API: WORKERS_API,
updateManually: true
updateManually: true,
defaultQueryParams: clusterId ? { cluster_id: clusterId } : undefined
});
const { goToGrafana, ActionButton } = useGranfanaLink({
type: 'worker'
@@ -251,7 +260,7 @@ const Workers = () => {
<>
<PageBox>
<FilterBar
showSelect={true}
showSelect={!clusterId}
selectHolder={intl.formatMessage({ id: 'clusters.filterBy.cluster' })}
marginBottom={22}
marginTop={30}