Преглед изворни кода

feat(toast): restore upload-progress toast for scheduler dispatches (#1625 follow-up)

FTP push to the printer into the server-side scheduler tick. That
removed the browser-side upload the old XHR-progress modal listened
to — users only saw the queue item flip to "active" with no visibility
into the FTP push + the H2D/H2D Pro 80-210 s project_file digestion
window before the printer actually started.

Port the legacy bg-dispatch toast rendering from
0b43ac0d:frontend/src/contexts/ToastContext.tsx lines 510-650 back in
place verbatim — same DOM tree, same Tailwind classes, same
formatFileSize bytes line, same uppercase status chip, same collapse
chevron, same awaitingPrinter derivation, same auto-dismiss. The only
adapt is the event ingestion: a useEffect maps the four scheduler-side
WS events to the legacy DispatchToastJob shape.

The toast materializes when the FTP push to the printer ACTUALLY
STARTS (queue_item_uploading) — NOT on POST /queue. A draft that
fired at queue-add made the toast jump to "Dispatched" before any
upload had happened.

Four backend WS events drive it: uploading (carries printer_name +
total_bytes), upload_progress (throttled at 200 ms / 256 KB to match
legacy background_dispatch.py:614-615 1:1, first call always emits,
completion always emits; an _UploadProgressBridge bridges from the
FTP executor thread to the asyncio loop), acked (printer transitioned
out of pre_state), failed (with a reason key the toast looks up as
dispatchToast.failed.{reason}). No queue_item_dispatched event: the
legacy path kept status=processing from upload start until printer
ack, "Awaiting printer..." derives from upload_progress_pct >= 99.9
(legacy uploadDoneAwaitingPrinter trick).

Per-user routing: WS connect resolves the principal username to
User.id once and stashes it on websocket.state, so
ws_manager.broadcast_to_user filters O(connections). Auth-disabled
installs route user_id=None to all connections — matches the legacy
single-user behaviour. The watchdog receives created_by_id through a
new kwarg so the static method can still emit acked without
re-fetching the queue item.
maziggy пре 2 месеци
родитељ
комит
9033b0f81e

Разлика између датотеке није приказан због своје велике величине
+ 0 - 0
CHANGELOG.md


+ 16 - 0
backend/app/api/routes/websocket.py

@@ -19,10 +19,12 @@ from __future__ import annotations
 import logging
 
 from fastapi import APIRouter, Query, WebSocket, WebSocketDisconnect
+from sqlalchemy import select
 
 from backend.app.core.auth import is_auth_enabled, verify_websocket_token
 from backend.app.core.database import async_session
 from backend.app.core.websocket import ws_manager
+from backend.app.models.user import User
 from backend.app.services.printer_manager import printer_manager, printer_state_to_dict
 
 logger = logging.getLogger(__name__)
@@ -86,6 +88,20 @@ async def websocket_endpoint(websocket: WebSocket, token: str | None = Query(def
     # ``broadcast_to_principal()`` helper can filter on it without
     # touching every call site.
     websocket.state.bambuddy_principal = principal
+    # Resolve principal username → User.id once at connect so
+    # ``ws_manager.broadcast_to_user()`` can filter without re-querying
+    # per message. Auth-disabled path keeps None (broadcast_to_user fans
+    # out to all when target is None — matches the legacy single-user
+    # toast behaviour). API-keyed principal is empty string → None.
+    principal_user_id: int | None = None
+    if principal:
+        try:
+            async with async_session() as db:
+                row = await db.execute(select(User.id).where(User.username == principal))
+                principal_user_id = row.scalar_one_or_none()
+        except Exception:  # SEC-AUTH-EXC: resolution failure is non-fatal — degrades to no per-user routing
+            logger.warning("WebSocket principal resolve failed for %s", principal, exc_info=True)
+    websocket.state.bambuddy_principal_user_id = principal_user_id
     logger.info("WebSocket client connected")
 
     try:

+ 112 - 0
backend/app/core/websocket.py

@@ -43,6 +43,42 @@ class ConnectionManager:
                 if conn in self.active_connections:
                     self.active_connections.remove(conn)
 
+    async def broadcast_to_user(self, user_id: int | None, message: dict[str, Any]):
+        """Send a message to every connection authenticated as the given user.
+
+        When ``user_id`` is None the message fans out to all connections —
+        this is the auth-disabled single-user path, where neither the queue
+        item's ``created_by_id`` nor the WS principal is set, and the
+        existing fan-out semantics are exactly what the user wants.
+
+        Per-user routing reads ``websocket.state.bambuddy_principal_user_id``
+        stamped at connect time (``routes/websocket.py``). Connections
+        without a stamped id are skipped on the targeted path so an
+        anonymous reader never receives another user's dispatch toast.
+        """
+        if user_id is None:
+            await self.broadcast(message)
+            return
+
+        if not self.active_connections:
+            return
+
+        data = json.dumps(message)
+        async with self._lock:
+            disconnected = []
+            for connection in self.active_connections:
+                conn_uid = getattr(connection.state, "bambuddy_principal_user_id", None)
+                if conn_uid != user_id:
+                    continue
+                try:
+                    await connection.send_text(data)
+                except Exception:
+                    disconnected.append(connection)
+
+            for conn in disconnected:
+                if conn in self.active_connections:
+                    self.active_connections.remove(conn)
+
     async def send_printer_status(self, printer_id: int, status: dict):
         """Send printer status update to all clients."""
         await self.broadcast(
@@ -91,6 +127,82 @@ class ConnectionManager:
             }
         )
 
+    async def send_queue_item_uploading(
+        self,
+        user_id: int | None,
+        queue_item_id: int,
+        printer_id: int,
+        printer_name: str | None,
+        file_name: str,
+        total_bytes: int,
+    ):
+        """Toast trigger: scheduler picked the item up, FTP upload starts."""
+        await self.broadcast_to_user(
+            user_id,
+            {
+                "type": "queue_item_uploading",
+                "queue_item_id": queue_item_id,
+                "printer_id": printer_id,
+                "printer_name": printer_name,
+                "file_name": file_name,
+                "total_bytes": total_bytes,
+            },
+        )
+
+    async def send_queue_item_upload_progress(
+        self,
+        user_id: int | None,
+        queue_item_id: int,
+        bytes_transferred: int,
+        total_bytes: int,
+    ):
+        """Toast update: throttled byte-level progress during the FTP upload."""
+        pct = int(round(100 * bytes_transferred / total_bytes)) if total_bytes else 0
+        await self.broadcast_to_user(
+            user_id,
+            {
+                "type": "queue_item_upload_progress",
+                "queue_item_id": queue_item_id,
+                "bytes_transferred": bytes_transferred,
+                "total_bytes": total_bytes,
+                "pct": pct,
+            },
+        )
+
+    async def send_queue_item_acked(
+        self,
+        user_id: int | None,
+        queue_item_id: int,
+        printer_id: int,
+    ):
+        """Toast trigger: watchdog confirmed the printer transitioned out of pre_state."""
+        await self.broadcast_to_user(
+            user_id,
+            {
+                "type": "queue_item_acked",
+                "queue_item_id": queue_item_id,
+                "printer_id": printer_id,
+            },
+        )
+
+    async def send_queue_item_failed(
+        self,
+        user_id: int | None,
+        queue_item_id: int,
+        printer_id: int | None,
+        reason: str,
+    ):
+        """Toast trigger: dispatch failed at any stage. Toast turns red, auto-dismisses."""
+        await self.broadcast_to_user(
+            user_id,
+            {
+                "type": "queue_item_failed",
+                "queue_item_id": queue_item_id,
+                "printer_id": printer_id,
+                "reason": reason,
+            },
+        )
+
     async def send_missing_spool_assignment(
         self,
         printer_id: int,

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

@@ -14,6 +14,7 @@ from sqlalchemy.orm import selectinload
 from backend.app.core.config import settings
 from backend.app.core.database import async_session, run_with_retry
 from backend.app.core.tasks import spawn_background_task
+from backend.app.core.websocket import ws_manager
 from backend.app.models.archive import PrintArchive
 from backend.app.models.library import LibraryFile
 from backend.app.models.print_queue import PrintQueueItem
@@ -42,6 +43,77 @@ from backend.app.utils.printer_models import normalize_printer_model
 
 logger = logging.getLogger(__name__)
 
+# Dispatch-toast progress throttling (#1625 follow-up). Mirrors the legacy
+# background_dispatch.py upload_progress_callback (200 ms time gate + 256 KB
+# byte gate) from before the scheduler unification. Time gate keeps small
+# files from going silent (a single 8 KB chunk fires once and that's it);
+# byte gate caps the broadcast rate on slow LAN where 200 ms covers many
+# chunks. uploaded >= total always emits so the bar closes cleanly even on
+# sub-200 ms files.
+_DISPATCH_PROGRESS_BYTE_STEP = 256 * 1024
+_DISPATCH_PROGRESS_MIN_INTERVAL_SECS = 0.2
+
+
+class _UploadProgressBridge:
+    """Thread-safe bridge from ``upload_file_async`` to the WS broadcaster.
+
+    ``upload_file_async`` runs the FTP transfer in an executor thread and
+    invokes its ``progress_callback`` from that thread, so the callback
+    body cannot ``await`` directly. This bridge captures the asyncio loop
+    at construction (on the scheduler thread) and uses
+    ``run_coroutine_threadsafe`` to hop back. The byte/time throttle
+    matches the legacy background_dispatch.py path 1:1 so the toast feels
+    identical to the pre-#1625 experience.
+
+    Failures inside the emit are swallowed — progress is a UX nicety, the
+    upload itself must not fail because of a WS hiccup.
+    """
+
+    def __init__(self, user_id: int | None, queue_item_id: int):
+        self._user_id = user_id
+        self._queue_item_id = queue_item_id
+        try:
+            self._loop = asyncio.get_running_loop()
+        except RuntimeError:
+            self._loop = None
+        self._last_emit_bytes = 0
+        self._last_emit_monotonic = 0.0
+        self._has_emitted = False
+
+    def __call__(self, bytes_transferred: int, total_bytes: int) -> None:
+        if self._loop is None or total_bytes <= 0:
+            return
+        now = time.monotonic()
+        # Mirrors legacy bg-dispatch: emit if first call OR upload complete
+        # OR 200 ms elapsed OR ≥256 KB transferred since last emit. Two of
+        # the four matter most: first-call so the user sees something even
+        # for sub-chunk-size files; uploaded >= total so the bar locks at
+        # 100% even when the throttle would otherwise eat it.
+        should_emit = (
+            not self._has_emitted
+            or bytes_transferred >= total_bytes
+            or now - self._last_emit_monotonic >= _DISPATCH_PROGRESS_MIN_INTERVAL_SECS
+            or bytes_transferred - self._last_emit_bytes >= _DISPATCH_PROGRESS_BYTE_STEP
+        )
+        if not should_emit:
+            return
+        self._has_emitted = True
+        self._last_emit_bytes = bytes_transferred
+        self._last_emit_monotonic = now
+        try:
+            asyncio.run_coroutine_threadsafe(
+                ws_manager.send_queue_item_upload_progress(
+                    user_id=self._user_id,
+                    queue_item_id=self._queue_item_id,
+                    bytes_transferred=bytes_transferred,
+                    total_bytes=total_bytes,
+                ),
+                self._loop,
+            )
+        except Exception:
+            pass  # progress is best-effort, never block the upload
+
+
 # Bambu firmware states that mean the project_file has actually been accepted
 # and the printer is now processing / running / paused mid-print. Used by the
 # dispatch watchdog (#1370): a transition into one of these states means the
@@ -2285,6 +2357,28 @@ class PrintScheduler:
         except Exception as e:
             logger.debug("Queue item %s: Delete failed (may not exist): %s", item.id, e)
 
+        # Dispatch toast — announce the upload start with the total byte
+        # count so the frontend can render an honest progress bar.
+        toast_uid = item.created_by_id
+        toast_file_name = filename.replace(".gcode.3mf", "").replace(".3mf", "")
+        try:
+            total_bytes = file_path.stat().st_size
+        except OSError:
+            total_bytes = 0
+        try:
+            await ws_manager.send_queue_item_uploading(
+                user_id=toast_uid,
+                queue_item_id=item.id,
+                printer_id=item.printer_id,
+                printer_name=printer.name,
+                file_name=toast_file_name,
+                total_bytes=total_bytes,
+            )
+        except Exception:
+            pass  # toast is best-effort
+
+        progress_bridge = _UploadProgressBridge(toast_uid, item.id)
+
         try:
             if ftp_retry_enabled:
                 uploaded = await with_ftp_retry(
@@ -2295,6 +2389,7 @@ class PrintScheduler:
                     remote_path,
                     socket_timeout=ftp_timeout,
                     printer_model=printer.model,
+                    progress_callback=progress_bridge,
                     max_retries=ftp_retry_count,
                     retry_delay=ftp_retry_delay,
                     operation_name=f"Upload print to {printer.name}",
@@ -2307,6 +2402,7 @@ class PrintScheduler:
                     remote_path,
                     socket_timeout=ftp_timeout,
                     printer_model=printer.model,
+                    progress_callback=progress_bridge,
                 )
         except Exception as e:
             uploaded = False
@@ -2338,6 +2434,15 @@ class PrintScheduler:
                 reason="Failed to upload file to printer",
                 db=db,
             )
+            try:
+                await ws_manager.send_queue_item_failed(
+                    user_id=toast_uid,
+                    queue_item_id=item.id,
+                    printer_id=item.printer_id,
+                    reason="upload_failed",
+                )
+            except Exception:
+                pass
             await self._power_off_if_needed(db, item)
             return
 
@@ -2439,6 +2544,13 @@ class PrintScheduler:
 
         if started:
             logger.info("Queue item %s: Print started successfully - %s", item.id, filename)
+            # No dispatch-toast event here: the legacy bg-dispatch path kept
+            # status='processing' from upload start until the printer acked
+            # (or timed out). The frontend derives "Awaiting printer…" purely
+            # from upload_progress_pct >= 99.9; an explicit 'dispatched' WS
+            # event would push the status chip out of 'PROCESSING' prematurely
+            # — which is exactly what the screenshot at #1625-followup
+            # complained about.
 
             # Register the local 3MF in the cover-cache so /cover skips FTP
             # (#1166 follow-up). file_path was resolved earlier from either the
@@ -2468,6 +2580,7 @@ class PrintScheduler:
                         pre_state,
                         pre_subtask_id,
                         pre_gcode_file,
+                        created_by_id=toast_uid,
                     ),
                     name=f"watchdog-print-start-{item.id}",
                 )
