Sfoglia il codice sorgente

feat(slicer): Pipelines — archive entry point + slicer progress toast (#1425 PR B follow-up)

Two real gaps from the PR B drop:

1. Run-with-pipeline only existed in the file manager. Operators who keep
   working files in archives had to copy them to the library to use a
   pipeline.

2. Triggering a slice via a pipeline produced a silent multi-second-to-
   minute wait. The manual SliceModal flow shows the sticky
   "Slicing X - Generating G-code 75%" persistent toast; the pipeline
   path went through asyncio.create_task directly and never registered
   with SliceJobTracker.

Archive entry point
- POST /slicer-pipelines/{id}/check-eligibility and /run accept
  source_archive_id as an alternative to source_library_file_id (XOR,
  enforced by Pydantic validator).
- PipelineRun.source_archive_id is a new nullable FK column with the
  ALTER TABLE migration in run_migrations (idempotent via _safe_execute,
  works on SQLite + Postgres).
- _resolve_source branches: archive path reads source_3mf_path with
  fallback to file_path, mirroring routes/archives.py.
- ArchiveCard's context menu picks up a "Run with pipeline" item next to
  Slice (only on source archives), gated on useSlicerApi + pipelines:run.
  Slice (only on source archives), gated on useSlicerApi + pipelines:run.
- Path-safety: SEC-PATH-OK markers added at both LibraryFile.file_path
  and archive.source_3mf_path join sites, citing the upload-time
  validators.

Progress toast
- Pipeline orchestration is now the `run` callable of a
  slice_dispatch.enqueue call — the same dispatcher SliceModal uses —
  instead of a bare asyncio.create_task. The SliceJob lifecycle drives
  the existing progress toast end to end with no separate notification
  surface for pipeline runs.
- PipelineRun.slice_job_id is set before the 202 returns.
- RunWithPipelineModal calls useSliceJobTracker().trackJob() from
  runMutation.onSuccess.
- RunWithPipelineModal source prop is now {kind, id, filename}
  mirroring SliceModal.SliceSource; api.checkPipelineEligibility +
  api.runPipeline take a discriminated-union source argument.
maziggy 2 mesi fa
parent
commit
4bbf0f031e
34 ha cambiato i file con 3098 aggiunte e 13 eliminazioni
  1. 0 0
      CHANGELOG.md
  2. 618 0
      backend/app/api/routes/pipeline_runs.py
  3. 16 0
      backend/app/api/routes/slicer_pipelines.py
  4. 10 0
      backend/app/core/database.py
  5. 3 0
      backend/app/main.py
  6. 3 0
      backend/app/models/__init__.py
  7. 105 0
      backend/app/models/pipeline_run.py
  8. 124 0
      backend/app/schemas/pipeline_run.py
  9. 7 0
      backend/app/schemas/slicer_pipeline.py
  10. 263 0
      backend/app/services/pipeline_eligibility.py
  11. 529 0
      backend/tests/integration/test_pipeline_runs_api.py
  12. 1 1
      frontend/scripts/check-i18n-parity.mjs
  13. 208 0
      frontend/src/__tests__/components/RunWithPipelineModal.test.tsx
  14. 112 1
      frontend/src/api/client.ts
  15. 362 0
      frontend/src/components/RunWithPipelineModal.tsx
  16. 136 7
      frontend/src/components/SlicerPipelinesPanel.tsx
  17. 46 0
      frontend/src/i18n/locales/de.ts
  18. 50 0
      frontend/src/i18n/locales/en.ts
  19. 46 0
      frontend/src/i18n/locales/es.ts
  20. 46 0
      frontend/src/i18n/locales/fr.ts
  21. 46 0
      frontend/src/i18n/locales/it.ts
  22. 46 0
      frontend/src/i18n/locales/ja.ts
  23. 47 1
      frontend/src/i18n/locales/ko.ts
  24. 46 0
      frontend/src/i18n/locales/pt-BR.ts
  25. 46 0
      frontend/src/i18n/locales/tr.ts
  26. 46 0
      frontend/src/i18n/locales/zh-CN.ts
  27. 46 0
      frontend/src/i18n/locales/zh-TW.ts
  28. 47 0
      frontend/src/pages/ArchivesPage.tsx
  29. 41 1
      frontend/src/pages/FileManagerPage.tsx
  30. 0 0
      static/assets/index-Bydn78aP.css
  31. 0 0
      static/assets/index-CEw4Iy3m.js
  32. 2 2
      static/index.html
  33. 0 0
      test_pipeline_archive_source.3mf
  34. 0 0
      test_pipeline_run_1.3mf

File diff suppressed because it is too large
+ 0 - 0
CHANGELOG.md


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

@@ -0,0 +1,618 @@
+"""API routes for Slicer Pipeline runs (#1425 PR B).
+
+PR B implements single-target dispatch: one Run-pipeline click =
+  slice the source file once with the pipeline's four preset slots →
+  enqueue one print on the pipeline's pinned target_printer_id.
+
+PR C extends this with copies > 1 + class targeting + fanout strategies;
+the data model already carries those columns so this file is the only
+place that changes shape.
+
+The slice is enqueued via ``slice_dispatch`` (the same path the manual
+SliceModal uses), so the in-process progress toast renders for pipeline
+runs the same way it does for manual slicing. The slice job's id lives
+on PipelineRun.slice_job_id and is returned from ``POST /run`` 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 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.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,
+    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__)
+
+
+# Two routers — one for the per-pipeline endpoints (mounted under the
+# existing ``/slicer-pipelines`` prefix) and one for the per-run endpoints
+# at ``/pipeline-runs``. Keeps the URL shape natural.
+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_printer_id=report.target_printer_id,
+        target_printer_name=report.target_printer_name,
+        issues=[
+            EligibilityIssueResponse(
+                kind=issue.kind,
+                slot_index=issue.slot_index,
+                expected=issue.expected,
+                actual=issue.actual,
+            )
+            for issue in report.issues
+        ],
+    )
+
+
+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 (offline at
+    the manager level)."""
+    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 _slice_request_from_pipeline(pipeline: SlicerPipeline) -> SliceRequest:
+    """Materialise a SliceRequest from a pipeline so we can hand it to
+    ``slice_and_persist`` exactly the way the existing SliceModal flow does."""
+    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_run_status(
+    persisted: str,
+    job: PipelineJob | None,
+    queue_entry: PrintQueueItem | None,
+) -> str:
+    if persisted in ("failed", "cancelled", "completed"):
+        return persisted
+    if queue_entry is None:
+        return persisted
+    queue_status = queue_entry.status
+    if queue_status == "completed":
+        return "completed"
+    if queue_status in ("failed", "cancelled", "aborted"):
+        return "failed" if queue_status != "cancelled" else "cancelled"
+    if queue_status == "printing":
+        return "in_progress"
+    return "dispatching" if persisted == "dispatching" else "queued"
+
+
+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"
+
+
+async def _materialise_run(db: AsyncSession, run: PipelineRun) -> PipelineRunResponse:
+    pipeline_name: str | None = None
+    if run.pipeline_id:
+        pipeline = (
+            await db.execute(select(SlicerPipeline).where(SlicerPipeline.id == run.pipeline_id))
+        ).scalar_one_or_none()
+        pipeline_name = pipeline.name if pipeline else None
+
+    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] = []
+    rolled_up_status = run.status
+    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)
+        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_status = _compute_run_status(run.status, job, queue_entry)
+
+    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,
+        copies=run.copies,
+        status=rolled_up_status,  # 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,
+    )
+
+
+# ---------------------------------------------------------------------------
+# 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]:
+    """Look up the source row + on-disk path for either input kind. Raises 404
+    when the row or its file is missing — same shape as the SliceModal's flow
+    for both libraryFile and archive."""
+    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 (routes/library.py POST /files), which writes a UUID-named file under base_dir/library_files/ — never user-controlled at this site.
+        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 (_resolve_source_3mf_path + archive ingestion) that already do resolve+relative_to containment. Mirrors routes/archives.py:3955.
+    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)
+
+
+def _make_orchestration_callable(
+    *,
+    run_id: int,
+    pipeline_id: int,
+    src_kind: SourceKind,
+    src_id: int,
+    src_filename: str,
+    src_path: Path,
+    target_printer_id: int,
+    creator_user_id: int | None,
+):
+    """Return the async callable that ``slice_dispatch.enqueue`` will run as
+    the background slice job. Wrapping it as the slice job's ``run`` means the
+    SliceJob's lifecycle (pending → running → completed/failed) drives the
+    progress toast on the frontend exactly the same as a manual SliceModal
+    slice — no separate notification surface for pipeline runs."""
+
+    async def _orchestrate(slice_job_id: int) -> dict:
+        # Local import — slice_and_persist lives in routes/library which
+        # imports back into this module transitively via slicer_pipelines.
+        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 {}
+
+            # Refresh the snapshot status now that slicing has actually
+            # started — the route handler already wrote slice_job_id but
+            # left status='queued' until this point.
+            run.status = "slicing"
+            run.started_at = datetime.now(timezone.utc)
+            await session.commit()
+
+            slice_request = _slice_request_from_pipeline(pipeline)
+            model_bytes = src_path.read_bytes()
+
+            # Resolve the folder for the sliced output. Library sources
+            # keep their folder; archive sources fall through to root.
+            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,  # threads --pipe progress
+                )
+            except HTTPException as exc:
+                # _SliceJobError isn't directly importable here without a
+                # heavier dep; surface the slice failure cleanly and let
+                # the dispatcher's generic Exception path mark the
+                # SliceJob failed. Persist the pipeline-side state here.
+                run.status = "failed"
+                run.error_message = f"Slice failed: {exc.detail}"
+                run.completed_at = datetime.now(timezone.utc)
+                await session.commit()
+                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()
+                raise
+
+            run.sliced_library_file_id = slice_response.library_file_id
+
+            # PR B copies=1 — exactly one PipelineJob to dispatch.
+            job = (
+                (await session.execute(select(PipelineJob).where(PipelineJob.pipeline_run_id == run_id)))
+                .scalars()
+                .first()
+            )
+            if job is None:
+                logger.warning("pipeline_run %d has no PipelineJob row", run_id)
+                run.status = "failed"
+                run.error_message = "Internal: missing pipeline_job row"
+                run.completed_at = datetime.now(timezone.utc)
+                await session.commit()
+                return slice_response.model_dump()
+
+            queue_item = PrintQueueItem(
+                printer_id=target_printer_id,
+                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 = target_printer_id
+            job.status = "queued"
+            job.dispatched_at = datetime.now(timezone.utc)
+
+            run.status = "dispatching"
+            await session.commit()
+
+            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),
+):
+    """Pre-flight check before a Run-pipeline click. Returns an eligibility
+    report; the frontend uses it to show a confirmation modal if any blocking
+    issue exists. Accepts either a library file id or an archive id (XOR)."""
+    pipeline = await _load_pipeline(db, pipeline_id)
+    # Source resolution — same shape as /run uses, so a 404 here means /run
+    # would also 404 and the user can't proceed.
+    await _resolve_source(
+        db,
+        library_file_id=body.source_library_file_id,
+        archive_id=body.source_archive_id,
+    )
+    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),
+):
+    """Kick off a pipeline run. PR B: copies=1, target = pipeline's pinned
+    printer. Returns 202 immediately; the slice runs in the background via
+    ``slice_dispatch`` and the slice job id rides on the response so the
+    frontend can attach the progress toast."""
+    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,
+    )
+
+    # Eligibility pre-flight. ``force=True`` from the confirmation modal
+    # bypasses the 409.
+    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())
+
+    if pipeline.target_printer_id is None:
+        raise HTTPException(
+            400,
+            "Pipeline has no target printer set. Open the pipeline in Settings → Workflow → Pipelines and choose a target.",
+        )
+
+    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=1,
+        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()
+
+    job = PipelineJob(
+        pipeline_run_id=run.id,
+        copy_index=0,
+        assigned_printer_id=pipeline.target_printer_id,
+        status="pending",
+    )
+    db.add(job)
+    await db.commit()
+    await db.refresh(run)
+
+    # Enqueue the slice job. The callable inside slice_dispatch.enqueue does
+    # the full slice → enqueue-print → state-update chain. SliceJob lifecycle
+    # drives the existing progress toast.
+    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,
+        target_printer_id=pipeline.target_printer_id,
+        creator_user_id=current_user.id if current_user else None,
+    )
+    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)
+
+
+# ---------------------------------------------------------------------------
+# /slicer-pipelines/{id}/runs  + /pipeline-runs/{id}  + cancel
+# ---------------------------------------------------------------------------
+
+
+@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()
+    )
+    return PipelineRunListResponse(runs=[await _materialise_run(db, r) for r in rows])
+
+
+@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. Pre-slice or post-slice — semantics
+    are described on the original PR B route."""
+    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"):
+        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)
+    return await _materialise_run(db, run)

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

@@ -151,6 +151,22 @@ async def update_pipeline(
     if data.bed_type is not None:
     if data.bed_type is not None:
         row.bed_type = data.bed_type
         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
+
     await db.commit()
     await db.commit()
     await db.refresh(row)
     await db.refresh(row)
     return _to_response(row)
     return _to_response(row)

+ 10 - 0
backend/app/core/database.py

@@ -189,6 +189,7 @@ async def init_db():
         oidc_provider,
         oidc_provider,
         orca_base_cache,
         orca_base_cache,
         pending_upload,
         pending_upload,
+        pipeline_run,
         print_batch,
         print_batch,
         print_log,
         print_log,
         print_queue,
         print_queue,
@@ -669,6 +670,15 @@ async def run_migrations(conn):
     """
     """
     from sqlalchemy import text
     from sqlalchemy import text
 
 
+    # Migration: Add source_archive_id column to pipeline_runs (#1425 PR B follow-up).
+    # Allows a pipeline run to source from an archive's source 3MF in addition
+    # to a library file. Idempotent — _safe_execute swallows the "already exists"
+    # case on both SQLite and Postgres.
+    await _safe_execute(
+        conn,
+        "ALTER TABLE pipeline_runs ADD COLUMN source_archive_id INTEGER REFERENCES print_archives(id) ON DELETE SET NULL",
+    )
+
     # Migration: Add is_favorite column to print_archives
     # Migration: Add is_favorite column to print_archives
     await _safe_execute(conn, "ALTER TABLE print_archives ADD COLUMN is_favorite BOOLEAN DEFAULT 0")
     await _safe_execute(conn, "ALTER TABLE print_archives ADD COLUMN is_favorite BOOLEAN DEFAULT 0")
 
 

+ 3 - 0
backend/app/main.py

@@ -49,6 +49,7 @@ from backend.app.api.routes import (
     obico,
     obico,
     orca_cloud,
     orca_cloud,
     pending_uploads,
     pending_uploads,
+    pipeline_runs,
     print_log,
     print_log,
     print_queue,
     print_queue,
     printer_sensor_history,
     printer_sensor_history,
@@ -6752,6 +6753,8 @@ app.include_router(library_tags.router, prefix=app_settings.api_prefix)
 app.include_router(library_trash.router, prefix=app_settings.api_prefix)
 app.include_router(library_trash.router, prefix=app_settings.api_prefix)
 app.include_router(slice_jobs.router, prefix=app_settings.api_prefix)
 app.include_router(slice_jobs.router, prefix=app_settings.api_prefix)
 app.include_router(slicer_pipelines.router, prefix=app_settings.api_prefix)
 app.include_router(slicer_pipelines.router, prefix=app_settings.api_prefix)
+app.include_router(pipeline_runs.pipeline_run_create_router, prefix=app_settings.api_prefix)
+app.include_router(pipeline_runs.pipeline_run_router, prefix=app_settings.api_prefix)
 app.include_router(slicer_presets.router, prefix=app_settings.api_prefix)
 app.include_router(slicer_presets.router, prefix=app_settings.api_prefix)
 app.include_router(archive_purge.router, prefix=app_settings.api_prefix)
 app.include_router(archive_purge.router, prefix=app_settings.api_prefix)
 app.include_router(makerworld.router, prefix=app_settings.api_prefix)
 app.include_router(makerworld.router, prefix=app_settings.api_prefix)

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

@@ -18,6 +18,7 @@ from backend.app.models.notification_template import NotificationTemplate
 from backend.app.models.oidc_provider import OIDCProvider, UserOIDCLink
 from backend.app.models.oidc_provider import OIDCProvider, UserOIDCLink
 from backend.app.models.orca_base_cache import OrcaBaseProfile
 from backend.app.models.orca_base_cache import OrcaBaseProfile
 from backend.app.models.pending_upload import PendingUpload
 from backend.app.models.pending_upload import PendingUpload
+from backend.app.models.pipeline_run import PipelineJob, PipelineRun
 from backend.app.models.print_batch import PrintBatch
 from backend.app.models.print_batch import PrintBatch
 from backend.app.models.printer import Printer
 from backend.app.models.printer import Printer
 from backend.app.models.printer_sensor_history import PrinterSensorHistory
 from backend.app.models.printer_sensor_history import PrinterSensorHistory
@@ -70,6 +71,8 @@ __all__ = [
     "OIDCProvider",
     "OIDCProvider",
     "UserOIDCLink",
     "UserOIDCLink",
     "OrcaBaseProfile",
     "OrcaBaseProfile",
+    "PipelineJob",
+    "PipelineRun",
     "SlicerPipeline",
     "SlicerPipeline",
     "Spool",
     "Spool",
     "SpoolKProfile",
     "SpoolKProfile",

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

@@ -0,0 +1,105 @@
+"""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"))
+
+    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")

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

