Explorar el Código

Add resizable printer cards and Queue Only mode

  Features:
  - Resizable printer cards (S/M/L/XL) with toolbar controls on Printers page
  - Queue Only mode for staging prints without automatic scheduling
    - "Queue Only" option in add/edit queue modals
    - Purple "Staged" badge and Play button to release to queue
    - manual_start field in database with migration

  Fixes:
  - Improved camera stream stuck detection with automatic reconnection

  Tests:
  - Added 16 integration tests for print queue API endpoints
maziggy hace 4 meses
padre
commit
8639f39028

+ 22 - 0
CHANGELOG.md

@@ -2,6 +2,28 @@
 
 
 All notable changes to Bambuddy will be documented in this file.
 All notable changes to Bambuddy will be documented in this file.
 
 
+## [0.1.6b6] - 2026-01-04
+
+### Added
+- **Resizable printer cards** - Adjust printer card size from the Printers page toolbar:
+  - Four sizes: Small, Medium (default), Large, XL
+  - Plus/minus buttons in toolbar header
+  - Size preference saved to localStorage
+  - Responsive grid adapts to selected size
+- **Queue Only mode** - Stage prints without automatic scheduling:
+  - New "Queue Only" option when adding prints to queue
+  - Staged prints show purple "Staged" badge
+  - Play button to manually release staged prints to the queue
+  - Edit queue items to switch between ASAP, Scheduled, and Queue Only modes
+  - Useful for preparing print batches before activating
+
+### Fixed
+- **Camera stream reconnection** - Improved detection of stuck camera streams with automatic reconnection
+
+### Tests
+- Added integration tests for print queue API endpoints (16 new tests)
+- Tests cover queue CRUD, manual_start flag, and start/cancel endpoints
+
 ## [0.1.6b5] - 2026-01-02
 ## [0.1.6b5] - 2026-01-02
 
 
 ### Added
 ### Added

+ 2 - 0
README.md

@@ -57,6 +57,7 @@
 - Live camera streaming (MJPEG) & snapshots
 - Live camera streaming (MJPEG) & snapshots
 - Fan status monitoring (part cooling, auxiliary, chamber)
 - Fan status monitoring (part cooling, auxiliary, chamber)
 - Printer control (stop, pause, resume)
 - Printer control (stop, pause, resume)
+- Resizable printer cards (S/M/L/XL)
 - Skip objects during print
 - Skip objects during print
 - AMS slot RFID re-read
 - AMS slot RFID re-read
 - HMS error monitoring with history
 - HMS error monitoring with history
@@ -68,6 +69,7 @@
 ### ⏰ Scheduling & Automation
 ### ⏰ Scheduling & Automation
 - Print queue with drag-and-drop
 - Print queue with drag-and-drop
 - Scheduled prints (date/time)
 - Scheduled prints (date/time)
+- Queue Only mode (stage without auto-start)
 - Smart plug integration (Tasmota)
 - Smart plug integration (Tasmota)
 - Energy consumption tracking
 - Energy consumption tracking
 - Auto power-on before print
 - Auto power-on before print

+ 2 - 2
backend/app/api/routes/camera.py

