diff --git a/invokeai/app/api/routers/session_queue.py b/invokeai/app/api/routers/session_queue.py index c94f249d2e5..7bb324153b1 100644 --- a/invokeai/app/api/routers/session_queue.py +++ b/invokeai/app/api/routers/session_queue.py @@ -7,7 +7,7 @@ from invokeai.app.api.dependencies import ApiDependencies from invokeai.app.services.session_processor.session_processor_common import SessionProcessorStatus from invokeai.app.services.session_queue.session_queue_common import ( - QUEUE_ITEM_STATUS, + QUEUE_ORDER_BY, Batch, BatchStatus, CancelAllExceptCurrentResult, @@ -18,6 +18,7 @@ DeleteByDestinationResult, EnqueueBatchResult, FieldIdentifier, + ItemIdsResult, PruneResult, RetryItemsResult, SessionQueueCountsByDestination, @@ -25,7 +26,7 @@ SessionQueueItemNotFoundError, SessionQueueStatus, ) -from invokeai.app.services.shared.pagination import CursorPaginatedResults +from invokeai.app.services.shared.sqlite.sqlite_common import SQLiteDirection session_queue_router = APIRouter(prefix="/v1/queue", tags=["queue"]) @@ -69,54 +70,75 @@ async def enqueue_batch( @session_queue_router.get( - "/{queue_id}/list", - operation_id="list_queue_items", + "/{queue_id}/list_all", + operation_id="list_all_queue_items", responses={ - 200: {"model": CursorPaginatedResults[SessionQueueItem]}, + 200: {"model": list[SessionQueueItem]}, }, ) -async def list_queue_items( +async def list_all_queue_items( queue_id: str = Path(description="The queue id to perform this operation on"), - limit: int = Query(default=50, description="The number of items to fetch"), - status: Optional[QUEUE_ITEM_STATUS] = Query(default=None, description="The status of items to fetch"), - cursor: Optional[int] = Query(default=None, description="The pagination cursor"), - priority: int = Query(default=0, description="The pagination cursor priority"), destination: Optional[str] = Query(default=None, description="The destination of queue items to fetch"), -) -> CursorPaginatedResults[SessionQueueItem]: - """Gets cursor-paginated queue items""" - +) -> list[SessionQueueItem]: + """Gets all queue items""" try: - return ApiDependencies.invoker.services.session_queue.list_queue_items( + return ApiDependencies.invoker.services.session_queue.list_all_queue_items( queue_id=queue_id, - limit=limit, - status=status, - cursor=cursor, - priority=priority, destination=destination, ) except Exception as e: - raise HTTPException(status_code=500, detail=f"Unexpected error while listing all items: {e}") + raise HTTPException(status_code=500, detail=f"Unexpected error while listing all queue items: {e}") @session_queue_router.get( - "/{queue_id}/list_all", - operation_id="list_all_queue_items", + "/{queue_id}/item_ids", + operation_id="get_queue_itemIds", responses={ - 200: {"model": list[SessionQueueItem]}, + 200: {"model": ItemIdsResult}, }, ) -async def list_all_queue_items( +async def get_queue_item_ids( queue_id: str = Path(description="The queue id to perform this operation on"), - destination: Optional[str] = Query(default=None, description="The destination of queue items to fetch"), -) -> list[SessionQueueItem]: - """Gets all queue items""" + order_by: QUEUE_ORDER_BY = Query(default="created_at", description="The sort field"), + order_dir: SQLiteDirection = Query(default=SQLiteDirection.Descending, description="The order of sort"), +) -> ItemIdsResult: + """Gets all queue item ids that match the given parameters""" try: - return ApiDependencies.invoker.services.session_queue.list_all_queue_items( - queue_id=queue_id, - destination=destination, + return ApiDependencies.invoker.services.session_queue.get_queue_item_ids( + queue_id=queue_id, order_by=order_by, order_dir=order_dir ) except Exception as e: - raise HTTPException(status_code=500, detail=f"Unexpected error while listing all queue items: {e}") + raise HTTPException(status_code=500, detail=f"Unexpected error while listing all queue item ids: {e}") + + +@session_queue_router.post( + "/{queue_id}/items_by_ids", + operation_id="get_queue_items_by_item_ids", + responses={200: {"model": list[SessionQueueItem]}}, +) +async def get_queue_items_by_item_ids( + queue_id: str = Path(description="The queue id to perform this operation on"), + item_ids: list[int] = Body( + embed=True, description="Object containing list of queue item ids to fetch queue items for" + ), +) -> list[SessionQueueItem]: + """Gets queue items for the specified queue item ids. Maintains order of item ids.""" + try: + session_queue_service = ApiDependencies.invoker.services.session_queue + + # Fetch queue items preserving the order of requested item ids + queue_items: list[SessionQueueItem] = [] + for item_id in item_ids: + try: + queue_item = session_queue_service.get_queue_item(item_id) + queue_items.append(queue_item) + except Exception: + # Skip missing queue items - they may have been deleted between item id fetch and queue item fetch + continue + + return queue_items + except Exception: + raise HTTPException(status_code=500, detail="Failed to get queue items") @session_queue_router.put( diff --git a/invokeai/app/services/events/events_common.py b/invokeai/app/services/events/events_common.py index 9bc4a972cca..add19d459e6 100644 --- a/invokeai/app/services/events/events_common.py +++ b/invokeai/app/services/events/events_common.py @@ -234,8 +234,8 @@ class QueueItemStatusChangedEvent(QueueItemEventBase): error_type: Optional[str] = Field(default=None, description="The error type, if any") error_message: Optional[str] = Field(default=None, description="The error message, if any") error_traceback: Optional[str] = Field(default=None, description="The error traceback, if any") - created_at: Optional[str] = Field(default=None, description="The timestamp when the queue item was created") - updated_at: Optional[str] = Field(default=None, description="The timestamp when the queue item was last updated") + created_at: str = Field(description="The timestamp when the queue item was created") + updated_at: str = Field(description="The timestamp when the queue item was last updated") started_at: Optional[str] = Field(default=None, description="The timestamp when the queue item was started") completed_at: Optional[str] = Field(default=None, description="The timestamp when the queue item was completed") batch_status: BatchStatus = Field(description="The status of the batch") @@ -258,8 +258,8 @@ def build( error_type=queue_item.error_type, error_message=queue_item.error_message, error_traceback=queue_item.error_traceback, - created_at=str(queue_item.created_at) if queue_item.created_at else None, - updated_at=str(queue_item.updated_at) if queue_item.updated_at else None, + created_at=str(queue_item.created_at), + updated_at=str(queue_item.updated_at), started_at=str(queue_item.started_at) if queue_item.started_at else None, completed_at=str(queue_item.completed_at) if queue_item.completed_at else None, batch_status=batch_status, diff --git a/invokeai/app/services/session_queue/session_queue_base.py b/invokeai/app/services/session_queue/session_queue_base.py index aa1126576dc..6169bef428a 100644 --- a/invokeai/app/services/session_queue/session_queue_base.py +++ b/invokeai/app/services/session_queue/session_queue_base.py @@ -2,7 +2,7 @@ from typing import Any, Coroutine, Optional from invokeai.app.services.session_queue.session_queue_common import ( - QUEUE_ITEM_STATUS, + QUEUE_ORDER_BY, Batch, BatchStatus, CancelAllExceptCurrentResult, @@ -15,6 +15,7 @@ EnqueueBatchResult, IsEmptyResult, IsFullResult, + ItemIdsResult, PruneResult, RetryItemsResult, SessionQueueCountsByDestination, @@ -22,7 +23,7 @@ SessionQueueStatus, ) from invokeai.app.services.shared.graph import GraphExecutionState -from invokeai.app.services.shared.pagination import CursorPaginatedResults +from invokeai.app.services.shared.sqlite.sqlite_common import SQLiteDirection class SessionQueueBase(ABC): @@ -136,25 +137,22 @@ def delete_all_except_current(self, queue_id: str) -> DeleteAllExceptCurrentResu pass @abstractmethod - def list_queue_items( + def list_all_queue_items( self, queue_id: str, - limit: int, - priority: int, - cursor: Optional[int] = None, - status: Optional[QUEUE_ITEM_STATUS] = None, destination: Optional[str] = None, - ) -> CursorPaginatedResults[SessionQueueItem]: - """Gets a page of session queue items""" + ) -> list[SessionQueueItem]: + """Gets all queue items that match the given parameters""" pass @abstractmethod - def list_all_queue_items( + def get_queue_item_ids( self, queue_id: str, - destination: Optional[str] = None, - ) -> list[SessionQueueItem]: - """Gets all queue items that match the given parameters""" + order_by: QUEUE_ORDER_BY = "created_at", + order_dir: SQLiteDirection = SQLiteDirection.Descending, + ) -> ItemIdsResult: + """Gets all queue item ids that match the given parameters""" pass @abstractmethod diff --git a/invokeai/app/services/session_queue/session_queue_common.py b/invokeai/app/services/session_queue/session_queue_common.py index 3524e60d49d..01e2f7d4b61 100644 --- a/invokeai/app/services/session_queue/session_queue_common.py +++ b/invokeai/app/services/session_queue/session_queue_common.py @@ -174,8 +174,17 @@ def validate_graph(cls, v: Graph): DEFAULT_QUEUE_ID = "default" +QUEUE_ORDER_BY = Literal["created_at", "completed_at"] QUEUE_ITEM_STATUS = Literal["pending", "in_progress", "completed", "failed", "canceled"] + +class ItemIdsResult(BaseModel): + """Response containing ordered item ids with metadata for optimistic updates.""" + + item_ids: list[int] = Field(description="Ordered list of item ids") + total_count: int = Field(description="Total number of queue items matching the query") + + NodeFieldValueValidator = TypeAdapter(list[NodeFieldValue]) diff --git a/invokeai/app/services/session_queue/session_queue_sqlite.py b/invokeai/app/services/session_queue/session_queue_sqlite.py index 2e450399bc2..ef25fba090d 100644 --- a/invokeai/app/services/session_queue/session_queue_sqlite.py +++ b/invokeai/app/services/session_queue/session_queue_sqlite.py @@ -10,6 +10,7 @@ from invokeai.app.services.session_queue.session_queue_common import ( DEFAULT_QUEUE_ID, QUEUE_ITEM_STATUS, + QUEUE_ORDER_BY, Batch, BatchStatus, CancelAllExceptCurrentResult, @@ -22,6 +23,7 @@ EnqueueBatchResult, IsEmptyResult, IsFullResult, + ItemIdsResult, PruneResult, RetryItemsResult, SessionQueueCountsByDestination, @@ -33,7 +35,7 @@ prepare_values_to_insert, ) from invokeai.app.services.shared.graph import GraphExecutionState -from invokeai.app.services.shared.pagination import CursorPaginatedResults +from invokeai.app.services.shared.sqlite.sqlite_common import SQLiteDirection from invokeai.app.services.shared.sqlite.sqlite_database import SqliteDatabase @@ -587,59 +589,6 @@ def set_queue_item_session(self, item_id: int, session: GraphExecutionState) -> ) return self.get_queue_item(item_id) - def list_queue_items( - self, - queue_id: str, - limit: int, - priority: int, - cursor: Optional[int] = None, - status: Optional[QUEUE_ITEM_STATUS] = None, - destination: Optional[str] = None, - ) -> CursorPaginatedResults[SessionQueueItem]: - with self._db.transaction() as cursor_: - item_id = cursor - query = """--sql - SELECT * - FROM session_queue - WHERE queue_id = ? - """ - params: list[Union[str, int]] = [queue_id] - - if status is not None: - query += """--sql - AND status = ? - """ - params.append(status) - - if destination is not None: - query += """---sql - AND destination = ? - """ - params.append(destination) - - if item_id is not None: - query += """--sql - AND (priority < ?) OR (priority = ? AND item_id > ?) - """ - params.extend([priority, priority, item_id]) - - query += """--sql - ORDER BY - priority DESC, - item_id ASC - LIMIT ? - """ - params.append(limit + 1) - cursor_.execute(query, params) - results = cast(list[sqlite3.Row], cursor_.fetchall()) - items = [SessionQueueItem.queue_item_from_dict(dict(result)) for result in results] - has_more = False - if len(items) > limit: - # remove the extra item - items.pop() - has_more = True - return CursorPaginatedResults(items=items, limit=limit, has_more=has_more) - def list_all_queue_items( self, queue_id: str, @@ -671,6 +620,27 @@ def list_all_queue_items( items = [SessionQueueItem.queue_item_from_dict(dict(result)) for result in results] return items + def get_queue_item_ids( + self, + queue_id: str, + order_by: QUEUE_ORDER_BY = "created_at", + order_dir: SQLiteDirection = SQLiteDirection.Descending, + ) -> ItemIdsResult: + with self._db.transaction() as cursor_: + query = f"""--sql + SELECT item_id + FROM session_queue + WHERE queue_id = ? + ORDER BY {order_by} {order_dir.value} + """ + query_params = [queue_id] + + cursor_.execute(query, query_params) + result = cast(list[sqlite3.Row], cursor_.fetchall()) + item_ids = [row[0] for row in result] + + return ItemIdsResult(item_ids=item_ids, total_count=len(item_ids)) + def get_queue_status(self, queue_id: str) -> SessionQueueStatus: with self._db.transaction() as cursor: cursor.execute( diff --git a/invokeai/frontend/web/public/locales/en.json b/invokeai/frontend/web/public/locales/en.json index f8ee57a94d5..763b7db7dbb 100644 --- a/invokeai/frontend/web/public/locales/en.json +++ b/invokeai/frontend/web/public/locales/en.json @@ -305,7 +305,6 @@ "workflows": "Workflows", "other": "Other", "gallery": "Gallery", - "batchFieldValues": "Batch Field Values", "item": "Item", "session": "Session", "notReady": "Unable to Queue", @@ -324,7 +323,13 @@ "iterations_other": "Iterations", "generations_one": "Generation", "generations_other": "Generations", - "batchSize": "Batch Size" + "batchSize": "Batch Size", + "createdAt": "Created At", + "completedAt": "Completed At", + "sortColumn": "Sort Column", + "sortBy": "Sort by {{column}}", + "sortOrderAscending": "Ascending", + "sortOrderDescending": "Descending" }, "invocationCache": { "invocationCache": "Invocation Cache", diff --git a/invokeai/frontend/web/src/features/queue/components/QueueList/QueueItemComponent.tsx b/invokeai/frontend/web/src/features/queue/components/QueueList/QueueItemComponent.tsx index 3f39c5e8be6..de415b899fd 100644 --- a/invokeai/frontend/web/src/features/queue/components/QueueList/QueueItemComponent.tsx +++ b/invokeai/frontend/web/src/features/queue/components/QueueList/QueueItemComponent.tsx @@ -1,5 +1,5 @@ -import type { ChakraProps, CollapseProps } from '@invoke-ai/ui-library'; -import { Badge, ButtonGroup, Collapse, Flex, IconButton, Text } from '@invoke-ai/ui-library'; +import type { ChakraProps, CollapseProps, FlexProps } from '@invoke-ai/ui-library'; +import { Badge, ButtonGroup, Collapse, Flex, IconButton, Spacer, Text } from '@invoke-ai/ui-library'; import QueueStatusBadge from 'features/queue/components/common/QueueStatusBadge'; import { useDestinationText } from 'features/queue/components/QueueList/useDestinationText'; import { useOriginText } from 'features/queue/components/QueueList/useOriginText'; @@ -9,7 +9,7 @@ import { getSecondsFromTimestamps } from 'features/queue/util/getSecondsFromTime import { useFeatureStatus } from 'features/system/hooks/useFeatureStatus'; import { selectShouldShowCredits } from 'features/system/store/configSlice'; import type { MouseEvent } from 'react'; -import { memo, useCallback, useMemo } from 'react'; +import { memo, useCallback, useMemo, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { PiArrowCounterClockwiseBold, PiXBold } from 'react-icons/pi'; import { useSelector } from 'react-redux'; @@ -17,14 +17,12 @@ import type { S } from 'services/api/types'; import { COLUMN_WIDTHS } from './constants'; import QueueItemDetail from './QueueItemDetail'; -import type { ListContext } from './types'; const selectedStyles = { bg: 'base.700' }; type InnerItemProps = { index: number; item: S['SessionQueueItem']; - context: ListContext; }; const sx: ChakraProps['sx'] = { @@ -32,12 +30,11 @@ const sx: ChakraProps['sx'] = { "&[aria-selected='true']": selectedStyles, }; -const QueueItemComponent = ({ index, item, context }: InnerItemProps) => { +const QueueItemComponent = ({ index, item }: InnerItemProps) => { const { t } = useTranslation(); const isRetryEnabled = useFeatureStatus('retryQueueItem'); - const handleToggle = useCallback(() => { - context.toggleQueueItem(item.item_id); - }, [context, item.item_id]); + const [isOpen, setIsOpen] = useState(false); + const handleToggle = useCallback(() => setIsOpen((s) => !s), [setIsOpen]); const cancelQueueItem = useCancelQueueItem(); const onClickCancelQueueItem = useCallback( (e: MouseEvent) => { @@ -54,7 +51,6 @@ const QueueItemComponent = ({ index, item, context }: InnerItemProps) => { }, [item.item_id, retryQueueItem] ); - const isOpen = useMemo(() => context.openQueueItems.includes(item.item_id), [context.openQueueItems, item.item_id]); const executionTime = useMemo(() => { if (!item.completed_at || !item.started_at) { @@ -83,12 +79,18 @@ const QueueItemComponent = ({ index, item, context }: InnerItemProps) => { data-testid="queue-item" > - + {index + 1} + + {item.created_at} + + + {item.completed_at || '-'} + {originText} @@ -112,25 +114,12 @@ const QueueItemComponent = ({ index, item, context }: InnerItemProps) => { {item.batch_id} - - {item.field_values && ( - - {item.field_values - .filter((v) => v.node_path !== 'metadata_accumulator') - .map(({ node_path, field_name, value }) => ( - - - {node_path}.{field_name} - - : {JSON.stringify(value)} - - ))} - - )} - {isValidationRun && {t('workflows.builder.publishingValidationRun')}} + + + {(!isFailed || !isRetryEnabled || isValidationRun) && ( @@ -167,3 +156,9 @@ const transition: CollapseProps['transition'] = { }; export default memo(QueueItemComponent); + +export const QueueItemPlaceholder = memo((props: FlexProps) => ( + +)); + +QueueItemPlaceholder.displayName = 'QueueItemPlaceholder'; diff --git a/invokeai/frontend/web/src/features/queue/components/QueueList/QueueList.tsx b/invokeai/frontend/web/src/features/queue/components/QueueList/QueueList.tsx index 06137aa409b..7e3b58bf049 100644 --- a/invokeai/frontend/web/src/features/queue/components/QueueList/QueueList.tsx +++ b/invokeai/frontend/web/src/features/queue/components/QueueList/QueueList.tsx @@ -1,108 +1,119 @@ import { Flex, Heading } from '@invoke-ai/ui-library'; -import { useAppDispatch, useAppSelector } from 'app/store/storeHooks'; import { IAINoContentFallbackWithSpinner } from 'common/components/IAIImageFallback'; -import { overlayScrollbarsParams } from 'common/components/OverlayScrollbars/constants'; -import { - listCursorChanged, - listPriorityChanged, - selectQueueListCursor, - selectQueueListPriority, -} from 'features/queue/store/queueSlice'; -import { useOverlayScrollbars } from 'overlayscrollbars-react'; -import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { useRangeBasedQueueItemFetching } from 'features/queue/hooks/useRangeBasedQueueItemFetching'; +import { memo, useCallback, useMemo, useRef } from 'react'; import { useTranslation } from 'react-i18next'; -import type { Components, ItemContent } from 'react-virtuoso'; +import type { + Components, + ComputeItemKey, + ItemContent, + ListRange, + ScrollSeekConfiguration, + VirtuosoHandle, +} from 'react-virtuoso'; import { Virtuoso } from 'react-virtuoso'; -import { queueItemsAdapterSelectors, useListQueueItemsQuery } from 'services/api/endpoints/queue'; -import type { S } from 'services/api/types'; +import { queueApi } from 'services/api/endpoints/queue'; -import QueueItemComponent from './QueueItemComponent'; +import QueueItemComponent, { QueueItemPlaceholder } from './QueueItemComponent'; import QueueListComponent from './QueueListComponent'; import QueueListHeader from './QueueListHeader'; import type { ListContext } from './types'; +import { useQueueItemIds } from './useQueueItemIds'; +import { useScrollableQueueList } from './useScrollableQueueList'; + +const QueueItemAtPosition = memo(({ index, itemId }: { index: number; itemId: number }) => { + /* + * We rely on the useRangeBasedQueueItemFetching to fetch all queue items, caching them with RTK Query. + * + * In this component, we just want to consume that cache. Unforutnately, RTK Query does not provide a way to + * subscribe to a query without triggering a new fetch. + * + * There is a hack, though: + * - https://github.com/reduxjs/redux-toolkit/discussions/4213 + * + * This essentially means "subscribe to the query once it has some data". + */ + + // Use `currentData` instead of `data` to prevent a flash of previous queue item rendered at this index + const { currentData: queueItem, isUninitialized } = queueApi.endpoints.getQueueItem.useQueryState(itemId); + queueApi.endpoints.getQueueItem.useQuerySubscription(itemId, { skip: isUninitialized }); + + if (!queueItem) { + return ; + } + + return ; +}); +QueueItemAtPosition.displayName = 'QueueItemAtPosition'; -// eslint-disable-next-line @typescript-eslint/no-explicit-any -type TableVirtuosoScrollerRef = (ref: HTMLElement | Window | null) => any; +const computeItemKey: ComputeItemKey = (index: number, itemId: number, context: ListContext) => { + return `${JSON.stringify(context.queryArgs)}-${itemId ?? index}`; +}; -const computeItemKey = (index: number, item: S['SessionQueueItem']): number => item.item_id; +const itemContent: ItemContent = (index, itemId) => ( + +); -const components: Components = { +const ScrollSeekPlaceholderComponent: Components['ScrollSeekPlaceholder'] = (_props) => { + return ( + + + + ); +}; + +ScrollSeekPlaceholderComponent.displayName = 'ScrollSeekPlaceholderComponent'; + +const components: Components = { List: QueueListComponent, + ScrollSeekPlaceholder: ScrollSeekPlaceholderComponent, }; -const itemContent: ItemContent = (index, item, context) => ( - -); +const scrollSeekConfiguration: ScrollSeekConfiguration = { + enter: (velocity) => { + return Math.abs(velocity) > 2048; + }, + exit: (velocity) => { + return velocity === 0; + }, +}; -const QueueList = () => { - const listCursor = useAppSelector(selectQueueListCursor); - const listPriority = useAppSelector(selectQueueListPriority); - const dispatch = useAppDispatch(); +export const QueueList = () => { + const virtuosoRef = useRef(null); + const rangeRef = useRef({ startIndex: 0, endIndex: 0 }); const rootRef = useRef(null); - const [scroller, setScroller] = useState(null); - const [initialize, osInstance] = useOverlayScrollbars(overlayScrollbarsParams); const { t } = useTranslation(); - useEffect(() => { - const { current: root } = rootRef; - if (scroller && root) { - initialize({ - target: root, - elements: { - viewport: scroller, - }, - }); - } - return () => osInstance()?.destroy(); - }, [scroller, initialize, osInstance]); - - const { data: listQueueItemsData, isLoading } = useListQueueItemsQuery( - { - cursor: listCursor, - priority: listPriority, + // Get the ordered list of queue item ids - this is our primary data source for virtualization + const { queryArgs, itemIds, isLoading } = useQueueItemIds(); + + // Use range-based fetching for bulk loading queue items into cache based on the visible range + const { onRangeChanged } = useRangeBasedQueueItemFetching({ + itemIds, + enabled: !isLoading, + }); + + const scrollerRef = useScrollableQueueList(rootRef) as (ref: HTMLElement | Window | null) => void; + + /* + * We have to keep track of the visible range for keep-selected-image-in-view functionality and push the range to + * the range-based queue item fetching hook. + */ + const handleRangeChanged = useCallback( + (range: ListRange) => { + rangeRef.current = range; + onRangeChanged(range); }, - { - refetchOnMountOrArgChange: true, - } + [onRangeChanged] ); - const queueItems = useMemo(() => { - if (!listQueueItemsData) { - return []; - } - return queueItemsAdapterSelectors.selectAll(listQueueItemsData); - }, [listQueueItemsData]); - - const handleLoadMore = useCallback(() => { - if (!listQueueItemsData?.has_more) { - return; - } - const lastItem = queueItems[queueItems.length - 1]; - if (!lastItem) { - return; - } - dispatch(listCursorChanged(lastItem.item_id)); - dispatch(listPriorityChanged(lastItem.priority)); - }, [dispatch, listQueueItemsData?.has_more, queueItems]); - - const [openQueueItems, setOpenQueueItems] = useState([]); - - const toggleQueueItem = useCallback((item_id: number) => { - setOpenQueueItems((prev) => { - if (prev.includes(item_id)) { - return prev.filter((id) => id !== item_id); - } - return [...prev, item_id]; - }); - }, []); - - const context = useMemo(() => ({ openQueueItems, toggleQueueItem }), [openQueueItems, toggleQueueItem]); + const context = useMemo(() => ({ queryArgs }), [queryArgs]); if (isLoading) { return ; } - if (!queueItems.length) { + if (!itemIds.length) { return ( {t('queue.queueEmpty')} @@ -114,18 +125,19 @@ const QueueList = () => { - - data={queueItems} - endReached={handleLoadMore} - scrollerRef={setScroller as TableVirtuosoScrollerRef} + + ref={virtuosoRef} + context={context} + data={itemIds} + increaseViewportBy={512} itemContent={itemContent} computeItemKey={computeItemKey} components={components} - context={context} + scrollerRef={scrollerRef} + scrollSeekConfiguration={scrollSeekConfiguration} + rangeChanged={handleRangeChanged} /> ); }; - -export default memo(QueueList); diff --git a/invokeai/frontend/web/src/features/queue/components/QueueList/QueueListHeader.tsx b/invokeai/frontend/web/src/features/queue/components/QueueList/QueueListHeader.tsx index c3b6ab2e097..c28b8f77a02 100644 --- a/invokeai/frontend/web/src/features/queue/components/QueueList/QueueListHeader.tsx +++ b/invokeai/frontend/web/src/features/queue/components/QueueList/QueueListHeader.tsx @@ -1,14 +1,16 @@ -import { Flex, Text } from '@invoke-ai/ui-library'; +import { Flex } from '@invoke-ai/ui-library'; import { selectShouldShowCredits } from 'features/system/store/configSlice'; import { memo } from 'react'; import { useTranslation } from 'react-i18next'; import { useSelector } from 'react-redux'; import { COLUMN_WIDTHS } from './constants'; +import QueueListHeaderColumn from './QueueListHeaderColumn'; const QueueListHeader = () => { const { t } = useTranslation(); const shouldShowCredits = useSelector(selectShouldShowCredits); + return ( { fontSize="sm" letterSpacing={1} > - - # - - - {t('queue.status')} - - - {t('queue.origin')} - - - {t('queue.destination')} - - - {t('queue.time')} - + + + + + + + {shouldShowCredits && ( - - {t('queue.credits')} - + )} - - {t('queue.batch')} - - - {t('queue.batchFieldValues')} - + ); }; diff --git a/invokeai/frontend/web/src/features/queue/components/QueueList/QueueListHeaderColumn.tsx b/invokeai/frontend/web/src/features/queue/components/QueueList/QueueListHeaderColumn.tsx new file mode 100644 index 00000000000..4b73c24e921 --- /dev/null +++ b/invokeai/frontend/web/src/features/queue/components/QueueList/QueueListHeaderColumn.tsx @@ -0,0 +1,102 @@ +import { Flex, IconButton, Text } from '@invoke-ai/ui-library'; +import { useAppDispatch } from 'app/store/storeHooks'; +import type * as CSS from 'csstype'; +import type { SortBy } from 'features/queue/store/queueSlice'; +import { + selectQueueSortBy, + selectQueueSortOrder, + sortByChanged, + sortOrderChanged, +} from 'features/queue/store/queueSlice'; +import { memo, useCallback, useMemo, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { PiSortAscendingBold, PiSortDescendingBold } from 'react-icons/pi'; +import { useSelector } from 'react-redux'; + +type QueueListHeaderColumnProps = { + field?: SortBy; + displayName: string; + alignItems?: CSS.Property.AlignItems; + ps?: CSS.Property.PaddingInlineStart | number; + w?: CSS.Property.Width | number; +}; + +const QueueListHeaderColumn = ({ field, displayName, alignItems, ps, w }: QueueListHeaderColumnProps) => { + const [isMouseHoveringColumn, setIsMouseHoveringColumn] = useState(false); + + const handleMouseEnterColumn = useCallback(() => { + setIsMouseHoveringColumn(true); + }, [setIsMouseHoveringColumn]); + const handleMouseLeaveColumn = useCallback(() => { + setIsMouseHoveringColumn(false); + }, [setIsMouseHoveringColumn]); + + return ( + + {displayName} + {!!field && ( + + )} + + ); +}; + +export default memo(QueueListHeaderColumn); + +type ColumnSortIconProps = { + field: SortBy; + displayName: string; + isMouseHoveringColumn: boolean; +}; + +const ColumnSortIcon = memo(({ field, displayName, isMouseHoveringColumn }: ColumnSortIconProps) => { + const { t } = useTranslation(); + const dispatch = useAppDispatch(); + + const sortBy = useSelector(selectQueueSortBy); + const sortOrder = useSelector(selectQueueSortOrder); + const isSortByColumn = useMemo(() => sortBy === field, [sortBy, field]); + const isShown = useMemo(() => isSortByColumn || isMouseHoveringColumn, [isSortByColumn, isMouseHoveringColumn]); + const tooltip = useMemo(() => { + if (isSortByColumn) { + return sortOrder === 'ASC' ? t('queue.sortOrderAscending') : t('queue.sortOrderDescending'); + } + return t('queue.sortBy', { column: displayName }); + }, [isSortByColumn, sortOrder, t, displayName]); + + // PiSortDescendingBold is used for ascending because the arrow points up + const icon = useMemo(() => (sortOrder === 'ASC' ? : ), [sortOrder]); + + const handleClickSortColumn = useCallback(() => { + if (isSortByColumn) { + if (sortOrder === 'ASC') { + dispatch(sortOrderChanged('DESC')); + } else { + dispatch(sortOrderChanged('ASC')); + } + } else { + dispatch(sortByChanged(field)); + } + }, [isSortByColumn, sortOrder, dispatch, field]); + + return ( + isShown && ( + + ) + ); +}); +ColumnSortIcon.displayName = 'ColumnSortIcon'; diff --git a/invokeai/frontend/web/src/features/queue/components/QueueList/constants.ts b/invokeai/frontend/web/src/features/queue/components/QueueList/constants.ts index 41d9047d9c4..e703278ae6d 100644 --- a/invokeai/frontend/web/src/features/queue/components/QueueList/constants.ts +++ b/invokeai/frontend/web/src/features/queue/components/QueueList/constants.ts @@ -1,13 +1,14 @@ export const COLUMN_WIDTHS = { - number: '3rem', + number: '2rem', statusBadge: '5.7rem', statusDot: 2, time: '4rem', credits: '4rem', origin: '5rem', destination: '6rem', - batchId: '5rem', - fieldValues: 'auto', + batchId: 'auto', + createdAt: '9.5rem', + completedAt: '9.5rem', validationRun: 'auto', actions: 'auto', } as const; diff --git a/invokeai/frontend/web/src/features/queue/components/QueueList/types.ts b/invokeai/frontend/web/src/features/queue/components/QueueList/types.ts index c9b317ac57a..79a62b3113f 100644 --- a/invokeai/frontend/web/src/features/queue/components/QueueList/types.ts +++ b/invokeai/frontend/web/src/features/queue/components/QueueList/types.ts @@ -1,4 +1,5 @@ +import type { GetQueueItemIdsArgs } from 'services/api/types'; + export type ListContext = { - openQueueItems: number[]; - toggleQueueItem: (item_id: number) => void; + queryArgs: GetQueueItemIdsArgs; }; diff --git a/invokeai/frontend/web/src/features/queue/components/QueueList/useQueueItemIds.ts b/invokeai/frontend/web/src/features/queue/components/QueueList/useQueueItemIds.ts new file mode 100644 index 00000000000..eee799d7507 --- /dev/null +++ b/invokeai/frontend/web/src/features/queue/components/QueueList/useQueueItemIds.ts @@ -0,0 +1,21 @@ +import { EMPTY_ARRAY } from 'app/store/constants'; +import { useAppSelector } from 'app/store/storeHooks'; +import { selectGetQueueItemIdsArgs } from 'features/queue/store/queueSlice'; +import { useGetQueueItemIdsQuery } from 'services/api/endpoints/queue'; +import { useDebounce } from 'use-debounce'; + +const getQueueItemIdsQueryOptions = { + refetchOnReconnect: true, + selectFromResult: ({ currentData, isLoading, isFetching }) => ({ + item_ids: currentData?.item_ids ?? EMPTY_ARRAY, + isLoading, + isFetching, + }), +} satisfies Parameters[1]; + +export const useQueueItemIds = () => { + const _queryArgs = useAppSelector(selectGetQueueItemIdsArgs); + const [queryArgs] = useDebounce(_queryArgs, 300); + const { item_ids, isLoading, isFetching } = useGetQueueItemIdsQuery(queryArgs, getQueueItemIdsQueryOptions); + return { queryArgs, itemIds: item_ids, isLoading, isFetching }; +}; diff --git a/invokeai/frontend/web/src/features/queue/components/QueueList/useScrollableQueueList.ts b/invokeai/frontend/web/src/features/queue/components/QueueList/useScrollableQueueList.ts new file mode 100644 index 00000000000..7cc2ff0c97a --- /dev/null +++ b/invokeai/frontend/web/src/features/queue/components/QueueList/useScrollableQueueList.ts @@ -0,0 +1,48 @@ +import { useOverlayScrollbars } from 'overlayscrollbars-react'; +import type { RefObject } from 'react'; +import { useEffect, useState } from 'react'; + +/** + * Handles the initialization of the overlay scrollbars for the queue list, returning the ref to the scroller element. + */ +export const useScrollableQueueList = (rootRef: RefObject) => { + const [scroller, scrollerRef] = useState(null); + const [initialize, osInstance] = useOverlayScrollbars({ + defer: true, + events: { + initialized(osInstance) { + // force overflow styles + const { viewport } = osInstance.elements(); + viewport.style.overflowX = `var(--os-viewport-overflow-x)`; + viewport.style.overflowY = `var(--os-viewport-overflow-y)`; + }, + }, + options: { + scrollbars: { + visibility: 'auto', + autoHide: 'scroll', + autoHideDelay: 1300, + theme: 'os-theme-dark', + }, + }, + }); + + useEffect(() => { + const { current: root } = rootRef; + + if (scroller && root) { + initialize({ + target: root, + elements: { + viewport: scroller, + }, + }); + } + + return () => { + osInstance()?.destroy(); + }; + }, [scroller, initialize, osInstance, rootRef]); + + return scrollerRef; +}; diff --git a/invokeai/frontend/web/src/features/queue/components/QueueTabContent.tsx b/invokeai/frontend/web/src/features/queue/components/QueueTabContent.tsx index 2dae5e6ebe4..96edb2685be 100644 --- a/invokeai/frontend/web/src/features/queue/components/QueueTabContent.tsx +++ b/invokeai/frontend/web/src/features/queue/components/QueueTabContent.tsx @@ -3,7 +3,7 @@ import { useFeatureStatus } from 'features/system/hooks/useFeatureStatus'; import { memo } from 'react'; import InvocationCacheStatus from './InvocationCacheStatus'; -import QueueList from './QueueList/QueueList'; +import { QueueList } from './QueueList/QueueList'; import QueueStatus from './QueueStatus'; import QueueTabQueueControls from './QueueTabQueueControls'; diff --git a/invokeai/frontend/web/src/features/queue/hooks/useClearQueue.ts b/invokeai/frontend/web/src/features/queue/hooks/useClearQueue.ts index 9c24448ed41..a81f7254be3 100644 --- a/invokeai/frontend/web/src/features/queue/hooks/useClearQueue.ts +++ b/invokeai/frontend/web/src/features/queue/hooks/useClearQueue.ts @@ -1,6 +1,4 @@ import { useStore } from '@nanostores/react'; -import { useAppDispatch } from 'app/store/storeHooks'; -import { listCursorChanged, listPriorityChanged } from 'features/queue/store/queueSlice'; import { toast } from 'features/toast/toast'; import { useCallback } from 'react'; import { useTranslation } from 'react-i18next'; @@ -9,7 +7,6 @@ import { $isConnected } from 'services/events/stores'; export const useClearQueue = () => { const { t } = useTranslation(); - const dispatch = useAppDispatch(); const { data: queueStatus } = useGetQueueStatusQuery(); const isConnected = useStore($isConnected); const [_trigger, { isLoading }] = useClearQueueMutation({ @@ -28,8 +25,6 @@ export const useClearQueue = () => { title: t('queue.clearSucceeded'), status: 'success', }); - dispatch(listCursorChanged(undefined)); - dispatch(listPriorityChanged(undefined)); } catch { toast({ id: 'QUEUE_CLEAR_FAILED', @@ -37,7 +32,7 @@ export const useClearQueue = () => { status: 'error', }); } - }, [queueStatus?.queue.total, _trigger, dispatch, t]); + }, [queueStatus?.queue.total, _trigger, t]); return { trigger, isLoading, isDisabled: !isConnected || !queueStatus?.queue.total }; }; diff --git a/invokeai/frontend/web/src/features/queue/hooks/usePruneQueue.ts b/invokeai/frontend/web/src/features/queue/hooks/usePruneQueue.ts index c186db96df1..86f134a006a 100644 --- a/invokeai/frontend/web/src/features/queue/hooks/usePruneQueue.ts +++ b/invokeai/frontend/web/src/features/queue/hooks/usePruneQueue.ts @@ -1,6 +1,4 @@ import { useStore } from '@nanostores/react'; -import { useAppDispatch } from 'app/store/storeHooks'; -import { listCursorChanged, listPriorityChanged } from 'features/queue/store/queueSlice'; import { toast } from 'features/toast/toast'; import { useCallback } from 'react'; import { useTranslation } from 'react-i18next'; @@ -8,7 +6,6 @@ import { useGetQueueStatusQuery, usePruneQueueMutation } from 'services/api/endp import { $isConnected } from 'services/events/stores'; export const usePruneQueue = () => { - const dispatch = useAppDispatch(); const { t } = useTranslation(); const isConnected = useStore($isConnected); const finishedCount = useFinishedCount(); @@ -24,8 +21,6 @@ export const usePruneQueue = () => { title: t('queue.pruneSucceeded', { item_count: data.deleted }), status: 'success', }); - dispatch(listCursorChanged(undefined)); - dispatch(listPriorityChanged(undefined)); } catch { toast({ id: 'PRUNE_FAILED', @@ -33,7 +28,7 @@ export const usePruneQueue = () => { status: 'error', }); } - }, [_trigger, dispatch, t]); + }, [_trigger, t]); return { trigger, isLoading, isDisabled: !isConnected || !finishedCount }; }; diff --git a/invokeai/frontend/web/src/features/queue/hooks/useRangeBasedQueueItemFetching.ts b/invokeai/frontend/web/src/features/queue/hooks/useRangeBasedQueueItemFetching.ts new file mode 100644 index 00000000000..b2d4c4ac813 --- /dev/null +++ b/invokeai/frontend/web/src/features/queue/hooks/useRangeBasedQueueItemFetching.ts @@ -0,0 +1,77 @@ +import { useAppStore } from 'app/store/storeHooks'; +import { useCallback, useEffect, useState } from 'react'; +import type { ListRange } from 'react-virtuoso'; +import { queueApi, useGetQueueItemDTOsByItemIdsMutation } from 'services/api/endpoints/queue'; +import { useThrottledCallback } from 'use-debounce'; + +interface UseRangeBasedQueueItemFetchingArgs { + itemIds: number[]; + enabled: boolean; +} + +interface UseRangeBasedQueueItemFetchingReturn { + onRangeChanged: (range: ListRange) => void; +} + +const getUncachedItemIds = (itemIds: number[], cachedItemIds: number[], ranges: ListRange[]): number[] => { + const uncachedItemIdsSet = new Set(); + const cachedItemIdsSet = new Set(cachedItemIds); + + for (const range of ranges) { + for (let i = range.startIndex; i <= range.endIndex; i++) { + const n = itemIds[i]!; + if (n && !cachedItemIdsSet.has(n)) { + uncachedItemIdsSet.add(n); + } + } + } + + return Array.from(uncachedItemIdsSet); +}; + +/** + * Hook for bulk fetching queue items based on the visible range from virtuoso. + * Individual quite item components should use `useGetQueueItemQuery(item_id)` to get their specific DTO. + * This hook ensures DTOs are bulk fetched and cached efficiently. + */ +export const useRangeBasedQueueItemFetching = ({ + itemIds, + enabled, +}: UseRangeBasedQueueItemFetchingArgs): UseRangeBasedQueueItemFetchingReturn => { + const store = useAppStore(); + const [getQueueItemDTOsByItemIds] = useGetQueueItemDTOsByItemIdsMutation(); + const [lastRange, setLastRange] = useState(null); + const [pendingRanges, setPendingRanges] = useState([]); + + const fetchQueueItems = useCallback( + (ranges: ListRange[], itemIds: number[]) => { + if (!enabled) { + return; + } + const cachedItemIds = queueApi.util.selectCachedArgsForQuery(store.getState(), 'getQueueItem'); + const uncachedItemIds = getUncachedItemIds(itemIds, cachedItemIds, ranges); + if (uncachedItemIds.length === 0) { + return; + } + getQueueItemDTOsByItemIds({ item_ids: uncachedItemIds }); + setPendingRanges([]); + }, + [enabled, getQueueItemDTOsByItemIds, store] + ); + + const throttledFetchQueueItems = useThrottledCallback(fetchQueueItems, 500); + + const onRangeChanged = useCallback((range: ListRange) => { + setLastRange(range); + setPendingRanges((prev) => [...prev, range]); + }, []); + + useEffect(() => { + const combinedRanges = lastRange ? [...pendingRanges, lastRange] : pendingRanges; + throttledFetchQueueItems(combinedRanges, itemIds); + }, [itemIds, lastRange, pendingRanges, throttledFetchQueueItems]); + + return { + onRangeChanged, + }; +}; diff --git a/invokeai/frontend/web/src/features/queue/store/queueSlice.ts b/invokeai/frontend/web/src/features/queue/store/queueSlice.ts index b6789400f87..7642005f173 100644 --- a/invokeai/frontend/web/src/features/queue/store/queueSlice.ts +++ b/invokeai/frontend/web/src/features/queue/store/queueSlice.ts @@ -1,42 +1,39 @@ import type { PayloadAction, Selector } from '@reduxjs/toolkit'; import { createSelector, createSlice } from '@reduxjs/toolkit'; +import { createMemoizedSelector } from 'app/store/createMemoizedSelector'; import type { RootState } from 'app/store/store'; import type { SliceConfig } from 'app/store/types'; +import { type GetQueueItemIdsArgs, zSQLiteDirection } from 'services/api/types'; import z from 'zod'; +const zSortBy = z.enum(['created_at', 'completed_at']); +export type SortBy = z.infer; + const zQueueState = z.object({ - listCursor: z.number().optional(), - listPriority: z.number().optional(), - selectedQueueItem: z.string().optional(), - resumeProcessorOnEnqueue: z.boolean(), + sortBy: zSortBy, + sortOrder: zSQLiteDirection, }); type QueueState = z.infer; const getInitialState = (): QueueState => ({ - listCursor: undefined, - listPriority: undefined, - selectedQueueItem: undefined, - resumeProcessorOnEnqueue: true, + sortBy: 'created_at', + sortOrder: 'DESC', }); const slice = createSlice({ name: 'queue', initialState: getInitialState(), reducers: { - listCursorChanged: (state, action: PayloadAction) => { - state.listCursor = action.payload; - }, - listPriorityChanged: (state, action: PayloadAction) => { - state.listPriority = action.payload; + sortByChanged: (state, action: PayloadAction) => { + state.sortBy = action.payload; }, - listParamsReset: (state) => { - state.listCursor = undefined; - state.listPriority = undefined; + sortOrderChanged: (state, action: PayloadAction<'ASC' | 'DESC'>) => { + state.sortOrder = action.payload; }, }, }); -export const { listCursorChanged, listPriorityChanged, listParamsReset } = slice.actions; +export const { sortByChanged, sortOrderChanged } = slice.actions; export const queueSliceConfig: SliceConfig = { slice, @@ -46,5 +43,12 @@ export const queueSliceConfig: SliceConfig = { const selectQueueSlice = (state: RootState) => state.queue; const createQueueSelector = (selector: Selector) => createSelector(selectQueueSlice, selector); -export const selectQueueListCursor = createQueueSelector((queue) => queue.listCursor); -export const selectQueueListPriority = createQueueSelector((queue) => queue.listPriority); +export const selectQueueSortBy = createQueueSelector((queue) => queue.sortBy); +export const selectQueueSortOrder = createQueueSelector((queue) => queue.sortOrder); +export const selectGetQueueItemIdsArgs = createMemoizedSelector( + [selectQueueSortBy, selectQueueSortOrder], + (order_by, order_dir): GetQueueItemIdsArgs => ({ + order_by, + order_dir, + }) +); diff --git a/invokeai/frontend/web/src/services/api/endpoints/queue.ts b/invokeai/frontend/web/src/services/api/endpoints/queue.ts index 741ac011305..81027d4f2b0 100644 --- a/invokeai/frontend/web/src/services/api/endpoints/queue.ts +++ b/invokeai/frontend/web/src/services/api/endpoints/queue.ts @@ -1,10 +1,14 @@ -import type { EntityState, ThunkDispatch, UnknownAction } from '@reduxjs/toolkit'; -import { createEntityAdapter } from '@reduxjs/toolkit'; -import { getSelectorsOptions } from 'app/store/createMemoizedSelector'; import { $queueId } from 'app/store/nanostores/queueId'; -import { listParamsReset } from 'features/queue/store/queueSlice'; import queryString from 'query-string'; import type { components, paths } from 'services/api/schema'; +import type { + GetQueueItemDTOsByItemIdsArgs, + GetQueueItemDTOsByItemIdsResult, + GetQueueItemIdsArgs, + GetQueueItemIdsResult, +} from 'services/api/types'; +import stableHash from 'stable-hash'; +import type { Param0 } from 'tsafe'; import type { ApiTagDescription } from '..'; import { api, buildV1Url, LIST_ALL_TAG, LIST_TAG } from '..'; @@ -17,47 +21,7 @@ import { api, buildV1Url, LIST_ALL_TAG, LIST_TAG } from '..'; */ const buildQueueUrl = (path: string = '') => buildV1Url(`queue/${$queueId.get()}/${path}`); -const getListQueueItemsUrl = (queryArgs?: paths['/api/v1/queue/{queue_id}/list']['get']['parameters']['query']) => { - const query = queryArgs - ? queryString.stringify(queryArgs, { - arrayFormat: 'none', - }) - : undefined; - - if (query) { - return buildQueueUrl(`list?${query}`); - } - - return buildQueueUrl('list'); -}; - -export type SessionQueueItemStatus = NonNullable< - NonNullable['status'] ->; - -export const queueItemsAdapter = createEntityAdapter({ - selectId: (queueItem) => String(queueItem.item_id), - sortComparer: (a, b) => { - // Sort by priority in descending order - if (a.priority > b.priority) { - return -1; - } - if (a.priority < b.priority) { - return 1; - } - - // If priority is the same, sort by id in ascending order - if (a.item_id < b.item_id) { - return -1; - } - if (a.item_id > b.item_id) { - return 1; - } - - return 0; - }, -}); -export const queueItemsAdapterSelectors = queueItemsAdapter.getSelectors(undefined, getSelectorsOptions); +export type SessionQueueItemStatus = NonNullable; export const queueApi = api.injectEndpoints({ endpoints: (build) => ({ @@ -74,6 +38,7 @@ export const queueApi = api.injectEndpoints({ 'CurrentSessionQueueItem', 'NextSessionQueueItem', 'QueueCountsByDestination', + 'SessionQueueItemIdList', { type: 'SessionQueueItem', id: LIST_TAG }, { type: 'SessionQueueItem', id: LIST_ALL_TAG }, ], @@ -81,7 +46,6 @@ export const queueApi = api.injectEndpoints({ const { dispatch, queryFulfilled } = api; try { const { data } = await queryFulfilled; - resetListQueryData(dispatch); dispatch( queueApi.util.updateQueryData('getQueueStatus', undefined, (draft) => { draft.queue.in_progress += data.item_ids.length; @@ -124,18 +88,10 @@ export const queueApi = api.injectEndpoints({ invalidatesTags: [ 'SessionQueueStatus', 'BatchStatus', + 'SessionQueueItemIdList', { type: 'SessionQueueItem', id: LIST_TAG }, { type: 'SessionQueueItem', id: LIST_ALL_TAG }, ], - onQueryStarted: async (arg, api) => { - const { dispatch, queryFulfilled } = api; - try { - await queryFulfilled; - resetListQueryData(dispatch); - } catch { - // no-op - } - }, }), clearQueue: build.mutation< paths['/api/v1/queue/{queue_id}/clear']['put']['responses']['200']['content']['application/json'], @@ -152,18 +108,10 @@ export const queueApi = api.injectEndpoints({ 'CurrentSessionQueueItem', 'NextSessionQueueItem', 'QueueCountsByDestination', + 'SessionQueueItemIdList', { type: 'SessionQueueItem', id: LIST_TAG }, { type: 'SessionQueueItem', id: LIST_ALL_TAG }, ], - onQueryStarted: async (arg, api) => { - const { dispatch, queryFulfilled } = api; - try { - await queryFulfilled; - resetListQueryData(dispatch); - } catch { - // no-op - } - }, }), getCurrentQueueItem: build.query< paths['/api/v1/queue/{queue_id}/current']['get']['responses']['200']['content']['application/json'], @@ -235,6 +183,7 @@ export const queueApi = api.injectEndpoints({ const tags: ApiTagDescription[] = ['FetchOnReconnect']; if (result) { tags.push({ type: 'SessionQueueItem', id: result.item_id }); + tags.push({ type: 'BatchStatus', id: result.batch_id }); } return tags; }, @@ -247,25 +196,6 @@ export const queueApi = api.injectEndpoints({ url: buildQueueUrl(`i/${item_id}/cancel`), method: 'PUT', }), - onQueryStarted: async (item_id, { dispatch, queryFulfilled }) => { - try { - const { data } = await queryFulfilled; - dispatch( - queueApi.util.updateQueryData('listQueueItems', undefined, (draft) => { - queueItemsAdapter.updateOne(draft, { - id: String(item_id), - changes: { - status: data.status, - completed_at: data.completed_at, - updated_at: data.updated_at, - }, - }); - }) - ); - } catch { - // no-op - } - }, invalidatesTags: (result) => { if (!result) { return []; @@ -289,15 +219,6 @@ export const queueApi = api.injectEndpoints({ method: 'PUT', body, }), - onQueryStarted: async (arg, api) => { - const { dispatch, queryFulfilled } = api; - try { - await queryFulfilled; - resetListQueryData(dispatch); - } catch { - // no-op - } - }, invalidatesTags: (result, error, { batch_ids }) => { if (!result) { return []; @@ -353,15 +274,6 @@ export const queueApi = api.injectEndpoints({ method: 'PUT', body, }), - onQueryStarted: async (arg, api) => { - const { dispatch, queryFulfilled } = api; - try { - await queryFulfilled; - resetListQueryData(dispatch); - } catch { - // no-op - } - }, invalidatesTags: (result, error, item_ids) => { if (!result) { return []; @@ -376,33 +288,6 @@ export const queueApi = api.injectEndpoints({ ]; }, }), - listQueueItems: build.query< - EntityState & { - has_more: boolean; - }, - { cursor?: number; priority?: number } | undefined - >({ - query: (queryArgs) => ({ - url: getListQueueItemsUrl(queryArgs), - method: 'GET', - }), - serializeQueryArgs: () => { - return buildQueueUrl('list'); - }, - transformResponse: (response: components['schemas']['CursorPaginatedResults_SessionQueueItem_']) => - queueItemsAdapter.addMany( - queueItemsAdapter.getInitialState({ - has_more: response.has_more, - }), - response.items - ), - merge: (cache, response) => { - queueItemsAdapter.addMany(cache, queueItemsAdapterSelectors.selectAll(response)); - cache.has_more = response.has_more; - }, - forceRefetch: ({ currentArg, previousArg }) => currentArg !== previousArg, - keepUnusedDataFor: 60 * 5, // 5 minutes - }), listAllQueueItems: build.query< paths['/api/v1/queue/{queue_id}/list_all']['get']['responses']['200']['content']['application/json'], paths['/api/v1/queue/{queue_id}/list_all']['get']['parameters']['query'] @@ -428,6 +313,43 @@ export const queueApi = api.injectEndpoints({ return tags; }, }), + getQueueItemIds: build.query({ + query: (queryArgs) => ({ + url: buildQueueUrl(`item_ids?${queryString.stringify(queryArgs)}`), + method: 'GET', + }), + providesTags: (queryArgs) => [ + 'FetchOnReconnect', + 'SessionQueueItemIdList', + { type: 'SessionQueueItemIdList', id: stableHash(queryArgs) }, + ], + }), + getQueueItemDTOsByItemIds: build.mutation({ + query: (body) => ({ + url: buildQueueUrl('items_by_ids'), + method: 'POST', + body, + }), + // Don't provide cache tags - we'll manually upsert into individual getQueueItem caches + async onQueryStarted(_, { dispatch, queryFulfilled }) { + try { + const { data: queueItems } = await queryFulfilled; + + // Upsert each queue item into the individual item cache + const updates: Param0 = []; + for (const queueItem of queueItems) { + updates.push({ + endpointName: 'getQueueItem', + arg: queueItem.item_id, + value: queueItem, + }); + } + dispatch(queueApi.util.upsertQueryEntries(updates)); + } catch { + // Handle error if needed + } + }, + }), deleteQueueItem: build.mutation({ query: ({ item_id }) => ({ url: buildQueueUrl(`i/${item_id}`), @@ -483,7 +405,8 @@ export const { usePruneQueueMutation, useGetQueueStatusQuery, useGetQueueItemQuery, - useListQueueItemsQuery, + useGetQueueItemIdsQuery, + useGetQueueItemDTOsByItemIdsMutation, useCancelQueueItemMutation, useCancelQueueItemsByDestinationMutation, useGetCurrentQueueItemQuery, @@ -498,24 +421,6 @@ export const { export const selectQueueStatus = queueApi.endpoints.getQueueStatus.select(); -const resetListQueryData = ( - // eslint-disable-next-line @typescript-eslint/no-explicit-any - dispatch: ThunkDispatch -) => { - dispatch( - queueApi.util.updateQueryData('listQueueItems', undefined, (draft) => { - // remove all items from the list - queueItemsAdapter.removeAll(draft); - // reset the has_more flag - draft.has_more = false; - }) - ); - // set the list cursor and priority to undefined - dispatch(listParamsReset()); - // we have to manually kick off another query to get the first page and re-initialize the list - dispatch(queueApi.endpoints.listQueueItems.initiate(undefined)); -}; - export const enqueueMutationFixedCacheKeyOptions = { fixedCacheKey: 'enqueueBatch', } as const; diff --git a/invokeai/frontend/web/src/services/api/index.ts b/invokeai/frontend/web/src/services/api/index.ts index ecd2fdaa0da..c8876bbffa4 100644 --- a/invokeai/frontend/web/src/services/api/index.ts +++ b/invokeai/frontend/web/src/services/api/index.ts @@ -30,6 +30,7 @@ const tagTypes = [ 'ImageCollection', 'ImageMetadataFromFile', 'IntermediatesCount', + 'SessionQueueItemIdList', 'SessionQueueItem', 'SessionQueueStatus', 'SessionProcessorStatus', diff --git a/invokeai/frontend/web/src/services/api/schema.ts b/invokeai/frontend/web/src/services/api/schema.ts index 2b4a35bb60c..df0661610ba 100644 --- a/invokeai/frontend/web/src/services/api/schema.ts +++ b/invokeai/frontend/web/src/services/api/schema.ts @@ -1379,7 +1379,7 @@ export type paths = { patch?: never; trace?: never; }; - "/api/v1/queue/{queue_id}/list": { + "/api/v1/queue/{queue_id}/list_all": { parameters: { query?: never; header?: never; @@ -1387,10 +1387,10 @@ export type paths = { cookie?: never; }; /** - * List Queue Items - * @description Gets cursor-paginated queue items + * List All Queue Items + * @description Gets all queue items */ - get: operations["list_queue_items"]; + get: operations["list_all_queue_items"]; put?: never; post?: never; delete?: never; @@ -1399,7 +1399,7 @@ export type paths = { patch?: never; trace?: never; }; - "/api/v1/queue/{queue_id}/list_all": { + "/api/v1/queue/{queue_id}/item_ids": { parameters: { query?: never; header?: never; @@ -1407,10 +1407,10 @@ export type paths = { cookie?: never; }; /** - * List All Queue Items - * @description Gets all queue items + * Get Queue Item Ids + * @description Gets all queue item ids that match the given parameters */ - get: operations["list_all_queue_items"]; + get: operations["get_queue_itemIds"]; put?: never; post?: never; delete?: never; @@ -1419,6 +1419,26 @@ export type paths = { patch?: never; trace?: never; }; + "/api/v1/queue/{queue_id}/items_by_ids": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Get Queue Items By Item Ids + * @description Gets queue items for the specified queue item ids. Maintains order of item ids. + */ + post: operations["get_queue_items_by_item_ids"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/api/v1/queue/{queue_id}/processor/resume": { parameters: { query?: never; @@ -2975,6 +2995,14 @@ export type components = { */ image_names: string[]; }; + /** Body_get_queue_items_by_item_ids */ + Body_get_queue_items_by_item_ids: { + /** + * Item Ids + * @description Object containing list of queue item ids to fetch queue items for + */ + item_ids: number[]; + }; /** Body_get_videos_by_ids */ Body_get_videos_by_ids: { /** @@ -6158,24 +6186,6 @@ export type components = { */ type: "crop_latents"; }; - /** CursorPaginatedResults[SessionQueueItem] */ - CursorPaginatedResults_SessionQueueItem_: { - /** - * Limit - * @description Limit of items to get - */ - limit: number; - /** - * Has More - * @description Whether there are more items available - */ - has_more: boolean; - /** - * Items - * @description Items - */ - items: components["schemas"]["SessionQueueItem"][]; - }; /** * OpenCV Inpaint * @description Simple inpaint using opencv. @@ -13526,6 +13536,22 @@ export type components = { */ type: "invokeai_img_val_thresholds"; }; + /** + * ItemIdsResult + * @description Response containing ordered item ids with metadata for optimistic updates. + */ + ItemIdsResult: { + /** + * Item Ids + * @description Ordered list of item ids + */ + item_ids: number[]; + /** + * Total Count + * @description Total number of queue items matching the query + */ + total_count: number; + }; /** * IterateInvocation * @description Iterates over a list of items @@ -18078,15 +18104,13 @@ export type components = { /** * Created At * @description The timestamp when the queue item was created - * @default null */ - created_at: string | null; + created_at: string; /** * Updated At * @description The timestamp when the queue item was last updated - * @default null */ - updated_at: string | null; + updated_at: string; /** * Started At * @description The timestamp when the queue item was started @@ -25843,17 +25867,9 @@ export interface operations { }; }; }; - list_queue_items: { + list_all_queue_items: { parameters: { query?: { - /** @description The number of items to fetch */ - limit?: number; - /** @description The status of items to fetch */ - status?: ("pending" | "in_progress" | "completed" | "failed" | "canceled") | null; - /** @description The pagination cursor */ - cursor?: number | null; - /** @description The pagination cursor priority */ - priority?: number; /** @description The destination of queue items to fetch */ destination?: string | null; }; @@ -25872,7 +25888,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["CursorPaginatedResults_SessionQueueItem_"]; + "application/json": components["schemas"]["SessionQueueItem"][]; }; }; /** @description Validation Error */ @@ -25886,11 +25902,13 @@ export interface operations { }; }; }; - list_all_queue_items: { + get_queue_itemIds: { parameters: { query?: { - /** @description The destination of queue items to fetch */ - destination?: string | null; + /** @description The sort field */ + order_by?: "created_at" | "completed_at"; + /** @description The order of sort */ + order_dir?: components["schemas"]["SQLiteDirection"]; }; header?: never; path: { @@ -25900,6 +25918,42 @@ export interface operations { cookie?: never; }; requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ItemIdsResult"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + get_queue_items_by_item_ids: { + parameters: { + query?: never; + header?: never; + path: { + /** @description The queue id to perform this operation on */ + queue_id: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["Body_get_queue_items_by_item_ids"]; + }; + }; responses: { /** @description Successful Response */ 200: { diff --git a/invokeai/frontend/web/src/services/api/types.ts b/invokeai/frontend/web/src/services/api/types.ts index 412c7ad5fdb..f794262820f 100644 --- a/invokeai/frontend/web/src/services/api/types.ts +++ b/invokeai/frontend/web/src/services/api/types.ts @@ -31,6 +31,15 @@ export type GraphAndWorkflowResponse = export type EnqueueBatchArg = paths['/api/v1/queue/{queue_id}/enqueue_batch']['post']['requestBody']['content']['application/json']; +export type GetQueueItemIdsResult = + paths['/api/v1/queue/{queue_id}/item_ids']['get']['responses']['200']['content']['application/json']; +export type GetQueueItemIdsArgs = NonNullable; + +export type GetQueueItemDTOsByItemIdsResult = + paths['/api/v1/queue/{queue_id}/items_by_ids']['post']['responses']['200']['content']['application/json']; +export type GetQueueItemDTOsByItemIdsArgs = + paths['/api/v1/queue/{queue_id}/items_by_ids']['post']['requestBody']['content']['application/json']; + export type InputFieldJSONSchemaExtra = S['InputFieldJSONSchemaExtra']; export type OutputFieldJSONSchemaExtra = S['OutputFieldJSONSchemaExtra']; export type InvocationJSONSchemaExtra = S['UIConfigBase']; diff --git a/invokeai/frontend/web/src/services/events/setEventListeners.tsx b/invokeai/frontend/web/src/services/events/setEventListeners.tsx index b02bd69e882..1db0e9b6f58 100644 --- a/invokeai/frontend/web/src/services/events/setEventListeners.tsx +++ b/invokeai/frontend/web/src/services/events/setEventListeners.tsx @@ -23,7 +23,7 @@ import { LRUCache } from 'lru-cache'; import type { ApiTagDescription } from 'services/api'; import { api, LIST_ALL_TAG, LIST_TAG } from 'services/api'; import { modelsApi } from 'services/api/endpoints/models'; -import { queueApi, queueItemsAdapter } from 'services/api/endpoints/queue'; +import { queueApi } from 'services/api/endpoints/queue'; import { workflowsApi } from 'services/api/endpoints/workflows'; import { buildOnInvocationComplete } from 'services/events/onInvocationComplete'; import { buildOnModelInstallError } from 'services/events/onModelInstallError'; @@ -364,20 +364,15 @@ export const setEventListeners = ({ socket, store, setIsConnected }: SetEventLis // // Update this specific queue item in the list of queue items dispatch( - queueApi.util.updateQueryData('listQueueItems', undefined, (draft) => { - queueItemsAdapter.updateOne(draft, { - id: String(item_id), - changes: { - status, - started_at, - updated_at: updated_at ?? undefined, - completed_at: completed_at ?? undefined, - error_type, - error_message, - error_traceback, - credits, - }, - }); + queueApi.util.updateQueryData('getQueueItem', item_id, (draft) => { + draft.status = status; + draft.started_at = started_at; + draft.updated_at = updated_at; + draft.completed_at = completed_at; + draft.error_type = error_type; + draft.error_message = error_message; + draft.error_traceback = error_traceback; + draft.credits = credits; }) );