@@ -0,0 +1,124 @@
+"""Pydantic schemas for PipelineRun + eligibility (#1425 PR B)."""
+
+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",
+    ]
+    slot_index: int | None = None
+    expected: str | None = None
+    actual: str | None = None
+
+
+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: bool
+    target_printer_id: int | None = None
+    target_printer_name: str | None = None
+    issues: list[EligibilityIssueResponse] = []
+
+
+class CheckEligibilityRequest(BaseModel):
+    """Exactly one of ``source_library_file_id`` / ``source_archive_id`` must
+    be set. The eligibility matcher itself doesn't read the source — it only
+    needs the pipeline + live AMS state — but the route validates source
+    existence here so the same modal can pre-flight both archive and library
+    sources without growing a second endpoint.
+    """
+
+    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):
+    """Same XOR shape as CheckEligibilityRequest — see that schema's docstring."""
+
+    source_library_file_id: int | None = None
+    source_archive_id: int | None = None
+    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 so the audit trail shows which runs bypassed pre-flight.",
+    )
+
+    @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
+    copies: int
+    status: Literal[
+        "queued",
+        "slicing",
+        "dispatching",
+        "in_progress",
+        "completed",
+        "failed",
+        "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] = []
+
+
+class PipelineRunListResponse(BaseModel):
+    runs: list[PipelineRunResponse] = []

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

@@ -47,6 +47,13 @@ class SlicerPipelineUpdate(BaseModel):
     filament_presets: list[PresetRef] | None = Field(default=None, min_length=1)
     filament_presets: list[PresetRef] | None = Field(default=None, min_length=1)
     bed_type: str | None = Field(default=None, max_length=64)
     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 the PR A default;
+    # PR B leaves the class column unwired (PR C wires it).
+    target_kind: Literal["specific_printer", "printer_class"] | None = None
+    target_printer_id: int | None = None
+
 
 
 class SlicerPipelineResponse(SlicerPipelineBase):
 class SlicerPipelineResponse(SlicerPipelineBase):
     """A single pipeline as returned by the API."""
     """A single pipeline as returned by the API."""

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

@@ -0,0 +1,263 @@
+"""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",
+]
+
+
+@dataclass(frozen=True)
+class EligibilityIssue:
+    kind: IssueKind
+    slot_index: int | None = None
+    expected: str | None = None
+    actual: str | None = None
+
+
+@dataclass(frozen=True)
+class EligibilityReport:
+    ok: bool
+    target_printer_id: int | None
+    target_printer_name: str | None
+    issues: tuple[EligibilityIssue, ...]
+
+
+# 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_pipeline_eligibility(
+    db: AsyncSession,
+    pipeline: SlicerPipeline,
+    printer_raw_status: dict | None,
+) -> EligibilityReport:
+    """Build the report. ``printer_raw_status`` is the live ``PrinterState``
+    serialised to a dict (``connected``, ``raw_data``), or ``None`` when the
+    target printer has no MQTT client. The route handler does the
+    ``printer_manager.get_status`` lookup so this stays unit-testable.
+    """
+    issues: list[EligibilityIssue] = []
+
+    # 1. Target printer set?
+    if pipeline.target_printer_id is None:
+        issues.append(EligibilityIssue(kind="printer_not_set"))
+        return EligibilityReport(
+            ok=False,
+            target_printer_id=None,
+            target_printer_name=None,
+            issues=tuple(issues),
+        )
+
+    printer = (await db.execute(select(Printer).where(Printer.id == pipeline.target_printer_id))).scalar_one_or_none()
+    if printer is None:
+        issues.append(EligibilityIssue(kind="printer_not_found"))
+        return EligibilityReport(
+            ok=False,
+            target_printer_id=pipeline.target_printer_id,
+            target_printer_name=None,
+            issues=tuple(issues),
+        )
+
+    target_name = printer.name
+
+    # 2. Disabled?
+    if not printer.is_active:
+        issues.append(EligibilityIssue(kind="printer_disabled"))
+
+    # 3. Offline?
+    if not printer_raw_status or not printer_raw_status.get("connected"):
+        issues.append(EligibilityIssue(kind="printer_offline"))
+        # Without live AMS state, skip slot checks — would surface as a
+        # cascade of misleading mismatches.
+        return EligibilityReport(
+            ok=not issues,
+            target_printer_id=printer.id,
+            target_printer_name=target_name,
+            issues=tuple(issues),
+        )
+
+    # 4. Per-slot filament match.
+    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,
+                )
+            )
+
+    # Unverified filament refs are surfaced as INFO — they don't flip ok=False.
+    # The user is told what we couldn't verify so they can sanity-check
+    # before pulling the trigger, but the lenient policy doesn't refuse to
+    # let them run.
+    blocking_issues = [i for i in issues if i.kind != "filament_unverified"]
+
+    return EligibilityReport(
+        ok=not blocking_issues,
+        target_printer_id=printer.id,
+        target_printer_name=target_name,
+        issues=tuple(issues),
+    )

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

@@ -0,0 +1,529 @@
+"""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"]]
+        assert "printer_not_set" in kinds
+
+    @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
+        assert any(i["kind"] == "printer_not_set" for i in detail["issues"])
+
+    @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": []}
+
+    @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 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

+ 1 - 1
frontend/scripts/check-i18n-parity.mjs

@@ -219,7 +219,7 @@ const IT_COGNATES = [
   'Bambu Cloud', 'Orca Cloud',  // brand names — same in every locale
   'Bambu Cloud', 'Orca Cloud',  // brand names — same in every locale
   'AMS Filament Backup',  // Bambu Lab product/firmware feature name
   'AMS Filament Backup',  // Bambu Lab product/firmware feature name
   'Email',  // common loanword in Italian, used verbatim in UI labels
   'Email',  // common loanword in Italian, used verbatim in UI labels
-  'Pipeline',  // #1425 — Slicer Pipelines (cognate in IT)
+  'Pipeline', 'slicing',  // #1425 — Slicer Pipelines (cognate in IT)
   'Status', 'Tag', 'Tags', 'Online', 'Offline', 'Standard', 'Filament',
   'Status', 'Tag', 'Tags', 'Online', 'Offline', 'Standard', 'Filament',
   'Filaments', 'Software', 'Hardware', 'Stop', 'Reset', 'Test', 'Code',
   'Filaments', 'Software', 'Hardware', 'Stop', 'Reset', 'Test', 'Code',
   'Token', 'Server', 'Port', 'Plate', 'Layer', 'Modal', 'Pin', 'Pro', 'Mini',
   'Token', 'Server', 'Port', 'Plate', 'Layer', 'Modal', 'Pin', 'Pro', 'Mini',

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

@@ -0,0 +1,208 @@
+/**
+ * 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,
+      );
+      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,
+      );
+    });
+  });
+
+  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);
+    });
+  });
+});

+ 112 - 1
frontend/src/api/client.ts

@@ -1530,11 +1530,87 @@ export interface SlicerPipelineCreateRequest {
   filament_presets: PresetRef[];
   filament_presets: PresetRef[];
   bed_type?: string | null;
   bed_type?: string | null;
 }
 }
-export type SlicerPipelineUpdateRequest = Partial<SlicerPipelineCreateRequest>;
+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;
+};
 export interface SlicerPipelinesListResponse {
 export interface SlicerPipelinesListResponse {
   pipelines: SlicerPipeline[];
   pipelines: SlicerPipeline[];
 }
 }
 
 
+// Slicer Pipeline runs (#1425 PR B)
+export type PipelineEligibilityKind =
+  | 'printer_not_set'
+  | 'printer_not_found'
+  | 'printer_disabled'
+  | 'printer_offline'
+  | 'filament_type_mismatch'
+  | 'filament_color_mismatch'
+  | 'ams_slot_missing'
+  | 'filament_unverified';
+export interface PipelineEligibilityIssue {
+  kind: PipelineEligibilityKind;
+  slot_index: number | null;
+  expected: string | null;
+  actual: string | null;
+}
+export interface PipelineEligibilityReport {
+  ok: boolean;
+  target_printer_id: number | null;
+  target_printer_name: string | null;
+  issues: PipelineEligibilityIssue[];
+}
+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;
+  copies: number;
+  status:
+    | 'queued'
+    | 'slicing'
+    | 'dispatching'
+    | 'in_progress'
+    | 'completed'
+    | 'failed'
+    | '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[];
+}
+export interface PipelineRunListResponse {
+  runs: PipelineRun[];
+}
+
 export interface SliceResponse {
 export interface SliceResponse {
   library_file_id: number;
   library_file_id: number;
   name: string;
   name: string;
@@ -3035,6 +3111,7 @@ export type Permission =
   | 'api_keys:read' | 'api_keys:create' | 'api_keys:update' | 'api_keys:delete'
   | 'api_keys:read' | 'api_keys:create' | 'api_keys:update' | 'api_keys:delete'
   | 'users:read' | 'users:create' | 'users:update' | 'users:delete'
   | 'users:read' | 'users:create' | 'users:update' | 'users:delete'
   | 'groups:read' | 'groups:create' | 'groups:update' | 'groups:delete'
   | 'groups:read' | 'groups:create' | 'groups:update' | 'groups:delete'
+  | 'pipelines:read' | 'pipelines:write' | 'pipelines:run'
   | 'websocket:connect';
   | 'websocket:connect';
 
 
 // Group types
 // Group types
@@ -6250,6 +6327,40 @@ export const api = {
     }),
     }),
   deleteSlicerPipeline: (id: number) =>
   deleteSlicerPipeline: (id: number) =>
     request<void>(`/slicer-pipelines/${id}`, { method: 'DELETE' }),
     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,
+  ) =>
+    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,
+      }),
+    }),
+  listPipelineRuns: (pipelineId: number, limit = 5) =>
+    request<PipelineRunListResponse>(
+      `/slicer-pipelines/${pipelineId}/runs?limit=${limit}`,
+    ),
+  getPipelineRun: (runId: number) =>
+    request<PipelineRun>(`/pipeline-runs/${runId}`),
+  cancelPipelineRun: (runId: number) =>
+    request<PipelineRun>(`/pipeline-runs/${runId}/cancel`, { method: 'POST' }),
 
 
   // Canonical Bambu printer-model registry — "Bambu Lab <model>" → short code.
   // Canonical Bambu printer-model registry — "Bambu Lab <model>" → short code.
   // Single source of truth shared with backend (PRINTER_MODEL_MAP); the
   // Single source of truth shared with backend (PRINTER_MODEL_MAP); the

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

@@ -0,0 +1,362 @@
+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 { trackJob } = useSliceJobTracker();
+
+  const { data: list, isLoading: pipelinesLoading } = useQuery({
+    queryKey: ['slicer-pipelines'],
+    queryFn: () => api.listSlicerPipelines(),
+  });
+  const { data: printers } = useQuery({
+    queryKey: ['printers'],
+    queryFn: () => api.getPrinters(),
+  });
+
+  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),
+    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) => {
+    if (!pipeline.target_printer_id) {
+      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}
+            />
+          )}
+        </div>
+      </div>
+    </div>
+  );
+}
+
+function PickStep({
+  source,
+  pipelines,
+  printerById,
+  loading,
+  onPick,
+}: {
+  source: { filename: string };
+  pipelines: SlicerPipeline[];
+  printerById: Record<number, { name: string }>;
+  loading: boolean;
+  onPick: (p: SlicerPipeline) => 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>
+      {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 targetName = p.target_printer_id ? printerById[p.target_printer_id]?.name : null;
+            return (
+              <li key={p.id}>
+                <button
+                  type="button"
+                  onClick={() => onPick(p)}
+                  disabled={!p.target_printer_id}
+                  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" />
+                    {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}</>;
+  }
+}

+ 136 - 7
frontend/src/components/SlicerPipelinesPanel.tsx

@@ -1,11 +1,13 @@
 import { useState } from 'react';
 import { useState } from 'react';
 import { useTranslation } from 'react-i18next';
 import { useTranslation } from 'react-i18next';
 import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
 import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
-import { AlertTriangle, Check, Edit2, Loader2, Trash2, Workflow, X } from 'lucide-react';
+import { AlertTriangle, Check, Edit2, Loader2, Printer as PrinterIcon, Trash2, Workflow, X } from 'lucide-react';
 import {
 import {
   api,
   api,
+  type PipelineRun,
   type PresetRef,
   type PresetRef,
   type PresetSource,
   type PresetSource,
+  type Printer as PrinterType,
   type SlicerPipeline,
   type SlicerPipeline,
   type UnifiedPresetsResponse,
   type UnifiedPresetsResponse,
 } from '../api/client';
 } from '../api/client';
@@ -47,9 +49,27 @@ export function SlicerPipelinesPanel() {
     queryFn: () => api.getSlicerPresets(),
     queryFn: () => api.getSlicerPresets(),
   });
   });
 
 
+  // Printers list for the target picker (PR B).
+  const { data: printers } = useQuery({
+    queryKey: ['printers'],
+    queryFn: () => api.getPrinters(),
+  });
+
   const updateMutation = useMutation({
   const updateMutation = useMutation({
-    mutationFn: ({ id, name, description }: { id: number; name?: string; description?: string | null }) =>
-      api.updateSlicerPipeline(id, { name, description }),
+    mutationFn: ({
+      id,
+      name,
+      description,
+      target_printer_id,
+      target_kind,
+    }: {
+      id: number;
+      name?: string;
+      description?: string | null;
+      target_printer_id?: number | null;
+      target_kind?: 'specific_printer' | 'printer_class';
+    }) =>
+      api.updateSlicerPipeline(id, { name, description, target_printer_id, target_kind }),
     onSuccess: () => {
     onSuccess: () => {
       queryClient.invalidateQueries({ queryKey: ['slicer-pipelines'] });
       queryClient.invalidateQueries({ queryKey: ['slicer-pipelines'] });
       showToast(t('settings.pipelines.toast.saved', 'Pipeline saved'), 'success');
       showToast(t('settings.pipelines.toast.saved', 'Pipeline saved'), 'success');
@@ -116,7 +136,8 @@ export function SlicerPipelinesPanel() {
                 key={p.id}
                 key={p.id}
                 pipeline={p}
                 pipeline={p}
                 presets={presets}
                 presets={presets}
-                onRename={(name, description) => updateMutation.mutate({ id: p.id, name, description })}
+                printers={printers ?? []}
+                onSave={(payload) => updateMutation.mutate({ id: p.id, ...payload })}
                 onDelete={() => {
                 onDelete={() => {
                   if (confirm(t('settings.pipelines.confirmDelete', 'Delete this pipeline? This cannot be undone.'))) {
                   if (confirm(t('settings.pipelines.confirmDelete', 'Delete this pipeline? This cannot be undone.'))) {
                     deleteMutation.mutate(p.id);
                     deleteMutation.mutate(p.id);
@@ -136,14 +157,21 @@ export function SlicerPipelinesPanel() {
 function PipelineRow({
 function PipelineRow({
   pipeline,
   pipeline,
   presets,
   presets,
-  onRename,
+  printers,
+  onSave,
   onDelete,
   onDelete,
   saving,
   saving,
   deleting,
   deleting,
 }: {
 }: {
   pipeline: SlicerPipeline;
   pipeline: SlicerPipeline;
   presets: UnifiedPresetsResponse | undefined;
   presets: UnifiedPresetsResponse | undefined;
-  onRename: (name: string, description: string | null) => void;
+  printers: PrinterType[];
+  onSave: (payload: {
+    name?: string;
+    description?: string | null;
+    target_printer_id?: number | null;
+    target_kind?: 'specific_printer' | 'printer_class';
+  }) => void;
   onDelete: () => void;
   onDelete: () => void;
   saving: boolean;
   saving: boolean;
   deleting: boolean;
   deleting: boolean;
@@ -152,6 +180,19 @@ function PipelineRow({
   const [editing, setEditing] = useState(false);
   const [editing, setEditing] = useState(false);
   const [draftName, setDraftName] = useState(pipeline.name);
   const [draftName, setDraftName] = useState(pipeline.name);
   const [draftDescription, setDraftDescription] = useState(pipeline.description ?? '');
   const [draftDescription, setDraftDescription] = useState(pipeline.description ?? '');
+  const [draftTargetPrinterId, setDraftTargetPrinterId] = useState<number | null>(
+    pipeline.target_printer_id,
+  );
+
+  // 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 printerName = resolveName(presets, 'printer', pipeline.printer_preset);
   const processName = resolveName(presets, 'process', pipeline.process_preset);
   const processName = resolveName(presets, 'process', pipeline.process_preset);
@@ -159,17 +200,28 @@ function PipelineRow({
   const hasStaleRef =
   const hasStaleRef =
     presets !== undefined &&
     presets !== undefined &&
     (printerName === null || processName === null || filamentResolutions.some((n) => n === null));
     (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 needsTarget = pipeline.target_printer_id === null;
 
 
   const handleSave = () => {
   const handleSave = () => {
     const trimmedName = draftName.trim();
     const trimmedName = draftName.trim();
     if (!trimmedName) return;
     if (!trimmedName) return;
-    onRename(trimmedName, draftDescription.trim() || null);
+    onSave({
+      name: trimmedName,
+      description: draftDescription.trim() || null,
+      target_kind: 'specific_printer',
+      // Backend treats 0 as "clear"; null in TS maps to that intent.
+      target_printer_id: draftTargetPrinterId ?? 0,
+    });
     setEditing(false);
     setEditing(false);
   };
   };
 
 
   const handleCancel = () => {
   const handleCancel = () => {
     setDraftName(pipeline.name);
     setDraftName(pipeline.name);
     setDraftDescription(pipeline.description ?? '');
     setDraftDescription(pipeline.description ?? '');
+    setDraftTargetPrinterId(pipeline.target_printer_id);
     setEditing(false);
     setEditing(false);
   };
   };
 
 
@@ -194,6 +246,28 @@ function PipelineRow({
                 rows={2}
                 rows={2}
                 className="w-full px-2 py-1 text-xs bg-bambu-dark border border-bambu-dark-tertiary rounded text-white"
                 className="w-full px-2 py-1 text-xs bg-bambu-dark border border-bambu-dark-tertiary rounded text-white"
               />
               />
+              <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>
             </div>
           ) : (
           ) : (
             <>
             <>
@@ -277,6 +351,43 @@ function PipelineRow({
               <span className="text-white">{pipeline.bed_type}</span>
               <span className="text-white">{pipeline.bed_type}</span>
             </div>
             </div>
           )}
           )}
+          <div className="text-bambu-gray flex items-center gap-1">
+            <PrinterIcon className="w-3 h-3" />
+            <span className="font-medium text-bambu-gray/80">
+              {t('settings.pipelines.field.targetPrinter', 'Target printer')}:
+            </span>{' '}
+            {targetPrinter ? (
+              <span className="text-white">{targetPrinter.name}</span>
+            ) : (
+              <span className="text-amber-400">
+                {t('settings.pipelines.noTargetHint', 'Set a target printer to run this')}
+              </span>
+            )}
+          </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>
         </div>
       )}
       )}
 
 
@@ -293,6 +404,24 @@ function PipelineRow({
   );
   );
 }
 }
 
 
+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',
+    cancelled: 'text-bambu-gray',
+  };
+  return (
+    <span className={colourClass[status]}>
+      {t(`settings.pipelines.runs.status.${status}`, status)}
+    </span>
+  );
+}
+
 function PresetLine({
 function PresetLine({
   label,
   label,
   ref,
   ref,

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

@@ -2570,6 +2570,8 @@ export default {
       field: {
       field: {
         name: 'Pipeline-Name',
         name: 'Pipeline-Name',
         description: 'Beschreibung',
         description: 'Beschreibung',
+        targetPrinter: 'Zieldrucker',
+        noTarget: '— Kein Ziel —',
       },
       },
       action: {
       action: {
         save: 'Speichern',
         save: 'Speichern',
@@ -2590,6 +2592,20 @@ export default {
         deleted: 'Pipeline gelöscht',
         deleted: 'Pipeline gelöscht',
         deleteFailed: 'Löschen fehlgeschlagen',
         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',
+          cancelled: 'abgebrochen',
+        },
+      },
     },
     },
   },
   },
 
 
@@ -3820,6 +3836,36 @@ export default {
     deleteConfirm: 'Möchten Sie dieses Filament wirklich löschen?',
     deleteConfirm: 'Möchten Sie dieses Filament wirklich löschen?',
     importFromPrinter: 'Vom Drucker importieren',
     importFromPrinter: 'Vom Drucker importieren',
     exportToFile: 'In Datei exportieren',
     exportToFile: 'In Datei exportieren',
+    runWithPipeline: {
+      actionLabel: 'Mit Pipeline ausführen',
+      noPermission: 'Du hast keine Berechtigung, Pipelines auszuführen',
+      modalTitle: 'Mit Pipeline ausführen',
+      confirmTitle: 'Lauf bestätigen',
+      confirmIntro: 'Pre-Flight hat Probleme mit diesem Lauf gefunden',
+      sourceHint: 'Quelle',
+      pipelineHint: 'Pipeline',
+      targetHint: 'Ziel',
+      pipelineListAria: 'Verfügbare Pipelines',
+      runAnyway: 'Trotzdem ausführen',
+      loading: 'Wird geladen…',
+      empty: 'Noch keine Pipelines gespeichert. Öffne den Slice-Dialog und klicke „Als Pipeline speichern“, um eine zu erstellen.',
+      noTarget: 'Kein Zieldrucker festgelegt',
+      noTargetMessage: 'Diese Pipeline hat keinen Zieldrucker. Öffne sie in den Einstellungen, um einen festzulegen.',
+      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.',
+      },
+    },
   },
   },
 
 
   // Slice (slicer-API integration via SliceModal)
   // Slice (slicer-API integration via SliceModal)

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

@@ -2586,6 +2586,8 @@ export default {
       field: {
       field: {
         name: 'Pipeline name',
         name: 'Pipeline name',
         description: 'Description',
         description: 'Description',
+        targetPrinter: 'Target printer',
+        noTarget: '— No target —',
       },
       },
       action: {
       action: {
         save: 'Save',
         save: 'Save',
@@ -2606,6 +2608,21 @@ export default {
         deleted: 'Pipeline deleted',
         deleted: 'Pipeline deleted',
         deleteFailed: 'Delete failed',
         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',
+          cancelled: 'cancelled',
+        },
+      },
     },
     },
   },
   },
 
 
@@ -3836,6 +3853,39 @@ export default {
     deleteConfirm: 'Are you sure you want to delete this filament?',
     deleteConfirm: 'Are you sure you want to delete this filament?',
     importFromPrinter: 'Import from Printer',
     importFromPrinter: 'Import from Printer',
     exportToFile: 'Export to File',
     exportToFile: 'Export to File',
+    // Slicer Pipelines run from the File Manager (#1425 PR B). The Run-with-pipeline
+    // modal is a two-step dialog: pick a pipeline, then either fire (eligibility ok)
+    // or confirm-and-fire (eligibility report shown). Lives in components/RunWithPipelineModal.tsx.
+    runWithPipeline: {
+      actionLabel: 'Run with pipeline',
+      noPermission: 'You do not have permission to run pipelines',
+      modalTitle: 'Run with pipeline',
+      confirmTitle: 'Confirm run',
+      confirmIntro: 'Pre-flight found issues with this run',
+      sourceHint: 'Source',
+      pipelineHint: 'Pipeline',
+      targetHint: 'Target',
+      pipelineListAria: 'Available pipelines',
+      runAnyway: 'Run anyway',
+      loading: 'Loading…',
+      empty: 'No pipelines saved yet. Open the Slice dialog and click "Save as pipeline" to create one.',
+      noTarget: 'No target printer set',
+      noTargetMessage: 'This pipeline has no target printer set. Open it in Settings to pick one.',
+      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.',
+      },
+    },
   },
   },
 
 
   // Slice (slicer-API integration via SliceModal)
   // Slice (slicer-API integration via SliceModal)

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

@@ -2573,6 +2573,8 @@ export default {
       field: {
       field: {
         name: 'Nombre de la pipeline',
         name: 'Nombre de la pipeline',
         description: 'Descripción',
         description: 'Descripción',
+        targetPrinter: 'Impresora de destino',
+        noTarget: '— Sin destino —',
       },
       },
       action: {
       action: {
         save: 'Guardar',
         save: 'Guardar',
@@ -2593,6 +2595,20 @@ export default {
         deleted: 'Pipeline eliminada',
         deleted: 'Pipeline eliminada',
         deleteFailed: 'Error al eliminar',
         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',
+          cancelled: 'cancelada',
+        },
+      },
     },
     },
   },
   },
 
 
@@ -3823,6 +3839,36 @@ export default {
     deleteConfirm: '¿Está seguro de que desea eliminar este filamento?',
     deleteConfirm: '¿Está seguro de que desea eliminar este filamento?',
     importFromPrinter: 'Importar desde la impresora',
     importFromPrinter: 'Importar desde la impresora',
     exportToFile: 'Exportar a un archivo',
     exportToFile: 'Exportar a un archivo',
+    runWithPipeline: {
+      actionLabel: 'Ejecutar con pipeline',
+      noPermission: 'No tienes permiso para ejecutar pipelines',
+      modalTitle: 'Ejecutar con pipeline',
+      confirmTitle: 'Confirmar ejecución',
+      confirmIntro: 'El pre-vuelo encontró problemas con esta ejecución',
+      sourceHint: 'Origen',
+      pipelineHint: 'Pipeline',
+      targetHint: 'Destino',
+      pipelineListAria: 'Pipelines disponibles',
+      runAnyway: 'Ejecutar de todos modos',
+      loading: 'Cargando…',
+      empty: 'Aún no hay pipelines guardadas. Abre el diálogo Cortar y haz clic en "Guardar como pipeline" para crear una.',
+      noTarget: 'Sin impresora de destino',
+      noTargetMessage: 'Esta pipeline no tiene impresora de destino. Ábrela en Ajustes para elegir una.',
+      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.',
+      },
+    },
   },
   },
 
 
   // Slice (slicer-API integration via SliceModal)
   // Slice (slicer-API integration via SliceModal)

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

@@ -2559,6 +2559,8 @@ export default {
       field: {
       field: {
         name: 'Nom du pipeline',
         name: 'Nom du pipeline',
         description: 'Description',
         description: 'Description',
+        targetPrinter: 'Imprimante cible',
+        noTarget: '— Aucune cible —',
       },
       },
       action: {
       action: {
         save: 'Enregistrer',
         save: 'Enregistrer',
@@ -2579,6 +2581,20 @@ export default {
         deleted: 'Pipeline supprimé',
         deleted: 'Pipeline supprimé',
         deleteFailed: 'Échec de la suppression',
         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',
+          cancelled: 'annulé',
+        },
+      },
     },
     },
   },
   },
 
 
@@ -3809,6 +3825,36 @@ export default {
     deleteConfirm: 'Supprimer ce filament ?',
     deleteConfirm: 'Supprimer ce filament ?',
     importFromPrinter: 'Importer de l\'imprimante',
     importFromPrinter: 'Importer de l\'imprimante',
     exportToFile: 'Exporter vers fichier',
     exportToFile: 'Exporter vers fichier',
+    runWithPipeline: {
+      actionLabel: 'Exécuter avec un pipeline',
+      noPermission: 'Vous n\'avez pas la permission d\'exécuter des pipelines',
+      modalTitle: 'Exécuter avec un pipeline',
+      confirmTitle: 'Confirmer l\'exécution',
+      confirmIntro: 'Le pré-vol a trouvé des problèmes avec cette exécution',
+      sourceHint: 'Source',
+      pipelineHint: 'Pipeline',
+      targetHint: 'Cible',
+      pipelineListAria: 'Pipelines disponibles',
+      runAnyway: 'Exécuter quand même',
+      loading: 'Chargement…',
+      empty: 'Aucun pipeline enregistré pour le moment. Ouvrez le dialogue Trancher et cliquez sur « Enregistrer comme pipeline » pour en créer un.',
+      noTarget: 'Aucune imprimante cible définie',
+      noTargetMessage: 'Ce pipeline n\'a pas d\'imprimante cible. Ouvrez-le dans les Paramètres pour en choisir une.',
+      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.',
+      },
+    },
   },
   },
 
 
   // Slice (slicer-API integration via SliceModal)
   // Slice (slicer-API integration via SliceModal)

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

@@ -2558,6 +2558,8 @@ export default {
       field: {
       field: {
         name: 'Nome pipeline',
         name: 'Nome pipeline',
         description: 'Descrizione',
         description: 'Descrizione',
+        targetPrinter: 'Stampante di destinazione',
+        noTarget: '— Nessuna destinazione —',
       },
       },
       action: {
       action: {
         save: 'Salva',
         save: 'Salva',
@@ -2578,6 +2580,20 @@ export default {
         deleted: 'Pipeline eliminata',
         deleted: 'Pipeline eliminata',
         deleteFailed: 'Eliminazione non riuscita',
         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',
+          cancelled: 'annullata',
+        },
+      },
     },
     },
   },
   },
 
 
@@ -3808,6 +3824,36 @@ export default {
     deleteConfirm: 'Sei sicuro di voler eliminare questo filamento?',
     deleteConfirm: 'Sei sicuro di voler eliminare questo filamento?',
     importFromPrinter: 'Importa da stampante',
     importFromPrinter: 'Importa da stampante',
     exportToFile: 'Esporta su file',
     exportToFile: 'Esporta su file',
+    runWithPipeline: {
+      actionLabel: 'Esegui con pipeline',
+      noPermission: 'Non hai il permesso di eseguire pipeline',
+      modalTitle: 'Esegui con pipeline',
+      confirmTitle: 'Conferma esecuzione',
+      confirmIntro: 'Il pre-volo ha trovato problemi con questa esecuzione',
+      sourceHint: 'Sorgente',
+      pipelineHint: 'Pipeline',
+      targetHint: 'Destinazione',
+      pipelineListAria: 'Pipeline disponibili',
+      runAnyway: 'Esegui comunque',
+      loading: 'Caricamento…',
+      empty: 'Nessuna pipeline salvata. Apri il dialogo Slice e fai clic su "Salva come pipeline" per crearne una.',
+      noTarget: 'Nessuna stampante di destinazione',
+      noTargetMessage: 'Questa pipeline non ha una stampante di destinazione. Aprila nelle Impostazioni per sceglierne una.',
+      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.',
+      },
+    },
   },
   },
 
 
   // Slice (slicer-API integration via SliceModal)
   // Slice (slicer-API integration via SliceModal)

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

@@ -2570,6 +2570,8 @@ export default {
       field: {
       field: {
         name: 'パイプライン名',
         name: 'パイプライン名',
         description: '説明',
         description: '説明',
+        targetPrinter: '対象プリンター',
+        noTarget: '— 対象なし —',
       },
       },
       action: {
       action: {
         save: '保存',
         save: '保存',
@@ -2590,6 +2592,20 @@ export default {
         deleted: 'パイプラインを削除しました',
         deleted: 'パイプラインを削除しました',
         deleteFailed: '削除に失敗しました',
         deleteFailed: '削除に失敗しました',
       },
       },
+      noTargetHint: '実行するには対象プリンターを設定してください',
+      noTargetWarning: 'このパイプラインを実行する前に対象プリンターを設定してください。',
+      runs: {
+        lastRun: '前回の実行',
+        status: {
+          queued: '待機中',
+          slicing: 'スライス中',
+          dispatching: '送信中',
+          in_progress: '印刷中',
+          completed: '完了',
+          failed: '失敗',
+          cancelled: 'キャンセル',
+        },
+      },
     },
     },
   },
   },
 
 
@@ -3820,6 +3836,36 @@ export default {
     deleteConfirm: 'このフィラメントを削除しますか?',
     deleteConfirm: 'このフィラメントを削除しますか?',
     importFromPrinter: 'プリンターからインポート',
     importFromPrinter: 'プリンターからインポート',
     exportToFile: 'ファイルにエクスポート',
     exportToFile: 'ファイルにエクスポート',
+    runWithPipeline: {
+      actionLabel: 'パイプラインで実行',
+      noPermission: 'パイプラインを実行する権限がありません',
+      modalTitle: 'パイプラインで実行',
+      confirmTitle: '実行を確認',
+      confirmIntro: 'プリフライトでこの実行に関する問題が見つかりました',
+      sourceHint: 'ソース',
+      pipelineHint: 'パイプライン',
+      targetHint: '対象',
+      pipelineListAria: '利用可能なパイプライン',
+      runAnyway: 'とにかく実行',
+      loading: '読み込み中…',
+      empty: '保存されたパイプラインはまだありません。スライスダイアログを開き「パイプラインとして保存」をクリックして作成してください。',
+      noTarget: '対象プリンターが未設定',
+      noTargetMessage: 'このパイプラインには対象プリンターが設定されていません。設定で開いて選択してください。',
+      toast: {
+        started: 'パイプラインの実行を開始しました',
+        failed: '実行を開始できませんでした',
+      },
+      issue: {
+        printerNotSet: 'このパイプラインに対象プリンターが設定されていません。',
+        printerNotFound: '対象プリンターが存在しません。',
+        printerDisabled: '対象プリンターは無効です。',
+        printerOffline: '対象プリンターはオフラインです。',
+        filamentType: 'フィラメントスロット {{slot}}: 期待 {{expected}}、AMS は {{actual}}',
+        filamentColor: 'フィラメントスロット {{slot}}: 色が異なります(期待 {{expected}}、AMS は {{actual}})',
+        amsSlotMissing: 'AMS スロット {{slot}} はこのプリンターで利用できません',
+        filamentUnverified: 'フィラメントスロット {{slot}} はクラウド/標準プリセットからのため、静的に検証できませんでした。',
+      },
+    },
   },
   },
 
 
   // Slice (slicer-API integration via SliceModal)
   // Slice (slicer-API integration via SliceModal)

+ 47 - 1
frontend/src/i18n/locales/ko.ts

@@ -2426,6 +2426,8 @@ export default {
       field: {
       field: {
         name: '파이프라인 이름',
         name: '파이프라인 이름',
         description: '설명',
         description: '설명',
+        targetPrinter: '대상 프린터',
+        noTarget: '— 대상 없음 —',
       },
       },
       action: {
       action: {
         save: '저장',
         save: '저장',
@@ -2446,6 +2448,20 @@ export default {
         deleted: '파이프라인이 삭제되었습니다',
         deleted: '파이프라인이 삭제되었습니다',
         deleteFailed: '삭제 실패',
         deleteFailed: '삭제 실패',
       },
       },
+      noTargetHint: '실행하려면 대상 프린터를 설정하세요',
+      noTargetWarning: '이 파이프라인을 실행하기 전에 대상 프린터를 설정하세요.',
+      runs: {
+        lastRun: '마지막 실행',
+        status: {
+          queued: '대기 중',
+          slicing: '슬라이싱 중',
+          dispatching: '전송 중',
+          in_progress: '인쇄 중',
+          completed: '완료됨',
+          failed: '실패',
+          cancelled: '취소됨',
+        },
+      },
     },
     },
   },
   },
   notification: {
   notification: {
@@ -3614,7 +3630,37 @@ export default {
     noFilaments: '라이브러리에 필라멘트 없음',
     noFilaments: '라이브러리에 필라멘트 없음',
     deleteConfirm: '이 필라멘트를 삭제하시겠습니까?',
     deleteConfirm: '이 필라멘트를 삭제하시겠습니까?',
     importFromPrinter: '프린터에서 가져오기',
     importFromPrinter: '프린터에서 가져오기',
-    exportToFile: '파일로 내보내기'
+    exportToFile: '파일로 내보내기',
+    runWithPipeline: {
+      actionLabel: '파이프라인으로 실행',
+      noPermission: '파이프라인을 실행할 권한이 없습니다',
+      modalTitle: '파이프라인으로 실행',
+      confirmTitle: '실행 확인',
+      confirmIntro: '사전 점검에서 이 실행과 관련된 문제를 발견했습니다',
+      sourceHint: '소스',
+      pipelineHint: '파이프라인',
+      targetHint: '대상',
+      pipelineListAria: '사용 가능한 파이프라인',
+      runAnyway: '그래도 실행',
+      loading: '불러오는 중…',
+      empty: '저장된 파이프라인이 없습니다. 슬라이스 대화상자를 열고 "파이프라인으로 저장"을 클릭하여 생성하세요.',
+      noTarget: '대상 프린터가 설정되지 않음',
+      noTargetMessage: '이 파이프라인에는 대상 프린터가 없습니다. 설정에서 열어 선택하세요.',
+      toast: {
+        started: '파이프라인 실행 시작됨',
+        failed: '실행을 시작할 수 없습니다',
+      },
+      issue: {
+        printerNotSet: '이 파이프라인에 대상 프린터가 설정되어 있지 않습니다.',
+        printerNotFound: '대상 프린터가 더 이상 존재하지 않습니다.',
+        printerDisabled: '대상 프린터가 비활성화되어 있습니다.',
+        printerOffline: '대상 프린터가 오프라인입니다.',
+        filamentType: '필라멘트 슬롯 {{slot}}: 예상 {{expected}}, AMS 실제 {{actual}}',
+        filamentColor: '필라멘트 슬롯 {{slot}}: 색상이 다릅니다 (예상 {{expected}}, AMS {{actual}})',
+        amsSlotMissing: 'AMS 슬롯 {{slot}}이(가) 이 프린터에서 사용 불가',
+        filamentUnverified: '필라멘트 슬롯 {{slot}}은(는) 클라우드/표준 프리셋이며 정적으로 검증할 수 없습니다.',
+      },
+    },
   },
   },
   slice: {
   slice: {
     title: '모델 슬라이싱',
     title: '모델 슬라이싱',

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

@@ -2558,6 +2558,8 @@ export default {
       field: {
       field: {
         name: 'Nome da pipeline',
         name: 'Nome da pipeline',
         description: 'Descrição',
         description: 'Descrição',
+        targetPrinter: 'Impressora de destino',
+        noTarget: '— Sem destino —',
       },
       },
       action: {
       action: {
         save: 'Salvar',
         save: 'Salvar',
@@ -2578,6 +2580,20 @@ export default {
         deleted: 'Pipeline excluída',
         deleted: 'Pipeline excluída',
         deleteFailed: 'Falha ao excluir',
         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',
+          cancelled: 'cancelada',
+        },
+      },
     },
     },
   },
   },
 
 
@@ -3808,6 +3824,36 @@ export default {
     deleteConfirm: 'Tem certeza de que deseja excluir este filamento?',
     deleteConfirm: 'Tem certeza de que deseja excluir este filamento?',
     importFromPrinter: 'Importar da Impressora',
     importFromPrinter: 'Importar da Impressora',
     exportToFile: 'Exportar para Arquivo',
     exportToFile: 'Exportar para Arquivo',
+    runWithPipeline: {
+      actionLabel: 'Executar com pipeline',
+      noPermission: 'Você não tem permissão para executar pipelines',
+      modalTitle: 'Executar com pipeline',
+      confirmTitle: 'Confirmar execução',
+      confirmIntro: 'A pré-verificação encontrou problemas com esta execução',
+      sourceHint: 'Origem',
+      pipelineHint: 'Pipeline',
+      targetHint: 'Destino',
+      pipelineListAria: 'Pipelines disponíveis',
+      runAnyway: 'Executar mesmo assim',
+      loading: 'Carregando…',
+      empty: 'Ainda não há pipelines salvas. Abra o diálogo Cortar e clique em "Salvar como pipeline" para criar uma.',
+      noTarget: 'Sem impressora de destino',
+      noTargetMessage: 'Esta pipeline não tem impressora de destino. Abra-a em Configurações para escolher uma.',
+      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.',
+      },
+    },
   },
   },
 
 
   // Slice (slicer-API integration via SliceModal)
   // Slice (slicer-API integration via SliceModal)

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

@@ -2574,6 +2574,8 @@ export default {
       field: {
       field: {
         name: 'Pipeline adı',
         name: 'Pipeline adı',
         description: 'Açıklama',
         description: 'Açıklama',
+        targetPrinter: 'Hedef yazıcı',
+        noTarget: '— Hedef yok —',
       },
       },
       action: {
       action: {
         save: 'Kaydet',
         save: 'Kaydet',
@@ -2594,6 +2596,20 @@ export default {
         deleted: 'Pipeline silindi',
         deleted: 'Pipeline silindi',
         deleteFailed: 'Silme başarısız',
         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',
+          cancelled: 'iptal edildi',
+        },
+      },
     },
     },
   },
   },
 
 
@@ -3810,6 +3826,36 @@ export default {
     deleteConfirm: 'Bu filamenti silmek istediğinizden emin misiniz?',
     deleteConfirm: 'Bu filamenti silmek istediğinizden emin misiniz?',
     importFromPrinter: 'Yazıcıdan İçe Aktar',
     importFromPrinter: 'Yazıcıdan İçe Aktar',
     exportToFile: 'Dosyaya Dışa Aktar',
     exportToFile: 'Dosyaya Dışa Aktar',
+    runWithPipeline: {
+      actionLabel: 'Pipeline ile çalıştır',
+      noPermission: 'Pipeline çalıştırma izniniz yok',
+      modalTitle: 'Pipeline ile çalıştır',
+      confirmTitle: 'Çalıştırmayı onayla',
+      confirmIntro: 'Ön kontrol bu çalıştırmayla ilgili sorunlar buldu',
+      sourceHint: 'Kaynak',
+      pipelineHint: 'Pipeline',
+      targetHint: 'Hedef',
+      pipelineListAria: 'Kullanılabilir pipeline\'lar',
+      runAnyway: 'Yine de çalıştır',
+      loading: 'Yükleniyor…',
+      empty: 'Henüz kayıtlı pipeline yok. Dilimleme diyalogunu açın ve oluşturmak için "Pipeline olarak kaydet"e tıklayın.',
+      noTarget: 'Hedef yazıcı belirlenmemiş',
+      noTargetMessage: 'Bu pipeline\'ın hedef yazıcısı yok. Birini seçmek için Ayarlar\'da açın.',
+      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ı.',
+      },
+    },
   },
   },
 
 
   // Dilimle (SliceModal ile slicer-API entegrasyonu)
   // Dilimle (SliceModal ile slicer-API entegrasyonu)

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

@@ -2558,6 +2558,8 @@ export default {
       field: {
       field: {
         name: '流水线名称',
         name: '流水线名称',
         description: '描述',
         description: '描述',
+        targetPrinter: '目标打印机',
+        noTarget: '— 无目标 —',
       },
       },
       action: {
       action: {
         save: '保存',
         save: '保存',
@@ -2578,6 +2580,20 @@ export default {
         deleted: '流水线已删除',
         deleted: '流水线已删除',
         deleteFailed: '删除失败',
         deleteFailed: '删除失败',
       },
       },
+      noTargetHint: '设置目标打印机以运行',
+      noTargetWarning: '运行此流水线前请先设置目标打印机。',
+      runs: {
+        lastRun: '上次运行',
+        status: {
+          queued: '排队中',
+          slicing: '切片中',
+          dispatching: '发送中',
+          in_progress: '打印中',
+          completed: '已完成',
+          failed: '失败',
+          cancelled: '已取消',
+        },
+      },
     },
     },
   },
   },
 
 
@@ -3808,6 +3824,36 @@ export default {
     deleteConfirm: '确定要删除此耗材吗?',
     deleteConfirm: '确定要删除此耗材吗?',
     importFromPrinter: '从打印机导入',
     importFromPrinter: '从打印机导入',
     exportToFile: '导出到文件',
     exportToFile: '导出到文件',
+    runWithPipeline: {
+      actionLabel: '用流水线运行',
+      noPermission: '您没有运行流水线的权限',
+      modalTitle: '用流水线运行',
+      confirmTitle: '确认运行',
+      confirmIntro: '预检发现此次运行存在问题',
+      sourceHint: '来源',
+      pipelineHint: '流水线',
+      targetHint: '目标',
+      pipelineListAria: '可用流水线',
+      runAnyway: '仍然运行',
+      loading: '加载中…',
+      empty: '尚未保存流水线。请打开切片对话框并点击"另存为流水线"创建一个。',
+      noTarget: '未设置目标打印机',
+      noTargetMessage: '此流水线没有目标打印机。请在设置中打开并选择一个。',
+      toast: {
+        started: '流水线运行已开始',
+        failed: '无法启动运行',
+      },
+      issue: {
+        printerNotSet: '此流水线未设置目标打印机。',
+        printerNotFound: '目标打印机已不存在。',
+        printerDisabled: '目标打印机已禁用。',
+        printerOffline: '目标打印机已离线。',
+        filamentType: '耗材槽 {{slot}}:期望 {{expected}},AMS 实际 {{actual}}',
+        filamentColor: '耗材槽 {{slot}}:颜色不同(期望 {{expected}},AMS 实际 {{actual}})',
+        amsSlotMissing: '此打印机上没有 AMS 槽 {{slot}}',
+        filamentUnverified: '耗材槽 {{slot}} 来自云端 / 标准预设,无法静态验证。',
+      },
+    },
   },
   },
 
 
   // Slice (slicer-API integration via SliceModal)
   // Slice (slicer-API integration via SliceModal)

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

@@ -2558,6 +2558,8 @@ export default {
       field: {
       field: {
         name: '管線名稱',
         name: '管線名稱',
         description: '描述',
         description: '描述',
+        targetPrinter: '目標印表機',
+        noTarget: '— 無目標 —',
       },
       },
       action: {
       action: {
         save: '儲存',
         save: '儲存',
@@ -2578,6 +2580,20 @@ export default {
         deleted: '管線已刪除',
         deleted: '管線已刪除',
         deleteFailed: '刪除失敗',
         deleteFailed: '刪除失敗',
       },
       },
+      noTargetHint: '設定目標印表機以執行',
+      noTargetWarning: '執行此管線前請先設定目標印表機。',
+      runs: {
+        lastRun: '上次執行',
+        status: {
+          queued: '排隊中',
+          slicing: '切片中',
+          dispatching: '傳送中',
+          in_progress: '列印中',
+          completed: '已完成',
+          failed: '失敗',
+          cancelled: '已取消',
+        },
+      },
     },
     },
   },
   },
 
 
@@ -3808,6 +3824,36 @@ export default {
     deleteConfirm: '確定要刪除此耗材嗎?',
     deleteConfirm: '確定要刪除此耗材嗎?',
     importFromPrinter: '從印表機匯入',
     importFromPrinter: '從印表機匯入',
     exportToFile: '匯出到檔案',
     exportToFile: '匯出到檔案',
+    runWithPipeline: {
+      actionLabel: '以管線執行',
+      noPermission: '您沒有執行管線的權限',
+      modalTitle: '以管線執行',
+      confirmTitle: '確認執行',
+      confirmIntro: '預檢發現此次執行存在問題',
+      sourceHint: '來源',
+      pipelineHint: '管線',
+      targetHint: '目標',
+      pipelineListAria: '可用管線',
+      runAnyway: '仍然執行',
+      loading: '載入中…',
+      empty: '尚未儲存管線。請開啟切片對話框並點擊「另存為管線」建立一個。',
+      noTarget: '未設定目標印表機',
+      noTargetMessage: '此管線沒有目標印表機。請在設定中開啟並選擇一個。',
+      toast: {
+        started: '管線執行已開始',
+        failed: '無法啟動執行',
+      },
+      issue: {
+        printerNotSet: '此管線未設定目標印表機。',
+        printerNotFound: '目標印表機已不存在。',
+        printerDisabled: '目標印表機已停用。',
+        printerOffline: '目標印表機已離線。',
+        filamentType: '耗材槽 {{slot}}:預期 {{expected}},AMS 實際 {{actual}}',
+        filamentColor: '耗材槽 {{slot}}:顏色不同(預期 {{expected}},AMS 實際 {{actual}})',
+        amsSlotMissing: '此印表機上沒有 AMS 槽 {{slot}}',
+        filamentUnverified: '耗材槽 {{slot}} 來自雲端 / 標準預設,無法靜態驗證。',
+      },
+    },
   },
   },
 
 
   // Slice (slicer-API integration via SliceModal)
   // Slice (slicer-API integration via SliceModal)

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

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

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

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

File diff suppressed because it is too large
+ 0 - 0
static/assets/index-Bydn78aP.css


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


+ 2 - 2
static/index.html

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

+ 0 - 0
test_pipeline_archive_source.3mf


+ 0 - 0
test_pipeline_run_1.3mf


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