diff --git a/CHANGELOG.md b/CHANGELOG.md index 9797ca04f..c2577e806 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -70,6 +70,8 @@ All notable changes to Bambuddy will be documented in this file. - **Per-printer Maintenance Mode toggle (#1476, requested by @IndividualGhost1905 / Ferdi SEVER)** — Operator-flipped "out of service" state per printer, surfaced as a wrench icon + amber pill on the card and a checkbox in the Edit Printer dialog. Requested for three real-world scenarios that all share the same shape: (1) parallel Bambuddy installs (dev + prod, primary + warm spare) where the printer rejects concurrent MQTT clients except one, leaving the others in a flicker-online state burning CPU and network; (2) printers under repair / awaiting spare parts that shouldn't accept queue jobs but should remain visible on the dashboard so they aren't forgotten; (3) temporary suspension during maintenance work. **What was already there, what was missing.** The backend field `Printer.is_active: bool` has shipped since the initial Bambuddy release — toggling it via `PATCH /printers/{id}` already disconnects MQTT (`printer_manager.disconnect_printer` at `printers.py:366`), stops the printer from being eligible for queue dispatch (`print_scheduler.py:520, 1588`, `print_queue.py:383`), excludes it from model-based filament lookups (`printers.py:197`), excludes it from metrics + diagnostic snapshots + scheduled-backup runs (`metrics.py:105`, `diagnostic_snapshot.py:126`, `github_backup.py:333`, `maintenance.py:457`), and is already honoured by PrinterSelector (filtered with a "show inactive" override, greyed + "(inactive)" label when shown). All three of Ferdi's use cases were structurally supported by `is_active` from day one. **The missing piece was UI exposure.** `grep is_active` on `PrintersPage.tsx` returned zero hits — no menu item, no edit field, no toggle. The only way to flip it was a direct API call. This change adds the surfaces that should have been there all along. **Card UI — replacement, not addition.** Per Ferdi-conversation feedback, the maintenance state replaces the print-status / cover-image container rather than stacking above it, so card heights stay identical across the grid: in expanded mode the same `` header renders an amber panel (wrench icon + "In Maintenance" + subtitle + Exit button) where the cover + progress would normally be; in compact mode a single amber pill replaces the progress bar. The header connection pill is also swapped — instead of the red "Offline" pill (which would be misleading because the disconnect is deliberate) the card shows an amber "Maintenance" pill, and the "Run Diagnostic" CTA is suppressed (that's reserved for involuntary offline triage). HMS / Queue / Firmware status pills are still gated by `status?.connected` so they fall away naturally with the MQTT disconnect. **Three entry points.** (1) Printer card three-dot overflow menu — `Enter maintenance mode` / `Exit maintenance mode` with a wrench icon, adjacent to the Edit and Reconnect actions. (2) Exit button inside the in-card amber panel, so a user noticing the card from across the room can flip back without opening the menu. (3) Checkbox in the EditPrinterModal — `Maintenance mode` with the same subtitle as the help line, so the toggle is discoverable from the edit dialog too (the checkbox is the inverse of `is_active` because the user-facing concept is "is this in maintenance" not "is it active"). **Mid-print safety prompt.** Entering maintenance mode on a printer in `RUNNING` / `PAUSE` state triggers a confirmation dialog before the toggle fires — disconnecting MQTT mid-print stops progress tracking + completion notifications for the in-flight job, which is usually NOT what the operator wants (they probably meant "after this print finishes"). Idle / FINISH / FAILED states skip the dialog and toggle directly. **What this does NOT change.** No backend change (`is_active` was already wired everywhere); no new permission (uses existing `printers:update`); no behaviour change for any other consumer (queue dispatch, scheduler, metrics, picker, backup — all already honoured `is_active`). The card stays visible on the Printers page (greyed temps/controls/fans below the amber banner) so the printer doesn't disappear from the operator's mental map — Ferdi explicitly wanted to remember it's there. Doesn't auto-pause Smart Plug logic or notification providers (would be a sensible follow-up if Ferdi asks; out of scope here to keep the diff bounded to "expose the existing gate"). The scheduled-maintenance dashboard at `/maintenance` (interval-tracked rod-cleaning / lube / belt tasks via the existing `MaintenanceHistory` and `PrinterMaintenance` models) is conceptually adjacent but operationally distinct — the dashboard tracks "this printer is due for cleaning"; Maintenance Mode tracks "this printer is currently out of service." A future "perform maintenance task → optionally enter maintenance mode while you do it" link is the natural connection but isn't wired here. **i18n.** Twelve new keys under `printers.maintenance.*` (title / subtitle / pillLabel / exitButton / menuEnter / menuExit / toastEntered / toastExited / confirmMidPrintTitle / confirmMidPrintMessage / editFieldLabel / editFieldHelp) — real translations in all 11 locales (de / en / es / fr / it / ja / ko / pt-BR / tr / zh-CN / zh-TW), parity 5228 leaves per locale, no English fallback. **Tests.** 4 new cases in `PrintersPage.test.tsx::'maintenance mode (#1476)'`: amber status panel renders with Exit button (and the regular "No active job" / "Ready to print" copy is absent — confirms the swap, not a stacked render); header pill swaps to amber Maintenance and the diagnostic CTA is suppressed; clicking Exit issues a `PATCH /printers/{id}` with `is_active: true`; active printers never show the maintenance panel. Existing test fixture (`mockPrinters`) got an explicit `is_active: true` to keep the existing 56 tests green on the new render path. **Type:** `PrinterCreate.is_active?: boolean` added to the TypeScript surface so the field flows cleanly through the existing `api.updatePrinter` helper. **Build + checks.** Full PrintersPage vitest 60/60 green; `npm run build` clean; ESLint clean; i18n parity 5228 × 11 locales green. ### Fixed +- **`require_previous_success` permanently blocked a printer's queue after one failure (#1818, reported by @jmassardo)** — Reporter scenario (P1S, farm-style queue): every queued job had "Only start if previous print succeeded" set; the first job failed (filament tangle / runout / clog); every subsequent job — including brand-new ones added after the printer was fixed and back online — was silently marked `skipped` with no path back. The only workaround was to delete each item and re-create it through PrintModal, impractical at farm scale. **Root cause** in `print_scheduler.py::_check_previous_success`: the lookback query returned the most recent terminal item in (`completed`, `failed`, `cancelled`, `aborted`) — `skipped` is intentionally excluded (#1667). Once the original failure landed, the lookback always walked back to that same failed item — every new skip is excluded from the lookback so it doesn't shift the window. With no code path to dismiss the originating failure, the gate stayed closed forever. "Clear Plate" only resolved the orthogonal plate-clear gate (`_is_printer_idle`); nothing in Bambuddy acknowledged a resolved failure. **Fix.** New `PrintQueueItem.gate_acknowledged: bool` column (default False). Postgres/SQLite-safe ALTER following the existing #1794 / stock-alert migration shape (`DEFAULT 0` on SQLite, `DEFAULT false` on Postgres). `_check_previous_success` adds `AND gate_acknowledged == False` to its lookback so acknowledged failures are walked past — back to whatever real predecessor came before, or to the no-predecessor-passes case. **New per-printer endpoint.** `POST /api/v1/queue/printer/{printer_id}/resume` (gated on `QUEUE_UPDATE_ALL`) does both halves of the resume in one atomic transaction: (1) `gate_acknowledged=True` on every failed/aborted item for that printer that's still gating; (2) flips items where `status='skipped' AND error_message='Previous print failed or was aborted'` back to `pending`, clears `error_message` + `completed_at`. Returns `{acknowledged, restored}` counts so the UI can render a precise toast. Per-printer scoped — a resume on printer A never touches printer B. Per-item acknowledgement is independent — a fresh failure AFTER a resume re-gates downstream items, so users don't silently steamroll past a new real problem. The endpoint is idempotent (second call after the first sees acknowledged=0, restored=0). Also intentionally narrow: skipped items whose `error_message` is something OTHER than the exact gate string (e.g. future skip reasons, manual UI skips) are left untouched — those encode different user intent. **Frontend.** Per-printer alert banner at the top of the Queue tab (above the layout / filter row) shown when a printer has at least one skipped item with the gate `error_message`. Banner is permission-gated on `queue:update_all` so viewers don't see a button they can't use. Each blocked printer gets its own row: an `AlertCircle` icon, a one-line "{printer} is blocked by a previous-print failure — N job(s) skipped" headline, a "Fix the printer issue, then resume to restore the skipped jobs and clear the gate." hint, and a "Resume after failure" button on the right that opens a warning-variant `ConfirmModal` with the printer name + count. Confirm fires the new mutation, invalidates `['queue']`, and shows a toast — "Resumed queue — N job(s) restored to pending". Banner disappears the moment the action lands. Visible on the active Queue tab regardless of layout (`position` or `printer`); History/Timeline tabs unchanged. **Backend tests** (12 new): 4 new `_check_previous_success` cases in `test_check_previous_success.py` covering acknowledged failure ignored, acknowledged aborted ignored, fresh failure after ack STILL gates (independence guarantee), acknowledged failure walks back to the prior completed predecessor. 7 new `TestResumeQueueAfterFailure` cases in `test_print_queue_api.py` covering unknown printer 404, clean-queue no-op, reporter's failed+N-skipped scenario, scoped to the requested printer only, aborted-status acknowledgement, narrow `error_message` filter (don't touch other skip reasons), second call is a no-op. Full backend `pytest -n 30`: 6398/6398 green, 75 s. `ruff check backend/` clean. **Frontend** — 1 new API client method (`resumeQueueAfterFailure`), parity check 5352 × 11 locales green (no English fallback). `npm run build` clean, `npm run lint` clean. **i18n.** 7 new keys (`queue.toast.resumedAfterFailure`, `queue.toast.resumeAfterFailureFailed`, `queue.resumeAfterFailure.banner` / `bannerHint` / `button` / `confirmTitle` / `confirmMessage`) translated in all 11 locales (de / en / es / fr / it / ja / ko / pt-BR / tr / zh-CN / zh-TW). **Scope.** No change to `_is_printer_idle` (Clear Plate stays orthogonal). No change to `#1667` `skipped`-excluded-from-lookback semantics — those are still load-bearing for the cancellation-cascade fix. No change to filament-deficit-skip path (different error_message → untouched). One legacy column carries a row state where `gate_acknowledged=True`; the column never gets written by anything except the resume endpoint, so it's effectively an opt-in row marker. No new permission — `QUEUE_UPDATE_ALL` already exists and is the appropriate scope for a printer-level operation that may affect items the user doesn't own. + - **Archives "Step 4" docs link 404'd (#1812, reported by @Spanholz)** — The Archives page's "Some prints couldn't be archived with thumbnails" amber banner linked to `https://bambuddy.cool/wiki/getting-started/#step-4-enable-store-sent-files-on-external-storage` — the wiki lives at the `wiki.bambuddy.cool` subdomain, not under `bambuddy.cool/wiki/`, so the link 404'd. Anchor was already correct (MkDocs slugifies the existing "Step 4: Enable Store sent files on external storage" heading to that exact id). Single-line fix in `frontend/src/pages/ArchivesPage.tsx:3271`; no i18n / no tests touched. - **Connection diagnostic reported false camera-port warning on A1 / A1 Mini / P1 (#1798, reported and fixed by @lesbass / Stefano Maffeis in #1799)** — The general Connection Diagnostic (`printer_diagnostic.py:124`) probed RTSPS port 322 unconditionally, even for printers that don't speak RTSP. A1, A1 Mini, and P1-family cameras use the chamber-image protocol on port 6000 — Bambuddy's live camera client already routes them there via `camera.py::get_camera_port(model)`. Result: a saved A1 Mini with a working live webcam still saw "Camera port (RTSPS 322) — Port 322 is unreachable" in the diagnostic, and overall status flipped to `warnings` for a healthy printer. Reporter confirmed: TCP probe from the container showed 6000 open, 322 refused; camera-specific diagnostic returned `protocol=chamber_image, port=6000, overall_status=ok`. **Fix.** The diagnostic now resolves the camera port via the same `get_camera_port()` the camera client uses (single source of truth — adding a new model in `camera.py` propagates here for free). Saved A1 / A1 Mini / P1 → probe 6000, render "Camera port (Chamber Image 6000)". Saved X1 / X2 / H2 / P2 → unchanged, still probe 322 / RTSPS. Pre-save Add-Printer diagnostic where the model isn't known yet falls back to 322 / RTSPS — same conservative default as before, so the Add-Printer flow doesn't regress for users on RTSP models. The diagnostic check id stays `port_rtsps` for backward compatibility with snapshot JSON consumers; the actual port / protocol now travel in `params` and the localized title and warn text interpolate `{{protocol}}` / `{{port}}`. **Frontend.** `ConnectionDiagnostic.tsx` interpolates the title (previously static) and merges defensive defaults (`{ protocol: 'RTSPS', port: 322, ...check.params }`) for `port_rtsps` so an older backend or cached response still renders sensibly. **i18n.** Title and warn strings updated in all 11 locales (en / de / es / fr / it / ja / ko / pt-BR / tr / zh-CN / zh-TW) to use `{{protocol}}` / `{{port}}` interpolation. `npm run check:i18n` clean. **Tests.** 2 new backend cases (A1 Mini → 6000 / Chamber Image with the 322 fallback closed asserting `port_rtsps=pass`; X1C → still probes 322 / RTSPS asserting the existing behavior is unchanged) and 1 new frontend case asserting the rendered "Camera port (Chamber Image 6000)" + "Port 6000 is unreachable" text. `_port_probe` defaults extended to include port 6000. Existing model-less tests unchanged — they go through the `printer.model is None` fallback path and still probe 322. diff --git a/backend/app/api/routes/print_queue.py b/backend/app/api/routes/print_queue.py index f2c9534a4..2e34f033e 100644 --- a/backend/app/api/routes/print_queue.py +++ b/backend/app/api/routes/print_queue.py @@ -1091,6 +1091,64 @@ async def reorder_queue( return {"message": f"Reordered {len(data.items)} items"} +@router.post("/printer/{printer_id}/resume") +async def resume_queue_after_failure( + printer_id: int, + db: AsyncSession = Depends(get_db), + _: User | None = RequirePermissionIfAuthEnabled(Permission.QUEUE_UPDATE_ALL), +): + """Clear the previous-success gate for a printer and restore skipped items. + + Single atomic op (#1818): + + * Sets ``gate_acknowledged=True`` on every ``failed`` / ``aborted`` queue + item for this printer that's still in the scheduler's lookback window, + so the next ``_check_previous_success`` call ignores them. + * Restores ``skipped`` items whose ``error_message`` matches the + scheduler's exact "Previous print failed or was aborted" gate string + back to ``pending`` (clears ``error_message`` + ``completed_at``). + + Returns counts so the UI can render a precise toast. No-op endpoint + (zero counts) when called against a printer with no gate to clear. + """ + result = await db.execute(select(Printer).where(Printer.id == printer_id)) + printer = result.scalar_one_or_none() + if not printer: + raise HTTPException(404, "Printer not found") + + ack_result = await db.execute( + select(PrintQueueItem) + .where(PrintQueueItem.printer_id == printer_id) + .where(PrintQueueItem.status.in_(["failed", "aborted"])) + .where(PrintQueueItem.gate_acknowledged == False) # noqa: E712 + ) + to_ack = ack_result.scalars().all() + for failed_item in to_ack: + failed_item.gate_acknowledged = True + + restore_result = await db.execute( + select(PrintQueueItem) + .where(PrintQueueItem.printer_id == printer_id) + .where(PrintQueueItem.status == "skipped") + .where(PrintQueueItem.error_message == "Previous print failed or was aborted") + ) + to_restore = restore_result.scalars().all() + for skipped_item in to_restore: + skipped_item.status = "pending" + skipped_item.error_message = None + skipped_item.completed_at = None + + await db.commit() + + logger.info( + "Resume after failure on printer %s: acknowledged %d failure(s), restored %d skipped item(s)", + printer_id, + len(to_ack), + len(to_restore), + ) + return {"acknowledged": len(to_ack), "restored": len(to_restore)} + + @router.post("/{item_id}/cancel") async def cancel_queue_item( item_id: int, diff --git a/backend/app/core/database.py b/backend/app/core/database.py index a3540eea4..f6e2b8dc6 100644 --- a/backend/app/core/database.py +++ b/backend/app/core/database.py @@ -3079,6 +3079,15 @@ async def run_migrations(conn): "ALTER TABLE notification_providers ADD COLUMN on_ai_failure_detection BOOLEAN DEFAULT false", ) + # Migration: Add gate_acknowledged column to print_queue (#1818). Cleared + # by the per-printer "Resume after failure" action so the scheduler's + # `_check_previous_success` lookback skips this row. Postgres rejects + # `DEFAULT 0` for BOOLEAN columns. + if is_sqlite(): + await _safe_execute(conn, "ALTER TABLE print_queue ADD COLUMN gate_acknowledged BOOLEAN DEFAULT 0") + else: + await _safe_execute(conn, "ALTER TABLE print_queue ADD COLUMN gate_acknowledged BOOLEAN DEFAULT false") + # Migration: Disambiguate the four ``user_print_*`` notification template # names by appending " Email" (#1792). See ``_migrate_rename_user_print_template_names``. await _migrate_rename_user_print_template_names(conn) diff --git a/backend/app/models/print_queue.py b/backend/app/models/print_queue.py index bc4a7b771..59a6878a4 100644 --- a/backend/app/models/print_queue.py +++ b/backend/app/models/print_queue.py @@ -90,6 +90,15 @@ class PrintQueueItem(Base): # Status: pending, printing, completed, failed, skipped, cancelled status: Mapped[str] = mapped_column(String(20), default="pending") + # Cleared by the per-printer "Resume after failure" action (#1818) so the + # scheduler's `_check_previous_success` lookback skips this row. Without + # this, a single `failed` or `aborted` print poisoned every later + # `require_previous_success` item on the same printer forever — the + # lookback excluded `skipped` but had no way to dismiss the originating + # failure. The flag is per-item, not per-printer, so a fresh failure + # after a resume re-gates downstream items independently. + gate_acknowledged: Mapped[bool] = mapped_column(Boolean, default=False) + # Set by the dispatch scheduler when the assigned spool can't satisfy # this print's per-slot filament weight (#1496). Display-only flag — the # actual deficit is recomputed live every time the user clicks ▶, so diff --git a/backend/app/services/print_scheduler.py b/backend/app/services/print_scheduler.py index c5ea2f7f4..82c5bdf4d 100644 --- a/backend/app/services/print_scheduler.py +++ b/backend/app/services/print_scheduler.py @@ -1901,12 +1901,18 @@ class PrintScheduler: items — counting it as a failed predecessor was the cascade bug that let a single cancellation block 18 items over 3 days for the reporter. Only `failed` and `aborted` — real print-attempt failures — block. + + Failures with `gate_acknowledged=True` (set by the per-printer Resume + action — #1818) are also excluded from the lookback so the user can + clear the gate after fixing the physical issue without having to + re-queue every downstream job. """ result = await db.execute( select(PrintQueueItem) .where(PrintQueueItem.printer_id == 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()) .limit(1) ) diff --git a/backend/tests/integration/test_print_queue_api.py b/backend/tests/integration/test_print_queue_api.py index bec2f39bc..ef41dd240 100644 --- a/backend/tests/integration/test_print_queue_api.py +++ b/backend/tests/integration/test_print_queue_api.py @@ -2247,3 +2247,247 @@ class TestAbortedStatusNormalisation: assert row["archive_deleted"] is False assert row["archive_name"] == "Live Archive" assert row["archive_thumbnail"] == "archives/test/live/thumbnail.png" + + +class TestResumeQueueAfterFailure: + """Integration tests for POST /api/v1/queue/printer/{id}/resume (#1818).""" + + @pytest.fixture + async def printer_factory(self, db_session): + _counter = [0] + + async def _create_printer(**kwargs): + from backend.app.models.printer import Printer + + _counter[0] += 1 + counter = _counter[0] + defaults = { + "name": f"Resume Printer {counter}", + "ip_address": f"192.168.42.{100 + counter}", + "serial_number": f"RESUMESERIAL{counter:04d}", + "access_code": "12345678", + "model": "P1S", + } + defaults.update(kwargs) + printer = Printer(**defaults) + db_session.add(printer) + await db_session.commit() + await db_session.refresh(printer) + return printer + + return _create_printer + + @pytest.fixture + async def archive_factory(self, db_session): + _counter = [0] + + async def _create_archive(**kwargs): + from backend.app.models.archive import PrintArchive + + _counter[0] += 1 + counter = _counter[0] + defaults = { + "filename": f"resume_print_{counter}.3mf", + "print_name": f"Resume Print {counter}", + "file_path": f"/tmp/resume_print_{counter}.3mf", + "file_size": 1024, + "content_hash": f"resumehash{counter:08d}", + "status": "completed", + } + defaults.update(kwargs) + archive = PrintArchive(**defaults) + db_session.add(archive) + await db_session.commit() + await db_session.refresh(archive) + return archive + + return _create_archive + + async def _add_item(self, db_session, printer, archive_factory, **kwargs): + from backend.app.models.print_queue import PrintQueueItem + + archive = await archive_factory() + defaults = { + "printer_id": printer.id, + "archive_id": archive.id, + "status": "pending", + "require_previous_success": True, + } + defaults.update(kwargs) + item = PrintQueueItem(**defaults) + db_session.add(item) + await db_session.commit() + await db_session.refresh(item) + return item + + @pytest.mark.asyncio + @pytest.mark.integration + async def test_resume_unknown_printer_returns_404(self, async_client: AsyncClient): + resp = await async_client.post("/api/v1/queue/printer/999999/resume") + assert resp.status_code == 404 + + @pytest.mark.asyncio + @pytest.mark.integration + async def test_resume_no_op_on_clean_queue(self, async_client: AsyncClient, printer_factory): + """Calling resume on a printer with no failures and no skipped items + returns zero counts — endpoint is idempotent and safe to spam.""" + printer = await printer_factory() + resp = await async_client.post(f"/api/v1/queue/printer/{printer.id}/resume") + assert resp.status_code == 200 + assert resp.json() == {"acknowledged": 0, "restored": 0} + + @pytest.mark.asyncio + @pytest.mark.integration + async def test_resume_acknowledges_failed_and_restores_skipped( + self, async_client: AsyncClient, printer_factory, archive_factory, db_session + ): + """Reporter's scenario: failed predecessor + N skipped downstream items. + Resume sets gate_acknowledged on the failure and flips skipped → pending.""" + from sqlalchemy import select + + from backend.app.models.print_queue import PrintQueueItem + + printer = await printer_factory() + failed = await self._add_item(db_session, printer, archive_factory, status="failed") + skipped_1 = await self._add_item( + db_session, + printer, + archive_factory, + status="skipped", + error_message="Previous print failed or was aborted", + ) + skipped_2 = await self._add_item( + db_session, + printer, + archive_factory, + status="skipped", + error_message="Previous print failed or was aborted", + ) + + resp = await async_client.post(f"/api/v1/queue/printer/{printer.id}/resume") + assert resp.status_code == 200 + assert resp.json() == {"acknowledged": 1, "restored": 2} + + failed_id = failed.id + skipped_ids = [skipped_1.id, skipped_2.id] + db_session.expire_all() + + result = await db_session.execute(select(PrintQueueItem).where(PrintQueueItem.id == failed_id)) + assert result.scalar_one().gate_acknowledged is True + + for sid in skipped_ids: + result = await db_session.execute(select(PrintQueueItem).where(PrintQueueItem.id == sid)) + row = result.scalar_one() + assert row.status == "pending" + assert row.error_message is None + assert row.completed_at is None + + @pytest.mark.asyncio + @pytest.mark.integration + async def test_resume_preserves_skipped_items_with_other_reasons( + self, async_client: AsyncClient, printer_factory, archive_factory, db_session + ): + """Skipped items whose error_message is something OTHER than the + gate string (e.g. filament-deficit promotion, future skip reasons) + must not be touched — they encode different user intent.""" + from sqlalchemy import select + + from backend.app.models.print_queue import PrintQueueItem + + printer = await printer_factory() + gate_skip = await self._add_item( + db_session, + printer, + archive_factory, + status="skipped", + error_message="Previous print failed or was aborted", + ) + other_skip = await self._add_item( + db_session, + printer, + archive_factory, + status="skipped", + error_message="User skipped via UI", + ) + + gate_id = gate_skip.id + other_id = other_skip.id + resp = await async_client.post(f"/api/v1/queue/printer/{printer.id}/resume") + assert resp.json() == {"acknowledged": 0, "restored": 1} + + db_session.expire_all() + result = await db_session.execute(select(PrintQueueItem).where(PrintQueueItem.id == gate_id)) + assert result.scalar_one().status == "pending" + result = await db_session.execute(select(PrintQueueItem).where(PrintQueueItem.id == other_id)) + assert result.scalar_one().status == "skipped" + + @pytest.mark.asyncio + @pytest.mark.integration + async def test_resume_scoped_to_printer( + self, async_client: AsyncClient, printer_factory, archive_factory, db_session + ): + """A resume on printer A must not clear printer B's gate — farms run + each printer's queue independently.""" + from sqlalchemy import select + + from backend.app.models.print_queue import PrintQueueItem + + p1 = await printer_factory() + p2 = await printer_factory() + failed_p1 = await self._add_item(db_session, p1, archive_factory, status="failed") + failed_p2 = await self._add_item(db_session, p2, archive_factory, status="failed") + + failed_p1_id = failed_p1.id + failed_p2_id = failed_p2.id + resp = await async_client.post(f"/api/v1/queue/printer/{p1.id}/resume") + assert resp.json() == {"acknowledged": 1, "restored": 0} + + db_session.expire_all() + result = await db_session.execute(select(PrintQueueItem).where(PrintQueueItem.id == failed_p1_id)) + assert result.scalar_one().gate_acknowledged is True + result = await db_session.execute(select(PrintQueueItem).where(PrintQueueItem.id == failed_p2_id)) + assert result.scalar_one().gate_acknowledged is False + + @pytest.mark.asyncio + @pytest.mark.integration + async def test_resume_handles_aborted_status( + self, async_client: AsyncClient, printer_factory, archive_factory, db_session + ): + """Aborted prints (printer-detected mid-print failure) gate the same + way failed prints do and must also be acknowledgeable.""" + from sqlalchemy import select + + from backend.app.models.print_queue import PrintQueueItem + + printer = await printer_factory() + aborted = await self._add_item(db_session, printer, archive_factory, status="aborted") + aborted_id = aborted.id + resp = await async_client.post(f"/api/v1/queue/printer/{printer.id}/resume") + assert resp.json() == {"acknowledged": 1, "restored": 0} + + db_session.expire_all() + result = await db_session.execute(select(PrintQueueItem).where(PrintQueueItem.id == aborted_id)) + assert result.scalar_one().gate_acknowledged is True + + @pytest.mark.asyncio + @pytest.mark.integration + async def test_resume_idempotent_second_call_is_no_op( + self, async_client: AsyncClient, printer_factory, archive_factory, db_session + ): + """Calling resume twice on the same printer doesn't re-acknowledge + the same failure — the second call sees acknowledged=0, restored=0.""" + printer = await printer_factory() + await self._add_item(db_session, printer, archive_factory, status="failed") + await self._add_item( + db_session, + printer, + archive_factory, + status="skipped", + error_message="Previous print failed or was aborted", + ) + + first = await async_client.post(f"/api/v1/queue/printer/{printer.id}/resume") + assert first.json() == {"acknowledged": 1, "restored": 1} + + second = await async_client.post(f"/api/v1/queue/printer/{printer.id}/resume") + assert second.json() == {"acknowledged": 0, "restored": 0} diff --git a/backend/tests/unit/test_check_previous_success.py b/backend/tests/unit/test_check_previous_success.py index 3a4d06ac7..3556f871b 100644 --- a/backend/tests/unit/test_check_previous_success.py +++ b/backend/tests/unit/test_check_previous_success.py @@ -47,7 +47,11 @@ def queue_factory(db_session, printer_factory): printer_holder["p"] = await printer_factory() return printer_holder["p"] - async def _add(status: str, error_message: str | None = None) -> PrintQueueItem: + async def _add( + status: str, + error_message: str | None = None, + gate_acknowledged: bool = False, + ) -> PrintQueueItem: printer = await _make_printer() counter["n"] += 1 item = PrintQueueItem( @@ -56,6 +60,7 @@ def queue_factory(db_session, printer_factory): error_message=error_message, completed_at=base_time + timedelta(minutes=counter["n"]), require_previous_success=True, + gate_acknowledged=gate_acknowledged, ) db_session.add(item) await db_session.commit() @@ -170,3 +175,44 @@ async def test_completed_then_failed_blocks(scheduler, db_session, queue_factory await queue_factory["add"]("failed") pending = await queue_factory["add_pending"]() assert await scheduler._check_previous_success(db_session, pending) is False + + +# ---- #1818: per-printer Resume-after-failure gate acknowledgement ---- + + +@pytest.mark.asyncio +async def test_acknowledged_failure_is_excluded(scheduler, db_session, queue_factory): + """The reporter scenario: failure with gate_acknowledged=True must NOT + block. Without the acknowledge filter, a single failure poisons every + later require_previous_success item forever.""" + await queue_factory["add"]("failed", gate_acknowledged=True) + pending = await queue_factory["add_pending"]() + assert await scheduler._check_previous_success(db_session, pending) is True + + +@pytest.mark.asyncio +async def test_acknowledged_aborted_is_excluded(scheduler, db_session, queue_factory): + await queue_factory["add"]("aborted", gate_acknowledged=True) + pending = await queue_factory["add_pending"]() + assert await scheduler._check_previous_success(db_session, pending) is True + + +@pytest.mark.asyncio +async def test_fresh_failure_after_ack_still_blocks(scheduler, db_session, queue_factory): + """Per-item acknowledgement is independent — a NEW failure after the + user resumed the queue must re-gate downstream items so they don't + silently steamroll past a real problem.""" + await queue_factory["add"]("failed", gate_acknowledged=True) # the old one + await queue_factory["add"]("failed", gate_acknowledged=False) # fresh post-resume + pending = await queue_factory["add_pending"]() + assert await scheduler._check_previous_success(db_session, pending) is False + + +@pytest.mark.asyncio +async def test_acknowledged_failure_walks_back_to_completed(scheduler, db_session, queue_factory): + """After acknowledging the failure, the next real predecessor (a + completed print prior to the failure) governs the gate.""" + await queue_factory["add"]("completed") + await queue_factory["add"]("failed", gate_acknowledged=True) + pending = await queue_factory["add_pending"]() + assert await scheduler._check_previous_success(db_session, pending) is True diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index 791192f0a..90bea3d2a 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -4771,6 +4771,17 @@ export const api = { const qs = opts?.skipFilamentCheck ? '?skip_filament_check=true' : ''; return request(`/queue/${id}/start${qs}`, { method: 'POST' }); }, + /** + * Clear the `require_previous_success` gate for a printer after the user + * resolves the failure. Acknowledges any failed/aborted predecessors and + * restores skipped items whose error_message matches the gate string + * back to pending. Returns counts so the UI can render a precise toast. + */ + resumeQueueAfterFailure: (printerId: number) => + request<{ acknowledged: number; restored: number }>( + `/queue/printer/${printerId}/resume`, + { method: 'POST' }, + ), bulkUpdateQueue: (data: PrintQueueBulkUpdate) => request('/queue/bulk', { method: 'PATCH', diff --git a/frontend/src/i18n/locales/de.ts b/frontend/src/i18n/locales/de.ts index 0459bafad..4adeef12f 100644 --- a/frontend/src/i18n/locales/de.ts +++ b/frontend/src/i18n/locales/de.ts @@ -1229,6 +1229,15 @@ export default { batchCreateFailed: 'Stapel konnte nicht erstellt werden', 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', + resumeAfterFailureFailed: 'Warteschlange konnte nicht fortgesetzt werden', + }, + 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.', + button: 'Nach Fehler fortsetzen', + confirmTitle: 'Warteschlange nach Fehler fortsetzen?', + confirmMessage: 'Setze {{count}} übersprungenen Auftrag/Aufträge auf {{printer}} wieder auf „Ausstehend“ und hebe die Vorgängersperre auf. Stelle vorher sicher, dass der Drucker bereit ist.', }, // Timeline view timeline: { diff --git a/frontend/src/i18n/locales/en.ts b/frontend/src/i18n/locales/en.ts index d0c78568a..35d8c7f8f 100644 --- a/frontend/src/i18n/locales/en.ts +++ b/frontend/src/i18n/locales/en.ts @@ -1239,6 +1239,15 @@ export default { batchCreateFailed: 'Failed to create batch', batchUngrouped: 'Ungrouped {{count}} item(s)', batchUngroupFailed: 'Failed to ungroup batch', + resumedAfterFailure: 'Resumed queue — {{restored}} job(s) restored to pending', + resumeAfterFailureFailed: 'Failed to resume queue', + }, + 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.', + button: 'Resume after failure', + confirmTitle: 'Resume queue after failure?', + confirmMessage: 'Restore {{count}} skipped job(s) on {{printer}} to pending and clear the previous-print gate. Make sure the printer is ready before continuing.', }, // Timeline view timeline: { diff --git a/frontend/src/i18n/locales/es.ts b/frontend/src/i18n/locales/es.ts index 1de1c3868..7b8d20ece 100644 --- a/frontend/src/i18n/locales/es.ts +++ b/frontend/src/i18n/locales/es.ts @@ -1229,6 +1229,15 @@ export default { batchCreateFailed: 'Error al crear el lote', batchUngrouped: '{{count}} elemento(s) desagrupado(s)', batchUngroupFailed: 'Error al desagrupar el lote', + resumedAfterFailure: 'Cola reanudada — {{restored}} trabajo(s) restaurado(s) a pendientes', + resumeAfterFailureFailed: 'Error al reanudar la cola', + }, + 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.', + button: 'Reanudar tras fallo', + confirmTitle: '¿Reanudar la cola tras el fallo?', + confirmMessage: 'Restaurar {{count}} trabajo(s) omitido(s) en {{printer}} a pendientes y limpiar el bloqueo del trabajo anterior. Asegúrate de que la impresora esté lista antes de continuar.', }, // Timeline view timeline: { diff --git a/frontend/src/i18n/locales/fr.ts b/frontend/src/i18n/locales/fr.ts index 9ca02de9e..94ed495a2 100644 --- a/frontend/src/i18n/locales/fr.ts +++ b/frontend/src/i18n/locales/fr.ts @@ -1229,6 +1229,15 @@ export default { batchCreateFailed: 'Échec de la création du lot', 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', + resumeAfterFailureFailed: 'Échec de la reprise de la file', + }, + 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.', + button: 'Reprendre après échec', + confirmTitle: 'Reprendre la file après l\'échec ?', + confirmMessage: 'Restaurer {{count}} tâche(s) ignorée(s) sur {{printer}} en attente et lever le blocage de l\'impression précédente. Assurez-vous que l\'imprimante est prête avant de continuer.', }, // Timeline view timeline: { diff --git a/frontend/src/i18n/locales/it.ts b/frontend/src/i18n/locales/it.ts index bcc671564..c765eadc9 100644 --- a/frontend/src/i18n/locales/it.ts +++ b/frontend/src/i18n/locales/it.ts @@ -1229,6 +1229,15 @@ export default { batchCreateFailed: 'Creazione lotto non riuscita', batchUngrouped: '{{count}} elemento/i separato/i', batchUngroupFailed: 'Separazione del lotto non riuscita', + resumedAfterFailure: 'Coda ripresa — {{restored}} processo/i ripristinato/i come in attesa', + resumeAfterFailureFailed: 'Ripresa della coda non riuscita', + }, + 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.', + button: 'Riprendi dopo errore', + confirmTitle: 'Riprendere la coda dopo l\'errore?', + confirmMessage: 'Ripristinare {{count}} processo/i saltato/i su {{printer}} come in attesa e rimuovere il blocco della stampa precedente. Assicurati che la stampante sia pronta prima di continuare.', }, // Timeline view timeline: { diff --git a/frontend/src/i18n/locales/ja.ts b/frontend/src/i18n/locales/ja.ts index 62e3fc40b..8a887ceb6 100644 --- a/frontend/src/i18n/locales/ja.ts +++ b/frontend/src/i18n/locales/ja.ts @@ -1228,6 +1228,15 @@ export default { batchCreateFailed: 'バッチの作成に失敗しました', batchUngrouped: '{{count}}件のグループを解除しました', batchUngroupFailed: 'バッチのグループ解除に失敗しました', + resumedAfterFailure: 'キューを再開しました — {{restored}}件のジョブを保留状態に戻しました', + resumeAfterFailureFailed: 'キューの再開に失敗しました', + }, + resumeAfterFailure: { + banner: '{{printer}} は前回の印刷失敗によりブロックされています — {{count}}件のジョブをスキップしました', + bannerHint: 'プリンターの問題を解決してから再開すると、スキップされたジョブを復元してブロックを解除できます。', + button: '失敗後に再開', + confirmTitle: '失敗後にキューを再開しますか?', + confirmMessage: '{{printer}} のスキップされた {{count}} 件のジョブを保留状態に戻し、前回の印刷ブロックを解除します。続行する前にプリンターが準備完了していることを確認してください。', }, // Timeline view timeline: { diff --git a/frontend/src/i18n/locales/ko.ts b/frontend/src/i18n/locales/ko.ts index 593444e68..637095278 100644 --- a/frontend/src/i18n/locales/ko.ts +++ b/frontend/src/i18n/locales/ko.ts @@ -1161,6 +1161,15 @@ export default { batchCreateFailed: '배치 만들기에 실패했습니다', batchUngrouped: '{{count}}개 항목의 그룹을 해제했습니다', batchUngroupFailed: '배치 그룹 해제에 실패했습니다', + resumedAfterFailure: '큐를 재개했습니다 — {{restored}}개 작업을 대기 상태로 복원했습니다', + resumeAfterFailureFailed: '큐 재개에 실패했습니다', + }, + resumeAfterFailure: { + banner: '{{printer}}이(가) 이전 인쇄 실패로 인해 차단되었습니다 — {{count}}개 작업이 건너뛰어졌습니다', + bannerHint: '프린터 문제를 해결한 후 재개하여 건너뛴 작업을 복원하고 차단을 해제하세요.', + button: '실패 후 재개', + confirmTitle: '실패 후 큐를 재개하시겠습니까?', + confirmMessage: '{{printer}}에서 건너뛴 {{count}}개 작업을 대기 상태로 복원하고 이전 인쇄 차단을 해제합니다. 계속하기 전에 프린터가 준비되었는지 확인하세요.', }, timeline: { listView: '목록', diff --git a/frontend/src/i18n/locales/pt-BR.ts b/frontend/src/i18n/locales/pt-BR.ts index 4730f4db5..5a8cf9990 100644 --- a/frontend/src/i18n/locales/pt-BR.ts +++ b/frontend/src/i18n/locales/pt-BR.ts @@ -1229,6 +1229,15 @@ export default { batchCreateFailed: 'Falha ao criar o lote', batchUngrouped: '{{count}} item(ns) desagrupado(s)', batchUngroupFailed: 'Falha ao desagrupar o lote', + resumedAfterFailure: 'Fila retomada — {{restored}} trabalho(s) restaurado(s) para pendente', + resumeAfterFailureFailed: 'Falha ao retomar a fila', + }, + 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.', + button: 'Retomar após falha', + confirmTitle: 'Retomar a fila após a falha?', + confirmMessage: 'Restaurar {{count}} trabalho(s) ignorado(s) em {{printer}} para pendente e remover o bloqueio da impressão anterior. Verifique se a impressora está pronta antes de continuar.', }, // Timeline view timeline: { diff --git a/frontend/src/i18n/locales/tr.ts b/frontend/src/i18n/locales/tr.ts index b50214a2d..a6a608827 100644 --- a/frontend/src/i18n/locales/tr.ts +++ b/frontend/src/i18n/locales/tr.ts @@ -1229,6 +1229,15 @@ export default { batchCreateFailed: 'Yığın oluşturma başarısız', 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ı', + resumeAfterFailureFailed: 'Kuyruk devam ettirilemedi', + }, + 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.', + button: 'Hatadan sonra devam et', + confirmTitle: 'Hatadan sonra kuyruğa devam edilsin mi?', + confirmMessage: '{{printer}} üzerindeki {{count}} atlanan işi bekleyene geri yükleyin ve önceki baskı engelini kaldırın. Devam etmeden önce yazıcının hazır olduğundan emin olun.', }, // Zaman çizelgesi görünümü timeline: { diff --git a/frontend/src/i18n/locales/zh-CN.ts b/frontend/src/i18n/locales/zh-CN.ts index b80bc9760..de97a8405 100644 --- a/frontend/src/i18n/locales/zh-CN.ts +++ b/frontend/src/i18n/locales/zh-CN.ts @@ -1229,6 +1229,15 @@ export default { batchCreateFailed: '创建批次失败', batchUngrouped: '已取消分组 {{count}} 项', batchUngroupFailed: '取消批次分组失败', + resumedAfterFailure: '已恢复队列 — {{restored}} 个任务已恢复为待处理', + resumeAfterFailureFailed: '恢复队列失败', + }, + resumeAfterFailure: { + banner: '{{printer}} 因之前的打印失败而被阻塞 — 已跳过 {{count}} 个任务', + bannerHint: '解决打印机问题后恢复队列,以还原跳过的任务并清除阻塞。', + button: '失败后恢复', + confirmTitle: '失败后恢复队列?', + confirmMessage: '将 {{printer}} 上的 {{count}} 个跳过的任务恢复为待处理,并清除上一个打印的阻塞。继续之前请确保打印机已就绪。', }, // Timeline view timeline: { diff --git a/frontend/src/i18n/locales/zh-TW.ts b/frontend/src/i18n/locales/zh-TW.ts index 49564514d..399cb895a 100644 --- a/frontend/src/i18n/locales/zh-TW.ts +++ b/frontend/src/i18n/locales/zh-TW.ts @@ -1229,6 +1229,15 @@ export default { batchCreateFailed: '建立批次失敗', batchUngrouped: '已取消分組 {{count}} 項', batchUngroupFailed: '取消批次分組失敗', + resumedAfterFailure: '已恢復佇列 — {{restored}} 個工作已恢復為待處理', + resumeAfterFailureFailed: '恢復佇列失敗', + }, + resumeAfterFailure: { + banner: '{{printer}} 因先前的列印失敗而被阻擋 — 已跳過 {{count}} 個工作', + bannerHint: '解決印表機問題後恢復佇列,以還原跳過的工作並清除阻擋。', + button: '失敗後恢復', + confirmTitle: '失敗後恢復佇列?', + confirmMessage: '將 {{printer}} 上的 {{count}} 個跳過的工作恢復為待處理,並清除上一個列印的阻擋。繼續前請確認印表機已就緒。', }, // Timeline view timeline: { diff --git a/frontend/src/pages/QueuePage.tsx b/frontend/src/pages/QueuePage.tsx index 4dbe9f5d0..d7932452e 100644 --- a/frontend/src/pages/QueuePage.tsx +++ b/frontend/src/pages/QueuePage.tsx @@ -58,6 +58,7 @@ import { PackageOpen, Ungroup, Ban, + PlayCircle, } from 'lucide-react'; import { api, ApiError } from '../api/client'; import { type TimeFormat, formatETA, formatDuration, formatRelativeTime, parseUTCDate } from '../utils/date'; @@ -1208,6 +1209,13 @@ export function QueuePage() { } | null>(null); const [selectedItems, setSelectedItems] = useState([]); const [showBulkEditModal, setShowBulkEditModal] = useState(false); + // #1818: per-printer Resume-after-failure confirm modal. Tracks which + // printer's gate the user is about to clear; null when no modal is open. + const [resumeConfirm, setResumeConfirm] = useState<{ + printerId: number; + printerName: string; + skippedCount: number; + } | null>(null); const [historySortBy, setHistorySortBy] = useState<'date' | 'name' | 'printer'>(() => { const saved = localStorage.getItem('queue.historySortBy'); return (saved as 'date' | 'name' | 'printer') || 'date'; @@ -1431,6 +1439,21 @@ export function QueuePage() { onError: () => showToast(t('queue.toast.bulkCancelFailed'), 'error'), }); + const resumeAfterFailureMutation = useMutation({ + mutationFn: (printerId: number) => api.resumeQueueAfterFailure(printerId), + onSuccess: (result) => { + queryClient.invalidateQueries({ queryKey: ['queue'] }); + setResumeConfirm(null); + showToast( + t('queue.toast.resumedAfterFailure', { + restored: result.restored, + acknowledged: result.acknowledged, + }), + ); + }, + onError: () => showToast(t('queue.toast.resumeAfterFailureFailed'), 'error'), + }); + const createBatchMutation = useMutation({ mutationFn: (data: { name: string; item_ids: number[] }) => api.createBatch(data), onSuccess: (batch) => { @@ -1823,6 +1846,42 @@ export function QueuePage() { }); }, [groupedRows, t]); + // #1818: printers whose queue is gated by a prior failure that's poisoning + // downstream `require_previous_success` items. We surface a per-printer + // Resume banner above the active queue so the user can clear the gate + + // restore the skipped jobs in one click, without re-queuing each one. + // Detection key: skipped + the scheduler's exact gate string. Other skip + // reasons (filament deficit, etc.) get their own UX and stay untouched. + const gateBlockedPrinters = useMemo< + Array<{ printerId: number; printerName: string; skippedCount: number }> + >(() => { + const counts = new Map(); + queue?.forEach((item) => { + if ( + item.status === 'skipped' && + item.error_message === 'Previous print failed or was aborted' && + item.printer_id + ) { + const existing = counts.get(item.printer_id); + if (existing) { + existing.count += 1; + } else { + counts.set(item.printer_id, { + name: item.printer_name || `Printer #${item.printer_id}`, + count: 1, + }); + } + } + }); + return Array.from(counts.entries()) + .map(([printerId, { name, count }]) => ({ + printerId, + printerName: name, + skippedCount: count, + })) + .sort((a, b) => a.printerName.localeCompare(b.printerName)); + }, [queue]); + const aggregateForRows = (rows: QueueRow[]) => { let count = 0; let time = 0; @@ -1891,6 +1950,43 @@ export function QueuePage() { t={t} /> + {/* #1818: Resume-after-failure banner. One row per printer whose queue + is gated by a prior failed/aborted print. Visible regardless of + tab/layout so the user can clear the gate without hunting for + skipped items. Hidden entirely when no gates are active. */} + {activeTab === 'queue' && gateBlockedPrinters.length > 0 && hasPermission('queue:update_all' as Permission) && ( +
+ {gateBlockedPrinters.map(({ printerId, printerName, skippedCount }) => ( +
+ +
+
+ {t('queue.resumeAfterFailure.banner', { + printer: printerName, + count: skippedCount, + })} +
+
+ {t('queue.resumeAfterFailure.bannerHint')} +
+
+ +
+ ))} +
+ )} + {/* Filters */}
without a
`);let s=e.getAttribute(`formaction`)||o.getAttribute(`action`);if(r=s?dn(s,t):null,n=e.getAttribute(`formmethod`)||o.getAttribute(`method`)||Pr,i=qr(e.getAttribute(`formenctype`))||qr(o.getAttribute(`enctype`))||Fr,a=new FormData(o,e),!Gr()){let{name:t,type:n,value:r}=e;if(n===`image`){let e=t?`${t}.`:``;a.append(`${e}x`,`0`),a.append(`${e}y`,`0`)}else t&&a.append(t,r)}}else if(Ir(e))throw Error(`Cannot submit element that is not ,