From 6b47bacfac1e8dd98e1451123ae398fc5664cddd Mon Sep 17 00:00:00 2001 From: gitlawr Date: Wed, 27 May 2026 12:22:31 +0800 Subject: [PATCH] feat: gate GPU Service on Kubernetes cluster availability MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GPU Service today only schedules on Kubernetes clusters; Docker / cloud clusters can't host the CRDs. Without any awareness of that, Org members whose Org has no K8s cluster (and no cluster_access grant on one) saw a menu they couldn't use and a form that bottomed out with backend errors. Two changes lock the UX down: - Boot probes the caller's cluster list once and stashes hasKubernetesCluster in initialState. A new canSeeGpuService predicate gates the menu — admins and Org owners always see it (they can add the cluster); everyone else only sees it when a reachable K8s cluster actually exists. - The instances page filters its own cluster list to Kubernetes before deciding what to show. With nothing reachable we render the Deployments-style 'No clusters available. Add a Kubernetes cluster to get started.' empty state and hide the create-instance CTA; admins and Org owners additionally get the 'Add cluster' button that jumps to cluster management. --- config/routes.ts | 1 + src/access.extensions.ts | 1 + src/access.ts | 17 +++++++- src/app.tsx | 27 +++++++++++- src/config/global.d.ts | 6 +++ src/locales/en-US/no-result.ts | 2 + src/locales/ja-JP/no-result.ts | 2 + src/locales/ru-RU/no-result.ts | 5 ++- src/locales/tr-TR/no-result.ts | 2 + src/locales/zh-CN/no-result.ts | 2 + src/pages/gpu-service/instances/index.tsx | 53 +++++++++++++++++++++-- 11 files changed, 109 insertions(+), 9 deletions(-) diff --git a/config/routes.ts b/config/routes.ts index 515812f3..04041c38 100644 --- a/config/routes.ts +++ b/config/routes.ts @@ -186,6 +186,7 @@ const baseRoutes = [ name: 'gpuService', path: '/gpu-service', key: 'gpuService', + access: 'canSeeGpuService', routes: [ { path: '/gpu-service', diff --git a/src/access.extensions.ts b/src/access.extensions.ts index d05da612..5f69d543 100644 --- a/src/access.extensions.ts +++ b/src/access.extensions.ts @@ -4,6 +4,7 @@ export type AccessPredicates = { canSeeAdmin: boolean; canSeeOrgAdmin: boolean; + canSeeGpuService: boolean; canManageCurrentOrg: boolean; canSeeUser: boolean; canDelete: boolean; diff --git a/src/access.ts b/src/access.ts index 1678b5a9..0c411a49 100644 --- a/src/access.ts +++ b/src/access.ts @@ -1,6 +1,9 @@ import { applyAccessExtensions } from './access.extensions'; -export default (initialState: { currentUser?: Global.UserInfo }) => { +export default (initialState: { + currentUser?: Global.UserInfo; + hasKubernetesCluster?: boolean; +}) => { const isPlatformAdmin = !!( initialState && initialState.currentUser && @@ -11,6 +14,12 @@ export default (initialState: { currentUser?: Global.UserInfo }) => { initialState.currentUser && !initialState.currentUser.is_admin ); + // GPU Service is Kubernetes-only. We only gate visibility down when + // the probe in `getInitialState` came back with a definitive answer; + // `undefined` (probe failed / not yet ready) collapses to the + // role-based default so a transient network blip can't lock anyone + // out of the menu. + const hasKubernetesCluster = initialState?.hasKubernetesCluster; // Predicate roles, top-down by strictness: // * `canSeeAdmin` — strictly platform admin (`users.is_admin`). @@ -18,6 +27,11 @@ export default (initialState: { currentUser?: Global.UserInfo }) => { // * `canSeeOrgAdmin` — admin-style menus that work cross-org // (Dashboard, Resources, Models, Cluster Management). Defaults // to platform admin; extensions widen to include org admins. + // * `canSeeGpuService` — GPU Service menu. Anyone allowed to + // manage clusters (admins, Org owners) sees it; non-admins fall + // through to "show only if a Kubernetes cluster is actually + // reachable" so Org members without scheduling access don't see + // a dead-end menu item. // * `canManageCurrentOrg` — pages that only make sense inside a // specific org context (member / group management). Defaults to // `false`; extensions widen when both an org is selected AND @@ -27,6 +41,7 @@ export default (initialState: { currentUser?: Global.UserInfo }) => { return applyAccessExtensions({ canSeeAdmin: isPlatformAdmin, canSeeOrgAdmin: isPlatformAdmin, + canSeeGpuService: isPlatformAdmin || hasKubernetesCluster !== false, canManageCurrentOrg: false, canSeeUser, canDelete: true, diff --git a/src/app.tsx b/src/app.tsx index 01034723..99df2b26 100644 --- a/src/app.tsx +++ b/src/app.tsx @@ -3,6 +3,8 @@ import { GPUStackVersionAtom, UpdateCheckAtom } from '@/atoms/user'; import { setAtomStorage } from '@/atoms/utils'; import { DEFAULT_ENTER_PAGE, GPUSTACK_API_BASE_URL } from '@/config/settings'; import { COLOR_PRIMARY } from '@/config/theme/constants'; +import { queryClusterList } from '@/pages/cluster-management/apis'; +import { ProviderValueMap } from '@/pages/cluster-management/config'; import { enterprisePluginReady } from '@/plugins/enterprise-ready'; import { GPUStackPluginManager } from '@/plugins/manager'; import { requestConfig } from '@/request-config'; @@ -34,11 +36,28 @@ const checkDefaultPage = async (userInfo: any) => { } }; +// Probes the caller's cluster list once so access predicates can gate +// 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". +const probeHasKubernetesCluster = async (): Promise => { + try { + const res = await queryClusterList({ page: -1 }); + return (res?.items ?? []).some( + (c) => c?.provider === ProviderValueMap.Kubernetes + ); + } catch (error) { + console.error('probeHasKubernetesCluster error', error); + return undefined; + } +}; + // runtime configuration export async function getInitialState(): Promise<{ fetchUserInfo: () => Promise; currentUser?: Global.UserInfo; pluginData?: Record; + hasKubernetesCluster?: boolean; }> { const { location } = history; @@ -122,12 +141,16 @@ export async function getInitialState(): Promise<{ getAppVersionInfo(); if (![DEFAULT_ENTER_PAGE.login].includes(location.pathname)) { - const userInfo = await fetchUserInfo(); + const [userInfo, hasKubernetesCluster] = await Promise.all([ + fetchUserInfo(), + probeHasKubernetesCluster() + ]); checkDefaultPage(userInfo); return { fetchUserInfo, currentUser: userInfo, - pluginData + pluginData, + hasKubernetesCluster }; } return { diff --git a/src/config/global.d.ts b/src/config/global.d.ts index 162e8b63..feff9ede 100644 --- a/src/config/global.d.ts +++ b/src/config/global.d.ts @@ -92,6 +92,12 @@ declare namespace Global { interface InitialStateType { fetchUserInfo: () => Promise; currentUser?: UserInfo; + // Captured at app boot so access predicates can gate GPU Service — + // the feature is Kubernetes-only, and Org members without a K8s + // cluster they can schedule on shouldn't see the menu. Refreshed + // by full page reload (e.g. OrgSwitcher) which re-runs + // getInitialState. + hasKubernetesCluster?: boolean; } type SearchParams = Pagination & { search?: string; [key: string]: any }; diff --git a/src/locales/en-US/no-result.ts b/src/locales/en-US/no-result.ts index 0f542225..a29f6b34 100644 --- a/src/locales/en-US/no-result.ts +++ b/src/locales/en-US/no-result.ts @@ -36,6 +36,8 @@ export default { 'noresult.catalog.nofound': 'No matching models found.', 'noresult.resources.cluster': 'No clusters available. Add a cluster to get started.', + 'noresult.resources.k8sCluster': + 'No clusters available. Add a Kubernetes cluster to get started.', 'noresult.resources.worker': 'No workers available. Add a worker to get started.', 'noresult.resources.gotocluster': 'Create Your First Cluster', diff --git a/src/locales/ja-JP/no-result.ts b/src/locales/ja-JP/no-result.ts index 0638d312..51f0a484 100644 --- a/src/locales/ja-JP/no-result.ts +++ b/src/locales/ja-JP/no-result.ts @@ -36,6 +36,8 @@ export default { 'noresult.catalog.nofound': 'No matching models found.', 'noresult.resources.cluster': 'No clusters available. Add a cluster to get started.', + 'noresult.resources.k8sCluster': + 'No clusters available. Add a Kubernetes cluster to get started.', 'noresult.resources.worker': 'No workers available. Add a worker to get started.', 'noresult.resources.gotocluster': 'Create Your First Cluster', diff --git a/src/locales/ru-RU/no-result.ts b/src/locales/ru-RU/no-result.ts index d438e3a4..4866b5c2 100644 --- a/src/locales/ru-RU/no-result.ts +++ b/src/locales/ru-RU/no-result.ts @@ -37,6 +37,8 @@ export default { 'noresult.catalog.nofound': 'Подходящие модели не найдены.', 'noresult.resources.cluster': 'No clusters available. Add a cluster to get started.', + 'noresult.resources.k8sCluster': + 'No clusters available. Add a Kubernetes cluster to get started.', 'noresult.resources.worker': 'No workers available. Add a worker to get started.', 'noresult.resources.gotocluster': 'Create Your First Cluster', @@ -67,8 +69,7 @@ export default { 'noresult.gpuservice.storage.subTitle': 'Хранилища ещё не добавлены.', 'noresult.gpuservice.storage.nofound': 'Подходящие хранилища не найдены.', 'noresult.gpuservice.storageType.title': 'Нет типов хранилищ', - 'noresult.gpuservice.storageType.subTitle': - 'Типы хранилищ ещё не добавлены.', + 'noresult.gpuservice.storageType.subTitle': 'Типы хранилищ ещё не добавлены.', 'noresult.gpuservice.storageType.nofound': 'Подходящие типы хранилищ не найдены.', 'noresult.gpuservice.sshkey.title': 'Нет открытых ключей SSH', diff --git a/src/locales/tr-TR/no-result.ts b/src/locales/tr-TR/no-result.ts index a5e78dfe..3cb3e70c 100644 --- a/src/locales/tr-TR/no-result.ts +++ b/src/locales/tr-TR/no-result.ts @@ -38,6 +38,8 @@ export default { 'noresult.catalog.nofound': 'Eşleşen model bulunamadı.', 'noresult.resources.cluster': 'Kullanılabilir küme yok. Başlamak için bir küme ekleyin.', + 'noresult.resources.k8sCluster': + 'Kullanılabilir küme yok. Başlamak için bir Kubernetes kümesi ekleyin.', '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', diff --git a/src/locales/zh-CN/no-result.ts b/src/locales/zh-CN/no-result.ts index 803977e4..bc225576 100644 --- a/src/locales/zh-CN/no-result.ts +++ b/src/locales/zh-CN/no-result.ts @@ -35,6 +35,8 @@ export default { 'noresult.catalog.subTitle': '尚未配置任何模型。', 'noresult.catalog.nofound': '未找到匹配的模型', 'noresult.resources.cluster': '暂无可用集群,请添加集群以开始使用。', + 'noresult.resources.k8sCluster': + '暂无可用集群,请添加 Kubernetes 集群以开始使用。', 'noresult.resources.worker': '暂无可用节点,请添加节点以开始使用。', 'noresult.resources.gotocluster': '创建您的第一个集群', 'noresult.resources.gotoworker': '添加节点', diff --git a/src/pages/gpu-service/instances/index.tsx b/src/pages/gpu-service/instances/index.tsx index 1d143c0c..e7ae3b24 100644 --- a/src/pages/gpu-service/instances/index.tsx +++ b/src/pages/gpu-service/instances/index.tsx @@ -1,13 +1,14 @@ import { PageAction } from '@/config'; import { PaginationKey, TABLE_SORT_DIRECTIONS } from '@/config/settings'; import useTableFetch from '@/hooks/use-table-fetch'; +import { ProviderValueMap } from '@/pages/cluster-management/config'; import { useQueryClusterList } from '@/pages/cluster-management/services/use-query-cluster-list'; import { DeleteModal, FilterBar, IconFont, NoResult } from '@gpustack/core-ui'; -import { useIntl } from '@umijs/max'; +import { useAccess, useIntl, useNavigate } from '@umijs/max'; import { useMemoizedFn } from 'ahooks'; import { ConfigProvider, message, Modal, Table } from 'antd'; import _ from 'lodash'; -import { useEffect } from 'react'; +import { useEffect, useMemo } from 'react'; import PageBox from '../../_components/page-box'; import { deleteGPUServiceInstance, @@ -27,6 +28,8 @@ import useUpdateInstance from './services/use-update-instance'; const GPUService: React.FC = () => { const intl = useIntl(); + const navigate = useNavigate(); + const access = useAccess(); const [, modalContextHolder] = Modal.useModal(); const { @@ -71,13 +74,24 @@ const GPUService: React.FC = () => { const { fetchClusterList, cancelRequest: cancelClusterRequest, - clusterList + clusterList, + loading: clusterLoading } = useQueryClusterList(); useEffect(() => { fetchClusterList({ page: -1 }); }, []); + // GPU Service today is Kubernetes-only — Docker / cloud clusters + // 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] + ); + const hasK8sCluster = k8sClusterList.length > 0; + const handleModalOk = async (data: FormData) => { try { if (openInstanceModalStatus.realAction === PageAction.CREATE) { @@ -129,6 +143,35 @@ const GPUService: React.FC = () => { const renderEmpty = (type?: string) => { if (type !== 'Table') return; + // No K8s cluster the caller can schedule on — replace the "no + // instances" empty state with a cluster-bootstrap prompt. The + // "Add cluster" CTA is reserved for callers who can actually + // create one (platform admin / Org owner); members see the + // explanation without a misleading button. + if (!clusterLoading && !hasK8sCluster) { + return ( + } + title={intl.formatMessage({ + id: 'noresult.gpuservice.instance.title' + })} + subTitle={intl.formatMessage({ + id: 'noresult.resources.k8sCluster' + })} + {...(access.canSeeOrgAdmin + ? { + buttonText: intl.formatMessage({ + id: 'noresult.resources.gotocluster' + }), + onClick: () => navigate('/cluster-management/clusters/list') + } + : {})} + /> + ); + } return ( { handleInputChange={handleNameChange} rowSelection={rowSelection} widths={{ input: 300 }} - handleClickPrimary={openCreateInstanceModal} + handleClickPrimary={ + hasK8sCluster ? openCreateInstanceModal : undefined + } buttonText={intl.formatMessage({ id: 'gpuservice.instance.add' })} handleDeleteByBatch={handleDeleteBatch} />