feat: gate GPU Service on Kubernetes cluster availability

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.
This commit is contained in:
gitlawr
2026-05-27 13:41:32 +08:00
committed by jialin
parent 099b419e34
commit 6b47bacfac
11 changed files with 109 additions and 9 deletions
+1
View File
@@ -186,6 +186,7 @@ const baseRoutes = [
name: 'gpuService', name: 'gpuService',
path: '/gpu-service', path: '/gpu-service',
key: 'gpuService', key: 'gpuService',
access: 'canSeeGpuService',
routes: [ routes: [
{ {
path: '/gpu-service', path: '/gpu-service',
+1
View File
@@ -4,6 +4,7 @@
export type AccessPredicates = { export type AccessPredicates = {
canSeeAdmin: boolean; canSeeAdmin: boolean;
canSeeOrgAdmin: boolean; canSeeOrgAdmin: boolean;
canSeeGpuService: boolean;
canManageCurrentOrg: boolean; canManageCurrentOrg: boolean;
canSeeUser: boolean; canSeeUser: boolean;
canDelete: boolean; canDelete: boolean;
+16 -1
View File
@@ -1,6 +1,9 @@
import { applyAccessExtensions } from './access.extensions'; import { applyAccessExtensions } from './access.extensions';
export default (initialState: { currentUser?: Global.UserInfo }) => { export default (initialState: {
currentUser?: Global.UserInfo;
hasKubernetesCluster?: boolean;
}) => {
const isPlatformAdmin = !!( const isPlatformAdmin = !!(
initialState && initialState &&
initialState.currentUser && initialState.currentUser &&
@@ -11,6 +14,12 @@ export default (initialState: { currentUser?: Global.UserInfo }) => {
initialState.currentUser && initialState.currentUser &&
!initialState.currentUser.is_admin !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: // Predicate roles, top-down by strictness:
// * `canSeeAdmin` — strictly platform admin (`users.is_admin`). // * `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 // * `canSeeOrgAdmin` — admin-style menus that work cross-org
// (Dashboard, Resources, Models, Cluster Management). Defaults // (Dashboard, Resources, Models, Cluster Management). Defaults
// to platform admin; extensions widen to include org admins. // 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 // * `canManageCurrentOrg` — pages that only make sense inside a
// specific org context (member / group management). Defaults to // specific org context (member / group management). Defaults to
// `false`; extensions widen when both an org is selected AND // `false`; extensions widen when both an org is selected AND
@@ -27,6 +41,7 @@ export default (initialState: { currentUser?: Global.UserInfo }) => {
return applyAccessExtensions({ return applyAccessExtensions({
canSeeAdmin: isPlatformAdmin, canSeeAdmin: isPlatformAdmin,
canSeeOrgAdmin: isPlatformAdmin, canSeeOrgAdmin: isPlatformAdmin,
canSeeGpuService: isPlatformAdmin || hasKubernetesCluster !== false,
canManageCurrentOrg: false, canManageCurrentOrg: false,
canSeeUser, canSeeUser,
canDelete: true, canDelete: true,
+25 -2
View File
@@ -3,6 +3,8 @@ import { GPUStackVersionAtom, UpdateCheckAtom } from '@/atoms/user';
import { setAtomStorage } from '@/atoms/utils'; import { setAtomStorage } from '@/atoms/utils';
import { DEFAULT_ENTER_PAGE, GPUSTACK_API_BASE_URL } from '@/config/settings'; import { DEFAULT_ENTER_PAGE, GPUSTACK_API_BASE_URL } from '@/config/settings';
import { COLOR_PRIMARY } from '@/config/theme/constants'; 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 { enterprisePluginReady } from '@/plugins/enterprise-ready';
import { GPUStackPluginManager } from '@/plugins/manager'; import { GPUStackPluginManager } from '@/plugins/manager';
import { requestConfig } from '@/request-config'; 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<boolean | undefined> => {
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 // runtime configuration
export async function getInitialState(): Promise<{ export async function getInitialState(): Promise<{
fetchUserInfo: () => Promise<Global.UserInfo>; fetchUserInfo: () => Promise<Global.UserInfo>;
currentUser?: Global.UserInfo; currentUser?: Global.UserInfo;
pluginData?: Record<string, any>; pluginData?: Record<string, any>;
hasKubernetesCluster?: boolean;
}> { }> {
const { location } = history; const { location } = history;
@@ -122,12 +141,16 @@ export async function getInitialState(): Promise<{
getAppVersionInfo(); getAppVersionInfo();
if (![DEFAULT_ENTER_PAGE.login].includes(location.pathname)) { if (![DEFAULT_ENTER_PAGE.login].includes(location.pathname)) {
const userInfo = await fetchUserInfo(); const [userInfo, hasKubernetesCluster] = await Promise.all([
fetchUserInfo(),
probeHasKubernetesCluster()
]);
checkDefaultPage(userInfo); checkDefaultPage(userInfo);
return { return {
fetchUserInfo, fetchUserInfo,
currentUser: userInfo, currentUser: userInfo,
pluginData pluginData,
hasKubernetesCluster
}; };
} }
return { return {
+6
View File
@@ -92,6 +92,12 @@ declare namespace Global {
interface InitialStateType { interface InitialStateType {
fetchUserInfo: () => Promise<UserInfo>; fetchUserInfo: () => Promise<UserInfo>;
currentUser?: UserInfo; 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 }; type SearchParams = Pagination & { search?: string; [key: string]: any };
+2
View File
@@ -36,6 +36,8 @@ export default {
'noresult.catalog.nofound': 'No matching models found.', 'noresult.catalog.nofound': 'No matching models found.',
'noresult.resources.cluster': 'noresult.resources.cluster':
'No clusters available. Add a cluster to get started.', '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': 'noresult.resources.worker':
'No workers available. Add a worker to get started.', 'No workers available. Add a worker to get started.',
'noresult.resources.gotocluster': 'Create Your First Cluster', 'noresult.resources.gotocluster': 'Create Your First Cluster',
+2
View File
@@ -36,6 +36,8 @@ export default {
'noresult.catalog.nofound': 'No matching models found.', 'noresult.catalog.nofound': 'No matching models found.',
'noresult.resources.cluster': 'noresult.resources.cluster':
'No clusters available. Add a cluster to get started.', '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': 'noresult.resources.worker':
'No workers available. Add a worker to get started.', 'No workers available. Add a worker to get started.',
'noresult.resources.gotocluster': 'Create Your First Cluster', 'noresult.resources.gotocluster': 'Create Your First Cluster',
+3 -2
View File
@@ -37,6 +37,8 @@ export default {
'noresult.catalog.nofound': 'Подходящие модели не найдены.', 'noresult.catalog.nofound': 'Подходящие модели не найдены.',
'noresult.resources.cluster': 'noresult.resources.cluster':
'No clusters available. Add a cluster to get started.', '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': 'noresult.resources.worker':
'No workers available. Add a worker to get started.', 'No workers available. Add a worker to get started.',
'noresult.resources.gotocluster': 'Create Your First Cluster', 'noresult.resources.gotocluster': 'Create Your First Cluster',
@@ -67,8 +69,7 @@ export default {
'noresult.gpuservice.storage.subTitle': 'Хранилища ещё не добавлены.', 'noresult.gpuservice.storage.subTitle': 'Хранилища ещё не добавлены.',
'noresult.gpuservice.storage.nofound': 'Подходящие хранилища не найдены.', 'noresult.gpuservice.storage.nofound': 'Подходящие хранилища не найдены.',
'noresult.gpuservice.storageType.title': 'Нет типов хранилищ', 'noresult.gpuservice.storageType.title': 'Нет типов хранилищ',
'noresult.gpuservice.storageType.subTitle': 'noresult.gpuservice.storageType.subTitle': 'Типы хранилищ ещё не добавлены.',
'Типы хранилищ ещё не добавлены.',
'noresult.gpuservice.storageType.nofound': 'noresult.gpuservice.storageType.nofound':
'Подходящие типы хранилищ не найдены.', 'Подходящие типы хранилищ не найдены.',
'noresult.gpuservice.sshkey.title': 'Нет открытых ключей SSH', 'noresult.gpuservice.sshkey.title': 'Нет открытых ключей SSH',
+2
View File
@@ -38,6 +38,8 @@ export default {
'noresult.catalog.nofound': 'Eşleşen model bulunamadı.', 'noresult.catalog.nofound': 'Eşleşen model bulunamadı.',
'noresult.resources.cluster': 'noresult.resources.cluster':
'Kullanılabilir küme yok. Başlamak için bir küme ekleyin.', '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': 'noresult.resources.worker':
'Kullanılabilir işçi düğüm yok. Başlamak için bir işçi düğüm ekleyin.', '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.gotocluster': 'İlk Kümenizi Oluşturun',
+2
View File
@@ -35,6 +35,8 @@ export default {
'noresult.catalog.subTitle': '尚未配置任何模型。', 'noresult.catalog.subTitle': '尚未配置任何模型。',
'noresult.catalog.nofound': '未找到匹配的模型', 'noresult.catalog.nofound': '未找到匹配的模型',
'noresult.resources.cluster': '暂无可用集群,请添加集群以开始使用。', 'noresult.resources.cluster': '暂无可用集群,请添加集群以开始使用。',
'noresult.resources.k8sCluster':
'暂无可用集群,请添加 Kubernetes 集群以开始使用。',
'noresult.resources.worker': '暂无可用节点,请添加节点以开始使用。', 'noresult.resources.worker': '暂无可用节点,请添加节点以开始使用。',
'noresult.resources.gotocluster': '创建您的第一个集群', 'noresult.resources.gotocluster': '创建您的第一个集群',
'noresult.resources.gotoworker': '添加节点', 'noresult.resources.gotoworker': '添加节点',
+49 -4
View File
@@ -1,13 +1,14 @@
import { PageAction } from '@/config'; import { PageAction } from '@/config';
import { PaginationKey, TABLE_SORT_DIRECTIONS } from '@/config/settings'; import { PaginationKey, TABLE_SORT_DIRECTIONS } from '@/config/settings';
import useTableFetch from '@/hooks/use-table-fetch'; 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 { useQueryClusterList } from '@/pages/cluster-management/services/use-query-cluster-list';
import { DeleteModal, FilterBar, IconFont, NoResult } from '@gpustack/core-ui'; 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 { useMemoizedFn } from 'ahooks';
import { ConfigProvider, message, Modal, Table } from 'antd'; import { ConfigProvider, message, Modal, Table } from 'antd';
import _ from 'lodash'; import _ from 'lodash';
import { useEffect } from 'react'; import { useEffect, useMemo } from 'react';
import PageBox from '../../_components/page-box'; import PageBox from '../../_components/page-box';
import { import {
deleteGPUServiceInstance, deleteGPUServiceInstance,
@@ -27,6 +28,8 @@ import useUpdateInstance from './services/use-update-instance';
const GPUService: React.FC = () => { const GPUService: React.FC = () => {
const intl = useIntl(); const intl = useIntl();
const navigate = useNavigate();
const access = useAccess();
const [, modalContextHolder] = Modal.useModal(); const [, modalContextHolder] = Modal.useModal();
const { const {
@@ -71,13 +74,24 @@ const GPUService: React.FC = () => {
const { const {
fetchClusterList, fetchClusterList,
cancelRequest: cancelClusterRequest, cancelRequest: cancelClusterRequest,
clusterList clusterList,
loading: clusterLoading
} = useQueryClusterList(); } = useQueryClusterList();
useEffect(() => { useEffect(() => {
fetchClusterList({ page: -1 }); 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) => { const handleModalOk = async (data: FormData) => {
try { try {
if (openInstanceModalStatus.realAction === PageAction.CREATE) { if (openInstanceModalStatus.realAction === PageAction.CREATE) {
@@ -129,6 +143,35 @@ const GPUService: React.FC = () => {
const renderEmpty = (type?: string) => { const renderEmpty = (type?: string) => {
if (type !== 'Table') return; 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 (
<NoResult
loading={dataSource.loading || clusterLoading}
loadend={dataSource.loadend}
dataSource={[]}
image={<IconFont type="icon-cloud-outlined" />}
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 ( return (
<NoResult <NoResult
loading={dataSource.loading} loading={dataSource.loading}
@@ -168,7 +211,9 @@ const GPUService: React.FC = () => {
handleInputChange={handleNameChange} handleInputChange={handleNameChange}
rowSelection={rowSelection} rowSelection={rowSelection}
widths={{ input: 300 }} widths={{ input: 300 }}
handleClickPrimary={openCreateInstanceModal} handleClickPrimary={
hasK8sCluster ? openCreateInstanceModal : undefined
}
buttonText={intl.formatMessage({ id: 'gpuservice.instance.add' })} buttonText={intl.formatMessage({ id: 'gpuservice.instance.add' })}
handleDeleteByBatch={handleDeleteBatch} handleDeleteByBatch={handleDeleteBatch}
/> />