import { useState, useMemo, useEffect, useCallback } from 'react'; import { useTranslation } from 'react-i18next'; import { useQuery, useQueries, useMutation, useQueryClient } from '@tanstack/react-query'; import { Link } from 'react-router-dom'; import { DndContext, DragOverlay, closestCenter, KeyboardSensor, PointerSensor, useSensor, useSensors, } from '@dnd-kit/core'; import type { DragEndEvent, DragStartEvent } from '@dnd-kit/core'; import { SortableContext, sortableKeyboardCoordinates, useSortable, verticalListSortingStrategy, } from '@dnd-kit/sortable'; import { CSS } from '@dnd-kit/utilities'; import { queueItemDisplayName } from '../utils/queueItemName'; import { Clock, Trash2, Play, X, CheckCircle, XCircle, AlertCircle, Calendar, Printer, GripVertical, SkipForward, ExternalLink, Power, StopCircle, Pencil, RefreshCw, Timer, ListOrdered, Layers, ArrowUp, ArrowDown, Hand, Check, CheckSquare, Square, User, Pause, Weight, ChevronDown, ChevronRight, ChevronUp, List, GanttChart, Code, Snail, Package, PackageOpen, Ungroup, Ban, PlayCircle, Workflow, } from 'lucide-react'; import { api, ApiError } from '../api/client'; import { PipelineRunsView } from './PipelineRunsPage'; import { type TimeFormat, formatETA, formatDuration, formatRelativeTime, parseUTCDate } from '../utils/date'; import { getBedTypeInfo } from '../utils/bedType'; import type { PrintQueueItem, PrintQueueBulkUpdate, Permission, CalibrationMode } from '../api/client'; import { Card } from '../components/Card'; import { Button } from '../components/Button'; import { ConfirmModal } from '../components/ConfirmModal'; import { PrintModal } from '../components/PrintModal'; import { useToast } from '../contexts/ToastContext'; import { useAuth } from '../contexts/AuthContext'; import { QueueStatsBar } from '../components/QueueStatsBar'; import { CompactHistoryRow } from '../components/CompactHistoryRow'; import { QueueTimelineView } from '../components/QueueTimelineView'; import { compareQueueOrder, compareQueueOrderAcrossLanes } from '../utils/queueOrder'; import { BatchOrdersView } from '../components/BatchOrdersView'; function formatWeight(g: number, useKg = false): string { if (useKg && g >= 1000) return `${(g / 1000).toFixed(1)}kg`; return `${Math.round(g)}g`; } function StatusBadge({ status, waitingReason, printerState, t }: { status: PrintQueueItem['status']; waitingReason?: string | null; printerState?: string | null; t: (key: string) => string }) { // Special case: pending with waiting_reason shows as "Waiting" if (status === 'pending' && waitingReason) { return ( {t('queue.status.waiting')} ); } // Special case: printing but printer is paused if (status === 'printing' && printerState === 'PAUSE') { return ( {t('queue.status.paused')} ); } const config = { pending: { icon: Clock, color: 'text-status-warning bg-status-warning/10 border-status-warning/20', label: t('queue.status.pending') }, printing: { icon: Play, color: 'text-blue-700 dark:text-blue-400 bg-blue-50 dark:bg-blue-400/10 border-blue-200 dark:border-blue-400/20', label: t('queue.status.printing') }, completed: { icon: CheckCircle, color: 'text-status-ok bg-status-ok/10 border-status-ok/20', label: t('queue.status.completed') }, failed: { icon: XCircle, color: 'text-status-error bg-status-error/10 border-status-error/20', label: t('queue.status.failed') }, skipped: { icon: SkipForward, color: 'text-orange-700 dark:text-orange-400 bg-orange-50 dark:bg-orange-400/10 border-orange-200 dark:border-orange-400/20', label: t('queue.status.skipped') }, cancelled: { icon: X, color: 'text-gray-400 bg-gray-400/10 border-gray-400/20', label: t('queue.status.cancelled') }, }; const { icon: Icon, color, label } = config[status]; return ( {label} ); } // Bulk edit modal for multiple queue items function BulkEditModal({ selectedCount, printers, onSave, onClose, isSaving, canControlPrinter, hasGcodeSnippets, t, }: { selectedCount: number; printers: { id: number; name: string; nozzle_count?: number }[]; onSave: (data: Partial) => void; onClose: () => void; isSaving: boolean; canControlPrinter: boolean; hasGcodeSnippets: boolean; t: (key: string, options?: Record) => string; }) { const [printerId, setPrinterId] = useState('unchanged'); const [manualStart, setManualStart] = useState('unchanged'); const [autoOffAfter, setAutoOffAfter] = useState('unchanged'); const [requirePreviousSuccess, setRequirePreviousSuccess] = useState('unchanged'); const [gcodeInjection, setGcodeInjection] = useState('unchanged'); const [bedLevelling, setBedLevelling] = useState('unchanged'); const [flowCali, setFlowCali] = useState('unchanged'); const [vibrationCali, setVibrationCali] = useState('unchanged'); const [layerInspect, setLayerInspect] = useState('unchanged'); const [timelapse, setTimelapse] = useState('unchanged'); const [useAms, setUseAms] = useState('unchanged'); const [nozzleOffsetCali, setNozzleOffsetCali] = useState('unchanged'); // Show the dual-nozzle-only toggle when the user has at least one // dual-nozzle printer registered (H2D/H2D Pro/H2C/X2D). Single-nozzle // queues never see it — the MQTT layer ignores the field anyway. const hasDualNozzlePrinter = printers.some(p => p.nozzle_count === 2); const handleSave = () => { const data: Partial = {}; if (printerId !== 'unchanged') data.printer_id = printerId; if (manualStart !== 'unchanged') data.manual_start = manualStart; if (autoOffAfter !== 'unchanged') data.auto_off_after = autoOffAfter; if (requirePreviousSuccess !== 'unchanged') data.require_previous_success = requirePreviousSuccess; if (gcodeInjection !== 'unchanged') data.gcode_injection = gcodeInjection; if (bedLevelling !== 'unchanged') data.bed_levelling = bedLevelling; if (flowCali !== 'unchanged') data.flow_cali = flowCali; if (vibrationCali !== 'unchanged') data.vibration_cali = vibrationCali; if (layerInspect !== 'unchanged') data.layer_inspect = layerInspect; if (timelapse !== 'unchanged') data.timelapse = timelapse; if (useAms !== 'unchanged') data.use_ams = useAms; if (nozzleOffsetCali !== 'unchanged') data.nozzle_offset_cali = nozzleOffsetCali; onSave(data); }; const hasChanges = printerId !== 'unchanged' || manualStart !== 'unchanged' || autoOffAfter !== 'unchanged' || requirePreviousSuccess !== 'unchanged' || bedLevelling !== 'unchanged' || flowCali !== 'unchanged' || vibrationCali !== 'unchanged' || layerInspect !== 'unchanged' || timelapse !== 'unchanged' || useAms !== 'unchanged' || nozzleOffsetCali !== 'unchanged' || gcodeInjection !== 'unchanged'; return (

{t('queue.bulkEdit.title', { count: selectedCount })}

{t('queue.bulkEdit.description')}

{/* Printer Assignment */}
{/* Queue Options */}
{/* Same gate as the print modal's checkbox (#3058): hidden until an admin has saved a snippet for some printer model, so the toggle never promises an injection that has nothing to inject. */} {hasGcodeSnippets && ( )}
{/* Print Options */}
{hasDualNozzlePrinter && ( )}
); } // Tri-state toggle for bulk edit (unchanged / on / off) function TriStateToggle({ label, value, onChange, disabled, t, }: { label: string; value: boolean | 'unchanged'; onChange: (val: boolean | 'unchanged') => void; disabled?: boolean; t: (key: string) => string; }) { return (
{label}
); } // Four-state selector for the tri-state calibration options in bulk edit // (unchanged / off / auto / on). Mirrors TriStateToggle's chrome. function CalibrationModeToggle({ label, value, onChange, t, }: { label: string; value: CalibrationMode | 'unchanged'; onChange: (val: CalibrationMode | 'unchanged') => void; t: (key: string) => string; }) { const modes: Array<{ key: CalibrationMode | 'unchanged'; label: string; active: string }> = [ { key: 'unchanged', label: '—', active: 'bg-bambu-dark-tertiary text-white' }, { key: 'off', label: t('settings.calibrationMode_off'), active: 'bg-red-100 dark:bg-red-500/20 text-red-700 dark:text-red-400' }, { key: 'auto', label: t('settings.calibrationMode_auto'), active: 'bg-blue-100 dark:bg-blue-500/20 text-blue-700 dark:text-blue-400' }, { key: 'on', label: t('settings.calibrationMode_on'), active: 'bg-bambu-green/20 text-bambu-green' }, ]; return (
{label}
{modes.map(({ key, label: modeLabel, active }) => ( ))}
); } // Sortable queue item for drag and drop function SortableQueueItem({ item, position, onEdit, onCancel, onRemove, onStop, onRequeue, onStart, onMoveUp, onMoveDown, timeFormat = 'system', isSelected = false, onToggleSelect, hasPermission, canModify, printerState, showEta = false, etaNow, t, }: { item: PrintQueueItem; position?: number; onEdit: () => void; onCancel: () => void; onRemove: () => void; onStop: () => void; onRequeue: () => void; onStart: () => void; // Mobile tap-to-reorder (#2667). Undefined = at a list boundary (button // shown disabled) or reordering isn't available; the desktop drag handle // is unaffected. Move one step among siblings, then persist via reorder. onMoveUp?: () => void; onMoveDown?: () => void; timeFormat?: TimeFormat; isSelected?: boolean; onToggleSelect?: () => void; hasPermission: (permission: Permission) => boolean; canModify: (resource: 'queue' | 'archives' | 'library', action: 'update' | 'delete' | 'reprint', createdById: number | null | undefined) => boolean; printerState?: string | null; // Whether this item qualifies for an "if started now" ETA (#2740), and the // instant to measure it from. Both are decided by the page so every row on // screen quotes the same clock. showEta?: boolean; etaNow?: number; t: (key: string, options?: Record) => string; }) { // Fetch printer status every 30 seconds while printing to monitor progress const { data: status } = useQuery({ queryKey: ['printerStatus', item.printer_id], queryFn: () => api.getPrinterStatus(item.printer_id!), refetchInterval: 30000, enabled: item.printer_id != null && printerState === 'printing', }); // Determine if we're printing a library file const isLibraryFile = !!item.library_file_id && !item.archive_id; // Fetch archive plate details. Skip when the linked archive has been // soft-deleted (#1348 follow-up): its 3MF is gone from disk so the // /plates endpoint just 404-storms the queue page. const { data: archivePlatesData } = useQuery({ queryKey: ['archive-plates', item.archive_id], queryFn: () => api.getArchivePlates(item.archive_id!), enabled: !!item.archive_id && !isLibraryFile && !item.archive_deleted, }); // Fetch library file plate details const { data: libraryPlatesData } = useQuery({ queryKey: ['library-file-plates', item.library_file_id], queryFn: () => api.getLibraryFilePlates(item.library_file_id!), enabled: isLibraryFile && !!item.library_file_id, }); // Combine plates data from either source const platesData = isLibraryFile ? libraryPlatesData : archivePlatesData; const plates = platesData?.plates ?? []; const canReorder = hasPermission('queue:reorder'); const { attributes, listeners, setNodeRef, transform, transition, isDragging, } = useSortable({ id: item.id, disabled: item.status !== 'pending' || !canReorder }); const style = { transform: CSS.Transform.toString(transform), transition, }; const isPrinting = item.status === 'printing'; const isPending = item.status === 'pending'; const isHistory = ['completed', 'failed', 'skipped', 'cancelled'].includes(item.status); // This is an "if started now" estimate, not a cumulative queue forecast, so // it is only shown for items the page determined could actually start now // (see etaEligibleIds). etaNow is the caller's ticking clock — deriving the // ETA from it rather than from Date.now() keeps this render deterministic and // stops the value freezing at first paint. const queueItemEta = isPending && showEta && item.print_time_seconds != null && item.print_time_seconds > 0 ? formatETA(item.print_time_seconds / 60, timeFormat, t, etaNow) : null; const isMobileSelectable = isPending && onToggleSelect; return (
{ if (window.innerWidth < 640) onToggleSelect(); } : undefined} > {/* Mobile selected left accent bar */} {isMobileSelectable && isSelected && (
)}
{/* Mobile reorder arrows (#2667). The desktop drag handle is hidden on phones and touch-drag is unreliable there, so pending rows get tap-to-move up/down controls instead. Shown only below `sm`. */} {isPending && (onMoveUp || onMoveDown) && (
e.stopPropagation()} >
)} {/* Mobile selection indicator — left accent bar only, no tick */} {/* Selection checkbox for pending items - hidden on mobile, tap card instead */} {isPending && onToggleSelect && ( )} {/* Drag handle or position number - hidden on mobile */} {isPending ? (
) : position !== undefined ? (
#{position}
) : (
)} {/* Thumbnail - use plate-specific thumbnail if plate_id is set */}
{item.archive_thumbnail ? ( ) : item.library_file_thumbnail ? ( ) : (
)}
{/* Info */}

