Kaynağa Gözat

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 ay önce
ebeveyn
işleme
b26b68c236
61 değiştirilmiş dosya ile 9701 ekleme ve 111 silme
  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

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

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

@@ -222,6 +222,13 @@ _APIKEY_DENIED_PERMISSIONS: frozenset[Permission] = frozenset(
         Permission.SMART_PLUGS_DELETE,
         # Network scanning — operator only (no API-key scope for this).
         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
 
 # Application version - single source of truth
-APP_VERSION = "0.2.4.8"
+APP_VERSION = "0.2.4.9"
 GITHUB_REPO = "maziggy/bambuddy"
 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,
         orca_base_cache,
         pending_upload,
+        pipeline_run,
         print_batch,
         print_log,
         print_queue,
@@ -198,6 +199,7 @@ async def init_db():
         project_bom,
         settings,
         shopping_list,
+        slicer_pipeline,
         slot_preset,
         smart_plug,
         smart_plug_energy_snapshot,
@@ -668,6 +670,23 @@ async def run_migrations(conn):
     """
     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
     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 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.user import User
 
@@ -3479,63 +3498,31 @@ async def seed_default_groups():
                 group.permissions = perms
         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"))
         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 (
-                "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:
                     perms.append(new_perm)
                     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:
                 admin_group.permissions = perms
         await session.commit()
@@ -3594,6 +3581,31 @@ async def seed_default_groups():
                 group.permissions = perms
         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
         if groups_created:
             # 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_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_CONNECT = "websocket:connect"
 
@@ -337,6 +342,11 @@ PERMISSION_CATEGORIES = {
         Permission.GROUPS_UPDATE,
         Permission.GROUPS_DELETE,
     ],
+    "Slicer Pipelines": [
+        Permission.PIPELINES_READ,
+        Permission.PIPELINES_WRITE,
+        Permission.PIPELINES_RUN,
+    ],
     "WebSocket": [
         Permission.WEBSOCKET_CONNECT,
     ],
@@ -452,6 +462,10 @@ DEFAULT_GROUPS = {
             Permission.SYSTEM_READ.value,
             # Settings - read only
             Permission.SETTINGS_READ.value,
+            # Slicer Pipelines - full access
+            Permission.PIPELINES_READ.value,
+            Permission.PIPELINES_WRITE.value,
+            Permission.PIPELINES_RUN.value,
             # WebSocket
             Permission.WEBSOCKET_CONNECT.value,
         ],
@@ -483,6 +497,8 @@ DEFAULT_GROUPS = {
             Permission.STATS_READ.value,
             Permission.SYSTEM_READ.value,
             Permission.SETTINGS_READ.value,
+            # Slicer Pipelines - read only
+            Permission.PIPELINES_READ.value,
             Permission.WEBSOCKET_CONNECT.value,
             # MakerWorld browsing only (no import — that writes to library)
             Permission.MAKERWORLD_VIEW.value,

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

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

+ 5 - 0
backend/app/main.py

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

+ 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("|")]
             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] = {}
             active_idx = active_extruders.index(1)
             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"):
                     try:
                         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.
         # This reflects the actual slicer assignment (respects "Auto For Flush").
         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"):
                 group_id_str = filament_elem.get("group_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")
         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.integration
     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
         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:
     """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 (/^#[0-9a-fA-F]{3,8}$/.test(value)) return true;   // hex color
   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 (/^[a-z0-9._%+-]+@[a-z0-9.-]+\.[a-z]{2,}$/i.test(value)) return true;  // email
   if (/^https?:\/\//.test(value)) return true;          // URL
@@ -147,6 +148,8 @@ const DE_COGNATES = [
 
   'Pause', 'Power', 'System', 'Problem', 'Designer', 'Extruder', 'Firmware',
   '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',
   'Version', 'Slot', 'Live', 'Rate', 'Host', 'Trend', 'Min', 'Admin', 'Cloud',
   'Filament', 'Filaments', 'Software', 'Hardware', 'Avatar', 'Pin', 'Modal',
@@ -179,6 +182,9 @@ const FR_COGNATES = [
   'Filaments', 'Software', 'Hardware', 'Stop', 'Reset', 'Test', 'Code',
   'Token', 'Server', 'Port', 'Plate', 'Layer', 'Active', 'Total', 'Avatar',
   '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',
   'Archives', 'Maintenance', 'Notifications', 'Notification', 'Position',
   'Pause', 'Solution', 'Source', 'Version', 'Format', 'Documentation',
@@ -217,6 +223,9 @@ const IT_COGNATES = [
   'Bambu Cloud', 'Orca Cloud',  // brand names — same in every locale
   'AMS Filament Backup',  // Bambu Lab product/firmware feature name
   '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',
   'Filaments', 'Software', 'Hardware', 'Stop', 'Reset', 'Test', 'Code',
   'Token', 'Server', 'Port', 'Plate', 'Layer', 'Modal', 'Pin', 'Pro', 'Mini',
@@ -260,6 +269,8 @@ const JA_COGNATES = [
 const PT_BR_COGNATES = [
   'Bambu Cloud', 'Orca Cloud',  // brand names — same in every locale
   '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',
   'Software', 'Hardware', 'Stop', 'Reset', 'Test', 'Code', 'Token', 'Server',
   'Port', 'Plate', 'Layer', 'Modal', 'Pin', 'Pro', 'Mini', 'Studio', 'Cache',
@@ -332,6 +343,8 @@ const KO_COGNATES = [
 const ES_COGNATES = [
   'Bambu Cloud', 'Orca Cloud',  // brand names — same in every locale
   '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',
   'Material:', 'Color', 'Hex', 'Local', 'Global', 'China', 'Editable',
   'Normal', 'Metal', 'Multicolor', 'Proxy', 'Host', 'Factor', 'Original',
@@ -353,6 +366,7 @@ const TR_COGNATES = [
   'Filament', 'Firmware', 'Disk', 'Hex', 'Test', 'Port', 'Model', 'Metal',
   'Bambu Cloud', 'Orca Cloud',  // brand names — same in every locale
   'AMS Filament Backup',  // Bambu Lab product/firmware feature name
+  'Pipeline', 'Filament {{n}}',  // #1425 — Slicer Pipelines (TR)
   'Min', 'Normal', 'Platform', 'Net', 'Trend', 'Commit', 'Global', 'Proxy',
   'N/A', 'email',
   'STARTTLS (Port 587)', 'SSL/TLS (Port 465)',

+ 4 - 0
frontend/src/App.tsx

@@ -197,6 +197,10 @@ function App() {
                   <Route index element={<PrintersPage />} />
                   <Route path="archives" element={<ArchivesPage />} />
                   <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="profiles" element={<ProfilesPage />} />
                   <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(),
     getSettings: 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>;
   getLibraryFileFilamentRequirements: 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 {
@@ -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', () => {
   beforeEach(() => {
     vi.clearAllMocks();
@@ -121,6 +136,8 @@ describe('SliceModal', () => {
       plate_id: 1,
       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 () => {
@@ -137,7 +154,7 @@ describe('SliceModal', () => {
     // 4 selects: printer, process, bed-type (#1337), filament. bed-type sits
     // between process and filament — it overrides curr_bed_type on the
     // 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[0].value).toBe('local:1');
     expect(selects[1].value).toBe('local:2');
@@ -158,7 +175,7 @@ describe('SliceModal', () => {
 
     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');
     expect(Array.from(groups).map((g) => g.label)).toEqual([
       'Imported',
@@ -190,7 +207,7 @@ describe('SliceModal', () => {
     });
 
     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');
   });
 
@@ -204,7 +221,7 @@ describe('SliceModal', () => {
     });
 
     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');
   });
 
@@ -260,7 +277,7 @@ describe('SliceModal', () => {
     // printer (0), process (1), bed-type (2), filament (3+). Find the
     // bed-type select by name rather than positional index so this stays
     // 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'),
     ) as HTMLSelectElement;
     expect(bedSelect).toBeDefined();
@@ -315,7 +332,7 @@ describe('SliceModal', () => {
     await waitFor(() => expect(screen.getByText('My Custom X1C')).toBeDefined());
 
     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.click(screen.getByRole('button', { name: /^Slice$/ }));
 
@@ -731,7 +748,7 @@ describe('SliceModal', () => {
 
     await waitFor(() => expect(screen.getByText('X1C')).toBeDefined());
     // 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 () => {
@@ -812,7 +829,7 @@ describe('SliceModal', () => {
     await waitFor(() => expect(screen.getByText('X1C')).toBeDefined());
 
     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
     // (#1337). Auto-picks land on printer/process/filaments; bed-type
     // 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 +
     // 2 filament (#1337) = 5. bed-type sits at index 2, filament slots
     // follow at 3 and 4.
-    const selects = screen.getAllByRole('combobox') as HTMLSelectElement[];
+    const selects = presetSelects();
     expect(selects).toHaveLength(5);
     // Slot 1 (used) is editable, slot 2 (not used) is disabled.
     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;
   // Default printer
   default_printer_id: number | null;
+  pipeline_max_copies: number;
   // Dark mode theme settings
   dark_style: 'classic' | 'glow' | 'vibrant';
   dark_background: 'neutral' | 'warm' | 'cool' | 'oled' | 'slate' | 'forest';
@@ -1502,6 +1503,139 @@ export interface UnifiedPresetsResponse {
   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 {
   library_file_id: number;
   name: string;
@@ -3002,6 +3136,7 @@ export type Permission =
   | 'api_keys:read' | 'api_keys:create' | 'api_keys:update' | 'api_keys:delete'
   | 'users:read' | 'users:create' | 'users:update' | 'users:delete'
   | 'groups:read' | 'groups:create' | 'groups:update' | 'groups:delete'
+  | 'pipelines:read' | 'pipelines:write' | 'pipelines:run'
   | 'websocket:connect';
 
 // Group types
@@ -6199,6 +6334,89 @@ export const api = {
       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.
   // Single source of truth shared with backend (PRINTER_MODEL_MAP); the
   // 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.
   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({
     queryKey: ['slicePlates', source.kind, source.id],
     queryFn: async () => {
@@ -682,6 +711,122 @@ export function SliceModal({ source, onClose }: SliceModalProps) {
                   status === 'ok' (returns null in that case), but the Refresh
                   button stays visible regardless so users can pick up cloud /
                   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
                 label={t('slice.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 { useTranslation } from 'react-i18next';
+import { formatFileSize } from '../utils/file';
 
 type ToastType = 'success' | 'error' | 'warning' | 'info' | 'loading';
 
+// Dispatch-toast types — ported verbatim from
+// 0b43ac0d:frontend/src/contexts/ToastContext.tsx. The visual rendering
+// block below is the legacy code 1:1; the only swap is the event ingestion
+// (now sourced from `bambuddy:dispatch-toast` window events that
+// useWebSocket forwards from the four backend WS event types added in
+// the #1625 follow-up). Same shape, same DOM, same styling, same i18n
+// surface — guarantees the modal looks identical to the pre-scheduler
+// experience that users remember.
+type DispatchJobStatus = 'processing' | 'completed' | 'failed';
+
+interface DispatchToastJob {
+  jobId: number;
+  sourceName: string;
+  printerName: string;
+  status: DispatchJobStatus;
+  uploadBytes?: number;
+  uploadTotalBytes?: number;
+  uploadProgressPct?: number;
+  failReason?: string;
+}
+
+interface DispatchToastData {
+  total: number;
+  processing: number;
+  completed: number;
+  failed: number;
+  jobs: DispatchToastJob[];
+}
+
 interface ToastAction {
   label: string;
   href: string;
@@ -22,17 +53,13 @@ interface Toast {
   type: ToastType;
   persistent?: boolean;
   action?: ToastAction;
+  dispatchData?: DispatchToastData;
 }
 
 interface ToastContextType {
   showToast: (message: string, type?: ToastType) => void;
   showPersistentToast: ShowPersistentToast;
   dismissToast: (id: string) => void;
-  /**
-   * Suppress the visible toast viewport while keeping the state machine alive.
-   * Used by the SpoolBuddy kiosk layout to keep the kiosk display free of
-   * main-app notifications.
-   */
   setViewportSuppressed: (suppressed: boolean) => void;
 }
 
@@ -62,9 +89,47 @@ const bgColors = {
   loading: 'bg-bambu-green/10 border-bambu-green/30',
 };
 
+const DISPATCH_TOAST_ID = 'background-dispatch';
+const DISPATCH_TERMINAL_DISMISS_MS = 3500;
+
+interface DispatchEventDetail {
+  type: string;
+  queue_item_id: number;
+  printer_id?: number | null;
+  printer_name?: string | null;
+  file_name?: string;
+  total_bytes?: number;
+  bytes_transferred?: number;
+  pct?: number;
+  reason?: string;
+}
+
+function isAwaitingPrinter(job: DispatchToastJob): boolean {
+  // Same trick the legacy code used to derive "Awaiting printer…" without
+  // a separate status. While the job is still 'processing' AND upload pct
+  // has reached 99.9%, the printer hasn't yet acked our project_file.
+  return (
+    job.status === 'processing'
+    && typeof job.uploadProgressPct === 'number'
+    && job.uploadProgressPct >= 99.9
+  );
+}
+
+function recomputeAggregate(jobs: DispatchToastJob[]): DispatchToastData {
+  return {
+    total: jobs.length,
+    processing: jobs.filter((j) => j.status === 'processing').length,
+    completed: jobs.filter((j) => j.status === 'completed').length,
+    failed: jobs.filter((j) => j.status === 'failed').length,
+    jobs,
+  };
+}
+
 export function ToastProvider({ children }: { children: ReactNode }) {
+  const { t } = useTranslation();
   const [toasts, setToasts] = useState<Toast[]>([]);
   const [viewportSuppressed, setViewportSuppressed] = useState(false);
+  const [isDispatchCollapsed, setIsDispatchCollapsed] = useState(false);
   const timeoutRefs = useRef<Map<string, ReturnType<typeof setTimeout>>>(new Map());
   // Tracks whether the provider is still mounted. A toast can be triggered by
   // an async callback that resolves AFTER React has unmounted us (common in
@@ -128,6 +193,123 @@ export function ToastProvider({ children }: { children: ReactNode }) {
     setToasts((prev) => prev.filter((t) => t.id !== id));
   }, []);
 
+  // Dispatch-toast ingestion. The four event types from the backend
+  // (queue_item_uploading / upload_progress / acked / failed) map to
+  // the legacy DispatchToastJob shape, then the same auto-dismiss +
+  // aggregate-recompute logic from 0b43ac0d takes over.
+  useEffect(() => {
+    const onDispatchEvent = (event: Event) => {
+      if (!isMountedRef.current) return;
+      const detail = (event as CustomEvent<DispatchEventDetail>).detail;
+      if (!detail || typeof detail.queue_item_id !== 'number') return;
+      const jobId = detail.queue_item_id;
+
+      setToasts((prev) => {
+        const existing = prev.find((toastItem) => toastItem.id === DISPATCH_TOAST_ID);
+        const existingJobs = existing?.dispatchData?.jobs ?? [];
+        const existingJobIndex = existingJobs.findIndex((j) => j.jobId === jobId);
+        const existingJob = existingJobIndex >= 0 ? existingJobs[existingJobIndex] : undefined;
+
+        let nextJob: DispatchToastJob | null = null;
+        const sourceName =
+          detail.file_name
+          || existingJob?.sourceName
+          || t('dispatchToast.untitled');
+        const printerName =
+          detail.printer_name
+          || existingJob?.printerName
+          || (detail.printer_id ? `Printer ${detail.printer_id}` : '');
+
+        switch (detail.type) {
+          case 'queue_item_uploading':
+            // Materialization point — job appears here, never on queue-add.
+            nextJob = {
+              jobId,
+              sourceName,
+              printerName,
+              status: 'processing',
+              uploadBytes: 0,
+              uploadTotalBytes: detail.total_bytes,
+              uploadProgressPct: 0,
+            };
+            break;
+          case 'queue_item_upload_progress':
+            if (!existingJob) return prev;
+            nextJob = {
+              ...existingJob,
+              uploadBytes: detail.bytes_transferred,
+              uploadTotalBytes: detail.total_bytes ?? existingJob.uploadTotalBytes,
+              uploadProgressPct: detail.pct,
+            };
+            break;
+          case 'queue_item_acked':
+            if (!existingJob) return prev;
+            nextJob = {
+              ...existingJob,
+              status: 'completed',
+              uploadProgressPct: 100,
+            };
+            break;
+          case 'queue_item_failed':
+            if (!existingJob) return prev;
+            nextJob = {
+              ...existingJob,
+              status: 'failed',
+              failReason: detail.reason,
+            };
+            break;
+          default:
+            return prev;
+        }
+
+        // Compose the updated jobs list
+        let updatedJobs: DispatchToastJob[];
+        if (existingJob) {
+          updatedJobs = [...existingJobs];
+          updatedJobs[existingJobIndex] = nextJob;
+        } else {
+          updatedJobs = [...existingJobs, nextJob];
+        }
+
+        const dispatchData = recomputeAggregate(updatedJobs);
+
+        const toastShape: Toast = {
+          id: DISPATCH_TOAST_ID,
+          message: t('dispatchToast.startingPrints'),
+          type: 'loading',
+          persistent: true,
+          dispatchData,
+        };
+
+        if (existing) {
+          return prev.map((toastItem) =>
+            toastItem.id === DISPATCH_TOAST_ID ? toastShape : toastItem,
+          );
+        }
+        return [...prev, toastShape];
+      });
+    };
+
+    window.addEventListener('bambuddy:dispatch-toast', onDispatchEvent);
+    return () => window.removeEventListener('bambuddy:dispatch-toast', onDispatchEvent);
+  }, [t]);
+
+  // Auto-dismiss the wrapper once every job has reached a terminal state.
+  useEffect(() => {
+    const dispatchToast = toasts.find((tst) => tst.id === DISPATCH_TOAST_ID);
+    if (!dispatchToast?.dispatchData) return;
+    const data = dispatchToast.dispatchData;
+    if (data.total === 0 || data.processing !== 0) return;
+    const existing = timeoutRefs.current.get(DISPATCH_TOAST_ID);
+    if (existing) clearTimeout(existing);
+    const timeout = setTimeout(() => {
+      if (!isMountedRef.current) return;
+      setToasts((prev) => prev.filter((tst) => tst.id !== DISPATCH_TOAST_ID));
+      timeoutRefs.current.delete(DISPATCH_TOAST_ID);
+    }, DISPATCH_TERMINAL_DISMISS_MS);
+    timeoutRefs.current.set(DISPATCH_TOAST_ID, timeout);
+  }, [toasts]);
+
   return (
     <ToastContext.Provider value={{ showToast, showPersistentToast, dismissToast, setViewportSuppressed }}>
       {children}
@@ -139,30 +321,151 @@ export function ToastProvider({ children }: { children: ReactNode }) {
         {toasts.map((toast) => (
           <div
             key={toast.id}
-            className={`rounded-lg border shadow-lg backdrop-blur-sm animate-slide-in ${bgColors[toast.type]} flex items-center gap-3 px-4 py-3`}
+            className={`rounded-lg border shadow-lg backdrop-blur-sm animate-slide-in ${bgColors[toast.type]} ${
+              toast.dispatchData ? 'w-[420px] p-3' : 'flex items-center gap-3 px-4 py-3'
+            }`}
+            data-testid={toast.dispatchData ? 'dispatch-toast-wrapper' : undefined}
           >
-            {icons[toast.type]}
-            <span className="text-white text-sm">{toast.message}</span>
-            {toast.action && (
-              <a
-                href={toast.action.href}
-                target="_blank"
-                rel="noopener noreferrer"
-                onClick={() => {
-                  toast.action?.onClick?.();
-                  dismissToast(toast.id);
-                }}
-                className="ml-2 px-2 py-1 rounded text-xs font-medium bg-bambu-green/20 text-bambu-green hover:bg-bambu-green/30 whitespace-nowrap"
-              >
-                {toast.action.label}
-              </a>
+            {toast.dispatchData ? (
+              // Legacy dispatch-toast rendering — verbatim port from
+              // 0b43ac0d:frontend/src/contexts/ToastContext.tsx lines
+              // 515–650. Same DOM, same Tailwind classes, same uppercase
+              // status chip, same `awaitingPrinter` derivation. Only
+              // diff vs legacy: no cancel button (the BG dispatch
+              // cancel endpoint doesn't exist in the scheduler model).
+              <>
+                <div className="flex items-start justify-between gap-3">
+                  <div className="flex items-start gap-2">
+                    {icons[toast.type]}
+                    <div>
+                      <p className="text-white text-sm font-medium">{t('dispatchToast.startingPrints')}</p>
+                      <p className="text-xs text-bambu-gray mt-0.5">
+                        {t('dispatchToast.progressSummary', {
+                          complete: toast.dispatchData.completed + toast.dispatchData.failed,
+                          total: toast.dispatchData.total,
+                          processing: toast.dispatchData.processing,
+                        })}
+                      </p>
+                    </div>
+                  </div>
+                  <div className="flex items-center gap-1">
+                    <button
+                      onClick={() => setIsDispatchCollapsed((prev) => !prev)}
+                      className="text-bambu-gray hover:text-white transition-colors"
+                      aria-label={isDispatchCollapsed ? t('dispatchToast.expandDetails') : t('dispatchToast.collapseDetails')}
+                      data-testid="dispatch-toast-collapse"
+                    >
+                      {isDispatchCollapsed ? <ChevronUp className="w-4 h-4" /> : <ChevronDown className="w-4 h-4" />}
+                    </button>
+                    <button
+                      onClick={() => dismissToast(toast.id)}
+                      className="text-bambu-gray hover:text-white transition-colors"
+                      aria-label={t('dispatchToast.dismiss')}
+                      data-testid="dispatch-toast-dismiss"
+                    >
+                      <X className="w-4 h-4" />
+                    </button>
+                  </div>
+                </div>
+
+                {!isDispatchCollapsed && (
+                  <div className="mt-3 space-y-2 max-h-64 overflow-y-auto pr-1">
+                    {toast.dispatchData.jobs.map((job) => {
+                      const uploadDoneAwaitingPrinter = isAwaitingPrinter(job);
+                      const barColorByStatus: Record<DispatchJobStatus, string> = {
+                        processing: 'bg-bambu-green',
+                        completed: 'bg-green-500',
+                        failed: 'bg-red-500',
+                      };
+                      const progressByStatus: Record<DispatchJobStatus, number> = {
+                        processing: 60,
+                        completed: 100,
+                        failed: 100,
+                      };
+                      return (
+                        <div
+                          key={job.jobId}
+                          className="rounded border border-white/10 bg-black/15 p-2"
+                          data-testid={`dispatch-toast-job-${job.jobId}`}
+                        >
+                          <div className="flex items-center justify-between gap-2">
+                            <span className="text-xs text-white truncate" title={job.sourceName}>
+                              {job.sourceName}
+                            </span>
+                            <span
+                              className="text-[11px] uppercase tracking-wide text-bambu-gray"
+                              data-testid={`dispatch-toast-status-${job.jobId}`}
+                            >
+                              {t(`dispatchToast.status.${job.status}`)}
+                            </span>
+                          </div>
+                          {job.printerName && (
+                            <div className="text-[11px] text-bambu-gray truncate" title={job.printerName}>
+                              {job.printerName}
+                            </div>
+                          )}
+                          {job.status === 'processing' ? (
+                            uploadDoneAwaitingPrinter ? (
+                              <div className="text-[11px] text-bambu-gray truncate">
+                                {t('dispatchToast.awaitingPrinter')}
+                              </div>
+                            ) : typeof job.uploadBytes === 'number'
+                                && typeof job.uploadTotalBytes === 'number'
+                                && job.uploadTotalBytes > 0 ? (
+                              <div className="text-[11px] text-bambu-gray truncate">
+                                {formatFileSize(job.uploadBytes)} / {formatFileSize(job.uploadTotalBytes)}
+                                {typeof job.uploadProgressPct === 'number' ? ` (${job.uploadProgressPct.toFixed(1)}%)` : ''}
+                              </div>
+                            ) : null
+                          ) : job.status === 'failed' && job.failReason ? (
+                            <div className="text-[11px] text-red-400 truncate">
+                              {t(`dispatchToast.failed.${job.failReason}`, { defaultValue: t('dispatchToast.failed.generic') })}
+                            </div>
+                          ) : null}
+                          <div className="mt-1 h-1.5 w-full rounded bg-white/10 overflow-hidden">
+                            <div
+                              className={`h-full ${barColorByStatus[job.status]} transition-all duration-300 ${uploadDoneAwaitingPrinter ? 'animate-pulse' : ''}`}
+                              style={{
+                                width: `${
+                                  job.status === 'processing' && typeof job.uploadProgressPct === 'number'
+                                    ? Math.max(0, Math.min(100, job.uploadProgressPct))
+                                    : progressByStatus[job.status]
+                                }%`,
+                              }}
+                            />
+                          </div>
+                        </div>
+                      );
+                    })}
+                  </div>
+                )}
+              </>
+            ) : (
+              <>
+                {icons[toast.type]}
+                <span className="text-white text-sm">{toast.message}</span>
+                {toast.action && (
+                  <a
+                    href={toast.action.href}
+                    target="_blank"
+                    rel="noopener noreferrer"
+                    onClick={() => {
+                      toast.action?.onClick?.();
+                      dismissToast(toast.id);
+                    }}
+                    className="ml-2 px-2 py-1 rounded text-xs font-medium bg-bambu-green/20 text-bambu-green hover:bg-bambu-green/30 whitespace-nowrap"
+                  >
+                    {toast.action.label}
+                  </a>
+                )}
+                <button
+                  onClick={() => dismissToast(toast.id)}
+                  className="ml-2 text-bambu-gray hover:text-white transition-colors"
+                >
+                  <X className="w-4 h-4" />
+                </button>
+              </>
             )}
-            <button
-              onClick={() => dismissToast(toast.id)}
-              className="ml-2 text-bambu-gray hover:text-white transition-colors"
-            >
-              <X className="w-4 h-4" />
-            </button>
           </div>
         ))}
       </div>

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

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

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

@@ -101,6 +101,8 @@ export default {
     now: 'Jetzt',
     collapse: 'Einklappen',
     expand: 'Ausklappen',
+    previous: 'Zurück',
+    next: 'Weiter',
     viewArchive: 'Archiv anzeigen',
     viewInFileManager: 'Im Dateimanager anzeigen',
     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: {
     filamentShort: {
@@ -1102,6 +1173,7 @@ export default {
       queue: 'Warteschlange',
       history: 'Verlauf',
       timeline: 'Zeitachse',
+      pipelines: 'Druckabläufe',
     },
     layout: {
       flatList: 'Liste',
@@ -1537,6 +1609,8 @@ export default {
       smartPlugs: 'Smart Plugs',
       notifications: 'Benachrichtigungen',
       queue: 'Workflow',
+      queueDispatch: 'Warteschlange & Dispatch',
+      queuePipelines: 'Pipelines',
       filament: 'Filament',
       network: 'Netzwerk',
       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.',
     },
 
+
+    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)
@@ -3759,6 +3923,41 @@ export default {
     deleteConfirm: 'Möchten Sie dieses Filament wirklich löschen?',
     importFromPrinter: 'Vom Drucker importieren',
     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)
@@ -3823,6 +4022,22 @@ export default {
       texturedPEI: 'Textured 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

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

@@ -101,6 +101,8 @@ export default {
     now: 'Now',
     collapse: 'Collapse',
     expand: 'Expand',
+    previous: 'Previous',
+    next: 'Next',
     viewArchive: 'View archive',
     viewInFileManager: 'View in File Manager',
     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: {
     title: 'Print Queue',
@@ -1107,6 +1183,7 @@ export default {
       queue: 'Queue',
       history: 'History',
       timeline: 'Timeline',
+      pipelines: 'Pipelines',
     },
     // Layout toggle on the Queue tab — distinct from the sort dropdown
     // (those control order; these control whether items render as one flat
@@ -1549,6 +1626,8 @@ export default {
       smartPlugs: 'Smart Plugs',
       notifications: 'Notifications',
       queue: 'Workflow',
+      queueDispatch: 'Queue & Dispatch',
+      queuePipelines: 'Pipelines',
       filament: 'Filament',
       network: 'Network',
       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.',
     },
 
+    // 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)
@@ -3774,6 +3952,46 @@ export default {
     deleteConfirm: 'Are you sure you want to delete this filament?',
     importFromPrinter: 'Import from Printer',
     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)
@@ -3838,6 +4056,22 @@ export default {
       texturedPEI: 'Textured 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

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

@@ -101,6 +101,8 @@ export default {
     now: 'Ahora',
     collapse: 'Contraer',
     expand: 'Expandir',
+    previous: 'Anterior',
+    next: 'Siguiente',
     viewArchive: 'Ver archivo',
     viewInFileManager: 'Ver en el gestor de archivos',
     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: {
     filamentShort: {
@@ -1102,6 +1173,7 @@ export default {
       queue: 'Cola',
       history: 'Historial',
       timeline: 'Cronología',
+      pipelines: 'Procesos',
     },
     layout: {
       flatList: 'Lista',
@@ -1538,6 +1610,8 @@ export default {
       smartPlugs: 'Enchufes inteligentes',
       notifications: 'Notificaciones',
       queue: 'Flujo de trabajo',
+      queueDispatch: 'Cola y Despacho',
+      queuePipelines: 'Pipelines',
       filament: 'Filamento',
       network: 'Red',
       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.',
     },
 
+
+    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)
@@ -3762,6 +3926,41 @@ export default {
     deleteConfirm: '¿Está seguro de que desea eliminar este filamento?',
     importFromPrinter: 'Importar desde la impresora',
     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)
@@ -3826,6 +4025,22 @@ export default {
       texturedPEI: 'Textured 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

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

@@ -101,6 +101,8 @@ export default {
     now: 'Maintenant',
     collapse: 'Réduire',
     expand: 'Développer',
+    previous: 'Précédent',
+    next: 'Suivant',
     viewArchive: 'Voir l\'archive',
     viewInFileManager: 'Voir dans le gestionnaire de fichiers',
     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: {
     filamentShort: {
@@ -1102,6 +1173,7 @@ export default {
       queue: 'File',
       history: 'Historique',
       timeline: 'Chronologie',
+      pipelines: 'Exécutions',
     },
     layout: {
       flatList: 'Liste',
@@ -1537,6 +1609,8 @@ export default {
       smartPlugs: 'Prises connectées',
       notifications: 'Notifications',
       queue: 'Flux de travail',
+      queueDispatch: 'File & Distribution',
+      queuePipelines: 'Pipelines',
       filament: 'Filament',
       network: 'Réseau',
       apiKeys: 'Clés API',
@@ -2519,6 +2593,96 @@ export default {
       commandQueued: 'Commande en file d\'attente',
       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)
@@ -3748,6 +3912,41 @@ export default {
     deleteConfirm: 'Supprimer ce filament ?',
     importFromPrinter: 'Importer de l\'imprimante',
     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)
@@ -3812,6 +4011,22 @@ export default {
       texturedPEI: 'Textured 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

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

@@ -101,6 +101,8 @@ export default {
     now: 'Ora',
     collapse: 'Comprimi',
     expand: 'Espandi',
+    previous: 'Precedente',
+    next: 'Successivo',
     viewArchive: 'Vedi archivio',
     viewInFileManager: 'Vedi nel Gestore file',
     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: {
     filamentShort: {
@@ -1102,6 +1173,7 @@ export default {
       queue: 'Coda',
       history: 'Cronologia',
       timeline: 'Linea temporale',
+      pipelines: 'Pipeline',
     },
     layout: {
       flatList: 'Elenco',
@@ -1537,6 +1609,8 @@ export default {
       smartPlugs: 'Prese smart',
       notifications: 'Notifiche',
       queue: 'Flusso',
+      queueDispatch: 'Coda e Dispatch',
+      queuePipelines: 'Pipeline',
       filament: 'Filamento',
       network: 'Rete',
       apiKeys: 'Chiavi API',
@@ -2518,6 +2592,96 @@ export default {
       commandQueued: 'Comando in coda',
       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)
@@ -3747,6 +3911,41 @@ export default {
     deleteConfirm: 'Sei sicuro di voler eliminare questo filamento?',
     importFromPrinter: 'Importa da stampante',
     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)
@@ -3811,6 +4010,22 @@ export default {
       texturedPEI: 'Textured 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

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

@@ -101,6 +101,8 @@ export default {
     now: '今すぐ',
     collapse: '折りたたむ',
     expand: '展開',
+    previous: '前へ',
+    next: '次へ',
     viewArchive: 'アーカイブを表示',
     viewInFileManager: 'ファイルマネージャーで表示',
     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: {
     filamentShort: {
@@ -1101,6 +1172,7 @@ export default {
       queue: 'キュー',
       history: '履歴',
       timeline: 'タイムライン',
+      pipelines: 'パイプライン',
     },
     layout: {
       flatList: 'リスト',
@@ -1536,6 +1608,8 @@ export default {
       smartPlugs: 'スマートプラグ',
       notifications: '通知',
       queue: 'ワークフロー',
+      queueDispatch: 'キューとディスパッチ',
+      queuePipelines: 'パイプライン',
       filament: 'フィラメント',
       network: 'ネットワーク',
       apiKeys: 'APIキー',
@@ -2530,6 +2604,96 @@ export default {
       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)
@@ -3759,6 +3923,41 @@ export default {
     deleteConfirm: 'このフィラメントを削除しますか?',
     importFromPrinter: 'プリンターからインポート',
     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)
@@ -3823,6 +4022,22 @@ export default {
       texturedPEI: 'Textured 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

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

@@ -98,6 +98,8 @@ export default {
     now: '지금',
     collapse: '접기',
     expand: '펼치기',
+    previous: '이전',
+    next: '다음',
     viewArchive: '아카이브 보기',
     viewInFileManager: '파일 관리자에서 보기',
     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: {
     title: '인쇄 대기열',
     subtitle: '인쇄 작업을 예약하고 관리하세요',
@@ -1046,6 +1115,7 @@ export default {
       queue: '큐',
       history: '기록',
       timeline: '타임라인',
+      pipelines: '파이프라인',
     },
     layout: {
       flatList: '목록',
@@ -1453,6 +1523,8 @@ export default {
       smartPlugs: '스마트 플러그',
       notifications: '알림',
       queue: '워크플로우',
+      queueDispatch: '큐 및 디스패치',
+      queuePipelines: '파이프라인',
       filament: '필라멘트',
       network: '네트워크',
       apiKeys: 'API 키',
@@ -2387,7 +2459,97 @@ export default {
     updateEnergyCost: '전기 요금 업데이트',
     updateEnergyCostDescription: '이 키가 /settings/electricity-price에 새 kWh당 전기 요금을 POST할 수 있도록 허용합니다. Home Assistant 동적 요금 자동화(Tibber, Octopus 등)에 유용합니다. API 키로 쓸 수 있는 유일한 설정 필드입니다.',
     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: {
     printStarted: {
@@ -3555,7 +3717,42 @@ export default {
     noFilaments: '라이브러리에 필라멘트 없음',
     deleteConfirm: '이 필라멘트를 삭제하시겠습니까?',
     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: {
     title: '모델 슬라이싱',
@@ -3617,7 +3814,23 @@ export default {
       highTemp: '고온 플레이트',
       texturedPEI: '텍스처 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: {
     title: 'Spoolman 통합',

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

@@ -101,6 +101,8 @@ export default {
     now: 'Agora',
     collapse: 'Recolher',
     expand: 'Expandir',
+    previous: 'Anterior',
+    next: 'Próximo',
     viewArchive: 'Ver arquivo',
     viewInFileManager: 'Ver no Gerenciador de Arquivos',
     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: {
     filamentShort: {
@@ -1102,6 +1173,7 @@ export default {
       queue: 'Fila',
       history: 'Histórico',
       timeline: 'Linha do tempo',
+      pipelines: 'Processos',
     },
     layout: {
       flatList: 'Lista',
@@ -1537,6 +1609,8 @@ export default {
       smartPlugs: 'Tomadas Inteligentes',
       notifications: 'Notificações',
       queue: 'Fluxo',
+      queueDispatch: 'Fila e Dispatch',
+      queuePipelines: 'Pipelines',
       filament: 'Filamento',
       network: 'Rede',
       apiKeys: 'Chaves API',
@@ -2518,6 +2592,96 @@ export default {
       commandQueued: 'Comando enfileirado',
       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)
@@ -3747,6 +3911,41 @@ export default {
     deleteConfirm: 'Tem certeza de que deseja excluir este filamento?',
     importFromPrinter: 'Importar da Impressora',
     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)
@@ -3811,6 +4010,22 @@ export default {
       texturedPEI: 'Textured 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

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

@@ -101,6 +101,8 @@ export default {
     now: 'Şimdi',
     collapse: 'Daralt',
     expand: 'Genişlet',
+    previous: 'Önceki',
+    next: 'Sonraki',
     viewArchive: 'Arşivi gör',
     viewInFileManager: 'Dosya Yöneticisinde gör',
     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ı
+  // 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: {
     title: 'Baskı Kuyruğu',
     subtitle: 'Baskı işlerinizi zamanlayın ve yönetin',
@@ -1102,6 +1173,7 @@ export default {
       queue: 'Kuyruk',
       history: 'Geçmiş',
       timeline: 'Zaman çizelgesi',
+      pipelines: 'Pipeline\'lar',
     },
     layout: {
       flatList: 'Liste',
@@ -1539,6 +1611,8 @@ export default {
       smartPlugs: 'Akıllı Prizler',
       notifications: 'Bildirimler',
       queue: 'İş Akışı',
+      queueDispatch: 'Kuyruk ve Sevkıyat',
+      queuePipelines: 'Pipeline\'lar',
       filament: 'Filament',
       network: 'Ağ',
       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.',
     },
 
+
+    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)
@@ -3749,6 +3913,41 @@ export default {
     deleteConfirm: 'Bu filamenti silmek istediğinizden emin misiniz?',
     importFromPrinter: 'Yazıcıdan İçe 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)
@@ -3813,6 +4012,22 @@ export default {
       texturedPEI: 'Dokulu 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

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

@@ -101,6 +101,8 @@ export default {
     now: '现在',
     collapse: '收起',
     expand: '展开',
+    previous: '上一页',
+    next: '下一页',
     viewArchive: '查看归档',
     viewInFileManager: '在文件管理器中查看',
     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: {
     filamentShort: {
@@ -1102,6 +1173,7 @@ export default {
       queue: '队列',
       history: '历史',
       timeline: '时间线',
+      pipelines: '流水线',
     },
     layout: {
       flatList: '列表',
@@ -1537,6 +1609,8 @@ export default {
       smartPlugs: '智能插座',
       notifications: '通知',
       queue: '工作流',
+      queueDispatch: '队列与调度',
+      queuePipelines: '流水线',
       filament: '耗材',
       network: '网络',
       apiKeys: 'API 密钥',
@@ -2518,6 +2592,96 @@ export default {
       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)
@@ -3747,6 +3911,41 @@ export default {
     deleteConfirm: '确定要删除此耗材吗?',
     importFromPrinter: '从打印机导入',
     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)
@@ -3811,6 +4010,22 @@ export default {
       texturedPEI: 'Textured 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

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

@@ -101,6 +101,8 @@ export default {
     now: '現在',
     collapse: '收起',
     expand: '展開',
+    previous: '上一頁',
+    next: '下一頁',
     viewArchive: '檢視歸檔',
     viewInFileManager: '在檔案管理器中檢視',
     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: {
     filamentShort: {
@@ -1102,6 +1173,7 @@ export default {
       queue: '佇列',
       history: '歷史',
       timeline: '時間軸',
+      pipelines: '管線',
     },
     layout: {
       flatList: '清單',
@@ -1537,6 +1609,8 @@ export default {
       smartPlugs: '智慧插座',
       notifications: '通知',
       queue: '工作流程',
+      queueDispatch: '佇列與分派',
+      queuePipelines: '管線',
       filament: '耗材',
       network: '網路',
       apiKeys: 'API 金鑰',
@@ -2518,6 +2592,96 @@ export default {
       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)
@@ -3747,6 +3911,41 @@ export default {
     deleteConfirm: '確定要刪除此耗材嗎?',
     importFromPrinter: '從印表機匯入',
     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)
@@ -3811,6 +4010,22 @@ export default {
       texturedPEI: 'Textured 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

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

@@ -59,6 +59,7 @@ import {
 } from 'lucide-react';
 import { api } from '../api/client';
 import { SliceModal } from '../components/SliceModal';
+import { RunWithPipelineModal } from '../components/RunWithPipelineModal';
 import { openInSlicer, type SlicerType } from '../utils/slicer';
 import { formatDateTime, formatDateOnly, parseUTCDate, type TimeFormat, formatDuration } from '../utils/date';
 import { getCurrencySymbol } from '../utils/currency';
@@ -183,6 +184,7 @@ function ArchiveCard({
   const navigate = useNavigate();
   const [showReprint, setShowReprint] = useState(false);
   const [showSliceModal, setShowSliceModal] = useState(false);
+  const [showRunPipeline, setShowRunPipeline] = useState(false);
   const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
   // #1343: when true, the delete also drops the row from Quick Stats. Default
   // 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'),
@@ -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 */}
       {showDeleteConfirm && (
         <ConfirmModal
@@ -1582,6 +1606,7 @@ function ArchiveListRow({
   const navigate = useNavigate();
   const [showReprint, setShowReprint] = useState(false);
   const [showSliceModal, setShowSliceModal] = useState(false);
+  const [showRunPipeline, setShowRunPipeline] = useState(false);
   const [showTimelapse, setShowTimelapse] = useState(false);
   const [showTimelapseSelect, setShowTimelapseSelect] = useState(false);
   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'),
@@ -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 */}
       {showDeleteConfirm && (
         <ConfirmModal

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

@@ -32,6 +32,7 @@ import {
   Archive as ArchiveIcon,
   Briefcase,
   Cog,
+  Play,
   Printer,
   Pencil,
   Image,
@@ -58,6 +59,7 @@ import { ConfirmModal } from '../components/ConfirmModal';
 import { PrintModal } from '../components/PrintModal';
 import { ModelViewerModal } from '../components/ModelViewerModal';
 import { SliceModal } from '../components/SliceModal';
+import { RunWithPipelineModal } from '../components/RunWithPipelineModal';
 import { BulkTagsPickerModal } from '../components/BulkTagsPickerModal';
 import { FileUploadModal } from '../components/FileUploadModal';
 import { FolderReadmePanel } from '../components/FolderReadmePanel';
@@ -729,6 +731,7 @@ interface FileCardProps {
   onDownload: (id: number) => void;
   onPrint?: (file: LibraryFileListItem) => void;
   onSlice?: (file: LibraryFileListItem) => void;
+  onRunPipeline?: (file: LibraryFileListItem) => void;
   useSlicerApi?: boolean;
   onPreview3d?: (file: LibraryFileListItem) => void;
   onRename?: (file: LibraryFileListItem) => void;
@@ -741,7 +744,7 @@ interface FileCardProps {
   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);
 
   return (
@@ -864,6 +867,19 @@ function FileCard({ file, isSelected, isMobile, onSelect, onDelete, onDownload,
                   {t('slice.action')}
                 </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') && (
                 <button
                   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 [printFile, setPrintFile] = 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 [thumbnailVersions, setThumbnailVersions] = useState<Record<number, number>>({});
   const [viewerFile, setViewerFile] = useState<LibraryFileListItem | null>(null);
@@ -2265,6 +2283,7 @@ export function FileManagerPage() {
                     onDownload={handleDownload}
                     onPrint={setPrintFile}
                     onSlice={setSliceFile}
+                    onRunPipeline={setRunPipelineFile}
                     useSlicerApi={settings?.use_slicer_api ?? false}
                     onPreview3d={(f) => {
                       // Sliced files (.gcode / .gcode.3mf) open the same
@@ -2446,6 +2465,20 @@ export function FileManagerPage() {
                           <Cog className="w-4 h-4" />
                         </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') && (
                         <button
                           onClick={() => {
@@ -2648,6 +2681,13 @@ export function FileManagerPage() {
         />
       )}
 
+      {runPipelineFile && (
+        <RunWithPipelineModal
+          source={{ kind: 'libraryFile', id: runPipelineFile.id, filename: runPipelineFile.filename }}
+          onClose={() => setRunPipelineFile(null)}
+        />
+      )}
+
       {viewerFile && (
         <ModelViewerModal
           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,
   Ban,
   PlayCircle,
+  Workflow,
 } from 'lucide-react';
 import { api, ApiError } from '../api/client';
+import { PipelineRunsView } from './PipelineRunsPage';
 import { type TimeFormat, formatETA, formatDuration, formatRelativeTime, parseUTCDate } from '../utils/date';
 import { getBedTypeInfo } from '../utils/bedType';
 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.
   // Tabbed page structure: Active queue stays as the main view; History
   // 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');
-    if (saved === 'history' || saved === 'timeline') return saved;
+    if (saved === 'history' || saved === 'timeline' || saved === 'pipelines') return saved;
     return 'queue';
   });
   // 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: '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 },
+          // 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 }) => (
           <button
             key={id}
@@ -1940,15 +1953,15 @@ export function QueuePage() {
         ))}
       </div>
 
-      {/* Summary Stats */}
-      <QueueStatsBar
+      {/* Summary Stats — about the print queue, not pipelines. */}
+      {activeTab !== 'pipelines' && <QueueStatsBar
         activeCount={activeItems.length}
         pendingCount={pendingItems.length}
         totalTime={totalQueueTime}
         totalWeight={totalWeight}
         historyCount={historyItems.length}
         t={t}
-      />
+      />}
 
       {/* #1818: Resume-after-failure banner. One row per printer whose queue
           is gated by a prior failed/aborted print. Visible regardless of
@@ -1987,7 +2000,10 @@ export function QueuePage() {
         </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">
         <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"
@@ -2049,6 +2065,7 @@ export function QueuePage() {
           </Button>
         )}
       </div>
+      )}
 
       {/* Queue-tab controls: layout toggle (Position / Printer) + SJF.
           Hidden on History/Timeline tabs since they don't apply. */}
@@ -2095,7 +2112,11 @@ export function QueuePage() {
         </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>
       ) : queue?.length === 0 ? (
         <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 { 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 { useNavigate, useSearchParams } from 'react-router-dom';
 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 { Card, CardContent, CardDensityProvider, CardHeader } from '../components/Card';
 import { SlicerBundlesPanel } from '../components/SlicerBundlesPanel';
+import { SlicerPipelinesPanel } from '../components/SlicerPipelinesPanel';
 import { CameraTokensSection } from './CameraTokensPage';
 import { Collapsible } from '../components/Collapsible';
 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 [activeTab, setActiveTab] = useState<TabType>(initialTab);
   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
   const handleTabChange = (tab: TabType) => {
@@ -197,6 +203,10 @@ export function SettingsPage() {
     if (tab === 'users') {
       setUsersSubTab('users');
     }
+    if (tab === 'queue') {
+      setQueueSubTab('dispatch');
+      searchParams.delete('sub');
+    }
     if (tab === 'general') {
       searchParams.delete('tab');
     } else {
@@ -204,6 +214,17 @@ export function SettingsPage() {
     }
     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 [newAPIKeyName, setNewAPIKeyName] = useState('');
   const [newAPIKeyPermissions, setNewAPIKeyPermissions] = useState({
@@ -4070,8 +4091,37 @@ export function SettingsPage() {
       )}
 
       {/* 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">
           {/* Left Column */}
           <div className="lg:w-1/2 space-y-3">
@@ -4375,6 +4425,47 @@ export function SettingsPage() {
           </div>
           {/* Right Column */}
           <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 */}
           <Card id="card-slicer">
             <CardHeader>
@@ -4798,6 +4889,8 @@ export function SettingsPage() {
           </Card>
           </div>
         </div>
+          )}
+        </div>
       )}
 
       {activeTab === 'filament' && localSettings && (

Dosya farkı çok büyük olduğundan ihmal edildi
+ 1 - 0
static/assets/index-BYzbe9TT.css


Dosya farkı çok büyük olduğundan ihmal edildi
+ 0 - 1
static/assets/index-CFvgt_ZD.css


Dosya farkı çok büyük olduğundan ihmal edildi
+ 0 - 0
static/assets/index-ChnivgH9.js


+ 2 - 2
static/index.html

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

+ 0 - 0
test_pipeline_archive_source.3mf


+ 0 - 0
test_pipeline_run_1.3mf


Bu fark içinde çok fazla dosya değişikliği olduğu için bazı dosyalar gösterilmiyor