feat(cluster-detail): add cluster switcher; consume core-ui header-slot
page-box now imports the header-slot bridge from core-ui and re-exports it, plus a dev warning when a header slot has multiple owners. cluster-detail breadcrumb becomes a BaseSelect to switch clusters, and the tab tables are keyed by id so they refetch on switch.
This commit is contained in:
@@ -4,64 +4,36 @@ import {
|
|||||||
RouteContext,
|
RouteContext,
|
||||||
type PageContainerProps
|
type PageContainerProps
|
||||||
} from '@ant-design/pro-components';
|
} 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 { Divider } from 'antd';
|
||||||
import classNames from 'classnames';
|
import classNames from 'classnames';
|
||||||
import {
|
import {
|
||||||
createContext,
|
|
||||||
useCallback,
|
useCallback,
|
||||||
useContext,
|
useContext,
|
||||||
useEffect,
|
useEffect,
|
||||||
useLayoutEffect,
|
|
||||||
useMemo,
|
useMemo,
|
||||||
useRef,
|
useRef,
|
||||||
useState
|
useState
|
||||||
} from 'react';
|
} from 'react';
|
||||||
import { createPortal } from 'react-dom';
|
|
||||||
import pageBoxCss from './styles/page-box.less';
|
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;
|
const paddingInlinePageContainerContent = 24;
|
||||||
|
|
||||||
type HeaderSlotContextValue = {
|
|
||||||
leftEl: HTMLElement | null;
|
|
||||||
rightEl: HTMLElement | null;
|
|
||||||
setContentStyle: (style: React.CSSProperties | undefined) => () => void;
|
|
||||||
};
|
|
||||||
|
|
||||||
const HeaderSlotContext = createContext<HeaderSlotContextValue | null>(null);
|
|
||||||
|
|
||||||
// Pages render <HeaderLeft> / <HeaderRight> 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<
|
export const PageContainerInner: React.FC<
|
||||||
PageContainerProps & {
|
PageContainerProps & {
|
||||||
leftContent?: React.ReactNode;
|
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<HeaderSlotContextValue>(
|
const slotValue = useMemo<HeaderSlotContextValue>(
|
||||||
() => ({
|
() => ({
|
||||||
leftEl,
|
leftEl,
|
||||||
rightEl,
|
rightEl,
|
||||||
setContentStyle
|
setContentStyle,
|
||||||
|
registerSlot
|
||||||
}),
|
}),
|
||||||
[leftEl, rightEl, setContentStyle]
|
[leftEl, rightEl, setContentStyle, registerSlot]
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -1,15 +1,15 @@
|
|||||||
import { clusterDetailAtom } from '@/atoms/clusters';
|
|
||||||
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 { getGPUStackPlugin } from '@/plugins';
|
||||||
import { IconFont } from '@gpustack/core-ui';
|
import { BaseSelect, IconFont } from '@gpustack/core-ui';
|
||||||
import { useIntl, useNavigate, useSearchParams } from '@umijs/max';
|
import { useIntl, useNavigate, useSearchParams } from '@umijs/max';
|
||||||
import { Tabs, type TabsProps } from 'antd';
|
import { Tabs, type TabsProps } from 'antd';
|
||||||
import { useAtomValue } from 'jotai';
|
import { useEffect } from 'react';
|
||||||
import { HeaderLeft } from '../_components/page-box';
|
import { HeaderLeft } from '../_components/page-box';
|
||||||
import PageBreadcrumb from '../_components/page-breadcrumb';
|
import PageBreadcrumb from '../_components/page-breadcrumb';
|
||||||
import ClusterBasic from './components/detail/cluster-basic';
|
import ClusterBasic from './components/detail/cluster-basic';
|
||||||
import ClusterSystemLoad from './components/detail/cluster-system-load';
|
import ClusterSystemLoad from './components/detail/cluster-system-load';
|
||||||
|
import { useQueryClusterList } from './services/use-query-cluster-list';
|
||||||
|
|
||||||
const ClusterDetailModal = () => {
|
const ClusterDetailModal = () => {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
@@ -17,7 +17,18 @@ const ClusterDetailModal = () => {
|
|||||||
const [searchParams] = useSearchParams();
|
const [searchParams] = useSearchParams();
|
||||||
const id = searchParams.get('id');
|
const id = searchParams.get('id');
|
||||||
const clusterName = searchParams.get('name');
|
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 = [
|
const breadcrumbItems = [
|
||||||
{
|
{
|
||||||
@@ -25,10 +36,27 @@ const ClusterDetailModal = () => {
|
|||||||
onClick: () => navigate(-1)
|
onClick: () => navigate(-1)
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: clusterName
|
title: (
|
||||||
|
<BaseSelect
|
||||||
|
size="small"
|
||||||
|
variant="borderless"
|
||||||
|
options={clusterList}
|
||||||
|
value={clusterName}
|
||||||
|
style={{ minWidth: 100 }}
|
||||||
|
popupMatchSelectWidth={false}
|
||||||
|
onChange={handleOnChange}
|
||||||
|
></BaseSelect>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
];
|
];
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchClusterList({ page: -1 });
|
||||||
|
return () => {
|
||||||
|
cancelRequest();
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
// Extension slot: a registered plugin may inject additional tab
|
// Extension slot: a registered plugin may inject additional tab
|
||||||
// items (e.g. per-cluster access / quota panels) by exporting
|
// items (e.g. per-cluster access / quota panels) by exporting
|
||||||
// ``clusterDetail.extraTabs(clusterId, intl)`` that returns an
|
// ``clusterDetail.extraTabs(clusterId, intl)`` that returns an
|
||||||
@@ -62,14 +90,20 @@ const ClusterDetailModal = () => {
|
|||||||
label: intl.formatMessage({ id: 'resources.nodes' }),
|
label: intl.formatMessage({ id: 'resources.nodes' }),
|
||||||
icon: <IconFont type="icon-resources" />,
|
icon: <IconFont type="icon-resources" />,
|
||||||
children: (
|
children: (
|
||||||
<WorkerList clusterId={Number(id)} source="clusterDetail" />
|
<WorkerList
|
||||||
|
key={id}
|
||||||
|
clusterId={Number(id)}
|
||||||
|
source="clusterDetail"
|
||||||
|
/>
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'gpus',
|
key: 'gpus',
|
||||||
label: intl.formatMessage({ id: 'menu.resources.gpus' }),
|
label: intl.formatMessage({ id: 'menu.resources.gpus' }),
|
||||||
icon: <IconFont type="icon-gpu1" />,
|
icon: <IconFont type="icon-gpu1" />,
|
||||||
children: <GPUList clusterId={Number(id)} source="clusterDetail" />
|
children: (
|
||||||
|
<GPUList key={id} clusterId={Number(id)} source="clusterDetail" />
|
||||||
|
)
|
||||||
},
|
},
|
||||||
...extraTabs
|
...extraTabs
|
||||||
]}
|
]}
|
||||||
|
|||||||
Reference in New Issue
Block a user