Reserve a stable block for the empty/loading state (matching the NoResult height) and fade the empty state in, so entering a list page no longer jumps between spinner, empty result, and rows. Apply scroll-table to the antd main lists and pass emptyMinHeight to the SealTable-based lists.
421 lines
13 KiB
TypeScript
421 lines
13 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 { handleBatchRequest } from '@/utils';
|
|
import { PlusOutlined } from '@ant-design/icons';
|
|
import {
|
|
DeleteModal,
|
|
DropdownButtons,
|
|
FilterBar,
|
|
IconFont,
|
|
NoResult
|
|
} from '@gpustack/core-ui';
|
|
import { useAccess, useIntl, useNavigate } from '@umijs/max';
|
|
import { useMemoizedFn } from 'ahooks';
|
|
import { Button, ConfigProvider, message, Modal, Space, Table } from 'antd';
|
|
import { useSetAtom } from 'jotai';
|
|
import _ from 'lodash';
|
|
import { useEffect, useMemo, useState } from 'react';
|
|
import PageBox from '../../_components/page-box';
|
|
import { queryGPUServiceStorage } from '../storage/apis';
|
|
import {
|
|
deleteGPUServiceInstance,
|
|
GPU_SERVICE_INSTANCES_API,
|
|
queryGPUServiceInstances,
|
|
startGPUServiceInstance,
|
|
stopGPUServiceInstance
|
|
} from './apis';
|
|
import AddModal from './components/add-modal';
|
|
import ViewEventsModal from './components/view-events-modal';
|
|
import ViewLogsModal from './components/view-logs-modal';
|
|
import { batchActionList } from './config';
|
|
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();
|
|
|
|
const handleAddK8sCluster = () => {
|
|
setClusterSession({
|
|
firstAddWorker: false,
|
|
firstAddCluster: true,
|
|
presetClusterType: 'gpu',
|
|
providerHint: ProviderValueMap.Kubernetes
|
|
});
|
|
navigate('/resources/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: '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,
|
|
clusterList,
|
|
loading: clusterLoading
|
|
} = useQueryClusterList();
|
|
|
|
// name → capacity for persistent volumes, so the Instance Type popover can
|
|
// show the persistent disk size (the instance spec only references it by
|
|
// name). Best-effort: falls back to the name if a PV can't be resolved.
|
|
const [pvCapacityByName, setPvCapacityByName] = useState<
|
|
Record<string, string>
|
|
>({});
|
|
|
|
useEffect(() => {
|
|
fetchClusterList({ page: -1 });
|
|
const fetchPVCapacities = async () => {
|
|
try {
|
|
const res = await queryGPUServiceStorage({ page: -1 } as any);
|
|
const map: Record<string, string> = {};
|
|
(res?.items || []).forEach((pv: any) => {
|
|
if (pv?.name && pv?.spec?.capacity) {
|
|
map[pv.name] = pv.spec.capacity;
|
|
}
|
|
});
|
|
setPvCapacityByName(map);
|
|
} catch {
|
|
// best-effort; the popover falls back to the PV name
|
|
}
|
|
};
|
|
fetchPVCapacities();
|
|
}, []);
|
|
|
|
const hasK8sCluster = useMemo(
|
|
() => clusterList.some((c) => c.provider === ProviderValueMap.Kubernetes),
|
|
[clusterList]
|
|
);
|
|
|
|
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 handleStart = useMemoizedFn((row: ListItem) => {
|
|
modalRef.current?.show({
|
|
content: 'gpuservice.instance',
|
|
title: 'common.title.start.confirm',
|
|
okText: 'common.button.start',
|
|
operation: 'common.start.single.confirm',
|
|
name: row.name,
|
|
async onOk() {
|
|
await startGPUServiceInstance(row.id);
|
|
rowSelection.removeSelectedKeys([row.id]);
|
|
message.success(intl.formatMessage({ id: 'common.message.success' }));
|
|
fetchData();
|
|
}
|
|
});
|
|
});
|
|
|
|
const handleStop = useMemoizedFn((row: ListItem) => {
|
|
modalRef.current?.show({
|
|
content: 'gpuservice.instance',
|
|
title: 'common.title.stop.confirm',
|
|
okText: 'common.button.stop',
|
|
operation: 'common.stop.single.confirm',
|
|
name: row.name,
|
|
async onOk() {
|
|
await stopGPUServiceInstance(row.id);
|
|
rowSelection.removeSelectedKeys([row.id]);
|
|
fetchData();
|
|
}
|
|
});
|
|
});
|
|
|
|
const handleStartBatch = useMemoizedFn(() => {
|
|
modalRef.current?.show({
|
|
content: 'gpuservice.instance',
|
|
title: 'common.title.start.confirm',
|
|
okText: 'common.button.start',
|
|
operation: 'common.start.confirm',
|
|
selection: true,
|
|
async onOk() {
|
|
const successIds: number[] = [];
|
|
const res = await handleBatchRequest(
|
|
rowSelection.selectedRowKeys,
|
|
async (id: number) => {
|
|
await startGPUServiceInstance(id);
|
|
successIds.push(id);
|
|
}
|
|
);
|
|
rowSelection.removeSelectedKeys(successIds);
|
|
fetchData();
|
|
return res;
|
|
}
|
|
});
|
|
});
|
|
|
|
const handleStopBatch = useMemoizedFn(() => {
|
|
modalRef.current?.show({
|
|
content: 'gpuservice.instance',
|
|
title: 'common.title.stop.confirm',
|
|
okText: 'common.button.stop',
|
|
operation: 'common.stop.confirm',
|
|
selection: true,
|
|
async onOk() {
|
|
const successIds: number[] = [];
|
|
const res = await handleBatchRequest(
|
|
rowSelection.selectedRowKeys,
|
|
async (id: number) => {
|
|
await stopGPUServiceInstance(id);
|
|
successIds.push(id);
|
|
}
|
|
);
|
|
rowSelection.removeSelectedKeys(successIds);
|
|
fetchData();
|
|
return res;
|
|
}
|
|
});
|
|
});
|
|
|
|
const handleSelect = useMemoizedFn((val: string, row: ListItem) => {
|
|
if (val === 'view') {
|
|
openViewInstanceModal(row);
|
|
} else if (val === 'edit') {
|
|
openEditInstanceModal(row);
|
|
} else if (val === 'delete') {
|
|
handleDelete({ ...row });
|
|
} else if (val === 'recreate') {
|
|
openRecreateInstanceModal(row);
|
|
} else if (val === 'viewlog') {
|
|
openViewLogsModal(row);
|
|
} else if (val === 'viewevent') {
|
|
openViewEventsModal(row);
|
|
} else if (val === 'start') {
|
|
handleStart(row);
|
|
} else if (val === 'stop') {
|
|
handleStop(row);
|
|
}
|
|
});
|
|
|
|
const handleBatchActionSelect = useMemoizedFn((val: string) => {
|
|
if (val === 'delete') {
|
|
handleDeleteBatch();
|
|
} else if (val === 'start') {
|
|
handleStartBatch();
|
|
} else if (val === 'stop') {
|
|
handleStopBatch();
|
|
}
|
|
});
|
|
|
|
const renderEmpty = (type?: string) => {
|
|
if (type !== 'Table') return;
|
|
if (!clusterLoading && !hasK8sCluster) {
|
|
return (
|
|
<NoResult
|
|
minHeight="calc(100vh - 300px)"
|
|
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
|
|
minHeight="calc(100vh - 300px)"
|
|
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,
|
|
pvCapacityByName
|
|
});
|
|
|
|
return (
|
|
<>
|
|
<PageBox>
|
|
<FilterBar
|
|
marginBottom={22}
|
|
marginTop={30}
|
|
showSelect={false}
|
|
handleSearch={handleSearch}
|
|
handleInputChange={handleNameChange}
|
|
rowSelection={rowSelection}
|
|
widths={{ input: 300 }}
|
|
right={
|
|
<Space size={16}>
|
|
{hasK8sCluster && (
|
|
<Button
|
|
icon={<PlusOutlined />}
|
|
type="primary"
|
|
onClick={openCreateInstanceModal}
|
|
>
|
|
{intl.formatMessage({ id: 'gpuservice.instance.add' })}
|
|
</Button>
|
|
)}
|
|
<DropdownButtons
|
|
items={batchActionList}
|
|
onSelect={handleBatchActionSelect}
|
|
disabled={!rowSelection.selectedRowKeys.length}
|
|
size="large"
|
|
showText={true}
|
|
extra={
|
|
rowSelection.selectedRowKeys.length > 0 && (
|
|
<span>({rowSelection.selectedRowKeys.length})</span>
|
|
)
|
|
}
|
|
/>
|
|
</Space>
|
|
}
|
|
/>
|
|
<ConfigProvider renderEmpty={renderEmpty}>
|
|
<Table
|
|
className={'scroll-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}
|
|
clusterList={clusterList}
|
|
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;
|