@@ -167,7 +167,7 @@ async def generate_rtsp_mjpeg_stream(
     ffmpeg = get_ffmpeg_path()
     ffmpeg = get_ffmpeg_path()
     if not ffmpeg:
     if not ffmpeg:
         logger.error("ffmpeg not found - camera streaming requires ffmpeg")
         logger.error("ffmpeg not found - camera streaming requires ffmpeg")
-        yield (b"--frame\r\n" b"Content-Type: text/plain\r\n\r\n" b"Error: ffmpeg not installed\r\n")
+        yield (b"--frame\r\nContent-Type: text/plain\r\n\r\nError: ffmpeg not installed\r\n")
         return
         return
 
 
     port = get_camera_port(model)
     port = get_camera_port(model)
@@ -303,7 +303,7 @@ async def generate_rtsp_mjpeg_stream(
 
 
     except FileNotFoundError:
     except FileNotFoundError:
         logger.error("ffmpeg not found - camera streaming requires ffmpeg")
         logger.error("ffmpeg not found - camera streaming requires ffmpeg")
-        yield (b"--frame\r\n" b"Content-Type: text/plain\r\n\r\n" b"Error: ffmpeg not installed\r\n")
+        yield (b"--frame\r\nContent-Type: text/plain\r\n\r\nError: ffmpeg not installed\r\n")
     except asyncio.CancelledError:
     except asyncio.CancelledError:
         logger.info(f"Camera stream task cancelled (stream_id={stream_id})")
         logger.info(f"Camera stream task cancelled (stream_id={stream_id})")
     except GeneratorExit:
     except GeneratorExit:

+ 32 - 0
backend/app/api/routes/print_queue.py

@@ -89,6 +89,7 @@ async def add_to_queue(
         scheduled_time=data.scheduled_time,
         scheduled_time=data.scheduled_time,
         require_previous_success=data.require_previous_success,
         require_previous_success=data.require_previous_success,
         auto_off_after=data.auto_off_after,
         auto_off_after=data.auto_off_after,
+        manual_start=data.manual_start,
         position=max_pos + 1,
         position=max_pos + 1,
         status="pending",
         status="pending",
     )
     )
@@ -272,3 +273,34 @@ async def stop_queue_item(
         asyncio.create_task(cooldown_and_poweroff())
         asyncio.create_task(cooldown_and_poweroff())
 
 
     return {"message": "Print stopped" if stop_sent else "Queue item cancelled (printer was offline)"}
     return {"message": "Print stopped" if stop_sent else "Queue item cancelled (printer was offline)"}
+
+
+@router.post("/{item_id}/start")
+async def start_queue_item(
+    item_id: int,
+    db: AsyncSession = Depends(get_db),
+):
+    """Manually start a staged (manual_start) queue item.
+
+    This clears the manual_start flag so the scheduler will pick it up,
+    or starts immediately if the printer is ready.
+    """
+    result = await db.execute(
+        select(PrintQueueItem)
+        .options(selectinload(PrintQueueItem.archive), selectinload(PrintQueueItem.printer))
+        .where(PrintQueueItem.id == item_id)
+    )
+    item = result.scalar_one_or_none()
+    if not item:
+        raise HTTPException(404, "Queue item not found")
+
+    if item.status != "pending":
+        raise HTTPException(400, f"Can only start pending items, current status: '{item.status}'")
+
+    # Clear manual_start flag so scheduler picks it up
+    item.manual_start = False
+    await db.commit()
+    await db.refresh(item, ["archive", "printer"])
+
+    logger.info(f"Manually started queue item {item_id} (cleared manual_start flag)")
+    return _enrich_response(item)

+ 2 - 0
backend/app/api/routes/settings.py

@@ -478,6 +478,7 @@ async def export_backup(
                     "scheduled_time": qi.scheduled_time.isoformat() if qi.scheduled_time else None,
                     "scheduled_time": qi.scheduled_time.isoformat() if qi.scheduled_time else None,
                     "require_previous_success": qi.require_previous_success,
                     "require_previous_success": qi.require_previous_success,
                     "auto_off_after": qi.auto_off_after,
                     "auto_off_after": qi.auto_off_after,
+                    "manual_start": qi.manual_start,
                     "status": qi.status,
                     "status": qi.status,
                     "started_at": qi.started_at.isoformat() if qi.started_at else None,
                     "started_at": qi.started_at.isoformat() if qi.started_at else None,
                     "completed_at": qi.completed_at.isoformat() if qi.completed_at else None,
                     "completed_at": qi.completed_at.isoformat() if qi.completed_at else None,
@@ -1468,6 +1469,7 @@ async def import_backup(
                 position=qi_data.get("position", 0),
                 position=qi_data.get("position", 0),
                 require_previous_success=qi_data.get("require_previous_success", False),
                 require_previous_success=qi_data.get("require_previous_success", False),
                 auto_off_after=qi_data.get("auto_off_after", False),
                 auto_off_after=qi_data.get("auto_off_after", False),
+                manual_start=qi_data.get("manual_start", False),
                 status=qi_data.get("status", "pending"),
                 status=qi_data.get("status", "pending"),
                 error_message=qi_data.get("error_message"),
                 error_message=qi_data.get("error_message"),
             )
             )

+ 6 - 0
backend/app/core/database.py

@@ -381,6 +381,12 @@ async def run_migrations(conn):
     except Exception:
     except Exception:
         pass
         pass
 
 
+    # Migration: Add manual_start column to print_queue for staged prints
+    try:
+        await conn.execute(text("ALTER TABLE print_queue ADD COLUMN manual_start BOOLEAN DEFAULT 0"))
+    except Exception:
+        pass
+
 
 
 async def seed_notification_templates():
 async def seed_notification_templates():
     """Seed default notification templates if they don't exist."""
     """Seed default notification templates if they don't exist."""

+ 1 - 1
backend/app/main.py

@@ -1616,7 +1616,7 @@ async def track_printer_runtime():
                                 needs_commit = True
                                 needs_commit = True
                                 logger.debug(
                                 logger.debug(
                                     f"[{printer.name}] Runtime tracking: added {int(elapsed)}s, "
                                     f"[{printer.name}] Runtime tracking: added {int(elapsed)}s, "
-                                    f"total={printer.runtime_seconds}s ({printer.runtime_seconds/3600:.2f}h)"
+                                    f"total={printer.runtime_seconds}s ({printer.runtime_seconds / 3600:.2f}h)"
                                 )
                                 )
                             else:
                             else:
                                 logger.warning(
                                 logger.warning(

+ 1 - 0
backend/app/models/print_queue.py

@@ -21,6 +21,7 @@ class PrintQueueItem(Base):
     # Scheduling
     # Scheduling
     position: Mapped[int] = mapped_column(Integer, default=0)  # Queue order
     position: Mapped[int] = mapped_column(Integer, default=0)  # Queue order
     scheduled_time: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)  # None = ASAP
     scheduled_time: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)  # None = ASAP
+    manual_start: Mapped[bool] = mapped_column(Boolean, default=False)  # Requires manual trigger to start
 
 
     # Conditions
     # Conditions
     require_previous_success: Mapped[bool] = mapped_column(Boolean, default=False)
     require_previous_success: Mapped[bool] = mapped_column(Boolean, default=False)

+ 3 - 0
backend/app/schemas/print_queue.py

@@ -21,6 +21,7 @@ class PrintQueueItemCreate(BaseModel):
     scheduled_time: datetime | None = None  # None = ASAP (next when idle)
     scheduled_time: datetime | None = None  # None = ASAP (next when idle)
     require_previous_success: bool = False
     require_previous_success: bool = False
     auto_off_after: bool = False  # Power off printer after print completes
     auto_off_after: bool = False  # Power off printer after print completes
+    manual_start: bool = False  # Requires manual trigger to start (staged)
 
 
 
 
 class PrintQueueItemUpdate(BaseModel):
 class PrintQueueItemUpdate(BaseModel):
@@ -29,6 +30,7 @@ class PrintQueueItemUpdate(BaseModel):
     scheduled_time: datetime | None = None
     scheduled_time: datetime | None = None
     require_previous_success: bool | None = None
     require_previous_success: bool | None = None
     auto_off_after: bool | None = None
     auto_off_after: bool | None = None
+    manual_start: bool | None = None
 
 
 
 
 class PrintQueueItemResponse(BaseModel):
 class PrintQueueItemResponse(BaseModel):
@@ -39,6 +41,7 @@ class PrintQueueItemResponse(BaseModel):
     scheduled_time: UTCDatetime
     scheduled_time: UTCDatetime
     require_previous_success: bool
     require_previous_success: bool
     auto_off_after: bool
     auto_off_after: bool
+    manual_start: bool
     status: Literal["pending", "printing", "completed", "failed", "skipped", "cancelled"]
     status: Literal["pending", "printing", "completed", "failed", "skipped", "cancelled"]
     started_at: UTCDatetime
     started_at: UTCDatetime
     completed_at: UTCDatetime
     completed_at: UTCDatetime

+ 1 - 1
backend/app/services/discovery.py

@@ -566,7 +566,7 @@ class TasmotaScanner:
                 try:
                 try:
                     await asyncio.gather(*tasks, return_exceptions=True)
                     await asyncio.gather(*tasks, return_exceptions=True)
                 except Exception as e:
                 except Exception as e:
-                    logger.warning(f"Batch {i//batch_size} error: {e}")
+                    logger.warning(f"Batch {i // batch_size} error: {e}")
                 self._scanned = min(i + batch_size, len(hosts))
                 self._scanned = min(i + batch_size, len(hosts))
 
 
             logger.info(f"Tasmota scan complete. Found {len(self._discovered)} devices.")
             logger.info(f"Tasmota scan complete. Found {len(self._discovered)} devices.")

+ 4 - 0
backend/app/services/print_scheduler.py

@@ -72,6 +72,10 @@ class PrintScheduler:
                 if item.scheduled_time and item.scheduled_time > datetime.utcnow():
                 if item.scheduled_time and item.scheduled_time > datetime.utcnow():
                     continue
                     continue
 
 
+                # Skip items that require manual start
+                if item.manual_start:
+                    continue
+
                 # Check if printer is idle
                 # Check if printer is idle
                 printer_idle = self._is_printer_idle(item.printer_id)
                 printer_idle = self._is_printer_idle(item.printer_id)
                 printer_connected = printer_manager.is_connected(item.printer_id)
                 printer_connected = printer_manager.is_connected(item.printer_id)

+ 5 - 9
backend/app/services/smart_plug_manager.py

@@ -162,9 +162,7 @@ class SmartPlugManager:
             )
             )
             return
             return
 
 
