Files
gpustack-ui/src/pages/gpu-service/instances/index.tsx
T
gitlawrandjialin 96e6ddfe8a 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.
2026-05-27 13:41:32 +08:00

290 lines
9.5 KiB
TypeScript

import { clusterSessionAtom } from '@/atoms/clusters';
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 { 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';
import {
deleteGPUServiceInstance,
GPU_SERVICE_INSTANCES_API,
queryGPUServiceInstances
} from './apis';
import AddModal from './components/add-modal';
import ViewEventsModal from './components/view-events-modal';
import ViewLogsModal from './components/view-logs-modal';
import { FormData, ListItem } from './config/types';
import useCreateInstance from './hooks/use-create-instance';
import useInstancesColumns from './hooks/use-instances-columns';
import useViewEvents from './hooks/use-view-events';
import useViewLogs from './hooks/use-view-logs';
import useCreateInstanceRequest from './services/use-create-instance';
import useUpdateInstance from './services/use-update-instance';
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,
queryParams,
sortOrder,
modalRef,
handleDelete,
handleDeleteBatch,
fetchData,
handlePageChange,
handleTableChange,
handleSearch,
handleNameChange
} = useTableFetch<ListItem>({
key: PaginationKey.Instances,
fetchAPI: queryGPUServiceInstances,
deleteAPI: deleteGPUServiceInstance,
watch: true,
API: GPU_SERVICE_INSTANCES_API,
contentForDelete: intl.formatMessage({ id: 'gpuservice.instance' })
});
const { fetchData: createInstance } = useCreateInstanceRequest();
const { fetchData: updateInstance } = useUpdateInstance();
const {
openInstanceModalStatus,
openCreateInstanceModal,
openEditInstanceModal,
openViewInstanceModal,
openRecreateInstanceModal,
closeInstanceModal
} = useCreateInstance();
const { openViewLogsModal, closeViewLogsModal, openViewLogsModalStatus } =
useViewLogs();
const {
openViewEventsModal,
closeViewEventsModal,
openViewEventsModalStatus
} = useViewEvents();
const {
fetchClusterList,
cancelRequest: cancelClusterRequest,
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) {
await deleteGPUServiceInstance(openInstanceModalStatus.currentData!.id);
await new Promise((resolve) => {
setTimeout(resolve, 300);
});
await createInstance({ data });
} else if (openInstanceModalStatus.action === PageAction.EDIT) {
await updateInstance({
id: openInstanceModalStatus.currentData!.id,
data
});
} else {
await createInstance({ data });
}
fetchData();
closeInstanceModal();
message.success(intl.formatMessage({ id: 'common.message.success' }));
} catch (error) {
// ignore
}
};
const handleSelect = useMemoizedFn((val: string, row: ListItem) => {
if (val === 'view') {
openViewInstanceModal(row);
} else if (val === 'edit') {
openEditInstanceModal(row);
} else if (val === 'delete') {
handleDelete({ ...row, name: row.name });
} else if (val === 'recreate') {
openRecreateInstanceModal(row);
} else if (val === 'viewlog') {
openViewLogsModal(row);
} else if (val === 'viewevent') {
openViewEventsModal(row);
}
// start / stop are disabled until backend endpoints exist
});
const handleBatchActionSelect = useMemoizedFn((val: string) => {
if (val === 'delete') {
handleDeleteBatch();
}
// start / stop are disabled until backend endpoints exist
});
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 (
<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.addk8scluster'
}),
onClick: handleAddK8sCluster
}
: {})}
/>
);
}
return (
<NoResult
loading={dataSource.loading}
loadend={dataSource.loadend}
dataSource={dataSource.dataList}
image={<IconFont type="icon-cloud-outlined" />}
filters={_.pick(queryParams, ['search'])}
noFoundText={intl.formatMessage({
id: 'noresult.gpuservice.instance.nofound'
})}
title={intl.formatMessage({
id: 'noresult.gpuservice.instance.title'
})}
subTitle={intl.formatMessage({
id: 'noresult.gpuservice.instance.subTitle'
})}
onClick={openCreateInstanceModal}
buttonText={intl.formatMessage({ id: 'noresult.button.add' })}
/>
);
};
const columns = useInstancesColumns({
handleSelect,
clusterList,
sortOrder
});
return (
<>
<PageBox>
<FilterBar
marginBottom={22}
marginTop={30}
showSelect={false}
handleSearch={handleSearch}
handleInputChange={handleNameChange}
rowSelection={rowSelection}
widths={{ input: 300 }}
handleClickPrimary={
hasK8sCluster ? openCreateInstanceModal : undefined
}
buttonText={intl.formatMessage({ id: 'gpuservice.instance.add' })}
handleDeleteByBatch={handleDeleteBatch}
/>
<ConfigProvider renderEmpty={renderEmpty}>
<Table
columns={columns}
dataSource={dataSource.dataList}
rowSelection={rowSelection}
loading={{
spinning: dataSource.loading,
size: 'middle'
}}
sortDirections={TABLE_SORT_DIRECTIONS}
showSorterTooltip={false}
rowKey={(record) => record.id}
onChange={handleTableChange}
pagination={{
showSizeChanger: true,
pageSize: queryParams.perPage,
current: queryParams.page,
total: dataSource.total,
hideOnSinglePage: queryParams.perPage === 10,
onChange: handlePageChange
}}
/>
</ConfigProvider>
</PageBox>
<AddModal
open={openInstanceModalStatus.open}
action={openInstanceModalStatus.action}
title={openInstanceModalStatus.title}
data={openInstanceModalStatus.currentData}
width={openInstanceModalStatus.width}
realAction={openInstanceModalStatus.realAction}
onCancel={closeInstanceModal}
onOk={handleModalOk}
/>
<ViewLogsModal
open={openViewLogsModalStatus.open}
url={openViewLogsModalStatus.url}
tail={openViewLogsModalStatus.tail}
onCancel={closeViewLogsModal}
/>
<ViewEventsModal
open={openViewEventsModalStatus.open}
name={openViewEventsModalStatus.name}
namespace={openViewEventsModalStatus.namespace}
clusterID={openViewEventsModalStatus.clusterID}
volumeName={openViewEventsModalStatus.volumeName}
hasPersistentVolume={openViewEventsModalStatus.hasPersistentVolume}
onCancel={closeViewEventsModal}
/>
<DeleteModal ref={modalRef} />
{modalContextHolder}
</>
);
};
export default GPUService;