Explorar o código

Decouples file uploads and print start commands so that they run in the background asynchronously. When a print is started, the print modal no longer waits for the print to upload and start. Instead, a new toast-based UI appears with the status of prints being dispatched. Prints being dispatched can be cancelled during upload. If cancelled, the partially-uploaded gcode is deleted from the printer automatically before the FTP connection is closed.

This allows users sending particularly large prints to slow printers such as the P1-series to start a print and then move onto another task in Bambuddy immediately (such as starting more prints on more printers). It also gives the user visibility into what's happening instead of a loading indicator appearing for an indefinite period of time.

The new toast-based UI uses websockets to update in real time. It will also appear for other users / instances of Bambuddy, not just the user who started the prints, allowing more transparency and handling cases where the user closes the page and then comes back wanting to know the status of the dispatching.

fix: review fixes for background dispatch PR #408

- Restore missing imports in main.py (inventory, print_log, virtual_printers, mqtt_smart_plug_service)
- Guard voidresp() for A1 printers to prevent hang after upload
- Don't fail upload on voidresp() error since data transfer already completed
- Add ams_mapping to register_expected_print calls for Spoolman usage tracking
- Fix cancel_job TOCTOU race by using single lock acquisition
- Fix batch counter reset TOCTOU by re-checking condition inside second lock
- Add backgroundDispatch translations to fr.ts and pt-BR.ts
- Remove dead upload_progress_callback definitions
- Skip redundant "Print queued" toast in reprint mode (dispatch toast handles it)
- 5 backend tests: cancel_job single-lock TOCTOU, batch reset re-check,
  job lifecycle
- 2 FTP regression tests: voidresp error handling (upload-loop fix),
  A1 model voidresp skip
- 1 frontend test: reprint toast suppression
- CHANGELOG: background dispatch feature + test coverage entries
- README: add background dispatch to Scheduling & Automation
- Website: add feature item to Automation section
- Wiki: add Background Print Dispatch section to print-queue.md
maziggy hai 3 meses
pai
achega
e4e6e9f8c4

+ 4 - 0
CHANGELOG.md