{queueItemDisplayName(item, (n) => t('common.plusNMore', { count: n }))} {(platesData?.is_multi_plate ?? false) && item.plate_id !== undefined && item.plate_id !== null && ` • ${plates.find(plate => plate.index === item.plate_id)?.name || t('queue.plateNumber', { index: item.plate_id })}`}

{item.archive_id ? ( ) : item.library_file_id ? ( ) : null} {item.batch_name && ( {item.batch_name} )}
{/* A cross-model item (#671) is waiting on several models at once. Showing only target_model would name whichever candidate is first and read as a lie the moment the other one runs. */} {(item.variants?.length ?? 0) > 1 && !item.printer_id ? `${t('queue.filter.any')} ${item.variants!.map(v => v.target_model).join(' / ')}${item.target_location ? ` @ ${item.target_location}` : ''}` : item.target_model && !item.printer_id ? `${t('queue.filter.any')} ${item.target_model}${item.target_location ? ` @ ${item.target_location}` : ''}${item.required_filament_types?.length ? ` (${item.required_filament_types.join(', ')})` : ''}` : item.printer_id === null ? t('queue.filter.unassigned') : (item.printer_name || `${t('common.printer')} #${item.printer_id}`)} {item.print_time_seconds && ( {formatDuration(item.print_time_seconds)} )} {queueItemEta && ( ETA {queueItemEta} )} {item.filament_used_grams && ( {formatWeight(item.filament_used_grams)} )} {(() => { // Build plate badge so the user knows which plate to mount before // walking to the printer (#1281). Hidden when the 3MF doesn't // carry curr_bed_type or the slicer used an unknown label. const bed = getBedTypeInfo(item.bed_type); if (!bed) return null; return ( {bed.label} ); })()} {item.created_by_username && ( {item.created_by_username} )} {isPending && !item.manual_start && ( {/* An item with no scheduled time used to render as "ASAP", which is the name of a dispatch mode the user may well not have picked -- ASAP and Queue differ only in insert position, and neither is stored on the item, so the two are indistinguishable here. Someone who chose Queue saw their row labelled ASAP and read it as Bambuddy overriding them (#2557, #3018). This column answers "when does it run", so it now says that instead of borrowing a mode name. */} {item.scheduled_time ? ((parseUTCDate(item.scheduled_time)?.getTime() ?? 0) - Date.now() < -60000 ? t?.('queue.time.overdue') ?? 'Overdue' : formatRelativeTime(item.scheduled_time, timeFormat, t)) : t?.('queue.time.whenFree') ?? 'When a printer is free'} )}
{/* Options badges */}
{item.manual_start && ( {t('queue.badges.staged')} )} {item.require_previous_success && ( {t('queue.badges.requiresPrevious')} )} {item.auto_off_after && ( {t('queue.badges.autoPowerOff')} )} {item.gcode_injection && ( {t('queue.badges.gcodeInjection')} )}
{/* Progress bar for printing items - TODO: integrate with WebSocket */} {isPrinting && status && (() => { // Gate progress/remaining/layer on printer actually running this print. // Between dispatch and RUNNING transition (H2D/P1 MQTT lag), status.progress // is stale from the previous print — showing 100% then snapping back to 0% // once the new print starts. Only trust these fields when state is active. const isActive = status.state === 'RUNNING' || status.state === 'PAUSE'; const progress = isActive ? (status.progress || 0) : 0; const remaining = isActive ? status.remaining_time : null; const layerNum = isActive ? status.layer_num : null; const totalLayers = isActive ? status.total_layers : null; return (
{Math.round(progress)}%
{remaining != null && remaining > 0 && ( <> {formatDuration(remaining * 60)} ETA {formatETA(remaining, timeFormat, t)} )} {layerNum != null && totalLayers != null && totalLayers > 0 && ( {layerNum}/{totalLayers} )}
); })()} {/* Waiting reason for model-based assignments */} {item.waiting_reason && item.status === 'pending' && (

{item.waiting_reason}

)} {/* Filament-short flag from the dispatch pre-flight (#1496). */} {item.filament_short && item.status === 'pending' && (

{t('queue.filamentShort.rowBadge')}

)} {/* Archive carries the slicer's own live-resolved AMS-slot pick (extra_data.slicer_ams_mapping) — reprints of this archive reuse the exact physical spool instead of re-deriving one. */} {item.archive_has_slicer_ams_mapping && (

{t('queue.slicerAmsMapping.rowBadge')}

)} {/* Error message */} {item.error_message && (

{item.error_message}

)}
{/* Status badge + Actions */}
e.stopPropagation()}>
{isPrinting && ( )} {isPending && ( <> {item.manual_start && ( )} )} {isHistory && ( <> )}
); } type QueueRow = | { kind: 'item'; item: PrintQueueItem } | { kind: 'batch'; batchId: number; batchName: string; items: PrintQueueItem[] }; interface QueueRowRenderProps { row: QueueRow; collapsed: boolean; onToggleBatch?: () => void; onUngroup?: () => void; setEditItem: (item: PrintQueueItem) => void; setConfirmAction: (a: { type: 'cancel' | 'remove' | 'stop'; item: PrintQueueItem }) => void; startMutation: { mutate: (vars: { id: number; skipFilamentCheck?: boolean }) => void }; selectedItems: number[]; handleToggleSelect: (id: number) => void; timeFormat: TimeFormat; // eslint-disable-next-line @typescript-eslint/no-explicit-any hasPermission: (p: any) => boolean; // eslint-disable-next-line @typescript-eslint/no-explicit-any canModify: (resource: any, action: any, createdById?: number | null) => boolean; t: (key: string, options?: Record) => string; // Items that qualify for an "if started now" ETA, and the shared clock it is // measured from (#2740). etaEligibleIds: Set; etaNow: number; aggregateForRows: (rows: QueueRow[]) => { count: number; time: number; weight: number }; // Mobile tap-to-reorder (#2667). onMoveUp/onMoveDown move this whole row // (single item or batch) one step among its siblings; onMoveBlock is the // low-level primitive SortableBatchRow uses to move a child within the // batch. All undefined when reordering isn't available (non-manual sort). onMoveUp?: () => void; onMoveDown?: () => void; onMoveBlock?: (movingIds: number[], anchorId: number, placeAfter: boolean) => void; } /** Renders either a single item or a collapsible batch group containing N * sibling items. The batch parent shows aggregate stats; children render * with the existing SortableQueueItem (only draggable inside the batch). */ function QueueRowRender(props: QueueRowRenderProps) { const { row, setEditItem, setConfirmAction, startMutation, selectedItems, handleToggleSelect, timeFormat, hasPermission, canModify, t, etaEligibleIds, etaNow, onMoveUp, onMoveDown, } = props; if (row.kind === 'item') { return ( setEditItem(row.item)} onCancel={() => setConfirmAction({ type: 'cancel', item: row.item })} onRemove={() => {}} onStop={() => {}} onRequeue={() => {}} onStart={() => startMutation.mutate({ id: row.item.id })} onMoveUp={onMoveUp} onMoveDown={onMoveDown} timeFormat={timeFormat} isSelected={selectedItems.includes(row.item.id)} onToggleSelect={() => handleToggleSelect(row.item.id)} hasPermission={hasPermission} canModify={canModify} showEta={etaEligibleIds.has(row.item.id)} etaNow={etaNow} t={t} /> ); } return ; } /** Batch parent header registered with dnd-kit so the whole group can be * reordered as one unit. Drag handle lives in the header itself; children * remain individually draggable while expanded for within-batch reorder. */ function SortableBatchRow({ row, collapsed, onToggleBatch, onUngroup, setEditItem, setConfirmAction, startMutation, selectedItems, handleToggleSelect, timeFormat, hasPermission, canModify, t, etaEligibleIds, etaNow, aggregateForRows, onMoveUp, onMoveDown, onMoveBlock, }: QueueRowRenderProps) { // Dispatcher (QueueRowRender) only mounts this with row.kind === 'batch'; // narrow up-front so the hook below can reference batchId unconditionally. const batchRow = row as Extract; const canReorder = hasPermission('queue:reorder'); const { attributes, listeners, setNodeRef, transform, transition, isDragging, } = useSortable({ id: `batch-${batchRow.batchId}`, disabled: !canReorder }); const style = { transform: CSS.Transform.toString(transform), transition, }; const agg = aggregateForRows([batchRow]); const allChildIds = batchRow.items.map((i) => i.id); const allSelected = allChildIds.length > 0 && allChildIds.every((id) => selectedItems.includes(id)); // Status rollup: worst-of-children (failed > printing > pending). const childStatuses = new Set(batchRow.items.map((i) => i.status)); // We never put non-pending into a batch grouping but render defensively. const rollupStatus: PrintQueueItem['status'] = childStatuses.has('failed') ? 'failed' : childStatuses.has('printing') ? 'printing' : 'pending'; const pendingChildren = batchRow.items.filter((i) => i.status === 'pending').length; return (
{/* Parent header */}
{/* Mobile reorder arrows for the whole group (#2667), mirroring the desktop drag handle which is hidden on phones. */} {canReorder && (onMoveUp || onMoveDown) && (
)} {canReorder && (
)}
{collapsed ? ( ) : ( )}

{batchRow.batchName}

{t('queue.batch.label', { count: agg.count })}
{agg.time > 0 && ( {formatDuration(agg.time)} )} {agg.weight > 0 && ( {formatWeight(agg.weight)} )} {pendingChildren > 0 && rollupStatus === 'pending' && ( {t('queue.batch.pendingCount', { count: pendingChildren })} )}
{onUngroup && ( )}
{/* Children (only when expanded) */} {!collapsed && (
{batchRow.items.map((child, ci) => ( setEditItem(child)} onCancel={() => setConfirmAction({ type: 'cancel', item: child })} onRemove={() => {}} onStop={() => {}} onRequeue={() => {}} onStart={() => startMutation.mutate({ id: child.id })} onMoveUp={ onMoveBlock && ci > 0 ? () => onMoveBlock([child.id], batchRow.items[ci - 1].id, false) : undefined } onMoveDown={ onMoveBlock && ci < batchRow.items.length - 1 ? () => onMoveBlock([child.id], batchRow.items[ci + 1].id, true) : undefined } timeFormat={timeFormat} isSelected={selectedItems.includes(child.id)} onToggleSelect={() => handleToggleSelect(child.id)} hasPermission={hasPermission} canModify={canModify} showEta={etaEligibleIds.has(child.id)} etaNow={etaNow} t={t} /> ))}
)}
); } type HistoryRow = | { kind: 'item'; item: PrintQueueItem } | { kind: 'batch'; batchId: number; batchName: string; items: PrintQueueItem[] }; interface HistorySectionProps { items: PrintQueueItem[]; collapsed: boolean; visibleCount: number; onShowMore: () => void; sortBy: 'date' | 'name' | 'printer'; sortAsc: boolean; onSortByChange: (v: 'date' | 'name' | 'printer') => void; onSortAscToggle: () => void; onRemove: (item: PrintQueueItem) => void; onRequeue: (item: PrintQueueItem) => void; timeFormat: TimeFormat; batchCollapsed: Record; toggleBatchCollapsed: (id: number) => void; // eslint-disable-next-line @typescript-eslint/no-explicit-any hasPermission: (p: any) => boolean; // eslint-disable-next-line @typescript-eslint/no-explicit-any canModify: (resource: any, action: any, createdById?: number | null) => boolean; t: (key: string, options?: Record) => string; } function HistorySection({ items, visibleCount, onShowMore, sortBy, sortAsc, onSortByChange, onSortAscToggle, onRemove, onRequeue, timeFormat, batchCollapsed, toggleBatchCollapsed, hasPermission, canModify, t, }: HistorySectionProps) { if (items.length === 0) { return (

{t('queue.history.emptyTitle')}

{t('queue.history.emptyDescription')}

); } // Group siblings sharing a batch_id into a single collapsible row. // First-seen order is preserved for batches; items keep their sorted // position from the parent's sort selector. const rows: HistoryRow[] = []; const seenBatches = new Set(); for (const item of items.slice(0, visibleCount)) { if (item.batch_id != null) { if (seenBatches.has(item.batch_id)) continue; seenBatches.add(item.batch_id); const siblings = items.filter((s) => s.batch_id === item.batch_id); rows.push({ kind: 'batch', batchId: item.batch_id, batchName: item.batch_name || t('queue.batch.defaultName'), items: siblings, }); } else { rows.push({ kind: 'item', item }); } } return (

