Bläddra i källkod

fix(permissions): self-heal Administrators to ALL_PERMISSIONS on upgrade + Pipelines runs dashboard polish

    Administrators system group sync
    - Fresh installs already bootstrap with ALL_PERMISSIONS, so they always have
      every permission. Upgrades previously only got what one-off backfill blocks
      in seed_default_groups() explicitly listed (library:purge, archives:purge,
      the OWN/ALL read-flag block, orca_cloud:auth, pipelines:*). Any Permission
      enum member added without a matching block silently stayed missing on
      existing admin rows. The most recent gap was printer_sensor_history:read
      (Sensor History charts returned 403 for upgraded admins).
    - seed_default_groups() now syncs Administrators to ALL_PERMISSIONS on every
      startup: append every Permission value that isn't already on the row.
      Additive only -- hand-added custom permissions are preserved.
    - The pure-admin one-off backfills (library:purge / archives:purge block,
      the OWN/ALL + orca_cloud:auth + legacy-read-flag block, the Administrators
      branch of the pipeline backfill) are retired since the sync subsumes
      them. Non-admin backfills (Operators / Viewers OWN-tier reads, Operators
      orca_cloud:auth, pipelines for non-admin groups, makerworld:*, clear_plate
      cross-group adders) are untouched.
    - Tests: test_administrators_printer_sensor_history_read_backfilled
      (regression for the reported gap),
      test_administrators_sync_covers_every_current_permission (generic
      invariant -- any future new permission lands on admin without needing
      a one-off test), test_administrators_sync_is_additive_only (custom
      permissions preserved). 12/12 backfill-migration + 102/102 broader
      permission tests green; ruff clean.

    Pipelines runs dashboard
    - PipelineRunsPage.tsx: the Pipeline / Status / Target filter row's three
      native <select> elements are replaced with a bambu-themed FilterDropdown
      (button trigger, floating menu, optgroup-style headers for the Target
      picker, hover + selected states with a check mark, closes on outside
      click and Escape). Same value/onChange contract -- visual only.
    - SlicerPipelinesPanel.tsx: wrap list?.pipelines ?? [] in useMemo so the
      reference is stable when the data is stable. Fixes the
      react-hooks/exhaustive-deps warning where the inline fallback returned
      a fresh empty array every render, invalidating both downstream useMemo
      caches (target-options + filtered-pipelines list).
maziggy 2 månader sedan
förälder
incheckning
b26b68c236
61 ändrade filer med 9701 tillägg och 111 borttagningar
  1. 3 0
      CHANGELOG.md
  2. 955 0
      backend/app/api/routes/pipeline_runs.py
  3. 1 0
      backend/app/api/routes/settings.py
  4. 199 0
      backend/app/api/routes/slicer_pipelines.py
  5. 16 0
      backend/app/api/routes/websocket.py
  6. 7 0
      backend/app/core/auth.py
  7. 1 1
      backend/app/core/config.py
  8. 61 49
      backend/app/core/database.py
  9. 16 0
      backend/app/core/permissions.py
  10. 112 0
      backend/app/core/websocket.py
  11. 5 0
      backend/app/main.py
  12. 5 0
      backend/app/models/__init__.py
  13. 111 0
      backend/app/models/pipeline_run.py
  14. 61 0
      backend/app/models/slicer_pipeline.py
  15. 173 0
      backend/app/schemas/pipeline_run.py
  16. 10 0
      backend/app/schemas/settings.py
  17. 83 0
      backend/app/schemas/slicer_pipeline.py
  18. 360 0
      backend/app/services/pipeline_eligibility.py
  19. 139 0
      backend/app/services/print_scheduler.py
  20. 24 7
      backend/app/utils/threemf_tools.py
  21. 927 0
      backend/tests/integration/test_pipeline_runs_api.py
  22. 57 0
      backend/tests/integration/test_read_permission_backfill_migration.py
  23. 158 0
      backend/tests/integration/test_slicer_pipelines_api.py
  24. 55 0
      backend/tests/unit/test_scheduler_ams_mapping.py
  25. 113 0
      backend/tests/unit/test_upload_progress_bridge.py
  26. 138 0
      backend/tests/unit/test_ws_broadcast_to_user.py
  27. 14 0
      frontend/scripts/check-i18n-parity.mjs
  28. 4 0
      frontend/src/App.tsx
  29. 210 0
      frontend/src/__tests__/components/RunWithPipelineModal.test.tsx
  30. 150 9
      frontend/src/__tests__/components/SliceModal.test.tsx
  31. 123 0
      frontend/src/__tests__/contexts/DispatchToastContext.test.tsx
  32. 156 0
      frontend/src/__tests__/pages/PipelineRunsPage.test.tsx
  33. 52 0
      frontend/src/__tests__/pages/SettingsPage.test.tsx
  34. 218 0
      frontend/src/api/client.ts
  35. 411 0
      frontend/src/components/RunWithPipelineModal.tsx
  36. 145 0
      frontend/src/components/SliceModal.tsx
  37. 752 0
      frontend/src/components/SlicerPipelinesPanel.tsx
  38. 331 28
      frontend/src/contexts/ToastContext.tsx
  39. 29 0
      frontend/src/hooks/useWebSocket.ts
  40. 215 0
      frontend/src/i18n/locales/de.ts
  41. 234 0
      frontend/src/i18n/locales/en.ts
  42. 215 0
      frontend/src/i18n/locales/es.ts
  43. 215 0
      frontend/src/i18n/locales/fr.ts
  44. 215 0
      frontend/src/i18n/locales/it.ts
  45. 215 0
      frontend/src/i18n/locales/ja.ts
  46. 216 3
      frontend/src/i18n/locales/ko.ts
  47. 215 0
      frontend/src/i18n/locales/pt-BR.ts
  48. 215 0
      frontend/src/i18n/locales/tr.ts
  49. 215 0
      frontend/src/i18n/locales/zh-CN.ts
  50. 215 0
      frontend/src/i18n/locales/zh-TW.ts
  51. 47 0
      frontend/src/pages/ArchivesPage.tsx
  52. 41 1
      frontend/src/pages/FileManagerPage.tsx
  53. 716 0
      frontend/src/pages/PipelineRunsPage.tsx
  54. 28 7
      frontend/src/pages/QueuePage.tsx
  55. 96 3
      frontend/src/pages/SettingsPage.tsx
  56. 1 0
      static/assets/index-BYzbe9TT.css
  57. 0 1
      static/assets/index-CFvgt_ZD.css
  58. 0 0
      static/assets/index-ChnivgH9.js
  59. 2 2
      static/index.html
  60. 0 0
      test_pipeline_archive_source.3mf
  61. 0 0
      test_pipeline_run_1.3mf

Filskillnaden har hållts tillbaka eftersom den är för stor
+ 3 - 0
CHANGELOG.md


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

@@ -0,0 +1,955 @@
+"""API routes for Slicer Pipeline runs (#1425 PR B + PR C).
+
+PR B implemented single-target dispatch: one Run-pipeline click =
+  slice the source once → enqueue ONE print on ``target_printer_id``.
+
+PR C extends this with:
+  * ``copies > 1`` — slice once, enqueue N copies.
+  * ``target_kind='printer_class'`` — pipeline targets a Bambu model code
+    (X1C / P1S / H2D / …); orchestrator distributes copies across matching
+    printers using the pipeline's ``fanout_strategy``.
+  * Retry-failed runs that re-attempt only the failed/cancelled copies of
+    a partial-failure run.
+  * Dashboard list endpoint (``GET /pipeline-runs``) with status + pipeline
+    filters and pagination.
+  * WebSocket ``pipeline_run_updated`` events on state transitions so the
+    dashboard refreshes live without polling.
+
+The slice itself runs through ``slice_dispatch`` (same path as the manual
+SliceModal), so the ``Slicing X — Generating G-code 75%`` toast renders
+end-to-end. The slice job's id rides on the run response so the frontend
+can call ``trackJob`` directly.
+"""
+
+from __future__ import annotations
+
+import json
+import logging
+from datetime import datetime, timezone
+from pathlib import Path
+from typing import Literal
+
+from fastapi import APIRouter, Depends, HTTPException
+from sqlalchemy import delete, desc, func, select
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from backend.app.core.auth import RequirePermissionIfAuthEnabled
+from backend.app.core.config import settings as app_settings
+from backend.app.core.database import async_session, get_db
+from backend.app.core.permissions import Permission
+from backend.app.core.websocket import ws_manager
+from backend.app.models.archive import PrintArchive
+from backend.app.models.library import LibraryFile
+from backend.app.models.pipeline_run import PipelineJob, PipelineRun
+from backend.app.models.print_queue import PrintQueueItem
+from backend.app.models.printer import Printer
+from backend.app.models.slicer_pipeline import SlicerPipeline
+from backend.app.models.user import User
+from backend.app.schemas.pipeline_run import (
+    CheckEligibilityRequest,
+    EligibilityIssueResponse,
+    EligibilityReportResponse,
+    PerPrinterReport as PerPrinterReportResponse,
+    PipelineJobResponse,
+    PipelineRunCreateRequest,
+    PipelineRunListResponse,
+    PipelineRunResponse,
+)
+from backend.app.schemas.slicer import PresetRef, SliceRequest
+from backend.app.services.pipeline_eligibility import (
+    EligibilityReport,
+    check_pipeline_eligibility,
+)
+
+logger = logging.getLogger(__name__)
+
+
+pipeline_run_create_router = APIRouter(prefix="/slicer-pipelines", tags=["Slicer Pipelines"])
+pipeline_run_router = APIRouter(prefix="/pipeline-runs", tags=["Slicer Pipelines"])
+
+
+# ---------------------------------------------------------------------------
+# Helpers
+# ---------------------------------------------------------------------------
+
+
+def _serialise_status(report: EligibilityReport) -> EligibilityReportResponse:
+    return EligibilityReportResponse(
+        ok=report.ok,
+        target_kind=report.target_kind,
+        target_printer_id=report.target_printer_id,
+        target_printer_name=report.target_printer_name,
+        target_model_class=report.target_model_class,
+        issues=[
+            EligibilityIssueResponse(
+                kind=issue.kind,
+                slot_index=issue.slot_index,
+                expected=issue.expected,
+                actual=issue.actual,
+            )
+            for issue in report.issues
+        ],
+        printer_reports=[
+            PerPrinterReportResponse(
+                printer_id=r.printer_id,
+                printer_name=r.printer_name,
+                ok=r.ok,
+                issues=[
+                    EligibilityIssueResponse(
+                        kind=i.kind,
+                        slot_index=i.slot_index,
+                        expected=i.expected,
+                        actual=i.actual,
+                    )
+                    for i in r.issues
+                ],
+            )
+            for r in report.printer_reports
+        ],
+    )
+
+
+async def _load_pipeline(db: AsyncSession, pipeline_id: int) -> SlicerPipeline:
+    pipeline = (
+        await db.execute(
+            select(SlicerPipeline).where(
+                SlicerPipeline.id == pipeline_id,
+                SlicerPipeline.is_deleted.is_(False),
+            )
+        )
+    ).scalar_one_or_none()
+    if pipeline is None:
+        raise HTTPException(404, "Pipeline not found")
+    return pipeline
+
+
+async def _load_printer_status(printer_id: int | None) -> dict | None:
+    """Snapshot the printer_manager's live PrinterState for the eligibility
+    matcher. Returns ``None`` when the printer has no MQTT client."""
+    if printer_id is None:
+        return None
+    from backend.app.services.printer_manager import printer_manager
+
+    state = printer_manager.get_status(printer_id)
+    if state is None:
+        return None
+    return {"connected": state.connected, "raw_data": state.raw_data}
+
+
+def _make_status_lookup():
+    """Closure that snapshots the printer_manager once per printer_id call.
+    Passed to the matcher's class-targeting branch so it can read live state
+    for every candidate printer."""
+
+    def _lookup(printer_id: int) -> dict | None:
+        from backend.app.services.printer_manager import printer_manager
+
+        state = printer_manager.get_status(printer_id)
+        if state is None:
+            return None
+        return {"connected": state.connected, "raw_data": state.raw_data}
+
+    return _lookup
+
+
+def _slice_request_from_pipeline(pipeline: SlicerPipeline) -> SliceRequest:
+    try:
+        raw_filaments = json.loads(pipeline.filament_presets_json or "[]")
+    except (json.JSONDecodeError, TypeError):
+        raw_filaments = []
+    filament_presets = [
+        PresetRef(source=r["source"], id=r["id"])
+        for r in raw_filaments
+        if isinstance(r, dict) and "source" in r and "id" in r
+    ]
+    return SliceRequest(
+        printer_preset=PresetRef(source=pipeline.printer_preset_source, id=pipeline.printer_preset_id),
+        process_preset=PresetRef(source=pipeline.process_preset_source, id=pipeline.process_preset_id),
+        filament_presets=filament_presets,
+        bed_type=pipeline.bed_type,
+        export_3mf=True,
+    )
+
+
+def _compute_job_status(
+    persisted: str,
+    queue_entry: PrintQueueItem | None,
+) -> str:
+    if persisted in ("failed", "cancelled", "completed"):
+        return persisted
+    if queue_entry is None:
+        return persisted
+    qs = queue_entry.status
+    if qs == "completed":
+        return "completed"
+    if qs in ("failed", "aborted"):
+        return "failed"
+    if qs == "cancelled":
+        return "cancelled"
+    if qs == "printing":
+        return "printing"
+    return "queued"
+
+
+def _roll_up_run_status(
+    persisted: str,
+    job_statuses: list[str],
+) -> str:
+    """Compute the run-level status from the per-job statuses.
+
+    Terminal-persisted always wins for explicit cancels / hard failures so
+    the dashboard doesn't flicker when one job's queue entry hasn't caught
+    up. Otherwise:
+      - all completed → completed
+      - any in_progress / printing / queued / dispatching → in_progress
+      - any failed alongside any completed → partial_failure
+      - all failed/cancelled → failed
+    """
+    if persisted in ("cancelled",):
+        return persisted
+    if not job_statuses:
+        return persisted
+
+    completed = sum(1 for s in job_statuses if s == "completed")
+    failed = sum(1 for s in job_statuses if s == "failed")
+    cancelled = sum(1 for s in job_statuses if s == "cancelled")
+    in_flight = sum(1 for s in job_statuses if s in ("printing", "queued", "awaiting_printer", "pending"))
+    total = len(job_statuses)
+
+    if completed == total:
+        return "completed"
+    if in_flight > 0:
+        return "in_progress" if persisted not in ("queued", "slicing", "dispatching") else persisted
+    # All copies are in terminal states.
+    if failed == 0 and cancelled == total:
+        return "cancelled"
+    if completed > 0 and (failed > 0 or cancelled > 0):
+        return "partial_failure"
+    if failed > 0:
+        return "failed"
+    return persisted
+
+
+async def _materialise_run(db: AsyncSession, run: PipelineRun) -> PipelineRunResponse:
+    pipeline_name: str | None = None
+    target_kind = None
+    target_printer_id = None
+    target_model_class = None
+    fanout_strategy = None
+    if run.pipeline_id:
+        pipeline = (
+            await db.execute(select(SlicerPipeline).where(SlicerPipeline.id == run.pipeline_id))
+        ).scalar_one_or_none()
+        if pipeline:
+            pipeline_name = pipeline.name
+            target_kind = pipeline.target_kind  # type: ignore[assignment]
+            target_printer_id = pipeline.target_printer_id
+            target_model_class = pipeline.target_model_class
+            fanout_strategy = pipeline.fanout_strategy  # type: ignore[assignment]
+
+    source_filename: str | None = None
+    if run.source_library_file_id:
+        src = (
+            await db.execute(select(LibraryFile).where(LibraryFile.id == run.source_library_file_id))
+        ).scalar_one_or_none()
+        source_filename = src.filename if src else None
+    elif run.source_archive_id:
+        arc = (
+            await db.execute(select(PrintArchive).where(PrintArchive.id == run.source_archive_id))
+        ).scalar_one_or_none()
+        source_filename = (arc.print_name or arc.filename) if arc else None
+
+    job_rows = (
+        (
+            await db.execute(
+                select(PipelineJob).where(PipelineJob.pipeline_run_id == run.id).order_by(PipelineJob.copy_index)
+            )
+        )
+        .scalars()
+        .all()
+    )
+
+    job_responses: list[PipelineJobResponse] = []
+    job_live_statuses: list[str] = []
+    for job in job_rows:
+        queue_entry = None
+        if job.queue_entry_id:
+            queue_entry = (
+                await db.execute(select(PrintQueueItem).where(PrintQueueItem.id == job.queue_entry_id))
+            ).scalar_one_or_none()
+
+        printer_name: str | None = None
+        if job.assigned_printer_id:
+            p = (await db.execute(select(Printer).where(Printer.id == job.assigned_printer_id))).scalar_one_or_none()
+            printer_name = p.name if p else None
+
+        live_job_status = _compute_job_status(job.status, queue_entry)
+        # If the job WAS dispatched (had a queue_entry_id) but the entry has
+        # since been deleted from the queue page, the user's intent was
+        # cancellation. Otherwise the run would stay forever showing as
+        # ``queued`` because the persisted job.status hasn't been updated.
+        if (
+            job.queue_entry_id is not None
+            and queue_entry is None
+            and live_job_status not in ("completed", "failed", "cancelled")
+        ):
+            live_job_status = "cancelled"
+        job_live_statuses.append(live_job_status)
+        job_responses.append(
+            PipelineJobResponse(
+                id=job.id,
+                pipeline_run_id=job.pipeline_run_id,
+                copy_index=job.copy_index,
+                assigned_printer_id=job.assigned_printer_id,
+                assigned_printer_name=printer_name,
+                queue_entry_id=job.queue_entry_id,
+                status=live_job_status,  # type: ignore[arg-type]
+                error_message=job.error_message,
+                dispatched_at=job.dispatched_at,
+                completed_at=job.completed_at,
+            )
+        )
+
+    rolled_up = _roll_up_run_status(run.status, job_live_statuses)
+
+    return PipelineRunResponse(
+        id=run.id,
+        pipeline_id=run.pipeline_id,
+        pipeline_name=pipeline_name,
+        source_library_file_id=run.source_library_file_id,
+        source_archive_id=run.source_archive_id,
+        source_filename=source_filename,
+        parent_run_id=run.parent_run_id,
+        copies=run.copies,
+        copies_completed=sum(1 for s in job_live_statuses if s == "completed"),
+        copies_failed=sum(1 for s in job_live_statuses if s == "failed"),
+        copies_cancelled=sum(1 for s in job_live_statuses if s == "cancelled"),
+        copies_in_progress=sum(
+            1 for s in job_live_statuses if s in ("printing", "queued", "awaiting_printer", "pending")
+        ),
+        status=rolled_up,  # type: ignore[arg-type]
+        slice_job_id=run.slice_job_id,
+        sliced_library_file_id=run.sliced_library_file_id,
+        eligibility_overridden=run.eligibility_overridden,
+        error_message=run.error_message,
+        created_by=run.created_by,
+        created_at=run.created_at,
+        started_at=run.started_at,
+        completed_at=run.completed_at,
+        jobs=job_responses,
+        target_kind=target_kind,
+        target_printer_id=target_printer_id,
+        target_model_class=target_model_class,
+        fanout_strategy=fanout_strategy,
+    )
+
+
+async def _publish_run_event(db: AsyncSession, run: PipelineRun) -> None:
+    """Broadcast a ``pipeline_run_updated`` event with the full materialised
+    run. Per-user routing via ``broadcast_to_user`` falls back to a global
+    broadcast when ``created_by`` is None (auth-disabled installs)."""
+    try:
+        payload = await _materialise_run(db, run)
+        await ws_manager.broadcast_to_user(
+            run.created_by,
+            {
+                "type": "pipeline_run_updated",
+                "run": payload.model_dump(mode="json"),
+            },
+        )
+    except Exception:
+        logger.exception("Failed to broadcast pipeline_run_updated for run %d", run.id)
+
+
+# ---------------------------------------------------------------------------
+# Source resolution + orchestration
+# ---------------------------------------------------------------------------
+
+
+SourceKind = Literal["library_file", "archive"]
+
+
+async def _resolve_source(
+    db: AsyncSession,
+    *,
+    library_file_id: int | None,
+    archive_id: int | None,
+) -> tuple[SourceKind, int, str, Path]:
+    if library_file_id is not None:
+        lib = (await db.execute(select(LibraryFile).where(LibraryFile.id == library_file_id))).scalar_one_or_none()
+        if lib is None:
+            raise HTTPException(404, "Source library file not found")
+        src_path = (
+            Path(app_settings.base_dir) / lib.file_path
+        )  # SEC-PATH-OK: lib.file_path is a LibraryFile DB column set only by the upload route, which writes a UUID-named file under base_dir/library_files/.
+        if not src_path.exists():
+            raise HTTPException(404, "Source library file missing on disk")
+        return ("library_file", lib.id, lib.filename, src_path)
+
+    assert archive_id is not None
+    arc = (await db.execute(select(PrintArchive).where(PrintArchive.id == archive_id))).scalar_one_or_none()
+    if arc is None:
+        raise HTTPException(404, "Source archive not found")
+    rel = arc.source_3mf_path or arc.file_path
+    if not rel:
+        raise HTTPException(400, "Archive has no source file to slice")
+    src_path = (
+        Path(app_settings.base_dir) / rel
+    )  # SEC-PATH-OK: rel is archive.source_3mf_path / archive.file_path, both set by upload-time validators that already do resolve+relative_to containment.
+    if not src_path.exists():
+        raise HTTPException(404, "Archive source file missing on disk")
+    name = arc.filename or arc.print_name or src_path.name
+    return ("archive", arc.id, name, src_path)
+
+
+async def _pick_assignments(
+    db: AsyncSession,
+    pipeline: SlicerPipeline,
+    copies: int,
+) -> list[tuple[int | None, str | None]]:
+    """Return ``[(printer_id_or_None, target_model_or_None), ...]`` of length
+    ``copies`` per the pipeline's fanout strategy. ``target_model_class``
+    items leave ``printer_id`` None so the scheduler picks any free matching
+    printer; specific assignments fill ``printer_id``."""
+    target_kind = pipeline.target_kind or "specific_printer"
+    if target_kind == "specific_printer" or pipeline.target_printer_id is not None:
+        assert pipeline.target_printer_id is not None
+        return [(pipeline.target_printer_id, None)] * copies
+
+    # Class-targeting. Enumerate matching printers + apply the strategy.
+    matching = (
+        (
+            await db.execute(
+                select(Printer)
+                .where(Printer.model == pipeline.target_model_class)
+                .where(Printer.is_active.is_(True))
+                .order_by(Printer.id)
+            )
+        )
+        .scalars()
+        .all()
+    )
+    if not matching:
+        # Shouldn't reach here when eligibility passes, but failing gracefully
+        # is better than a TypeError on next-slot pick.
+        return [(None, pipeline.target_model_class)] * copies
+
+    strategy = pipeline.fanout_strategy or "max_parallel"
+    if strategy == "fill_one_first":
+        # Pin every copy to the first match. Scheduler dispatches them serially
+        # to that printer. If the printer breaks, copies wait; that's the
+        # documented trade-off.
+        return [(matching[0].id, None)] * copies
+    if strategy == "round_robin":
+        # Cycle through eligible printers — copy ``i`` lands on
+        # ``matching[i % len(matching)]``. Each item gets a fixed printer_id.
+        return [(matching[i % len(matching)].id, None) for i in range(copies)]
+    # max_parallel — leave printer_id=None, set target_model so the scheduler
+    # picks any free X1C / P1S / … for each item independently.
+    return [(None, pipeline.target_model_class)] * copies
+
+
+def _make_orchestration_callable(
+    *,
+    run_id: int,
+    pipeline_id: int,
+    src_kind: SourceKind,
+    src_id: int,
+    src_filename: str,
+    src_path: Path,
+    creator_user_id: int | None,
+    copies: int,
+):
+    """Returns the async callable that ``slice_dispatch.enqueue`` runs as the
+    background slice job. Wraps slice + multi-copy enqueue + state update."""
+
+    async def _orchestrate(slice_job_id: int) -> dict:
+        from backend.app.api.routes.library import slice_and_persist
+
+        async with async_session() as session:
+            run = (await session.execute(select(PipelineRun).where(PipelineRun.id == run_id))).scalar_one_or_none()
+            pipeline = (
+                await session.execute(select(SlicerPipeline).where(SlicerPipeline.id == pipeline_id))
+            ).scalar_one_or_none()
+            if run is None or pipeline is None:
+                logger.warning("pipeline_run %d or pipeline %d disappeared mid-orchestration", run_id, pipeline_id)
+                return {}
+
+            # Honour a cancel that landed between ``POST /run`` returning and
+            # this background task starting. If the run was cancelled while
+            # still in ``queued`` we must NOT flip it back to ``slicing`` —
+            # the operator's intent was to stop, and overwriting status here
+            # was the bug that left runs stuck at ``dispatching`` after a
+            # user-side cancel (#1425 PR C bug report).
+            if run.status == "cancelled":
+                logger.info("pipeline_run %d was cancelled before slicing started", run_id)
+                return {}
+
+            run.status = "slicing"
+            run.started_at = datetime.now(timezone.utc)
+            await session.commit()
+            await _publish_run_event(session, run)
+
+            slice_request = _slice_request_from_pipeline(pipeline)
+            model_bytes = src_path.read_bytes()
+
+            folder_id: int | None = None
+            if src_kind == "library_file":
+                lib = (await session.execute(select(LibraryFile).where(LibraryFile.id == src_id))).scalar_one_or_none()
+                if lib is not None:
+                    folder_id = lib.folder_id
+
+            try:
+                slice_response = await slice_and_persist(
+                    session,
+                    model_bytes=model_bytes,
+                    model_filename=src_filename,
+                    folder_id=folder_id,
+                    extra_metadata={
+                        f"sliced_from_{src_kind}_id": src_id,
+                        "sliced_via_pipeline_id": pipeline.id,
+                        "sliced_via_pipeline_run_id": run.id,
+                    },
+                    request=slice_request,
+                    current_user_id=creator_user_id,
+                    job_id=slice_job_id,
+                )
+            except HTTPException as exc:
+                run.status = "failed"
+                run.error_message = f"Slice failed: {exc.detail}"
+                run.completed_at = datetime.now(timezone.utc)
+                await session.commit()
+                await _publish_run_event(session, run)
+                raise
+            except Exception as exc:
+                logger.exception("Pipeline run %d slice raised unexpectedly", run_id)
+                run.status = "failed"
+                run.error_message = f"Slice failed: {exc}"
+                run.completed_at = datetime.now(timezone.utc)
+                await session.commit()
+                await _publish_run_event(session, run)
+                raise
+
+            run.sliced_library_file_id = slice_response.library_file_id
+
+            # Re-check cancellation: the slice can take minutes, and the
+            # operator may have hit Cancel during that window. Refresh from
+            # the DB rather than trusting our in-memory `run` (the cancel
+            # route writes via a separate session). When cancelled, don't
+            # enqueue print queue items — that's the whole point of cancel.
+            await session.refresh(run)
+            if run.status == "cancelled":
+                logger.info("pipeline_run %d cancelled mid-slice; skipping queue enqueue", run_id)
+                await session.commit()
+                return slice_response.model_dump()
+
+            # PR C: enqueue N copies per the picked assignment strategy.
+            assignments = await _pick_assignments(session, pipeline, copies)
+
+            jobs = (
+                (
+                    await session.execute(
+                        select(PipelineJob)
+                        .where(PipelineJob.pipeline_run_id == run_id)
+                        .order_by(PipelineJob.copy_index)
+                    )
+                )
+                .scalars()
+                .all()
+            )
+            if len(jobs) != copies:
+                logger.warning("pipeline_run %d expected %d jobs, found %d", run_id, copies, len(jobs))
+
+            for job, (printer_id, target_model) in zip(jobs, assignments, strict=False):
+                queue_item = PrintQueueItem(
+                    printer_id=printer_id,
+                    target_model=target_model,
+                    library_file_id=slice_response.library_file_id,
+                    created_by_id=creator_user_id,
+                    status="pending",
+                )
+                session.add(queue_item)
+                await session.flush()
+
+                job.queue_entry_id = queue_item.id
+                job.assigned_printer_id = printer_id  # may be None for max_parallel
+                # Don't write job.status yet — final cancellation check below
+                # may flip it to 'cancelled' instead. dispatched_at is fine to
+                # set unconditionally since the orchestration actually got here.
+                job.dispatched_at = datetime.now(timezone.utc)
+
+            # Final cancellation check before committing 'dispatching'. The
+            # cancel route writes via a separate session so we have to refresh
+            # to see the latest. If the cancel landed in this narrow window —
+            # AFTER the post-slice refresh but BEFORE this commit — the queue
+            # entries we just created would otherwise pick up and print. Mark
+            # them + the per-copy jobs cancelled so the user's intent sticks.
+            await session.refresh(run)
+            if run.status == "cancelled":
+                logger.info(
+                    "pipeline_run %d cancelled in the dispatch window; cancelling its %d queue entries",
+                    run_id,
+                    len(jobs),
+                )
+                for job in jobs:
+                    if job.queue_entry_id:
+                        qe = (
+                            await session.execute(select(PrintQueueItem).where(PrintQueueItem.id == job.queue_entry_id))
+                        ).scalar_one_or_none()
+                        if qe is not None and qe.status in ("pending", "queued"):
+                            qe.status = "cancelled"
+                    if job.status not in ("completed", "failed", "cancelled"):
+                        job.status = "cancelled"
+                        job.completed_at = datetime.now(timezone.utc)
+                await session.commit()
+                await _publish_run_event(session, run)
+                return slice_response.model_dump()
+
+            for job in jobs:
+                job.status = "queued"
+            run.status = "dispatching"
+            await session.commit()
+            await _publish_run_event(session, run)
+
+            return slice_response.model_dump()
+
+    return _orchestrate
+
+
+# ---------------------------------------------------------------------------
+# /slicer-pipelines/{id}/check-eligibility
+# ---------------------------------------------------------------------------
+
+
+@pipeline_run_create_router.post("/{pipeline_id}/check-eligibility", response_model=EligibilityReportResponse)
+async def check_eligibility(
+    pipeline_id: int,
+    body: CheckEligibilityRequest,
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.PIPELINES_READ),
+    db: AsyncSession = Depends(get_db),
+):
+    pipeline = await _load_pipeline(db, pipeline_id)
+    await _resolve_source(
+        db,
+        library_file_id=body.source_library_file_id,
+        archive_id=body.source_archive_id,
+    )
+    if pipeline.target_kind == "printer_class" and pipeline.target_printer_id is None:
+        report = await check_pipeline_eligibility(db, pipeline, status_lookup=_make_status_lookup())
+    else:
+        status = await _load_printer_status(pipeline.target_printer_id)
+        report = await check_pipeline_eligibility(db, pipeline, status)
+    return _serialise_status(report)
+
+
+# ---------------------------------------------------------------------------
+# /slicer-pipelines/{id}/run
+# ---------------------------------------------------------------------------
+
+
+@pipeline_run_create_router.post("/{pipeline_id}/run", response_model=PipelineRunResponse, status_code=202)
+async def run_pipeline(
+    pipeline_id: int,
+    body: PipelineRunCreateRequest,
+    current_user: User | None = RequirePermissionIfAuthEnabled(Permission.PIPELINES_RUN),
+    db: AsyncSession = Depends(get_db),
+):
+    from backend.app.api.routes.settings import get_setting
+    from backend.app.services.slice_dispatch import slice_dispatch
+
+    pipeline = await _load_pipeline(db, pipeline_id)
+    src_kind, src_id, src_filename, src_path = await _resolve_source(
+        db,
+        library_file_id=body.source_library_file_id,
+        archive_id=body.source_archive_id,
+    )
+
+    # Cap copies against the configured ceiling.
+    raw_cap = await get_setting(db, "pipeline_max_copies")
+    try:
+        cap = int(raw_cap) if raw_cap else 50
+    except (TypeError, ValueError):
+        cap = 50
+    if body.copies > cap:
+        raise HTTPException(
+            422,
+            f"copies={body.copies} exceeds pipeline_max_copies setting ({cap})",
+        )
+
+    # Eligibility pre-flight.
+    if pipeline.target_kind == "printer_class" and pipeline.target_printer_id is None:
+        report = await check_pipeline_eligibility(db, pipeline, status_lookup=_make_status_lookup())
+    else:
+        status = await _load_printer_status(pipeline.target_printer_id)
+        report = await check_pipeline_eligibility(db, pipeline, status)
+
+    if not report.ok and not body.force:
+        raise HTTPException(status_code=409, detail=_serialise_status(report).model_dump())
+
+    # Need a target — specific or class — to dispatch.
+    if pipeline.target_printer_id is None and not pipeline.target_model_class:
+        raise HTTPException(
+            400,
+            "Pipeline has no target. Open the pipeline in Settings → Workflow → Pipelines and choose a target printer or printer class.",
+        )
+
+    run = PipelineRun(
+        pipeline_id=pipeline.id,
+        source_library_file_id=src_id if src_kind == "library_file" else None,
+        source_archive_id=src_id if src_kind == "archive" else None,
+        copies=body.copies,
+        status="queued",
+        eligibility_overridden=(not report.ok and body.force),
+        created_by=current_user.id if current_user else None,
+    )
+    db.add(run)
+    await db.flush()
+
+    # One PipelineJob per copy. PR B was copies=1, PR C generalises.
+    for i in range(body.copies):
+        db.add(
+            PipelineJob(
+                pipeline_run_id=run.id,
+                copy_index=i,
+                status="pending",
+            )
+        )
+    await db.commit()
+    await db.refresh(run)
+    await _publish_run_event(db, run)
+
+    orchestrate = _make_orchestration_callable(
+        run_id=run.id,
+        pipeline_id=pipeline.id,
+        src_kind=src_kind,
+        src_id=src_id,
+        src_filename=src_filename,
+        src_path=src_path,
+        creator_user_id=current_user.id if current_user else None,
+        copies=body.copies,
+    )
+    slice_job = await slice_dispatch.enqueue(
+        kind="library_file" if src_kind == "library_file" else "archive",
+        source_id=src_id,
+        source_name=src_filename,
+        run=orchestrate,
+    )
+
+    run.slice_job_id = slice_job.id
+    await db.commit()
+    await db.refresh(run)
+
+    return await _materialise_run(db, run)
+
+
+# ---------------------------------------------------------------------------
+# Lists, reads, cancel, retry-failed
+# ---------------------------------------------------------------------------
+
+
+@pipeline_run_create_router.get("/{pipeline_id}/runs", response_model=PipelineRunListResponse)
+async def list_runs_for_pipeline(
+    pipeline_id: int,
+    limit: int = 10,
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.PIPELINES_READ),
+    db: AsyncSession = Depends(get_db),
+):
+    limit = max(1, min(limit, 100))
+    rows = (
+        (
+            await db.execute(
+                select(PipelineRun)
+                .where(PipelineRun.pipeline_id == pipeline_id)
+                .order_by(PipelineRun.id.desc())
+                .limit(limit)
+            )
+        )
+        .scalars()
+        .all()
+    )
+    total = (
+        await db.execute(select(func.count()).select_from(PipelineRun).where(PipelineRun.pipeline_id == pipeline_id))
+    ).scalar() or 0
+    return PipelineRunListResponse(
+        runs=[await _materialise_run(db, r) for r in rows],
+        total=total,
+    )
+
+
+@pipeline_run_router.get("", response_model=PipelineRunListResponse)
+async def list_all_runs(
+    limit: int = 25,
+    offset: int = 0,
+    pipeline_id: int | None = None,
+    status: str | None = None,
+    target_printer_id: int | None = None,
+    target_model_class: str | None = None,
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.PIPELINES_READ),
+    db: AsyncSession = Depends(get_db),
+):
+    """Dashboard list. Newest first; filters on pipeline_id + status +
+    target_printer_id + target_model_class. The ``status`` filter matches
+    the persisted snapshot, not the live roll-up — in-progress runs may
+    appear under ``dispatching`` until the next state transition writes
+    through. ``target_*`` filters JOIN to the pipeline so runs whose
+    pipeline currently points at the printer / class are returned."""
+    limit = max(1, min(limit, 100))
+    offset = max(0, offset)
+
+    stmt = select(PipelineRun)
+    count_stmt = select(func.count()).select_from(PipelineRun)
+    if pipeline_id is not None:
+        stmt = stmt.where(PipelineRun.pipeline_id == pipeline_id)
+        count_stmt = count_stmt.where(PipelineRun.pipeline_id == pipeline_id)
+    if status:
+        stmt = stmt.where(PipelineRun.status == status)
+        count_stmt = count_stmt.where(PipelineRun.status == status)
+    if target_printer_id is not None or target_model_class is not None:
+        stmt = stmt.join(SlicerPipeline, SlicerPipeline.id == PipelineRun.pipeline_id)
+        count_stmt = count_stmt.join(SlicerPipeline, SlicerPipeline.id == PipelineRun.pipeline_id)
+        if target_printer_id is not None:
+            stmt = stmt.where(SlicerPipeline.target_printer_id == target_printer_id)
+            count_stmt = count_stmt.where(SlicerPipeline.target_printer_id == target_printer_id)
+        if target_model_class is not None:
+            stmt = stmt.where(SlicerPipeline.target_model_class == target_model_class)
+            count_stmt = count_stmt.where(SlicerPipeline.target_model_class == target_model_class)
+
+    rows = (await db.execute(stmt.order_by(desc(PipelineRun.id)).offset(offset).limit(limit))).scalars().all()
+    total = (await db.execute(count_stmt)).scalar() or 0
+
+    return PipelineRunListResponse(
+        runs=[await _materialise_run(db, r) for r in rows],
+        total=total,
+    )
+
+
+_TERMINAL_RUN_STATUSES = ("completed", "failed", "cancelled", "partial_failure")
+
+
+@pipeline_run_router.post("/clear")
+async def clear_terminal_runs(
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.PIPELINES_WRITE),
+    db: AsyncSession = Depends(get_db),
+):
+    """Delete every terminal pipeline run (completed / failed / cancelled /
+    partial_failure). In-flight runs (queued / slicing / dispatching /
+    in_progress) are preserved — clearing those mid-flight would lose the
+    operator's intent. Cascades to PipelineJob via the ondelete='CASCADE'
+    relationship; the linked PrintQueueItem rows stay (they have their own
+    lifecycle on the queue page)."""
+    # Count first so the response can report how many got cleared. Done
+    # under the same session/transaction as the delete so the numbers can't
+    # drift if another caller races in.
+    count_stmt = select(func.count()).select_from(PipelineRun).where(PipelineRun.status.in_(_TERMINAL_RUN_STATUSES))
+    n = (await db.execute(count_stmt)).scalar() or 0
+    if n > 0:
+        await db.execute(delete(PipelineRun).where(PipelineRun.status.in_(_TERMINAL_RUN_STATUSES)))
+        await db.commit()
+    return {"deleted": n}
+
+
+@pipeline_run_router.get("/{run_id}", response_model=PipelineRunResponse)
+async def get_run(
+    run_id: int,
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.PIPELINES_READ),
+    db: AsyncSession = Depends(get_db),
+):
+    run = (await db.execute(select(PipelineRun).where(PipelineRun.id == run_id))).scalar_one_or_none()
+    if run is None:
+        raise HTTPException(404, "Pipeline run not found")
+    return await _materialise_run(db, run)
+
+
+@pipeline_run_router.post("/{run_id}/cancel", response_model=PipelineRunResponse)
+async def cancel_run(
+    run_id: int,
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.PIPELINES_RUN),
+    db: AsyncSession = Depends(get_db),
+):
+    """Cancel a queued / in-flight run. Cascades to all non-terminal queue
+    entries; in-flight prints continue on the printer (operator must Stop)."""
+    run = (await db.execute(select(PipelineRun).where(PipelineRun.id == run_id))).scalar_one_or_none()
+    if run is None:
+        raise HTTPException(404, "Pipeline run not found")
+
+    if run.status in ("completed", "failed", "cancelled", "partial_failure"):
+        return await _materialise_run(db, run)
+
+    run.status = "cancelled"
+    run.completed_at = datetime.now(timezone.utc)
+    if not run.error_message:
+        run.error_message = "Cancelled by user"
+
+    job_rows = (await db.execute(select(PipelineJob).where(PipelineJob.pipeline_run_id == run.id))).scalars().all()
+    for job in job_rows:
+        if job.queue_entry_id:
+            queue_entry = (
+                await db.execute(select(PrintQueueItem).where(PrintQueueItem.id == job.queue_entry_id))
+            ).scalar_one_or_none()
+            if queue_entry is not None and queue_entry.status in ("pending", "queued"):
+                queue_entry.status = "cancelled"
+        if job.status not in ("completed", "failed", "cancelled"):
+            job.status = "cancelled"
+            job.completed_at = datetime.now(timezone.utc)
+
+    await db.commit()
+    await db.refresh(run)
+    await _publish_run_event(db, run)
+    return await _materialise_run(db, run)
+
+
+@pipeline_run_router.post("/{run_id}/retry-failed", response_model=PipelineRunResponse, status_code=202)
+async def retry_failed(
+    run_id: int,
+    current_user: User | None = RequirePermissionIfAuthEnabled(Permission.PIPELINES_RUN),
+    db: AsyncSession = Depends(get_db),
+):
+    """Create a new run with copies = (failed + cancelled count) from the
+    parent. Same pipeline, same source. Eligibility re-checked at run time
+    (it might pass this time — operator may have fixed the issue)."""
+    parent = (await db.execute(select(PipelineRun).where(PipelineRun.id == run_id))).scalar_one_or_none()
+    if parent is None:
+        raise HTTPException(404, "Pipeline run not found")
+    if parent.pipeline_id is None:
+        raise HTTPException(400, "Original pipeline was deleted; cannot retry")
+    if parent.source_library_file_id is None and parent.source_archive_id is None:
+        raise HTTPException(400, "Original source was deleted; cannot retry")
+
+    # Count the parent's failed + cancelled jobs.
+    parent_jobs = (
+        (await db.execute(select(PipelineJob).where(PipelineJob.pipeline_run_id == parent.id))).scalars().all()
+    )
+    fail_count = 0
+    for j in parent_jobs:
+        queue_entry = None
+        if j.queue_entry_id:
+            queue_entry = (
+                await db.execute(select(PrintQueueItem).where(PrintQueueItem.id == j.queue_entry_id))
+            ).scalar_one_or_none()
+        live = _compute_job_status(j.status, queue_entry)
+        if live in ("failed", "cancelled"):
+            fail_count += 1
+
+    if fail_count == 0:
+        raise HTTPException(400, "No failed copies to retry")
+
+    # Build the request payload the same way the user would have via /run.
+    body = PipelineRunCreateRequest(
+        source_library_file_id=parent.source_library_file_id,
+        source_archive_id=parent.source_archive_id,
+        copies=fail_count,
+        force=True,  # operator already accepted eligibility on the parent
+    )
+
+    # Reuse the run_pipeline route logic via a direct call — keeps the
+    # orchestration single-sourced. The result inherits parent_run_id.
+    new_run_response = await run_pipeline(parent.pipeline_id, body, current_user=current_user, db=db)
+
+    # Stamp parent_run_id on the freshly-created run.
+    new_row = (await db.execute(select(PipelineRun).where(PipelineRun.id == new_run_response.id))).scalar_one_or_none()
+    if new_row is not None:
+        new_row.parent_run_id = parent.id
+        await db.commit()
+        await db.refresh(new_row)
+        return await _materialise_run(db, new_row)
+    return new_run_response

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

@@ -164,6 +164,7 @@ async def _build_settings_response(db: AsyncSession, is_api_key: bool = False) -
             "stagger_interval_minutes",
             "stagger_interval_minutes",
             "forecast_global_lead_time_days",
             "forecast_global_lead_time_days",
             "session_max_hours",
             "session_max_hours",
+            "pipeline_max_copies",
         ]:
         ]:
             settings_dict[setting.key] = int(setting.value)
             settings_dict[setting.key] = int(setting.value)
         elif setting.key == "default_printer_id":
         elif setting.key == "default_printer_id":

+ 199 - 0
backend/app/api/routes/slicer_pipelines.py

@@ -0,0 +1,199 @@
+"""API routes for Slicer Pipelines (#1425, PR A — definitions only).
+
+A pipeline bundles printer / process / filament(s) / bed-type picks so the
+SliceModal can apply them in one click. PR A surfaces only CRUD + an
+``apply`` helper that returns the pipeline as the four ``PresetRef`` slots a
+``SliceRequest`` expects. PR B adds single-target dispatch; PR C adds
+multi-copy fanout and the run dashboard.
+"""
+
+import json
+import logging
+
+from fastapi import APIRouter, Depends, HTTPException
+from sqlalchemy import select
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from backend.app.core.auth import RequirePermissionIfAuthEnabled
+from backend.app.core.database import get_db
+from backend.app.core.permissions import Permission
+from backend.app.models.slicer_pipeline import SlicerPipeline
+from backend.app.models.user import User
+from backend.app.schemas.slicer import PresetRef
+from backend.app.schemas.slicer_pipeline import (
+    SlicerPipelineCreate,
+    SlicerPipelineListResponse,
+    SlicerPipelineResponse,
+    SlicerPipelineUpdate,
+)
+
+logger = logging.getLogger(__name__)
+
+router = APIRouter(prefix="/slicer-pipelines", tags=["Slicer Pipelines"])
+
+
+def _to_response(row: SlicerPipeline) -> SlicerPipelineResponse:
+    """Materialise the JSON filament list back into PresetRef objects so the
+    response shape matches the create/update input shape exactly."""
+    try:
+        raw = json.loads(row.filament_presets_json) if row.filament_presets_json else []
+    except (json.JSONDecodeError, TypeError):
+        # Row was hand-edited or corrupted — return an empty list rather than
+        # 500ing on a list endpoint. Edit/run paths will surface the problem.
+        logger.warning("slicer_pipeline %d has invalid filament_presets_json", row.id)
+        raw = []
+    filament_presets = [PresetRef(**f) for f in raw if isinstance(f, dict)]
+
+    return SlicerPipelineResponse(
+        id=row.id,
+        name=row.name,
+        description=row.description,
+        printer_preset=PresetRef(source=row.printer_preset_source, id=row.printer_preset_id),
+        process_preset=PresetRef(source=row.process_preset_source, id=row.process_preset_id),
+        filament_presets=filament_presets,
+        bed_type=row.bed_type,
+        target_kind=row.target_kind,  # type: ignore[arg-type]
+        target_printer_id=row.target_printer_id,
+        target_model_class=row.target_model_class,
+        fanout_strategy=row.fanout_strategy,  # type: ignore[arg-type]
+        created_by=row.created_by,
+        created_at=row.created_at,
+        updated_at=row.updated_at,
+    )
+
+
+@router.get("/", response_model=SlicerPipelineListResponse)
+async def list_pipelines(
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.PIPELINES_READ),
+    db: AsyncSession = Depends(get_db),
+):
+    """List all pipelines, newest first. Soft-deleted rows are hidden."""
+    result = await db.execute(
+        select(SlicerPipeline).where(SlicerPipeline.is_deleted.is_(False)).order_by(SlicerPipeline.id.desc())
+    )
+    rows = result.scalars().all()
+    return SlicerPipelineListResponse(pipelines=[_to_response(r) for r in rows])
+
+
+@router.post("/", response_model=SlicerPipelineResponse, status_code=201)
+async def create_pipeline(
+    data: SlicerPipelineCreate,
+    current_user: User | None = RequirePermissionIfAuthEnabled(Permission.PIPELINES_WRITE),
+    db: AsyncSession = Depends(get_db),
+):
+    """Create a new pipeline."""
+    row = SlicerPipeline(
+        name=data.name.strip(),
+        description=data.description,
+        printer_preset_source=data.printer_preset.source,
+        printer_preset_id=data.printer_preset.id,
+        process_preset_source=data.process_preset.source,
+        process_preset_id=data.process_preset.id,
+        filament_presets_json=json.dumps([f.model_dump() for f in data.filament_presets]),
+        bed_type=data.bed_type,
+        created_by=current_user.id if current_user else None,
+    )
+    db.add(row)
+    await db.commit()
+    await db.refresh(row)
+    return _to_response(row)
+
+
+@router.get("/{pipeline_id}", response_model=SlicerPipelineResponse)
+async def get_pipeline(
+    pipeline_id: int,
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.PIPELINES_READ),
+    db: AsyncSession = Depends(get_db),
+):
+    """Read one pipeline by id."""
+    result = await db.execute(
+        select(SlicerPipeline).where(
+            SlicerPipeline.id == pipeline_id,
+            SlicerPipeline.is_deleted.is_(False),
+        )
+    )
+    row = result.scalar_one_or_none()
+    if not row:
+        raise HTTPException(404, "Pipeline not found")
+    return _to_response(row)
+
+
+@router.put("/{pipeline_id}", response_model=SlicerPipelineResponse)
+async def update_pipeline(
+    pipeline_id: int,
+    data: SlicerPipelineUpdate,
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.PIPELINES_WRITE),
+    db: AsyncSession = Depends(get_db),
+):
+    """Update a pipeline. Only fields present in the payload are written."""
+    result = await db.execute(
+        select(SlicerPipeline).where(
+            SlicerPipeline.id == pipeline_id,
+            SlicerPipeline.is_deleted.is_(False),
+        )
+    )
+    row = result.scalar_one_or_none()
+    if not row:
+        raise HTTPException(404, "Pipeline not found")
+
+    if data.name is not None:
+        row.name = data.name.strip()
+    if data.description is not None:
+        row.description = data.description
+    if data.printer_preset is not None:
+        row.printer_preset_source = data.printer_preset.source
+        row.printer_preset_id = data.printer_preset.id
+    if data.process_preset is not None:
+        row.process_preset_source = data.process_preset.source
+        row.process_preset_id = data.process_preset.id
+    if data.filament_presets is not None:
+        row.filament_presets_json = json.dumps([f.model_dump() for f in data.filament_presets])
+    if data.bed_type is not None:
+        row.bed_type = data.bed_type
+
+    # PR B target binding. The schema accepts ``target_kind=specific_printer``
+    # without ``target_printer_id`` (operator may be saving the kind first),
+    # but a 'specific_printer' kind with a printer_id of 0 is rejected since
+    # printer ids are always positive — guard against the JSON-coerced
+    # empty-string case from the frontend.
+    if data.target_kind is not None:
+        row.target_kind = data.target_kind
+    if data.target_printer_id is not None:
+        # ``target_printer_id=0`` from the frontend means "clear the target"
+        # (the <option value=""> case). Anything positive must reference an
+        # actual printer row.
+        if data.target_printer_id == 0:
+            row.target_printer_id = None
+        else:
+            row.target_printer_id = data.target_printer_id
+    # PR C — class targeting + fanout strategy. Empty string from the frontend
+    # also clears the class (radio toggled away).
+    if data.target_model_class is not None:
+        row.target_model_class = data.target_model_class or None
+    if data.fanout_strategy is not None:
+        row.fanout_strategy = data.fanout_strategy
+
+    await db.commit()
+    await db.refresh(row)
+    return _to_response(row)
+
+
+@router.delete("/{pipeline_id}", status_code=204)
+async def delete_pipeline(
+    pipeline_id: int,
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.PIPELINES_WRITE),
+    db: AsyncSession = Depends(get_db),
+):
+    """Soft-delete a pipeline (sets is_deleted=True so PR B+ run history can
+    still resolve pipeline metadata)."""
+    result = await db.execute(
+        select(SlicerPipeline).where(
+            SlicerPipeline.id == pipeline_id,
+            SlicerPipeline.is_deleted.is_(False),
+        )
+    )
+    row = result.scalar_one_or_none()
+    if not row:
+        raise HTTPException(404, "Pipeline not found")
+    row.is_deleted = True
+    await db.commit()

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

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

+ 7 - 0
backend/app/core/auth.py

@@ -222,6 +222,13 @@ _APIKEY_DENIED_PERMISSIONS: frozenset[Permission] = frozenset(
         Permission.SMART_PLUGS_DELETE,
         Permission.SMART_PLUGS_DELETE,
         # Network scanning — operator only (no API-key scope for this).
         # Network scanning — operator only (no API-key scope for this).
         Permission.DISCOVERY_SCAN,
         Permission.DISCOVERY_SCAN,
+        # Slicer Pipelines (#1425) — admin authoring + the print-spending Run
+        # action. PR A only ships CRUD; PR B / PR C may move PIPELINES_RUN onto
+        # `can_queue` (it queues prints) once the run dispatch lands. PR A keeps
+        # all three denied so they fail closed for any API-key surface.
+        Permission.PIPELINES_READ,
+        Permission.PIPELINES_WRITE,
+        Permission.PIPELINES_RUN,
     }
     }
 )
 )
 
 

+ 1 - 1
backend/app/core/config.py

@@ -6,7 +6,7 @@ from pathlib import Path
 from pydantic_settings import BaseSettings
 from pydantic_settings import BaseSettings
 
 
 # Application version - single source of truth
 # Application version - single source of truth
-APP_VERSION = "0.2.4.8"
+APP_VERSION = "0.2.4.9"
 GITHUB_REPO = "maziggy/bambuddy"
 GITHUB_REPO = "maziggy/bambuddy"
 BUG_REPORT_RELAY_URL = os.environ.get("BUG_REPORT_RELAY_URL", "https://bambuddy.cool/api/bug-report")
 BUG_REPORT_RELAY_URL = os.environ.get("BUG_REPORT_RELAY_URL", "https://bambuddy.cool/api/bug-report")
 
 

+ 61 - 49
backend/app/core/database.py

@@ -189,6 +189,7 @@ async def init_db():
         oidc_provider,
         oidc_provider,
         orca_base_cache,
         orca_base_cache,
         pending_upload,
         pending_upload,
+        pipeline_run,
         print_batch,
         print_batch,
         print_log,
         print_log,
         print_queue,
         print_queue,
@@ -198,6 +199,7 @@ async def init_db():
         project_bom,
         project_bom,
         settings,
         settings,
         shopping_list,
         shopping_list,
+        slicer_pipeline,
         slot_preset,
         slot_preset,
         smart_plug,
         smart_plug,
         smart_plug_energy_snapshot,
         smart_plug_energy_snapshot,
@@ -668,6 +670,23 @@ async def run_migrations(conn):
     """
     """
     from sqlalchemy import text
     from sqlalchemy import text
 
 
+    # Migration: Add parent_run_id column to pipeline_runs (#1425 PR C).
+    # Links a retry-failed run back to its parent so the dashboard can show
+    # "Retry of run #N" inline. Idempotent on both SQLite and Postgres.
+    await _safe_execute(
+        conn,
+        "ALTER TABLE pipeline_runs ADD COLUMN parent_run_id INTEGER REFERENCES pipeline_runs(id) ON DELETE SET NULL",
+    )
+
+    # Migration: Add source_archive_id column to pipeline_runs (#1425 PR B follow-up).
+    # Allows a pipeline run to source from an archive's source 3MF in addition
+    # to a library file. Idempotent — _safe_execute swallows the "already exists"
+    # case on both SQLite and Postgres.
+    await _safe_execute(
+        conn,
+        "ALTER TABLE pipeline_runs ADD COLUMN source_archive_id INTEGER REFERENCES print_archives(id) ON DELETE SET NULL",
+    )
+
     # Migration: Add is_favorite column to print_archives
     # Migration: Add is_favorite column to print_archives
     await _safe_execute(conn, "ALTER TABLE print_archives ADD COLUMN is_favorite BOOLEAN DEFAULT 0")
     await _safe_execute(conn, "ALTER TABLE print_archives ADD COLUMN is_favorite BOOLEAN DEFAULT 0")
 
 
@@ -3326,7 +3345,7 @@ async def seed_default_groups():
 
 
     from sqlalchemy import select
     from sqlalchemy import select
 
 
-    from backend.app.core.permissions import DEFAULT_GROUPS
+    from backend.app.core.permissions import ALL_PERMISSIONS, DEFAULT_GROUPS
     from backend.app.models.group import Group
     from backend.app.models.group import Group
     from backend.app.models.user import User
     from backend.app.models.user import User
 
 
@@ -3479,63 +3498,31 @@ async def seed_default_groups():
                 group.permissions = perms
                 group.permissions = perms
         await session.commit()
         await session.commit()
 
 
-        # Backfill library:purge + archives:purge for the Administrators group
-        # on existing installs. Both permissions were added after Administrators
-        # was first seeded, so upgrading users miss them even though the default
-        # config (ALL_PERMISSIONS) includes them for fresh installs.
-        result = await session.execute(select(Group).where(Group.name == "Administrators"))
-        admin_group = result.scalar_one_or_none()
-        if admin_group and admin_group.permissions is not None:
-            perms = list(admin_group.permissions)
-            added = False
-            for new_perm in ("library:purge", "archives:purge"):
-                if new_perm not in perms:
-                    perms.append(new_perm)
-                    added = True
-                    logger.info("Added %s to Administrators group (backfill)", new_perm)
-            if added:
-                admin_group.permissions = perms
-        await session.commit()
-
-        # Backfill the read flag set for the Administrators group on existing
-        # installs (maziggy/bambuddy-security #2). Two layers:
-        #
-        # (a) New OWN/ALL splits — `archives:read_own` etc. Fresh installs get
-        #     these via ALL_PERMISSIONS; upgrades need the explicit backfill
-        #     so admin's permission set matches a fresh install's.
+        # Backfill: sync the Administrators system group to ALL_PERMISSIONS.
+        # Administrators' contract is full access to every feature — fresh
+        # installs get that via DEFAULT_GROUPS["Administrators"]["permissions"]
+        # = ALL_PERMISSIONS. Upgrading installs would otherwise stay frozen at
+        # whatever permission set existed when they were first seeded, so a
+        # newly-added Permission enum member silently leaves admins gated out
+        # of the feature it controls.
         #
         #
-        # (b) Legacy `archives:read` / `library:read` / `queue:read`. The
-        #     frontend still gates download / preview UI on these LEGACY
-        #     strings (see ArchivesPage / FileManagerPage), so admin needs
-        #     them retained even though the new API uses the OWN/ALL split.
-        #     The PERMISSION_MIGRATION_ALL map deliberately doesn't rename
-        #     read flags for admin — this backfill ensures they're present
-        #     even if they were stripped by hand or by an older migration.
-        #
-        # Also includes orca_cloud:auth for parity with fresh-install
-        # behaviour (ALL_PERMISSIONS covers it; backfill makes sure an
-        # admin role that's been customised since seed still has it).
+        # Generalises the previous one-off admin backfills (library:purge,
+        # archives:purge, the OWN/ALL read-flag set + legacy read flags,
+        # orca_cloud:auth, printer_sensor_history:read, …): every current
+        # Permission enum value is appended to the admin group if missing.
+        # Additive only — never removes a permission an operator added by
+        # hand. Run AFTER the legacy-rename migration above so the renamed
+        # OWN/ALL variants land in the group before the sync sees them.
         result = await session.execute(select(Group).where(Group.name == "Administrators"))
         result = await session.execute(select(Group).where(Group.name == "Administrators"))
         admin_group = result.scalar_one_or_none()
         admin_group = result.scalar_one_or_none()
         if admin_group and admin_group.permissions is not None:
         if admin_group and admin_group.permissions is not None:
             perms = list(admin_group.permissions)
             perms = list(admin_group.permissions)
             added = False
             added = False
-            for new_perm in (
-                "archives:read",
-                "archives:read_own",
-                "archives:read_all",
-                "library:read",
-                "library:read_own",
-                "library:read_all",
-                "queue:read",
-                "queue:read_own",
-                "queue:read_all",
-                "orca_cloud:auth",
-            ):
+            for new_perm in ALL_PERMISSIONS:
                 if new_perm not in perms:
                 if new_perm not in perms:
                     perms.append(new_perm)
                     perms.append(new_perm)
                     added = True
                     added = True
-                    logger.info("Added %s to Administrators group (backfill)", new_perm)
+                    logger.info("Added %s to Administrators group (ALL_PERMISSIONS sync)", new_perm)
             if added:
             if added:
                 admin_group.permissions = perms
                 admin_group.permissions = perms
         await session.commit()
         await session.commit()
@@ -3594,6 +3581,31 @@ async def seed_default_groups():
                 group.permissions = perms
                 group.permissions = perms
         await session.commit()
         await session.commit()
 
 
+        # Backfill pipeline permissions (#1425) for non-admin groups.
+        # Administrators is handled by the ALL_PERMISSIONS sync above.
+        #   - Operators: all three (matches fresh-install DEFAULT_GROUPS)
+        #   - Any other group with library:read_own or settings:read:
+        #     pipelines:read only
+        result = await session.execute(select(Group))
+        for group in result.scalars().all():
+            if not group.permissions or group.name == "Administrators":
+                continue
+            perms = list(group.permissions)
+            changed = False
+            if group.name == "Operators":
+                for new_perm in ("pipelines:read", "pipelines:write", "pipelines:run"):
+                    if new_perm not in perms:
+                        perms.append(new_perm)
+                        changed = True
+                        logger.info("Added %s to Operators group (backfill)", new_perm)
+            elif "pipelines:read" not in perms and ("library:read_own" in perms or "settings:read" in perms):
+                perms.append("pipelines:read")
+                changed = True
+                logger.info("Added pipelines:read to group '%s' (backfill)", group.name)
+            if changed:
+                group.permissions = perms
+        await session.commit()
+
         # Migrate existing users to groups if they're not already in any group
         # Migrate existing users to groups if they're not already in any group
         if groups_created:
         if groups_created:
             # Refresh to get newly created groups
             # Refresh to get newly created groups

+ 16 - 0
backend/app/core/permissions.py

@@ -178,6 +178,11 @@ class Permission(StrEnum):
     GROUPS_UPDATE = "groups:update"
     GROUPS_UPDATE = "groups:update"
     GROUPS_DELETE = "groups:delete"
     GROUPS_DELETE = "groups:delete"
 
 
+    # Slicer Pipelines (#1425)
+    PIPELINES_READ = "pipelines:read"  # View pipeline definitions and run history
+    PIPELINES_WRITE = "pipelines:write"  # Create / edit / delete pipeline definitions
+    PIPELINES_RUN = "pipelines:run"  # Kick off a pipeline run (PR C); separate because spending filament is a different trust dimension than authoring the recipe
+
     # WebSocket connection
     # WebSocket connection
     WEBSOCKET_CONNECT = "websocket:connect"
     WEBSOCKET_CONNECT = "websocket:connect"
 
 
@@ -337,6 +342,11 @@ PERMISSION_CATEGORIES = {
         Permission.GROUPS_UPDATE,
         Permission.GROUPS_UPDATE,
         Permission.GROUPS_DELETE,
         Permission.GROUPS_DELETE,
     ],
     ],
+    "Slicer Pipelines": [
+        Permission.PIPELINES_READ,
+        Permission.PIPELINES_WRITE,
+        Permission.PIPELINES_RUN,
+    ],
     "WebSocket": [
     "WebSocket": [
         Permission.WEBSOCKET_CONNECT,
         Permission.WEBSOCKET_CONNECT,
     ],
     ],
@@ -452,6 +462,10 @@ DEFAULT_GROUPS = {
             Permission.SYSTEM_READ.value,
             Permission.SYSTEM_READ.value,
             # Settings - read only
             # Settings - read only
             Permission.SETTINGS_READ.value,
             Permission.SETTINGS_READ.value,
+            # Slicer Pipelines - full access
+            Permission.PIPELINES_READ.value,
+            Permission.PIPELINES_WRITE.value,
+            Permission.PIPELINES_RUN.value,
             # WebSocket
             # WebSocket
             Permission.WEBSOCKET_CONNECT.value,
             Permission.WEBSOCKET_CONNECT.value,
         ],
         ],
@@ -483,6 +497,8 @@ DEFAULT_GROUPS = {
             Permission.STATS_READ.value,
             Permission.STATS_READ.value,
             Permission.SYSTEM_READ.value,
             Permission.SYSTEM_READ.value,
             Permission.SETTINGS_READ.value,
             Permission.SETTINGS_READ.value,
+            # Slicer Pipelines - read only
+            Permission.PIPELINES_READ.value,
             Permission.WEBSOCKET_CONNECT.value,
             Permission.WEBSOCKET_CONNECT.value,
             # MakerWorld browsing only (no import — that writes to library)
             # MakerWorld browsing only (no import — that writes to library)
             Permission.MAKERWORLD_VIEW.value,
             Permission.MAKERWORLD_VIEW.value,

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

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

+ 5 - 0
backend/app/main.py

@@ -49,6 +49,7 @@ from backend.app.api.routes import (
     obico,
     obico,
     orca_cloud,
     orca_cloud,
     pending_uploads,
     pending_uploads,
+    pipeline_runs,
     print_log,
     print_log,
     print_queue,
     print_queue,
     printer_sensor_history,
     printer_sensor_history,
@@ -56,6 +57,7 @@ from backend.app.api.routes import (
     projects,
     projects,
     settings as settings_routes,
     settings as settings_routes,
     slice_jobs,
     slice_jobs,
+    slicer_pipelines,
     slicer_presets,
     slicer_presets,
     smart_plugs,
     smart_plugs,
     sponsor_prompt,
     sponsor_prompt,
@@ -6750,6 +6752,9 @@ app.include_router(library.router, prefix=app_settings.api_prefix)
 app.include_router(library_tags.router, prefix=app_settings.api_prefix)
 app.include_router(library_tags.router, prefix=app_settings.api_prefix)
 app.include_router(library_trash.router, prefix=app_settings.api_prefix)
 app.include_router(library_trash.router, prefix=app_settings.api_prefix)
 app.include_router(slice_jobs.router, prefix=app_settings.api_prefix)
 app.include_router(slice_jobs.router, prefix=app_settings.api_prefix)
+app.include_router(slicer_pipelines.router, prefix=app_settings.api_prefix)
+app.include_router(pipeline_runs.pipeline_run_create_router, prefix=app_settings.api_prefix)
+app.include_router(pipeline_runs.pipeline_run_router, prefix=app_settings.api_prefix)
 app.include_router(slicer_presets.router, prefix=app_settings.api_prefix)
 app.include_router(slicer_presets.router, prefix=app_settings.api_prefix)
 app.include_router(archive_purge.router, prefix=app_settings.api_prefix)
 app.include_router(archive_purge.router, prefix=app_settings.api_prefix)
 app.include_router(makerworld.router, prefix=app_settings.api_prefix)
 app.include_router(makerworld.router, prefix=app_settings.api_prefix)

+ 5 - 0
backend/app/models/__init__.py

@@ -18,11 +18,13 @@ from backend.app.models.notification_template import NotificationTemplate
 from backend.app.models.oidc_provider import OIDCProvider, UserOIDCLink
 from backend.app.models.oidc_provider import OIDCProvider, UserOIDCLink
 from backend.app.models.orca_base_cache import OrcaBaseProfile
 from backend.app.models.orca_base_cache import OrcaBaseProfile
 from backend.app.models.pending_upload import PendingUpload
 from backend.app.models.pending_upload import PendingUpload
+from backend.app.models.pipeline_run import PipelineJob, PipelineRun
 from backend.app.models.print_batch import PrintBatch
 from backend.app.models.print_batch import PrintBatch
 from backend.app.models.printer import Printer
 from backend.app.models.printer import Printer
 from backend.app.models.printer_sensor_history import PrinterSensorHistory
 from backend.app.models.printer_sensor_history import PrinterSensorHistory
 from backend.app.models.project import Project
 from backend.app.models.project import Project
 from backend.app.models.settings import Settings
 from backend.app.models.settings import Settings
+from backend.app.models.slicer_pipeline import SlicerPipeline
 from backend.app.models.smart_plug import SmartPlug
 from backend.app.models.smart_plug import SmartPlug
 from backend.app.models.smart_plug_energy_snapshot import SmartPlugEnergySnapshot
 from backend.app.models.smart_plug_energy_snapshot import SmartPlugEnergySnapshot
 from backend.app.models.sponsor_toast_state import SponsorToastState
 from backend.app.models.sponsor_toast_state import SponsorToastState
@@ -69,6 +71,9 @@ __all__ = [
     "OIDCProvider",
     "OIDCProvider",
     "UserOIDCLink",
     "UserOIDCLink",
     "OrcaBaseProfile",
     "OrcaBaseProfile",
+    "PipelineJob",
+    "PipelineRun",
+    "SlicerPipeline",
     "Spool",
     "Spool",
     "SpoolKProfile",
     "SpoolKProfile",
     "SpoolAssignment",
     "SpoolAssignment",

+ 111 - 0
backend/app/models/pipeline_run.py

@@ -0,0 +1,111 @@
+"""Models for a Slicer Pipeline run (#1425 PR B).
+
+A PipelineRun is one "Run pipeline" click: slice the source file once with the
+pipeline's four preset slots, then enqueue a single print on the pipeline's
+pinned target printer (PR B = single-target dispatch). PR C extends this with
+copies > 1 and class targeting + fanout strategies.
+
+Status on a PipelineRun is mostly COMPUTED from the underlying slice_job
+(in-memory) + the linked queue_entry's state at read time — see
+``api/routes/pipeline_runs.py`` ``_compute_run_status`` for the rules. The
+``status`` column is the persisted snapshot used as a fallback / for filtering
+in list queries; it's updated on terminal transitions (slice failure, cancel,
+or queue-entry completion).
+"""
+
+from datetime import datetime
+
+from sqlalchemy import Boolean, DateTime, ForeignKey, Integer, String, Text, func
+from sqlalchemy.orm import Mapped, mapped_column, relationship
+
+from backend.app.core.database import Base
+
+
+class PipelineRun(Base):
+    """One run-pipeline invocation. PR B always carries exactly one
+    PipelineJob (copies=1); PR C will allow N."""
+
+    __tablename__ = "pipeline_runs"
+
+    id: Mapped[int] = mapped_column(primary_key=True)
+
+    # Pipeline + source. ``ondelete='SET NULL'`` on both so run history survives
+    # the user soft-deleting a pipeline or removing the source library file.
+    pipeline_id: Mapped[int | None] = mapped_column(Integer, ForeignKey("slicer_pipelines.id", ondelete="SET NULL"))
+    source_library_file_id: Mapped[int | None] = mapped_column(
+        Integer, ForeignKey("library_files.id", ondelete="SET NULL")
+    )
+    # Mutually exclusive with source_library_file_id. When set, the orchestrator
+    # reads ``archive.source_3mf_path`` (falling back to ``file_path``) for the
+    # slice input. Lets ArchiveCard's "Run with pipeline" reuse the same /run
+    # endpoint instead of growing a second route.
+    source_archive_id: Mapped[int | None] = mapped_column(Integer, ForeignKey("print_archives.id", ondelete="SET NULL"))
+
+    # Set when this run was created by ``POST /pipeline-runs/{parent}/retry-failed``.
+    # Chains the new run back to the run whose failed copies it re-attempts so
+    # the dashboard can show "Retry of run #N" inline. ``SET NULL`` so cleaning
+    # up old runs doesn't dangle retries.
+    parent_run_id: Mapped[int | None] = mapped_column(Integer, ForeignKey("pipeline_runs.id", ondelete="SET NULL"))
+
+    copies: Mapped[int] = mapped_column(Integer, default=1)
+
+    # Snapshot status — terminal transitions are persisted here, in-flight
+    # reads compute from slice_job + queue_entry. Values:
+    #   'queued', 'slicing', 'dispatching', 'in_progress',
+    #   'completed', 'failed', 'cancelled'
+    status: Mapped[str] = mapped_column(String(20), default="queued")
+
+    # Slice integration. slice_job_id is the in-memory slice_dispatch id (so
+    # it's a plain int, not an FK). sliced_library_file_id is the produced
+    # gcode.3mf row.
+    slice_job_id: Mapped[int | None] = mapped_column(Integer)
+    sliced_library_file_id: Mapped[int | None] = mapped_column(
+        Integer, ForeignKey("library_files.id", ondelete="SET NULL")
+    )
+
+    # True when the operator chose to "Run anyway" past eligibility issues
+    # (filament mismatch, etc.). Surfaced in run history so the audit log
+    # shows which runs bypassed the pre-flight.
+    eligibility_overridden: Mapped[bool] = mapped_column(Boolean, default=False, server_default="0")
+
+    error_message: Mapped[str | None] = mapped_column(Text)
+
+    created_by: Mapped[int | None] = mapped_column(Integer, ForeignKey("users.id", ondelete="SET NULL"))
+    created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())
+    started_at: Mapped[datetime | None] = mapped_column(DateTime)
+    completed_at: Mapped[datetime | None] = mapped_column(DateTime)
+
+    jobs: Mapped[list["PipelineJob"]] = relationship(
+        back_populates="run",
+        cascade="all, delete-orphan",
+        order_by="PipelineJob.copy_index",
+    )
+
+
+class PipelineJob(Base):
+    """One copy within a PipelineRun. PR B: always exactly one per run.
+
+    Each job binds the run to one queue entry (``queue_entry_id``). The
+    queue entry's status drives this job's status; this row mostly carries
+    the run-side narrative (dispatch timestamps, error message) so deleting
+    the queue entry later doesn't lose the audit trail.
+    """
+
+    __tablename__ = "pipeline_jobs"
+
+    id: Mapped[int] = mapped_column(primary_key=True)
+    pipeline_run_id: Mapped[int] = mapped_column(Integer, ForeignKey("pipeline_runs.id", ondelete="CASCADE"))
+    copy_index: Mapped[int] = mapped_column(Integer, default=0)
+
+    assigned_printer_id: Mapped[int | None] = mapped_column(Integer, ForeignKey("printers.id", ondelete="SET NULL"))
+    queue_entry_id: Mapped[int | None] = mapped_column(Integer, ForeignKey("print_queue.id", ondelete="SET NULL"))
+
+    # Values: 'pending', 'awaiting_printer', 'queued', 'printing',
+    #         'completed', 'failed', 'cancelled'
+    status: Mapped[str] = mapped_column(String(20), default="pending")
+    error_message: Mapped[str | None] = mapped_column(Text)
+
+    dispatched_at: Mapped[datetime | None] = mapped_column(DateTime)
+    completed_at: Mapped[datetime | None] = mapped_column(DateTime)
+
+    run: Mapped["PipelineRun"] = relationship(back_populates="jobs")

+ 61 - 0
backend/app/models/slicer_pipeline.py

@@ -0,0 +1,61 @@
+"""Model for a Slicing/Printing Pipeline definition (#1425).
+
+A pipeline bundles the four slot picks a user normally makes in the SliceModal
+(printer / process / filament(s) / bed type) under a named, reusable preset.
+This is PR A — bundle definitions only. Run state and dispatch live in
+``pipeline_runs`` / ``pipeline_jobs`` (PR B + PR C).
+
+The target_* and fanout_strategy columns are materialised now to avoid a
+second migration when PR B / PR C land; PR A's API accepts the defaults and
+the UI doesn't expose them yet.
+"""
+
+from datetime import datetime
+
+from sqlalchemy import Boolean, DateTime, ForeignKey, Integer, String, Text, func
+from sqlalchemy.orm import Mapped, mapped_column
+
+from backend.app.core.database import Base
+
+
+class SlicerPipeline(Base):
+    """A named slicer preset bundle (printer + process + filament[s] + bed)."""
+
+    __tablename__ = "slicer_pipelines"
+
+    id: Mapped[int] = mapped_column(primary_key=True)
+    name: Mapped[str] = mapped_column(String(200))
+    description: Mapped[str | None] = mapped_column(String(1000))
+
+    # Preset slots. ``*_source`` mirrors PresetRef.source semantics
+    # (orca_cloud / cloud / local / standard); ``*_id`` is the opaque
+    # source-specific id the slicer pipeline uses to resolve content.
+    printer_preset_source: Mapped[str] = mapped_column(String(20))
+    printer_preset_id: Mapped[str] = mapped_column(String(200))
+    process_preset_source: Mapped[str] = mapped_column(String(20))
+    process_preset_id: Mapped[str] = mapped_column(String(200))
+    # JSON array of {"source": ..., "id": ...} entries — one per AMS slot the
+    # source plate is expected to use. Stored as JSON text per Bambuddy's
+    # convention (see LocalPreset.compatible_printers).
+    filament_presets_json: Mapped[str] = mapped_column(Text)
+
+    bed_type: Mapped[str | None] = mapped_column(String(64))
+
+    # Target — PR B+ wiring; PR A treats every pipeline as a bundle without
+    # an active target. Kept materialised so PR B is code-only, not a
+    # migration. ``target_kind`` ∈ {"specific_printer", "printer_class"}.
+    target_kind: Mapped[str] = mapped_column(String(20), default="printer_class")
+    target_printer_id: Mapped[int | None] = mapped_column(Integer, ForeignKey("printers.id", ondelete="SET NULL"))
+    target_model_class: Mapped[str | None] = mapped_column(String(20))
+
+    # Fanout strategy for PR C multi-copy runs. PR A defaults it; the UI
+    # doesn't expose it yet. Values: max_parallel / fill_one_first / round_robin.
+    fanout_strategy: Mapped[str] = mapped_column(String(20), default="max_parallel")
+
+    # Audit fields. created_by is nullable so pipelines survive user deletes
+    # and so installs without auth enabled (current_user is None) still work.
+    created_by: Mapped[int | None] = mapped_column(Integer, ForeignKey("users.id", ondelete="SET NULL"))
+    is_deleted: Mapped[bool] = mapped_column(Boolean, default=False, server_default="0")
+
+    created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())
+    updated_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now(), onupdate=func.now())

+ 173 - 0
backend/app/schemas/pipeline_run.py

@@ -0,0 +1,173 @@
+"""Pydantic schemas for PipelineRun + eligibility (#1425 PR B + PR C)."""
+
+from datetime import datetime
+from typing import Literal
+
+from pydantic import BaseModel, Field, model_validator
+
+
+class EligibilityIssueResponse(BaseModel):
+    """Single eligibility issue — see ``services/pipeline_eligibility.py`` for
+    the full list of ``kind`` values and what each means."""
+
+    kind: Literal[
+        "printer_not_set",
+        "printer_not_found",
+        "printer_disabled",
+        "printer_offline",
+        "filament_type_mismatch",
+        "filament_color_mismatch",
+        "ams_slot_missing",
+        "filament_unverified",
+        "no_class_matches",  # PR C: target_kind='printer_class' and zero printers in the install match the model
+        "class_not_set",  # PR C: target_kind='printer_class' with no target_model_class
+    ]
+    slot_index: int | None = None
+    expected: str | None = None
+    actual: str | None = None
+
+
+class PerPrinterReport(BaseModel):
+    """One row of class-targeting eligibility — per matching printer.
+
+    PR C extends the top-level report with this list so the confirmation modal
+    can show ``3 of 5 X1Cs eligible`` plus a per-printer breakdown of why each
+    candidate is or isn't usable.
+    """
+
+    printer_id: int
+    printer_name: str
+    ok: bool
+    issues: list[EligibilityIssueResponse] = []
+
+
+class EligibilityReportResponse(BaseModel):
+    """Returned by both ``POST /check-eligibility`` and (on 409) ``POST /run``
+    so the frontend can render the same modal in either flow.
+
+    ``ok`` semantics:
+      - ``target_kind='specific_printer'``: ``ok`` mirrors that single
+        printer's eligibility (no blocking issues).
+      - ``target_kind='printer_class'``: ``ok`` is True iff **at least one**
+        matching printer passes — the run can dispatch even if some
+        candidates in the class are offline / filament-mismatched, because
+        the scheduler will pick any eligible one. The per-printer list lives
+        on ``printer_reports`` so the operator sees the full picture.
+
+    ``issues`` carries class-level issues only (``no_class_matches``,
+    ``class_not_set``) — per-printer detail moves to ``printer_reports``.
+    """
+
+    ok: bool
+    target_kind: Literal["specific_printer", "printer_class"] = "specific_printer"
+    target_printer_id: int | None = None
+    target_printer_name: str | None = None
+    target_model_class: str | None = None
+    issues: list[EligibilityIssueResponse] = []
+    printer_reports: list[PerPrinterReport] = []
+
+
+class CheckEligibilityRequest(BaseModel):
+    """Exactly one of ``source_library_file_id`` / ``source_archive_id`` must
+    be set."""
+
+    source_library_file_id: int | None = None
+    source_archive_id: int | None = None
+    force: bool = Field(default=False)
+
+    @model_validator(mode="after")
+    def exactly_one_source(self) -> "CheckEligibilityRequest":
+        if (self.source_library_file_id is None) == (self.source_archive_id is None):
+            raise ValueError("exactly one of source_library_file_id or source_archive_id must be set")
+        return self
+
+
+class PipelineRunCreateRequest(BaseModel):
+    """``copies`` defaults to 1 (PR B parity). The route handler enforces the
+    ``pipeline_max_copies`` setting on top of the schema's lower bound."""
+
+    source_library_file_id: int | None = None
+    source_archive_id: int | None = None
+    copies: int = Field(default=1, ge=1, le=1000)
+    force: bool = Field(
+        default=False,
+        description=(
+            "When False (default), the route returns 409 with the eligibility "
+            "report if any blocking issue exists. When True, the run starts "
+            "even when issues exist — recorded on PipelineRun.eligibility_overridden."
+        ),
+    )
+
+    @model_validator(mode="after")
+    def exactly_one_source(self) -> "PipelineRunCreateRequest":
+        if (self.source_library_file_id is None) == (self.source_archive_id is None):
+            raise ValueError("exactly one of source_library_file_id or source_archive_id must be set")
+        return self
+
+
+class PipelineJobResponse(BaseModel):
+    id: int
+    pipeline_run_id: int
+    copy_index: int
+    assigned_printer_id: int | None
+    assigned_printer_name: str | None = None
+    queue_entry_id: int | None
+    status: Literal[
+        "pending",
+        "awaiting_printer",
+        "queued",
+        "printing",
+        "completed",
+        "failed",
+        "cancelled",
+    ]
+    error_message: str | None = None
+    dispatched_at: datetime | None = None
+    completed_at: datetime | None = None
+
+
+class PipelineRunResponse(BaseModel):
+    id: int
+    pipeline_id: int | None
+    pipeline_name: str | None = None
+    source_library_file_id: int | None
+    source_archive_id: int | None = None
+    source_filename: str | None = None
+    parent_run_id: int | None = None
+    copies: int
+    # Roll-up counts used by the dashboard's per-row summary. Computed at read
+    # time from the per-job statuses so they always match the live state.
+    copies_completed: int = 0
+    copies_failed: int = 0
+    copies_cancelled: int = 0
+    copies_in_progress: int = 0
+    status: Literal[
+        "queued",
+        "slicing",
+        "dispatching",
+        "in_progress",
+        "completed",
+        "failed",
+        "partial_failure",  # PR C: some copies succeeded, some failed/cancelled
+        "cancelled",
+    ]
+    slice_job_id: int | None
+    sliced_library_file_id: int | None
+    eligibility_overridden: bool
+    error_message: str | None = None
+    created_by: int | None
+    created_at: datetime
+    started_at: datetime | None
+    completed_at: datetime | None
+    jobs: list[PipelineJobResponse] = []
+    # Pipeline target snapshot — copied onto the response so the dashboard
+    # doesn't need a second query to display "Run on X1C class" per row.
+    target_kind: Literal["specific_printer", "printer_class"] | None = None
+    target_printer_id: int | None = None
+    target_model_class: str | None = None
+    fanout_strategy: Literal["max_parallel", "fill_one_first", "round_robin"] | None = None
+
+
+class PipelineRunListResponse(BaseModel):
+    runs: list[PipelineRunResponse] = []
+    total: int = 0  # PR C: for the dashboard's paginator

+ 10 - 0
backend/app/schemas/settings.py

@@ -140,6 +140,15 @@ class AppSettings(BaseModel):
     # Default printer for operations
     # Default printer for operations
     default_printer_id: int | None = Field(default=None, description="Default printer ID for uploads, reprints, etc.")
     default_printer_id: int | None = Field(default=None, description="Default printer ID for uploads, reprints, etc.")
 
 
+    # Slicer Pipelines (#1425 PR C). Cap on the ``copies`` field in the
+    # Run-with-pipeline modal — keeps a misclick from queueing 5000 prints.
+    pipeline_max_copies: int = Field(
+        default=50,
+        ge=1,
+        le=1000,
+        description="Upper bound on the copies an operator can request when running a Slicer Pipeline. Larger fleets / production rigs can raise this; the hard ceiling at 1000 is a sanity guard against fat-fingered input.",
+    )
+
     # Virtual Printer
     # Virtual Printer
     virtual_printer_enabled: bool = Field(default=False, description="Enable virtual printer for slicer uploads")
     virtual_printer_enabled: bool = Field(default=False, description="Enable virtual printer for slicer uploads")
     virtual_printer_access_code: str = Field(default="", description="Access code for virtual printer authentication")
     virtual_printer_access_code: str = Field(default="", description="Access code for virtual printer authentication")
@@ -458,6 +467,7 @@ class AppSettingsUpdate(BaseModel):
     date_format: str | None = None
     date_format: str | None = None
     time_format: str | None = None
     time_format: str | None = None
     default_printer_id: int | None = None
     default_printer_id: int | None = None
+    pipeline_max_copies: int | None = None
     virtual_printer_enabled: bool | None = None
     virtual_printer_enabled: bool | None = None
     virtual_printer_access_code: str | None = None
     virtual_printer_access_code: str | None = None
     virtual_printer_mode: str | None = None
     virtual_printer_mode: str | None = None

+ 83 - 0
backend/app/schemas/slicer_pipeline.py

@@ -0,0 +1,83 @@
+"""Pydantic schemas for the Slicer Pipeline API (#1425, PR A).
+
+A pipeline bundles printer / process / filament(s) / bed-type picks under a
+reusable name. PR A surfaces only the bundle; target_kind / target_printer_id /
+target_model_class / fanout_strategy are persisted but the API treats them as
+opaque defaults — they come alive in PR B (single-target dispatch) and PR C
+(multi-copy + class targeting + fanout).
+"""
+
+from datetime import datetime
+from typing import Literal
+
+from pydantic import BaseModel, Field
+
+from backend.app.schemas.slicer import PresetRef
+
+
+class SlicerPipelineBase(BaseModel):
+    """Fields editable on create + update."""
+
+    name: str = Field(..., min_length=1, max_length=200)
+    description: str | None = Field(default=None, max_length=1000)
+
+    printer_preset: PresetRef
+    process_preset: PresetRef
+    filament_presets: list[PresetRef] = Field(
+        ...,
+        min_length=1,
+        description="One PresetRef per AMS slot. Order matches the source plate's filament-slot order.",
+    )
+    bed_type: str | None = Field(default=None, max_length=64)
+
+
+class SlicerPipelineCreate(SlicerPipelineBase):
+    """Payload for POST /slicer-pipelines."""
+
+
+class SlicerPipelineUpdate(BaseModel):
+    """Payload for PUT /slicer-pipelines/{id}. All fields optional; only those
+    present are written. Preset and filament list are replaced wholesale when
+    set (we don't support partial filament-slot edits)."""
+
+    name: str | None = Field(default=None, min_length=1, max_length=200)
+    description: str | None = Field(default=None, max_length=1000)
+    printer_preset: PresetRef | None = None
+    process_preset: PresetRef | None = None
+    filament_presets: list[PresetRef] | None = Field(default=None, min_length=1)
+    bed_type: str | None = Field(default=None, max_length=64)
+
+    # PR B target binding. ``target_kind='specific_printer'`` requires
+    # ``target_printer_id`` to be set OR cleared in the same payload (route
+    # handler enforces). ``target_kind='printer_class'`` is wired by PR C
+    # together with ``target_model_class`` (a Bambu model code like 'X1C')
+    # and the fanout strategy that distributes copies across matching
+    # printers.
+    target_kind: Literal["specific_printer", "printer_class"] | None = None
+    target_printer_id: int | None = None
+    target_model_class: str | None = Field(default=None, max_length=20)
+    fanout_strategy: Literal["max_parallel", "fill_one_first", "round_robin"] | None = None
+
+
+class SlicerPipelineResponse(SlicerPipelineBase):
+    """A single pipeline as returned by the API."""
+
+    id: int
+    created_by: int | None
+    created_at: datetime
+    updated_at: datetime
+
+    # Echoed for PR B+ readiness; PR A always returns the persisted defaults.
+    target_kind: Literal["specific_printer", "printer_class"] = "printer_class"
+    target_printer_id: int | None = None
+    target_model_class: str | None = None
+    fanout_strategy: Literal["max_parallel", "fill_one_first", "round_robin"] = "max_parallel"
+
+    model_config = {"from_attributes": True}
+
+
+class SlicerPipelineListResponse(BaseModel):
+    """Wraps the list so the response stays additive when run/job counts get
+    surfaced in PR B+ (e.g. a ``meta`` field for last-run timestamps)."""
+
+    pipelines: list[SlicerPipelineResponse] = []

+ 360 - 0
backend/app/services/pipeline_eligibility.py

@@ -0,0 +1,360 @@
+"""Eligibility matcher for Slicer Pipeline runs (#1425 PR B).
+
+Given a pipeline + the user's pinned target printer, this returns a structured
+report of issues the operator should resolve before running. The frontend
+displays the report; the user can ``Run anyway`` to proceed (lenient policy —
+the print may still fail at the printer, but Bambuddy isn't going to refuse
+the click).
+
+Issue kinds (pinned for tests + i18n keys):
+  - printer_not_set         — pipeline has no target_printer_id
+  - printer_not_found       — target_printer_id points at a deleted/missing row
+  - printer_disabled        — Printer.is_active is False (#1476)
+  - printer_offline         — MQTT not connected
+  - filament_type_mismatch  — AMS slot loaded with wrong filament type
+  - filament_color_mismatch — type matches, colour differs
+  - ams_slot_missing        — pipeline expects N filament slots but AMS exposes fewer
+  - filament_unverified     — pipeline filament preset is a non-local tier we
+                              can't statically read (cloud / orca_cloud / standard);
+                              the run will proceed, but the operator should
+                              double-check
+
+The matcher is a pure-ish function over (pipeline, printer row, live AMS state,
+local-preset dict) so unit tests can drive it with fixtures without spinning up
+MQTT. The route handler is the only place that talks to ``printer_manager``.
+"""
+
+from __future__ import annotations
+
+import json
+from dataclasses import dataclass
+from typing import Literal
+
+from sqlalchemy import select
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from backend.app.models.local_preset import LocalPreset
+from backend.app.models.printer import Printer
+from backend.app.models.slicer_pipeline import SlicerPipeline
+
+IssueKind = Literal[
+    "printer_not_set",
+    "printer_not_found",
+    "printer_disabled",
+    "printer_offline",
+    "filament_type_mismatch",
+    "filament_color_mismatch",
+    "ams_slot_missing",
+    "filament_unverified",
+    "no_class_matches",
+    "class_not_set",
+]
+
+
+@dataclass(frozen=True)
+class EligibilityIssue:
+    kind: IssueKind
+    slot_index: int | None = None
+    expected: str | None = None
+    actual: str | None = None
+
+
+@dataclass(frozen=True)
+class PerPrinterReport:
+    """One row of the class-targeting eligibility breakdown."""
+
+    printer_id: int
+    printer_name: str
+    ok: bool
+    issues: tuple[EligibilityIssue, ...]
+
+
+@dataclass(frozen=True)
+class EligibilityReport:
+    ok: bool
+    target_kind: Literal["specific_printer", "printer_class"]
+    target_printer_id: int | None
+    target_printer_name: str | None
+    target_model_class: str | None
+    issues: tuple[EligibilityIssue, ...]
+    printer_reports: tuple[PerPrinterReport, ...] = ()
+
+
+# Same equivalence map as print_scheduler._canonical_filament_type but kept
+# local so this module has no upward dependency on the scheduler. Mirrors the
+# scheduler's behaviour: BBL-prefixed product names normalise to the base type
+# (e.g. "PLA Basic" → "PLA"). When the scheduler's map gets a new alias, this
+# one needs the same entry.
+_FILAMENT_EQUIV_MAP = {
+    "PLA": "PLA",
+    "PLA BASIC": "PLA",
+    "PLA MATTE": "PLA",
+    "PLA SILK": "PLA",
+    "PLA PRO": "PLA",
+    "PLA TOUGH": "PLA",
+    "PETG": "PETG",
+    "PETG HF": "PETG",
+    "PETG BASIC": "PETG",
+    "PETG TRANSLUCENT": "PETG",
+    "ABS": "ABS",
+    "ASA": "ASA",
+    "TPU": "TPU",
+    "TPU 95A": "TPU",
+    "PC": "PC",
+    "PA": "PA",
+    "PA-CF": "PA",
+    "PVA": "PVA",
+}
+
+
+def _canonical(ftype: str) -> str:
+    upper = (ftype or "").strip().upper()
+    return _FILAMENT_EQUIV_MAP.get(upper, upper)
+
+
+def _normalise_colour(colour: str | None) -> str:
+    if not colour:
+        return ""
+    return colour.replace("#", "").lower()[:6]
+
+
+def _ams_slots(raw_data: dict) -> list[tuple[str, str]]:
+    """Flatten AMS + external spool into ``[(type, colour_hex6), ...]`` in slot
+    order. Uses the same field shape as print_scheduler._check_required_filaments.
+    """
+    out: list[tuple[str, str]] = []
+    for ams_unit in raw_data.get("ams") or []:
+        for tray in ams_unit.get("tray") or []:
+            tray_type = tray.get("tray_type") or ""
+            tray_colour = tray.get("tray_color") or ""
+            out.append((_canonical(tray_type), _normalise_colour(tray_colour)))
+    for vt in raw_data.get("vt_tray") or []:
+        vt_type = vt.get("tray_type") or ""
+        vt_colour = vt.get("tray_color") or ""
+        out.append((_canonical(vt_type), _normalise_colour(vt_colour)))
+    return out
+
+
+async def _expected_filament(
+    db: AsyncSession,
+    source: str,
+    preset_id: str,
+) -> tuple[str | None, str | None]:
+    """Return ``(canonical_type, normalised_colour)`` for a pipeline filament
+    slot's PresetRef, or ``(None, None)`` when the preset can't be resolved
+    statically (cloud / orca_cloud / standard — read at slice time, not here).
+    """
+    if source != "local":
+        # Cloud / orca_cloud / standard: surface as ``filament_unverified``
+        # in the report, the matcher decides.
+        return (None, None)
+    try:
+        local_id = int(preset_id)
+    except (TypeError, ValueError):
+        return (None, None)
+    row = (await db.execute(select(LocalPreset).where(LocalPreset.id == local_id))).scalar_one_or_none()
+    if row is None:
+        return (None, None)
+    return (_canonical(row.filament_type or ""), _normalise_colour(row.default_filament_colour))
+
+
+async def _check_one_printer(
+    db: AsyncSession,
+    pipeline: SlicerPipeline,
+    printer: Printer,
+    printer_raw_status: dict | None,
+) -> tuple[bool, tuple[EligibilityIssue, ...]]:
+    """Run the per-printer eligibility checks. Returns ``(ok, issues)`` so the
+    caller can flatten them into either a single-printer or class-targeting
+    report. Pulled out of the original entry function so PR C's class branch
+    can reuse it for each candidate printer."""
+    issues: list[EligibilityIssue] = []
+
+    if not printer.is_active:
+        issues.append(EligibilityIssue(kind="printer_disabled"))
+
+    if not printer_raw_status or not printer_raw_status.get("connected"):
+        issues.append(EligibilityIssue(kind="printer_offline"))
+        return (not issues, tuple(issues))
+
+    try:
+        filament_refs = json.loads(pipeline.filament_presets_json or "[]")
+    except (json.JSONDecodeError, TypeError):
+        filament_refs = []
+
+    ams_slots = _ams_slots(printer_raw_status.get("raw_data") or {})
+
+    for slot_index, ref in enumerate(filament_refs):
+        if not isinstance(ref, dict):
+            continue
+        source = ref.get("source", "")
+        preset_id = ref.get("id", "")
+        expected_type, expected_colour = await _expected_filament(db, source, str(preset_id))
+
+        if expected_type is None:
+            issues.append(
+                EligibilityIssue(
+                    kind="filament_unverified",
+                    slot_index=slot_index,
+                    expected=f"{source}:{preset_id}",
+                )
+            )
+            continue
+
+        if slot_index >= len(ams_slots):
+            issues.append(
+                EligibilityIssue(
+                    kind="ams_slot_missing",
+                    slot_index=slot_index,
+                    expected=expected_type,
+                )
+            )
+            continue
+
+        actual_type, actual_colour = ams_slots[slot_index]
+        if expected_type and actual_type and expected_type != actual_type:
+            issues.append(
+                EligibilityIssue(
+                    kind="filament_type_mismatch",
+                    slot_index=slot_index,
+                    expected=expected_type,
+                    actual=actual_type or "(empty)",
+                )
+            )
+            continue
+        if expected_colour and actual_colour and expected_colour != actual_colour:
+            issues.append(
+                EligibilityIssue(
+                    kind="filament_color_mismatch",
+                    slot_index=slot_index,
+                    expected=expected_colour,
+                    actual=actual_colour,
+                )
+            )
+
+    # ``filament_unverified`` is informational — doesn't flip ok=False.
+    blocking_issues = [i for i in issues if i.kind != "filament_unverified"]
+    return (not blocking_issues, tuple(issues))
+
+
+async def check_pipeline_eligibility(
+    db: AsyncSession,
+    pipeline: SlicerPipeline,
+    printer_raw_status: dict | None = None,
+    *,
+    status_lookup: object = None,
+) -> EligibilityReport:
+    """Build the eligibility report.
+
+    Two calling shapes, chosen by ``pipeline.target_kind``:
+      - ``specific_printer``: ``printer_raw_status`` carries the live
+        ``PrinterState`` dict (``connected`` + ``raw_data``) for the pinned
+        target_printer_id. PR B signature, preserved.
+      - ``printer_class``: ``status_lookup`` is a callable
+        ``(printer_id) -> dict | None`` that the matcher calls for each
+        printer whose model matches ``pipeline.target_model_class``.
+    """
+    # PR A pipelines default target_kind to 'printer_class' but PR B and
+    # earlier UI only let users pin a specific_printer; treat
+    # ``target_printer_id is not None`` as the source of truth for the
+    # specific-printer path until the editor exposes target_kind explicitly.
+    if pipeline.target_printer_id is not None or pipeline.target_kind == "specific_printer":
+        # Specific-printer branch (PR B parity).
+        if pipeline.target_printer_id is None:
+            return EligibilityReport(
+                ok=False,
+                target_kind="specific_printer",
+                target_printer_id=None,
+                target_printer_name=None,
+                target_model_class=None,
+                issues=(EligibilityIssue(kind="printer_not_set"),),
+            )
+
+        printer = (
+            await db.execute(select(Printer).where(Printer.id == pipeline.target_printer_id))
+        ).scalar_one_or_none()
+        if printer is None:
+            return EligibilityReport(
+                ok=False,
+                target_kind="specific_printer",
+                target_printer_id=pipeline.target_printer_id,
+                target_printer_name=None,
+                target_model_class=None,
+                issues=(EligibilityIssue(kind="printer_not_found"),),
+            )
+
+        ok, issues = await _check_one_printer(db, pipeline, printer, printer_raw_status)
+        return EligibilityReport(
+            ok=ok,
+            target_kind="specific_printer",
+            target_printer_id=printer.id,
+            target_printer_name=printer.name,
+            target_model_class=None,
+            issues=issues,
+        )
+
+    # Class-targeting branch (PR C).
+    if not pipeline.target_model_class:
+        return EligibilityReport(
+            ok=False,
+            target_kind="printer_class",
+            target_printer_id=None,
+            target_printer_name=None,
+            target_model_class=None,
+            issues=(EligibilityIssue(kind="class_not_set"),),
+        )
+
+    candidates = (await db.execute(select(Printer).where(Printer.model == pipeline.target_model_class))).scalars().all()
+
+    if not candidates:
+        return EligibilityReport(
+            ok=False,
+            target_kind="printer_class",
+            target_printer_id=None,
+            target_printer_name=None,
+            target_model_class=pipeline.target_model_class,
+            issues=(
+                EligibilityIssue(
+                    kind="no_class_matches",
+                    expected=pipeline.target_model_class,
+                ),
+            ),
+        )
+
+    reports: list[PerPrinterReport] = []
+    if status_lookup is None:
+        # Treat all printers as offline when no lookup was provided — keeps
+        # the matcher pure-ish for unit tests.
+        for printer in candidates:
+            ok, issues = await _check_one_printer(db, pipeline, printer, None)
+            reports.append(
+                PerPrinterReport(
+                    printer_id=printer.id,
+                    printer_name=printer.name,
+                    ok=ok,
+                    issues=issues,
+                )
+            )
+    else:
+        for printer in candidates:
+            raw = status_lookup(printer.id)
+            ok, issues = await _check_one_printer(db, pipeline, printer, raw)
+            reports.append(
+                PerPrinterReport(
+                    printer_id=printer.id,
+                    printer_name=printer.name,
+                    ok=ok,
+                    issues=issues,
+                )
+            )
+
+    any_ok = any(r.ok for r in reports)
+    return EligibilityReport(
+        ok=any_ok,
+        target_kind="printer_class",
+        target_printer_id=None,
+        target_printer_name=None,
+        target_model_class=pipeline.target_model_class,
+        issues=(),
+        printer_reports=tuple(reports),
+    )

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

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

+ 24 - 7
backend/app/utils/threemf_tools.py

@@ -351,13 +351,32 @@ def extract_nozzle_mapping_from_3mf(zf: zipfile.ZipFile) -> dict[int, int] | Non
             nozzle_counts = [n.partition("#")[2] for n in stats_str.split("|")]
             nozzle_counts = [n.partition("#")[2] for n in stats_str.split("|")]
             active_extruders.append(1 if any(c not in ("0", "") for c in nozzle_counts) else 0)
             active_extruders.append(1 if any(c not in ("0", "") for c in nozzle_counts) else 0)
 
 
-        if sum(active_extruders) == 1:
+        # Parse slice_info once: needed by both the single-active shortcut
+        # (to verify the slice is actually single-group, #1825) and Priority 1.
+        si_root: ET.Element | None = None
+        distinct_group_ids: set[int] = set()
+        if "Metadata/slice_info.config" in zf.namelist():
+            si_content = zf.read("Metadata/slice_info.config").decode()
+            si_root = ET.fromstring(si_content)
+            for filament_elem in si_root.findall(".//filament"):
+                gid = filament_elem.get("group_id")
+                if gid is not None:
+                    try:
+                        distinct_group_ids.add(int(gid))
+                    except (ValueError, TypeError):
+                        pass
+
+        # Single-active shortcut: only safe when the slice actually uses one
+        # group. extruder_nozzle_stats can under-report a second installed
+        # nozzle when its volume-type differs from the profile's enumerated
+        # types (HT-AMS / High-Flow asymmetry on H2D, #1825); without this
+        # guard the shortcut collapses a real multi-extruder slice onto one
+        # nozzle and the group_id mapping below is skipped.
+        if sum(active_extruders) == 1 and len(distinct_group_ids) <= 1:
             nozzle_mapping: dict[int, int] = {}
             nozzle_mapping: dict[int, int] = {}
             active_idx = active_extruders.index(1)
             active_idx = active_extruders.index(1)
             target_extruder = int(physical_extruder_map[active_idx])
             target_extruder = int(physical_extruder_map[active_idx])
-            if "Metadata/slice_info.config" in zf.namelist():
-                si_content = zf.read("Metadata/slice_info.config").decode()
-                si_root = ET.fromstring(si_content)
+            if si_root is not None:
                 for filament_elem in si_root.findall(".//filament"):
                 for filament_elem in si_root.findall(".//filament"):
                     try:
                     try:
                         nozzle_mapping[int(filament_elem.get("id"))] = target_extruder
                         nozzle_mapping[int(filament_elem.get("id"))] = target_extruder
@@ -368,9 +387,7 @@ def extract_nozzle_mapping_from_3mf(zf: zipfile.ZipFile) -> dict[int, int] | Non
         # Priority 1: Use group_id from slice_info filament elements.
         # Priority 1: Use group_id from slice_info filament elements.
         # This reflects the actual slicer assignment (respects "Auto For Flush").
         # This reflects the actual slicer assignment (respects "Auto For Flush").
         nozzle_mapping: dict[int, int] = {}
         nozzle_mapping: dict[int, int] = {}
-        if "Metadata/slice_info.config" in zf.namelist():
-            si_content = zf.read("Metadata/slice_info.config").decode()
-            si_root = ET.fromstring(si_content)
+        if si_root is not None:
             for filament_elem in si_root.findall(".//filament"):
             for filament_elem in si_root.findall(".//filament"):
                 group_id_str = filament_elem.get("group_id")
                 group_id_str = filament_elem.get("group_id")
                 filament_id_str = filament_elem.get("id")
                 filament_id_str = filament_elem.get("id")

+ 927 - 0
backend/tests/integration/test_pipeline_runs_api.py

@@ -0,0 +1,927 @@
+"""Integration tests for Slicer Pipeline runs (#1425 PR B).
+
+Slicing itself is a network call to the slicer sidecar — these tests
+stub ``slice_and_persist`` so the orchestration logic is exercised without
+needing a live sidecar in CI.
+"""
+
+from __future__ import annotations
+
+from unittest.mock import AsyncMock, patch
+
+import pytest
+from httpx import AsyncClient
+
+
+def _pipeline_payload(**overrides) -> dict:
+    payload = {
+        "name": "Production Batch",
+        "description": None,
+        "printer_preset": {"source": "local", "id": "1"},
+        "process_preset": {"source": "local", "id": "2"},
+        "filament_presets": [{"source": "local", "id": "3"}],
+        "bed_type": None,
+    }
+    payload.update(overrides)
+    return payload
+
+
+@pytest.fixture
+async def pipeline_factory(async_client: AsyncClient):
+    """Create pipelines via the API + optionally set a target printer."""
+
+    async def _make(target_printer_id: int | None = None, **overrides) -> dict:
+        resp = await async_client.post("/api/v1/slicer-pipelines/", json=_pipeline_payload(**overrides))
+        assert resp.status_code == 201, resp.text
+        pipeline = resp.json()
+        if target_printer_id is not None:
+            put_resp = await async_client.put(
+                f"/api/v1/slicer-pipelines/{pipeline['id']}",
+                json={"target_kind": "specific_printer", "target_printer_id": target_printer_id},
+            )
+            assert put_resp.status_code == 200, put_resp.text
+            pipeline = put_resp.json()
+        return pipeline
+
+    return _make
+
+
+@pytest.fixture
+async def printer_factory(db_session):
+    """Insert a Printer row for tests that need a target_printer_id."""
+    from backend.app.models.printer import Printer
+
+    counter = [0]
+
+    async def _make(**overrides) -> Printer:
+        counter[0] += 1
+        defaults = {
+            "name": f"X1C #{counter[0]}",
+            "serial_number": f"SERIAL{counter[0]:04d}",
+            "ip_address": "192.0.2.1",
+            "access_code": "ABCD1234",
+            "model": "Bambu Lab X1 Carbon",
+            "is_active": True,
+        }
+        defaults.update(overrides)
+        printer = Printer(**defaults)
+        db_session.add(printer)
+        await db_session.commit()
+        await db_session.refresh(printer)
+        return printer
+
+    return _make
+
+
+@pytest.fixture
+async def library_file_factory(db_session):
+    """Insert a LibraryFile row for tests that need a source_library_file_id."""
+    from pathlib import Path
+
+    from backend.app.core.config import settings as app_settings
+    from backend.app.models.library import LibraryFile
+
+    counter = [0]
+
+    async def _make(**overrides) -> LibraryFile:
+        counter[0] += 1
+        # Materialise an empty file on disk so the orchestration's path-exists
+        # guard passes when tests reach it.
+        rel = f"test_pipeline_run_{counter[0]}.3mf"
+        abs_path = Path(app_settings.base_dir) / rel
+        abs_path.parent.mkdir(parents=True, exist_ok=True)
+        abs_path.write_bytes(b"")
+        defaults = {
+            "filename": f"cube_{counter[0]}.3mf",
+            "file_path": rel,
+            "file_type": "3mf",
+            "file_size": 0,
+            "file_hash": f"hash_{counter[0]}",
+            "source_type": "uploaded",
+        }
+        defaults.update(overrides)
+        row = LibraryFile(**defaults)
+        db_session.add(row)
+        await db_session.commit()
+        await db_session.refresh(row)
+        return row
+
+    return _make
+
+
+class TestSlicerPipelineTarget:
+    """PUT /slicer-pipelines/{id} accepts the new target fields."""
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_update_writes_target(self, async_client: AsyncClient, pipeline_factory, printer_factory):
+        printer = await printer_factory()
+        pipeline = await pipeline_factory()
+        resp = await async_client.put(
+            f"/api/v1/slicer-pipelines/{pipeline['id']}",
+            json={"target_kind": "specific_printer", "target_printer_id": printer.id},
+        )
+        assert resp.status_code == 200, resp.text
+        updated = resp.json()
+        assert updated["target_kind"] == "specific_printer"
+        assert updated["target_printer_id"] == printer.id
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_update_target_printer_id_zero_clears(
+        self, async_client: AsyncClient, pipeline_factory, printer_factory
+    ):
+        """Empty-select dropdown sends target_printer_id=0 → backend treats
+        as 'clear' rather than referencing printer #0 (which doesn't exist)."""
+        printer = await printer_factory()
+        pipeline = await pipeline_factory(target_printer_id=printer.id)
+        resp = await async_client.put(
+            f"/api/v1/slicer-pipelines/{pipeline['id']}",
+            json={"target_printer_id": 0},
+        )
+        assert resp.status_code == 200, resp.text
+        assert resp.json()["target_printer_id"] is None
+
+
+class TestCheckEligibility:
+    """POST /slicer-pipelines/{id}/check-eligibility surfaces structured issues."""
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_no_target_set(
+        self,
+        async_client: AsyncClient,
+        pipeline_factory,
+        library_file_factory,
+    ):
+        pipeline = await pipeline_factory()  # no target set
+        src = await library_file_factory()
+        resp = await async_client.post(
+            f"/api/v1/slicer-pipelines/{pipeline['id']}/check-eligibility",
+            json={"source_library_file_id": src.id},
+        )
+        assert resp.status_code == 200
+        body = resp.json()
+        assert body["ok"] is False
+        kinds = [i["kind"] for i in body["issues"]]
+        # PR A defaults target_kind to 'printer_class' so a freshly-saved
+        # pipeline with no target_model_class surfaces ``class_not_set``; the
+        # PR B UI path that hadn't pinned a target_printer_id would surface
+        # ``printer_not_set``. Both signal the same thing to the operator;
+        # accept either.
+        assert kinds == ["class_not_set"] or kinds == ["printer_not_set"]
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_printer_disabled(
+        self,
+        async_client: AsyncClient,
+        pipeline_factory,
+        printer_factory,
+        library_file_factory,
+    ):
+        printer = await printer_factory(is_active=False)
+        pipeline = await pipeline_factory(target_printer_id=printer.id)
+        src = await library_file_factory()
+        with patch("backend.app.api.routes.pipeline_runs._load_printer_status", new=AsyncMock(return_value=None)):
+            resp = await async_client.post(
+                f"/api/v1/slicer-pipelines/{pipeline['id']}/check-eligibility",
+                json={"source_library_file_id": src.id},
+            )
+        assert resp.status_code == 200
+        body = resp.json()
+        kinds = [i["kind"] for i in body["issues"]]
+        assert "printer_disabled" in kinds
+        # printer_offline also fires because get_status returns None — both
+        # issues are expected and both block.
+        assert "printer_offline" in kinds
+        assert body["ok"] is False
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_online_match_clears_issues(
+        self,
+        async_client: AsyncClient,
+        pipeline_factory,
+        printer_factory,
+        library_file_factory,
+        db_session,
+    ):
+        """Patch printer_manager so AMS slot 0 carries the same canonical
+        type the pipeline's local-tier filament preset declares."""
+        from backend.app.models.local_preset import LocalPreset
+
+        preset = LocalPreset(
+            name="My PLA",
+            preset_type="filament",
+            source="manual",
+            setting="{}",
+            filament_type="PLA",
+            default_filament_colour="#FFFFFF",
+        )
+        db_session.add(preset)
+        await db_session.commit()
+        await db_session.refresh(preset)
+
+        printer = await printer_factory()
+        pipeline = await pipeline_factory(
+            target_printer_id=printer.id,
+            filament_presets=[{"source": "local", "id": str(preset.id)}],
+        )
+        src = await library_file_factory()
+
+        live_status = {
+            "connected": True,
+            "raw_data": {"ams": [{"tray": [{"tray_type": "PLA Basic", "tray_color": "FFFFFFFF"}]}]},
+        }
+        with patch(
+            "backend.app.api.routes.pipeline_runs._load_printer_status",
+            new=AsyncMock(return_value=live_status),
+        ):
+            resp = await async_client.post(
+                f"/api/v1/slicer-pipelines/{pipeline['id']}/check-eligibility",
+                json={"source_library_file_id": src.id},
+            )
+        assert resp.status_code == 200
+        body = resp.json()
+        assert body["ok"] is True
+        assert body["issues"] == []
+        assert body["target_printer_name"] == printer.name
+
+
+class TestRunPipeline:
+    """POST /slicer-pipelines/{id}/run orchestrates slice + enqueue."""
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_run_with_issues_and_no_force_returns_409(
+        self,
+        async_client: AsyncClient,
+        pipeline_factory,
+        library_file_factory,
+    ):
+        pipeline = await pipeline_factory()  # no target set
+        src = await library_file_factory()
+        resp = await async_client.post(
+            f"/api/v1/slicer-pipelines/{pipeline['id']}/run",
+            json={"source_library_file_id": src.id},
+        )
+        assert resp.status_code == 409
+        # Eligibility report rides in detail.
+        detail = resp.json()["detail"]
+        assert detail["ok"] is False
+        # printer_not_set or class_not_set — depends on the PR A default
+        # target_kind. Both mean "no target chosen yet".
+        kinds = [i["kind"] for i in detail["issues"]]
+        assert "printer_not_set" in kinds or "class_not_set" in kinds
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_run_force_with_no_target_still_400(
+        self,
+        async_client: AsyncClient,
+        pipeline_factory,
+        library_file_factory,
+    ):
+        """``force=True`` bypasses the 409 but the run endpoint still needs a
+        target to enqueue against — the second guard returns 400."""
+        pipeline = await pipeline_factory()
+        src = await library_file_factory()
+        resp = await async_client.post(
+            f"/api/v1/slicer-pipelines/{pipeline['id']}/run",
+            json={"source_library_file_id": src.id, "force": True},
+        )
+        assert resp.status_code == 400
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_run_creates_run_and_job(
+        self,
+        async_client: AsyncClient,
+        pipeline_factory,
+        printer_factory,
+        library_file_factory,
+    ):
+        printer = await printer_factory()
+        pipeline = await pipeline_factory(target_printer_id=printer.id)
+        src = await library_file_factory()
+
+        live_status = {"connected": True, "raw_data": {"ams": []}}
+        # AMS empty → eligibility surfaces filament_unverified (non-blocking)
+        # for the standard-tier filament refs the default factory uses; report
+        # is ok=True so no force needed.
+        from dataclasses import dataclass
+
+        @dataclass
+        class _FakeSliceJob:
+            id: int = 9001
+
+        with (
+            patch(
+                "backend.app.api.routes.pipeline_runs._load_printer_status",
+                new=AsyncMock(return_value=live_status),
+            ),
+            patch(
+                "backend.app.services.slice_dispatch.slice_dispatch.enqueue",
+                new=AsyncMock(return_value=_FakeSliceJob()),
+            ),
+        ):
+            resp = await async_client.post(
+                f"/api/v1/slicer-pipelines/{pipeline['id']}/run",
+                json={"source_library_file_id": src.id},
+            )
+        assert resp.status_code == 202, resp.text
+        body = resp.json()
+        assert body["pipeline_id"] == pipeline["id"]
+        assert body["source_library_file_id"] == src.id
+        assert body["copies"] == 1
+        assert body["status"] == "queued"
+        assert len(body["jobs"]) == 1
+        assert body["jobs"][0]["copy_index"] == 0
+        assert body["eligibility_overridden"] is False
+        # slice_job_id rides on the response so the frontend can call
+        # trackJob and render the progress toast.
+        assert body["slice_job_id"] == 9001
+
+
+class TestRunListAndGet:
+    """Run history surfaces."""
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_list_runs_empty(
+        self,
+        async_client: AsyncClient,
+        pipeline_factory,
+    ):
+        pipeline = await pipeline_factory()
+        resp = await async_client.get(f"/api/v1/slicer-pipelines/{pipeline['id']}/runs")
+        assert resp.status_code == 200
+        assert resp.json() == {"runs": [], "total": 0}
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_get_run_404(
+        self,
+        async_client: AsyncClient,
+    ):
+        resp = await async_client.get("/api/v1/pipeline-runs/99999")
+        assert resp.status_code == 404
+
+
+class TestCancelRun:
+    """Cancellation marks the run + linked queue entry."""
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_cancel_unknown_run_404(self, async_client: AsyncClient):
+        resp = await async_client.post("/api/v1/pipeline-runs/99999/cancel")
+        assert resp.status_code == 404
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_cancel_marks_queued_run(
+        self,
+        async_client: AsyncClient,
+        pipeline_factory,
+        printer_factory,
+        library_file_factory,
+        db_session,
+    ):
+        printer = await printer_factory()
+        pipeline = await pipeline_factory(target_printer_id=printer.id)
+        src = await library_file_factory()
+        live_status = {"connected": True, "raw_data": {"ams": []}}
+        from dataclasses import dataclass
+
+        @dataclass
+        class _FakeSliceJob:
+            id: int = 9001
+
+        with (
+            patch(
+                "backend.app.api.routes.pipeline_runs._load_printer_status",
+                new=AsyncMock(return_value=live_status),
+            ),
+            patch(
+                "backend.app.services.slice_dispatch.slice_dispatch.enqueue",
+                new=AsyncMock(return_value=_FakeSliceJob()),
+            ),
+        ):
+            run_resp = await async_client.post(
+                f"/api/v1/slicer-pipelines/{pipeline['id']}/run",
+                json={"source_library_file_id": src.id},
+            )
+        run_id = run_resp.json()["id"]
+        cancel_resp = await async_client.post(f"/api/v1/pipeline-runs/{run_id}/cancel")
+        assert cancel_resp.status_code == 200
+        assert cancel_resp.json()["status"] == "cancelled"
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_run_accepts_archive_source(
+        self,
+        async_client: AsyncClient,
+        pipeline_factory,
+        printer_factory,
+        db_session,
+    ):
+        """``source_archive_id`` is accepted in place of source_library_file_id."""
+        from pathlib import Path
+
+        from backend.app.core.config import settings as app_settings
+        from backend.app.models.archive import PrintArchive
+
+        printer = await printer_factory()
+        pipeline = await pipeline_factory(target_printer_id=printer.id)
+
+        rel = "test_pipeline_archive_source.3mf"
+        (Path(app_settings.base_dir) / rel).write_bytes(b"")
+        archive = PrintArchive(
+            printer_id=printer.id,
+            filename="Archive Source.3mf",
+            file_path=rel,
+            file_size=0,
+            source_3mf_path=rel,
+        )
+        db_session.add(archive)
+        await db_session.commit()
+        await db_session.refresh(archive)
+
+        from dataclasses import dataclass
+
+        @dataclass
+        class _FakeSliceJob:
+            id: int = 7777
+
+        live_status = {"connected": True, "raw_data": {"ams": []}}
+        with (
+            patch(
+                "backend.app.api.routes.pipeline_runs._load_printer_status",
+                new=AsyncMock(return_value=live_status),
+            ),
+            patch(
+                "backend.app.services.slice_dispatch.slice_dispatch.enqueue",
+                new=AsyncMock(return_value=_FakeSliceJob()),
+            ),
+        ):
+            resp = await async_client.post(
+                f"/api/v1/slicer-pipelines/{pipeline['id']}/run",
+                json={"source_archive_id": archive.id},
+            )
+        assert resp.status_code == 202, resp.text
+        body = resp.json()
+        assert body["source_library_file_id"] is None
+        assert body["source_archive_id"] == archive.id
+        assert body["slice_job_id"] == 7777
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_run_rejects_no_source(
+        self,
+        async_client: AsyncClient,
+        pipeline_factory,
+        printer_factory,
+    ):
+        printer = await printer_factory()
+        pipeline = await pipeline_factory(target_printer_id=printer.id)
+        resp = await async_client.post(f"/api/v1/slicer-pipelines/{pipeline['id']}/run", json={})
+        assert resp.status_code == 422
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_run_rejects_both_sources(
+        self,
+        async_client: AsyncClient,
+        pipeline_factory,
+        printer_factory,
+        library_file_factory,
+    ):
+        printer = await printer_factory()
+        pipeline = await pipeline_factory(target_printer_id=printer.id)
+        src = await library_file_factory()
+        resp = await async_client.post(
+            f"/api/v1/slicer-pipelines/{pipeline['id']}/run",
+            json={"source_library_file_id": src.id, "source_archive_id": 99},
+        )
+        assert resp.status_code == 422
+
+
+class TestPipelineC:
+    """PR C — multi-copy, class targeting, fanout strategies, retry-failed,
+    dashboard list, max-copies cap."""
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_copies_cap_enforced(
+        self,
+        async_client: AsyncClient,
+        pipeline_factory,
+        printer_factory,
+        library_file_factory,
+    ):
+        printer = await printer_factory()
+        pipeline = await pipeline_factory(target_printer_id=printer.id)
+        src = await library_file_factory()
+        # Default cap is 50; over-request returns 422 even with valid eligibility.
+        resp = await async_client.post(
+            f"/api/v1/slicer-pipelines/{pipeline['id']}/run",
+            json={"source_library_file_id": src.id, "copies": 9999},
+        )
+        assert resp.status_code == 422  # schema gate (le=1000)
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_run_copies_3_creates_3_jobs(
+        self,
+        async_client: AsyncClient,
+        pipeline_factory,
+        printer_factory,
+        library_file_factory,
+    ):
+        from dataclasses import dataclass
+
+        @dataclass
+        class _FakeSliceJob:
+            id: int = 5555
+
+        printer = await printer_factory()
+        pipeline = await pipeline_factory(target_printer_id=printer.id)
+        src = await library_file_factory()
+
+        live_status = {"connected": True, "raw_data": {"ams": []}}
+        with (
+            patch(
+                "backend.app.api.routes.pipeline_runs._load_printer_status",
+                new=AsyncMock(return_value=live_status),
+            ),
+            patch(
+                "backend.app.services.slice_dispatch.slice_dispatch.enqueue",
+                new=AsyncMock(return_value=_FakeSliceJob()),
+            ),
+        ):
+            resp = await async_client.post(
+                f"/api/v1/slicer-pipelines/{pipeline['id']}/run",
+                json={"source_library_file_id": src.id, "copies": 3},
+            )
+        assert resp.status_code == 202, resp.text
+        body = resp.json()
+        assert body["copies"] == 3
+        assert len(body["jobs"]) == 3
+        assert [j["copy_index"] for j in body["jobs"]] == [0, 1, 2]
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_class_eligibility_per_printer_breakdown(
+        self,
+        async_client: AsyncClient,
+        pipeline_factory,
+        printer_factory,
+        library_file_factory,
+    ):
+        """target_kind='printer_class' surfaces per-printer reports."""
+        await printer_factory(model="X1C")
+        await printer_factory(model="X1C")
+        await printer_factory(model="P1S")  # noise — different model
+        pipeline = await pipeline_factory()
+        # Wire class targeting via PUT.
+        put_resp = await async_client.put(
+            f"/api/v1/slicer-pipelines/{pipeline['id']}",
+            json={
+                "target_kind": "printer_class",
+                "target_printer_id": 0,
+                "target_model_class": "X1C",
+                "fanout_strategy": "max_parallel",
+            },
+        )
+        assert put_resp.status_code == 200, put_resp.text
+        src = await library_file_factory()
+        resp = await async_client.post(
+            f"/api/v1/slicer-pipelines/{pipeline['id']}/check-eligibility",
+            json={"source_library_file_id": src.id},
+        )
+        assert resp.status_code == 200, resp.text
+        body = resp.json()
+        assert body["target_kind"] == "printer_class"
+        assert body["target_model_class"] == "X1C"
+        # Two X1Cs were created — both should appear in the per-printer breakdown.
+        assert len(body["printer_reports"]) == 2
+        assert all(r["printer_name"].startswith("X1C") for r in body["printer_reports"])
+        # AMS empty + no live state → both are offline, so ok=False.
+        assert body["ok"] is False
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_class_eligibility_no_matching_printers(
+        self,
+        async_client: AsyncClient,
+        pipeline_factory,
+        printer_factory,
+        library_file_factory,
+    ):
+        await printer_factory(model="P1S")  # only a P1S in the install
+        pipeline = await pipeline_factory()
+        await async_client.put(
+            f"/api/v1/slicer-pipelines/{pipeline['id']}",
+            json={
+                "target_kind": "printer_class",
+                "target_printer_id": 0,
+                "target_model_class": "X1C",
+            },
+        )
+        src = await library_file_factory()
+        resp = await async_client.post(
+            f"/api/v1/slicer-pipelines/{pipeline['id']}/check-eligibility",
+            json={"source_library_file_id": src.id},
+        )
+        body = resp.json()
+        assert body["ok"] is False
+        assert any(i["kind"] == "no_class_matches" for i in body["issues"])
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_list_all_runs_dashboard_endpoint(
+        self,
+        async_client: AsyncClient,
+        pipeline_factory,
+        printer_factory,
+        library_file_factory,
+        db_session,
+    ):
+        from backend.app.models.pipeline_run import PipelineRun
+
+        printer = await printer_factory()
+        pipeline = await pipeline_factory(target_printer_id=printer.id)
+        src = await library_file_factory()
+        for i in range(3):
+            run = PipelineRun(
+                pipeline_id=pipeline["id"],
+                source_library_file_id=src.id,
+                copies=1,
+                status="completed" if i % 2 == 0 else "failed",
+            )
+            db_session.add(run)
+        await db_session.commit()
+
+        resp = await async_client.get("/api/v1/pipeline-runs?limit=10")
+        assert resp.status_code == 200
+        body = resp.json()
+        assert body["total"] == 3
+        assert len(body["runs"]) == 3
+        # Newest first.
+        assert body["runs"][0]["id"] > body["runs"][-1]["id"]
+
+        # Filter by status.
+        resp = await async_client.get("/api/v1/pipeline-runs?status=failed")
+        body = resp.json()
+        assert all(r["status"] == "failed" for r in body["runs"])
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_retry_failed_creates_child_run(
+        self,
+        async_client: AsyncClient,
+        pipeline_factory,
+        printer_factory,
+        library_file_factory,
+        db_session,
+    ):
+        from dataclasses import dataclass
+
+        from backend.app.models.pipeline_run import PipelineJob, PipelineRun
+
+        @dataclass
+        class _FakeSliceJob:
+            id: int = 6666
+
+        printer = await printer_factory()
+        pipeline = await pipeline_factory(target_printer_id=printer.id)
+        src = await library_file_factory()
+        # Build a parent run with 3 jobs: 1 completed, 2 failed → retry
+        # should request copies=2.
+        parent = PipelineRun(
+            pipeline_id=pipeline["id"],
+            source_library_file_id=src.id,
+            copies=3,
+            status="partial_failure",
+        )
+        db_session.add(parent)
+        await db_session.flush()
+        for idx, status in enumerate(["completed", "failed", "failed"]):
+            db_session.add(PipelineJob(pipeline_run_id=parent.id, copy_index=idx, status=status))
+        await db_session.commit()
+        await db_session.refresh(parent)
+
+        live_status = {"connected": True, "raw_data": {"ams": []}}
+        with (
+            patch(
+                "backend.app.api.routes.pipeline_runs._load_printer_status",
+                new=AsyncMock(return_value=live_status),
+            ),
+            patch(
+                "backend.app.services.slice_dispatch.slice_dispatch.enqueue",
+                new=AsyncMock(return_value=_FakeSliceJob()),
+            ),
+        ):
+            resp = await async_client.post(f"/api/v1/pipeline-runs/{parent.id}/retry-failed")
+        assert resp.status_code == 202, resp.text
+        body = resp.json()
+        assert body["copies"] == 2  # only the 2 failed copies
+        assert body["parent_run_id"] == parent.id
+
+
+class TestPolishFollowUp:
+    """Polish-pass fixes: dashboard target filters, clear endpoint, and the
+    deleted-queue-entry → cancelled rollup behaviour."""
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_dashboard_filters_by_target_printer(
+        self,
+        async_client: AsyncClient,
+        pipeline_factory,
+        printer_factory,
+        library_file_factory,
+        db_session,
+    ):
+        from backend.app.models.pipeline_run import PipelineRun
+
+        printer_a = await printer_factory()
+        printer_b = await printer_factory()
+        pipe_a = await pipeline_factory(target_printer_id=printer_a.id)
+        pipe_b = await pipeline_factory(target_printer_id=printer_b.id)
+        src = await library_file_factory()
+        for pipe in (pipe_a, pipe_a, pipe_b):
+            db_session.add(
+                PipelineRun(
+                    pipeline_id=pipe["id"],
+                    source_library_file_id=src.id,
+                    copies=1,
+                    status="completed",
+                )
+            )
+        await db_session.commit()
+
+        resp = await async_client.get(f"/api/v1/pipeline-runs?target_printer_id={printer_a.id}")
+        assert resp.status_code == 200
+        body = resp.json()
+        assert body["total"] == 2
+        assert all(r["target_printer_id"] == printer_a.id for r in body["runs"])
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_dashboard_filters_by_target_model_class(
+        self,
+        async_client: AsyncClient,
+        pipeline_factory,
+        printer_factory,
+        library_file_factory,
+        db_session,
+    ):
+        from backend.app.models.pipeline_run import PipelineRun
+
+        await printer_factory(model="X1C")
+        await printer_factory(model="P1S")
+        # Two pipelines, one class-targeting X1C, one P1S.
+        pipe_x = await pipeline_factory()
+        await async_client.put(
+            f"/api/v1/slicer-pipelines/{pipe_x['id']}",
+            json={"target_kind": "printer_class", "target_printer_id": 0, "target_model_class": "X1C"},
+        )
+        pipe_p = await pipeline_factory()
+        await async_client.put(
+            f"/api/v1/slicer-pipelines/{pipe_p['id']}",
+            json={"target_kind": "printer_class", "target_printer_id": 0, "target_model_class": "P1S"},
+        )
+        src = await library_file_factory()
+        for pipe in (pipe_x, pipe_p, pipe_p):
+            db_session.add(
+                PipelineRun(
+                    pipeline_id=pipe["id"],
+                    source_library_file_id=src.id,
+                    copies=1,
+                    status="completed",
+                )
+            )
+        await db_session.commit()
+
+        resp = await async_client.get("/api/v1/pipeline-runs?target_model_class=P1S")
+        assert resp.status_code == 200
+        body = resp.json()
+        assert body["total"] == 2
+        assert all(r["target_model_class"] == "P1S" for r in body["runs"])
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_clear_endpoint_deletes_terminal_runs_only(
+        self,
+        async_client: AsyncClient,
+        pipeline_factory,
+        printer_factory,
+        library_file_factory,
+        db_session,
+    ):
+        from backend.app.models.pipeline_run import PipelineRun
+
+        printer = await printer_factory()
+        pipe = await pipeline_factory(target_printer_id=printer.id)
+        src = await library_file_factory()
+        for status in ("completed", "failed", "cancelled", "partial_failure", "dispatching", "in_progress"):
+            db_session.add(
+                PipelineRun(
+                    pipeline_id=pipe["id"],
+                    source_library_file_id=src.id,
+                    copies=1,
+                    status=status,
+                )
+            )
+        await db_session.commit()
+
+        resp = await async_client.post("/api/v1/pipeline-runs/clear")
+        assert resp.status_code == 200, resp.text
+        assert resp.json()["deleted"] == 4  # 4 terminal statuses cleared
+
+        # The in-flight rows survive.
+        survivors = (await async_client.get("/api/v1/pipeline-runs")).json()
+        assert survivors["total"] == 2
+        assert {r["status"] for r in survivors["runs"]} == {"dispatching", "in_progress"}
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_deleted_queue_entry_rolls_up_as_cancelled(
+        self,
+        async_client: AsyncClient,
+        pipeline_factory,
+        printer_factory,
+        library_file_factory,
+        db_session,
+    ):
+        """When the queue entry that a PipelineJob is linked to gets deleted
+        from the print-queue page, the job's live status should roll up to
+        ``cancelled`` so the run doesn't sit forever showing ``queued`` /
+        ``dispatching``."""
+        from backend.app.models.pipeline_run import PipelineJob, PipelineRun
+
+        printer = await printer_factory()
+        pipe = await pipeline_factory(target_printer_id=printer.id)
+        src = await library_file_factory()
+        # Simulate the state PR C leaves a successful dispatch in: run is
+        # 'dispatching' and the job has a queue_entry_id pointing at a
+        # PrintQueueItem that no longer exists.
+        run = PipelineRun(
+            pipeline_id=pipe["id"],
+            source_library_file_id=src.id,
+            copies=1,
+            status="dispatching",
+        )
+        db_session.add(run)
+        await db_session.flush()
+        db_session.add(
+            PipelineJob(
+                pipeline_run_id=run.id,
+                copy_index=0,
+                queue_entry_id=999999,  # Doesn't exist — simulates manual delete from queue.
+                assigned_printer_id=printer.id,
+                status="queued",
+            )
+        )
+        await db_session.commit()
+        await db_session.refresh(run)
+
+        resp = await async_client.get(f"/api/v1/pipeline-runs/{run.id}")
+        assert resp.status_code == 200, resp.text
+        body = resp.json()
+        # Job rolled up to cancelled because the queue entry is gone.
+        assert body["jobs"][0]["status"] == "cancelled"
+        # Run also rolls up — all jobs cancelled → run reads as cancelled.
+        assert body["status"] == "cancelled"
+
+
+class TestCancelTerminal:
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_cancel_terminal_run_is_idempotent(
+        self,
+        async_client: AsyncClient,
+        pipeline_factory,
+        printer_factory,
+        library_file_factory,
+        db_session,
+    ):
+        from backend.app.models.pipeline_run import PipelineRun
+
+        printer = await printer_factory()
+        pipeline = await pipeline_factory(target_printer_id=printer.id)
+        src = await library_file_factory()
+        run = PipelineRun(
+            pipeline_id=pipeline["id"],
+            source_library_file_id=src.id,
+            copies=1,
+            status="completed",
+        )
+        db_session.add(run)
+        await db_session.commit()
+        await db_session.refresh(run)
+        resp = await async_client.post(f"/api/v1/pipeline-runs/{run.id}/cancel")
+        assert resp.status_code == 200
+        assert resp.json()["status"] == "completed"  # unchanged

+ 57 - 0
backend/tests/integration/test_read_permission_backfill_migration.py

@@ -218,6 +218,63 @@ class TestReadPermissionMigration:
         perms = await _get_perms("Operators")
         perms = await _get_perms("Operators")
         assert "orca_cloud:auth" in perms
         assert "orca_cloud:auth" in perms
 
 
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_administrators_printer_sensor_history_read_backfilled(self, async_client: AsyncClient):
+        """Admin without `printer_sensor_history:read` (older custom edit or
+        a DB seeded before that permission existed) gets it backfilled —
+        regression for the gap maziggy hit on a live install where the
+        per-permission admin backfills missed it."""
+        await seed_default_groups()
+        async with _database_module.async_session() as session:
+            grp = (await session.execute(select(Group).where(Group.name == "Administrators"))).scalar_one()
+            grp.permissions = [p for p in (grp.permissions or []) if p != "printer_sensor_history:read"]
+            await session.commit()
+
+        await seed_default_groups()
+
+        perms = await _get_perms("Administrators")
+        assert "printer_sensor_history:read" in perms
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_administrators_sync_covers_every_current_permission(self, async_client: AsyncClient):
+        """Generic invariant: ALL_PERMISSIONS sync ensures every Permission
+        enum value is present on the Administrators group, no matter what
+        was stripped pre-backfill. Catches every future "new permission
+        missing on upgrade" regression without needing a one-off test."""
+        from backend.app.core.permissions import ALL_PERMISSIONS
+
+        await seed_default_groups()
+        # Wipe the admin group's permission list entirely and force the sync
+        # to put everything back.
+        async with _database_module.async_session() as session:
+            grp = (await session.execute(select(Group).where(Group.name == "Administrators"))).scalar_one()
+            grp.permissions = []
+            await session.commit()
+
+        await seed_default_groups()
+
+        perms = await _get_perms("Administrators")
+        missing = [p for p in ALL_PERMISSIONS if p not in perms]
+        assert not missing, f"Administrators missing permissions after backfill: {missing}"
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_administrators_sync_is_additive_only(self, async_client: AsyncClient):
+        """The sync block must never remove a permission an operator added by
+        hand — only add missing entries from ALL_PERMISSIONS."""
+        await seed_default_groups()
+        async with _database_module.async_session() as session:
+            grp = (await session.execute(select(Group).where(Group.name == "Administrators"))).scalar_one()
+            grp.permissions = [*(grp.permissions or []), "custom:plugin_permission"]
+            await session.commit()
+
+        await seed_default_groups()
+
+        perms = await _get_perms("Administrators")
+        assert "custom:plugin_permission" in perms
+
     @pytest.mark.asyncio
     @pytest.mark.asyncio
     @pytest.mark.integration
     @pytest.mark.integration
     async def test_viewers_do_not_get_orca_cloud_auth(self, async_client: AsyncClient):
     async def test_viewers_do_not_get_orca_cloud_auth(self, async_client: AsyncClient):

+ 158 - 0
backend/tests/integration/test_slicer_pipelines_api.py

@@ -0,0 +1,158 @@
+"""Integration tests for the Slicer Pipelines API (#1425 PR A)."""
+
+import pytest
+from httpx import AsyncClient
+
+
+def _preset_ref(source: str, id_: str) -> dict:
+    return {"source": source, "id": id_}
+
+
+def _payload(**overrides) -> dict:
+    payload = {
+        "name": "Production Batch",
+        "description": "High speed PLA on X1C",
+        "printer_preset": _preset_ref("local", "42"),
+        "process_preset": _preset_ref("local", "7"),
+        "filament_presets": [_preset_ref("local", "11"), _preset_ref("standard", "PLA Basic")],
+        "bed_type": "Textured PEI Plate",
+    }
+    payload.update(overrides)
+    return payload
+
+
+class TestSlicerPipelinesAPI:
+    """CRUD + edge cases for /api/v1/slicer-pipelines."""
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_list_empty(self, async_client: AsyncClient):
+        """Empty list response uses the canonical {pipelines: []} envelope."""
+        resp = await async_client.get("/api/v1/slicer-pipelines/")
+        assert resp.status_code == 200
+        data = resp.json()
+        assert data == {"pipelines": []}
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_create_and_list(self, async_client: AsyncClient):
+        """A newly-created pipeline appears in the list with its full shape."""
+        resp = await async_client.post("/api/v1/slicer-pipelines/", json=_payload())
+        assert resp.status_code == 201, resp.text
+        created = resp.json()
+        assert created["name"] == "Production Batch"
+        assert created["printer_preset"] == _preset_ref("local", "42")
+        assert created["process_preset"] == _preset_ref("local", "7")
+        assert created["filament_presets"] == [
+            _preset_ref("local", "11"),
+            _preset_ref("standard", "PLA Basic"),
+        ]
+        assert created["bed_type"] == "Textured PEI Plate"
+        # PR A defaults persisted but not user-set
+        assert created["target_kind"] == "printer_class"
+        assert created["target_printer_id"] is None
+        assert created["fanout_strategy"] == "max_parallel"
+
+        list_resp = await async_client.get("/api/v1/slicer-pipelines/")
+        assert list_resp.status_code == 200
+        ids = [p["id"] for p in list_resp.json()["pipelines"]]
+        assert created["id"] in ids
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_get_by_id(self, async_client: AsyncClient):
+        """Round-trips the preset slots through JSON storage faithfully."""
+        created = (await async_client.post("/api/v1/slicer-pipelines/", json=_payload())).json()
+        resp = await async_client.get(f"/api/v1/slicer-pipelines/{created['id']}")
+        assert resp.status_code == 200
+        fetched = resp.json()
+        assert fetched["printer_preset"] == _preset_ref("local", "42")
+        assert fetched["filament_presets"] == [
+            _preset_ref("local", "11"),
+            _preset_ref("standard", "PLA Basic"),
+        ]
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_get_not_found(self, async_client: AsyncClient):
+        resp = await async_client.get("/api/v1/slicer-pipelines/99999")
+        assert resp.status_code == 404
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_update_partial(self, async_client: AsyncClient):
+        """PUT writes only fields that are present; others stay unchanged."""
+        created = (await async_client.post("/api/v1/slicer-pipelines/", json=_payload())).json()
+        resp = await async_client.put(
+            f"/api/v1/slicer-pipelines/{created['id']}",
+            json={"name": "Renamed", "bed_type": "Cool Plate"},
+        )
+        assert resp.status_code == 200
+        updated = resp.json()
+        assert updated["name"] == "Renamed"
+        assert updated["bed_type"] == "Cool Plate"
+        # Untouched fields preserved
+        assert updated["printer_preset"] == _preset_ref("local", "42")
+        assert updated["filament_presets"] == created["filament_presets"]
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_update_filament_list_replaces_wholesale(self, async_client: AsyncClient):
+        """Setting filament_presets replaces the entire list."""
+        created = (await async_client.post("/api/v1/slicer-pipelines/", json=_payload())).json()
+        new_filaments = [_preset_ref("cloud", "PFUS1"), _preset_ref("cloud", "PFUS2"), _preset_ref("cloud", "PFUS3")]
+        resp = await async_client.put(
+            f"/api/v1/slicer-pipelines/{created['id']}",
+            json={"filament_presets": new_filaments},
+        )
+        assert resp.status_code == 200
+        assert resp.json()["filament_presets"] == new_filaments
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_delete_is_soft(self, async_client: AsyncClient):
+        """DELETE hides from list + GET-by-id but doesn't drop the row (PR B+
+        run history must still resolve pipeline metadata)."""
+        created = (await async_client.post("/api/v1/slicer-pipelines/", json=_payload())).json()
+        resp = await async_client.delete(f"/api/v1/slicer-pipelines/{created['id']}")
+        assert resp.status_code == 204
+        # Hidden from list
+        list_resp = await async_client.get("/api/v1/slicer-pipelines/")
+        assert created["id"] not in [p["id"] for p in list_resp.json()["pipelines"]]
+        # Hidden from GET
+        get_resp = await async_client.get(f"/api/v1/slicer-pipelines/{created['id']}")
+        assert get_resp.status_code == 404
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_delete_not_found(self, async_client: AsyncClient):
+        resp = await async_client.delete("/api/v1/slicer-pipelines/99999")
+        assert resp.status_code == 404
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_create_rejects_empty_filament_list(self, async_client: AsyncClient):
+        """The schema requires at least one filament slot."""
+        payload = _payload(filament_presets=[])
+        resp = await async_client.post("/api/v1/slicer-pipelines/", json=payload)
+        assert resp.status_code == 422
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_create_rejects_invalid_preset_source(self, async_client: AsyncClient):
+        """PresetRef.source is constrained to the four known tiers."""
+        bad = _preset_ref("bogus_source", "1")
+        payload = _payload(printer_preset=bad)
+        resp = await async_client.post("/api/v1/slicer-pipelines/", json=payload)
+        assert resp.status_code == 422
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_list_orders_newest_first(self, async_client: AsyncClient):
+        first = (await async_client.post("/api/v1/slicer-pipelines/", json=_payload(name="First"))).json()
+        second = (await async_client.post("/api/v1/slicer-pipelines/", json=_payload(name="Second"))).json()
+        third = (await async_client.post("/api/v1/slicer-pipelines/", json=_payload(name="Third"))).json()
+        listing = (await async_client.get("/api/v1/slicer-pipelines/")).json()["pipelines"]
+        # Filter to the three we just made (DB may have other rows from other tests)
+        ours = [p for p in listing if p["id"] in {first["id"], second["id"], third["id"]}]
+        assert [p["name"] for p in ours] == ["Third", "Second", "First"]

+ 55 - 0
backend/tests/unit/test_scheduler_ams_mapping.py

@@ -1029,6 +1029,61 @@ class TestExtractNozzleMappingFrom3mf:
         assert result is None
         assert result is None
         zf.close()
         zf.close()
 
 
+    def test_single_active_under_report_with_multi_group_falls_through(self):
+        """#1825: extruder_nozzle_stats under-reports the second nozzle, but the
+        slice genuinely uses two extruders (group_id 0 and 1). The shortcut must
+        NOT fire — the parser has to honour the per-filament group_id assignment.
+
+        H2D HT-AMS scenario from the report: physical_extruder_map ['1','0'],
+        extruder_nozzle_stats ['Standard#1','Standard#0'] (sum==1), but ASA is
+        group_id 0 (→ LEFT) and PETG is group_id 1 (→ RIGHT). Before the fix
+        the shortcut collapsed both onto LEFT; now group_id wins.
+        """
+        slice_info = """<?xml version="1.0" encoding="UTF-8"?>
+        <config>
+          <plate>
+            <filament id="1" type="ASA"  color="#FF0000" used_g="5.0" group_id="0"/>
+            <filament id="2" type="PETG" color="#00FF00" used_g="3.0" group_id="1"/>
+          </plate>
+        </config>"""
+        zf = _make_3mf_zip(
+            {
+                "physical_extruder_map": ["1", "0"],
+                "extruder_nozzle_stats": ["Standard#1", "Standard#0"],
+            },
+            slice_info_xml=slice_info,
+        )
+        result = extract_nozzle_mapping_from_3mf(zf)
+        # group_id 0 → physical_extruder_map[0] = 1 (LEFT)
+        # group_id 1 → physical_extruder_map[1] = 0 (RIGHT)
+        assert result == {1: 1, 2: 0}
+        zf.close()
+
+    def test_single_active_with_single_group_still_uses_shortcut(self):
+        """When stats report one active extruder AND the slice is truly
+        single-group, the shortcut is still correct and must continue to fire
+        (preserves the #851 behaviour for genuine single-nozzle prints made on
+        a multi-nozzle printer where only one nozzle is installed).
+        """
+        slice_info = """<?xml version="1.0" encoding="UTF-8"?>
+        <config>
+          <plate>
+            <filament id="1" type="PLA" color="#FF0000" used_g="5.0" group_id="0"/>
+            <filament id="2" type="PLA" color="#00FF00" used_g="3.0" group_id="0"/>
+          </plate>
+        </config>"""
+        zf = _make_3mf_zip(
+            {
+                "physical_extruder_map": ["1", "0"],
+                "extruder_nozzle_stats": ["Standard#1", "Standard#0"],
+            },
+            slice_info_xml=slice_info,
+        )
+        result = extract_nozzle_mapping_from_3mf(zf)
+        # Only extruder index 0 is active → physical_extruder_map[0] = 1 (LEFT)
+        assert result == {1: 1, 2: 1}
+        zf.close()
+
 
 
 class TestNozzleAwareMapping:
 class TestNozzleAwareMapping:
     """Test nozzle-aware filament matching in the print scheduler."""
     """Test nozzle-aware filament matching in the print scheduler."""

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

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

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

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

+ 14 - 0
frontend/scripts/check-i18n-parity.mjs

@@ -119,6 +119,7 @@ function isAlwaysAllowedIdentical(value) {
   if (/^v?\d+(\.\d+)+/.test(value)) return true;        // version-like
   if (/^v?\d+(\.\d+)+/.test(value)) return true;        // version-like
   if (/^#[0-9a-fA-F]{3,8}$/.test(value)) return true;   // hex color
   if (/^#[0-9a-fA-F]{3,8}$/.test(value)) return true;   // hex color
   if (/^\{\{[^}]+\}\}$/.test(value)) return true;       // pure placeholder
   if (/^\{\{[^}]+\}\}$/.test(value)) return true;       // pure placeholder
+  if (/^\{\{[^}]+\}\}([\s/\-–·,]+\{\{[^}]+\}\})+$/.test(value)) return true;  // placeholders joined by punctuation only ({{a}} / {{b}})
   if (/^[0-9a-fA-F]{6}$/.test(value)) return true;      // bare hex color
   if (/^[0-9a-fA-F]{6}$/.test(value)) return true;      // bare hex color
   if (/^[a-z0-9._%+-]+@[a-z0-9.-]+\.[a-z]{2,}$/i.test(value)) return true;  // email
   if (/^[a-z0-9._%+-]+@[a-z0-9.-]+\.[a-z]{2,}$/i.test(value)) return true;  // email
   if (/^https?:\/\//.test(value)) return true;          // URL
   if (/^https?:\/\//.test(value)) return true;          // URL
@@ -147,6 +148,8 @@ const DE_COGNATES = [
 
 
   'Pause', 'Power', 'System', 'Problem', 'Designer', 'Extruder', 'Firmware',
   'Pause', 'Power', 'System', 'Problem', 'Designer', 'Extruder', 'Firmware',
   'Material', 'Original', 'Position', 'Webhook', 'Workflow', 'Slicer',
   'Material', 'Original', 'Position', 'Webhook', 'Workflow', 'Slicer',
+  'Pipeline', 'Pipelines', 'Filament {{n}}',  // #1425 — Slicer Pipelines (DE)
+  'parallel',  // #1425 PR C polish — "parallel" is the same word in German
   'Region', 'Normal', 'Orange', 'Branch', 'Budget', 'Commit', 'Global',
   'Region', 'Normal', 'Orange', 'Branch', 'Budget', 'Commit', 'Global',
   'Version', 'Slot', 'Live', 'Rate', 'Host', 'Trend', 'Min', 'Admin', 'Cloud',
   'Version', 'Slot', 'Live', 'Rate', 'Host', 'Trend', 'Min', 'Admin', 'Cloud',
   'Filament', 'Filaments', 'Software', 'Hardware', 'Avatar', 'Pin', 'Modal',
   'Filament', 'Filaments', 'Software', 'Hardware', 'Avatar', 'Pin', 'Modal',
@@ -179,6 +182,9 @@ const FR_COGNATES = [
   'Filaments', 'Software', 'Hardware', 'Stop', 'Reset', 'Test', 'Code',
   'Filaments', 'Software', 'Hardware', 'Stop', 'Reset', 'Test', 'Code',
   'Token', 'Server', 'Port', 'Plate', 'Layer', 'Active', 'Total', 'Avatar',
   'Token', 'Server', 'Port', 'Plate', 'Layer', 'Active', 'Total', 'Avatar',
   'Job', 'Modal', 'Pin', 'Pro', 'Mini', 'Studio', 'Excellent', 'Description',
   'Job', 'Modal', 'Pin', 'Pro', 'Mini', 'Studio', 'Excellent', 'Description',
+  'Pipeline', 'Pipelines', 'Filament {{n}}',  // #1425 — Slicer Pipelines (FR)
+  'Copies', '{{n}} copies', 'max {{n}}',  // #1425 PR C — French uses these forms verbatim
+  'round robin',  // borrowed English term used as-is in French tech contexts
   'Action', 'Actions', 'Date', 'Type', 'Cache', 'Service', 'Configuration',
   'Action', 'Actions', 'Date', 'Type', 'Cache', 'Service', 'Configuration',
   'Archives', 'Maintenance', 'Notifications', 'Notification', 'Position',
   'Archives', 'Maintenance', 'Notifications', 'Notification', 'Position',
   'Pause', 'Solution', 'Source', 'Version', 'Format', 'Documentation',
   'Pause', 'Solution', 'Source', 'Version', 'Format', 'Documentation',
@@ -217,6 +223,9 @@ const IT_COGNATES = [
   'Bambu Cloud', 'Orca Cloud',  // brand names — same in every locale
   'Bambu Cloud', 'Orca Cloud',  // brand names — same in every locale
   'AMS Filament Backup',  // Bambu Lab product/firmware feature name
   'AMS Filament Backup',  // Bambu Lab product/firmware feature name
   'Email',  // common loanword in Italian, used verbatim in UI labels
   'Email',  // common loanword in Italian, used verbatim in UI labels
+  'Pipeline', 'slicing',  // #1425 — Slicer Pipelines (cognate in IT)
+  'max {{n}}',  // #1425 PR C — same form in Italian (max + number)
+  'round robin',  // borrowed English term used as-is in Italian tech contexts
   'Status', 'Tag', 'Tags', 'Online', 'Offline', 'Standard', 'Filament',
   'Status', 'Tag', 'Tags', 'Online', 'Offline', 'Standard', 'Filament',
   'Filaments', 'Software', 'Hardware', 'Stop', 'Reset', 'Test', 'Code',
   'Filaments', 'Software', 'Hardware', 'Stop', 'Reset', 'Test', 'Code',
   'Token', 'Server', 'Port', 'Plate', 'Layer', 'Modal', 'Pin', 'Pro', 'Mini',
   'Token', 'Server', 'Port', 'Plate', 'Layer', 'Modal', 'Pin', 'Pro', 'Mini',
@@ -260,6 +269,8 @@ const JA_COGNATES = [
 const PT_BR_COGNATES = [
 const PT_BR_COGNATES = [
   'Bambu Cloud', 'Orca Cloud',  // brand names — same in every locale
   'Bambu Cloud', 'Orca Cloud',  // brand names — same in every locale
   'AMS Filament Backup',  // Bambu Lab product/firmware feature name
   'AMS Filament Backup',  // Bambu Lab product/firmware feature name
+  'Pipeline', 'Pipelines',  // #1425 — Slicer Pipelines (PT-BR)
+  'round robin',  // borrowed English term used as-is in Portuguese tech contexts
   'Status', 'Tag', 'Tags', 'Online', 'Offline', 'Standard', 'Filament',
   'Status', 'Tag', 'Tags', 'Online', 'Offline', 'Standard', 'Filament',
   'Software', 'Hardware', 'Stop', 'Reset', 'Test', 'Code', 'Token', 'Server',
   'Software', 'Hardware', 'Stop', 'Reset', 'Test', 'Code', 'Token', 'Server',
   'Port', 'Plate', 'Layer', 'Modal', 'Pin', 'Pro', 'Mini', 'Studio', 'Cache',
   'Port', 'Plate', 'Layer', 'Modal', 'Pin', 'Pro', 'Mini', 'Studio', 'Cache',
@@ -332,6 +343,8 @@ const KO_COGNATES = [
 const ES_COGNATES = [
 const ES_COGNATES = [
   'Bambu Cloud', 'Orca Cloud',  // brand names — same in every locale
   'Bambu Cloud', 'Orca Cloud',  // brand names — same in every locale
   'AMS Filament Backup',  // Bambu Lab product/firmware feature name
   'AMS Filament Backup',  // Bambu Lab product/firmware feature name
+  'Pipeline', 'Pipelines',  // #1425 — Slicer Pipelines (ES)
+  'round robin',  // borrowed English term used as-is in Spanish tech contexts
   'Error', 'Firmware', 'General', 'Control', 'Total', 'total', 'Material',
   'Error', 'Firmware', 'General', 'Control', 'Total', 'total', 'Material',
   'Material:', 'Color', 'Hex', 'Local', 'Global', 'China', 'Editable',
   'Material:', 'Color', 'Hex', 'Local', 'Global', 'China', 'Editable',
   'Normal', 'Metal', 'Multicolor', 'Proxy', 'Host', 'Factor', 'Original',
   'Normal', 'Metal', 'Multicolor', 'Proxy', 'Host', 'Factor', 'Original',
@@ -353,6 +366,7 @@ const TR_COGNATES = [
   'Filament', 'Firmware', 'Disk', 'Hex', 'Test', 'Port', 'Model', 'Metal',
   'Filament', 'Firmware', 'Disk', 'Hex', 'Test', 'Port', 'Model', 'Metal',
   'Bambu Cloud', 'Orca Cloud',  // brand names — same in every locale
   'Bambu Cloud', 'Orca Cloud',  // brand names — same in every locale
   'AMS Filament Backup',  // Bambu Lab product/firmware feature name
   'AMS Filament Backup',  // Bambu Lab product/firmware feature name
+  'Pipeline', 'Filament {{n}}',  // #1425 — Slicer Pipelines (TR)
   'Min', 'Normal', 'Platform', 'Net', 'Trend', 'Commit', 'Global', 'Proxy',
   'Min', 'Normal', 'Platform', 'Net', 'Trend', 'Commit', 'Global', 'Proxy',
   'N/A', 'email',
   'N/A', 'email',
   'STARTTLS (Port 587)', 'SSL/TLS (Port 465)',
   'STARTTLS (Port 587)', 'SSL/TLS (Port 465)',

+ 4 - 0
frontend/src/App.tsx

@@ -197,6 +197,10 @@ function App() {
                   <Route index element={<PrintersPage />} />
                   <Route index element={<PrintersPage />} />
                   <Route path="archives" element={<ArchivesPage />} />
                   <Route path="archives" element={<ArchivesPage />} />
                   <Route path="queue" element={<QueuePage />} />
                   <Route path="queue" element={<QueuePage />} />
+                  {/* Slicer Pipelines (#1425) — Pipelines tab lives on the
+                      Print Queue page (Queue + History + Timeline +
+                      Pipelines). Old standalone URL redirects. */}
+                  <Route path="pipelines/runs" element={<Navigate to="/queue?tab=pipelines" replace />} />
                   <Route path="stats" element={<StatsPage />} />
                   <Route path="stats" element={<StatsPage />} />
                   <Route path="profiles" element={<ProfilesPage />} />
                   <Route path="profiles" element={<ProfilesPage />} />
                   <Route path="maintenance" element={<MaintenancePage />} />
                   <Route path="maintenance" element={<MaintenancePage />} />

+ 210 - 0
frontend/src/__tests__/components/RunWithPipelineModal.test.tsx

@@ -0,0 +1,210 @@
+/**
+ * Tests for RunWithPipelineModal (#1425 PR B).
+ *
+ * Pin the two-step flow: pick → optional confirmation → run. Verify the
+ * fast path skips the modal step and the slow path renders the eligibility
+ * issues with the "Run anyway" button.
+ */
+
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+import { screen, waitFor } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import { render } from '../utils';
+import { RunWithPipelineModal } from '../../components/RunWithPipelineModal';
+import { SliceJobTrackerProvider } from '../../contexts/SliceJobTrackerContext';
+import { api, type Printer, type SlicerPipeline } from '../../api/client';
+
+vi.mock('../../api/client', () => ({
+  api: {
+    listSlicerPipelines: vi.fn(),
+    getPrinters: vi.fn(),
+    checkPipelineEligibility: vi.fn(),
+    runPipeline: vi.fn(),
+    // ThemeContext / AuthContext bootstrap touches these on mount.
+    getSettings: vi.fn().mockResolvedValue({}),
+    updateSettings: vi.fn().mockResolvedValue({}),
+  },
+}));
+
+const mockApi = api as unknown as {
+  listSlicerPipelines: ReturnType<typeof vi.fn>;
+  getPrinters: ReturnType<typeof vi.fn>;
+  checkPipelineEligibility: ReturnType<typeof vi.fn>;
+  runPipeline: ReturnType<typeof vi.fn>;
+};
+
+function makePipeline(overrides: Partial<SlicerPipeline> = {}): SlicerPipeline {
+  return {
+    id: 1,
+    name: 'Production Batch',
+    description: null,
+    printer_preset: { source: 'local', id: '1' },
+    process_preset: { source: 'local', id: '2' },
+    filament_presets: [{ source: 'local', id: '3' }],
+    bed_type: null,
+    target_kind: 'specific_printer',
+    target_printer_id: 42,
+    target_model_class: null,
+    fanout_strategy: 'max_parallel',
+    created_by: null,
+    created_at: '2026-06-27T00:00:00Z',
+    updated_at: '2026-06-27T00:00:00Z',
+    ...overrides,
+  };
+}
+
+function makePrinter(overrides: Partial<Printer> = {}): Printer {
+  return {
+    id: 42,
+    name: 'X1C #2',
+    serial_number: 'TEST',
+    ip_address: '192.0.2.1',
+    access_code: '****',
+    model: 'X1C',
+    location: null,
+    is_active: true,
+    nozzle_count: 1,
+    ...overrides,
+  } as unknown as Printer;
+}
+
+describe('RunWithPipelineModal', () => {
+  beforeEach(() => {
+    vi.clearAllMocks();
+    mockApi.getPrinters.mockResolvedValue([makePrinter()]);
+  });
+
+  it('renders the empty state when no pipelines exist', async () => {
+    mockApi.listSlicerPipelines.mockResolvedValue({ pipelines: [] });
+    render(
+      <SliceJobTrackerProvider>
+        <RunWithPipelineModal source={{ kind: 'libraryFile', id: 99, filename: 'cube.3mf' }} onClose={vi.fn()} />
+      </SliceJobTrackerProvider>,
+    );
+    await waitFor(() => {
+      expect(screen.getByText(/No pipelines saved yet/i)).toBeInTheDocument();
+    });
+  });
+
+  it('disables pipelines with no target printer', async () => {
+    mockApi.listSlicerPipelines.mockResolvedValue({
+      pipelines: [makePipeline({ target_printer_id: null })],
+    });
+    render(
+      <SliceJobTrackerProvider>
+        <RunWithPipelineModal source={{ kind: 'libraryFile', id: 99, filename: 'cube.3mf' }} onClose={vi.fn()} />
+      </SliceJobTrackerProvider>,
+    );
+    await waitFor(() => {
+      expect(screen.getByText(/No target printer set/i)).toBeInTheDocument();
+    });
+    const button = screen.getByRole('button', { name: /Production Batch/i });
+    expect((button as HTMLButtonElement).disabled).toBe(true);
+  });
+
+  it('fires the run immediately when eligibility is ok (fast path)', async () => {
+    mockApi.listSlicerPipelines.mockResolvedValue({ pipelines: [makePipeline()] });
+    mockApi.checkPipelineEligibility.mockResolvedValue({
+      ok: true,
+      target_printer_id: 42,
+      target_printer_name: 'X1C #2',
+      issues: [],
+    });
+    mockApi.runPipeline.mockResolvedValue({});
+    const onClose = vi.fn();
+    render(
+      <SliceJobTrackerProvider>
+        <RunWithPipelineModal source={{ kind: 'libraryFile', id: 99, filename: 'cube.3mf' }} onClose={onClose} />
+      </SliceJobTrackerProvider>,
+    );
+    await waitFor(() => expect(screen.getByText('Production Batch')).toBeInTheDocument());
+
+    const user = userEvent.setup();
+    await user.click(screen.getByRole('button', { name: /Production Batch/i }));
+
+    await waitFor(() => {
+      expect(mockApi.checkPipelineEligibility).toHaveBeenCalledWith(1, {
+        kind: 'libraryFile',
+        id: 99,
+      });
+      expect(mockApi.runPipeline).toHaveBeenCalledWith(
+        1,
+        { kind: 'libraryFile', id: 99 },
+        false,
+        1,
+      );
+      expect(onClose).toHaveBeenCalled();
+    });
+  });
+
+  it('threads source kind="archive" through eligibility + run', async () => {
+    mockApi.listSlicerPipelines.mockResolvedValue({ pipelines: [makePipeline()] });
+    mockApi.checkPipelineEligibility.mockResolvedValue({
+      ok: true,
+      target_printer_id: 42,
+      target_printer_name: 'X1C #2',
+      issues: [],
+    });
+    mockApi.runPipeline.mockResolvedValue({ slice_job_id: 9001 });
+    render(
+      <SliceJobTrackerProvider>
+        <RunWithPipelineModal
+          source={{ kind: 'archive', id: 7, filename: 'archive.3mf' }}
+          onClose={vi.fn()}
+        />
+      </SliceJobTrackerProvider>,
+    );
+    await waitFor(() => expect(screen.getByText('Production Batch')).toBeInTheDocument());
+    const user = userEvent.setup();
+    await user.click(screen.getByRole('button', { name: /Production Batch/i }));
+    await waitFor(() => {
+      expect(mockApi.checkPipelineEligibility).toHaveBeenCalledWith(1, {
+        kind: 'archive',
+        id: 7,
+      });
+      expect(mockApi.runPipeline).toHaveBeenCalledWith(
+        1,
+        { kind: 'archive', id: 7 },
+        false,
+        1,
+      );
+    });
+  });
+
+  it('shows the eligibility report + Run-anyway button when issues exist', async () => {
+    mockApi.listSlicerPipelines.mockResolvedValue({ pipelines: [makePipeline()] });
+    mockApi.checkPipelineEligibility.mockResolvedValue({
+      ok: false,
+      target_printer_id: 42,
+      target_printer_name: 'X1C #2',
+      issues: [
+        {
+          kind: 'filament_type_mismatch',
+          slot_index: 0,
+          expected: 'PLA',
+          actual: 'PETG',
+        },
+      ],
+    });
+    mockApi.runPipeline.mockResolvedValue({});
+    render(
+      <SliceJobTrackerProvider>
+        <RunWithPipelineModal source={{ kind: 'libraryFile', id: 99, filename: 'cube.3mf' }} onClose={vi.fn()} />
+      </SliceJobTrackerProvider>,
+    );
+    await waitFor(() => expect(screen.getByText('Production Batch')).toBeInTheDocument());
+
+    const user = userEvent.setup();
+    await user.click(screen.getByRole('button', { name: /Production Batch/i }));
+
+    // Confirmation view appears with the issue listed.
+    await waitFor(() => {
+      expect(screen.getByText(/expected PLA/i)).toBeInTheDocument();
+    });
+    // Clicking Run anyway fires the run with force=true and the new source-kind shape.
+    await user.click(screen.getByRole('button', { name: /Run anyway/i }));
+    await waitFor(() => {
+      expect(mockApi.runPipeline).toHaveBeenCalledWith(1, { kind: 'libraryFile', id: 99 }, true, 1);
+    });
+  });
+});

+ 150 - 9
frontend/src/__tests__/components/SliceModal.test.tsx

@@ -28,6 +28,9 @@ vi.mock('../../api/client', () => ({
     getArchiveFilamentRequirements: vi.fn(),
     getArchiveFilamentRequirements: vi.fn(),
     getSettings: vi.fn().mockResolvedValue({}),
     getSettings: vi.fn().mockResolvedValue({}),
     updateSettings: vi.fn().mockResolvedValue({}),
     updateSettings: vi.fn().mockResolvedValue({}),
+    // Slicer Pipelines (#1425)
+    listSlicerPipelines: vi.fn(),
+    createSlicerPipeline: vi.fn(),
   },
   },
 }));
 }));
 
 
@@ -40,6 +43,8 @@ const mockApi = api as unknown as {
   getArchivePlates: ReturnType<typeof vi.fn>;
   getArchivePlates: ReturnType<typeof vi.fn>;
   getLibraryFileFilamentRequirements: ReturnType<typeof vi.fn>;
   getLibraryFileFilamentRequirements: ReturnType<typeof vi.fn>;
   getArchiveFilamentRequirements: ReturnType<typeof vi.fn>;
   getArchiveFilamentRequirements: ReturnType<typeof vi.fn>;
+  listSlicerPipelines: ReturnType<typeof vi.fn>;
+  createSlicerPipeline: ReturnType<typeof vi.fn>;
 };
 };
 
 
 function makeUnified(overrides: Partial<UnifiedPresetsResponse> = {}): UnifiedPresetsResponse {
 function makeUnified(overrides: Partial<UnifiedPresetsResponse> = {}): UnifiedPresetsResponse {
@@ -80,6 +85,16 @@ function renderWithTracker(props: Parameters<typeof SliceModal>[0]) {
   );
   );
 }
 }
 
 
+// SliceModal renders one extra combobox for the Slicer Pipelines (#1425)
+// "Apply pipeline" dropdown above the preset slots. Tests written before
+// pipelines landed assume selects[0] = printer; this helper drops the
+// pipeline combobox so those indices stay stable.
+function presetSelects(): HTMLSelectElement[] {
+  return (screen.getAllByRole('combobox') as HTMLSelectElement[]).filter(
+    (el) => el.getAttribute('aria-label') !== 'Apply pipeline',
+  );
+}
+
 describe('SliceModal', () => {
 describe('SliceModal', () => {
   beforeEach(() => {
   beforeEach(() => {
     vi.clearAllMocks();
     vi.clearAllMocks();
@@ -121,6 +136,8 @@ describe('SliceModal', () => {
       plate_id: 1,
       plate_id: 1,
       filaments: [],
       filaments: [],
     });
     });
+    // Default: no saved pipelines. Tests opt in by overriding this.
+    mockApi.listSlicerPipelines.mockResolvedValue({ pipelines: [] });
   });
   });
 
 
   it('auto-selects the highest-priority tier per slot on first load', async () => {
   it('auto-selects the highest-priority tier per slot on first load', async () => {
@@ -137,7 +154,7 @@ describe('SliceModal', () => {
     // 4 selects: printer, process, bed-type (#1337), filament. bed-type sits
     // 4 selects: printer, process, bed-type (#1337), filament. bed-type sits
     // between process and filament — it overrides curr_bed_type on the
     // between process and filament — it overrides curr_bed_type on the
     // process preset so the related controls cluster — and defaults to "".
     // process preset so the related controls cluster — and defaults to "".
-    const selects = screen.getAllByRole('combobox') as HTMLSelectElement[];
+    const selects = presetSelects();
     expect(selects).toHaveLength(4);
     expect(selects).toHaveLength(4);
     expect(selects[0].value).toBe('local:1');
     expect(selects[0].value).toBe('local:1');
     expect(selects[1].value).toBe('local:2');
     expect(selects[1].value).toBe('local:2');
@@ -158,7 +175,7 @@ describe('SliceModal', () => {
 
 
     await waitFor(() => expect(screen.getByText('Imported X1C 0.4')).toBeDefined());
     await waitFor(() => expect(screen.getByText('Imported X1C 0.4')).toBeDefined());
 
 
-    const printerSelect = screen.getAllByRole('combobox')[0];
+    const printerSelect = presetSelects()[0];
     const groups = printerSelect.querySelectorAll('optgroup');
     const groups = printerSelect.querySelectorAll('optgroup');
     expect(Array.from(groups).map((g) => g.label)).toEqual([
     expect(Array.from(groups).map((g) => g.label)).toEqual([
       'Imported',
       'Imported',
@@ -190,7 +207,7 @@ describe('SliceModal', () => {
     });
     });
 
 
     await waitFor(() => expect(screen.getByText('Imported X1C 0.4')).toBeDefined());
     await waitFor(() => expect(screen.getByText('Imported X1C 0.4')).toBeDefined());
-    const selects = screen.getAllByRole('combobox') as HTMLSelectElement[];
+    const selects = presetSelects();
     expect(selects[0].value).toBe('local:1');
     expect(selects[0].value).toBe('local:1');
   });
   });
 
 
@@ -204,7 +221,7 @@ describe('SliceModal', () => {
     });
     });
 
 
     await waitFor(() => expect(screen.getByText('Bambu Lab X1 Carbon 0.4 nozzle')).toBeDefined());
     await waitFor(() => expect(screen.getByText('Bambu Lab X1 Carbon 0.4 nozzle')).toBeDefined());
-    const selects = screen.getAllByRole('combobox') as HTMLSelectElement[];
+    const selects = presetSelects();
     expect(selects[0].value).toBe('standard:Bambu Lab X1 Carbon 0.4 nozzle');
     expect(selects[0].value).toBe('standard:Bambu Lab X1 Carbon 0.4 nozzle');
   });
   });
 
 
@@ -260,7 +277,7 @@ describe('SliceModal', () => {
     // printer (0), process (1), bed-type (2), filament (3+). Find the
     // printer (0), process (1), bed-type (2), filament (3+). Find the
     // bed-type select by name rather than positional index so this stays
     // bed-type select by name rather than positional index so this stays
     // green if the layout adds another control around it.
     // green if the layout adds another control around it.
-    const bedSelect = screen.getAllByRole('combobox').find((el) =>
+    const bedSelect = presetSelects().find((el) =>
       (el as HTMLSelectElement).options[0]?.textContent?.toLowerCase().includes('auto'),
       (el as HTMLSelectElement).options[0]?.textContent?.toLowerCase().includes('auto'),
     ) as HTMLSelectElement;
     ) as HTMLSelectElement;
     expect(bedSelect).toBeDefined();
     expect(bedSelect).toBeDefined();
@@ -315,7 +332,7 @@ describe('SliceModal', () => {
     await waitFor(() => expect(screen.getByText('My Custom X1C')).toBeDefined());
     await waitFor(() => expect(screen.getByText('My Custom X1C')).toBeDefined());
 
 
     const user = userEvent.setup();
     const user = userEvent.setup();
-    const selects = screen.getAllByRole('combobox');
+    const selects = presetSelects();
     await user.selectOptions(selects[0], 'standard:Bambu Lab X1 Carbon 0.4 nozzle');
     await user.selectOptions(selects[0], 'standard:Bambu Lab X1 Carbon 0.4 nozzle');
     await user.click(screen.getByRole('button', { name: /^Slice$/ }));
     await user.click(screen.getByRole('button', { name: /^Slice$/ }));
 
 
@@ -731,7 +748,7 @@ describe('SliceModal', () => {
 
 
     await waitFor(() => expect(screen.getByText('X1C')).toBeDefined());
     await waitFor(() => expect(screen.getByText('X1C')).toBeDefined());
     // 1 printer + 1 process + 2 filament + 1 bed-type (#1337) = 5 dropdowns.
     // 1 printer + 1 process + 2 filament + 1 bed-type (#1337) = 5 dropdowns.
-    expect(screen.getAllByRole('combobox')).toHaveLength(5);
+    expect(presetSelects()).toHaveLength(5);
   });
   });
 
 
   it('pre-picks each filament slot by matching colour metadata', async () => {
   it('pre-picks each filament slot by matching colour metadata', async () => {
@@ -812,7 +829,7 @@ describe('SliceModal', () => {
     await waitFor(() => expect(screen.getByText('X1C')).toBeDefined());
     await waitFor(() => expect(screen.getByText('X1C')).toBeDefined());
 
 
     const user = userEvent.setup();
     const user = userEvent.setup();
-    const selects = screen.getAllByRole('combobox') as HTMLSelectElement[];
+    const selects = presetSelects();
     // Order: 0 printer, 1 process, 2 bed-type, 3 filament-1, 4 filament-2
     // Order: 0 printer, 1 process, 2 bed-type, 3 filament-1, 4 filament-2
     // (#1337). Auto-picks land on printer/process/filaments; bed-type
     // (#1337). Auto-picks land on printer/process/filaments; bed-type
     // defaults to "". Swap filament-1 (index 3) from the auto-picked black
     // defaults to "". Swap filament-1 (index 3) from the auto-picked black
@@ -937,7 +954,7 @@ describe('SliceModal', () => {
     // Both filament rows render — 1 printer + 1 process + 1 bed-type +
     // Both filament rows render — 1 printer + 1 process + 1 bed-type +
     // 2 filament (#1337) = 5. bed-type sits at index 2, filament slots
     // 2 filament (#1337) = 5. bed-type sits at index 2, filament slots
     // follow at 3 and 4.
     // follow at 3 and 4.
-    const selects = screen.getAllByRole('combobox') as HTMLSelectElement[];
+    const selects = presetSelects();
     expect(selects).toHaveLength(5);
     expect(selects).toHaveLength(5);
     // Slot 1 (used) is editable, slot 2 (not used) is disabled.
     // Slot 1 (used) is editable, slot 2 (not used) is disabled.
     expect(selects[3].disabled).toBe(false);
     expect(selects[3].disabled).toBe(false);
@@ -1018,4 +1035,128 @@ describe('SliceModal', () => {
     });
     });
   });
   });
 
 
+  // ------------------------------------------------------------------
+  // Slicer Pipelines (#1425) — Apply / Save integration in SliceModal
+  // ------------------------------------------------------------------
+
+  it('Apply pipeline dropdown is disabled and shows empty hint when no pipelines exist', async () => {
+    mockApi.listSlicerPipelines.mockResolvedValue({ pipelines: [] });
+    renderWithTracker({
+      source: { kind: 'libraryFile', id: 100, filename: 'Cube.stl' },
+      onClose: vi.fn(),
+    });
+    await waitFor(() => {
+      const select = screen.getByLabelText(/Apply pipeline/i) as HTMLSelectElement;
+      expect(select.disabled).toBe(true);
+      expect(select.querySelector('option')?.textContent).toMatch(/No saved pipelines/i);
+    });
+  });
+
+  it('applies a saved pipeline to printer, process, and bed_type slots on selection', async () => {
+    mockApi.listSlicerPipelines.mockResolvedValue({
+      pipelines: [
+        {
+          id: 7,
+          name: 'Production Batch',
+          description: null,
+          printer_preset: { source: 'local', id: '1' },
+          process_preset: { source: 'local', id: '2' },
+          filament_presets: [{ source: 'local', id: '3' }],
+          bed_type: 'Textured PEI Plate',
+          target_kind: 'printer_class',
+          target_printer_id: null,
+          target_model_class: null,
+          fanout_strategy: 'max_parallel',
+          created_by: null,
+          created_at: '2026-06-27T00:00:00Z',
+          updated_at: '2026-06-27T00:00:00Z',
+        },
+      ],
+    });
+
+    renderWithTracker({
+      source: { kind: 'libraryFile', id: 100, filename: 'Cube.stl' },
+      onClose: vi.fn(),
+    });
+
+    // Wait for presets + pipelines listing to populate the modal.
+    await waitFor(() => {
+      const select = screen.getByLabelText(/Apply pipeline/i) as HTMLSelectElement;
+      expect(select.disabled).toBe(false);
+      expect(within(select).getByText('Production Batch')).toBeDefined();
+    });
+
+    const user = userEvent.setup();
+    await user.selectOptions(screen.getByLabelText(/Apply pipeline/i), '7');
+
+    // After applying, submitting the slice request should carry the
+    // pipeline's preset refs end-to-end.
+    mockApi.sliceLibraryFile.mockResolvedValue({
+      job_id: 42,
+      status: 'queued',
+      status_url: '/api/v1/slice-jobs/42',
+    });
+
+    await user.click(screen.getByRole('button', { name: /^Slice$/ }));
+
+    await waitFor(() => {
+      expect(mockApi.sliceLibraryFile).toHaveBeenCalled();
+      const [, body] = mockApi.sliceLibraryFile.mock.calls[0];
+      expect(body.printer_preset).toEqual({ source: 'local', id: '1' });
+      expect(body.process_preset).toEqual({ source: 'local', id: '2' });
+      expect(body.filament_presets[0]).toEqual({ source: 'local', id: '3' });
+      expect(body.bed_type).toBe('Textured PEI Plate');
+    });
+  });
+
+  it('saves the current four-slot selection as a new pipeline when the user clicks Save as pipeline', async () => {
+    mockApi.listSlicerPipelines.mockResolvedValue({ pipelines: [] });
+    mockApi.createSlicerPipeline.mockResolvedValue({
+      id: 99,
+      name: 'My Default',
+      description: null,
+      printer_preset: { source: 'local', id: '1' },
+      process_preset: { source: 'local', id: '2' },
+      filament_presets: [{ source: 'local', id: '3' }],
+      bed_type: null,
+      target_kind: 'printer_class',
+      target_printer_id: null,
+      target_model_class: null,
+      fanout_strategy: 'max_parallel',
+      created_by: null,
+      created_at: '2026-06-27T00:00:00Z',
+      updated_at: '2026-06-27T00:00:00Z',
+    });
+
+    renderWithTracker({
+      source: { kind: 'libraryFile', id: 100, filename: 'Cube.stl' },
+      onClose: vi.fn(),
+    });
+
+    // Wait for auto-pick to populate all four slots from the fullThreeTier
+    // listing — then Save as pipeline becomes enabled.
+    const user = userEvent.setup();
+    let saveBtn: HTMLButtonElement;
+    await waitFor(() => {
+      saveBtn = screen.getByRole('button', { name: /^Save as pipeline$/ }) as HTMLButtonElement;
+      expect(saveBtn.disabled).toBe(false);
+    });
+    await user.click(saveBtn!);
+
+    const nameInput = screen.getByLabelText(/New pipeline name/i);
+    await user.type(nameInput, 'My Default');
+    await user.click(screen.getByRole('button', { name: /^Save$/ }));
+
+    await waitFor(() => {
+      expect(mockApi.createSlicerPipeline).toHaveBeenCalledTimes(1);
+      const body = mockApi.createSlicerPipeline.mock.calls[0][0];
+      expect(body.name).toBe('My Default');
+      // The four slots come from the auto-picked unified-presets listing —
+      // local tier wins per SLICE_MODAL_TIER_ORDER.
+      expect(body.printer_preset.source).toBe('local');
+      expect(body.process_preset.source).toBe('local');
+      expect(body.filament_presets[0].source).toBe('local');
+    });
+  });
+
 });
 });

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

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

+ 156 - 0
frontend/src/__tests__/pages/PipelineRunsPage.test.tsx

@@ -0,0 +1,156 @@
+/**
+ * Tests for PipelineRunsPage — dashboard for Slicer Pipeline runs (#1425 PR C).
+ *
+ * Pin the load → list → expand → cancel/retry flow. We mock the api client
+ * so we can exercise the dashboard without hitting the WS subscription.
+ */
+
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+import { screen, waitFor } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import { render } from '../utils';
+import { PipelineRunsView } from '../../pages/PipelineRunsPage';
+import { api, type PipelineRun } from '../../api/client';
+
+vi.mock('../../api/client', () => ({
+  api: {
+    listSlicerPipelines: vi.fn(),
+    listAllPipelineRuns: vi.fn(),
+    cancelPipelineRun: vi.fn(),
+    retryFailedPipelineRun: vi.fn(),
+    // Bootstrap calls — ThemeContext / AuthContext touch these on mount.
+    getSettings: vi.fn().mockResolvedValue({}),
+    updateSettings: vi.fn().mockResolvedValue({}),
+  },
+}));
+
+const mockApi = api as unknown as {
+  listSlicerPipelines: ReturnType<typeof vi.fn>;
+  listAllPipelineRuns: ReturnType<typeof vi.fn>;
+  cancelPipelineRun: ReturnType<typeof vi.fn>;
+  retryFailedPipelineRun: ReturnType<typeof vi.fn>;
+};
+
+function makeRun(overrides: Partial<PipelineRun> = {}): PipelineRun {
+  return {
+    id: 1,
+    pipeline_id: 10,
+    pipeline_name: 'Production Batch',
+    source_library_file_id: 99,
+    source_archive_id: null,
+    source_filename: 'cube.3mf',
+    parent_run_id: null,
+    copies: 3,
+    copies_completed: 1,
+    copies_failed: 0,
+    copies_cancelled: 0,
+    copies_in_progress: 2,
+    status: 'in_progress',
+    slice_job_id: 4242,
+    sliced_library_file_id: 100,
+    eligibility_overridden: false,
+    error_message: null,
+    created_by: null,
+    created_at: '2026-06-27T15:00:00Z',
+    started_at: '2026-06-27T15:00:01Z',
+    completed_at: null,
+    jobs: [
+      {
+        id: 1,
+        pipeline_run_id: 1,
+        copy_index: 0,
+        assigned_printer_id: 5,
+        assigned_printer_name: 'X1C #1',
+        queue_entry_id: 200,
+        status: 'completed',
+        error_message: null,
+        dispatched_at: '2026-06-27T15:00:02Z',
+        completed_at: '2026-06-27T15:30:00Z',
+      },
+      {
+        id: 2,
+        pipeline_run_id: 1,
+        copy_index: 1,
+        assigned_printer_id: 6,
+        assigned_printer_name: 'X1C #2',
+        queue_entry_id: 201,
+        status: 'printing',
+        error_message: null,
+        dispatched_at: '2026-06-27T15:00:02Z',
+        completed_at: null,
+      },
+    ],
+    target_kind: 'printer_class',
+    target_printer_id: null,
+    target_model_class: 'X1C',
+    fanout_strategy: 'max_parallel',
+    ...overrides,
+  };
+}
+
+describe('PipelineRunsView', () => {
+  beforeEach(() => {
+    vi.clearAllMocks();
+    mockApi.listSlicerPipelines.mockResolvedValue({ pipelines: [] });
+    mockApi.listAllPipelineRuns.mockResolvedValue({ runs: [], total: 0 });
+  });
+
+  it('renders empty state when no runs exist', async () => {
+    render(<PipelineRunsView />);
+    await waitFor(() => {
+      expect(screen.getByText(/No pipeline runs yet/i)).toBeInTheDocument();
+    });
+  });
+
+  it('lists runs and surfaces the pipeline name + status chip', async () => {
+    mockApi.listAllPipelineRuns.mockResolvedValue({ runs: [makeRun()], total: 1 });
+    render(<PipelineRunsView />);
+    await waitFor(() => {
+      expect(screen.getByText(/Production Batch/i)).toBeInTheDocument();
+      // Status chip uses the i18n key.
+      expect(screen.getAllByText(/printing/i).length).toBeGreaterThan(0);
+    });
+  });
+
+  it('shows a Cancel button on in-flight runs and fires the mutation', async () => {
+    mockApi.listAllPipelineRuns.mockResolvedValue({ runs: [makeRun()], total: 1 });
+    mockApi.cancelPipelineRun.mockResolvedValue(makeRun({ status: 'cancelled' }));
+    render(<PipelineRunsView />);
+    const user = userEvent.setup();
+    await waitFor(() => expect(screen.getByText(/Production Batch/i)).toBeInTheDocument());
+    await user.click(screen.getByRole('button', { name: /Cancel/i }));
+    await waitFor(() => expect(mockApi.cancelPipelineRun).toHaveBeenCalledWith(1));
+  });
+
+  it('shows a Retry-failed button on partial_failure runs and fires the mutation', async () => {
+    mockApi.listAllPipelineRuns.mockResolvedValue({
+      runs: [
+        makeRun({
+          status: 'partial_failure',
+          copies_completed: 1,
+          copies_failed: 2,
+          copies_in_progress: 0,
+        }),
+      ],
+      total: 1,
+    });
+    mockApi.retryFailedPipelineRun.mockResolvedValue(makeRun({ id: 2, parent_run_id: 1, copies: 2 }));
+    render(<PipelineRunsView />);
+    const user = userEvent.setup();
+    await waitFor(() => expect(screen.getByText(/Production Batch/i)).toBeInTheDocument());
+    await user.click(screen.getByRole('button', { name: /Retry failed/i }));
+    await waitFor(() => expect(mockApi.retryFailedPipelineRun).toHaveBeenCalledWith(1));
+  });
+
+  it('expands the row to show per-copy jobs', async () => {
+    mockApi.listAllPipelineRuns.mockResolvedValue({ runs: [makeRun()], total: 1 });
+    render(<PipelineRunsView />);
+    const user = userEvent.setup();
+    await waitFor(() => expect(screen.getByText(/Production Batch/i)).toBeInTheDocument());
+    await user.click(screen.getByRole('button', { name: /Expand/i }));
+    await waitFor(() => {
+      expect(screen.getByText('X1C #1')).toBeInTheDocument();
+      expect(screen.getByText('X1C #2')).toBeInTheDocument();
+    });
+  });
+});

+ 52 - 0
frontend/src/__tests__/pages/SettingsPage.test.tsx

@@ -1289,4 +1289,56 @@ describe('SettingsPage', () => {
       });
       });
     });
     });
   });
   });
+
+  // --------------------------------------------------------------------
+  // Slicer Pipelines (#1425) — Workflow tab sub-tabs
+  // --------------------------------------------------------------------
+  describe('workflow sub-tabs (#1425)', () => {
+    beforeEach(() => {
+      // Endpoints the Pipelines panel calls (#1425).
+      server.use(
+        http.get('/api/v1/slicer-pipelines/', () => HttpResponse.json({ pipelines: [] })),
+        http.get('/api/v1/slicer/presets', () =>
+          HttpResponse.json({
+            orca_cloud: { printer: [], process: [], filament: [] },
+            cloud: { printer: [], process: [], filament: [] },
+            local: { printer: [], process: [], filament: [] },
+            standard: { printer: [], process: [], filament: [] },
+            cloud_status: 'ok',
+            orca_cloud_status: 'ok',
+          }),
+        ),
+      );
+    });
+
+    it('renders Queue & Dispatch + Pipelines sub-tabs under Workflow', async () => {
+      render(<SettingsPage />);
+      const user = userEvent.setup();
+      await waitFor(() => {
+        // Workflow tab in the sidebar — exact match to avoid colliding with
+        // "Print Queue" or "Queue Settings" labels elsewhere on the page.
+        expect(screen.getByRole('button', { name: 'Workflow' })).toBeInTheDocument();
+      });
+      await user.click(screen.getByRole('button', { name: 'Workflow' }));
+      await waitFor(() => {
+        expect(screen.getByRole('button', { name: /Queue & Dispatch/i })).toBeInTheDocument();
+        expect(screen.getByRole('button', { name: /^Pipelines$/i })).toBeInTheDocument();
+      });
+    });
+
+    it('clicking Pipelines sub-tab shows the empty-state hint and updates the URL', async () => {
+      render(<SettingsPage />);
+      const user = userEvent.setup();
+      await waitFor(() => expect(screen.getByRole('button', { name: 'Workflow' })).toBeInTheDocument());
+      await user.click(screen.getByRole('button', { name: 'Workflow' }));
+      await user.click(screen.getByRole('button', { name: /^Pipelines$/i }));
+
+      await waitFor(() => {
+        expect(screen.getByText(/No pipelines yet/i)).toBeInTheDocument();
+        // Deep-link URL carries both ?tab=queue and ?sub=pipelines
+        expect(window.location.search).toContain('tab=queue');
+        expect(window.location.search).toContain('sub=pipelines');
+      });
+    });
+  });
 });
 });

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

@@ -1139,6 +1139,7 @@ export interface AppSettings {
   spoolman_url: string;
   spoolman_url: string;
   // Default printer
   // Default printer
   default_printer_id: number | null;
   default_printer_id: number | null;
+  pipeline_max_copies: number;
   // Dark mode theme settings
   // Dark mode theme settings
   dark_style: 'classic' | 'glow' | 'vibrant';
   dark_style: 'classic' | 'glow' | 'vibrant';
   dark_background: 'neutral' | 'warm' | 'cool' | 'oled' | 'slate' | 'forest';
   dark_background: 'neutral' | 'warm' | 'cool' | 'oled' | 'slate' | 'forest';
@@ -1502,6 +1503,139 @@ export interface UnifiedPresetsResponse {
   orca_cloud_status: SlicerCloudStatus;
   orca_cloud_status: SlicerCloudStatus;
 }
 }
 
 
+// Slicer Pipelines (#1425) — named bundles of preset slots the SliceModal
+// can apply in one click. PR A surfaces only the bundle; target_* and
+// fanout_strategy round-trip from the backend but the UI doesn't yet expose
+// them (they come alive in PR B / PR C).
+export interface SlicerPipeline {
+  id: number;
+  name: string;
+  description: string | null;
+  printer_preset: PresetRef;
+  process_preset: PresetRef;
+  filament_presets: PresetRef[];
+  bed_type: string | null;
+  target_kind: 'specific_printer' | 'printer_class';
+  target_printer_id: number | null;
+  target_model_class: string | null;
+  fanout_strategy: 'max_parallel' | 'fill_one_first' | 'round_robin';
+  created_by: number | null;
+  created_at: string;
+  updated_at: string;
+}
+export interface SlicerPipelineCreateRequest {
+  name: string;
+  description?: string | null;
+  printer_preset: PresetRef;
+  process_preset: PresetRef;
+  filament_presets: PresetRef[];
+  bed_type?: string | null;
+}
+export type SlicerPipelineUpdateRequest = Partial<SlicerPipelineCreateRequest> & {
+  target_kind?: 'specific_printer' | 'printer_class';
+  // ``target_printer_id: 0`` means "clear the target" — the backend maps that
+  // to null. Use null in TypeScript for the same intent.
+  target_printer_id?: number | null;
+  target_model_class?: string | null;
+  fanout_strategy?: 'max_parallel' | 'fill_one_first' | 'round_robin';
+};
+export interface SlicerPipelinesListResponse {
+  pipelines: SlicerPipeline[];
+}
+
+// Slicer Pipeline runs (#1425 PR B + PR C)
+export type PipelineEligibilityKind =
+  | 'printer_not_set'
+  | 'printer_not_found'
+  | 'printer_disabled'
+  | 'printer_offline'
+  | 'filament_type_mismatch'
+  | 'filament_color_mismatch'
+  | 'ams_slot_missing'
+  | 'filament_unverified'
+  | 'no_class_matches'
+  | 'class_not_set';
+export interface PipelineEligibilityIssue {
+  kind: PipelineEligibilityKind;
+  slot_index: number | null;
+  expected: string | null;
+  actual: string | null;
+}
+export interface PipelinePerPrinterReport {
+  printer_id: number;
+  printer_name: string;
+  ok: boolean;
+  issues: PipelineEligibilityIssue[];
+}
+export interface PipelineEligibilityReport {
+  ok: boolean;
+  target_kind: 'specific_printer' | 'printer_class';
+  target_printer_id: number | null;
+  target_printer_name: string | null;
+  target_model_class: string | null;
+  issues: PipelineEligibilityIssue[];
+  printer_reports: PipelinePerPrinterReport[];
+}
+export interface PipelineJob {
+  id: number;
+  pipeline_run_id: number;
+  copy_index: number;
+  assigned_printer_id: number | null;
+  assigned_printer_name: string | null;
+  queue_entry_id: number | null;
+  status:
+    | 'pending'
+    | 'awaiting_printer'
+    | 'queued'
+    | 'printing'
+    | 'completed'
+    | 'failed'
+    | 'cancelled';
+  error_message: string | null;
+  dispatched_at: string | null;
+  completed_at: string | null;
+}
+export interface PipelineRun {
+  id: number;
+  pipeline_id: number | null;
+  pipeline_name: string | null;
+  source_library_file_id: number | null;
+  source_archive_id: number | null;
+  source_filename: string | null;
+  parent_run_id: number | null;
+  copies: number;
+  copies_completed: number;
+  copies_failed: number;
+  copies_cancelled: number;
+  copies_in_progress: number;
+  status:
+    | 'queued'
+    | 'slicing'
+    | 'dispatching'
+    | 'in_progress'
+    | 'completed'
+    | 'failed'
+    | 'partial_failure'
+    | 'cancelled';
+  slice_job_id: number | null;
+  sliced_library_file_id: number | null;
+  eligibility_overridden: boolean;
+  error_message: string | null;
+  created_by: number | null;
+  created_at: string;
+  started_at: string | null;
+  completed_at: string | null;
+  jobs: PipelineJob[];
+  target_kind: 'specific_printer' | 'printer_class' | null;
+  target_printer_id: number | null;
+  target_model_class: string | null;
+  fanout_strategy: 'max_parallel' | 'fill_one_first' | 'round_robin' | null;
+}
+export interface PipelineRunListResponse {
+  runs: PipelineRun[];
+  total: number;
+}
+
 export interface SliceResponse {
 export interface SliceResponse {
   library_file_id: number;
   library_file_id: number;
   name: string;
   name: string;
@@ -3002,6 +3136,7 @@ export type Permission =
   | 'api_keys:read' | 'api_keys:create' | 'api_keys:update' | 'api_keys:delete'
   | 'api_keys:read' | 'api_keys:create' | 'api_keys:update' | 'api_keys:delete'
   | 'users:read' | 'users:create' | 'users:update' | 'users:delete'
   | 'users:read' | 'users:create' | 'users:update' | 'users:delete'
   | 'groups:read' | 'groups:create' | 'groups:update' | 'groups:delete'
   | 'groups:read' | 'groups:create' | 'groups:update' | 'groups:delete'
+  | 'pipelines:read' | 'pipelines:write' | 'pipelines:run'
   | 'websocket:connect';
   | 'websocket:connect';
 
 
 // Group types
 // Group types
@@ -6199,6 +6334,89 @@ export const api = {
       options?.refresh ? '/slicer/presets?refresh=true' : '/slicer/presets',
       options?.refresh ? '/slicer/presets?refresh=true' : '/slicer/presets',
     ),
     ),
 
 
+  // Slicer Pipelines (#1425) — preset bundles the SliceModal can apply in
+  // one click. CRUD is gated on PIPELINES_READ / PIPELINES_WRITE.
+  listSlicerPipelines: () =>
+    request<SlicerPipelinesListResponse>('/slicer-pipelines/'),
+  getSlicerPipeline: (id: number) =>
+    request<SlicerPipeline>(`/slicer-pipelines/${id}`),
+  createSlicerPipeline: (data: SlicerPipelineCreateRequest) =>
+    request<SlicerPipeline>('/slicer-pipelines/', {
+      method: 'POST',
+      body: JSON.stringify(data),
+    }),
+  updateSlicerPipeline: (id: number, data: SlicerPipelineUpdateRequest) =>
+    request<SlicerPipeline>(`/slicer-pipelines/${id}`, {
+      method: 'PUT',
+      body: JSON.stringify(data),
+    }),
+  deleteSlicerPipeline: (id: number) =>
+    request<void>(`/slicer-pipelines/${id}`, { method: 'DELETE' }),
+  checkPipelineEligibility: (
+    pipelineId: number,
+    source: { kind: 'libraryFile'; id: number } | { kind: 'archive'; id: number },
+  ) =>
+    request<PipelineEligibilityReport>(`/slicer-pipelines/${pipelineId}/check-eligibility`, {
+      method: 'POST',
+      body: JSON.stringify(
+        source.kind === 'libraryFile'
+          ? { source_library_file_id: source.id }
+          : { source_archive_id: source.id },
+      ),
+    }),
+  runPipeline: (
+    pipelineId: number,
+    source: { kind: 'libraryFile'; id: number } | { kind: 'archive'; id: number },
+    force = false,
+    copies = 1,
+  ) =>
+    request<PipelineRun>(`/slicer-pipelines/${pipelineId}/run`, {
+      method: 'POST',
+      body: JSON.stringify({
+        ...(source.kind === 'libraryFile'
+          ? { source_library_file_id: source.id }
+          : { source_archive_id: source.id }),
+        force,
+        copies,
+      }),
+    }),
+  listPipelineRuns: (pipelineId: number, limit = 5) =>
+    request<PipelineRunListResponse>(
+      `/slicer-pipelines/${pipelineId}/runs?limit=${limit}`,
+    ),
+  // Dashboard list across all pipelines (#1425 PR C).
+  listAllPipelineRuns: (params: {
+    limit?: number;
+    offset?: number;
+    pipelineId?: number;
+    status?: string;
+    targetPrinterId?: number;
+    targetModelClass?: string;
+  } = {}) => {
+    const search = new URLSearchParams();
+    if (params.limit) search.set('limit', String(params.limit));
+    if (params.offset) search.set('offset', String(params.offset));
+    if (params.pipelineId) search.set('pipeline_id', String(params.pipelineId));
+    if (params.status) search.set('status', params.status);
+    if (params.targetPrinterId) search.set('target_printer_id', String(params.targetPrinterId));
+    if (params.targetModelClass) search.set('target_model_class', params.targetModelClass);
+    const q = search.toString();
+    return request<PipelineRunListResponse>(
+      `/pipeline-runs${q ? '?' + q : ''}`,
+    );
+  },
+  // Clear terminal pipeline runs (#1425 PR C polish). Deletes all runs in
+  // a terminal state (completed/failed/cancelled/partial_failure); in-flight
+  // runs are preserved.
+  clearTerminalPipelineRuns: () =>
+    request<{ deleted: number }>('/pipeline-runs/clear', { method: 'POST' }),
+  getPipelineRun: (runId: number) =>
+    request<PipelineRun>(`/pipeline-runs/${runId}`),
+  cancelPipelineRun: (runId: number) =>
+    request<PipelineRun>(`/pipeline-runs/${runId}/cancel`, { method: 'POST' }),
+  retryFailedPipelineRun: (runId: number) =>
+    request<PipelineRun>(`/pipeline-runs/${runId}/retry-failed`, { method: 'POST' }),
+
   // Canonical Bambu printer-model registry — "Bambu Lab <model>" → short code.
   // Canonical Bambu printer-model registry — "Bambu Lab <model>" → short code.
   // Single source of truth shared with backend (PRINTER_MODEL_MAP); the
   // Single source of truth shared with backend (PRINTER_MODEL_MAP); the
   // SliceModal uses this to classify cloud / standard presets by their
   // SliceModal uses this to classify cloud / standard presets by their

+ 411 - 0
frontend/src/components/RunWithPipelineModal.tsx

@@ -0,0 +1,411 @@
+import { useState } from 'react';
+import { useTranslation } from 'react-i18next';
+import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
+import { AlertTriangle, Cog, Loader2, Play, Printer as PrinterIcon, X } from 'lucide-react';
+import {
+  api,
+  type PipelineEligibilityReport,
+  type Printer as PrinterType,
+  type SlicerPipeline,
+} from '../api/client';
+import { useToast } from '../contexts/ToastContext';
+import { useSliceJobTracker } from '../contexts/SliceJobTrackerContext';
+
+// Same source-kind shape SliceModal uses, so the same library-file vs archive
+// distinction flows through eligibility-check, run dispatch, AND the progress
+// toast tracker.
+export type RunPipelineSource =
+  | { kind: 'libraryFile'; id: number; filename: string }
+  | { kind: 'archive'; id: number; filename: string };
+
+export interface RunWithPipelineModalProps {
+  source: RunPipelineSource;
+  onClose: () => void;
+}
+
+// Two-step modal. Step 1: pick a pipeline. Step 2: confirm eligibility
+// (skipped when ok=true) and run. Lives in two views in the same modal so
+// the user keeps context — most production runs hit the green path and
+// never see step 2.
+export function RunWithPipelineModal({ source, onClose }: RunWithPipelineModalProps) {
+  const { t } = useTranslation();
+  const queryClient = useQueryClient();
+  const { showToast } = useToast();
+
+  const [picked, setPicked] = useState<SlicerPipeline | null>(null);
+  const [report, setReport] = useState<PipelineEligibilityReport | null>(null);
+  const [copies, setCopies] = useState<number>(1);
+  const { trackJob } = useSliceJobTracker();
+
+  const { data: list, isLoading: pipelinesLoading } = useQuery({
+    queryKey: ['slicer-pipelines'],
+    queryFn: () => api.listSlicerPipelines(),
+  });
+  const { data: printers } = useQuery({
+    queryKey: ['printers'],
+    queryFn: () => api.getPrinters(),
+  });
+  // Cap from settings (PR C). Falls back to 50 when the fetch is in-flight or
+  // missing — same default the backend writes.
+  const { data: settings } = useQuery({
+    queryKey: ['app-settings'],
+    queryFn: () => api.getSettings(),
+  });
+  const maxCopies = settings?.pipeline_max_copies ?? 50;
+
+  const sourceRef = { kind: source.kind, id: source.id } as const;
+  const checkMutation = useMutation({
+    mutationFn: (pipelineId: number) =>
+      api.checkPipelineEligibility(pipelineId, sourceRef),
+  });
+
+  const runMutation = useMutation({
+    mutationFn: ({ pipelineId, force }: { pipelineId: number; force: boolean }) =>
+      api.runPipeline(pipelineId, sourceRef, force, copies),
+    onSuccess: (run) => {
+      queryClient.invalidateQueries({ queryKey: ['slicer-pipelines'] });
+      queryClient.invalidateQueries({ queryKey: ['pipeline-runs'] });
+      // Hand the slice job off to the existing tracker so the same persistent
+      // progress toast renders for pipeline runs as for manual SliceModal
+      // slices — no separate notification surface.
+      if (run.slice_job_id) {
+        trackJob(run.slice_job_id, source.kind, source.filename);
+      }
+      showToast(t('library.runWithPipeline.toast.started', 'Pipeline run started'), 'success');
+      onClose();
+    },
+    onError: (err: Error) => {
+      showToast(err.message || t('library.runWithPipeline.toast.failed', 'Could not start run'), 'error');
+    },
+  });
+
+  const pipelines = list?.pipelines ?? [];
+  const printerById: Record<number, PrinterType> = (printers ?? []).reduce((acc, p) => {
+    acc[p.id] = p;
+    return acc;
+  }, {} as Record<number, PrinterType>);
+
+  const handlePick = async (pipeline: SlicerPipeline) => {
+    const hasTarget =
+      pipeline.target_printer_id ||
+      (pipeline.target_kind === 'printer_class' && pipeline.target_model_class);
+    if (!hasTarget) {
+      showToast(
+        t('library.runWithPipeline.noTargetMessage', 'This pipeline has no target printer set. Open it in Settings to pick one.'),
+        'error',
+      );
+      return;
+    }
+    setPicked(pipeline);
+    try {
+      const result = await checkMutation.mutateAsync(pipeline.id);
+      setReport(result);
+      if (result.ok) {
+        runMutation.mutate({ pipelineId: pipeline.id, force: false });
+      }
+    } catch {
+      // Network error — keep the user on step 1 so they can retry.
+      setPicked(null);
+    }
+  };
+
+  const handleConfirm = () => {
+    if (!picked) return;
+    runMutation.mutate({ pipelineId: picked.id, force: true });
+  };
+
+  const handleBack = () => {
+    setPicked(null);
+    setReport(null);
+  };
+
+  return (
+    <div
+      className="fixed inset-0 z-50 bg-black/60 flex items-center justify-center p-4"
+      onClick={onClose}
+      role="dialog"
+      aria-modal="true"
+      aria-label={t('library.runWithPipeline.modalTitle', 'Run with pipeline')}
+    >
+      <div
+        className="bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg shadow-2xl w-full max-w-md max-h-[80vh] flex flex-col overflow-hidden"
+        onClick={(e) => e.stopPropagation()}
+      >
+        <div className="flex items-center justify-between px-4 py-3 border-b border-bambu-dark-tertiary">
+          <h3 className="text-sm font-semibold text-white flex items-center gap-2">
+            <Play className="w-4 h-4 text-bambu-green" />
+            {picked && report
+              ? t('library.runWithPipeline.confirmTitle', 'Confirm run')
+              : t('library.runWithPipeline.modalTitle', 'Run with pipeline')}
+          </h3>
+          <button
+            type="button"
+            onClick={onClose}
+            aria-label={t('common.close', 'Close')}
+            className="text-bambu-gray hover:text-white"
+          >
+            <X className="w-4 h-4" />
+          </button>
+        </div>
+
+        <div className="flex-1 overflow-y-auto p-4 space-y-3">
+          {picked && report ? (
+            <ConfirmStep
+              pipeline={picked}
+              report={report}
+              source={source}
+              onBack={handleBack}
+              onConfirm={handleConfirm}
+              running={runMutation.isPending}
+            />
+          ) : (
+            <PickStep
+              source={source}
+              pipelines={pipelines}
+              printerById={printerById}
+              loading={pipelinesLoading || checkMutation.isPending}
+              onPick={handlePick}
+              copies={copies}
+              maxCopies={maxCopies}
+              onCopiesChange={setCopies}
+            />
+          )}
+        </div>
+      </div>
+    </div>
+  );
+}
+
+function PickStep({
+  source,
+  pipelines,
+  printerById,
+  loading,
+  onPick,
+  copies,
+  maxCopies,
+  onCopiesChange,
+}: {
+  source: { filename: string };
+  pipelines: SlicerPipeline[];
+  printerById: Record<number, { name: string }>;
+  loading: boolean;
+  onPick: (p: SlicerPipeline) => void;
+  copies: number;
+  maxCopies: number;
+  onCopiesChange: (n: number) => void;
+}) {
+  const { t } = useTranslation();
+  return (
+    <>
+      <p className="text-xs text-bambu-gray">
+        {t('library.runWithPipeline.sourceHint', 'Source')}:{' '}
+        <span className="text-white">{source.filename}</span>
+      </p>
+      <div className="flex items-center gap-2">
+        <label className="text-xs text-bambu-gray" htmlFor="run-pipeline-copies">
+          {t('library.runWithPipeline.copies', 'Copies')}:
+        </label>
+        <input
+          id="run-pipeline-copies"
+          type="number"
+          min={1}
+          max={maxCopies}
+          value={copies}
+          onChange={(e) => {
+            const n = parseInt(e.target.value, 10);
+            if (Number.isNaN(n)) return;
+            onCopiesChange(Math.max(1, Math.min(maxCopies, n)));
+          }}
+          aria-label={t('library.runWithPipeline.copies', 'Copies')}
+          className="w-20 px-2 py-1 text-xs bg-bambu-dark border border-bambu-dark-tertiary rounded text-white"
+        />
+        <span className="text-xs text-bambu-gray/60">
+          {t('library.runWithPipeline.copiesHint', 'max {{n}}', { n: maxCopies })}
+        </span>
+      </div>
+      {loading && (
+        <div className="flex items-center gap-2 text-sm text-bambu-gray">
+          <Loader2 className="w-4 h-4 animate-spin" />
+          {t('library.runWithPipeline.loading', 'Loading…')}
+        </div>
+      )}
+      {!loading && pipelines.length === 0 && (
+        <p className="text-sm text-bambu-gray">
+          {t(
+            'library.runWithPipeline.empty',
+            'No pipelines saved yet. Open the Slice dialog and click "Save as pipeline" to create one.',
+          )}
+        </p>
+      )}
+      {!loading && pipelines.length > 0 && (
+        <ul className="space-y-1.5" aria-label={t('library.runWithPipeline.pipelineListAria', 'Available pipelines')}>
+          {pipelines.map((p) => {
+            const isClass = p.target_kind === 'printer_class';
+            const targetName = p.target_printer_id ? printerById[p.target_printer_id]?.name : null;
+            const classLabel = isClass && p.target_model_class
+              ? t('library.runWithPipeline.classTarget', 'Any {{model}}', { model: p.target_model_class })
+              : null;
+            const hasTarget = !!(p.target_printer_id || (isClass && p.target_model_class));
+            return (
+              <li key={p.id}>
+                <button
+                  type="button"
+                  onClick={() => onPick(p)}
+                  disabled={!hasTarget}
+                  className="w-full text-left px-3 py-2 rounded-md border border-bambu-dark-tertiary bg-bambu-dark/40 hover:bg-bambu-dark-tertiary disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
+                >
+                  <div className="flex items-center gap-2">
+                    <Cog className="w-3.5 h-3.5 text-bambu-green flex-shrink-0" />
+                    <span className="text-sm font-medium text-white truncate">{p.name}</span>
+                  </div>
+                  <div className="mt-1 text-xs text-bambu-gray flex items-center gap-1">
+                    <PrinterIcon className="w-3 h-3" />
+                    {classLabel ? (
+                      <span>{classLabel}</span>
+                    ) : targetName ? (
+                      <span>{targetName}</span>
+                    ) : (
+                      <span className="text-amber-400">
+                        {t('library.runWithPipeline.noTarget', 'No target printer set')}
+                      </span>
+                    )}
+                  </div>
+                </button>
+              </li>
+            );
+          })}
+        </ul>
+      )}
+    </>
+  );
+}
+
+function ConfirmStep({
+  pipeline,
+  report,
+  source,
+  onBack,
+  onConfirm,
+  running,
+}: {
+  pipeline: SlicerPipeline;
+  report: PipelineEligibilityReport;
+  source: { filename: string };
+  onBack: () => void;
+  onConfirm: () => void;
+  running: boolean;
+}) {
+  const { t } = useTranslation();
+
+  return (
+    <>
+      <div className="text-xs text-bambu-gray">
+        <p>
+          {t('library.runWithPipeline.confirmIntro', 'Pre-flight found issues with this run')}:
+        </p>
+        <p className="mt-1">
+          <span className="text-bambu-gray/70">{t('library.runWithPipeline.sourceHint', 'Source')}: </span>
+          <span className="text-white">{source.filename}</span>
+        </p>
+        <p>
+          <span className="text-bambu-gray/70">{t('library.runWithPipeline.pipelineHint', 'Pipeline')}: </span>
+          <span className="text-white">{pipeline.name}</span>
+        </p>
+        {report.target_printer_name && (
+          <p>
+            <span className="text-bambu-gray/70">{t('library.runWithPipeline.targetHint', 'Target')}: </span>
+            <span className="text-white">{report.target_printer_name}</span>
+          </p>
+        )}
+      </div>
+
+      <ul className="space-y-1.5">
+        {report.issues.map((issue, idx) => (
+          <li key={idx} className="flex items-start gap-2 text-xs">
+            <AlertTriangle className="w-3.5 h-3.5 text-amber-400 flex-shrink-0 mt-0.5" />
+            <span className="text-bambu-gray">
+              <IssueText issue={issue} />
+            </span>
+          </li>
+        ))}
+      </ul>
+
+      <div className="flex items-center justify-end gap-2 pt-2 border-t border-bambu-dark-tertiary">
+        <button
+          type="button"
+          onClick={onBack}
+          disabled={running}
+          className="px-3 py-1.5 text-xs text-bambu-gray hover:text-white"
+        >
+          {t('common.back', 'Back')}
+        </button>
+        <button
+          type="button"
+          onClick={onConfirm}
+          disabled={running}
+          className="px-3 py-1.5 text-xs bg-amber-500 hover:bg-amber-600 text-white rounded disabled:opacity-50 flex items-center gap-1"
+        >
+          {running ? (
+            <Loader2 className="w-3 h-3 animate-spin" />
+          ) : (
+            <Play className="w-3 h-3" />
+          )}
+          {t('library.runWithPipeline.runAnyway', 'Run anyway')}
+        </button>
+      </div>
+    </>
+  );
+}
+
+function IssueText({ issue }: { issue: PipelineEligibilityReport['issues'][number] }) {
+  const { t } = useTranslation();
+  switch (issue.kind) {
+    case 'printer_not_set':
+      return <>{t('library.runWithPipeline.issue.printerNotSet', 'No target printer set on this pipeline.')}</>;
+    case 'printer_not_found':
+      return <>{t('library.runWithPipeline.issue.printerNotFound', 'Target printer no longer exists.')}</>;
+    case 'printer_disabled':
+      return <>{t('library.runWithPipeline.issue.printerDisabled', 'Target printer is disabled.')}</>;
+    case 'printer_offline':
+      return <>{t('library.runWithPipeline.issue.printerOffline', 'Target printer is offline.')}</>;
+    case 'filament_type_mismatch':
+      return (
+        <>
+          {t('library.runWithPipeline.issue.filamentType', 'Filament slot {{slot}}: expected {{expected}}, AMS has {{actual}}', {
+            slot: (issue.slot_index ?? 0) + 1,
+            expected: issue.expected ?? '?',
+            actual: issue.actual ?? '?',
+          })}
+        </>
+      );
+    case 'filament_color_mismatch':
+      return (
+        <>
+          {t('library.runWithPipeline.issue.filamentColor', 'Filament slot {{slot}}: colour differs (expected {{expected}}, AMS has {{actual}})', {
+            slot: (issue.slot_index ?? 0) + 1,
+            expected: issue.expected ?? '?',
+            actual: issue.actual ?? '?',
+          })}
+        </>
+      );
+    case 'ams_slot_missing':
+      return (
+        <>
+          {t('library.runWithPipeline.issue.amsSlotMissing', 'AMS slot {{slot}} not available on this printer', {
+            slot: (issue.slot_index ?? 0) + 1,
+          })}
+        </>
+      );
+    case 'filament_unverified':
+      return (
+        <>
+          {t('library.runWithPipeline.issue.filamentUnverified', 'Filament slot {{slot}} comes from a cloud / standard preset and could not be statically verified.', {
+            slot: (issue.slot_index ?? 0) + 1,
+          })}
+        </>
+      );
+    default:
+      return <>{issue.kind}</>;
+  }
+}

+ 145 - 0
frontend/src/components/SliceModal.tsx

@@ -343,6 +343,35 @@ export function SliceModal({ source, onClose }: SliceModalProps) {
   // user had no way to switch plates without cloning the preset.
   // user had no way to switch plates without cloning the preset.
   const [bedType, setBedType] = useState<string | null>(null);
   const [bedType, setBedType] = useState<string | null>(null);
 
 
+  // Slicer Pipelines (#1425) — apply a saved preset bundle to all four slots
+  // with one pick, or save the current selection as a new pipeline.
+  const pipelinesQuery = useQuery({
+    queryKey: ['slicer-pipelines'],
+    queryFn: () => api.listSlicerPipelines(),
+    staleTime: 60_000,
+  });
+  const [savePipelineOpen, setSavePipelineOpen] = useState(false);
+  const [pipelineDraftName, setPipelineDraftName] = useState('');
+  const { showToast } = useToast();
+  const createPipelineMutation = useMutation({
+    mutationFn: (body: {
+      name: string;
+      printer_preset: PresetRef;
+      process_preset: PresetRef;
+      filament_presets: PresetRef[];
+      bed_type: string | null;
+    }) => api.createSlicerPipeline(body),
+    onSuccess: () => {
+      queryClient.invalidateQueries({ queryKey: ['slicer-pipelines'] });
+      showToast(t('slice.pipelines.toast.saved', 'Pipeline saved'), 'success');
+      setSavePipelineOpen(false);
+      setPipelineDraftName('');
+    },
+    onError: (err: Error) => {
+      showToast(err.message || t('slice.pipelines.toast.saveFailed', 'Save failed'), 'error');
+    },
+  });
+
   const platesQuery = useQuery({
   const platesQuery = useQuery({
     queryKey: ['slicePlates', source.kind, source.id],
     queryKey: ['slicePlates', source.kind, source.id],
     queryFn: async () => {
     queryFn: async () => {
@@ -682,6 +711,122 @@ export function SliceModal({ source, onClose }: SliceModalProps) {
                   status === 'ok' (returns null in that case), but the Refresh
                   status === 'ok' (returns null in that case), but the Refresh
                   button stays visible regardless so users can pick up cloud /
                   button stays visible regardless so users can pick up cloud /
                   bundled changes even when sign-in is healthy. */}
                   bundled changes even when sign-in is healthy. */}
+              {/* Slicer Pipelines (#1425): apply a saved preset bundle to all
+                  four slots, or save the current selection as a pipeline.
+                  Pipelines are managed in Settings → Workflow → Pipelines. */}
+              <div className="flex flex-wrap items-center gap-2 px-2 py-1.5 rounded-md bg-bambu-dark/40 border border-bambu-dark-tertiary">
+                <span className="text-xs font-medium text-bambu-gray flex items-center gap-1">
+                  <Cog className="w-3.5 h-3.5" /> {t('slice.pipelines.label', 'Pipeline')}
+                </span>
+                <select
+                  value=""
+                  disabled={isEnqueuing || (pipelinesQuery.data?.pipelines.length ?? 0) === 0}
+                  onChange={(e) => {
+                    const id = parseInt(e.target.value, 10);
+                    if (Number.isNaN(id)) return;
+                    const picked = pipelinesQuery.data?.pipelines.find((p) => p.id === id);
+                    if (!picked) return;
+                    // Apply slot state. The filament list is right-padded from
+                    // current state so a pipeline with fewer entries than the
+                    // current source's slot count keeps the existing tail.
+                    setPrinterPreset(picked.printer_preset);
+                    setProcessPreset(picked.process_preset);
+                    setBedType(picked.bed_type);
+                    setFilamentPresets((current) => {
+                      const next = current.length > 0 ? [...current] : picked.filament_presets.map(() => null);
+                      for (let i = 0; i < next.length; i++) {
+                        if (i < picked.filament_presets.length) {
+                          next[i] = picked.filament_presets[i];
+                        }
+                      }
+                      return next;
+                    });
+                    showToast(t('slice.pipelines.toast.applied', 'Applied "{{name}}"', { name: picked.name }), 'success');
+                    // Reset the dropdown so the user can re-apply the same
+                    // pipeline if needed (selects don't fire onChange when
+                    // value reselects the same option).
+                    e.target.value = '';
+                  }}
+                  className="text-xs px-2 py-1 bg-bambu-dark border border-bambu-dark-tertiary rounded text-white disabled:opacity-50 disabled:cursor-not-allowed flex-1 min-w-[10ch]"
+                  aria-label={t('slice.pipelines.applyAria', 'Apply pipeline')}
+                >
+                  <option value="">
+                    {(pipelinesQuery.data?.pipelines.length ?? 0) === 0
+                      ? t('slice.pipelines.empty', 'No saved pipelines')
+                      : t('slice.pipelines.applyPrompt', 'Apply pipeline…')}
+                  </option>
+                  {pipelinesQuery.data?.pipelines.map((p) => (
+                    <option key={p.id} value={p.id}>
+                      {p.name}
+                    </option>
+                  ))}
+                </select>
+                {!savePipelineOpen ? (
+                  <button
+                    type="button"
+                    onClick={() => {
+                      setPipelineDraftName('');
+                      setSavePipelineOpen(true);
+                    }}
+                    disabled={
+                      isEnqueuing ||
+                      !printerPreset ||
+                      !processPreset ||
+                      filamentPresets.length === 0 ||
+                      filamentPresets.some((f) => f === null)
+                    }
+                    className="text-xs px-2 py-1 bg-bambu-green/20 hover:bg-bambu-green/30 text-bambu-green border border-bambu-green/40 rounded disabled:opacity-50 disabled:cursor-not-allowed"
+                    title={t('slice.pipelines.saveTitle', 'Save the current four-slot selection as a reusable pipeline')}
+                  >
+                    {t('slice.pipelines.saveButton', 'Save as pipeline')}
+                  </button>
+                ) : (
+                  <div className="flex items-center gap-1 flex-1 min-w-[16ch]">
+                    <input
+                      autoFocus
+                      value={pipelineDraftName}
+                      onChange={(e) => setPipelineDraftName(e.target.value)}
+                      placeholder={t('slice.pipelines.namePlaceholder', 'Pipeline name')}
+                      aria-label={t('slice.pipelines.nameAria', 'New pipeline name')}
+                      className="flex-1 text-xs px-2 py-1 bg-bambu-dark border border-bambu-dark-tertiary rounded text-white"
+                    />
+                    <button
+                      type="button"
+                      onClick={() => {
+                        const trimmed = pipelineDraftName.trim();
+                        if (!trimmed || !printerPreset || !processPreset) return;
+                        const nonNull = filamentPresets.filter((f): f is PresetRef => f !== null);
+                        if (nonNull.length === 0) return;
+                        createPipelineMutation.mutate({
+                          name: trimmed,
+                          printer_preset: printerPreset,
+                          process_preset: processPreset,
+                          filament_presets: nonNull,
+                          bed_type: bedType,
+                        });
+                      }}
+                      disabled={createPipelineMutation.isPending || !pipelineDraftName.trim()}
+                      className="text-xs px-2 py-1 bg-bambu-green hover:bg-bambu-green/80 text-white rounded disabled:opacity-50"
+                    >
+                      {createPipelineMutation.isPending ? (
+                        <Loader2 className="w-3 h-3 animate-spin" />
+                      ) : (
+                        t('common.save', 'Save')
+                      )}
+                    </button>
+                    <button
+                      type="button"
+                      onClick={() => {
+                        setSavePipelineOpen(false);
+                        setPipelineDraftName('');
+                      }}
+                      className="text-xs px-2 py-1 text-bambu-gray hover:text-white"
+                    >
+                      {t('common.cancel', 'Cancel')}
+                    </button>
+                  </div>
+                )}
+              </div>
               <PresetDropdown
               <PresetDropdown
                 label={t('slice.printer')}
                 label={t('slice.printer')}
                 slot="printer"
                 slot="printer"

+ 752 - 0
frontend/src/components/SlicerPipelinesPanel.tsx

@@ -0,0 +1,752 @@
+import { useMemo, useState } from 'react';
+import { useTranslation } from 'react-i18next';
+import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
+import { AlertTriangle, Check, Edit2, Loader2, Printer as PrinterIcon, Search, Trash2, Workflow, X } from 'lucide-react';
+import {
+  api,
+  type PipelineRun,
+  type PresetRef,
+  type PresetSource,
+  type Printer as PrinterType,
+  type SlicerPipeline,
+  type UnifiedPresetsResponse,
+} from '../api/client';
+import { Card, CardContent, CardHeader } from './Card';
+import { useToast } from '../contexts/ToastContext';
+
+// Resolve a PresetRef back to its pretty name via the unified-presets listing.
+// Returns null when the ref no longer points at a known preset — render a
+// "deleted" badge in that case so users can see what to fix.
+function resolveName(presets: UnifiedPresetsResponse | undefined, slot: 'printer' | 'process' | 'filament', ref: PresetRef): string | null {
+  if (!presets) return null;
+  const list = presets[ref.source]?.[slot] ?? [];
+  const hit = list.find((p) => p.id === ref.id);
+  return hit ? hit.name : null;
+}
+
+const SOURCE_LABEL: Record<PresetSource, string> = {
+  orca_cloud: 'Orca Cloud',
+  cloud: 'Bambu Cloud',
+  local: 'Imported',
+  standard: 'Standard',
+};
+
+export function SlicerPipelinesPanel() {
+  const { t } = useTranslation();
+  const queryClient = useQueryClient();
+  const { showToast } = useToast();
+
+  const { data: list, isLoading, error } = useQuery({
+    queryKey: ['slicer-pipelines'],
+    queryFn: () => api.listSlicerPipelines(),
+  });
+
+  // The unified presets endpoint is the source of pretty names for each
+  // PresetRef. Same listing the SliceModal pulls — reused here to avoid a
+  // second round-trip to the slicer registry.
+  const { data: presets } = useQuery({
+    queryKey: ['slicer-presets'],
+    queryFn: () => api.getSlicerPresets(),
+  });
+
+  // Printers list for the target picker (PR B).
+  const { data: printers } = useQuery({
+    queryKey: ['printers'],
+    queryFn: () => api.getPrinters(),
+  });
+
+  const updateMutation = useMutation({
+    mutationFn: ({
+      id,
+      name,
+      description,
+      target_printer_id,
+      target_kind,
+      target_model_class,
+      fanout_strategy,
+    }: {
+      id: number;
+      name?: string;
+      description?: string | null;
+      target_printer_id?: number | null;
+      target_kind?: 'specific_printer' | 'printer_class';
+      target_model_class?: string | null;
+      fanout_strategy?: 'max_parallel' | 'fill_one_first' | 'round_robin';
+    }) =>
+      api.updateSlicerPipeline(id, {
+        name,
+        description,
+        target_printer_id,
+        target_kind,
+        target_model_class,
+        fanout_strategy,
+      }),
+    onSuccess: () => {
+      queryClient.invalidateQueries({ queryKey: ['slicer-pipelines'] });
+      showToast(t('settings.pipelines.toast.saved', 'Pipeline saved'), 'success');
+    },
+    onError: (err: Error) => {
+      showToast(err.message || t('settings.pipelines.toast.saveFailed', 'Save failed'), 'error');
+    },
+  });
+
+  const deleteMutation = useMutation({
+    mutationFn: (id: number) => api.deleteSlicerPipeline(id),
+    onSuccess: () => {
+      queryClient.invalidateQueries({ queryKey: ['slicer-pipelines'] });
+      showToast(t('settings.pipelines.toast.deleted', 'Pipeline deleted'), 'success');
+    },
+    onError: (err: Error) => {
+      showToast(err.message || t('settings.pipelines.toast.deleteFailed', 'Delete failed'), 'error');
+    },
+  });
+
+  // Panel-level search + filter (#1425 PR C polish). Filters by pipeline name
+  // (case-insensitive substring) and by target — the dropdown lists every
+  // distinct target in use across the saved pipelines so operators can jump
+  // straight to "show me everything for X1C #2" or "everything for the H2D
+  // class". State is local — list is small enough that re-rendering on every
+  // keystroke is fine.
+  const [searchTerm, setSearchTerm] = useState('');
+  // Encoded target filter value: '' = all, 'none' = no target set,
+  // 'p:<printer_id>' = specific printer, 'c:<model_class>' = printer class.
+  const [targetFilter, setTargetFilter] = useState<string>('');
+
+  const allPipelines = useMemo(() => list?.pipelines ?? [], [list?.pipelines]);
+
+  // Build the dropdown's options from the targets actually in use. Only
+  // printers / classes that at least one pipeline points at appear — keeps
+  // the dropdown short and meaningful for installs with many printers but
+  // few pipelines.
+  const targetOptions = useMemo(() => {
+    const printerIds = new Set<number>();
+    const classes = new Set<string>();
+    let anyWithoutTarget = false;
+    for (const p of allPipelines) {
+      if (p.target_kind === 'printer_class' && p.target_model_class) {
+        classes.add(p.target_model_class);
+      } else if (p.target_printer_id) {
+        printerIds.add(p.target_printer_id);
+      } else {
+        anyWithoutTarget = true;
+      }
+    }
+    return {
+      printers: (printers ?? []).filter((pr) => printerIds.has(pr.id)),
+      classes: Array.from(classes).sort(),
+      anyWithoutTarget,
+    };
+  }, [allPipelines, printers]);
+
+  const pipelines = useMemo(() => {
+    const term = searchTerm.trim().toLowerCase();
+    return allPipelines.filter((p) => {
+      if (term && !p.name.toLowerCase().includes(term)) return false;
+      if (targetFilter === 'none') {
+        const hasTarget = p.target_kind === 'printer_class'
+          ? !!p.target_model_class
+          : p.target_printer_id !== null;
+        if (hasTarget) return false;
+      } else if (targetFilter.startsWith('p:')) {
+        const wantId = parseInt(targetFilter.slice(2), 10);
+        if (p.target_kind === 'printer_class' || p.target_printer_id !== wantId) return false;
+      } else if (targetFilter.startsWith('c:')) {
+        const wantClass = targetFilter.slice(2);
+        if (p.target_kind !== 'printer_class' || p.target_model_class !== wantClass) return false;
+      }
+      return true;
+    });
+  }, [allPipelines, searchTerm, targetFilter]);
+
+  return (
+    <Card>
+      <CardHeader>
+        <h3 className="text-base font-semibold text-white flex items-center gap-2">
+          <Workflow className="w-4 h-4 text-bambu-green" />
+          {t('settings.pipelines.title', 'Slicer Pipelines')}
+        </h3>
+        <p className="text-xs text-bambu-gray mt-1">
+          {t(
+            'settings.pipelines.subtitle',
+            'Reusable preset bundles (printer + process + filaments + bed type). Save one from the Slice dialog and apply it with a single click on the next file.',
+          )}
+        </p>
+      </CardHeader>
+      <CardContent>
+        {isLoading && (
+          <div className="flex items-center gap-2 text-sm text-bambu-gray">
+            <Loader2 className="w-4 h-4 animate-spin" />
+            {t('settings.pipelines.loading', 'Loading pipelines…')}
+          </div>
+        )}
+        {error && (
+          <div className="text-sm text-red-400">
+            {t('settings.pipelines.loadError', 'Could not load pipelines.')}
+          </div>
+        )}
+        {/* Search + target-type filter. Only render when there are pipelines
+            to filter; the empty-state hint reads better without controls. */}
+        {!isLoading && !error && allPipelines.length > 0 && (
+          <div className="flex flex-wrap items-center gap-2 mb-3">
+            <div className="relative flex-1 min-w-[12rem]">
+              <Search className="absolute left-2 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-bambu-gray pointer-events-none" />
+              <input
+                type="search"
+                value={searchTerm}
+                onChange={(e) => setSearchTerm(e.target.value)}
+                placeholder={t('settings.pipelines.searchPlaceholder', 'Search pipelines…')}
+                aria-label={t('settings.pipelines.searchPlaceholder', 'Search pipelines…')}
+                className="w-full pl-7 pr-2 py-1 text-xs bg-bambu-dark border border-bambu-dark-tertiary rounded text-white"
+              />
+            </div>
+            <select
+              value={targetFilter}
+              onChange={(e) => setTargetFilter(e.target.value)}
+              aria-label={t('settings.pipelines.filterTarget', 'Filter by target')}
+              className="text-xs px-2 py-1 bg-bambu-dark border border-bambu-dark-tertiary rounded text-white"
+            >
+              <option value="">
+                {t('settings.pipelines.filter.all', 'All targets')}
+              </option>
+              {targetOptions.printers.length > 0 && (
+                <optgroup label={t('settings.pipelines.field.targetKindSpecific', 'Specific printer')}>
+                  {targetOptions.printers.map((p) => (
+                    <option key={`p-${p.id}`} value={`p:${p.id}`}>
+                      {p.name}
+                    </option>
+                  ))}
+                </optgroup>
+              )}
+              {targetOptions.classes.length > 0 && (
+                <optgroup label={t('settings.pipelines.field.targetKindClass', 'Printer class')}>
+                  {targetOptions.classes.map((c) => (
+                    <option key={`c-${c}`} value={`c:${c}`}>
+                      {t('library.runWithPipeline.classTarget', 'Any {{model}}', { model: c })}
+                    </option>
+                  ))}
+                </optgroup>
+              )}
+              {targetOptions.anyWithoutTarget && (
+                <option value="none">
+                  {t('settings.pipelines.filter.noTarget', 'No target set')}
+                </option>
+              )}
+            </select>
+            {(searchTerm || targetFilter) && (
+              <span className="text-xs text-bambu-gray">
+                {t('settings.pipelines.filter.count', '{{shown}} / {{total}}', {
+                  shown: pipelines.length,
+                  total: allPipelines.length,
+                })}
+              </span>
+            )}
+          </div>
+        )}
+        {!isLoading && !error && allPipelines.length === 0 && (
+          <div className="text-sm text-bambu-gray space-y-2">
+            <p>{t('settings.pipelines.empty.title', 'No pipelines yet.')}</p>
+            <p>
+              {t(
+                'settings.pipelines.empty.howto',
+                'Open the Slice dialog for any file, pick your printer / process / filaments / bed type, then click "Save as pipeline". Your saved pipelines will appear here.',
+              )}
+            </p>
+          </div>
+        )}
+        {!isLoading && !error && allPipelines.length > 0 && pipelines.length === 0 && (
+          <p className="text-sm text-bambu-gray">
+            {t('settings.pipelines.filter.noMatches', 'No pipelines match the current filters.')}
+          </p>
+        )}
+        {!isLoading && !error && pipelines.length > 0 && (
+          <div className="space-y-2">
+            {pipelines.map((p) => (
+              <PipelineRow
+                key={p.id}
+                pipeline={p}
+                presets={presets}
+                printers={printers ?? []}
+                onSave={(payload) => updateMutation.mutate({ id: p.id, ...payload })}
+                onDelete={() => {
+                  if (confirm(t('settings.pipelines.confirmDelete', 'Delete this pipeline? This cannot be undone.'))) {
+                    deleteMutation.mutate(p.id);
+                  }
+                }}
+                saving={updateMutation.isPending}
+                deleting={deleteMutation.isPending}
+              />
+            ))}
+          </div>
+        )}
+      </CardContent>
+    </Card>
+  );
+}
+
+function PipelineRow({
+  pipeline,
+  presets,
+  printers,
+  onSave,
+  onDelete,
+  saving,
+  deleting,
+}: {
+  pipeline: SlicerPipeline;
+  presets: UnifiedPresetsResponse | undefined;
+  printers: PrinterType[];
+  onSave: (payload: {
+    name?: string;
+    description?: string | null;
+    target_printer_id?: number | null;
+    target_kind?: 'specific_printer' | 'printer_class';
+    target_model_class?: string | null;
+    fanout_strategy?: 'max_parallel' | 'fill_one_first' | 'round_robin';
+  }) => void;
+  onDelete: () => void;
+  saving: boolean;
+  deleting: boolean;
+}) {
+  const { t } = useTranslation();
+  const [editing, setEditing] = useState(false);
+  const [draftName, setDraftName] = useState(pipeline.name);
+  const [draftDescription, setDraftDescription] = useState(pipeline.description ?? '');
+  const [draftTargetPrinterId, setDraftTargetPrinterId] = useState<number | null>(
+    pipeline.target_printer_id,
+  );
+  // PR C: target kind, model class, and fanout strategy.
+  const [draftTargetKind, setDraftTargetKind] = useState<'specific_printer' | 'printer_class'>(
+    pipeline.target_kind === 'printer_class' ? 'printer_class' : 'specific_printer',
+  );
+  const [draftTargetModelClass, setDraftTargetModelClass] = useState<string>(
+    pipeline.target_model_class ?? '',
+  );
+  const [draftFanout, setDraftFanout] = useState<'max_parallel' | 'fill_one_first' | 'round_robin'>(
+    pipeline.fanout_strategy ?? 'max_parallel',
+  );
+  // Installed model classes — derived from the loaded printers list so the
+  // dropdown only offers models the user actually has. Same data the row
+  // header uses, no second fetch.
+  const installedModels = Array.from(
+    new Set(printers.map((p) => p.model).filter((m): m is string => !!m)),
+  ).sort();
+
+  // Recent runs for the inline last-run summary. ``enabled: editing === false``
+  // avoids re-querying every keystroke while the editor is open.
+  const { data: runsList } = useQuery({
+    queryKey: ['pipeline-runs', pipeline.id],
+    queryFn: () => api.listPipelineRuns(pipeline.id, 1),
+    enabled: !editing,
+    refetchInterval: 15_000,
+  });
+  const lastRun: PipelineRun | undefined = runsList?.runs?.[0];
+
+  const printerName = resolveName(presets, 'printer', pipeline.printer_preset);
+  const processName = resolveName(presets, 'process', pipeline.process_preset);
+  const filamentResolutions = pipeline.filament_presets.map((f) => resolveName(presets, 'filament', f));
+  // Collapse identical filaments into a single "All N slots" line — most
+  // production pipelines load the same filament into every AMS slot, and
+  // listing the same line three times is just noise. Compares raw preset
+  // refs (source + id) rather than resolved names so the dedup is correct
+  // even when ``presets`` hasn't loaded yet.
+  const filamentsAllIdentical =
+    pipeline.filament_presets.length > 1 &&
+    pipeline.filament_presets.every(
+      (f) =>
+        f.source === pipeline.filament_presets[0].source &&
+        f.id === pipeline.filament_presets[0].id,
+    );
+
+  const hasStaleRef =
+    presets !== undefined &&
+    (printerName === null || processName === null || filamentResolutions.some((n) => n === null));
+  const targetPrinter = pipeline.target_printer_id
+    ? printers.find((p) => p.id === pipeline.target_printer_id)
+    : undefined;
+  const isClassTargeting = pipeline.target_kind === 'printer_class';
+  const needsTarget = isClassTargeting
+    ? !pipeline.target_model_class
+    : pipeline.target_printer_id === null;
+
+  const handleSave = () => {
+    const trimmedName = draftName.trim();
+    if (!trimmedName) return;
+    onSave({
+      name: trimmedName,
+      description: draftDescription.trim() || null,
+      target_kind: draftTargetKind,
+      // Backend treats 0 as "clear"; null in TS maps to that intent.
+      target_printer_id:
+        draftTargetKind === 'specific_printer' ? (draftTargetPrinterId ?? 0) : 0,
+      target_model_class:
+        draftTargetKind === 'printer_class' ? (draftTargetModelClass || null) : null,
+      fanout_strategy: draftFanout,
+    });
+    setEditing(false);
+  };
+
+  const handleCancel = () => {
+    setDraftName(pipeline.name);
+    setDraftDescription(pipeline.description ?? '');
+    setDraftTargetPrinterId(pipeline.target_printer_id);
+    setDraftTargetKind(pipeline.target_kind === 'printer_class' ? 'printer_class' : 'specific_printer');
+    setDraftTargetModelClass(pipeline.target_model_class ?? '');
+    setDraftFanout(pipeline.fanout_strategy ?? 'max_parallel');
+    setEditing(false);
+  };
+
+  return (
+    <div className="rounded-md border border-bambu-dark-tertiary bg-bambu-dark/40 px-3 py-2">
+      <div className="flex items-start justify-between gap-3">
+        <div className="min-w-0 flex-1">
+          {editing ? (
+            <div className="space-y-2">
+              <input
+                value={draftName}
+                onChange={(e) => setDraftName(e.target.value)}
+                aria-label={t('settings.pipelines.field.name', 'Pipeline name')}
+                placeholder={t('settings.pipelines.field.name', 'Pipeline name')}
+                className="w-full px-2 py-1 text-sm bg-bambu-dark border border-bambu-dark-tertiary rounded text-white"
+              />
+              <textarea
+                value={draftDescription}
+                onChange={(e) => setDraftDescription(e.target.value)}
+                aria-label={t('settings.pipelines.field.description', 'Description')}
+                placeholder={t('settings.pipelines.field.description', 'Description')}
+                rows={2}
+                className="w-full px-2 py-1 text-xs bg-bambu-dark border border-bambu-dark-tertiary rounded text-white"
+              />
+              {/* PR C — target-kind radio (Specific printer / Printer class)
+                  drives whether the printer dropdown or the class picker is
+                  active. Both fields are kept on state so toggling back and
+                  forth doesn't lose the user's previous pick. */}
+              <div>
+                <label className="text-xs text-bambu-gray block mb-1">
+                  {t('settings.pipelines.field.targetKind', 'Target type')}
+                </label>
+                <div className="flex gap-3 text-xs">
+                  <label className="flex items-center gap-1 text-white">
+                    <input
+                      type="radio"
+                      name={`target-kind-${pipeline.id}`}
+                      value="specific_printer"
+                      checked={draftTargetKind === 'specific_printer'}
+                      onChange={() => setDraftTargetKind('specific_printer')}
+                      aria-label={t('settings.pipelines.field.targetKindSpecific', 'Specific printer')}
+                    />
+                    {t('settings.pipelines.field.targetKindSpecific', 'Specific printer')}
+                  </label>
+                  <label className="flex items-center gap-1 text-white">
+                    <input
+                      type="radio"
+                      name={`target-kind-${pipeline.id}`}
+                      value="printer_class"
+                      checked={draftTargetKind === 'printer_class'}
+                      onChange={() => setDraftTargetKind('printer_class')}
+                      aria-label={t('settings.pipelines.field.targetKindClass', 'Printer class')}
+                    />
+                    {t('settings.pipelines.field.targetKindClass', 'Printer class')}
+                  </label>
+                </div>
+              </div>
+
+              {draftTargetKind === 'specific_printer' ? (
+                <div>
+                  <label className="text-xs text-bambu-gray block mb-1">
+                    {t('settings.pipelines.field.targetPrinter', 'Target printer')}
+                  </label>
+                  <select
+                    value={draftTargetPrinterId ?? ''}
+                    onChange={(e) =>
+                      setDraftTargetPrinterId(e.target.value ? parseInt(e.target.value, 10) : null)
+                    }
+                    aria-label={t('settings.pipelines.field.targetPrinter', 'Target printer')}
+                    className="w-full px-2 py-1 text-xs bg-bambu-dark border border-bambu-dark-tertiary rounded text-white"
+                  >
+                    <option value="">
+                      {t('settings.pipelines.field.noTarget', '— No target —')}
+                    </option>
+                    {printers.map((p) => (
+                      <option key={p.id} value={p.id}>
+                        {p.name}
+                      </option>
+                    ))}
+                  </select>
+                </div>
+              ) : (
+                <div className="space-y-2">
+                  <div>
+                    <label className="text-xs text-bambu-gray block mb-1">
+                      {t('settings.pipelines.field.targetModelClass', 'Printer model')}
+                    </label>
+                    <select
+                      value={draftTargetModelClass}
+                      onChange={(e) => setDraftTargetModelClass(e.target.value)}
+                      aria-label={t('settings.pipelines.field.targetModelClass', 'Printer model')}
+                      className="w-full px-2 py-1 text-xs bg-bambu-dark border border-bambu-dark-tertiary rounded text-white"
+                    >
+                      <option value="">
+                        {t('settings.pipelines.field.noTarget', '— No target —')}
+                      </option>
+                      {installedModels.map((m) => (
+                        <option key={m} value={m}>
+                          {m}
+                        </option>
+                      ))}
+                    </select>
+                  </div>
+                  <div>
+                    <label className="text-xs text-bambu-gray block mb-1">
+                      {t('settings.pipelines.field.fanoutStrategy', 'Fanout strategy')}
+                    </label>
+                    <select
+                      value={draftFanout}
+                      onChange={(e) =>
+                        setDraftFanout(e.target.value as 'max_parallel' | 'fill_one_first' | 'round_robin')
+                      }
+                      aria-label={t('settings.pipelines.field.fanoutStrategy', 'Fanout strategy')}
+                      className="w-full px-2 py-1 text-xs bg-bambu-dark border border-bambu-dark-tertiary rounded text-white"
+                    >
+                      <option value="max_parallel">
+                        {t('settings.pipelines.field.fanout.max_parallel', 'Max parallel — distribute across any idle matching printer')}
+                      </option>
+                      <option value="round_robin">
+                        {t('settings.pipelines.field.fanout.round_robin', 'Round robin — cycle through eligible printers')}
+                      </option>
+                      <option value="fill_one_first">
+                        {t('settings.pipelines.field.fanout.fill_one_first', 'Fill one first — pin all copies to one printer')}
+                      </option>
+                    </select>
+                  </div>
+                </div>
+              )}
+            </div>
+          ) : (
+            <>
+              {/* Header: name + inline target chip (PR C polish). The target
+                  context — specific printer name OR class+strategy — is the
+                  thing the operator most needs to read at a glance, so it
+                  rides up here next to the title instead of buried below. */}
+              <div className="flex flex-wrap items-baseline gap-x-2 gap-y-1">
+                <h4 className="text-sm font-medium text-white truncate">{pipeline.name}</h4>
+                <span
+                  className={`text-xs px-1.5 py-0.5 rounded inline-flex items-center gap-1 ${
+                    needsTarget
+                      ? 'bg-amber-500/15 text-amber-400'
+                      : 'bg-bambu-dark-tertiary text-bambu-gray'
+                  }`}
+                >
+                  <PrinterIcon className="w-3 h-3" />
+                  {needsTarget ? (
+                    t('settings.pipelines.noTargetHint', 'Set a target printer to run this')
+                  ) : isClassTargeting ? (
+                    <>
+                      {t('library.runWithPipeline.classTarget', 'Any {{model}}', {
+                        model: pipeline.target_model_class,
+                      })}
+                      {pipeline.fanout_strategy && (
+                        <span className="text-bambu-gray/60">
+                          {' · '}
+                          {t(
+                            `settings.pipelines.field.fanoutShort.${pipeline.fanout_strategy}`,
+                            pipeline.fanout_strategy,
+                          )}
+                        </span>
+                      )}
+                    </>
+                  ) : (
+                    targetPrinter?.name ?? ''
+                  )}
+                </span>
+              </div>
+              {pipeline.description && (
+                <p className="text-xs text-bambu-gray mt-0.5">{pipeline.description}</p>
+              )}
+            </>
+          )}
+        </div>
+        <div className="flex items-center gap-1 flex-shrink-0">
+          {editing ? (
+            <>
+              <button
+                onClick={handleSave}
+                disabled={saving || !draftName.trim()}
+                aria-label={t('settings.pipelines.action.save', 'Save')}
+                className="p-1.5 text-bambu-green hover:bg-bambu-dark-tertiary rounded disabled:opacity-50"
+              >
+                <Check className="w-4 h-4" />
+              </button>
+              <button
+                onClick={handleCancel}
+                aria-label={t('settings.pipelines.action.cancel', 'Cancel')}
+                className="p-1.5 text-bambu-gray hover:bg-bambu-dark-tertiary rounded"
+              >
+                <X className="w-4 h-4" />
+              </button>
+            </>
+          ) : (
+            <>
+              <button
+                onClick={() => setEditing(true)}
+                aria-label={t('settings.pipelines.action.rename', 'Rename')}
+                className="p-1.5 text-bambu-gray hover:text-white hover:bg-bambu-dark-tertiary rounded"
+              >
+                <Edit2 className="w-4 h-4" />
+              </button>
+              <button
+                onClick={onDelete}
+                disabled={deleting}
+                aria-label={t('settings.pipelines.action.delete', 'Delete')}
+                className="p-1.5 text-bambu-gray hover:text-red-400 hover:bg-bambu-dark-tertiary rounded disabled:opacity-50"
+              >
+                <Trash2 className="w-4 h-4" />
+              </button>
+            </>
+          )}
+        </div>
+      </div>
+
+      {!editing && (
+        <div className="mt-2 grid grid-cols-1 md:grid-cols-2 gap-x-4 gap-y-2 text-xs">
+          {/* Profiles group — printer / process / bed. These travel together
+              because they describe the slicer profile bundle that produces a
+              single gcode. The full preset name (including the BambuStudio
+              ``@BBL <model>`` suffix) is shown verbatim so the user can match
+              it 1:1 against what they see in the slicer. */}
+          <div className="space-y-0.5">
+            <div className="text-[10px] uppercase tracking-wide text-bambu-gray/60">
+              {t('settings.pipelines.group.profiles', 'Profiles')}
+            </div>
+            <PresetLine
+              label={t('settings.pipelines.slot.printer', 'Printer')}
+              ref={pipeline.printer_preset}
+              name={printerName}
+            />
+            <PresetLine
+              label={t('settings.pipelines.slot.process', 'Process')}
+              ref={pipeline.process_preset}
+              name={processName}
+            />
+            {pipeline.bed_type && (
+              <div className="text-bambu-gray">
+                <span className="font-medium text-bambu-gray/80">
+                  {t('settings.pipelines.slot.bed', 'Bed')}:
+                </span>{' '}
+                <span className="text-white">{pipeline.bed_type}</span>
+              </div>
+            )}
+          </div>
+          {/* Filaments group — one per AMS slot. When every slot is the same
+              filament (the common single-color production-batch case) we
+              collapse them into a single ``All 4 slots: PLA Basic`` line. */}
+          <div className="space-y-0.5">
+            <div className="text-[10px] uppercase tracking-wide text-bambu-gray/60">
+              {t('settings.pipelines.group.filaments', 'Filaments')}
+              {pipeline.filament_presets.length > 1 && (
+                <span className="text-bambu-gray/60 normal-case ml-1">
+                  ({pipeline.filament_presets.length})
+                </span>
+              )}
+            </div>
+            {filamentsAllIdentical ? (
+              <PresetLine
+                label={t('settings.pipelines.slot.filamentAll', 'All {{n}} slots', {
+                  n: pipeline.filament_presets.length,
+                })}
+                ref={pipeline.filament_presets[0]}
+                name={filamentResolutions[0]}
+              />
+            ) : (
+              pipeline.filament_presets.map((f, i) => (
+                <PresetLine
+                  key={i}
+                  label={
+                    pipeline.filament_presets.length > 1
+                      ? t('settings.pipelines.slot.filamentN', 'Filament {{n}}', { n: i + 1 })
+                      : t('settings.pipelines.slot.filament', 'Filament')
+                  }
+                  ref={f}
+                  name={filamentResolutions[i]}
+                />
+              ))
+            )}
+          </div>
+        </div>
+      )}
+
+      {!editing && lastRun && (
+        <div className="mt-1.5 text-xs text-bambu-gray flex items-center gap-1">
+          <span className="font-medium text-bambu-gray/80">
+            {t('settings.pipelines.runs.lastRun', 'Last run')}:
+          </span>{' '}
+          <RunStatusBadge status={lastRun.status} />
+          {lastRun.created_at && (
+            <span className="text-bambu-gray/60">
+              · {new Date(lastRun.created_at).toLocaleString()}
+            </span>
+          )}
+        </div>
+      )}
+
+      {needsTarget && !editing && (
+        <div className="mt-2 flex items-center gap-1.5 text-xs text-amber-400">
+          <AlertTriangle className="w-3.5 h-3.5" />
+          {t(
+            'settings.pipelines.noTargetWarning',
+            'Set a target printer before running this pipeline.',
+          )}
+        </div>
+      )}
+
+      {hasStaleRef && !editing && (
+        <div className="mt-2 flex items-center gap-1.5 text-xs text-amber-400">
+          <AlertTriangle className="w-3.5 h-3.5" />
+          {t(
+            'settings.pipelines.staleWarning',
+            'One or more referenced presets no longer exist. Re-save this pipeline from the Slice dialog to fix.',
+          )}
+        </div>
+      )}
+    </div>
+  );
+}
+
+function RunStatusBadge({ status }: { status: PipelineRun['status'] }) {
+  const { t } = useTranslation();
+  const colourClass: Record<PipelineRun['status'], string> = {
+    queued: 'text-bambu-gray',
+    slicing: 'text-blue-400',
+    dispatching: 'text-blue-400',
+    in_progress: 'text-bambu-green',
+    completed: 'text-bambu-green',
+    failed: 'text-red-400',
+    partial_failure: 'text-amber-400',
+    cancelled: 'text-bambu-gray',
+  };
+  return (
+    <span className={colourClass[status]}>
+      {t(`settings.pipelines.runs.status.${status}`, status)}
+    </span>
+  );
+}
+
+function PresetLine({
+  label,
+  ref,
+  name,
+}: {
+  label: string;
+  ref: PresetRef;
+  name: string | null;
+}) {
+  return (
+    <div className="text-bambu-gray truncate">
+      <span className="font-medium text-bambu-gray/80">{label}:</span>{' '}
+      {name ? (
+        <span className="text-white">{name}</span>
+      ) : (
+        <span className="text-amber-400">[{SOURCE_LABEL[ref.source]} #{ref.id}]</span>
+      )}
+    </div>
+  );
+}

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

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

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

@@ -11,6 +11,10 @@ interface WebSocketMessage {
   data?: Record<string, unknown>;
   data?: Record<string, unknown>;
   printer_name?: string;
   printer_name?: string;
   missing_slots?: Array<{ slot?: string }>;
   missing_slots?: Array<{ slot?: string }>;
+  // Slicer Pipeline run events (#1425 PR C). ``run`` carries the full
+  // PipelineRunResponse payload — typed loosely here so the WebSocket hook
+  // doesn't pull the full client.ts types in.
+  run?: { pipeline_id?: number | null };
 }
 }
 
 
 export function useWebSocket() {
 export function useWebSocket() {
@@ -383,6 +387,31 @@ export function useWebSocket() {
         debouncedInvalidate('spoolbuddy-devices');
         debouncedInvalidate('spoolbuddy-devices');
         debouncedInvalidate('spoolbuddy-update-check');
         debouncedInvalidate('spoolbuddy-update-check');
         break;
         break;
+
+      // Dispatch toast lifecycle (#1625 follow-up — restored the upload
+      // progress UI that the scheduler unification removed). Four backend
+      // event types collapse to one frontend channel. No
+      // `queue_item_queued` (the toast must wait for the upload to
+      // actually start) and no `queue_item_dispatched` (the legacy
+      // background-dispatch flow kept status='processing' from upload
+      // start until printer ack — the "Awaiting printer…" subtitle is
+      // derived from upload_progress_pct >= 99.9, not from a separate
+      // event).
+      case 'queue_item_uploading':
+      case 'queue_item_upload_progress':
+      case 'queue_item_acked':
+      case 'queue_item_failed':
+        window.dispatchEvent(new CustomEvent('bambuddy:dispatch-toast', { detail: message }));
+        break;
+      // Slicer Pipeline runs (#1425 PR C). State transitions on the run
+      // refresh both the dashboard list AND the per-pipeline "Last run"
+      // chip in Settings → Pipelines.
+      case 'pipeline_run_updated':
+        queryClient.invalidateQueries({ queryKey: ['pipeline-runs-all'] });
+        if (message.run?.pipeline_id) {
+          queryClient.invalidateQueries({ queryKey: ['pipeline-runs', message.run.pipeline_id] });
+        }
+        break;
     }
     }
   }, [queryClient, debouncedInvalidate, throttledPrinterStatusUpdate, showToast, t]);
   }, [queryClient, debouncedInvalidate, throttledPrinterStatusUpdate, showToast, t]);
 
 

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

@@ -101,6 +101,8 @@ export default {
     now: 'Jetzt',
     now: 'Jetzt',
     collapse: 'Einklappen',
     collapse: 'Einklappen',
     expand: 'Ausklappen',
     expand: 'Ausklappen',
+    previous: 'Zurück',
+    next: 'Weiter',
     viewArchive: 'Archiv anzeigen',
     viewArchive: 'Archiv anzeigen',
     viewInFileManager: 'Im Dateimanager anzeigen',
     viewInFileManager: 'Im Dateimanager anzeigen',
     addedBy: 'Hinzugefügt von {{username}}',
     addedBy: 'Hinzugefügt von {{username}}',
@@ -1023,6 +1025,75 @@ export default {
     },
     },
   },
   },
 
 
+  // Sticky upload-progress toast (#1625 follow-up)
+  dispatchToast: {
+    untitled: 'Druckjob',
+    startingPrints: 'Drucke starten',
+    progressSummary: '{{complete}}/{{total}} fertig • Verarbeitung: {{processing}}',
+    expandDetails: 'Versanddetails ausklappen',
+    collapseDetails: 'Versanddetails einklappen',
+    awaitingPrinter: 'Warte auf Drucker…',
+    status: {
+      processing: 'Verarbeitung',
+      completed: 'Fertig',
+      failed: 'Fehlgeschlagen',
+    },
+    failed: {
+      generic: 'Versand fehlgeschlagen',
+      upload_failed: 'Upload zum Drucker fehlgeschlagen',
+      start_command_failed: 'Drucker hat Startbefehl abgelehnt',
+    },
+    dismiss: 'Schließen',
+  },
+
+  // Pipeline Runs dashboard (#1425 PR C).
+  pipelineRuns: {
+    title: 'Pipeline-Läufe',
+    loading: 'Wird geladen…',
+    empty: 'Noch keine Pipeline-Läufe.',
+    filter: {
+      pipeline: 'Pipeline',
+      status: 'Status',
+      target: 'Ziel',
+      all: 'Alle',
+      allPipelines: 'Alle Pipelines',
+      allStatus: 'Alle Status',
+      allTargets: 'Alle Ziele',
+      clear: 'Filter zurücksetzen',
+      noMatches: 'Keine Läufe entsprechen den aktuellen Filtern.',
+    },
+    totalCount_one: '{{n}} Lauf',
+    totalCount_other: '{{n}} Läufe',
+    copies: '{{n}} Kopien',
+    failedCount: '{{n}} fehlgeschlagen',
+    copyN: 'Kopie {{n}}',
+    retryFailed: 'Fehlgeschlagene wiederholen',
+    retryOf: 'Wiederholung von #{{n}}',
+    pagination: '{{start}}–{{end}} von {{total}}',
+    toast: {
+      cancelled: 'Lauf abgebrochen',
+      cancelFailed: 'Abbruch fehlgeschlagen',
+      retryStarted: 'Wiederholung gestartet',
+      retryFailed: 'Wiederholung fehlgeschlagen',
+      cleared: '{{n}} Läufe gelöscht',
+      clearFailed: 'Löschen fehlgeschlagen',
+    },
+    clearLog: 'Verlauf löschen',
+    clearConfirmTitle: 'Verlauf löschen?',
+    clearConfirmBody: 'Jeden abgeschlossenen, fehlgeschlagenen, abgebrochenen und teilweise fehlgeschlagenen Pipeline-Lauf löschen? Laufende Läufe bleiben erhalten. Dies kann nicht rückgängig gemacht werden.',
+    clearConfirmAction: 'Löschen',
+    jobStatus: {
+      pending: 'ausstehend',
+      awaiting_printer: 'wartet auf Drucker',
+      queued: 'in Warteschlange',
+      printing: 'druckt',
+      completed: 'abgeschlossen',
+      failed: 'fehlgeschlagen',
+      cancelled: 'abgebrochen',
+    },
+    cancelledByUser: 'Vom Benutzer abgebrochen',
+  },
+
   // Queue page
   // Queue page
   queue: {
   queue: {
     filamentShort: {
     filamentShort: {
@@ -1102,6 +1173,7 @@ export default {
       queue: 'Warteschlange',
       queue: 'Warteschlange',
       history: 'Verlauf',
       history: 'Verlauf',
       timeline: 'Zeitachse',
       timeline: 'Zeitachse',
+      pipelines: 'Druckabläufe',
     },
     },
     layout: {
     layout: {
       flatList: 'Liste',
       flatList: 'Liste',
@@ -1537,6 +1609,8 @@ export default {
       smartPlugs: 'Smart Plugs',
       smartPlugs: 'Smart Plugs',
       notifications: 'Benachrichtigungen',
       notifications: 'Benachrichtigungen',
       queue: 'Workflow',
       queue: 'Workflow',
+      queueDispatch: 'Warteschlange & Dispatch',
+      queuePipelines: 'Pipelines',
       filament: 'Filament',
       filament: 'Filament',
       network: 'Netzwerk',
       network: 'Netzwerk',
       apiKeys: 'API-Schlüssel',
       apiKeys: 'API-Schlüssel',
@@ -2530,6 +2604,96 @@ export default {
       migrationErrorWarning: '{{count}} Legacy-Eintrag/Einträge konnten beim Start nicht verschlüsselt werden. Prüfen Sie die Server-Logs und starten Sie Bambuddy neu.',
       migrationErrorWarning: '{{count}} Legacy-Eintrag/Einträge konnten beim Start nicht verschlüsselt werden. Prüfen Sie die Server-Logs und starten Sie Bambuddy neu.',
     },
     },
 
 
+
+    pipelineLimits: {
+      title: 'Slicer-Pipeline-Limits',
+      maxCopiesLabel: 'Max. Kopien pro Lauf',
+      maxCopiesDesc: 'Obergrenze für die Anzahl an Kopien, die Operatoren beim Ausführen einer Pipeline anfordern können. Serverseitige Obergrenze ist 1000.',
+    },
+
+    // Slicer Pipelines (#1425): list/edit/delete preset bundles users saved
+    // from the Slice dialog. Lives in Settings → Workflow → Pipelines sub-tab.
+    pipelines: {
+      title: 'Slicer-Pipelines',
+      subtitle: 'Wiederverwendbare Preset-Bundles (Drucker + Prozess + Filamente + Druckplatte). Speichere eines aus dem Slice-Dialog und wende es beim nächsten Datei-Slice mit einem Klick an.',
+      loading: 'Pipelines werden geladen…',
+      loadError: 'Pipelines konnten nicht geladen werden.',
+      confirmDelete: 'Diese Pipeline löschen? Das kann nicht rückgängig gemacht werden.',
+      staleWarning: 'Eines oder mehrere referenzierte Presets existieren nicht mehr. Speichere diese Pipeline erneut aus dem Slice-Dialog, um sie zu reparieren.',
+      empty: {
+        title: 'Noch keine Pipelines.',
+        howto: 'Öffne den Slice-Dialog für eine beliebige Datei, wähle Drucker / Prozess / Filamente / Druckplatte und klicke „Als Pipeline speichern“. Deine gespeicherten Pipelines erscheinen hier.',
+      },
+      field: {
+        name: 'Pipeline-Name',
+        description: 'Beschreibung',
+        targetPrinter: 'Zieldrucker',
+        noTarget: '— Kein Ziel —',
+        targetKind: 'Zielart',
+        targetKindSpecific: 'Spezifischer Drucker',
+        targetKindClass: 'Druckerklasse',
+        targetModelClass: 'Druckermodell',
+        fanoutStrategy: 'Verteilungsstrategie',
+        fanout: {
+          max_parallel: 'Max parallel — auf alle verfügbaren passenden Drucker verteilen',
+          round_robin: 'Reihum — durch geeignete Drucker rotieren',
+          fill_one_first: 'Erst einen füllen — alle Kopien an einen Drucker binden',
+        },
+        fanoutShort: {
+          max_parallel: 'parallel',
+          round_robin: 'Reihum',
+          fill_one_first: 'einer zuerst',
+        },
+      },
+      action: {
+        save: 'Speichern',
+        cancel: 'Abbrechen',
+        rename: 'Umbenennen',
+        delete: 'Löschen',
+      },
+      slot: {
+        printer: 'Drucker',
+        process: 'Prozess',
+        filament: 'Filament',
+        filamentN: 'Filament {{n}}',
+        filamentAll: 'Alle {{n}} Slots',
+        bed: 'Druckplatte',
+      },
+      group: {
+        profiles: 'Profile',
+        filaments: 'Filamente',
+      },
+      searchPlaceholder: 'Pipelines durchsuchen…',
+      filterTargetType: 'Nach Zielart filtern',
+      filterTarget: 'Nach Ziel filtern',
+      filter: {
+        all: 'Alle Ziele',
+        noTarget: 'Kein Ziel festgelegt',
+        count: '{{shown}} / {{total}}',
+        noMatches: 'Keine Pipelines entsprechen den aktuellen Filtern.',
+      },
+      toast: {
+        saved: 'Pipeline gespeichert',
+        saveFailed: 'Speichern fehlgeschlagen',
+        deleted: 'Pipeline gelöscht',
+        deleteFailed: 'Löschen fehlgeschlagen',
+      },
+      noTargetHint: 'Lege einen Zieldrucker fest, um diese auszuführen',
+      noTargetWarning: 'Lege einen Zieldrucker fest, bevor du diese Pipeline ausführst.',
+      runs: {
+        lastRun: 'Letzter Lauf',
+        status: {
+          queued: 'in Warteschlange',
+          slicing: 'wird geschnitten',
+          dispatching: 'wird gesendet',
+          in_progress: 'druckt',
+          completed: 'abgeschlossen',
+          failed: 'fehlgeschlagen',
+          partial_failure: 'teilweise fehlgeschlagen',
+          cancelled: 'abgebrochen',
+        },
+      },
+    },
   },
   },
 
 
   // Notifications (for push notifications)
   // Notifications (for push notifications)
@@ -3759,6 +3923,41 @@ export default {
     deleteConfirm: 'Möchten Sie dieses Filament wirklich löschen?',
     deleteConfirm: 'Möchten Sie dieses Filament wirklich löschen?',
     importFromPrinter: 'Vom Drucker importieren',
     importFromPrinter: 'Vom Drucker importieren',
     exportToFile: 'In Datei exportieren',
     exportToFile: 'In Datei exportieren',
+    runWithPipeline: {
+      actionLabel: 'Mit Pipeline ausführen',
+      noPermission: 'Du hast keine Berechtigung, Pipelines auszuführen',
+      modalTitle: 'Mit Pipeline ausführen',
+      confirmTitle: 'Lauf bestätigen',
+      confirmIntro: 'Pre-Flight hat Probleme mit diesem Lauf gefunden',
+      sourceHint: 'Quelle',
+      pipelineHint: 'Pipeline',
+      targetHint: 'Ziel',
+      pipelineListAria: 'Verfügbare Pipelines',
+      runAnyway: 'Trotzdem ausführen',
+      loading: 'Wird geladen…',
+      empty: 'Noch keine Pipelines gespeichert. Öffne den Slice-Dialog und klicke „Als Pipeline speichern“, um eine zu erstellen.',
+      noTarget: 'Kein Zieldrucker festgelegt',
+      noTargetMessage: 'Diese Pipeline hat keinen Zieldrucker. Öffne sie in den Einstellungen, um einen festzulegen.',
+      copies: 'Kopien',
+      copiesHint: 'max. {{n}}',
+      classTarget: 'Beliebiger {{model}}',
+      toast: {
+        started: 'Pipeline-Lauf gestartet',
+        failed: 'Lauf konnte nicht gestartet werden',
+      },
+      issue: {
+        printerNotSet: 'Kein Zieldrucker für diese Pipeline festgelegt.',
+        printerNotFound: 'Zieldrucker existiert nicht mehr.',
+        printerDisabled: 'Zieldrucker ist deaktiviert.',
+        printerOffline: 'Zieldrucker ist offline.',
+        filamentType: 'Filament-Slot {{slot}}: erwartet {{expected}}, AMS hat {{actual}}',
+        filamentColor: 'Filament-Slot {{slot}}: Farbe weicht ab (erwartet {{expected}}, AMS hat {{actual}})',
+        amsSlotMissing: 'AMS-Slot {{slot}} ist auf diesem Drucker nicht verfügbar',
+        filamentUnverified: 'Filament-Slot {{slot}} stammt aus einem Cloud-/Standard-Preset und konnte nicht statisch verifiziert werden.',
+        noClassMatches: 'Keine Drucker in dieser Installation entsprechen der Zielmodellklasse der Pipeline ({{expected}}).',
+        classNotSet: 'Pipeline-Ziel ist auf eine Druckerklasse gesetzt, aber kein Modell wurde gewählt.',
+      },
+    },
   },
   },
 
 
   // Slice (slicer-API integration via SliceModal)
   // Slice (slicer-API integration via SliceModal)
@@ -3823,6 +4022,22 @@ export default {
       texturedPEI: 'Textured PEI Plate',
       texturedPEI: 'Textured PEI Plate',
       smoothPEI: 'Smooth PEI Plate',
       smoothPEI: 'Smooth PEI Plate',
     },
     },
+    // Slicer Pipelines (#1425) — apply a saved bundle or save the current pick.
+    pipelines: {
+      label: 'Pipeline',
+      applyAria: 'Pipeline anwenden',
+      applyPrompt: 'Pipeline anwenden…',
+      empty: 'Keine gespeicherten Pipelines',
+      saveButton: 'Als Pipeline speichern',
+      saveTitle: 'Aktuelle Auswahl aller vier Slots als wiederverwendbare Pipeline speichern',
+      namePlaceholder: 'Pipeline-Name',
+      nameAria: 'Neuer Pipeline-Name',
+      toast: {
+        applied: '„{{name}}“ angewendet',
+        saved: 'Pipeline gespeichert',
+        saveFailed: 'Speichern fehlgeschlagen',
+      },
+    },
   },
   },
 
 
   // Spoolman
   // Spoolman

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

@@ -101,6 +101,8 @@ export default {
     now: 'Now',
     now: 'Now',
     collapse: 'Collapse',
     collapse: 'Collapse',
     expand: 'Expand',
     expand: 'Expand',
+    previous: 'Previous',
+    next: 'Next',
     viewArchive: 'View archive',
     viewArchive: 'View archive',
     viewInFileManager: 'View in File Manager',
     viewInFileManager: 'View in File Manager',
     addedBy: 'Added by {{username}}',
     addedBy: 'Added by {{username}}',
@@ -1027,6 +1029,80 @@ export default {
     },
     },
   },
   },
 
 
+  // Sticky upload-progress toast (#1625 follow-up — restored the
+  // legacy bg-dispatch toast UI for the scheduler-driven dispatch path).
+  // Strings mirror 0b43ac0d's backgroundDispatch namespace.
+  dispatchToast: {
+    untitled: 'Print job',
+    startingPrints: 'Starting prints',
+    progressSummary: '{{complete}}/{{total}} complete • Processing: {{processing}}',
+    expandDetails: 'Expand dispatch details',
+    collapseDetails: 'Collapse dispatch details',
+    awaitingPrinter: 'Awaiting printer…',
+    status: {
+      processing: 'Processing',
+      completed: 'Completed',
+      failed: 'Failed',
+    },
+    failed: {
+      generic: 'Dispatch failed',
+      upload_failed: 'Upload to printer failed',
+      start_command_failed: 'Printer rejected start command',
+    },
+    dismiss: 'Dismiss',
+  },
+
+  // Pipeline Runs dashboard (#1425 PR C). Lists every Slicer Pipeline run
+  // across every pipeline with status + pipeline filters and pagination.
+  // Each row expands to per-copy job status; partial-failure runs get a
+  // Retry-failed button; in-flight runs get a Cancel button.
+  pipelineRuns: {
+    title: 'Pipeline Runs',
+    loading: 'Loading…',
+    empty: 'No pipeline runs yet.',
+    filter: {
+      pipeline: 'Pipeline',
+      status: 'Status',
+      target: 'Target',
+      all: 'All',
+      allPipelines: 'All pipelines',
+      allStatus: 'All statuses',
+      allTargets: 'All targets',
+      clear: 'Clear filters',
+      noMatches: 'No runs match the current filters.',
+    },
+    totalCount_one: '{{n}} run',
+    totalCount_other: '{{n}} runs',
+    copies: '{{n}} copies',
+    failedCount: '{{n}} failed',
+    copyN: 'Copy {{n}}',
+    retryFailed: 'Retry failed',
+    retryOf: 'retry of #{{n}}',
+    pagination: '{{start}}–{{end}} of {{total}}',
+    cancelledByUser: 'Cancelled by user',
+    toast: {
+      cancelled: 'Run cancelled',
+      cancelFailed: 'Cancel failed',
+      retryStarted: 'Retry started',
+      retryFailed: 'Retry failed',
+      cleared: '{{n}} runs cleared',
+      clearFailed: 'Clear failed',
+    },
+    clearLog: 'Clear log',
+    clearConfirmTitle: 'Clear log?',
+    clearConfirmBody: 'Delete every completed, failed, cancelled, and partial-failure pipeline run? In-flight runs are kept. This cannot be undone.',
+    clearConfirmAction: 'Clear',
+    jobStatus: {
+      pending: 'pending',
+      awaiting_printer: 'awaiting printer',
+      queued: 'queued',
+      printing: 'printing',
+      completed: 'completed',
+      failed: 'failed',
+      cancelled: 'cancelled',
+    },
+  },
+
   // Queue page
   // Queue page
   queue: {
   queue: {
     title: 'Print Queue',
     title: 'Print Queue',
@@ -1107,6 +1183,7 @@ export default {
       queue: 'Queue',
       queue: 'Queue',
       history: 'History',
       history: 'History',
       timeline: 'Timeline',
       timeline: 'Timeline',
+      pipelines: 'Pipelines',
     },
     },
     // Layout toggle on the Queue tab — distinct from the sort dropdown
     // Layout toggle on the Queue tab — distinct from the sort dropdown
     // (those control order; these control whether items render as one flat
     // (those control order; these control whether items render as one flat
@@ -1549,6 +1626,8 @@ export default {
       smartPlugs: 'Smart Plugs',
       smartPlugs: 'Smart Plugs',
       notifications: 'Notifications',
       notifications: 'Notifications',
       queue: 'Workflow',
       queue: 'Workflow',
+      queueDispatch: 'Queue & Dispatch',
+      queuePipelines: 'Pipelines',
       filament: 'Filament',
       filament: 'Filament',
       network: 'Network',
       network: 'Network',
       apiKeys: 'API Keys',
       apiKeys: 'API Keys',
@@ -2545,6 +2624,105 @@ export default {
       migrationErrorWarning: '{{count}} legacy row(s) failed to re-encrypt at startup. Check server logs and restart Bambuddy to retry.',
       migrationErrorWarning: '{{count}} legacy row(s) failed to re-encrypt at startup. Check server logs and restart Bambuddy to retry.',
     },
     },
 
 
+    // Slicer Pipeline limits (#1425 PR C). Admin-tunable cap that constrains
+    // the copies input in the Run-with-pipeline modal. Lives on the Workflow
+    // tab's Queue & Dispatch sub-tab.
+    pipelineLimits: {
+      title: 'Slicer Pipeline limits',
+      maxCopiesLabel: 'Max copies per run',
+      maxCopiesDesc: 'Upper bound on the copies operators can request when running a pipeline. Server-side hard cap is 1000.',
+    },
+
+    // Slicer Pipelines (#1425): list/edit/delete preset bundles users saved
+    // from the Slice dialog. Lives in Settings → Workflow → Pipelines sub-tab.
+    pipelines: {
+      title: 'Slicer Pipelines',
+      subtitle: 'Reusable preset bundles (printer + process + filaments + bed type). Save one from the Slice dialog and apply it with a single click on the next file.',
+      loading: 'Loading pipelines…',
+      loadError: 'Could not load pipelines.',
+      confirmDelete: 'Delete this pipeline? This cannot be undone.',
+      staleWarning: 'One or more referenced presets no longer exist. Re-save this pipeline from the Slice dialog to fix.',
+      empty: {
+        title: 'No pipelines yet.',
+        howto: 'Open the Slice dialog for any file, pick your printer / process / filaments / bed type, then click "Save as pipeline". Your saved pipelines will appear here.',
+      },
+      field: {
+        name: 'Pipeline name',
+        description: 'Description',
+        targetPrinter: 'Target printer',
+        noTarget: '— No target —',
+        // PR C
+        targetKind: 'Target type',
+        targetKindSpecific: 'Specific printer',
+        targetKindClass: 'Printer class',
+        targetModelClass: 'Printer model',
+        fanoutStrategy: 'Fanout strategy',
+        fanout: {
+          max_parallel: 'Max parallel — distribute across any idle matching printer',
+          round_robin: 'Round robin — cycle through eligible printers',
+          fill_one_first: 'Fill one first — pin all copies to one printer',
+        },
+        // Short labels for the inline target chip on each pipeline card.
+        // The verbose ones above explain the strategy in the editor; the
+        // card just needs a compact reminder of which one is in use.
+        fanoutShort: {
+          max_parallel: 'parallel',
+          round_robin: 'round robin',
+          fill_one_first: 'fill one first',
+        },
+      },
+      action: {
+        save: 'Save',
+        cancel: 'Cancel',
+        rename: 'Rename',
+        delete: 'Delete',
+      },
+      slot: {
+        printer: 'Printer',
+        process: 'Process',
+        filament: 'Filament',
+        filamentN: 'Filament {{n}}',
+        filamentAll: 'All {{n}} slots',
+        bed: 'Bed',
+      },
+      // PR C polish — grouped sections in the card body, plus the
+      // panel-level search + filter row.
+      group: {
+        profiles: 'Profiles',
+        filaments: 'Filaments',
+      },
+      searchPlaceholder: 'Search pipelines…',
+      filterTargetType: 'Filter by target type',
+      filterTarget: 'Filter by target',
+      filter: {
+        all: 'All targets',
+        noTarget: 'No target set',
+        count: '{{shown}} / {{total}}',
+        noMatches: 'No pipelines match the current filters.',
+      },
+      toast: {
+        saved: 'Pipeline saved',
+        saveFailed: 'Save failed',
+        deleted: 'Pipeline deleted',
+        deleteFailed: 'Delete failed',
+      },
+      // PR B target binding + last-run summary
+      noTargetHint: 'Set a target printer to run this',
+      noTargetWarning: 'Set a target printer before running this pipeline.',
+      runs: {
+        lastRun: 'Last run',
+        status: {
+          queued: 'queued',
+          slicing: 'slicing',
+          dispatching: 'dispatching',
+          in_progress: 'printing',
+          completed: 'completed',
+          failed: 'failed',
+          partial_failure: 'partial failure',
+          cancelled: 'cancelled',
+        },
+      },
+    },
   },
   },
 
 
   // Notifications (for push notifications)
   // Notifications (for push notifications)
@@ -3774,6 +3952,46 @@ export default {
     deleteConfirm: 'Are you sure you want to delete this filament?',
     deleteConfirm: 'Are you sure you want to delete this filament?',
     importFromPrinter: 'Import from Printer',
     importFromPrinter: 'Import from Printer',
     exportToFile: 'Export to File',
     exportToFile: 'Export to File',
+    // Slicer Pipelines run from the File Manager (#1425 PR B). The Run-with-pipeline
+    // modal is a two-step dialog: pick a pipeline, then either fire (eligibility ok)
+    // or confirm-and-fire (eligibility report shown). Lives in components/RunWithPipelineModal.tsx.
+    runWithPipeline: {
+      actionLabel: 'Run with pipeline',
+      noPermission: 'You do not have permission to run pipelines',
+      modalTitle: 'Run with pipeline',
+      confirmTitle: 'Confirm run',
+      confirmIntro: 'Pre-flight found issues with this run',
+      sourceHint: 'Source',
+      pipelineHint: 'Pipeline',
+      targetHint: 'Target',
+      pipelineListAria: 'Available pipelines',
+      runAnyway: 'Run anyway',
+      loading: 'Loading…',
+      empty: 'No pipelines saved yet. Open the Slice dialog and click "Save as pipeline" to create one.',
+      noTarget: 'No target printer set',
+      noTargetMessage: 'This pipeline has no target printer set. Open it in Settings to pick one.',
+      // PR C — copies input + class-targeted pipelines.
+      copies: 'Copies',
+      copiesHint: 'max {{n}}',
+      classTarget: 'Any {{model}}',
+      toast: {
+        started: 'Pipeline run started',
+        failed: 'Could not start run',
+      },
+      issue: {
+        printerNotSet: 'No target printer set on this pipeline.',
+        printerNotFound: 'Target printer no longer exists.',
+        printerDisabled: 'Target printer is disabled.',
+        printerOffline: 'Target printer is offline.',
+        filamentType: 'Filament slot {{slot}}: expected {{expected}}, AMS has {{actual}}',
+        filamentColor: 'Filament slot {{slot}}: colour differs (expected {{expected}}, AMS has {{actual}})',
+        amsSlotMissing: 'AMS slot {{slot}} not available on this printer',
+        filamentUnverified: 'Filament slot {{slot}} comes from a cloud / standard preset and could not be statically verified.',
+        // PR C — class targeting
+        noClassMatches: 'No printers in this install match the pipeline\'s target model class ({{expected}}).',
+        classNotSet: 'Pipeline target is set to a printer class but no model was chosen.',
+      },
+    },
   },
   },
 
 
   // Slice (slicer-API integration via SliceModal)
   // Slice (slicer-API integration via SliceModal)
@@ -3838,6 +4056,22 @@ export default {
       texturedPEI: 'Textured PEI Plate',
       texturedPEI: 'Textured PEI Plate',
       smoothPEI: 'Smooth PEI Plate',
       smoothPEI: 'Smooth PEI Plate',
     },
     },
+    // Slicer Pipelines (#1425) — apply a saved bundle or save the current pick.
+    pipelines: {
+      label: 'Pipeline',
+      applyAria: 'Apply pipeline',
+      applyPrompt: 'Apply pipeline…',
+      empty: 'No saved pipelines',
+      saveButton: 'Save as pipeline',
+      saveTitle: 'Save the current four-slot selection as a reusable pipeline',
+      namePlaceholder: 'Pipeline name',
+      nameAria: 'New pipeline name',
+      toast: {
+        applied: 'Applied "{{name}}"',
+        saved: 'Pipeline saved',
+        saveFailed: 'Save failed',
+      },
+    },
   },
   },
 
 
   // Spoolman
   // Spoolman

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

@@ -101,6 +101,8 @@ export default {
     now: 'Ahora',
     now: 'Ahora',
     collapse: 'Contraer',
     collapse: 'Contraer',
     expand: 'Expandir',
     expand: 'Expandir',
+    previous: 'Anterior',
+    next: 'Siguiente',
     viewArchive: 'Ver archivo',
     viewArchive: 'Ver archivo',
     viewInFileManager: 'Ver en el gestor de archivos',
     viewInFileManager: 'Ver en el gestor de archivos',
     addedBy: 'Añadido por {{username}}',
     addedBy: 'Añadido por {{username}}',
@@ -1023,6 +1025,75 @@ export default {
     },
     },
   },
   },
 
 
+  // Sticky upload-progress toast (#1625 follow-up)
+  dispatchToast: {
+    untitled: 'Trabajo de impresión',
+    startingPrints: 'Iniciando impresiones',
+    progressSummary: '{{complete}}/{{total}} listas • Procesando: {{processing}}',
+    expandDetails: 'Expandir detalles del despacho',
+    collapseDetails: 'Contraer detalles del despacho',
+    awaitingPrinter: 'Esperando a la impresora…',
+    status: {
+      processing: 'Procesando',
+      completed: 'Completada',
+      failed: 'Fallida',
+    },
+    failed: {
+      generic: 'Despacho fallido',
+      upload_failed: 'Fallo al subir a la impresora',
+      start_command_failed: 'La impresora rechazó el comando de inicio',
+    },
+    dismiss: 'Cerrar',
+  },
+
+  // Pipeline Runs dashboard (#1425 PR C).
+  pipelineRuns: {
+    title: 'Ejecuciones de pipeline',
+    loading: 'Cargando…',
+    empty: 'Aún no hay ejecuciones de pipeline.',
+    filter: {
+      pipeline: 'Pipeline',
+      status: 'Estado',
+      target: 'Destino',
+      all: 'Todas',
+      allPipelines: 'Todas las pipelines',
+      allStatus: 'Todos los estados',
+      allTargets: 'Todos los destinos',
+      clear: 'Limpiar filtros',
+      noMatches: 'Ninguna ejecución coincide con los filtros actuales.',
+    },
+    totalCount_one: '{{n}} ejecución',
+    totalCount_other: '{{n}} ejecuciones',
+    copies: '{{n}} copias',
+    failedCount: '{{n}} fallidas',
+    copyN: 'Copia {{n}}',
+    retryFailed: 'Reintentar fallidas',
+    retryOf: 'reintento de #{{n}}',
+    pagination: '{{start}}–{{end}} de {{total}}',
+    toast: {
+      cancelled: 'Ejecución cancelada',
+      cancelFailed: 'Cancelación fallida',
+      retryStarted: 'Reintento iniciado',
+      retryFailed: 'Reintento fallido',
+      cleared: '{{n}} ejecuciones eliminadas',
+      clearFailed: 'Error al borrar',
+    },
+    clearLog: 'Borrar historial',
+    clearConfirmTitle: '¿Borrar historial?',
+    clearConfirmBody: '¿Eliminar todas las ejecuciones de pipeline completadas, fallidas, canceladas y con fallos parciales? Las ejecuciones en curso se conservan. Esto no se puede deshacer.',
+    clearConfirmAction: 'Borrar',
+    jobStatus: {
+      pending: 'pendiente',
+      awaiting_printer: 'esperando impresora',
+      queued: 'en cola',
+      printing: 'imprimiendo',
+      completed: 'completada',
+      failed: 'fallida',
+      cancelled: 'cancelada',
+    },
+    cancelledByUser: 'Cancelado por el usuario',
+  },
+
   // Queue page
   // Queue page
   queue: {
   queue: {
     filamentShort: {
     filamentShort: {
@@ -1102,6 +1173,7 @@ export default {
       queue: 'Cola',
       queue: 'Cola',
       history: 'Historial',
       history: 'Historial',
       timeline: 'Cronología',
       timeline: 'Cronología',
+      pipelines: 'Procesos',
     },
     },
     layout: {
     layout: {
       flatList: 'Lista',
       flatList: 'Lista',
@@ -1538,6 +1610,8 @@ export default {
       smartPlugs: 'Enchufes inteligentes',
       smartPlugs: 'Enchufes inteligentes',
       notifications: 'Notificaciones',
       notifications: 'Notificaciones',
       queue: 'Flujo de trabajo',
       queue: 'Flujo de trabajo',
+      queueDispatch: 'Cola y Despacho',
+      queuePipelines: 'Pipelines',
       filament: 'Filamento',
       filament: 'Filamento',
       network: 'Red',
       network: 'Red',
       apiKeys: 'Claves API',
       apiKeys: 'Claves API',
@@ -2533,6 +2607,96 @@ export default {
       migrationErrorWarning: '{{count}} fila(s) heredada(s) no se pudieron volver a cifrar al iniciar. Revise los registros del servidor y reinicie Bambuddy para reintentarlo.',
       migrationErrorWarning: '{{count}} fila(s) heredada(s) no se pudieron volver a cifrar al iniciar. Revise los registros del servidor y reinicie Bambuddy para reintentarlo.',
     },
     },
 
 
+
+    pipelineLimits: {
+      title: 'Límites de pipelines del cortador',
+      maxCopiesLabel: 'Copias máximas por ejecución',
+      maxCopiesDesc: 'Límite superior de copias que los operadores pueden solicitar al ejecutar una pipeline. El límite máximo del servidor es 1000.',
+    },
+
+    // Slicer Pipelines (#1425): list/edit/delete preset bundles users saved
+    // from the Slice dialog. Lives in Settings → Workflow → Pipelines sub-tab.
+    pipelines: {
+      title: 'Pipelines del Cortador',
+      subtitle: 'Paquetes reutilizables de preajustes (impresora + proceso + filamentos + tipo de placa). Guarda uno desde el diálogo Cortar y aplícalo con un clic al siguiente archivo.',
+      loading: 'Cargando pipelines…',
+      loadError: 'No se pudieron cargar las pipelines.',
+      confirmDelete: '¿Eliminar esta pipeline? Esto no se puede deshacer.',
+      staleWarning: 'Uno o más preajustes referenciados ya no existen. Vuelve a guardar esta pipeline desde el diálogo Cortar para corregirla.',
+      empty: {
+        title: 'Aún no hay pipelines.',
+        howto: 'Abre el diálogo Cortar para cualquier archivo, elige impresora / proceso / filamentos / placa, y haz clic en "Guardar como pipeline". Tus pipelines guardadas aparecerán aquí.',
+      },
+      field: {
+        name: 'Nombre de la pipeline',
+        description: 'Descripción',
+        targetPrinter: 'Impresora de destino',
+        noTarget: '— Sin destino —',
+        targetKind: 'Tipo de destino',
+        targetKindSpecific: 'Impresora específica',
+        targetKindClass: 'Clase de impresora',
+        targetModelClass: 'Modelo de impresora',
+        fanoutStrategy: 'Estrategia de distribución',
+        fanout: {
+          max_parallel: 'Máximo paralelo — distribuir entre cualquier impresora libre coincidente',
+          round_robin: 'Round robin — alternar entre impresoras elegibles',
+          fill_one_first: 'Llenar una primero — fijar todas las copias a una impresora',
+        },
+        fanoutShort: {
+          max_parallel: 'paralelo',
+          round_robin: 'round robin',
+          fill_one_first: 'primero uno',
+        },
+      },
+      action: {
+        save: 'Guardar',
+        cancel: 'Cancelar',
+        rename: 'Renombrar',
+        delete: 'Eliminar',
+      },
+      slot: {
+        printer: 'Impresora',
+        process: 'Proceso',
+        filament: 'Filamento',
+        filamentN: 'Filamento {{n}}',
+        filamentAll: 'Todos los {{n}} slots',
+        bed: 'Placa',
+      },
+      group: {
+        profiles: 'Perfiles',
+        filaments: 'Filamentos',
+      },
+      searchPlaceholder: 'Buscar pipelines…',
+      filterTargetType: 'Filtrar por tipo de destino',
+      filterTarget: 'Filtrar por destino',
+      filter: {
+        all: 'Todos los destinos',
+        noTarget: 'Sin destino',
+        count: '{{shown}} / {{total}}',
+        noMatches: 'Ninguna pipeline coincide con los filtros actuales.',
+      },
+      toast: {
+        saved: 'Pipeline guardada',
+        saveFailed: 'Error al guardar',
+        deleted: 'Pipeline eliminada',
+        deleteFailed: 'Error al eliminar',
+      },
+      noTargetHint: 'Establece una impresora de destino para ejecutar',
+      noTargetWarning: 'Establece una impresora de destino antes de ejecutar esta pipeline.',
+      runs: {
+        lastRun: 'Última ejecución',
+        status: {
+          queued: 'en cola',
+          slicing: 'cortando',
+          dispatching: 'enviando',
+          in_progress: 'imprimiendo',
+          completed: 'completada',
+          failed: 'fallida',
+          partial_failure: 'fallo parcial',
+          cancelled: 'cancelada',
+        },
+      },
+    },
   },
   },
 
 
   // Notifications (for push notifications)
   // Notifications (for push notifications)
@@ -3762,6 +3926,41 @@ export default {
     deleteConfirm: '¿Está seguro de que desea eliminar este filamento?',
     deleteConfirm: '¿Está seguro de que desea eliminar este filamento?',
     importFromPrinter: 'Importar desde la impresora',
     importFromPrinter: 'Importar desde la impresora',
     exportToFile: 'Exportar a un archivo',
     exportToFile: 'Exportar a un archivo',
+    runWithPipeline: {
+      actionLabel: 'Ejecutar con pipeline',
+      noPermission: 'No tienes permiso para ejecutar pipelines',
+      modalTitle: 'Ejecutar con pipeline',
+      confirmTitle: 'Confirmar ejecución',
+      confirmIntro: 'El pre-vuelo encontró problemas con esta ejecución',
+      sourceHint: 'Origen',
+      pipelineHint: 'Pipeline',
+      targetHint: 'Destino',
+      pipelineListAria: 'Pipelines disponibles',
+      runAnyway: 'Ejecutar de todos modos',
+      loading: 'Cargando…',
+      empty: 'Aún no hay pipelines guardadas. Abre el diálogo Cortar y haz clic en "Guardar como pipeline" para crear una.',
+      noTarget: 'Sin impresora de destino',
+      noTargetMessage: 'Esta pipeline no tiene impresora de destino. Ábrela en Ajustes para elegir una.',
+      copies: 'Copias',
+      copiesHint: 'máx {{n}}',
+      classTarget: 'Cualquier {{model}}',
+      toast: {
+        started: 'Ejecución de pipeline iniciada',
+        failed: 'No se pudo iniciar la ejecución',
+      },
+      issue: {
+        printerNotSet: 'Sin impresora de destino en esta pipeline.',
+        printerNotFound: 'La impresora de destino ya no existe.',
+        printerDisabled: 'La impresora de destino está deshabilitada.',
+        printerOffline: 'La impresora de destino está desconectada.',
+        filamentType: 'Slot de filamento {{slot}}: esperado {{expected}}, AMS tiene {{actual}}',
+        filamentColor: 'Slot de filamento {{slot}}: color distinto (esperado {{expected}}, AMS tiene {{actual}})',
+        amsSlotMissing: 'Slot AMS {{slot}} no disponible en esta impresora',
+        filamentUnverified: 'Slot de filamento {{slot}} proviene de un preset de nube / estándar y no se pudo verificar estáticamente.',
+        noClassMatches: 'Ninguna impresora en esta instalación coincide con la clase de modelo objetivo de la pipeline ({{expected}}).',
+        classNotSet: 'El destino de la pipeline es una clase de impresora pero no se eligió ningún modelo.',
+      },
+    },
   },
   },
 
 
   // Slice (slicer-API integration via SliceModal)
   // Slice (slicer-API integration via SliceModal)
@@ -3826,6 +4025,22 @@ export default {
       texturedPEI: 'Textured PEI Plate',
       texturedPEI: 'Textured PEI Plate',
       smoothPEI: 'Smooth PEI Plate',
       smoothPEI: 'Smooth PEI Plate',
     },
     },
+    // Slicer Pipelines (#1425) — apply a saved bundle or save the current pick.
+    pipelines: {
+      label: 'Pipeline',
+      applyAria: 'Aplicar pipeline',
+      applyPrompt: 'Aplicar pipeline…',
+      empty: 'Sin pipelines guardadas',
+      saveButton: 'Guardar como pipeline',
+      saveTitle: 'Guardar la selección actual de los cuatro ajustes como pipeline reutilizable',
+      namePlaceholder: 'Nombre de la pipeline',
+      nameAria: 'Nuevo nombre de pipeline',
+      toast: {
+        applied: '"{{name}}" aplicada',
+        saved: 'Pipeline guardada',
+        saveFailed: 'Error al guardar',
+      },
+    },
   },
   },
 
 
   // Spoolman
   // Spoolman

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

@@ -101,6 +101,8 @@ export default {
     now: 'Maintenant',
     now: 'Maintenant',
     collapse: 'Réduire',
     collapse: 'Réduire',
     expand: 'Développer',
     expand: 'Développer',
+    previous: 'Précédent',
+    next: 'Suivant',
     viewArchive: 'Voir l\'archive',
     viewArchive: 'Voir l\'archive',
     viewInFileManager: 'Voir dans le gestionnaire de fichiers',
     viewInFileManager: 'Voir dans le gestionnaire de fichiers',
     addedBy: 'Ajouté par {{username}}',
     addedBy: 'Ajouté par {{username}}',
@@ -1023,6 +1025,75 @@ export default {
     },
     },
   },
   },
 
 
+  // Sticky upload-progress toast (#1625 follow-up)
+  dispatchToast: {
+    untitled: 'Tâche d\'impression',
+    startingPrints: 'Démarrage des impressions',
+    progressSummary: '{{complete}}/{{total}} terminées • En cours : {{processing}}',
+    expandDetails: 'Afficher les détails de l\'envoi',
+    collapseDetails: 'Masquer les détails de l\'envoi',
+    awaitingPrinter: 'En attente de l\'imprimante…',
+    status: {
+      processing: 'En cours',
+      completed: 'Terminée',
+      failed: 'Échouée',
+    },
+    failed: {
+      generic: 'Échec de l\'envoi',
+      upload_failed: 'Échec du téléversement vers l\'imprimante',
+      start_command_failed: 'L\'imprimante a rejeté la commande de démarrage',
+    },
+    dismiss: 'Fermer',
+  },
+
+  // Pipeline Runs dashboard (#1425 PR C).
+  pipelineRuns: {
+    title: 'Exécutions de pipeline',
+    loading: 'Chargement…',
+    empty: 'Aucune exécution de pipeline pour le moment.',
+    filter: {
+      pipeline: 'Pipeline',
+      status: 'Statut',
+      target: 'Cible',
+      all: 'Toutes',
+      allPipelines: 'Tous les pipelines',
+      allStatus: 'Tous les statuts',
+      allTargets: 'Toutes les cibles',
+      clear: 'Effacer les filtres',
+      noMatches: 'Aucune exécution ne correspond aux filtres actuels.',
+    },
+    totalCount_one: '{{n}} exécution',
+    totalCount_other: '{{n}} exécutions',
+    copies: '{{n}} copies',
+    failedCount: '{{n}} échouées',
+    copyN: 'Copie {{n}}',
+    retryFailed: 'Réessayer les échouées',
+    retryOf: 'réessai de #{{n}}',
+    pagination: '{{start}}–{{end}} sur {{total}}',
+    toast: {
+      cancelled: 'Exécution annulée',
+      cancelFailed: 'Annulation échouée',
+      retryStarted: 'Réessai démarré',
+      retryFailed: 'Réessai échoué',
+      cleared: '{{n}} exécutions effacées',
+      clearFailed: 'Échec de l\'effacement',
+    },
+    clearLog: 'Effacer le journal',
+    clearConfirmTitle: 'Effacer le journal ?',
+    clearConfirmBody: 'Supprimer toutes les exécutions de pipeline terminées, échouées, annulées et en échec partiel ? Les exécutions en cours sont conservées. Ceci ne peut pas être annulé.',
+    clearConfirmAction: 'Effacer',
+    jobStatus: {
+      pending: 'en attente',
+      awaiting_printer: 'attente imprimante',
+      queued: 'en file',
+      printing: 'impression',
+      completed: 'terminée',
+      failed: 'échouée',
+      cancelled: 'annulée',
+    },
+    cancelledByUser: 'Annulé par l\'utilisateur',
+  },
+
   // Queue page
   // Queue page
   queue: {
   queue: {
     filamentShort: {
     filamentShort: {
@@ -1102,6 +1173,7 @@ export default {
       queue: 'File',
       queue: 'File',
       history: 'Historique',
       history: 'Historique',
       timeline: 'Chronologie',
       timeline: 'Chronologie',
+      pipelines: 'Exécutions',
     },
     },
     layout: {
     layout: {
       flatList: 'Liste',
       flatList: 'Liste',
@@ -1537,6 +1609,8 @@ export default {
       smartPlugs: 'Prises connectées',
       smartPlugs: 'Prises connectées',
       notifications: 'Notifications',
       notifications: 'Notifications',
       queue: 'Flux de travail',
       queue: 'Flux de travail',
+      queueDispatch: 'File & Distribution',
+      queuePipelines: 'Pipelines',
       filament: 'Filament',
       filament: 'Filament',
       network: 'Réseau',
       network: 'Réseau',
       apiKeys: 'Clés API',
       apiKeys: 'Clés API',
@@ -2519,6 +2593,96 @@ export default {
       commandQueued: 'Commande en file d\'attente',
       commandQueued: 'Commande en file d\'attente',
       commandError: 'Échec de l\'envoi de la commande',
       commandError: 'Échec de l\'envoi de la commande',
     },
     },
+
+    pipelineLimits: {
+      title: 'Limites des pipelines du trancheur',
+      maxCopiesLabel: 'Copies maximales par exécution',
+      maxCopiesDesc: 'Limite supérieure du nombre de copies que les opérateurs peuvent demander lors de l\'exécution d\'un pipeline. La limite stricte côté serveur est 1000.',
+    },
+
+    // Slicer Pipelines (#1425): list/edit/delete preset bundles users saved
+    // from the Slice dialog. Lives in Settings → Workflow → Pipelines sub-tab.
+    pipelines: {
+      title: 'Pipelines du Trancheur',
+      subtitle: 'Lots de préréglages réutilisables (imprimante + processus + filaments + type de plateau). Enregistrez-en un depuis le dialogue Trancher et appliquez-le en un clic au fichier suivant.',
+      loading: 'Chargement des pipelines…',
+      loadError: 'Impossible de charger les pipelines.',
+      confirmDelete: 'Supprimer ce pipeline ? Cela ne peut pas être annulé.',
+      staleWarning: 'Un ou plusieurs préréglages référencés n\'existent plus. Réenregistrez ce pipeline depuis le dialogue Trancher pour le corriger.',
+      empty: {
+        title: 'Aucun pipeline pour le moment.',
+        howto: 'Ouvrez le dialogue Trancher pour n\'importe quel fichier, choisissez imprimante / processus / filaments / plateau, puis cliquez sur « Enregistrer comme pipeline ». Vos pipelines enregistrés apparaîtront ici.',
+      },
+      field: {
+        name: 'Nom du pipeline',
+        description: 'Description',
+        targetPrinter: 'Imprimante cible',
+        noTarget: '— Aucune cible —',
+        targetKind: 'Type de cible',
+        targetKindSpecific: 'Imprimante spécifique',
+        targetKindClass: 'Classe d\'imprimante',
+        targetModelClass: 'Modèle d\'imprimante',
+        fanoutStrategy: 'Stratégie de distribution',
+        fanout: {
+          max_parallel: 'Max parallèle — distribuer sur toute imprimante libre correspondante',
+          round_robin: 'Round robin — alterner entre imprimantes éligibles',
+          fill_one_first: 'Remplir une d\'abord — épingler toutes les copies à une imprimante',
+        },
+        fanoutShort: {
+          max_parallel: 'parallèle',
+          round_robin: 'round robin',
+          fill_one_first: 'remplir une',
+        },
+      },
+      action: {
+        save: 'Enregistrer',
+        cancel: 'Annuler',
+        rename: 'Renommer',
+        delete: 'Supprimer',
+      },
+      slot: {
+        printer: 'Imprimante',
+        process: 'Processus',
+        filament: 'Filament',
+        filamentN: 'Filament {{n}}',
+        filamentAll: 'Tous les {{n}} emplacements',
+        bed: 'Plateau',
+      },
+      group: {
+        profiles: 'Profils',
+        filaments: 'Filaments',
+      },
+      searchPlaceholder: 'Rechercher des pipelines…',
+      filterTargetType: 'Filtrer par type de cible',
+      filterTarget: 'Filtrer par cible',
+      filter: {
+        all: 'Toutes les cibles',
+        noTarget: 'Aucune cible',
+        count: '{{shown}} / {{total}}',
+        noMatches: 'Aucun pipeline ne correspond aux filtres actuels.',
+      },
+      toast: {
+        saved: 'Pipeline enregistré',
+        saveFailed: 'Échec de l\'enregistrement',
+        deleted: 'Pipeline supprimé',
+        deleteFailed: 'Échec de la suppression',
+      },
+      noTargetHint: 'Définissez une imprimante cible pour exécuter',
+      noTargetWarning: 'Définissez une imprimante cible avant d\'exécuter ce pipeline.',
+      runs: {
+        lastRun: 'Dernière exécution',
+        status: {
+          queued: 'en file',
+          slicing: 'tranchage',
+          dispatching: 'envoi',
+          in_progress: 'impression',
+          completed: 'terminé',
+          failed: 'échec',
+          partial_failure: 'échec partiel',
+          cancelled: 'annulé',
+        },
+      },
+    },
   },
   },
 
 
   // Notifications (for push notifications)
   // Notifications (for push notifications)
@@ -3748,6 +3912,41 @@ export default {
     deleteConfirm: 'Supprimer ce filament ?',
     deleteConfirm: 'Supprimer ce filament ?',
     importFromPrinter: 'Importer de l\'imprimante',
     importFromPrinter: 'Importer de l\'imprimante',
     exportToFile: 'Exporter vers fichier',
     exportToFile: 'Exporter vers fichier',
+    runWithPipeline: {
+      actionLabel: 'Exécuter avec un pipeline',
+      noPermission: 'Vous n\'avez pas la permission d\'exécuter des pipelines',
+      modalTitle: 'Exécuter avec un pipeline',
+      confirmTitle: 'Confirmer l\'exécution',
+      confirmIntro: 'Le pré-vol a trouvé des problèmes avec cette exécution',
+      sourceHint: 'Source',
+      pipelineHint: 'Pipeline',
+      targetHint: 'Cible',
+      pipelineListAria: 'Pipelines disponibles',
+      runAnyway: 'Exécuter quand même',
+      loading: 'Chargement…',
+      empty: 'Aucun pipeline enregistré pour le moment. Ouvrez le dialogue Trancher et cliquez sur « Enregistrer comme pipeline » pour en créer un.',
+      noTarget: 'Aucune imprimante cible définie',
+      noTargetMessage: 'Ce pipeline n\'a pas d\'imprimante cible. Ouvrez-le dans les Paramètres pour en choisir une.',
+      copies: 'Copies',
+      copiesHint: 'max {{n}}',
+      classTarget: 'N\'importe quel {{model}}',
+      toast: {
+        started: 'Exécution de pipeline démarrée',
+        failed: 'Impossible de démarrer l\'exécution',
+      },
+      issue: {
+        printerNotSet: 'Aucune imprimante cible définie pour ce pipeline.',
+        printerNotFound: 'L\'imprimante cible n\'existe plus.',
+        printerDisabled: 'L\'imprimante cible est désactivée.',
+        printerOffline: 'L\'imprimante cible est hors ligne.',
+        filamentType: 'Emplacement filament {{slot}} : attendu {{expected}}, AMS a {{actual}}',
+        filamentColor: 'Emplacement filament {{slot}} : couleur différente (attendu {{expected}}, AMS a {{actual}})',
+        amsSlotMissing: 'Emplacement AMS {{slot}} indisponible sur cette imprimante',
+        filamentUnverified: 'L\'emplacement filament {{slot}} provient d\'un préréglage cloud / standard et n\'a pas pu être vérifié statiquement.',
+        noClassMatches: 'Aucune imprimante de cette installation ne correspond à la classe de modèle cible du pipeline ({{expected}}).',
+        classNotSet: 'La cible du pipeline est une classe d\'imprimante mais aucun modèle n\'a été choisi.',
+      },
+    },
   },
   },
 
 
   // Slice (slicer-API integration via SliceModal)
   // Slice (slicer-API integration via SliceModal)
@@ -3812,6 +4011,22 @@ export default {
       texturedPEI: 'Textured PEI Plate',
       texturedPEI: 'Textured PEI Plate',
       smoothPEI: 'Smooth PEI Plate',
       smoothPEI: 'Smooth PEI Plate',
     },
     },
+    // Slicer Pipelines (#1425) — apply a saved bundle or save the current pick.
+    pipelines: {
+      label: 'Pipeline',
+      applyAria: 'Appliquer le pipeline',
+      applyPrompt: 'Appliquer un pipeline…',
+      empty: 'Aucun pipeline enregistré',
+      saveButton: 'Enregistrer comme pipeline',
+      saveTitle: 'Enregistrer la sélection actuelle des quatre emplacements comme pipeline réutilisable',
+      namePlaceholder: 'Nom du pipeline',
+      nameAria: 'Nouveau nom de pipeline',
+      toast: {
+        applied: '« {{name}} » appliqué',
+        saved: 'Pipeline enregistré',
+        saveFailed: 'Échec de l\'enregistrement',
+      },
+    },
   },
   },
 
 
   // Spoolman
   // Spoolman

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

@@ -101,6 +101,8 @@ export default {
     now: 'Ora',
     now: 'Ora',
     collapse: 'Comprimi',
     collapse: 'Comprimi',
     expand: 'Espandi',
     expand: 'Espandi',
+    previous: 'Precedente',
+    next: 'Successivo',
     viewArchive: 'Vedi archivio',
     viewArchive: 'Vedi archivio',
     viewInFileManager: 'Vedi nel Gestore file',
     viewInFileManager: 'Vedi nel Gestore file',
     addedBy: 'Aggiunto da {{username}}',
     addedBy: 'Aggiunto da {{username}}',
@@ -1023,6 +1025,75 @@ export default {
     },
     },
   },
   },
 
 
+  // Sticky upload-progress toast (#1625 follow-up)
+  dispatchToast: {
+    untitled: 'Lavoro di stampa',
+    startingPrints: 'Avvio delle stampe',
+    progressSummary: '{{complete}}/{{total}} completate • In corso: {{processing}}',
+    expandDetails: 'Espandi i dettagli dell\'invio',
+    collapseDetails: 'Comprimi i dettagli dell\'invio',
+    awaitingPrinter: 'In attesa della stampante…',
+    status: {
+      processing: 'In corso',
+      completed: 'Completata',
+      failed: 'Fallita',
+    },
+    failed: {
+      generic: 'Invio fallito',
+      upload_failed: 'Caricamento sulla stampante fallito',
+      start_command_failed: 'La stampante ha rifiutato il comando di avvio',
+    },
+    dismiss: 'Chiudi',
+  },
+
+  // Pipeline Runs dashboard (#1425 PR C).
+  pipelineRuns: {
+    title: 'Esecuzioni pipeline',
+    loading: 'Caricamento…',
+    empty: 'Nessuna esecuzione di pipeline ancora.',
+    filter: {
+      pipeline: 'Pipeline',
+      status: 'Stato',
+      target: 'Destinazione',
+      all: 'Tutte',
+      allPipelines: 'Tutte le pipeline',
+      allStatus: 'Tutti gli stati',
+      allTargets: 'Tutte le destinazioni',
+      clear: 'Pulisci filtri',
+      noMatches: 'Nessuna esecuzione corrisponde ai filtri attuali.',
+    },
+    totalCount_one: '{{n}} esecuzione',
+    totalCount_other: '{{n}} esecuzioni',
+    copies: '{{n}} copie',
+    failedCount: '{{n}} fallite',
+    copyN: 'Copia {{n}}',
+    retryFailed: 'Riprova fallite',
+    retryOf: 'ritentativo di #{{n}}',
+    pagination: '{{start}}–{{end}} di {{total}}',
+    toast: {
+      cancelled: 'Esecuzione annullata',
+      cancelFailed: 'Annullamento fallito',
+      retryStarted: 'Ritentativo avviato',
+      retryFailed: 'Ritentativo fallito',
+      cleared: '{{n}} esecuzioni eliminate',
+      clearFailed: 'Eliminazione fallita',
+    },
+    clearLog: 'Cancella cronologia',
+    clearConfirmTitle: 'Cancellare la cronologia?',
+    clearConfirmBody: 'Eliminare tutte le esecuzioni di pipeline completate, fallite, annullate e con fallimento parziale? Le esecuzioni in corso sono conservate. Questa operazione non può essere annullata.',
+    clearConfirmAction: 'Cancella',
+    jobStatus: {
+      pending: 'in attesa',
+      awaiting_printer: 'in attesa stampante',
+      queued: 'in coda',
+      printing: 'stampa',
+      completed: 'completata',
+      failed: 'fallita',
+      cancelled: 'annullata',
+    },
+    cancelledByUser: 'Annullato dall\'utente',
+  },
+
   // Queue page
   // Queue page
   queue: {
   queue: {
     filamentShort: {
     filamentShort: {
@@ -1102,6 +1173,7 @@ export default {
       queue: 'Coda',
       queue: 'Coda',
       history: 'Cronologia',
       history: 'Cronologia',
       timeline: 'Linea temporale',
       timeline: 'Linea temporale',
+      pipelines: 'Pipeline',
     },
     },
     layout: {
     layout: {
       flatList: 'Elenco',
       flatList: 'Elenco',
@@ -1537,6 +1609,8 @@ export default {
       smartPlugs: 'Prese smart',
       smartPlugs: 'Prese smart',
       notifications: 'Notifiche',
       notifications: 'Notifiche',
       queue: 'Flusso',
       queue: 'Flusso',
+      queueDispatch: 'Coda e Dispatch',
+      queuePipelines: 'Pipeline',
       filament: 'Filamento',
       filament: 'Filamento',
       network: 'Rete',
       network: 'Rete',
       apiKeys: 'Chiavi API',
       apiKeys: 'Chiavi API',
@@ -2518,6 +2592,96 @@ export default {
       commandQueued: 'Comando in coda',
       commandQueued: 'Comando in coda',
       commandError: 'Invio comando non riuscito',
       commandError: 'Invio comando non riuscito',
     },
     },
+
+    pipelineLimits: {
+      title: 'Limiti pipeline dello slicer',
+      maxCopiesLabel: 'Copie massime per esecuzione',
+      maxCopiesDesc: 'Limite superiore alle copie che gli operatori possono richiedere durante l\'esecuzione di una pipeline. Il limite massimo lato server è 1000.',
+    },
+
+    // Slicer Pipelines (#1425): list/edit/delete preset bundles users saved
+    // from the Slice dialog. Lives in Settings → Workflow → Pipelines sub-tab.
+    pipelines: {
+      title: 'Pipeline dello Slicer',
+      subtitle: 'Bundle di preset riutilizzabili (stampante + processo + filamenti + tipo di piatto). Salvane uno dal dialogo Slice e applicalo con un clic al file successivo.',
+      loading: 'Caricamento pipeline…',
+      loadError: 'Impossibile caricare le pipeline.',
+      confirmDelete: 'Eliminare questa pipeline? L\'operazione non può essere annullata.',
+      staleWarning: 'Uno o più preset referenziati non esistono più. Risalva questa pipeline dal dialogo Slice per ripararla.',
+      empty: {
+        title: 'Nessuna pipeline ancora.',
+        howto: 'Apri il dialogo Slice per un file, scegli stampante / processo / filamenti / piatto, poi clicca "Salva come pipeline". Le tue pipeline salvate appariranno qui.',
+      },
+      field: {
+        name: 'Nome pipeline',
+        description: 'Descrizione',
+        targetPrinter: 'Stampante di destinazione',
+        noTarget: '— Nessuna destinazione —',
+        targetKind: 'Tipo destinazione',
+        targetKindSpecific: 'Stampante specifica',
+        targetKindClass: 'Classe stampante',
+        targetModelClass: 'Modello stampante',
+        fanoutStrategy: 'Strategia di distribuzione',
+        fanout: {
+          max_parallel: 'Max parallelo — distribuisci su qualsiasi stampante libera corrispondente',
+          round_robin: 'Round robin — alterna tra stampanti idonee',
+          fill_one_first: 'Riempi una prima — assegna tutte le copie a una stampante',
+        },
+        fanoutShort: {
+          max_parallel: 'parallelo',
+          round_robin: 'round robin',
+          fill_one_first: 'prima una',
+        },
+      },
+      action: {
+        save: 'Salva',
+        cancel: 'Annulla',
+        rename: 'Rinomina',
+        delete: 'Elimina',
+      },
+      slot: {
+        printer: 'Stampante',
+        process: 'Processo',
+        filament: 'Filamento',
+        filamentN: 'Filamento {{n}}',
+        filamentAll: 'Tutti i {{n}} slot',
+        bed: 'Piatto',
+      },
+      group: {
+        profiles: 'Profili',
+        filaments: 'Filamenti',
+      },
+      searchPlaceholder: 'Cerca pipeline…',
+      filterTargetType: 'Filtra per tipo di destinazione',
+      filterTarget: 'Filtra per destinazione',
+      filter: {
+        all: 'Tutte le destinazioni',
+        noTarget: 'Nessuna destinazione',
+        count: '{{shown}} / {{total}}',
+        noMatches: 'Nessuna pipeline corrisponde ai filtri attuali.',
+      },
+      toast: {
+        saved: 'Pipeline salvata',
+        saveFailed: 'Salvataggio non riuscito',
+        deleted: 'Pipeline eliminata',
+        deleteFailed: 'Eliminazione non riuscita',
+      },
+      noTargetHint: 'Imposta una stampante di destinazione per eseguire',
+      noTargetWarning: 'Imposta una stampante di destinazione prima di eseguire questa pipeline.',
+      runs: {
+        lastRun: 'Ultima esecuzione',
+        status: {
+          queued: 'in coda',
+          slicing: 'slicing',
+          dispatching: 'invio',
+          in_progress: 'stampa',
+          completed: 'completata',
+          failed: 'fallita',
+          partial_failure: 'fallimento parziale',
+          cancelled: 'annullata',
+        },
+      },
+    },
   },
   },
 
 
   // Notifications (for push notifications)
   // Notifications (for push notifications)
@@ -3747,6 +3911,41 @@ export default {
     deleteConfirm: 'Sei sicuro di voler eliminare questo filamento?',
     deleteConfirm: 'Sei sicuro di voler eliminare questo filamento?',
     importFromPrinter: 'Importa da stampante',
     importFromPrinter: 'Importa da stampante',
     exportToFile: 'Esporta su file',
     exportToFile: 'Esporta su file',
+    runWithPipeline: {
+      actionLabel: 'Esegui con pipeline',
+      noPermission: 'Non hai il permesso di eseguire pipeline',
+      modalTitle: 'Esegui con pipeline',
+      confirmTitle: 'Conferma esecuzione',
+      confirmIntro: 'Il pre-volo ha trovato problemi con questa esecuzione',
+      sourceHint: 'Sorgente',
+      pipelineHint: 'Pipeline',
+      targetHint: 'Destinazione',
+      pipelineListAria: 'Pipeline disponibili',
+      runAnyway: 'Esegui comunque',
+      loading: 'Caricamento…',
+      empty: 'Nessuna pipeline salvata. Apri il dialogo Slice e fai clic su "Salva come pipeline" per crearne una.',
+      noTarget: 'Nessuna stampante di destinazione',
+      noTargetMessage: 'Questa pipeline non ha una stampante di destinazione. Aprila nelle Impostazioni per sceglierne una.',
+      copies: 'Copie',
+      copiesHint: 'max {{n}}',
+      classTarget: 'Qualsiasi {{model}}',
+      toast: {
+        started: 'Esecuzione della pipeline avviata',
+        failed: 'Impossibile avviare l\'esecuzione',
+      },
+      issue: {
+        printerNotSet: 'Nessuna stampante di destinazione su questa pipeline.',
+        printerNotFound: 'La stampante di destinazione non esiste più.',
+        printerDisabled: 'La stampante di destinazione è disabilitata.',
+        printerOffline: 'La stampante di destinazione è offline.',
+        filamentType: 'Slot filamento {{slot}}: atteso {{expected}}, AMS ha {{actual}}',
+        filamentColor: 'Slot filamento {{slot}}: colore differente (atteso {{expected}}, AMS ha {{actual}})',
+        amsSlotMissing: 'Slot AMS {{slot}} non disponibile su questa stampante',
+        filamentUnverified: 'Lo slot filamento {{slot}} proviene da un preset cloud / standard e non può essere verificato staticamente.',
+        noClassMatches: 'Nessuna stampante in questa installazione corrisponde alla classe di modello target della pipeline ({{expected}}).',
+        classNotSet: 'La destinazione della pipeline è una classe di stampante ma nessun modello è stato scelto.',
+      },
+    },
   },
   },
 
 
   // Slice (slicer-API integration via SliceModal)
   // Slice (slicer-API integration via SliceModal)
@@ -3811,6 +4010,22 @@ export default {
       texturedPEI: 'Textured PEI Plate',
       texturedPEI: 'Textured PEI Plate',
       smoothPEI: 'Smooth PEI Plate',
       smoothPEI: 'Smooth PEI Plate',
     },
     },
+    // Slicer Pipelines (#1425) — apply a saved bundle or save the current pick.
+    pipelines: {
+      label: 'Pipeline',
+      applyAria: 'Applica pipeline',
+      applyPrompt: 'Applica pipeline…',
+      empty: 'Nessuna pipeline salvata',
+      saveButton: 'Salva come pipeline',
+      saveTitle: 'Salva la selezione corrente di tutti e quattro gli slot come pipeline riutilizzabile',
+      namePlaceholder: 'Nome pipeline',
+      nameAria: 'Nome nuova pipeline',
+      toast: {
+        applied: '"{{name}}" applicata',
+        saved: 'Pipeline salvata',
+        saveFailed: 'Salvataggio non riuscito',
+      },
+    },
   },
   },
 
 
   // Spoolman
   // Spoolman

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

@@ -101,6 +101,8 @@ export default {
     now: '今すぐ',
     now: '今すぐ',
     collapse: '折りたたむ',
     collapse: '折りたたむ',
     expand: '展開',
     expand: '展開',
+    previous: '前へ',
+    next: '次へ',
     viewArchive: 'アーカイブを表示',
     viewArchive: 'アーカイブを表示',
     viewInFileManager: 'ファイルマネージャーで表示',
     viewInFileManager: 'ファイルマネージャーで表示',
     addedBy: '{{username}}が追加',
     addedBy: '{{username}}が追加',
@@ -1022,6 +1024,75 @@ export default {
     },
     },
   },
   },
 
 
+  // Sticky upload-progress toast (#1625 follow-up)
+  dispatchToast: {
+    untitled: '印刷ジョブ',
+    startingPrints: '印刷を開始しています',
+    progressSummary: '{{complete}}/{{total}} 完了 • 処理中: {{processing}}',
+    expandDetails: '送信の詳細を表示',
+    collapseDetails: '送信の詳細を非表示',
+    awaitingPrinter: 'プリンターを待機中…',
+    status: {
+      processing: '処理中',
+      completed: '完了',
+      failed: '失敗',
+    },
+    failed: {
+      generic: '送信に失敗しました',
+      upload_failed: 'プリンターへのアップロードに失敗しました',
+      start_command_failed: 'プリンターが開始コマンドを拒否しました',
+    },
+    dismiss: '閉じる',
+  },
+
+  // Pipeline Runs dashboard (#1425 PR C).
+  pipelineRuns: {
+    title: 'パイプライン実行',
+    loading: '読み込み中…',
+    empty: 'パイプラインの実行はまだありません。',
+    filter: {
+      pipeline: 'パイプライン',
+      status: 'ステータス',
+      target: '対象',
+      all: 'すべて',
+      allPipelines: 'すべてのパイプライン',
+      allStatus: 'すべてのステータス',
+      allTargets: 'すべての対象',
+      clear: 'フィルターをクリア',
+      noMatches: '現在のフィルターに一致する実行はありません。',
+    },
+    totalCount_one: '{{n}} 件の実行',
+    totalCount_other: '{{n}} 件の実行',
+    copies: '{{n}} 部',
+    failedCount: '{{n}} 失敗',
+    copyN: 'コピー {{n}}',
+    retryFailed: '失敗を再試行',
+    retryOf: '#{{n}} の再試行',
+    pagination: '{{total}} 件中 {{start}}–{{end}}',
+    toast: {
+      cancelled: '実行をキャンセルしました',
+      cancelFailed: 'キャンセルに失敗しました',
+      retryStarted: '再試行を開始しました',
+      retryFailed: '再試行に失敗しました',
+      cleared: '{{n}} 件の実行を削除しました',
+      clearFailed: '削除に失敗しました',
+    },
+    clearLog: 'ログをクリア',
+    clearConfirmTitle: 'ログをクリアしますか?',
+    clearConfirmBody: '完了、失敗、キャンセル、部分的失敗のすべてのパイプライン実行を削除しますか?実行中のものは残ります。元に戻せません。',
+    clearConfirmAction: 'クリア',
+    jobStatus: {
+      pending: '保留中',
+      awaiting_printer: 'プリンター待機中',
+      queued: '待機中',
+      printing: '印刷中',
+      completed: '完了',
+      failed: '失敗',
+      cancelled: 'キャンセル',
+    },
+    cancelledByUser: 'ユーザーによりキャンセル',
+  },
+
   // Queue page
   // Queue page
   queue: {
   queue: {
     filamentShort: {
     filamentShort: {
@@ -1101,6 +1172,7 @@ export default {
       queue: 'キュー',
       queue: 'キュー',
       history: '履歴',
       history: '履歴',
       timeline: 'タイムライン',
       timeline: 'タイムライン',
+      pipelines: 'パイプライン',
     },
     },
     layout: {
     layout: {
       flatList: 'リスト',
       flatList: 'リスト',
@@ -1536,6 +1608,8 @@ export default {
       smartPlugs: 'スマートプラグ',
       smartPlugs: 'スマートプラグ',
       notifications: '通知',
       notifications: '通知',
       queue: 'ワークフロー',
       queue: 'ワークフロー',
+      queueDispatch: 'キューとディスパッチ',
+      queuePipelines: 'パイプライン',
       filament: 'フィラメント',
       filament: 'フィラメント',
       network: 'ネットワーク',
       network: 'ネットワーク',
       apiKeys: 'APIキー',
       apiKeys: 'APIキー',
@@ -2530,6 +2604,96 @@ export default {
       migrationErrorWarning: '{{count}} 件のレガシー行を起動時に再暗号化できませんでした。サーバーログを確認し、Bambuddy を再起動して再試行してください。',
       migrationErrorWarning: '{{count}} 件のレガシー行を起動時に再暗号化できませんでした。サーバーログを確認し、Bambuddy を再起動して再試行してください。',
     },
     },
 
 
+
+    pipelineLimits: {
+      title: 'スライサーパイプラインの上限',
+      maxCopiesLabel: '実行あたりの最大コピー数',
+      maxCopiesDesc: 'パイプライン実行時にオペレーターが要求できるコピー数の上限。サーバー側の上限は 1000 です。',
+    },
+
+    // Slicer Pipelines (#1425): list/edit/delete preset bundles users saved
+    // from the Slice dialog. Lives in Settings → Workflow → Pipelines sub-tab.
+    pipelines: {
+      title: 'スライサーパイプライン',
+      subtitle: '再利用可能なプリセットバンドル(プリンター + プロセス + フィラメント + ベッドタイプ)。スライスダイアログから保存し、次のファイルにワンクリックで適用できます。',
+      loading: 'パイプラインを読み込み中…',
+      loadError: 'パイプラインを読み込めませんでした。',
+      confirmDelete: 'このパイプラインを削除しますか?元に戻せません。',
+      staleWarning: '参照されているプリセットが見つかりません。スライスダイアログから再保存して修正してください。',
+      empty: {
+        title: 'パイプラインはまだありません。',
+        howto: '任意のファイルでスライスダイアログを開き、プリンター / プロセス / フィラメント / ベッドタイプを選んで「パイプラインとして保存」をクリックしてください。保存したパイプラインはここに表示されます。',
+      },
+      field: {
+        name: 'パイプライン名',
+        description: '説明',
+        targetPrinter: '対象プリンター',
+        noTarget: '— 対象なし —',
+        targetKind: '対象種別',
+        targetKindSpecific: '特定のプリンター',
+        targetKindClass: 'プリンタークラス',
+        targetModelClass: 'プリンターモデル',
+        fanoutStrategy: '分散戦略',
+        fanout: {
+          max_parallel: '最大並列 — 一致する空きプリンターに分散',
+          round_robin: 'ラウンドロビン — 適格なプリンター間で循環',
+          fill_one_first: '1台ずつ埋める — すべてのコピーを1台に固定',
+        },
+        fanoutShort: {
+          max_parallel: '並列',
+          round_robin: 'ラウンドロビン',
+          fill_one_first: '1台ずつ',
+        },
+      },
+      action: {
+        save: '保存',
+        cancel: 'キャンセル',
+        rename: '名前変更',
+        delete: '削除',
+      },
+      slot: {
+        printer: 'プリンター',
+        process: 'プロセス',
+        filament: 'フィラメント',
+        filamentN: 'フィラメント {{n}}',
+        filamentAll: '{{n}} スロットすべて',
+        bed: 'ベッド',
+      },
+      group: {
+        profiles: 'プロファイル',
+        filaments: 'フィラメント',
+      },
+      searchPlaceholder: 'パイプラインを検索…',
+      filterTargetType: '対象種別でフィルター',
+      filterTarget: '対象でフィルター',
+      filter: {
+        all: 'すべての対象',
+        noTarget: '対象未設定',
+        count: '{{shown}} / {{total}}',
+        noMatches: '現在のフィルターに一致するパイプラインはありません。',
+      },
+      toast: {
+        saved: 'パイプラインを保存しました',
+        saveFailed: '保存に失敗しました',
+        deleted: 'パイプラインを削除しました',
+        deleteFailed: '削除に失敗しました',
+      },
+      noTargetHint: '実行するには対象プリンターを設定してください',
+      noTargetWarning: 'このパイプラインを実行する前に対象プリンターを設定してください。',
+      runs: {
+        lastRun: '前回の実行',
+        status: {
+          queued: '待機中',
+          slicing: 'スライス中',
+          dispatching: '送信中',
+          in_progress: '印刷中',
+          completed: '完了',
+          failed: '失敗',
+          partial_failure: '部分的失敗',
+          cancelled: 'キャンセル',
+        },
+      },
+    },
   },
   },
 
 
   // Notifications (for push notifications)
   // Notifications (for push notifications)
@@ -3759,6 +3923,41 @@ export default {
     deleteConfirm: 'このフィラメントを削除しますか?',
     deleteConfirm: 'このフィラメントを削除しますか?',
     importFromPrinter: 'プリンターからインポート',
     importFromPrinter: 'プリンターからインポート',
     exportToFile: 'ファイルにエクスポート',
     exportToFile: 'ファイルにエクスポート',
+    runWithPipeline: {
+      actionLabel: 'パイプラインで実行',
+      noPermission: 'パイプラインを実行する権限がありません',
+      modalTitle: 'パイプラインで実行',
+      confirmTitle: '実行を確認',
+      confirmIntro: 'プリフライトでこの実行に関する問題が見つかりました',
+      sourceHint: 'ソース',
+      pipelineHint: 'パイプライン',
+      targetHint: '対象',
+      pipelineListAria: '利用可能なパイプライン',
+      runAnyway: 'とにかく実行',
+      loading: '読み込み中…',
+      empty: '保存されたパイプラインはまだありません。スライスダイアログを開き「パイプラインとして保存」をクリックして作成してください。',
+      noTarget: '対象プリンターが未設定',
+      noTargetMessage: 'このパイプラインには対象プリンターが設定されていません。設定で開いて選択してください。',
+      copies: 'コピー数',
+      copiesHint: '最大 {{n}}',
+      classTarget: '任意の {{model}}',
+      toast: {
+        started: 'パイプラインの実行を開始しました',
+        failed: '実行を開始できませんでした',
+      },
+      issue: {
+        printerNotSet: 'このパイプラインに対象プリンターが設定されていません。',
+        printerNotFound: '対象プリンターが存在しません。',
+        printerDisabled: '対象プリンターは無効です。',
+        printerOffline: '対象プリンターはオフラインです。',
+        filamentType: 'フィラメントスロット {{slot}}: 期待 {{expected}}、AMS は {{actual}}',
+        filamentColor: 'フィラメントスロット {{slot}}: 色が異なります(期待 {{expected}}、AMS は {{actual}})',
+        amsSlotMissing: 'AMS スロット {{slot}} はこのプリンターで利用できません',
+        filamentUnverified: 'フィラメントスロット {{slot}} はクラウド/標準プリセットからのため、静的に検証できませんでした。',
+        noClassMatches: 'このインストール内に、パイプラインの対象モデルクラス({{expected}})と一致するプリンターがありません。',
+        classNotSet: 'パイプラインの対象がプリンタークラスに設定されていますが、モデルが選択されていません。',
+      },
+    },
   },
   },
 
 
   // Slice (slicer-API integration via SliceModal)
   // Slice (slicer-API integration via SliceModal)
@@ -3823,6 +4022,22 @@ export default {
       texturedPEI: 'Textured PEI Plate',
       texturedPEI: 'Textured PEI Plate',
       smoothPEI: 'Smooth PEI Plate',
       smoothPEI: 'Smooth PEI Plate',
     },
     },
+    // Slicer Pipelines (#1425) — apply a saved bundle or save the current pick.
+    pipelines: {
+      label: 'パイプライン',
+      applyAria: 'パイプラインを適用',
+      applyPrompt: 'パイプラインを適用…',
+      empty: '保存されたパイプラインがありません',
+      saveButton: 'パイプラインとして保存',
+      saveTitle: '現在の4つのスロットの選択を再利用可能なパイプラインとして保存',
+      namePlaceholder: 'パイプライン名',
+      nameAria: '新しいパイプライン名',
+      toast: {
+        applied: '「{{name}}」を適用しました',
+        saved: 'パイプラインを保存しました',
+        saveFailed: '保存に失敗しました',
+      },
+    },
   },
   },
 
 
   // Spoolman
   // Spoolman

+ 216 - 3
frontend/src/i18n/locales/ko.ts

@@ -98,6 +98,8 @@ export default {
     now: '지금',
     now: '지금',
     collapse: '접기',
     collapse: '접기',
     expand: '펼치기',
     expand: '펼치기',
+    previous: '이전',
+    next: '다음',
     viewArchive: '아카이브 보기',
     viewArchive: '아카이브 보기',
     viewInFileManager: '파일 관리자에서 보기',
     viewInFileManager: '파일 관리자에서 보기',
     addedBy: '{{username}}님이 추가함',
     addedBy: '{{username}}님이 추가함',
@@ -979,6 +981,73 @@ export default {
       }
       }
     }
     }
   },
   },
+  dispatchToast: {
+    untitled: '인쇄 작업',
+    startingPrints: '인쇄 시작 중',
+    progressSummary: '{{complete}}/{{total}} 완료 • 처리 중: {{processing}}',
+    expandDetails: '디스패치 세부 정보 펼치기',
+    collapseDetails: '디스패치 세부 정보 접기',
+    awaitingPrinter: '프린터 응답 대기 중…',
+    status: {
+      processing: '처리 중',
+      completed: '완료',
+      failed: '실패',
+    },
+    failed: {
+      generic: '디스패치 실패',
+      upload_failed: '프린터로 업로드 실패',
+      start_command_failed: '프린터가 시작 명령을 거부했습니다',
+    },
+    dismiss: '닫기',
+  },
+  // Pipeline Runs dashboard (#1425 PR C).
+  pipelineRuns: {
+    title: '파이프라인 실행',
+    loading: '불러오는 중…',
+    empty: '아직 파이프라인 실행이 없습니다.',
+    filter: {
+      pipeline: '파이프라인',
+      status: '상태',
+      target: '대상',
+      all: '전체',
+      allPipelines: '모든 파이프라인',
+      allStatus: '모든 상태',
+      allTargets: '모든 대상',
+      clear: '필터 지우기',
+      noMatches: '현재 필터와 일치하는 실행이 없습니다.',
+    },
+    totalCount_one: '{{n}}회 실행',
+    totalCount_other: '{{n}}회 실행',
+    copies: '사본 {{n}}',
+    failedCount: '실패 {{n}}',
+    copyN: '사본 {{n}}',
+    retryFailed: '실패 재시도',
+    retryOf: '#{{n}}의 재시도',
+    pagination: '{{total}} 중 {{start}}–{{end}}',
+    toast: {
+      cancelled: '실행 취소됨',
+      cancelFailed: '취소 실패',
+      retryStarted: '재시도 시작됨',
+      retryFailed: '재시도 실패',
+      cleared: '{{n}}회 실행 삭제됨',
+      clearFailed: '삭제 실패',
+    },
+    clearLog: '로그 지우기',
+    clearConfirmTitle: '로그를 지울까요?',
+    clearConfirmBody: '완료, 실패, 취소, 부분 실패한 모든 파이프라인 실행을 삭제합니까? 진행 중인 실행은 유지됩니다. 되돌릴 수 없습니다.',
+    clearConfirmAction: '지우기',
+    jobStatus: {
+      pending: '대기 중',
+      awaiting_printer: '프린터 대기 중',
+      queued: '대기 중',
+      printing: '인쇄 중',
+      completed: '완료됨',
+      failed: '실패',
+      cancelled: '취소됨',
+    },
+    cancelledByUser: '사용자에 의해 취소됨',
+  },
+
   queue: {
   queue: {
     title: '인쇄 대기열',
     title: '인쇄 대기열',
     subtitle: '인쇄 작업을 예약하고 관리하세요',
     subtitle: '인쇄 작업을 예약하고 관리하세요',
@@ -1046,6 +1115,7 @@ export default {
       queue: '큐',
       queue: '큐',
       history: '기록',
       history: '기록',
       timeline: '타임라인',
       timeline: '타임라인',
+      pipelines: '파이프라인',
     },
     },
     layout: {
     layout: {
       flatList: '목록',
       flatList: '목록',
@@ -1453,6 +1523,8 @@ export default {
       smartPlugs: '스마트 플러그',
       smartPlugs: '스마트 플러그',
       notifications: '알림',
       notifications: '알림',
       queue: '워크플로우',
       queue: '워크플로우',
+      queueDispatch: '큐 및 디스패치',
+      queuePipelines: '파이프라인',
       filament: '필라멘트',
       filament: '필라멘트',
       network: '네트워크',
       network: '네트워크',
       apiKeys: 'API 키',
       apiKeys: 'API 키',
@@ -2387,7 +2459,97 @@ export default {
     updateEnergyCost: '전기 요금 업데이트',
     updateEnergyCost: '전기 요금 업데이트',
     updateEnergyCostDescription: '이 키가 /settings/electricity-price에 새 kWh당 전기 요금을 POST할 수 있도록 허용합니다. Home Assistant 동적 요금 자동화(Tibber, Octopus 등)에 유용합니다. API 키로 쓸 수 있는 유일한 설정 필드입니다.',
     updateEnergyCostDescription: '이 키가 /settings/electricity-price에 새 kWh당 전기 요금을 POST할 수 있도록 허용합니다. Home Assistant 동적 요금 자동화(Tibber, Octopus 등)에 유용합니다. API 키로 쓸 수 있는 유일한 설정 필드입니다.',
     energyCostBadge: '에너지',
     energyCostBadge: '에너지',
-    passwordRequirements: '최소 8자, 대문자, 소문자, 숫자, 특수문자 각 1개 이상 포함'
+    passwordRequirements: '최소 8자, 대문자, 소문자, 숫자, 특수문자 각 1개 이상 포함',
+
+    pipelineLimits: {
+      title: '슬라이서 파이프라인 한도',
+      maxCopiesLabel: '실행당 최대 사본 수',
+      maxCopiesDesc: '운영자가 파이프라인 실행 시 요청할 수 있는 사본 수의 상한. 서버 측 절대 상한은 1000입니다.',
+    },
+
+    // Slicer Pipelines (#1425): list/edit/delete preset bundles users saved
+    // from the Slice dialog. Lives in Settings → Workflow → Pipelines sub-tab.
+    pipelines: {
+      title: '슬라이서 파이프라인',
+      subtitle: '재사용 가능한 프리셋 묶음(프린터 + 프로세스 + 필라멘트 + 베드 유형). 슬라이스 대화상자에서 저장한 후 다음 파일에 한 번의 클릭으로 적용하세요.',
+      loading: '파이프라인 불러오는 중…',
+      loadError: '파이프라인을 불러오지 못했습니다.',
+      confirmDelete: '이 파이프라인을 삭제하시겠습니까? 되돌릴 수 없습니다.',
+      staleWarning: '참조된 프리셋이 더 이상 존재하지 않습니다. 슬라이스 대화상자에서 이 파이프라인을 다시 저장하여 수정하세요.',
+      empty: {
+        title: '아직 파이프라인이 없습니다.',
+        howto: '임의의 파일에서 슬라이스 대화상자를 열고 프린터 / 프로세스 / 필라멘트 / 베드를 선택한 다음 "파이프라인으로 저장"을 클릭하세요. 저장된 파이프라인이 여기에 표시됩니다.',
+      },
+      field: {
+        name: '파이프라인 이름',
+        description: '설명',
+        targetPrinter: '대상 프린터',
+        noTarget: '— 대상 없음 —',
+        targetKind: '대상 유형',
+        targetKindSpecific: '특정 프린터',
+        targetKindClass: '프린터 클래스',
+        targetModelClass: '프린터 모델',
+        fanoutStrategy: '분산 전략',
+        fanout: {
+          max_parallel: '최대 병렬 — 일치하는 유휴 프린터에 분산',
+          round_robin: '라운드 로빈 — 적격 프린터를 순환',
+          fill_one_first: '하나 먼저 채우기 — 모든 사본을 한 프린터에 고정',
+        },
+        fanoutShort: {
+          max_parallel: '병렬',
+          round_robin: '라운드 로빈',
+          fill_one_first: '하나 먼저',
+        },
+      },
+      action: {
+        save: '저장',
+        cancel: '취소',
+        rename: '이름 변경',
+        delete: '삭제',
+      },
+      slot: {
+        printer: '프린터',
+        process: '프로세스',
+        filament: '필라멘트',
+        filamentN: '필라멘트 {{n}}',
+        filamentAll: '전체 {{n}} 슬롯',
+        bed: '베드',
+      },
+      group: {
+        profiles: '프로필',
+        filaments: '필라멘트',
+      },
+      searchPlaceholder: '파이프라인 검색…',
+      filterTargetType: '대상 유형으로 필터',
+      filterTarget: '대상으로 필터',
+      filter: {
+        all: '모든 대상',
+        noTarget: '대상 미설정',
+        count: '{{shown}} / {{total}}',
+        noMatches: '현재 필터와 일치하는 파이프라인이 없습니다.',
+      },
+      toast: {
+        saved: '파이프라인이 저장되었습니다',
+        saveFailed: '저장 실패',
+        deleted: '파이프라인이 삭제되었습니다',
+        deleteFailed: '삭제 실패',
+      },
+      noTargetHint: '실행하려면 대상 프린터를 설정하세요',
+      noTargetWarning: '이 파이프라인을 실행하기 전에 대상 프린터를 설정하세요.',
+      runs: {
+        lastRun: '마지막 실행',
+        status: {
+          queued: '대기 중',
+          slicing: '슬라이싱 중',
+          dispatching: '전송 중',
+          in_progress: '인쇄 중',
+          completed: '완료됨',
+          failed: '실패',
+          partial_failure: '부분 실패',
+          cancelled: '취소됨',
+        },
+      },
+    },
   },
   },
   notification: {
   notification: {
     printStarted: {
     printStarted: {
@@ -3555,7 +3717,42 @@ export default {
     noFilaments: '라이브러리에 필라멘트 없음',
     noFilaments: '라이브러리에 필라멘트 없음',
     deleteConfirm: '이 필라멘트를 삭제하시겠습니까?',
     deleteConfirm: '이 필라멘트를 삭제하시겠습니까?',
     importFromPrinter: '프린터에서 가져오기',
     importFromPrinter: '프린터에서 가져오기',
-    exportToFile: '파일로 내보내기'
+    exportToFile: '파일로 내보내기',
+    runWithPipeline: {
+      actionLabel: '파이프라인으로 실행',
+      noPermission: '파이프라인을 실행할 권한이 없습니다',
+      modalTitle: '파이프라인으로 실행',
+      confirmTitle: '실행 확인',
+      confirmIntro: '사전 점검에서 이 실행과 관련된 문제를 발견했습니다',
+      sourceHint: '소스',
+      pipelineHint: '파이프라인',
+      targetHint: '대상',
+      pipelineListAria: '사용 가능한 파이프라인',
+      runAnyway: '그래도 실행',
+      loading: '불러오는 중…',
+      empty: '저장된 파이프라인이 없습니다. 슬라이스 대화상자를 열고 "파이프라인으로 저장"을 클릭하여 생성하세요.',
+      noTarget: '대상 프린터가 설정되지 않음',
+      noTargetMessage: '이 파이프라인에는 대상 프린터가 없습니다. 설정에서 열어 선택하세요.',
+      copies: '사본 수',
+      copiesHint: '최대 {{n}}',
+      classTarget: '임의의 {{model}}',
+      toast: {
+        started: '파이프라인 실행 시작됨',
+        failed: '실행을 시작할 수 없습니다',
+      },
+      issue: {
+        printerNotSet: '이 파이프라인에 대상 프린터가 설정되어 있지 않습니다.',
+        printerNotFound: '대상 프린터가 더 이상 존재하지 않습니다.',
+        printerDisabled: '대상 프린터가 비활성화되어 있습니다.',
+        printerOffline: '대상 프린터가 오프라인입니다.',
+        filamentType: '필라멘트 슬롯 {{slot}}: 예상 {{expected}}, AMS 실제 {{actual}}',
+        filamentColor: '필라멘트 슬롯 {{slot}}: 색상이 다릅니다 (예상 {{expected}}, AMS {{actual}})',
+        amsSlotMissing: 'AMS 슬롯 {{slot}}이(가) 이 프린터에서 사용 불가',
+        filamentUnverified: '필라멘트 슬롯 {{slot}}은(는) 클라우드/표준 프리셋이며 정적으로 검증할 수 없습니다.',
+        noClassMatches: '이 설치에서 파이프라인의 대상 모델 클래스({{expected}})와 일치하는 프린터가 없습니다.',
+        classNotSet: '파이프라인 대상이 프린터 클래스로 설정되었지만 모델이 선택되지 않았습니다.',
+      },
+    },
   },
   },
   slice: {
   slice: {
     title: '모델 슬라이싱',
     title: '모델 슬라이싱',
@@ -3617,7 +3814,23 @@ export default {
       highTemp: '고온 플레이트',
       highTemp: '고온 플레이트',
       texturedPEI: '텍스처 PEI 플레이트',
       texturedPEI: '텍스처 PEI 플레이트',
       smoothPEI: '매끄러운 PEI 플레이트'
       smoothPEI: '매끄러운 PEI 플레이트'
-    }
+    },
+    // Slicer Pipelines (#1425) — apply a saved bundle or save the current pick.
+    pipelines: {
+      label: '파이프라인',
+      applyAria: '파이프라인 적용',
+      applyPrompt: '파이프라인 적용…',
+      empty: '저장된 파이프라인이 없습니다',
+      saveButton: '파이프라인으로 저장',
+      saveTitle: '현재 네 개 슬롯의 선택을 재사용 가능한 파이프라인으로 저장합니다',
+      namePlaceholder: '파이프라인 이름',
+      nameAria: '새 파이프라인 이름',
+      toast: {
+        applied: '"{{name}}" 적용됨',
+        saved: '파이프라인이 저장되었습니다',
+        saveFailed: '저장 실패',
+      },
+    },
   },
   },
   spoolman: {
   spoolman: {
     title: 'Spoolman 통합',
     title: 'Spoolman 통합',

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

@@ -101,6 +101,8 @@ export default {
     now: 'Agora',
     now: 'Agora',
     collapse: 'Recolher',
     collapse: 'Recolher',
     expand: 'Expandir',
     expand: 'Expandir',
+    previous: 'Anterior',
+    next: 'Próximo',
     viewArchive: 'Ver arquivo',
     viewArchive: 'Ver arquivo',
     viewInFileManager: 'Ver no Gerenciador de Arquivos',
     viewInFileManager: 'Ver no Gerenciador de Arquivos',
     addedBy: 'Adicionado por {{username}}',
     addedBy: 'Adicionado por {{username}}',
@@ -1023,6 +1025,75 @@ export default {
     },
     },
   },
   },
 
 
+  // Sticky upload-progress toast (#1625 follow-up)
+  dispatchToast: {
+    untitled: 'Trabalho de impressão',
+    startingPrints: 'Iniciando impressões',
+    progressSummary: '{{complete}}/{{total}} concluídas • Processando: {{processing}}',
+    expandDetails: 'Expandir detalhes do envio',
+    collapseDetails: 'Recolher detalhes do envio',
+    awaitingPrinter: 'Aguardando impressora…',
+    status: {
+      processing: 'Processando',
+      completed: 'Concluída',
+      failed: 'Falhou',
+    },
+    failed: {
+      generic: 'Falha no envio',
+      upload_failed: 'Falha ao enviar para a impressora',
+      start_command_failed: 'Impressora rejeitou o comando de início',
+    },
+    dismiss: 'Fechar',
+  },
+
+  // Pipeline Runs dashboard (#1425 PR C).
+  pipelineRuns: {
+    title: 'Execuções de pipeline',
+    loading: 'Carregando…',
+    empty: 'Nenhuma execução de pipeline ainda.',
+    filter: {
+      pipeline: 'Pipeline',
+      status: 'Status',
+      target: 'Destino',
+      all: 'Todas',
+      allPipelines: 'Todas as pipelines',
+      allStatus: 'Todos os status',
+      allTargets: 'Todos os destinos',
+      clear: 'Limpar filtros',
+      noMatches: 'Nenhuma execução corresponde aos filtros atuais.',
+    },
+    totalCount_one: '{{n}} execução',
+    totalCount_other: '{{n}} execuções',
+    copies: '{{n}} cópias',
+    failedCount: '{{n}} falharam',
+    copyN: 'Cópia {{n}}',
+    retryFailed: 'Repetir falhas',
+    retryOf: 'nova tentativa de #{{n}}',
+    pagination: '{{start}}–{{end}} de {{total}}',
+    toast: {
+      cancelled: 'Execução cancelada',
+      cancelFailed: 'Cancelamento falhou',
+      retryStarted: 'Nova tentativa iniciada',
+      retryFailed: 'Nova tentativa falhou',
+      cleared: '{{n}} execuções limpas',
+      clearFailed: 'Falha ao limpar',
+    },
+    clearLog: 'Limpar histórico',
+    clearConfirmTitle: 'Limpar histórico?',
+    clearConfirmBody: 'Excluir todas as execuções de pipeline concluídas, falhas, canceladas e com falha parcial? Execuções em andamento são mantidas. Isso não pode ser desfeito.',
+    clearConfirmAction: 'Limpar',
+    jobStatus: {
+      pending: 'pendente',
+      awaiting_printer: 'aguardando impressora',
+      queued: 'na fila',
+      printing: 'imprimindo',
+      completed: 'concluída',
+      failed: 'falhou',
+      cancelled: 'cancelada',
+    },
+    cancelledByUser: 'Cancelado pelo usuário',
+  },
+
   // Queue page
   // Queue page
   queue: {
   queue: {
     filamentShort: {
     filamentShort: {
@@ -1102,6 +1173,7 @@ export default {
       queue: 'Fila',
       queue: 'Fila',
       history: 'Histórico',
       history: 'Histórico',
       timeline: 'Linha do tempo',
       timeline: 'Linha do tempo',
+      pipelines: 'Processos',
     },
     },
     layout: {
     layout: {
       flatList: 'Lista',
       flatList: 'Lista',
@@ -1537,6 +1609,8 @@ export default {
       smartPlugs: 'Tomadas Inteligentes',
       smartPlugs: 'Tomadas Inteligentes',
       notifications: 'Notificações',
       notifications: 'Notificações',
       queue: 'Fluxo',
       queue: 'Fluxo',
+      queueDispatch: 'Fila e Dispatch',
+      queuePipelines: 'Pipelines',
       filament: 'Filamento',
       filament: 'Filamento',
       network: 'Rede',
       network: 'Rede',
       apiKeys: 'Chaves API',
       apiKeys: 'Chaves API',
@@ -2518,6 +2592,96 @@ export default {
       commandQueued: 'Comando enfileirado',
       commandQueued: 'Comando enfileirado',
       commandError: 'Falha ao enviar comando',
       commandError: 'Falha ao enviar comando',
     },
     },
+
+    pipelineLimits: {
+      title: 'Limites de pipelines do slicer',
+      maxCopiesLabel: 'Cópias máximas por execução',
+      maxCopiesDesc: 'Limite superior de cópias que operadores podem solicitar ao executar uma pipeline. O limite rígido no lado do servidor é 1000.',
+    },
+
+    // Slicer Pipelines (#1425): list/edit/delete preset bundles users saved
+    // from the Slice dialog. Lives in Settings → Workflow → Pipelines sub-tab.
+    pipelines: {
+      title: 'Pipelines do Slicer',
+      subtitle: 'Pacotes de predefinições reutilizáveis (impressora + processo + filamentos + tipo de mesa). Salve um a partir do diálogo Cortar e aplique com um clique no próximo arquivo.',
+      loading: 'Carregando pipelines…',
+      loadError: 'Não foi possível carregar as pipelines.',
+      confirmDelete: 'Excluir esta pipeline? Isso não pode ser desfeito.',
+      staleWarning: 'Uma ou mais predefinições referenciadas não existem mais. Salve novamente esta pipeline a partir do diálogo Cortar para corrigir.',
+      empty: {
+        title: 'Ainda não há pipelines.',
+        howto: 'Abra o diálogo Cortar em qualquer arquivo, escolha impressora / processo / filamentos / mesa e clique em "Salvar como pipeline". Suas pipelines salvas aparecerão aqui.',
+      },
+      field: {
+        name: 'Nome da pipeline',
+        description: 'Descrição',
+        targetPrinter: 'Impressora de destino',
+        noTarget: '— Sem destino —',
+        targetKind: 'Tipo de destino',
+        targetKindSpecific: 'Impressora específica',
+        targetKindClass: 'Classe de impressora',
+        targetModelClass: 'Modelo de impressora',
+        fanoutStrategy: 'Estratégia de distribuição',
+        fanout: {
+          max_parallel: 'Máximo paralelo — distribuir em qualquer impressora ociosa compatível',
+          round_robin: 'Round robin — alternar entre impressoras elegíveis',
+          fill_one_first: 'Encher uma primeiro — fixar todas as cópias em uma impressora',
+        },
+        fanoutShort: {
+          max_parallel: 'paralelo',
+          round_robin: 'round robin',
+          fill_one_first: 'primeiro um',
+        },
+      },
+      action: {
+        save: 'Salvar',
+        cancel: 'Cancelar',
+        rename: 'Renomear',
+        delete: 'Excluir',
+      },
+      slot: {
+        printer: 'Impressora',
+        process: 'Processo',
+        filament: 'Filamento',
+        filamentN: 'Filamento {{n}}',
+        filamentAll: 'Todos os {{n}} slots',
+        bed: 'Mesa',
+      },
+      group: {
+        profiles: 'Perfis',
+        filaments: 'Filamentos',
+      },
+      searchPlaceholder: 'Buscar pipelines…',
+      filterTargetType: 'Filtrar por tipo de destino',
+      filterTarget: 'Filtrar por destino',
+      filter: {
+        all: 'Todos os destinos',
+        noTarget: 'Sem destino',
+        count: '{{shown}} / {{total}}',
+        noMatches: 'Nenhuma pipeline corresponde aos filtros atuais.',
+      },
+      toast: {
+        saved: 'Pipeline salva',
+        saveFailed: 'Falha ao salvar',
+        deleted: 'Pipeline excluída',
+        deleteFailed: 'Falha ao excluir',
+      },
+      noTargetHint: 'Defina uma impressora de destino para executar',
+      noTargetWarning: 'Defina uma impressora de destino antes de executar esta pipeline.',
+      runs: {
+        lastRun: 'Última execução',
+        status: {
+          queued: 'na fila',
+          slicing: 'cortando',
+          dispatching: 'enviando',
+          in_progress: 'imprimindo',
+          completed: 'concluída',
+          failed: 'falhou',
+          partial_failure: 'falha parcial',
+          cancelled: 'cancelada',
+        },
+      },
+    },
   },
   },
 
 
   // Notifications (for push notifications)
   // Notifications (for push notifications)
@@ -3747,6 +3911,41 @@ export default {
     deleteConfirm: 'Tem certeza de que deseja excluir este filamento?',
     deleteConfirm: 'Tem certeza de que deseja excluir este filamento?',
     importFromPrinter: 'Importar da Impressora',
     importFromPrinter: 'Importar da Impressora',
     exportToFile: 'Exportar para Arquivo',
     exportToFile: 'Exportar para Arquivo',
+    runWithPipeline: {
+      actionLabel: 'Executar com pipeline',
+      noPermission: 'Você não tem permissão para executar pipelines',
+      modalTitle: 'Executar com pipeline',
+      confirmTitle: 'Confirmar execução',
+      confirmIntro: 'A pré-verificação encontrou problemas com esta execução',
+      sourceHint: 'Origem',
+      pipelineHint: 'Pipeline',
+      targetHint: 'Destino',
+      pipelineListAria: 'Pipelines disponíveis',
+      runAnyway: 'Executar mesmo assim',
+      loading: 'Carregando…',
+      empty: 'Ainda não há pipelines salvas. Abra o diálogo Cortar e clique em "Salvar como pipeline" para criar uma.',
+      noTarget: 'Sem impressora de destino',
+      noTargetMessage: 'Esta pipeline não tem impressora de destino. Abra-a em Configurações para escolher uma.',
+      copies: 'Cópias',
+      copiesHint: 'máx {{n}}',
+      classTarget: 'Qualquer {{model}}',
+      toast: {
+        started: 'Execução da pipeline iniciada',
+        failed: 'Não foi possível iniciar a execução',
+      },
+      issue: {
+        printerNotSet: 'Sem impressora de destino nesta pipeline.',
+        printerNotFound: 'Impressora de destino não existe mais.',
+        printerDisabled: 'Impressora de destino está desativada.',
+        printerOffline: 'Impressora de destino está offline.',
+        filamentType: 'Slot de filamento {{slot}}: esperado {{expected}}, AMS tem {{actual}}',
+        filamentColor: 'Slot de filamento {{slot}}: cor diferente (esperado {{expected}}, AMS tem {{actual}})',
+        amsSlotMissing: 'Slot AMS {{slot}} indisponível nesta impressora',
+        filamentUnverified: 'Slot de filamento {{slot}} vem de uma predefinição da nuvem / padrão e não pôde ser verificado estaticamente.',
+        noClassMatches: 'Nenhuma impressora nesta instalação corresponde à classe de modelo de destino da pipeline ({{expected}}).',
+        classNotSet: 'O destino da pipeline está configurado como classe de impressora, mas nenhum modelo foi escolhido.',
+      },
+    },
   },
   },
 
 
   // Slice (slicer-API integration via SliceModal)
   // Slice (slicer-API integration via SliceModal)
@@ -3811,6 +4010,22 @@ export default {
       texturedPEI: 'Textured PEI Plate',
       texturedPEI: 'Textured PEI Plate',
       smoothPEI: 'Smooth PEI Plate',
       smoothPEI: 'Smooth PEI Plate',
     },
     },
+    // Slicer Pipelines (#1425) — apply a saved bundle or save the current pick.
+    pipelines: {
+      label: 'Pipeline',
+      applyAria: 'Aplicar pipeline',
+      applyPrompt: 'Aplicar pipeline…',
+      empty: 'Nenhuma pipeline salva',
+      saveButton: 'Salvar como pipeline',
+      saveTitle: 'Salvar a seleção atual dos quatro slots como uma pipeline reutilizável',
+      namePlaceholder: 'Nome da pipeline',
+      nameAria: 'Novo nome de pipeline',
+      toast: {
+        applied: '"{{name}}" aplicada',
+        saved: 'Pipeline salva',
+        saveFailed: 'Falha ao salvar',
+      },
+    },
   },
   },
 
 
   // Spoolman
   // Spoolman

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

@@ -101,6 +101,8 @@ export default {
     now: 'Şimdi',
     now: 'Şimdi',
     collapse: 'Daralt',
     collapse: 'Daralt',
     expand: 'Genişlet',
     expand: 'Genişlet',
+    previous: 'Önceki',
+    next: 'Sonraki',
     viewArchive: 'Arşivi gör',
     viewArchive: 'Arşivi gör',
     viewInFileManager: 'Dosya Yöneticisinde gör',
     viewInFileManager: 'Dosya Yöneticisinde gör',
     addedBy: '{{username}} tarafından eklendi',
     addedBy: '{{username}} tarafından eklendi',
@@ -1023,7 +1025,76 @@ export default {
     },
     },
   },
   },
 
 
+  // Sticky upload-progress toast (#1625 follow-up)
+  dispatchToast: {
+    untitled: 'Yazdırma işi',
+    startingPrints: 'Yazdırmalar başlatılıyor',
+    progressSummary: '{{complete}}/{{total}} tamamlandı • İşleniyor: {{processing}}',
+    expandDetails: 'Gönderim ayrıntılarını genişlet',
+    collapseDetails: 'Gönderim ayrıntılarını daralt',
+    awaitingPrinter: 'Yazıcı bekleniyor…',
+    status: {
+      processing: 'İşleniyor',
+      completed: 'Tamamlandı',
+      failed: 'Başarısız',
+    },
+    failed: {
+      generic: 'Gönderim başarısız',
+      upload_failed: 'Yazıcıya yükleme başarısız',
+      start_command_failed: 'Yazıcı başlatma komutunu reddetti',
+    },
+    dismiss: 'Kapat',
+  },
+
   // Kuyruk sayfası
   // Kuyruk sayfası
+  // Pipeline Runs dashboard (#1425 PR C).
+  pipelineRuns: {
+    title: 'Pipeline çalıştırmaları',
+    loading: 'Yükleniyor…',
+    empty: 'Henüz pipeline çalıştırması yok.',
+    filter: {
+      pipeline: 'Pipeline',
+      status: 'Durum',
+      target: 'Hedef',
+      all: 'Tümü',
+      allPipelines: 'Tüm pipeline\'lar',
+      allStatus: 'Tüm durumlar',
+      allTargets: 'Tüm hedefler',
+      clear: 'Filtreleri temizle',
+      noMatches: 'Mevcut filtrelere uyan çalıştırma yok.',
+    },
+    totalCount_one: '{{n}} çalıştırma',
+    totalCount_other: '{{n}} çalıştırma',
+    copies: '{{n}} kopya',
+    failedCount: '{{n}} başarısız',
+    copyN: 'Kopya {{n}}',
+    retryFailed: 'Başarısızları tekrar dene',
+    retryOf: '#{{n}} yeniden denemesi',
+    pagination: '{{total}} içinden {{start}}–{{end}}',
+    toast: {
+      cancelled: 'Çalıştırma iptal edildi',
+      cancelFailed: 'İptal başarısız',
+      retryStarted: 'Yeniden deneme başlatıldı',
+      retryFailed: 'Yeniden deneme başarısız',
+      cleared: '{{n}} çalıştırma temizlendi',
+      clearFailed: 'Temizleme başarısız',
+    },
+    clearLog: 'Geçmişi temizle',
+    clearConfirmTitle: 'Geçmiş temizlensin mi?',
+    clearConfirmBody: 'Tamamlanan, başarısız olan, iptal edilen ve kısmen başarısız tüm pipeline çalıştırmaları silinsin mi? Devam eden çalıştırmalar korunur. Bu geri alınamaz.',
+    clearConfirmAction: 'Temizle',
+    jobStatus: {
+      pending: 'beklemede',
+      awaiting_printer: 'yazıcı bekleniyor',
+      queued: 'kuyrukta',
+      printing: 'yazdırılıyor',
+      completed: 'tamamlandı',
+      failed: 'başarısız',
+      cancelled: 'iptal edildi',
+    },
+    cancelledByUser: 'Kullanıcı tarafından iptal edildi',
+  },
+
   queue: {
   queue: {
     title: 'Baskı Kuyruğu',
     title: 'Baskı Kuyruğu',
     subtitle: 'Baskı işlerinizi zamanlayın ve yönetin',
     subtitle: 'Baskı işlerinizi zamanlayın ve yönetin',
@@ -1102,6 +1173,7 @@ export default {
       queue: 'Kuyruk',
       queue: 'Kuyruk',
       history: 'Geçmiş',
       history: 'Geçmiş',
       timeline: 'Zaman çizelgesi',
       timeline: 'Zaman çizelgesi',
+      pipelines: 'Pipeline\'lar',
     },
     },
     layout: {
     layout: {
       flatList: 'Liste',
       flatList: 'Liste',
@@ -1539,6 +1611,8 @@ export default {
       smartPlugs: 'Akıllı Prizler',
       smartPlugs: 'Akıllı Prizler',
       notifications: 'Bildirimler',
       notifications: 'Bildirimler',
       queue: 'İş Akışı',
       queue: 'İş Akışı',
+      queueDispatch: 'Kuyruk ve Sevkıyat',
+      queuePipelines: 'Pipeline\'lar',
       filament: 'Filament',
       filament: 'Filament',
       network: 'Ağ',
       network: 'Ağ',
       apiKeys: 'API Anahtarları',
       apiKeys: 'API Anahtarları',
@@ -2534,6 +2608,96 @@ export default {
       migrationErrorWarning: 'Başlangıçta {{count}} eski satır yeniden şifrelenemedi. Sunucu günlüklerini kontrol edin ve yeniden denemek için Bambuddy\'yi yeniden başlatın.',
       migrationErrorWarning: 'Başlangıçta {{count}} eski satır yeniden şifrelenemedi. Sunucu günlüklerini kontrol edin ve yeniden denemek için Bambuddy\'yi yeniden başlatın.',
     },
     },
 
 
+
+    pipelineLimits: {
+      title: 'Dilimleyici pipeline limitleri',
+      maxCopiesLabel: 'Çalıştırma başına maksimum kopya',
+      maxCopiesDesc: 'Bir pipeline çalıştırıldığında operatörlerin isteyebileceği kopya sayısı üst sınırı. Sunucu tarafı sert sınır 1000\'dir.',
+    },
+
+    // Slicer Pipelines (#1425): list/edit/delete preset bundles users saved
+    // from the Slice dialog. Lives in Settings → Workflow → Pipelines sub-tab.
+    pipelines: {
+      title: 'Dilimleyici Pipeline\'ları',
+      subtitle: 'Yeniden kullanılabilir ön ayar paketleri (yazıcı + işlem + filamentler + tabla türü). Dilimleme diyalogundan bir tane kaydedin ve bir sonraki dosyaya tek tıkla uygulayın.',
+      loading: 'Pipeline\'lar yükleniyor…',
+      loadError: 'Pipeline\'lar yüklenemedi.',
+      confirmDelete: 'Bu pipeline silinsin mi? Bu işlem geri alınamaz.',
+      staleWarning: 'Atıfta bulunulan bir veya daha fazla ön ayar artık mevcut değil. Dilimleme diyalogundan bu pipeline\'ı yeniden kaydederek düzeltin.',
+      empty: {
+        title: 'Henüz pipeline yok.',
+        howto: 'Herhangi bir dosya için Dilimleme diyalogunu açın, yazıcı / işlem / filamentler / tablayı seçin, sonra "Pipeline olarak kaydet"e tıklayın. Kaydedilen pipeline\'larınız burada görünecek.',
+      },
+      field: {
+        name: 'Pipeline adı',
+        description: 'Açıklama',
+        targetPrinter: 'Hedef yazıcı',
+        noTarget: '— Hedef yok —',
+        targetKind: 'Hedef türü',
+        targetKindSpecific: 'Belirli yazıcı',
+        targetKindClass: 'Yazıcı sınıfı',
+        targetModelClass: 'Yazıcı modeli',
+        fanoutStrategy: 'Dağıtım stratejisi',
+        fanout: {
+          max_parallel: 'Maksimum paralel — eşleşen boş yazıcılara dağıt',
+          round_robin: 'Sırayla — uygun yazıcılar arasında döndür',
+          fill_one_first: 'Önce birini doldur — tüm kopyaları tek yazıcıya sabitle',
+        },
+        fanoutShort: {
+          max_parallel: 'paralel',
+          round_robin: 'sırayla',
+          fill_one_first: 'önce bir',
+        },
+      },
+      action: {
+        save: 'Kaydet',
+        cancel: 'İptal',
+        rename: 'Yeniden adlandır',
+        delete: 'Sil',
+      },
+      slot: {
+        printer: 'Yazıcı',
+        process: 'İşlem',
+        filament: 'Filament',
+        filamentN: 'Filament {{n}}',
+        filamentAll: 'Tüm {{n}} yuva',
+        bed: 'Tabla',
+      },
+      group: {
+        profiles: 'Profiller',
+        filaments: 'Filamentler',
+      },
+      searchPlaceholder: 'Pipeline\'larda ara…',
+      filterTargetType: 'Hedef türüne göre filtrele',
+      filterTarget: 'Hedefe göre filtrele',
+      filter: {
+        all: 'Tüm hedefler',
+        noTarget: 'Hedef belirlenmemiş',
+        count: '{{shown}} / {{total}}',
+        noMatches: 'Mevcut filtrelere uyan pipeline yok.',
+      },
+      toast: {
+        saved: 'Pipeline kaydedildi',
+        saveFailed: 'Kaydetme başarısız',
+        deleted: 'Pipeline silindi',
+        deleteFailed: 'Silme başarısız',
+      },
+      noTargetHint: 'Çalıştırmak için bir hedef yazıcı belirleyin',
+      noTargetWarning: 'Bu pipeline\'ı çalıştırmadan önce bir hedef yazıcı belirleyin.',
+      runs: {
+        lastRun: 'Son çalıştırma',
+        status: {
+          queued: 'kuyrukta',
+          slicing: 'dilimleniyor',
+          dispatching: 'gönderiliyor',
+          in_progress: 'yazdırılıyor',
+          completed: 'tamamlandı',
+          failed: 'başarısız',
+          partial_failure: 'kısmi başarısızlık',
+          cancelled: 'iptal edildi',
+        },
+      },
+    },
   },
   },
 
 
   // Bildirimler (push bildirimleri için)
   // Bildirimler (push bildirimleri için)
@@ -3749,6 +3913,41 @@ export default {
     deleteConfirm: 'Bu filamenti silmek istediğinizden emin misiniz?',
     deleteConfirm: 'Bu filamenti silmek istediğinizden emin misiniz?',
     importFromPrinter: 'Yazıcıdan İçe Aktar',
     importFromPrinter: 'Yazıcıdan İçe Aktar',
     exportToFile: 'Dosyaya Dışa Aktar',
     exportToFile: 'Dosyaya Dışa Aktar',
+    runWithPipeline: {
+      actionLabel: 'Pipeline ile çalıştır',
+      noPermission: 'Pipeline çalıştırma izniniz yok',
+      modalTitle: 'Pipeline ile çalıştır',
+      confirmTitle: 'Çalıştırmayı onayla',
+      confirmIntro: 'Ön kontrol bu çalıştırmayla ilgili sorunlar buldu',
+      sourceHint: 'Kaynak',
+      pipelineHint: 'Pipeline',
+      targetHint: 'Hedef',
+      pipelineListAria: 'Kullanılabilir pipeline\'lar',
+      runAnyway: 'Yine de çalıştır',
+      loading: 'Yükleniyor…',
+      empty: 'Henüz kayıtlı pipeline yok. Dilimleme diyalogunu açın ve oluşturmak için "Pipeline olarak kaydet"e tıklayın.',
+      noTarget: 'Hedef yazıcı belirlenmemiş',
+      noTargetMessage: 'Bu pipeline\'ın hedef yazıcısı yok. Birini seçmek için Ayarlar\'da açın.',
+      copies: 'Kopyalar',
+      copiesHint: 'maks {{n}}',
+      classTarget: 'Herhangi {{model}}',
+      toast: {
+        started: 'Pipeline çalıştırması başladı',
+        failed: 'Çalıştırma başlatılamadı',
+      },
+      issue: {
+        printerNotSet: 'Bu pipeline\'da hedef yazıcı belirlenmemiş.',
+        printerNotFound: 'Hedef yazıcı artık mevcut değil.',
+        printerDisabled: 'Hedef yazıcı devre dışı.',
+        printerOffline: 'Hedef yazıcı çevrimdışı.',
+        filamentType: 'Filament yuvası {{slot}}: beklenen {{expected}}, AMS\'de {{actual}}',
+        filamentColor: 'Filament yuvası {{slot}}: renk farklı (beklenen {{expected}}, AMS\'de {{actual}})',
+        amsSlotMissing: 'AMS yuvası {{slot}} bu yazıcıda yok',
+        filamentUnverified: 'Filament yuvası {{slot}} bulut / standart ön ayardan geldiği için statik olarak doğrulanamadı.',
+        noClassMatches: 'Bu kurulumda pipeline\'ın hedef model sınıfıyla ({{expected}}) eşleşen yazıcı yok.',
+        classNotSet: 'Pipeline hedefi yazıcı sınıfı olarak ayarlanmış ancak model seçilmemiş.',
+      },
+    },
   },
   },
 
 
   // Dilimle (SliceModal ile slicer-API entegrasyonu)
   // Dilimle (SliceModal ile slicer-API entegrasyonu)
@@ -3813,6 +4012,22 @@ export default {
       texturedPEI: 'Dokulu PEI Plakası',
       texturedPEI: 'Dokulu PEI Plakası',
       smoothPEI: 'Düz PEI Plakası',
       smoothPEI: 'Düz PEI Plakası',
     },
     },
+    // Slicer Pipelines (#1425) — apply a saved bundle or save the current pick.
+    pipelines: {
+      label: 'Pipeline',
+      applyAria: 'Pipeline uygula',
+      applyPrompt: 'Pipeline uygula…',
+      empty: 'Kayıtlı pipeline yok',
+      saveButton: 'Pipeline olarak kaydet',
+      saveTitle: 'Dört slotluk mevcut seçimi yeniden kullanılabilir pipeline olarak kaydet',
+      namePlaceholder: 'Pipeline adı',
+      nameAria: 'Yeni pipeline adı',
+      toast: {
+        applied: '"{{name}}" uygulandı',
+        saved: 'Pipeline kaydedildi',
+        saveFailed: 'Kaydetme başarısız',
+      },
+    },
   },
   },
 
 
   // Spoolman
   // Spoolman

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

@@ -101,6 +101,8 @@ export default {
     now: '现在',
     now: '现在',
     collapse: '收起',
     collapse: '收起',
     expand: '展开',
     expand: '展开',
+    previous: '上一页',
+    next: '下一页',
     viewArchive: '查看归档',
     viewArchive: '查看归档',
     viewInFileManager: '在文件管理器中查看',
     viewInFileManager: '在文件管理器中查看',
     addedBy: '由 {{username}} 添加',
     addedBy: '由 {{username}} 添加',
@@ -1023,6 +1025,75 @@ export default {
     },
     },
   },
   },
 
 
+  // Sticky upload-progress toast (#1625 follow-up)
+  dispatchToast: {
+    untitled: '打印任务',
+    startingPrints: '正在开始打印',
+    progressSummary: '{{complete}}/{{total}} 已完成 • 处理中: {{processing}}',
+    expandDetails: '展开派发详情',
+    collapseDetails: '收起派发详情',
+    awaitingPrinter: '等待打印机响应…',
+    status: {
+      processing: '处理中',
+      completed: '已完成',
+      failed: '失败',
+    },
+    failed: {
+      generic: '派发失败',
+      upload_failed: '上传到打印机失败',
+      start_command_failed: '打印机拒绝了开始命令',
+    },
+    dismiss: '关闭',
+  },
+
+  // Pipeline Runs dashboard (#1425 PR C).
+  pipelineRuns: {
+    title: '流水线运行',
+    loading: '加载中…',
+    empty: '暂无流水线运行。',
+    filter: {
+      pipeline: '流水线',
+      status: '状态',
+      target: '目标',
+      all: '全部',
+      allPipelines: '全部流水线',
+      allStatus: '全部状态',
+      allTargets: '全部目标',
+      clear: '清除筛选',
+      noMatches: '没有运行匹配当前筛选条件。',
+    },
+    totalCount_one: '{{n}} 次运行',
+    totalCount_other: '{{n}} 次运行',
+    copies: '{{n}} 份',
+    failedCount: '{{n}} 失败',
+    copyN: '副本 {{n}}',
+    retryFailed: '重试失败的',
+    retryOf: '#{{n}} 的重试',
+    pagination: '{{total}} 中的 {{start}}–{{end}}',
+    toast: {
+      cancelled: '运行已取消',
+      cancelFailed: '取消失败',
+      retryStarted: '已开始重试',
+      retryFailed: '重试失败',
+      cleared: '已清除 {{n}} 次运行',
+      clearFailed: '清除失败',
+    },
+    clearLog: '清除日志',
+    clearConfirmTitle: '清除日志?',
+    clearConfirmBody: '删除所有已完成、失败、已取消和部分失败的流水线运行?进行中的运行将保留。此操作无法撤销。',
+    clearConfirmAction: '清除',
+    jobStatus: {
+      pending: '等待中',
+      awaiting_printer: '等待打印机',
+      queued: '排队中',
+      printing: '打印中',
+      completed: '已完成',
+      failed: '失败',
+      cancelled: '已取消',
+    },
+    cancelledByUser: '用户已取消',
+  },
+
   // Queue page
   // Queue page
   queue: {
   queue: {
     filamentShort: {
     filamentShort: {
@@ -1102,6 +1173,7 @@ export default {
       queue: '队列',
       queue: '队列',
       history: '历史',
       history: '历史',
       timeline: '时间线',
       timeline: '时间线',
+      pipelines: '流水线',
     },
     },
     layout: {
     layout: {
       flatList: '列表',
       flatList: '列表',
@@ -1537,6 +1609,8 @@ export default {
       smartPlugs: '智能插座',
       smartPlugs: '智能插座',
       notifications: '通知',
       notifications: '通知',
       queue: '工作流',
       queue: '工作流',
+      queueDispatch: '队列与调度',
+      queuePipelines: '流水线',
       filament: '耗材',
       filament: '耗材',
       network: '网络',
       network: '网络',
       apiKeys: 'API 密钥',
       apiKeys: 'API 密钥',
@@ -2518,6 +2592,96 @@ export default {
       migrationErrorWarning: '{{count}} 行旧数据在启动时未能重新加密。请检查服务器日志并重启 Bambuddy 以重试。',
       migrationErrorWarning: '{{count}} 行旧数据在启动时未能重新加密。请检查服务器日志并重启 Bambuddy 以重试。',
     },
     },
 
 
+
+    pipelineLimits: {
+      title: '切片机流水线限制',
+      maxCopiesLabel: '每次运行最大副本数',
+      maxCopiesDesc: '操作员运行流水线时可请求的副本数上限。服务器端硬性上限为 1000。',
+    },
+
+    // Slicer Pipelines (#1425): list/edit/delete preset bundles users saved
+    // from the Slice dialog. Lives in Settings → Workflow → Pipelines sub-tab.
+    pipelines: {
+      title: '切片机流水线',
+      subtitle: '可复用的预设捆绑包(打印机 + 工艺 + 耗材 + 热床类型)。在切片对话框中保存一个,下次切片文件时一键应用。',
+      loading: '正在加载流水线…',
+      loadError: '无法加载流水线。',
+      confirmDelete: '删除此流水线?此操作无法撤销。',
+      staleWarning: '引用的一个或多个预设已不存在。请在切片对话框中重新保存此流水线以修复。',
+      empty: {
+        title: '暂无流水线。',
+        howto: '为任意文件打开切片对话框,选择打印机 / 工艺 / 耗材 / 热床,然后点击"另存为流水线"。保存的流水线将显示在此处。',
+      },
+      field: {
+        name: '流水线名称',
+        description: '描述',
+        targetPrinter: '目标打印机',
+        noTarget: '— 无目标 —',
+        targetKind: '目标类型',
+        targetKindSpecific: '特定打印机',
+        targetKindClass: '打印机类别',
+        targetModelClass: '打印机型号',
+        fanoutStrategy: '分发策略',
+        fanout: {
+          max_parallel: '最大并行 — 分发到任何空闲匹配的打印机',
+          round_robin: '轮询 — 在合格打印机间循环',
+          fill_one_first: '先填一台 — 将所有副本固定到一台打印机',
+        },
+        fanoutShort: {
+          max_parallel: '并行',
+          round_robin: '轮询',
+          fill_one_first: '先填一台',
+        },
+      },
+      action: {
+        save: '保存',
+        cancel: '取消',
+        rename: '重命名',
+        delete: '删除',
+      },
+      slot: {
+        printer: '打印机',
+        process: '工艺',
+        filament: '耗材',
+        filamentN: '耗材 {{n}}',
+        filamentAll: '全部 {{n}} 个槽',
+        bed: '热床',
+      },
+      group: {
+        profiles: '配置文件',
+        filaments: '耗材',
+      },
+      searchPlaceholder: '搜索流水线…',
+      filterTargetType: '按目标类型筛选',
+      filterTarget: '按目标筛选',
+      filter: {
+        all: '全部目标',
+        noTarget: '未设置目标',
+        count: '{{shown}} / {{total}}',
+        noMatches: '没有流水线匹配当前筛选条件。',
+      },
+      toast: {
+        saved: '流水线已保存',
+        saveFailed: '保存失败',
+        deleted: '流水线已删除',
+        deleteFailed: '删除失败',
+      },
+      noTargetHint: '设置目标打印机以运行',
+      noTargetWarning: '运行此流水线前请先设置目标打印机。',
+      runs: {
+        lastRun: '上次运行',
+        status: {
+          queued: '排队中',
+          slicing: '切片中',
+          dispatching: '发送中',
+          in_progress: '打印中',
+          completed: '已完成',
+          failed: '失败',
+          partial_failure: '部分失败',
+          cancelled: '已取消',
+        },
+      },
+    },
   },
   },
 
 
   // Notifications (for push notifications)
   // Notifications (for push notifications)
@@ -3747,6 +3911,41 @@ export default {
     deleteConfirm: '确定要删除此耗材吗?',
     deleteConfirm: '确定要删除此耗材吗?',
     importFromPrinter: '从打印机导入',
     importFromPrinter: '从打印机导入',
     exportToFile: '导出到文件',
     exportToFile: '导出到文件',
+    runWithPipeline: {
+      actionLabel: '用流水线运行',
+      noPermission: '您没有运行流水线的权限',
+      modalTitle: '用流水线运行',
+      confirmTitle: '确认运行',
+      confirmIntro: '预检发现此次运行存在问题',
+      sourceHint: '来源',
+      pipelineHint: '流水线',
+      targetHint: '目标',
+      pipelineListAria: '可用流水线',
+      runAnyway: '仍然运行',
+      loading: '加载中…',
+      empty: '尚未保存流水线。请打开切片对话框并点击"另存为流水线"创建一个。',
+      noTarget: '未设置目标打印机',
+      noTargetMessage: '此流水线没有目标打印机。请在设置中打开并选择一个。',
+      copies: '副本数',
+      copiesHint: '最多 {{n}}',
+      classTarget: '任意 {{model}}',
+      toast: {
+        started: '流水线运行已开始',
+        failed: '无法启动运行',
+      },
+      issue: {
+        printerNotSet: '此流水线未设置目标打印机。',
+        printerNotFound: '目标打印机已不存在。',
+        printerDisabled: '目标打印机已禁用。',
+        printerOffline: '目标打印机已离线。',
+        filamentType: '耗材槽 {{slot}}:期望 {{expected}},AMS 实际 {{actual}}',
+        filamentColor: '耗材槽 {{slot}}:颜色不同(期望 {{expected}},AMS 实际 {{actual}})',
+        amsSlotMissing: '此打印机上没有 AMS 槽 {{slot}}',
+        filamentUnverified: '耗材槽 {{slot}} 来自云端 / 标准预设,无法静态验证。',
+        noClassMatches: '此安装中没有匹配该流水线目标型号类别({{expected}})的打印机。',
+        classNotSet: '流水线目标设置为打印机类别但未选择型号。',
+      },
+    },
   },
   },
 
 
   // Slice (slicer-API integration via SliceModal)
   // Slice (slicer-API integration via SliceModal)
@@ -3811,6 +4010,22 @@ export default {
       texturedPEI: 'Textured PEI Plate',
       texturedPEI: 'Textured PEI Plate',
       smoothPEI: 'Smooth PEI Plate',
       smoothPEI: 'Smooth PEI Plate',
     },
     },
+    // Slicer Pipelines (#1425) — apply a saved bundle or save the current pick.
+    pipelines: {
+      label: '流水线',
+      applyAria: '应用流水线',
+      applyPrompt: '应用流水线…',
+      empty: '无已保存流水线',
+      saveButton: '另存为流水线',
+      saveTitle: '将当前四个槽位的选择保存为可复用流水线',
+      namePlaceholder: '流水线名称',
+      nameAria: '新流水线名称',
+      toast: {
+        applied: '已应用 "{{name}}"',
+        saved: '流水线已保存',
+        saveFailed: '保存失败',
+      },
+    },
   },
   },
 
 
   // Spoolman
   // Spoolman

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

@@ -101,6 +101,8 @@ export default {
     now: '現在',
     now: '現在',
     collapse: '收起',
     collapse: '收起',
     expand: '展開',
     expand: '展開',
+    previous: '上一頁',
+    next: '下一頁',
     viewArchive: '檢視歸檔',
     viewArchive: '檢視歸檔',
     viewInFileManager: '在檔案管理器中檢視',
     viewInFileManager: '在檔案管理器中檢視',
     addedBy: '由 {{username}} 新增',
     addedBy: '由 {{username}} 新增',
@@ -1023,6 +1025,75 @@ export default {
     },
     },
   },
   },
 
 
+  // Sticky upload-progress toast (#1625 follow-up)
+  dispatchToast: {
+    untitled: '列印任務',
+    startingPrints: '正在開始列印',
+    progressSummary: '{{complete}}/{{total}} 已完成 • 處理中: {{processing}}',
+    expandDetails: '展開派發詳情',
+    collapseDetails: '收起派發詳情',
+    awaitingPrinter: '等待印表機回應…',
+    status: {
+      processing: '處理中',
+      completed: '已完成',
+      failed: '失敗',
+    },
+    failed: {
+      generic: '派發失敗',
+      upload_failed: '上傳到印表機失敗',
+      start_command_failed: '印表機拒絕了開始命令',
+    },
+    dismiss: '關閉',
+  },
+
+  // Pipeline Runs dashboard (#1425 PR C).
+  pipelineRuns: {
+    title: '管線執行',
+    loading: '載入中…',
+    empty: '尚無管線執行。',
+    filter: {
+      pipeline: '管線',
+      status: '狀態',
+      target: '目標',
+      all: '全部',
+      allPipelines: '全部管線',
+      allStatus: '全部狀態',
+      allTargets: '全部目標',
+      clear: '清除篩選',
+      noMatches: '沒有執行符合目前的篩選條件。',
+    },
+    totalCount_one: '{{n}} 次執行',
+    totalCount_other: '{{n}} 次執行',
+    copies: '{{n}} 份',
+    failedCount: '{{n}} 失敗',
+    copyN: '副本 {{n}}',
+    retryFailed: '重試失敗的',
+    retryOf: '#{{n}} 的重試',
+    pagination: '{{total}} 中的 {{start}}–{{end}}',
+    toast: {
+      cancelled: '執行已取消',
+      cancelFailed: '取消失敗',
+      retryStarted: '已開始重試',
+      retryFailed: '重試失敗',
+      cleared: '已清除 {{n}} 次執行',
+      clearFailed: '清除失敗',
+    },
+    clearLog: '清除日誌',
+    clearConfirmTitle: '清除日誌?',
+    clearConfirmBody: '刪除所有已完成、失敗、已取消和部分失敗的管線執行?進行中的執行將保留。此操作無法復原。',
+    clearConfirmAction: '清除',
+    jobStatus: {
+      pending: '等待中',
+      awaiting_printer: '等待印表機',
+      queued: '排隊中',
+      printing: '列印中',
+      completed: '已完成',
+      failed: '失敗',
+      cancelled: '已取消',
+    },
+    cancelledByUser: '使用者已取消',
+  },
+
   // Queue page
   // Queue page
   queue: {
   queue: {
     filamentShort: {
     filamentShort: {
@@ -1102,6 +1173,7 @@ export default {
       queue: '佇列',
       queue: '佇列',
       history: '歷史',
       history: '歷史',
       timeline: '時間軸',
       timeline: '時間軸',
+      pipelines: '管線',
     },
     },
     layout: {
     layout: {
       flatList: '清單',
       flatList: '清單',
@@ -1537,6 +1609,8 @@ export default {
       smartPlugs: '智慧插座',
       smartPlugs: '智慧插座',
       notifications: '通知',
       notifications: '通知',
       queue: '工作流程',
       queue: '工作流程',
+      queueDispatch: '佇列與分派',
+      queuePipelines: '管線',
       filament: '耗材',
       filament: '耗材',
       network: '網路',
       network: '網路',
       apiKeys: 'API 金鑰',
       apiKeys: 'API 金鑰',
@@ -2518,6 +2592,96 @@ export default {
       migrationErrorWarning: '{{count}} 行舊資料在啟動時未能重新加密。請檢查伺服器日誌並重新啟動 Bambuddy 以重試。',
       migrationErrorWarning: '{{count}} 行舊資料在啟動時未能重新加密。請檢查伺服器日誌並重新啟動 Bambuddy 以重試。',
     },
     },
 
 
+
+    pipelineLimits: {
+      title: '切片機管線限制',
+      maxCopiesLabel: '每次執行最大副本數',
+      maxCopiesDesc: '操作員執行管線時可請求的副本數上限。伺服器端硬性上限為 1000。',
+    },
+
+    // Slicer Pipelines (#1425): list/edit/delete preset bundles users saved
+    // from the Slice dialog. Lives in Settings → Workflow → Pipelines sub-tab.
+    pipelines: {
+      title: '切片機管線',
+      subtitle: '可重複使用的預設組合(印表機 + 製程 + 耗材 + 熱床類型)。在切片對話框中儲存一個,下次切片檔案時一鍵套用。',
+      loading: '正在載入管線…',
+      loadError: '無法載入管線。',
+      confirmDelete: '刪除此管線?此操作無法復原。',
+      staleWarning: '參考的一個或多個預設已不存在。請在切片對話框中重新儲存此管線以修正。',
+      empty: {
+        title: '尚無管線。',
+        howto: '為任意檔案開啟切片對話框,選擇印表機 / 製程 / 耗材 / 熱床,然後點擊「另存為管線」。儲存的管線將顯示於此處。',
+      },
+      field: {
+        name: '管線名稱',
+        description: '描述',
+        targetPrinter: '目標印表機',
+        noTarget: '— 無目標 —',
+        targetKind: '目標類型',
+        targetKindSpecific: '特定印表機',
+        targetKindClass: '印表機類別',
+        targetModelClass: '印表機型號',
+        fanoutStrategy: '分發策略',
+        fanout: {
+          max_parallel: '最大並行 — 分發到任何空閒匹配的印表機',
+          round_robin: '輪替 — 在合格印表機間循環',
+          fill_one_first: '先填一台 — 將所有副本固定到一台印表機',
+        },
+        fanoutShort: {
+          max_parallel: '並行',
+          round_robin: '輪替',
+          fill_one_first: '先填一台',
+        },
+      },
+      action: {
+        save: '儲存',
+        cancel: '取消',
+        rename: '重新命名',
+        delete: '刪除',
+      },
+      slot: {
+        printer: '印表機',
+        process: '製程',
+        filament: '耗材',
+        filamentN: '耗材 {{n}}',
+        filamentAll: '全部 {{n}} 個槽',
+        bed: '熱床',
+      },
+      group: {
+        profiles: '設定檔',
+        filaments: '耗材',
+      },
+      searchPlaceholder: '搜尋管線…',
+      filterTargetType: '依目標類型篩選',
+      filterTarget: '依目標篩選',
+      filter: {
+        all: '全部目標',
+        noTarget: '未設定目標',
+        count: '{{shown}} / {{total}}',
+        noMatches: '沒有管線符合目前的篩選條件。',
+      },
+      toast: {
+        saved: '管線已儲存',
+        saveFailed: '儲存失敗',
+        deleted: '管線已刪除',
+        deleteFailed: '刪除失敗',
+      },
+      noTargetHint: '設定目標印表機以執行',
+      noTargetWarning: '執行此管線前請先設定目標印表機。',
+      runs: {
+        lastRun: '上次執行',
+        status: {
+          queued: '排隊中',
+          slicing: '切片中',
+          dispatching: '傳送中',
+          in_progress: '列印中',
+          completed: '已完成',
+          failed: '失敗',
+          partial_failure: '部分失敗',
+          cancelled: '已取消',
+        },
+      },
+    },
   },
   },
 
 
   // Notifications (for push notifications)
   // Notifications (for push notifications)
@@ -3747,6 +3911,41 @@ export default {
     deleteConfirm: '確定要刪除此耗材嗎?',
     deleteConfirm: '確定要刪除此耗材嗎?',
     importFromPrinter: '從印表機匯入',
     importFromPrinter: '從印表機匯入',
     exportToFile: '匯出到檔案',
     exportToFile: '匯出到檔案',
+    runWithPipeline: {
+      actionLabel: '以管線執行',
+      noPermission: '您沒有執行管線的權限',
+      modalTitle: '以管線執行',
+      confirmTitle: '確認執行',
+      confirmIntro: '預檢發現此次執行存在問題',
+      sourceHint: '來源',
+      pipelineHint: '管線',
+      targetHint: '目標',
+      pipelineListAria: '可用管線',
+      runAnyway: '仍然執行',
+      loading: '載入中…',
+      empty: '尚未儲存管線。請開啟切片對話框並點擊「另存為管線」建立一個。',
+      noTarget: '未設定目標印表機',
+      noTargetMessage: '此管線沒有目標印表機。請在設定中開啟並選擇一個。',
+      copies: '副本數',
+      copiesHint: '最多 {{n}}',
+      classTarget: '任意 {{model}}',
+      toast: {
+        started: '管線執行已開始',
+        failed: '無法啟動執行',
+      },
+      issue: {
+        printerNotSet: '此管線未設定目標印表機。',
+        printerNotFound: '目標印表機已不存在。',
+        printerDisabled: '目標印表機已停用。',
+        printerOffline: '目標印表機已離線。',
+        filamentType: '耗材槽 {{slot}}:預期 {{expected}},AMS 實際 {{actual}}',
+        filamentColor: '耗材槽 {{slot}}:顏色不同(預期 {{expected}},AMS 實際 {{actual}})',
+        amsSlotMissing: '此印表機上沒有 AMS 槽 {{slot}}',
+        filamentUnverified: '耗材槽 {{slot}} 來自雲端 / 標準預設,無法靜態驗證。',
+        noClassMatches: '此安裝中沒有符合該管線目標型號類別({{expected}})的印表機。',
+        classNotSet: '管線目標設定為印表機類別但未選擇型號。',
+      },
+    },
   },
   },
 
 
   // Slice (slicer-API integration via SliceModal)
   // Slice (slicer-API integration via SliceModal)
@@ -3811,6 +4010,22 @@ export default {
       texturedPEI: 'Textured PEI Plate',
       texturedPEI: 'Textured PEI Plate',
       smoothPEI: 'Smooth PEI Plate',
       smoothPEI: 'Smooth PEI Plate',
     },
     },
+    // Slicer Pipelines (#1425) — apply a saved bundle or save the current pick.
+    pipelines: {
+      label: '管線',
+      applyAria: '套用管線',
+      applyPrompt: '套用管線…',
+      empty: '無已儲存管線',
+      saveButton: '另存為管線',
+      saveTitle: '將目前四個槽位的選擇儲存為可重複使用的管線',
+      namePlaceholder: '管線名稱',
+      nameAria: '新管線名稱',
+      toast: {
+        applied: '已套用「{{name}}」',
+        saved: '管線已儲存',
+        saveFailed: '儲存失敗',
+      },
+    },
   },
   },
 
 
   // Spoolman
   // Spoolman

+ 47 - 0
frontend/src/pages/ArchivesPage.tsx

@@ -59,6 +59,7 @@ import {
 } from 'lucide-react';
 } from 'lucide-react';
 import { api } from '../api/client';
 import { api } from '../api/client';
 import { SliceModal } from '../components/SliceModal';
 import { SliceModal } from '../components/SliceModal';
+import { RunWithPipelineModal } from '../components/RunWithPipelineModal';
 import { openInSlicer, type SlicerType } from '../utils/slicer';
 import { openInSlicer, type SlicerType } from '../utils/slicer';
 import { formatDateTime, formatDateOnly, parseUTCDate, type TimeFormat, formatDuration } from '../utils/date';
 import { formatDateTime, formatDateOnly, parseUTCDate, type TimeFormat, formatDuration } from '../utils/date';
 import { getCurrencySymbol } from '../utils/currency';
 import { getCurrencySymbol } from '../utils/currency';
@@ -183,6 +184,7 @@ function ArchiveCard({
   const navigate = useNavigate();
   const navigate = useNavigate();
   const [showReprint, setShowReprint] = useState(false);
   const [showReprint, setShowReprint] = useState(false);
   const [showSliceModal, setShowSliceModal] = useState(false);
   const [showSliceModal, setShowSliceModal] = useState(false);
+  const [showRunPipeline, setShowRunPipeline] = useState(false);
   const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
   const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
   // #1343: when true, the delete also drops the row from Quick Stats. Default
   // #1343: when true, the delete also drops the row from Quick Stats. Default
   // off — soft delete preserves the archive's filament/time/cost contribution.
   // off — soft delete preserves the archive's filament/time/cost contribution.
@@ -438,6 +440,19 @@ function ArchiveCard({
           }
           }
         },
         },
       },
       },
+      // Run-with-pipeline (#1425 PR B follow-up). Sources from archive's
+      // source 3MF (or file_path fallback). Only when slicer-api is on.
+      ...(useSlicerApi
+        ? [{
+            label: t('library.runWithPipeline.actionLabel'),
+            icon: <Play className="w-4 h-4" />,
+            onClick: () => setShowRunPipeline(true),
+            disabled: !hasPermission('pipelines:run'),
+            title: !hasPermission('pipelines:run')
+              ? t('library.runWithPipeline.noPermission')
+              : undefined,
+          }]
+        : []),
     ]),
     ]),
     {
     {
       label: archive.external_url ? t('archives.menu.externalLink') : t('archives.menu.viewOnMakerWorld'),
       label: archive.external_url ? t('archives.menu.externalLink') : t('archives.menu.viewOnMakerWorld'),
@@ -1277,6 +1292,15 @@ function ArchiveCard({
         />
         />
       )}
       )}
 
 
+      {/* Run-with-Pipeline Modal (#1425 PR B). Sources from archive — backend
+          reads source_3mf_path, falls back to file_path. */}
+      {showRunPipeline && (
+        <RunWithPipelineModal
+          source={{ kind: 'archive', id: archive.id, filename: archive.print_name || archive.filename || 'model' }}
+          onClose={() => setShowRunPipeline(false)}
+        />
+      )}
+
       {/* Delete Confirmation */}
       {/* Delete Confirmation */}
       {showDeleteConfirm && (
       {showDeleteConfirm && (
         <ConfirmModal
         <ConfirmModal
@@ -1582,6 +1606,7 @@ function ArchiveListRow({
   const navigate = useNavigate();
   const navigate = useNavigate();
   const [showReprint, setShowReprint] = useState(false);
   const [showReprint, setShowReprint] = useState(false);
   const [showSliceModal, setShowSliceModal] = useState(false);
   const [showSliceModal, setShowSliceModal] = useState(false);
+  const [showRunPipeline, setShowRunPipeline] = useState(false);
   const [showTimelapse, setShowTimelapse] = useState(false);
   const [showTimelapse, setShowTimelapse] = useState(false);
   const [showTimelapseSelect, setShowTimelapseSelect] = useState(false);
   const [showTimelapseSelect, setShowTimelapseSelect] = useState(false);
   const [availableTimelapses, setAvailableTimelapses] = useState<Array<{ name: string; path: string; size: number; mtime: string | null }>>([]);
   const [availableTimelapses, setAvailableTimelapses] = useState<Array<{ name: string; path: string; size: number; mtime: string | null }>>([]);
@@ -1797,6 +1822,19 @@ function ArchiveListRow({
           }
           }
         },
         },
       },
       },
+      // Run-with-pipeline (#1425 PR B follow-up). Sources from archive's
+      // source 3MF (or file_path fallback). Only when slicer-api is on.
+      ...(useSlicerApi
+        ? [{
+            label: t('library.runWithPipeline.actionLabel'),
+            icon: <Play className="w-4 h-4" />,
+            onClick: () => setShowRunPipeline(true),
+            disabled: !hasPermission('pipelines:run'),
+            title: !hasPermission('pipelines:run')
+              ? t('library.runWithPipeline.noPermission')
+              : undefined,
+          }]
+        : []),
     ]),
     ]),
     {
     {
       label: archive.external_url ? t('archives.menu.externalLink') : t('archives.menu.viewOnMakerWorld'),
       label: archive.external_url ? t('archives.menu.externalLink') : t('archives.menu.viewOnMakerWorld'),
@@ -2280,6 +2318,15 @@ function ArchiveListRow({
         />
         />
       )}
       )}
 
 
+      {/* Run-with-Pipeline Modal (#1425 PR B). Sources from archive — backend
+          reads source_3mf_path, falls back to file_path. */}
+      {showRunPipeline && (
+        <RunWithPipelineModal
+          source={{ kind: 'archive', id: archive.id, filename: archive.print_name || archive.filename || 'model' }}
+          onClose={() => setShowRunPipeline(false)}
+        />
+      )}
+
       {/* Delete Confirmation */}
       {/* Delete Confirmation */}
       {showDeleteConfirm && (
       {showDeleteConfirm && (
         <ConfirmModal
         <ConfirmModal

+ 41 - 1
frontend/src/pages/FileManagerPage.tsx

@@ -32,6 +32,7 @@ import {
   Archive as ArchiveIcon,
   Archive as ArchiveIcon,
   Briefcase,
   Briefcase,
   Cog,
   Cog,
+  Play,
   Printer,
   Printer,
   Pencil,
   Pencil,
   Image,
   Image,
@@ -58,6 +59,7 @@ import { ConfirmModal } from '../components/ConfirmModal';
 import { PrintModal } from '../components/PrintModal';
 import { PrintModal } from '../components/PrintModal';
 import { ModelViewerModal } from '../components/ModelViewerModal';
 import { ModelViewerModal } from '../components/ModelViewerModal';
 import { SliceModal } from '../components/SliceModal';
 import { SliceModal } from '../components/SliceModal';
+import { RunWithPipelineModal } from '../components/RunWithPipelineModal';
 import { BulkTagsPickerModal } from '../components/BulkTagsPickerModal';
 import { BulkTagsPickerModal } from '../components/BulkTagsPickerModal';
 import { FileUploadModal } from '../components/FileUploadModal';
 import { FileUploadModal } from '../components/FileUploadModal';
 import { FolderReadmePanel } from '../components/FolderReadmePanel';
 import { FolderReadmePanel } from '../components/FolderReadmePanel';
@@ -729,6 +731,7 @@ interface FileCardProps {
   onDownload: (id: number) => void;
   onDownload: (id: number) => void;
   onPrint?: (file: LibraryFileListItem) => void;
   onPrint?: (file: LibraryFileListItem) => void;
   onSlice?: (file: LibraryFileListItem) => void;
   onSlice?: (file: LibraryFileListItem) => void;
+  onRunPipeline?: (file: LibraryFileListItem) => void;
   useSlicerApi?: boolean;
   useSlicerApi?: boolean;
   onPreview3d?: (file: LibraryFileListItem) => void;
   onPreview3d?: (file: LibraryFileListItem) => void;
   onRename?: (file: LibraryFileListItem) => void;
   onRename?: (file: LibraryFileListItem) => void;
@@ -741,7 +744,7 @@ interface FileCardProps {
   t: TFunction;
   t: TFunction;
 }
 }
 
 
-function FileCard({ file, isSelected, isMobile, onSelect, onDelete, onDownload, onPrint, onSlice, useSlicerApi, onPreview3d, onRename, onGenerateThumbnail, onTagClick, thumbnailVersion, hasPermission, canModify, authEnabled, t }: FileCardProps) {
+function FileCard({ file, isSelected, isMobile, onSelect, onDelete, onDownload, onPrint, onSlice, onRunPipeline, useSlicerApi, onPreview3d, onRename, onGenerateThumbnail, onTagClick, thumbnailVersion, hasPermission, canModify, authEnabled, t }: FileCardProps) {
   const [showActions, setShowActions] = useState(false);
   const [showActions, setShowActions] = useState(false);
 
 
   return (
   return (
@@ -864,6 +867,19 @@ function FileCard({ file, isSelected, isMobile, onSelect, onDelete, onDownload,
                   {t('slice.action')}
                   {t('slice.action')}
                 </button>
                 </button>
               )}
               )}
+              {onRunPipeline && useSlicerApi && isSliceableFilename(file.filename) && (
+                <button
+                  className={`w-full px-3 py-1.5 text-left text-sm flex items-center gap-2 ${
+                    hasPermission('pipelines:run') ? 'text-white hover:bg-bambu-dark' : 'text-bambu-gray cursor-not-allowed'
+                  }`}
+                  onClick={() => { if (hasPermission('pipelines:run')) { onRunPipeline(file); setShowActions(false); } }}
+                  disabled={!hasPermission('pipelines:run')}
+                  title={!hasPermission('pipelines:run') ? t('library.runWithPipeline.noPermission') : undefined}
+                >
+                  <Play className="w-3.5 h-3.5" />
+                  {t('library.runWithPipeline.actionLabel')}
+                </button>
+              )}
               {onPreview3d && (file.file_type === '3mf' || file.file_type === 'gcode' || file.file_type === 'stl' || file.file_type === 'gcode.3mf') && (
               {onPreview3d && (file.file_type === '3mf' || file.file_type === 'gcode' || file.file_type === 'stl' || file.file_type === 'gcode.3mf') && (
                 <button
                 <button
                   className={`w-full px-3 py-1.5 text-left text-sm flex items-center gap-2 ${
                   className={`w-full px-3 py-1.5 text-left text-sm flex items-center gap-2 ${
@@ -978,6 +994,8 @@ export function FileManagerPage() {
   const [deleteConfirm, setDeleteConfirm] = useState<{ type: 'file' | 'folder' | 'bulk'; id: number; count?: number } | null>(null);
   const [deleteConfirm, setDeleteConfirm] = useState<{ type: 'file' | 'folder' | 'bulk'; id: number; count?: number } | null>(null);
   const [printFile, setPrintFile] = useState<LibraryFileListItem | null>(null);
   const [printFile, setPrintFile] = useState<LibraryFileListItem | null>(null);
   const [sliceFile, setSliceFile] = useState<LibraryFileListItem | null>(null);
   const [sliceFile, setSliceFile] = useState<LibraryFileListItem | null>(null);
+  // Slicer Pipelines (#1425 PR B) — file gets "Run with pipeline" action.
+  const [runPipelineFile, setRunPipelineFile] = useState<LibraryFileListItem | null>(null);
   const [renameItem, setRenameItem] = useState<{ type: 'file' | 'folder'; id: number; name: string } | null>(null);
   const [renameItem, setRenameItem] = useState<{ type: 'file' | 'folder'; id: number; name: string } | null>(null);
   const [thumbnailVersions, setThumbnailVersions] = useState<Record<number, number>>({});
   const [thumbnailVersions, setThumbnailVersions] = useState<Record<number, number>>({});
   const [viewerFile, setViewerFile] = useState<LibraryFileListItem | null>(null);
   const [viewerFile, setViewerFile] = useState<LibraryFileListItem | null>(null);
@@ -2265,6 +2283,7 @@ export function FileManagerPage() {
                     onDownload={handleDownload}
                     onDownload={handleDownload}
                     onPrint={setPrintFile}
                     onPrint={setPrintFile}
                     onSlice={setSliceFile}
                     onSlice={setSliceFile}
+                    onRunPipeline={setRunPipelineFile}
                     useSlicerApi={settings?.use_slicer_api ?? false}
                     useSlicerApi={settings?.use_slicer_api ?? false}
                     onPreview3d={(f) => {
                     onPreview3d={(f) => {
                       // Sliced files (.gcode / .gcode.3mf) open the same
                       // Sliced files (.gcode / .gcode.3mf) open the same
@@ -2446,6 +2465,20 @@ export function FileManagerPage() {
                           <Cog className="w-4 h-4" />
                           <Cog className="w-4 h-4" />
                         </button>
                         </button>
                       )}
                       )}
+                      {(settings?.use_slicer_api ?? false) && isSliceableFilename(file.filename) && (
+                        <button
+                          onClick={() => hasPermission('pipelines:run') && setRunPipelineFile(file)}
+                          className={`p-1.5 rounded transition-colors ${
+                            hasPermission('pipelines:run')
+                              ? 'hover:bg-bambu-dark text-bambu-gray hover:text-bambu-green'
+                              : 'text-bambu-gray/50 cursor-not-allowed'
+                          }`}
+                          title={hasPermission('pipelines:run') ? t('library.runWithPipeline.actionLabel', 'Run with pipeline') : t('library.runWithPipeline.noPermission', 'You do not have permission to run pipelines')}
+                          disabled={!hasPermission('pipelines:run')}
+                        >
+                          <Play className="w-4 h-4" />
+                        </button>
+                      )}
                       {(file.file_type === '3mf' || file.file_type === 'gcode' || file.file_type === 'gcode.3mf' || file.file_type === 'stl') && (
                       {(file.file_type === '3mf' || file.file_type === 'gcode' || file.file_type === 'gcode.3mf' || file.file_type === 'stl') && (
                         <button
                         <button
                           onClick={() => {
                           onClick={() => {
@@ -2648,6 +2681,13 @@ export function FileManagerPage() {
         />
         />
       )}
       )}
 
 
+      {runPipelineFile && (
+        <RunWithPipelineModal
+          source={{ kind: 'libraryFile', id: runPipelineFile.id, filename: runPipelineFile.filename }}
+          onClose={() => setRunPipelineFile(null)}
+        />
+      )}
+
       {viewerFile && (
       {viewerFile && (
         <ModelViewerModal
         <ModelViewerModal
           libraryFileId={viewerFile.id}
           libraryFileId={viewerFile.id}

+ 716 - 0
frontend/src/pages/PipelineRunsPage.tsx

@@ -0,0 +1,716 @@
+import { useEffect, useRef, useState, type ReactNode } from 'react';
+import { useTranslation } from 'react-i18next';
+import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
+import {
+  Check,
+  ChevronDown,
+  ChevronRight,
+  FileText,
+  Filter,
+  Loader2,
+  Printer as PrinterIcon,
+  RefreshCw,
+  RotateCcw,
+  Trash2,
+  Workflow,
+  X,
+} from 'lucide-react';
+import { api, type PipelineRun, type Printer, type SlicerPipeline } from '../api/client';
+import { useToast } from '../contexts/ToastContext';
+
+type DropdownOption = { value: string; label: string; group?: string };
+
+const STATUSES = [
+  '',
+  'queued',
+  'slicing',
+  'dispatching',
+  'in_progress',
+  'completed',
+  'partial_failure',
+  'failed',
+  'cancelled',
+] as const;
+
+const PAGE_LIMIT = 25;
+
+// Dashboard for Slicer Pipeline runs (#1425 PR C).
+// Renders the content of the Pipelines tab on the Print Queue page. Lists
+// every run across every pipeline with status + pipeline filters and
+// pagination; each row expands to show per-copy status; in-flight runs get a
+// Cancel button, partial-failure runs get a Retry-failed button. Designed to
+// embed inside QueuePage's tab strip — no page wrapper, no top-level title
+// (the tab strip provides both).
+export function PipelineRunsView() {
+  const { t } = useTranslation();
+  const queryClient = useQueryClient();
+  const { showToast } = useToast();
+
+  const [statusFilter, setStatusFilter] = useState<string>('');
+  const [pipelineFilter, setPipelineFilter] = useState<number | null>(null);
+  // Target filter — same encoded shape as SlicerPipelinesPanel: '' = all,
+  // 'p:<printer_id>' = specific printer, 'c:<model_class>' = printer class.
+  const [targetFilter, setTargetFilter] = useState<string>('');
+  const [offset, setOffset] = useState(0);
+  const [expanded, setExpanded] = useState<Set<number>>(new Set());
+  const [showClearConfirm, setShowClearConfirm] = useState(false);
+
+  const { data: pipelines } = useQuery({
+    queryKey: ['slicer-pipelines'],
+    queryFn: () => api.listSlicerPipelines(),
+  });
+  // Used to resolve target_printer_id → printer name on each row so
+  // specific-printer runs show "H2D #1" instead of just an id.
+  const { data: printers } = useQuery({
+    queryKey: ['printers'],
+    queryFn: () => api.getPrinters(),
+  });
+  const printersById: Record<number, Printer> = (printers ?? []).reduce(
+    (acc, p) => {
+      acc[p.id] = p;
+      return acc;
+    },
+    {} as Record<number, Printer>,
+  );
+
+  const targetPrinterId = targetFilter.startsWith('p:')
+    ? parseInt(targetFilter.slice(2), 10)
+    : undefined;
+  const targetModelClass = targetFilter.startsWith('c:')
+    ? targetFilter.slice(2)
+    : undefined;
+
+  const { data: runsList, isLoading } = useQuery({
+    queryKey: ['pipeline-runs-all', statusFilter, pipelineFilter, offset, targetFilter],
+    queryFn: () =>
+      api.listAllPipelineRuns({
+        limit: PAGE_LIMIT,
+        offset,
+        pipelineId: pipelineFilter ?? undefined,
+        status: statusFilter || undefined,
+        targetPrinterId,
+        targetModelClass,
+      }),
+    refetchInterval: 15_000,
+  });
+
+  const cancelMutation = useMutation({
+    mutationFn: (runId: number) => api.cancelPipelineRun(runId),
+    onSuccess: () => {
+      queryClient.invalidateQueries({ queryKey: ['pipeline-runs-all'] });
+      showToast(t('pipelineRuns.toast.cancelled', 'Run cancelled'), 'success');
+    },
+    onError: (err: Error) =>
+      showToast(err.message || t('pipelineRuns.toast.cancelFailed', 'Cancel failed'), 'error'),
+  });
+
+  const retryMutation = useMutation({
+    mutationFn: (runId: number) => api.retryFailedPipelineRun(runId),
+    onSuccess: () => {
+      queryClient.invalidateQueries({ queryKey: ['pipeline-runs-all'] });
+      showToast(t('pipelineRuns.toast.retryStarted', 'Retry started'), 'success');
+    },
+    onError: (err: Error) =>
+      showToast(err.message || t('pipelineRuns.toast.retryFailed', 'Retry failed'), 'error'),
+  });
+
+  const clearMutation = useMutation({
+    mutationFn: () => api.clearTerminalPipelineRuns(),
+    onSuccess: (result) => {
+      queryClient.invalidateQueries({ queryKey: ['pipeline-runs-all'] });
+      setShowClearConfirm(false);
+      setOffset(0);
+      showToast(
+        t('pipelineRuns.toast.cleared', '{{n}} runs cleared', { n: result.deleted }),
+        'success',
+      );
+    },
+    onError: (err: Error) =>
+      showToast(err.message || t('pipelineRuns.toast.clearFailed', 'Clear failed'), 'error'),
+  });
+
+  const runs = runsList?.runs ?? [];
+  const total = runsList?.total ?? 0;
+  const pipelinesById: Record<number, SlicerPipeline> = (pipelines?.pipelines ?? []).reduce(
+    (acc, p) => {
+      acc[p.id] = p;
+      return acc;
+    },
+    {} as Record<number, SlicerPipeline>,
+  );
+
+  const toggle = (runId: number) => {
+    setExpanded((prev) => {
+      const next = new Set(prev);
+      if (next.has(runId)) next.delete(runId);
+      else next.add(runId);
+      return next;
+    });
+  };
+
+  const hasFilter = !!(statusFilter || pipelineFilter !== null || targetFilter);
+
+  // Build the target dropdown's options from the pipelines actually in use.
+  // Only printers / classes that at least one saved pipeline points at appear
+  // — keeps the dropdown short and meaningful.
+  const targetOptions = (() => {
+    const printerIds = new Set<number>();
+    const classes = new Set<string>();
+    for (const p of pipelines?.pipelines ?? []) {
+      if (p.target_kind === 'printer_class' && p.target_model_class) {
+        classes.add(p.target_model_class);
+      } else if (p.target_printer_id) {
+        printerIds.add(p.target_printer_id);
+      }
+    }
+    return {
+      printers: (printers ?? []).filter((pr) => printerIds.has(pr.id)),
+      classes: Array.from(classes).sort(),
+    };
+  })();
+
+  return (
+    <div>
+      {/* Filter row — bare dropdowns with placeholder labels and an inline
+          refresh button. The previous version had ``Pipeline:`` / ``Status:``
+          labels next to dropdowns that already declared the same thing, so the
+          row read as repetitive. */}
+      <div className="flex flex-wrap items-center gap-2 mb-4 text-sm">
+        <button
+          type="button"
+          onClick={() => queryClient.invalidateQueries({ queryKey: ['pipeline-runs-all'] })}
+          aria-label={t('common.refresh', 'Refresh')}
+          title={t('common.refresh', 'Refresh')}
+          className="p-1.5 text-bambu-gray hover:text-white border border-bambu-dark-tertiary rounded"
+        >
+          <RefreshCw className="w-3.5 h-3.5" />
+        </button>
+        <FilterDropdown
+          icon={<Workflow className="w-3.5 h-3.5 text-bambu-gray" />}
+          ariaLabel={t('pipelineRuns.filter.pipeline', 'Pipeline')}
+          value={pipelineFilter === null ? '' : String(pipelineFilter)}
+          onChange={(v) => {
+            setOffset(0);
+            setPipelineFilter(v ? parseInt(v, 10) : null);
+          }}
+          options={[
+            { value: '', label: t('pipelineRuns.filter.allPipelines', 'All pipelines') },
+            ...((pipelines?.pipelines ?? []).map((p) => ({
+              value: String(p.id),
+              label: p.name,
+            })) as DropdownOption[]),
+          ]}
+        />
+        <FilterDropdown
+          icon={<Filter className="w-3.5 h-3.5 text-bambu-gray" />}
+          ariaLabel={t('pipelineRuns.filter.status', 'Status')}
+          value={statusFilter}
+          onChange={(v) => {
+            setOffset(0);
+            setStatusFilter(v);
+          }}
+          options={STATUSES.map((s) => ({
+            value: s,
+            label:
+              s === ''
+                ? t('pipelineRuns.filter.allStatus', 'All statuses')
+                : t(`settings.pipelines.runs.status.${s}`, s),
+          }))}
+        />
+        {/* Target filter: built from targets actually in use across saved
+            pipelines so the dropdown stays short. Mirrors the SlicerPipelinesPanel
+            target picker. */}
+        {(targetOptions.printers.length > 0 || targetOptions.classes.length > 0) && (
+          <FilterDropdown
+            icon={<PrinterIcon className="w-3.5 h-3.5 text-bambu-gray" />}
+            ariaLabel={t('pipelineRuns.filter.target', 'Target')}
+            value={targetFilter}
+            onChange={(v) => {
+              setOffset(0);
+              setTargetFilter(v);
+            }}
+            options={[
+              { value: '', label: t('pipelineRuns.filter.allTargets', 'All targets') },
+              ...targetOptions.printers.map((p) => ({
+                value: `p:${p.id}`,
+                label: p.name,
+                group: t('settings.pipelines.field.targetKindSpecific', 'Specific printer'),
+              })),
+              ...targetOptions.classes.map((c) => ({
+                value: `c:${c}`,
+                label: t('library.runWithPipeline.classTarget', 'Any {{model}}', { model: c }),
+                group: t('settings.pipelines.field.targetKindClass', 'Printer class'),
+              })),
+            ]}
+          />
+        )}
+        {hasFilter && (
+          <button
+            type="button"
+            onClick={() => {
+              setStatusFilter('');
+              setPipelineFilter(null);
+              setTargetFilter('');
+              setOffset(0);
+            }}
+            className="text-xs text-bambu-gray hover:text-white"
+          >
+            {t('pipelineRuns.filter.clear', 'Clear filters')}
+          </button>
+        )}
+        <div className="ml-auto flex items-center gap-2 text-xs text-bambu-gray">
+          {!isLoading && total > 0 && (
+            <span>{t('pipelineRuns.totalCount', '{{n}} run', { n: total, count: total })}</span>
+          )}
+          {/* Clear logs — opens a confirmation modal; only enabled when there
+              are terminal runs that the endpoint could actually delete. */}
+          <button
+            type="button"
+            onClick={() => setShowClearConfirm(true)}
+            disabled={total === 0}
+            className="flex items-center gap-1 px-2 py-1 text-red-400 hover:bg-red-500/10 rounded disabled:opacity-50 disabled:cursor-not-allowed"
+          >
+            <Trash2 className="w-3 h-3" />
+            {t('pipelineRuns.clearLog', 'Clear log')}
+          </button>
+        </div>
+      </div>
+
+      {/* Clear-log confirmation modal. Only deletes terminal runs (completed
+          / failed / cancelled / partial_failure); in-flight runs are
+          preserved so an active batch isn't accidentally torched. */}
+      {showClearConfirm && (
+        <div
+          className="fixed inset-0 z-50 bg-black/60 flex items-center justify-center p-4"
+          onClick={() => setShowClearConfirm(false)}
+          role="dialog"
+          aria-modal="true"
+        >
+          <div
+            className="bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg shadow-2xl w-full max-w-md p-4"
+            onClick={(e) => e.stopPropagation()}
+          >
+            <h3 className="text-base font-semibold text-white flex items-center gap-2">
+              <Trash2 className="w-4 h-4 text-red-400" />
+              {t('pipelineRuns.clearConfirmTitle', 'Clear log?')}
+            </h3>
+            <p className="text-sm text-bambu-gray mt-2">
+              {t(
+                'pipelineRuns.clearConfirmBody',
+                'Delete every completed, failed, cancelled, and partial-failure pipeline run? In-flight runs are kept. This cannot be undone.',
+              )}
+            </p>
+            <div className="flex items-center justify-end gap-2 mt-4">
+              <button
+                type="button"
+                onClick={() => setShowClearConfirm(false)}
+                disabled={clearMutation.isPending}
+                className="px-3 py-1.5 text-sm text-bambu-gray hover:text-white"
+              >
+                {t('common.cancel', 'Cancel')}
+              </button>
+              <button
+                type="button"
+                onClick={() => clearMutation.mutate()}
+                disabled={clearMutation.isPending}
+                className="px-3 py-1.5 text-sm bg-red-500 hover:bg-red-600 text-white rounded disabled:opacity-50 flex items-center gap-1"
+              >
+                {clearMutation.isPending ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <Trash2 className="w-3.5 h-3.5" />}
+                {t('pipelineRuns.clearConfirmAction', 'Clear')}
+              </button>
+            </div>
+          </div>
+        </div>
+      )}
+
+      {isLoading && (
+        <div className="flex items-center gap-2 text-bambu-gray">
+          <Loader2 className="w-4 h-4 animate-spin" />
+          {t('pipelineRuns.loading', 'Loading…')}
+        </div>
+      )}
+      {!isLoading && runs.length === 0 && (
+        <p className="text-sm text-bambu-gray">
+          {hasFilter
+            ? t('pipelineRuns.filter.noMatches', 'No runs match the current filters.')
+            : t('pipelineRuns.empty', 'No pipeline runs yet.')}
+        </p>
+      )}
+
+      {!isLoading && runs.length > 0 && (
+        <div className="space-y-1.5">
+          {runs.map((run) => (
+            <RunRow
+              key={run.id}
+              run={run}
+              pipeline={run.pipeline_id ? pipelinesById[run.pipeline_id] : undefined}
+              printersById={printersById}
+              expanded={expanded.has(run.id)}
+              onToggle={() => toggle(run.id)}
+              onCancel={() => cancelMutation.mutate(run.id)}
+              onRetry={() => retryMutation.mutate(run.id)}
+              cancelling={cancelMutation.isPending}
+              retrying={retryMutation.isPending}
+            />
+          ))}
+        </div>
+      )}
+
+      {!isLoading && total > PAGE_LIMIT && (
+        <div className="flex items-center justify-between mt-4 text-sm">
+          <button
+            type="button"
+            onClick={() => setOffset(Math.max(0, offset - PAGE_LIMIT))}
+            disabled={offset === 0}
+            className="px-3 py-1.5 rounded border border-bambu-dark-tertiary disabled:opacity-50 text-bambu-gray hover:text-white"
+          >
+            {t('common.previous', 'Previous')}
+          </button>
+          <span className="text-bambu-gray text-xs">
+            {t('pipelineRuns.pagination', '{{start}}–{{end}} of {{total}}', {
+              start: offset + 1,
+              end: Math.min(offset + PAGE_LIMIT, total),
+              total,
+            })}
+          </span>
+          <button
+            type="button"
+            onClick={() => setOffset(offset + PAGE_LIMIT)}
+            disabled={offset + PAGE_LIMIT >= total}
+            className="px-3 py-1.5 rounded border border-bambu-dark-tertiary disabled:opacity-50 text-bambu-gray hover:text-white"
+          >
+            {t('common.next', 'Next')}
+          </button>
+        </div>
+      )}
+    </div>
+  );
+}
+
+function RunRow({
+  run,
+  pipeline,
+  printersById,
+  expanded,
+  onToggle,
+  onCancel,
+  onRetry,
+  cancelling,
+  retrying,
+}: {
+  run: PipelineRun;
+  pipeline: SlicerPipeline | undefined;
+  printersById: Record<number, Printer>;
+  expanded: boolean;
+  onToggle: () => void;
+  onCancel: () => void;
+  onRetry: () => void;
+  cancelling: boolean;
+  retrying: boolean;
+}) {
+  const { t } = useTranslation();
+  const inFlight = ['queued', 'slicing', 'dispatching', 'in_progress'].includes(run.status);
+  const partial = run.status === 'partial_failure' || run.status === 'failed';
+  const userCancelled = run.status === 'cancelled' && run.error_message === 'Cancelled by user';
+
+  // Target chip — class targeting reads "Any X1C", specific-printer reads the
+  // printer's actual name (resolved from the printersById map). The chip is
+  // only rendered when we have a target to show; an unbound pipeline simply
+  // omits it.
+  const targetLabel = run.target_kind === 'printer_class' && run.target_model_class
+    ? t('library.runWithPipeline.classTarget', 'Any {{model}}', { model: run.target_model_class })
+    : run.target_printer_id && printersById[run.target_printer_id]
+      ? printersById[run.target_printer_id].name
+      : null;
+
+  return (
+    <div className="rounded-md border border-bambu-dark-tertiary bg-bambu-dark/40 overflow-hidden">
+      <div className="flex items-center gap-2 px-3 py-2">
+        <button
+          type="button"
+          onClick={onToggle}
+          aria-label={expanded ? t('common.collapse', 'Collapse') : t('common.expand', 'Expand')}
+          aria-expanded={expanded}
+          className="text-bambu-gray hover:text-white flex-shrink-0"
+        >
+          {expanded ? <ChevronDown className="w-4 h-4" /> : <ChevronRight className="w-4 h-4" />}
+        </button>
+        <div className="flex-1 min-w-0">
+          {/* Top line: run number · pipeline name · status badge. The status
+              chip is bigger and more saturated than the previous version so
+              it actually pops at a glance — finding "what failed" was hard
+              when all chips read the same washed-out grey. */}
+          <div className="flex flex-wrap items-center gap-x-2 gap-y-0.5 text-sm">
+            <span className="font-medium text-white">
+              #{run.id}
+            </span>
+            <span className="text-white truncate">
+              {pipeline?.name ?? run.pipeline_name ?? '—'}
+            </span>
+            <RunStatusChip status={run.status} />
+            {targetLabel && (
+              <span className="text-xs px-1.5 py-0.5 rounded bg-bambu-dark-tertiary text-bambu-gray flex items-center gap-1">
+                <PrinterIcon className="w-3 h-3" />
+                {targetLabel}
+              </span>
+            )}
+            {run.parent_run_id && (
+              <span className="text-xs text-bambu-gray/70 italic">
+                {t('pipelineRuns.retryOf', 'retry of #{{n}}', { n: run.parent_run_id })}
+              </span>
+            )}
+          </div>
+          {/* Second line: source filename — give it its own row so long
+              titles don't crowd the metadata. */}
+          {run.source_filename && (
+            <div className="mt-0.5 flex items-center gap-1.5 text-xs text-bambu-gray min-w-0">
+              <FileText className="w-3 h-3 flex-shrink-0" />
+              <span className="truncate" title={run.source_filename}>
+                {run.source_filename}
+              </span>
+            </div>
+          )}
+          {/* Third line: timestamp + roll-up counts. Greyed out so the eye
+              jumps to the title + chip first. */}
+          <div className="mt-0.5 text-xs text-bambu-gray/70 flex flex-wrap items-center gap-x-2">
+            <span>{new Date(run.created_at).toLocaleString()}</span>
+            {run.copies > 1 && (
+              <>
+                <span className="text-bambu-gray/40">·</span>
+                <span>{t('pipelineRuns.copies', '{{n}} copies', { n: run.copies })}</span>
+              </>
+            )}
+            {(run.copies_completed > 0 || run.copies_failed > 0 || run.copies_cancelled > 0) && (
+              <>
+                <span className="text-bambu-gray/40">·</span>
+                <span>
+                  <span className="text-bambu-green">{run.copies_completed}</span>
+                  /{run.copies}
+                </span>
+                {run.copies_failed > 0 && (
+                  <>
+                    <span className="text-bambu-gray/40">·</span>
+                    <span className="text-red-400">
+                      {t('pipelineRuns.failedCount', '{{n}} failed', { n: run.copies_failed })}
+                    </span>
+                  </>
+                )}
+              </>
+            )}
+          </div>
+        </div>
+        <div className="flex items-center gap-1 flex-shrink-0">
+          {inFlight && (
+            <button
+              type="button"
+              onClick={onCancel}
+              disabled={cancelling}
+              aria-label={t('common.cancel', 'Cancel')}
+              className="text-xs px-2 py-1 text-red-400 hover:bg-red-500/10 rounded disabled:opacity-50 flex items-center gap-1"
+            >
+              <X className="w-3 h-3" />
+              {t('common.cancel', 'Cancel')}
+            </button>
+          )}
+          {partial && (
+            <button
+              type="button"
+              onClick={onRetry}
+              disabled={retrying}
+              aria-label={t('pipelineRuns.retryFailed', 'Retry failed')}
+              className="text-xs px-2 py-1 text-bambu-green hover:bg-bambu-green/10 rounded disabled:opacity-50 flex items-center gap-1"
+            >
+              <RotateCcw className="w-3 h-3" />
+              {t('pipelineRuns.retryFailed', 'Retry failed')}
+            </button>
+          )}
+        </div>
+      </div>
+      {expanded && (
+        <div className="border-t border-bambu-dark-tertiary px-3 py-2 bg-bambu-dark/30">
+          <div className="space-y-1.5">
+            {run.jobs.map((job) => (
+              <div key={job.id} className="flex items-center gap-2 text-xs">
+                <span className="text-bambu-gray/60 w-14 flex-shrink-0">
+                  {t('pipelineRuns.copyN', 'Copy {{n}}', { n: job.copy_index + 1 })}
+                </span>
+                <JobStatusChip status={job.status} />
+                {job.assigned_printer_name && (
+                  <span className="text-bambu-gray flex items-center gap-1 truncate">
+                    <PrinterIcon className="w-3 h-3 text-bambu-gray/60" />
+                    <span className="text-white truncate">{job.assigned_printer_name}</span>
+                  </span>
+                )}
+                {job.error_message && (
+                  <span className="text-red-400 truncate" title={job.error_message}>
+                    {job.error_message}
+                  </span>
+                )}
+              </div>
+            ))}
+          </div>
+          {run.error_message && !userCancelled && (
+            <div className="text-xs text-red-400 mt-2 pt-2 border-t border-bambu-dark-tertiary">
+              {run.error_message}
+            </div>
+          )}
+          {userCancelled && (
+            <div className="text-xs text-bambu-gray/70 mt-2 pt-2 border-t border-bambu-dark-tertiary italic">
+              {t('pipelineRuns.cancelledByUser', 'Cancelled by user')}
+            </div>
+          )}
+        </div>
+      )}
+    </div>
+  );
+}
+
+// High-contrast badges on solid Tailwind palette colours. The previous
+// version used ``bg-bambu-gray/25 text-bambu-gray`` etc. — same hue for
+// background AND text — which made every chip look washed-out grey
+// regardless of state. These use saturated 700-tier backgrounds with bright
+// 100-tier text, so each state reads at a glance.
+function RunStatusChip({ status }: { status: PipelineRun['status'] }) {
+  const { t } = useTranslation();
+  const colours: Record<PipelineRun['status'], string> = {
+    queued: 'bg-slate-700 text-slate-200',
+    slicing: 'bg-sky-700 text-sky-100',
+    dispatching: 'bg-sky-700 text-sky-100',
+    in_progress: 'bg-emerald-700 text-emerald-100',
+    completed: 'bg-emerald-700 text-emerald-100',
+    failed: 'bg-red-700 text-red-100',
+    partial_failure: 'bg-amber-700 text-amber-100',
+    cancelled: 'bg-rose-900 text-rose-200',
+  };
+  return (
+    <span className={`px-1.5 py-0.5 rounded text-[10px] uppercase font-semibold tracking-wide whitespace-nowrap ${colours[status]}`}>
+      {t(`settings.pipelines.runs.status.${status}`, status)}
+    </span>
+  );
+}
+
+// Custom dropdown — bambu-themed replacement for the native browser
+// `<select>` element. Same value/onChange contract; supports optional
+// `group` per option to render section headers (replaces <optgroup>).
+// Closes on outside click and Escape.
+function FilterDropdown({
+  value,
+  onChange,
+  options,
+  icon,
+  ariaLabel,
+}: {
+  value: string;
+  onChange: (value: string) => void;
+  options: DropdownOption[];
+  icon: ReactNode;
+  ariaLabel: string;
+}) {
+  const [open, setOpen] = useState(false);
+  const ref = useRef<HTMLDivElement>(null);
+
+  useEffect(() => {
+    if (!open) return;
+    const handleMouseDown = (e: MouseEvent) => {
+      if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false);
+    };
+    const handleKey = (e: KeyboardEvent) => {
+      if (e.key === 'Escape') setOpen(false);
+    };
+    document.addEventListener('mousedown', handleMouseDown);
+    document.addEventListener('keydown', handleKey);
+    return () => {
+      document.removeEventListener('mousedown', handleMouseDown);
+      document.removeEventListener('keydown', handleKey);
+    };
+  }, [open]);
+
+  const selected = options.find((o) => o.value === value);
+
+  // Group consecutive options that share the same `group` so the menu can
+  // render one header per group (mirrors the <optgroup> shape).
+  const grouped: { group: string | undefined; options: DropdownOption[] }[] = [];
+  for (const opt of options) {
+    const last = grouped[grouped.length - 1];
+    if (last && last.group === opt.group) last.options.push(opt);
+    else grouped.push({ group: opt.group, options: [opt] });
+  }
+
+  return (
+    <div ref={ref} className="relative">
+      <button
+        type="button"
+        onClick={() => setOpen((v) => !v)}
+        aria-label={ariaLabel}
+        aria-haspopup="listbox"
+        aria-expanded={open}
+        className="flex items-center gap-1.5 px-2 py-1 border border-bambu-dark-tertiary rounded bg-bambu-dark/40 text-xs text-white hover:border-bambu-gray/60 focus:outline-none focus:ring-1 focus:ring-bambu-green/40"
+      >
+        {icon}
+        <span className="truncate max-w-[14rem]">{selected?.label ?? ''}</span>
+        <ChevronDown
+          className={`w-3 h-3 text-bambu-gray transition-transform ${open ? 'rotate-180' : ''}`}
+        />
+      </button>
+      {open && (
+        <div
+          role="listbox"
+          className="absolute left-0 top-full mt-1 z-30 min-w-full max-h-72 overflow-auto rounded border border-bambu-dark-tertiary bg-bambu-dark-secondary shadow-xl py-1"
+        >
+          {grouped.map((g, gi) => (
+            <div key={gi}>
+              {g.group && (
+                <div className="px-2 pt-1.5 pb-0.5 text-[10px] uppercase tracking-wider text-bambu-gray/60">
+                  {g.group}
+                </div>
+              )}
+              {g.options.map((opt) => {
+                const isSelected = opt.value === value;
+                return (
+                  <button
+                    key={opt.value}
+                    type="button"
+                    role="option"
+                    aria-selected={isSelected}
+                    onClick={() => {
+                      onChange(opt.value);
+                      setOpen(false);
+                    }}
+                    className={`flex w-full items-center gap-1.5 text-left px-2 py-1 text-xs whitespace-nowrap ${
+                      isSelected
+                        ? 'bg-bambu-dark-tertiary text-white'
+                        : 'text-bambu-gray hover:bg-bambu-dark-tertiary/60 hover:text-white'
+                    }`}
+                  >
+                    <Check
+                      className={`w-3 h-3 flex-shrink-0 ${isSelected ? 'opacity-100' : 'opacity-0'}`}
+                    />
+                    <span className="truncate">{opt.label}</span>
+                  </button>
+                );
+              })}
+            </div>
+          ))}
+        </div>
+      )}
+    </div>
+  );
+}
+
+function JobStatusChip({ status }: { status: PipelineRun['jobs'][number]['status'] }) {
+  const { t } = useTranslation();
+  const colours: Record<PipelineRun['jobs'][number]['status'], string> = {
+    pending: 'bg-slate-700 text-slate-200',
+    awaiting_printer: 'bg-sky-700 text-sky-100',
+    queued: 'bg-sky-700 text-sky-100',
+    printing: 'bg-emerald-700 text-emerald-100',
+    completed: 'bg-emerald-700 text-emerald-100',
+    failed: 'bg-red-700 text-red-100',
+    cancelled: 'bg-rose-900 text-rose-200',
+  };
+  return (
+    <span className={`px-1.5 py-0.5 rounded text-[10px] uppercase font-semibold tracking-wide ${colours[status]}`}>
+      {t(`pipelineRuns.jobStatus.${status}`, status)}
+    </span>
+  );
+}

+ 28 - 7
frontend/src/pages/QueuePage.tsx

@@ -59,8 +59,10 @@ import {
   Ungroup,
   Ungroup,
   Ban,
   Ban,
   PlayCircle,
   PlayCircle,
+  Workflow,
 } from 'lucide-react';
 } from 'lucide-react';
 import { api, ApiError } from '../api/client';
 import { api, ApiError } from '../api/client';
+import { PipelineRunsView } from './PipelineRunsPage';
 import { type TimeFormat, formatETA, formatDuration, formatRelativeTime, parseUTCDate } from '../utils/date';
 import { type TimeFormat, formatETA, formatDuration, formatRelativeTime, parseUTCDate } from '../utils/date';
 import { getBedTypeInfo } from '../utils/bedType';
 import { getBedTypeInfo } from '../utils/bedType';
 import type { PrintQueueItem, PrintQueueBulkUpdate, Permission } from '../api/client';
 import type { PrintQueueItem, PrintQueueBulkUpdate, Permission } from '../api/client';
@@ -1236,9 +1238,16 @@ export function QueuePage() {
   // History tab renders unconditionally so this no longer drives the UI.
   // History tab renders unconditionally so this no longer drives the UI.
   // Tabbed page structure: Active queue stays as the main view; History
   // Tabbed page structure: Active queue stays as the main view; History
   // and Timeline split off. Persists per-user via localStorage.
   // and Timeline split off. Persists per-user via localStorage.
-  const [activeTab, setActiveTab] = useState<'queue' | 'history' | 'timeline'>(() => {
+  const [activeTab, setActiveTab] = useState<'queue' | 'history' | 'timeline' | 'pipelines'>(() => {
+    // URL deep-link wins so the legacy /pipelines/runs redirect lands on the
+    // right tab. localStorage holds the per-user last-selected fallback.
+    const search = new URLSearchParams(window.location.search);
+    const url = search.get('tab');
+    if (url === 'pipelines' || url === 'history' || url === 'timeline' || url === 'queue') {
+      return url;
+    }
     const saved = localStorage.getItem('queue.activeTab');
     const saved = localStorage.getItem('queue.activeTab');
-    if (saved === 'history' || saved === 'timeline') return saved;
+    if (saved === 'history' || saved === 'timeline' || saved === 'pipelines') return saved;
     return 'queue';
     return 'queue';
   });
   });
   // Active-tab layout toggle. "position" = today's flat list; "printer"
   // Active-tab layout toggle. "position" = today's flat list; "printer"
@@ -1917,6 +1926,10 @@ export function QueuePage() {
           { id: 'queue' as const, label: t('queue.tabs.queue'), icon: Clock, count: pendingItems.length + activeItems.length },
           { id: 'queue' as const, label: t('queue.tabs.queue'), icon: Clock, count: pendingItems.length + activeItems.length },
           { id: 'history' as const, label: t('queue.tabs.history'), icon: ListOrdered, count: historyItems.length },
           { id: 'history' as const, label: t('queue.tabs.history'), icon: ListOrdered, count: historyItems.length },
           { id: 'timeline' as const, label: t('queue.tabs.timeline'), icon: GanttChart, count: null as number | null },
           { id: 'timeline' as const, label: t('queue.tabs.timeline'), icon: GanttChart, count: null as number | null },
+          // Slicer Pipelines dashboard (#1425 PR C). Lives here instead of
+          // its own sidebar entry so the Print Queue page is the single
+          // place an operator looks for "what's running / what ran".
+          { id: 'pipelines' as const, label: t('queue.tabs.pipelines'), icon: Workflow, count: null as number | null },
         ]).map(({ id, label, icon: Icon, count }) => (
         ]).map(({ id, label, icon: Icon, count }) => (
           <button
           <button
             key={id}
             key={id}
@@ -1940,15 +1953,15 @@ export function QueuePage() {
         ))}
         ))}
       </div>
       </div>
 
 
-      {/* Summary Stats */}
-      <QueueStatsBar
+      {/* Summary Stats — about the print queue, not pipelines. */}
+      {activeTab !== 'pipelines' && <QueueStatsBar
         activeCount={activeItems.length}
         activeCount={activeItems.length}
         pendingCount={pendingItems.length}
         pendingCount={pendingItems.length}
         totalTime={totalQueueTime}
         totalTime={totalQueueTime}
         totalWeight={totalWeight}
         totalWeight={totalWeight}
         historyCount={historyItems.length}
         historyCount={historyItems.length}
         t={t}
         t={t}
-      />
+      />}
 
 
       {/* #1818: Resume-after-failure banner. One row per printer whose queue
       {/* #1818: Resume-after-failure banner. One row per printer whose queue
           is gated by a prior failed/aborted print. Visible regardless of
           is gated by a prior failed/aborted print. Visible regardless of
@@ -1987,7 +2000,10 @@ export function QueuePage() {
         </div>
         </div>
       )}
       )}
 
 
-      {/* Filters */}
+      {/* 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. */}
+      {activeTab !== 'pipelines' && (
       <div className="flex flex-wrap items-center gap-2 sm:gap-4 mb-6">
       <div className="flex flex-wrap items-center gap-2 sm:gap-4 mb-6">
         <select
         <select
           className="px-2 sm:px-3 py-2 text-sm sm:text-base bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none min-w-0 flex-1 sm:flex-none"
           className="px-2 sm:px-3 py-2 text-sm sm:text-base bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none min-w-0 flex-1 sm:flex-none"
@@ -2049,6 +2065,7 @@ export function QueuePage() {
           </Button>
           </Button>
         )}
         )}
       </div>
       </div>
+      )}
 
 
       {/* Queue-tab controls: layout toggle (Position / Printer) + SJF.
       {/* Queue-tab controls: layout toggle (Position / Printer) + SJF.
           Hidden on History/Timeline tabs since they don't apply. */}
           Hidden on History/Timeline tabs since they don't apply. */}
@@ -2095,7 +2112,11 @@ export function QueuePage() {
         </div>
         </div>
       )}
       )}
 
 
-      {isLoading ? (
+      {/* Pipelines tab short-circuits before the queue-empty branch so the
+          dashboard renders even when the regular queue is empty. */}
+      {activeTab === 'pipelines' ? (
+        <PipelineRunsView />
+      ) : isLoading ? (
         <div className="text-center py-12 text-bambu-gray">{t('common.loading')}</div>
         <div className="text-center py-12 text-bambu-gray">{t('common.loading')}</div>
       ) : queue?.length === 0 ? (
       ) : queue?.length === 0 ? (
         <Card className="p-12 text-center border-dashed">
         <Card className="p-12 text-center border-dashed">

+ 96 - 3
frontend/src/pages/SettingsPage.tsx

@@ -1,5 +1,5 @@
 import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
 import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
-import { Loader2, Plus, Plug, AlertTriangle, RotateCcw, Bell, Download, RefreshCw, ExternalLink, Globe, Droplets, Thermometer, FileText, Edit2, Send, CheckCircle, XCircle, History, Trash2, Zap, TrendingUp, Calendar, DollarSign, Power, PowerOff, Key, Copy, Database, X, Shield, Printer, Cylinder, Wifi, Home, Video, Users, Lock, Unlock, ChevronDown, Save, Mail, Flame, Layers, ListOrdered, Code, Search, Scale, Settings as SettingsIcon, ScanEye, Cog, QrCode, Heart } from 'lucide-react';
+import { Loader2, Plus, Plug, AlertTriangle, RotateCcw, Bell, Download, RefreshCw, ExternalLink, Globe, Droplets, Thermometer, FileText, Edit2, Send, CheckCircle, XCircle, History, Trash2, Zap, TrendingUp, Calendar, DollarSign, Power, PowerOff, Key, Copy, Database, X, Shield, Printer, Cylinder, Wifi, Home, Video, Users, Lock, Unlock, ChevronDown, Save, Mail, Flame, Layers, ListOrdered, Code, Search, Scale, Settings as SettingsIcon, ScanEye, Cog, QrCode, Heart, Workflow } from 'lucide-react';
 import { useTranslation } from 'react-i18next';
 import { useTranslation } from 'react-i18next';
 import { useNavigate, useSearchParams } from 'react-router-dom';
 import { useNavigate, useSearchParams } from 'react-router-dom';
 import { api } from '../api/client';
 import { api } from '../api/client';
@@ -11,6 +11,7 @@ import { PRESET_CATEGORIES, parsePresetTriple } from '../utils/temperatureFanPre
 import type { APIKey, AppSettings, AppSettingsUpdate, SmartPlug, SmartPlugStatus, NotificationProvider, NotificationTemplate, UpdateStatus, GitHubBackupStatus, CloudAuthStatus, UserCreate, UserUpdate, UserResponse, StorageUsageResponse } from '../api/client';
 import type { APIKey, AppSettings, AppSettingsUpdate, SmartPlug, SmartPlugStatus, NotificationProvider, NotificationTemplate, UpdateStatus, GitHubBackupStatus, CloudAuthStatus, UserCreate, UserUpdate, UserResponse, StorageUsageResponse } from '../api/client';
 import { Card, CardContent, CardDensityProvider, CardHeader } from '../components/Card';
 import { Card, CardContent, CardDensityProvider, CardHeader } from '../components/Card';
 import { SlicerBundlesPanel } from '../components/SlicerBundlesPanel';
 import { SlicerBundlesPanel } from '../components/SlicerBundlesPanel';
+import { SlicerPipelinesPanel } from '../components/SlicerPipelinesPanel';
 import { CameraTokensSection } from './CameraTokensPage';
 import { CameraTokensSection } from './CameraTokensPage';
 import { Collapsible } from '../components/Collapsible';
 import { Collapsible } from '../components/Collapsible';
 import { Button } from '../components/Button';
 import { Button } from '../components/Button';
@@ -190,6 +191,11 @@ export function SettingsPage() {
   const initialTab = isLegacyEmailTab ? 'users' : (tabParam && validTabs.includes(tabParam as TabType) ? tabParam as TabType : 'general');
   const initialTab = isLegacyEmailTab ? 'users' : (tabParam && validTabs.includes(tabParam as TabType) ? tabParam as TabType : 'general');
   const [activeTab, setActiveTab] = useState<TabType>(initialTab);
   const [activeTab, setActiveTab] = useState<TabType>(initialTab);
   const [usersSubTab, setUsersSubTab] = useState<UsersSubTab>(isLegacyEmailTab ? 'email' : 'users');
   const [usersSubTab, setUsersSubTab] = useState<UsersSubTab>(isLegacyEmailTab ? 'email' : 'users');
+  // Workflow tab sub-tabs (#1425): 'dispatch' = current Workflow content,
+  // 'pipelines' = Slicer Pipelines management. URL: ?tab=queue&sub=pipelines.
+  const initialQueueSub: 'dispatch' | 'pipelines' =
+    tabParam === 'queue' && searchParams.get('sub') === 'pipelines' ? 'pipelines' : 'dispatch';
+  const [queueSubTab, setQueueSubTab] = useState<'dispatch' | 'pipelines'>(initialQueueSub);
 
 
   // Update URL when tab changes
   // Update URL when tab changes
   const handleTabChange = (tab: TabType) => {
   const handleTabChange = (tab: TabType) => {
@@ -197,6 +203,10 @@ export function SettingsPage() {
     if (tab === 'users') {
     if (tab === 'users') {
       setUsersSubTab('users');
       setUsersSubTab('users');
     }
     }
+    if (tab === 'queue') {
+      setQueueSubTab('dispatch');
+      searchParams.delete('sub');
+    }
     if (tab === 'general') {
     if (tab === 'general') {
       searchParams.delete('tab');
       searchParams.delete('tab');
     } else {
     } else {
@@ -204,6 +214,17 @@ export function SettingsPage() {
     }
     }
     setSearchParams(searchParams, { replace: true });
     setSearchParams(searchParams, { replace: true });
   };
   };
+
+  // Switch the Workflow tab's sub-tab and reflect it in the URL so deep-links work.
+  const handleQueueSubTabChange = (sub: 'dispatch' | 'pipelines') => {
+    setQueueSubTab(sub);
+    if (sub === 'pipelines') {
+      searchParams.set('sub', 'pipelines');
+    } else {
+      searchParams.delete('sub');
+    }
+    setSearchParams(searchParams, { replace: true });
+  };
   const [showCreateAPIKey, setShowCreateAPIKey] = useState(false);
   const [showCreateAPIKey, setShowCreateAPIKey] = useState(false);
   const [newAPIKeyName, setNewAPIKeyName] = useState('');
   const [newAPIKeyName, setNewAPIKeyName] = useState('');
   const [newAPIKeyPermissions, setNewAPIKeyPermissions] = useState({
   const [newAPIKeyPermissions, setNewAPIKeyPermissions] = useState({
@@ -4070,8 +4091,37 @@ export function SettingsPage() {
       )}
       )}
 
 
       {/* Filament Tab */}
       {/* Filament Tab */}
-      {/* Queue Tab */}
-      {activeTab === 'queue' && localSettings && (
+      {/* Queue Tab — sub-tabs: Queue & Dispatch (current content) / Pipelines (#1425) */}
+      {activeTab === 'queue' && (
+        <div className="space-y-3">
+          {/* Sub-tab nav, mirroring the Authentication tab pattern */}
+          <div className="flex gap-1 border-b border-bambu-dark-tertiary">
+            <button
+              onClick={() => handleQueueSubTabChange('dispatch')}
+              className={`px-4 py-2 text-sm font-medium transition-colors border-b-2 -mb-px flex items-center gap-2 ${
+                queueSubTab === 'dispatch'
+                  ? 'text-bambu-green border-bambu-green'
+                  : 'text-bambu-gray hover:text-gray-900 dark:hover:text-white border-transparent'
+              }`}
+            >
+              <ListOrdered className="w-4 h-4" />
+              {t('settings.tabs.queueDispatch', 'Queue & Dispatch')}
+            </button>
+            <button
+              onClick={() => handleQueueSubTabChange('pipelines')}
+              className={`px-4 py-2 text-sm font-medium transition-colors border-b-2 -mb-px flex items-center gap-2 ${
+                queueSubTab === 'pipelines'
+                  ? 'text-bambu-green border-bambu-green'
+                  : 'text-bambu-gray hover:text-gray-900 dark:hover:text-white border-transparent'
+              }`}
+            >
+              <Workflow className="w-4 h-4" />
+              {t('settings.tabs.queuePipelines', 'Pipelines')}
+            </button>
+          </div>
+
+          {queueSubTab === 'pipelines' && <SlicerPipelinesPanel />}
+          {queueSubTab === 'dispatch' && localSettings && (
         <div className="flex flex-col lg:flex-row gap-4 lg:gap-6">
         <div className="flex flex-col lg:flex-row gap-4 lg:gap-6">
           {/* Left Column */}
           {/* Left Column */}
           <div className="lg:w-1/2 space-y-3">
           <div className="lg:w-1/2 space-y-3">
@@ -4375,6 +4425,47 @@ export function SettingsPage() {
           </div>
           </div>
           {/* Right Column */}
           {/* Right Column */}
           <div className="lg:w-1/2 space-y-3">
           <div className="lg:w-1/2 space-y-3">
+
+          {/* Slicer Pipelines (#1425 PR C). Cap on the copies input in
+              the Run-with-pipeline modal to prevent fat-fingered queue
+              floods. Hard ceiling at 1000 enforced server-side. */}
+          <Card id="card-pipelines">
+            <CardHeader>
+              <h3 className="text-base font-semibold text-white flex items-center gap-2">
+                <Workflow className="w-4 h-4 text-bambu-green" />
+                {t('settings.pipelineLimits.title', 'Slicer Pipeline limits')}
+              </h3>
+            </CardHeader>
+            <CardContent className="space-y-2">
+              <div className="flex items-center justify-between gap-3">
+                <div className="flex-1">
+                  <p className="text-sm text-white">
+                    {t('settings.pipelineLimits.maxCopiesLabel', 'Max copies per run')}
+                  </p>
+                  <p className="text-xs text-bambu-gray mt-0.5">
+                    {t(
+                      'settings.pipelineLimits.maxCopiesDesc',
+                      'Upper bound on the copies operators can request when running a pipeline. Server-side hard cap is 1000.',
+                    )}
+                  </p>
+                </div>
+                <input
+                  type="number"
+                  min={1}
+                  max={1000}
+                  value={localSettings.pipeline_max_copies ?? 50}
+                  onChange={(e) => {
+                    const n = parseInt(e.target.value, 10);
+                    if (Number.isNaN(n)) return;
+                    updateSetting('pipeline_max_copies', Math.max(1, Math.min(1000, n)));
+                  }}
+                  aria-label={t('settings.pipelineLimits.maxCopiesLabel', 'Max copies per run')}
+                  className="w-24 px-2 py-1 bg-bambu-dark border border-bambu-dark-tertiary rounded text-white text-sm"
+                />
+              </div>
+            </CardContent>
+          </Card>
+
           {/* Slicer */}
           {/* Slicer */}
           <Card id="card-slicer">
           <Card id="card-slicer">
             <CardHeader>
             <CardHeader>
@@ -4798,6 +4889,8 @@ export function SettingsPage() {
           </Card>
           </Card>
           </div>
           </div>
         </div>
         </div>
+          )}
+        </div>
       )}
       )}
 
 
       {activeTab === 'filament' && localSettings && (
       {activeTab === 'filament' && localSettings && (

Filskillnaden har hållts tillbaka eftersom den är för stor
+ 1 - 0
static/assets/index-BYzbe9TT.css


Filskillnaden har hållts tillbaka eftersom den är för stor
+ 0 - 1
static/assets/index-CFvgt_ZD.css


Filskillnaden har hållts tillbaka eftersom den är för stor
+ 0 - 0
static/assets/index-ChnivgH9.js


+ 2 - 2
static/index.html

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

+ 0 - 0
test_pipeline_archive_source.3mf


+ 0 - 0
test_pipeline_run_1.3mf


Vissa filer visades inte eftersom för många filer har ändrats