Add virtual printer Queue mode and sidebar badge indicators

Virtual Printer Changes:
- Add new "Queue" mode that archives files and adds them to print queue
- Rename modes: "Immediate" → "Archive", "Queue for Review" → "Review"
- Three modes now: Archive (immediate), Review (pending), Queue (print queue)
- Queue mode creates unassigned queue items (printer_id=null)

Print Queue Changes:
- Make printer_id nullable in PrintQueueItem model/schema
- Add support for unassigned queue items (no printer assigned)
- Queue page shows "Unassigned" filter option
- Edit modal allows assigning printer to unassigned items
- Unassigned items highlighted in orange

Sidebar Badge Indicators:
- Queue icon shows yellow badge with pending queue item count
- Archive icon shows blue badge with pending upload count
- Badges auto-update every 5 seconds and on window focus

Other:
- Update backup/restore to handle nullable printer_id and ams_mapping
- Add database migration for nullable printer_id column
- Update VirtualPrinterSettings tests for new modes
- Update virtual printer API integration tests
- Remove speed controls documentation from wiki (not implemented)
This commit is contained in:
maziggy
2026-01-17 13:30:00 +01:00
parent 699d7b0c71
commit 6fd11ddc77
19 changed files with 452 additions and 149 deletions
@@ -99,11 +99,11 @@ describe('VirtualPrinterSettings', () => {
});
});
it('renders archive mode section', async () => {
it('renders mode section', async () => {
render(<VirtualPrinterSettings />);
await waitFor(() => {
expect(screen.getByText('Archive Mode')).toBeInTheDocument();
expect(screen.getByText('Mode')).toBeInTheDocument();
});
});
@@ -259,28 +259,48 @@ describe('VirtualPrinterSettings', () => {
});
});
describe('archive mode', () => {
it('renders immediate mode option', async () => {
describe('mode selection', () => {
it('renders Archive mode option', async () => {
render(<VirtualPrinterSettings />);
await waitFor(() => {
expect(screen.getByText('Immediate')).toBeInTheDocument();
expect(
screen.getByText('Archive files as soon as they are uploaded')
).toBeInTheDocument();
expect(screen.getByText('Archive')).toBeInTheDocument();
expect(screen.getByText('Archive files immediately')).toBeInTheDocument();
});
});
it('renders queue mode option', async () => {
it('renders Review mode option', async () => {
render(<VirtualPrinterSettings />);
await waitFor(() => {
expect(screen.getByText('Queue for Review')).toBeInTheDocument();
expect(screen.getByText('Review and tag files before archiving')).toBeInTheDocument();
expect(screen.getByText('Review')).toBeInTheDocument();
expect(screen.getByText('Review and tag before archiving')).toBeInTheDocument();
});
});
it('highlights current mode', async () => {
it('renders Queue mode option', async () => {
render(<VirtualPrinterSettings />);
await waitFor(() => {
expect(screen.getByText('Queue')).toBeInTheDocument();
expect(screen.getByText('Archive and add to print queue')).toBeInTheDocument();
});
});
it('highlights current mode (review)', async () => {
vi.mocked(virtualPrinterApi.getSettings).mockResolvedValue(
createMockSettings({ mode: 'review' })
);
render(<VirtualPrinterSettings />);
await waitFor(() => {
const reviewButton = screen.getByText('Review').closest('button');
expect(reviewButton?.className).toContain('border-bambu-green');
});
});
it('highlights current mode (legacy queue maps to review)', async () => {
vi.mocked(virtualPrinterApi.getSettings).mockResolvedValue(
createMockSettings({ mode: 'queue' })
);
@@ -288,30 +308,52 @@ describe('VirtualPrinterSettings', () => {
render(<VirtualPrinterSettings />);
await waitFor(() => {
const queueButton = screen.getByText('Queue for Review').closest('button');
expect(queueButton?.className).toContain('border-bambu-green');
const reviewButton = screen.getByText('Review').closest('button');
expect(reviewButton?.className).toContain('border-bambu-green');
});
});
it('changes mode on click', async () => {
it('changes mode to review on click', async () => {
const user = userEvent.setup();
vi.mocked(virtualPrinterApi.updateSettings).mockResolvedValue(
createMockSettings({ mode: 'queue' })
createMockSettings({ mode: 'review' })
);
render(<VirtualPrinterSettings />);
await waitFor(() => {
expect(screen.getByText('Queue for Review')).toBeInTheDocument();
expect(screen.getByText('Review')).toBeInTheDocument();
});
const queueButton = screen.getByText('Queue for Review').closest('button');
const reviewButton = screen.getByText('Review').closest('button');
if (reviewButton) {
await user.click(reviewButton);
}
await waitFor(() => {
expect(virtualPrinterApi.updateSettings).toHaveBeenCalledWith({ mode: 'review' });
});
});
it('changes mode to print_queue on click', async () => {
const user = userEvent.setup();
vi.mocked(virtualPrinterApi.updateSettings).mockResolvedValue(
createMockSettings({ mode: 'print_queue' })
);
render(<VirtualPrinterSettings />);
await waitFor(() => {
expect(screen.getByText('Queue')).toBeInTheDocument();
});
const queueButton = screen.getByText('Queue').closest('button');
if (queueButton) {
await user.click(queueButton);
}
await waitFor(() => {
expect(virtualPrinterApi.updateSettings).toHaveBeenCalledWith({ mode: 'queue' });
expect(virtualPrinterApi.updateSettings).toHaveBeenCalledWith({ mode: 'print_queue' });
});
});
});
+6 -6
View File
@@ -814,7 +814,7 @@ export interface DiscoveredTasmotaDevice {
// Print Queue types
export interface PrintQueueItem {
id: number;
printer_id: number;
printer_id: number | null; // null = unassigned
archive_id: number;
position: number;
scheduled_time: string | null;
@@ -834,7 +834,7 @@ export interface PrintQueueItem {
}
export interface PrintQueueItemCreate {
printer_id: number;
printer_id?: number | null; // null = unassigned
archive_id: number;
scheduled_time?: string | null;
require_previous_success?: boolean;
@@ -844,7 +844,7 @@ export interface PrintQueueItemCreate {
}
export interface PrintQueueItemUpdate {
printer_id?: number;
printer_id?: number | null; // null = unassign
position?: number;
scheduled_time?: string | null;
require_previous_success?: boolean;
@@ -2516,7 +2516,7 @@ export const discoveryApi = {
export interface VirtualPrinterStatus {
enabled: boolean;
running: boolean;
mode: 'immediate' | 'queue';
mode: 'immediate' | 'queue' | 'review' | 'print_queue'; // 'queue' is legacy, normalized to 'review'
name: string;
serial: string;
model: string;
@@ -2527,7 +2527,7 @@ export interface VirtualPrinterStatus {
export interface VirtualPrinterSettings {
enabled: boolean;
access_code_set: boolean;
mode: 'immediate' | 'queue';
mode: 'immediate' | 'queue' | 'review' | 'print_queue'; // 'queue' is legacy, normalized to 'review'
model: string;
status: VirtualPrinterStatus;
}
@@ -2558,7 +2558,7 @@ export const virtualPrinterApi = {
updateSettings: (data: {
enabled?: boolean;
access_code?: string;
mode?: 'immediate' | 'queue';
mode?: 'immediate' | 'review' | 'print_queue';
model?: string;
}) => {
const params = new URLSearchParams();
+24 -14
View File
@@ -17,7 +17,7 @@ export function EditQueueItemModal({ item, onClose }: EditQueueItemModalProps) {
const queryClient = useQueryClient();
const { showToast } = useToast();
const [printerId, setPrinterId] = useState<number>(item.printer_id);
const [printerId, setPrinterId] = useState<number | null>(item.printer_id);
// Check if scheduled_time is a "placeholder" far-future date (more than 6 months out)
const isPlaceholderDate = item.scheduled_time &&
@@ -69,8 +69,8 @@ export function EditQueueItemModal({ item, onClose }: EditQueueItemModalProps) {
// Fetch printer status when a printer is selected
const { data: printerStatus } = useQuery({
queryKey: ['printer-status', printerId],
queryFn: () => api.getPrinterStatus(printerId),
enabled: !!printerId,
queryFn: () => api.getPrinterStatus(printerId!),
enabled: printerId !== null,
});
// Clear manual mappings when printer changes (but not on initial load)
@@ -370,21 +370,31 @@ export function EditQueueItemModal({ item, onClose }: EditQueueItemModalProps) {
No printers configured
</div>
) : (
<select
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"
value={printerId}
onChange={(e) => setPrinterId(Number(e.target.value))}
required
>
{printers?.map((p) => (
<option key={p.id} value={p.id}>{p.name}</option>
))}
</select>
<>
<select
className={`w-full px-3 py-2 bg-bambu-dark border rounded-lg text-white focus:border-bambu-green focus:outline-none ${
printerId === null ? 'border-orange-400' : 'border-bambu-dark-tertiary'
}`}
value={printerId ?? ''}
onChange={(e) => setPrinterId(e.target.value ? Number(e.target.value) : null)}
>
<option value="">-- Select a printer --</option>
{printers?.map((p) => (
<option key={p.id} value={p.id}>{p.name}</option>
))}
</select>
{printerId === null && (
<p className="text-xs text-orange-400 mt-1 flex items-center gap-1">
<AlertCircle className="w-3 h-3" />
Assign a printer to enable printing
</p>
)}
</>
)}
</div>
{/* Filament Mapping Section */}
{printerId && hasFilamentReqs && (
{printerId !== null && hasFilamentReqs && (
<div>
<button
type="button"
+36 -2
View File
@@ -6,7 +6,7 @@ import { useTheme } from '../contexts/ThemeContext';
import { KeyboardShortcutsModal } from './KeyboardShortcutsModal';
import { SwitchbarPopover } from './SwitchbarPopover';
import { useQuery } from '@tanstack/react-query';
import { api, supportApi } from '../api/client';
import { api, supportApi, pendingUploadsApi } from '../api/client';
import { getIconByName } from './IconPicker';
import { useIsMobile } from '../hooks/useIsMobile';
@@ -126,6 +126,26 @@ export function Layout() {
refetchInterval: 60 * 1000, // Refresh every minute
});
// Fetch pending queue items count for badge
const { data: queueItems } = useQuery({
queryKey: ['queue', 'pending'],
queryFn: () => api.getQueue(undefined, 'pending'),
staleTime: 5 * 1000, // 5 seconds
refetchInterval: 5 * 1000, // Refresh every 5 seconds
refetchOnWindowFocus: true,
});
const pendingQueueCount = queueItems?.length ?? 0;
// Fetch pending uploads count for archive badge (virtual printer review items)
const { data: pendingUploadsData } = useQuery({
queryKey: ['pending-uploads', 'count'],
queryFn: pendingUploadsApi.getCount,
staleTime: 5 * 1000, // 5 seconds
refetchInterval: 5 * 1000, // Refresh every 5 seconds
refetchOnWindowFocus: true,
});
const pendingUploadsCount = pendingUploadsData?.count ?? 0;
// Calculate debug duration client-side for real-time updates
const [debugDuration, setDebugDuration] = useState<number | null>(null);
useEffect(() => {
@@ -418,6 +438,11 @@ export function Layout() {
if (!navItem) return null;
const { to, icon: Icon, labelKey } = navItem;
const showQueueBadge = id === 'queue' && pendingQueueCount > 0;
const showArchiveBadge = id === 'archives' && pendingUploadsCount > 0;
const badgeCount = showQueueBadge ? pendingQueueCount : showArchiveBadge ? pendingUploadsCount : 0;
const showBadge = showQueueBadge || showArchiveBadge;
return (
<li
key={id}
@@ -449,7 +474,16 @@ export function Layout() {
{sidebarExpanded && !isMobile && (
<GripVertical className="w-4 h-4 flex-shrink-0 opacity-0 group-hover:opacity-50 cursor-grab active:cursor-grabbing -ml-1" />
)}
<Icon className="w-5 h-5 flex-shrink-0" />
<div className="relative">
<Icon className="w-5 h-5 flex-shrink-0" />
{showBadge && (
<span className={`absolute -top-1.5 -right-1.5 min-w-[18px] h-[18px] px-1 flex items-center justify-center text-[10px] font-bold rounded-full ${
showArchiveBadge ? 'bg-blue-500 text-white' : 'bg-yellow-500 text-black'
}`}>
{badgeCount > 99 ? '99+' : badgeCount}
</span>
)}
</div>
{(isMobile || sidebarExpanded) && <span>{t(labelKey)}</span>}
</NavLink>
</li>
@@ -12,7 +12,7 @@ export function VirtualPrinterSettings() {
const [localEnabled, setLocalEnabled] = useState(false);
const [localAccessCode, setLocalAccessCode] = useState('');
const [localMode, setLocalMode] = useState<'immediate' | 'queue'>('immediate');
const [localMode, setLocalMode] = useState<'immediate' | 'review' | 'print_queue'>('immediate');
const [localModel, setLocalModel] = useState('3DPrinter-X1-Carbon');
const [showAccessCode, setShowAccessCode] = useState(false);
const [pendingAction, setPendingAction] = useState<'toggle' | 'accessCode' | 'mode' | 'model' | null>(null);
@@ -34,14 +34,19 @@ export function VirtualPrinterSettings() {
useEffect(() => {
if (settings) {
setLocalEnabled(settings.enabled);
setLocalMode(settings.mode);
// Map legacy 'queue' mode to 'review'
let mode: 'immediate' | 'review' | 'print_queue' = settings.mode === 'queue' ? 'review' : settings.mode;
if (mode !== 'immediate' && mode !== 'review' && mode !== 'print_queue') {
mode = 'immediate'; // fallback
}
setLocalMode(mode);
setLocalModel(settings.model);
}
}, [settings]);
// Update mutation
const updateMutation = useMutation({
mutationFn: (data: { enabled?: boolean; access_code?: string; mode?: 'immediate' | 'queue'; model?: string }) =>
mutationFn: (data: { enabled?: boolean; access_code?: string; mode?: 'immediate' | 'review' | 'print_queue'; model?: string }) =>
virtualPrinterApi.updateSettings(data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['virtual-printer-settings'] });
@@ -53,7 +58,9 @@ export function VirtualPrinterSettings() {
// Revert local state on error
if (settings) {
setLocalEnabled(settings.enabled);
setLocalMode(settings.mode);
// Map legacy 'queue' mode to 'review'
const mode = settings.mode === 'queue' ? 'review' : settings.mode;
setLocalMode(mode === 'print_queue' || mode === 'review' ? mode : 'immediate');
setLocalModel(settings.model);
}
setPendingAction(null);
@@ -96,7 +103,7 @@ export function VirtualPrinterSettings() {
setLocalAccessCode(''); // Clear after saving
};
const handleModeChange = (mode: 'immediate' | 'queue') => {
const handleModeChange = (mode: 'immediate' | 'review' | 'print_queue') => {
setLocalMode(mode);
setPendingAction('mode');
updateMutation.mutate({ mode });
@@ -251,10 +258,10 @@ export function VirtualPrinterSettings() {
</p>
</div>
{/* Archive Mode */}
{/* Mode */}
<div className="py-3 border-t border-bambu-dark-tertiary">
<div className="text-white font-medium mb-2">Archive Mode</div>
<div className="grid grid-cols-2 gap-3">
<div className="text-white font-medium mb-2">Mode</div>
<div className="grid grid-cols-3 gap-3">
<button
onClick={() => handleModeChange('immediate')}
disabled={pendingAction === 'mode'}
@@ -264,20 +271,32 @@ export function VirtualPrinterSettings() {
: 'border-bambu-dark-tertiary hover:border-bambu-gray'
}`}
>
<div className="text-white font-medium">Immediate</div>
<div className="text-xs text-bambu-gray">Archive files as soon as they are uploaded</div>
<div className="text-white font-medium">Archive</div>
<div className="text-xs text-bambu-gray">Archive files immediately</div>
</button>
<button
onClick={() => handleModeChange('queue')}
onClick={() => handleModeChange('review')}
disabled={pendingAction === 'mode'}
className={`p-3 rounded-lg border text-left transition-colors ${
localMode === 'queue'
localMode === 'review'
? 'border-bambu-green bg-bambu-green/10'
: 'border-bambu-dark-tertiary hover:border-bambu-gray'
}`}
>
<div className="text-white font-medium">Queue for Review</div>
<div className="text-xs text-bambu-gray">Review and tag files before archiving</div>
<div className="text-white font-medium">Review</div>
<div className="text-xs text-bambu-gray">Review and tag before archiving</div>
</button>
<button
onClick={() => handleModeChange('print_queue')}
disabled={pendingAction === 'mode'}
className={`p-3 rounded-lg border text-left transition-colors ${
localMode === 'print_queue'
? 'border-bambu-green bg-bambu-green/10'
: 'border-bambu-dark-tertiary hover:border-bambu-gray'
}`}
>
<div className="text-white font-medium">Queue</div>
<div className="text-xs text-bambu-gray">Archive and add to print queue</div>
</button>
</div>
</div>
+10 -4
View File
@@ -195,9 +195,9 @@ function SortableQueueItem({
</div>
<div className="flex items-center gap-3 text-sm text-bambu-gray">
<span className="flex items-center gap-1.5">
<span className={`flex items-center gap-1.5 ${item.printer_id === null ? 'text-orange-400' : ''}`}>
<Printer className="w-3.5 h-3.5" />
{item.printer_name || `Printer #${item.printer_id}`}
{item.printer_id === null ? 'Unassigned' : (item.printer_name || `Printer #${item.printer_id}`)}
</span>
{item.print_time_seconds && (
<span className="flex items-center gap-1.5">
@@ -600,10 +600,16 @@ export function QueuePage() {
<div className="flex items-center gap-4 mb-6">
<select
className="px-3 py-2 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none"
value={filterPrinter || ''}
onChange={(e) => setFilterPrinter(e.target.value ? Number(e.target.value) : null)}
value={filterPrinter === -1 ? 'unassigned' : (filterPrinter || '')}
onChange={(e) => {
const val = e.target.value;
if (val === 'unassigned') setFilterPrinter(-1);
else if (val === '') setFilterPrinter(null);
else setFilterPrinter(Number(val));
}}
>
<option value="">All Printers</option>
<option value="unassigned">Unassigned</option>
{printers?.map((p) => (
<option key={p.id} value={p.id}>{p.name}</option>
))}