From 125a5be91f6669a79bd289f5f39bd0b38072e378 Mon Sep 17 00:00:00 2001 From: gitlawr Date: Mon, 11 May 2026 09:35:28 +0800 Subject: [PATCH] feat: restore cluster detail surface, gate cluster-list name link via plugin MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- config/routes.ts | 20 +++++------ .../cluster-management/cluster-detail.tsx | 33 ++++++++++++------- .../hooks/use-cluster-columns.tsx | 23 +++++++++++-- src/pages/resources/components/gpus.tsx | 15 +++++++-- src/pages/resources/components/workers.tsx | 15 +++++++-- 5 files changed, 76 insertions(+), 30 deletions(-) diff --git a/config/routes.ts b/config/routes.ts index 1956cb63..a9e3abbb 100644 --- a/config/routes.ts +++ b/config/routes.ts @@ -302,16 +302,16 @@ const baseRoutes = [ '/cluster-management/clusters/create' ] }, - // { - // name: 'clusterDetail', - // path: '/cluster-management/clusters/detail', - // key: 'clusterDetail', - // icon: 'icon-cluster2-outline', - // selectedIcon: 'icon-cluster2-filled', - // defaultIcon: 'icon-cluster2-outline', - // hideInMenu: true, - // component: './cluster-management/cluster-detail' - // }, + { + name: 'clusterDetail', + path: '/cluster-management/clusters/detail', + key: 'clusterDetail', + icon: 'icon-cluster2-outline', + selectedIcon: 'icon-cluster2-filled', + defaultIcon: 'icon-cluster2-outline', + hideInMenu: true, + component: './cluster-management/cluster-detail' + }, { name: 'credentials', path: '/cluster-management/credentials', diff --git a/src/pages/cluster-management/cluster-detail.tsx b/src/pages/cluster-management/cluster-detail.tsx index f6df61b2..69db24ee 100644 --- a/src/pages/cluster-management/cluster-detail.tsx +++ b/src/pages/cluster-management/cluster-detail.tsx @@ -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[number]; + const extraTabs = (getGPUStackPlugin()?.clusterDetail?.extraTabs?.( + Number(id), + intl + ) ?? []) as TabItem[]; + return ( { key: 'workers', label: `Workers`, icon: , - children: - }, - { - key: 'deployments', - label: `Deployments`, - icon: , - children: + children: }, { key: 'gpus', label: `GPUs`, icon: , - children: - } + children: + }, + ...extraTabs ]} /> diff --git a/src/pages/cluster-management/hooks/use-cluster-columns.tsx b/src/pages/cluster-management/hooks/use-cluster-columns.tsx index e63beb16..c2bbd4ea 100644 --- a/src/pages/cluster-management/hooks/use-cluster-columns.tsx +++ b/src/pages/cluster-management/hooks/use-cluster-columns.tsx @@ -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) => ( <> - - {record.name} + + {nameLinkable ? ( + onCellClick?.(record, 'name')} + > + {record.name} + + ) : ( + {record.name} + )} {record.is_default && ( { +// 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 = ({ 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} > { +// 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 = ({ 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 = () => { <>