diff --git a/invokeai/frontend/web/public/locales/en.json b/invokeai/frontend/web/public/locales/en.json index 9968532303..c990984e29 100644 --- a/invokeai/frontend/web/public/locales/en.json +++ b/invokeai/frontend/web/public/locales/en.json @@ -1092,6 +1092,7 @@ "triggerPhrases": "Trigger Phrases", "loraTriggerPhrases": "LoRA Trigger Phrases", "mainModelTriggerPhrases": "Main Model Trigger Phrases", + "queueEmpty": "The install queue is empty.", "selectAll": "Select All", "typePhraseHere": "Type phrase here", "t5Encoder": "T5 Encoder", diff --git a/invokeai/frontend/web/src/features/modelManagerV2/subpanels/AddModelPanel/ModelInstallQueue/ModelInstallQueue.tsx b/invokeai/frontend/web/src/features/modelManagerV2/subpanels/AddModelPanel/ModelInstallQueue/ModelInstallQueue.tsx index 8c0e343b2c..28a4c067f7 100644 --- a/invokeai/frontend/web/src/features/modelManagerV2/subpanels/AddModelPanel/ModelInstallQueue/ModelInstallQueue.tsx +++ b/invokeai/frontend/web/src/features/modelManagerV2/subpanels/AddModelPanel/ModelInstallQueue/ModelInstallQueue.tsx @@ -12,8 +12,9 @@ import { MenuItem, MenuList, Table, - TableContainer, Tbody, + Td, + Text, Th, Thead, Tr, @@ -22,9 +23,9 @@ 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 { PiBroomBold, PiCaretDownBold, PiPauseBold, PiPlayBold, PiXBold } from 'react-icons/pi'; +import { useTranslation } from 'react-i18next'; +import { PiBroomBold, PiCaretDownBold, PiPauseFill, PiPlayFill, PiXBold } from 'react-icons/pi'; import { useCancelModelInstallMutation, useListModelInstallsQuery, @@ -51,14 +52,40 @@ const ModelQueueTableSx: SystemStyleObject = { 'td, th': { borderColor: 'base.700', }, + + th: { + position: 'sticky', + top: 0, + zIndex: 1, + backgroundColor: 'base.800', + py: 2, + }, + + 'th:first-of-type': { + borderTopLeftRadius: 'base', + }, + 'th:last-of-type': { + borderTopRightRadius: 'base', + }, + 'tr:last-of-type td:first-of-type': { + borderBottomLeftRadius: 'base', + }, + 'tr:last-of-type td:last-of-type': { + borderBottomRightRadius: 'base', + }, }; export const ModelInstallQueue = memo(() => { + const { t } = useTranslation(); const isConnected = useStore($isConnected); const { data } = useListModelInstallsQuery(); const [bulkActionInProgress, setBulkActionInProgress] = useState<'pause' | 'resume' | 'cancel' | null>(null); const bulkActionLockRef = useRef(false); + const reversedData = useMemo(() => { + return data?.toReversed() ?? []; + }, [data]); + const [cancelModelInstall] = useCancelModelInstallMutation(); const [pauseModelInstall] = usePauseModelInstallMutation(); const [resumeModelInstall] = useResumeModelInstallMutation(); @@ -152,7 +179,7 @@ export const ModelInstallQueue = memo(() => { setBulkActionInProgress(null); } }, - [cancelModelInstall, isPruning, pauseModelInstall, resumeModelInstall] + [cancelModelInstall, isPruning, pauseModelInstall, resumeModelInstall, t] ); const pruneCompletedModelInstalls = useCallback(async () => { @@ -174,7 +201,7 @@ export const ModelInstallQueue = memo(() => { status: 'error', }); } - }, [_pruneCompletedModelInstalls]); + }, [_pruneCompletedModelInstalls, t]); const hasPauseableInstalls = pauseableInstallIds.length > 0; const hasResumableInstalls = resumableInstallIds.length > 0; @@ -230,7 +257,7 @@ export const ModelInstallQueue = memo(() => { } /> : } + icon={showResumeAll ? : } isDisabled={!pauseResumeAvailable || isBulkActionRunning || isPruning} onClick={pauseOrResumeAll} > @@ -249,25 +276,31 @@ export const ModelInstallQueue = memo(() => { {/* Model Queue List */} - + - - - +
+ + + + + + + + + + {data?.length === 0 ? ( - - - - + - - - {data?.map((model) => ( - - ))} - -
NameStatus + Actions +
NameStatusActions + {t('modelManager.queueEmpty')} +
-
+ ) : ( + reversedData?.map((model) => ) + )} + +
diff --git a/invokeai/frontend/web/src/features/modelManagerV2/subpanels/AddModelPanel/ModelInstallQueue/ModelInstallQueueBadge.tsx b/invokeai/frontend/web/src/features/modelManagerV2/subpanels/AddModelPanel/ModelInstallQueue/ModelInstallQueueBadge.tsx index 4a5cd0cb68..acc48373d6 100644 --- a/invokeai/frontend/web/src/features/modelManagerV2/subpanels/AddModelPanel/ModelInstallQueue/ModelInstallQueueBadge.tsx +++ b/invokeai/frontend/web/src/features/modelManagerV2/subpanels/AddModelPanel/ModelInstallQueue/ModelInstallQueueBadge.tsx @@ -5,7 +5,7 @@ import type { ModelInstallStatus } from 'services/api/types'; const STATUSES = { waiting: { colorScheme: 'cyan', translationKey: 'queue.pending' }, - downloading: { colorScheme: 'yellow', translationKey: 'queue.in_progress' }, + downloading: { colorScheme: 'blue', translationKey: 'queue.in_progress' }, downloads_done: { colorScheme: 'yellow', translationKey: 'queue.in_progress' }, running: { colorScheme: 'yellow', translationKey: 'queue.in_progress' }, paused: { colorScheme: 'orange', translationKey: 'queue.paused' }, @@ -14,18 +14,21 @@ const STATUSES = { cancelled: { colorScheme: 'orange', translationKey: 'queue.canceled' }, } as const satisfies Partial>; -const ModelInstallQueueBadge = ({ status }: { status?: ModelInstallStatus }) => { - const { t } = useTranslation(); - const statusConfig = status ? STATUSES[status] : undefined; +export const ModelInstallQueueBadge = memo( + ({ status, label }: { status?: ModelInstallStatus; label?: string | null }) => { + const { t } = useTranslation(); + const statusConfig = status ? STATUSES[status] : undefined; - if (!statusConfig) { - return null; + if (!statusConfig) { + return null; + } + + return ( + + {label ?? t(statusConfig.translationKey)} + + ); } +); - return ( - - {t(statusConfig.translationKey)} - - ); -}; -export default memo(ModelInstallQueueBadge); +ModelInstallQueueBadge.displayName = 'ModelInstallQueueBadge'; diff --git a/invokeai/frontend/web/src/features/modelManagerV2/subpanels/AddModelPanel/ModelInstallQueue/ModelInstallQueueItem.tsx b/invokeai/frontend/web/src/features/modelManagerV2/subpanels/AddModelPanel/ModelInstallQueue/ModelInstallQueueItem.tsx index 162f6b4ed5..ca872970ea 100644 --- a/invokeai/frontend/web/src/features/modelManagerV2/subpanels/AddModelPanel/ModelInstallQueue/ModelInstallQueueItem.tsx +++ b/invokeai/frontend/web/src/features/modelManagerV2/subpanels/AddModelPanel/ModelInstallQueue/ModelInstallQueueItem.tsx @@ -15,19 +15,20 @@ import { 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 { useTranslation } from 'react-i18next'; import { PiArrowClockwiseBold, + PiCheckBold, PiCheckCircleFill, PiLineVerticalBold, - PiPauseBold, - PiPlayBold, + PiMinusBold, + PiPauseFill, + PiPlayFill, PiWarningBold, PiWarningDiamondBold, PiWarningFill, PiXBold, - PiXCircleBold, } from 'react-icons/pi'; import { useCancelModelInstallMutation, @@ -38,7 +39,7 @@ import { } from 'services/api/endpoints/models'; import type { ModelInstallJob } from 'services/api/types'; -import ModelInstallQueueBadge from './ModelInstallQueueBadge'; +import { ModelInstallQueueBadge } from './ModelInstallQueueBadge'; type ModelListItemProps = { installJob: ModelInstallJob; @@ -57,34 +58,39 @@ const formatBytes = (bytes: number) => { }; const ProgressColumnSx: SystemStyleObject = { - display: 'flex', alignItems: 'center', justifyContent: 'center', }; -const ModelNameColumnSx: SystemStyleObject = { - display: 'flex', +const ModelInfoColumnSx: SystemStyleObject = { flexDirection: 'column', alignItems: 'flex-start', - gap: 1, + gap: 0.5, }; 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', +}; + +const CircularProgressSx: SystemStyleObject = { + '.chakra-progress__track': { + stroke: 'base.600', + }, + '.chakra-progress__indicator': { + stroke: 'blue.300', + }, }; export const ModelInstallQueueItem = memo((props: ModelListItemProps) => { + const { t } = useTranslation(); const { installJob } = props; const [deleteImportModel] = useCancelModelInstallMutation(); @@ -111,7 +117,7 @@ export const ModelInstallQueueItem = memo((props: ModelListItemProps) => { status: 'error', }); }); - }, [deleteImportModel, installJob]); + }, [deleteImportModel, installJob, t]); const handlePauseModelInstall = useCallback(() => { pauseModelInstall(installJob.id) @@ -130,7 +136,7 @@ export const ModelInstallQueueItem = memo((props: ModelListItemProps) => { status: 'error', }); }); - }, [installJob, pauseModelInstall]); + }, [installJob, pauseModelInstall, t]); const hasRestartedFromScratch = useCallback((job: ModelInstallJob) => { return ( @@ -168,7 +174,7 @@ export const ModelInstallQueueItem = memo((props: ModelListItemProps) => { status: 'error', }); }); - }, [hasRestartedFromScratch, installJob, resumeModelInstall]); + }, [hasRestartedFromScratch, installJob, resumeModelInstall, t]); const handleRestartFailed = useCallback(() => { restartFailedModelInstall(installJob.id) @@ -187,7 +193,7 @@ export const ModelInstallQueueItem = memo((props: ModelListItemProps) => { status: 'error', }); }); - }, [installJob.id, restartFailedModelInstall]); + }, [installJob.id, restartFailedModelInstall, t]); const handleRestartFile = useCallback( (fileSource: string) => { @@ -208,7 +214,7 @@ export const ModelInstallQueueItem = memo((props: ModelListItemProps) => { }); }); }, - [installJob.id, restartModelInstallFile] + [installJob.id, restartModelInstallFile, t] ); const getRestartFileHandler = useCallback( @@ -227,7 +233,7 @@ export const ModelInstallQueueItem = memo((props: ModelListItemProps) => { default: return t('common.unknown'); } - }, [installJob.source]); + }, [installJob.source, t]); const modelName = useMemo(() => { switch (installJob.source.type) { @@ -245,7 +251,7 @@ export const ModelInstallQueueItem = memo((props: ModelListItemProps) => { default: return t('common.unknown'); } - }, [installJob.source]); + }, [installJob.source, t]); const progressValue = useMemo(() => { if (installJob.status === 'completed' || installJob.status === 'error' || installJob.status === 'cancelled') { @@ -271,6 +277,27 @@ export const ModelInstallQueueItem = memo((props: ModelListItemProps) => { return null; }, [installJob.bytes, installJob.download_parts, installJob.status, installJob.total_bytes]); + const progressTooltip = useMemo(() => { + if (installJob.status !== 'downloading') { + return ''; + } + const parts = installJob.download_parts; + if (parts && parts.length > 0) { + const totalBytesFromParts = parts.reduce((sum, part) => sum + (part.total_bytes ?? 0), 0); + const currentBytesFromParts = parts.reduce((sum, part) => sum + (part.bytes ?? 0), 0); + const totalBytes = Math.max(totalBytesFromParts, installJob.total_bytes ?? 0); + const currentBytes = Math.max(currentBytesFromParts, installJob.bytes ?? 0); + if (totalBytes > 0) { + return `${formatBytes(currentBytes)} / ${formatBytes(totalBytes)}`; + } + return ''; + } + if (!isNil(installJob.bytes) && !isNil(installJob.total_bytes) && installJob.total_bytes > 0) { + return `${formatBytes(installJob.bytes)} / ${formatBytes(installJob.total_bytes)}`; + } + return ''; + }, [installJob.bytes, installJob.download_parts, installJob.total_bytes, installJob.status]); + const restartRequiredParts = useMemo(() => { return installJob.download_parts?.filter((part) => part.resume_required || part.status === 'error') ?? []; }, [installJob.download_parts]); @@ -288,13 +315,13 @@ export const ModelInstallQueueItem = memo((props: ModelListItemProps) => { status: 'warning', }); } - }, [hasRestartedFromScratch, installJob]); + }, [hasRestartedFromScratch, installJob, t]); const hasRestartRequired = restartRequiredParts.length > 0; - const showPause = installJob.status === 'downloading' || installJob.status === 'waiting'; - const showResume = installJob.status === 'paused' && !hasRestartRequired; - const showCancel = + const canPause = installJob.status === 'downloading' || installJob.status === 'waiting'; + const canResume = installJob.status === 'paused' && !hasRestartRequired; + const canCancel = installJob.status === 'downloading' || installJob.status === 'waiting' || installJob.status === 'running' || @@ -304,44 +331,49 @@ export const ModelInstallQueueItem = memo((props: ModelListItemProps) => { {/* Progress */} - - {hasRestartRequired ? ( - - - - ) : progressValue && progressValue < 100 ? ( - + + {installJob.status === 'downloading' || installJob.status === 'waiting' || installJob.status === 'running' ? ( + + + + ) : installJob.status === 'paused' ? ( + + + ) : installJob.status === 'cancelled' ? ( - - - + + + + ) : installJob.status === 'error' ? ( + + + ) : ( - - - + + + )} - + {/* Model Info */} - + {modelName} - - + + {sourceLocation} - {installJob.error_reason && ( - - {t('queue.failed')}: {installJob.error} - - )} - + {hasRestartRequired && ( {restartRequiredParts.map((part) => { @@ -376,44 +408,39 @@ export const ModelInstallQueueItem = memo((props: ModelListItemProps) => { })} )} - + {/* Status */} - - + + {hasRestartRequired && ( {t('modelManager.restartRequired')} )} - + {/* Actions */} - - {showResume ? ( - - ) : showPause ? ( - - ) : null} + + {/* Pause/Resume installatino */} + {canResume || + (canPause && ( + + ))} + {/* Restart installation if required */} {hasRestartRequired && ( )} - {showCancel && ( + {/* Cancel installation */} + {canCancel && ( } @@ -438,59 +466,14 @@ export const ModelInstallQueueItem = memo((props: ModelListItemProps) => { /> )} - {!showCancel && !showPause && !showResume && No actions available} - + {!canCancel && !canPause && !canResume && ( + // TODO: Add an individual prune action here? + No actions available + )} + ); }); ModelInstallQueueItem.displayName = 'ModelInstallQueueItem'; - -type TooltipLabelProps = { - installJob: ModelInstallJob; - name: string; - source: string; -}; - -const TooltipLabel = memo(({ name, source, installJob }: TooltipLabelProps) => { - const progressString = useMemo(() => { - if (installJob.status !== 'downloading') { - return ''; - } - const parts = installJob.download_parts; - if (parts && parts.length > 0) { - const totalBytesFromParts = parts.reduce((sum, part) => sum + (part.total_bytes ?? 0), 0); - const currentBytesFromParts = parts.reduce((sum, part) => sum + (part.bytes ?? 0), 0); - const totalBytes = Math.max(totalBytesFromParts, installJob.total_bytes ?? 0); - const currentBytes = Math.max(currentBytesFromParts, installJob.bytes ?? 0); - if (totalBytes > 0) { - return `${formatBytes(currentBytes)} / ${formatBytes(totalBytes)}`; - } - return ''; - } - if (!isNil(installJob.bytes) && !isNil(installJob.total_bytes) && installJob.total_bytes > 0) { - return `${formatBytes(installJob.bytes)} / ${formatBytes(installJob.total_bytes)}`; - } - return ''; - }, [installJob.bytes, installJob.download_parts, installJob.total_bytes, installJob.status]); - - return ( - <> - - {name} - {progressString && {progressString}} - - - {source} - - {installJob.error_reason && ( - - {t('queue.failed')}: {installJob.error} - - )} - - ); -}); - -TooltipLabel.displayName = 'TooltipLabel';