-        logger.info(
-            f"Print completed successfully on printer {printer_id}, " f"scheduling turn-off for plug '{plug.name}'"
-        )
+        logger.info(f"Print completed successfully on printer {printer_id}, scheduling turn-off for plug '{plug.name}'")
 
 
         if plug.off_delay_mode == "time":
         if plug.off_delay_mode == "time":
             self._schedule_delayed_off(plug, printer_id, plug.off_delay_minutes * 60)
             self._schedule_delayed_off(plug, printer_id, plug.off_delay_minutes * 60)
@@ -227,7 +225,7 @@ class SmartPlugManager:
         # Cancel any existing task for this plug
         # Cancel any existing task for this plug
         self._cancel_pending_off(plug.id)
         self._cancel_pending_off(plug.id)
 
 
-        logger.info(f"Scheduling temperature-based turn-off for plug '{plug.name}' " f"(threshold: {temp_threshold}°C)")
+        logger.info(f"Scheduling temperature-based turn-off for plug '{plug.name}' (threshold: {temp_threshold}°C)")
 
 
         # Mark as pending in database (survives restarts)
         # Mark as pending in database (survives restarts)
         asyncio.create_task(self._mark_auto_off_pending(plug.id, True))
         asyncio.create_task(self._mark_auto_off_pending(plug.id, True))
@@ -281,9 +279,7 @@ class SmartPlugManager:
                             f"threshold={temp_threshold}°C"
                             f"threshold={temp_threshold}°C"
                         )
                         )
                     else:
                     else:
-                        logger.info(
-                            f"Temp check plug {plug_id}: nozzle={nozzle_temp}°C, " f"threshold={temp_threshold}°C"
-                        )
+                        logger.info(f"Temp check plug {plug_id}: nozzle={nozzle_temp}°C, threshold={temp_threshold}°C")
 
 
                     if max_nozzle_temp < temp_threshold:
                     if max_nozzle_temp < temp_threshold:
                         # All nozzles are below threshold, turn off
                         # All nozzles are below threshold, turn off
@@ -398,7 +394,7 @@ class SmartPlugManager:
                         elapsed = (datetime.utcnow() - plug.auto_off_pending_since).total_seconds()
                         elapsed = (datetime.utcnow() - plug.auto_off_pending_since).total_seconds()
                         if elapsed > 7200:  # 2 hours
                         if elapsed > 7200:  # 2 hours
                             logger.warning(
                             logger.warning(
-                                f"Auto-off for plug '{plug.name}' was pending for {elapsed/60:.0f} minutes, "
+                                f"Auto-off for plug '{plug.name}' was pending for {elapsed / 60:.0f} minutes, "
                                 f"clearing stale pending state"
                                 f"clearing stale pending state"
                             )
                             )
                             plug.auto_off_pending = False
                             plug.auto_off_pending = False
@@ -406,7 +402,7 @@ class SmartPlugManager:
                             await db.commit()
                             await db.commit()
                             continue
                             continue
 
 
-                    logger.info(f"Resuming pending auto-off for plug '{plug.name}' " f"(printer {plug.printer_id})")
+                    logger.info(f"Resuming pending auto-off for plug '{plug.name}' (printer {plug.printer_id})")
 
 
                     # Resume the appropriate off mode
                     # Resume the appropriate off mode
                     if plug.off_delay_mode == "temperature":
                     if plug.off_delay_mode == "temperature":

+ 1 - 3
backend/app/services/spoolman.py

@@ -577,9 +577,7 @@ class SpoolmanClient:
             )
             )
 
 
         # Spool not found - auto-create it
         # Spool not found - auto-create it
-        logger.info(
-            f"Creating new spool in Spoolman for {tray.tray_sub_brands} " f"(tray_uuid: {tray.tray_uuid[:16]}...)"
-        )
+        logger.info(f"Creating new spool in Spoolman for {tray.tray_sub_brands} (tray_uuid: {tray.tray_uuid[:16]}...)")
 
 
         # First find or create the filament type
         # First find or create the filament type
         filament = await self._find_or_create_filament(tray)
         filament = await self._find_or_create_filament(tray)

+ 446 - 0
backend/tests/integration/test_print_queue_api.py

