feat(model manager queue): improve ui/ux

- standardized table row widths
- sticky table header
- reverse table data direction (new items on top)
- queue empty state
- ui and icon tweaks
- add progress tooltip
- add code comments for sanity
This commit is contained in:
joshistoast
2026-02-24 17:41:01 -07:00
parent 2b61addb51
commit 33e1a1e39a
4 changed files with 181 additions and 161 deletions

View File

@@ -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",

View File

@@ -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(() => {
<MenuButton as={IconButton} size="sm" icon={<PiCaretDownBold />} />
<MenuList>
<MenuItem
icon={showResumeAll ? <PiPlayBold /> : <PiPauseBold />}
icon={showResumeAll ? <PiPlayFill /> : <PiPauseFill />}
isDisabled={!pauseResumeAvailable || isBulkActionRunning || isPruning}
onClick={pauseOrResumeAll}
>
@@ -249,25 +276,31 @@ export const ModelInstallQueue = memo(() => {
</Flex>
{/* Model Queue List */}
<Box layerStyle="second" py={4} borderRadius="base" w="full" h="full">
<Box layerStyle="second" borderRadius="base" w="full" h="full">
<ScrollableContent>
<TableContainer>
<Table size="sm" sx={ModelQueueTableSx}>
<Thead>
<Table size="sm" sx={ModelQueueTableSx}>
<Thead>
<Tr>
<Th minWidth="50px"></Th>
<Th width="80%">Name</Th>
<Th minWidth="115px">Status</Th>
<Th minWidth="130px" textAlign="right">
Actions
</Th>
</Tr>
</Thead>
<Tbody>
{data?.length === 0 ? (
<Tr>
<Th></Th>
<Th>Name</Th>
<Th>Status</Th>
<Th textAlign="right">Actions</Th>
<Td colSpan={4} textAlign="center" py={8} color="base.500">
<Text>{t('modelManager.queueEmpty')}</Text>
</Td>
</Tr>
</Thead>
<Tbody>
{data?.map((model) => (
<ModelInstallQueueItem key={model.id} installJob={model} />
))}
</Tbody>
</Table>
</TableContainer>
) : (
reversedData?.map((model) => <ModelInstallQueueItem key={model.id} installJob={model} />)
)}
</Tbody>
</Table>
</ScrollableContent>
</Box>
</Flex>

View File

@@ -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<Record<ModelInstallStatus, { colorScheme: string; translationKey: string }>>;
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 (
<Badge variant="outline" colorScheme={statusConfig.colorScheme}>
{label ?? t(statusConfig.translationKey)}
</Badge>
);
}
);
return (
<Badge textAlign="center" colorScheme={statusConfig.colorScheme}>
{t(statusConfig.translationKey)}
</Badge>
);
};
export default memo(ModelInstallQueueBadge);
ModelInstallQueueBadge.displayName = 'ModelInstallQueueBadge';

View File

@@ -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) => {
<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')}
/>
<Flex sx={ProgressColumnSx}>
{installJob.status === 'downloading' || installJob.status === 'waiting' || installJob.status === 'running' ? (
<Tooltip label={progressTooltip} isDisabled={!progressTooltip} hasArrow openDelay={0}>
<CircularProgress
size="20px"
value={progressValue ?? 0}
isIndeterminate={
progressValue === null || installJob.status === 'waiting' || installJob.status === 'running'
}
aria-label={t('accessibility.invokeProgressBar')}
sx={CircularProgressSx}
thickness={12}
/>
</Tooltip>
) : installJob.status === 'paused' ? (
<Flex sx={{ color: 'orange.300' }}>
<PiPauseFill size={16} />
</Flex>
) : installJob.status === 'cancelled' ? (
<Box sx={{ color: 'orange.200', transform: 'rotate(90deg)' }}>
<PiLineVerticalBold size={20} />
</Box>
<Flex sx={{ color: 'orange.200', transform: 'rotate(-45deg)' }}>
<PiMinusBold size={16} />
</Flex>
) : installJob.status === 'error' ? (
<Flex sx={{ color: 'red.300' }}>
<PiXBold size={16} />
</Flex>
) : (
<Box sx={{ color: 'green.300' }}>
<PiCheckCircleFill size={24} />
</Box>
<Flex sx={{ color: 'green.300' }}>
<PiCheckBold size={16} />
</Flex>
)}
</Box>
</Flex>
</Td>
{/* Model Info */}
<Td>
<Box sx={ModelNameColumnSx}>
<Flex sx={ModelInfoColumnSx}>
<Text fontWeight="semibold">{modelName}</Text>
<Box>
<Text as="span" fontStyle="italic" wordBreak="break-all">
<Tooltip label={sourceLocation} placement="top-start" hasArrow>
<Text fontStyle="italic" fontSize="2xs" maxW="250px" noOfLines={1} cursor="default">
{sourceLocation}
</Text>
{installJob.error_reason && (
<Text as="span" color="error.500" ml={2}>
{t('queue.failed')}: {installJob.error}
</Text>
)}
</Box>
</Tooltip>
{hasRestartRequired && (
<Flex direction="column" gap={1} w="full" mt={1}>
{restartRequiredParts.map((part) => {
@@ -376,44 +408,39 @@ export const ModelInstallQueueItem = memo((props: ModelListItemProps) => {
})}
</Flex>
)}
</Box>
</Flex>
</Td>
{/* Status */}
<Td>
<Box sx={BadgesColumnSx}>
<ModelInstallQueueBadge status={installJob.status} />
<Flex sx={BadgesColumnSx}>
<ModelInstallQueueBadge status={installJob.status} label={installJob.error} />
{hasRestartRequired && (
<Tooltip label={t('modelManager.restartRequiredTooltip')}>
<Badge colorScheme="red">{t('modelManager.restartRequired')}</Badge>
</Tooltip>
)}
</Box>
</Flex>
</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}
<Flex sx={ActionsColumnSx}>
{/* Pause/Resume installatino */}
{canResume ||
(canPause && (
<Button
size="sm"
tooltip={canResume ? t('modelManager.resume') : t('modelManager.pause')}
leftIcon={canResume ? <PiPlayFill /> : <PiPauseFill />}
onClick={canResume ? handleResumeModelInstall : handlePauseModelInstall}
variant={canResume ? 'solid' : 'outline'}
>
{canResume ? t('modelManager.resume') : t('modelManager.pause')}
</Button>
))}
{/* Restart installation if required */}
{hasRestartRequired && (
<Button
tooltip={t('modelManager.restartFailed')}
@@ -427,7 +454,8 @@ export const ModelInstallQueueItem = memo((props: ModelListItemProps) => {
</Button>
)}
{showCancel && (
{/* Cancel installation */}
{canCancel && (
<IconButton
tooltip={t('modelManager.cancel')}
icon={<PiXBold />}
@@ -438,59 +466,14 @@ export const ModelInstallQueueItem = memo((props: ModelListItemProps) => {
/>
)}
{!showCancel && !showPause && !showResume && <Text fontSize="2xs">No actions available</Text>}
</Box>
{!canCancel && !canPause && !canResume && (
// TODO: Add an individual prune action here?
<Text fontSize="2xs">No actions available</Text>
)}
</Flex>
</Td>
</Tr>
);
});
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 (
<>
<Flex gap={3} justifyContent="space-between">
<Text fontWeight="semibold">{name}</Text>
{progressString && <Text>{progressString}</Text>}
</Flex>
<Text fontStyle="italic" wordBreak="break-all">
{source}
</Text>
{installJob.error_reason && (
<Text color="error.500">
{t('queue.failed')}: {installJob.error}
</Text>
)}
</>
);
});
TooltipLabel.displayName = 'TooltipLabel';