Bladeren bron

fix(queue): "only if the previous print succeeded" looks at the right printer

For a job queued for any printer of a model, the check ran before a
printer was chosen and nearly always let the job go. It now checks the
printer about to be used, takes another of the model when that one's last
print failed, and waits (with Resume for the model) when every free one
failed. On PostgreSQL a cancel without an end time no longer hides a
failure before it.
maziggy 6 dagen geleden
bovenliggende
commit
5c575b5bae
100 gewijzigde bestanden met toevoegingen van 555 en 59 verwijderingen
  1. 2 2
      .basedpyright/baseline.json
  2. 1 0
      CHANGELOG.md
  3. 2 0
      backend/app/api/routes/pipeline_runs.py
  4. 42 0
      backend/app/api/routes/print_queue.py
  5. 56 40
      backend/app/services/print_scheduler.py
  6. 234 0
      backend/tests/unit/test_previous_success_per_printer.py
  7. 33 0
      frontend/src/__tests__/pages/QueuePage.test.tsx
  8. 7 0
      frontend/src/api/client.ts
  9. 6 0
      frontend/src/i18n/locales/de.ts
  10. 6 0
      frontend/src/i18n/locales/en.ts
  11. 6 0
      frontend/src/i18n/locales/es.ts
  12. 6 0
      frontend/src/i18n/locales/fr.ts
  13. 6 0
      frontend/src/i18n/locales/it.ts
  14. 6 0
      frontend/src/i18n/locales/ja.ts
  15. 6 0
      frontend/src/i18n/locales/ko.ts
  16. 6 0
      frontend/src/i18n/locales/nl.ts
  17. 6 0
      frontend/src/i18n/locales/pt-BR.ts
  18. 6 0
      frontend/src/i18n/locales/ru.ts
  19. 6 0
      frontend/src/i18n/locales/sv.ts
  20. 6 0
      frontend/src/i18n/locales/tr.ts
  21. 6 0
      frontend/src/i18n/locales/uk.ts
  22. 6 0
      frontend/src/i18n/locales/zh-CN.ts
  23. 6 0
      frontend/src/i18n/locales/zh-TW.ts
  24. 73 0
      frontend/src/pages/QueuePage.tsx
  25. 0 0
      static/assets/AdditionalSection-ynSkmTcf.js
  26. 0 0
      static/assets/AmsUnitCard-Csm4KTS_.js
  27. 0 0
      static/assets/ArchivesPage-Cvi0K-rD.js
  28. 0 0
      static/assets/AreaChart-Db04IqSY.js
  29. 0 0
      static/assets/AssignToAmsModal-tml7LZuj.js
  30. 0 0
      static/assets/AuthContext-DS-dpwJp.js
  31. 1 1
      static/assets/Button-BOrMgOyo.js
  32. 0 0
      static/assets/CamWallPage-CWNsOj79.js
  33. 0 0
      static/assets/CameraDiagnoseModal-DBbpU5C3.js
  34. 0 0
      static/assets/CameraPage-DsOiZm5_.js
  35. 0 0
      static/assets/CameraWall-C4LXcZxv.js
  36. 1 1
      static/assets/Card-C6dM19X9.js
  37. 0 0
      static/assets/CartesianChart-DmYbYMcT.js
  38. 0 0
      static/assets/ColumnConfigModal-DvLrcYmB.js
  39. 0 0
      static/assets/ConfigureAmsSlotModal-BWMvMCB6.js
  40. 0 0
      static/assets/ConnectAuthorizePage-B-BuRHmy.js
  41. 0 0
      static/assets/ContextMenu-T-3vFFhy.js
  42. 0 0
      static/assets/CopyButton-BKAT9PLw.js
  43. 0 0
      static/assets/ErrorBarContext-CFG2cIjr.js
  44. 0 0
      static/assets/ExternalLinkPage-DQKXj1ic.js
  45. 0 1
      static/assets/FileManagerPage-C_KJ7Paz.js
  46. 0 0
      static/assets/FileUploadModal-Cf5Tfn4d.js
  47. 0 0
      static/assets/FinancePage-D8DxfkPn.js
  48. 0 0
      static/assets/GCodeViewerPage-DHkArJo8.js
  49. 0 0
      static/assets/GcodeToolpathViewer-DDhsskoJ.js
  50. 0 0
      static/assets/GroupEditPage-Dgyrku2H.js
  51. 0 0
      static/assets/ImagePreviewModal-bRsLacf3.js
  52. 0 0
      static/assets/InventoryPage-Bw6WOk77.js
  53. 0 0
      static/assets/LibraryTrashPage-3xcyeVut.js
  54. 0 0
      static/assets/LoginPage-CFxieoDA.js
  55. 0 0
      static/assets/MaintenancePage-BV1lG5YP.js
  56. 0 0
      static/assets/ModelSourcesPage-CWFiLjD7.js
  57. 0 0
      static/assets/NotificationsPage-Bht6nHmS.js
  58. 1 1
      static/assets/OverlayFrame-BIdWPGNk.js
  59. 0 1
      static/assets/PdfPreviewModal-B1DP1K1b.js
  60. 0 0
      static/assets/PhotoGalleryModal-CmkRwq8P.js
  61. 0 0
      static/assets/PrinterLocationsPage-BK9vpaX5.js
  62. 0 0
      static/assets/PrintersPage-alt5xx8U.js
  63. 0 0
      static/assets/ProfilesPage-PpLBPwoi.js
  64. 0 0
      static/assets/ProjectDetailPage-BECOgLLU.js
  65. 0 0
      static/assets/ProjectsPage-RvqXGGx0.js
  66. 0 0
      static/assets/QueryClientProvider-BqikUEEO.js
  67. 0 0
      static/assets/QueuePage-CENLVY2N.js
  68. 0 0
      static/assets/QueuePage-CvI9-tuU.js
  69. 0 0
      static/assets/ReferenceLine-CHRcTJTi.js
  70. 0 0
      static/assets/RichTextEditor-DxGPdPhT.js
  71. 0 0
      static/assets/SettingsPage-MUbZ4ImQ.js
  72. 0 0
      static/assets/SetupPage-f1RHNOjc.js
  73. 0 0
      static/assets/SliceModal-CK7yDRCZ.js
  74. 0 0
      static/assets/SpoolBuddyAmsPage-C-28HP2y.js
  75. 0 0
      static/assets/SpoolBuddyCalibrationPage-B61xHTmJ.js
  76. 0 0
      static/assets/SpoolBuddyDashboard-NfsRuMDU.js
  77. 0 0
      static/assets/SpoolBuddyInventoryPage-BULEAz4p.js
  78. 1 1
      static/assets/SpoolBuddySettingsPage-NRj95eq_.js
  79. 0 0
      static/assets/SpoolBuddyWriteTagPage-VDNWVIJl.js
  80. 1 1
      static/assets/SpoolIcon-BTzgxzF3.js
  81. 0 0
      static/assets/SpreadsheetPreviewModal-BqWIw5an.js
  82. 0 0
      static/assets/StatsPage-Dwpmh26Z.js
  83. 0 0
      static/assets/StreamOverlayPage-CE2JdNbS.js
  84. 0 0
      static/assets/SystemInfoPage-6GQNGs6g.js
  85. 0 0
      static/assets/ToastContext-CypVB8nG.js
  86. 1 1
      static/assets/Toggle-B27GN7KJ.js
  87. 1 0
      static/assets/arrow-down-Cxl6vzXB.js
  88. 0 1
      static/assets/arrow-down-DdKSvvIT.js
  89. 1 0
      static/assets/arrow-left-BdDYciL5.js
  90. 0 1
      static/assets/arrow-left-CLRcXhCQ.js
  91. 1 0
      static/assets/arrow-right-D8L4bue0.js
  92. 0 1
      static/assets/arrow-right-nSPrsTyj.js
  93. 1 0
      static/assets/arrow-up-BA-zWepm.js
  94. 0 1
      static/assets/arrow-up-BY4M7cKH.js
  95. 1 1
      static/assets/arrow-up-down-c4lzTQGp.js
  96. 1 1
      static/assets/box-C-Edwu2W.js
  97. 1 1
      static/assets/briefcase-aGpG9O_J.js
  98. 1 1
      static/assets/calendar-evWoPgiR.js
  99. 0 1
      static/assets/check-BvjJlBTt.js
  100. 1 0
      static/assets/check-DeJLbG-z.js

+ 2 - 2
.basedpyright/baseline.json

@@ -4170,8 +4170,8 @@
             {
                 "code": "reportArgumentType",
                 "range": {
-                    "startColumn": 28,
-                    "endColumn": 50,
+                    "startColumn": 32,
+                    "endColumn": 54,
                     "lineCount": 1
                 }
             },

+ 1 - 0
CHANGELOG.md

