mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-10-20 00:38:38 +02:00
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:
@@ -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 = [
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user