feat(model manager): redesign queue

This commit is contained in:
joshistoast
2026-02-24 01:20:46 -07:00
parent 7aa3c95767
commit 2b61addb51
4 changed files with 266 additions and 123 deletions

View File

@@ -1,11 +1,30 @@
import { Box, Button, Flex, Heading } from '@invoke-ai/ui-library';
import type { SystemStyleObject } from '@invoke-ai/ui-library';
import {
Badge,
Box,
Button,
ButtonGroup,
Flex,
Heading,
IconButton,
Menu,
MenuButton,
MenuItem,
MenuList,
Table,
TableContainer,
Tbody,
Th,
Thead,
Tr,
} from '@invoke-ai/ui-library';
import { useStore } from '@nanostores/react';
import ScrollableContent from 'common/components/OverlayScrollbars/ScrollableContent';
import { getApiErrorDetail } from 'features/modelManagerV2/util/getApiErrorDetail';
import { toast } from 'features/toast/toast';
import { t } from 'i18next';
import { memo, useCallback, useMemo, useRef, useState } from 'react';
import { PiPauseBold, PiPlayBold, PiXBold } from 'react-icons/pi';
import { PiBroomBold, PiCaretDownBold, PiPauseBold, PiPlayBold, PiXBold } from 'react-icons/pi';
import {
useCancelModelInstallMutation,
useListModelInstallsQuery,
@@ -22,6 +41,18 @@ const hasRestartRequired = (job: ModelInstallJob) => {
return job.download_parts?.some((part) => part.resume_required || part.status === 'error') ?? false;
};
const ModelQueueTableSx: SystemStyleObject = {
'& tbody tr:nth-of-type(odd)': {
backgroundColor: 'rgba(255, 255, 255, 0.04)',
},
'& tbody tr:nth-of-type(even)': {
backgroundColor: 'transparent',
},
'td, th': {
borderColor: 'base.700',
},
};
export const ModelInstallQueue = memo(() => {
const isConnected = useStore($isConnected);
const { data } = useListModelInstallsQuery();
@@ -158,7 +189,6 @@ export const ModelInstallQueue = memo(() => {
}, [data]);
const pauseResumeLabel = showResumeAll ? t('modelManager.resumeAll') : t('modelManager.pauseAll');
const pauseResumeTooltip = showResumeAll ? t('modelManager.resumeAllTooltip') : t('modelManager.pauseAllTooltip');
const pauseOrResumeAll = useCallback(() => {
if (showResumeAll) {
@@ -176,41 +206,19 @@ export const ModelInstallQueue = memo(() => {
const isBulkActionRunning = bulkActionInProgress !== null;
return (
<Flex flexDir="column" p={3} h="full" gap={3}>
<Flex flexDir="column" h="full" gap={4}>
{/* Model Queue Header */}
<Flex justifyContent="space-between" alignItems="center">
<Flex alignItems="center" gap={2}>
<Heading size="sm">{t('modelManager.installQueue')}</Heading>
{!isConnected && (
<Box layerStyle="first" px={2} py={0.5} borderRadius="base">
<Heading size="sm" color="error.300">
{t('modelManager.backendDisconnected')}
</Heading>
</Box>
)}
<Heading size="md">{t('modelManager.installQueue')}</Heading>
{!isConnected && <Badge colorScheme="red">{t('modelManager.backendDisconnected')}</Badge>}
</Flex>
<Flex gap={2} alignItems="center">
<Button
size="sm"
leftIcon={showResumeAll ? <PiPlayBold /> : <PiPauseBold />}
isDisabled={!pauseResumeAvailable || isBulkActionRunning || isPruning}
isLoading={bulkActionInProgress === 'pause' || bulkActionInProgress === 'resume'}
onClick={pauseOrResumeAll}
tooltip={pauseResumeTooltip}
>
{pauseResumeLabel}
</Button>
<Button
size="sm"
leftIcon={<PiXBold />}
isDisabled={!hasCancelableInstalls || isBulkActionRunning || isPruning}
isLoading={bulkActionInProgress === 'cancel'}
onClick={cancelAll}
tooltip={t('modelManager.cancelAllTooltip')}
>
{t('modelManager.cancelAll')}
</Button>
{/* Bulk Actions */}
<ButtonGroup>
<Button
size="sm"
leftIcon={<PiBroomBold />}
isDisabled={!pruneAvailable || isBulkActionRunning}
isLoading={isPruning}
onClick={pruneCompletedModelInstalls}
@@ -218,15 +226,48 @@ export const ModelInstallQueue = memo(() => {
>
{t('modelManager.prune')}
</Button>
</Flex>
<Menu>
<MenuButton as={IconButton} size="sm" icon={<PiCaretDownBold />} />
<MenuList>
<MenuItem
icon={showResumeAll ? <PiPlayBold /> : <PiPauseBold />}
isDisabled={!pauseResumeAvailable || isBulkActionRunning || isPruning}
onClick={pauseOrResumeAll}
>
{pauseResumeLabel}
</MenuItem>
<MenuItem
icon={<PiXBold />}
isDisabled={!hasCancelableInstalls || isBulkActionRunning || isPruning}
onClick={cancelAll}
>
{t('modelManager.cancelAll')}
</MenuItem>
</MenuList>
</Menu>
</ButtonGroup>
</Flex>
<Box layerStyle="first" p={3} borderRadius="base" w="full" h="full">
{/* Model Queue List */}
<Box layerStyle="second" py={4} borderRadius="base" w="full" h="full">
<ScrollableContent>
<Flex flexDir="column-reverse" gap="2" w="full">
{data?.map((model) => (
<ModelInstallQueueItem key={model.id} installJob={model} />
))}
</Flex>
<TableContainer>
<Table size="sm" sx={ModelQueueTableSx}>
<Thead>
<Tr>
<Th></Th>
<Th>Name</Th>
<Th>Status</Th>
<Th textAlign="right">Actions</Th>
</Tr>
</Thead>
<Tbody>
{data?.map((model) => (
<ModelInstallQueueItem key={model.id} installJob={model} />
))}
</Tbody>
</Table>
</TableContainer>
</ScrollableContent>
</Box>
</Flex>

View File

@@ -23,7 +23,7 @@ const ModelInstallQueueBadge = ({ status }: { status?: ModelInstallStatus }) =>
}
return (
<Badge textAlign="center" w="134px" colorScheme={statusConfig.colorScheme}>
<Badge textAlign="center" colorScheme={statusConfig.colorScheme}>
{t(statusConfig.translationKey)}
</Badge>
);

View File

@@ -1,10 +1,34 @@
import { Badge, Box, Flex, IconButton, Progress, Text, Tooltip } from '@invoke-ai/ui-library';
import type { SystemStyleObject } from '@invoke-ai/ui-library';
import {
Badge,
Box,
Button,
CircularProgress,
Flex,
Icon,
IconButton,
Td,
Text,
Tooltip,
Tr,
} from '@invoke-ai/ui-library';
import { isNil } from 'es-toolkit/compat';
import { getApiErrorDetail } from 'features/modelManagerV2/util/getApiErrorDetail';
import { toast } from 'features/toast/toast';
import { t } from 'i18next';
import { memo, useCallback, useEffect, useMemo, useRef } from 'react';
import { PiArrowClockwiseBold, PiPauseBold, PiPlayBold, PiXBold } from 'react-icons/pi';
import {
PiArrowClockwiseBold,
PiCheckCircleFill,
PiLineVerticalBold,
PiPauseBold,
PiPlayBold,
PiWarningBold,
PiWarningDiamondBold,
PiWarningFill,
PiXBold,
PiXCircleBold,
} from 'react-icons/pi';
import {
useCancelModelInstallMutation,
usePauseModelInstallMutation,
@@ -32,6 +56,34 @@ const formatBytes = (bytes: number) => {
return `${bytes.toFixed(2)} ${units[i]}`;
};
const ProgressColumnSx: SystemStyleObject = {
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
};
const ModelNameColumnSx: SystemStyleObject = {
display: 'flex',
flexDirection: 'column',
alignItems: 'flex-start',
gap: 1,
};
const BadgesColumnSx: SystemStyleObject = {
display: 'flex',
gap: 1,
alignItems: 'flex-start',
flexWrap: 'wrap',
};
const ActionsColumnSx: SystemStyleObject = {
display: 'flex',
gap: 2,
alignItems: 'flex-start',
justifyContent: 'flex-end',
minWidth: '90px',
};
export const ModelInstallQueueItem = memo((props: ModelListItemProps) => {
const { installJob } = props;
@@ -249,98 +301,147 @@ export const ModelInstallQueueItem = memo((props: ModelListItemProps) => {
installJob.status === 'paused';
return (
<>
<Flex gap={1} w="full" alignItems="center">
<Tooltip maxW={600} label={<TooltipLabel name={modelName} source={sourceLocation} installJob={installJob} />}>
<Flex gap={3} w="full" alignItems="center">
<Text w={96} whiteSpace="nowrap" overflow="hidden" textOverflow="ellipsis">
{modelName}
</Text>
<Progress
w="full"
flexGrow={1}
<Tr>
{/* Progress */}
<Td>
<Box sx={ProgressColumnSx}>
{hasRestartRequired ? (
<Box sx={{ color: 'orange.300' }}>
<PiWarningBold size={20} />
</Box>
) : progressValue && progressValue < 100 ? (
<CircularProgress
size="20px"
value={progressValue ?? 0}
isIndeterminate={progressValue === null}
aria-label={t('accessibility.invokeProgressBar')}
h={2}
/>
<ModelInstallQueueBadge status={installJob.status} />
</Flex>
</Tooltip>
<Flex gap={1} alignItems="center" justifyContent="flex-end" minW="90px">
{showResume && (
<IconButton
size="xs"
tooltip={t('modelManager.resume')}
aria-label={t('modelManager.resume')}
icon={<PiPlayBold />}
onClick={handleResumeModelInstall}
variant="ghost"
/>
)}
{showPause && (
<IconButton
size="xs"
tooltip={t('modelManager.pause')}
aria-label={t('modelManager.pause')}
icon={<PiPauseBold />}
onClick={handlePauseModelInstall}
variant="ghost"
/>
) : installJob.status === 'cancelled' ? (
<Box sx={{ color: 'orange.200', transform: 'rotate(90deg)' }}>
<PiLineVerticalBold size={20} />
</Box>
) : (
<Box sx={{ color: 'green.300' }}>
<PiCheckCircleFill size={24} />
</Box>
)}
</Box>
</Td>
{/* Model Info */}
<Td>
<Box sx={ModelNameColumnSx}>
<Text fontWeight="semibold">{modelName}</Text>
<Box>
<Text as="span" fontStyle="italic" wordBreak="break-all">
{sourceLocation}
</Text>
{installJob.error_reason && (
<Text as="span" color="error.500" ml={2}>
{t('queue.failed')}: {installJob.error}
</Text>
)}
</Box>
{hasRestartRequired && (
<IconButton
size="xs"
tooltip={t('modelManager.restartFailed')}
aria-label={t('modelManager.restartFailed')}
icon={<PiArrowClockwiseBold />}
onClick={handleRestartFailed}
variant="ghost"
/>
<Flex direction="column" gap={1} w="full" mt={1}>
{restartRequiredParts.map((part) => {
const fileName = part.source.split('/').slice(-1)[0] ?? t('common.unknown');
const isResumeRequired = part.resume_required;
return (
<Flex key={part.source} gap={2} alignItems="center" wrap="wrap" p={2} bg="base.800" borderRadius="md">
<Icon
as={isResumeRequired ? PiWarningFill : PiWarningDiamondBold}
color={isResumeRequired ? 'orange.500' : 'red.500'}
/>
<Text fontSize="xs" color="base.200" maxW="200px" noOfLines={1} title={fileName}>
{fileName}
</Text>
<Badge colorScheme={isResumeRequired ? 'orange' : 'red'} fontSize="10px">
{isResumeRequired ? t('modelManager.restartRequired') : t('queue.failed')}
</Badge>
<Text fontSize="xs" color="warning.400">
{isResumeRequired ? t('modelManager.resumeRefused') : t('queue.failed')}
</Text>
<IconButton
size="xs"
tooltip={t('modelManager.restartFile')}
aria-label={t('modelManager.restartFile')}
icon={<PiArrowClockwiseBold />}
onClick={getRestartFileHandler(part.source)}
variant="ghost"
ml="auto"
/>
</Flex>
);
})}
</Flex>
)}
</Box>
</Td>
{/* Status */}
<Td>
<Box sx={BadgesColumnSx}>
<ModelInstallQueueBadge status={installJob.status} />
{hasRestartRequired && (
<Tooltip label={t('modelManager.restartRequiredTooltip')}>
<Badge colorScheme="red">{t('modelManager.restartRequired')}</Badge>
</Tooltip>
)}
</Box>
</Td>
{/* Actions */}
<Td textAlign="right" minWidth={130}>
<Box sx={ActionsColumnSx}>
{showResume ? (
<Button
size="sm"
tooltip={t('modelManager.resume')}
leftIcon={<PiPlayBold />}
onClick={handleResumeModelInstall}
>
{t('modelManager.resume')}
</Button>
) : showPause ? (
<Button
size="sm"
tooltip={t('modelManager.pause')}
leftIcon={<PiPauseBold />}
onClick={handlePauseModelInstall}
>
{t('modelManager.pause')}
</Button>
) : null}
{hasRestartRequired && (
<Button
tooltip={t('modelManager.restartFailed')}
size="sm"
leftIcon={<PiArrowClockwiseBold />}
onClick={handleRestartFailed}
colorScheme="error"
variant="ghost"
>
{t('modelManager.restartFailed')}
</Button>
)}
{showCancel && (
<IconButton
size="xs"
tooltip={t('modelManager.cancel')}
aria-label={t('modelManager.cancel')}
icon={<PiXBold />}
aria-label={t('modelManager.cancel')}
onClick={handleDeleteModelImport}
variant="ghost"
size="sm"
colorScheme="error"
/>
)}
{!showResume && !showPause && !showCancel && <Box w="24px" />}
</Flex>
</Flex>
{hasRestartRequired && (
<Flex direction="column" gap={1} w="full" mt={1}>
{restartRequiredParts.map((part) => {
const fileName = part.source.split('/').slice(-1)[0] ?? t('common.unknown');
const isResumeRequired = part.resume_required;
return (
<Flex key={part.source} gap={2} alignItems="center" wrap="wrap">
<Text fontSize="xs" color="base.200" maxW="200px" noOfLines={1}>
{fileName}
</Text>
<Badge colorScheme={isResumeRequired ? 'orange' : 'red'} fontSize="10px">
{isResumeRequired ? t('modelManager.restartRequired') : t('queue.failed')}
</Badge>
<Text fontSize="xs" color="warning.400">
{isResumeRequired ? t('modelManager.resumeRefused') : t('queue.failed')}
</Text>
<IconButton
size="xs"
tooltip={t('modelManager.restartFile')}
aria-label={t('modelManager.restartFile')}
icon={<PiArrowClockwiseBold />}
onClick={getRestartFileHandler(part.source)}
variant="ghost"
/>
</Flex>
);
})}
</Flex>
)}
</>
{!showCancel && !showPause && !showResume && <Text fontSize="2xs">No actions available</Text>}
</Box>
</Td>
</Tr>
);
});

View File

@@ -1,5 +1,5 @@
import type { SystemStyleObject } from '@invoke-ai/ui-library';
import { Box, Flex, Heading, Tab, TabList, TabPanel, TabPanels, Tabs } from '@invoke-ai/ui-library';
import { Box, Divider, Flex, Heading, Tab, TabList, TabPanel, TabPanels, Tabs } from '@invoke-ai/ui-library';
import { useStore } from '@nanostores/react';
import { $installModelsTabIndex } from 'features/modelManagerV2/store/installModelsStore';
import { StarterModelsForm } from 'features/modelManagerV2/subpanels/AddModelPanel/StarterModels/StarterModelsForm';
@@ -88,7 +88,8 @@ export const InstallModels = memo(() => {
</TabPanel>
</TabPanels>
</Tabs>
<Box layerStyle="second" borderRadius="base" h="50%">
<Divider />
<Box h="65%">
<ModelInstallQueue />
</Box>
</Flex>