diff --git a/src/pages/_components/page-box.tsx b/src/pages/_components/page-box.tsx index b0ae2279..0854504b 100644 --- a/src/pages/_components/page-box.tsx +++ b/src/pages/_components/page-box.tsx @@ -4,64 +4,36 @@ import { RouteContext, type PageContainerProps } from '@ant-design/pro-components'; -import { useOverlayScroller } from '@gpustack/core-ui'; +import { + HeaderSlotContext, + useOverlayScroller, + type HeaderSlotContextValue +} from '@gpustack/core-ui'; import { Divider } from 'antd'; import classNames from 'classnames'; import { - createContext, useCallback, useContext, useEffect, - useLayoutEffect, useMemo, useRef, useState } from 'react'; -import { createPortal } from 'react-dom'; import pageBoxCss from './styles/page-box.less'; +// The header-slot bridge (HeaderSlotContext, HeaderLeft, HeaderRight, +// usePageContentStyle) lives in @gpustack/core-ui so the host and the +// enterprise plugin share the SAME context instance and can portal into this +// layout-owned header bar. Re-exported here so existing host import sites +// keep working. +export { + HeaderLeft, + HeaderRight, + usePageContentStyle +} from '@gpustack/core-ui'; + const paddingInlinePageContainerContent = 24; -type HeaderSlotContextValue = { - leftEl: HTMLElement | null; - rightEl: HTMLElement | null; - setContentStyle: (style: React.CSSProperties | undefined) => () => void; -}; - -const HeaderSlotContext = createContext(null); - -// Pages render / as part of their JSX; the children -// are portaled into the layout-owned PageContainerInner header bar so the -// shell never unmounts between routes. Visibility of the default title / -// right divider is driven by CSS (`:empty` / `:not(:empty)`) on the portal -// target, so they react synchronously to DOM mutations — no React state, no -// re-renders, no inter-frame flicker. -export const HeaderLeft: React.FC<{ children: React.ReactNode }> = ({ - children -}) => { - const ctx = useContext(HeaderSlotContext); - return ctx?.leftEl ? createPortal(children, ctx.leftEl) : null; -}; - -export const HeaderRight: React.FC<{ children: React.ReactNode }> = ({ - children -}) => { - const ctx = useContext(HeaderSlotContext); - return ctx?.rightEl ? createPortal(children, ctx.rightEl) : null; -}; - -// Pages that need to override the layout-owned content wrapper style -// (e.g. playground pages that want zero padding) call this hook in render. -// Applied synchronously via useLayoutEffect to avoid first-paint flicker. -export const usePageContentStyle = (style?: React.CSSProperties): void => { - const ctx = useContext(HeaderSlotContext); - const stable = JSON.stringify(style ?? null); - useLayoutEffect(() => { - if (!ctx) return; - return ctx.setContentStyle(style); - }, [stable, ctx?.setContentStyle]); -}; - export const PageContainerInner: React.FC< PageContainerProps & { leftContent?: React.ReactNode; @@ -97,13 +69,33 @@ export const PageContainerInner: React.FC< [] ); + const slotOwnersRef = useRef<{ left: number; right: number }>({ + left: 0, + right: 0 + }); + + const registerSlot = useCallback((slot: 'left' | 'right') => { + const owners = slotOwnersRef.current; + owners[slot] += 1; + if (process.env.NODE_ENV !== 'production' && owners[slot] > 1) { + const name = slot === 'left' ? 'HeaderLeft' : 'HeaderRight'; + console.warn( + `[PageContainerInner] ${owners[slot]} <${name}> are mounted at once; their content stacks in the same header slot. Only one page/component should own each slot at a time.` + ); + } + return () => { + owners[slot] -= 1; + }; + }, []); + const slotValue = useMemo( () => ({ leftEl, rightEl, - setContentStyle + setContentStyle, + registerSlot }), - [leftEl, rightEl, setContentStyle] + [leftEl, rightEl, setContentStyle, registerSlot] ); return ( diff --git a/src/pages/cluster-management/cluster-detail.tsx b/src/pages/cluster-management/cluster-detail.tsx index 657d71f3..a78322d7 100644 --- a/src/pages/cluster-management/cluster-detail.tsx +++ b/src/pages/cluster-management/cluster-detail.tsx @@ -1,15 +1,15 @@ -import { clusterDetailAtom } from '@/atoms/clusters'; 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 { BaseSelect, IconFont } from '@gpustack/core-ui'; import { useIntl, useNavigate, useSearchParams } from '@umijs/max'; import { Tabs, type TabsProps } from 'antd'; -import { useAtomValue } from 'jotai'; +import { useEffect } from 'react'; import { HeaderLeft } from '../_components/page-box'; import PageBreadcrumb from '../_components/page-breadcrumb'; import ClusterBasic from './components/detail/cluster-basic'; import ClusterSystemLoad from './components/detail/cluster-system-load'; +import { useQueryClusterList } from './services/use-query-cluster-list'; const ClusterDetailModal = () => { const navigate = useNavigate(); @@ -17,7 +17,18 @@ const ClusterDetailModal = () => { const [searchParams] = useSearchParams(); const id = searchParams.get('id'); const clusterName = searchParams.get('name'); - const clusterDetailData = useAtomValue(clusterDetailAtom); + const { clusterList, fetchClusterList, cancelRequest } = + useQueryClusterList(); + + // Switch to another cluster's detail from the breadcrumb. Navigating with a + // new ``id`` re-renders this page in place; the detail children refetch on + // their ``clusterId`` prop change, so the whole view updates. + const handleOnChange = (value: number, option: any) => { + navigate( + `/resources/clusters/detail?id=${option.value}&name=${option.label}&page=clusters`, + { replace: true } + ); + }; const breadcrumbItems = [ { @@ -25,10 +36,27 @@ const ClusterDetailModal = () => { onClick: () => navigate(-1) }, { - title: clusterName + title: ( + + ) } ]; + useEffect(() => { + fetchClusterList({ page: -1 }); + return () => { + cancelRequest(); + }; + }, []); + // 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 @@ -62,14 +90,20 @@ const ClusterDetailModal = () => { label: intl.formatMessage({ id: 'resources.nodes' }), icon: , children: ( - + ) }, { key: 'gpus', label: intl.formatMessage({ id: 'menu.resources.gpus' }), icon: , - children: + children: ( + + ) }, ...extraTabs ]}