@@ -163,6 +163,7 @@ All notable changes to Bambuddy will be documented in this file.
 - **The frontend build no longer warns about `path` and `crypto` being externalized for the STEP previewer (#2976)** — `occt-import-js`, the Emscripten build behind STEP previews, requires both modules, but only inside its `ENVIRONMENT_IS_NODE` branches; in the browser it loads its `.wasm` from the URL the preview worker passes and draws randomness from `crypto.getRandomValues`. Vite still externalized both and printed two warnings on every build. `vite.config.ts` now drops exactly those two warnings for that one package through `build.rolldownOptions.onLog`, so an externalization anywhere else, or of any other module, still shows.
 
 ### Fixed
+- **"Only if the previous print succeeded" looks at the right printer** — For a job queued for any printer of a model, the check ran before a printer was chosen, against jobs with no printer, so it nearly always let the job go, even onto a printer whose last print had failed; when it did trigger, the job was skipped for good. It now checks the printer it is about to use, passes over one whose last print failed and takes another of the model, and waits if every free one failed last. The queue then shows a **Resume after failure** banner for that model, which clears the gate on its printers, and the "job waiting" notification also goes out when a job that waited for a busy printer comes to wait on such a failure. On PostgreSQL, a job cancelled without an end time (cancelling an order, a pipeline or a kept order run) also counted as the latest print and hid a real failure before it; such cancels now record their end time, and the check no longer ranks a missing one first.
 - **A queued job moved to another printer no longer keeps the old printer's settings** — A job's AMS tray mapping, **Print Anyway** and nozzle choice are worked out for one printer. Moving jobs to another printer (bulk edit, or editing one job) kept them, so a job could print from the slot that held the right material on the old printer but another colour on the new one, and a **Print Anyway** given for the old printer skipped the new printer's filament checks. **Dispatch remaining** of an order also pinned every copy of a job queued for any printer of a model to the printer its latest run happened to land on, with that printer's settings, so the other printers of that model stayed idle. These settings now stay with the old printer and are worked out again for the new one; plate, AMS use, filament overrides and print options are kept. Bulk edit leaves jobs with printer alternatives alone, as editing one does, and a job it moves to a printer no longer also waits for a model.
 - **Pages work for users without the settings permission** — Users in a group without **settings:read** could not read the install settings, so pages that need a few of them fell back to defaults. With billing on, the print dialog hid the cost centre picker and the print was then refused for lacking one. The print dialog also lost its print option and stagger defaults, currency and filament preference. The queue showed the wrong order with shortest job first on, the File Manager lost **Slice**, filament warnings an administrator had turned off still appeared, adding an unknown spool from its tag put it in the built-in inventory even in Spoolman mode, and the theme, currency and forecast lead time fell back to defaults. These settings now reach every signed-in user, without the credentials **settings:read** also shows; G-code snippets are only reported as set or not.
 - **SpoolBuddy writes the Spoolman spool to the tag in Spoolman mode** — Built-in spools are kept after switching to Spoolman. When one of them had the same id as the Spoolman spool being written, SpoolBuddy wrote the built-in spool's material, colour and weight to the tag and linked the tag to that hidden spool, while reporting success. The Spoolman spool never got the tag, so scanning it later found nothing. Writing a tag now uses only the inventory in use.

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

@@ -646,6 +646,7 @@ def _make_orchestration_callable(
                         ).scalar_one_or_none()
                         if qe is not None and qe.status in ("pending", "queued"):
                             qe.status = "cancelled"
+                            qe.completed_at = datetime.now(timezone.utc)
                     if job.status not in ("completed", "failed", "cancelled"):
                         job.status = "cancelled"
                         job.completed_at = datetime.now(timezone.utc)
@@ -1000,6 +1001,7 @@ async def cancel_run(
             ).scalar_one_or_none()
             if queue_entry is not None and queue_entry.status in ("pending", "queued"):
                 queue_entry.status = "cancelled"
+                queue_entry.completed_at = datetime.now(timezone.utc)
         if job.status in ("queued", "printing") and queue_entry is None:
             # Dispatched, its entry gone: whether it printed is unknown, and
             # calling it cancelled would make retry print it again.

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

@@ -1908,6 +1908,7 @@ async def cancel_batch(
     cancelled_ids: list[int] = []
     for item in pending_items:
         item.status = "cancelled"
+        item.completed_at = datetime.now(timezone.utc)
         await release_budget_reservation(
             db,
             source_type="print_queue",
@@ -2289,6 +2290,7 @@ async def delete_queue_item(
     )
     if keep_as_cancelled:
         item.status = "cancelled"
+        item.completed_at = datetime.now(timezone.utc)
         await db.flush()
         # The order may have been sitting on "completed" if this run's target
         # was met by it; cancelling reopens it.
@@ -2392,6 +2394,46 @@ async def resume_queue_after_failure(
     return {"acknowledged": len(to_ack), "restored": len(to_restore)}
 
 
+@router.post("/model/{model}/resume")
+async def resume_model_queue_after_failure(
+    model: str,
+    db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.QUEUE_UPDATE_ALL),
+    printer_scope: PrinterScope = RequestPrinterScope,
+):
+    """Clear the previous-success gate on every printer of a model the caller may use.
+
+    A job for any printer of a model waits, rather than being skipped, when the
+    last print failed on every free one; this is its Resume. Same effect per
+    printer as :func:`resume_queue_after_failure`, which has no skipped jobs to
+    restore here.
+    """
+    from backend.app.services.print_scheduler import scheduler
+
+    printers = await scheduler._printers_for_model(db, model, None, printer_scope)
+    printer_ids = [p.id for p in printers]
+    acknowledged = 0
+    if printer_ids:
+        result = await db.execute(
+            select(PrintQueueItem)
+            .where(PrintQueueItem.printer_id.in_(printer_ids))
+            .where(PrintQueueItem.status.in_(["failed", "aborted"]))
+            .where(PrintQueueItem.gate_acknowledged == False)  # noqa: E712
+        )
+        for failed_item in result.scalars().all():
+            failed_item.gate_acknowledged = True
+            acknowledged += 1
+        await db.commit()
+
+    logger.info(
+        "Resume after failure for model %s: acknowledged %d failure(s) on %d printer(s)",
+        model,
+        acknowledged,
+        len(printer_ids),
+    )
+    return {"acknowledged": acknowledged, "printers": len(printer_ids)}
+
+
 @router.post("/{item_id}/cancel")
 async def cancel_queue_item(
     item_id: int,

+ 56 - 40
backend/app/services/print_scheduler.py

@@ -524,6 +524,11 @@ def _candidates_for(item: PrintQueueItem) -> list[_ModelCandidate]:
     ]
 
 
+# Why a job for any printer of a model waits when every free one's last print
+# failed. The queue page matches it to offer Resume for the model.
+MODEL_GATE_WAIT_REASON = "The last print failed on every free matching printer"
+
+
 def _collapse_waiting_reasons(per_model: list[tuple[str | None, str]]) -> str | None:
     """Fold one waiting reason per candidate into a single line for the item.
 
@@ -2215,6 +2220,8 @@ class PrintScheduler:
                     # printer for a file that can never legally run on it is
                     # worse than not waking at all (#2786).
                     wakeable_candidates: list[_ModelCandidate] = []
+                    # Printers passed over because their last print failed.
+                    gate_blocked: set[int] = set()
 
                     if not candidates:
                         # Every candidate file has been deleted or trashed out from
@@ -2258,21 +2265,33 @@ class PrintScheduler:
                             self._resolve_variant(item, candidate)
                             return await self._filament_short_on(db, item, pid)
 
-                        match_id, match_reason = await self._find_idle_printer_for_model(
-                            db,
-                            candidate.target_model,
-                            # Sensor-held printers are unavailable to the
-                            # matcher but stay out of busy_printers itself
-                            # (#1148) — see where `interlocked` is built.
-                            busy_printers | interlocked.keys(),
-                            effective_types,
-                            item.target_location,
-                            filament_overrides=filament_overrides,
-                            require_plate_clear=require_plate_clear,
-                            wakeable_ids=wakeable_printer_ids,
-                            printer_scope=item_scope,
-                            is_short=is_short,
-                        )
+                        while True:
+                            match_id, match_reason = await self._find_idle_printer_for_model(
+                                db,
+                                candidate.target_model,
+                                # Sensor-held printers are unavailable to the
+                                # matcher but stay out of busy_printers itself
+                                # (#1148) — see where `interlocked` is built.
+                                busy_printers | interlocked.keys() | gate_blocked,
+                                effective_types,
+                                item.target_location,
+                                filament_overrides=filament_overrides,
+                                require_plate_clear=require_plate_clear,
+                                wakeable_ids=wakeable_printer_ids,
+                                printer_scope=item_scope,
+                                is_short=is_short,
+                            )
+                            # "Only if the previous print succeeded" is about the
+                            # printer the job lands on: one whose last print failed
+                            # is passed over for another of the model.
+                            if (
+                                match_id
+                                and item.require_previous_success
+                                and not await self._check_previous_success(db, item, printer_id=match_id)
+                            ):
+                                gate_blocked.add(match_id)
+                                continue
+                            break
                         if match_id:
                             printer_id = match_id
                             chosen = candidate
@@ -2308,6 +2327,10 @@ class PrintScheduler:
                             skip_reasons["powered_on_printer"] = skip_reasons.get("powered_on_printer", 0) + 1
                             continue
 
+                    if printer_id is None and gate_blocked:
+                        # The matcher reports the printers passed over as busy;
+                        # what holds the job is their failed last print.
+                        per_model_reasons = [(item.target_model, MODEL_GATE_WAIT_REASON)]
                     waiting_reason = None if printer_id else _collapse_waiting_reasons(per_model_reasons)
 
                     # Fold the winning variant's file and settings onto the item
@@ -2319,12 +2342,19 @@ class PrintScheduler:
                     # Update waiting_reason if changed and send notification when first waiting
                     if item.waiting_reason != waiting_reason:
                         was_waiting = item.waiting_reason is not None
+                        previous_reason = item.waiting_reason
                         item.waiting_reason = waiting_reason
                         await db.commit()
 
                         # Send waiting notification only when transitioning to waiting state
                         # and the reason requires user action (not just "all printers busy")
-                        if waiting_reason and not was_waiting and not self._is_busy_only(waiting_reason):
+                        # Also when a job that only waited for a busy printer
+                        # now waits on something the user has to do.
+                        if (
+                            waiting_reason
+                            and (not was_waiting or self._is_busy_only(previous_reason or ""))
+                            and not self._is_busy_only(waiting_reason)
+                        ):
                             job_name = await self._get_job_name(db, item)
                             await notification_service.on_queue_job_waiting(
                                 job_name=job_name,
@@ -2348,27 +2378,6 @@ class PrintScheduler:
                             skip_reasons["library_row_in_use"] = skip_reasons.get("library_row_in_use", 0) + 1
                             continue
 
-                        # Check condition (previous print success) before assigning
-                        if item.require_previous_success:
-                            if not await self._check_previous_success(db, item):
-                                item.status = "skipped"
-                                item.error_message = "Previous print failed or was aborted"
-                                item.completed_at = datetime.now(timezone.utc)
-                                await db.commit()
-                                logger.info("Skipped queue item %s - previous print failed", item.id)
-
-                                # Send notification
-                                job_name = await self._get_job_name(db, item)
-                                printer = await self._get_printer(db, printer_id)
-                                await notification_service.on_queue_job_skipped(
-                                    job_name=job_name,
-                                    printer_id=printer_id,
-                                    printer_name=printer.name if printer else "Unknown",
-                                    reason="Previous print failed or was aborted",
-                                    db=db,
-                                )
-                                continue
-
                         # Assign printer and start - clear waiting reason
                         item.printer_id = printer_id
                         item.waiting_reason = None
@@ -7571,9 +7580,14 @@ class PrintScheduler:
         logger.warning("Printer %s did not connect within %ss after power on", printer_id, self._power_on_wait_time)
         return False
 
-    async def _check_previous_success(self, db: AsyncSession, item: PrintQueueItem) -> bool:
+    async def _check_previous_success(
+        self, db: AsyncSession, item: PrintQueueItem, printer_id: int | None = None
+    ) -> bool:
         """Check if the previous print on this printer succeeded.
 
+        ``printer_id`` names the printer to look at when the item has none yet
+        (a job for any printer of a model, checked against the one offered).
+
         A user-cancelled predecessor is treated as neutral — `cancelled` is a
         deliberate action, not a failure, so subsequent items should still
         dispatch (#1667). `skipped` is excluded from the lookback entirely:
@@ -7589,11 +7603,13 @@ class PrintScheduler:
         """
         result = await db.execute(
             select(PrintQueueItem)
-            .where(PrintQueueItem.printer_id == item.printer_id)
+            .where(PrintQueueItem.printer_id == (printer_id if printer_id is not None else item.printer_id))
             .where(PrintQueueItem.id != item.id)
             .where(PrintQueueItem.status.in_(["completed", "failed", "cancelled", "aborted"]))
             .where(PrintQueueItem.gate_acknowledged == False)  # noqa: E712
-            .order_by(PrintQueueItem.completed_at.desc())
+            # A row ended without a completion time must not count as the
+            # latest: PostgreSQL sorts NULL first in a descending order.
+            .order_by(PrintQueueItem.completed_at.desc().nulls_last(), PrintQueueItem.id.desc())
             .limit(1)
         )
         prev_item = result.scalar_one_or_none()

+ 234 - 0
backend/tests/unit/test_previous_success_per_printer.py

@@ -0,0 +1,234 @@
+"""'Only if the previous print succeeded' is about the printer a job lands on."""
+
+from __future__ import annotations
+
+from datetime import datetime, timezone
+from pathlib import Path
+from types import SimpleNamespace
+from unittest.mock import AsyncMock, patch
+
+import pytest
+from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
+
+import backend.app.models  # noqa: F401
+from backend.app.core.database import Base
+from backend.app.models.archive import PrintArchive
+from backend.app.models.print_queue import PrintQueueItem
+from backend.app.models.printer import Printer
+from backend.app.services.print_scheduler import MODEL_GATE_WAIT_REASON, PrintScheduler
+from backend.tests.unit.test_scheduler_busy_reasons_3018 import _scheduler
+
+pytestmark = pytest.mark.asyncio
+
+
+@pytest.fixture
+async def farm(tmp_path):
+    engine = create_async_engine("sqlite+aiosqlite:///:memory:", echo=False)
+    async with engine.begin() as conn:
+        await conn.run_sync(Base.metadata.create_all)
+    session_maker = async_sessionmaker(engine, expire_on_commit=False)
+    base_dir = tmp_path / "farm"
+    (base_dir / "archives").mkdir(parents=True)
+    (base_dir / "archives" / "job.3mf").write_bytes(b"archive payload")
+    async with session_maker() as db:
+        printers = [
+            Printer(name=name, serial_number=name, ip_address=f"10.0.0.{i}", access_code="code", model="X1C")
+            for i, name in enumerate(("A", "B"), start=1)
+        ]
+        db.add_all(printers)
+        await db.flush()
+        archive = PrintArchive(
+            printer_id=printers[0].id,
+            filename="job.3mf",
+            file_path="archives/job.3mf",
+            file_size=15,
+            print_time_seconds=120,
+            status="completed",
+            sliced_for_model="X1C",
+        )
+        db.add(archive)
+        await db.flush()
+        ids = SimpleNamespace(a=printers[0].id, b=printers[1].id, archive=archive.id)
+        await db.commit()
+    try:
+        yield SimpleNamespace(session_maker=session_maker, base_dir=base_dir, ids=ids)
+    finally:
+        await engine.dispose()
+
+
+async def _last_print(farm, printer_id: int, status: str) -> None:
+    async with farm.session_maker() as db:
+        db.add(
+            PrintQueueItem(
+                printer_id=printer_id,
+                archive_id=farm.ids.archive,
+                status=status,
+                position=0,
+                completed_at=datetime(2026, 10, 1, tzinfo=timezone.utc),
+            )
+        )
+        await db.commit()
+
+
+async def _model_job(farm) -> int:
+    async with farm.session_maker() as db:
+        item = PrintQueueItem(
+            target_model="X1C",
+            archive_id=farm.ids.archive,
+            status="pending",
+            position=1,
+            require_previous_success=True,
+        )
+        db.add(item)
+        await db.commit()
+        return item.id
+
+
+async def _run(farm, item_id: int, waiting=None) -> PrintQueueItem:
+    async def matcher(db, model, exclude, *args, **kwargs):
+        for printer_id in (farm.ids.a, farm.ids.b):
+            if printer_id not in exclude:
+                return printer_id, None
+        return None, "All matching printers are busy"
+
+    async with _scheduler(farm, idle=True) as scheduler:
+        with (
+            patch.object(scheduler, "_find_idle_printer_for_model", matcher),
+            patch(
+                "backend.app.services.notification_service.notification_service.on_queue_job_waiting",
+                waiting or AsyncMock(),
+            ),
+        ):
+            await scheduler.check_queue()
+    async with farm.session_maker() as db:
+        return await db.get(PrintQueueItem, item_id)
+
+
+async def test_a_printer_whose_last_print_failed_is_passed_over(farm):
+    await _last_print(farm, farm.ids.a, "failed")
+    item_id = await _model_job(farm)
+
+    item = await _run(farm, item_id)
+
+    assert item.status != "skipped"
+    assert item.printer_id == farm.ids.b
+
+
+async def test_the_job_waits_when_every_printer_failed_last(farm):
+    await _last_print(farm, farm.ids.a, "failed")
+    await _last_print(farm, farm.ids.b, "aborted")
+    item_id = await _model_job(farm)
+
+    item = await _run(farm, item_id)
+
+    assert item.status == "pending"
+    assert item.printer_id is None
+    assert item.waiting_reason == MODEL_GATE_WAIT_REASON
+
+
+async def test_the_check_reads_the_printer_it_is_given(db_session, printer_factory):
+    a, b = await printer_factory(), await printer_factory()
+    db_session.add(PrintQueueItem(printer_id=a.id, status="failed", completed_at=datetime.now(timezone.utc)))
+    job = PrintQueueItem(target_model="X1C", status="pending", require_previous_success=True)
+    db_session.add(job)
+    await db_session.commit()
+    scheduler = PrintScheduler()
+
+    assert await scheduler._check_previous_success(db_session, job, printer_id=a.id) is False
+    assert await scheduler._check_previous_success(db_session, job, printer_id=b.id) is True
+
+
+async def test_a_row_ended_without_a_time_is_not_the_latest(db_session, printer_factory):
+    printer = await printer_factory()
+    db_session.add(PrintQueueItem(printer_id=printer.id, status="failed", completed_at=datetime.now(timezone.utc)))
+    await db_session.flush()
+    db_session.add(PrintQueueItem(printer_id=printer.id, status="cancelled", completed_at=None))
+    job = PrintQueueItem(printer_id=printer.id, status="pending", require_previous_success=True)
+    db_session.add(job)
+    await db_session.commit()
+
+    assert await PrintScheduler()._check_previous_success(db_session, job) is False
+
+
+async def test_cancelling_an_order_stamps_when_its_jobs_ended(
+    async_client, db_session, printer_factory, archive_factory
+):
+    from backend.tests.integration.test_print_batch_orders import _create_order, _queue_item
+
+    printer = await printer_factory()
+    archive = await archive_factory(printer.id)
+    order = await _create_order(async_client, archive.id, [{"plate_id": 1, "quantity_target": 2}])
+    item = await _queue_item(async_client, printer.id, archive.id, order["id"], plate_id=1)
+
+    response = await async_client.delete(f"/api/v1/queue/batches/{order['id']}")
+
+    assert response.status_code == 200, response.text
+    db_session.expire_all()
+    row = await db_session.get(PrintQueueItem, item["id"])
+    assert row.status == "cancelled"
+    assert row.completed_at is not None
+
+
+async def test_a_job_that_waited_for_a_busy_printer_says_when_it_now_needs_you(farm):
+    await _last_print(farm, farm.ids.a, "failed")
+    await _last_print(farm, farm.ids.b, "failed")
+    item_id = await _model_job(farm)
+    async with farm.session_maker() as db:
+        (await db.get(PrintQueueItem, item_id)).waiting_reason = "Busy: A, B"
+        await db.commit()
+    waiting = AsyncMock()
+
+    await _run(farm, item_id, waiting)
+
+    waiting.assert_awaited_once()
+    assert waiting.await_args.kwargs["waiting_reason"] == MODEL_GATE_WAIT_REASON
+
+
+async def test_resuming_a_model_clears_the_gate_on_its_printers(async_client, db_session, printer_factory):
+    x1c_a = await printer_factory(model="X1C")
+    x1c_b = await printer_factory(model="X1C")
+    p1s = await printer_factory(model="P1S")
+    ids = {"a": x1c_a.id, "b": x1c_b.id, "p": p1s.id}
+    for printer_id in ids.values():
+        db_session.add(PrintQueueItem(printer_id=printer_id, status="failed", completed_at=datetime.now(timezone.utc)))
+    await db_session.commit()
+
+    response = await async_client.post("/api/v1/queue/model/X1C/resume")
+
+    assert response.status_code == 200, response.text
+    assert response.json() == {"acknowledged": 2, "printers": 2}
+    db_session.expire_all()
+    from sqlalchemy import select
+
+    rows = (await db_session.execute(select(PrintQueueItem))).scalars().all()
+    acknowledged = {row.printer_id: row.gate_acknowledged for row in rows}
+    assert acknowledged == {ids["a"]: True, ids["b"]: True, ids["p"]: False}
+
+
+async def test_resuming_a_model_keeps_to_the_callers_printers(async_client, db_session, printer_factory):
+    from sqlalchemy import select
+
+    from backend.tests.integration.test_printer_scope_1727 import _admin_token, _auth, _group, _user
+
+    ours, theirs = await printer_factory(model="X1C"), await printer_factory(model="X1C")
+    ours_id, theirs_id = ours.id, theirs.id
+    for printer_id in (ours_id, theirs_id):
+        db_session.add(PrintQueueItem(printer_id=printer_id, status="failed", completed_at=datetime.now(timezone.utc)))
+    await db_session.commit()
+    token = await _admin_token(async_client)
+    perms = await _group(async_client, token, "perms", permissions=["printers:read", "queue:read", "queue:update_all"])
+    team = await _group(async_client, token, "team", printer_ids=[ours_id])
+    member = _auth((await _user(async_client, token, "member", [perms, team]))[0])
+
+    response = await async_client.post("/api/v1/queue/model/X1C/resume", headers=member)
+
+    assert response.json() == {"acknowledged": 1, "printers": 1}
+    db_session.expire_all()
+    rows = (await db_session.execute(select(PrintQueueItem))).scalars().all()
+    assert {row.printer_id: row.gate_acknowledged for row in rows} == {ours_id: True, theirs_id: False}
+
+
+def test_the_queue_page_knows_the_wait_reason():
+    """The queue page offers Resume for jobs waiting with exactly this reason."""
+    page = Path(__file__).resolve().parents[3] / "frontend" / "src" / "pages" / "QueuePage.tsx"
+    assert f"'{MODEL_GATE_WAIT_REASON}'" in page.read_text()

+ 33 - 0
frontend/src/__tests__/pages/QueuePage.test.tsx

@@ -1944,3 +1944,36 @@ describe('QueuePage', () => {
     });
   });
 });
+
+describe('QueuePage: jobs held by failed prints on every printer of their model', () => {
+  it('offers Resume for the model', async () => {
+    let resumed: string | null = null;
+    server.use(
+      http.get('/api/v1/queue/', () =>
+        HttpResponse.json([
+          {
+            ...mockQueueItems[0],
+            printer_id: null,
+            printer_name: null,
+            target_model: 'X1C',
+            require_previous_success: true,
+            waiting_reason: 'The last print failed on every free matching printer',
+          },
+        ]),
+      ),
+      http.post('/api/v1/queue/model/:model/resume', ({ params }) => {
+        resumed = params.model as string;
+        return HttpResponse.json({ acknowledged: 2, printers: 2 });
+      }),
+    );
+    const user = userEvent.setup();
+    render(<QueuePage />);
+
+    await user.click(await screen.findByRole('button', { name: /resume after failure/i }));
+    // The confirm dialog's own button carries the same label.
+    await waitFor(() => expect(screen.getAllByRole('button', { name: /resume after failure/i })).toHaveLength(2));
+    await user.click(screen.getAllByRole('button', { name: /resume after failure/i })[1]);
+
+    await waitFor(() => expect(resumed).toBe('X1C'));
+  });
+});

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

@@ -6549,6 +6549,13 @@ export const api = {
    * restores skipped items whose error_message matches the gate string
    * back to pending. Returns counts so the UI can render a precise toast.
    */
+  // A job for any printer of a model waits when the last print failed on every
+  // free one; this clears the gate on the model's printers.
+  resumeModelQueueAfterFailure: (model: string) =>
+    request<{ acknowledged: number; printers: number }>(
+      `/queue/model/${encodeURIComponent(model)}/resume`,
+      { method: 'POST' },
+    ),
   resumeQueueAfterFailure: (printerId: number) =>
     request<{ acknowledged: number; restored: number }>(
       `/queue/printer/${printerId}/resume`,

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

@@ -1669,8 +1669,14 @@ export default {
       batchUngrouped: '{{count}} Eintrag/Einträge aus Stapel gelöst',
       batchUngroupFailed: 'Stapel konnte nicht aufgelöst werden',
       resumedAfterFailure: 'Warteschlange fortgesetzt — {{restored}} Auftrag/Aufträge wieder eingereiht',
+      resumedModelAfterFailure: 'Sperre bei {{count}} fehlgeschlagenen Druck(en) aufgehoben',
       resumeAfterFailureFailed: 'Warteschlange konnte nicht fortgesetzt werden',
     },
+    resumeModelAfterFailure: {
+      banner: 'Aufträge für beliebige {{model}} warten ({{count}}): Der letzte Druck ist auf jedem freien {{model}} fehlgeschlagen',
+      bannerHint: 'Behebe das Problem an den Druckern und setze dann fort, um die Sperre aufzuheben. Die Aufträge starten dann auf dem nächsten freien {{model}}.',
+      confirmMessage: 'Die Sperre nach fehlgeschlagenem Druck auf allen {{model}}-Druckern aufheben, die du nutzen darfst. Stelle sicher, dass die Drucker bereit sind.',
+    },
     resumeAfterFailure: {
       banner: '{{printer}} ist durch einen vorherigen Druckfehler blockiert — {{count}} Auftrag/Aufträge übersprungen',
       bannerHint: 'Behebe das Druckerproblem und setze die Warteschlange dann fort, um die übersprungenen Aufträge wiederherzustellen und die Sperre aufzuheben.',

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

@@ -1687,8 +1687,14 @@ export default {
       batchUngrouped: 'Ungrouped {{count}} item(s)',
       batchUngroupFailed: 'Failed to ungroup batch',
       resumedAfterFailure: 'Resumed queue — {{restored}} job(s) restored to pending',
+      resumedModelAfterFailure: 'Gate cleared on {{count}} failed print(s)',
       resumeAfterFailureFailed: 'Failed to resume queue',
     },
+    resumeModelAfterFailure: {
+      banner: 'Jobs for any {{model}} wait ({{count}}): the last print failed on every free {{model}}',
+      bannerHint: 'Fix the printers, then resume to clear the gate. The jobs then start on the next free {{model}}.',
+      confirmMessage: 'Clear the previous-print gate on every {{model}} printer you can use. Make sure the printers are ready before continuing.',
+    },
     resumeAfterFailure: {
       banner: '{{printer}} is blocked by a previous-print failure — {{count}} job(s) skipped',
       bannerHint: 'Fix the printer issue, then resume to restore the skipped jobs and clear the gate.',

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

@@ -1669,8 +1669,14 @@ export default {
       batchUngrouped: '{{count}} elemento(s) desagrupado(s)',
       batchUngroupFailed: 'Error al desagrupar el lote',
       resumedAfterFailure: 'Cola reanudada — {{restored}} trabajo(s) restaurado(s) a pendientes',
+      resumedModelAfterFailure: 'Bloqueo eliminado en {{count}} impresión(es) fallida(s)',
       resumeAfterFailureFailed: 'Error al reanudar la cola',
     },
+    resumeModelAfterFailure: {
+      banner: 'Hay trabajos para cualquier {{model}} esperando ({{count}}): la última impresión falló en cada {{model}} libre',
+      bannerHint: 'Soluciona el problema de las impresoras y reanuda para quitar el bloqueo. Los trabajos empezarán en la próxima {{model}} libre.',
+      confirmMessage: 'Quitar el bloqueo por impresión anterior fallida en todas las impresoras {{model}} que puedes usar. Asegúrate de que las impresoras estén listas antes de continuar.',
+    },
     resumeAfterFailure: {
       banner: '{{printer}} está bloqueado por un fallo de impresión previo — {{count}} trabajo(s) omitido(s)',
       bannerHint: 'Soluciona el problema de la impresora y reanuda para restaurar los trabajos omitidos y limpiar el bloqueo.',

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

@@ -1669,8 +1669,14 @@ export default {
       batchUngrouped: '{{count}} élément(s) dégroupé(s)',
       batchUngroupFailed: 'Échec du dégroupement du lot',
       resumedAfterFailure: 'File reprise — {{restored}} tâche(s) restaurée(s) en attente',
+      resumedModelAfterFailure: 'Blocage levé sur {{count}} impression(s) échouée(s)',
       resumeAfterFailureFailed: 'Échec de la reprise de la file',
     },
+    resumeModelAfterFailure: {
+      banner: "Des tâches pour n'importe quelle {{model}} attendent ({{count}}) : la dernière impression a échoué sur chaque {{model}} libre",
+      bannerHint: 'Corrigez les imprimantes, puis reprenez pour lever le blocage. Les tâches démarreront sur la prochaine {{model}} libre.',
+      confirmMessage: "Lever le blocage lié à l'impression précédente sur toutes les imprimantes {{model}} que vous pouvez utiliser. Vérifiez que les imprimantes sont prêtes avant de continuer.",
+    },
     resumeAfterFailure: {
       banner: '{{printer}} est bloquée par un échec d\'impression précédent — {{count}} tâche(s) ignorée(s)',
       bannerHint: 'Résolvez le problème de l\'imprimante, puis reprenez la file pour restaurer les tâches ignorées et lever le blocage.',

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

@@ -1669,8 +1669,14 @@ export default {
       batchUngrouped: '{{count}} elemento/i separato/i',
       batchUngroupFailed: 'Separazione del lotto non riuscita',
       resumedAfterFailure: 'Coda ripresa — {{restored}} processo/i ripristinato/i come in attesa',
+      resumedModelAfterFailure: 'Blocco rimosso su {{count}} stampa/e non riuscita/e',
       resumeAfterFailureFailed: 'Ripresa della coda non riuscita',
     },
+    resumeModelAfterFailure: {
+      banner: "Lavori per qualsiasi {{model}} in attesa ({{count}}): l'ultima stampa non è riuscita su ogni {{model}} libera",
+      bannerHint: 'Risolvi il problema delle stampanti, poi riprendi per rimuovere il blocco. I lavori partiranno sulla prossima {{model}} libera.',
+      confirmMessage: 'Rimuovi il blocco della stampa precedente su tutte le stampanti {{model}} che puoi usare. Assicurati che le stampanti siano pronte prima di continuare.',
+    },
     resumeAfterFailure: {
       banner: '{{printer}} è bloccata da un errore di stampa precedente — {{count}} processo/i saltato/i',
       bannerHint: 'Risolvi il problema della stampante, poi riprendi per ripristinare i processi saltati e rimuovere il blocco.',

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

@@ -1668,8 +1668,14 @@ export default {
       batchUngrouped: '{{count}}件のグループを解除しました',
       batchUngroupFailed: 'バッチのグループ解除に失敗しました',
       resumedAfterFailure: 'キューを再開しました — {{restored}}件のジョブを保留状態に戻しました',
+      resumedModelAfterFailure: '{{count}} 件の失敗した印刷のブロックを解除しました',
       resumeAfterFailureFailed: 'キューの再開に失敗しました',
     },
+    resumeModelAfterFailure: {
+      banner: '任意の {{model}} 向けのジョブが待機中です ({{count}}): 空いているすべての {{model}} で最後の印刷が失敗しました',
+      bannerHint: 'プリンターの問題を解決してから再開し、ブロックを解除してください。ジョブは次に空いた {{model}} で開始されます。',
+      confirmMessage: '使用できるすべての {{model}} プリンターで前回の印刷によるブロックを解除します。続行する前にプリンターの準備ができていることを確認してください。',
+    },
     resumeAfterFailure: {
       banner: '{{printer}} は前回の印刷失敗によりブロックされています — {{count}}件のジョブをスキップしました',
       bannerHint: 'プリンターの問題を解決してから再開すると、スキップされたジョブを復元してブロックを解除できます。',

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

@@ -1590,8 +1590,14 @@ export default {
       batchUngrouped: '{{count}}개 항목의 그룹을 해제했습니다',
       batchUngroupFailed: '배치 그룹 해제에 실패했습니다',
       resumedAfterFailure: '큐를 재개했습니다 — {{restored}}개 작업을 대기 상태로 복원했습니다',
+      resumedModelAfterFailure: '실패한 출력 {{count}}건의 차단을 해제했습니다',
       resumeAfterFailureFailed: '큐 재개에 실패했습니다',
     },
+    resumeModelAfterFailure: {
+      banner: '모든 {{model}}용 작업이 대기 중입니다 ({{count}}): 비어 있는 모든 {{model}}에서 마지막 출력이 실패했습니다',
+      bannerHint: '프린터 문제를 해결한 다음 재개하여 차단을 해제하세요. 작업은 다음으로 비는 {{model}}에서 시작됩니다.',
+      confirmMessage: '사용할 수 있는 모든 {{model}} 프린터에서 이전 출력 차단을 해제합니다. 계속하기 전에 프린터가 준비되었는지 확인하세요.',
+    },
     resumeAfterFailure: {
       banner: '{{printer}}이(가) 이전 인쇄 실패로 인해 차단되었습니다 — {{count}}개 작업이 건너뛰어졌습니다',
       bannerHint: '프린터 문제를 해결한 후 재개하여 건너뛴 작업을 복원하고 차단을 해제하세요.',

+ 6 - 0
frontend/src/i18n/locales/nl.ts

@@ -1685,8 +1685,14 @@ export default {
       batchUngrouped: 'Groepering van {{count}} item(s) opgeheven',
       batchUngroupFailed: 'Batchgroepering opheffen mislukt',
       resumedAfterFailure: 'Wachtrij hervat — {{restored}} taak/taken teruggezet naar in afwachting',
+      resumedModelAfterFailure: 'Blokkade opgeheven bij {{count}} mislukte print(s)',
       resumeAfterFailureFailed: 'Wachtrij hervatten mislukt',
     },
+    resumeModelAfterFailure: {
+      banner: 'Opdrachten voor elke {{model}} wachten ({{count}}): de laatste print is op elke vrije {{model}} mislukt',
+      bannerHint: 'Los het probleem met de printers op en hervat om de blokkade op te heffen. De opdrachten starten dan op de volgende vrije {{model}}.',
+      confirmMessage: 'De blokkade na een mislukte print opheffen op alle {{model}}-printers die je mag gebruiken. Zorg dat de printers klaar zijn voordat je doorgaat.',
+    },
     resumeAfterFailure: {
       banner: '{{printer}} is geblokkeerd door een eerdere afdrukfout — {{count}} taak/taken overgeslagen',
       bannerHint: 'Los het printerprobleem op en hervat daarna om de overgeslagen taken terug te zetten en de blokkering op te heffen.',

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

@@ -1669,8 +1669,14 @@ export default {
       batchUngrouped: '{{count}} item(ns) desagrupado(s)',
       batchUngroupFailed: 'Falha ao desagrupar o lote',
       resumedAfterFailure: 'Fila retomada — {{restored}} trabalho(s) restaurado(s) para pendente',
+      resumedModelAfterFailure: 'Bloqueio removido em {{count}} impressão(ões) com falha',
       resumeAfterFailureFailed: 'Falha ao retomar a fila',
     },
+    resumeModelAfterFailure: {
+      banner: 'Trabalhos para qualquer {{model}} aguardando ({{count}}): a última impressão falhou em cada {{model}} livre',
+      bannerHint: 'Corrija as impressoras e retome para remover o bloqueio. Os trabalhos começarão na próxima {{model}} livre.',
+      confirmMessage: 'Remover o bloqueio de impressão anterior em todas as impressoras {{model}} que você pode usar. Verifique se as impressoras estão prontas antes de continuar.',
+    },
     resumeAfterFailure: {
       banner: '{{printer}} está bloqueada por uma falha de impressão anterior — {{count}} trabalho(s) ignorado(s)',
       bannerHint: 'Resolva o problema da impressora e retome para restaurar os trabalhos ignorados e remover o bloqueio.',

+ 6 - 0
frontend/src/i18n/locales/ru.ts

@@ -1604,8 +1604,14 @@ export default {
       batchUngrouped: "Разгруппировано заданий: {{count}}",
       batchUngroupFailed: "Не удалось разгруппировать партию",
       resumedAfterFailure: "Очередь возобновлена — восстановлено заданий: {{restored}}",
+      resumedModelAfterFailure: 'Блокировка снята для {{count}} неудачных печатей',
       resumeAfterFailureFailed: "Не удалось возобновить очередь",
     },
+    resumeModelAfterFailure: {
+      banner: 'Задания для любого {{model}} ждут ({{count}}): последняя печать не удалась на каждом свободном {{model}}',
+      bannerHint: 'Устраните проблему на принтерах, затем продолжите, чтобы снять блокировку. Задания начнутся на следующем свободном {{model}}.',
+      confirmMessage: 'Снять блокировку после неудачной печати на всех доступных вам принтерах {{model}}. Убедитесь, что принтеры готовы, прежде чем продолжить.',
+    },
     resumeAfterFailure: {
       banner: "Очередь принтера {{printer}} заблокирована из-за ошибки предыдущей печати — пропущено заданий: {{count}}",
       bannerHint: "Устраните проблему с принтером, затем возобновите очередь, чтобы вернуть пропущенные задания и снять блокировку.",

+ 6 - 0
frontend/src/i18n/locales/sv.ts

@@ -1685,8 +1685,14 @@ export default {
       batchUngrouped: 'Avgrupperade {{count}} post(er)',
       batchUngroupFailed: 'Kunde inte avgruppera batch',
       resumedAfterFailure: 'Återupptog kö — {{restored}} jobb återställda till väntande',
+      resumedModelAfterFailure: 'Spärren hävd för {{count}} misslyckade utskrift(er)',
       resumeAfterFailureFailed: 'Kunde inte återuppta kö',
     },
+    resumeModelAfterFailure: {
+      banner: 'Jobb för valfri {{model}} väntar ({{count}}): den senaste utskriften misslyckades på varje ledig {{model}}',
+      bannerHint: 'Åtgärda skrivarna och återuppta sedan för att häva spärren. Jobben startar då på nästa lediga {{model}}.',
+      confirmMessage: 'Häv spärren efter misslyckad utskrift på alla {{model}}-skrivare du får använda. Se till att skrivarna är redo innan du fortsätter.',
+    },
     resumeAfterFailure: {
       banner: '{{printer}} är blockerad av ett tidigare utskriftsfel — {{count}} jobb hoppades över',
       bannerHint: 'Åtgärda skrivarproblemet, sedan återuppta för att återställa de hoppade jobben och rensa blockeringen.',

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

@@ -1669,8 +1669,14 @@ export default {
       batchUngrouped: '{{count}} öğe gruptan çıkarıldı',
       batchUngroupFailed: 'Yığını gruptan çıkarma başarısız',
       resumedAfterFailure: 'Kuyruk devam ettirildi — {{restored}} iş bekleyene geri alındı',
+      resumedModelAfterFailure: '{{count}} başarısız baskıda engel kaldırıldı',
       resumeAfterFailureFailed: 'Kuyruk devam ettirilemedi',
     },
+    resumeModelAfterFailure: {
+      banner: 'Herhangi bir {{model}} için işler bekliyor ({{count}}): son baskı her boş {{model}} üzerinde başarısız oldu',
+      bannerHint: 'Yazıcılardaki sorunu giderin, ardından engeli kaldırmak için devam edin. İşler bir sonraki boş {{model}} üzerinde başlar.',
+      confirmMessage: 'Kullanabileceğiniz tüm {{model}} yazıcılarında önceki baskı engelini kaldırın. Devam etmeden önce yazıcıların hazır olduğundan emin olun.',
+    },
     resumeAfterFailure: {
       banner: '{{printer}} önceki bir baskı hatası nedeniyle engellendi — {{count}} iş atlandı',
       bannerHint: 'Yazıcı sorununu çözün, ardından atlanan işleri geri yüklemek ve engellemeyi kaldırmak için devam ettirin.',

+ 6 - 0
frontend/src/i18n/locales/uk.ts

@@ -1684,8 +1684,14 @@ export default {
       batchUngrouped: "Розгруповано елементів: {{count}}",
       batchUngroupFailed: "Не вдалося розгрупувати партію",
       resumedAfterFailure: "Відновлено чергу — {{restored}} завдання відновлено до стану очікування",
+      resumedModelAfterFailure: 'Блокування знято для {{count}} невдалих друків',
       resumeAfterFailureFailed: "Не вдалося відновити чергу",
     },
+    resumeModelAfterFailure: {
+      banner: 'Завдання для будь-якого {{model}} чекають ({{count}}): останній друк не вдався на кожному вільному {{model}}',
+      bannerHint: 'Усуньте проблему на принтерах, потім продовжте, щоб зняти блокування. Завдання почнуться на наступному вільному {{model}}.',
+      confirmMessage: 'Зняти блокування після невдалого друку на всіх доступних вам принтерах {{model}}. Переконайтеся, що принтери готові, перш ніж продовжити.',
+    },
     resumeAfterFailure: {
       banner: "{{printer}} заблоковано через помилку попереднього друку — завдання {{count}} пропущено",
       bannerHint: "Усуньте проблему з принтером, а потім відновіть чергу, щоб повернути пропущені завдання й зняти блокування.",

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

@@ -1669,8 +1669,14 @@ export default {
       batchUngrouped: '已取消分组 {{count}} 项',
       batchUngroupFailed: '取消批次分组失败',
       resumedAfterFailure: '已恢复队列 — {{restored}} 个任务已恢复为待处理',
+      resumedModelAfterFailure: '已解除 {{count}} 次失败打印的阻止',
       resumeAfterFailureFailed: '恢复队列失败',
     },
+    resumeModelAfterFailure: {
+      banner: '任意 {{model}} 的任务正在等待 ({{count}}):每台空闲 {{model}} 的上一次打印均失败',
+      bannerHint: '修复打印机后继续以解除阻止。任务将在下一台空闲的 {{model}} 上开始。',
+      confirmMessage: '解除你可使用的所有 {{model}} 打印机上的前次打印阻止。继续之前请确认打印机已就绪。',
+    },
     resumeAfterFailure: {
       banner: '{{printer}} 因之前的打印失败而被阻塞 — 已跳过 {{count}} 个任务',
       bannerHint: '解决打印机问题后恢复队列,以还原跳过的任务并清除阻塞。',

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

@@ -1669,8 +1669,14 @@ export default {
       batchUngrouped: '已取消分組 {{count}} 項',
       batchUngroupFailed: '取消批次分組失敗',
       resumedAfterFailure: '已恢復佇列 — {{restored}} 個工作已恢復為待處理',
+      resumedModelAfterFailure: '已解除 {{count}} 次失敗列印的阻擋',
       resumeAfterFailureFailed: '恢復佇列失敗',
     },
+    resumeModelAfterFailure: {
+      banner: '任意 {{model}} 的工作正在等待 ({{count}}):每台閒置 {{model}} 的上一次列印皆失敗',
+      bannerHint: '修復印表機後繼續以解除阻擋。工作將在下一台閒置的 {{model}} 上開始。',
+      confirmMessage: '解除你可使用的所有 {{model}} 印表機上的前次列印阻擋。繼續之前請確認印表機已就緒。',
+    },
     resumeAfterFailure: {
       banner: '{{printer}} 因先前的列印失敗而被阻擋 — 已跳過 {{count}} 個工作',
       bannerHint: '解決印表機問題後恢復佇列,以還原跳過的工作並清除阻擋。',

+ 73 - 0
frontend/src/pages/QueuePage.tsx

@@ -87,6 +87,9 @@ import { BatchOrdersView } from '../components/BatchOrdersView';
 import { buildLoadedFilaments, type LoadedFilament } from '../hooks/useFilamentMapping';
 import { hasGcodeSnippets, useUiSettings } from '../hooks/useUiSettings';
 
+// The scheduler's MODEL_GATE_WAIT_REASON (print_scheduler.py).
+const MODEL_GATE_WAIT_REASON = 'The last print failed on every free matching printer';
+
 type QueueFilamentDisplay = {
   slotId: number;
   type: string;
@@ -1737,6 +1740,8 @@ export function QueuePage() {
     printerName: string;
     skippedCount: number;
   } | null>(null);
+  // Same for a model whose waiting jobs are held by failed last prints.
+  const [resumeModelConfirm, setResumeModelConfirm] = useState<string | null>(null);
   const [historySortBy, setHistorySortBy] = useState<'date' | 'name' | 'printer'>(() => {
     const saved = localStorage.getItem('queue.historySortBy');
     return (saved as 'date' | 'name' | 'printer') || 'date';
@@ -2038,6 +2043,16 @@ export function QueuePage() {
     onError: () => showToast(t('queue.toast.resumeAfterFailureFailed'), 'error'),
   });
 
+  const resumeModelMutation = useMutation({
+    mutationFn: (model: string) => api.resumeModelQueueAfterFailure(model),
+    onSuccess: (result) => {
+      queryClient.invalidateQueries({ queryKey: ['queue'] });
+      setResumeModelConfirm(null);
+      showToast(t('queue.toast.resumedModelAfterFailure', { count: result.acknowledged }));
+    },
+    onError: () => showToast(t('queue.toast.resumeAfterFailureFailed'), 'error'),
+  });
+
   const createBatchMutation = useMutation({
     mutationFn: (data: { name: string; item_ids: number[] }) => api.createBatch(data),
     onSuccess: (batch) => {
@@ -2622,6 +2637,25 @@ export function QueuePage() {
       .sort((a, b) => a.printerName.localeCompare(b.printerName));
   }, [queue]);
 
+  // Models whose "any printer" jobs wait because the last print failed on
+  // every free printer of the model (the scheduler's MODEL_GATE_WAIT_REASON).
+  const gateBlockedModels = useMemo<Array<{ model: string; waitingCount: number }>>(() => {
+    const counts = new Map<string, number>();
+    queue?.forEach((item) => {
+      if (
+        item.status === 'pending' &&
+        !item.printer_id &&
+        item.target_model &&
+        item.waiting_reason === MODEL_GATE_WAIT_REASON
+      ) {
+        counts.set(item.target_model, (counts.get(item.target_model) ?? 0) + 1);
+      }
+    });
+    return Array.from(counts.entries())
+      .map(([model, waitingCount]) => ({ model, waitingCount }))
+      .sort((a, b) => a.model.localeCompare(b.model));
+  }, [queue]);
+
   const aggregateForRows = (rows: QueueRow[]) => {
     let count = 0;
     let time = 0;
@@ -2732,6 +2766,34 @@ export function QueuePage() {
         </div>
       )}
 
+      {activeTab === 'queue' && gateBlockedModels.length > 0 && hasPermission('queue:update_all' as Permission) && (
+        <div className="mb-4 space-y-2">
+          {gateBlockedModels.map(({ model, waitingCount }) => (
+            <div
+              key={model}
+              className="flex items-center gap-3 px-4 py-3 bg-orange-50 dark:bg-orange-500/10 border border-orange-300 dark:border-orange-500/30 rounded-lg"
+            >
+              <AlertCircle className="w-5 h-5 text-orange-600 dark:text-orange-400 flex-shrink-0" />
+              <div className="flex-1 min-w-0">
+                <div className="text-sm text-orange-800 dark:text-orange-200">
+                  {t('queue.resumeModelAfterFailure.banner', { model, count: waitingCount })}
+                </div>
+                <div className="text-xs text-orange-800/80 dark:text-orange-200/70 mt-0.5">
+                  {t('queue.resumeModelAfterFailure.bannerHint', { model })}
+                </div>
+              </div>
+              <button
+                onClick={() => setResumeModelConfirm(model)}
+                className="flex items-center gap-1.5 px-3 py-1.5 bg-orange-100 dark:bg-orange-500/20 hover:bg-orange-200 dark:hover:bg-orange-500/30 text-orange-900 dark:text-orange-100 text-sm rounded-md border border-orange-300 dark:border-orange-500/40 transition-colors flex-shrink-0"
+              >
+                <PlayCircle className="w-4 h-4" />
+                {t('queue.resumeAfterFailure.button')}
+              </button>
+            </div>
+          ))}
+        </div>
+      )}
+
       {/* Filters — about the print queue items (printer / status / location).
           The Pipelines tab has its own pipeline + status filters inside the
           dashboard, so this row is hidden when that tab is active. */}
@@ -3260,6 +3322,17 @@ export function QueuePage() {
         />
       )}
 
+      {resumeModelConfirm && (
+        <ConfirmModal
+          title={t('queue.resumeAfterFailure.confirmTitle')}
+          message={t('queue.resumeModelAfterFailure.confirmMessage', { model: resumeModelConfirm })}
+          confirmText={t('queue.resumeAfterFailure.button')}
+          variant="warning"
+          onConfirm={() => resumeModelMutation.mutate(resumeModelConfirm)}
+          onCancel={() => setResumeModelConfirm(null)}
+        />
+      )}
+
       {/* Clear History Confirm Modal */}
       {showClearHistoryConfirm && (
         <ConfirmModal

File diff suppressed because it is too large
+ 0 - 0
static/assets/AdditionalSection-ynSkmTcf.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/AmsUnitCard-Csm4KTS_.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/ArchivesPage-Cvi0K-rD.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/AreaChart-Db04IqSY.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/AssignToAmsModal-tml7LZuj.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/AuthContext-DS-dpwJp.js


+ 1 - 1
static/assets/Button-CPK1NMqY.js → static/assets/Button-BOrMgOyo.js

@@ -1 +1 @@
-import{C as e}from"./client-BBL32ZR9.js";var t=e();function n({variant:e=`primary`,size:n=`md`,className:r=``,children:i,...a}){return(0,t.jsx)(`button`,{className:`inline-flex items-center justify-center font-medium rounded-lg transition-colors focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-offset-bambu-dark disabled:opacity-50 disabled:cursor-not-allowed ${{primary:`bg-bambu-green hover:bg-bambu-green-light text-white focus:ring-bambu-green`,secondary:`bg-bambu-dark-tertiary hover:bg-bambu-gray-dark text-white focus:ring-bambu-gray`,danger:`bg-red-600 hover:bg-red-700 text-white focus:ring-red-500`,ghost:`bg-transparent hover:bg-bambu-dark-tertiary text-bambu-gray-light hover:text-white`}[e]} ${{sm:`px-3 py-1.5 text-sm gap-1.5 min-h-[44px] md:min-h-0`,md:`px-4 py-2 text-sm gap-2 min-h-[44px] md:min-h-0`,lg:`px-6 py-3 text-base gap-2 min-h-[48px] md:min-h-0`}[n]} ${r}`,...a,children:i})}export{n as t};
+import{C as e}from"./client-C5b1pVUf.js";var t=e();function n({variant:e=`primary`,size:n=`md`,className:r=``,children:i,...a}){return(0,t.jsx)(`button`,{className:`inline-flex items-center justify-center font-medium rounded-lg transition-colors focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-offset-bambu-dark disabled:opacity-50 disabled:cursor-not-allowed ${{primary:`bg-bambu-green hover:bg-bambu-green-light text-white focus:ring-bambu-green`,secondary:`bg-bambu-dark-tertiary hover:bg-bambu-gray-dark text-white focus:ring-bambu-gray`,danger:`bg-red-600 hover:bg-red-700 text-white focus:ring-red-500`,ghost:`bg-transparent hover:bg-bambu-dark-tertiary text-bambu-gray-light hover:text-white`}[e]} ${{sm:`px-3 py-1.5 text-sm gap-1.5 min-h-[44px] md:min-h-0`,md:`px-4 py-2 text-sm gap-2 min-h-[44px] md:min-h-0`,lg:`px-6 py-3 text-base gap-2 min-h-[48px] md:min-h-0`}[n]} ${r}`,...a,children:i})}export{n as t};

File diff suppressed because it is too large
+ 0 - 0
static/assets/CamWallPage-CWNsOj79.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/CameraDiagnoseModal-DBbpU5C3.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/CameraPage-DsOiZm5_.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/CameraWall-C4LXcZxv.js


+ 1 - 1
static/assets/Card-DnEKZXBw.js → static/assets/Card-C6dM19X9.js

@@ -1 +1 @@
-import{i as e}from"./chunk-aKtaBQYM.js";import{C as t,D as n}from"./client-BBL32ZR9.js";var r=e(n(),1),i=t(),a=(0,r.createContext)(`normal`);function o({density:e,children:t}){return(0,i.jsx)(a.Provider,{value:e,children:t})}function s({children:e,className:t=``,onClick:n,onContextMenu:r,...a}){return(0,i.jsx)(`div`,{className:`bg-bambu-dark-secondary rounded-xl border border-bambu-dark-tertiary card-shadow ${n?`cursor-pointer`:``} ${t}`,onClick:n,onContextMenu:r,...a,children:e})}function c({children:e,className:t=``,dense:n}){let o=(0,r.useContext)(a)===`dense`;return(0,i.jsx)(`div`,{className:`${n??o?`px-4 py-2.5`:`px-6 py-4`} border-b border-bambu-dark-tertiary ${t}`,children:e})}function l({children:e,className:t=``,dense:n}){let o=(0,r.useContext)(a)===`dense`;return(0,i.jsx)(`div`,{className:`${n??o?`p-4`:`p-6`} ${t}`,children:e})}export{c as i,l as n,o as r,s as t};
+import{i as e}from"./chunk-aKtaBQYM.js";import{C as t,D as n}from"./client-C5b1pVUf.js";var r=e(n(),1),i=t(),a=(0,r.createContext)(`normal`);function o({density:e,children:t}){return(0,i.jsx)(a.Provider,{value:e,children:t})}function s({children:e,className:t=``,onClick:n,onContextMenu:r,...a}){return(0,i.jsx)(`div`,{className:`bg-bambu-dark-secondary rounded-xl border border-bambu-dark-tertiary card-shadow ${n?`cursor-pointer`:``} ${t}`,onClick:n,onContextMenu:r,...a,children:e})}function c({children:e,className:t=``,dense:n}){let o=(0,r.useContext)(a)===`dense`;return(0,i.jsx)(`div`,{className:`${n??o?`px-4 py-2.5`:`px-6 py-4`} border-b border-bambu-dark-tertiary ${t}`,children:e})}function l({children:e,className:t=``,dense:n}){let o=(0,r.useContext)(a)===`dense`;return(0,i.jsx)(`div`,{className:`${n??o?`p-4`:`p-6`} ${t}`,children:e})}export{c as i,l as n,o as r,s as t};

File diff suppressed because it is too large
+ 0 - 0
static/assets/CartesianChart-DmYbYMcT.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/ColumnConfigModal-DvLrcYmB.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/ConfigureAmsSlotModal-BWMvMCB6.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/ConnectAuthorizePage-B-BuRHmy.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/ContextMenu-T-3vFFhy.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/CopyButton-BKAT9PLw.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/ErrorBarContext-CFG2cIjr.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/ExternalLinkPage-DQKXj1ic.js


File diff suppressed because it is too large
+ 0 - 1
static/assets/FileManagerPage-C_KJ7Paz.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/FileUploadModal-Cf5Tfn4d.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/FinancePage-D8DxfkPn.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/GCodeViewerPage-DHkArJo8.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/GcodeToolpathViewer-DDhsskoJ.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/GroupEditPage-Dgyrku2H.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/ImagePreviewModal-bRsLacf3.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/InventoryPage-Bw6WOk77.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/LibraryTrashPage-3xcyeVut.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/LoginPage-CFxieoDA.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/MaintenancePage-BV1lG5YP.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/ModelSourcesPage-CWFiLjD7.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/NotificationsPage-Bht6nHmS.js


+ 1 - 1
static/assets/OverlayFrame-DO4-H9rs.js → static/assets/OverlayFrame-BIdWPGNk.js

@@ -1 +1 @@
-import{i as e}from"./chunk-aKtaBQYM.js";import{C as t,D as n,a as r}from"./client-BBL32ZR9.js";var i=e(n(),1);function a(e,t=null,n=0){let[a,o]=(0,i.useState)(null);return(0,i.useEffect)(()=>{if(!e)return;let i=new AbortController,a;return r.getOverlayLogo(t,i.signal).then(e=>{i.signal.aborted||(e?(a=URL.createObjectURL(e),o({url:a,token:t,revision:n})):o(null))}).catch(()=>{i.signal.aborted||o(null)}),()=>{i.abort(),a&&URL.revokeObjectURL(a)}},[e,t,n]),e&&a?.token===t&&a.revision===n?a.url:null}var o={logo:!1,logoRevision:0,from:``,to:``};function s(e){return/^#[0-9a-f]{6}$/i.test(e)}function c(e,t){return e&&t&&s(e)&&s(t)?`linear-gradient(to right, ${e}, ${t})`:void 0}function l(e){if(e)return{backgroundImage:e,backgroundClip:`text`,WebkitBackgroundClip:`text`,color:`transparent`,WebkitTextFillColor:`transparent`}}var u={landscape:{width:1920,height:1080},portrait:{width:1080,height:1920}},d=t();function f({layout:e,preview:t=!1,children:n}){let r=(0,i.useRef)(null),[a,o]=(0,i.useState)(0),{width:s,height:c}=u[e];(0,i.useLayoutEffect)(()=>{let e=r.current;if(!e)return;let t=()=>o(Math.min(e.clientWidth/s,e.clientHeight/c));if(t(),typeof ResizeObserver>`u`)return window.addEventListener(`resize`,t),()=>window.removeEventListener(`resize`,t);let n=new ResizeObserver(t);return n.observe(e),()=>n.disconnect()},[s,c]);let l={width:s,height:c,"--overlay-vmin":`10.8px`,"--overlay-height":`${c}px`,position:`absolute`,top:`50%`,left:`50%`,transform:`translate(-50%, -50%) scale(${a})`,transformOrigin:`center`};return(0,d.jsx)(`div`,{ref:r,className:t?`relative w-full overflow-hidden`:`fixed inset-0 overflow-hidden`,style:t?{aspectRatio:`${s} / ${c}`}:void 0,children:(0,d.jsx)(`div`,{style:l,children:n})})}export{c as a,s as i,u as n,l as o,o as r,a as s,f as t};
+import{i as e}from"./chunk-aKtaBQYM.js";import{C as t,D as n,a as r}from"./client-C5b1pVUf.js";var i=e(n(),1);function a(e,t=null,n=0){let[a,o]=(0,i.useState)(null);return(0,i.useEffect)(()=>{if(!e)return;let i=new AbortController,a;return r.getOverlayLogo(t,i.signal).then(e=>{i.signal.aborted||(e?(a=URL.createObjectURL(e),o({url:a,token:t,revision:n})):o(null))}).catch(()=>{i.signal.aborted||o(null)}),()=>{i.abort(),a&&URL.revokeObjectURL(a)}},[e,t,n]),e&&a?.token===t&&a.revision===n?a.url:null}var o={logo:!1,logoRevision:0,from:``,to:``};function s(e){return/^#[0-9a-f]{6}$/i.test(e)}function c(e,t){return e&&t&&s(e)&&s(t)?`linear-gradient(to right, ${e}, ${t})`:void 0}function l(e){if(e)return{backgroundImage:e,backgroundClip:`text`,WebkitBackgroundClip:`text`,color:`transparent`,WebkitTextFillColor:`transparent`}}var u={landscape:{width:1920,height:1080},portrait:{width:1080,height:1920}},d=t();function f({layout:e,preview:t=!1,children:n}){let r=(0,i.useRef)(null),[a,o]=(0,i.useState)(0),{width:s,height:c}=u[e];(0,i.useLayoutEffect)(()=>{let e=r.current;if(!e)return;let t=()=>o(Math.min(e.clientWidth/s,e.clientHeight/c));if(t(),typeof ResizeObserver>`u`)return window.addEventListener(`resize`,t),()=>window.removeEventListener(`resize`,t);let n=new ResizeObserver(t);return n.observe(e),()=>n.disconnect()},[s,c]);let l={width:s,height:c,"--overlay-vmin":`10.8px`,"--overlay-height":`${c}px`,position:`absolute`,top:`50%`,left:`50%`,transform:`translate(-50%, -50%) scale(${a})`,transformOrigin:`center`};return(0,d.jsx)(`div`,{ref:r,className:t?`relative w-full overflow-hidden`:`fixed inset-0 overflow-hidden`,style:t?{aspectRatio:`${s} / ${c}`}:void 0,children:(0,d.jsx)(`div`,{style:l,children:n})})}export{c as a,s as i,u as n,l as o,o as r,a as s,f as t};

File diff suppressed because it is too large
+ 0 - 1
static/assets/PdfPreviewModal-B1DP1K1b.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/PhotoGalleryModal-CmkRwq8P.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/PrinterLocationsPage-BK9vpaX5.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/PrintersPage-alt5xx8U.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/ProfilesPage-PpLBPwoi.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/ProjectDetailPage-BECOgLLU.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/ProjectsPage-RvqXGGx0.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/QueryClientProvider-BqikUEEO.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/QueuePage-CENLVY2N.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/QueuePage-CvI9-tuU.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/ReferenceLine-CHRcTJTi.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/RichTextEditor-DxGPdPhT.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/SettingsPage-MUbZ4ImQ.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/SetupPage-f1RHNOjc.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/SliceModal-CK7yDRCZ.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/SpoolBuddyAmsPage-C-28HP2y.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/SpoolBuddyCalibrationPage-B61xHTmJ.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/SpoolBuddyDashboard-NfsRuMDU.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/SpoolBuddyInventoryPage-BULEAz4p.js


File diff suppressed because it is too large
+ 1 - 1
static/assets/SpoolBuddySettingsPage-NRj95eq_.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/SpoolBuddyWriteTagPage-VDNWVIJl.js


+ 1 - 1
static/assets/SpoolIcon-ClX6PZuR.js → static/assets/SpoolIcon-BTzgxzF3.js

@@ -1 +1 @@
-import{i as e}from"./chunk-aKtaBQYM.js";import{C as t,D as n}from"./client-BBL32ZR9.js";import{n as r}from"./filamentSwatchHelpers-D3oXQSGk.js";import{t as i}from"./spoolPaint-CrvpHqdD.js";var a=e(n(),1),o=t();function s({spool:e,inset:t,size:n,border:i}){let s=n>=80?`card`:`preview`,c=(0,a.useMemo)(()=>r({effectSize:s,rgba:e.rgba,extraColors:e.extra_colors,effectType:e.effect_type,subtype:e.subtype}),[s,e.rgba,e.extra_colors,e.effect_type,e.subtype]),l=`${t*100}%`;return(0,o.jsx)(`div`,{"data-testid":`spool-paint`,className:`absolute rounded-full box-border`,style:{top:l,left:l,right:l,bottom:l,...c,backgroundPosition:`center`,border:i}})}function c({inset:e}){let t=`${e*100}%`;return(0,o.jsx)(`div`,{"data-testid":`spool-shade`,className:`absolute rounded-full`,style:{top:t,left:t,right:t,bottom:t,backgroundColor:`rgba(0, 0, 0, 0.15)`}})}function l({color:e,isEmpty:t,size:n=32,spool:r}){return t?(0,o.jsx)(`div`,{className:`rounded-full border-2 border-dashed border-zinc-500 flex items-center justify-center`,style:{width:n,height:n},children:(0,o.jsx)(`div`,{className:`w-2 h-2 rounded-full bg-zinc-600`})}):i(r)?(0,o.jsxs)(`div`,{className:`relative shrink-0`,style:{width:n,height:n},children:[(0,o.jsx)(s,{spool:r,size:n,inset:2/32,border:`${1.5*n/32}px solid rgba(255, 255, 255, 0.7)`}),(0,o.jsx)(c,{inset:5/32})]}):(0,o.jsxs)(`svg`,{width:n,height:n,viewBox:`0 0 32 32`,children:[(0,o.jsx)(`circle`,{cx:`16`,cy:`16`,r:`14`,fill:e,stroke:`white`,strokeWidth:`1.5`,strokeOpacity:`0.7`}),(0,o.jsx)(`circle`,{cx:`16`,cy:`16`,r:`11`,fill:e,style:{filter:`brightness(0.85)`}})]})}export{s as n,c as r,l as t};
+import{i as e}from"./chunk-aKtaBQYM.js";import{C as t,D as n}from"./client-C5b1pVUf.js";import{n as r}from"./filamentSwatchHelpers-D3oXQSGk.js";import{t as i}from"./spoolPaint-CrvpHqdD.js";var a=e(n(),1),o=t();function s({spool:e,inset:t,size:n,border:i}){let s=n>=80?`card`:`preview`,c=(0,a.useMemo)(()=>r({effectSize:s,rgba:e.rgba,extraColors:e.extra_colors,effectType:e.effect_type,subtype:e.subtype}),[s,e.rgba,e.extra_colors,e.effect_type,e.subtype]),l=`${t*100}%`;return(0,o.jsx)(`div`,{"data-testid":`spool-paint`,className:`absolute rounded-full box-border`,style:{top:l,left:l,right:l,bottom:l,...c,backgroundPosition:`center`,border:i}})}function c({inset:e}){let t=`${e*100}%`;return(0,o.jsx)(`div`,{"data-testid":`spool-shade`,className:`absolute rounded-full`,style:{top:t,left:t,right:t,bottom:t,backgroundColor:`rgba(0, 0, 0, 0.15)`}})}function l({color:e,isEmpty:t,size:n=32,spool:r}){return t?(0,o.jsx)(`div`,{className:`rounded-full border-2 border-dashed border-zinc-500 flex items-center justify-center`,style:{width:n,height:n},children:(0,o.jsx)(`div`,{className:`w-2 h-2 rounded-full bg-zinc-600`})}):i(r)?(0,o.jsxs)(`div`,{className:`relative shrink-0`,style:{width:n,height:n},children:[(0,o.jsx)(s,{spool:r,size:n,inset:2/32,border:`${1.5*n/32}px solid rgba(255, 255, 255, 0.7)`}),(0,o.jsx)(c,{inset:5/32})]}):(0,o.jsxs)(`svg`,{width:n,height:n,viewBox:`0 0 32 32`,children:[(0,o.jsx)(`circle`,{cx:`16`,cy:`16`,r:`14`,fill:e,stroke:`white`,strokeWidth:`1.5`,strokeOpacity:`0.7`}),(0,o.jsx)(`circle`,{cx:`16`,cy:`16`,r:`11`,fill:e,style:{filter:`brightness(0.85)`}})]})}export{s as n,c as r,l as t};

File diff suppressed because it is too large
+ 0 - 0
static/assets/SpreadsheetPreviewModal-BqWIw5an.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/StatsPage-Dwpmh26Z.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/StreamOverlayPage-CE2JdNbS.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/SystemInfoPage-6GQNGs6g.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/ToastContext-CypVB8nG.js


+ 1 - 1
static/assets/Toggle-DgrG1Mr6.js → static/assets/Toggle-B27GN7KJ.js

@@ -1 +1 @@
-import{C as e}from"./client-BBL32ZR9.js";var t=e();function n({checked:e,onChange:n,disabled:r}){return(0,t.jsx)(`button`,{type:`button`,role:`switch`,"aria-checked":e,disabled:r,onClick:t=>{t.preventDefault(),t.stopPropagation(),r||n(!e)},className:`relative inline-flex w-11 h-7 md:w-9 md:h-5 rounded-full transition-colors flex-shrink-0 focus:outline-none focus:ring-2 focus:ring-bambu-green focus:ring-offset-2 focus:ring-offset-bambu-dark ${r?`bg-bambu-dark-tertiary/50 cursor-not-allowed opacity-50`:e?`bg-bambu-green cursor-pointer`:`bg-bambu-dark-tertiary cursor-pointer hover:bg-bambu-dark-tertiary/80`}`,children:(0,t.jsx)(`span`,{className:`pointer-events-none absolute top-[3px] md:top-[2px] left-[3px] md:left-[2px] w-5 h-5 md:w-4 md:h-4 bg-white rounded-full shadow transition-transform duration-200 ease-in-out ${e?`translate-x-4`:`translate-x-0`}`})})}export{n as t};
+import{C as e}from"./client-C5b1pVUf.js";var t=e();function n({checked:e,onChange:n,disabled:r}){return(0,t.jsx)(`button`,{type:`button`,role:`switch`,"aria-checked":e,disabled:r,onClick:t=>{t.preventDefault(),t.stopPropagation(),r||n(!e)},className:`relative inline-flex w-11 h-7 md:w-9 md:h-5 rounded-full transition-colors flex-shrink-0 focus:outline-none focus:ring-2 focus:ring-bambu-green focus:ring-offset-2 focus:ring-offset-bambu-dark ${r?`bg-bambu-dark-tertiary/50 cursor-not-allowed opacity-50`:e?`bg-bambu-green cursor-pointer`:`bg-bambu-dark-tertiary cursor-pointer hover:bg-bambu-dark-tertiary/80`}`,children:(0,t.jsx)(`span`,{className:`pointer-events-none absolute top-[3px] md:top-[2px] left-[3px] md:left-[2px] w-5 h-5 md:w-4 md:h-4 bg-white rounded-full shadow transition-transform duration-200 ease-in-out ${e?`translate-x-4`:`translate-x-0`}`})})}export{n as t};

+ 1 - 0
static/assets/arrow-down-Cxl6vzXB.js

@@ -0,0 +1 @@
+import{t as e}from"./createLucideIcon-Br1j9igo.js";var t=e(`arrow-down`,[[`path`,{d:`M12 5v14`,key:`s699le`}],[`path`,{d:`m19 12-7 7-7-7`,key:`1idqje`}]]);export{t};

+ 0 - 1
static/assets/arrow-down-DdKSvvIT.js

@@ -1 +0,0 @@
-import{t as e}from"./createLucideIcon-BfV73Zxr.js";var t=e(`arrow-down`,[[`path`,{d:`M12 5v14`,key:`s699le`}],[`path`,{d:`m19 12-7 7-7-7`,key:`1idqje`}]]);export{t};

+ 1 - 0
static/assets/arrow-left-BdDYciL5.js

@@ -0,0 +1 @@
+import{t as e}from"./createLucideIcon-Br1j9igo.js";var t=e(`arrow-left`,[[`path`,{d:`m12 19-7-7 7-7`,key:`1l729n`}],[`path`,{d:`M19 12H5`,key:`x3x0zl`}]]);export{t};

+ 0 - 1
static/assets/arrow-left-CLRcXhCQ.js

@@ -1 +0,0 @@
-import{t as e}from"./createLucideIcon-BfV73Zxr.js";var t=e(`arrow-left`,[[`path`,{d:`m12 19-7-7 7-7`,key:`1l729n`}],[`path`,{d:`M19 12H5`,key:`x3x0zl`}]]);export{t};

+ 1 - 0
static/assets/arrow-right-D8L4bue0.js

@@ -0,0 +1 @@
+import{t as e}from"./createLucideIcon-Br1j9igo.js";var t=e(`arrow-right`,[[`path`,{d:`M5 12h14`,key:`1ays0h`}],[`path`,{d:`m12 5 7 7-7 7`,key:`xquz4c`}]]);export{t};

+ 0 - 1
static/assets/arrow-right-nSPrsTyj.js

@@ -1 +0,0 @@
-import{t as e}from"./createLucideIcon-BfV73Zxr.js";var t=e(`arrow-right`,[[`path`,{d:`M5 12h14`,key:`1ays0h`}],[`path`,{d:`m12 5 7 7-7 7`,key:`xquz4c`}]]);export{t};

+ 1 - 0
static/assets/arrow-up-BA-zWepm.js

@@ -0,0 +1 @@
+import{t as e}from"./createLucideIcon-Br1j9igo.js";var t=e(`arrow-up`,[[`path`,{d:`m5 12 7-7 7 7`,key:`hav0vg`}],[`path`,{d:`M12 19V5`,key:`x0mq9r`}]]);export{t};

+ 0 - 1
static/assets/arrow-up-BY4M7cKH.js

@@ -1 +0,0 @@
-import{t as e}from"./createLucideIcon-BfV73Zxr.js";var t=e(`arrow-up`,[[`path`,{d:`m5 12 7-7 7 7`,key:`hav0vg`}],[`path`,{d:`M12 19V5`,key:`x0mq9r`}]]);export{t};

+ 1 - 1
static/assets/arrow-up-down-BJuAEYDN.js → static/assets/arrow-up-down-c4lzTQGp.js

@@ -1 +1 @@
-import{t as e}from"./createLucideIcon-BfV73Zxr.js";var t=e(`arrow-up-down`,[[`path`,{d:`m21 16-4 4-4-4`,key:`f6ql7i`}],[`path`,{d:`M17 20V4`,key:`1ejh1v`}],[`path`,{d:`m3 8 4-4 4 4`,key:`11wl7u`}],[`path`,{d:`M7 4v16`,key:`1glfcx`}]]);export{t};
+import{t as e}from"./createLucideIcon-Br1j9igo.js";var t=e(`arrow-up-down`,[[`path`,{d:`m21 16-4 4-4-4`,key:`f6ql7i`}],[`path`,{d:`M17 20V4`,key:`1ejh1v`}],[`path`,{d:`m3 8 4-4 4 4`,key:`11wl7u`}],[`path`,{d:`M7 4v16`,key:`1glfcx`}]]);export{t};

+ 1 - 1
static/assets/box-kT6JNvh7.js → static/assets/box-C-Edwu2W.js

@@ -1 +1 @@
-import{t as e}from"./createLucideIcon-BfV73Zxr.js";var t=e(`box`,[[`path`,{d:`M21 8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16Z`,key:`hh9hay`}],[`path`,{d:`m3.3 7 8.7 5 8.7-5`,key:`g66t2b`}],[`path`,{d:`M12 22V12`,key:`d0xqtd`}]]);export{t};
+import{t as e}from"./createLucideIcon-Br1j9igo.js";var t=e(`box`,[[`path`,{d:`M21 8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16Z`,key:`hh9hay`}],[`path`,{d:`m3.3 7 8.7 5 8.7-5`,key:`g66t2b`}],[`path`,{d:`M12 22V12`,key:`d0xqtd`}]]);export{t};

+ 1 - 1
static/assets/briefcase-B2us4N5v.js → static/assets/briefcase-aGpG9O_J.js

@@ -1 +1 @@
-import{t as e}from"./createLucideIcon-BfV73Zxr.js";var t=e(`briefcase`,[[`path`,{d:`M16 20V4a2 2 0 0 0-2-2h-4a2 2 0 0 0-2 2v16`,key:`jecpp`}],[`rect`,{width:`20`,height:`14`,x:`2`,y:`6`,rx:`2`,key:`i6l2r4`}]]);export{t};
+import{t as e}from"./createLucideIcon-Br1j9igo.js";var t=e(`briefcase`,[[`path`,{d:`M16 20V4a2 2 0 0 0-2-2h-4a2 2 0 0 0-2 2v16`,key:`jecpp`}],[`rect`,{width:`20`,height:`14`,x:`2`,y:`6`,rx:`2`,key:`i6l2r4`}]]);export{t};

+ 1 - 1
static/assets/calendar-B74Xc1SM.js → static/assets/calendar-evWoPgiR.js

@@ -1 +1 @@
-import{t as e}from"./createLucideIcon-BfV73Zxr.js";var t=e(`calendar`,[[`path`,{d:`M8 2v4`,key:`1cmpym`}],[`path`,{d:`M16 2v4`,key:`4m81vk`}],[`rect`,{width:`18`,height:`18`,x:`3`,y:`4`,rx:`2`,key:`1hopcy`}],[`path`,{d:`M3 10h18`,key:`8toen8`}]]);export{t};
+import{t as e}from"./createLucideIcon-Br1j9igo.js";var t=e(`calendar`,[[`path`,{d:`M8 2v4`,key:`1cmpym`}],[`path`,{d:`M16 2v4`,key:`4m81vk`}],[`rect`,{width:`18`,height:`18`,x:`3`,y:`4`,rx:`2`,key:`1hopcy`}],[`path`,{d:`M3 10h18`,key:`8toen8`}]]);export{t};

+ 0 - 1
static/assets/check-BvjJlBTt.js

@@ -1 +0,0 @@
-import{t as e}from"./createLucideIcon-BfV73Zxr.js";var t=e(`check`,[[`path`,{d:`M20 6 9 17l-5-5`,key:`1gmf2c`}]]);export{t};

+ 1 - 0
static/assets/check-DeJLbG-z.js

@@ -0,0 +1 @@
+import{t as e}from"./createLucideIcon-Br1j9igo.js";var t=e(`check`,[[`path`,{d:`M20 6 9 17l-5-5`,key:`1gmf2c`}]]);export{t};

Some files were not shown because too many files changed in this diff