{t('queue.sections.history')} ({t('queue.itemCount', { count: items.length })})

{rows.map((row) => { if (row.kind === 'item') { return ( onRemove(row.item)} onRequeue={() => onRequeue(row.item)} timeFormat={timeFormat} hasPermission={hasPermission} canModify={canModify} t={t} /> ); } // Batch group — spans the full grid width so it visually anchors // its children below it. The children themselves render in the // same responsive grid pattern inside the expanded body. const collapsed = batchCollapsed[row.batchId] ?? true; const completed = row.items.filter((i) => i.status === 'completed').length; const failed = row.items.filter((i) => i.status === 'failed').length; const skipped = row.items.filter((i) => i.status === 'skipped').length; const cancelled = row.items.filter((i) => i.status === 'cancelled').length; const latest = row.items .map((i) => i.completed_at || i.created_at) .filter((v): v is string => !!v) .sort() .at(-1); return (
{!collapsed && (
{row.items.map((child) => ( onRemove(child)} onRequeue={() => onRequeue(child)} timeFormat={timeFormat} hasPermission={hasPermission} canModify={canModify} t={t} /> ))}
)}
); })}
{items.length > visibleCount && (
{t('queue.history.showingCount', { shown: Math.min(visibleCount, items.length), total: items.length, })}
)}
); } const HISTORY_PAGE_SIZE = 50; export function QueuePage() { const { t } = useTranslation(); const queryClient = useQueryClient(); const { showToast } = useToast(); const { hasPermission, hasAnyPermission, canModify } = useAuth(); const [filterPrinter, setFilterPrinter] = useState(null); const [filterStatus, setFilterStatus] = useState(''); const [filterLocation, setFilterLocation] = useState(''); const [showClearHistoryConfirm, setShowClearHistoryConfirm] = useState(false); const [editItem, setEditItem] = useState(null); const [requeueItem, setRequeueItem] = useState(null); const [confirmAction, setConfirmAction] = useState<{ type: 'cancel' | 'remove' | 'stop'; item: PrintQueueItem; } | null>(null); const [selectedItems, setSelectedItems] = useState([]); const [showBulkEditModal, setShowBulkEditModal] = useState(false); // #1818: per-printer Resume-after-failure confirm modal. Tracks which // printer's gate the user is about to clear; null when no modal is open. const [resumeConfirm, setResumeConfirm] = useState<{ printerId: number; printerName: string; skippedCount: number; } | null>(null); const [historySortBy, setHistorySortBy] = useState<'date' | 'name' | 'printer'>(() => { const saved = localStorage.getItem('queue.historySortBy'); return (saved as 'date' | 'name' | 'printer') || 'date'; }); const [historySortAsc, setHistorySortAsc] = useState(() => { const saved = localStorage.getItem('queue.historySortAsc'); return saved !== null ? saved === 'true' : false; }); // #2682: History renders progressively — start at one page, grow on demand. // Reset happens only on a deliberate re-sort / filter change (below), NOT on // the periodic queue poll, so an expanded view doesn't snap back mid-scroll. const [historyVisibleCount, setHistoryVisibleCount] = useState(HISTORY_PAGE_SIZE); const [pendingSortBy, setPendingSortBy] = useState<'position' | 'name' | 'printer' | 'time'>(() => { const saved = localStorage.getItem('queue.pendingSortBy'); return (saved as 'position' | 'name' | 'printer' | 'time') || 'position'; }); const [pendingSortAsc, setPendingSortAsc] = useState(() => { const saved = localStorage.getItem('queue.pendingSortAsc'); return saved !== null ? saved === 'true' : true; }); // historyCollapsed legacy state retained only for localStorage migration; the // History tab renders unconditionally so this no longer drives the UI. // Tabbed page structure: Active queue stays as the main view; History // and Timeline split off. Persists per-user via localStorage. const [activeTab, setActiveTab] = useState<'queue' | 'batches' | 'history' | 'timeline' | 'pipelines'>(() => { // URL deep-link wins so the legacy /pipelines/runs redirect lands on the // right tab. localStorage holds the per-user last-selected fallback. const search = new URLSearchParams(window.location.search); const url = search.get('tab'); if (url === 'pipelines' || url === 'history' || url === 'timeline' || url === 'queue' || url === 'batches') { return url; } const saved = localStorage.getItem('queue.activeTab'); if (saved === 'history' || saved === 'timeline' || saved === 'pipelines' || saved === 'batches') return saved; return 'queue'; }); // Active-tab layout toggle. "position" = today's flat list; "printer" // groups items under per-printer section headers with aggregate stats. const [activeLayout, setActiveLayout] = useState<'position' | 'printer'>(() => { const saved = localStorage.getItem('queue.activeLayout'); return saved === 'printer' ? 'printer' : 'position'; }); // Per-batch collapse state, keyed by batch_id. Default = collapsed // (matches the SimplyPrint/Files convention — show the rollup first). const [batchCollapsed, setBatchCollapsed] = useState>(() => { try { const saved = localStorage.getItem('queue.batchCollapsed'); return saved ? JSON.parse(saved) : {}; } catch { return {}; } }); // Multi-drag bookkeeping for DragOverlay. Numeric for single items, string // `batch-` when a whole group is being dragged. const [activeDragId, setActiveDragId] = useState(null); // "Group as batch" modal. const [groupBatchModal, setGroupBatchModal] = useState(false); // Ungroup confirm. const [ungroupBatchId, setUngroupBatchId] = useState(null); // Persist sort settings to localStorage useEffect(() => { localStorage.setItem('queue.historySortBy', historySortBy); }, [historySortBy]); useEffect(() => { localStorage.setItem('queue.historySortAsc', String(historySortAsc)); }, [historySortAsc]); // Collapse History back to a single page when the user re-sorts or changes // the location filter (deliberate view changes). Intentionally excludes the // queue poll so periodic refetches keep the expanded count. useEffect(() => { setHistoryVisibleCount(HISTORY_PAGE_SIZE); }, [historySortBy, historySortAsc, filterLocation]); useEffect(() => { localStorage.setItem('queue.pendingSortBy', pendingSortBy); }, [pendingSortBy]); useEffect(() => { localStorage.setItem('queue.pendingSortAsc', String(pendingSortAsc)); }, [pendingSortAsc]); useEffect(() => { localStorage.setItem('queue.activeTab', activeTab); }, [activeTab]); useEffect(() => { localStorage.setItem('queue.activeLayout', activeLayout); }, [activeLayout]); useEffect(() => { localStorage.setItem('queue.batchCollapsed', JSON.stringify(batchCollapsed)); }, [batchCollapsed]); const sensors = useSensors( useSensor(PointerSensor, { activationConstraint: { distance: 8 } }), useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }) ); const { data: settings } = useQuery({ queryKey: ['settings'], queryFn: api.getSettings, }); const timeFormat: TimeFormat = settings?.time_format || 'system'; // Badge count for the Batches tab (#342). Deliberately its own query rather // than derived from the queue: an order whose runs have all finished has no // queue rows left, and those are precisely the orders the tab exists to // surface. Shares the ['batches'] key with the tab itself, so dispatching or // cancelling refreshes both. const { data: activeBatches } = useQuery({ queryKey: ['batches', 'active'], queryFn: () => api.getBatches('active'), }); const activeBatchCount = activeBatches?.length ?? 0; const { data: queue, isLoading } = useQuery({ queryKey: ['queue', filterPrinter, filterStatus], queryFn: () => api.getQueue(filterPrinter || undefined, filterStatus || undefined), refetchInterval: 5000, }); const { data: printers } = useQuery({ queryKey: ['printers'], queryFn: () => api.getPrinters(), }); const sjfMutation = useMutation({ mutationFn: (enabled: boolean) => api.updateSettings({ queue_shortest_first: enabled }), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['settings'] }); }, }); const cancelMutation = useMutation({ mutationFn: (id: number) => api.cancelQueueItem(id), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['queue'] }); showToast(t('queue.toast.cancelled')); }, onError: () => showToast(t('queue.toast.cancelFailed'), 'error'), }); const removeMutation = useMutation({ mutationFn: (id: number) => api.removeFromQueue(id), onSuccess: (result) => { queryClient.invalidateQueries({ queryKey: ['queue'] }); queryClient.invalidateQueries({ queryKey: ['batches'] }); // The backend keeps an order's last run for a plate, cancelled rather // than deleted, so the order can still re-queue it (#2960). Say so: // the row stays on screen and silence would read as a failed delete. showToast(result.deleted === false ? t('queue.toast.keptForOrder') : t('queue.toast.removed')); }, onError: () => showToast(t('queue.toast.removeFailed'), 'error'), }); const stopMutation = useMutation({ mutationFn: (id: number) => api.stopQueueItem(id), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['queue'] }); showToast(t('queue.toast.stopped')); }, onError: () => showToast(t('queue.toast.stopFailed'), 'error'), }); // Filament-deficit confirmation state (#1496). When the backend returns // 409 with `code=insufficient_filament` we stash the deficit + item id // here; the modal at the bottom of the page reads it and the "Print // Anyway" path re-issues the start with `skipFilamentCheck=true`. const [filamentShortConfirm, setFilamentShortConfirm] = useState<{ itemId: number; deficit: Array<{ slot_id: number; required_grams: number; remaining_grams: number | null; filament_type?: string | null; }>; } | null>(null); const startMutation = useMutation({ mutationFn: ({ id, skipFilamentCheck }: { id: number; skipFilamentCheck?: boolean }) => api.startQueueItem(id, { skipFilamentCheck }), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['queue'] }); showToast(t('queue.toast.released')); setFilamentShortConfirm(null); }, onError: (error: unknown, variables) => { if (error instanceof ApiError && error.status === 409 && error.code === 'insufficient_filament') { const deficitRaw = (error.detail?.deficit ?? []) as Array<{ slot_id: number; required_grams: number; remaining_grams: number | null; filament_type?: string | null; }>; setFilamentShortConfirm({ itemId: variables.id, deficit: deficitRaw }); return; } showToast(t('queue.toast.startFailed'), 'error'); }, }); const reorderMutation = useMutation({ mutationFn: (items: { id: number; position: number }[]) => api.reorderQueue(items), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['queue'] }); }, onError: () => showToast(t('queue.toast.reorderFailed'), 'error'), }); const clearHistoryMutation = useMutation({ mutationFn: async () => { const historyItems = queue?.filter(i => ['completed', 'failed', 'skipped', 'cancelled'].includes(i.status) ) || []; let cleared = 0; let kept = 0; for (const item of historyItems) { const result = await api.removeFromQueue(item.id); // A row a batch order still needs is kept rather than deleted, so the // count has to come from what the backend actually did (#2960). if (result.deleted === false) kept += 1; else cleared += 1; } return { cleared, kept }; }, onSuccess: ({ cleared, kept }) => { queryClient.invalidateQueries({ queryKey: ['queue'] }); queryClient.invalidateQueries({ queryKey: ['batches'] }); showToast( kept > 0 ? `${t('queue.toast.historyCleared', { count: cleared })} ${t('queue.toast.historyKeptForOrders', { kept })}` : t('queue.toast.historyCleared', { count: cleared }) ); }, onError: () => showToast(t('queue.toast.clearHistoryFailed'), 'error'), }); const bulkUpdateMutation = useMutation({ mutationFn: (data: PrintQueueBulkUpdate) => api.bulkUpdateQueue(data), onSuccess: (result) => { queryClient.invalidateQueries({ queryKey: ['queue'] }); setSelectedItems([]); setShowBulkEditModal(false); showToast(result.message); }, onError: () => showToast(t('queue.toast.updateFailed'), 'error'), }); const bulkCancelMutation = useMutation({ mutationFn: async (ids: number[]) => { for (const id of ids) { await api.cancelQueueItem(id); } return ids.length; }, onSuccess: (count) => { queryClient.invalidateQueries({ queryKey: ['queue'] }); setSelectedItems([]); showToast(t('queue.toast.bulkCancelled', { count })); }, onError: () => showToast(t('queue.toast.bulkCancelFailed'), 'error'), }); const resumeAfterFailureMutation = useMutation({ mutationFn: (printerId: number) => api.resumeQueueAfterFailure(printerId), onSuccess: (result) => { queryClient.invalidateQueries({ queryKey: ['queue'] }); setResumeConfirm(null); showToast( t('queue.toast.resumedAfterFailure', { restored: result.restored, acknowledged: result.acknowledged, }), ); }, onError: () => showToast(t('queue.toast.resumeAfterFailureFailed'), 'error'), }); const createBatchMutation = useMutation({ mutationFn: (data: { name: string; item_ids: number[] }) => api.createBatch(data), onSuccess: (batch) => { queryClient.invalidateQueries({ queryKey: ['queue'] }); setSelectedItems([]); setGroupBatchModal(false); // New batches start expanded so the user sees what they just grouped. setBatchCollapsed((prev) => ({ ...prev, [batch.id]: false })); showToast(t('queue.toast.batchCreated', { name: batch.name })); }, onError: () => showToast(t('queue.toast.batchCreateFailed'), 'error'), }); const ungroupBatchMutation = useMutation({ mutationFn: (id: number) => api.ungroupBatch(id), onSuccess: (result) => { queryClient.invalidateQueries({ queryKey: ['queue'] }); setUngroupBatchId(null); showToast(t('queue.toast.batchUngrouped', { count: result.ungrouped_count })); }, onError: () => showToast(t('queue.toast.batchUngroupFailed'), 'error'), }); const handleToggleSelect = (id: number) => { setSelectedItems(prev => prev.includes(id) ? prev.filter(x => x !== id) : [...prev, id] ); }; // Get unique locations from printers for the filter dropdown const uniqueLocations = useMemo(() => { const locations = new Set(); printers?.forEach(p => { if (p.location) locations.add(p.location); }); // Also include locations from queue items (for model-based assignments) queue?.forEach(item => { if (item.target_location) locations.add(item.target_location); }); return Array.from(locations).sort(); }, [printers, queue]); // Helper to check if a queue item matches the location filter const matchesLocationFilter = useCallback((item: PrintQueueItem): boolean => { if (!filterLocation) return true; // For model-based assignments, check target_location if (item.target_location) return item.target_location === filterLocation; // For printer-based assignments, check the printer's location if (item.printer_id) { const printer = printers?.find(p => p.id === item.printer_id); return printer?.location === filterLocation; } return false; }, [filterLocation, printers]); const pendingItems = useMemo(() => { let items = queue?.filter(i => i.status === 'pending') || []; // Apply location filter if (filterLocation) { items = items.filter(matchesLocationFilter); } // Helper to get scheduled time as timestamp (ASAP/placeholder = 0 for earliest) const getScheduledTime = (item: PrintQueueItem): number => { if (!item.scheduled_time) return 0; const time = parseUTCDate(item.scheduled_time)?.getTime() ?? 0; // Placeholder dates (> 6 months out) are treated as ASAP const sixMonthsFromNow = Date.now() + (180 * 24 * 60 * 60 * 1000); return time > sixMonthsFromNow ? 0 : time; }; // When SJF is enabled, override sort to match scheduler order if (settings?.queue_shortest_first) { return [...items].sort((a, b) => compareQueueOrderAcrossLanes(a, b, true)); } return [...items].sort((a, b) => { let cmp: number; if (pendingSortBy === 'name') { const aName = a.archive_name || a.library_file_name || ''; const bName = b.archive_name || b.library_file_name || ''; cmp = aName.localeCompare(bName); } else if (pendingSortBy === 'printer') { cmp = (a.printer_name || '').localeCompare(b.printer_name || ''); } else if (pendingSortBy === 'time') { // Sort by scheduled start time (when print will begin) cmp = getScheduledTime(a) - getScheduledTime(b); } else { cmp = a.position - b.position; } return pendingSortAsc ? cmp : -cmp; }); }, [queue, pendingSortBy, pendingSortAsc, matchesLocationFilter, filterLocation, settings?.queue_shortest_first]); const handleSelectAll = () => { const allPendingIds = pendingItems.map(i => i.id); if (selectedItems.length === allPendingIds.length) { setSelectedItems([]); } else { setSelectedItems(allPendingIds); } }; const activeItems = useMemo(() => { let items = queue?.filter(i => i.status === 'printing') || []; if (filterLocation) { items = items.filter(matchesLocationFilter); } return items; }, [queue, filterLocation, matchesLocationFilter]); // Queue items eligible for an "if started now" ETA (#2740). // // The ETA answers "when would this finish if it began right now", so it may // only appear on items that really could begin right now. waiting_reason now // covers the pinned-printer case too (#3074), but it is still not enough on // its own: it says whether the scheduler had a reason to hold the item on its // last pass, not whether this item is the one that printer takes next. Two // items pinned to the same free printer both come back with no reason, and // only one of them can start now — which is what the ordering below works out. // // Computed from the unfiltered queue on purpose — hiding a printer behind the // location filter must not make its printer look free. const etaEligibleIds = useMemo(() => { const eligible = new Set(); if (!queue) return eligible; const busyPrinters = new Set(); queue.forEach(item => { if (item.status === 'printing' && item.printer_id) busyPrinters.add(item.printer_id); }); const isFutureScheduled = (item: PrintQueueItem): boolean => { if (!item.scheduled_time) return false; return (parseUTCDate(item.scheduled_time)?.getTime() ?? 0) > Date.now(); }; // Mirrors the scheduler's own ordering so "next up" here means the item the // scheduler would actually dispatch next, not whatever the user sorted by. // Bucketed by printer immediately below, so the within-lane comparator is // the right one -- no cross-lane grouping needed. const schedulerOrder = (a: PrintQueueItem, b: PrintQueueItem): number => compareQueueOrder(a, b, settings?.queue_shortest_first ?? false); // Claimants for each printer, in the order the scheduler would take them. // Staged and future-scheduled items are excluded: the scheduler skips both // without marking the printer busy, so neither holds up the item behind it. const contenders = new Map(); queue .filter( item => item.status === 'pending' && item.printer_id != null && !item.manual_start && !isFutureScheduled(item) ) .sort(schedulerOrder) .forEach(item => { const list = contenders.get(item.printer_id!) ?? []; list.push(item); contenders.set(item.printer_id!, list); }); queue.forEach(item => { if (item.status !== 'pending') return; // Blocked, scheduled for later, or no usable duration to add. if (item.waiting_reason) return; if (isFutureScheduled(item)) return; if (item.print_time_seconds == null || item.print_time_seconds <= 0) return; // Conditional on an earlier print's outcome, which the UI cannot see: the // scheduler may skip it outright rather than ever running it. if (item.require_previous_success) return; // Model-based items have no printer yet; an empty waiting_reason is the // scheduler saying it found one, so trust that. if (item.printer_id == null) { eligible.add(item.id); return; } if (busyPrinters.has(item.printer_id)) return; // Staged items wait on the user, not on the queue, so they are startable // whenever their printer is free regardless of what is queued ahead. if (item.manual_start) { eligible.add(item.id); return; } if (contenders.get(item.printer_id)?.[0]?.id === item.id) eligible.add(item.id); }); return eligible; }, [queue, settings?.queue_shortest_first]); // The ETA is "now + duration", so it goes stale on its own. Nothing else // re-renders these rows while the queue payload is unchanged (react-query's // structural sharing keeps the reference stable), so drive it from a clock of // our own. Only runs while an ETA is actually on screen. const [etaNow, setEtaNow] = useState(() => Date.now()); const hasEtas = etaEligibleIds.size > 0; useEffect(() => { if (!hasEtas) return; setEtaNow(Date.now()); const id = setInterval(() => setEtaNow(Date.now()), 30000); return () => clearInterval(id); }, [hasEtas]); // Get unique printer IDs from active items to fetch their statuses const activePrinterIds = useMemo(() => { const ids = new Set(); activeItems.forEach(item => { if (item.printer_id) ids.add(item.printer_id); }); return Array.from(ids); }, [activeItems]); // Fetch printer statuses for printers with active jobs const printerStatusQueries = useQueries({ queries: activePrinterIds.map(printerId => ({ queryKey: ['printerStatus', printerId], queryFn: () => api.getPrinterStatus(printerId), refetchInterval: 5000, })), }); // Build a map of printer_id -> state for quick lookup const printerStateMap = useMemo(() => { const map: Record = {}; activePrinterIds.forEach((printerId, index) => { const result = printerStatusQueries[index]; if (result?.data?.state) { map[printerId] = result.data.state; } }); return map; }, [activePrinterIds, printerStatusQueries]); // Build a map of printer_id -> full status for timeline view const printerStatusMap = useMemo(() => { const map: Record = {}; activePrinterIds.forEach((printerId, index) => { const result = printerStatusQueries[index]; if (result?.data) { map[printerId] = { progress: result.data.progress ?? undefined, remaining_time: result.data.remaining_time ?? undefined, state: result.data.state ?? undefined, }; } }); return map; }, [activePrinterIds, printerStatusQueries]); const historyItems = useMemo(() => { let items = queue?.filter(i => ['completed', 'failed', 'skipped', 'cancelled'].includes(i.status)) || []; if (filterLocation) { items = items.filter(matchesLocationFilter); } return [...items].sort((a, b) => { let cmp: number; if (historySortBy === 'name') { const aName = a.archive_name || a.library_file_name || ''; const bName = b.archive_name || b.library_file_name || ''; cmp = aName.localeCompare(bName); } else if (historySortBy === 'printer') { cmp = (a.printer_name || '').localeCompare(b.printer_name || ''); } else { // Default: by date - most recent first (desc) is the natural order cmp = (parseUTCDate(b.completed_at || b.created_at)?.getTime() ?? 0) - (parseUTCDate(a.completed_at || a.created_at)?.getTime() ?? 0); } return historySortAsc ? -cmp : cmp; }); }, [queue, historySortBy, historySortAsc, matchesLocationFilter, filterLocation]); // Calculate total queue time const totalQueueTime = useMemo(() => { return pendingItems.reduce((acc, item) => acc + (item.print_time_seconds || 0), 0); }, [pendingItems]); // Calculate total material weight const totalWeight = useMemo(() => { return pendingItems.reduce((acc, item) => acc + (item.filament_used_grams || 0), 0); }, [pendingItems]); const handleDragStart = (event: DragStartEvent) => { const id = event.active.id; setActiveDragId(typeof id === 'number' || typeof id === 'string' ? id : null); }; const handleDragEnd = (event: DragEndEvent) => { const { active, over } = event; setActiveDragId(null); if (!over || active.id === over.id) return; // Resolve dragged source → movingIds (preserving order from pendingItems). // - `batch-`: every child of that batch, in their current order // - selected + dragged is one of them: contiguous multi-drag block // - otherwise: single row let movingIds: number[]; const activeId = active.id; if (typeof activeId === 'string' && activeId.startsWith('batch-')) { const batchId = Number(activeId.slice('batch-'.length)); movingIds = pendingItems.filter((i) => i.batch_id === batchId).map((i) => i.id); } else { const draggedId = activeId as number; movingIds = selectedItems.includes(draggedId) && selectedItems.length > 1 ? selectedItems.slice().sort((a, b) => { const ai = pendingItems.findIndex((i) => i.id === a); const bi = pendingItems.findIndex((i) => i.id === b); return ai - bi; }) : [draggedId]; } if (movingIds.length === 0) return; // Resolve drop target → index inside pendingItems. A `batch-` drop // target anchors at the batch's first child, so dropping above another // batch lands the moving block immediately before it. let overIndex: number; const overId = over.id; if (typeof overId === 'string' && overId.startsWith('batch-')) { const overBatchId = Number(overId.slice('batch-'.length)); overIndex = pendingItems.findIndex((i) => i.batch_id === overBatchId); } else { overIndex = pendingItems.findIndex((i) => i.id === overId); } if (overIndex === -1) return; // Remove the moving items, then re-insert at overIndex (adjusted). const overAnchor = pendingItems[overIndex]; const remaining = pendingItems.filter((i) => !movingIds.includes(i.id)); let insertAt = remaining.findIndex((i) => i.id === overAnchor.id); if (insertAt === -1) insertAt = overIndex; // If dragging downward across the drop target, insert AFTER it; upward // = before. dnd-kit's `over` is the row under the pointer, not the gap. const firstMovingIndex = pendingItems.findIndex((i) => i.id === movingIds[0]); if (firstMovingIndex < overIndex) insertAt += 1; const reordered = [ ...remaining.slice(0, insertAt), ...movingIds .map((id) => pendingItems.find((i) => i.id === id)) .filter((x): x is PrintQueueItem => !!x), ...remaining.slice(insertAt), ]; const updates = reordered.map((item, index) => ({ id: item.id, position: index + 1, })); reorderMutation.mutate(updates); }; // Group pending items by batch_id. Items with batch_id null render as // standalone rows; items sharing a batch_id render as a collapsible // group keyed by that id. Items inside a group keep their original // relative order from pendingItems. const groupedRows = useMemo(() => { const rows: QueueRow[] = []; const seenBatches = new Set(); for (const item of pendingItems) { if (item.batch_id != null) { if (seenBatches.has(item.batch_id)) continue; seenBatches.add(item.batch_id); const siblings = pendingItems.filter((s) => s.batch_id === item.batch_id); rows.push({ kind: 'batch', batchId: item.batch_id, batchName: item.batch_name || t('queue.batch.defaultName'), items: siblings, }); } else { rows.push({ kind: 'item', item }); } } return rows; }, [pendingItems, t]); // Mobile tap-to-reorder (#2667). The desktop drag handle is hidden on // phones and touch-drag is unreliable, so pending rows get up/down arrows. // Reordering only has a defined meaning in the manual "position" sort with // SJF off — any other sort re-orders the list itself, so we don't offer it. const canReorderManually = hasPermission('queue:reorder') && pendingSortBy === 'position' && !settings?.queue_shortest_first; const rowItemIds = (row: QueueRow): number[] => row.kind === 'item' ? [row.item.id] : row.items.map((i) => i.id); // Move a block of items to sit immediately before (or after) an anchor item // in the global pending order, then persist — the same remove-and-reinsert // shape as handleDragEnd, so arrows and drag agree. Anchoring to a real item // id keeps it correct in the printer-grouped layout, where a bucket's rows // aren't contiguous in the global order. const moveBlockRelativeTo = ( movingIds: number[], anchorId: number, placeAfter: boolean, ) => { const remaining = pendingItems.filter((i) => !movingIds.includes(i.id)); let insertAt = remaining.findIndex((i) => i.id === anchorId); if (insertAt === -1) return; if (placeAfter) insertAt += 1; const movingItems = movingIds .map((id) => pendingItems.find((i) => i.id === id)) .filter((x): x is PrintQueueItem => !!x); const reordered = [ ...remaining.slice(0, insertAt), ...movingItems, ...remaining.slice(insertAt), ]; reorderMutation.mutate( reordered.map((item, index) => ({ id: item.id, position: index + 1 })), ); }; // Build up/down thunks for the row at `idx` within its displayed sibling // list (the flat list, or a single printer bucket). Undefined at a boundary // (button rendered disabled) or when manual reorder isn't available. const rowMovers = ( rows: QueueRow[], idx: number, ): { onMoveUp?: () => void; onMoveDown?: () => void } => { if (!canReorderManually) return {}; const moving = rowItemIds(rows[idx]); const onMoveUp = idx > 0 ? () => moveBlockRelativeTo(moving, rowItemIds(rows[idx - 1])[0], false) : undefined; const onMoveDown = idx < rows.length - 1 ? () => { const next = rowItemIds(rows[idx + 1]); moveBlockRelativeTo(moving, next[next.length - 1], true); } : undefined; return { onMoveUp, onMoveDown }; }; // SortableContext ID list. // - Standalone pending items: their numeric id. // - Batch parents: the synthetic `batch-` string, always present so the // group itself is draggable and acts as a drop target whether collapsed // or expanded. // - Expanded batch children: their numeric id, so within-batch reorder // keeps working. Collapsed children are detached from the DOM and // intentionally omitted to keep dnd-kit's collision resolver clean. const sortableIds = useMemo<(number | string)[]>(() => { const ids: (number | string)[] = []; for (const row of groupedRows) { if (row.kind === 'item') { ids.push(row.item.id); } else { ids.push(`batch-${row.batchId}`); const collapsed = batchCollapsed[row.batchId] ?? true; if (!collapsed) { for (const child of row.items) ids.push(child.id); } } } return ids; }, [groupedRows, batchCollapsed]); // Items already in a batch can't be grouped; "Group as batch" only shows // when 2+ ungrouped items are selected. const canGroupSelected = useMemo(() => { if (selectedItems.length < 2) return false; return selectedItems.every((id) => { const item = pendingItems.find((p) => p.id === id); return item && item.batch_id == null; }); }, [selectedItems, pendingItems]); const toggleBatchCollapsed = (id: number) => { setBatchCollapsed((prev) => ({ ...prev, [id]: !(prev[id] ?? true) })); }; // Group by printer view. Items are bucketed by printer_id (null = model // assignment or unassigned, keyed by target_model or "unassigned"). type PrinterBucket = { key: string; printerId: number | null; targetModel: string | null; label: string; isUnassigned: boolean; rows: QueueRow[]; }; const printerBuckets = useMemo(() => { const buckets = new Map(); const bucketForItem = (item: PrintQueueItem): { key: string; label: string; printerId: number | null; targetModel: string | null; isUnassigned: boolean } => { if (item.printer_id) { return { key: `printer:${item.printer_id}`, label: item.printer_name || `Printer #${item.printer_id}`, printerId: item.printer_id, targetModel: null, isUnassigned: false, }; } // A cross-model item (#671) is waiting on several models. Its own // target_model is just the first candidate mirrored onto the row, so // bucketing on it would file the job under one printer it might never // run on — and the row underneath already says "Any H2D / X1C". if ((item.variants?.length ?? 0) > 1) { const models = item.variants!.map((v) => v.target_model).join(' / '); return { key: `models:${models}`, label: `${t('queue.filter.any')} ${models}`, printerId: null, targetModel: null, isUnassigned: false, }; } if (item.target_model) { return { key: `model:${item.target_model}`, label: `${t('queue.filter.any')} ${item.target_model}`, printerId: null, targetModel: item.target_model, isUnassigned: false, }; } return { key: 'unassigned', label: t('queue.filter.unassigned'), printerId: null, targetModel: null, isUnassigned: true, }; }; for (const row of groupedRows) { const representative = row.kind === 'item' ? row.item : row.items[0]; if (!representative) continue; const meta = bucketForItem(representative); if (!buckets.has(meta.key)) { buckets.set(meta.key, { ...meta, rows: [] }); } buckets.get(meta.key)!.rows.push(row); } return Array.from(buckets.values()).sort((a, b) => { if (a.isUnassigned && !b.isUnassigned) return 1; if (!a.isUnassigned && b.isUnassigned) return -1; return a.label.localeCompare(b.label); }); }, [groupedRows, t]); // #1818: printers whose queue is gated by a prior failure that's poisoning // downstream `require_previous_success` items. We surface a per-printer // Resume banner above the active queue so the user can clear the gate + // restore the skipped jobs in one click, without re-queuing each one. // Detection key: skipped + the scheduler's exact gate string. Other skip // reasons (filament deficit, etc.) get their own UX and stay untouched. const gateBlockedPrinters = useMemo< Array<{ printerId: number; printerName: string; skippedCount: number }> >(() => { const counts = new Map(); queue?.forEach((item) => { if ( item.status === 'skipped' && item.error_message === 'Previous print failed or was aborted' && item.printer_id ) { const existing = counts.get(item.printer_id); if (existing) { existing.count += 1; } else { counts.set(item.printer_id, { name: item.printer_name || `Printer #${item.printer_id}`, count: 1, }); } } }); return Array.from(counts.entries()) .map(([printerId, { name, count }]) => ({ printerId, printerName: name, skippedCount: count, })) .sort((a, b) => a.printerName.localeCompare(b.printerName)); }, [queue]); const aggregateForRows = (rows: QueueRow[]) => { let count = 0; let time = 0; let weight = 0; for (const row of rows) { const items = row.kind === 'item' ? [row.item] : row.items; for (const item of items) { count += 1; time += item.print_time_seconds || 0; weight += item.filament_used_grams || 0; } } return { count, time, weight }; }; return (
{/* Header */}

{t('queue.title')}

{t('queue.subtitle')}

{/* Tab strip — Active queue is the main view; History and Timeline live in their own tabs so the queue page stays focused. */}
{([ { id: 'queue' as const, label: t('queue.tabs.queue'), icon: Clock, count: pendingItems.length + activeItems.length }, { id: 'batches' as const, label: t('queue.tabs.batches'), icon: Package, count: activeBatchCount }, { id: 'history' as const, label: t('queue.tabs.history'), icon: ListOrdered, count: historyItems.length }, { id: 'timeline' as const, label: t('queue.tabs.timeline'), icon: GanttChart, count: null as number | null }, // Slicer Pipelines dashboard (#1425 PR C). Lives here instead of // its own sidebar entry so the Print Queue page is the single // place an operator looks for "what's running / what ran". { id: 'pipelines' as const, label: t('queue.tabs.pipelines'), icon: Workflow, count: null as number | null }, ]).map(({ id, label, icon: Icon, count }) => ( ))}
{/* Summary Stats — about the print queue, not pipelines. */} {activeTab !== 'pipelines' && activeTab !== 'batches' && } {/* #1818: Resume-after-failure banner. One row per printer whose queue is gated by a prior failed/aborted print. Visible regardless of tab/layout so the user can clear the gate without hunting for skipped items. Hidden entirely when no gates are active. */} {activeTab === 'queue' && gateBlockedPrinters.length > 0 && hasPermission('queue:update_all' as Permission) && (
{gateBlockedPrinters.map(({ printerId, printerName, skippedCount }) => (
{t('queue.resumeAfterFailure.banner', { printer: printerName, count: skippedCount, })}
{t('queue.resumeAfterFailure.bannerHint')}
))}
)} {/* Filters — about the print queue items (printer / status / location). The Pipelines tab has its own pipeline + status filters inside the dashboard, so this row is hidden when that tab is active. */} {activeTab !== 'pipelines' && activeTab !== 'batches' && (
{uniqueLocations.length > 0 && ( )}
{activeTab === 'history' && historyItems.length > 0 && ( )}
)} {/* Queue-tab controls: layout toggle (Position / Printer) + SJF. Hidden on History/Timeline tabs since they don't apply. */} {activeTab === 'queue' && (
)} {/* Pipelines tab short-circuits before the queue-empty branch so the dashboard renders even when the regular queue is empty. */} {activeTab === 'pipelines' ? ( ) : activeTab === 'batches' ? ( ) : isLoading ? (
{t('common.loading')}
) : queue?.length === 0 ? (

{t('queue.empty.title')}

{t('queue.empty.description')}

) : activeTab === 'timeline' ? ( { if (['completed', 'failed', 'skipped', 'cancelled'].includes(item.status)) { setRequeueItem(item); } else if (item.status === 'pending') { setEditItem(item); } else if (item.status === 'printing') { setConfirmAction({ type: 'stop', item }); } }} t={t} /> ) : activeTab === 'history' ? ( setHistoryVisibleCount((c) => c + HISTORY_PAGE_SIZE)} sortBy={historySortBy} sortAsc={historySortAsc} onSortByChange={setHistorySortBy} onSortAscToggle={() => setHistorySortAsc(!historySortAsc)} onRemove={(item) => setConfirmAction({ type: 'remove', item })} onRequeue={setRequeueItem} timeFormat={timeFormat} batchCollapsed={batchCollapsed} toggleBatchCollapsed={toggleBatchCollapsed} hasPermission={hasPermission} canModify={canModify} t={t} /> ) : (
{/* Active Prints */} {activeItems.length > 0 && (

{t('queue.sections.currentlyPrinting')}

{activeItems.map((item) => ( {}} onCancel={() => {}} onRemove={() => {}} onStop={() => setConfirmAction({ type: 'stop', item })} onRequeue={() => {}} onStart={() => {}} timeFormat={timeFormat} hasPermission={hasPermission} canModify={canModify} printerState={item.printer_id ? printerStateMap[item.printer_id] : null} t={t} /> ))}
)} {/* Pending Queue */} {pendingItems.length > 0 && (

{t('queue.sections.queued')} ({t('queue.itemCount', { count: pendingItems.length })}) {t('queue.dragToReorder')}

{/* Bulk action toolbar (now with "Group as batch") */}
{selectedItems.length > 0 && ( <> {t('queue.bulkEdit.selected', { count: selectedItems.length })}
{canGroupSelected && ( )} )}
{activeLayout === 'position' ? (
{groupedRows.map((row, idx) => ( toggleBatchCollapsed(row.batchId) : undefined} onUngroup={row.kind === 'batch' ? () => setUngroupBatchId(row.batchId) : undefined} setEditItem={setEditItem} setConfirmAction={setConfirmAction} startMutation={startMutation} selectedItems={selectedItems} handleToggleSelect={handleToggleSelect} timeFormat={timeFormat} hasPermission={hasPermission} canModify={canModify} t={t} etaEligibleIds={etaEligibleIds} etaNow={etaNow} aggregateForRows={aggregateForRows} {...rowMovers(groupedRows, idx)} onMoveBlock={canReorderManually ? moveBlockRelativeTo : undefined} /> ))}
) : (
{printerBuckets.map((bucket) => { const agg = aggregateForRows(bucket.rows); return (
{bucket.label} {t('queue.itemCount', { count: agg.count })} {agg.time > 0 && {formatDuration(agg.time)}} {agg.weight > 0 && {formatWeight(agg.weight)}}
{bucket.rows.map((row, idx) => ( toggleBatchCollapsed(row.batchId) : undefined} onUngroup={row.kind === 'batch' ? () => setUngroupBatchId(row.batchId) : undefined} setEditItem={setEditItem} setConfirmAction={setConfirmAction} startMutation={startMutation} selectedItems={selectedItems} handleToggleSelect={handleToggleSelect} timeFormat={timeFormat} hasPermission={hasPermission} canModify={canModify} t={t} etaEligibleIds={etaEligibleIds} etaNow={etaNow} aggregateForRows={aggregateForRows} {...rowMovers(bucket.rows, idx)} onMoveBlock={canReorderManually ? moveBlockRelativeTo : undefined} /> ))}
); })}
)}
{(() => { if (activeDragId === null) return null; // Batch drag — show the group ghost with copy count. if (typeof activeDragId === 'string' && activeDragId.startsWith('batch-')) { const batchId = Number(activeDragId.slice('batch-'.length)); const siblings = pendingItems.filter((i) => i.batch_id === batchId); if (siblings.length === 0) return null; const name = siblings[0].batch_name || t('queue.batch.defaultName'); return (
{t('queue.dragGhost.batch', { defaultValue: '{{name}} ({{count}} copies)', name, count: siblings.length, })}
); } // Multi-row drag — show the N-item ghost. if (typeof activeDragId === 'number' && selectedItems.includes(activeDragId) && selectedItems.length > 1) { return (
{t('queue.dragGhost.multiCount', { count: selectedItems.length })}
); } return null; })()}
)}
)} {/* Edit Modal */} {editItem && ( t('common.plusNMore', { count: n }))} queueItem={editItem} onClose={() => setEditItem(null)} /> )} {/* Re-queue Modal */} {requeueItem && ( t('common.plusNMore', { count: n }))} onClose={() => setRequeueItem(null)} /> )} {/* Confirm Action Modal */} {filamentShortConfirm && ( t('queue.filamentShort.lineItem', { slot: d.slot_id, required: Math.round(d.required_grams), remaining: d.remaining_grams == null ? t('queue.filamentShort.unknown') : Math.round(d.remaining_grams), }), ) .join('\n') } confirmText={t('queue.filamentShort.printAnyway')} variant="warning" onConfirm={() => { startMutation.mutate({ id: filamentShortConfirm.itemId, skipFilamentCheck: true }); }} onCancel={() => setFilamentShortConfirm(null)} /> )} {confirmAction && ( { if (confirmAction.type === 'cancel') { cancelMutation.mutate(confirmAction.item.id); } else if (confirmAction.type === 'stop') { stopMutation.mutate(confirmAction.item.id); } else { removeMutation.mutate(confirmAction.item.id); } setConfirmAction(null); }} onCancel={() => setConfirmAction(null)} /> )} {/* #1818: Resume-after-failure confirm */} {resumeConfirm && ( resumeAfterFailureMutation.mutate(resumeConfirm.printerId)} onCancel={() => setResumeConfirm(null)} /> )} {/* Clear History Confirm Modal */} {showClearHistoryConfirm && ( { clearHistoryMutation.mutate(); setShowClearHistoryConfirm(false); }} onCancel={() => setShowClearHistoryConfirm(false)} /> )} {/* Bulk Edit Modal */} {showBulkEditModal && ( ({ id: p.id, name: p.name, nozzle_count: p.nozzle_count })) || []} onSave={(data) => { if (Object.keys(data).length > 0) { bulkUpdateMutation.mutate({ item_ids: selectedItems, ...data }); } }} onClose={() => setShowBulkEditModal(false)} isSaving={bulkUpdateMutation.isPending} canControlPrinter={hasPermission('printers:control')} hasGcodeSnippets={!!settings?.gcode_snippets} t={t} /> )} {/* Group as batch modal — name prompt */} {groupBatchModal && ( { // Suggest a name derived from the first selected item's source. const first = pendingItems.find((i) => selectedItems.includes(i.id)); const raw = first?.archive_name || first?.library_file_name || ''; const cleaned = raw.replace(/\.gcode\.3mf$/i, '').replace(/\.3mf$/i, ''); return cleaned ? `${cleaned}` : t('queue.batch.defaultName'); })()} onSave={(name) => createBatchMutation.mutate({ name, item_ids: selectedItems })} onClose={() => setGroupBatchModal(false)} t={t} /> )} {/* Ungroup batch confirm */} {ungroupBatchId !== null && ( ungroupBatchMutation.mutate(ungroupBatchId)} onCancel={() => setUngroupBatchId(null)} /> )}
); } interface GroupBatchModalProps { itemCount: number; defaultName: string; isSaving: boolean; onSave: (name: string) => void; onClose: () => void; t: (key: string, options?: Record) => string; } function GroupBatchModal({ itemCount, defaultName, isSaving, onSave, onClose, t }: GroupBatchModalProps) { const [name, setName] = useState(defaultName); return (

{t('queue.batch.groupAsBatch')}

{t('queue.batch.groupAsBatchDescription', { count: itemCount })}

setName(e.target.value)} placeholder={t('queue.batch.namePlaceholder')} maxLength={120} className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none mb-5" />
); }