@@ -2533,6 +2646,15 @@ class PrintScheduler:
                 reason="Failed to send print command to printer - check printer connection and status",
                 db=db,
             )
+            try:
+                await ws_manager.send_queue_item_failed(
+                    user_id=toast_uid,
+                    queue_item_id=item.id,
+                    printer_id=item.printer_id,
+                    reason="start_command_failed",
+                )
+            except Exception:
+                pass
 
             await self._power_off_if_needed(db, item)
 
@@ -2546,6 +2668,7 @@ class PrintScheduler:
         timeout: float = 90.0,
         phase_b_timeout: float = 180.0,
         poll_interval: float = 3.0,
+        created_by_id: int | None = None,
     ) -> None:
         """Revert a queue item if the printer never acknowledges the start command.
 
@@ -2597,6 +2720,14 @@ class PrintScheduler:
                 # would otherwise look like "command landed" and leave the
                 # queue item stuck in 'printing' forever (#1370).
                 scheduler._release_dispatch_hold(printer_id)
+                try:
+                    await ws_manager.send_queue_item_acked(
+                        user_id=created_by_id,
+                        queue_item_id=queue_item_id,
+                        printer_id=printer_id,
+                    )
+                except Exception:
+                    pass
                 return
             if pre_subtask_id is not None and status.subtask_id is not None and status.subtask_id != pre_subtask_id:
                 # Phase A exit — printer accepted the file (subtask_id flipped
@@ -2618,6 +2749,14 @@ class PrintScheduler:
                 last_status = status
                 if status.state in _ACTIVE_PRINT_STATES:
                     scheduler._release_dispatch_hold(printer_id)
+                    try:
+                        await ws_manager.send_queue_item_acked(
+                            user_id=created_by_id,
+                            queue_item_id=queue_item_id,
+                            printer_id=printer_id,
+                        )
+                    except Exception:
+                        pass
                     return
 
         # No active-state transition. Revert the item so the scheduler can retry.

+ 113 - 0
backend/tests/unit/test_upload_progress_bridge.py

@@ -0,0 +1,113 @@
+"""Throttle contract for the scheduler's upload-progress bridge.
+
+The legacy bg-dispatch path (last seen in
+backend/app/services/background_dispatch.py before commit 61c8898b) used:
+    - 200 ms time gate
+    - 256 KB byte gate
+    - always emit on first call and at uploaded >= total
+
+The scheduler-driven dispatch must feel identical to the pre-#1625 path,
+so the throttle here mirrors that 1:1.
+"""
+
+from __future__ import annotations
+
+import asyncio
+
+import pytest
+
+from backend.app.services.print_scheduler import _UploadProgressBridge
+
+
+@pytest.mark.asyncio
+async def test_first_call_always_emits(monkeypatch):
+    bridge = _UploadProgressBridge(user_id=1, queue_item_id=1)
+    bridge._loop = asyncio.get_running_loop()
+    calls: list[tuple[int, int]] = []
+
+    def fake_run(coro, loop):  # noqa: ARG001
+        coro.close()
+        calls.append((1, 1))
+
+    monkeypatch.setattr("backend.app.services.print_scheduler.asyncio.run_coroutine_threadsafe", fake_run)
+
+    # First chunk, tiny payload — must emit so the user sees something
+    # even for sub-chunk-size files where the upload finishes inside the
+    # very first FTP callback.
+    bridge(8192, 16384)
+    assert len(calls) == 1
+
+
+@pytest.mark.asyncio
+async def test_emit_at_completion_even_under_throttle_gates(monkeypatch):
+    bridge = _UploadProgressBridge(user_id=1, queue_item_id=2)
+    bridge._loop = asyncio.get_running_loop()
+    calls: list[int] = []
+
+    def fake_run(coro, loop):  # noqa: ARG001
+        coro.close()
+        calls.append(1)
+
+    monkeypatch.setattr("backend.app.services.print_scheduler.asyncio.run_coroutine_threadsafe", fake_run)
+
+    # First call, force pretend-recent emit so neither time nor byte gate fires.
+    bridge(50_000, 1_000_000)
+    bridge._last_emit_monotonic = float("inf") * 0 + 1e18  # implausibly recent
+    bridge._last_emit_bytes = 50_000
+
+    # Mid-upload chunk well under both gates — would normally skip.
+    bridge(60_000, 1_000_000)
+
+    # Completion — must always emit so the bar locks at 100%.
+    bridge(1_000_000, 1_000_000)
+
+    # First + completion. Mid-upload chunk skipped (last_emit_monotonic is
+    # in the future, byte step is only 10 KB).
+    assert len(calls) == 2
+
+
+@pytest.mark.asyncio
+async def test_emit_after_256kb_step_even_under_time_gate(monkeypatch):
+    bridge = _UploadProgressBridge(user_id=1, queue_item_id=3)
+    bridge._loop = asyncio.get_running_loop()
+    calls: list[int] = []
+
+    def fake_run(coro, loop):  # noqa: ARG001
+        coro.close()
+        calls.append(1)
+
+    monkeypatch.setattr("backend.app.services.print_scheduler.asyncio.run_coroutine_threadsafe", fake_run)
+
+    bridge(8192, 10_000_000)  # first emit
+    # Pretend time gate not met but byte gate IS met (256 KB further).
+    bridge._last_emit_monotonic = 1e18
+    bridge._last_emit_bytes = 8192
+
+    bridge(8192 + 256 * 1024 + 1, 10_000_000)
+    assert len(calls) == 2
+
+
+@pytest.mark.asyncio
+async def test_no_emit_when_total_bytes_zero(monkeypatch):
+    bridge = _UploadProgressBridge(user_id=1, queue_item_id=4)
+    bridge._loop = asyncio.get_running_loop()
+    calls: list[int] = []
+
+    def fake_run(coro, loop):  # noqa: ARG001
+        coro.close()
+        calls.append(1)
+
+    monkeypatch.setattr("backend.app.services.print_scheduler.asyncio.run_coroutine_threadsafe", fake_run)
+
+    bridge(0, 0)
+    bridge(100, 0)
+
+    assert calls == []
+
+
+def test_silent_when_no_running_loop_captured():
+    """Constructed outside an asyncio loop — the bridge captures None and
+    every call is a no-op."""
+    bridge = _UploadProgressBridge(user_id=1, queue_item_id=5)
+    assert bridge._loop is None
+    bridge(1, 100)  # must not raise

+ 138 - 0
backend/tests/unit/test_ws_broadcast_to_user.py

@@ -0,0 +1,138 @@
+"""WebSocket dispatch-toast routing (#1625 follow-up).
+
+Two contracts pinned here:
+
+1. ``broadcast_to_user(uid, msg)`` only delivers to connections whose
+   ``websocket.state.bambuddy_principal_user_id`` matches the target,
+   and fans out to all when the target is None (auth-disabled path).
+2. The six ``send_queue_item_*`` helpers serialize the right payload
+   shape — the frontend toast reads exact field names + types.
+"""
+
+from __future__ import annotations
+
+import json
+from types import SimpleNamespace
+from unittest.mock import AsyncMock
+
+import pytest
+
+from backend.app.core.websocket import ConnectionManager
+
+
+def _mock_conn(user_id: int | None):
+    """Build a stand-in WebSocket-shaped object with the principal stamp."""
+    conn = SimpleNamespace()
+    conn.state = SimpleNamespace()
+    conn.state.bambuddy_principal_user_id = user_id
+    conn.send_text = AsyncMock()
+    return conn
+
+
+@pytest.mark.asyncio
+async def test_broadcast_to_user_filters_by_principal_user_id():
+    """A targeted broadcast only reaches the principal's connections."""
+    mgr = ConnectionManager()
+    alice = _mock_conn(7)
+    bob = _mock_conn(8)
+    anon = _mock_conn(None)  # auth-disabled session — skipped on targeted path
+    mgr.active_connections = [alice, bob, anon]
+
+    await mgr.broadcast_to_user(7, {"type": "queue_item_uploading", "queue_item_id": 1})
+
+    alice.send_text.assert_awaited_once()
+    bob.send_text.assert_not_awaited()
+    anon.send_text.assert_not_awaited()
+
+
+@pytest.mark.asyncio
+async def test_broadcast_to_user_none_fans_out_to_all():
+    """Auth-disabled installs route ``user_id=None`` to every connection
+    via the regular broadcast — matches the legacy single-user toast
+    behaviour where there was no per-user routing at all."""
+    mgr = ConnectionManager()
+    a = _mock_conn(None)
+    b = _mock_conn(None)
+    mgr.active_connections = [a, b]
+
+    await mgr.broadcast_to_user(None, {"type": "queue_item_uploading", "queue_item_id": 1})
+
+    a.send_text.assert_awaited_once()
+    b.send_text.assert_awaited_once()
+
+
+@pytest.mark.asyncio
+async def test_send_queue_item_uploading_carries_total_bytes():
+    mgr = ConnectionManager()
+    target = _mock_conn(42)
+    mgr.active_connections = [target]
+
+    await mgr.send_queue_item_uploading(
+        user_id=42,
+        queue_item_id=11,
+        printer_id=1,
+        printer_name="H2D-1",
+        file_name="cube.3mf",
+        total_bytes=12345,
+    )
+
+    payload = json.loads(target.send_text.await_args.args[0])
+    assert payload == {
+        "type": "queue_item_uploading",
+        "queue_item_id": 11,
+        "printer_id": 1,
+        "printer_name": "H2D-1",
+        "file_name": "cube.3mf",
+        "total_bytes": 12345,
+    }
+
+
+@pytest.mark.asyncio
+async def test_send_queue_item_upload_progress_computes_pct_server_side():
+    """The toast renders the pct field verbatim — the backend has to
+    compute it. Avoid divide-by-zero on a zero-byte upload."""
+    mgr = ConnectionManager()
+    target = _mock_conn(5)
+    mgr.active_connections = [target]
+
+    await mgr.send_queue_item_upload_progress(
+        user_id=5,
+        queue_item_id=3,
+        bytes_transferred=50,
+        total_bytes=200,
+    )
+    payload = json.loads(target.send_text.await_args.args[0])
+    assert payload["pct"] == 25
+
+    target.send_text.reset_mock()
+    await mgr.send_queue_item_upload_progress(
+        user_id=5,
+        queue_item_id=3,
+        bytes_transferred=0,
+        total_bytes=0,
+    )
+    payload = json.loads(target.send_text.await_args.args[0])
+    assert payload["pct"] == 0
+
+
+@pytest.mark.asyncio
+async def test_send_queue_item_failed_carries_reason_key():
+    """The frontend looks up ``dispatchToast.failed.{reason}`` — so the
+    backend must hand the toast a reason string the i18n can match."""
+    mgr = ConnectionManager()
+    target = _mock_conn(99)
+    mgr.active_connections = [target]
+
+    await mgr.send_queue_item_failed(
+        user_id=99,
+        queue_item_id=8,
+        printer_id=2,
+        reason="upload_failed",
+    )
+    payload = json.loads(target.send_text.await_args.args[0])
+    assert payload == {
+        "type": "queue_item_failed",
+        "queue_item_id": 8,
+        "printer_id": 2,
+        "reason": "upload_failed",
+    }

+ 123 - 0
frontend/src/__tests__/contexts/DispatchToastContext.test.tsx

@@ -0,0 +1,123 @@
+/**
+ * Dispatch-toast tests against the legacy-port-verbatim implementation
+ * inside ToastContext.tsx (the standalone DispatchToastStack component
+ * was removed; the toast now lives in ToastContext, matching the
+ * pre-#1625 location at 0b43ac0d:frontend/src/contexts/ToastContext.tsx).
+ */
+import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
+import { act, screen, fireEvent } from '@testing-library/react';
+import { render } from '../utils';
+
+function emit(detail: Record<string, unknown>) {
+  act(() => {
+    window.dispatchEvent(new CustomEvent('bambuddy:dispatch-toast', { detail }));
+  });
+}
+
+describe('Dispatch toast (inside ToastContext)', () => {
+  beforeEach(() => {
+    vi.useFakeTimers();
+  });
+
+  afterEach(() => {
+    vi.useRealTimers();
+  });
+
+  it('does NOT render on a stray progress event before any uploading event', () => {
+    render(<div />);
+    emit({ type: 'queue_item_upload_progress', queue_item_id: 99, bytes_transferred: 1, total_bytes: 100, pct: 1 });
+    emit({ type: 'queue_item_acked', queue_item_id: 99 });
+    expect(screen.queryByTestId('dispatch-toast-wrapper')).toBeNull();
+  });
+
+  it('materializes on uploading; status chip stays PROCESSING through upload', () => {
+    render(<div />);
+
+    emit({
+      type: 'queue_item_uploading',
+      queue_item_id: 42,
+      printer_id: 1,
+      printer_name: 'H2D-1',
+      file_name: 'cube.3mf',
+      total_bytes: 10 * 1024 * 1024,
+    });
+    expect(screen.getByTestId('dispatch-toast-wrapper')).toBeInTheDocument();
+    expect(screen.getByText('cube.3mf')).toBeInTheDocument();
+    expect(screen.getByText('H2D-1')).toBeInTheDocument();
+    expect(screen.getByTestId('dispatch-toast-status-42')).toHaveTextContent(/processing/i);
+
+    emit({ type: 'queue_item_upload_progress', queue_item_id: 42, bytes_transferred: 5 * 1024 * 1024, total_bytes: 10 * 1024 * 1024, pct: 50.0 });
+    expect(screen.getByText(/50\.0%/)).toBeInTheDocument();
+    expect(screen.getByTestId('dispatch-toast-status-42')).toHaveTextContent(/processing/i);
+  });
+
+  it('shows "Awaiting printer" once pct >= 99.9 while status STAYS processing', () => {
+    render(<div />);
+    emit({
+      type: 'queue_item_uploading',
+      queue_item_id: 42,
+      printer_id: 1,
+      printer_name: 'H2D-1',
+      file_name: 'cube.3mf',
+      total_bytes: 100,
+    });
+    emit({ type: 'queue_item_upload_progress', queue_item_id: 42, bytes_transferred: 100, total_bytes: 100, pct: 100 });
+
+    expect(screen.getByTestId('dispatch-toast-status-42')).toHaveTextContent(/processing/i);
+    expect(screen.getByText(/awaiting/i)).toBeInTheDocument();
+  });
+
+  it('acked flips chip to COMPLETED and wrapper auto-dismisses', () => {
+    render(<div />);
+    emit({
+      type: 'queue_item_uploading',
+      queue_item_id: 42,
+      printer_id: 1,
+      printer_name: 'H2D-1',
+      file_name: 'cube.3mf',
+      total_bytes: 100,
+    });
+    emit({ type: 'queue_item_acked', queue_item_id: 42 });
+    expect(screen.getByTestId('dispatch-toast-status-42')).toHaveTextContent(/completed/i);
+
+    act(() => { vi.advanceTimersByTime(3501); });
+    expect(screen.queryByTestId('dispatch-toast-wrapper')).toBeNull();
+  });
+
+  it('two concurrent jobs render as rows inside ONE wrapper', () => {
+    render(<div />);
+
+    emit({ type: 'queue_item_uploading', queue_item_id: 1, printer_id: 1, printer_name: 'H2D-1', file_name: 'a.3mf', total_bytes: 1000 });
+    emit({ type: 'queue_item_uploading', queue_item_id: 2, printer_id: 2, printer_name: 'H2D-2', file_name: 'b.3mf', total_bytes: 2000 });
+
+    expect(screen.getAllByTestId('dispatch-toast-wrapper')).toHaveLength(1);
+    expect(screen.getByTestId('dispatch-toast-job-1')).toBeInTheDocument();
+    expect(screen.getByTestId('dispatch-toast-job-2')).toBeInTheDocument();
+  });
+
+  it('failed shows red bar + reason; wrapper auto-dismisses', () => {
+    render(<div />);
+    emit({ type: 'queue_item_uploading', queue_item_id: 7, printer_id: 1, printer_name: 'H2D-1', file_name: 'job.3mf', total_bytes: 100 });
+    emit({ type: 'queue_item_failed', queue_item_id: 7, reason: 'upload_failed' });
+    expect(screen.getByTestId('dispatch-toast-status-7')).toHaveTextContent(/failed/i);
+
+    act(() => { vi.advanceTimersByTime(3501); });
+    expect(screen.queryByTestId('dispatch-toast-wrapper')).toBeNull();
+  });
+
+  it('collapse hides job rows but keeps the header visible', () => {
+    render(<div />);
+    emit({ type: 'queue_item_uploading', queue_item_id: 1, printer_id: 1, printer_name: 'H2D-1', file_name: 'a.3mf', total_bytes: 1000 });
+    expect(screen.getByTestId('dispatch-toast-job-1')).toBeInTheDocument();
+    fireEvent.click(screen.getByTestId('dispatch-toast-collapse'));
+    expect(screen.queryByTestId('dispatch-toast-job-1')).toBeNull();
+    expect(screen.getByTestId('dispatch-toast-wrapper')).toBeInTheDocument();
+  });
+
+  it('dismiss button hides the wrapper immediately', () => {
+    render(<div />);
+    emit({ type: 'queue_item_uploading', queue_item_id: 1, printer_id: 1, printer_name: 'H2D-1', file_name: 'a.3mf', total_bytes: 1000 });
+    fireEvent.click(screen.getByTestId('dispatch-toast-dismiss'));
+    expect(screen.queryByTestId('dispatch-toast-wrapper')).toBeNull();
+  });
+});

+ 331 - 28
frontend/src/contexts/ToastContext.tsx

@@ -1,8 +1,39 @@
-import { AlertCircle, CheckCircle, Info, Loader2, X, XCircle } from 'lucide-react';
+import { AlertCircle, CheckCircle, ChevronDown, ChevronUp, Info, Loader2, X, XCircle } from 'lucide-react';
 import { createContext, useCallback, useContext, useEffect, useRef, useState, type ReactNode } from 'react';
+import { useTranslation } from 'react-i18next';
+import { formatFileSize } from '../utils/file';
 
 type ToastType = 'success' | 'error' | 'warning' | 'info' | 'loading';
 
+// Dispatch-toast types — ported verbatim from
+// 0b43ac0d:frontend/src/contexts/ToastContext.tsx. The visual rendering
+// block below is the legacy code 1:1; the only swap is the event ingestion
+// (now sourced from `bambuddy:dispatch-toast` window events that
+// useWebSocket forwards from the four backend WS event types added in
+// the #1625 follow-up). Same shape, same DOM, same styling, same i18n
+// surface — guarantees the modal looks identical to the pre-scheduler
+// experience that users remember.
+type DispatchJobStatus = 'processing' | 'completed' | 'failed';
+
+interface DispatchToastJob {
+  jobId: number;
+  sourceName: string;
+  printerName: string;
+  status: DispatchJobStatus;
+  uploadBytes?: number;
+  uploadTotalBytes?: number;
+  uploadProgressPct?: number;
+  failReason?: string;
+}
+
+interface DispatchToastData {
+  total: number;
+  processing: number;
+  completed: number;
+  failed: number;
+  jobs: DispatchToastJob[];
+}
+
 interface ToastAction {
   label: string;
   href: string;
@@ -22,17 +53,13 @@ interface Toast {
   type: ToastType;
   persistent?: boolean;
   action?: ToastAction;
+  dispatchData?: DispatchToastData;
 }
 
 interface ToastContextType {
   showToast: (message: string, type?: ToastType) => void;
   showPersistentToast: ShowPersistentToast;
   dismissToast: (id: string) => void;
-  /**
-   * Suppress the visible toast viewport while keeping the state machine alive.
-   * Used by the SpoolBuddy kiosk layout to keep the kiosk display free of
-   * main-app notifications.
-   */
   setViewportSuppressed: (suppressed: boolean) => void;
 }
 
@@ -62,9 +89,47 @@ const bgColors = {
   loading: 'bg-bambu-green/10 border-bambu-green/30',
 };
 
+const DISPATCH_TOAST_ID = 'background-dispatch';
+const DISPATCH_TERMINAL_DISMISS_MS = 3500;
+
+interface DispatchEventDetail {
+  type: string;
+  queue_item_id: number;
+  printer_id?: number | null;
+  printer_name?: string | null;
+  file_name?: string;
+  total_bytes?: number;
+  bytes_transferred?: number;
+  pct?: number;
+  reason?: string;
+}
+
+function isAwaitingPrinter(job: DispatchToastJob): boolean {
+  // Same trick the legacy code used to derive "Awaiting printer…" without
+  // a separate status. While the job is still 'processing' AND upload pct
+  // has reached 99.9%, the printer hasn't yet acked our project_file.
+  return (
+    job.status === 'processing'
+    && typeof job.uploadProgressPct === 'number'
+    && job.uploadProgressPct >= 99.9
+  );
+}
+
+function recomputeAggregate(jobs: DispatchToastJob[]): DispatchToastData {
+  return {
+    total: jobs.length,
+    processing: jobs.filter((j) => j.status === 'processing').length,
+    completed: jobs.filter((j) => j.status === 'completed').length,
+    failed: jobs.filter((j) => j.status === 'failed').length,
+    jobs,
+  };
+}
+
 export function ToastProvider({ children }: { children: ReactNode }) {
+  const { t } = useTranslation();
   const [toasts, setToasts] = useState<Toast[]>([]);
   const [viewportSuppressed, setViewportSuppressed] = useState(false);
+  const [isDispatchCollapsed, setIsDispatchCollapsed] = useState(false);
   const timeoutRefs = useRef<Map<string, ReturnType<typeof setTimeout>>>(new Map());
   // Tracks whether the provider is still mounted. A toast can be triggered by
   // an async callback that resolves AFTER React has unmounted us (common in
@@ -128,6 +193,123 @@ export function ToastProvider({ children }: { children: ReactNode }) {
     setToasts((prev) => prev.filter((t) => t.id !== id));
   }, []);
 
+  // Dispatch-toast ingestion. The four event types from the backend
+  // (queue_item_uploading / upload_progress / acked / failed) map to
+  // the legacy DispatchToastJob shape, then the same auto-dismiss +
+  // aggregate-recompute logic from 0b43ac0d takes over.
+  useEffect(() => {
+    const onDispatchEvent = (event: Event) => {
+      if (!isMountedRef.current) return;
+      const detail = (event as CustomEvent<DispatchEventDetail>).detail;
+      if (!detail || typeof detail.queue_item_id !== 'number') return;
+      const jobId = detail.queue_item_id;
+
+      setToasts((prev) => {
+        const existing = prev.find((toastItem) => toastItem.id === DISPATCH_TOAST_ID);
+        const existingJobs = existing?.dispatchData?.jobs ?? [];
+        const existingJobIndex = existingJobs.findIndex((j) => j.jobId === jobId);
+        const existingJob = existingJobIndex >= 0 ? existingJobs[existingJobIndex] : undefined;
+
+        let nextJob: DispatchToastJob | null = null;
+        const sourceName =
+          detail.file_name
+          || existingJob?.sourceName
+          || t('dispatchToast.untitled');
+        const printerName =
+          detail.printer_name
+          || existingJob?.printerName
+          || (detail.printer_id ? `Printer ${detail.printer_id}` : '');
+
+        switch (detail.type) {
+          case 'queue_item_uploading':
+            // Materialization point — job appears here, never on queue-add.
+            nextJob = {
+              jobId,
+              sourceName,
+              printerName,
+              status: 'processing',
+              uploadBytes: 0,
+              uploadTotalBytes: detail.total_bytes,
+              uploadProgressPct: 0,
+            };
+            break;
+          case 'queue_item_upload_progress':
+            if (!existingJob) return prev;
+            nextJob = {
+              ...existingJob,
+              uploadBytes: detail.bytes_transferred,
+              uploadTotalBytes: detail.total_bytes ?? existingJob.uploadTotalBytes,
+              uploadProgressPct: detail.pct,
+            };
+            break;
+          case 'queue_item_acked':
+            if (!existingJob) return prev;
+            nextJob = {
+              ...existingJob,
+              status: 'completed',
+              uploadProgressPct: 100,
+            };
+            break;
+          case 'queue_item_failed':
+            if (!existingJob) return prev;
+            nextJob = {
+              ...existingJob,
+              status: 'failed',
+              failReason: detail.reason,
+            };
+            break;
+          default:
+            return prev;
+        }
+
+        // Compose the updated jobs list
+        let updatedJobs: DispatchToastJob[];
+        if (existingJob) {
+          updatedJobs = [...existingJobs];
+          updatedJobs[existingJobIndex] = nextJob;
+        } else {
+          updatedJobs = [...existingJobs, nextJob];
+        }
+
+        const dispatchData = recomputeAggregate(updatedJobs);
+
+        const toastShape: Toast = {
+          id: DISPATCH_TOAST_ID,
+          message: t('dispatchToast.startingPrints'),
+          type: 'loading',
+          persistent: true,
+          dispatchData,
+        };
+
+        if (existing) {
+          return prev.map((toastItem) =>
+            toastItem.id === DISPATCH_TOAST_ID ? toastShape : toastItem,
+          );
+        }
+        return [...prev, toastShape];
+      });
+    };
+
+    window.addEventListener('bambuddy:dispatch-toast', onDispatchEvent);
+    return () => window.removeEventListener('bambuddy:dispatch-toast', onDispatchEvent);
+  }, [t]);
+
+  // Auto-dismiss the wrapper once every job has reached a terminal state.
+  useEffect(() => {
+    const dispatchToast = toasts.find((tst) => tst.id === DISPATCH_TOAST_ID);
+    if (!dispatchToast?.dispatchData) return;
+    const data = dispatchToast.dispatchData;
+    if (data.total === 0 || data.processing !== 0) return;
+    const existing = timeoutRefs.current.get(DISPATCH_TOAST_ID);
+    if (existing) clearTimeout(existing);
+    const timeout = setTimeout(() => {
+      if (!isMountedRef.current) return;
+      setToasts((prev) => prev.filter((tst) => tst.id !== DISPATCH_TOAST_ID));
+      timeoutRefs.current.delete(DISPATCH_TOAST_ID);
+    }, DISPATCH_TERMINAL_DISMISS_MS);
+    timeoutRefs.current.set(DISPATCH_TOAST_ID, timeout);
+  }, [toasts]);
+
   return (
     <ToastContext.Provider value={{ showToast, showPersistentToast, dismissToast, setViewportSuppressed }}>
       {children}
@@ -139,30 +321,151 @@ export function ToastProvider({ children }: { children: ReactNode }) {
         {toasts.map((toast) => (
           <div
             key={toast.id}
-            className={`rounded-lg border shadow-lg backdrop-blur-sm animate-slide-in ${bgColors[toast.type]} flex items-center gap-3 px-4 py-3`}
+            className={`rounded-lg border shadow-lg backdrop-blur-sm animate-slide-in ${bgColors[toast.type]} ${
+              toast.dispatchData ? 'w-[420px] p-3' : 'flex items-center gap-3 px-4 py-3'
+            }`}
+            data-testid={toast.dispatchData ? 'dispatch-toast-wrapper' : undefined}
           >
-            {icons[toast.type]}
-            <span className="text-white text-sm">{toast.message}</span>
-            {toast.action && (
-              <a
-                href={toast.action.href}
-                target="_blank"
-                rel="noopener noreferrer"
-                onClick={() => {
-                  toast.action?.onClick?.();
-                  dismissToast(toast.id);
-                }}
-                className="ml-2 px-2 py-1 rounded text-xs font-medium bg-bambu-green/20 text-bambu-green hover:bg-bambu-green/30 whitespace-nowrap"
-              >
-                {toast.action.label}
-              </a>
+            {toast.dispatchData ? (
+              // Legacy dispatch-toast rendering — verbatim port from
+              // 0b43ac0d:frontend/src/contexts/ToastContext.tsx lines
+              // 515–650. Same DOM, same Tailwind classes, same uppercase
+              // status chip, same `awaitingPrinter` derivation. Only
+              // diff vs legacy: no cancel button (the BG dispatch
+              // cancel endpoint doesn't exist in the scheduler model).
+              <>
+                <div className="flex items-start justify-between gap-3">
+                  <div className="flex items-start gap-2">
+                    {icons[toast.type]}
+                    <div>
+                      <p className="text-white text-sm font-medium">{t('dispatchToast.startingPrints')}</p>
+                      <p className="text-xs text-bambu-gray mt-0.5">
+                        {t('dispatchToast.progressSummary', {
+                          complete: toast.dispatchData.completed + toast.dispatchData.failed,
+                          total: toast.dispatchData.total,
+                          processing: toast.dispatchData.processing,
+                        })}
+                      </p>
+                    </div>
+                  </div>
+                  <div className="flex items-center gap-1">
+                    <button
+                      onClick={() => setIsDispatchCollapsed((prev) => !prev)}
+                      className="text-bambu-gray hover:text-white transition-colors"
+                      aria-label={isDispatchCollapsed ? t('dispatchToast.expandDetails') : t('dispatchToast.collapseDetails')}
+                      data-testid="dispatch-toast-collapse"
+                    >
+                      {isDispatchCollapsed ? <ChevronUp className="w-4 h-4" /> : <ChevronDown className="w-4 h-4" />}
+                    </button>
+                    <button
+                      onClick={() => dismissToast(toast.id)}
+                      className="text-bambu-gray hover:text-white transition-colors"
+                      aria-label={t('dispatchToast.dismiss')}
+                      data-testid="dispatch-toast-dismiss"
+                    >
+                      <X className="w-4 h-4" />
+                    </button>
+                  </div>
+                </div>
+
+                {!isDispatchCollapsed && (
+                  <div className="mt-3 space-y-2 max-h-64 overflow-y-auto pr-1">
+                    {toast.dispatchData.jobs.map((job) => {
+                      const uploadDoneAwaitingPrinter = isAwaitingPrinter(job);
+                      const barColorByStatus: Record<DispatchJobStatus, string> = {
+                        processing: 'bg-bambu-green',
+                        completed: 'bg-green-500',
+                        failed: 'bg-red-500',
+                      };
+                      const progressByStatus: Record<DispatchJobStatus, number> = {
+                        processing: 60,
+                        completed: 100,
+                        failed: 100,
+                      };
+                      return (
+                        <div
+                          key={job.jobId}
+                          className="rounded border border-white/10 bg-black/15 p-2"
+                          data-testid={`dispatch-toast-job-${job.jobId}`}
+                        >
+                          <div className="flex items-center justify-between gap-2">
+                            <span className="text-xs text-white truncate" title={job.sourceName}>
+                              {job.sourceName}
+                            </span>
+                            <span
+                              className="text-[11px] uppercase tracking-wide text-bambu-gray"
+                              data-testid={`dispatch-toast-status-${job.jobId}`}
+                            >
+                              {t(`dispatchToast.status.${job.status}`)}
+                            </span>
+                          </div>
+                          {job.printerName && (
+                            <div className="text-[11px] text-bambu-gray truncate" title={job.printerName}>
+                              {job.printerName}
+                            </div>
+                          )}
+                          {job.status === 'processing' ? (
+                            uploadDoneAwaitingPrinter ? (
+                              <div className="text-[11px] text-bambu-gray truncate">
+                                {t('dispatchToast.awaitingPrinter')}
+                              </div>
+                            ) : typeof job.uploadBytes === 'number'
+                                && typeof job.uploadTotalBytes === 'number'
+                                && job.uploadTotalBytes > 0 ? (
+                              <div className="text-[11px] text-bambu-gray truncate">
+                                {formatFileSize(job.uploadBytes)} / {formatFileSize(job.uploadTotalBytes)}
+                                {typeof job.uploadProgressPct === 'number' ? ` (${job.uploadProgressPct.toFixed(1)}%)` : ''}
+                              </div>
+                            ) : null
+                          ) : job.status === 'failed' && job.failReason ? (
+                            <div className="text-[11px] text-red-400 truncate">
+                              {t(`dispatchToast.failed.${job.failReason}`, { defaultValue: t('dispatchToast.failed.generic') })}
+                            </div>
+                          ) : null}
+                          <div className="mt-1 h-1.5 w-full rounded bg-white/10 overflow-hidden">
+                            <div
+                              className={`h-full ${barColorByStatus[job.status]} transition-all duration-300 ${uploadDoneAwaitingPrinter ? 'animate-pulse' : ''}`}
+                              style={{
+                                width: `${
+                                  job.status === 'processing' && typeof job.uploadProgressPct === 'number'
+                                    ? Math.max(0, Math.min(100, job.uploadProgressPct))
+                                    : progressByStatus[job.status]
+                                }%`,
+                              }}
+                            />
+                          </div>
+                        </div>
+                      );
+                    })}
+                  </div>
+                )}
+              </>
+            ) : (
+              <>
+                {icons[toast.type]}
+                <span className="text-white text-sm">{toast.message}</span>
+                {toast.action && (
+                  <a
+                    href={toast.action.href}
+                    target="_blank"
+                    rel="noopener noreferrer"
+                    onClick={() => {
+                      toast.action?.onClick?.();
+                      dismissToast(toast.id);
+                    }}
+                    className="ml-2 px-2 py-1 rounded text-xs font-medium bg-bambu-green/20 text-bambu-green hover:bg-bambu-green/30 whitespace-nowrap"
+                  >
+                    {toast.action.label}
+                  </a>
+                )}
+                <button
+                  onClick={() => dismissToast(toast.id)}
+                  className="ml-2 text-bambu-gray hover:text-white transition-colors"
+                >
+                  <X className="w-4 h-4" />
+                </button>
+              </>
             )}
-            <button
-              onClick={() => dismissToast(toast.id)}
-              className="ml-2 text-bambu-gray hover:text-white transition-colors"
-            >
-              <X className="w-4 h-4" />
-            </button>
           </div>
         ))}
       </div>

+ 16 - 0
frontend/src/hooks/useWebSocket.ts

@@ -383,6 +383,22 @@ export function useWebSocket() {
         debouncedInvalidate('spoolbuddy-devices');
         debouncedInvalidate('spoolbuddy-update-check');
         break;
+
+      // Dispatch toast lifecycle (#1625 follow-up — restored the upload
+      // progress UI that the scheduler unification removed). Four backend
+      // event types collapse to one frontend channel. No
+      // `queue_item_queued` (the toast must wait for the upload to
+      // actually start) and no `queue_item_dispatched` (the legacy
+      // background-dispatch flow kept status='processing' from upload
+      // start until printer ack — the "Awaiting printer…" subtitle is
+      // derived from upload_progress_pct >= 99.9, not from a separate
+      // event).
+      case 'queue_item_uploading':
+      case 'queue_item_upload_progress':
+      case 'queue_item_acked':
+      case 'queue_item_failed':
+        window.dispatchEvent(new CustomEvent('bambuddy:dispatch-toast', { detail: message }));
+        break;
     }
   }, [queryClient, debouncedInvalidate, throttledPrinterStatusUpdate, showToast, t]);
 

+ 21 - 0
frontend/src/i18n/locales/de.ts

@@ -1023,6 +1023,27 @@ export default {
     },
   },
 
+  // Sticky upload-progress toast (#1625 follow-up)
+  dispatchToast: {
+    untitled: 'Druckjob',
+    startingPrints: 'Drucke starten',
+    progressSummary: '{{complete}}/{{total}} fertig • Verarbeitung: {{processing}}',
+    expandDetails: 'Versanddetails ausklappen',
+    collapseDetails: 'Versanddetails einklappen',
+    awaitingPrinter: 'Warte auf Drucker…',
+    status: {
+      processing: 'Verarbeitung',
+      completed: 'Fertig',
+      failed: 'Fehlgeschlagen',
+    },
+    failed: {
+      generic: 'Versand fehlgeschlagen',
+      upload_failed: 'Upload zum Drucker fehlgeschlagen',
+      start_command_failed: 'Drucker hat Startbefehl abgelehnt',
+    },
+    dismiss: 'Schließen',
+  },
+
   // Queue page
   queue: {
     filamentShort: {

+ 23 - 0
frontend/src/i18n/locales/en.ts

@@ -1027,6 +1027,29 @@ export default {
     },
   },
 
+  // Sticky upload-progress toast (#1625 follow-up — restored the
+  // legacy bg-dispatch toast UI for the scheduler-driven dispatch path).
+  // Strings mirror 0b43ac0d's backgroundDispatch namespace.
+  dispatchToast: {
+    untitled: 'Print job',
+    startingPrints: 'Starting prints',
+    progressSummary: '{{complete}}/{{total}} complete • Processing: {{processing}}',
+    expandDetails: 'Expand dispatch details',
+    collapseDetails: 'Collapse dispatch details',
+    awaitingPrinter: 'Awaiting printer…',
+    status: {
+      processing: 'Processing',
+      completed: 'Completed',
+      failed: 'Failed',
+    },
+    failed: {
+      generic: 'Dispatch failed',
+      upload_failed: 'Upload to printer failed',
+      start_command_failed: 'Printer rejected start command',
+    },
+    dismiss: 'Dismiss',
+  },
+
   // Queue page
   queue: {
     title: 'Print Queue',

+ 21 - 0
frontend/src/i18n/locales/es.ts

@@ -1023,6 +1023,27 @@ export default {
     },
   },
 
+  // Sticky upload-progress toast (#1625 follow-up)
+  dispatchToast: {
+    untitled: 'Trabajo de impresión',
+    startingPrints: 'Iniciando impresiones',
+    progressSummary: '{{complete}}/{{total}} listas • Procesando: {{processing}}',
+    expandDetails: 'Expandir detalles del despacho',
+    collapseDetails: 'Contraer detalles del despacho',
+    awaitingPrinter: 'Esperando a la impresora…',
+    status: {
+      processing: 'Procesando',
+      completed: 'Completada',
+      failed: 'Fallida',
+    },
+    failed: {
+      generic: 'Despacho fallido',
+      upload_failed: 'Fallo al subir a la impresora',
+      start_command_failed: 'La impresora rechazó el comando de inicio',
+    },
+    dismiss: 'Cerrar',
+  },
+
   // Queue page
   queue: {
     filamentShort: {

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

@@ -1023,6 +1023,27 @@ export default {
     },
   },
 
+  // Sticky upload-progress toast (#1625 follow-up)
+  dispatchToast: {
+    untitled: 'Tâche d\'impression',
+    startingPrints: 'Démarrage des impressions',
+    progressSummary: '{{complete}}/{{total}} terminées • En cours : {{processing}}',
+    expandDetails: 'Afficher les détails de l\'envoi',
+    collapseDetails: 'Masquer les détails de l\'envoi',
+    awaitingPrinter: 'En attente de l\'imprimante…',
+    status: {
+      processing: 'En cours',
+      completed: 'Terminée',
+      failed: 'Échouée',
+    },
+    failed: {
+      generic: 'Échec de l\'envoi',
+      upload_failed: 'Échec du téléversement vers l\'imprimante',
+      start_command_failed: 'L\'imprimante a rejeté la commande de démarrage',
+    },
+    dismiss: 'Fermer',
+  },
+
   // Queue page
   queue: {
     filamentShort: {

+ 21 - 0
frontend/src/i18n/locales/it.ts

@@ -1023,6 +1023,27 @@ export default {
     },
   },
 
+  // Sticky upload-progress toast (#1625 follow-up)
+  dispatchToast: {
+    untitled: 'Lavoro di stampa',
+    startingPrints: 'Avvio delle stampe',
+    progressSummary: '{{complete}}/{{total}} completate • In corso: {{processing}}',
+    expandDetails: 'Espandi i dettagli dell\'invio',
+    collapseDetails: 'Comprimi i dettagli dell\'invio',
+    awaitingPrinter: 'In attesa della stampante…',
+    status: {
+      processing: 'In corso',
+      completed: 'Completata',
+      failed: 'Fallita',
+    },
+    failed: {
+      generic: 'Invio fallito',
+      upload_failed: 'Caricamento sulla stampante fallito',
+      start_command_failed: 'La stampante ha rifiutato il comando di avvio',
+    },
+    dismiss: 'Chiudi',
+  },
+
   // Queue page
   queue: {
     filamentShort: {

+ 21 - 0
frontend/src/i18n/locales/ja.ts

@@ -1022,6 +1022,27 @@ export default {
     },
   },
 
+  // Sticky upload-progress toast (#1625 follow-up)
+  dispatchToast: {
+    untitled: '印刷ジョブ',
+    startingPrints: '印刷を開始しています',
+    progressSummary: '{{complete}}/{{total}} 完了 • 処理中: {{processing}}',
+    expandDetails: '送信の詳細を表示',
+    collapseDetails: '送信の詳細を非表示',
+    awaitingPrinter: 'プリンターを待機中…',
+    status: {
+      processing: '処理中',
+      completed: '完了',
+      failed: '失敗',
+    },
+    failed: {
+      generic: '送信に失敗しました',
+      upload_failed: 'プリンターへのアップロードに失敗しました',
+      start_command_failed: 'プリンターが開始コマンドを拒否しました',
+    },
+    dismiss: '閉じる',
+  },
+
   // Queue page
   queue: {
     filamentShort: {

+ 19 - 0
frontend/src/i18n/locales/ko.ts

@@ -979,6 +979,25 @@ export default {
       }
     }
   },
+  dispatchToast: {
+    untitled: '인쇄 작업',
+    startingPrints: '인쇄 시작 중',
+    progressSummary: '{{complete}}/{{total}} 완료 • 처리 중: {{processing}}',
+    expandDetails: '디스패치 세부 정보 펼치기',
+    collapseDetails: '디스패치 세부 정보 접기',
+    awaitingPrinter: '프린터 응답 대기 중…',
+    status: {
+      processing: '처리 중',
+      completed: '완료',
+      failed: '실패',
+    },
+    failed: {
+      generic: '디스패치 실패',
+      upload_failed: '프린터로 업로드 실패',
+      start_command_failed: '프린터가 시작 명령을 거부했습니다',
+    },
+    dismiss: '닫기',
+  },
   queue: {
     title: '인쇄 대기열',
     subtitle: '인쇄 작업을 예약하고 관리하세요',

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

@@ -1023,6 +1023,27 @@ export default {
     },
   },
 
+  // Sticky upload-progress toast (#1625 follow-up)
+  dispatchToast: {
+    untitled: 'Trabalho de impressão',
+    startingPrints: 'Iniciando impressões',
+    progressSummary: '{{complete}}/{{total}} concluídas • Processando: {{processing}}',
+    expandDetails: 'Expandir detalhes do envio',
+    collapseDetails: 'Recolher detalhes do envio',
+    awaitingPrinter: 'Aguardando impressora…',
+    status: {
+      processing: 'Processando',
+      completed: 'Concluída',
+      failed: 'Falhou',
+    },
+    failed: {
+      generic: 'Falha no envio',
+      upload_failed: 'Falha ao enviar para a impressora',
+      start_command_failed: 'Impressora rejeitou o comando de início',
+    },
+    dismiss: 'Fechar',
+  },
+
   // Queue page
   queue: {
     filamentShort: {

+ 21 - 0
frontend/src/i18n/locales/tr.ts

@@ -1023,6 +1023,27 @@ export default {
     },
   },
 
+  // Sticky upload-progress toast (#1625 follow-up)
+  dispatchToast: {
+    untitled: 'Yazdırma işi',
+    startingPrints: 'Yazdırmalar başlatılıyor',
+    progressSummary: '{{complete}}/{{total}} tamamlandı • İşleniyor: {{processing}}',
+    expandDetails: 'Gönderim ayrıntılarını genişlet',
+    collapseDetails: 'Gönderim ayrıntılarını daralt',
+    awaitingPrinter: 'Yazıcı bekleniyor…',
+    status: {
+      processing: 'İşleniyor',
+      completed: 'Tamamlandı',
+      failed: 'Başarısız',
+    },
+    failed: {
+      generic: 'Gönderim başarısız',
+      upload_failed: 'Yazıcıya yükleme başarısız',
+      start_command_failed: 'Yazıcı başlatma komutunu reddetti',
+    },
+    dismiss: 'Kapat',
+  },
+
   // Kuyruk sayfası
   queue: {
     title: 'Baskı Kuyruğu',

+ 21 - 0
frontend/src/i18n/locales/zh-CN.ts

@@ -1023,6 +1023,27 @@ export default {
     },
   },
 
+  // Sticky upload-progress toast (#1625 follow-up)
+  dispatchToast: {
+    untitled: '打印任务',
+    startingPrints: '正在开始打印',
+    progressSummary: '{{complete}}/{{total}} 已完成 • 处理中: {{processing}}',
+    expandDetails: '展开派发详情',
+    collapseDetails: '收起派发详情',
+    awaitingPrinter: '等待打印机响应…',
+    status: {
+      processing: '处理中',
+      completed: '已完成',
+      failed: '失败',
+    },
+    failed: {
+      generic: '派发失败',
+      upload_failed: '上传到打印机失败',
+      start_command_failed: '打印机拒绝了开始命令',
+    },
+    dismiss: '关闭',
+  },
+
   // Queue page
   queue: {
     filamentShort: {

+ 21 - 0
frontend/src/i18n/locales/zh-TW.ts

@@ -1023,6 +1023,27 @@ export default {
     },
   },
 
+  // Sticky upload-progress toast (#1625 follow-up)
+  dispatchToast: {
+    untitled: '列印任務',
+    startingPrints: '正在開始列印',
+    progressSummary: '{{complete}}/{{total}} 已完成 • 處理中: {{processing}}',
+    expandDetails: '展開派發詳情',
+    collapseDetails: '收起派發詳情',
+    awaitingPrinter: '等待印表機回應…',
+    status: {
+      processing: '處理中',
+      completed: '已完成',
+      failed: '失敗',
+    },
+    failed: {
+      generic: '派發失敗',
+      upload_failed: '上傳到印表機失敗',
+      start_command_failed: '印表機拒絕了開始命令',
+    },
+    dismiss: '關閉',
+  },
+
   // Queue page
   queue: {
     filamentShort: {

Разлика између датотеке није приказан због своје велике величине
+ 0 - 0
static/assets/index-B1_XGLrL.js


Разлика између датотеке није приказан због своје велике величине
+ 1 - 0
static/assets/index-Bxs6ZGEZ.css


Разлика између датотеке није приказан због своје велике величине
+ 0 - 1
static/assets/index-CFvgt_ZD.css


+ 2 - 2
static/index.html

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

Неке датотеке нису приказане због велике количине промена