diff --git a/src/hooks/use-update-chunk-list.ts b/src/hooks/use-update-chunk-list.ts index dbe1b5be..d5c5dcd1 100644 --- a/src/hooks/use-update-chunk-list.ts +++ b/src/hooks/use-update-chunk-list.ts @@ -14,7 +14,6 @@ type EventsType = 'CREATE' | 'UPDATE' | 'DELETE' | 'INSERT'; export function useUpdateChunkedList(options: { events?: EventsType[]; dataList?: any[]; - triggerAt?: React.MutableRefObject; limit?: number; onCreate?: (args: any) => void; onUpdate?: (args: any) => void; @@ -24,9 +23,9 @@ export function useUpdateChunkedList(options: { filterFun?: (args: any) => boolean; mapFun?: (args: any) => any; computedID?: (d: object) => string; + isNewItem?: (item: any) => boolean; }) { - const { events = ['CREATE', 'DELETE', 'UPDATE', 'INSERT'], triggerAt } = - options; + const { events = ['CREATE', 'DELETE', 'UPDATE', 'INSERT'] } = options; const deletedIdsRef = useRef>(new Set()); const cacheDataListRef = useRef(options.dataList || []); const timerRef = useRef(null); @@ -71,17 +70,14 @@ export function useUpdateChunkedList(options: { (sItem: any) => sItem.id === item.id ); const updateItem = { ...item }; - if (updateIndex === -1 && !triggerAt?.current) { + if (updateIndex === -1) { acc.push(updateItem); - } else if (!triggerAt?.current) { + } else { cacheDataListRef.current[updateIndex] = updateItem; } + // only push items created after the watch started - // TODO: only push items created after triggerAt - if ( - triggerAt?.current && - Date.parse(item.created_at) >= triggerAt.current - ) { + if (options.isNewItem?.(item)) { latestCreateList.push(updateItem); } @@ -103,10 +99,8 @@ export function useUpdateChunkedList(options: { cacheDataListRef.current = cacheDataListRef.current?.filter( (item: any) => { // collect deleted items - if (triggerAt?.current) { - if (ids?.includes(item.id)) { - deletedList.push(item); - } + if (ids?.includes(item.id) && !options.isNewItem?.(item)) { + deletedList.push(item); } return !ids?.includes(item.id); } @@ -133,10 +127,8 @@ export function useUpdateChunkedList(options: { updateItem, ...cacheDataListRef.current.slice(0, limit - 1) ]; - if (options.onUpdate && triggerAt?.current) { - if (Date.parse(item.created_at) >= triggerAt.current) { - options.onUpdate?.([updateItem]); - } + if (options.onUpdate && options.isNewItem?.(item)) { + options.onUpdate?.([updateItem]); } } }); diff --git a/src/layouts/index.tsx b/src/layouts/index.tsx index 6fed13c2..f467a355 100644 --- a/src/layouts/index.tsx +++ b/src/layouts/index.tsx @@ -396,7 +396,6 @@ export default (props: any) => { )} onCollapse={onCollapse} onMenuHeaderClick={onMenuHeaderClick} - menuHeaderRender={renderMenuHeader} collapsed={userSettings.collapsed} onPageChange={onPageChange} formatMessage={formatMessage} diff --git a/src/pages/cluster-management/components/add-worker/add-worker-step.tsx b/src/pages/cluster-management/components/add-worker/add-worker-step.tsx index fdaa4c19..7a8ad3fb 100644 --- a/src/pages/cluster-management/components/add-worker/add-worker-step.tsx +++ b/src/pages/cluster-management/components/add-worker/add-worker-step.tsx @@ -67,6 +67,7 @@ const AddWorkerSteps: React.FC = (props) => { ); const { update, summary, register } = useSummaryStatus(); const { addedCount, createModelsChunkRequest } = useAddWorkerMessage(); + console.log('addedCount=========', addedCount); const onToggle = (open: boolean, key: string) => { setCollapseKey(open ? new Set([key]) : new Set()); @@ -81,6 +82,7 @@ const AddWorkerSteps: React.FC = (props) => { setCollapseKey(new Set([stepList[0]])); }, [stepList]); + console.log('actionSource=========', actionSource, registrationInfo); React.useEffect(() => { // this effect is only triggered when used in cluster create page inner if (actionSource === 'page' && registrationInfo?.cluster_id) { @@ -164,6 +166,7 @@ const AddWorkerSteps: React.FC = (props) => { )} {actionSource === 'modal' && ( + // show in cluster create page inner )} diff --git a/src/pages/cluster-management/components/add-worker/index.tsx b/src/pages/cluster-management/components/add-worker/index.tsx index fe73747d..2e36407c 100644 --- a/src/pages/cluster-management/components/add-worker/index.tsx +++ b/src/pages/cluster-management/components/add-worker/index.tsx @@ -75,6 +75,7 @@ const AddWorker: React.FC = (props) => { }); const handleOnClusterChange = async (value: number, row?: any) => { + console.log('handleOnClusterChange value, row=========', value, row); try { createModelsChunkRequest({ cluster_id: value }); axiosTokenRef.current?.cancel?.(); @@ -121,6 +122,8 @@ const AddWorker: React.FC = (props) => { ); }; + console.log('addedCount 1========', addedCount); + return ( (null); const triggerAtRef = useRef(0); + const existingIdsRef = useRef>(new Set()); + const snapshotReceivedRef = useRef(false); const [, setWorkerAddedCount] = useAtom(workerAddedCountAtom); + // fetchData auto-cancels any in-flight request on each call and on unmount, + // so a stale seed from a previous open/cluster can't leak in + const { fetchData: fetchWorkerList, cancelRequest: cancelWorkerListRequest } = + useQueryWorkerList(); + + const isNewWorker = (item: any) => { + console.log( + 'isNewWorker item:', + item, + existingIdsRef.current, + snapshotReceivedRef.current + ); + if (item?.id == null) { + return false; + } + if (existingIdsRef.current.has(item.id)) { + return false; + } + existingIdsRef.current.add(item.id); + // before the full snapshot has been received, every item is pre-existing + return snapshotReceivedRef.current; + }; const updateAddedCount = (count: number) => { setAddedCount(count); @@ -30,8 +55,9 @@ export default function useAddWorkerMessage() { const { updateChunkedList } = useUpdateChunkedList({ events: ['CREATE', 'INSERT'], dataList: [], - triggerAt: triggerAtRef, + isNewItem: isNewWorker, onCreate: (newItems: any) => { + console.log('onCreate newItems:', newItems, triggerAtRef.current); if (triggerAtRef.current) { newItemsRef.current = newItemsRef.current.concat(newItems); showAddWorkerMessage(); @@ -49,6 +75,27 @@ export default function useAddWorkerMessage() { _.each(list, (data: any) => { updateChunkedList(data); }); + // fallback: if seeding the baseline failed, the first watch chunk marks the + // snapshot as received (only reliable when there is at least one worker) + snapshotReceivedRef.current = true; + }; + + // Seed the baseline set of already-existing worker ids via REST before the + // watch starts. Relying on the watch's first chunk fails when a cluster has + // zero workers (no CREATE event is sent, so the snapshot flag never flips and + // genuinely new workers get misclassified as pre-existing). + const seedExistingWorkers = async (params: Record) => { + try { + const items = await fetchWorkerList({ ...params, page: -1 } as any); + (items || []).forEach((item: any) => { + if (item?.id != null) { + existingIdsRef.current.add(item.id); + } + }); + snapshotReceivedRef.current = true; + } catch (error) { + // ignore: fall back to the first watch chunk (see updateHandler) + } }; const resetAddedCount = () => { @@ -56,11 +103,18 @@ export default function useAddWorkerMessage() { chunkRequestRef.current?.current?.cancel?.(); newItemsRef.current = []; triggerAtRef.current = 0; + existingIdsRef.current = new Set(); + snapshotReceivedRef.current = false; + // cancel any in-flight seed request + cancelWorkerListRequest(); clearTimeout(timerRef.current); }; const createModelsChunkRequest = async (params = {}) => { resetAddedCount(); + // seed the baseline before watching so new workers are detected even when + // the cluster currently has zero workers + await seedExistingWorkers(params); try { chunkRequestRef.current = setChunkRequest({ url: `${WORKERS_API}?${qs.stringify(_.pickBy(params, (val: any) => !!val))}`, diff --git a/src/pages/llmodels/instance-view/index.tsx b/src/pages/llmodels/instance-view/index.tsx index 30a7dcc6..7324238b 100644 --- a/src/pages/llmodels/instance-view/index.tsx +++ b/src/pages/llmodels/instance-view/index.tsx @@ -6,7 +6,7 @@ import { useIntl } from '@umijs/max'; import { useMemoizedFn } from 'ahooks'; import { ConfigProvider, Table } from 'antd'; import _ from 'lodash'; -import { forwardRef, useImperativeHandle } from 'react'; +import { forwardRef, useEffect, useImperativeHandle } from 'react'; import { deleteModelInstance, MODEL_INSTANCE_API, @@ -16,6 +16,7 @@ import ViewLogsModal from '../components/view-logs-modal'; import { useDeploymentsContext } from '../config/deploments-context'; import { ModelInstanceListItem as ListItem } from '../config/types'; import useViewInstanceLogs from '../hooks/use-view-instance-logs'; +import useQueryModelList from '../services/use-query-model-list'; import LeftFilters from './left-filters'; import useInstanceColumns from './use-instance-columns'; @@ -44,10 +45,15 @@ const InstanceView = forwardRef((props, ref) => { contentForDelete: 'menu.models.instances' }); const intl = useIntl(); + const { dataList: modelList, fetchData: fetchModelList } = + useQueryModelList(); const { clusterList, workerList } = useDeploymentsContext(); const { openViewLogsModal, openViewLogsModalStatus, closeViewLogsModal } = useViewInstanceLogs(); + useEffect(() => { + fetchModelList({ page: -1 }); + }, []); const handleSelect = useMemoizedFn((val: any, row: ListItem) => { if (val === 'delete') { handleDelete(row, { @@ -119,6 +125,7 @@ const InstanceView = forwardRef((props, ref) => { const columns = useInstanceColumns({ handleSelect, clusterList, + modelList, workerList }); diff --git a/src/pages/llmodels/instance-view/use-instance-columns.tsx b/src/pages/llmodels/instance-view/use-instance-columns.tsx index 3d215f8d..9daa4214 100644 --- a/src/pages/llmodels/instance-view/use-instance-columns.tsx +++ b/src/pages/llmodels/instance-view/use-instance-columns.tsx @@ -15,7 +15,10 @@ import InstanceStatusCell from '../components/instance-cells/instance-status-cel import NameCell, { NameCellProps } from '../components/instance-cells/name-cell'; -import { ModelInstanceListItem as ListItem } from '../config/types'; +import { + ModelInstanceListItem as ListItem, + ListItem as ModelListItem +} from '../config/types'; import { calcTotalVram } from '../utils'; const WorkerInfoContent: React.FC = ({ record, modelData }) => { let workerIp = '-'; @@ -48,6 +51,7 @@ const WorkerInfoContent: React.FC = ({ record, modelData }) => { const useInstancesColumns = (options: { workerList: workerListItem[]; + modelList: ModelListItem[]; clusterList: Global.BaseOption< number, { @@ -60,7 +64,8 @@ const useInstancesColumns = (options: { onCellClick?: (record: ListItem, dataIndex: string) => void; }): ColumnsType => { const intl = useIntl(); - const { workerList, clusterList, handleSelect, onCellClick } = options; + const { workerList, clusterList, modelList, handleSelect, onCellClick } = + options; const renderWorkerCell = (text: number, record: ListItem) => { if (text) { @@ -144,7 +149,7 @@ const useInstancesColumns = (options: { width: 160, render: (text: string, record: ListItem) => ( - {}} /> + ( model.id === record.model_id + ) as ModelListItem + } onSelect={handleSelect} > ) } ]; - }, [handleSelect, onCellClick, workerList, clusterList]); + }, [handleSelect, onCellClick, workerList, clusterList, modelList]); }; export default useInstancesColumns; diff --git a/src/pages/resources/services/use-query-worker-list.ts b/src/pages/resources/services/use-query-worker-list.ts new file mode 100644 index 00000000..d947b018 --- /dev/null +++ b/src/pages/resources/services/use-query-worker-list.ts @@ -0,0 +1,25 @@ +import { useQueryDataList } from '@gpustack/core-ui'; +import { queryWorkersList } from '../apis'; +import { ListItem } from '../config/types'; + +export const useQueryWorkerList = (optons?: { + getLabel?: (item: ListItem) => string; + getValue?: (item: ListItem) => any; +}) => { + const { dataList, loading, fetchData, cancelRequest } = useQueryDataList< + ListItem, + Global.SearchParams + >({ + key: 'workerList', + fetchList: queryWorkersList + }); + + return { + dataList, + loading, + fetchData, + cancelRequest + }; +}; + +export default useQueryWorkerList;