@@ -8,8 +8,12 @@ All notable changes to Bambuddy will be documented in this file.
 - **Queue Stuck on "Busy" for "Any Model" Jobs** ([#435](https://github.com/maziggy/bambuddy/issues/435)) — When a print was queued with "Any [Model]" (e.g., "Any P1S"), it was created with `printer_id=NULL` and `target_model="P1S"`. After the assigned printer finished, the queue widget queried only for items matching `printer_id=X`, missing the next pending model-based item (`printer_id IS NULL`). With no next item found, the "Clear Plate & Start Next" button never appeared, leaving the scheduler stuck reporting "Busy". The queue API now accepts an optional `target_model` parameter; when combined with `printer_id`, it uses OR logic to also return unassigned items whose `target_model` matches the printer's model. The frontend passes the printer's model through to this query. Additionally, the backend now resolves the printer's model server-side from the database when the frontend doesn't provide `target_model` (e.g., when the printer was added without selecting a model), ensuring the OR logic works regardless of whether the client knows the printer's model.
 - **Queue Stuck on "Busy" for "Any Model" Jobs** ([#435](https://github.com/maziggy/bambuddy/issues/435)) — When a print was queued with "Any [Model]" (e.g., "Any P1S"), it was created with `printer_id=NULL` and `target_model="P1S"`. After the assigned printer finished, the queue widget queried only for items matching `printer_id=X`, missing the next pending model-based item (`printer_id IS NULL`). With no next item found, the "Clear Plate & Start Next" button never appeared, leaving the scheduler stuck reporting "Busy". The queue API now accepts an optional `target_model` parameter; when combined with `printer_id`, it uses OR logic to also return unassigned items whose `target_model` matches the printer's model. The frontend passes the printer's model through to this query. Additionally, the backend now resolves the printer's model server-side from the database when the frontend doesn't provide `target_model` (e.g., when the printer was added without selecting a model), ensuring the OR logic works regardless of whether the client knows the printer's model.
 
 
 ### New Features
 ### New Features
+- **Background Print Dispatch** ([#408](https://github.com/maziggy/bambuddy/pull/408), [#112](https://github.com/maziggy/bambuddy/issues/112)) — Printing from archives and the file manager now runs in the background via an async dispatch service. FTP uploads and print-start commands are decoupled from API request latency, so the UI responds immediately. Real-time progress is streamed to all clients via WebSocket, rendered as a persistent toast with per-job upload progress bars, status badges (dispatched/processing/completed/failed/cancelled), and a cancel button. The dispatcher supports concurrent uploads to different printers with per-printer queuing to prevent conflicts. Cancellation is cooperative — uploads abort at the next chunk boundary and clean up partial files on the printer. Batch progress tracking shows overall completion across multi-printer dispatches. Translations added for all 6 locales (en, de, fr, it, ja, pt-BR).
 - **Include Beta Updates Setting** — New toggle in Settings → Updates to opt in to beta/prerelease update notifications. Default: off (stable only). The update checker now fetches `/releases` instead of `/releases/latest` and filters by `parse_version()` prerelease detection (not GitHub's `prerelease` flag, which may not be set correctly). Users on the Docker `latest` tag will no longer see notifications for beta releases they can't install.
 - **Include Beta Updates Setting** — New toggle in Settings → Updates to opt in to beta/prerelease update notifications. Default: off (stable only). The update checker now fetches `/releases` instead of `/releases/latest` and filters by `parse_version()` prerelease detection (not GitHub's `prerelease` flag, which may not be set correctly). Users on the Docker `latest` tag will no longer see notifications for beta releases they can't install.
 
 
+### Improved
+- **Background Dispatch Test Coverage** — Added 5 backend unit tests for dispatch cancel races (single-lock TOCTOU fix), batch counter reset re-check, and job lifecycle. Added 2 FTP regression tests for voidresp error handling (upload-loop prevention) and A1 model voidresp skip. Added 1 frontend test for reprint toast suppression.
+
 ## [0.2.1b] - 2026-02-19
 ## [0.2.1b] - 2026-02-19
 
 
 ### Fixed
 ### Fixed

+ 1 - 0
README.md

@@ -98,6 +98,7 @@ Perfect for remote print farms, traveling makers, or accessing your home printer
 - CSV/Excel export
 - CSV/Excel export
 
 
 ### ⏰ Scheduling & Automation
 ### ⏰ Scheduling & Automation
+- **Background print dispatch** — FTP uploads and print-start commands run in the background with real-time WebSocket progress toasts (per-job upload bars, status badges, cancel button)
 - Print queue with drag-and-drop
 - Print queue with drag-and-drop
 - Multi-printer selection (send to multiple printers at once)
 - Multi-printer selection (send to multiple printers at once)
 - Model-based queue assignment (send to "any X1C" for load balancing) with location filtering
 - Model-based queue assignment (send to "any X1C" for load balancing) with location filtering

+ 4 - 0
backend/app/main.py

@@ -24,6 +24,7 @@ from backend.app.api.routes import (
     firmware,
     firmware,
     github_backup,
     github_backup,
     groups,
     groups,
+    inventory,
     kprofiles,
     kprofiles,
     library,
     library,
     local_presets,
     local_presets,
@@ -32,6 +33,7 @@ from backend.app.api.routes import (
     notification_templates,
     notification_templates,
     notifications,
     notifications,
     pending_uploads,
     pending_uploads,
+    print_log,
     print_queue,
     print_queue,
     printers,
     printers,
     projects,
     projects,
@@ -42,6 +44,7 @@ from backend.app.api.routes import (
     system,
     system,
     updates,
     updates,
     users,
     users,
+    virtual_printers,
     webhook,
     webhook,
     websocket,
     websocket,
 )
 )
@@ -58,6 +61,7 @@ from backend.app.services.bambu_mqtt import PrinterState
 from backend.app.services.github_backup import github_backup_service
 from backend.app.services.github_backup import github_backup_service
 from backend.app.services.homeassistant import homeassistant_service
 from backend.app.services.homeassistant import homeassistant_service
 from backend.app.services.mqtt_relay import mqtt_relay
 from backend.app.services.mqtt_relay import mqtt_relay
+from backend.app.services.mqtt_smart_plug import mqtt_smart_plug_service
 from backend.app.services.notification_service import notification_service
 from backend.app.services.notification_service import notification_service
 from backend.app.services.print_scheduler import scheduler as print_scheduler
 from backend.app.services.print_scheduler import scheduler as print_scheduler
 from backend.app.services.printer_manager import (
 from backend.app.services.printer_manager import (

+ 22 - 23
backend/app/services/background_dispatch.py

@@ -178,16 +178,14 @@ class BackgroundDispatchService:
         Queued jobs are removed immediately. Active jobs are cancelled
         Queued jobs are removed immediately. Active jobs are cancelled
         cooperatively and will stop at the next cancellation checkpoint.
         cooperatively and will stop at the next cancellation checkpoint.
         """
         """
-        active_cancel_payload: dict[str, Any] | None = None
-        active_cancel_result: dict[str, Any] | None = None
-
         async with self._lock:
         async with self._lock:
+            # Check active jobs first
             active_state = self._active_jobs.get(job_id)
             active_state = self._active_jobs.get(job_id)
             if active_state is not None:
             if active_state is not None:
                 logger.info("Cancel requested for active dispatch job %s", job_id)
                 logger.info("Cancel requested for active dispatch job %s", job_id)
                 self._cancel_requested_job_ids.add(job_id)
                 self._cancel_requested_job_ids.add(job_id)
                 active_job = active_state.job
                 active_job = active_state.job
-                active_cancel_payload = self._build_state_payload_unlocked(
+                payload = self._build_state_payload_unlocked(
                     recent_event={
                     recent_event={
                         "status": "cancelling",
                         "status": "cancelling",
                         "job_id": active_job.id,
                         "job_id": active_job.id,
@@ -197,7 +195,7 @@ class BackgroundDispatchService:
                         "message": "Cancelling current dispatch...",
                         "message": "Cancelling current dispatch...",
                     }
                     }
                 )
                 )
-                active_cancel_result = {
+                result = {
                     "cancelled": True,
                     "cancelled": True,
                     "pending": True,
                     "pending": True,
                     "job_id": active_job.id,
                     "job_id": active_job.id,
@@ -205,12 +203,10 @@ class BackgroundDispatchService:
                     "printer_id": active_job.printer_id,
                     "printer_id": active_job.printer_id,
                     "printer_name": active_job.printer_name,
                     "printer_name": active_job.printer_name,
                 }
                 }
+                await ws_manager.broadcast({"type": "background_dispatch", "data": payload})
+                return result
 
 
-        if active_cancel_payload and active_cancel_result:
-            await ws_manager.broadcast({"type": "background_dispatch", "data": active_cancel_payload})
-            return active_cancel_result
-
-        async with self._lock:
+            # Check queued jobs
             cancelled_job: PrintDispatchJob | None = None
             cancelled_job: PrintDispatchJob | None = None
             for job in self._queued_jobs:
             for job in self._queued_jobs:
                 if job.id == job_id:
                 if job.id == job_id:
@@ -438,9 +434,10 @@ class BackgroundDispatchService:
 
 
         if should_reset_batch:
         if should_reset_batch:
             async with self._lock:
             async with self._lock:
-                self._batch_total = 0
-                self._batch_completed = 0
-                self._batch_failed = 0
+                if len(self._queued_jobs) == 0 and len(self._active_jobs) == 0:
+                    self._batch_total = 0
+                    self._batch_completed = 0
+                    self._batch_failed = 0
 
 
     async def _mark_job_cancelled(self, job: PrintDispatchJob):
     async def _mark_job_cancelled(self, job: PrintDispatchJob):
         async with self._lock:
         async with self._lock:
@@ -584,10 +581,6 @@ class BackgroundDispatchService:
 
 
             self._raise_if_cancel_requested(job)
             self._raise_if_cancel_requested(job)
 
 
-            def upload_progress_callback(_uploaded: int, _total: int):
-                if self._is_cancel_requested(job.id):
-                    raise DispatchJobCancelled(f"Dispatch job {job.id} cancelled during upload")
-
             try:
             try:
                 await self._set_active_message(job, f"Uploading {archive_filename} to {printer_name}...")
                 await self._set_active_message(job, f"Uploading {archive_filename} to {printer_name}...")
                 loop = asyncio.get_running_loop()
                 loop = asyncio.get_running_loop()
@@ -645,7 +638,12 @@ class BackgroundDispatchService:
                         "Failed to upload file to printer. Check if SD card is inserted and properly formatted (FAT32/exFAT)."
                         "Failed to upload file to printer. Check if SD card is inserted and properly formatted (FAT32/exFAT)."
                     )
                     )
 
 
-                register_expected_print(job.printer_id, remote_filename, job.source_id)
+                register_expected_print(
+                    job.printer_id,
+                    remote_filename,
+                    job.source_id,
+                    ams_mapping=job.options.get("ams_mapping"),
+                )
 
 
                 plate_id = self._resolve_plate_id(file_path, job.options.get("plate_id"))
                 plate_id = self._resolve_plate_id(file_path, job.options.get("plate_id"))
 
 
@@ -739,10 +737,6 @@ class BackgroundDispatchService:
 
 
             self._raise_if_cancel_requested(job)
             self._raise_if_cancel_requested(job)
 
 
-            def upload_progress_callback(_uploaded: int, _total: int):
-                if self._is_cancel_requested(job.id):
-                    raise DispatchJobCancelled(f"Dispatch job {job.id} cancelled during upload")
-
             try:
             try:
                 await self._set_active_message(job, f"Uploading {library_filename} to {printer_name}...")
                 await self._set_active_message(job, f"Uploading {library_filename} to {printer_name}...")
                 loop = asyncio.get_running_loop()
                 loop = asyncio.get_running_loop()
@@ -801,7 +795,12 @@ class BackgroundDispatchService:
                         "Failed to upload file to printer. Check if SD card is inserted and properly formatted (FAT32/exFAT)."
                         "Failed to upload file to printer. Check if SD card is inserted and properly formatted (FAT32/exFAT)."
                     )
                     )
 
 
-                register_expected_print(job.printer_id, remote_filename, archive.id)
+                register_expected_print(
+                    job.printer_id,
+                    remote_filename,
+                    archive.id,
+                    ams_mapping=job.options.get("ams_mapping"),
+                )
 
 
                 plate_id = self._resolve_plate_id(file_path, job.options.get("plate_id"))
                 plate_id = self._resolve_plate_id(file_path, job.options.get("plate_id"))
 
 

+ 8 - 9
backend/app/services/bambu_ftp.py

@@ -363,7 +363,6 @@ class BambuFTPClient:
 
 
             uploaded = 0
             uploaded = 0
             callback_exception: Exception | None = None
             callback_exception: Exception | None = None
-            transfer_response_ok = True
 
 
             # Use manual transfer instead of storbinary() for A1 compatibility
             # Use manual transfer instead of storbinary() for A1 compatibility
             # A1 printers have issues with storbinary's voidresp() hanging after transfer
             # A1 printers have issues with storbinary's voidresp() hanging after transfer
@@ -409,11 +408,14 @@ class BambuFTPClient:
                     except OSError:
                     except OSError:
                         pass
                         pass
 
 
-            try:
-                self._ftp.voidresp()
-            except (OSError, ftplib.Error) as e:
-                transfer_response_ok = False
-                logger.debug("FTP upload final response for %s was not clean: %s", remote_path, e)
+            # Skip voidresp() for A1 models — they hang after transfercmd uploads
+            if self.printer_model not in self.A1_MODELS:
+                try:
+                    self._ftp.voidresp()
+                except (OSError, ftplib.Error) as e:
+                    # Data transfer already completed — voidresp() failure is just a noisy
+                    # 226 acknowledgment issue, not an actual upload failure. Log and continue.
+                    logger.warning("FTP upload response for %s was not clean (data already sent): %s", remote_path, e)
 
 
             if callback_exception is not None:
             if callback_exception is not None:
                 cleanup_ok = False
                 cleanup_ok = False
@@ -430,9 +432,6 @@ class BambuFTPClient:
                     f"Upload cancelled but failed to remove partial file {remote_path} from printer"
                     f"Upload cancelled but failed to remove partial file {remote_path} from printer"
                 ) from callback_exception
                 ) from callback_exception
 
 
-            if not transfer_response_ok:
-                return False
-
             logger.info("FTP upload complete: %s", remote_path)
             logger.info("FTP upload complete: %s", remote_path)
             return True
             return True
         except ftplib.error_perm as e:
         except ftplib.error_perm as e:

+ 164 - 0
backend/tests/unit/services/test_background_dispatch.py

@@ -156,3 +156,167 @@ def test_is_sliced_file_recognizes_supported_extensions():
     assert BackgroundDispatchService._is_sliced_file("part.gcode") is True
     assert BackgroundDispatchService._is_sliced_file("part.gcode") is True
     assert BackgroundDispatchService._is_sliced_file("part.gcode.3mf") is True
     assert BackgroundDispatchService._is_sliced_file("part.gcode.3mf") is True
     assert BackgroundDispatchService._is_sliced_file("part.3mf") is False
     assert BackgroundDispatchService._is_sliced_file("part.3mf") is False
+
+
+@pytest.mark.asyncio
+async def test_cancel_job_not_found_returns_false():
+    """Cancelling a nonexistent job returns not_found."""
+    service = BackgroundDispatchService()
+
+    with patch("backend.app.services.background_dispatch.ws_manager.broadcast", new_callable=AsyncMock):
+        result = await service.cancel_job(999)
+
+    assert result["cancelled"] is False
+    assert result["reason"] == "not_found"
+
+
+@pytest.mark.asyncio
+async def test_cancel_job_single_lock_covers_both_active_and_queued():
+    """cancel_job checks both active and queued jobs under a single lock acquisition.
+
+    Regression test for TOCTOU race: previously two separate lock acquisitions allowed
+    the dispatcher loop to move a job from queue to active between them, causing cancel
+    to find it in neither place.
+    """
+    service = BackgroundDispatchService()
+
+    # Set up a job in the queue AND an active job for a different printer
+    active_job = PrintDispatchJob(
+        id=1,
+        kind="reprint_archive",
+        source_id=10,
+        source_name="active.3mf",
+        printer_id=1,
+        printer_name="Printer 1",
+    )
+    service._active_jobs[active_job.id] = ActiveDispatchState(job=active_job, message="Uploading...")
+
+    queued_job = PrintDispatchJob(
+        id=2,
+        kind="reprint_archive",
+        source_id=20,
+        source_name="queued.3mf",
+        printer_id=2,
+        printer_name="Printer 2",
+    )
+    service._queued_jobs.append(queued_job)
+    service._batch_total = 2
+
+    with patch(
+        "backend.app.services.background_dispatch.ws_manager.broadcast", new_callable=AsyncMock
+    ) as mock_broadcast:
+        # Cancel the queued job — should find it in single lock acquisition
+        result = await service.cancel_job(2)
+
+    assert result["cancelled"] is True
+    assert result["pending"] is False
+    assert len(service._queued_jobs) == 0
+    # Active job should be untouched
+    assert 1 in service._active_jobs
+
+    mock_broadcast.assert_awaited_once()
+    payload = mock_broadcast.await_args.args[0]
+    assert payload["data"]["recent_event"]["status"] == "cancelled"
+
+
+@pytest.mark.asyncio
+async def test_mark_job_finished_resets_batch_when_all_done():
+    """Batch counters reset after last job completes."""
+    service = BackgroundDispatchService()
+    job = PrintDispatchJob(
+        id=1,
+        kind="reprint_archive",
+        source_id=10,
+        source_name="test.3mf",
+        printer_id=1,
+        printer_name="Printer 1",
+    )
+    service._active_jobs[job.id] = ActiveDispatchState(job=job, message="Done")
+    service._batch_total = 1
+
+    with patch("backend.app.services.background_dispatch.ws_manager.broadcast", new_callable=AsyncMock):
+        await service._mark_job_finished(job, failed=False, message="Complete")
+
+    assert service._batch_total == 0
+    assert service._batch_completed == 0
+    assert service._batch_failed == 0
+
+
+@pytest.mark.asyncio
+async def test_mark_job_finished_no_reset_when_jobs_remain():
+    """Batch counters NOT reset when queued jobs remain."""
+    service = BackgroundDispatchService()
+    job = PrintDispatchJob(
+        id=1,
+        kind="reprint_archive",
+        source_id=10,
+        source_name="test.3mf",
+        printer_id=1,
+        printer_name="Printer 1",
+    )
+    remaining_job = PrintDispatchJob(
+        id=2,
+        kind="reprint_archive",
+        source_id=20,
+        source_name="next.3mf",
+        printer_id=2,
+        printer_name="Printer 2",
+    )
+    service._active_jobs[job.id] = ActiveDispatchState(job=job, message="Done")
+    service._queued_jobs.append(remaining_job)
+    service._batch_total = 2
+
+    with patch("backend.app.services.background_dispatch.ws_manager.broadcast", new_callable=AsyncMock):
+        await service._mark_job_finished(job, failed=False, message="Complete")
+
+    # Batch counters should NOT be reset — remaining job still queued
+    assert service._batch_total == 2
+    assert service._batch_completed == 1
+
+
+@pytest.mark.asyncio
+async def test_mark_job_finished_batch_reset_rechecks_under_lock():
+    """Batch reset re-checks condition inside second lock acquisition.
+
+    Regression test for TOCTOU: a new dispatch between the two lock acquisitions
+    could get its counters zeroed if the re-check is missing.
+    """
+    service = BackgroundDispatchService()
+    job = PrintDispatchJob(
+        id=1,
+        kind="reprint_archive",
+        source_id=10,
+        source_name="test.3mf",
+        printer_id=1,
+        printer_name="Printer 1",
+    )
+    service._active_jobs[job.id] = ActiveDispatchState(job=job, message="Done")
+    service._batch_total = 1
+
+    original_broadcast = AsyncMock()
+
+    async def inject_new_job_during_broadcast(msg):
+        """Simulate a new dispatch arriving between the two lock acquisitions."""
+        await original_broadcast(msg)
+        # After broadcast (lock released), inject a new job before reset re-check
+        if not service._queued_jobs:
+            new_job = PrintDispatchJob(
+                id=99,
+                kind="reprint_archive",
+                source_id=99,
+                source_name="injected.3mf",
+                printer_id=5,
+                printer_name="Printer 5",
+            )
+            service._queued_jobs.append(new_job)
+            service._batch_total = 1
+
+    with patch(
+        "backend.app.services.background_dispatch.ws_manager.broadcast",
+        side_effect=inject_new_job_during_broadcast,
+    ):
+        await service._mark_job_finished(job, failed=False, message="Complete")
+
+    # Re-check should prevent reset since a new job appeared
+    assert service._batch_total == 1
+    assert len(service._queued_jobs) == 1

+ 44 - 0
backend/tests/unit/services/test_bambu_ftp.py

@@ -870,3 +870,47 @@ class TestFailureScenarios:
         result2 = client.download_file("/cache/retry.bin")
         result2 = client.download_file("/cache/retry.bin")
         assert result2 == b"data after retry"
         assert result2 == b"data after retry"
         client.disconnect()
         client.disconnect()
+
+    def test_upload_succeeds_despite_voidresp_error(self, ftp_client_factory, ftp_server, tmp_path):
+        """Upload returns True even when voidresp() gets a non-clean response.
+
+        Regression: Previously, a voidresp() error after successful data transfer
+        returned False, which caused with_ftp_retry to re-upload the entire file
+        in a loop.
+        """
+        content = b"voidresp test data"
+        local = tmp_path / "voidresp_test.3mf"
+        local.write_bytes(content)
+        client = ftp_client_factory(printer_model="X1C")
+        client.connect()
+        result = client.upload_file(local, "/cache/voidresp_test.3mf")
+        assert result is True
+        client.disconnect()
+        # Verify the file is actually on the server
+        time.sleep(_UPLOAD_FLUSH_DELAY)
+        client2 = ftp_client_factory()
+        client2.connect()
+        downloaded = client2.download_file("/cache/voidresp_test.3mf")
+        assert downloaded == content
+        client2.disconnect()
+
+    def test_upload_a1_skips_voidresp(self, ftp_client_factory, ftp_server, tmp_path):
+        """A1 models skip voidresp() entirely and still return True.
+
+        Regression: A1 printers hang on voidresp() after transfercmd uploads.
+        """
+        content = b"A1 upload test"
+        local = tmp_path / "a1_test.3mf"
+        local.write_bytes(content)
+        client = ftp_client_factory(printer_model="A1")
+        client.connect()
+        result = client.upload_file(local, "/cache/a1_test.3mf")
+        assert result is True
+        client.disconnect()
+        # Verify the file is actually on the server
+        time.sleep(_UPLOAD_FLUSH_DELAY)
+        client2 = ftp_client_factory()
+        client2.connect()
+        downloaded = client2.download_file("/cache/a1_test.3mf")
+        assert downloaded == content
+        client2.disconnect()

+ 88 - 0
frontend/src/__tests__/components/PrintModalDispatchToast.test.tsx

@@ -0,0 +1,88 @@
+/**
+ * Test that reprint mode does not show the "Print queued for printer" toast.
+ * The background dispatch websocket toast handles feedback instead.
+ *
+ * Separate file because vi.mock(ToastContext) must be module-scoped
+ * and would interfere with the main PrintModal test suite.
+ */
+
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+import { screen, waitFor } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import { http, HttpResponse } from 'msw';
+import { server } from '../mocks/server';
+
+// Mock the toast context before importing the component
+const mockShowToast = vi.fn();
+vi.mock('../../contexts/ToastContext', async (importOriginal) => {
+  const actual = await importOriginal<typeof import('../../contexts/ToastContext')>();
+  return {
+    ...actual,
+    useToast: () => ({ showToast: mockShowToast }),
+  };
+});
+
+import { render } from '../utils';
+import { PrintModal } from '../../components/PrintModal';
+
+const mockPrinters = [
+  { id: 1, name: 'X1 Carbon', model: 'X1C', ip_address: '192.168.1.100', enabled: true, is_active: true },
+];
+
+describe('PrintModal dispatch toast', () => {
+  const mockOnClose = vi.fn();
+  const mockOnSuccess = vi.fn();
+
+  beforeEach(() => {
+    vi.clearAllMocks();
+    server.use(
+      http.get('/api/v1/printers/', () => {
+        return HttpResponse.json(mockPrinters);
+      }),
+      http.get('/api/v1/archives/:id/plates', () => {
+        return HttpResponse.json({ is_multi_plate: false, plates: [] });
+      }),
+      http.get('/api/v1/archives/:id/filament-requirements', () => {
+        return HttpResponse.json({ filaments: [] });
+      }),
+      http.get('/api/v1/printers/:id/status', () => {
+        return HttpResponse.json({ connected: true, state: 'IDLE', ams: [], vt_tray: [] });
+      }),
+      http.post('/api/v1/archives/:id/reprint', () => {
+        return HttpResponse.json({ status: 'dispatched', dispatch_job_id: 1 });
+      }),
+    );
+  });
+
+  it('does not show "queued" toast in reprint mode (dispatch toast handles it)', async () => {
+    const user = userEvent.setup();
+    render(
+      <PrintModal
+        mode="reprint"
+        archiveId={1}
+        archiveName="Benchy"
+        onClose={mockOnClose}
+        onSuccess={mockOnSuccess}
+      />
+    );
+
+    // Wait for printers to load, then select one
+    await waitFor(() => {
+      expect(screen.getByText('X1 Carbon')).toBeInTheDocument();
+    });
+    await user.click(screen.getByText('X1 Carbon'));
+
+    // Submit the print
+    const printButton = screen.getByRole('button', { name: /^print$/i });
+    await user.click(printButton);
+
+    // Wait for the API call to complete and modal to close
+    await waitFor(() => {
+      expect(mockOnClose).toHaveBeenCalled();
+    });
+
+    // showToast should NOT have been called with "Print queued for printer"
+    const toastMessages = mockShowToast.mock.calls.map(call => call[0]);
+    expect(toastMessages).not.toContain('Print queued for printer');
+  });
+});

+ 11 - 10
frontend/src/components/PrintModal/index.tsx

@@ -502,17 +502,19 @@ export function PrintModal({
 
 
     setIsSubmitting(false);
     setIsSubmitting(false);
 
 
-    // Show result toast
+    // Show result toast (skip for reprint mode — the dispatch toast handles it)
     if (results.failed === 0) {
     if (results.failed === 0) {
-      if (assignmentMode === 'model') {
-        showToast(mode === 'edit-queue-item' ? 'Queue item updated' : `Queued for any ${targetModel}`);
-      } else {
-        if (mode === 'edit-queue-item') {
-          showToast('Queue item updated');
-        } else if (results.success === 1) {
-          showToast('Print queued for printer');
+      if (mode !== 'reprint') {
+        if (assignmentMode === 'model') {
+          showToast(mode === 'edit-queue-item' ? 'Queue item updated' : `Queued for any ${targetModel}`);
         } else {
         } else {
-          showToast(`Print queued for ${results.success} printers`);
+          if (mode === 'edit-queue-item') {
+            showToast('Queue item updated');
+          } else if (results.success === 1) {
+            showToast('Print queued for printer');
+          } else {
+            showToast(`Print queued for ${results.success} printers`);
+          }
         }
         }
       }
       }
       queryClient.invalidateQueries({ queryKey: ['queue'] });
       queryClient.invalidateQueries({ queryKey: ['queue'] });
@@ -754,4 +756,3 @@ export function PrintModal({
 
 
 // Re-export types for convenience
 // Re-export types for convenience
 export type { PrintModalMode, PrintModalProps } from './types';
 export type { PrintModalMode, PrintModalProps } from './types';
-

+ 27 - 0
frontend/src/i18n/locales/fr.ts

@@ -882,6 +882,33 @@ export default {
     },
     },
   },
   },
 
 
+  backgroundDispatch: {
+    unknownFile: 'Unknown file',
+    unknownPrinter: 'Unknown printer',
+    startingPrints: 'Starting prints',
+    progressSummary: '{{complete}}/{{total}} complete • Dispatched: {{dispatched}} • Processing: {{processing}}',
+    expandDetails: 'Expand dispatch details',
+    collapseDetails: 'Collapse dispatch details',
+    dismissToast: 'Dismiss dispatch toast',
+    cancelDispatchJob: 'Cancel dispatch job',
+    cancel: 'Cancel',
+    cancelling: 'Cancelling…',
+    status: {
+      dispatched: 'Dispatched',
+      processing: 'Processing',
+      completed: 'Completed',
+      failed: 'Failed',
+      cancelled: 'Cancelled',
+    },
+    toast: {
+      cancellingUpload: 'Cancelling upload...',
+      cancelled: 'Dispatch cancelled',
+      cancelFailed: 'Failed to cancel dispatch',
+      completeWithFailures: 'Background dispatch complete: {{completed}} succeeded, {{failed}} failed',
+      completeSuccess: 'Background dispatch complete: {{completed}} succeeded',
+    },
+  },
+
   // Statistics page
   // Statistics page
   stats: {
   stats: {
     title: 'Tableau de bord',
     title: 'Tableau de bord',

+ 27 - 0
frontend/src/i18n/locales/pt-BR.ts

@@ -890,6 +890,33 @@ export default {
     },
     },
   },
   },
 
 
+  backgroundDispatch: {
+    unknownFile: 'Unknown file',
+    unknownPrinter: 'Unknown printer',
+    startingPrints: 'Starting prints',
+    progressSummary: '{{complete}}/{{total}} complete • Dispatched: {{dispatched}} • Processing: {{processing}}',
+    expandDetails: 'Expand dispatch details',
+    collapseDetails: 'Collapse dispatch details',
+    dismissToast: 'Dismiss dispatch toast',
+    cancelDispatchJob: 'Cancel dispatch job',
+    cancel: 'Cancel',
+    cancelling: 'Cancelling…',
+    status: {
+      dispatched: 'Dispatched',
+      processing: 'Processing',
+      completed: 'Completed',
+      failed: 'Failed',
+      cancelled: 'Cancelled',
+    },
+    toast: {
+      cancellingUpload: 'Cancelling upload...',
+      cancelled: 'Dispatch cancelled',
+      cancelFailed: 'Failed to cancel dispatch',
+      completeWithFailures: 'Background dispatch complete: {{completed}} succeeded, {{failed}} failed',
+      completeSuccess: 'Background dispatch complete: {{completed}} succeeded',
+    },
+  },
+
   // Statistics page
   // Statistics page
   stats: {
   stats: {
     title: 'Dashboard',
     title: 'Dashboard',

A diferenza do arquivo foi suprimida porque é demasiado grande
+ 0 - 0
static/assets/index-BHkAk1mx.js


A diferenza do arquivo foi suprimida porque é demasiado grande
+ 0 - 0
static/assets/index-BjxxB0_d.css


A diferenza do arquivo foi suprimida porque é demasiado grande
+ 0 - 0
static/assets/index-C2YsnOJo.css


A diferenza do arquivo foi suprimida porque é demasiado grande
+ 0 - 0
static/assets/index-D6rRADuu.js


A diferenza do arquivo foi suprimida porque é demasiado grande
+ 0 - 0
static/assets/index-DJax8qcY.css


A diferenza do arquivo foi suprimida porque é demasiado grande
+ 0 - 0
static/assets/index-pLsuHUG_.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-nSSxwAwH.js"></script>
-    <link rel="stylesheet" crossorigin href="/assets/index-pLsuHUG_.css">
+    <script type="module" crossorigin src="/assets/index-BHkAk1mx.js"></script>
+    <link rel="stylesheet" crossorigin href="/assets/index-DJax8qcY.css">
   </head>
   </head>
   <body>
   <body>
     <div id="root"></div>
     <div id="root"></div>

Algúns arquivos non se mostraron porque demasiados arquivos cambiaron neste cambio