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:
+18
-1
@@ -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<boolean | undefined> => {
|
||||
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;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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<ClusterListItem | null>(null);
|
||||
|
||||
@@ -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.',
|
||||
|
||||
@@ -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.',
|
||||
|
||||
@@ -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.',
|
||||
|
||||
@@ -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.',
|
||||
|
||||
@@ -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': '尚未添加任何基准测试。',
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
</>
|
||||
|
||||
@@ -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
|
||||
}
|
||||
: {})}
|
||||
/>
|
||||
|
||||
Reference in New Issue
Block a user