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:
@@ -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',
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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 {
|
||||||
|
|||||||
Vendored
+6
@@ -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 };
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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': '添加节点',
|
||||||
|
|||||||
@@ -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}
|
||||||
/>
|
/>
|
||||||
|
|||||||
Reference in New Issue
Block a user