fix: collapse GPU Service menu in Personal Org; add K8s-direct CTA

Two follow-ups to the GPU Service gating:

- In Personal-Org view the access extension was stripping
  canSeeAdmin/canSeeOrgAdmin but leaving canSeeGpuService at its
  admin-shortcut value, so platform admins switched into Personal
  still saw the menu even though Personal scope can't host a K8s
  cluster. Mirror the probe result through sessionStorage so the
  extension can fall back to the strict cluster-availability signal
  in that branch.

- The empty-state CTA now reads 'Add a Kubernetes Cluster' and, on
  click, opens the cluster-create flow with Kubernetes preselected
  via clusterSession.providerHint. ClusterCreate consumes the hint
  on mount: it seeds extraData.provider and starts at the configure
  step instead of the provider catalog, so the user lands one click
  closer to the form they actually need.
This commit is contained in:
gitlawr
2026-05-27 13:41:32 +08:00
committed by jialin
parent 6b47bacfac
commit 96e6ddfe8a
11 changed files with 79 additions and 10 deletions
@@ -55,10 +55,16 @@ const MainWrapper = styled.div`
const ClusterCreate: React.FC<{
action: PageActionType;
// Preselect a provider and skip the provider-catalog step. Set by
// empty-state CTAs that already know which kind of cluster the user
// is heading for (e.g. GPU Service's "Add a Kubernetes Cluster").
providerHint?: string;
setCurrentTitle?: (title: string) => void;
onClose?: () => void;
}> = ({ onClose, action, setCurrentTitle }) => {
const startStep = 0;
}> = ({ onClose, action, providerHint, setCurrentTitle }) => {
// When the caller already picked a provider for us, start one step
// in — provider catalog is step 0; configure is step 1.
const startStep = providerHint ? 1 : 0;
const stepList = useStepList();
const [systemConfigState] = useAtom(systemConfigAtom);
const intl = useIntl();
@@ -79,7 +85,7 @@ const ClusterCreate: React.FC<{
cluster_id: 0
});
const [extraData, setExtraData] = useState<ClusterFormData>({
provider: ProviderValueMap.Docker
provider: (providerHint as ProviderType) ?? ProviderValueMap.Docker
} as ClusterFormData);
const [formValues, setFormValues] = useState<Record<string, any>>({});
const [submitLoading, setSubmitLoading] = useState<boolean>(false);
@@ -6,13 +6,18 @@ import ClusterCreate from './cluster-create';
interface ClusterModalProps {
open: boolean;
title: string;
// When set, ClusterCreate preselects this provider and skips the
// provider-catalog step. Used by feature pages (e.g. GPU Service)
// whose empty state already implies which kind of cluster is needed.
providerHint?: string;
onClose: () => void;
}
const ClusterModal: React.FC<ClusterModalProps> = ({
open,
onClose,
title
title,
providerHint
}) => {
const [currentTitle, setCurrentTitle] = React.useState<string>(title);
const handleCancel = () => {
@@ -41,6 +46,7 @@ const ClusterModal: React.FC<ClusterModalProps> = ({
<ClusterCreate
onClose={handleCancel}
action={PageAction.CREATE}
providerHint={providerHint}
setCurrentTitle={setCurrentTitle}
></ClusterCreate>
</GSDrawer>
+16 -1
View File
@@ -310,14 +310,28 @@ const Clusters: React.FC = () => {
}
}, [clusterSession, dataSource.loadend, dataSource.dataList]);
// Provider hint follows the auto-open from one render to the next:
// the session atom is cleared right after we open the modal, but
// ClusterCreate mounts a tick later and needs the value to skip the
// provider-catalog step. Cache it locally and clear on close.
const [pendingProviderHint, setPendingProviderHint] = useState<
string | undefined
>(undefined);
useEffect(() => {
if (clusterSession?.firstAddCluster && dataSource.loadend) {
setPendingProviderHint(clusterSession.providerHint);
openClusterModal();
// reset session
setClusterSession(null);
}
}, [clusterSession, dataSource.loadend]);
const handleClusterModalClose = () => {
setPendingProviderHint(undefined);
closeClusterModal();
};
const renderChildren = (
list: any,
options: { parent?: any; [key: string]: any }
@@ -443,7 +457,8 @@ const Clusters: React.FC = () => {
id: 'menu.clusterManagement.clusterCreate'
})}
open={clusterModalStatus.open}
onClose={closeClusterModal}
providerHint={pendingProviderHint}
onClose={handleClusterModalClose}
></ClusterModal>
{AddWorkerModal}
</>
+18 -4
View File
@@ -1,3 +1,4 @@
import { clusterSessionAtom } from '@/atoms/clusters';
import { PageAction } from '@/config';
import { PaginationKey, TABLE_SORT_DIRECTIONS } from '@/config/settings';
import useTableFetch from '@/hooks/use-table-fetch';
@@ -7,6 +8,7 @@ import { DeleteModal, FilterBar, IconFont, NoResult } from '@gpustack/core-ui';
import { useAccess, useIntl, useNavigate } from '@umijs/max';
import { useMemoizedFn } from 'ahooks';
import { ConfigProvider, message, Modal, Table } from 'antd';
import { useSetAtom } from 'jotai';
import _ from 'lodash';
import { useEffect, useMemo } from 'react';
import PageBox from '../../_components/page-box';
@@ -30,8 +32,21 @@ const GPUService: React.FC = () => {
const intl = useIntl();
const navigate = useNavigate();
const access = useAccess();
const setClusterSession = useSetAtom(clusterSessionAtom);
const [, modalContextHolder] = Modal.useModal();
// Empty-state CTA: send the user to the cluster-create flow with
// Kubernetes preselected so the GPU Service feature is reachable
// in one step instead of having to pick the provider themselves.
const handleAddK8sCluster = () => {
setClusterSession({
firstAddWorker: false,
firstAddCluster: true,
providerHint: ProviderValueMap.Kubernetes
});
navigate('/cluster-management/clusters/list');
};
const {
dataSource,
rowSelection,
@@ -86,8 +101,7 @@ const GPUService: React.FC = () => {
// can't host the CRDs. Filter so the page reflects scheduling
// reality even when the caller owns non-K8s clusters.
const k8sClusterList = useMemo(
() =>
clusterList.filter((c) => c.provider === ProviderValueMap.Kubernetes),
() => clusterList.filter((c) => c.provider === ProviderValueMap.Kubernetes),
[clusterList]
);
const hasK8sCluster = k8sClusterList.length > 0;
@@ -164,9 +178,9 @@ const GPUService: React.FC = () => {
{...(access.canSeeOrgAdmin
? {
buttonText: intl.formatMessage({
id: 'noresult.resources.gotocluster'
id: 'noresult.resources.addk8scluster'
}),
onClick: () => navigate('/cluster-management/clusters/list')
onClick: handleAddK8sCluster
}
: {})}
/>