From 96e6ddfe8a9b5aef64c77d5ab087d03682558f21 Mon Sep 17 00:00:00 2001 From: gitlawr Date: Wed, 27 May 2026 12:38:16 +0800 Subject: [PATCH] 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. --- src/app.tsx | 19 +++++++++++++++- src/atoms/clusters.ts | 6 +++++ src/locales/en-US/no-result.ts | 1 + src/locales/ja-JP/no-result.ts | 1 + src/locales/ru-RU/no-result.ts | 1 + src/locales/tr-TR/no-result.ts | 1 + src/locales/zh-CN/no-result.ts | 1 + .../cluster-management/cluster-create.tsx | 12 +++++++--- .../cluster-management/cluster-modal.tsx | 8 ++++++- src/pages/cluster-management/clusters.tsx | 17 +++++++++++++- src/pages/gpu-service/instances/index.tsx | 22 +++++++++++++++---- 11 files changed, 79 insertions(+), 10 deletions(-) diff --git a/src/app.tsx b/src/app.tsx index 99df2b26..3cfe231b 100644 --- a/src/app.tsx +++ b/src/app.tsx @@ -40,14 +40,31 @@ const checkDefaultPage = async (userInfo: any) => { // GPU Service (Kubernetes-only). Cheap (one list request) and never // blocks login — any failure just falls back to `undefined`, which // the predicate treats as "unknown / don't restrict beyond role". +// The result is also mirrored into sessionStorage so access extensions +// that run without the initialState argument can read it (e.g. to +// override the admin shortcut in scopes where the menu shouldn't +// show even for admins). +const HAS_K8S_CLUSTER_KEY = 'hasKubernetesCluster'; const probeHasKubernetesCluster = async (): Promise => { try { const res = await queryClusterList({ page: -1 }); - return (res?.items ?? []).some( + const value = (res?.items ?? []).some( (c) => c?.provider === ProviderValueMap.Kubernetes ); + try { + window.sessionStorage.setItem(HAS_K8S_CLUSTER_KEY, JSON.stringify(value)); + } catch { + // sessionStorage may be unavailable (Safari private mode); the + // access predicate already handles a missing value as "unknown". + } + return value; } catch (error) { console.error('probeHasKubernetesCluster error', error); + try { + window.sessionStorage.removeItem(HAS_K8S_CLUSTER_KEY); + } catch { + // ignore + } return undefined; } }; diff --git a/src/atoms/clusters.ts b/src/atoms/clusters.ts index 3da49e0d..9f438421 100644 --- a/src/atoms/clusters.ts +++ b/src/atoms/clusters.ts @@ -63,6 +63,12 @@ export const fromClusterCreationAtom = atom(false); export const clusterSessionAtom = atom<{ firstAddWorker: boolean; firstAddCluster: boolean; + // Provider to preselect when the create flow opens — set by the + // empty-state CTA on feature pages that need a specific provider + // (e.g. GPU Service can only schedule on Kubernetes, so its + // "Add Cluster" button skips provider catalog and lands on the + // K8s configure step). Consumed once by ClusterCreate on mount. + providerHint?: string; } | null>(null); export const clusterDetailAtom = atom(null); diff --git a/src/locales/en-US/no-result.ts b/src/locales/en-US/no-result.ts index a29f6b34..d88d7795 100644 --- a/src/locales/en-US/no-result.ts +++ b/src/locales/en-US/no-result.ts @@ -41,6 +41,7 @@ export default { 'noresult.resources.worker': 'No workers available. Add a worker to get started.', 'noresult.resources.gotocluster': 'Create Your First Cluster', + 'noresult.resources.addk8scluster': 'Add a Kubernetes Cluster', 'noresult.resources.gotoworker': 'Add Worker', 'noresult.benchmark.title': 'No Benchmarks', 'noresult.benchmark.subTitle': 'No benchmarks have been added yet.', diff --git a/src/locales/ja-JP/no-result.ts b/src/locales/ja-JP/no-result.ts index 51f0a484..f27b86d0 100644 --- a/src/locales/ja-JP/no-result.ts +++ b/src/locales/ja-JP/no-result.ts @@ -41,6 +41,7 @@ export default { 'noresult.resources.worker': 'No workers available. Add a worker to get started.', 'noresult.resources.gotocluster': 'Create Your First Cluster', + 'noresult.resources.addk8scluster': 'Add a Kubernetes Cluster', 'noresult.resources.gotoworker': 'Add Worker', 'noresult.benchmark.title': 'No Benchmarks', 'noresult.benchmark.subTitle': 'No benchmarks have been added yet.', diff --git a/src/locales/ru-RU/no-result.ts b/src/locales/ru-RU/no-result.ts index 4866b5c2..9963f828 100644 --- a/src/locales/ru-RU/no-result.ts +++ b/src/locales/ru-RU/no-result.ts @@ -42,6 +42,7 @@ export default { 'noresult.resources.worker': 'No workers available. Add a worker to get started.', 'noresult.resources.gotocluster': 'Create Your First Cluster', + 'noresult.resources.addk8scluster': 'Add a Kubernetes Cluster', 'noresult.resources.gotoworker': 'Add Worker', 'noresult.benchmark.title': 'No Benchmarks', 'noresult.benchmark.subTitle': 'No benchmarks have been added yet.', diff --git a/src/locales/tr-TR/no-result.ts b/src/locales/tr-TR/no-result.ts index 3cb3e70c..ec4f76df 100644 --- a/src/locales/tr-TR/no-result.ts +++ b/src/locales/tr-TR/no-result.ts @@ -43,6 +43,7 @@ export default { 'noresult.resources.worker': 'Kullanılabilir işçi düğüm yok. Başlamak için bir işçi düğüm ekleyin.', 'noresult.resources.gotocluster': 'İlk Kümenizi Oluşturun', + 'noresult.resources.addk8scluster': 'Kubernetes Kümesi Ekle', 'noresult.resources.gotoworker': 'İşçi Düğüm Ekle', 'noresult.benchmark.title': 'Kıyaslama Yok', 'noresult.benchmark.subTitle': 'Henüz kıyaslama eklenmedi.', diff --git a/src/locales/zh-CN/no-result.ts b/src/locales/zh-CN/no-result.ts index bc225576..856ca92d 100644 --- a/src/locales/zh-CN/no-result.ts +++ b/src/locales/zh-CN/no-result.ts @@ -39,6 +39,7 @@ export default { '暂无可用集群,请添加 Kubernetes 集群以开始使用。', 'noresult.resources.worker': '暂无可用节点,请添加节点以开始使用。', 'noresult.resources.gotocluster': '创建您的第一个集群', + 'noresult.resources.addk8scluster': '添加 Kubernetes 集群', 'noresult.resources.gotoworker': '添加节点', 'noresult.benchmark.title': '暂无基准测试', 'noresult.benchmark.subTitle': '尚未添加任何基准测试。', diff --git a/src/pages/cluster-management/cluster-create.tsx b/src/pages/cluster-management/cluster-create.tsx index 35e926ba..4dc2c6d1 100644 --- a/src/pages/cluster-management/cluster-create.tsx +++ b/src/pages/cluster-management/cluster-create.tsx @@ -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({ - provider: ProviderValueMap.Docker + provider: (providerHint as ProviderType) ?? ProviderValueMap.Docker } as ClusterFormData); const [formValues, setFormValues] = useState>({}); const [submitLoading, setSubmitLoading] = useState(false); diff --git a/src/pages/cluster-management/cluster-modal.tsx b/src/pages/cluster-management/cluster-modal.tsx index 5a7b11d2..99ac7f9f 100644 --- a/src/pages/cluster-management/cluster-modal.tsx +++ b/src/pages/cluster-management/cluster-modal.tsx @@ -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 = ({ open, onClose, - title + title, + providerHint }) => { const [currentTitle, setCurrentTitle] = React.useState(title); const handleCancel = () => { @@ -41,6 +46,7 @@ const ClusterModal: React.FC = ({ diff --git a/src/pages/cluster-management/clusters.tsx b/src/pages/cluster-management/clusters.tsx index dc5998a1..6998c280 100644 --- a/src/pages/cluster-management/clusters.tsx +++ b/src/pages/cluster-management/clusters.tsx @@ -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} > {AddWorkerModal} diff --git a/src/pages/gpu-service/instances/index.tsx b/src/pages/gpu-service/instances/index.tsx index e7ae3b24..12342a63 100644 --- a/src/pages/gpu-service/instances/index.tsx +++ b/src/pages/gpu-service/instances/index.tsx @@ -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 } : {})} />