Files
gpustack-ui/src/pages/resources/components/workers.tsx
T
gitlawrandjialin cd4b1b8629 fix(clusters): scope cluster pickers to the owning org
Cluster pickers listed every visible cluster, which includes clusters
shared with all authenticated users (e.g. the default org's "shared with
everyone" clusters). A member of a custom org could then pick another
org's cluster for a model deployment or a new worker.

Request only the current org's own clusters (mine=true) for:
- the model deploy picker (models page),
- the deploy-from-model-file picker (model files page), and
- the add-worker picker.

These resources are owner-scoped on the backend, so the model list and
the worker table (including its cluster-name column) only reference
own-org clusters. The add-model-file worker cascader is already own-org
via the owner-scoped worker list. Platform admin in the "All" view
bypasses mine and is scoped instead by the org picker.
2026-07-08 20:12:00 +08:00

353 lines
10 KiB
TypeScript

import { PaginationKey, TABLE_SORT_DIRECTIONS } from '@/config/settings';
import useTableFetch from '@/hooks/use-table-fetch';
import PageBox from '@/pages/_components/page-box';
import { DockerStepsFromWorker } from '@/pages/cluster-management/components/add-worker/config';
import { ClusterListItem } from '@/pages/cluster-management/config/types';
import useAddWorker from '@/pages/cluster-management/hooks/use-add-worker';
import { useQueryClusterList } from '@/pages/cluster-management/services/use-query-cluster-list';
import useNoResourceResult from '@/pages/llmodels/hooks/use-no-resource-result';
import useGranfanaLink from '@/pages/resources/hooks/use-grafana-link';
import { DeleteModal, FilterBar } from '@gpustack/core-ui';
import { useIntl } from '@umijs/max';
import { useMemoizedFn } from 'ahooks';
import { ConfigProvider, Table, message } from 'antd';
import { useEffect, useState } from 'react';
import {
WORKERS_API,
deleteWorker,
downloadWorkerPrivateKey,
queryWorkersList,
updateWorker
} from '../apis';
import { ListItem } from '../config/types';
import useWorkerColumns from '../hooks/use-worker-columns';
import useWorkerMaintenance from '../hooks/use-worker-maintenance';
import UpdateLabels from './update-labels';
import WorkerDetailModal from './worker-detail-modal';
import WorkerRightActions from './worker-right-actions';
// Optional ``clusterId`` pins the list to a single cluster (used by
// the cluster-detail page) and hides the cluster-filter dropdown so
// the user can't change scope away from the cluster they're already
// inside.
interface WorkersProps {
clusterId?: number;
source?: 'clusterDetail';
}
const Workers: React.FC<WorkersProps> = ({ clusterId, source }) => {
const {
dataSource,
rowSelection,
queryParams,
modalRef,
extraStatus,
sortOrder,
handleDelete,
handleDeleteBatch,
fetchData,
handlePageChange,
handleTableChange,
handleSearch,
handleQueryChange,
handleNameChange
} = useTableFetch<ListItem>({
key: PaginationKey.Workers,
events: ['UPDATE', 'DELETE', 'CREATE'],
fetchAPI: queryWorkersList,
deleteAPI: deleteWorker,
contentForDelete: 'resources.worker',
watch: true,
API: WORKERS_API,
updateManually: true,
defaultQueryParams: clusterId ? { cluster_id: clusterId } : undefined
});
const { goToGrafana, ActionButton } = useGranfanaLink({
type: 'worker'
});
const { MaintenanceModal, handleStopMaintenance, setOpenStatus } =
useWorkerMaintenance({ fetchData: handleSearch });
const { fetchClusterList } = useQueryClusterList({
useStateData: false
});
const intl = useIntl();
const [updateLabelsData, setUpdateLabelsData] = useState<{
open: boolean;
data: ListItem;
}>({
open: false,
data: {} as ListItem
});
const [clusterData, setClusterData] = useState<{
list: Global.BaseOption<number, ClusterListItem>[];
loading: boolean;
data: Record<number, string>;
}>({
list: [],
loading: false,
data: {}
});
const [workerDetailStatus, setWorkerDetailStatus] = useState<{
open: boolean;
currentData: ListItem | null;
}>({
open: false,
currentData: null
});
const { handleAddWorker, checkDefaultCluster, AddWorkerModal, setStepList } =
useAddWorker({
clusterList: clusterData.list,
clusterLoading: clusterData.loading
});
const getClusterList = async () => {
try {
// Own-org clusters only (mine=true). A worker can only join a cluster
// its org owns, so another org's cluster (e.g. the Default org's
// "shared with everyone" clusters) must not be offered in the picker.
// The worker list is owner-scoped too, so this list also covers every
// cluster the table's name column can reference.
const params = {
page: -1,
mine: true
};
const items = await fetchClusterList(params);
const clusterMap = items?.reduce(
(acc: Record<number, string>, item: any) => {
acc[item.id] = item.name;
return acc;
},
{}
);
const list = items?.map((item: any) => ({
label: item.name,
value: item.id,
id: item.id,
state: item.state,
is_default: item.is_default,
provider: item.provider
}));
setClusterData({
list,
loading: false,
data: clusterMap
});
} catch (error) {
setClusterData({
list: [],
loading: false,
data: {}
});
}
};
const handleUpdateLabelsOk = async (values: Record<string, any>) => {
try {
await updateWorker(updateLabelsData.data.id, {
...updateLabelsData.data,
labels: values.labels
});
message.success(intl.formatMessage({ id: 'common.message.success' }));
fetchData();
setUpdateLabelsData({ open: false, data: {} as ListItem });
} catch (error) {
console.log('error', error);
}
};
const handleCancelUpdateLabels = () => {
setUpdateLabelsData({
...updateLabelsData,
open: false
});
};
const handleUpdateLabels = (record: ListItem) => {
setUpdateLabelsData({
open: true,
data: {
...record
}
});
};
const handleViewDetail = (record: ListItem) => {
setWorkerDetailStatus({
open: true,
currentData: record
});
};
const handleSelect = useMemoizedFn((val: any, record: ListItem) => {
if (val === 'edit') {
handleUpdateLabels(record);
return;
}
if (val === 'delete') {
handleDelete(record);
}
if (val === 'details') {
handleViewDetail(record);
}
if (val === 'download_ssh_key') {
downloadWorkerPrivateKey({
id: record.id,
name: record.name
});
}
if (val === 'star_maintenance') {
setOpenStatus({
open: true,
currentData: record
});
}
if (val === 'stop_maintenance') {
handleStopMaintenance(record);
}
if (val === 'metrics') {
goToGrafana(record);
}
});
const handleOnAddWorker = () => {
const targetCluster = checkDefaultCluster(clusterData.list);
if (targetCluster) {
handleAddWorker(targetCluster as ClusterListItem, true);
} else {
message.info(intl.formatMessage({ id: 'noresult.resources.cluster' }));
}
};
const { noResourceResult } = useNoResourceResult({
loadend: dataSource.loadend,
loading: dataSource.loading,
dataSource: dataSource.dataList,
queryParams: queryParams,
iconType: 'icon-resources',
title: intl.formatMessage({ id: 'noresult.workers.title' }),
noClusters: !clusterData.list.length,
noWorkers: dataSource.dataList.length === 0 && clusterData.list.length > 0,
defaultContent: {
subTitle: intl.formatMessage({ id: 'noresult.workers.subTitle' }),
noFoundText: intl.formatMessage({ id: 'noresult.workers.nofound' }),
buttonText: intl.formatMessage({ id: 'noresult.workers.button.add' }),
onClick: handleOnAddWorker
}
});
const renderEmpty = (type?: string) => {
if (type !== 'Table') return;
return noResourceResult;
};
const handleClusterChange = (value: number) => {
handleQueryChange({
page: 1,
cluster_id: value
});
};
const columns = useWorkerColumns({
clusterData,
loadend: dataSource.loadend,
firstLoad: extraStatus.firstLoad,
sortOrder,
source,
handleSelect
});
useEffect(() => {
getClusterList();
setStepList(DockerStepsFromWorker);
}, []);
return (
<>
<PageBox>
<FilterBar
showSelect={source !== 'clusterDetail'}
selectHolder={intl.formatMessage({ id: 'clusters.filterBy.cluster' })}
marginBottom={22}
marginTop={30}
buttonText={intl.formatMessage({ id: 'resources.button.create' })}
handleDeleteByBatch={handleDeleteBatch}
handleSearch={handleSearch}
handleSelectChange={handleClusterChange}
handleClickPrimary={handleOnAddWorker}
handleInputChange={handleNameChange}
rowSelection={rowSelection}
selectOptions={clusterData.list}
widths={
source !== 'clusterDetail'
? { select: 230, input: 230 }
: { input: 300 }
}
right={
source === 'clusterDetail' ? (
<></>
) : (
<WorkerRightActions
handleDeleteByBatch={handleDeleteBatch}
handleClickPrimary={handleOnAddWorker}
rowSelection={rowSelection}
MonitorButton={ActionButton()}
></WorkerRightActions>
)
}
></FilterBar>
<ConfigProvider renderEmpty={renderEmpty}>
<Table
columns={columns}
sortDirections={TABLE_SORT_DIRECTIONS}
showSorterTooltip={false}
tableLayout={'auto'}
className={'scroll-table'}
dataSource={dataSource.dataList}
loading={{
spinning: dataSource.loading,
size: 'middle'
}}
rowKey="id"
scroll={{ x: 900 }}
onChange={handleTableChange}
rowSelection={source === 'clusterDetail' ? undefined : rowSelection}
pagination={{
size: 'middle',
showSizeChanger: true,
pageSize: queryParams.perPage,
current: queryParams.page,
total: dataSource.total,
hideOnSinglePage: queryParams.perPage === 10,
onChange: handlePageChange
}}
></Table>
</ConfigProvider>
<DeleteModal ref={modalRef}></DeleteModal>
<UpdateLabels
open={updateLabelsData.open}
onOk={handleUpdateLabelsOk}
onCancel={handleCancelUpdateLabels}
data={{
name: updateLabelsData.data.name,
labels: updateLabelsData.data.labels
}}
></UpdateLabels>
<WorkerDetailModal
open={workerDetailStatus.open}
currentData={workerDetailStatus.currentData}
onClose={() =>
setWorkerDetailStatus({ currentData: null, open: false })
}
/>
{MaintenanceModal}
{AddWorkerModal}
</PageBox>
</>
);
};
export default Workers;