Model-based queue assignment with filament validation and queue notifications (#162)

Model-based queue assignment:
- Extract printer_model from sliced 3MF files during upload
- Display sliced-for model in archive view
- New queue mode: assign to "Any [Model]" instead of specific printer
- Scheduler auto-assigns to first idle printer of matching model
- Filament validation: only assign to printers with required filament types loaded
- Waiting reason display shows why jobs are waiting (e.g., "Waiting for filament: Printer1 (needs PLA)")
- "Waiting" status badge (purple) distinguishes from regular "Pending"

Queue notifications (7 new events):
- Job Added: When a job is added to queue
- Job Assigned: When a model-based job is assigned to a printer
- Job Started: When a queue job starts printing
- Job Waiting: When a job is waiting for filament (enabled by default)
- Job Skipped: When a job is skipped due to previous failure (enabled by default)
- Job Failed: When a job fails to start (enabled by default)
- Queue Complete: When all queued jobs finish

Backend changes:
- New columns: print_queue.target_model, print_queue.required_filament_types, print_queue.waiting_reason
- New columns: notification_providers.on_queue_job_* (7 event triggers)
- Notification templates for all queue events
- Scheduler validates filament compatibility before model-based assignment
- Queue API extracts filament types from 3MF when adding model-based items
- Local backup/restore includes queue notification settings

Frontend changes:
- TypeScript interfaces updated for new fields
- Queue page shows waiting reason and "Waiting" badge
- Notification settings includes "Print Queue" section with 7 toggles

Closes #162
This commit is contained in:
maziggy
2026-01-28 13:39:08 +01:00
parent 2eb21e8b42
commit 7c5c4ee5bb
23 changed files with 943 additions and 105 deletions
@@ -63,6 +63,14 @@ const createMockProvider = (
on_ams_temperature_high: false,
on_ams_ht_humidity_high: false,
on_ams_ht_temperature_high: false,
on_plate_not_empty: true,
on_queue_job_added: false,
on_queue_job_assigned: false,
on_queue_job_started: false,
on_queue_job_waiting: true,
on_queue_job_skipped: true,
on_queue_job_failed: true,
on_queue_completed: false,
quiet_hours_enabled: false,
quiet_hours_start: null,
quiet_hours_end: null,
@@ -282,3 +290,83 @@ describe('NotificationProviderCard AMS toggles', () => {
});
});
});
describe('NotificationProviderCard Queue notifications', () => {
describe('queue job notifications', () => {
it('includes on_queue_job_added in provider data', () => {
const provider = createMockProvider({ on_queue_job_added: true });
expect(provider.on_queue_job_added).toBe(true);
});
it('includes on_queue_job_assigned in provider data', () => {
const provider = createMockProvider({ on_queue_job_assigned: true });
expect(provider.on_queue_job_assigned).toBe(true);
});
it('includes on_queue_job_started in provider data', () => {
const provider = createMockProvider({ on_queue_job_started: true });
expect(provider.on_queue_job_started).toBe(true);
});
it('includes on_queue_job_waiting in provider data', () => {
const provider = createMockProvider({ on_queue_job_waiting: true });
expect(provider.on_queue_job_waiting).toBe(true);
});
it('includes on_queue_job_skipped in provider data', () => {
const provider = createMockProvider({ on_queue_job_skipped: true });
expect(provider.on_queue_job_skipped).toBe(true);
});
it('includes on_queue_job_failed in provider data', () => {
const provider = createMockProvider({ on_queue_job_failed: true });
expect(provider.on_queue_job_failed).toBe(true);
});
it('includes on_queue_completed in provider data', () => {
const provider = createMockProvider({ on_queue_completed: true });
expect(provider.on_queue_completed).toBe(true);
});
});
describe('queue notification defaults', () => {
it('defaults actionable notifications to true', () => {
const provider = createMockProvider();
// These should default to true (actionable - user needs to do something)
expect(provider.on_queue_job_waiting).toBe(true);
expect(provider.on_queue_job_skipped).toBe(true);
expect(provider.on_queue_job_failed).toBe(true);
});
it('defaults informational notifications to false', () => {
const provider = createMockProvider();
// These should default to false (informational only)
expect(provider.on_queue_job_added).toBe(false);
expect(provider.on_queue_job_assigned).toBe(false);
expect(provider.on_queue_job_started).toBe(false);
expect(provider.on_queue_completed).toBe(false);
});
});
describe('queue notification combinations', () => {
it('supports all queue toggles independently', () => {
const provider = createMockProvider({
on_queue_job_added: true,
on_queue_job_assigned: false,
on_queue_job_started: true,
on_queue_job_waiting: false,
on_queue_job_skipped: true,
on_queue_job_failed: false,
on_queue_completed: true,
});
expect(provider.on_queue_job_added).toBe(true);
expect(provider.on_queue_job_assigned).toBe(false);
expect(provider.on_queue_job_started).toBe(true);
expect(provider.on_queue_job_waiting).toBe(false);
expect(provider.on_queue_job_skipped).toBe(true);
expect(provider.on_queue_job_failed).toBe(false);
expect(provider.on_queue_completed).toBe(true);
});
});
});
@@ -10,20 +10,12 @@ import { UploadModal } from '../../components/UploadModal';
import { http, HttpResponse } from 'msw';
import { server } from '../mocks/server';
const mockPrinters = [
{ id: 1, name: 'X1 Carbon', model: 'X1C', serial_number: '123' },
{ id: 2, name: 'P1S', model: 'P1S', serial_number: '456' },
];
describe('UploadModal', () => {
const mockOnClose = vi.fn();
beforeEach(() => {
vi.clearAllMocks();
server.use(
http.get('/api/v1/printers/', () => {
return HttpResponse.json(mockPrinters);
}),
http.post('/api/v1/archives/upload-bulk', async () => {
return HttpResponse.json({
uploaded: 1,
@@ -54,17 +46,6 @@ describe('UploadModal', () => {
expect(screen.getByRole('button', { name: 'Browse Files' })).toBeInTheDocument();
});
it('renders printer selection dropdown', async () => {
render(<UploadModal onClose={mockOnClose} />);
await waitFor(() => {
expect(screen.getByText('Associate with printer (optional)')).toBeInTheDocument();
});
const select = screen.getByRole('combobox');
expect(select).toBeInTheDocument();
});
it('renders Cancel button', () => {
render(<UploadModal onClose={mockOnClose} />);
@@ -79,32 +60,6 @@ describe('UploadModal', () => {
});
});
describe('printer selection', () => {
it('shows available printers in dropdown', async () => {
render(<UploadModal onClose={mockOnClose} />);
await waitFor(() => {
// Check for printer options in the select
expect(screen.getByRole('option', { name: 'No printer' })).toBeInTheDocument();
expect(screen.getByRole('option', { name: 'X1 Carbon' })).toBeInTheDocument();
expect(screen.getByRole('option', { name: 'P1S' })).toBeInTheDocument();
});
});
it('allows selecting a printer', async () => {
render(<UploadModal onClose={mockOnClose} />);
await waitFor(() => {
expect(screen.getByRole('option', { name: 'X1 Carbon' })).toBeInTheDocument();
});
const select = screen.getByRole('combobox');
fireEvent.change(select, { target: { value: '1' } });
expect(select).toHaveValue('1');
});
});
describe('file handling with initialFiles', () => {
it('shows initial files when provided', () => {
const initialFiles = [
+26
View File
@@ -1002,6 +1002,8 @@ export interface PrintQueueItem {
id: number;
printer_id: number | null; // null = unassigned
target_model: string | null; // Target printer model for model-based assignment
required_filament_types: string[] | null; // Required filament types for model-based assignment
waiting_reason: string | null; // Why a model-based job hasn't started yet
// Either archive_id OR library_file_id must be set (archive created at print start)
archive_id: number | null;
library_file_id: number | null;
@@ -1215,6 +1217,14 @@ export interface NotificationProvider {
on_ams_ht_temperature_high: boolean;
// Build plate detection
on_plate_not_empty: boolean;
// Print queue events
on_queue_job_added: boolean;
on_queue_job_assigned: boolean;
on_queue_job_started: boolean;
on_queue_job_waiting: boolean;
on_queue_job_skipped: boolean;
on_queue_job_failed: boolean;
on_queue_completed: boolean;
// Quiet hours
quiet_hours_enabled: boolean;
quiet_hours_start: string | null;
@@ -1257,6 +1267,14 @@ export interface NotificationProviderCreate {
on_ams_ht_temperature_high?: boolean;
// Build plate detection
on_plate_not_empty?: boolean;
// Print queue events
on_queue_job_added?: boolean;
on_queue_job_assigned?: boolean;
on_queue_job_started?: boolean;
on_queue_job_waiting?: boolean;
on_queue_job_skipped?: boolean;
on_queue_job_failed?: boolean;
on_queue_completed?: boolean;
// Quiet hours
quiet_hours_enabled?: boolean;
quiet_hours_start?: string | null;
@@ -1292,6 +1310,14 @@ export interface NotificationProviderUpdate {
on_ams_ht_temperature_high?: boolean;
// Build plate detection
on_plate_not_empty?: boolean;
// Print queue events
on_queue_job_added?: boolean;
on_queue_job_assigned?: boolean;
on_queue_job_started?: boolean;
on_queue_job_waiting?: boolean;
on_queue_job_skipped?: boolean;
on_queue_job_failed?: boolean;
on_queue_completed?: boolean;
// Quiet hours
quiet_hours_enabled?: boolean;
quiet_hours_start?: string | null;
@@ -398,6 +398,88 @@ export function NotificationProviderCard({ provider, onEdit }: NotificationProvi
</div>
</div>
{/* Print Queue Events */}
<div className="space-y-2">
<p className="text-xs text-bambu-gray uppercase tracking-wide">Print Queue</p>
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-white">Job Added</p>
<p className="text-xs text-bambu-gray">Job added to queue</p>
</div>
<Toggle
checked={provider.on_queue_job_added ?? false}
onChange={(checked) => updateMutation.mutate({ on_queue_job_added: checked })}
/>
</div>
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-white">Job Assigned</p>
<p className="text-xs text-bambu-gray">Model-based job assigned to printer</p>
</div>
<Toggle
checked={provider.on_queue_job_assigned ?? false}
onChange={(checked) => updateMutation.mutate({ on_queue_job_assigned: checked })}
/>
</div>
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-white">Job Started</p>
<p className="text-xs text-bambu-gray">Queue job started printing</p>
</div>
<Toggle
checked={provider.on_queue_job_started ?? false}
onChange={(checked) => updateMutation.mutate({ on_queue_job_started: checked })}
/>
</div>
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-white">Job Waiting</p>
<p className="text-xs text-bambu-gray">Job waiting for filament</p>
</div>
<Toggle
checked={provider.on_queue_job_waiting ?? true}
onChange={(checked) => updateMutation.mutate({ on_queue_job_waiting: checked })}
/>
</div>
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-white">Job Skipped</p>
<p className="text-xs text-bambu-gray">Job skipped (previous failed)</p>
</div>
<Toggle
checked={provider.on_queue_job_skipped ?? true}
onChange={(checked) => updateMutation.mutate({ on_queue_job_skipped: checked })}
/>
</div>
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-white">Job Failed</p>
<p className="text-xs text-bambu-gray">Job failed to start</p>
</div>
<Toggle
checked={provider.on_queue_job_failed ?? true}
onChange={(checked) => updateMutation.mutate({ on_queue_job_failed: checked })}
/>
</div>
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-white">Queue Complete</p>
<p className="text-xs text-bambu-gray">All queue jobs finished</p>
</div>
<Toggle
checked={provider.on_queue_completed ?? false}
onChange={(checked) => updateMutation.mutate({ on_queue_completed: checked })}
/>
</div>
</div>
{/* Quiet Hours */}
<div className="space-y-2">
<div className="flex items-center justify-between">
+21 -3
View File
@@ -77,7 +77,17 @@ function formatRelativeTime(dateString: string | null, timeFormat: TimeFormat =
return formatDateTime(dateString, timeFormat);
}
function StatusBadge({ status }: { status: PrintQueueItem['status'] }) {
function StatusBadge({ status, waitingReason }: { status: PrintQueueItem['status']; waitingReason?: string | null }) {
// Special case: pending with waiting_reason shows as "Waiting"
if (status === 'pending' && waitingReason) {
return (
<span className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-medium border text-purple-400 bg-purple-400/10 border-purple-400/20">
<Clock className="w-3.5 h-3.5" />
Waiting
</span>
);
}
const config = {
pending: { icon: Clock, color: 'text-status-warning bg-status-warning/10 border-status-warning/20', label: 'Pending' },
printing: { icon: Play, color: 'text-blue-400 bg-blue-400/10 border-blue-400/20', label: 'Printing' },
@@ -394,7 +404,7 @@ function SortableQueueItem({
<span className={`flex items-center gap-1.5 ${item.printer_id === null && !item.target_model ? 'text-orange-400' : ''} ${item.target_model ? 'text-blue-400' : ''}`}>
<Printer className="w-3.5 h-3.5" />
{item.target_model
? `Any ${item.target_model}`
? `Any ${item.target_model}${item.required_filament_types?.length ? ` (${item.required_filament_types.join(', ')})` : ''}`
: item.printer_id === null
? 'Unassigned'
: (item.printer_name || `Printer #${item.printer_id}`)}
@@ -444,6 +454,14 @@ function SortableQueueItem({
</div>
)}
{/* Waiting reason for model-based assignments */}
{item.waiting_reason && item.status === 'pending' && (
<p className="text-xs text-purple-400 mt-2 flex items-start gap-1">
<AlertCircle className="w-3 h-3 mt-0.5 flex-shrink-0" />
<span>{item.waiting_reason}</span>
</p>
)}
{/* Error message */}
{item.error_message && (
<p className="text-xs text-red-400 mt-2 flex items-center gap-1">
@@ -454,7 +472,7 @@ function SortableQueueItem({
</div>
{/* Status badge */}
<StatusBadge status={item.status} />
<StatusBadge status={item.status} waitingReason={item.waiting_reason} />
{/* Actions */}
<div className="flex items-center gap-1">