@@ -0,0 +1,446 @@
+"""Integration tests for Print Queue API endpoints."""
+
+import pytest
+from httpx import AsyncClient
+
+
+class TestPrintQueueAPI:
+    """Integration tests for /api/v1/queue endpoints."""
+
+    @pytest.fixture
+    async def printer_factory(self, db_session):
+        """Factory to create test printers."""
+        _counter = [0]
+
+        async def _create_printer(**kwargs):
+            from backend.app.models.printer import Printer
+
+            _counter[0] += 1
+            counter = _counter[0]
+
+            defaults = {
+                "name": f"Test Printer {counter}",
+                "ip_address": f"192.168.1.{100 + counter}",
+                "serial_number": f"TESTSERIAL{counter:04d}",
+                "access_code": "12345678",
+                "model": "X1C",
+            }
+            defaults.update(kwargs)
+
+            printer = Printer(**defaults)
+            db_session.add(printer)
+            await db_session.commit()
+            await db_session.refresh(printer)
+            return printer
+
+        return _create_printer
+
+    @pytest.fixture
+    async def archive_factory(self, db_session):
+        """Factory to create test archives."""
+        _counter = [0]
+
+        async def _create_archive(**kwargs):
+            from backend.app.models.archive import PrintArchive
+
+            _counter[0] += 1
+            counter = _counter[0]
+
+            defaults = {
+                "filename": f"test_print_{counter}.3mf",
+                "print_name": f"Test Print {counter}",
+                "file_path": f"/tmp/test_print_{counter}.3mf",
+                "file_size": 1024,
+                "content_hash": f"testhash{counter:08d}",
+                "status": "completed",
+            }
+            defaults.update(kwargs)
+
+            archive = PrintArchive(**defaults)
+            db_session.add(archive)
+            await db_session.commit()
+            await db_session.refresh(archive)
+            return archive
+
+        return _create_archive
+
+    @pytest.fixture
+    async def queue_item_factory(self, db_session, printer_factory, archive_factory):
+        """Factory to create test queue items."""
+        _counter = [0]
+
+        async def _create_queue_item(**kwargs):
+            from backend.app.models.print_queue import PrintQueueItem
+
+            _counter[0] += 1
+            counter = _counter[0]
+
+            # Create printer and archive if not provided
+            if "printer_id" not in kwargs:
+                printer = await printer_factory()
+                kwargs["printer_id"] = printer.id
+
+            if "archive_id" not in kwargs:
+                archive = await archive_factory()
+                kwargs["archive_id"] = archive.id
+
+            defaults = {
+                "status": "pending",
+                "position": counter,
+            }
+            defaults.update(kwargs)
+
+            item = PrintQueueItem(**defaults)
+            db_session.add(item)
+            await db_session.commit()
+            await db_session.refresh(item)
+            return item
+
+        return _create_queue_item
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_list_queue_empty(self, async_client: AsyncClient):
+        """Verify empty list when no queue items exist."""
+        response = await async_client.get("/api/v1/queue/")
+        assert response.status_code == 200
+        assert isinstance(response.json(), list)
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_add_to_queue(self, async_client: AsyncClient, printer_factory, archive_factory, db_session):
+        """Verify item can be added to queue."""
+        printer = await printer_factory()
+        archive = await archive_factory()
+
+        data = {
+            "printer_id": printer.id,
+            "archive_id": archive.id,
+        }
+        response = await async_client.post("/api/v1/queue/", json=data)
+        assert response.status_code == 200
+        result = response.json()
+        assert result["printer_id"] == printer.id
+        assert result["archive_id"] == archive.id
+        assert result["status"] == "pending"
+        assert result["manual_start"] is False
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_add_to_queue_with_manual_start(
+        self, async_client: AsyncClient, printer_factory, archive_factory, db_session
+    ):
+        """Verify item can be added to queue with manual_start=True."""
+        printer = await printer_factory()
+        archive = await archive_factory()
+
+        data = {
+            "printer_id": printer.id,
+            "archive_id": archive.id,
+            "manual_start": True,
+        }
+        response = await async_client.post("/api/v1/queue/", json=data)
+        assert response.status_code == 200
+        result = response.json()
+        assert result["printer_id"] == printer.id
+        assert result["archive_id"] == archive.id
+        assert result["status"] == "pending"
+        assert result["manual_start"] is True
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_get_queue_item(self, async_client: AsyncClient, queue_item_factory, db_session):
+        """Verify single queue item can be retrieved."""
+        item = await queue_item_factory()
+        response = await async_client.get(f"/api/v1/queue/{item.id}")
+        assert response.status_code == 200
+        assert response.json()["id"] == item.id
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_get_queue_item_not_found(self, async_client: AsyncClient):
+        """Verify 404 for non-existent queue item."""
+        response = await async_client.get("/api/v1/queue/9999")
+        assert response.status_code == 404
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_update_queue_item(self, async_client: AsyncClient, queue_item_factory, db_session):
+        """Verify queue item can be updated."""
+        item = await queue_item_factory()
+        response = await async_client.patch(f"/api/v1/queue/{item.id}", json={"auto_off_after": True})
+        assert response.status_code == 200
+        result = response.json()
+        assert result["auto_off_after"] is True
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_update_queue_item_manual_start(self, async_client: AsyncClient, queue_item_factory, db_session):
+        """Verify queue item manual_start can be updated."""
+        item = await queue_item_factory(manual_start=False)
+        response = await async_client.patch(f"/api/v1/queue/{item.id}", json={"manual_start": True})
+        assert response.status_code == 200
+        result = response.json()
+        assert result["manual_start"] is True
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_delete_queue_item(self, async_client: AsyncClient, queue_item_factory, db_session):
+        """Verify queue item can be deleted."""
+        item = await queue_item_factory()
+        response = await async_client.delete(f"/api/v1/queue/{item.id}")
+        assert response.status_code == 200
+        assert response.json()["message"] == "Queue item deleted"
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_delete_queue_item_not_found(self, async_client: AsyncClient):
+        """Verify 404 for deleting non-existent queue item."""
+        response = await async_client.delete("/api/v1/queue/9999")
+        assert response.status_code == 404
+
+
+class TestQueueStartEndpoint:
+    """Tests for the /queue/{item_id}/start endpoint."""
+
+    @pytest.fixture
+    async def printer_factory(self, db_session):
+        """Factory to create test printers."""
+        _counter = [0]
+
+        async def _create_printer(**kwargs):
+            from backend.app.models.printer import Printer
+
+            _counter[0] += 1
+            counter = _counter[0]
+
+            defaults = {
+                "name": f"Test Printer {counter}",
+                "ip_address": f"192.168.1.{100 + counter}",
+                "serial_number": f"TESTSERIAL{counter:04d}",
+                "access_code": "12345678",
+                "model": "X1C",
+            }
+            defaults.update(kwargs)
+
+            printer = Printer(**defaults)
+            db_session.add(printer)
+            await db_session.commit()
+            await db_session.refresh(printer)
+            return printer
+
+        return _create_printer
+
+    @pytest.fixture
+    async def archive_factory(self, db_session):
+        """Factory to create test archives."""
+        _counter = [0]
+
+        async def _create_archive(**kwargs):
+            from backend.app.models.archive import PrintArchive
+
+            _counter[0] += 1
+            counter = _counter[0]
+
+            defaults = {
+                "filename": f"test_print_{counter}.3mf",
+                "print_name": f"Test Print {counter}",
+                "file_path": f"/tmp/test_print_{counter}.3mf",
+                "file_size": 1024,
+                "content_hash": f"testhash{counter:08d}",
+                "status": "completed",
+            }
+            defaults.update(kwargs)
+
+            archive = PrintArchive(**defaults)
+            db_session.add(archive)
+            await db_session.commit()
+            await db_session.refresh(archive)
+            return archive
+
+        return _create_archive
+
+    @pytest.fixture
+    async def queue_item_factory(self, db_session, printer_factory, archive_factory):
+        """Factory to create test queue items."""
+        _counter = [0]
+
+        async def _create_queue_item(**kwargs):
+            from backend.app.models.print_queue import PrintQueueItem
+
+            _counter[0] += 1
+            counter = _counter[0]
+
+            if "printer_id" not in kwargs:
+                printer = await printer_factory()
+                kwargs["printer_id"] = printer.id
+
+            if "archive_id" not in kwargs:
+                archive = await archive_factory()
+                kwargs["archive_id"] = archive.id
+
+            defaults = {
+                "status": "pending",
+                "position": counter,
+            }
+            defaults.update(kwargs)
+
+            item = PrintQueueItem(**defaults)
+            db_session.add(item)
+            await db_session.commit()
+            await db_session.refresh(item)
+            return item
+
+        return _create_queue_item
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_start_staged_queue_item(self, async_client: AsyncClient, queue_item_factory, db_session):
+        """Verify starting a staged (manual_start=True) queue item clears the flag."""
+        item = await queue_item_factory(manual_start=True)
+        assert item.manual_start is True
+
+        response = await async_client.post(f"/api/v1/queue/{item.id}/start")
+        assert response.status_code == 200
+        result = response.json()
+        assert result["manual_start"] is False
+        assert result["status"] == "pending"
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_start_non_staged_queue_item(self, async_client: AsyncClient, queue_item_factory, db_session):
+        """Verify starting a non-staged queue item still works (idempotent)."""
+        item = await queue_item_factory(manual_start=False)
+        assert item.manual_start is False
+
+        response = await async_client.post(f"/api/v1/queue/{item.id}/start")
+        assert response.status_code == 200
+        result = response.json()
+        assert result["manual_start"] is False
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_start_queue_item_not_found(self, async_client: AsyncClient):
+        """Verify 404 for non-existent queue item."""
+        response = await async_client.post("/api/v1/queue/9999/start")
+        assert response.status_code == 404
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_start_non_pending_queue_item(self, async_client: AsyncClient, queue_item_factory, db_session):
+        """Verify 400 error when trying to start a non-pending queue item."""
+        item = await queue_item_factory(status="printing", manual_start=True)
+
+        response = await async_client.post(f"/api/v1/queue/{item.id}/start")
+        assert response.status_code == 400
+        assert "pending" in response.json()["detail"].lower()
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_start_completed_queue_item(self, async_client: AsyncClient, queue_item_factory, db_session):
+        """Verify 400 error when trying to start a completed queue item."""
+        item = await queue_item_factory(status="completed", manual_start=True)
+
+        response = await async_client.post(f"/api/v1/queue/{item.id}/start")
+        assert response.status_code == 400
+
+
+class TestQueueCancelEndpoint:
+    """Tests for the /queue/{item_id}/cancel endpoint."""
+
+    @pytest.fixture
+    async def printer_factory(self, db_session):
+        """Factory to create test printers."""
+
+        async def _create_printer(**kwargs):
+            from backend.app.models.printer import Printer
+
+            defaults = {
+                "name": "Cancel Test Printer",
+                "ip_address": "192.168.1.200",
+                "serial_number": "TESTCANCEL001",
+                "access_code": "12345678",
+                "model": "X1C",
+            }
+            defaults.update(kwargs)
+
+            printer = Printer(**defaults)
+            db_session.add(printer)
+            await db_session.commit()
+            await db_session.refresh(printer)
+            return printer
+
+        return _create_printer
+
+    @pytest.fixture
+    async def archive_factory(self, db_session):
+        """Factory to create test archives."""
+
+        async def _create_archive(**kwargs):
+            from backend.app.models.archive import PrintArchive
+
+            defaults = {
+                "filename": "cancel_test.3mf",
+                "print_name": "Cancel Test Print",
+                "file_path": "/tmp/cancel_test.3mf",
+                "file_size": 1024,
+                "content_hash": "cancelhash001",
+                "status": "completed",
+            }
+            defaults.update(kwargs)
+
+            archive = PrintArchive(**defaults)
+            db_session.add(archive)
+            await db_session.commit()
+            await db_session.refresh(archive)
+            return archive
+
+        return _create_archive
+
+    @pytest.fixture
+    async def queue_item_factory(self, db_session, printer_factory, archive_factory):
+        """Factory to create test queue items."""
+
+        async def _create_queue_item(**kwargs):
+            from backend.app.models.print_queue import PrintQueueItem
+
+            if "printer_id" not in kwargs:
+                printer = await printer_factory()
+                kwargs["printer_id"] = printer.id
+
+            if "archive_id" not in kwargs:
+                archive = await archive_factory()
+                kwargs["archive_id"] = archive.id
+
+            defaults = {
+                "status": "pending",
+                "position": 1,
+            }
+            defaults.update(kwargs)
+
+            item = PrintQueueItem(**defaults)
+            db_session.add(item)
+            await db_session.commit()
+            await db_session.refresh(item)
+            return item
+
+        return _create_queue_item
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_cancel_pending_queue_item(self, async_client: AsyncClient, queue_item_factory, db_session):
+        """Verify cancelling a pending queue item."""
+        item = await queue_item_factory(status="pending")
+
+        response = await async_client.post(f"/api/v1/queue/{item.id}/cancel")
+        assert response.status_code == 200
+        assert response.json()["message"] == "Queue item cancelled"
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_cancel_non_pending_queue_item(self, async_client: AsyncClient, queue_item_factory, db_session):
+        """Verify 400 error when trying to cancel a non-pending queue item."""
+        item = await queue_item_factory(status="printing")
+
+        response = await async_client.post(f"/api/v1/queue/{item.id}/cancel")
+        assert response.status_code == 400

+ 5 - 0
frontend/src/api/client.ts

@@ -789,6 +789,7 @@ export interface PrintQueueItem {
   scheduled_time: string | null;
   scheduled_time: string | null;
   require_previous_success: boolean;
   require_previous_success: boolean;
   auto_off_after: boolean;
   auto_off_after: boolean;
+  manual_start: boolean;  // Requires manual trigger to start (staged)
   status: 'pending' | 'printing' | 'completed' | 'failed' | 'skipped' | 'cancelled';
   status: 'pending' | 'printing' | 'completed' | 'failed' | 'skipped' | 'cancelled';
   started_at: string | null;
   started_at: string | null;
   completed_at: string | null;
   completed_at: string | null;
@@ -806,6 +807,7 @@ export interface PrintQueueItemCreate {
   scheduled_time?: string | null;
   scheduled_time?: string | null;
   require_previous_success?: boolean;
   require_previous_success?: boolean;
   auto_off_after?: boolean;
   auto_off_after?: boolean;
+  manual_start?: boolean;  // Requires manual trigger to start (staged)
 }
 }
 
 
 export interface PrintQueueItemUpdate {
 export interface PrintQueueItemUpdate {
@@ -814,6 +816,7 @@ export interface PrintQueueItemUpdate {
   scheduled_time?: string | null;
   scheduled_time?: string | null;
   require_previous_success?: boolean;
   require_previous_success?: boolean;
   auto_off_after?: boolean;
   auto_off_after?: boolean;
+  manual_start?: boolean;
 }
 }
 
 
 // MQTT Logging types
 // MQTT Logging types
@@ -1914,6 +1917,8 @@ export const api = {
     request<{ message: string }>(`/queue/${id}/cancel`, { method: 'POST' }),
     request<{ message: string }>(`/queue/${id}/cancel`, { method: 'POST' }),
   stopQueueItem: (id: number) =>
   stopQueueItem: (id: number) =>
     request<{ message: string }>(`/queue/${id}/stop`, { method: 'POST' }),
     request<{ message: string }>(`/queue/${id}/stop`, { method: 'POST' }),
+  startQueueItem: (id: number) =>
+    request<PrintQueueItem>(`/queue/${id}/start`, { method: 'POST' }),
 
 
   // K-Profiles
   // K-Profiles
   getKProfiles: (printerId: number, nozzleDiameter = '0.4') =>
   getKProfiles: (printerId: number, nozzleDiameter = '0.4') =>

+ 21 - 6
frontend/src/components/AddToQueueModal.tsx

@@ -1,6 +1,6 @@
 import { useState, useEffect } from 'react';
 import { useState, useEffect } from 'react';
 import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
 import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
-import { Calendar, Clock, X, AlertCircle, Power } from 'lucide-react';
+import { Calendar, Clock, X, AlertCircle, Power, Hand } from 'lucide-react';
 import { api } from '../api/client';
 import { api } from '../api/client';
 import type { PrintQueueItemCreate } from '../api/client';
 import type { PrintQueueItemCreate } from '../api/client';
 import { Card, CardContent } from './Card';
 import { Card, CardContent } from './Card';
@@ -18,7 +18,7 @@ export function AddToQueueModal({ archiveId, archiveName, onClose }: AddToQueueM
   const { showToast } = useToast();
   const { showToast } = useToast();
 
 
   const [printerId, setPrinterId] = useState<number | null>(null);
   const [printerId, setPrinterId] = useState<number | null>(null);
-  const [scheduleType, setScheduleType] = useState<'asap' | 'scheduled'>('asap');
+  const [scheduleType, setScheduleType] = useState<'asap' | 'scheduled' | 'manual'>('asap');
   const [scheduledTime, setScheduledTime] = useState('');
   const [scheduledTime, setScheduledTime] = useState('');
   const [requirePreviousSuccess, setRequirePreviousSuccess] = useState(false);
   const [requirePreviousSuccess, setRequirePreviousSuccess] = useState(false);
   const [autoOffAfter, setAutoOffAfter] = useState(false);
   const [autoOffAfter, setAutoOffAfter] = useState(false);
@@ -68,6 +68,7 @@ export function AddToQueueModal({ archiveId, archiveName, onClose }: AddToQueueM
       archive_id: archiveId,
       archive_id: archiveId,
       require_previous_success: requirePreviousSuccess,
       require_previous_success: requirePreviousSuccess,
       auto_off_after: autoOffAfter,
       auto_off_after: autoOffAfter,
+      manual_start: scheduleType === 'manual',
     };
     };
 
 
     if (scheduleType === 'scheduled' && scheduledTime) {
     if (scheduleType === 'scheduled' && scheduledTime) {
@@ -142,7 +143,7 @@ export function AddToQueueModal({ archiveId, archiveName, onClose }: AddToQueueM
               <div className="flex gap-2">
               <div className="flex gap-2">
                 <button
                 <button
                   type="button"
                   type="button"
-                  className={`flex-1 px-3 py-2 rounded-lg border text-sm flex items-center justify-center gap-2 transition-colors ${
+                  className={`flex-1 px-2 py-2 rounded-lg border text-sm flex items-center justify-center gap-1.5 transition-colors ${
                     scheduleType === 'asap'
                     scheduleType === 'asap'
                       ? 'bg-bambu-green border-bambu-green text-white'
                       ? 'bg-bambu-green border-bambu-green text-white'
                       : 'bg-bambu-dark border-bambu-dark-tertiary text-bambu-gray hover:text-white'
                       : 'bg-bambu-dark border-bambu-dark-tertiary text-bambu-gray hover:text-white'
@@ -150,11 +151,11 @@ export function AddToQueueModal({ archiveId, archiveName, onClose }: AddToQueueM
                   onClick={() => setScheduleType('asap')}
                   onClick={() => setScheduleType('asap')}
                 >
                 >
                   <Clock className="w-4 h-4" />
                   <Clock className="w-4 h-4" />
-                  ASAP (when idle)
+                  ASAP
                 </button>
                 </button>
                 <button
                 <button
                   type="button"
                   type="button"
-                  className={`flex-1 px-3 py-2 rounded-lg border text-sm flex items-center justify-center gap-2 transition-colors ${
+                  className={`flex-1 px-2 py-2 rounded-lg border text-sm flex items-center justify-center gap-1.5 transition-colors ${
                     scheduleType === 'scheduled'
                     scheduleType === 'scheduled'
                       ? 'bg-bambu-green border-bambu-green text-white'
                       ? 'bg-bambu-green border-bambu-green text-white'
                       : 'bg-bambu-dark border-bambu-dark-tertiary text-bambu-gray hover:text-white'
                       : 'bg-bambu-dark border-bambu-dark-tertiary text-bambu-gray hover:text-white'
@@ -164,6 +165,18 @@ export function AddToQueueModal({ archiveId, archiveName, onClose }: AddToQueueM
                   <Calendar className="w-4 h-4" />
                   <Calendar className="w-4 h-4" />
                   Scheduled
                   Scheduled
                 </button>
                 </button>
+                <button
+                  type="button"
+                  className={`flex-1 px-2 py-2 rounded-lg border text-sm flex items-center justify-center gap-1.5 transition-colors ${
+                    scheduleType === 'manual'
+                      ? 'bg-bambu-green border-bambu-green text-white'
+                      : 'bg-bambu-dark border-bambu-dark-tertiary text-bambu-gray hover:text-white'
+                  }`}
+                  onClick={() => setScheduleType('manual')}
+                >
+                  <Hand className="w-4 h-4" />
+                  Queue Only
+                </button>
               </div>
               </div>
             </div>
             </div>
 
 
@@ -215,7 +228,9 @@ export function AddToQueueModal({ archiveId, archiveName, onClose }: AddToQueueM
             <p className="text-xs text-bambu-gray">
             <p className="text-xs text-bambu-gray">
               {scheduleType === 'asap'
               {scheduleType === 'asap'
                 ? 'Print will start as soon as the printer is idle.'
                 ? 'Print will start as soon as the printer is idle.'
-                : 'Print will start at the scheduled time if the printer is idle. If busy, it will wait until the printer becomes available.'}
+                : scheduleType === 'scheduled'
+                ? 'Print will start at the scheduled time if the printer is idle. If busy, it will wait until the printer becomes available.'
+                : 'Print will be staged but won\'t start automatically. Use the Start button to release it to the queue.'}
             </p>
             </p>
 
 
             {/* Actions */}
             {/* Actions */}

+ 25 - 8
frontend/src/components/EditQueueItemModal.tsx

@@ -1,6 +1,6 @@
 import { useState, useEffect } from 'react';
 import { useState, useEffect } from 'react';
 import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
 import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
-import { Calendar, Clock, X, AlertCircle, Power, Pencil } from 'lucide-react';
+import { Calendar, Clock, X, AlertCircle, Power, Pencil, Hand } from 'lucide-react';
 import { api } from '../api/client';
 import { api } from '../api/client';
 import type { PrintQueueItem, PrintQueueItemUpdate } from '../api/client';
 import type { PrintQueueItem, PrintQueueItemUpdate } from '../api/client';
 import { Card, CardContent } from './Card';
 import { Card, CardContent } from './Card';
@@ -22,9 +22,11 @@ export function EditQueueItemModal({ item, onClose }: EditQueueItemModalProps) {
   const isPlaceholderDate = item.scheduled_time &&
   const isPlaceholderDate = item.scheduled_time &&
     new Date(item.scheduled_time).getTime() > Date.now() + (180 * 24 * 60 * 60 * 1000);
     new Date(item.scheduled_time).getTime() > Date.now() + (180 * 24 * 60 * 60 * 1000);
 
 
-  const [scheduleType, setScheduleType] = useState<'asap' | 'scheduled'>(
-    item.scheduled_time && !isPlaceholderDate ? 'scheduled' : 'asap'
-  );
+  const [scheduleType, setScheduleType] = useState<'asap' | 'scheduled' | 'manual'>(() => {
+    if (item.manual_start) return 'manual';
+    if (item.scheduled_time && !isPlaceholderDate) return 'scheduled';
+    return 'asap';
+  });
   const [scheduledTime, setScheduledTime] = useState(() => {
   const [scheduledTime, setScheduledTime] = useState(() => {
     if (item.scheduled_time && !isPlaceholderDate) {
     if (item.scheduled_time && !isPlaceholderDate) {
       // Convert ISO to local datetime-local format
       // Convert ISO to local datetime-local format
@@ -69,6 +71,7 @@ export function EditQueueItemModal({ item, onClose }: EditQueueItemModalProps) {
       printer_id: printerId,
       printer_id: printerId,
       require_previous_success: requirePreviousSuccess,
       require_previous_success: requirePreviousSuccess,
       auto_off_after: autoOffAfter,
       auto_off_after: autoOffAfter,
+      manual_start: scheduleType === 'manual',
     };
     };
 
 
     if (scheduleType === 'scheduled' && scheduledTime) {
     if (scheduleType === 'scheduled' && scheduledTime) {
@@ -146,7 +149,7 @@ export function EditQueueItemModal({ item, onClose }: EditQueueItemModalProps) {
               <div className="flex gap-2">
               <div className="flex gap-2">
                 <button
                 <button
                   type="button"
                   type="button"
-                  className={`flex-1 px-3 py-2 rounded-lg border text-sm flex items-center justify-center gap-2 transition-colors ${
+                  className={`flex-1 px-2 py-2 rounded-lg border text-sm flex items-center justify-center gap-1.5 transition-colors ${
                     scheduleType === 'asap'
                     scheduleType === 'asap'
                       ? 'bg-bambu-green border-bambu-green text-white'
                       ? 'bg-bambu-green border-bambu-green text-white'
                       : 'bg-bambu-dark border-bambu-dark-tertiary text-bambu-gray hover:text-white'
                       : 'bg-bambu-dark border-bambu-dark-tertiary text-bambu-gray hover:text-white'
@@ -154,11 +157,11 @@ export function EditQueueItemModal({ item, onClose }: EditQueueItemModalProps) {
                   onClick={() => setScheduleType('asap')}
                   onClick={() => setScheduleType('asap')}
                 >
                 >
                   <Clock className="w-4 h-4" />
                   <Clock className="w-4 h-4" />
-                  ASAP (when idle)
+                  ASAP
                 </button>
                 </button>
                 <button
                 <button
                   type="button"
                   type="button"
-                  className={`flex-1 px-3 py-2 rounded-lg border text-sm flex items-center justify-center gap-2 transition-colors ${
+                  className={`flex-1 px-2 py-2 rounded-lg border text-sm flex items-center justify-center gap-1.5 transition-colors ${
                     scheduleType === 'scheduled'
                     scheduleType === 'scheduled'
                       ? 'bg-bambu-green border-bambu-green text-white'
                       ? 'bg-bambu-green border-bambu-green text-white'
                       : 'bg-bambu-dark border-bambu-dark-tertiary text-bambu-gray hover:text-white'
                       : 'bg-bambu-dark border-bambu-dark-tertiary text-bambu-gray hover:text-white'
@@ -168,6 +171,18 @@ export function EditQueueItemModal({ item, onClose }: EditQueueItemModalProps) {
                   <Calendar className="w-4 h-4" />
                   <Calendar className="w-4 h-4" />
                   Scheduled
                   Scheduled
                 </button>
                 </button>
+                <button
+                  type="button"
+                  className={`flex-1 px-2 py-2 rounded-lg border text-sm flex items-center justify-center gap-1.5 transition-colors ${
+                    scheduleType === 'manual'
+                      ? 'bg-bambu-green border-bambu-green text-white'
+                      : 'bg-bambu-dark border-bambu-dark-tertiary text-bambu-gray hover:text-white'
+                  }`}
+                  onClick={() => setScheduleType('manual')}
+                >
+                  <Hand className="w-4 h-4" />
+                  Queue Only
+                </button>
               </div>
               </div>
             </div>
             </div>
 
 
@@ -219,7 +234,9 @@ export function EditQueueItemModal({ item, onClose }: EditQueueItemModalProps) {
             <p className="text-xs text-bambu-gray">
             <p className="text-xs text-bambu-gray">
               {scheduleType === 'asap'
               {scheduleType === 'asap'
                 ? 'Print will start as soon as the printer is idle.'
                 ? 'Print will start as soon as the printer is idle.'
-                : 'Print will start at the scheduled time if the printer is idle. If busy, it will wait until the printer becomes available.'}
+                : scheduleType === 'scheduled'
+                ? 'Print will start at the scheduled time if the printer is idle. If busy, it will wait until the printer becomes available.'
+                : 'Print will be staged but won\'t start automatically. Use the Start button to release it to the queue.'}
             </p>
             </p>
 
 
             {/* Actions */}
             {/* Actions */}

+ 33 - 1
frontend/src/pages/QueuePage.tsx

@@ -40,6 +40,7 @@ import {
   Layers,
   Layers,
   ArrowUp,
   ArrowUp,
   ArrowDown,
   ArrowDown,
+  Hand,
 } from 'lucide-react';
 } from 'lucide-react';
 import { api } from '../api/client';
 import { api } from '../api/client';
 import type { PrintQueueItem } from '../api/client';
 import type { PrintQueueItem } from '../api/client';
@@ -101,6 +102,7 @@ function SortableQueueItem({
   onRemove,
   onRemove,
   onStop,
   onStop,
   onRequeue,
   onRequeue,
+  onStart,
 }: {
 }: {
   item: PrintQueueItem;
   item: PrintQueueItem;
   position?: number;
   position?: number;
@@ -109,6 +111,7 @@ function SortableQueueItem({
   onRemove: () => void;
   onRemove: () => void;
   onStop: () => void;
   onStop: () => void;
   onRequeue: () => void;
   onRequeue: () => void;
+  onStart: () => void;
 }) {
 }) {
   const {
   const {
     attributes,
     attributes,
@@ -198,7 +201,7 @@ function SortableQueueItem({
                 {formatDuration(item.print_time_seconds)}
                 {formatDuration(item.print_time_seconds)}
               </span>
               </span>
             )}
             )}
-            {isPending && (
+            {isPending && !item.manual_start && (
               <span className="flex items-center gap-1.5">
               <span className="flex items-center gap-1.5">
                 <Clock className="w-3.5 h-3.5" />
                 <Clock className="w-3.5 h-3.5" />
                 {formatRelativeTime(item.scheduled_time)}
                 {formatRelativeTime(item.scheduled_time)}
@@ -208,6 +211,12 @@ function SortableQueueItem({
 
 
           {/* Options badges */}
           {/* Options badges */}
           <div className="flex items-center gap-2 mt-2">
           <div className="flex items-center gap-2 mt-2">
+            {item.manual_start && (
+              <span className="text-xs px-2 py-0.5 bg-purple-500/10 text-purple-400 rounded-full border border-purple-500/20 flex items-center gap-1">
+                <Hand className="w-3 h-3" />
+                Staged
+              </span>
+            )}
             {item.require_previous_success && (
             {item.require_previous_success && (
               <span className="text-xs px-2 py-0.5 bg-orange-500/10 text-orange-400 rounded-full border border-orange-500/20">
               <span className="text-xs px-2 py-0.5 bg-orange-500/10 text-orange-400 rounded-full border border-orange-500/20">
                 Requires previous success
                 Requires previous success
@@ -258,6 +267,17 @@ function SortableQueueItem({
           )}
           )}
           {isPending && (
           {isPending && (
             <>
             <>
+              {item.manual_start && (
+                <Button
+                  variant="ghost"
+                  size="sm"
+                  onClick={onStart}
+                  title="Start Print"
+                  className="text-bambu-green hover:text-bambu-green-light hover:bg-bambu-green/10"
+                >
+                  <Play className="w-4 h-4" />
+                </Button>
+              )}
               <Button
               <Button
                 variant="ghost"
                 variant="ghost"
                 size="sm"
                 size="sm"
@@ -393,6 +413,15 @@ export function QueuePage() {
     onError: () => showToast('Failed to stop print', 'error'),
     onError: () => showToast('Failed to stop print', 'error'),
   });
   });
 
 
+  const startMutation = useMutation({
+    mutationFn: (id: number) => api.startQueueItem(id),
+    onSuccess: () => {
+      queryClient.invalidateQueries({ queryKey: ['queue'] });
+      showToast('Print released to queue');
+    },
+    onError: () => showToast('Failed to start print', 'error'),
+  });
+
   const reorderMutation = useMutation({
   const reorderMutation = useMutation({
     mutationFn: (items: { id: number; position: number }[]) => api.reorderQueue(items),
     mutationFn: (items: { id: number; position: number }[]) => api.reorderQueue(items),
     onSuccess: () => {
     onSuccess: () => {
@@ -627,6 +656,7 @@ export function QueuePage() {
                     onRemove={() => {}}
                     onRemove={() => {}}
                     onStop={() => setConfirmAction({ type: 'stop', item })}
                     onStop={() => setConfirmAction({ type: 'stop', item })}
                     onRequeue={() => {}}
                     onRequeue={() => {}}
+                    onStart={() => {}}
                   />
                   />
                 ))}
                 ))}
               </div>
               </div>
@@ -689,6 +719,7 @@ export function QueuePage() {
                         onRemove={() => {}}
                         onRemove={() => {}}
                         onStop={() => {}}
                         onStop={() => {}}
                         onRequeue={() => {}}
                         onRequeue={() => {}}
+                        onStart={() => startMutation.mutate(item.id)}
                       />
                       />
                     ))}
                     ))}
                   </div>
                   </div>
@@ -740,6 +771,7 @@ export function QueuePage() {
                     onRemove={() => setConfirmAction({ type: 'remove', item })}
                     onRemove={() => setConfirmAction({ type: 'remove', item })}
                     onStop={() => {}}
                     onStop={() => {}}
                     onRequeue={() => setRequeueItem(item)}
                     onRequeue={() => setRequeueItem(item)}
+                    onStart={() => {}}
                   />
                   />
                 ))}
                 ))}
               </div>
               </div>

La diferencia del archivo ha sido suprimido porque es demasiado grande
+ 0 - 0
static/assets/index-DXIzheUf.js


La diferencia del archivo ha sido suprimido porque es demasiado grande
+ 0 - 0
static/assets/index-YKsbWJQ9.css


La diferencia del archivo ha sido suprimido porque es demasiado grande
+ 0 - 0
static/assets/index-ta6Ao_MI.css


+ 2 - 2
static/index.html

@@ -23,8 +23,8 @@
 
 
     <!-- Splash screens for iOS -->
     <!-- Splash screens for iOS -->
     <link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
     <link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
-    <script type="module" crossorigin src="/assets/index-BWv5Xlm9.js"></script>
-    <link rel="stylesheet" crossorigin href="/assets/index-ta6Ao_MI.css">
+    <script type="module" crossorigin src="/assets/index-DXIzheUf.js"></script>
+    <link rel="stylesheet" crossorigin href="/assets/index-YKsbWJQ9.css">
   </head>
   </head>
   <body>
   <body>
     <div id="root"></div>
     <div id="root"></div>

Algunos archivos no se mostraron porque demasiados archivos cambiaron en este cambio